aboutsummaryrefslogtreecommitdiff
path: root/game/addons/zylann.hterrain/hterrain_data.gd
diff options
context:
space:
mode:
Diffstat (limited to 'game/addons/zylann.hterrain/hterrain_data.gd')
-rw-r--r--game/addons/zylann.hterrain/hterrain_data.gd1843
1 files changed, 1843 insertions, 0 deletions
diff --git a/game/addons/zylann.hterrain/hterrain_data.gd b/game/addons/zylann.hterrain/hterrain_data.gd
new file mode 100644
index 0000000..74597f5
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_data.gd
@@ -0,0 +1,1843 @@
+
+# Holds data of the terrain.
+# This is mostly a set of textures using specific formats, some precalculated, and metadata.
+
+@tool
+extends Resource
+
+const HT_Grid = preload("./util/grid.gd")
+const HT_Util = preload("./util/util.gd")
+const HT_Errors = preload("./util/errors.gd")
+const HT_Logger = preload("./util/logger.gd")
+const HT_ImageFileCache = preload("./util/image_file_cache.gd")
+const HT_XYZFormat = preload("./util/xyz_format.gd")
+
+# Note: indexes matters for saving, don't re-order
+# TODO Rename "CHANNEL" to "MAP", makes more sense and less confusing with RGBA channels
+const CHANNEL_HEIGHT = 0
+const CHANNEL_NORMAL = 1
+const CHANNEL_SPLAT = 2
+const CHANNEL_COLOR = 3
+const CHANNEL_DETAIL = 4
+const CHANNEL_GLOBAL_ALBEDO = 5
+const CHANNEL_SPLAT_INDEX = 6
+const CHANNEL_SPLAT_WEIGHT = 7
+const CHANNEL_COUNT = 8
+
+const _map_types = {
+ CHANNEL_HEIGHT: {
+ name = "height",
+ shader_param_name = "u_terrain_heightmap",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RF,
+ default_fill = Color(0, 0, 0, 1),
+ default_count = 1,
+ can_be_saved_as_png = false,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_NORMAL: {
+ name = "normal",
+ shader_param_name = "u_terrain_normalmap",
+ filter = true,
+ mipmaps = false,
+ # TODO RGB8 is a lie, we should use RGBA8 and pack something in A I guess
+ texture_format = Image.FORMAT_RGB8,
+ default_fill = Color(0.5, 0.5, 1.0),
+ default_count = 1,
+ can_be_saved_as_png = true,
+ authored = false,
+ srgb = false
+ },
+ CHANNEL_SPLAT: {
+ name = "splat",
+ shader_param_name = [
+ "u_terrain_splatmap", # not _0 for compatibility
+ "u_terrain_splatmap_1",
+ "u_terrain_splatmap_2",
+ "u_terrain_splatmap_3"
+ ],
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RGBA8,
+ default_fill = [Color(1, 0, 0, 0), Color(0, 0, 0, 0)],
+ default_count = 1,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_COLOR: {
+ name = "color",
+ shader_param_name = "u_terrain_colormap",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RGBA8,
+ default_fill = Color(1, 1, 1, 1),
+ default_count = 1,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = true
+ },
+ CHANNEL_DETAIL: {
+ name = "detail",
+ shader_param_name = "u_terrain_detailmap",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_R8,
+ default_fill = Color(0, 0, 0),
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_GLOBAL_ALBEDO: {
+ name = "global_albedo",
+ shader_param_name = "u_terrain_globalmap",
+ filter = true,
+ mipmaps = true,
+ texture_format = Image.FORMAT_RGB8,
+ default_fill = null,
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = false,
+ srgb = true
+ },
+ CHANNEL_SPLAT_INDEX: {
+ name = "splat_index",
+ shader_param_name = "u_terrain_splat_index_map",
+ filter = false,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RGB8,
+ default_fill = Color(0, 0, 0),
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_SPLAT_WEIGHT: {
+ name = "splat_weight",
+ shader_param_name = "u_terrain_splat_weight_map",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RG8,
+ default_fill = Color(1, 0, 0),
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ }
+}
+
+# Resolution is a power of two + 1
+const MAX_RESOLUTION = 4097
+const MIN_RESOLUTION = 65 # must be higher than largest chunk size
+const DEFAULT_RESOLUTION = 513
+const SUPPORTED_RESOLUTIONS = [65, 129, 257, 513, 1025, 2049, 4097]
+
+const VERTICAL_BOUNDS_CHUNK_SIZE = 16
+# TODO Have undo chunk size to emphasise the fact it's independent
+
+const META_EXTENSION = "hterrain"
+const META_FILENAME = "data.hterrain"
+const META_VERSION = "0.11"
+
+signal resolution_changed
+signal region_changed(x, y, w, h, channel)
+signal map_added(type, index)
+signal map_removed(type, index)
+signal map_changed(type, index)
+
+
+# A map is a texture covering the terrain.
+# The usage of a map depends on its type (heightmap, normalmap, splatmap...).
+class HT_Map:
+ var texture: Texture2D
+ # Reference used in case we need the data CPU-side
+ var image: Image
+ # ID used for saving, because when adding/removing maps,
+ # we shouldn't rename texture files just because the indexes change.
+ # This is mostly for internal keeping.
+ # The API still uses indexes that may shift if your remove a map.
+ var id := -1
+ # Should be set to true if the map has unsaved modifications.
+ var modified := true
+
+ func _init(p_id: int):
+ id = p_id
+
+
+var _resolution := 0
+
+# There can be multiple maps of the same type, though most of them are single
+# [map_type][instance_index] => map
+var _maps := [[]]
+
+# RGF image where R is min height and G is max height
+var _chunked_vertical_bounds := Image.new()
+
+var _locked := false
+
+var _edit_disable_apply_undo := false
+var _logger := HT_Logger.get_for(self)
+
+
+func _init():
+ # Initialize default maps
+ _set_default_maps()
+
+
+func _set_default_maps():
+ _maps.resize(CHANNEL_COUNT)
+ for c in CHANNEL_COUNT:
+ var maps := []
+ var n : int = _map_types[c].default_count
+ for i in n:
+ maps.append(HT_Map.new(i))
+ _maps[c] = maps
+
+
+func _edit_load_default():
+ _logger.debug("Loading default data")
+ _set_default_maps()
+ resize(DEFAULT_RESOLUTION)
+
+
+# Don't use the data if this getter returns false
+func is_locked() -> bool:
+ return _locked
+
+
+func get_resolution() -> int:
+ return _resolution
+
+
+# @obsolete
+func set_resolution(p_res):
+ _logger.error("`HTerrainData.set_resolution()` is obsolete, use `resize()` instead")
+ resize(p_res)
+
+
+# @obsolete
+func set_resolution2(p_res, update_normals):
+ _logger.error("`HTerrainData.set_resolution2()` is obsolete, use `resize()` instead")
+ resize(p_res, true, Vector2(-1, -1))
+
+
+# Resizes all maps of the terrain. This may take some time to complete.
+# Note that no upload to GPU is done, you have to do it once you're done with all changes,
+# by calling `notify_region_change` or `notify_full_change`.
+# p_res: new resolution. Must be a power of two + 1.
+# stretch: if true, the terrain will be stretched in X and Z axes.
+# If false, it will be cropped or expanded.
+# anchor: if stretch is false, decides which side or corner to crop/expand the terrain from.
+#
+# There is an off-by-one in the data,
+# so for example a map of 512x512 will actually have 513x513 cells.
+# Here is why:
+# If we had an even amount of cells, it would produce this situation when making LOD chunks:
+#
+# x---x---x---x x---x---x---x
+# | | | | | |
+# x---x---x---x x x x x
+# | | | | | |
+# x---x---x---x x---x---x---x
+# | | | | | |
+# x---x---x---x x x x x
+#
+# LOD 0 LOD 1
+#
+# We would be forced to ignore the last cells because they would produce an irregular chunk.
+# We need an off-by-one because quads making up chunks SHARE their consecutive vertices.
+# One quad needs at least 2x2 cells to exist.
+# Two quads of the heightmap share an edge, which needs a total of 3x3 cells, not 4x4.
+# One chunk has 16x16 quads, so it needs 17x17 cells,
+# not 16, where the last cell is shared with the next chunk.
+# As a result, a map of 4x4 chunks needs 65x65 cells, not 64x64.
+func resize(p_res: int, stretch := true, anchor := Vector2(-1, -1)):
+ assert(typeof(p_res) == TYPE_INT)
+ assert(typeof(stretch) == TYPE_BOOL)
+ assert(typeof(anchor) == TYPE_VECTOR2)
+
+ _logger.debug(str("set_resolution ", p_res))
+
+ if p_res == get_resolution():
+ return
+
+ p_res = clampi(p_res, MIN_RESOLUTION, MAX_RESOLUTION)
+
+ # Power of two is important for LOD.
+ # Also, grid data is off by one,
+ # because for an even number of quads you need an odd number of vertices.
+ # To prevent size from increasing at every deserialization,
+ # remove 1 before applying power of two.
+ p_res = HT_Util.next_power_of_two(p_res - 1) + 1
+
+ _resolution = p_res;
+
+ for channel in CHANNEL_COUNT:
+ var maps : Array = _maps[channel]
+
+ for index in len(maps):
+ _logger.debug(str("Resizing ", get_map_debug_name(channel, index), "..."))
+
+ var map : HT_Map = maps[index]
+ var im := map.image
+
+ if im == null:
+ _logger.debug("Image not in memory, creating it")
+ im = Image.create(_resolution, _resolution, false, get_channel_format(channel))
+
+ var fill_color = _get_map_default_fill_color(channel, index)
+ if fill_color != null:
+ _logger.debug(str("Fill with ", fill_color))
+ im.fill(fill_color)
+
+ else:
+ if stretch and not _map_types[channel].authored:
+ # Create a blank new image, it will be automatically computed later
+ im = Image.create(_resolution, _resolution, false, get_channel_format(channel))
+ else:
+ if stretch:
+ if im.get_format() == Image.FORMAT_RGB8:
+ # Can't directly resize this format
+ var float_heightmap := convert_heightmap_to_float(im, _logger)
+ float_heightmap.resize(_resolution, _resolution)
+ im = Image.create(
+ float_heightmap.get_width(),
+ float_heightmap.get_height(), im.has_mipmaps(), im.get_format())
+ convert_float_heightmap_to_rgb8(float_heightmap, im)
+ else:
+ # Assuming float or single-component fixed-point
+ im.resize(_resolution, _resolution)
+ else:
+ var fill_color = _get_map_default_fill_color(channel, index)
+ im = HT_Util.get_cropped_image(im, _resolution, _resolution, \
+ fill_color, anchor)
+
+ map.image = im
+ map.modified = true
+
+ _update_all_vertical_bounds()
+
+ resolution_changed.emit()
+
+
+# TODO Can't hint it, the return is a nullable Color
+static func _get_map_default_fill_color(map_type: int, map_index: int):
+ var config = _map_types[map_type].default_fill
+ if config == null:
+ # No fill required
+ return null
+ if typeof(config) == TYPE_COLOR:
+ # Standard color fill
+ return config
+ assert(typeof(config) == TYPE_ARRAY)
+ assert(len(config) == 2)
+ if map_index == 0:
+ # First map has this config
+ return config[0]
+ # Others have this
+ return config[1]
+
+
+# Gets the height at the given cell position.
+# This height is raw and doesn't account for scaling of the terrain node.
+# This function is relatively slow due to locking, so don't use it to fetch large areas.
+func get_height_at(x: int, y: int) -> float:
+ # Height data must be loaded in RAM
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+ match im.get_format():
+ Image.FORMAT_RF:
+ return HT_Util.get_pixel_clamped(im, x, y).r
+ Image.FORMAT_RGB8:
+ return decode_height_from_rgb8_unorm(HT_Util.get_pixel_clamped(im, x, y))
+ _:
+ _logger.error(str("Invalid heigthmap format ", im.get_format()))
+ return 0.0
+
+
+# Gets the height at the given floating-point cell position.
+# This height is raw and doesn't account for scaling of the terrain node.
+# This function is relatively slow due to locking, so don't use it to fetch large areas
+func get_interpolated_height_at(pos: Vector3) -> float:
+ # Height data must be loaded in RAM
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+ var map_type = _map_types[CHANNEL_HEIGHT]
+ assert(im.get_format() == map_type.texture_format)
+
+ # The function takes a Vector3 for convenience so it's easier to use in 3D scripting
+ var x0 := int(floorf(pos.x))
+ var y0 := int(floorf(pos.z))
+
+ var xf := pos.x - x0
+ var yf := pos.z - y0
+
+ var h00 : float
+ var h10 : float
+ var h01 : float
+ var h11 : float
+
+ match im.get_format():
+ Image.FORMAT_RF:
+ h00 = HT_Util.get_pixel_clamped(im, x0, y0).r
+ h10 = HT_Util.get_pixel_clamped(im, x0 + 1, y0).r
+ h01 = HT_Util.get_pixel_clamped(im, x0, y0 + 1).r
+ h11 = HT_Util.get_pixel_clamped(im, x0 + 1, y0 + 1).r
+
+ Image.FORMAT_RGB8:
+ var c00 := HT_Util.get_pixel_clamped(im, x0, y0)
+ var c10 := HT_Util.get_pixel_clamped(im, x0 + 1, y0)
+ var c01 := HT_Util.get_pixel_clamped(im, x0, y0 + 1)
+ var c11 := HT_Util.get_pixel_clamped(im, x0 + 1, y0 + 1)
+
+ h00 = decode_height_from_rgb8_unorm(c00)
+ h10 = decode_height_from_rgb8_unorm(c10)
+ h01 = decode_height_from_rgb8_unorm(c01)
+ h11 = decode_height_from_rgb8_unorm(c11)
+
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+ return 0.0
+
+ # Bilinear filter
+ var h := lerpf(lerpf(h00, h10, xf), lerpf(h01, h11, xf), yf)
+ return h
+
+# Gets all heights within the given rectangle in cells.
+# This height is raw and doesn't account for scaling of the terrain node.
+# Data is returned as a PackedFloat32Array.
+func get_heights_region(x0: int, y0: int, w: int, h: int) -> PackedFloat32Array:
+ var im = get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ var min_x := clampi(x0, 0, im.get_width())
+ var min_y := clampi(y0, 0, im.get_height())
+ var max_x := clampi(x0 + w, 0, im.get_width() + 1)
+ var max_y := clampi(y0 + h, 0, im.get_height() + 1)
+
+ var heights := PackedFloat32Array()
+
+ var area := (max_x - min_x) * (max_y - min_y)
+ if area == 0:
+ _logger.debug("Empty heights region!")
+ return heights
+
+ heights.resize(area)
+
+ var i := 0
+
+ if im.get_format() == Image.FORMAT_RF or im.get_format() == Image.FORMAT_RH:
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ heights[i] = im.get_pixel(x, y).r
+ i += 1
+
+ elif im.get_format() == Image.FORMAT_RGB8:
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var c := im.get_pixel(x, y)
+ heights[i] = decode_height_from_rgb8_unorm(c)
+ i += 1
+
+ else:
+ _logger.error(str("Unknown heightmap format! ", im.get_format()))
+
+ return heights
+
+
+# Gets all heights as an array indexed as [x + y * width].
+# This height is raw and doesn't account for scaling of the terrain node.
+func get_all_heights() -> PackedFloat32Array:
+ var im = get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+ if im.get_format() == Image.FORMAT_RF:
+ return im.get_data().to_float32_array()
+ else:
+ return get_heights_region(0, 0, _resolution, _resolution)
+
+
+# Call this function after you end modifying a map.
+# It will commit the change to the GPU so the change will take effect.
+# In the editor, it will also mark the map as modified so it will be saved when needed.
+# Finally, it will emit `region_changed`,
+# which allows other systems to catch up (like physics or grass)
+#
+# p_rect:
+# modified area.
+#
+# map_type:
+# which kind of map changed, see CHANNEL_* constants
+#
+# index:
+# index of the map that changed
+#
+# p_upload_to_texture:
+# the modified region will be copied from the map image to the texture.
+# If the change already occurred on GPU, you may set this to false.
+#
+# p_update_vertical_bounds:
+# if the modified map is the heightmap, vertical bounds will be updated.
+#
+func notify_region_change(
+ p_rect: Rect2,
+ p_map_type: int,
+ p_index := 0,
+ p_upload_to_texture := true,
+ p_update_vertical_bounds := true):
+
+ assert(p_map_type >= 0 and p_map_type < CHANNEL_COUNT)
+
+ var min_x := int(p_rect.position.x)
+ var min_y := int(p_rect.position.y)
+ var size_x := int(p_rect.size.x)
+ var size_y := int(p_rect.size.y)
+
+ if p_map_type == CHANNEL_HEIGHT and p_update_vertical_bounds:
+ assert(p_index == 0)
+ _update_vertical_bounds(min_x, min_y, size_x, size_y)
+
+ if p_upload_to_texture:
+ _upload_region(p_map_type, p_index, min_x, min_y, size_x, size_y)
+
+ _maps[p_map_type][p_index].modified = true
+
+ region_changed.emit(min_x, min_y, size_x, size_y, p_map_type)
+ changed.emit()
+
+
+func notify_full_change():
+ for maptype in range(CHANNEL_COUNT):
+ # Ignore normals because they get updated along with heights
+ if maptype == CHANNEL_NORMAL:
+ continue
+ var maps = _maps[maptype]
+ for index in len(maps):
+ notify_region_change(Rect2(0, 0, _resolution, _resolution), maptype, index)
+
+
+func _edit_set_disable_apply_undo(e: bool):
+ _edit_disable_apply_undo = e
+
+
+func _edit_apply_undo(undo_data: Dictionary, image_cache: HT_ImageFileCache):
+ if _edit_disable_apply_undo:
+ return
+
+ var chunk_positions: Array = undo_data["chunk_positions"]
+ var map_infos: Array = undo_data["maps"]
+ var chunk_size: int = undo_data["chunk_size"]
+
+ _logger.debug(str("Applying ", len(chunk_positions), " undo/redo chunks"))
+
+ # Validate input
+
+ for map_info in map_infos:
+ assert(map_info.map_type >= 0 and map_info.map_type < CHANNEL_COUNT)
+ assert(len(map_info.chunks) == len(chunk_positions))
+ for im_cache_id in map_info.chunks:
+ assert(typeof(im_cache_id) == TYPE_INT)
+
+ # Apply for each map
+ for map_info in map_infos:
+ var map_type := map_info.map_type as int
+ var map_index := map_info.map_index as int
+
+ var regions_changed := []
+
+ for chunk_index in len(map_info.chunks):
+ var cpos : Vector2 = chunk_positions[chunk_index]
+ var cpos_x := int(cpos.x)
+ var cpos_y := int(cpos.y)
+
+ var min_x := cpos_x * chunk_size
+ var min_y := cpos_y * chunk_size
+ var max_x := min_x + chunk_size
+ var max_y := min_y + chunk_size
+
+ var data_id = map_info.chunks[chunk_index]
+ var data := image_cache.load_image(data_id)
+ assert(data != null)
+
+ var dst_image := get_image(map_type, map_index)
+ assert(dst_image != null)
+
+ if _map_types[map_type].authored:
+ #_logger.debug(str("Apply undo chunk ", cpos, " to ", Vector2(min_x, min_y)))
+ var src_rect := Rect2i(0, 0, data.get_width(), data.get_height())
+ dst_image.blit_rect(data, src_rect, Vector2i(min_x, min_y))
+ else:
+ _logger.error(
+ str("Channel ", map_type, " is a calculated channel!, no undo on this one"))
+
+ # Defer this to a second pass,
+ # otherwise it causes order-dependent artifacts on the normal map
+ regions_changed.append([
+ Rect2(min_x, min_y, max_x - min_x, max_y - min_y), map_type, map_index])
+
+ for args in regions_changed:
+ notify_region_change(args[0], args[1], args[2])
+
+
+#static func _debug_dump_heightmap(src: Image, fpath: String):
+# var im = Image.new()
+# im.create(src.get_width(), src.get_height(), false, Image.FORMAT_RGB8)
+# im.lock()
+# src.lock()
+# for y in im.get_height():
+# for x in im.get_width():
+# var col = src.get_pixel(x, y)
+# var c = col.r - floor(col.r)
+# im.set_pixel(x, y, Color(c, 0.0, 0.0, 1.0))
+# im.unlock()
+# src.unlock()
+# im.save_png(fpath)
+
+
+# TODO Support map indexes
+# Used for undoing full-terrain changes
+func _edit_apply_maps_from_file_cache(image_file_cache: HT_ImageFileCache, map_ids: Dictionary):
+ if _edit_disable_apply_undo:
+ return
+ for map_type in map_ids:
+ var id = map_ids[map_type]
+ var src_im := image_file_cache.load_image(id)
+ if src_im == null:
+ continue
+ var index := 0
+ var dst_im := get_image(map_type, index)
+ var rect := Rect2i(0, 0, src_im.get_height(), src_im.get_height())
+ dst_im.blit_rect(src_im, rect, Vector2i())
+ notify_region_change(rect, map_type, index)
+
+
+func _upload_channel(channel: int, index: int):
+ _upload_region(channel, index, 0, 0, _resolution, _resolution)
+
+
+func _upload_region(channel: int, index: int, min_x: int, min_y: int, size_x: int, size_y: int):
+ #_logger.debug("Upload ", min_x, ", ", min_y, ", ", size_x, "x", size_y)
+ #var time_before = OS.get_ticks_msec()
+
+ var map : HT_Map = _maps[channel][index]
+
+ var image := map.image
+ assert(image != null)
+ assert(size_x > 0 and size_y > 0)
+
+ # TODO Actually, I think the input params should be valid in the first place...
+ if min_x < 0:
+ min_x = 0
+ if min_y < 0:
+ min_y = 0
+ if min_x + size_x > image.get_width():
+ size_x = image.get_width() - min_x
+ if min_y + size_y > image.get_height():
+ size_y = image.get_height() - min_y
+ if size_x <= 0 or size_y <= 0:
+ return
+
+ var texture := map.texture
+
+ if texture == null or not (texture is ImageTexture):
+ # The texture doesn't exist yet in an editable format
+ if texture != null and not (texture is ImageTexture):
+ _logger.debug(str(
+ "_upload_region was used but the texture isn't an ImageTexture. ",\
+ "The map ", channel, "[", index, "] will be reuploaded entirely."))
+ else:
+ _logger.debug(str(
+ "_upload_region was used but the texture is not created yet. ",\
+ "The map ", channel, "[", index, "] will be uploaded entirely."))
+
+ map.texture = ImageTexture.create_from_image(image)
+
+ # Need to notify because other systems may want to grab the new texture object
+ map_changed.emit(channel, index)
+
+ # TODO Unfortunately Texture2D.get_size() wasn't updated to use Vector2i in Godot 4
+ elif Vector2i(texture.get_size()) != image.get_size():
+ _logger.debug(str(
+ "_upload_region was used but the image size is different. ",\
+ "The map ", channel, "[", index, "] will be reuploaded entirely."))
+
+ map.texture = ImageTexture.create_from_image(image)
+
+ # Since Godot 4, need to notify because other systems may want to grab the new texture
+ # object. In Godot 3 it wasn't necessary because we were able to resize a texture without
+ # having to recreate it from scratch...
+ map_changed.emit(channel, index)
+
+ else:
+ HT_Util.update_texture_partial(texture, image,
+ Rect2i(min_x, min_y, size_x, size_y), Vector2i(min_x, min_y))
+
+ #_logger.debug(str("Channel updated ", channel))
+
+ #var time_elapsed = OS.get_ticks_msec() - time_before
+ #_logger.debug(str("Texture upload time: ", time_elapsed, "ms"))
+
+
+# Gets how many instances of a given map are present in the terrain data.
+# A return value of 0 means there is no such map, and querying for it might cause errors.
+func get_map_count(map_type: int) -> int:
+ if map_type < len(_maps):
+ return len(_maps[map_type])
+ return 0
+
+
+# TODO Deprecated
+func _edit_add_detail_map():
+ return _edit_add_map(CHANNEL_DETAIL)
+
+
+# TODO Deprecated
+func _edit_remove_detail_map(index):
+ _edit_remove_map(CHANNEL_DETAIL, index)
+
+
+func _edit_add_map(map_type: int) -> int:
+ # TODO Check minimum and maximum instances of a given map
+ _logger.debug(str("Adding map of type ", get_channel_name(map_type)))
+ while map_type >= len(_maps):
+ _maps.append([])
+ var maps = _maps[map_type]
+ var map = HT_Map.new(_get_free_id(map_type))
+ map.image = Image.create(_resolution, _resolution, false, get_channel_format(map_type))
+ var index = len(maps)
+ var default_color = _get_map_default_fill_color(map_type, index)
+ if default_color != null:
+ map.image.fill(default_color)
+ maps.append(map)
+ map_added.emit(map_type, index)
+ return index
+
+
+func _edit_insert_map_from_image_cache(map_type: int, index: int, image_cache, image_id: int):
+ if _edit_disable_apply_undo:
+ return
+ _logger.debug(str("Adding map of type ", get_channel_name(map_type),
+ " from an image at index ", index))
+ while map_type >= len(_maps):
+ _maps.append([])
+ var maps = _maps[map_type]
+ var map := HT_Map.new(_get_free_id(map_type))
+ map.image = image_cache.load_image(image_id)
+ maps.insert(index, map)
+ map_added.emit(map_type, index)
+
+
+func _edit_remove_map(map_type: int, index: int):
+ # TODO Check minimum and maximum instances of a given map
+ _logger.debug(str("Removing map ", get_channel_name(map_type), " at index ", index))
+ var maps : Array = _maps[map_type]
+ maps.remove_at(index)
+ map_removed.emit(map_type, index)
+
+
+func _get_free_id(map_type: int) -> int:
+ var maps = _maps[map_type]
+ var id = 0
+ while _get_map_by_id(map_type, id) != null:
+ id += 1
+ return id
+
+
+func _get_map_by_id(map_type: int, id: int) -> HT_Map:
+ var maps = _maps[map_type]
+ for map in maps:
+ if map.id == id:
+ return map
+ return null
+
+
+func get_image(map_type: int, index := 0) -> Image:
+ var maps = _maps[map_type]
+ return maps[index].image
+
+
+func get_texture(map_type: int, index := 0, writable := false) -> Texture:
+ # TODO Split into `get_texture` and `get_writable_texture`?
+
+ var maps : Array = _maps[map_type]
+ var map : HT_Map = maps[index]
+
+ if map.image != null:
+ if map.texture == null:
+ _upload_channel(map_type, index)
+ elif writable and not (map.texture is ImageTexture):
+ _upload_channel(map_type, index)
+ else:
+ if writable:
+ _logger.warn(str("Requested writable terrain texture ",
+ get_map_debug_name(map_type, index), ", but it's not available in this context"))
+
+ return map.texture
+
+
+func has_texture(map_type: int, index: int) -> bool:
+ var maps = _maps[map_type]
+ return index < len(maps)
+
+
+func get_aabb() -> AABB:
+ # TODO Why subtract 1? I forgot
+ # TODO Optimize for full region, this is actually quite costy
+ return get_region_aabb(0, 0, _resolution - 1, _resolution - 1)
+
+
+# Not so useful in itself, but GDScript is slow,
+# so I needed it to speed up the LOD hack I had to do to take height into account
+func get_point_aabb(cell_x: int, cell_y: int) -> Vector2:
+ assert(typeof(cell_x) == TYPE_INT)
+ assert(typeof(cell_y) == TYPE_INT)
+
+ var cx = cell_x / VERTICAL_BOUNDS_CHUNK_SIZE
+ var cy = cell_y / VERTICAL_BOUNDS_CHUNK_SIZE
+
+ if cx < 0:
+ cx = 0
+ if cy < 0:
+ cy = 0
+ if cx >= _chunked_vertical_bounds.get_width():
+ cx = _chunked_vertical_bounds.get_width() - 1
+ if cy >= _chunked_vertical_bounds.get_height():
+ cy = _chunked_vertical_bounds.get_height() - 1
+
+ var b := _chunked_vertical_bounds.get_pixel(cx, cy)
+ return Vector2(b.r, b.g)
+
+
+func get_region_aabb(origin_in_cells_x: int, origin_in_cells_y: int,
+ size_in_cells_x: int, size_in_cells_y: int) -> AABB:
+
+ assert(typeof(origin_in_cells_x) == TYPE_INT)
+ assert(typeof(origin_in_cells_y) == TYPE_INT)
+ assert(typeof(size_in_cells_x) == TYPE_INT)
+ assert(typeof(size_in_cells_y) == TYPE_INT)
+
+ # Get info from cached vertical bounds,
+ # which is a lot faster than directly fetching heights from the map.
+ # It's not 100% accurate, but enough for culling use case if chunk size is decently chosen.
+
+ var cmin_x := origin_in_cells_x / VERTICAL_BOUNDS_CHUNK_SIZE
+ var cmin_y := origin_in_cells_y / VERTICAL_BOUNDS_CHUNK_SIZE
+
+ var cmax_x := (origin_in_cells_x + size_in_cells_x - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+ var cmax_y := (origin_in_cells_y + size_in_cells_y - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+
+ cmin_x = clampi(cmin_x, 0, _chunked_vertical_bounds.get_width() - 1)
+ cmin_y = clampi(cmin_y, 0, _chunked_vertical_bounds.get_height() - 1)
+ cmax_x = clampi(cmax_x, 0, _chunked_vertical_bounds.get_width())
+ cmax_y = clampi(cmax_y, 0, _chunked_vertical_bounds.get_height())
+
+ var min_height := _chunked_vertical_bounds.get_pixel(cmin_x, cmin_y).r
+ var max_height = min_height
+
+ for y in range(cmin_y, cmax_y):
+ for x in range(cmin_x, cmax_x):
+ var b = _chunked_vertical_bounds.get_pixel(x, y)
+ min_height = minf(b.r, min_height)
+ max_height = maxf(b.g, max_height)
+
+ var aabb = AABB()
+ aabb.position = Vector3(origin_in_cells_x, min_height, origin_in_cells_y)
+ aabb.size = Vector3(size_in_cells_x, max_height - min_height, size_in_cells_y)
+
+ return aabb
+
+
+func _update_all_vertical_bounds():
+ var csize_x := _resolution / VERTICAL_BOUNDS_CHUNK_SIZE
+ var csize_y := _resolution / VERTICAL_BOUNDS_CHUNK_SIZE
+ _logger.debug(str("Updating all vertical bounds... (", csize_x , "x", csize_y, " chunks)"))
+ _chunked_vertical_bounds = Image.create(csize_x, csize_y, false, Image.FORMAT_RGF)
+ _update_vertical_bounds(0, 0, _resolution - 1, _resolution - 1)
+
+
+func update_vertical_bounds(p_rect: Rect2):
+ var min_x := int(p_rect.position.x)
+ var min_y := int(p_rect.position.y)
+ var size_x := int(p_rect.size.x)
+ var size_y := int(p_rect.size.y)
+
+ _update_vertical_bounds(min_x, min_y, size_x, size_y)
+
+
+func _update_vertical_bounds(origin_in_cells_x: int, origin_in_cells_y: int, \
+ size_in_cells_x: int, size_in_cells_y: int):
+
+ var cmin_x := origin_in_cells_x / VERTICAL_BOUNDS_CHUNK_SIZE
+ var cmin_y := origin_in_cells_y / VERTICAL_BOUNDS_CHUNK_SIZE
+
+ var cmax_x := (origin_in_cells_x + size_in_cells_x - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+ var cmax_y := (origin_in_cells_y + size_in_cells_y - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+
+ cmin_x = clampi(cmin_x, 0, _chunked_vertical_bounds.get_width() - 1)
+ cmin_y = clampi(cmin_y, 0, _chunked_vertical_bounds.get_height() - 1)
+ cmax_x = clampi(cmax_x, 0, _chunked_vertical_bounds.get_width())
+ cmax_y = clampi(cmax_y, 0, _chunked_vertical_bounds.get_height())
+
+ # Note: chunks in _chunked_vertical_bounds share their edge cells and
+ # have an actual size of chunk size + 1.
+ var chunk_size_x := VERTICAL_BOUNDS_CHUNK_SIZE + 1
+ var chunk_size_y := VERTICAL_BOUNDS_CHUNK_SIZE + 1
+
+ for y in range(cmin_y, cmax_y):
+ var pmin_y := y * VERTICAL_BOUNDS_CHUNK_SIZE
+
+ for x in range(cmin_x, cmax_x):
+ var pmin_x := x * VERTICAL_BOUNDS_CHUNK_SIZE
+ var b = _compute_vertical_bounds_at(pmin_x, pmin_y, chunk_size_x, chunk_size_y)
+ _chunked_vertical_bounds.set_pixel(x, y, Color(b.x, b.y, 0))
+
+
+func _compute_vertical_bounds_at(
+ origin_x: int, origin_y: int, size_x: int, size_y: int) -> Vector2:
+
+ var heights := get_image(CHANNEL_HEIGHT)
+ assert(heights != null)
+ match heights.get_format():
+ Image.FORMAT_RF:
+ return _get_heights_range_f(heights, Rect2i(origin_x, origin_y, size_x, size_y))
+ Image.FORMAT_RGB8:
+ return _get_heights_range_rgb8(heights, Rect2i(origin_x, origin_y, size_x, size_y))
+ _:
+ _logger.error(str("Unknown heightmap format ", heights.get_format()))
+ return Vector2()
+
+
+static func _get_heights_range_rgb8(im: Image, rect: Rect2i) -> Vector2:
+ assert(im.get_format() == Image.FORMAT_RGB8)
+
+ rect = rect.intersection(Rect2i(0, 0, im.get_width(), im.get_height()))
+ var min_x := rect.position.x
+ var min_y := rect.position.y
+ var max_x := min_x + rect.size.x
+ var max_y := min_y + rect.size.y
+
+ var min_height := decode_height_from_rgb8_unorm(im.get_pixel(min_x, min_y))
+ var max_height := min_height
+
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var h := decode_height_from_rgb8_unorm(im.get_pixel(x, y))
+ min_height = minf(h, min_height)
+ max_height = maxf(h, max_height)
+
+ return Vector2(min_height, max_height)
+
+
+static func _get_heights_range_f(im: Image, rect: Rect2i) -> Vector2:
+ assert(im.get_format() == Image.FORMAT_RF)
+
+ rect = rect.intersection(Rect2i(0, 0, im.get_width(), im.get_height()))
+ var min_x := rect.position.x
+ var min_y := rect.position.y
+ var max_x := min_x + rect.size.x
+ var max_y := min_y + rect.size.y
+
+ var min_height := im.get_pixel(min_x, min_y).r
+ var max_height := min_height
+
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var h := im.get_pixel(x, y).r
+ min_height = minf(h, min_height)
+ max_height = maxf(h, max_height)
+
+ return Vector2(min_height, max_height)
+
+
+func save_data(data_dir: String) -> bool:
+ _logger.debug("Saving terrain data...")
+
+ _locked = true
+
+ _save_metadata(data_dir.path_join(META_FILENAME))
+
+ var map_count = _get_total_map_count()
+
+ var all_succeeded = true
+
+ var pi = 0
+ for map_type in CHANNEL_COUNT:
+ var maps : Array = _maps[map_type]
+
+ for index in len(maps):
+ var map : HT_Map = maps[index]
+ if not map.modified:
+ _logger.debug(str(
+ "Skipping non-modified ", get_map_debug_name(map_type, index)))
+ continue
+
+ _logger.debug(str("Saving map ", get_map_debug_name(map_type, index),
+ " as ", _get_map_filename(map_type, index), "..."))
+
+ all_succeeded = all_succeeded and _save_map(data_dir, map_type, index)
+
+ map.modified = false
+ pi += 1
+
+ # TODO Cleanup unused map files?
+
+ # TODO In editor, trigger reimport on generated assets
+ _locked = false
+
+ return all_succeeded
+
+
+func _is_any_map_modified() -> bool:
+ for maplist in _maps:
+ for map in maplist:
+ if map.modified:
+ return true
+ return false
+
+
+func _get_total_map_count() -> int:
+ var s = 0
+ for maps in _maps:
+ s += len(maps)
+ return s
+
+
+func _load_metadata(path: String):
+ var f = FileAccess.open(path, FileAccess.READ)
+ assert(f != null)
+ var text = f.get_as_text()
+ f = null # close file
+ var json = JSON.new()
+ assert(json.parse(text) == OK)
+ _deserialize_metadata(json.data)
+
+
+func _save_metadata(path: String):
+ var d = _serialize_metadata()
+ var text = JSON.stringify(d, "\t", true)
+ var f = FileAccess.open(path, FileAccess.WRITE)
+ var err = f.get_error()
+ assert(err == OK)
+ f.store_string(text)
+
+
+func _serialize_metadata() -> Dictionary:
+ var data := []
+ data.resize(len(_maps))
+
+ for i in range(len(_maps)):
+ var maps = _maps[i]
+ var maps_data := []
+
+ for j in range(len(maps)):
+ var map : HT_Map = maps[j]
+ maps_data.append({ "id": map.id })
+
+ data[i] = maps_data
+
+ return {
+ "version": META_VERSION,
+ "maps": data
+ }
+
+
+# Parse metadata that we'll then use to load the actual terrain
+# (How many maps, which files to load etc...)
+func _deserialize_metadata(dict: Dictionary) -> bool:
+ if not dict.has("version"):
+ _logger.error("Terrain metadata has no version")
+ return false
+
+ if dict.version != META_VERSION:
+ _logger.error("Terrain metadata version mismatch. Got {0}, expected {1}" \
+ .format([dict.version, META_VERSION]))
+ return false
+
+ var data = dict["maps"]
+ assert(len(data) <= len(_maps))
+
+ for i in len(data):
+ var maps = _maps[i]
+
+ var maps_data = data[i]
+ if len(maps) != len(maps_data):
+ maps.resize(len(maps_data))
+
+ for j in len(maps):
+ var map = maps[j]
+ # Cast because the data comes from json, where every number is double
+ var id := int(maps_data[j].id)
+ if map == null:
+ map = HT_Map.new(id)
+ maps[j] = map
+ else:
+ map.id = id
+
+ return true
+
+
+func load_data(dir_path: String):
+ _locked = true
+
+ _load_metadata(dir_path.path_join(META_FILENAME))
+
+ _logger.debug("Loading terrain data...")
+
+ var channel_instance_sum = _get_total_map_count()
+ var pi = 0
+
+ # Note: if we loaded all maps at once before uploading them to VRAM,
+ # it would take a lot more RAM than if we load them one by one
+ for map_type in len(_maps):
+ var maps = _maps[map_type]
+
+ for index in len(maps):
+ _logger.debug(str("Loading map ", get_map_debug_name(map_type, index),
+ " from ", _get_map_filename(map_type, index), "..."))
+
+ _load_map(dir_path, map_type, index)
+
+ # A map that was just loaded is considered not modified yet
+ maps[index].modified = false
+
+ pi += 1
+
+ _logger.debug("Calculating vertical bounds...")
+ _update_all_vertical_bounds()
+
+ _logger.debug("Notify resolution change...")
+
+ _locked = false
+ resolution_changed.emit()
+
+
+func get_data_dir() -> String:
+ # The HTerrainData resource represents the metadata and entry point for Godot.
+ # It should be placed within a folder dedicated for terrain storage.
+ # Other heavy data such as maps are stored next to that file.
+ return resource_path.get_base_dir()
+
+
+func _save_map(dir_path: String, map_type: int, index: int) -> bool:
+ var map : HT_Map = _maps[map_type][index]
+ var im := map.image
+ if im == null:
+ var tex := map.texture
+ if tex != null:
+ _logger.debug(str("Image not found for map ", map_type, ", downloading from VRAM"))
+ im = tex.get_image()
+ else:
+ _logger.debug(str("No data in map ", map_type, "[", index, "]"))
+ # This data doesn't have such map
+ return true
+
+ # The function says "absolute" but in reality it accepts paths like `res://x`,
+ # which from a user standpoint are not absolute. Also, `FileAccess.file_exists` exists but
+ # isn't named "absolute" :shrug:
+ if not DirAccess.dir_exists_absolute(dir_path):
+ var err := DirAccess.make_dir_absolute(dir_path)
+ if err != OK:
+ _logger.error("Could not create directory '{0}', error {1}" \
+ .format([dir_path, HT_Errors.get_message(err)]))
+ return false
+
+ var fpath := dir_path.path_join(_get_map_filename(map_type, index))
+
+ return _save_map_image(fpath, map_type, im)
+
+
+func _save_map_image(fpath: String, map_type: int, im: Image) -> bool:
+ if _channel_can_be_saved_as_png(map_type):
+ fpath += ".png"
+ var err := im.save_png(fpath)
+ if err != OK:
+ _logger.error("Could not save '{0}', error {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return false
+ _try_write_default_import_options(fpath, map_type, _logger)
+
+ else:
+ fpath += ".res"
+ var err := ResourceSaver.save(im, fpath)
+ if err != OK:
+ _logger.error("Could not save '{0}', error {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return false
+
+ return true
+
+
+static func _try_write_default_import_options(
+ fpath: String, channel: int, logger: HT_Logger.HT_LoggerBase):
+
+ var imp_fpath := fpath + ".import"
+ if FileAccess.file_exists(imp_fpath):
+ # Already exists
+ return
+
+ var map_info = _map_types[channel]
+ var srgb: bool = map_info.srgb
+
+ var defaults : Dictionary
+
+ if channel == CHANNEL_HEIGHT:
+ defaults = {
+ "remap": {
+ # Have the heightmap editable as an image by default
+ "importer": "image",
+ "type": "Image"
+ },
+ "deps": {
+ "source_file": fpath
+ }
+ }
+
+ else:
+ defaults = {
+ "remap": {
+ "importer": "texture",
+ "type": "CompressedTexture2D"
+ },
+ "deps": {
+ "source_file": fpath
+ },
+ "params": {
+ # Use lossless compression.
+ # Lossy ruins quality and makes the editor choke on big textures.
+ # TODO I would have used ImageTexture.COMPRESS_LOSSLESS,
+ # but apparently what is saved in the .import file does not match,
+ # and rather corresponds TO THE UI IN THE IMPORT DOCK :facepalm:
+ "compress/mode": 0,
+
+ "compress/hdr_compression": 0,
+ "compress/normal_map": 0,
+ # No mipmaps
+ "mipmaps/limit": 0,
+
+ # Most textures aren't color.
+ # Same here, this is mapping something from the import dock UI,
+ # and doesn't have any enum associated, just raw numbers in C++ code...
+ # 0 = "disabled", 1 = "enabled", 2 = "detect"
+ "flags/srgb": 2 if srgb else 0,
+
+ # No need for this, the meaning of alpha is never transparency
+ "process/fix_alpha_border": false,
+
+ # Don't try to be smart.
+ # This can actually overwrite the settings with defaults...
+ # https://github.com/godotengine/godot/issues/24220
+ "detect_3d/compress_to": 0,
+ }
+ }
+
+ HT_Util.write_import_file(defaults, imp_fpath, logger)
+
+
+func _load_map(dir: String, map_type: int, index: int) -> bool:
+ var fpath := dir.path_join(_get_map_filename(map_type, index))
+
+ # Maps must be configured before being loaded
+ var map : HT_Map = _maps[map_type][index]
+ # while len(_maps) <= map_type:
+ # _maps.append([])
+ # while len(_maps[map_type]) <= index:
+ # _maps[map_type].append(null)
+ # var map = _maps[map_type][index]
+ # if map == null:
+ # map = Map.new()
+ # _maps[map_type][index] = map
+
+ if _channel_can_be_saved_as_png(map_type):
+ fpath += ".png"
+ else:
+ fpath += ".res"
+
+ var tex = load(fpath)
+
+ var must_load_image_in_editor := true
+
+ # Short-term compatibility with RGB8 encoding from the godot4 branch
+ if Engine.is_editor_hint() and tex == null and map_type == CHANNEL_HEIGHT:
+ var legacy_fpath := fpath.get_basename() + ".png"
+ var temp = load(legacy_fpath)
+ if temp != null:
+ if temp is Texture2D:
+ temp = temp.get_image()
+ if temp is Image:
+ if temp.get_format() == Image.FORMAT_RGB8:
+ _logger.warn(str(
+ "Found a heightmap using legacy RGB8 format. It will be converted to RF. ",
+ "You may want to remove the old file: {0}").format([fpath]))
+ tex = convert_heightmap_to_float(temp, _logger)
+ _save_map_image(fpath.get_basename(), map_type, tex)
+
+ if tex != null and tex is Image:
+ # The texture is imported as Image,
+ # perhaps the user wants it to be accessible from RAM in game.
+ _logger.debug("Map {0} is imported as Image. An ImageTexture will be generated." \
+ .format([get_map_debug_name(map_type, index)]))
+ map.image = tex
+ tex = ImageTexture.create_from_image(map.image)
+ must_load_image_in_editor = false
+
+ map.texture = tex
+
+ if Engine.is_editor_hint():
+ if must_load_image_in_editor:
+ # But in the editor we want textures to be editable,
+ # so we have to automatically load the data also in RAM
+ if map.image == null:
+ map.image = Image.load_from_file(fpath)
+ else:
+ map.image.load(fpath)
+ _ensure_map_format(map.image, map_type, index)
+
+ if map_type == CHANNEL_HEIGHT:
+ _resolution = map.image.get_width()
+
+ return true
+
+
+func _ensure_map_format(im: Image, map_type: int, index: int):
+ var format := im.get_format()
+ var expected_format : int = _map_types[map_type].texture_format
+ if format != expected_format:
+ _logger.warn("Map {0} loaded as format {1}, expected {2}. Will be converted." \
+ .format([get_map_debug_name(map_type, index), format, expected_format]))
+ im.convert(expected_format)
+
+
+# Imports images into the terrain data by converting them to the internal format.
+# It is possible to omit some of them, in which case those already setup will be used.
+# This function is quite permissive, and will only fail if there is really no way to import.
+# It may involve cropping, so preliminary checks should be done to inform the user.
+#
+# TODO Plan is to make this function threaded, in case import takes too long.
+# So anything that could mess with the main thread should be avoided.
+# Eventually, it would be temporarily removed from the terrain node to work
+# in isolation during import.
+func _edit_import_maps(input: Dictionary) -> bool:
+ assert(typeof(input) == TYPE_DICTIONARY)
+
+ if input.has(CHANNEL_HEIGHT):
+ var params = input[CHANNEL_HEIGHT]
+ if not _import_heightmap(
+ params.path, params.min_height, params.max_height, params.big_endian):
+ return false
+
+ # TODO Import indexed maps?
+ var maptypes := [CHANNEL_COLOR, CHANNEL_SPLAT]
+
+ for map_type in maptypes:
+ if input.has(map_type):
+ var params = input[map_type]
+ if not _import_map(map_type, params.path):
+ return false
+
+ return true
+
+
+# Provided an arbitrary width and height,
+# returns the closest size the terrain actuallysupports
+static func get_adjusted_map_size(width: int, height: int) -> int:
+ var width_po2 = HT_Util.next_power_of_two(width - 1) + 1
+ var height_po2 = HT_Util.next_power_of_two(height - 1) + 1
+ var size_po2 = mini(width_po2, height_po2)
+ size_po2 = clampi(size_po2, MIN_RESOLUTION, MAX_RESOLUTION)
+ return size_po2
+
+
+func _import_heightmap(fpath: String, min_y: float, max_y: float, big_endian: bool) -> bool:
+ var ext := fpath.get_extension().to_lower()
+
+ if ext == "png":
+ # Godot can only load 8-bit PNG,
+ # so we have to bring it back to float in the wanted range
+
+ var src_image := Image.load_from_file(fpath)
+ # TODO No way to access the error code?
+ if src_image == null:
+ return false
+
+ var res := get_adjusted_map_size(src_image.get_width(), src_image.get_height())
+ if res != src_image.get_width():
+ src_image.crop(res, res)
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", res, "x", res, "..."))
+ resize(src_image.get_width(), false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ var hrange := max_y - min_y
+
+ var width := mini(im.get_width(), src_image.get_width())
+ var height := mini(im.get_height(), src_image.get_height())
+
+ _logger.debug("Converting to internal format...")
+
+ # Convert to internal format with range scaling
+ match im.get_format():
+ Image.FORMAT_RF:
+ for y in width:
+ for x in height:
+ var gs := src_image.get_pixel(x, y).r
+ var h := min_y + hrange * gs
+ im.set_pixel(x, y, Color(h, h, h))
+ Image.FORMAT_RGB8:
+ for y in width:
+ for x in height:
+ var gs := src_image.get_pixel(x, y).r
+ var h := min_y + hrange * gs
+ im.set_pixel(x, y, encode_height_to_rgb8_unorm(h))
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+
+ elif ext == "exr":
+ var src_image := Image.load_from_file(fpath)
+ # TODO No way to access the error code?
+ if src_image == null:
+ return false
+
+ var res := get_adjusted_map_size(src_image.get_width(), src_image.get_height())
+ if res != src_image.get_width():
+ src_image.crop(res, res)
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", res, "x", res, "..."))
+ resize(src_image.get_width(), false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ _logger.debug("Converting to internal format...")
+
+ match im.get_format():
+ Image.FORMAT_RF:
+ # See https://github.com/Zylann/godot_heightmap_plugin/issues/34
+ # Godot can load EXR but it always makes them have at least 3-channels.
+ # Heightmaps need only one, so we have to get rid of 2.
+ var height_format = _map_types[CHANNEL_HEIGHT].texture_format
+ src_image.convert(height_format)
+ im.blit_rect(src_image, Rect2i(0, 0, res, res), Vector2i())
+
+ Image.FORMAT_RGB8:
+ convert_float_heightmap_to_rgb8(src_image, im)
+
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+
+ elif ext == "raw":
+ # RAW files don't contain size, so we have to deduce it from 16-bit size.
+ # We also need to bring it back to float in the wanted range.
+
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ return false
+
+ var file_len := f.get_length()
+ var file_res := HT_Util.integer_square_root(file_len / 2)
+ if file_res == -1:
+ # Can't deduce size
+ return false
+
+ # TODO Need a way to know which endianess our system has!
+ # For now we have to make an assumption...
+ # This function is most supposed to execute in the editor.
+ # The editor officially runs on desktop architectures, which are
+ # generally little-endian.
+ if big_endian:
+ f.big_endian = true
+
+ var res := get_adjusted_map_size(file_res, file_res)
+
+ var width := res
+ var height := res
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", width, "x", height, "..."))
+ resize(res, false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ var hrange := max_y - min_y
+
+ _logger.debug("Converting to internal format...")
+
+ var rw := mini(res, file_res)
+ var rh := mini(res, file_res)
+
+ # Convert to internal format
+ var h := 0.0
+ for y in rh:
+ for x in rw:
+ var gs := float(f.get_16()) / 65535.0
+ h = min_y + hrange * float(gs)
+ match im.get_format():
+ Image.FORMAT_RF:
+ im.set_pixel(x, y, Color(h, 0, 0))
+ Image.FORMAT_RGB8:
+ im.set_pixel(x, y, encode_height_to_rgb8_unorm(h))
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+ return false
+
+ # Skip next pixels if the file is bigger than the accepted resolution
+ for x in range(rw, file_res):
+ f.get_16()
+
+ elif ext == "xyz":
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ return false
+
+ var bounds := HT_XYZFormat.load_bounds(f)
+ var res := get_adjusted_map_size(bounds.image_width, bounds.image_height)
+
+ var width := res
+ var height := res
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", width, "x", height, "..."))
+ resize(res, false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ im.fill(Color(0,0,0))
+
+ _logger.debug(str("Parsing XYZ file (this can take a while)..."))
+ f.seek(0)
+ var float_heightmap := Image.create(im.get_width(), im.get_height(), false, Image.FORMAT_RF)
+ HT_XYZFormat.load_heightmap(f, float_heightmap, bounds)
+
+ # Flipping because in Godot, for X to mean "east"/"right", Z must be backward,
+ # and we are using Z to map the Y axis of the heightmap image.
+ float_heightmap.flip_y()
+
+ match im.get_format():
+ Image.FORMAT_RF:
+ im.blit_rect(float_heightmap, Rect2i(0, 0, res, res), Vector2i())
+ Image.FORMAT_RGB8:
+ convert_float_heightmap_to_rgb8(float_heightmap, im)
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+
+ # Note: when importing maps with non-compliant sizes and flipping,
+ # the result might not be aligned to global coordinates.
+ # If this is a problem, we could just offset the terrain to compensate?
+
+ else:
+ # File extension not recognized
+ return false
+
+ _locked = false
+
+ _logger.debug("Notify region change...")
+ notify_region_change(Rect2(0, 0, get_resolution(), get_resolution()), CHANNEL_HEIGHT)
+
+ return true
+
+
+func _import_map(map_type: int, path: String) -> bool:
+ # Heightmap requires special treatment
+ assert(map_type != CHANNEL_HEIGHT)
+
+ var im := Image.load_from_file(path)
+ # TODO No way to get the error code?
+ if im == null:
+ return false
+
+ var res := get_resolution()
+ if im.get_width() != res or im.get_height() != res:
+ im.crop(res, res)
+
+ if im.get_format() != get_channel_format(map_type):
+ im.convert(get_channel_format(map_type))
+
+ var map : HT_Map = _maps[map_type][0]
+ map.image = im
+
+ notify_region_change(Rect2(0, 0, im.get_width(), im.get_height()), map_type)
+ return true
+
+
+# TODO Workaround for https://github.com/Zylann/godot_heightmap_plugin/issues/101
+func _dummy_function():
+ pass
+
+
+static func _get_xz(v: Vector3) -> Vector2:
+ return Vector2(v.x, v.z)
+
+
+class HT_CellRaycastContext:
+ var begin_pos := Vector3()
+ var _cell_begin_pos_y := 0.0
+ var _cell_begin_pos_2d := Vector2()
+ var dir := Vector3()
+ var dir_2d := Vector2()
+ var vertical_bounds : Image
+ var hit = null # Vector3
+ var heightmap : Image
+ var broad_param_2d_to_3d := 1.0
+ var cell_param_2d_to_3d := 1.0
+ # TODO Can't call static functions of the enclosing class.....................
+ var decode_height_func : Callable
+ #var dbg
+
+ func broad_cb(cx: int, cz: int, enter_param: float, exit_param: float) -> bool:
+ if cx < 0 or cz < 0 or cz >= vertical_bounds.get_height() \
+ or cx >= vertical_bounds.get_width():
+ # The function may occasionally be called at boundary values
+ return false
+ var vb := vertical_bounds.get_pixel(cx, cz)
+ var begin := begin_pos + dir * (enter_param * broad_param_2d_to_3d)
+ var exit_y := begin_pos.y + dir.y * exit_param * broad_param_2d_to_3d
+ #_spawn_box(Vector3(cx * VERTICAL_BOUNDS_CHUNK_SIZE, \
+ # begin.y, cz * VERTICAL_BOUNDS_CHUNK_SIZE), 2.0)
+ if begin.y < vb.r or exit_y > vb.g:
+ # Not hitting this chunk
+ return false
+ # We may be hitting something in this chunk, perform a narrow phase
+ # through terrain cells
+ var distance_in_chunk_2d := (exit_param - enter_param) * VERTICAL_BOUNDS_CHUNK_SIZE
+ var cell_ray_origin_2d := Vector2(begin.x, begin.z)
+ _cell_begin_pos_y = begin.y
+ _cell_begin_pos_2d = cell_ray_origin_2d
+ var rhit = HT_Util.grid_raytrace_2d(
+ cell_ray_origin_2d, dir_2d, cell_cb, distance_in_chunk_2d)
+ return rhit != null
+
+ func cell_cb(cx: int, cz: int, enter_param: float, exit_param: float) -> bool:
+ var enter_pos := _cell_begin_pos_2d + dir_2d * enter_param
+ #var exit_pos := _cell_begin_pos_2d + dir_2d * exit_param
+
+ var enter_y := _cell_begin_pos_y + dir.y * enter_param * cell_param_2d_to_3d
+ var exit_y := _cell_begin_pos_y + dir.y * exit_param * cell_param_2d_to_3d
+
+ hit = _intersect_cell(heightmap, cx, cz, Vector3(enter_pos.x, enter_y, enter_pos.y), dir,
+ decode_height_func)
+
+ return hit != null
+
+ static func _intersect_cell(heightmap: Image, cx: int, cz: int,
+ begin_pos: Vector3, dir: Vector3, decode_func : Callable):
+
+ var c00 := HT_Util.get_pixel_clamped(heightmap, cx, cz)
+ var c10 := HT_Util.get_pixel_clamped(heightmap, cx + 1, cz)
+ var c01 := HT_Util.get_pixel_clamped(heightmap, cx, cz + 1)
+ var c11 := HT_Util.get_pixel_clamped(heightmap, cx + 1, cz + 1)
+
+ var h00 : float = decode_func.call(c00)
+ var h10 : float = decode_func.call(c10)
+ var h01 : float = decode_func.call(c01)
+ var h11 : float = decode_func.call(c11)
+
+ var p00 := Vector3(cx, h00, cz)
+ var p10 := Vector3(cx + 1, h10, cz)
+ var p01 := Vector3(cx, h01, cz + 1)
+ var p11 := Vector3(cx + 1, h11, cz + 1)
+
+ var th0 = Geometry3D.ray_intersects_triangle(begin_pos, dir, p00, p10, p11)
+ var th1 = Geometry3D.ray_intersects_triangle(begin_pos, dir, p00, p11, p01)
+
+ if th0 != null:
+ return th0
+ return th1
+
+# func _spawn_box(pos: Vector3, r: float):
+# if not Input.is_key_pressed(KEY_CONTROL):
+# return
+# var mi = MeshInstance.new()
+# mi.mesh = CubeMesh.new()
+# mi.translation = pos * dbg.map_scale
+# mi.scale = Vector3(r, r, r)
+# dbg.add_child(mi)
+# mi.owner = dbg.get_tree().edited_scene_root
+
+
+# Raycasts heightmap image directly without using a collider.
+# The coordinate system is such that Y is up, terrain minimum corner is at (0, 0),
+# and one heightmap pixel is one space unit.
+# TODO Cannot hint as `-> Vector2` because it can be null if there is no hit
+func cell_raycast(ray_origin: Vector3, ray_direction: Vector3, max_distance: float):
+ var heightmap := get_image(CHANNEL_HEIGHT)
+ if heightmap == null:
+ return null
+
+ var terrain_rect := Rect2(Vector2(), Vector2(_resolution, _resolution))
+
+ # Project and clip into 2D
+ var ray_origin_2d := _get_xz(ray_origin)
+ var ray_end_2d := _get_xz(ray_origin + ray_direction * max_distance)
+ var clipped_segment_2d := HT_Util.get_segment_clipped_by_rect(terrain_rect,
+ ray_origin_2d, ray_end_2d)
+ # TODO We could clip along Y too if we had total AABB cached somewhere
+
+ if len(clipped_segment_2d) == 0:
+ # Not hitting the terrain area
+ return null
+
+ var max_distance_2d := ray_origin_2d.distance_to(ray_end_2d)
+ if max_distance_2d < 0.001:
+ # TODO Direct vertical hit?
+ return null
+
+ # Get ratio along the segment where the first point was clipped
+ var begin_clip_param := ray_origin_2d.distance_to(clipped_segment_2d[0]) / max_distance_2d
+
+ var ray_direction_2d := _get_xz(ray_direction).normalized()
+
+ var ctx := HT_CellRaycastContext.new()
+ ctx.begin_pos = ray_origin + ray_direction * (begin_clip_param * max_distance)
+ ctx.dir = ray_direction
+ ctx.dir_2d = ray_direction_2d
+ ctx.vertical_bounds = _chunked_vertical_bounds
+ ctx.heightmap = heightmap
+ ctx.cell_param_2d_to_3d = max_distance / max_distance_2d
+ ctx.broad_param_2d_to_3d = ctx.cell_param_2d_to_3d * VERTICAL_BOUNDS_CHUNK_SIZE
+
+ match heightmap.get_format():
+ Image.FORMAT_RF:
+ ctx.decode_height_func = decode_height_from_f
+ Image.FORMAT_RGB8:
+ ctx.decode_height_func = decode_height_from_rgb8_unorm
+ _:
+ _logger.error(str("Invalid heightmap format ", heightmap.get_format()))
+ return null
+
+ #ctx.dbg = dbg
+
+ # Broad phase through cached vertical bound chunks
+ var broad_ray_origin = clipped_segment_2d[0] / VERTICAL_BOUNDS_CHUNK_SIZE
+ var broad_max_distance = \
+ clipped_segment_2d[0].distance_to(clipped_segment_2d[1]) / VERTICAL_BOUNDS_CHUNK_SIZE
+ var hit_bp = HT_Util.grid_raytrace_2d(broad_ray_origin, ray_direction_2d, ctx.broad_cb,
+ broad_max_distance)
+
+ if hit_bp == null:
+ # No hit
+ return null
+
+ return Vector2(ctx.hit.x, ctx.hit.z)
+
+
+static func encode_normal(n: Vector3) -> Color:
+ n = 0.5 * (n + Vector3.ONE)
+ return Color(n.x, n.z, n.y)
+
+
+static func get_channel_format(channel: int) -> int:
+ return _map_types[channel].texture_format as int
+
+
+# Note: PNG supports 16-bit channels, unfortunately Godot doesn't
+static func _channel_can_be_saved_as_png(channel: int) -> bool:
+ return _map_types[channel].can_be_saved_as_png
+
+
+static func get_channel_name(c: int) -> String:
+ return _map_types[c].name as String
+
+
+static func get_map_debug_name(map_type: int, index: int) -> String:
+ return str(get_channel_name(map_type), "[", index, "]")
+
+
+func _get_map_filename(map_type: int, index: int) -> String:
+ var name = get_channel_name(map_type)
+ var id = _maps[map_type][index].id
+ if id > 0:
+ name += str(id + 1)
+ return name
+
+
+static func get_map_shader_param_name(map_type: int, index: int) -> String:
+ var param_name = _map_types[map_type].shader_param_name
+ if typeof(param_name) == TYPE_STRING:
+ return param_name
+ return param_name[index]
+
+
+# TODO Can't type hint because it returns a nullable array
+#static func get_map_type_and_index_from_shader_param_name(p_name: String):
+# for map_type in _map_types:
+# var pn = _map_types[map_type].shader_param_name
+# if typeof(pn) == TYPE_STRING:
+# if pn == p_name:
+# return [map_type, 0]
+# else:
+# for i in len(pn):
+# if pn[i] == p_name:
+# return [map_type, i]
+# return null
+
+
+static func decode_height_from_f(c: Color) -> float:
+ return c.r
+
+
+const _V2_UNIT_STEPS = 1024.0
+const _V2_MIN = -8192.0
+const _V2_MAX = 8191.0
+const _V2_DF = 255.0 / _V2_UNIT_STEPS
+
+# This RGB8 encoding implementation is specific to this plugin.
+# It was used in the port to Godot 4.0 for a time, until it was found float
+# textures could be used.
+
+static func decode_height_from_rgb8_unorm(c: Color) -> float:
+ return (c.r * 0.25 + c.g * 64.0 + c.b * 16384.0) * (4.0 * _V2_DF) + _V2_MIN
+
+
+static func encode_height_to_rgb8_unorm(h: float) -> Color:
+ h -= _V2_MIN
+ var i := int(h * _V2_UNIT_STEPS)
+ var r := i % 256
+ var g := (i / 256) % 256
+ var b := i / 65536
+ return Color(r, g, b, 255.0) / 255.0
+
+
+static func convert_heightmap_to_float(src: Image, logger: HT_Logger.HT_LoggerBase) -> Image:
+ var src_format := src.get_format()
+
+ if src_format == Image.FORMAT_RH:
+ var im : Image = src.duplicate()
+ im.convert(Image.FORMAT_RF)
+ return im
+
+ if src_format == Image.FORMAT_RF:
+ return src.duplicate() as Image
+
+ if src_format == Image.FORMAT_RGB8:
+ var im := Image.create(src.get_width(), src.get_height(), false, Image.FORMAT_RF)
+ for y in src.get_height():
+ for x in src.get_width():
+ var c := src.get_pixel(x, y)
+ var h := decode_height_from_rgb8_unorm(c)
+ im.set_pixel(x, y, Color(h, h, h, 1.0))
+ return im
+
+ logger.error("Unknown source heightmap format!")
+ return null
+
+
+static func convert_float_heightmap_to_rgb8(src: Image, dst: Image):
+ assert(dst.get_format() == Image.FORMAT_RGB8)
+ assert(dst.get_size() == src.get_size())
+
+ for y in src.get_height():
+ for x in src.get_width():
+ var h = src.get_pixel(x, y).r
+ dst.set_pixel(x, y, encode_height_to_rgb8_unorm(h))
+