diff --git a/CHANGELOG.md b/CHANGELOG.md index fbfaaee..bee9226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ For a more detailed list of past and incoming changes, see the commit history. - Used absolute path in shader #includes, so forking doesn't fail to find them by default - Added `HTerrainData.reload` function to allow reloading a terrain while the game is running (after saving in the editor) - Update properties in the inspector when a custom shader is modified +- Added support for importing and exporting 32-bit raw files 1.7.2 diff --git a/addons/zylann.hterrain/doc/docs/index.md b/addons/zylann.hterrain/doc/docs/index.md index e800e38..0982f97 100644 --- a/addons/zylann.hterrain/doc/docs/index.md +++ b/addons/zylann.hterrain/doc/docs/index.md @@ -692,7 +692,7 @@ This window allows you to import several kinds of data, such as heightmap but al There are a few things to check before you can successfully import a terrain though: - The resolution should be power of two + 1, and square. If it isn't, the plugin will attempt to crop it, which might be OK or not if you can deal with map borders that this will produce. -- If you import a RAW heightmap, it has to be encoded using 16-bit unsigned integer format. +- If you import a RAW heightmap, it has to be encoded using either 16-bit or 32-bit unsigned integer format. Upon selecting a file via the file chooser dialog, the importer will attempt to deduce the bit depth. - If you import a PNG heightmap, Godot can only load it as 8-bit depth, so it is not recommended for high-range terrains because it doesn't have enough height precision. This feature also can't be undone when executed, as all terrain data will be overwritten with the new one. If anything isn't correct, the tool will warn you before to prevent data loss. diff --git a/addons/zylann.hterrain/hterrain_data.gd b/addons/zylann.hterrain/hterrain_data.gd index 6c25f8e..d23d040 100644 --- a/addons/zylann.hterrain/hterrain_data.gd +++ b/addons/zylann.hterrain/hterrain_data.gd @@ -12,6 +12,12 @@ const HT_Logger = preload("./util/logger.gd") const HT_ImageFileCache = preload("./util/image_file_cache.gd") const HT_XYZFormat = preload("./util/xyz_format.gd") +enum { + BIT_DEPTH_UNDEFINED = 0, + BIT_DEPTH_16 = 16, + BIT_DEPTH_32 = 32 +} + # 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 @@ -1393,7 +1399,7 @@ func _edit_import_maps(input: Dictionary) -> bool: 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): + params.path, params.min_height, params.max_height, params.big_endian, params.bit_depth): return false # TODO Import indexed maps? @@ -1418,7 +1424,7 @@ static func get_adjusted_map_size(width: int, height: int) -> int: return size_po2 -func _import_heightmap(fpath: String, min_y: float, max_y: float, big_endian: bool) -> bool: +func _import_heightmap(fpath: String, min_y: float, max_y: float, big_endian: bool, bit_depth: int) -> bool: var ext := fpath.get_extension().to_lower() if ext == "png": @@ -1502,7 +1508,7 @@ func _import_heightmap(fpath: String, min_y: float, max_y: float, big_endian: bo _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. + # RAW files don't contain size, so we take the user's bit depth import choice. # We also need to bring it back to float in the wanted range. var f := FileAccess.open(fpath, FileAccess.READ) @@ -1510,7 +1516,7 @@ func _import_heightmap(fpath: String, min_y: float, max_y: float, big_endian: bo return false var file_len := f.get_length() - var file_res := HT_Util.integer_square_root(file_len / 2) + var file_res := HT_Util.integer_square_root(file_len / (bit_depth/8)) if file_res == -1: # Can't deduce size return false @@ -1545,22 +1551,40 @@ func _import_heightmap(fpath: String, min_y: float, max_y: float, big_endian: bo # 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() + if bit_depth == BIT_DEPTH_32: + for y in rh: + for x in rw: + var gs := float(f.get_32()) / 4294967295.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_32() + else: + 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) diff --git a/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd b/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd index b989099..5690eb1 100644 --- a/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd +++ b/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd @@ -10,10 +10,11 @@ const HT_Logger = preload("../../util/logger.gd") const FORMAT_RH = 0 const FORMAT_RF = 1 const FORMAT_R16 = 2 -const FORMAT_PNG8 = 3 -const FORMAT_EXRH = 4 -const FORMAT_EXRF = 5 -const FORMAT_COUNT = 6 +const FORMAT_R32 = 3 +const FORMAT_PNG8 = 4 +const FORMAT_EXRH = 5 +const FORMAT_EXRF = 6 +const FORMAT_COUNT = 7 @onready var _output_path_line_edit := $VB/Grid/OutputPath/HeightmapPathLineEdit as LineEdit @onready var _format_selector := $VB/Grid/FormatSelector as OptionButton @@ -41,7 +42,8 @@ func _ready(): _format_names[FORMAT_RH] = "16-bit RAW float" _format_names[FORMAT_RF] = "32-bit RAW float" - _format_names[FORMAT_R16] = "16-bit RAW unsigned" + _format_names[FORMAT_R16] = "16-bit RAW int unsigned (little endian)" + _format_names[FORMAT_R32] = "32-bit RAW int unsigned (little endian)" _format_names[FORMAT_PNG8] = "8-bit PNG greyscale" _format_names[FORMAT_EXRH] = "16-bit float greyscale EXR" _format_names[FORMAT_EXRF] = "32-bit float greyscale EXR" @@ -49,6 +51,7 @@ func _ready(): _format_extensions[FORMAT_RH] = "raw" _format_extensions[FORMAT_RF] = "raw" _format_extensions[FORMAT_R16] = "raw" + _format_extensions[FORMAT_R32] = "raw" _format_extensions[FORMAT_PNG8] = "png" _format_extensions[FORMAT_EXRH] = "exr" _format_extensions[FORMAT_EXRF] = "exr" @@ -142,7 +145,7 @@ func _export() -> bool: var err := FileAccess.get_open_error() _print_file_error(fpath, err) return false - + if format == FORMAT_RH: float_heightmap.convert(Image.FORMAT_RH) f.store_buffer(float_heightmap.get_data()) @@ -155,14 +158,15 @@ func _export() -> bool: for y in float_heightmap.get_height(): for x in float_heightmap.get_width(): var h := int((float_heightmap.get_pixel(x, y).r - height_min) * hscale) - if h < 0: - h = 0 - elif h > 65535: - h = 65535 - if x % 50 == 0: - _logger.debug(str(h)) - f.store_16(h) - + f.store_16(clampi(h, 0, 65535)) + + elif format == FORMAT_R32: + var hscale := 4294967295.0 / (height_max - height_min) + for y in float_heightmap.get_height(): + for x in float_heightmap.get_width(): + var h := int((float_heightmap.get_pixel(x, y).r - height_min) * hscale) + f.store_32(clampi(h, 0, 4294967295)) + if save_error == OK: _logger.debug("Exported heightmap as \"{0}\"".format([fpath])) return true diff --git a/addons/zylann.hterrain/tools/importer/importer_dialog.gd b/addons/zylann.hterrain/tools/importer/importer_dialog.gd index 5f57bb1..afdba53 100644 --- a/addons/zylann.hterrain/tools/importer/importer_dialog.gd +++ b/addons/zylann.hterrain/tools/importer/importer_dialog.gd @@ -16,8 +16,11 @@ signal permanent_change_performed(message) @onready var _warnings_label : Label = \ $VBoxContainer/ColorRect/ScrollContainer/VBoxContainer/Warnings -const RAW_LITTLE_ENDIAN = 0 -const RAW_BIG_ENDIAN = 1 + +enum { + RAW_LITTLE_ENDIAN, + RAW_BIG_ENDIAN +} var _terrain : HTerrain = null var _logger = HT_Logger.get_for(self) @@ -37,7 +40,13 @@ func _ready(): "raw_endianess": { "type": TYPE_INT, "usage": "enum", - "enum_items": ["Little Endian", "Big Endian"], + "enum_items": [[RAW_LITTLE_ENDIAN, "Little Endian"], [RAW_BIG_ENDIAN, "Big Endian"]], + "enabled": false + }, + "bit_depth": { + "type": TYPE_INT, + "usage": "enum", + "enum_items": [[HTerrainData.BIT_DEPTH_16, "16-bit"], [HTerrainData.BIT_DEPTH_32, "32-bit"]], "enabled": false }, "min_height": { @@ -61,7 +70,7 @@ func _ready(): "exts": ["png"] } }) - + # Testing # _errors_label.text = "- Hello World!" # _warnings_label.text = "- Yolo Jesus!" @@ -137,7 +146,8 @@ func _on_ImportButton_pressed(): "path": heightmap_path, "min_height": _inspector.get_value("min_height"), "max_height": _inspector.get_value("max_height"), - "big_endian": endianess == RAW_BIG_ENDIAN + "big_endian": endianess == RAW_BIG_ENDIAN, + "bit_depth": _inspector.get_value("bit_depth"), } var colormap_path = _inspector.get_value("colormap") @@ -168,6 +178,31 @@ func _on_Inspector_property_changed(key: String, value): if key == "heightmap": var is_raw = value.get_extension().to_lower() == "raw" _inspector.set_property_enabled("raw_endianess", is_raw) + _inspector.set_property_enabled("bit_depth", is_raw) + if is_raw: + var bit_depth:int = _estimate_bit_depth_for_raw_file(value) + if bit_depth == HTerrainData.BIT_DEPTH_UNDEFINED: + bit_depth = HTerrainData.BIT_DEPTH_16 # fallback depth value + _inspector.set_value("bit_depth", bit_depth) + + +# _estimate_bit_depth_for_raw_file returns the file's identified bit depth, or 0. +static func _estimate_bit_depth_for_raw_file(path: String) -> int: + var ext := path.get_extension().to_lower() + if ext == "raw": + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return HTerrainData.BIT_DEPTH_UNDEFINED + + var file_len := f.get_length() + f = null # close file + + for bit_depth in [HTerrainData.BIT_DEPTH_16, HTerrainData.BIT_DEPTH_32]: + var file_res := HT_Util.integer_square_root(file_len / (bit_depth/8)) + if file_res > 0: + return bit_depth + + return HTerrainData.BIT_DEPTH_UNDEFINED func _validate_form() -> HT_ErrorCheckReport: @@ -176,6 +211,7 @@ func _validate_form() -> HT_ErrorCheckReport: var heightmap_path : String = _inspector.get_value("heightmap") var splatmap_path : String = _inspector.get_value("splatmap") var colormap_path : String = _inspector.get_value("colormap") + var bit_depth = _inspector.get_value("bit_depth") if colormap_path == "" and heightmap_path == "" and splatmap_path == "": res.errors.append("No maps specified.") @@ -195,7 +231,7 @@ func _validate_form() -> HT_ErrorCheckReport: # so we avoid loading other maps every time to do further checks. return res - var image_size_result = _load_image_size(heightmap_path, _logger) + var image_size_result = _load_image_size(heightmap_path, _logger, bit_depth) if image_size_result.error_code != OK: res.errors.append(str("Cannot open heightmap file: ", image_size_result.to_string())) return res @@ -211,18 +247,18 @@ func _validate_form() -> HT_ErrorCheckReport: heightmap_size = adjusted_size if splatmap_path != "": - _check_map_size(splatmap_path, "splatmap", heightmap_size, res, _logger) + _check_map_size(splatmap_path, "splatmap", heightmap_size, bit_depth, res, _logger) if colormap_path != "": - _check_map_size(colormap_path, "colormap", heightmap_size, res, _logger) + _check_map_size(colormap_path, "colormap", heightmap_size, bit_depth, res, _logger) return res -static func _check_map_size(path: String, map_name: String, heightmap_size: int, +static func _check_map_size(path: String, map_name: String, heightmap_size: int, bit_depth: int, res: HT_ErrorCheckReport, logger): - var size_result := _load_image_size(path, logger) + var size_result := _load_image_size(path, logger, bit_depth) if size_result.error_code != OK: res.errors.append(str("Cannot open splatmap file: ", size_result.to_string())) return @@ -251,7 +287,7 @@ class HT_ImageSizeResult: return HT_Errors.get_message(error_code) -static func _load_image_size(path: String, logger) -> HT_ImageSizeResult: +static func _load_image_size(path: String, logger, bit_depth: int) -> HT_ImageSizeResult: var ext := path.get_extension().to_lower() var result := HT_ImageSizeResult.new() @@ -276,14 +312,13 @@ static func _load_image_size(path: String, logger) -> HT_ImageSizeResult: result.error_code = err return result - # Assume the raw data is square in 16-bit format, - # so its size is function of file length + # Assume the raw data is square, so its size is function of file length var flen := f.get_length() f = null - var size_px = HT_Util.integer_square_root(flen / 2) + var size_px = HT_Util.integer_square_root(flen / (bit_depth/8)) if size_px == -1: result.error_code = ERR_INVALID_DATA - result.error_message = "RAW image is not square" + result.error_message = "RAW image is not square or your bit depth choice is incorrect…" return result logger.debug("Deduced RAW heightmap resolution: {0}*{1}, for a length of {2}" \ diff --git a/addons/zylann.hterrain/tools/inspector/inspector.gd b/addons/zylann.hterrain/tools/inspector/inspector.gd index 6039cee..be14635 100644 --- a/addons/zylann.hterrain/tools/inspector/inspector.gd +++ b/addons/zylann.hterrain/tools/inspector/inspector.gd @@ -209,16 +209,21 @@ func _make_editor(key: String, prop: Dictionary) -> HT_InspectorEditor: var option_button := OptionButton.new() for i in len(prop.enum_items): - var item = prop.enum_items[i] - option_button.add_item(item) - - # TODO We assume index, actually + var item:Array = prop.enum_items[i] + var value:int = item[0] + var text:String = item[1] + option_button.add_item(text, value) + getter = option_button.get_selected_id - setter = option_button.select + setter = func select_id(id: int): + var index:int = option_button.get_item_index(id) + assert(index >= 0) + option_button.select(index) + option_button.item_selected.connect(_property_edited.bind(key)) editor = option_button - + else: # Numeric value var spinbox := SpinBox.new()