diff options
author | Gone2Daly <71726742+Gone2Daly@users.noreply.github.com> | 2023-07-22 21:05:42 +0200 |
---|---|---|
committer | Gone2Daly <71726742+Gone2Daly@users.noreply.github.com> | 2023-07-22 21:05:42 +0200 |
commit | 71b3cd829f80de4c2cd3972d8bfd5ee470a5d180 (patch) | |
tree | b4280fde6eef2ae6987648bc7bf8e00e9011bb7f /game/addons/zylann.hterrain/util | |
parent | ce9022d0df74d6c33db3686622be2050d873ab0b (diff) |
init_testtest3d
Diffstat (limited to 'game/addons/zylann.hterrain/util')
-rw-r--r-- | game/addons/zylann.hterrain/util/direct_mesh_instance.gd | 65 | ||||
-rw-r--r-- | game/addons/zylann.hterrain/util/direct_multimesh_instance.gd | 48 | ||||
-rw-r--r-- | game/addons/zylann.hterrain/util/errors.gd | 58 | ||||
-rw-r--r-- | game/addons/zylann.hterrain/util/grid.gd | 203 | ||||
-rw-r--r-- | game/addons/zylann.hterrain/util/image_file_cache.gd | 291 | ||||
-rw-r--r-- | game/addons/zylann.hterrain/util/logger.gd | 34 | ||||
-rw-r--r-- | game/addons/zylann.hterrain/util/util.gd | 549 | ||||
-rw-r--r-- | game/addons/zylann.hterrain/util/xyz_format.gd | 109 |
8 files changed, 1357 insertions, 0 deletions
diff --git a/game/addons/zylann.hterrain/util/direct_mesh_instance.gd b/game/addons/zylann.hterrain/util/direct_mesh_instance.gd new file mode 100644 index 0000000..17c89c9 --- /dev/null +++ b/game/addons/zylann.hterrain/util/direct_mesh_instance.gd @@ -0,0 +1,65 @@ +@tool + +# Implementation of MeshInstance which doesn't use the scene tree + +var _mesh_instance := RID() +# Need to keep a reference so that the mesh RID doesn't get freed +var _mesh : Mesh + + +func _init(): + var rs = RenderingServer + _mesh_instance = rs.instance_create() + rs.instance_set_visible(_mesh_instance, true) + + +func _notification(p_what: int): + if p_what == NOTIFICATION_PREDELETE: + if _mesh_instance != RID(): + RenderingServer.free_rid(_mesh_instance) + _mesh_instance = RID() + + +func enter_world(world: World3D): + assert(_mesh_instance != RID()) + RenderingServer.instance_set_scenario(_mesh_instance, world.get_scenario()) + + +func exit_world(): + assert(_mesh_instance != RID()) + RenderingServer.instance_set_scenario(_mesh_instance, RID()) + + +func set_world(world: World3D): + if world != null: + enter_world(world) + else: + exit_world() + + +func set_transform(world_transform: Transform3D): + assert(_mesh_instance != RID()) + RenderingServer.instance_set_transform(_mesh_instance, world_transform) + + +func set_mesh(mesh: Mesh): + assert(_mesh_instance != RID()) + RenderingServer.instance_set_base(_mesh_instance, mesh.get_rid() if mesh != null else RID()) + _mesh = mesh + + +func set_material(material: Material): + assert(_mesh_instance != RID()) + RenderingServer.instance_geometry_set_material_override( \ + _mesh_instance, material.get_rid() if material != null else RID()) + + +func set_visible(visible: bool): + assert(_mesh_instance != RID()) + RenderingServer.instance_set_visible(_mesh_instance, visible) + + +func set_aabb(aabb: AABB): + assert(_mesh_instance != RID()) + RenderingServer.instance_set_custom_aabb(_mesh_instance, aabb) + diff --git a/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd b/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd new file mode 100644 index 0000000..dbb899b --- /dev/null +++ b/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd @@ -0,0 +1,48 @@ +@tool + +# Implementation of MultiMeshInstance which doesn't use the scene tree + +var _multimesh_instance := RID() + + +func _init(): + _multimesh_instance = RenderingServer.instance_create() + + +func _notification(what: int): + if what == NOTIFICATION_PREDELETE: + RenderingServer.free_rid(_multimesh_instance) + + +func set_world(world: World3D): + RenderingServer.instance_set_scenario( + _multimesh_instance, world.get_scenario() if world != null else RID()) + + +func set_visible(visible: bool): + RenderingServer.instance_set_visible(_multimesh_instance, visible) + + +func set_transform(trans: Transform3D): + RenderingServer.instance_set_transform(_multimesh_instance, trans) + + +func set_multimesh(mm: MultiMesh): + RenderingServer.instance_set_base(_multimesh_instance, mm.get_rid() if mm != null else RID()) + + +func set_material_override(material: Material): + RenderingServer.instance_geometry_set_material_override( \ + _multimesh_instance, material.get_rid() if material != null else RID()) + + +func set_aabb(aabb: AABB): + RenderingServer.instance_set_custom_aabb(_multimesh_instance, aabb) + + +func set_layer_mask(mask: int): + RenderingServer.instance_set_layer_mask(_multimesh_instance, mask) + + +func set_cast_shadow(cast_shadow: int): + RenderingServer.instance_geometry_set_cast_shadows_setting(_multimesh_instance, cast_shadow) diff --git a/game/addons/zylann.hterrain/util/errors.gd b/game/addons/zylann.hterrain/util/errors.gd new file mode 100644 index 0000000..c6a2c63 --- /dev/null +++ b/game/addons/zylann.hterrain/util/errors.gd @@ -0,0 +1,58 @@ +@tool + +# Taken from https://docs.godotengine.org/en/3.0/classes/class_@globalscope.html#enum-globalscope-error +const _names = { + OK: "ok", + FAILED: "Generic error.", + ERR_UNAVAILABLE: "Unavailable error", + ERR_UNCONFIGURED: "Unconfigured error", + ERR_UNAUTHORIZED: "Unauthorized error", + ERR_PARAMETER_RANGE_ERROR: "Parameter range error", + ERR_OUT_OF_MEMORY: "Out of memory (OOM) error", + ERR_FILE_NOT_FOUND: "File Not found error", + ERR_FILE_BAD_DRIVE: "File Bad drive error", + ERR_FILE_BAD_PATH: "File Bad path error", + ERR_FILE_NO_PERMISSION: "File No permission error", + ERR_FILE_ALREADY_IN_USE: "File Already in use error", + ERR_FILE_CANT_OPEN: "File Can't open error", + ERR_FILE_CANT_WRITE: "File Can't write error", + ERR_FILE_CANT_READ: "File Can't read error", + ERR_FILE_UNRECOGNIZED: "File Unrecognized error", + ERR_FILE_CORRUPT: "File Corrupt error", + ERR_FILE_MISSING_DEPENDENCIES: "File Missing dependencies error", + ERR_FILE_EOF: "File End of file (EOF) error", + ERR_CANT_OPEN: "Can't open error", + ERR_CANT_CREATE: "Can't create error", + ERR_QUERY_FAILED: "Query failed error", + ERR_ALREADY_IN_USE: "Already in use error", + ERR_LOCKED: "Locked error", + ERR_TIMEOUT: "Timeout error", + ERR_CANT_CONNECT: "Can't connect", + ERR_CANT_RESOLVE: "Can't resolve", + ERR_CONNECTION_ERROR: "Connection error", + ERR_CANT_ACQUIRE_RESOURCE: "Can't acquire resource error", + ERR_CANT_FORK: "Can't fork", + ERR_INVALID_DATA: "Invalid data error", + ERR_INVALID_PARAMETER: "Invalid parameter error", + ERR_ALREADY_EXISTS: "Already exists error", + ERR_DOES_NOT_EXIST: "Does not exist error", + ERR_DATABASE_CANT_READ: "Database Read error", + ERR_DATABASE_CANT_WRITE: "Database Write error", + ERR_COMPILATION_FAILED: "Compilation failed error", + ERR_METHOD_NOT_FOUND: "Method not found error", + ERR_LINK_FAILED: "Linking failed error", + ERR_SCRIPT_FAILED: "Script failed error", + ERR_CYCLIC_LINK: "Cycling link (import cycle) error", + ERR_INVALID_DECLARATION: "Invalid declaration", + ERR_DUPLICATE_SYMBOL: "Duplicate symbol", + ERR_PARSE_ERROR: "Parse error", + ERR_BUSY: "Busy error", + ERR_SKIP: "Skip error", + ERR_HELP: "Help error", + ERR_BUG: "Bug error", + ERR_PRINTER_ON_FIRE: "The printer is on fire" +} + +static func get_message(err_code: int): + return str("[", err_code, "]: ", _names[err_code]) + diff --git a/game/addons/zylann.hterrain/util/grid.gd b/game/addons/zylann.hterrain/util/grid.gd new file mode 100644 index 0000000..b28e83b --- /dev/null +++ b/game/addons/zylann.hterrain/util/grid.gd @@ -0,0 +1,203 @@ + +# Note: `tool` is optional but without it there are no error reporting in the editor +@tool + +# TODO Remove grid_ prefixes, context is already given by the script itself + + +# Performs a positive integer division rounded to upper (4/2 = 2, 5/3 = 2) +static func up_div(a: int, b: int): + if a % b != 0: + return a / b + 1 + return a / b + + +# Creates a 2D array as an array of arrays. +# if v is provided, all cells will contain the same value. +# if v is a funcref, it will be executed to fill the grid cell per cell. +static func create_grid(w: int, h: int, v=null): + var is_create_func = typeof(v) == TYPE_CALLABLE + var grid := [] + grid.resize(h) + for y in range(grid.size()): + var row := [] + row.resize(w) + if is_create_func: + for x in range(row.size()): + row[x] = v.call(x, y) + else: + for x in range(row.size()): + row[x] = v + grid[y] = row + return grid + + +# Creates a 2D array that is a copy of another 2D array +static func clone_grid(other_grid): + var grid := [] + grid.resize(other_grid.size()) + for y in range(0, grid.size()): + var row := [] + var other_row = other_grid[y] + row.resize(other_row.size()) + grid[y] = row + for x in range(0, row.size()): + row[x] = other_row[x] + return grid + + +# Resizes a 2D array and allows to set or call functions for each deleted and created cells. +# This is especially useful if cells contain objects and you don't want to loose existing data. +static func resize_grid(grid, new_width, new_height, create_func=null, delete_func=null): + # Check parameters + assert(new_width >= 0 and new_height >= 0) + assert(grid != null) + if delete_func != null: + assert(typeof(delete_func) == TYPE_CALLABLE) + # `create_func` can also be a default value + var is_create_func = typeof(create_func) == TYPE_CALLABLE + + # Get old size (supposed to be rectangular!) + var old_height = grid.size() + var old_width = 0 + if grid.size() != 0: + old_width = grid[0].size() + + # Delete old rows + if new_height < old_height: + if delete_func != null: + for y in range(new_height, grid.size()): + var row = grid[y] + for x in len(row): + var elem = row[x] + delete_func.call(elem) + grid.resize(new_height) + + # Delete old columns + if new_width < old_width: + for y in len(grid): + var row = grid[y] + if delete_func != null: + for x in range(new_width, row.size()): + var elem = row[x] + delete_func.call(elem) + row.resize(new_width) + + # Create new columns + if new_width > old_width: + for y in len(grid): + var row = grid[y] + row.resize(new_width) + if is_create_func: + for x in range(old_width, new_width): + row[x] = create_func.call(x,y) + else: + for x in range(old_width, new_width): + row[x] = create_func + + # Create new rows + if new_height > old_height: + grid.resize(new_height) + for y in range(old_height, new_height): + var row = [] + row.resize(new_width) + grid[y] = row + if is_create_func: + for x in new_width: + row[x] = create_func.call(x,y) + else: + for x in new_width: + row[x] = create_func + + # Debug test check + assert(grid.size() == new_height) + for y in len(grid): + assert(len(grid[y]) == new_width) + + +# Retrieves the minimum and maximum values from a grid +static func grid_min_max(grid): + if grid.size() == 0 or grid[0].size() == 0: + return [0,0] + var vmin = grid[0][0] + var vmax = vmin + for y in len(grid): + var row = grid[y] + for x in len(row): + var v = row[x] + if v > vmax: + vmax = v + elif v < vmin: + vmin = v + return [vmin, vmax] + + +# Copies a sub-region of a grid as a new grid. No boundary check! +static func grid_extract_area(src_grid, x0, y0, w, h): + var dst = create_grid(w, h) + for y in h: + var dst_row = dst[y] + var src_row = src_grid[y0+y] + for x in w: + dst_row[x] = src_row[x0+x] + return dst + + +# Extracts data and crops the result if the requested rect crosses the bounds +static func grid_extract_area_safe_crop(src_grid, x0, y0, w, h): + # Return empty is completely out of bounds + var gw = src_grid.size() + if gw == 0: + return [] + var gh = src_grid[0].size() + if x0 >= gw or y0 >= gh: + return [] + + # Crop min pos + if x0 < 0: + w += x0 + x0 = 0 + if y0 < 0: + h += y0 + y0 = 0 + + # Crop max pos + if x0 + w >= gw: + w = gw-x0 + if y0 + h >= gh: + h = gh-y0 + + return grid_extract_area(src_grid, x0, y0, w, h) + + +# Sets values from a grid inside another grid. No boundary check! +static func grid_paste(src_grid, dst_grid, x0, y0): + for y in range(0, src_grid.size()): + var src_row = src_grid[y] + var dst_row = dst_grid[y0+y] + for x in range(0, src_row.size()): + dst_row[x0+x] = src_row[x] + + +# Tests if two grids are the same size and contain the same values +static func grid_equals(a, b): + if a.size() != b.size(): + return false + for y in a.size(): + var a_row = a[y] + var b_row = b[y] + if a_row.size() != b_row.size(): + return false + for x in b_row.size(): + if a_row[x] != b_row[x]: + return false + return true + + +static func grid_get_or_default(grid, x, y, defval=null): + if y >= 0 and y < len(grid): + var row = grid[y] + if x >= 0 and x < len(row): + return row[x] + return defval + diff --git a/game/addons/zylann.hterrain/util/image_file_cache.gd b/game/addons/zylann.hterrain/util/image_file_cache.gd new file mode 100644 index 0000000..e39e4cd --- /dev/null +++ b/game/addons/zylann.hterrain/util/image_file_cache.gd @@ -0,0 +1,291 @@ +@tool + +# Used to store temporary images on disk. +# This is useful for undo/redo as image edition can quickly fill up memory. + +# Image data is stored in archive files together, +# because when dealing with many images it speeds up filesystem I/O on Windows. +# If the file exceeds a predefined size, a new one is created. +# Writing to disk is performed from a thread, to leave the main thread responsive. +# However if you want to obtain an image back while it didn't save yet, the main thread will block. +# When the application or plugin is closed, the files get cleared. + +const HT_Logger = preload("./logger.gd") +const HT_Errors = preload("./errors.gd") + +const CACHE_FILE_SIZE_THRESHOLD = 1048576 +# For debugging +const USE_THREAD = true + +var _cache_dir := "" +var _next_id := 0 +var _session_id := "" +var _cache_image_info := {} +var _logger = HT_Logger.get_for(self) +var _current_cache_file_index := 0 +var _cache_file_offset := 0 + +var _saving_thread := Thread.new() +var _save_queue := [] +var _save_queue_mutex := Mutex.new() +var _save_semaphore := Semaphore.new() +var _save_thread_running := false + + +func _init(cache_dir: String): + assert(cache_dir != "") + _cache_dir = cache_dir + var rng := RandomNumberGenerator.new() + rng.randomize() + for i in 16: + _session_id += str(rng.randi() % 10) + _logger.debug(str("Image cache session ID: ", _session_id)) + if not DirAccess.dir_exists_absolute(_cache_dir): + var err := DirAccess.make_dir_absolute(_cache_dir) + if err != OK: + _logger.error("Could not create directory {0}: {1}" \ + .format([_cache_dir, HT_Errors.get_message(err)])) + _save_thread_running = true + if USE_THREAD: + _saving_thread.start(_save_thread_func) + + +# TODO Cannot cleanup the cache in destructor! +# Godot doesn't allow me to call clear()... +# https://github.com/godotengine/godot/issues/31166 +func _notification(what: int): + if what == NOTIFICATION_PREDELETE: + #clear() + _save_thread_running = false + _save_semaphore.post() + if USE_THREAD: + _saving_thread.wait_to_finish() + + +func _create_new_cache_file(fpath: String): + var f := FileAccess.open(fpath, FileAccess.WRITE) + if f == null: + var err = FileAccess.get_open_error() + _logger.error("Failed to create new cache file {0}: {1}" \ + .format([fpath, HT_Errors.get_message(err)])) + return + + +func _get_current_cache_file_name() -> String: + return _cache_dir.path_join(str(_session_id, "_", _current_cache_file_index, ".cache")) + + +func save_image(im: Image) -> int: + assert(im != null) + if im.has_mipmaps(): + # TODO Add support for this? Didn't need it so far + _logger.error("Caching an image with mipmaps, this isn't supported") + + var fpath := _get_current_cache_file_name() + if _next_id == 0: + # First file + _create_new_cache_file(fpath) + + var id := _next_id + _next_id += 1 + + var item := { + # Duplicate the image so we are sure nothing funny will happen to it + # while the thread saves it + "image": im.duplicate(), + "path": fpath, + "data_offset": _cache_file_offset, + "saved": false + } + + _cache_file_offset += _get_image_data_size(im) + if _cache_file_offset >= CACHE_FILE_SIZE_THRESHOLD: + _cache_file_offset = 0 + _current_cache_file_index += 1 + _create_new_cache_file(_get_current_cache_file_name()) + + _cache_image_info[id] = item + + _save_queue_mutex.lock() + _save_queue.append(item) + _save_queue_mutex.unlock() + + _save_semaphore.post() + + if not USE_THREAD: + var before = Time.get_ticks_msec() + while len(_save_queue) > 0: + _save_thread_func() + if Time.get_ticks_msec() - before > 10_000: + _logger.error("Taking to long to empty save queue in non-threaded mode!") + + return id + + +static func _get_image_data_size(im: Image) -> int: + return 1 + 4 + 4 + 4 + len(im.get_data()) + + +static func _write_image(f: FileAccess, im: Image): + f.store_8(im.get_format()) + f.store_32(im.get_width()) + f.store_32(im.get_height()) + var data : PackedByteArray = im.get_data() + f.store_32(len(data)) + f.store_buffer(data) + + +static func _read_image(f: FileAccess) -> Image: + var format := f.get_8() + var width := f.get_32() + var height := f.get_32() + var data_size := f.get_32() + var data := f.get_buffer(data_size) + var im := Image.create_from_data(width, height, false, format, data) + return im + + +func load_image(id: int) -> Image: + var info := _cache_image_info[id] as Dictionary + + var timeout := 5.0 + var time_before := Time.get_ticks_msec() + # We could just grab `image`, because the thread only reads it. + # However it's still not safe to do that if we write or even lock it, + # so we have to assume it still has ownership of it. + while not info.saved: + OS.delay_msec(8.0) + _logger.debug("Waiting for cached image {0}...".format([id])) + if Time.get_ticks_msec() - time_before > timeout: + _logger.error("Could not get image {0} from cache. Something went wrong.".format([id])) + return null + + var fpath := info.path as String + + var f := FileAccess.open(fpath, FileAccess.READ) + if f == null: + var err := FileAccess.get_open_error() + _logger.error("Could not load cached image from {0}: {1}" \ + .format([fpath, HT_Errors.get_message(err)])) + return null + + f.seek(info.data_offset) + var im = _read_image(f) + f = null # close file + + assert(im != null) + return im + + +func clear(): + _logger.debug("Clearing image cache") + + var dir := DirAccess.open(_cache_dir) + if dir == null: + #var err = DirAccess.get_open_error() + _logger.error("Could not open image file cache directory '{0}'" \ + .format([_cache_dir])) + return + + dir.include_hidden = false + dir.include_navigational = false + + var err := dir.list_dir_begin() + if err != OK: + _logger.error("Could not start list_dir_begin in '{0}'".format([_cache_dir])) + return + + # Delete all cache files + while true: + var fpath := dir.get_next() + if fpath == "": + break + if fpath.ends_with(".cache"): + _logger.debug(str("Deleting ", fpath)) + err = dir.remove(fpath) + if err != OK: + _logger.error("Failed to delete cache file '{0}': {1}" \ + .format([_cache_dir.path_join(fpath), HT_Errors.get_message(err)])) + + _cache_image_info.clear() + + +func _save_thread_func(): + # Threads keep a reference to the object of the function they run. + # So if the object is a Reference, and that reference owns the thread... we get a cycle. + # We can break the cycle by removing 1 to the count inside the thread. + # The thread's reference will never die unexpectedly because we stop and destroy the thread + # in the destructor of the reference. + # If that workaround explodes one day, another way could be to use an intermediary instance + # extending Object, and run a function on that instead. + # + # I added this in Godot 3, and it seems to still be relevant in Godot 4 because if I don't + # do it, objects are leaking. + # + # BUT it seems to end up triggering a crash in debug Godot builds due to unrefing RefCounted + # with refcount == 0, so I guess it's wrong now? + # So basically, either I do it and I risk a crash, + # or I don't do it and then it causes a leak... + # TODO Make this shit use `Object` + # + # if USE_THREAD: + # unreference() + + while _save_thread_running: + _save_queue_mutex.lock() + var to_save := _save_queue.duplicate(false) + _save_queue.clear() + _save_queue_mutex.unlock() + + if len(to_save) == 0: + if USE_THREAD: + _save_semaphore.wait() + continue + + var f : FileAccess + var path := "" + + for item in to_save: + # Keep re-using the same file if we did not change path. + # It makes I/Os faster. + if item.path != path: + # Close previous file + f = null + + path = item.path + + f = FileAccess.open(path, FileAccess.READ_WRITE) + if f == null: + var err := FileAccess.get_open_error() + call_deferred("_on_error", "Could not open file {0}: {1}" \ + .format([path, HT_Errors.get_message(err)])) + path = "" + continue + + f.seek(item.data_offset) + _write_image(f, item.image) + # Notify main thread. + # The thread does not modify data, only reads it. + call_deferred("_on_image_saved", item) + + # Workaround some weird behavior in Godot 4: + # when the next loop runs, `f` IS NOT CLEANED UP. A reference is still held before `var f` + # is reached, which means the file is still locked while the thread is waiting on the + # semaphore... so I have to explicitely "close" the file here. + f = null + + if not USE_THREAD: + break + + +func _on_error(msg: String): + _logger.error(msg) + + +func _on_image_saved(item: Dictionary): + _logger.debug(str("Saved ", item.path)) + item.saved = true + # Should remove image from memory (for usually being last reference) + item.image = null + + diff --git a/game/addons/zylann.hterrain/util/logger.gd b/game/addons/zylann.hterrain/util/logger.gd new file mode 100644 index 0000000..fcc78a3 --- /dev/null +++ b/game/addons/zylann.hterrain/util/logger.gd @@ -0,0 +1,34 @@ +@tool + +class HT_LoggerBase: + var _context := "" + + func _init(p_context): + _context = p_context + + func debug(msg: String): + pass + + func warn(msg: String): + push_warning("{0}: {1}".format([_context, msg])) + + func error(msg: String): + push_error("{0}: {1}".format([_context, msg])) + + +class HT_LoggerVerbose extends HT_LoggerBase: + func _init(p_context: String): + super(p_context) + + func debug(msg: String): + print(_context, ": ", msg) + + +static func get_for(owner: Object) -> HT_LoggerBase: + # Note: don't store the owner. If it's a Reference, it could create a cycle + var script : Script = owner.get_script() + var context := script.resource_path.get_file() + if OS.is_stdout_verbose(): + return HT_LoggerVerbose.new(context) + return HT_LoggerBase.new(context) + diff --git a/game/addons/zylann.hterrain/util/util.gd b/game/addons/zylann.hterrain/util/util.gd new file mode 100644 index 0000000..e2cd32c --- /dev/null +++ b/game/addons/zylann.hterrain/util/util.gd @@ -0,0 +1,549 @@ +@tool + +const HT_Errors = preload("./errors.gd") + + +# Godot has this internally but doesn't expose it +static func next_power_of_two(x: int) -> int: + x -= 1 + x |= x >> 1 + x |= x >> 2 + x |= x >> 4 + x |= x >> 8 + x |= x >> 16 + x += 1 + return x + + +# CubeMesh doesn't have a wireframe option +static func create_wirecube_mesh(color = Color(1,1,1)) -> Mesh: + var positions := PackedVector3Array([ + Vector3(0, 0, 0), + Vector3(1, 0, 0), + Vector3(1, 0, 1), + Vector3(0, 0, 1), + Vector3(0, 1, 0), + Vector3(1, 1, 0), + Vector3(1, 1, 1), + Vector3(0, 1, 1), + ]) + var colors := PackedColorArray([ + color, color, color, color, + color, color, color, color, + ]) + var indices := PackedInt32Array([ + 0, 1, + 1, 2, + 2, 3, + 3, 0, + + 4, 5, + 5, 6, + 6, 7, + 7, 4, + + 0, 4, + 1, 5, + 2, 6, + 3, 7 + ]) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = positions + arrays[Mesh.ARRAY_COLOR] = colors + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_LINES, arrays) + return mesh + + +static func integer_square_root(x: int) -> int: + assert(typeof(x) == TYPE_INT) + var r := int(roundf(sqrt(x))) + if r * r == x: + return r + # Does not exist + return -1 + + +# Formats integer using a separator between each 3-digit group +static func format_integer(n: int, sep := ",") -> String: + assert(typeof(n) == TYPE_INT) + + var negative := false + if n < 0: + negative = true + n = -n + + var s = "" + while n >= 1000: + s = str(sep, str(n % 1000).pad_zeros(3), s) + n /= 1000 + + if negative: + return str("-", str(n), s) + else: + return str(str(n), s) + + +# Goes up all parents until a node of the given class is found +static func get_node_in_parents(node: Node, klass) -> Node: + while node != null: + node = node.get_parent() + if node != null and is_instance_of(node, klass): + return node + return null + + +# Goes down all children until a node of the given class is found +static func find_first_node(node: Node, klass) -> Node: + if is_instance_of(node, klass): + return node + for i in node.get_child_count(): + var child := node.get_child(i) + var found_node := find_first_node(child, klass) + if found_node != null: + return found_node + return null + + +static func is_in_edited_scene(node: Node) -> bool: + if not node.is_inside_tree(): + return false + var edited_scene := node.get_tree().edited_scene_root + if node == edited_scene: + return true + return edited_scene != null and edited_scene.is_ancestor_of(node) + + +# Get an extended or cropped version of an image, +# with optional anchoring to decide in which direction to extend or crop. +# New pixels are filled with the provided fill color. +static func get_cropped_image(src: Image, width: int, height: int, + fill_color=null, anchor=Vector2(-1, -1)) -> Image: + + width = int(width) + height = int(height) + if width == src.get_width() and height == src.get_height(): + return src + var im := Image.create(width, height, false, src.get_format()) + if fill_color != null: + im.fill(fill_color) + var p = get_cropped_image_params( + src.get_width(), src.get_height(), width, height, anchor) + im.blit_rect(src, p.src_rect, p.dst_pos) + return im + + +static func get_cropped_image_params(src_w: int, src_h: int, dst_w: int, dst_h: int, + anchor: Vector2) -> Dictionary: + + var rel_anchor := (anchor + Vector2(1, 1)) / 2.0 + + var dst_x := (dst_w - src_w) * rel_anchor.x + var dst_y := (dst_h - src_h) * rel_anchor.y + + var src_x := 0 + var src_y := 0 + + if dst_x < 0: + src_x -= dst_x + src_w -= dst_x + dst_x = 0 + + if dst_y < 0: + src_y -= dst_y + src_h -= dst_y + dst_y = 0 + + if dst_x + src_w >= dst_w: + src_w = dst_w - dst_x + + if dst_y + src_h >= dst_h: + src_h = dst_h - dst_y + + return { + "src_rect": Rect2i(src_x, src_y, src_w, src_h), + "dst_pos": Vector2i(dst_x, dst_y) + } + +# TODO Workaround for https://github.com/godotengine/godot/issues/24488 +# TODO Simplify in Godot 3.1 if that's still not fixed, +# using https://github.com/godotengine/godot/pull/21806 +# And actually that function does not even work. +#static func get_shader_param_or_default(mat: Material, name: String): +# assert(mat.shader != null) +# var v = mat.get_shader_param(name) +# if v != null: +# return v +# var params = VisualServer.shader_get_param_list(mat.shader) +# for p in params: +# if p.name == name: +# match p.type: +# TYPE_OBJECT: +# return null +# # I should normally check default values, +# # however they are not accessible +# TYPE_BOOL: +# return false +# TYPE_REAL: +# return 0.0 +# TYPE_VECTOR2: +# return Vector2() +# TYPE_VECTOR3: +# return Vector3() +# TYPE_COLOR: +# return Color() +# return null + + +# Generic way to apply editor scale to a plugin UI scene. +# It is slower than doing it manually on specific controls. +# Takes a node as root because since Godot 4 Window dialogs are no longer Controls. +static func apply_dpi_scale(root: Node, dpi_scale: float): + if dpi_scale == 1.0: + return + var to_process := [root] + while len(to_process) > 0: + var node : Node = to_process[-1] + to_process.pop_back() + if node is Window: + node.size = Vector2(node.size) * dpi_scale + elif node is Viewport or node is SubViewport: + continue + elif node is Control: + if node.custom_minimum_size != Vector2(0, 0): + node.custom_minimum_size = node.custom_minimum_size * dpi_scale + var parent = node.get_parent() + if parent != null: + if not (parent is Container): + node.offset_bottom *= dpi_scale + node.offset_left *= dpi_scale + node.offset_top *= dpi_scale + node.offset_right *= dpi_scale + for i in node.get_child_count(): + to_process.append(node.get_child(i)) + + +# TODO AABB has `intersects_segment` but doesn't provide the hit point +# So we have to rely on a less efficient method. +# Returns a list of intersections between an AABB and a segment, sorted +# by distance to the beginning of the segment. +static func get_aabb_intersection_with_segment(aabb: AABB, + segment_begin: Vector3, segment_end: Vector3) -> Array: + + var hits := [] + + if not aabb.intersects_segment(segment_begin, segment_end): + return hits + + var hit + + var x_rect := Rect2(aabb.position.y, aabb.position.z, aabb.size.y, aabb.size.z) + + hit = Plane(Vector3(1, 0, 0), aabb.position.x) \ + .intersects_segment(segment_begin, segment_end) + if hit != null and x_rect.has_point(Vector2(hit.y, hit.z)): + hits.append(hit) + + hit = Plane(Vector3(1, 0, 0), aabb.end.x) \ + .intersects_segment(segment_begin, segment_end) + if hit != null and x_rect.has_point(Vector2(hit.y, hit.z)): + hits.append(hit) + + var y_rect := Rect2(aabb.position.x, aabb.position.z, aabb.size.x, aabb.size.z) + + hit = Plane(Vector3(0, 1, 0), aabb.position.y) \ + .intersects_segment(segment_begin, segment_end) + if hit != null and y_rect.has_point(Vector2(hit.x, hit.z)): + hits.append(hit) + + hit = Plane(Vector3(0, 1, 0), aabb.end.y) \ + .intersects_segment(segment_begin, segment_end) + if hit != null and y_rect.has_point(Vector2(hit.x, hit.z)): + hits.append(hit) + + var z_rect := Rect2(aabb.position.x, aabb.position.y, aabb.size.x, aabb.size.y) + + hit = Plane(Vector3(0, 0, 1), aabb.position.z) \ + .intersects_segment(segment_begin, segment_end) + if hit != null and z_rect.has_point(Vector2(hit.x, hit.y)): + hits.append(hit) + + hit = Plane(Vector3(0, 0, 1), aabb.end.z) \ + .intersects_segment(segment_begin, segment_end) + if hit != null and z_rect.has_point(Vector2(hit.x, hit.y)): + hits.append(hit) + + if len(hits) == 2: + # The segment has two hit points. Sort them by distance + var d0 = hits[0].distance_squared_to(segment_begin) + var d1 = hits[1].distance_squared_to(segment_begin) + if d0 > d1: + var temp = hits[0] + hits[0] = hits[1] + hits[1] = temp + else: + assert(len(hits) < 2) + + return hits + + +class HT_GridRaytraceResult2D: + var hit_cell_pos: Vector2 + var prev_cell_pos: Vector2 + + +# Iterates through a virtual 2D grid of unit-sized square cells, +# and executes an action on each cell intersecting the given segment, +# ordered from begin to end. +# One of my most re-used pieces of code :) +# +# Initially inspired by http://www.cse.yorku.ca/~amana/research/grid.pdf +# +# Ported from https://github.com/bulletphysics/bullet3/blob/ +# 687780af6b491056700cfb22cab57e61aeec6ab8/src/BulletCollision/CollisionShapes/ +# btHeightfieldTerrainShape.cpp#L418 +# +static func grid_raytrace_2d(ray_origin: Vector2, ray_direction: Vector2, + quad_predicate: Callable, max_distance: float) -> HT_GridRaytraceResult2D: + + if max_distance < 0.0001: + # Consider the ray is too small to hit anything + return null + + var xi_step := 0 + if ray_direction.x > 0: + xi_step = 1 + elif ray_direction.x < 0: + xi_step = -1 + + var yi_step := 0 + if ray_direction.y > 0: + yi_step = 1 + elif ray_direction.y < 0: + yi_step = -1 + + var infinite := 9999999.0 + + var param_delta_x := infinite + if xi_step != 0: + param_delta_x = 1.0 / absf(ray_direction.x) + + var param_delta_y := infinite + if yi_step != 0: + param_delta_y = 1.0 / absf(ray_direction.y) + + # pos = param * dir + # At which value of `param` we will cross a x-axis lane? + var param_cross_x := infinite + # At which value of `param` we will cross a y-axis lane? + var param_cross_y := infinite + + # param_cross_x and param_cross_z are initialized as being the first cross + # X initialization + if xi_step != 0: + if xi_step == 1: + param_cross_x = (ceilf(ray_origin.x) - ray_origin.x) * param_delta_x + else: + param_cross_x = (ray_origin.x - floorf(ray_origin.x)) * param_delta_x + else: + # Will never cross on X + param_cross_x = infinite + + # Y initialization + if yi_step != 0: + if yi_step == 1: + param_cross_y = (ceilf(ray_origin.y) - ray_origin.y) * param_delta_y + else: + param_cross_y = (ray_origin.y - floorf(ray_origin.y)) * param_delta_y + else: + # Will never cross on Y + param_cross_y = infinite + + var x := int(floorf(ray_origin.x)) + var y := int(floorf(ray_origin.y)) + + # Workaround cases where the ray starts at an integer position + if param_cross_x == 0.0: + param_cross_x += param_delta_x + # If going backwards, we should ignore the position we would get by the above flooring, + # because the ray is not heading in that direction + if xi_step == -1: + x -= 1 + + if param_cross_y == 0.0: + param_cross_y += param_delta_y + if yi_step == -1: + y -= 1 + + var prev_x := x + var prev_y := y + var param := 0.0 + var prev_param := 0.0 + + while true: + prev_x = x + prev_y = y + prev_param = param + + if param_cross_x < param_cross_y: + # X lane + x += xi_step + # Assign before advancing the param, + # to be in sync with the initialization step + param = param_cross_x + param_cross_x += param_delta_x + + else: + # Y lane + y += yi_step + param = param_cross_y + param_cross_y += param_delta_y + + if param > max_distance: + param = max_distance + # quad coordinates, enter param, exit/end param + if quad_predicate.call(prev_x, prev_y, prev_param, param): + var res := HT_GridRaytraceResult2D.new() + res.hit_cell_pos = Vector2(x, y) + res.prev_cell_pos = Vector2(prev_x, prev_y) + return res + else: + break + + elif quad_predicate.call(prev_x, prev_y, prev_param, param): + var res := HT_GridRaytraceResult2D.new() + res.hit_cell_pos = Vector2(x, y) + res.prev_cell_pos = Vector2(prev_x, prev_y) + return res + + return null + + +static func get_segment_clipped_by_rect(rect: Rect2, + segment_begin: Vector2, segment_end: Vector2) -> Array: + + # / + # A-----/---B A-----+---B + # | / | => | / | + # | / | | / | + # C--/------D C--+------D + # / + + if rect.has_point(segment_begin) and rect.has_point(segment_end): + return [segment_begin, segment_end] + + var a := rect.position + var b := Vector2(rect.end.x, rect.position.y) + var c := Vector2(rect.position.x, rect.end.y) + var d := rect.end + + var ab = Geometry2D.segment_intersects_segment(segment_begin, segment_end, a, b) + var cd = Geometry2D.segment_intersects_segment(segment_begin, segment_end, c, d) + var ac = Geometry2D.segment_intersects_segment(segment_begin, segment_end, a, c) + var bd = Geometry2D.segment_intersects_segment(segment_begin, segment_end, b, d) + + var hits = [] + if ab != null: + hits.append(ab) + if cd != null: + hits.append(cd) + if ac != null: + hits.append(ac) + if bd != null: + hits.append(bd) + + # Now we need to order the hits from begin to end + if len(hits) == 1: + if rect.has_point(segment_begin): + hits = [segment_begin, hits[0]] + elif rect.has_point(segment_end): + hits = [hits[0], segment_end] + else: + # TODO This has a tendency to happen with integer coordinates... + # How can you get only 1 hit and have no end of the segment + # inside of the rectangle? Float precision shit? Assume no hit... + return [] + + elif len(hits) == 2: + var d0 = hits[0].distance_squared_to(segment_begin) + var d1 = hits[1].distance_squared_to(segment_begin) + if d0 > d1: + hits = [hits[1], hits[0]] + + return hits + + +static func get_pixel_clamped(im: Image, x: int, y: int) -> Color: + x = clampi(x, 0, im.get_width() - 1) + y = clampi(y, 0, im.get_height() - 1) + return im.get_pixel(x, y) + + +static func update_configuration_warning(node: Node, recursive: bool): + if not Engine.is_editor_hint(): + return + node.update_configuration_warnings() + if recursive: + for i in node.get_child_count(): + var child = node.get_child(i) + update_configuration_warning(child, true) + + +static func write_import_file(settings: Dictionary, imp_fpath: String, logger) -> bool: + # TODO Should use ConfigFile instead + var f := FileAccess.open(imp_fpath, FileAccess.WRITE) + if f == null: + var err = FileAccess.get_open_error() + logger.error("Could not open '{0}' for write, error {1}" \ + .format([imp_fpath, HT_Errors.get_message(err)])) + return false + + for section in settings: + f.store_line(str("[", section, "]")) + f.store_line("") + var params = settings[section] + for key in params: + var v = params[key] + var sv + match typeof(v): + TYPE_STRING: + sv = str('"', v.replace('"', '\"'), '"') + TYPE_BOOL: + sv = "true" if v else "false" + _: + sv = str(v) + f.store_line(str(key, "=", sv)) + f.store_line("") + + return true + + +static func update_texture_partial( + tex: ImageTexture, im: Image, src_rect: Rect2i, dst_pos: Vector2i): + + # ..ooo@@@XXX%%%xx.. + # .oo@@XXX%x%xxx.. ` . + # .o@XX%%xx.. ` . + # o@X%.. ..ooooooo + # .@X%x. ..o@@^^ ^^@@o + # .ooo@@@@@@ooo.. ..o@@^ @X% + # o@@^^^ ^^^@@@ooo.oo@@^ % + # xzI -*-- ^^^o^^ --*- % + # @@@o ooooooo^@@^o^@X^@oooooo .X%x + # I@@@@@@@@@XX%%xx ( o@o )X%x@ROMBASED@@@X%x + # I@@@@XX%%xx oo@@@@X% @@X%x ^^^@@@@@@@X%x + # @X%xx o@@@@@@@X% @@XX%%x ) ^^@X%x + # ^ xx o@@@@@@@@Xx ^ @XX%%x xxx + # o@@^^^ooo I^^ I^o ooo . x + # oo @^ IX I ^X @^ oo + # IX U . V IX + # V . . V + # + + # TODO Optimize: Godot 4 has lost the ability to update textures partially! + var fuck = tex.get_image() + fuck.blit_rect(im, src_rect, dst_pos) + tex.update(fuck) + diff --git a/game/addons/zylann.hterrain/util/xyz_format.gd b/game/addons/zylann.hterrain/util/xyz_format.gd new file mode 100644 index 0000000..86e4a1a --- /dev/null +++ b/game/addons/zylann.hterrain/util/xyz_format.gd @@ -0,0 +1,109 @@ +@tool + +# XYZ files are text files containing a list of 3D points. +# They can be found in GIS software as an export format for heightmaps. +# In order to turn it into a heightmap we may calculate bounds first +# to find the origin and then set points in an image. + + +class HT_XYZBounds: + # Note: it is important for these to be double-precision floats, + # GIS data can have large coordinates + var min_x := 0.0 + var min_y := 0.0 + + var max_x := 0.0 + var max_y := 0.0 + + var line_count := 0 + + var image_width := 0 + var image_height := 0 + + +# TODO `split_float` returns 32-bit floats, despite internally parsing doubles... +# Despite that, I still use it here because it doesn't seem to cause issues and is faster. +# If it becomes an issue, we'll have to switch to `split` and casting to `float`. + +static func load_bounds(f: FileAccess) -> HT_XYZBounds: + # It is faster to get line and split floats than using CSV functions + var line := f.get_line() + var floats := line.split_floats(" ") + + # We only care about X and Y, it makes less operations to do in the loop. + # Z is the height and will remain as-is at the end. + var min_pos_x := floats[0] + var min_pos_y := floats[1] + + var max_pos_x := min_pos_x + var max_pos_y := min_pos_y + + # Start at 1 because we just read the first line + var line_count := 1 + + # We know the file is a series of float triplets + while not f.eof_reached(): + line = f.get_line() + + # The last line can be empty + if len(line) < 2: + break + + floats = line.split_floats(" ") + + var pos_x := floats[0] + var pos_y := floats[1] + + min_pos_x = minf(min_pos_x, pos_x) + min_pos_y = minf(min_pos_y, pos_y) + + max_pos_x = maxf(max_pos_x, pos_x) + max_pos_y = maxf(max_pos_y, pos_y) + + line_count += 1 + + var bounds := HT_XYZBounds.new() + bounds.min_x = min_pos_x + bounds.min_y = min_pos_y + bounds.max_x = max_pos_x + bounds.max_y = max_pos_y + bounds.line_count = line_count + bounds.image_width = int(max_pos_x - min_pos_x) + 1 + bounds.image_height = int(max_pos_y - min_pos_y) + 1 + return bounds + + +# Loads points into an image with existing dimensions and format. +# `f` must be positioned at the beginning of the series of points. +# If `bounds` is `null`, it will be computed. +static func load_heightmap(f: FileAccess, dst_image: Image, bounds: HT_XYZBounds): + # We are not going to read the entire file directly in memory, because it can be really big. + # Instead we'll parse it directly and the only thing we retain in memory is the heightmap. + # This can be really slow on big files. If we can assume the file is square and points + # separated by 1 unit each in a grid pattern, it could be a bit faster, but + # parsing points from text really is the main bottleneck (40 seconds to load a 2000x2000 file!). + + # Bounds can be precalculated + if bounds == null: + var file_begin := f.get_position() + bounds = load_bounds(f) + f.seek(file_begin) + + # Put min coordinates on the GDScript stack so they are faster to access + var min_pos_x := bounds.min_x + var min_pos_y := bounds.min_y + var line_count := bounds.line_count + + for i in line_count: + var line := f.get_line() + var floats := line.split_floats(" ") + var x := int(floats[0] - min_pos_x) + var y := int(floats[1] - min_pos_y) + + # Make sure the coordinate is inside the image, + # due to float imprecision or potentially non-grid-aligned points. + # Could use `Rect2` to check faster but it uses floats. + # `Rect2i` would be better but is only available in Godot 4. + if x >= 0 and y >= 0 and x < dst_image.get_width() and y < dst_image.get_height(): + dst_image.set_pixel(x, y, Color(floats[2], 0, 0)) + |