From 7ea293da732eecc089af0cb18a024c6d4b135421 Mon Sep 17 00:00:00 2001 From: Marc Gilleron Date: Tue, 24 Mar 2020 23:27:52 +0000 Subject: [PATCH] Using the terrain generator can be undone --- addons/zylann.hterrain/hterrain_data.gd | 20 ++- .../tools/generator/generator_dialog.gd | 162 +++++++++++++----- .../tools/generator/generator_dialog.tscn | 2 +- addons/zylann.hterrain/tools/plugin.gd | 57 +++--- .../zylann.hterrain/util/image_file_cache.gd | 121 +++++++++++++ 5 files changed, 296 insertions(+), 66 deletions(-) create mode 100644 addons/zylann.hterrain/util/image_file_cache.gd diff --git a/addons/zylann.hterrain/hterrain_data.gd b/addons/zylann.hterrain/hterrain_data.gd index bea362aa..61b8c873 100644 --- a/addons/zylann.hterrain/hterrain_data.gd +++ b/addons/zylann.hterrain/hterrain_data.gd @@ -351,7 +351,6 @@ func _edit_set_disable_apply_undo(e: bool): func _edit_apply_undo(undo_data: Dictionary): - if _edit_disable_apply_undo: return @@ -379,7 +378,6 @@ func _edit_apply_undo(undo_data: Dictionary): var regions_changed = [] # Apply - for i in range(len(chunk_datas)): var cpos_x = chunk_positions[2 * i] var cpos_y = chunk_positions[2 * i + 1] @@ -398,13 +396,11 @@ func _edit_apply_undo(undo_data: Dictionary): assert(dst_image != null) match channel: - CHANNEL_HEIGHT, \ CHANNEL_SPLAT, \ CHANNEL_COLOR, \ CHANNEL_DETAIL: dst_image.blit_rect(data, data_rect, Vector2(min_x, min_y)) - CHANNEL_NORMAL, \ CHANNEL_GLOBAL_ALBEDO: printerr("This is a calculated channel!, no undo on this one\n") @@ -420,6 +416,22 @@ func _edit_apply_undo(undo_data: Dictionary): notify_region_change(args[0], args[1], args[2]) +# Used for undoing full-terrain changes +func _edit_apply_maps_from_file_cache(image_file_cache, 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 = Rect2(0, 0, src_im.get_height(), src_im.get_height()) + dst_im.blit_rect(src_im, rect, Vector2()) + notify_region_change(rect, map_type, index) + + func _upload_channel(channel: int, index: int): _upload_region(channel, index, 0, 0, _resolution, _resolution) diff --git a/addons/zylann.hterrain/tools/generator/generator_dialog.gd b/addons/zylann.hterrain/tools/generator/generator_dialog.gd index 3922c032..ff14ab7e 100644 --- a/addons/zylann.hterrain/tools/generator/generator_dialog.gd +++ b/addons/zylann.hterrain/tools/generator/generator_dialog.gd @@ -1,18 +1,16 @@ tool extends WindowDialog - -# TODO Cap this resolution to terrain size, in case it is smaller (bigger uses chunking) -const VIEWPORT_RESOLUTION = 512 -const NOISE_PERM_TEXTURE_SIZE = 256 - const HTerrainData = preload("../../hterrain_data.gd") const HTerrainMesher = preload("../../hterrain_mesher.gd") const Util = preload("../../util/util.gd") const TextureGenerator = preload("texture_generator.gd") +# TODO Cap this resolution to terrain size, in case it is smaller (bigger uses chunking) +const VIEWPORT_RESOLUTION = 512 +const NOISE_PERM_TEXTURE_SIZE = 256 + signal progress_notified(info) # { "progress": real, "message": string, "finished": bool } -signal permanent_change_performed(message) onready var _inspector = $VBoxContainer/Editor/Settings/Inspector onready var _preview = $VBoxContainer/Editor/Preview/TerrainPreview @@ -25,39 +23,101 @@ var _applying = false var _generator = null var _generated_textures = [null, null] var _dialog_visible = false +var _undo_map_ids := {} +var _image_cache = null +var _undo_redo : UndoRedo static func get_shader(shader_name): - var path = "res://addons/zylann.hterrain/tools/generator/shaders".plus_file(str(shader_name, ".shader")) + var path = "res://addons/zylann.hterrain/tools/generator/shaders"\ + .plus_file(str(shader_name, ".shader")) #print("Loading ", path) return load(path) func _ready(): _inspector.set_prototype({ - "seed": { "type": TYPE_INT, "randomizable": true, "range": { "min": -100000, "max": 100000 }, "slidable": false}, - "offset": { "type": TYPE_VECTOR2 }, - "base_height": { "type": TYPE_REAL, "range": {"min": -500.0, "max": 500.0, "step": 0.1 }}, - "height_range": { "type": TYPE_REAL, "range": {"min": 0.0, "max": 2000.0, "step": 0.1 }, "default_value": 150.0 }, - "scale": { "type": TYPE_REAL, "range": {"min": 1.0, "max": 1000.0, "step": 1.0}, "default_value": 50.0 }, - "roughness": { "type": TYPE_REAL, "range": {"min": 0.0, "max": 1.0, "step": 0.01}, "default_value": 0.4 }, - "curve": { "type": TYPE_REAL, "range": {"min": 1.0, "max": 10.0, "step": 0.1}, "default_value": 1.0 }, - "octaves": { "type": TYPE_INT, "range": {"min": 1, "max": 10, "step": 1}, "default_value": 6 }, - "erosion_steps": { "type": TYPE_INT, "range": {"min": 0, "max": 100, "step": 1}, "default_value": 0 }, - "erosion_weight": { "type": TYPE_REAL, "range": { "min": 0.0, "max": 1.0 }, "default_value": 0.5 }, - "erosion_slope_factor": { "type": TYPE_REAL, "range": { "min": 0.0, "max": 1.0 }, "default_value": 0.0 }, - "erosion_slope_direction": { "type": TYPE_VECTOR2, "default_value": Vector2(0, 0) }, - "erosion_slope_invert": { "type": TYPE_BOOL, "default_value": false }, - "dilation": { "type": TYPE_REAL, "range": { "min": 0.0, "max": 1.0 }, "default_value": 0.0 }, - "show_sea": { "type": TYPE_BOOL, "default_value": true }, - "shadows": { "type": TYPE_BOOL, "default_value": true } + "seed": { + "type": TYPE_INT, + "randomizable": true, + "range": { "min": -100000, "max": 100000 }, + "slidable": false + }, + "offset": { + "type": TYPE_VECTOR2 + }, + "base_height": { + "type": TYPE_REAL, + "range": {"min": -500.0, "max": 500.0, "step": 0.1 } + }, + "height_range": { + "type": TYPE_REAL, + "range": {"min": 0.0, "max": 2000.0, "step": 0.1 }, + "default_value": 150.0 + }, + "scale": { + "type": TYPE_REAL, + "range": {"min": 1.0, "max": 1000.0, "step": 1.0}, + "default_value": 50.0 + }, + "roughness": { + "type": TYPE_REAL, + "range": {"min": 0.0, "max": 1.0, "step": 0.01}, + "default_value": 0.4 + }, + "curve": { + "type": TYPE_REAL, + "range": {"min": 1.0, "max": 10.0, "step": 0.1}, + "default_value": 1.0 + }, + "octaves": { + "type": TYPE_INT, + "range": {"min": 1, "max": 10, "step": 1}, + "default_value": 6 + }, + "erosion_steps": { + "type": TYPE_INT, + "range": {"min": 0, "max": 100, "step": 1}, + "default_value": 0 + }, + "erosion_weight": { + "type": TYPE_REAL, + "range": { "min": 0.0, "max": 1.0 }, + "default_value": 0.5 + }, + "erosion_slope_factor": { + "type": TYPE_REAL, + "range": { "min": 0.0, "max": 1.0 }, + "default_value": 0.0 + }, + "erosion_slope_direction": { + "type": TYPE_VECTOR2, + "default_value": Vector2(0, 0) + }, + "erosion_slope_invert": { + "type": TYPE_BOOL, + "default_value": false + }, + "dilation": { + "type": TYPE_REAL, + "range": { "min": 0.0, "max": 1.0 }, + "default_value": 0.0 + }, + "show_sea": { + "type": TYPE_BOOL, + "default_value": true + }, + "shadows": { + "type": TYPE_BOOL, + "default_value": true + } }) _generator = TextureGenerator.new() _generator.set_resolution(Vector2(VIEWPORT_RESOLUTION, VIEWPORT_RESOLUTION)) # Setup the extra pixels we want on max edges for terrain - # TODO I wonder if it's not better to let the generator shaders work in pixels instead of NDC, - # rather than putting a padding system there + # TODO I wonder if it's not better to let the generator shaders work in pixels + # instead of NDC, rather than putting a padding system there _generator.set_output_padding([0, 1, 0, 1]) _generator.connect("output_generated", self, "_on_TextureGenerator_output_generated") _generator.connect("completed", self, "_on_TextureGenerator_completed") @@ -77,11 +137,17 @@ func set_terrain(terrain): _terrain = terrain +func set_image_cache(image_cache): + _image_cache = image_cache + + +func set_undo_redo(ur): + _undo_redo = ur + + func _notification(what): match what: - NOTIFICATION_VISIBILITY_CHANGED: - # We don't want any of this to run in an edited scene if Util.is_in_edited_scene(self): return @@ -98,7 +164,7 @@ func _notification(what): if _noise_texture == null: _regen_noise_perm_texture(_inspector.get_value("seed")) - _update_generator() + _update_generator(true) else: # if not _applying: @@ -109,7 +175,7 @@ func _notification(what): _dialog_visible = false -func _update_generator(preview=true): +func _update_generator(preview: bool): var scale = _inspector.get_value("scale") # Scale is inverted in the shader if abs(scale) < 0.01: @@ -240,9 +306,9 @@ func _on_Inspector_property_changed(key, value): _preview.set_shadows_enabled(value) "seed": _regen_noise_perm_texture(value) - _update_generator() + _update_generator(true) _: - _update_generator() + _update_generator(true) func _on_TerrainPreview_dragged(relative, button_mask): @@ -254,25 +320,28 @@ func _on_TerrainPreview_dragged(relative, button_mask): func _apply(): if _terrain == null: - printerr("ERROR: cannot apply, terrain is null") + push_error("ERROR: cannot apply, terrain is null") return var data = _terrain.get_data() if data == null: - printerr("ERROR: cannot apply, terrain data is null") + push_error("ERROR: cannot apply, terrain data is null") return var dst_heights = data.get_image(HTerrainData.CHANNEL_HEIGHT) if dst_heights == null: - printerr("ERROR: terrain heightmap image isn't loaded") + push_error("ERROR: terrain heightmap image isn't loaded") return var dst_normals = data.get_image(HTerrainData.CHANNEL_NORMAL) if dst_normals == null: - printerr("ERROR: terrain normal image isn't loaded") + push_error("ERROR: terrain normal image isn't loaded") return _applying = true + + _undo_map_ids[HTerrainData.CHANNEL_HEIGHT] = _image_cache.save_image(dst_heights) + _undo_map_ids[HTerrainData.CHANNEL_NORMAL] = _image_cache.save_image(dst_normals) _update_generator(false) @@ -320,7 +389,8 @@ func _on_TextureGenerator_output_generated(image, info): emit_signal("progress_notified", { "progress": info.progress, - "message": "Calculating sector (" + str(info.sector.x) + ", " + str(info.sector.y) + ")" + "message": "Calculating sector (" + + str(info.sector.x) + ", " + str(info.sector.y) + ")" }) # if info.maptype == HTerrainData.CHANNEL_NORMAL: @@ -333,14 +403,26 @@ func _on_TextureGenerator_completed(): if not _applying: return _applying = false - + assert(_terrain != null) - var data = _terrain.get_data() - var resolution = data.get_resolution() + var data : HTerrainData = _terrain.get_data() + var resolution := data.get_resolution() data.notify_region_change(Rect2(0, 0, resolution, resolution), HTerrainData.CHANNEL_HEIGHT) + var redo_map_ids = {} + for map_type in _undo_map_ids: + redo_map_ids[map_type] = _image_cache.save_image(data.get_image(map_type)) + + data._edit_set_disable_apply_undo(true) + _undo_redo.create_action("Generate terrain") + _undo_redo.add_do_method( + data, "_edit_apply_maps_from_file_cache", _image_cache, redo_map_ids) + _undo_redo.add_undo_method( + data, "_edit_apply_maps_from_file_cache", _image_cache, _undo_map_ids) + _undo_redo.commit_action() + data._edit_set_disable_apply_undo(false) + emit_signal("progress_notified", { "finished": true }) - emit_signal("permanent_change_performed", "Generate terrain") print("Done") diff --git a/addons/zylann.hterrain/tools/generator/generator_dialog.tscn b/addons/zylann.hterrain/tools/generator/generator_dialog.tscn index 81712143..1957204a 100644 --- a/addons/zylann.hterrain/tools/generator/generator_dialog.tscn +++ b/addons/zylann.hterrain/tools/generator/generator_dialog.tscn @@ -195,7 +195,7 @@ toggle_mode = false enabled_focus_mode = 2 shortcut = null group = null -text = "Apply (no undo)" +text = "Apply" flat = false align = 1 diff --git a/addons/zylann.hterrain/tools/plugin.gd b/addons/zylann.hterrain/tools/plugin.gd index 90aa6394..39a7a85f 100644 --- a/addons/zylann.hterrain/tools/plugin.gd +++ b/addons/zylann.hterrain/tools/plugin.gd @@ -6,19 +6,20 @@ const HTerrain = preload("../hterrain.gd") const HTerrainDetailLayer = preload("../hterrain_detail_layer.gd") const HTerrainData = preload("../hterrain_data.gd") const HTerrainMesher = preload("../hterrain_mesher.gd") -const PreviewGenerator = preload("preview_generator.gd") +const PreviewGenerator = preload("./preview_generator.gd") const Brush = preload("../hterrain_brush.gd") -const BrushDecal = preload("brush/decal.gd") +const BrushDecal = preload("./brush/decal.gd") const Util = preload("../util/util.gd") -const LoadTextureDialog = preload("load_texture_dialog.gd") -const GlobalMapBaker = preload("globalmap_baker.gd") - -const EditPanel = preload("panel.tscn") -const ProgressWindow = preload("progress_window.tscn") -const GeneratorDialog = preload("generator/generator_dialog.tscn") -const ImportDialog = preload("importer/importer_dialog.tscn") -const GenerateMeshDialog = preload("generate_mesh_dialog.tscn") -const ResizeDialog = preload("resize_dialog/resize_dialog.tscn") +const LoadTextureDialog = preload("./load_texture_dialog.gd") +const GlobalMapBaker = preload("./globalmap_baker.gd") +const ImageFileCache = preload("../util/image_file_cache.gd") + +const EditPanel = preload("./panel.tscn") +const ProgressWindow = preload("./progress_window.tscn") +const GeneratorDialog = preload("./generator/generator_dialog.tscn") +const ImportDialog = preload("./importer/importer_dialog.tscn") +const GenerateMeshDialog = preload("./generate_mesh_dialog.tscn") +const ResizeDialog = preload("./resize_dialog/resize_dialog.tscn") const ExportImageDialog = preload("./exporter/export_image_dialog.tscn") const MENU_IMPORT_MAPS = 0 @@ -47,6 +48,7 @@ var _resize_dialog = null var _globalmap_baker = null var _menu_button : MenuButton var _terrain_had_data_previous_frame = false +var _image_cache : ImageFileCache var _brush : Brush = null var _brush_decal : BrushDecal = null @@ -63,7 +65,8 @@ func _enter_tree(): print("HTerrain plugin Enter tree") add_custom_type("HTerrain", "Spatial", HTerrain, get_icon("heightmap_node")) - add_custom_type("HTerrainDetailLayer", "Spatial", HTerrainDetailLayer, get_icon("detail_layer_node")) + add_custom_type("HTerrainDetailLayer", "Spatial", HTerrainDetailLayer, + get_icon("detail_layer_node")) add_custom_type("HTerrainData", "Resource", HTerrainData, get_icon("heightmap_data")) _preview_generator = PreviewGenerator.new() @@ -76,6 +79,8 @@ func _enter_tree(): _brush_decal.set_shape(_brush.get_shape()) _brush.connect("shape_changed", _brush_decal, "set_shape") + _image_cache = ImageFileCache.new("user://hterrain_image_cache") + var editor_interface := get_editor_interface() var base_control := editor_interface.get_base_control() _load_texture_dialog = LoadTextureDialog.new() @@ -166,7 +171,8 @@ func _enter_tree(): _generator_dialog = GeneratorDialog.instance() _generator_dialog.connect("progress_notified", self, "_terrain_progress_notified") - _generator_dialog.connect("permanent_change_performed", self, "_on_permanent_change_performed") + _generator_dialog.set_image_cache(_image_cache) + _generator_dialog.set_undo_redo(get_undo_redo()) base_control.add_child(_generator_dialog) _import_dialog = ImportDialog.instance() @@ -232,6 +238,9 @@ func _exit_tree(): get_editor_interface().get_resource_previewer().remove_preview_generator(_preview_generator) _preview_generator = null + # TODO Manual clear cuz it can't do it automatically due to a Godot bug + _image_cache.clear() + # TODO https://github.com/godotengine/godot/issues/6254#issuecomment-246139694 # This was supposed to be automatic, but was never implemented it seems... remove_custom_type("HTerrain") @@ -276,9 +285,11 @@ func edit(object): static func _get_terrain_from_object(object): if object != null and object is Spatial: + if not object.is_inside_tree(): + return null if object is HTerrain: return object - if object is HTerrainDetailLayer and object.is_inside_tree() and object.get_parent() is HTerrain: + if object is HTerrainDetailLayer and object.get_parent() is HTerrain: return object.get_parent() return null @@ -322,7 +333,8 @@ func make_visible(visible): _brush_decal.update_visibility() # TODO Workaround https://github.com/godotengine/godot/issues/6459 - # When the user selects another node, I want the plugin to release its references to the terrain. + # When the user selects another node, + # I want the plugin to release its references to the terrain. if not visible: edit(null) @@ -484,17 +496,20 @@ func _menu_item_selected(id): MENU_UPDATE_EDITOR_COLLIDER: # This is for editor tools to be able to use terrain collision. - # It's not automatic because keeping this collider up to date is expensive, - # but not too bad IMO because that feature is not often used in editor for now. + # It's not automatic because keeping this collider up to date is + # expensive, but not too bad IMO because that feature is not often + # used in editor for now. # If users complain too much about this, there are ways to improve it: # - # 1) When the terrain gets deselected, update the terrain collider in a thread automatically. - # This is still expensive but should be easy to do. + # 1) When the terrain gets deselected, update the terrain collider + # in a thread automatically. This is still expensive but should + # be easy to do. # # 2) Bullet actually support modifying the heights dynamically, # as long as we stay within min and max bounds, - # so PR a change to the Godot heightmap collider to support passing a Float Image directly, - # and make it so the data is in sync (no CoW plz!!). It's trickier than 1) but almost free. + # so PR a change to the Godot heightmap collider to support passing + # a Float Image directly, and make it so the data is in sync + # (no CoW plz!!). It's trickier than 1) but almost free. # _node.update_collider() diff --git a/addons/zylann.hterrain/util/image_file_cache.gd b/addons/zylann.hterrain/util/image_file_cache.gd new file mode 100644 index 00000000..15798091 --- /dev/null +++ b/addons/zylann.hterrain/util/image_file_cache.gd @@ -0,0 +1,121 @@ + +# Used to store temporary images on disk. +# This is useful for undo/redo as image edition can quickly fill up memory. + +var _cache_dir := "" +var _next_id := 0 +var _session_id := "" +var _cache_image_info := {} + + +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) + print("Image cache session ID: ", _session_id) + var dir := Directory.new() + if not dir.dir_exists(_cache_dir): + var err = dir.make_dir(_cache_dir) + if err != OK: + push_error("Could not create directory {0}, error {1}" \ + .format([_cache_dir, err])) + + +# 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): +# if what == NOTIFICATION_PREDELETE: +# clear() + + +func save_image(im: Image) -> int: + var id = _next_id + var fpath = _cache_dir.plus_file(str(_session_id, "_", id)) + # TODO Could use a thread here + + var err + match im.get_format(): + Image.FORMAT_R8,\ + Image.FORMAT_RG8,\ + Image.FORMAT_RGB8,\ + Image.FORMAT_RGBA8: + fpath += ".png" + err = im.save_png(fpath) + Image.FORMAT_RH,\ + Image.FORMAT_RGH,\ + Image.FORMAT_RGBH,\ + Image.FORMAT_RGBAH: + # TODO Can't save an EXR to user:// + # See https://github.com/godotengine/godot/issues/34490 +# fpath += ".exr" +# err = im.save_exr(fpath) + fpath += ".res" + err = ResourceSaver.save(fpath, im) + _: + err = str("Cannot save image format ", im.get_format()) + + # Remembering original format is important, + # because Godot's image loader often force-converts into larger formats + _cache_image_info[id] = { + "format": im.get_format(), + "path": fpath + } + + if err != OK: + push_error("Could not save image file to {0}, error {1}".format([fpath, err])) + _next_id += 1 + return id + + +func load_image(id: int) -> Image: + var info := _cache_image_info[id] as Dictionary + var fpath := info.path as String + + var im : Image + var err : int + if fpath.ends_with(".res"): + im = ResourceLoader.load(fpath) + if im == null: + err = ERR_CANT_OPEN + else: + im = Image.new() + err = im.load(fpath) + + if err != OK: + push_error("Could not load cached image from {0}, error {1}".format([fpath, err])) + return null + + im.convert(info.format) + return im + + +func clear(): + print("Clearing image cache") + + var dir := Directory.new() + var err := dir.open(_cache_dir) + if err != OK: + push_error("Could not open image file cache directory " + str(_cache_dir)) + return + + err = dir.list_dir_begin(true, true) + if err != OK: + push_error("Could not start list_dir_begin in " + str(_cache_dir)) + return + + while true: + var fpath := dir.get_next() + if fpath == "": + break + if fpath.ends_with(".png") or fpath.ends_with(".res"): + print("Deleted ", fpath) + err = dir.remove(fpath) + if err != OK: + push_error("Failed to delete cache file " + _cache_dir.plus_file(fpath)) + + _cache_image_info.clear() +