aboutsummaryrefslogtreecommitdiff
path: root/game/addons/zylann.hterrain/tools/brush
diff options
context:
space:
mode:
author Gone2Daly <71726742+Gone2Daly@users.noreply.github.com>2023-07-22 21:05:42 +0200
committer Gone2Daly <71726742+Gone2Daly@users.noreply.github.com>2023-07-22 21:05:42 +0200
commit71b3cd829f80de4c2cd3972d8bfd5ee470a5d180 (patch)
treeb4280fde6eef2ae6987648bc7bf8e00e9011bb7f /game/addons/zylann.hterrain/tools/brush
parentce9022d0df74d6c33db3686622be2050d873ab0b (diff)
init_testtest3d
Diffstat (limited to 'game/addons/zylann.hterrain/tools/brush')
-rw-r--r--game/addons/zylann.hterrain/tools/brush/brush.gd217
-rw-r--r--game/addons/zylann.hterrain/tools/brush/brush_editor.gd234
-rw-r--r--game/addons/zylann.hterrain/tools/brush/brush_editor.tscn130
-rw-r--r--game/addons/zylann.hterrain/tools/brush/decal.gd121
-rw-r--r--game/addons/zylann.hterrain/tools/brush/decal.gdshader41
-rw-r--r--game/addons/zylann.hterrain/tools/brush/no_blend.gdshader6
-rw-r--r--game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader9
-rw-r--r--game/addons/zylann.hterrain/tools/brush/painter.gd399
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd280
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn211
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd41
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd70
-rw-r--r--game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn22
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader19
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader68
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader64
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader22
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader45
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader22
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader81
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader63
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader89
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exrbin0 -> 12881 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round0.exrbin0 -> 11022 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round1.exrbin0 -> 17380 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round2.exrbin0 -> 10895 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round3.exrbin0 -> 8906 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/smoke.exrbin0 -> 10348 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/texture1.exrbin0 -> 17201 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/thing.exrbin0 -> 25254 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exrbin0 -> 12597 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import34
-rw-r--r--game/addons/zylann.hterrain/tools/brush/terrain_painter.gd573
42 files changed, 3167 insertions, 0 deletions
diff --git a/game/addons/zylann.hterrain/tools/brush/brush.gd b/game/addons/zylann.hterrain/tools/brush/brush.gd
new file mode 100644
index 0000000..73d447f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/brush.gd
@@ -0,0 +1,217 @@
+@tool
+
+# Brush properties (shape, transform, timing and opacity).
+# Other attributes like color, height or texture index are tool-specific,
+# while brush properties apply to all of them.
+# This is separate from Painter because it could apply to multiple Painters at once.
+
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Painter = preload("./painter.gd")
+
+const SHAPES_DIR = "addons/zylann.hterrain/tools/brush/shapes"
+const DEFAULT_BRUSH_TEXTURE_PATH = SHAPES_DIR + "/round2.exr"
+# Reasonable size for sliders to be usable
+const MAX_SIZE_FOR_SLIDERS = 500
+# Absolute size limit. Terrains can't be larger than that, and it will be very slow to paint
+const MAX_SIZE = 4000
+
+signal size_changed(new_size)
+signal shapes_changed
+signal shape_index_changed
+
+var _size := 32
+var _opacity := 1.0
+var _random_rotation := false
+var _pressure_enabled := false
+var _pressure_over_scale := 0.5
+var _pressure_over_opacity := 0.5
+# TODO Rename stamp_*?
+var _frequency_distance := 0.0
+var _frequency_time_ms := 0
+# Array of greyscale textures
+var _shapes : Array[Texture2D] = []
+
+var _shape_index := 0
+var _shape_cycling_enabled := false
+var _prev_position := Vector2(-999, -999)
+var _prev_time_ms := 0
+
+
+func set_size(size: int):
+ if size < 1:
+ size = 1
+ if size != _size:
+ _size = size
+ size_changed.emit(_size)
+
+
+func get_size() -> int:
+ return _size
+
+
+func set_opacity(opacity: float):
+ _opacity = clampf(opacity, 0.0, 1.0)
+
+
+func get_opacity() -> float:
+ return _opacity
+
+
+func set_random_rotation_enabled(enabled: bool):
+ _random_rotation = enabled
+
+
+func is_random_rotation_enabled() -> bool:
+ return _random_rotation
+
+
+func set_pressure_enabled(enabled: bool):
+ _pressure_enabled = enabled
+
+
+func is_pressure_enabled() -> bool:
+ return _pressure_enabled
+
+
+func set_pressure_over_scale(amount: float):
+ _pressure_over_scale = clampf(amount, 0.0, 1.0)
+
+
+func get_pressure_over_scale() -> float:
+ return _pressure_over_scale
+
+
+func set_pressure_over_opacity(amount: float):
+ _pressure_over_opacity = clampf(amount, 0.0, 1.0)
+
+
+func get_pressure_over_opacity() -> float:
+ return _pressure_over_opacity
+
+
+func set_frequency_distance(d: float):
+ _frequency_distance = maxf(d, 0.0)
+
+
+func get_frequency_distance() -> float:
+ return _frequency_distance
+
+
+func set_frequency_time_ms(t: int):
+ if t < 0:
+ t = 0
+ _frequency_time_ms = t
+
+
+func get_frequency_time_ms() -> int:
+ return _frequency_time_ms
+
+
+func set_shapes(shapes: Array[Texture2D]):
+ assert(len(shapes) >= 1)
+ for s in shapes:
+ assert(s != null)
+ assert(s is Texture2D)
+ _shapes = shapes.duplicate(false)
+ if _shape_index >= len(_shapes):
+ _shape_index = len(_shapes) - 1
+ shapes_changed.emit()
+
+
+func get_shapes() -> Array[Texture2D]:
+ return _shapes.duplicate(false)
+
+
+func get_shape(i: int) -> Texture2D:
+ return _shapes[i]
+
+
+func get_shape_index() -> int:
+ return _shape_index
+
+
+func set_shape_index(i: int):
+ assert(i >= 0)
+ assert(i < len(_shapes))
+ _shape_index = i
+ shape_index_changed.emit()
+
+
+func set_shape_cycling_enabled(enable: bool):
+ _shape_cycling_enabled = enable
+
+
+func is_shape_cycling_enabled() -> bool:
+ return _shape_cycling_enabled
+
+
+static func load_shape_from_image_file(fpath: String, logger, retries := 1) -> Texture2D:
+ var im := Image.new()
+ var err := im.load(fpath)
+ if err != OK:
+ if retries > 0:
+ # TODO There is a bug with Godot randomly being unable to load images.
+ # See https://github.com/Zylann/godot_heightmap_plugin/issues/219
+ # Attempting to workaround this by retrying (I suspect it's because of non-initialized
+ # variable in Godot's C++ code...)
+ logger.error("Could not load image at '{0}', error {1}. Retrying..." \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return load_shape_from_image_file(fpath, logger, retries - 1)
+ else:
+ logger.error("Could not load image at '{0}', error {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return null
+ var tex := ImageTexture.create_from_image(im)
+ return tex
+
+
+# Call this while handling mouse or pen input.
+# If it returns false, painting should not run.
+func configure_paint_input(painters: Array[HT_Painter], position: Vector2, pressure: float) -> bool:
+ assert(len(_shapes) != 0)
+
+ # DEBUG
+ #pressure = 0.5 + 0.5 * sin(OS.get_ticks_msec() / 200.0)
+
+ if position.distance_to(_prev_position) < _frequency_distance:
+ return false
+ var now := Time.get_ticks_msec()
+ if (now - _prev_time_ms) < _frequency_time_ms:
+ return false
+ _prev_position = position
+ _prev_time_ms = now
+
+ for painter_index in len(painters):
+ var painter : HT_Painter = painters[painter_index]
+
+ if _random_rotation:
+ painter.set_brush_rotation(randf_range(-PI, PI))
+ else:
+ painter.set_brush_rotation(0.0)
+
+ painter.set_brush_texture(_shapes[_shape_index])
+ painter.set_brush_size(_size)
+
+ if _pressure_enabled:
+ painter.set_brush_scale(lerpf(1.0, pressure, _pressure_over_scale))
+ painter.set_brush_opacity(_opacity * lerpf(1.0, pressure, _pressure_over_opacity))
+ else:
+ painter.set_brush_scale(1.0)
+ painter.set_brush_opacity(_opacity)
+
+ #painter.paint_input(position)
+
+ if _shape_cycling_enabled:
+ _shape_index += 1
+ if _shape_index >= len(_shapes):
+ _shape_index = 0
+
+ return true
+
+
+# Call this when the user releases the pen or mouse button
+func on_paint_end():
+ _prev_position = Vector2(-999, -999)
+ _prev_time_ms = 0
+
+
diff --git a/game/addons/zylann.hterrain/tools/brush/brush_editor.gd b/game/addons/zylann.hterrain/tools/brush/brush_editor.gd
new file mode 100644
index 0000000..1e645a3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/brush_editor.gd
@@ -0,0 +1,234 @@
+@tool
+extends Control
+
+const HT_TerrainPainter = preload("./terrain_painter.gd")
+const HT_Brush = preload("./brush.gd")
+const HT_Errors = preload("../../util/errors.gd")
+#const NativeFactory = preload("../../native/factory.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_IntervalSlider = preload("../util/interval_slider.gd")
+
+const HT_BrushSettingsDialogScene = preload("./settings_dialog/brush_settings_dialog.tscn")
+const HT_BrushSettingsDialog = preload("./settings_dialog/brush_settings_dialog.gd")
+
+
+@onready var _size_slider : Slider = $GridContainer/BrushSizeControl/Slider
+@onready var _size_value_label : Label = $GridContainer/BrushSizeControl/Label
+#onready var _size_label = _params_container.get_node("BrushSizeLabel")
+
+@onready var _opacity_slider : Slider = $GridContainer/BrushOpacityControl/Slider
+@onready var _opacity_value_label : Label = $GridContainer/BrushOpacityControl/Label
+@onready var _opacity_control : Control = $GridContainer/BrushOpacityControl
+@onready var _opacity_label : Label = $GridContainer/BrushOpacityLabel
+
+@onready var _flatten_height_container : Control = $GridContainer/HB
+@onready var _flatten_height_box : SpinBox = $GridContainer/HB/FlattenHeightControl
+@onready var _flatten_height_label : Label = $GridContainer/FlattenHeightLabel
+@onready var _flatten_height_pick_button : Button = $GridContainer/HB/FlattenHeightPickButton
+
+@onready var _color_picker : ColorPickerButton = $GridContainer/ColorPickerButton
+@onready var _color_label : Label = $GridContainer/ColorLabel
+
+@onready var _density_slider : Slider = $GridContainer/DensitySlider
+@onready var _density_label : Label = $GridContainer/DensityLabel
+
+@onready var _holes_label : Label = $GridContainer/HoleLabel
+@onready var _holes_checkbox : CheckBox = $GridContainer/HoleCheckbox
+
+@onready var _slope_limit_label : Label = $GridContainer/SlopeLimitLabel
+@onready var _slope_limit_control : HT_IntervalSlider = $GridContainer/SlopeLimit
+
+@onready var _shape_texture_rect : TextureRect = get_node("BrushShapeButton/TextureRect")
+
+var _terrain_painter : HT_TerrainPainter
+var _brush_settings_dialog : HT_BrushSettingsDialog = null
+var _logger = HT_Logger.get_for(self)
+
+# TODO This is an ugly workaround for https://github.com/godotengine/godot/issues/19479
+@onready var _temp_node = get_node("Temp")
+@onready var _grid_container = get_node("GridContainer")
+func _set_visibility_of(node: Control, v: bool):
+ node.get_parent().remove_child(node)
+ if v:
+ _grid_container.add_child(node)
+ else:
+ _temp_node.add_child(node)
+ node.visible = v
+
+
+func _ready():
+ _size_slider.value_changed.connect(_on_size_slider_value_changed)
+ _opacity_slider.value_changed.connect(_on_opacity_slider_value_changed)
+ _flatten_height_box.value_changed.connect(_on_flatten_height_box_value_changed)
+ _color_picker.color_changed.connect(_on_color_picker_color_changed)
+ _density_slider.value_changed.connect(_on_density_slider_changed)
+ _holes_checkbox.toggled.connect(_on_holes_checkbox_toggled)
+ _slope_limit_control.changed.connect(_on_slope_limit_changed)
+
+ _size_slider.max_value = HT_Brush.MAX_SIZE_FOR_SLIDERS
+ #if NativeFactory.is_native_available():
+ # _size_slider.max_value = 200
+ #else:
+ # _size_slider.max_value = 50
+
+
+func setup_dialogs(base_control: Node):
+ assert(_brush_settings_dialog == null)
+ _brush_settings_dialog = HT_BrushSettingsDialogScene.instantiate()
+ base_control.add_child(_brush_settings_dialog)
+
+ # That dialog has sub-dialogs
+ _brush_settings_dialog.setup_dialogs(base_control)
+ _brush_settings_dialog.set_brush(_terrain_painter.get_brush())
+
+
+func _exit_tree():
+ if _brush_settings_dialog != null:
+ _brush_settings_dialog.queue_free()
+ _brush_settings_dialog = null
+
+# Testing display modes
+#var mode = 0
+#func _input(event):
+# if event is InputEventKey:
+# if event.pressed:
+# set_display_mode(mode)
+# mode += 1
+# if mode >= Brush.MODE_COUNT:
+# mode = 0
+
+func set_terrain_painter(terrain_painter: HT_TerrainPainter):
+ if _terrain_painter != null:
+ _terrain_painter.flatten_height_changed.disconnect(_on_flatten_height_changed)
+ _terrain_painter.get_brush().shapes_changed.disconnect(_on_brush_shapes_changed)
+ _terrain_painter.get_brush().shape_index_changed.disconnect(_on_brush_shape_index_changed)
+
+ _terrain_painter = terrain_painter
+
+ if _terrain_painter != null:
+ # TODO Had an issue in Godot 3.2.3 where mismatching type would silently cast to null...
+ # It happens if the argument went through a Variant (for example if call_deferred is used)
+ assert(_terrain_painter != null)
+
+ if _terrain_painter != null:
+ # Initial brush params
+ _size_slider.value = _terrain_painter.get_brush().get_size()
+ _opacity_slider.ratio = _terrain_painter.get_brush().get_opacity()
+ # Initial specific params
+ _flatten_height_box.value = _terrain_painter.get_flatten_height()
+ _color_picker.get_picker().color = _terrain_painter.get_color()
+ _density_slider.value = _terrain_painter.get_detail_density()
+ _holes_checkbox.button_pressed = not _terrain_painter.get_mask_flag()
+
+ var low := rad_to_deg(_terrain_painter.get_slope_limit_low_angle())
+ var high := rad_to_deg(_terrain_painter.get_slope_limit_high_angle())
+ _slope_limit_control.set_values(low, high)
+
+ set_display_mode(_terrain_painter.get_mode())
+
+ # Load default brush
+ var brush := _terrain_painter.get_brush()
+ var default_shape_fpath := HT_Brush.DEFAULT_BRUSH_TEXTURE_PATH
+ var default_shape := HT_Brush.load_shape_from_image_file(default_shape_fpath, _logger)
+ brush.set_shapes([default_shape])
+ _update_shape_preview()
+
+ _terrain_painter.flatten_height_changed.connect(_on_flatten_height_changed)
+ brush.shapes_changed.connect(_on_brush_shapes_changed)
+ brush.shape_index_changed.connect(_on_brush_shape_index_changed)
+
+
+func _on_flatten_height_changed():
+ _flatten_height_box.value = _terrain_painter.get_flatten_height()
+ _flatten_height_pick_button.button_pressed = false
+
+
+func _on_brush_shapes_changed():
+ _update_shape_preview()
+
+
+func _on_brush_shape_index_changed():
+ _update_shape_preview()
+
+
+func _update_shape_preview():
+ var brush := _terrain_painter.get_brush()
+ var i := brush.get_shape_index()
+ _shape_texture_rect.texture = brush.get_shape(i)
+
+
+func set_display_mode(mode: int):
+ var show_flatten := mode == HT_TerrainPainter.MODE_FLATTEN
+ var show_color := mode == HT_TerrainPainter.MODE_COLOR
+ var show_density := mode == HT_TerrainPainter.MODE_DETAIL
+ var show_opacity := mode != HT_TerrainPainter.MODE_MASK
+ var show_holes := mode == HT_TerrainPainter.MODE_MASK
+ var show_slope_limit := \
+ mode == HT_TerrainPainter.MODE_SPLAT or mode == HT_TerrainPainter.MODE_DETAIL
+
+ _set_visibility_of(_opacity_label, show_opacity)
+ _set_visibility_of(_opacity_control, show_opacity)
+
+ _set_visibility_of(_color_label, show_color)
+ _set_visibility_of(_color_picker, show_color)
+
+ _set_visibility_of(_flatten_height_label, show_flatten)
+ _set_visibility_of(_flatten_height_container, show_flatten)
+
+ _set_visibility_of(_density_label, show_density)
+ _set_visibility_of(_density_slider, show_density)
+
+ _set_visibility_of(_holes_label, show_holes)
+ _set_visibility_of(_holes_checkbox, show_holes)
+
+ _set_visibility_of(_slope_limit_label, show_slope_limit)
+ _set_visibility_of(_slope_limit_control, show_slope_limit)
+
+ _flatten_height_pick_button.button_pressed = false
+
+
+func _on_size_slider_value_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_brush_size(int(v))
+ _size_value_label.text = str(v)
+
+
+func _on_opacity_slider_value_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_opacity(_opacity_slider.ratio)
+ _opacity_value_label.text = str(v)
+
+
+func _on_flatten_height_box_value_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_flatten_height(v)
+
+
+func _on_color_picker_color_changed(v: Color):
+ if _terrain_painter != null:
+ _terrain_painter.set_color(v)
+
+
+func _on_density_slider_changed(v: float):
+ if _terrain_painter != null:
+ _terrain_painter.set_detail_density(v)
+
+
+func _on_holes_checkbox_toggled(v: bool):
+ if _terrain_painter != null:
+ # When checked, we draw holes. When unchecked, we clear holes
+ _terrain_painter.set_mask_flag(not v)
+
+
+func _on_BrushShapeButton_pressed():
+ _brush_settings_dialog.popup_centered()
+
+
+func _on_FlattenHeightPickButton_pressed():
+ _terrain_painter.set_meta("pick_height", true)
+
+
+func _on_slope_limit_changed():
+ var low = deg_to_rad(_slope_limit_control.get_low_value())
+ var high = deg_to_rad(_slope_limit_control.get_high_value())
+ _terrain_painter.set_slope_limit_angles(low, high)
diff --git a/game/addons/zylann.hterrain/tools/brush/brush_editor.tscn b/game/addons/zylann.hterrain/tools/brush/brush_editor.tscn
new file mode 100644
index 0000000..42ef980
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/brush_editor.tscn
@@ -0,0 +1,130 @@
+[gd_scene load_steps=4 format=3 uid="uid://bd42ig216p216"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/brush_editor.gd" id="1"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/util/interval_slider.gd" id="3"]
+
+[sub_resource type="CanvasItemMaterial" id="1"]
+blend_mode = 1
+
+[node name="BrushEditor" type="HBoxContainer"]
+custom_minimum_size = Vector2(200, 0)
+offset_right = 293.0
+offset_bottom = 211.0
+script = ExtResource("1")
+
+[node name="BrushShapeButton" type="Button" parent="."]
+custom_minimum_size = Vector2(50, 0)
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="BrushShapeButton"]
+material = SubResource("1")
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+expand_mode = 1
+stretch_mode = 5
+
+[node name="GridContainer" type="GridContainer" parent="."]
+layout_mode = 2
+size_flags_horizontal = 3
+columns = 2
+
+[node name="BrushSizeLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Brush size"
+
+[node name="BrushSizeControl" type="HBoxContainer" parent="GridContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+mouse_filter = 0
+
+[node name="Slider" type="HSlider" parent="GridContainer/BrushSizeControl"]
+custom_minimum_size = Vector2(60, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 1
+min_value = 2.0
+max_value = 500.0
+value = 2.0
+exp_edit = true
+rounded = true
+
+[node name="Label" type="Label" parent="GridContainer/BrushSizeControl"]
+layout_mode = 2
+text = "999"
+
+[node name="BrushOpacityLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Brush opacity"
+
+[node name="BrushOpacityControl" type="HBoxContainer" parent="GridContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Slider" type="HSlider" parent="GridContainer/BrushOpacityControl"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 1
+
+[node name="Label" type="Label" parent="GridContainer/BrushOpacityControl"]
+layout_mode = 2
+text = "999"
+
+[node name="FlattenHeightLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Flatten height"
+
+[node name="HB" type="HBoxContainer" parent="GridContainer"]
+layout_mode = 2
+
+[node name="FlattenHeightControl" type="SpinBox" parent="GridContainer/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+min_value = -500.0
+max_value = 500.0
+step = 0.01
+
+[node name="FlattenHeightPickButton" type="Button" parent="GridContainer/HB"]
+layout_mode = 2
+toggle_mode = true
+text = "Pick"
+
+[node name="ColorLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Color"
+
+[node name="ColorPickerButton" type="ColorPickerButton" parent="GridContainer"]
+layout_mode = 2
+toggle_mode = false
+color = Color(1, 1, 1, 1)
+
+[node name="DensityLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Detail density"
+
+[node name="DensitySlider" type="HSlider" parent="GridContainer"]
+layout_mode = 2
+max_value = 1.0
+step = 0.1
+
+[node name="HoleLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Draw holes"
+
+[node name="HoleCheckbox" type="CheckBox" parent="GridContainer"]
+layout_mode = 2
+
+[node name="SlopeLimitLabel" type="Label" parent="GridContainer"]
+layout_mode = 2
+text = "Slope limit"
+
+[node name="SlopeLimit" type="Control" parent="GridContainer"]
+layout_mode = 2
+script = ExtResource("3")
+range = Vector2(0, 90)
+
+[node name="Temp" type="Node" parent="."]
+
+[connection signal="pressed" from="BrushShapeButton" to="." method="_on_BrushShapeButton_pressed"]
+[connection signal="pressed" from="GridContainer/HB/FlattenHeightPickButton" to="." method="_on_FlattenHeightPickButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/brush/decal.gd b/game/addons/zylann.hterrain/tools/brush/decal.gd
new file mode 100644
index 0000000..13433ed
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/decal.gd
@@ -0,0 +1,121 @@
+@tool
+# Shows a cursor on top of the terrain to preview where the brush will paint
+
+# TODO Use an actual decal node, it wasn't available in Godot 3
+
+const HT_DirectMeshInstance = preload("../../util/direct_mesh_instance.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Util = preload("../../util/util.gd")
+
+var _mesh_instance : HT_DirectMeshInstance
+var _mesh : PlaneMesh
+var _material = ShaderMaterial.new()
+#var _debug_mesh = CubeMesh.new()
+#var _debug_mesh_instance = null
+
+var _terrain : HTerrain = null
+
+
+func _init():
+ _material.shader = load("res://addons/zylann.hterrain/tools/brush/decal.gdshader")
+ _mesh_instance = HT_DirectMeshInstance.new()
+ _mesh_instance.set_material(_material)
+
+ _mesh = PlaneMesh.new()
+ _mesh_instance.set_mesh(_mesh)
+
+ #_debug_mesh_instance = DirectMeshInstance.new()
+ #_debug_mesh_instance.set_mesh(_debug_mesh)
+
+
+func set_size(size: float):
+ _mesh.size = Vector2(size, size)
+ # Must line up to terrain vertex policy, so must apply an off-by-one.
+ # If I don't do that, the brush will appear to wobble above the ground
+ var ss := size - 1
+ # Don't subdivide too much
+ while ss > 50:
+ ss /= 2
+ _mesh.subdivide_width = ss
+ _mesh.subdivide_depth = ss
+
+
+#func set_shape(shape_image):
+# set_size(shape_image.get_width())
+
+
+func _on_terrain_transform_changed(terrain_global_trans: Transform3D):
+ var inv = terrain_global_trans.affine_inverse()
+ _material.set_shader_parameter("u_terrain_inverse_transform", inv)
+
+ var normal_basis = terrain_global_trans.basis.inverse().transposed()
+ _material.set_shader_parameter("u_terrain_normal_basis", normal_basis)
+
+
+func set_terrain(terrain: HTerrain):
+ if _terrain == terrain:
+ return
+
+ if _terrain != null:
+ _terrain.transform_changed.disconnect(_on_terrain_transform_changed)
+ _mesh_instance.exit_world()
+ #_debug_mesh_instance.exit_world()
+
+ _terrain = terrain
+
+ if _terrain != null:
+ _terrain.transform_changed.connect(_on_terrain_transform_changed)
+ _on_terrain_transform_changed(_terrain.get_internal_transform())
+ _mesh_instance.enter_world(terrain.get_world_3d())
+ #_debug_mesh_instance.enter_world(terrain.get_world())
+
+ update_visibility()
+
+
+func set_position(p_local_pos: Vector3):
+ assert(_terrain != null)
+ assert(typeof(p_local_pos) == TYPE_VECTOR3)
+
+ # Set custom AABB (in local cells) because the decal is displaced by shader
+ var data = _terrain.get_data()
+ if data != null:
+ var r = _mesh.size / 2
+ var aabb = data.get_region_aabb( \
+ int(p_local_pos.x - r.x), \
+ int(p_local_pos.z - r.y), \
+ int(2 * r.x), \
+ int(2 * r.y))
+ aabb.position = Vector3(-r.x, aabb.position.y, -r.y)
+ _mesh.custom_aabb = aabb
+ #_debug_mesh.size = aabb.size
+
+ var trans = Transform3D(Basis(), p_local_pos)
+ var terrain_gt = _terrain.get_internal_transform()
+ trans = terrain_gt * trans
+ _mesh_instance.set_transform(trans)
+ #_debug_mesh_instance.set_transform(trans)
+
+
+# This is called very often so it should be cheap
+func update_visibility():
+ var heightmap = _get_heightmap(_terrain)
+ if heightmap == null:
+ # I do this for refcounting because heightmaps are large resources
+ _material.set_shader_parameter("u_terrain_heightmap", null)
+ _mesh_instance.set_visible(false)
+ #_debug_mesh_instance.set_visible(false)
+ else:
+ _material.set_shader_parameter("u_terrain_heightmap", heightmap)
+ _mesh_instance.set_visible(true)
+ #_debug_mesh_instance.set_visible(true)
+
+
+func _get_heightmap(terrain):
+ if terrain == null:
+ return null
+ var data = terrain.get_data()
+ if data == null:
+ return null
+ return data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
diff --git a/game/addons/zylann.hterrain/tools/brush/decal.gdshader b/game/addons/zylann.hterrain/tools/brush/decal.gdshader
new file mode 100644
index 0000000..b56f43a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/decal.gdshader
@@ -0,0 +1,41 @@
+shader_type spatial;
+render_mode unshaded;//, depth_test_disable;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+void vertex() {
+ vec2 cell_coords = (u_terrain_inverse_transform * MODEL_MATRIX * vec4(VERTEX, 1)).xz;
+
+ vec2 ps = vec2(1.0) / vec2(textureSize(u_terrain_heightmap, 0));
+ vec2 uv = ps * cell_coords;
+
+ // Get terrain normal
+ float k = 1.0;
+ float left = get_height(u_terrain_heightmap, uv + vec2(-ps.x, 0)) * k;
+ float right = get_height(u_terrain_heightmap, uv + vec2(ps.x, 0)) * k;
+ float back = get_height(u_terrain_heightmap, uv + vec2(0, -ps.y)) * k;
+ float fore = get_height(u_terrain_heightmap, uv + vec2(0, ps.y)) * k;
+ vec3 n = normalize(vec3(left - right, 2.0, back - fore));
+
+ n = u_terrain_normal_basis * n;
+
+ float h = get_height(u_terrain_heightmap, uv);
+ VERTEX.y = h;
+ VERTEX += 1.0 * n;
+ NORMAL = n;//vec3(0.0, 1.0, 0.0);
+}
+
+void fragment() {
+ float len = length(2.0 * UV - 1.0);
+ float g = clamp(1.0 - 15.0 * abs(0.9 - len), 0.0, 1.0);
+ ALBEDO = vec3(1.0, 0.1, 0.1);
+ ALPHA = g;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/no_blend.gdshader b/game/addons/zylann.hterrain/tools/brush/no_blend.gdshader
new file mode 100644
index 0000000..8ae0f84
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/no_blend.gdshader
@@ -0,0 +1,6 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+void fragment() {
+ COLOR = texture(TEXTURE, UV);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader b/game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader
new file mode 100644
index 0000000..d0460de
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/no_blend_rf.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+void fragment() {
+ float h = sample_heightmap(TEXTURE, UV);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/painter.gd b/game/addons/zylann.hterrain/tools/brush/painter.gd
new file mode 100644
index 0000000..fa4be98
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/painter.gd
@@ -0,0 +1,399 @@
+
+# Core logic to paint a texture using shaders, with undo/redo support.
+# Operations are delayed so results are only available the next frame.
+# This doesn't implement UI or brush behavior, only rendering logic.
+#
+# Note: due to the absence of channel separation function in Image,
+# you may need to use multiple painters at once if your application exploits multiple channels.
+# Example: when painting a heightmap, it would be doable to output height in R, normalmap in GB, and
+# then separate channels in two images at the end.
+
+@tool
+extends Node
+
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Util = preload("../../util/util.gd")
+const HT_NoBlendShader = preload("./no_blend.gdshader")
+const HT_NoBlendRFShader = preload("./no_blend_rf.gdshader")
+
+const UNDO_CHUNK_SIZE = 64
+
+# All painting shaders can use these common parameters
+const SHADER_PARAM_SRC_TEXTURE = "u_src_texture"
+const SHADER_PARAM_SRC_RECT = "u_src_rect"
+const SHADER_PARAM_OPACITY = "u_opacity"
+
+const _API_SHADER_PARAMS = [
+ SHADER_PARAM_SRC_TEXTURE,
+ SHADER_PARAM_SRC_RECT,
+ SHADER_PARAM_OPACITY
+]
+
+# Emitted when a region of the painted texture actually changed.
+# Note 1: the image might not have changed yet at this point.
+# Note 2: the user could still be in the middle of dragging the brush.
+signal texture_region_changed(rect)
+
+# Godot doesn't support 32-bit float rendering, so painting is limited to 16-bit depth.
+# We should get this in Godot 4.0, either as Compute or renderer improvement
+const _hdr_formats = [
+ Image.FORMAT_RH,
+ Image.FORMAT_RGH,
+ Image.FORMAT_RGBH,
+ Image.FORMAT_RGBAH
+]
+
+const _supported_formats = [
+ Image.FORMAT_R8,
+ Image.FORMAT_RG8,
+ Image.FORMAT_RGB8,
+ Image.FORMAT_RGBA8
+ # No longer supported since Godot 4 removed support for it in 2D viewports...
+# Image.FORMAT_RH,
+# Image.FORMAT_RGH,
+# Image.FORMAT_RGBH,
+# Image.FORMAT_RGBAH
+]
+
+# - SubViewport (size of edited region + margin to allow quad rotation)
+# |- Background
+# | Fills pixels with unmodified source image.
+# |- Brush sprite
+# Size of actual brush, scaled/rotated, modifies source image.
+# Assigned texture is the brush texture, src image is a shader param
+
+var _viewport : SubViewport
+var _viewport_bg_sprite : Sprite2D
+var _viewport_brush_sprite : Sprite2D
+var _brush_size := 32
+var _brush_scale := 1.0
+var _brush_position := Vector2()
+var _brush_opacity := 1.0
+var _brush_texture : Texture
+var _last_brush_position := Vector2()
+var _brush_material := ShaderMaterial.new()
+var _no_blend_material : ShaderMaterial
+var _image : Image
+var _texture : ImageTexture
+var _cmd_paint := false
+var _pending_paint_render := false
+var _modified_chunks := {}
+var _modified_shader_params := {}
+
+var _debug_display : TextureRect
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ _viewport = SubViewport.new()
+ _viewport.size = Vector2(_brush_size, _brush_size)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+ #_viewport.hdr = false
+ # Require 4 components (RGBA)
+ _viewport.transparent_bg = true
+ # Apparently HDR doesn't work if this is set to 2D... so let's waste a depth buffer :/
+ #_viewport.usage = Viewport.USAGE_2D
+ #_viewport.keep_3d_linear
+
+ # There is no "blend_disabled" option on standard CanvasItemMaterial...
+ _no_blend_material = ShaderMaterial.new()
+ _no_blend_material.shader = HT_NoBlendShader
+ _viewport_bg_sprite = Sprite2D.new()
+ _viewport_bg_sprite.centered = false
+ _viewport_bg_sprite.material = _no_blend_material
+ _viewport.add_child(_viewport_bg_sprite)
+
+ _viewport_brush_sprite = Sprite2D.new()
+ _viewport_brush_sprite.centered = true
+ _viewport_brush_sprite.material = _brush_material
+ _viewport_brush_sprite.position = _viewport.size / 2.0
+ _viewport.add_child(_viewport_brush_sprite)
+
+ add_child(_viewport)
+
+
+func set_debug_display(dd: TextureRect):
+ _debug_display = dd
+ _debug_display.texture = _viewport.get_texture()
+
+
+func set_image(image: Image, texture: ImageTexture):
+ assert((image == null and texture == null) or (image != null and texture != null))
+ _image = image
+ _texture = texture
+ _viewport_bg_sprite.texture = _texture
+ _brush_material.set_shader_parameter(SHADER_PARAM_SRC_TEXTURE, _texture)
+ if image != null:
+ if image.get_format() == Image.FORMAT_RF:
+ # In case of RF all shaders must encode their fragment outputs in RGBA8,
+ # including the unmodified background, as Godot 4.0 does not support RF viewports
+ _no_blend_material.shader = HT_NoBlendRFShader
+ else:
+ _no_blend_material.shader = HT_NoBlendShader
+ # TODO HDR is required in order to paint heightmaps.
+ # Seems Godot 4.0 does not support it, so we have to wait for Godot 4.1...
+ #_viewport.hdr = image.get_format() in _hdr_formats
+ if (image.get_format() in _hdr_formats) and image.get_format() != Image.FORMAT_RF:
+ push_error("Godot 4.0 does not support HDR viewports for GPU-editing heightmaps! " +
+ "Only RF is supported using a bit packing hack.")
+ #print("PAINTER VIEWPORT HDR: ", _viewport.hdr)
+
+
+# Sets the size of the brush in pixels.
+# This will cause the internal viewport to resize, which is expensive.
+# If you need to frequently change brush size during a paint stroke, prefer using scale instead.
+func set_brush_size(new_size: int):
+ _brush_size = new_size
+
+
+func get_brush_size() -> int:
+ return _brush_size
+
+
+func set_brush_rotation(rotation: float):
+ _viewport_brush_sprite.rotation = rotation
+
+
+func get_brush_rotation() -> float:
+ return _viewport_bg_sprite.rotation
+
+
+# The difference between size and scale, is that size is in pixels, while scale is a multiplier.
+# Scale is also a lot cheaper to change, so you may prefer changing it instead of size if that
+# happens often during a painting stroke.
+func set_brush_scale(s: float):
+ _brush_scale = clampf(s, 0.0, 1.0)
+ #_viewport_brush_sprite.scale = Vector2(s, s)
+
+
+func get_brush_scale() -> float:
+ return _viewport_bg_sprite.scale.x
+
+
+func set_brush_opacity(opacity: float):
+ _brush_opacity = clampf(opacity, 0.0, 1.0)
+
+
+func get_brush_opacity() -> float:
+ return _brush_opacity
+
+
+func set_brush_texture(texture: Texture):
+ _viewport_brush_sprite.texture = texture
+
+
+func set_brush_shader(shader: Shader):
+ if _brush_material.shader != shader:
+ _brush_material.shader = shader
+
+
+func set_brush_shader_param(p: String, v):
+ assert(not _API_SHADER_PARAMS.has(p))
+ _modified_shader_params[p] = true
+ _brush_material.set_shader_parameter(p, v)
+
+
+func clear_brush_shader_params():
+ for key in _modified_shader_params:
+ _brush_material.set_shader_parameter(key, null)
+ _modified_shader_params.clear()
+
+
+# If we want to be able to rotate the brush quad every frame,
+# we must prepare a bigger viewport otherwise the quad will not fit inside
+static func _get_size_fit_for_rotation(src_size: Vector2) -> Vector2i:
+ var d = int(ceilf(src_size.length()))
+ return Vector2i(d, d)
+
+
+# You must call this from an `_input` function or similar.
+func paint_input(center_pos: Vector2):
+ var vp_size := _get_size_fit_for_rotation(Vector2(_brush_size, _brush_size))
+ if _viewport.size != vp_size:
+ # Do this lazily so the brush slider won't lag while adjusting it
+ # TODO An "sliding_ended" handling might produce better user experience
+ _viewport.size = vp_size
+ _viewport_brush_sprite.position = _viewport.size / 2.0
+
+ # Need to floor the position in case the brush has an odd size
+ var brush_pos := (center_pos - _viewport.size * 0.5).round()
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+ _viewport_bg_sprite.position = -brush_pos
+ _brush_position = brush_pos
+ _cmd_paint = true
+
+ # We want this quad to have a specific size, regardless of the texture assigned to it
+ _viewport_brush_sprite.scale = \
+ _brush_scale * Vector2(_brush_size, _brush_size) \
+ / Vector2(_viewport_brush_sprite.texture.get_size())
+
+ # Using a Color because Godot doesn't understand vec4
+ var rect := Color()
+ rect.r = brush_pos.x / _texture.get_width()
+ rect.g = brush_pos.y / _texture.get_height()
+ rect.b = float(_viewport.size.x) / float(_texture.get_width())
+ rect.a = float(_viewport.size.y) / float(_texture.get_height())
+ # In order to make sure that u_brush_rect is never bigger than the brush:
+ # 1. we ceil() the result of lower-left corner
+ # 2. we floor() the result of upper-right corner
+ # and then rederive width and height from the result
+# var half_brush:Vector2 = Vector2(_brush_size, _brush_size) / 2
+# var brush_LL := (center_pos - half_brush).ceil()
+# var brush_UR := (center_pos + half_brush).floor()
+# rect.r = brush_LL.x / _texture.get_width()
+# rect.g = brush_LL.y / _texture.get_height()
+# rect.b = (brush_UR.x - brush_LL.x) / _texture.get_width()
+# rect.a = (brush_UR.y - brush_LL.y) / _texture.get_height()
+ _brush_material.set_shader_parameter(SHADER_PARAM_SRC_RECT, rect)
+ _brush_material.set_shader_parameter(SHADER_PARAM_OPACITY, _brush_opacity)
+
+
+# Don't commit until this is false
+func is_operation_pending() -> bool:
+ return _pending_paint_render or _cmd_paint
+
+
+# Applies changes to the Image, and returns modified chunks for UndoRedo.
+func commit() -> Dictionary:
+ if is_operation_pending():
+ _logger.error("Painter commit() was called while an operation is still pending")
+ return _commit_modified_chunks()
+
+
+func has_modified_chunks() -> bool:
+ return len(_modified_chunks) > 0
+
+
+func _process(delta: float):
+ if _pending_paint_render:
+ _pending_paint_render = false
+
+ #print("Paint result at frame ", Engine.get_frames_drawn())
+ var viewport_image := _viewport.get_texture().get_image()
+
+ if _image.get_format() == Image.FORMAT_RF:
+ # Reinterpret RGBA8 as RF. This assumes painting shaders encode the output properly.
+ assert(viewport_image.get_format() == Image.FORMAT_RGBA8)
+ viewport_image = Image.create_from_data(
+ viewport_image.get_width(), viewport_image.get_height(), false, Image.FORMAT_RF,
+ viewport_image.get_data())
+ else:
+ viewport_image.convert(_image.get_format())
+
+ var brush_pos := _last_brush_position
+
+ var dst_x : int = clamp(brush_pos.x, 0, _texture.get_width())
+ var dst_y : int = clamp(brush_pos.y, 0, _texture.get_height())
+
+ var src_x : int = maxf(-brush_pos.x, 0)
+ var src_y : int = maxf(-brush_pos.y, 0)
+ var src_w : int = minf(maxf(_viewport.size.x - src_x, 0), _texture.get_width() - dst_x)
+ var src_h : int = minf(maxf(_viewport.size.y - src_y, 0), _texture.get_height() - dst_y)
+
+ if src_w != 0 and src_h != 0:
+ _mark_modified_chunks(dst_x, dst_y, src_w, src_h)
+ HT_Util.update_texture_partial(_texture, viewport_image,
+ Rect2i(src_x, src_y, src_w, src_h), Vector2i(dst_x, dst_y))
+ texture_region_changed.emit(Rect2(dst_x, dst_y, src_w, src_h))
+
+ # Input is handled just before process, so we still have to wait till next frame
+ if _cmd_paint:
+ _pending_paint_render = true
+ _last_brush_position = _brush_position
+ # Consume input
+ _cmd_paint = false
+
+
+func _mark_modified_chunks(bx: int, by: int, bw: int, bh: int):
+ var cs := UNDO_CHUNK_SIZE
+
+ var cmin_x := bx / cs
+ var cmin_y := by / cs
+ var cmax_x := (bx + bw - 1) / cs + 1
+ var cmax_y := (by + bh - 1) / cs + 1
+
+ for cy in range(cmin_y, cmax_y):
+ for cx in range(cmin_x, cmax_x):
+ #print("Marking chunk ", Vector2(cx, cy))
+ _modified_chunks[Vector2(cx, cy)] = true
+
+
+func _commit_modified_chunks() -> Dictionary:
+ var time_before := Time.get_ticks_msec()
+
+ var cs := UNDO_CHUNK_SIZE
+ var chunks_positions := []
+ var chunks_initial_data := []
+ var chunks_final_data := []
+
+ #_logger.debug("About to commit ", len(_modified_chunks), " chunks")
+
+ # TODO get_data_partial() would be nice...
+ var final_image := _texture.get_image()
+ for cpos in _modified_chunks:
+ var cx : int = cpos.x
+ var cy : int = cpos.y
+
+ var x := cx * cs
+ var y := cy * cs
+ var w : int = mini(cs, _image.get_width() - x)
+ var h : int = mini(cs, _image.get_height() - y)
+
+ var rect := Rect2i(x, y, w, h)
+ var initial_data := _image.get_region(rect)
+ var final_data := final_image.get_region(rect)
+
+ chunks_positions.append(cpos)
+ chunks_initial_data.append(initial_data)
+ chunks_final_data.append(final_data)
+ #_image_equals(initial_data, final_data)
+
+ # TODO We could also just replace the image with `final_image`...
+ # TODO Use `final_data` instead?
+ _image.blit_rect(final_image, rect, rect.position)
+
+ _modified_chunks.clear()
+
+ var time_spent := Time.get_ticks_msec() - time_before
+ _logger.debug("Spent {0} ms to commit paint operation".format([time_spent]))
+
+ return {
+ "chunk_positions": chunks_positions,
+ "chunk_initial_datas": chunks_initial_data,
+ "chunk_final_datas": chunks_final_data
+ }
+
+
+# DEBUG
+#func _input(event):
+# if event is InputEventKey:
+# if event.pressed:
+# if event.control and event.scancode == KEY_SPACE:
+# print("Saving painter viewport ", name)
+# var im = _viewport.get_texture().get_data()
+# im.convert(Image.FORMAT_RGBA8)
+# im.save_png(str("test_painter_viewport_", name, ".png"))
+
+
+#static func _image_equals(im_a: Image, im_b: Image) -> bool:
+# if im_a.get_size() != im_b.get_size():
+# print("Diff size: ", im_a.get_size, ", ", im_b.get_size())
+# return false
+# if im_a.get_format() != im_b.get_format():
+# print("Diff format: ", im_a.get_format(), ", ", im_b.get_format())
+# return false
+# im_a.lock()
+# im_b.lock()
+# for y in im_a.get_height():
+# for x in im_a.get_width():
+# var ca = im_a.get_pixel(x, y)
+# var cb = im_b.get_pixel(x, y)
+# if ca != cb:
+# print("Diff pixel ", x, ", ", y)
+# return false
+# im_a.unlock()
+# im_b.unlock()
+# print("SAME")
+# return true
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd
new file mode 100644
index 0000000..71f81d1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd
@@ -0,0 +1,280 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../../util/util.gd")
+const HT_Brush = preload("../brush.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_SpinSlider = preload("../../util/spin_slider.gd")
+const HT_Scratchpad = preload("./preview_scratchpad.gd")
+
+@onready var _scratchpad : HT_Scratchpad = $VB/HB/VB3/PreviewScratchpad
+
+@onready var _shape_list : ItemList = $VB/HB/VB/ShapeList
+@onready var _remove_shape_button : Button = $VB/HB/VB/HBoxContainer/RemoveShape
+@onready var _change_shape_button : Button = $VB/HB/VB/ChangeShape
+
+@onready var _size_slider : HT_SpinSlider = $VB/HB/VB2/Settings/Size
+@onready var _opacity_slider : HT_SpinSlider = $VB/HB/VB2/Settings/Opacity
+@onready var _pressure_enabled_checkbox : CheckBox = $VB/HB/VB2/Settings/PressureEnabled
+@onready var _pressure_over_size_slider : HT_SpinSlider = $VB/HB/VB2/Settings/PressureOverSize
+@onready var _pressure_over_opacity_slider : HT_SpinSlider = $VB/HB/VB2/Settings/PressureOverOpacity
+@onready var _frequency_distance_slider : HT_SpinSlider = $VB/HB/VB2/Settings/FrequencyDistance
+@onready var _frequency_time_slider : HT_SpinSlider = $VB/HB/VB2/Settings/FrequencyTime
+@onready var _random_rotation_checkbox : CheckBox = $VB/HB/VB2/Settings/RandomRotation
+@onready var _shape_cycling_checkbox : CheckBox = $VB/HB/VB2/Settings/ShapeCycling
+
+var _brush : HT_Brush
+# This is a `EditorFileDialog`,
+# but cannot type it because I want to be able to test it by running the scene.
+# And when I run it, Godot does not allow to use `EditorFileDialog`.
+var _load_image_dialog
+# -1 means add, otherwise replace
+var _load_image_index := -1
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ _size_slider.set_max_value(HT_Brush.MAX_SIZE_FOR_SLIDERS)
+ _size_slider.set_greater_max_value(HT_Brush.MAX_SIZE)
+
+ # TESTING
+ if not Engine.is_editor_hint():
+ setup_dialogs(self)
+ call_deferred("popup")
+
+
+func set_brush(brush : HT_Brush):
+ assert(brush != null)
+ _brush = brush
+ _update_controls_from_brush()
+
+
+# `base_control` can no longer be hinted as a `Control` because in Godot 4 it could be a
+# window or dialog, which are no longer controls...
+func setup_dialogs(base_control: Node):
+ assert(_load_image_dialog == null)
+ _load_image_dialog = HT_EditorUtil.create_open_file_dialog()
+ _load_image_dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ _load_image_dialog.add_filter("*.exr ; EXR files")
+ _load_image_dialog.unresizable = false
+ _load_image_dialog.access = EditorFileDialog.ACCESS_FILESYSTEM
+ _load_image_dialog.current_dir = HT_Brush.SHAPES_DIR
+ _load_image_dialog.file_selected.connect(_on_LoadImageDialog_file_selected)
+ _load_image_dialog.files_selected.connect(_on_LoadImageDialog_files_selected)
+ #base_control.add_child(_load_image_dialog)
+ # When a dialog opens another dialog, we get this error:
+ # "Transient parent has another exclusive child."
+ # Which is worked around by making the other dialog a child of the first one (I don't know why)
+ add_child(_load_image_dialog)
+
+
+func _exit_tree():
+ if _load_image_dialog != null:
+ _load_image_dialog.queue_free()
+ _load_image_dialog = null
+
+
+func _get_shapes_from_gui() -> Array[Texture2D]:
+ var shapes : Array[Texture2D] = []
+ for i in _shape_list.get_item_count():
+ var icon : Texture2D = _shape_list.get_item_icon(i)
+ assert(icon != null)
+ shapes.append(icon)
+ return shapes
+
+
+func _update_shapes_gui(shapes: Array[Texture2D]):
+ _shape_list.clear()
+ for shape in shapes:
+ assert(shape != null)
+ assert(shape is Texture2D)
+ _shape_list.add_icon_item(shape)
+ _update_shape_list_buttons()
+
+
+func _on_AddShape_pressed():
+ _load_image_index = -1
+ _load_image_dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILES
+ _load_image_dialog.popup_centered_ratio(0.7)
+
+
+func _on_RemoveShape_pressed():
+ var selected_indices := _shape_list.get_selected_items()
+ if len(selected_indices) == 0:
+ return
+
+ var index : int = selected_indices[0]
+ _shape_list.remove_item(index)
+
+ var shapes := _get_shapes_from_gui()
+ for brush in _get_brushes():
+ brush.set_shapes(shapes)
+
+ _update_shape_list_buttons()
+
+
+func _on_ShapeList_item_activated(index: int):
+ _request_modify_shape(index)
+
+
+func _on_ChangeShape_pressed():
+ var selected = _shape_list.get_selected_items()
+ if len(selected) == 0:
+ return
+ _request_modify_shape(selected[0])
+
+
+func _request_modify_shape(index: int):
+ _load_image_index = index
+ _load_image_dialog.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ _load_image_dialog.popup_centered_ratio(0.7)
+
+
+func _on_LoadImageDialog_files_selected(fpaths: PackedStringArray):
+ var shapes := _get_shapes_from_gui()
+
+ for fpath in fpaths:
+ var tex := HT_Brush.load_shape_from_image_file(fpath, _logger)
+ if tex == null:
+ # Failed
+ continue
+ shapes.append(tex)
+
+ for brush in _get_brushes():
+ brush.set_shapes(shapes)
+
+ _update_shapes_gui(shapes)
+
+
+func _on_LoadImageDialog_file_selected(fpath: String):
+ var tex := HT_Brush.load_shape_from_image_file(fpath, _logger)
+ if tex == null:
+ # Failed
+ return
+
+ var shapes := _get_shapes_from_gui()
+ if _load_image_index == -1 or _load_image_index >= len(shapes):
+ # Add
+ shapes.append(tex)
+ else:
+ # Replace
+ assert(_load_image_index >= 0)
+ shapes[_load_image_index] = tex
+
+ for brush in _get_brushes():
+ brush.set_shapes(shapes)
+
+ _update_shapes_gui(shapes)
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ # Testing the scratchpad because visibility can not only change before entering the tree
+ # since Godot 4 port, it can also change between entering the tree and being _ready...
+ if visible and _scratchpad != null:
+ _update_controls_from_brush()
+
+
+func _update_controls_from_brush():
+ var brush := _brush
+
+ if brush == null:
+ # To allow testing
+ brush = _scratchpad.get_painter().get_brush()
+
+ _update_shapes_gui(brush.get_shapes())
+
+ _size_slider.set_value(brush.get_size(), false)
+ _opacity_slider.set_value(brush.get_opacity() * 100.0, false)
+ _pressure_enabled_checkbox.button_pressed = brush.is_pressure_enabled()
+ _pressure_over_size_slider.set_value(brush.get_pressure_over_scale() * 100.0, false)
+ _pressure_over_opacity_slider.set_value(brush.get_pressure_over_opacity() * 100.0, false)
+ _frequency_distance_slider.set_value(brush.get_frequency_distance(), false)
+ _frequency_time_slider.set_value(
+ 1000.0 / maxf(0.1, float(brush.get_frequency_time_ms())), false)
+ _random_rotation_checkbox.button_pressed = brush.is_random_rotation_enabled()
+ _shape_cycling_checkbox.button_pressed = brush.is_shape_cycling_enabled()
+
+
+func _on_ClearScratchpad_pressed():
+ _scratchpad.reset_image()
+
+
+func _on_Size_value_changed(value: float):
+ for brush in _get_brushes():
+ brush.set_size(value)
+
+
+func _on_Opacity_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_opacity(value / 100.0)
+
+
+func _on_PressureEnabled_toggled(button_pressed):
+ for brush in _get_brushes():
+ brush.set_pressure_enabled(button_pressed)
+
+
+func _on_PressureOverSize_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_pressure_over_scale(value / 100.0)
+
+
+func _on_PressureOverOpacity_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_pressure_over_opacity(value / 100.0)
+
+
+func _on_FrequencyDistance_value_changed(value):
+ for brush in _get_brushes():
+ brush.set_frequency_distance(value)
+
+
+func _on_FrequencyTime_value_changed(fps):
+ fps = max(1.0, fps)
+ var ms = 1000.0 / fps
+ if is_equal_approx(fps, 60.0):
+ ms = 0
+ for brush in _get_brushes():
+ brush.set_frequency_time_ms(ms)
+
+
+func _on_RandomRotation_toggled(button_pressed: bool):
+ for brush in _get_brushes():
+ brush.set_random_rotation_enabled(button_pressed)
+
+
+func _on_shape_cycling_toggled(button_pressed: bool):
+ for brush in _get_brushes():
+ brush.set_shape_cycling_enabled(button_pressed)
+
+
+func _get_brushes() -> Array[HT_Brush]:
+ if _brush != null:
+ # We edit both the preview brush and the terrain brush
+ # TODO Could we simply share the brush?
+ return [_brush, _scratchpad.get_painter().get_brush()]
+ # When testing the dialog in isolation, the edited brush might be null
+ return [_scratchpad.get_painter().get_brush()]
+
+
+func _on_ShapeList_item_selected(index):
+ _update_shape_list_buttons()
+ for brush in _get_brushes():
+ brush.set_shape_index(index)
+
+
+func _update_shape_list_buttons():
+ var selected_count := len(_shape_list.get_selected_items())
+ # There must be at least one shape
+ _remove_shape_button.disabled = _shape_list.get_item_count() == 1 or selected_count == 0
+ _change_shape_button.disabled = selected_count == 0
+
+
+func _on_shape_list_empty_clicked(at_position, mouse_button_index):
+ _update_shape_list_buttons()
+
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn
new file mode 100644
index 0000000..7d7e07d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.tscn
@@ -0,0 +1,211 @@
+[gd_scene load_steps=4 format=3 uid="uid://d2rt3wj8xkhp2"]
+
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/spin_slider.tscn" id="2"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/brush_settings_dialog.gd" id="3"]
+[ext_resource type="PackedScene" uid="uid://ng00jipfeucy" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn" id="4"]
+
+[node name="BrushSettingsDialog" type="AcceptDialog"]
+title = "Brush settings"
+size = Vector2i(700, 422)
+min_size = Vector2i(700, 400)
+script = ExtResource("3")
+
+[node name="VB" type="VBoxContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -49.0
+
+[node name="HB" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="VB" type="VBoxContainer" parent="VB/HB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Label" type="Label" parent="VB/HB/VB"]
+layout_mode = 2
+text = "Shapes"
+
+[node name="ShapeList" type="ItemList" parent="VB/HB/VB"]
+layout_mode = 2
+size_flags_vertical = 3
+fixed_icon_size = Vector2i(100, 100)
+
+[node name="ChangeShape" type="Button" parent="VB/HB/VB"]
+layout_mode = 2
+disabled = true
+text = "Change..."
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VB/HB/VB"]
+layout_mode = 2
+
+[node name="AddShape" type="Button" parent="VB/HB/VB/HBoxContainer"]
+layout_mode = 2
+text = "Add..."
+
+[node name="RemoveShape" type="Button" parent="VB/HB/VB/HBoxContainer"]
+layout_mode = 2
+disabled = true
+text = "Remove"
+
+[node name="VB2" type="VBoxContainer" parent="VB/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="VB/HB/VB2"]
+layout_mode = 2
+
+[node name="Settings" type="VBoxContainer" parent="VB/HB/VB2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Size" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+size_flags_horizontal = 3
+value = 32.0
+min_value = 2.0
+max_value = 500.0
+prefix = "Size:"
+suffix = "px"
+rounded = true
+centered = true
+allow_greater = true
+greater_max_value = 4000.0
+
+[node name="Opacity" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+size_flags_horizontal = 3
+value = 100.0
+max_value = 100.0
+prefix = "Opacity"
+suffix = "%"
+rounded = true
+centered = true
+greater_max_value = 10000.0
+
+[node name="PressureEnabled" type="CheckBox" parent="VB/HB/VB2/Settings"]
+layout_mode = 2
+text = "Enable pressure (pen tablets)"
+
+[node name="PressureOverSize" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+value = 50.0
+max_value = 100.0
+prefix = "Pressure affects size:"
+suffix = "%"
+centered = true
+greater_max_value = 10000.0
+
+[node name="PressureOverOpacity" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+value = 50.0
+max_value = 100.0
+prefix = "Pressure affects opacity:"
+suffix = "%"
+centered = true
+greater_max_value = 10000.0
+
+[node name="FrequencyTime" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+value = 60.0
+min_value = 1.0
+max_value = 60.0
+prefix = "Frequency time:"
+suffix = "fps"
+centered = true
+greater_max_value = 10000.0
+
+[node name="FrequencyDistance" parent="VB/HB/VB2/Settings" instance=ExtResource("2")]
+custom_minimum_size = Vector2(32, 28)
+layout_mode = 2
+max_value = 100.0
+prefix = "Frequency distance:"
+suffix = "px"
+centered = true
+greater_max_value = 4000.0
+
+[node name="RandomRotation" type="CheckBox" parent="VB/HB/VB2/Settings"]
+layout_mode = 2
+text = "Random rotation"
+
+[node name="ShapeCycling" type="CheckBox" parent="VB/HB/VB2/Settings"]
+layout_mode = 2
+text = "Shape cycling"
+
+[node name="HSeparator" type="HSeparator" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="SizeLimitHB" type="HBoxContainer" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/HB/VB2/Settings/SizeLimitHB"]
+layout_mode = 2
+mouse_filter = 0
+text = "Size limit:"
+
+[node name="SizeLimit" type="SpinBox" parent="VB/HB/VB2/Settings/SizeLimitHB"]
+layout_mode = 2
+size_flags_horizontal = 3
+min_value = 1.0
+max_value = 1000.0
+value = 200.0
+
+[node name="HSeparator2" type="HSeparator" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="VB/HB/VB2/Settings"]
+visible = false
+layout_mode = 2
+
+[node name="Button" type="Button" parent="VB/HB/VB2/Settings/HB"]
+layout_mode = 2
+text = "Load preset..."
+
+[node name="Button2" type="Button" parent="VB/HB/VB2/Settings/HB"]
+layout_mode = 2
+text = "Save preset..."
+
+[node name="VB3" type="VBoxContainer" parent="VB/HB"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/HB/VB3"]
+layout_mode = 2
+text = "Scratchpad"
+
+[node name="PreviewScratchpad" parent="VB/HB/VB3" instance=ExtResource("4")]
+custom_minimum_size = Vector2(200, 300)
+layout_mode = 2
+
+[node name="ClearScratchpad" type="Button" parent="VB/HB/VB3"]
+layout_mode = 2
+text = "Clear"
+
+[connection signal="empty_clicked" from="VB/HB/VB/ShapeList" to="." method="_on_shape_list_empty_clicked"]
+[connection signal="item_activated" from="VB/HB/VB/ShapeList" to="." method="_on_ShapeList_item_activated"]
+[connection signal="item_selected" from="VB/HB/VB/ShapeList" to="." method="_on_ShapeList_item_selected"]
+[connection signal="pressed" from="VB/HB/VB/ChangeShape" to="." method="_on_ChangeShape_pressed"]
+[connection signal="pressed" from="VB/HB/VB/HBoxContainer/AddShape" to="." method="_on_AddShape_pressed"]
+[connection signal="pressed" from="VB/HB/VB/HBoxContainer/RemoveShape" to="." method="_on_RemoveShape_pressed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/Size" to="." method="_on_Size_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/Opacity" to="." method="_on_Opacity_value_changed"]
+[connection signal="toggled" from="VB/HB/VB2/Settings/PressureEnabled" to="." method="_on_PressureEnabled_toggled"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/PressureOverSize" to="." method="_on_PressureOverSize_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/PressureOverOpacity" to="." method="_on_PressureOverOpacity_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/FrequencyTime" to="." method="_on_FrequencyTime_value_changed"]
+[connection signal="value_changed" from="VB/HB/VB2/Settings/FrequencyDistance" to="." method="_on_FrequencyDistance_value_changed"]
+[connection signal="toggled" from="VB/HB/VB2/Settings/RandomRotation" to="." method="_on_RandomRotation_toggled"]
+[connection signal="toggled" from="VB/HB/VB2/Settings/ShapeCycling" to="." method="_on_shape_cycling_toggled"]
+[connection signal="pressed" from="VB/HB/VB3/ClearScratchpad" to="." method="_on_ClearScratchpad_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd
new file mode 100644
index 0000000..be52072
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd
@@ -0,0 +1,41 @@
+@tool
+extends Node
+
+const HT_Painter = preload("./../painter.gd")
+const HT_Brush = preload("./../brush.gd")
+
+const HT_ColorShader = preload("../shaders/color.gdshader")
+
+var _painter : HT_Painter
+var _brush : HT_Brush
+
+
+func _init():
+ var p = HT_Painter.new()
+ # The name is just for debugging
+ p.set_name("Painter")
+ add_child(p)
+ _painter = p
+
+ _brush = HT_Brush.new()
+
+
+func set_image_texture(image: Image, texture: ImageTexture):
+ _painter.set_image(image, texture)
+
+
+func get_brush() -> HT_Brush:
+ return _brush
+
+
+# This may be called from an `_input` callback
+func paint_input(position: Vector2, pressure: float):
+ var p : HT_Painter = _painter
+
+ if not _brush.configure_paint_input([p], position, pressure):
+ return
+
+ p.set_brush_shader(HT_ColorShader)
+ p.set_brush_shader_param("u_color", Color(0,0,0,1))
+ #p.set_image(_image, _texture)
+ p.paint_input(position)
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd
new file mode 100644
index 0000000..cec0728
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd
@@ -0,0 +1,70 @@
+@tool
+extends Control
+
+const HT_PreviewPainter = preload("./preview_painter.gd")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_DefaultBrushTexture = preload("../shapes/round2.exr")
+const HT_Brush = preload("../brush.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_Util = preload("../../../util/util.gd")
+
+@onready var _texture_rect : TextureRect = $TextureRect
+@onready var _painter : HT_PreviewPainter = $Painter
+
+var _logger := HT_Logger.get_for(self)
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ # If it runs in the edited scene,
+ # saving the scene would also save the ImageTexture in it...
+ return
+ reset_image()
+ # Default so it doesn't crash when painting and can be tested
+ var default_brush_texture = \
+ HT_EditorUtil.load_texture(HT_Brush.DEFAULT_BRUSH_TEXTURE_PATH, _logger)
+ _painter.get_brush().set_shapes([default_brush_texture])
+
+
+func reset_image():
+ var image = Image.create(_texture_rect.size.x, _texture_rect.size.y, false, Image.FORMAT_RGB8)
+ image.fill(Color(1,1,1))
+
+ # TEST
+# var fnl = FastNoiseLite.new()
+# for y in image.get_height():
+# for x in image.get_width():
+# var g = 0.5 + 0.5 * fnl.get_noise_2d(x, y)
+# image.set_pixel(x, y, Color(g, g, g, 1.0))
+
+ var texture = ImageTexture.create_from_image(image)
+ _texture_rect.texture = texture
+ _painter.set_image_texture(image, texture)
+
+
+func get_painter() -> HT_PreviewPainter:
+ return _painter
+
+
+func _gui_input(event):
+ if event is InputEventMouseMotion:
+ if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
+ _painter.paint_input(event.position, event.pressure)
+ queue_redraw()
+
+ elif event is InputEventMouseButton:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ if event.pressed:
+ # TODO `pressure` is not available on button events
+ # So I have to assume zero... which means clicks do not paint anything?
+ _painter.paint_input(event.position, 0.0)
+ else:
+ _painter.get_brush().on_paint_end()
+
+
+func _draw():
+ var mpos = get_local_mouse_position()
+ var brush = _painter.get_brush()
+ draw_arc(mpos, 0.5 * brush.get_size(), -PI, PI, 32, Color(1, 0.2, 0.2), 2.0, true)
+
diff --git a/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn
new file mode 100644
index 0000000..0b50c91
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.tscn
@@ -0,0 +1,22 @@
+[gd_scene load_steps=3 format=3 uid="uid://ng00jipfeucy"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/preview_scratchpad.gd" id="1"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/brush/settings_dialog/preview_painter.gd" id="2"]
+
+[node name="PreviewScratchpad" type="Control"]
+clip_contents = true
+layout_mode = 3
+anchors_preset = 0
+offset_right = 200.0
+offset_bottom = 274.0
+script = ExtResource("1")
+
+[node name="Painter" type="Node" parent="."]
+script = ExtResource("2")
+
+[node name="TextureRect" type="TextureRect" parent="."]
+show_behind_parent = true
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+stretch_mode = 5
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader
new file mode 100644
index 0000000..f8a05c5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/alpha.gdshader
@@ -0,0 +1,19 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_value = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec4 src = texture(u_src_texture, get_src_uv(SCREEN_UV));
+ COLOR = vec4(src.rgb, mix(src.a, u_value, brush_value));
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader
new file mode 100644
index 0000000..eec65f5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/color.gdshader
@@ -0,0 +1,68 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_color = vec4(1.0);
+uniform sampler2D u_heightmap;
+uniform float u_normal_min_y = 0.0;
+uniform float u_normal_max_y = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+vec3 get_normal(sampler2D heightmap, vec2 pos) {
+ vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0));
+ float hnx = get_height(heightmap, pos + vec2(-ps.x, 0.0));
+ float hpx = get_height(heightmap, pos + vec2(ps.x, 0.0));
+ float hny = get_height(heightmap, pos + vec2(0.0, -ps.y));
+ float hpy = get_height(heightmap, pos + vec2(0.0, ps.y));
+ return normalize(vec3(hnx - hpx, 2.0, hpy - hny));
+}
+
+// Limits painting based on the slope, with a bit of falloff
+float apply_slope_limit(float brush_value, vec3 normal, float normal_min_y, float normal_max_y) {
+ float normal_falloff = 0.2;
+
+ // If an edge is at min/max, make sure it won't be affected by falloff
+ normal_min_y = normal_min_y <= 0.0 ? -2.0 : normal_min_y;
+ normal_max_y = normal_max_y >= 1.0 ? 2.0 : normal_max_y;
+
+ brush_value *= 1.0 - smoothstep(
+ normal_max_y - normal_falloff,
+ normal_max_y + normal_falloff, normal.y);
+
+ brush_value *= smoothstep(
+ normal_min_y - normal_falloff,
+ normal_min_y + normal_falloff, normal.y);
+
+ return brush_value;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec3 normal = get_normal(u_heightmap, src_uv);
+ brush_value = apply_slope_limit(brush_value, normal, u_normal_min_y, u_normal_max_y);
+
+ vec4 src = texture(u_src_texture, src_uv);
+
+ // Despite hints, albedo textures render darker.
+ // Trying to undo sRGB does not work because of 8-bit precision loss
+ // that would occur either in texture, or on the source image.
+ // So it's not possible to use viewports to paint albedo...
+ //src.rgb = pow(src.rgb, vec3(0.4545));
+
+ vec4 col = vec4(mix(src.rgb, u_color.rgb, brush_value), src.a);
+ COLOR = col;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader
new file mode 100644
index 0000000..669f4d7
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/erode.gdshader
@@ -0,0 +1,64 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_color = vec4(1.0);
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+// float get_noise(vec2 pos) {
+// return fract(sin(dot(pos.xy ,vec2(12.9898,78.233))) * 43758.5453);
+// }
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+float erode(sampler2D heightmap, vec2 uv, vec2 pixel_size, float weight) {
+ float r = 3.0;
+
+ // Divide so the shader stays neighbor dependent 1 pixel across.
+ // For this to work, filtering must be enabled.
+ vec2 eps = pixel_size / (0.99 * r);
+
+ float h = get_height(heightmap, uv);
+ float eh = h;
+ //float dh = h;
+
+ // Morphology with circular structuring element
+ for (float y = -r; y <= r; ++y) {
+ for (float x = -r; x <= r; ++x) {
+
+ vec2 p = vec2(x, y);
+ float nh = get_height(heightmap, uv + p * eps);
+
+ float s = max(length(p) - r, 0);
+ eh = min(eh, nh + s);
+
+ //s = min(r - length(p), 0);
+ //dh = max(dh, nh + s);
+ }
+ }
+
+ eh = mix(h, eh, weight);
+ //dh = mix(h, dh, u_weight);
+
+ float ph = eh;//mix(eh, dh, u_dilation);
+
+ return ph;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+ vec2 src_pixel_size = 1.0 / vec2(textureSize(u_src_texture, 0));
+ float ph = erode(u_src_texture, get_src_uv(SCREEN_UV), src_pixel_size, brush_value);
+ //ph += brush_value * 0.35;
+ COLOR = encode_height_to_viewport(ph);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader
new file mode 100644
index 0000000..c51f03a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/flatten.gdshader
@@ -0,0 +1,22 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_flatten_value;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ float src_h = sample_heightmap(u_src_texture, get_src_uv(SCREEN_UV));
+ float h = mix(src_h, u_flatten_value, brush_value);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader
new file mode 100644
index 0000000..4721b43
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/level.gdshader
@@ -0,0 +1,45 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_factor = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+// TODO Could actually level to whatever height the brush was at the beginning of the stroke?
+
+void fragment() {
+ float brush_value = u_factor * u_opacity * texture(TEXTURE, UV).r;
+
+ // The heightmap does not have mipmaps,
+ // so we need to use an approximation of average.
+ // This is not a very good one though...
+ float dst_h = 0.0;
+ vec2 uv_min = vec2(u_src_rect.xy);
+ vec2 uv_max = vec2(u_src_rect.xy + u_src_rect.zw);
+ for (int i = 0; i < 5; ++i) {
+ for (int j = 0; j < 5; ++j) {
+ float x = mix(uv_min.x, uv_max.x, float(i) / 4.0);
+ float y = mix(uv_min.y, uv_max.y, float(j) / 4.0);
+ float h = get_height(u_src_texture, vec2(x, y));
+ dst_h += h;
+ }
+ }
+ dst_h /= (5.0 * 5.0);
+
+ // TODO I have no idea if this will check out
+ float src_h = get_height(u_src_texture, get_src_uv(SCREEN_UV));
+ float h = mix(src_h, dst_h, brush_value);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader
new file mode 100644
index 0000000..10ee982
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/raise.gdshader
@@ -0,0 +1,22 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_factor = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_factor * u_opacity * texture(TEXTURE, UV).r;
+
+ float src_h = sample_heightmap(u_src_texture, get_src_uv(SCREEN_UV));
+ float h = src_h + brush_value;
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader
new file mode 100644
index 0000000..27123e4
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/smooth.gdshader
@@ -0,0 +1,34 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform float u_factor = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+void fragment() {
+ float brush_value = u_factor * u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_pixel_size = 1.0 / vec2(textureSize(u_src_texture, 0));
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec2 offset = src_pixel_size;
+ float src_nx = get_height(u_src_texture, src_uv - vec2(offset.x, 0.0));
+ float src_px = get_height(u_src_texture, src_uv + vec2(offset.x, 0.0));
+ float src_ny = get_height(u_src_texture, src_uv - vec2(0.0, offset.y));
+ float src_py = get_height(u_src_texture, src_uv + vec2(0.0, offset.y));
+ float src_h = get_height(u_src_texture, src_uv);
+ float dst_h = (src_h + src_nx + src_px + src_ny + src_py) * 0.2;
+ float h = mix(src_h, dst_h, brush_value);
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader
new file mode 100644
index 0000000..68ebaa8
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/splat16.gdshader
@@ -0,0 +1,81 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_splat = vec4(1.0, 0.0, 0.0, 0.0);
+uniform sampler2D u_other_splatmap_1;
+uniform sampler2D u_other_splatmap_2;
+uniform sampler2D u_other_splatmap_3;
+uniform sampler2D u_heightmap;
+uniform float u_normal_min_y = 0.0;
+uniform float u_normal_max_y = 1.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float sum(vec4 v) {
+ return v.x + v.y + v.z + v.w;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+vec3 get_normal(sampler2D heightmap, vec2 pos) {
+ vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0));
+ float hnx = get_height(heightmap, pos + vec2(-ps.x, 0.0));
+ float hpx = get_height(heightmap, pos + vec2(ps.x, 0.0));
+ float hny = get_height(heightmap, pos + vec2(0.0, -ps.y));
+ float hpy = get_height(heightmap, pos + vec2(0.0, ps.y));
+ return normalize(vec3(hnx - hpx, 2.0, hpy - hny));
+}
+
+// Limits painting based on the slope, with a bit of falloff
+float apply_slope_limit(float brush_value, vec3 normal, float normal_min_y, float normal_max_y) {
+ float normal_falloff = 0.2;
+
+ // If an edge is at min/max, make sure it won't be affected by falloff
+ normal_min_y = normal_min_y <= 0.0 ? -2.0 : normal_min_y;
+ normal_max_y = normal_max_y >= 1.0 ? 2.0 : normal_max_y;
+
+ brush_value *= 1.0 - smoothstep(
+ normal_max_y - normal_falloff,
+ normal_max_y + normal_falloff, normal.y);
+
+ brush_value *= smoothstep(
+ normal_min_y - normal_falloff,
+ normal_min_y + normal_falloff, normal.y);
+
+ return brush_value;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec3 normal = get_normal(u_heightmap, src_uv);
+ brush_value = apply_slope_limit(brush_value, normal, u_normal_min_y, u_normal_max_y);
+
+ // It is assumed 3 other renders are done the same with the other 3
+ vec4 src0 = texture(u_src_texture, src_uv);
+ vec4 src1 = texture(u_other_splatmap_1, src_uv);
+ vec4 src2 = texture(u_other_splatmap_2, src_uv);
+ vec4 src3 = texture(u_other_splatmap_3, src_uv);
+ float t = brush_value;
+ vec4 s0 = mix(src0, u_splat, t);
+ vec4 s1 = mix(src1, vec4(0.0), t);
+ vec4 s2 = mix(src2, vec4(0.0), t);
+ vec4 s3 = mix(src3, vec4(0.0), t);
+ float sum = sum(s0) + sum(s1) + sum(s2) + sum(s3);
+ s0 /= sum;
+ s1 /= sum;
+ s2 /= sum;
+ s3 /= sum;
+ COLOR = s0;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader
new file mode 100644
index 0000000..1291dbd
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/splat4.gdshader
@@ -0,0 +1,63 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform vec4 u_splat = vec4(1.0, 0.0, 0.0, 0.0);
+uniform sampler2D u_heightmap;
+uniform float u_normal_min_y = 0.0;
+uniform float u_normal_max_y = 1.0;
+//uniform float u_normal_falloff = 0.0;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+vec3 get_normal(sampler2D heightmap, vec2 pos) {
+ vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0));
+ float hnx = get_height(heightmap, pos + vec2(-ps.x, 0.0));
+ float hpx = get_height(heightmap, pos + vec2(ps.x, 0.0));
+ float hny = get_height(heightmap, pos + vec2(0.0, -ps.y));
+ float hpy = get_height(heightmap, pos + vec2(0.0, ps.y));
+ return normalize(vec3(hnx - hpx, 2.0, hpy - hny));
+}
+
+// Limits painting based on the slope, with a bit of falloff
+float apply_slope_limit(float brush_value, vec3 normal, float normal_min_y, float normal_max_y) {
+ float normal_falloff = 0.2;
+
+ // If an edge is at min/max, make sure it won't be affected by falloff
+ normal_min_y = normal_min_y <= 0.0 ? -2.0 : normal_min_y;
+ normal_max_y = normal_max_y >= 1.0 ? 2.0 : normal_max_y;
+
+ brush_value *= 1.0 - smoothstep(
+ normal_max_y - normal_falloff,
+ normal_max_y + normal_falloff, normal.y);
+
+ brush_value *= smoothstep(
+ normal_min_y - normal_falloff,
+ normal_min_y + normal_falloff, normal.y);
+
+ return brush_value;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec3 normal = get_normal(u_heightmap, src_uv);
+ brush_value = apply_slope_limit(brush_value, normal, u_normal_min_y, u_normal_max_y);
+
+ vec4 src_splat = texture(u_src_texture, src_uv);
+ vec4 s = mix(src_splat, u_splat, brush_value);
+ s = s / (s.r + s.g + s.b + s.a);
+ COLOR = s;
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader b/game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader
new file mode 100644
index 0000000..9828068
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.gdshader
@@ -0,0 +1,89 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+uniform sampler2D u_src_texture;
+uniform vec4 u_src_rect;
+uniform float u_opacity = 1.0;
+uniform int u_texture_index;
+uniform int u_mode; // 0: output index, 1: output weight
+uniform sampler2D u_index_map;
+uniform sampler2D u_weight_map;
+
+vec2 get_src_uv(vec2 screen_uv) {
+ vec2 uv = u_src_rect.xy + screen_uv * u_src_rect.zw;
+ return uv;
+}
+
+void fragment() {
+ float brush_value = u_opacity * texture(TEXTURE, UV).r;
+
+ vec2 src_uv = get_src_uv(SCREEN_UV);
+ vec4 iv = texture(u_index_map, src_uv);
+ vec4 wv = texture(u_weight_map, src_uv);
+
+ float i[3] = {iv.r, iv.g, iv.b};
+ float w[3] = {wv.r, wv.g, wv.b};
+
+ if (brush_value > 0.0) {
+ float texture_index_f = float(u_texture_index) / 255.0;
+ int ci = u_texture_index % 3;
+
+ float cm[3] = {-1.0, -1.0, -1.0};
+ cm[ci] = 1.0;
+
+ // Decompress third weight to make computations easier
+ w[2] = 1.0 - w[0] - w[1];
+
+ if (abs(i[ci] - texture_index_f) > 0.001) {
+ // Pixel does not have our texture index,
+ // transfer its weight to other components first
+ if (w[ci] > brush_value) {
+ w[0] -= cm[0] * brush_value;
+ w[1] -= cm[1] * brush_value;
+ w[2] -= cm[2] * brush_value;
+
+ } else if (w[ci] >= 0.f) {
+ w[ci] = 0.f;
+ i[ci] = texture_index_f;
+ }
+
+ } else {
+ // Pixel has our texture index, increase its weight
+ if (w[ci] + brush_value < 1.f) {
+ w[0] += cm[0] * brush_value;
+ w[1] += cm[1] * brush_value;
+ w[2] += cm[2] * brush_value;
+
+ } else {
+ // Pixel weight is full, we can set all components to the same index.
+ // Need to nullify other weights because they would otherwise never reach
+ // zero due to normalization
+ w[0] = 0.0;
+ w[1] = 0.0;
+ w[2] = 0.0;
+
+ w[ci] = 1.0;
+
+ i[0] = texture_index_f;
+ i[1] = texture_index_f;
+ i[2] = texture_index_f;
+ }
+ }
+
+ w[0] = clamp(w[0], 0.0, 1.0);
+ w[1] = clamp(w[1], 0.0, 1.0);
+ w[2] = clamp(w[2], 0.0, 1.0);
+
+ // Renormalize
+ float sum = w[0] + w[1] + w[2];
+ w[0] /= sum;
+ w[1] /= sum;
+ w[2] /= sum;
+ }
+
+ if (u_mode == 0) {
+ COLOR = vec4(i[0], i[1], i[2], 1.0);
+ } else {
+ COLOR = vec4(w[0], w[1], w[2], 1.0);
+ }
+}
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr
new file mode 100644
index 0000000..14b66d6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import
new file mode 100644
index 0000000..c547891
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cua2gxgj2pum5"
+path="res://.godot/imported/acrylic1.exr-8a4b622f104c607118d296791ee118f3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr"
+dest_files=["res://.godot/imported/acrylic1.exr-8a4b622f104c607118d296791ee118f3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr
new file mode 100644
index 0000000..e91d97e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import
new file mode 100644
index 0000000..6308ee6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dekau2j7fx14d"
+path="res://.godot/imported/round0.exr-fc6d691e8892911b1b4496769ee75dbb.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round0.exr"
+dest_files=["res://.godot/imported/round0.exr-fc6d691e8892911b1b4496769ee75dbb.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr
new file mode 100644
index 0000000..f6931ba
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import
new file mode 100644
index 0000000..e64db1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bbo5hotdg6mg7"
+path="res://.godot/imported/round1.exr-8050cfbed31968e6ce8bd055fbaa6897.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round1.exr"
+dest_files=["res://.godot/imported/round1.exr-8050cfbed31968e6ce8bd055fbaa6897.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr
new file mode 100644
index 0000000..477ab7e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import
new file mode 100644
index 0000000..6038a8e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://tn1ww3c47pwy"
+path="res://.godot/imported/round2.exr-2a843db3bf131f2b2f5964ce65600f42.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round2.exr"
+dest_files=["res://.godot/imported/round2.exr-2a843db3bf131f2b2f5964ce65600f42.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr
new file mode 100644
index 0000000..b466f92
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import
new file mode 100644
index 0000000..e8f2107
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://baim7e27k13r4"
+path="res://.godot/imported/round3.exr-77a9cdd9a592eb6010dc1db702d42c3a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/round3.exr"
+dest_files=["res://.godot/imported/round3.exr-77a9cdd9a592eb6010dc1db702d42c3a.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr
new file mode 100644
index 0000000..021947b
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import
new file mode 100644
index 0000000..85151ff
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cl6pxk6wr4hem"
+path="res://.godot/imported/smoke.exr-0061a0a2acdf1ca295ec547e4b8c920d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/smoke.exr"
+dest_files=["res://.godot/imported/smoke.exr-0061a0a2acdf1ca295ec547e4b8c920d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr
new file mode 100644
index 0000000..f456b77
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import
new file mode 100644
index 0000000..9226d03
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dqdkxiq52oo0j"
+path="res://.godot/imported/texture1.exr-0fac1840855f814972ea5666743101fc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/texture1.exr"
+dest_files=["res://.godot/imported/texture1.exr-0fac1840855f814972ea5666743101fc.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr
new file mode 100644
index 0000000..49d341e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import
new file mode 100644
index 0000000..90ba164
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://blljfgdlwdae5"
+path="res://.godot/imported/thing.exr-8e88d861fe83e5e870fa01faee694c73.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/thing.exr"
+dest_files=["res://.godot/imported/thing.exr-8e88d861fe83e5e870fa01faee694c73.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr
new file mode 100644
index 0000000..d65bc6e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import
new file mode 100644
index 0000000..2a1c46a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ca5nk2h4ukm0g"
+path="res://.godot/imported/vegetation1.exr-0573f4c73944e2dd8f3202b8930ac625.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr"
+dest_files=["res://.godot/imported/vegetation1.exr-0573f4c73944e2dd8f3202b8930ac625.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/game/addons/zylann.hterrain/tools/brush/terrain_painter.gd b/game/addons/zylann.hterrain/tools/brush/terrain_painter.gd
new file mode 100644
index 0000000..81891b6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/brush/terrain_painter.gd
@@ -0,0 +1,573 @@
+@tool
+extends Node
+
+const HT_Painter = preload("./painter.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Brush = preload("./brush.gd")
+
+const HT_RaiseShader = preload("./shaders/raise.gdshader")
+const HT_SmoothShader = preload("./shaders/smooth.gdshader")
+const HT_LevelShader = preload("./shaders/level.gdshader")
+const HT_FlattenShader = preload("./shaders/flatten.gdshader")
+const HT_ErodeShader = preload("./shaders/erode.gdshader")
+const HT_Splat4Shader = preload("./shaders/splat4.gdshader")
+const HT_Splat16Shader = preload("./shaders/splat16.gdshader")
+const HT_SplatIndexedShader = preload("./shaders/splat_indexed.gdshader")
+const HT_ColorShader = preload("./shaders/color.gdshader")
+const HT_AlphaShader = preload("./shaders/alpha.gdshader")
+
+const MODE_RAISE = 0
+const MODE_LOWER = 1
+const MODE_SMOOTH = 2
+const MODE_FLATTEN = 3
+const MODE_SPLAT = 4
+const MODE_COLOR = 5
+const MODE_MASK = 6
+const MODE_DETAIL = 7
+const MODE_LEVEL = 8
+const MODE_ERODE = 9
+const MODE_COUNT = 10
+
+class HT_ModifiedMap:
+ var map_type := 0
+ var map_index := 0
+ var painter_index := 0
+
+signal flatten_height_changed
+
+var _painters : Array[HT_Painter] = []
+
+var _brush := HT_Brush.new()
+
+var _color := Color(1, 0, 0, 1)
+var _mask_flag := false
+var _mode := MODE_RAISE
+var _flatten_height := 0.0
+var _detail_index := 0
+var _detail_density := 1.0
+var _texture_index := 0
+var _slope_limit_low_angle := 0.0
+var _slope_limit_high_angle := PI / 2.0
+
+var _modified_maps := []
+var _terrain : HTerrain
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ for i in 4:
+ var p := HT_Painter.new()
+ # The name is just for debugging
+ p.set_name(str("Painter", i))
+ #p.set_brush_size(_brush_size)
+ p.texture_region_changed.connect(_on_painter_texture_region_changed.bind(i))
+ add_child(p)
+ _painters.append(p)
+
+
+func get_brush() -> HT_Brush:
+ return _brush
+
+
+func get_brush_size() -> int:
+ return _brush.get_size()
+
+
+func set_brush_size(s: int):
+ _brush.set_size(s)
+# for p in _painters:
+# p.set_brush_size(_brush_size)
+
+
+func set_brush_texture(texture: Texture2D):
+ _brush.set_shapes([texture])
+# for p in _painters:
+# p.set_brush_texture(texture)
+
+
+func get_opacity() -> float:
+ return _brush.get_opacity()
+
+
+func set_opacity(opacity: float):
+ _brush.set_opacity(opacity)
+
+
+func set_flatten_height(h: float):
+ if h == _flatten_height:
+ return
+ _flatten_height = h
+ flatten_height_changed.emit()
+
+
+func get_flatten_height() -> float:
+ return _flatten_height
+
+
+func set_color(c: Color):
+ _color = c
+
+
+func get_color() -> Color:
+ return _color
+
+
+func set_mask_flag(m: bool):
+ _mask_flag = m
+
+
+func get_mask_flag() -> bool:
+ return _mask_flag
+
+
+func set_detail_density(d: float):
+ _detail_density = clampf(d, 0.0, 1.0)
+
+
+func get_detail_density() -> float:
+ return _detail_density
+
+
+func set_detail_index(di: int):
+ _detail_index = di
+
+
+func set_texture_index(i: int):
+ _texture_index = i
+
+
+func get_texture_index() -> int:
+ return _texture_index
+
+
+func get_slope_limit_low_angle() -> float:
+ return _slope_limit_low_angle
+
+
+func get_slope_limit_high_angle() -> float:
+ return _slope_limit_high_angle
+
+
+func set_slope_limit_angles(low: float, high: float):
+ _slope_limit_low_angle = low
+ _slope_limit_high_angle = high
+
+
+func is_operation_pending() -> bool:
+ for p in _painters:
+ if p.is_operation_pending():
+ return true
+ return false
+
+
+func has_modified_chunks() -> bool:
+ for p in _painters:
+ if p.has_modified_chunks():
+ return true
+ return false
+
+
+func get_undo_chunk_size() -> int:
+ return HT_Painter.UNDO_CHUNK_SIZE
+
+
+func commit() -> Dictionary:
+ assert(_terrain.get_data() != null)
+ var terrain_data = _terrain.get_data()
+ assert(not terrain_data.is_locked())
+
+ var changes := []
+ var chunk_positions : Array
+
+ assert(len(_modified_maps) > 0)
+
+ for mm in _modified_maps:
+ #print("Flushing painter ", mm.painter_index)
+ var painter : HT_Painter = _painters[mm.painter_index]
+ var info := painter.commit()
+
+ # Note, positions are always the same for each map
+ chunk_positions = info.chunk_positions
+
+ changes.append({
+ "map_type": mm.map_type,
+ "map_index": mm.map_index,
+ "chunk_initial_datas": info.chunk_initial_datas,
+ "chunk_final_datas": info.chunk_final_datas
+ })
+
+ var cs := get_undo_chunk_size()
+ for pos in info.chunk_positions:
+ var rect = Rect2(pos * cs, Vector2(cs, cs))
+ # This will update vertical bounds and notify normal map baker,
+ # since the latter updates out of order for preview
+ terrain_data.notify_region_change(rect, mm.map_type, mm.map_index, false, true)
+
+# for i in len(_painters):
+# var p = _painters[i]
+# if p.has_modified_chunks():
+# print("Painter ", i, " has modified chunks")
+
+ # `commit()` is supposed to consume these chunks, there should be none left
+ assert(not has_modified_chunks())
+
+ return {
+ "chunk_positions": chunk_positions,
+ "maps": changes
+ }
+
+
+func set_mode(mode: int):
+ assert(mode >= 0 and mode < MODE_COUNT)
+ _mode = mode
+
+
+func get_mode() -> int:
+ return _mode
+
+
+func set_terrain(terrain: HTerrain):
+ if terrain == _terrain:
+ return
+ _terrain = terrain
+ # It's important to release resources here,
+ # otherwise Godot keeps modified terrain maps in memory and "reloads" them like that
+ # next time we reopen the scene, even if we didn't save it
+ for p in _painters:
+ p.set_image(null, null)
+ p.clear_brush_shader_params()
+
+
+# This may be called from an `_input` callback.
+# Returns `true` if any change was performed.
+func paint_input(position: Vector2, pressure: float) -> bool:
+ assert(_terrain.get_data() != null)
+ var data := _terrain.get_data()
+ assert(not data.is_locked())
+
+ if not _brush.configure_paint_input(_painters, position, pressure):
+ # Sometimes painting may not happen due to frequency options
+ return false
+
+ _modified_maps.clear()
+
+ match _mode:
+ MODE_RAISE:
+ _paint_height(data, position, 1.0)
+
+ MODE_LOWER:
+ _paint_height(data, position, -1.0)
+
+ MODE_SMOOTH:
+ _paint_smooth(data, position)
+
+ MODE_FLATTEN:
+ _paint_flatten(data, position)
+
+ MODE_LEVEL:
+ _paint_level(data, position)
+
+ MODE_ERODE:
+ _paint_erode(data, position)
+
+ MODE_SPLAT:
+ # TODO Properly support what happens when painting outside of supported index
+ # var supported_slots_count := terrain.get_cached_ground_texture_slot_count()
+ # if _texture_index >= supported_slots_count:
+ # _logger.debug("Painting out of range of supported texture slots: {0}/{1}" \
+ # .format([_texture_index, supported_slots_count]))
+ # return
+ if _terrain.is_using_indexed_splatmap():
+ _paint_splat_indexed(data, position)
+ else:
+ var splatmap_count := _terrain.get_used_splatmaps_count()
+ match splatmap_count:
+ 1:
+ _paint_splat4(data, position)
+ 4:
+ _paint_splat16(data, position)
+
+ MODE_COLOR:
+ _paint_color(data, position)
+
+ MODE_MASK:
+ _paint_mask(data, position)
+
+ MODE_DETAIL:
+ _paint_detail(data, position)
+
+ _:
+ _logger.error("Unknown mode {0}".format([_mode]))
+
+ assert(len(_modified_maps) > 0)
+ return true
+
+
+func _on_painter_texture_region_changed(rect: Rect2, painter_index: int):
+ var data := _terrain.get_data()
+ if data == null:
+ return
+ for mm in _modified_maps:
+ if mm.painter_index == painter_index:
+ # This will tell auto-baked maps to update (like normals).
+ data.notify_region_change(rect, mm.map_type, mm.map_index, false, false)
+ break
+
+
+func _paint_height(data: HTerrainData, position: Vector2, factor: float):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ # When using sculpting tools, make it dependent on brush size
+ var raise_strength := 10.0 + float(_brush.get_size())
+ var delta := factor * (2.0 / 60.0) * raise_strength
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_RaiseShader)
+ p.set_brush_shader_param("u_factor", delta)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_smooth(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_SmoothShader)
+ p.set_brush_shader_param("u_factor", 1.0)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_flatten(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_FlattenShader)
+ p.set_brush_shader_param("u_flatten_value", _flatten_height)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_level(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_LevelShader)
+ p.set_brush_shader_param("u_factor", (10.0 / 60.0))
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_erode(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_HEIGHT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_ErodeShader)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_splat4(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_SPLAT)
+ var texture := data.get_texture(HTerrainData.CHANNEL_SPLAT, 0, true)
+ var heightmap_texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_SPLAT
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+ var splat := Color(0.0, 0.0, 0.0, 0.0)
+ splat[_texture_index] = 1.0;
+ p.set_brush_shader(HT_Splat4Shader)
+ p.set_brush_shader_param("u_splat", splat)
+ _set_slope_limit_shader_params(p, heightmap_texture)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_splat_indexed(data: HTerrainData, position: Vector2):
+ var map_types := [
+ HTerrainData.CHANNEL_SPLAT_INDEX,
+ HTerrainData.CHANNEL_SPLAT_WEIGHT
+ ]
+ _modified_maps = []
+
+ var textures := []
+ for mode in 2:
+ textures.append(data.get_texture(map_types[mode], 0, true))
+
+ for mode in 2:
+ var image := data.get_image(map_types[mode])
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = map_types[mode]
+ mm.map_index = 0
+ mm.painter_index = mode
+ _modified_maps.append(mm)
+
+ var p : HT_Painter = _painters[mode]
+
+ p.set_brush_shader(HT_SplatIndexedShader)
+ p.set_brush_shader_param("u_mode", mode)
+ p.set_brush_shader_param("u_index_map", textures[0])
+ p.set_brush_shader_param("u_weight_map", textures[1])
+ p.set_brush_shader_param("u_texture_index", _texture_index)
+ p.set_image(image, textures[mode])
+ p.paint_input(position)
+
+
+func _paint_splat16(data: HTerrainData, position: Vector2):
+ # Make sure required maps are present
+ while data.get_map_count(HTerrainData.CHANNEL_SPLAT) < 4:
+ data._edit_add_map(HTerrainData.CHANNEL_SPLAT)
+
+ var splats := []
+ for i in 4:
+ splats.append(Color(0.0, 0.0, 0.0, 0.0))
+ splats[_texture_index / 4][_texture_index % 4] = 1.0
+
+ var textures := []
+ for i in 4:
+ textures.append(data.get_texture(HTerrainData.CHANNEL_SPLAT, i, true))
+
+ var heightmap_texture := data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0)
+
+ for i in 4:
+ var image : Image = data.get_image(HTerrainData.CHANNEL_SPLAT, i)
+ var texture : Texture = textures[i]
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_SPLAT
+ mm.map_index = i
+ mm.painter_index = i
+ _modified_maps.append(mm)
+
+ var p : HT_Painter = _painters[i]
+
+ var other_splatmaps := []
+ for tex in textures:
+ if tex != texture:
+ other_splatmaps.append(tex)
+
+ p.set_brush_shader(HT_Splat16Shader)
+ p.set_brush_shader_param("u_splat", splats[i])
+ p.set_brush_shader_param("u_other_splatmap_1", other_splatmaps[0])
+ p.set_brush_shader_param("u_other_splatmap_2", other_splatmaps[1])
+ p.set_brush_shader_param("u_other_splatmap_3", other_splatmaps[2])
+ _set_slope_limit_shader_params(p, heightmap_texture)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_color(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_COLOR)
+ var texture := data.get_texture(HTerrainData.CHANNEL_COLOR, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_COLOR
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ # There was a problem with painting colors because of sRGB
+ # https://github.com/Zylann/godot_heightmap_plugin/issues/17#issuecomment-734001879
+
+ p.set_brush_shader(HT_ColorShader)
+ p.set_brush_shader_param("u_color", _color)
+ p.set_brush_shader_param("u_normal_min_y", 0.0)
+ p.set_brush_shader_param("u_normal_max_y", 1.0)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_mask(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_COLOR)
+ var texture := data.get_texture(HTerrainData.CHANNEL_COLOR, 0, true)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_COLOR
+ mm.map_index = 0
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+
+ p.set_brush_shader(HT_AlphaShader)
+ p.set_brush_shader_param("u_value", 1.0 if _mask_flag else 0.0)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _paint_detail(data: HTerrainData, position: Vector2):
+ var image := data.get_image(HTerrainData.CHANNEL_DETAIL, _detail_index)
+ var texture := data.get_texture(HTerrainData.CHANNEL_DETAIL, _detail_index, true)
+ var heightmap_texture = data.get_texture(HTerrainData.CHANNEL_HEIGHT, 0)
+
+ var mm := HT_ModifiedMap.new()
+ mm.map_type = HTerrainData.CHANNEL_DETAIL
+ mm.map_index = _detail_index
+ mm.painter_index = 0
+ _modified_maps = [mm]
+
+ var p : HT_Painter = _painters[0]
+ var c := Color(_detail_density, _detail_density, _detail_density, 1.0)
+
+ # TODO Don't use this shader (why?)
+ p.set_brush_shader(HT_ColorShader)
+ p.set_brush_shader_param("u_color", c)
+ _set_slope_limit_shader_params(p, heightmap_texture)
+ p.set_image(image, texture)
+ p.paint_input(position)
+
+
+func _set_slope_limit_shader_params(p: HT_Painter, heightmap_texture: Texture):
+ p.set_brush_shader_param("u_normal_min_y", cos(_slope_limit_high_angle))
+ p.set_brush_shader_param("u_normal_max_y", cos(_slope_limit_low_angle) + 0.001)
+ p.set_brush_shader_param("u_heightmap", heightmap_texture)