aboutsummaryrefslogtreecommitdiff
path: root/game
diff options
context:
space:
mode:
Diffstat (limited to 'game')
-rw-r--r--game/addons/zylann.hterrain/LICENSE.md11
-rw-r--r--game/addons/zylann.hterrain/doc/.gdignore0
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/alpha_blending_and_depth_blending.pngbin0 -> 320154 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/bad_array_blending.pngbin0 -> 122066 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/brush_editor.pngbin0 -> 3286 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/channel_packer.pngbin0 -> 16909 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/choose_bullet_physics.pngbin0 -> 31292 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/color_painting.pngbin0 -> 345984 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/data_directory_property.pngbin0 -> 8418 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/default_terrain.pngbin0 -> 12588 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/detail_layers.pngbin0 -> 739810 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/dilation.pngbin0 -> 55493 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/erosion_slope.pngbin0 -> 120726 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/erosion_steps.pngbin0 -> 203951 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/generator.pngbin0 -> 43813 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/gimp_png_preserve_colors.pngbin0 -> 8621 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/grass_models.pngbin0 -> 212007 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/hole_painting.pngbin0 -> 244074 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/ignore_tools_on_export.pngbin0 -> 13912 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/importer.pngbin0 -> 17043 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set.pngbin0 -> 256393 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set_save.pngbin0 -> 28115 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/lod_geometry.pngbin0 -> 414746 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/lookdev_grass.pngbin0 -> 741784 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/lookdev_menu.pngbin0 -> 230484 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/lots_of_textures_blending.pngbin0 -> 184093 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/low_poly.pngbin0 -> 233318 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/normalmap_conventions.pngbin0 -> 56589 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/overview.pngbin0 -> 436236 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/panel_import_button.pngbin0 -> 14146 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/panel_textures_edit_button.pngbin0 -> 28461 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/pbr_textures.pngbin0 -> 171987 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/resize_tool.pngbin0 -> 5403 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/sculpting_tools.pngbin0 -> 16396 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/single_sampling_and_triplanar_sampling.pngbin0 -> 220943 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/slope_limit_tool.pngbin0 -> 10864 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/splatmap_and_textured_result.pngbin0 -> 95955 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_array_import_dock.pngbin0 -> 9044 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_atlas_example.pngbin0 -> 47464 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_dialog.pngbin0 -> 53896 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_set_editor.pngbin0 -> 153980 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool.pngbin0 -> 45978 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_directory.pngbin0 -> 4929 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_success.pngbin0 -> 8047 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_texture_types.pngbin0 -> 152337 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/texture_slots.pngbin0 -> 6389 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/tiling_artifacts.pngbin0 -> 1525731 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/tiling_reduction.pngbin0 -> 1050281 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/transition_array_blending.pngbin0 -> 134901 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/update_editor_collider.pngbin0 -> 112581 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/images/warped_checker_variations.pngbin0 -> 835193 bytes
-rw-r--r--game/addons/zylann.hterrain/doc/docs/index.md1166
-rw-r--r--game/addons/zylann.hterrain/doc/mkdocs.yml11
-rw-r--r--game/addons/zylann.hterrain/doc/requirements.txt1
-rw-r--r--game/addons/zylann.hterrain/hterrain.gd1665
-rw-r--r--game/addons/zylann.hterrain/hterrain_chunk.gd125
-rw-r--r--game/addons/zylann.hterrain/hterrain_chunk_debug.gd67
-rw-r--r--game/addons/zylann.hterrain/hterrain_collider.gd118
-rw-r--r--game/addons/zylann.hterrain/hterrain_data.gd1843
-rw-r--r--game/addons/zylann.hterrain/hterrain_detail_layer.gd742
-rw-r--r--game/addons/zylann.hterrain/hterrain_mesher.gd358
-rw-r--r--game/addons/zylann.hterrain/hterrain_resource_loader.gd35
-rw-r--r--game/addons/zylann.hterrain/hterrain_resource_saver.gd29
-rw-r--r--game/addons/zylann.hterrain/hterrain_texture_set.gd253
-rw-r--r--game/addons/zylann.hterrain/models/grass_quad.obj.import21
-rw-r--r--game/addons/zylann.hterrain/models/grass_quad_x2.obj.import21
-rw-r--r--game/addons/zylann.hterrain/models/grass_quad_x3.obj.import21
-rw-r--r--game/addons/zylann.hterrain/models/grass_quad_x4.obj.import21
-rw-r--r--game/addons/zylann.hterrain/native/.clang-format127
-rw-r--r--game/addons/zylann.hterrain/native/.gitignore4
-rw-r--r--game/addons/zylann.hterrain/native/SConstruct119
-rw-r--r--game/addons/zylann.hterrain/native/factory.gd55
-rw-r--r--game/addons/zylann.hterrain/native/image_utils_generic.gd316
-rw-r--r--game/addons/zylann.hterrain/native/quad_tree_lod_generic.gd188
-rw-r--r--game/addons/zylann.hterrain/native/src/.gdignore0
-rw-r--r--game/addons/zylann.hterrain/native/src/gd_library.cpp30
-rw-r--r--game/addons/zylann.hterrain/native/src/image_utils.cpp364
-rw-r--r--game/addons/zylann.hterrain/native/src/image_utils.h38
-rw-r--r--game/addons/zylann.hterrain/native/src/int_range_2d.h59
-rw-r--r--game/addons/zylann.hterrain/native/src/math_funcs.h28
-rw-r--r--game/addons/zylann.hterrain/native/src/quad_tree_lod.cpp242
-rw-r--r--game/addons/zylann.hterrain/native/src/quad_tree_lod.h121
-rw-r--r--game/addons/zylann.hterrain/native/src/vector2i.h19
-rw-r--r--game/addons/zylann.hterrain/plugin.cfg7
-rw-r--r--game/addons/zylann.hterrain/shaders/array.gdshader170
-rw-r--r--game/addons/zylann.hterrain/shaders/array_global.gdshader87
-rw-r--r--game/addons/zylann.hterrain/shaders/detail.gdshader107
-rw-r--r--game/addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc66
-rw-r--r--game/addons/zylann.hterrain/shaders/include/heightmap_rgb8_encoding.gdshaderinc57
-rw-r--r--game/addons/zylann.hterrain/shaders/lookdev.gdshader71
-rw-r--r--game/addons/zylann.hterrain/shaders/low_poly.gdshader63
-rw-r--r--game/addons/zylann.hterrain/shaders/multisplat16.gdshader373
-rw-r--r--game/addons/zylann.hterrain/shaders/multisplat16_global.gdshader173
-rw-r--r--game/addons/zylann.hterrain/shaders/multisplat16_lite.gdshader254
-rw-r--r--game/addons/zylann.hterrain/shaders/simple4.gdshader329
-rw-r--r--game/addons/zylann.hterrain/shaders/simple4_global.gdshader83
-rw-r--r--game/addons/zylann.hterrain/shaders/simple4_lite.gdshader211
-rw-r--r--game/addons/zylann.hterrain/tools/about/about_dialog.gd28
-rw-r--r--game/addons/zylann.hterrain/tools/about/about_dialog.tscn85
-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
-rw-r--r--game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader25
-rw-r--r--game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd200
-rw-r--r--game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn48
-rw-r--r--game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd221
-rw-r--r--game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn125
-rw-r--r--game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd54
-rw-r--r--game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn69
-rw-r--r--game/addons/zylann.hterrain/tools/generator/generator_dialog.gd562
-rw-r--r--game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn84
-rw-r--r--game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader27
-rw-r--r--game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader76
-rw-r--r--game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader211
-rw-r--r--game/addons/zylann.hterrain/tools/generator/texture_generator.gd331
-rw-r--r--game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd43
-rw-r--r--game/addons/zylann.hterrain/tools/globalmap_baker.gd168
-rw-r--r--game/addons/zylann.hterrain/tools/icons/empty.pngbin0 -> 832 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/icons/empty.png.import34
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg65
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg90
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_grass.svg90
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg90
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg61
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg102
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg116
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg72
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg61
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg61
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg5
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg72
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg68
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg66
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg68
-rw-r--r--game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import37
-rw-r--r--game/addons/zylann.hterrain/tools/icons/white.pngbin0 -> 135 bytes
-rw-r--r--game/addons/zylann.hterrain/tools/icons/white.png.import35
-rw-r--r--game/addons/zylann.hterrain/tools/importer/importer_dialog.gd312
-rw-r--r--game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn87
-rw-r--r--game/addons/zylann.hterrain/tools/inspector/inspector.gd479
-rw-r--r--game/addons/zylann.hterrain/tools/inspector/inspector.tscn15
-rw-r--r--game/addons/zylann.hterrain/tools/load_texture_dialog.gd22
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap.gd140
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap.tscn56
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader24
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd24
-rw-r--r--game/addons/zylann.hterrain/tools/minimap/ratio_container.gd32
-rw-r--r--game/addons/zylann.hterrain/tools/normalmap_baker.gd148
-rw-r--r--game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd86
-rw-r--r--game/addons/zylann.hterrain/tools/panel.gd75
-rw-r--r--game/addons/zylann.hterrain/tools/panel.tscn58
-rw-r--r--game/addons/zylann.hterrain/tools/plugin.gd884
-rw-r--r--game/addons/zylann.hterrain/tools/preview_generator.gd65
-rw-r--r--game/addons/zylann.hterrain/tools/progress_window.gd32
-rw-r--r--game/addons/zylann.hterrain/tools/progress_window.tscn22
-rw-r--r--game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd174
-rw-r--r--game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn159
-rw-r--r--game/addons/zylann.hterrain/tools/terrain_preview.gd143
-rw-r--r--game/addons/zylann.hterrain/tools/terrain_preview.tscn38
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader6
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres9
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader9
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader7
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres6
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader9
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader28
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd41
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd60
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn29
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd529
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn194
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd920
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn218
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd134
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn48
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd79
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn20
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd72
-rw-r--r--game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn25
-rw-r--r--game/addons/zylann.hterrain/tools/util/dialog_fitter.gd53
-rw-r--r--game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn10
-rw-r--r--game/addons/zylann.hterrain/tools/util/editor_util.gd104
-rw-r--r--game/addons/zylann.hterrain/tools/util/interval_slider.gd197
-rw-r--r--game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd19
-rw-r--r--game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd9
-rw-r--r--game/addons/zylann.hterrain/tools/util/result.gd36
-rw-r--r--game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd11
-rw-r--r--game/addons/zylann.hterrain/tools/util/spin_slider.gd385
-rw-r--r--game/addons/zylann.hterrain/tools/util/spin_slider.tscn13
-rw-r--r--game/addons/zylann.hterrain/util/direct_mesh_instance.gd65
-rw-r--r--game/addons/zylann.hterrain/util/direct_multimesh_instance.gd48
-rw-r--r--game/addons/zylann.hterrain/util/errors.gd58
-rw-r--r--game/addons/zylann.hterrain/util/grid.gd203
-rw-r--r--game/addons/zylann.hterrain/util/image_file_cache.gd291
-rw-r--r--game/addons/zylann.hterrain/util/logger.gd34
-rw-r--r--game/addons/zylann.hterrain/util/util.gd549
-rw-r--r--game/addons/zylann.hterrain/util/xyz_format.gd109
-rw-r--r--game/project.godot2
-rw-r--r--game/src/Game/GameSession/MapView.tscn30
-rw-r--r--game/testing/21.gdshader76
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/.gitattributes2
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/.gitignore2
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/Cube.gd34
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/README.md3
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/Water.gd32
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exrbin0 -> 73949801 bytes
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exr.import35
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/assets/shaders/water.gdshader81
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/icon.svg1
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/icon.svg.import37
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/main.tscn143
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/project.godot25
-rw-r--r--game/testing/Buoyancy-in-Godot-4-master/water.tscn69
-rw-r--r--game/testing/dataft/color.pngbin0 -> 594391 bytes
-rw-r--r--game/testing/dataft/color.png.import34
-rw-r--r--game/testing/dataft/data.hterrain29
-rw-r--r--game/testing/dataft/height.resbin0 -> 16793982 bytes
-rw-r--r--game/testing/dataft/normal.pngbin0 -> 1122014 bytes
-rw-r--r--game/testing/dataft/normal.png.import34
-rw-r--r--game/testing/dataft/splat.pngbin0 -> 21249 bytes
-rw-r--r--game/testing/dataft/splat.png.import34
-rw-r--r--game/testing/exp.pngbin0 -> 306610 bytes
-rw-r--r--game/testing/exp.png.import34
-rw-r--r--game/testing/mountains-nowater.pngbin0 -> 21414765 bytes
-rw-r--r--game/testing/mountains-nowater.png.import34
-rw-r--r--game/testing/mountains.pngbin0 -> 19299874 bytes
-rw-r--r--game/testing/mountains.png.import34
-rw-r--r--game/testing/mountains1.pngbin0 -> 351478 bytes
-rw-r--r--game/testing/mountains1.png.import35
-rw-r--r--game/testing/mountains1.rawbin0 -> 67108864 bytes
-rw-r--r--game/testing/mountains1.raw.pal4
-rw-r--r--game/testing/terrain.pngbin0 -> 597514 bytes
-rw-r--r--game/testing/terrain.png.import34
-rw-r--r--game/testing/testing.gdshader11
-rw-r--r--game/testing/testing.tscn115
-rw-r--r--game/testing/water.gdshader80
-rw-r--r--game/testing/water.tscn69
318 files changed, 27809 insertions, 2 deletions
diff --git a/game/addons/zylann.hterrain/LICENSE.md b/game/addons/zylann.hterrain/LICENSE.md
new file mode 100644
index 0000000..a71a954
--- /dev/null
+++ b/game/addons/zylann.hterrain/LICENSE.md
@@ -0,0 +1,11 @@
+HeightMap terrain for Godot Engine
+------------------------------------
+
+Copyright (c) 2016-2023 Marc Gilleron
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
diff --git a/game/addons/zylann.hterrain/doc/.gdignore b/game/addons/zylann.hterrain/doc/.gdignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/.gdignore
diff --git a/game/addons/zylann.hterrain/doc/docs/images/alpha_blending_and_depth_blending.png b/game/addons/zylann.hterrain/doc/docs/images/alpha_blending_and_depth_blending.png
new file mode 100644
index 0000000..e26ecfa
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/alpha_blending_and_depth_blending.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/bad_array_blending.png b/game/addons/zylann.hterrain/doc/docs/images/bad_array_blending.png
new file mode 100644
index 0000000..7cf6d49
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/bad_array_blending.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/brush_editor.png b/game/addons/zylann.hterrain/doc/docs/images/brush_editor.png
new file mode 100644
index 0000000..6641352
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/brush_editor.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/channel_packer.png b/game/addons/zylann.hterrain/doc/docs/images/channel_packer.png
new file mode 100644
index 0000000..1edd264
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/channel_packer.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/choose_bullet_physics.png b/game/addons/zylann.hterrain/doc/docs/images/choose_bullet_physics.png
new file mode 100644
index 0000000..0ee70ae
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/choose_bullet_physics.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/color_painting.png b/game/addons/zylann.hterrain/doc/docs/images/color_painting.png
new file mode 100644
index 0000000..c8f7ad1
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/color_painting.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/data_directory_property.png b/game/addons/zylann.hterrain/doc/docs/images/data_directory_property.png
new file mode 100644
index 0000000..29ecfc7
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/data_directory_property.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/default_terrain.png b/game/addons/zylann.hterrain/doc/docs/images/default_terrain.png
new file mode 100644
index 0000000..106cedf
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/default_terrain.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/detail_layers.png b/game/addons/zylann.hterrain/doc/docs/images/detail_layers.png
new file mode 100644
index 0000000..c0e3975
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/detail_layers.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/dilation.png b/game/addons/zylann.hterrain/doc/docs/images/dilation.png
new file mode 100644
index 0000000..820dbd2
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/dilation.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/erosion_slope.png b/game/addons/zylann.hterrain/doc/docs/images/erosion_slope.png
new file mode 100644
index 0000000..698ac62
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/erosion_slope.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/erosion_steps.png b/game/addons/zylann.hterrain/doc/docs/images/erosion_steps.png
new file mode 100644
index 0000000..9498f70
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/erosion_steps.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/generator.png b/game/addons/zylann.hterrain/doc/docs/images/generator.png
new file mode 100644
index 0000000..44012b6
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/generator.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/gimp_png_preserve_colors.png b/game/addons/zylann.hterrain/doc/docs/images/gimp_png_preserve_colors.png
new file mode 100644
index 0000000..c1a1729
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/gimp_png_preserve_colors.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/grass_models.png b/game/addons/zylann.hterrain/doc/docs/images/grass_models.png
new file mode 100644
index 0000000..2e9370f
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/grass_models.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/hole_painting.png b/game/addons/zylann.hterrain/doc/docs/images/hole_painting.png
new file mode 100644
index 0000000..c9ad329
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/hole_painting.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/ignore_tools_on_export.png b/game/addons/zylann.hterrain/doc/docs/images/ignore_tools_on_export.png
new file mode 100644
index 0000000..702facd
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/ignore_tools_on_export.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/importer.png b/game/addons/zylann.hterrain/doc/docs/images/importer.png
new file mode 100644
index 0000000..84f372f
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/importer.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set.png b/game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set.png
new file mode 100644
index 0000000..3cca8f8
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set_save.png b/game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set_save.png
new file mode 100644
index 0000000..e4c48a1
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/inspector_texture_set_save.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/lod_geometry.png b/game/addons/zylann.hterrain/doc/docs/images/lod_geometry.png
new file mode 100644
index 0000000..4b898be
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/lod_geometry.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/lookdev_grass.png b/game/addons/zylann.hterrain/doc/docs/images/lookdev_grass.png
new file mode 100644
index 0000000..3c5c939
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/lookdev_grass.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/lookdev_menu.png b/game/addons/zylann.hterrain/doc/docs/images/lookdev_menu.png
new file mode 100644
index 0000000..17b61c8
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/lookdev_menu.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/lots_of_textures_blending.png b/game/addons/zylann.hterrain/doc/docs/images/lots_of_textures_blending.png
new file mode 100644
index 0000000..0ef702b
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/lots_of_textures_blending.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/low_poly.png b/game/addons/zylann.hterrain/doc/docs/images/low_poly.png
new file mode 100644
index 0000000..d518309
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/low_poly.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/normalmap_conventions.png b/game/addons/zylann.hterrain/doc/docs/images/normalmap_conventions.png
new file mode 100644
index 0000000..ef9e4b1
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/normalmap_conventions.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/overview.png b/game/addons/zylann.hterrain/doc/docs/images/overview.png
new file mode 100644
index 0000000..b84815a
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/overview.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/panel_import_button.png b/game/addons/zylann.hterrain/doc/docs/images/panel_import_button.png
new file mode 100644
index 0000000..d0eb5b7
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/panel_import_button.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/panel_textures_edit_button.png b/game/addons/zylann.hterrain/doc/docs/images/panel_textures_edit_button.png
new file mode 100644
index 0000000..58b48b1
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/panel_textures_edit_button.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/pbr_textures.png b/game/addons/zylann.hterrain/doc/docs/images/pbr_textures.png
new file mode 100644
index 0000000..ffc3188
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/pbr_textures.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/resize_tool.png b/game/addons/zylann.hterrain/doc/docs/images/resize_tool.png
new file mode 100644
index 0000000..c78350e
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/resize_tool.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/sculpting_tools.png b/game/addons/zylann.hterrain/doc/docs/images/sculpting_tools.png
new file mode 100644
index 0000000..dcfddf3
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/sculpting_tools.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/single_sampling_and_triplanar_sampling.png b/game/addons/zylann.hterrain/doc/docs/images/single_sampling_and_triplanar_sampling.png
new file mode 100644
index 0000000..1eb05e9
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/single_sampling_and_triplanar_sampling.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/slope_limit_tool.png b/game/addons/zylann.hterrain/doc/docs/images/slope_limit_tool.png
new file mode 100644
index 0000000..1320d58
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/slope_limit_tool.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/splatmap_and_textured_result.png b/game/addons/zylann.hterrain/doc/docs/images/splatmap_and_textured_result.png
new file mode 100644
index 0000000..5c9f008
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/splatmap_and_textured_result.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_array_import_dock.png b/game/addons/zylann.hterrain/doc/docs/images/texture_array_import_dock.png
new file mode 100644
index 0000000..17e0ea1
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_array_import_dock.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_atlas_example.png b/game/addons/zylann.hterrain/doc/docs/images/texture_atlas_example.png
new file mode 100644
index 0000000..6a04498
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_atlas_example.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_dialog.png b/game/addons/zylann.hterrain/doc/docs/images/texture_dialog.png
new file mode 100644
index 0000000..431b5c9
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_dialog.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_set_editor.png b/game/addons/zylann.hterrain/doc/docs/images/texture_set_editor.png
new file mode 100644
index 0000000..f80d478
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_set_editor.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool.png b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool.png
new file mode 100644
index 0000000..bf5daf0
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_directory.png b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_directory.png
new file mode 100644
index 0000000..1ce61f0
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_directory.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_success.png b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_success.png
new file mode 100644
index 0000000..5a75cf8
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_success.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_texture_types.png b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_texture_types.png
new file mode 100644
index 0000000..55ce92a
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_texture_types.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/texture_slots.png b/game/addons/zylann.hterrain/doc/docs/images/texture_slots.png
new file mode 100644
index 0000000..cfebe7b
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/texture_slots.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/tiling_artifacts.png b/game/addons/zylann.hterrain/doc/docs/images/tiling_artifacts.png
new file mode 100644
index 0000000..37b9d2c
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/tiling_artifacts.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/tiling_reduction.png b/game/addons/zylann.hterrain/doc/docs/images/tiling_reduction.png
new file mode 100644
index 0000000..af36cb3
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/tiling_reduction.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/transition_array_blending.png b/game/addons/zylann.hterrain/doc/docs/images/transition_array_blending.png
new file mode 100644
index 0000000..45d2c8e
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/transition_array_blending.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/update_editor_collider.png b/game/addons/zylann.hterrain/doc/docs/images/update_editor_collider.png
new file mode 100644
index 0000000..7c58aa5
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/update_editor_collider.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/images/warped_checker_variations.png b/game/addons/zylann.hterrain/doc/docs/images/warped_checker_variations.png
new file mode 100644
index 0000000..4e756f6
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/images/warped_checker_variations.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/doc/docs/index.md b/game/addons/zylann.hterrain/doc/docs/index.md
new file mode 100644
index 0000000..07f41b2
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/docs/index.md
@@ -0,0 +1,1166 @@
+HTerrain plugin documentation
+===============================
+
+Overview
+----------
+
+This plugin allows to create heightmap-based terrains in Godot Engine. This kind of terrain uses 2D images, such as for heights or texturing information, which makes it cheap to implement while covering most use cases.
+
+It is entirely built on top of the `VisualServer` scripting API, which means it should be expected to work on all platforms supported by Godot's `GLES3` renderer.
+
+![Screenshot of the editor with the plugin enabled and arrows showing where UIs are](images/overview.png)
+
+
+### Video tutorials
+
+This written doc should be the most up to date and precise information, but video tutorials exist for a quick start.
+
+- [Kasper's tutorial](https://www.youtube.com/watch?v=Af1f2JPvSIs) about version 1.5.2 (16 Jan 2021)
+- [GamesFromScratch presentation](https://www.youtube.com/watch?v=jYVO0-_sXZs), also featuring the [WaterWays](https://github.com/Arnklit/WaterGenGodot) plugin (23 dec 2020)
+- [qubodupDev's Tutorial](https://www.youtube.com/watch?v=k_ISq6JyVSs) about version 1.3.3 (27 aug 2020)
+- [Old tutorial](https://www.youtube.com/watch?v=eZuvfIHDeT4&) about version 0.8 (10 aug 2018! A lot is outdated in it but let's keep it here for the record)
+
+### How to install
+
+You will need to use Godot 4.1 or later.
+
+To get the last version that supported Godot 3.x, checkout the `godot3` branch in the Git repository.
+
+#### Automatically
+
+In Godot, go to the Asset Library tab, search for the terrain plugin, download it and then install it.
+Then you need to activate the plugin in your `ProjectSettings`.
+
+#### Manually
+
+The plugin can be found on the [Asset Library website](https://godotengine.org/asset-library/asset/231). The download will give you a `.zip` file. Decompress it at the root of your project. This should make it so the following hierarchy is respected:
+
+```
+addons/
+ zylann.hterrain/
+ <plugin files>
+```
+
+Then you need to activate the plugin in your `ProjectSettings`.
+
+
+### How to update
+
+When a new version of the plugin comes up, you may want to update. If you re-run the same installation steps, it should work most of the time. However this is not a clean way to update, because files might have been renamed, moved or deleted, and they won't be cleaned up. This is an issue with Godot's plugin management in general (TODO: [make a proposal](https://github.com/godotengine/godot-proposals/issues)).
+
+So a cleaner way would be:
+
+- Turn off the plugin
+- Close all your scenes (or close Godot entirely)
+- Delete the `addons/zylann.hterrain` folder
+- Then install the new version and enable it
+
+
+### Development versions
+
+The latest development version of the plugin can be found on [Github](https://github.com/Zylann/godot_heightmap_plugin).
+It is the most recently developed version, but might also have some bugs.
+
+
+Creating a terrain
+--------------------
+
+### Creating a HTerrain node
+
+Features of this plugin are mainly available from the `HTerrain` node. To create one, click the `+` icon at the top of the scene tree dock, and navigate to this node type to select it.
+
+There is one last step until you can work on the terrain: you need to specify a folder in which all the data will be stored. The reason is that terrain data is very heavy, and it's a better idea to store it separately from the scene.
+Select the `HTerrain` node, and click on the folder icon to choose that folder.
+
+![Screenshot of the data dir property](images/data_directory_property.png)
+
+Once the folder is set, a default terrain should show up, ready to be edited.
+
+![Screenshot of the default terrain](images/default_terrain.png)
+
+!!! note
+ If you don't have a default environment, it's possible that you won't see anything, so make sure you either have one, or add a light to the scene to see it. Also, because terrains are pretty large (513 units by default), it is handy to change the view distance of the editor camera so that you can see further: go to `View`, `Options`, and then increase `far distance`.
+
+### Terrain dimensions
+
+By default, the terrain is a bit small, so if you want to make it bigger, there are two ways:
+
+- Modify `map_scale`, which will scale the ground without modifying the scale of all child nodes while using the same memory. As the scale cannot be equal or less than `0`, the limit of `0.01` (1 cm per cell) was set as an arbitrary safety guard. This value is still high enough to not run into precision floating-point problems.
+- Use the `resize` tool in the `Terrain` menu, which will increase the resolution instead and take more memory.
+
+![Screenshot of the resize tool](images/resize_tool.png)
+
+If you use the `resize` tool, you can also choose to either stretch the existing terrain, or crop it by selecting an anchor point. Currently, this operation is permanent and cannot be undone, so if you want to go back, you should make a backup.
+
+!!! note
+ The resolution of the terrain is limited to powers of two + 1, mainly because of the way LOD was implemented. The reason why there is an extra 1 is down to the fact that to make 1 quad, you need 2x2 vertices. If you need LOD, you must have an even number of quads that you can divide by 2, and so on. However there is a possibility to tweak that in the future because this might not play well with the way older graphics cards store textures.
+
+
+Sculpting
+-----------
+
+### Brush types
+
+The default terrain is flat, but you may want to create hills and mountains. Because it uses a heightmap, editing this terrain is equivalent to editing an image. Because of this, the main tool is a brush with a configurable size and shape. You can see which area will be affected inside a 3D red circle appearing under your mouse, and you can choose how strong painting is by changing the `Brush opacity` slider.
+
+![Screenshot of the brush widget](images/brush_editor.png)
+
+To modify the heightmap, you can use the following brush modes, available at the top of the viewport:
+
+![Screenshot of the sculpting tools](images/sculpting_tools.png)
+
+- **Raise**: raises the height of the terrain to produce hills
+- **Lower**: digs down to create crevices
+- **Smooth**: smoothes heights locally
+- **Level**: averages the heights within the radius of the brush until ground eventually becomes flat
+- **Flatten**: directly sets the height to a given value, which can be useful as an eraser or to make plateaux. It is also possible to pick a height from the viewport using the picking button.
+- **Erode**: smoothes the landscape by simulating erosion. When used on noisy terrain, it often produces characteristic shapes found in nature.
+
+!!! note
+ Heightmaps work best for hills and large mountains. Making sharp cliffs or walls are not recommended because it stretches geometry too much, and might cause issues with collisions. To make cliffs it's a better idea to place actual meshes on top.
+
+### Normals
+
+As you sculpt, the plugin automatically recomputes normals of the terrain, and saves it in a texture. This way, it can be used directly in ground shaders, grass shaders and previews at a smaller cost. Also, it allows to keep the same amount of details in the distance independently from geometry, which allows for levels of detail to work without affecting perceived quality too much.
+
+
+### Collisions
+
+You can enable or disable collisions by checking the `Collisions enabled` property in the inspector.
+
+Heightmap-based terrains usually implement collisions directly using the heightmap, which saves a lot of computations compared to a classic mesh collider.
+
+![Screenshot of the option to choose physics engines in project settings](images/choose_bullet_physics.png)
+
+Some editor tools rely on colliders to work, such as snapping to ground or plugins like Scatter or other prop placement utilities. To make sure the collider is up to date, you can force it to update after sculpting with the `Terrain -> Update Editor Collider` menu:
+
+![Screenshot of the menu to update the collider](images/update_editor_collider.png)
+
+Note: if you use Godot 3.3, you need to make sure to use the Bullet physics engine in your project settings.
+
+
+#### Known issues
+
+- **Updating the collider**: In theory, Bullet allows us to specify a direct reference to the image data. This would allow the collider to automatically update for free. However, we still had to duplicate the heightmap for safety, to avoid potential crashes if it gets mis-used. Even if we didn't copy it, the link could be broken anytime because of internal Copy-on-Write behavior in Godot. This is why the collider update is manual, because copying the heightmap results in an expensive operation. It can't be threaded as well because in Godot physics engines are not thread-safe yet. It might be improved in the future, hopefully.
+
+- **Misaligned collider in editor**: At time of writing, the Bullet integration has an issue about colliders in the editor if the terrain is translated, which does not happen in game: [Godot issue #37337](https://github.com/godotengine/godot/issues/37337)
+
+
+### Holes
+
+It is possible to cut holes in the terrain by using the `Holes` brush. Use it with `draw holes` checked to cut them, and uncheck it to erase them. This can be useful if you want to embed a cave mesh or a well on the ground. You can still use the brush because holes are also a texture covering the whole terrain, and the ground shader will basically discard pixels that are over an area where pixels have a value of zero.
+
+![Screenshot with holes](images/hole_painting.png)
+
+At the moment, this brush uses the alpha channel of the color map to store where the holes are.
+
+!!! note
+ This brush only produces holes visually. In order to have holes in the collider too, you have to do some tricks with collision layers because the collision shape this plugin uses (Bullet heightfield) cannot have holes. It might be added in the future, because it can be done by editing the C++ code and drop collision triangles in the main heightmap collision routine.
+
+ See [issue 125](https://github.com/Zylann/godot_heightmap_plugin/issues/125)
+
+
+### Level of detail
+
+This terrain supports level of details on the geometry using a quad tree. It is divided in chunks of 32x32 (or 16x16 depending on your settings), which can be scaled by a power of two depending on the distance from the camera. If a group of 4 chunks are far enough, they will join into a single one. If a chunk is close enough, it will split in 4 smaller ones. Having chunks also improves culling because if you had a single big mesh for the whole terrain, that would be a lot of vertices for the GPU to go through.
+Care is also taken to make sure transitions between LODs are seamless, so if you toggle wireframe rendering in the editor you can see variants of the same meshes being used depending on which LOD their neighbors are using.
+
+![Screenshot of how LOD vertices decimate in the distance](images/lod_geometry.png)
+
+LOD can be mainly tweaked in two ways:
+
+- `lod scale`: this is a factor determining at which distance chunks will split or join. The higher it is, the more details there will be, but the slower the game will be. The lower it is, the faster quality will decrease over distance, but will increase speed.
+- `chunk size`: this is the base size of a chunk. There aren't many values that it can be, and it has a similar relation as `lod scale`. The difference is, it affects how many geometry instances will need to be culled and drawn, so higher values will actually reduce the number of draw calls. But if it's too big, it will take more memory due to all chunk variants that are precalculated.
+
+In the future, this technique could be improved by using GPU tessellation, once the Godot rendering engine supports it. GPU clipmaps are also a possibility, because at the moment the quad tree is updated on the CPU.
+
+!!! note
+ Due to limitations of the Godot renderer's scripting API, LOD only works around one main camera, so it's not possible to have two cameras with split-screen for example. Also, in the editor, LOD only works while the `HTerrain` node is selected, because it's the only time the EditorPlugin is able to obtain camera information (but it should work regardless when you launch the game).
+
+
+Texturing
+-----------
+
+### Overview
+
+Applying textures to terrains is a bit different than single models, because they are very large and a more optimal approach needs to be taken to keep memory and performance to an acceptable level. One very common way of doing it is by using a splatmap. A splatmap is another texture covering the whole terrain, whose role is to store which detail textures should be blended, and these textures may repeat seamlessly.
+
+![Screenshot showing splatmap and textured result](images/splatmap_and_textured_result.png)
+
+This magic is done with a single shader, i.e a single `ShaderMaterial` in Godot's terminology. This material is handled internally by the plugin, but you can customize it in several ways.
+
+There are mainly 3 families of shaders this plugin supports:
+
+- `CLASSIC4`: simple shaders where each texture may be a separate resource. They are limited to 4 textures.
+- `MULTISPLAT16`: more advanced shader using more splatmaps and texture arrays. It's expensive but supports up to 16 textures.
+- `ARRAY`: experimental shader also using texture arrays, which comes with constraints, but allows to paint a lot more different textures.
+- Other shaders don't need textures, like `LOW_POLY`, which only uses colors.
+
+On the `HTerrain` node, there is a property called `shader_type`, which lets you choose among built-in shaders. The one you choose will define which workflow to follow: textures, or texture arrays.
+
+At time of writing, `CLASSIC4` shaders are better supported, and are the default choice.
+Texture array shaders may be used more in the future.
+
+
+### Getting PBR textures
+
+*If you only plan to use simple color textures, you can skip to [Texture Sets](#texture-sets).*
+
+Before you can paint textures, you have to set them up. It is recommended to pick textures which can tile infinitely, and preferably use "organic" ones, because terrains are best-suited for exterior natural environments.
+For each texture, you may find the following types of images, common in PBR shading:
+
+- Albedo, color, or diffuse (required)
+- Bump, height, or displacement (optional)
+- Normal, or normalmap (optional)
+- Roughness (optional)
+
+![Screenshot of PBR textures](images/pbr_textures.png)
+
+You can find some of these textures for free at [cc0textures.com](http://cc0textures.com).
+
+!!! note: Some shaders have a `Lite` and non-lite versions. One main difference between them is that `Lite` versions don't require normal maps, but the others require them. If you use a non-lite shader and forget to assign normal maps, shading will look wrong.
+
+It is preferable to place those source images under a specific directory. Also, since the images will only serve as an input to generate the actual game resources, it is better to place a `.gdignore` file inside that directory. This way, Godot will not include those source files in the exported game:
+
+```
+terrain_test/
+ terrain_data/
+ height.res
+ data.hterrain
+ ...
+ textures_src/
+ .gdignore
+ grass_albedo.png
+ grass_bump.png
+ grass_normal.png
+ grass_roughness.png
+ rocks_albedo.png
+ rocks_bump.png
+ rocks_normal.png
+ rocks_roughness.png
+ ...
+ terrain_scene.tscn
+ ...
+```
+
+!!! note
+ While bump might not be used often, this plugin actually uses it to achieve [better blending effects](#depth-blending).
+
+
+### Using the import tool
+
+Ground textures are stored in a `HTerrainTextureSet` resource. All terrains come with a default one. However, it can be tedious to setup every texture and pack them, especially if you need PBR.
+
+This plugin comes with an optional import tool. Select your `HTerrain` node, and in the bottom panel, click the `Import...` button:
+
+![Screenshot of bottom panel import button](images/panel_import_button.png)
+
+This brings up the import tool dialog:
+
+![Screenshot of the texture set import tool](images/texture_set_import_tool.png)
+
+#### Import mode
+
+One of the first important things is to decide which import mode you want to use:
+
+- `Textures`: For simple terrains with up to 4 textures (in use with `CLASSIC4` shaders)
+- `TextureArrays`: For more complex terrains with up to 16 textures (in use with `MULTISPLAT16` and `ARRAY` shaders)
+
+This choice depends on the shader you will use for the terrain. Some shaders expect individual textures and others expect texture arrays.
+
+#### Smart file pick
+
+If you use PBR textures, there might be a lot of files to assign. If you use a naming convention, you can start loading an albedo texture, and the tool will attempt to find all the other maps automatically by recognizing other image file names. For example, using this convention may allow this shortcut to work:
+
+- `grass_albedo.png`
+- `grass_bump.png`
+- `grass_normal.png`
+- `grass_roughness.png`
+
+![Screenshot of texture types in import tool](images/texture_set_import_tool_texture_types.png)
+
+
+#### Normal maps
+
+As indicated in the [Godot documentation](https://docs.godotengine.org/en/stable/tutorials/3d/spatial_material.html#normal-map), normal maps are expected to use OpenGL convention (X+, Y+, Z+). So it is possible that normalmaps you find online use a different convention.
+
+To help with this, the import tool allows you to flip Y, in case the normalmap uses DirectX convention.
+
+![Examples of normalmap conventions](images/normalmap_conventions.png)
+
+
+#### Importing
+
+When importing, this tool will need to generate a few files representing intermediate Godot resources. You may have to choose the directory where those resources will be created, otherwise they will be placed at the root of your project.
+
+![Screenshot of directory option in import tool](images/texture_set_import_tool_directory.png)
+
+Once everything is ready, you can click `Import`. This can take a little while.
+If all goes well, a popup will tell you when it's done, and your terrain's texture set will be filled up with the imported textures.
+
+![Screenshot of import success](images/texture_set_import_tool_success.png)
+
+If importing goes wrong, most of the time an error will show up and the `HTerrainTextureSet` will not be modified.
+If it succeeded but you are unhappy with the result, it is possible to undo the changes done to the terrain using `Ctrl+Z`.
+
+!!! note
+ - If you need to change something after the first import, you can go back to the importing tool and change settings, then click `Import` again.
+ - Importing with this tool will overwrite the whole set each time.
+ - The tool does not store the settings anywhere, but it should fill them up as much as it can from existing sets so you shouldn't need to fill everything up again.
+ - Custom importers are used as backend in order to support these features automatically, instead of default Godot importers. If you need more tinkering, you can take a look at [packed texture importers](#packed-texture-importers).
+
+
+### Texture Sets
+
+#### Description
+
+`HTerrainTextureSet` is a custom resource which contains all textures a terrain can blend on the ground (grass, dirt, rocks, leaves, snow, sand...). All terrains come with an empty one assigned by default.
+
+The import tool seen earlier is the quickest way to fill one up from base textures, but it is not mandatory if you prefer to do things manually.
+
+You can inspect and edit the current set by selecting your `HTerrain` node, and in the bottom panel "Textures" section, click `Edit...`:
+
+![Screenshot of the bottom panel edit button](images/panel_textures_edit_button.png)
+
+This opens the following dialog:
+
+![Screenshot of the texture set editor](images/texture_set_editor.png)
+
+Unlike the import tool, this dialog shows you the actual resources used by the terrain. They may be either pairs of two packed textures for each slot, or two `TextureArray` resources.
+
+If you are using a `CLASSIC4` shader, you should be able to add and remove slots using the `+` and `-` buttons, and directly load color textures in the `Albedo` channel.
+For using texture arrays or PBR textures, it might be better to use the [import tool](#getting-pbr-textures).
+
+Actions done in this dialog behave like an extension of the inspector, and can be undone with `Ctrl+Z`.
+
+
+#### Re-using a texture set
+
+Texture sets are embedded in terrains by default, but it is possible to use the same set on another terrain. To do this, the `HTerrainTextureSet` must be saved as a `.tres` file.
+
+![Screenshot of texture set in the inspector](images/inspector_texture_set.png)
+
+- Select your `HTerrain` node
+- In the inspector, right-click on the value of the `texture_set` property
+- A HUGE menu will open (this is a Godot issue). Scroll all the way down with mouse wheel.
+- Click the `Edit...` menu item to edit the resource
+- On top of the inspector, a floppy disk icon should appear. You can click on it and choose `Save As...`
+
+![Screenshot of saving a texture set from inspector](images/inspector_texture_set_save.png)
+
+- A file dialog will prompt you for the location you want to put the resource file. Once you're done, click `Save`.
+
+Once you have a `.tres` file, you will be able to pick it up in your other terrain, by clicking on the `texture_set` property, but choosing `Load` this time.
+You can also navigate to the `.tres` file in the `FileSystem` dock, then drag and drop to the property.
+
+
+### Shader types
+
+#### Classic4
+
+The `CLASSIC4` shader is a simple splatmap technique, where R, G, B, A match the weight of 4 respective textures. Then are all blended together in every pixel of the ground. Here is how it looks when applied:
+
+![Screenshot showing splatmap and textured result](images/splatmap_and_textured_result.png)
+
+It comes in two variants:
+
+- `CLASSIC4`: full-featured shader, however it requires your textures to have normal maps. If you don't assign them, shading will look wrong.
+- `CLASSIC4_LITE`: simpler shader with less features. It only requires albedo textures.
+
+
+#### MultiSplat16
+
+the `MULTISPLAT16` shader is an extension of the splatmap technique, but uses 4 splatmaps instead of 1. It also uses `TextureArrays` instead of individual textures. It allows to support up to 16 textures at once, and can blend up to 4 in the same pixel. It dynamically chooses the 4 most-representative textures to blend them.
+
+It also comes in two variants:
+
+- `MULTISPLAT16`: full-featured shader, however it requires your texture arrays to have normal maps.
+- `MULTISPLAT16_LITE`: simpler shader with less features. It only requires albedo texture arrays.
+
+It is the recommended choice if you need more than 4 textures, because it is much easier to use than the `ARRAY` shader and produces less artifacts.
+
+One downside is performance: it is about twice slower than `CLASSIC4` (on an nVidia 1060, a fullscreen `CLASSIC4` is 0.8 ms, while `MULTISPLAT16` is 1.8 ms).
+Although, considering objects placed on the terrain should usually occlude ground pixels, the cost might be lower in a real game scenario.
+
+
+#### LowPoly
+
+The `LOWPOLY` shader is the simplest shader. It produces a faceted look with only simple colors, and no textures. You will need to use the color brush to paint it.
+
+![Screenshot of the lowpoly shader](images/low_poly.png)
+
+!!! note
+ If you need faceted visuals with other shaders using textures, you can obtain the same result by [customizing the shader](#custom-shaders), and adding this line at the end of `fragment()`:
+ `NORMAL = normalize(cross(dFdx(VERTEX), dFdy(VERTEX)));`
+
+
+#### Array
+
+**WARNING: this shader is still experimental. It's not ideal and has known flaws, so it may change in the future.**
+
+The `ARRAY` shader uses a more advanced technique to render ground textures. Instead of one splatmap and many individual textures, it uses a weightmap, an index map, and a `TextureArray`.
+
+The two maps are different from the classic one:
+
+- `SPLAT_INDEX`: this one stores the indexes of the textures to blend in every pixel of the ground. Indexes are stored respectively in R, G and B, and correspond to layers of the `TextureArray`.
+- `SPLAT_WEIGHT`: this one stores the weight of the 3 textures to blend on each pixel. It only has R and G channels, because the third one can be inferred (their sum must be 1).
+
+This allows to paint up to 256 different textures, however it introduces an important constraint: you cannot blend more than 3 textures at a given pixel.
+
+Painting the proper indexes and weights can be a challenge, so for now, the plugin comes with a compromise. Each texture is assigned a fixed color component, R, G or B. So for a given texture, all textures that have an index separated by a multiple of 3 from this texture will not always be able to blend with it. For example, texture `2` might not blend with texture `5`, `8`, `11`, `14` etc. So choosing where you place textures in the `TextureArray` can be important.
+
+Here is a close-up on an area where some textures are not totally blending, because they use the same color component:
+
+![Bad transition](images/bad_array_blending.png)
+
+In this situation, another workaround is to use a transition texture: if A and B cannot blend, use texture C which can blend with A and B:
+
+![Fixed transition](images/transition_array_blending.png)
+
+You may see this pop up quite often when using this shader, but it can often be worked around.
+The brush for this isn't perfect. This limitation can be smoothed out in the future, if a better algorithm is found which can work in real-time.
+
+
+### Creating a `TextureArray` manually
+
+!!! note
+ It is now possible to use the [import tool](#using-the-import-tool) to set this up automatically. The following description explains how to do it manually.
+
+Contrary to `CLASSIC4` shaders, you cannot directly assign individual textures with a shader that requires `TextureArray`. Instead, you'll have to import one.
+
+1) With an image editor, create an image, which you subdivide in square tiles, like an atlas. I each of them, place one ground texture, like so:
+
+![Example of an atlas for creating a Texture Array](images/texture_atlas_example.png)
+
+2) Place that atlas in your Godot project. The editor will attempt to import it a first time, it can take a while if it's big.
+
+3) Select the atlas, and go to the `Import` dock. Change the import type to `TextureArray`.
+
+![Texture Array import dock](images/texture_array_import_dock.png)
+
+4) Make sure the `Repeat` mode is enabled. Then, change the tile counts below to match your grid. Once you're done, click `Re-import`. Godot will ask you to restart the editor, do that (I have no idea why).
+
+5) Once the editor has restarted, select your terrain node, and make sure it uses the `ARRAY` shader type (or a similar custom shader). In the bottom panel, click on the `Edit...` button to edit the `HTerrainTextureSet` used by the terrain.
+
+6) In the dialog, click on the `Load Array...` button under `Albedo` to load your texture array. You can do the same process with normal maps if needed.
+
+7) The bottom panel should now update to show much more texture slots. They will appear in the same order they are in the atlas, from left-to-right. If the panel doesn't update, select another node and click the terrain again. You should now be able to paint.
+
+![Lots of textures blending](images/lots_of_textures_blending.png)
+
+
+
+### Packing textures manually
+
+!!! note
+ It is now possible to use the [import tool](#using-the-import-tool) to set this up automatically. The following description explains how to do it manually.
+
+The main ground shaders provided by the plugin should work fine with only regular albedo, but it supports a few features to make the ground look more realistic, such as normal maps, bump and roughness. To achieve this, shaders expects packed textures. The main reason is that more than one texture has to be sampled at a time, to allow them to blend. With a classic splatmap, it's 4 at once. If we want normalmaps, it becomes 8, and if we want roughness it becomes 12 etc, which is already a lot, in addition to internal textures Godot uses in the background. Not all GPUs allow that many textures in the shader, so a better approach is to combine them as much as possible into single images. This reduces the number of texture units, and reduces the number of fetches to do in the pixel shader.
+
+![Screenshot of the channel packer plugin](images/channel_packer.png)
+
+For this reason, the plugin uses the following convention in ground textures:
+
+- `Albedo` in RGB, `Bump` in A
+- `Normal` in RGB, `Roughness` in A
+
+This operation can be done in an image editing program such as Gimp, or with a Godot plugin such as [Channel Packer](https://godotengine.org/asset-library/asset/230).
+It can also be done using [packed texture importers](packed-texture-importers), which are now included in the plugin.
+
+!!! note
+ Normal maps must follow the OpenGL convention, where Y goes up. They are recognizable by being "brighter" on the top of bumpy features (because Y is green, which is the most energetic color to the human eye):
+
+ ![Examples of normalmap conventions](images/normalmap_conventions.png)
+
+ See also [Godot's documentation notes about normal maps](https://docs.godotengine.org/en/latest/getting_started/workflow/assets/importing_images.html#normal-map)
+
+!!! note
+ Because Godot would strip out the alpha channel if a packed texture was imported as a normal map, you should not make your texture import as "Normal Map" in the importer dock.
+
+
+### Packed texture importers
+
+In order to support the [import tool](#using-the-import-tool), this plugin defines two special texture importers, which allow to pack multiple input textures into one. They otherwise behave the same as Godot's default importers.
+
+The type of file they import are JSON files, which refer to the source image files you wish to pack together, along with a few other options.
+
+#### Packed textures
+
+File extension: `.packed_tex`
+
+Example for an albedo+bump texture:
+```json
+{
+ "contains_albedo": true,
+ "src": {
+ "rgb": "res://textures/src/grass_albedo.png",
+ "a": "res://textures/src/grass_bump.png",
+ }
+}
+```
+
+Example for a normal+roughness texture, with conversion from DirectX to OpenGL (optional):
+```json
+{
+ "src": {
+ "rgb": "res://textures/src/rocks_normal.png",
+ "a": "res://textures/src/rocks_roughness.png",
+ "normalmap_flip_y": true
+ }
+}
+```
+
+You can also specify a plain color instead of a path, if you don't need a texture. It will act as if the source texture was filled with this color. The expected format is ARGB.
+
+```
+ "rgb": "#ff888800"
+```
+
+#### Packed texture arrays
+
+File extension: `.packed_texarr`
+
+This one requires you to specify a `resolution`, because each layer of the texture array must have the same size and be square. The resolution is a single integer number.
+What you can put in each layer is the same as for [packed textures](#packed-textures).
+
+```json
+{
+ "contains_albedo": true,
+ "resolution": 1024,
+ "layers": [
+ {
+ "rgb": "res://textures/src/grass_albedo.png",
+ "a": "res://textures/src/grass_bump.png"
+ },
+ {
+ "rgb": "res://textures/src/rocks_albedo.png",
+ "a": "res://textures/src/rocks_bump.png"
+ },
+ {
+ "rgb": "res://textures/src/sand_albedo.png",
+ "a": "res://textures/src/sand_bump.png"
+ }
+ ]
+}
+```
+
+#### Limitations
+
+Such importers support most of the features needed for terrain textures, however some features found in Godot's importers are not implemented. This is because Godot does not have any API to extend the existing importers, so they had to be re-implemented from scratch in GDScript. For example, lossy compression to save disk space is not supported, because it requires access to WebP compression API which is not exposed.
+
+See [Godot proposal](https://github.com/godotengine/godot-proposals/issues/1943)
+
+
+### Depth blending
+
+`Bump` textures holds a particular usage in this plugin:
+You may have noticed that when you paint multiple textures, the terrain blends them together to produce smooth transitions. Usually, a classic way is to do a "transparency" transition using the splatmap. However, this rarely gives realistic visuals, so an option is to enable `depth blending` under `Shader Params`.
+
+![Screenshot of depth blending VS alpha blending](images/alpha_blending_and_depth_blending.png)
+
+This feature changes the way blending operates by taking the bump of the ground textures into account. For example, if you have sand blending with pebbles, at the transition you will see sand infiltrate between the pebbles because the pixels between pebbles have lower bump than the pebbles. You can see this technique illustrated in a [Gamasutra article](https://www.gamasutra.com/blogs/AndreyMishkinis/20130716/196339/Advanced_Terrain_Texture_Splatting.php).
+It was tweaked a bit to work with 3 or 4 textures, and works best with fairly low brush opacity, around 10%.
+
+
+### Triplanar mapping
+
+Making cliffs with a heightmap terrain is not recommended, because it stretches the geometry too much and makes textures look bad. Nevertheless, you can enable triplanar mapping on such texture in order for it to not look stretched. This option is in the shader section in the inspector.
+
+![Screenshot of triplanar mapping VS no triplanar](images/single_sampling_and_triplanar_sampling.png)
+
+In the case of the `CLASSIC4` shader, cliffs usually are made of the same ground texture, so it is only available for textures setup in the 4th slot, called `cliff`. It could be made to work on all slots, however it involves modifying the shader to add more options, which you may see in a later article.
+
+The `ARRAY` shader does not have triplanar mapping yet, but it may be added in the future.
+
+
+### Tiling reduction
+
+The fact repeating textures are used for the ground also means they will not look as good at medium to far distance, due to the pattern it produces:
+
+![Screenshot of tiling artifacts](images/tiling_artifacts.png)
+
+On shaders supporting it, the `tile_reduction` parameter allows to break the patterns a bit to attenuate the effect:
+
+![Screenshot of reduced tiling artifacts](images/tiling_reduction.png)
+
+This option is present under the form of a `vec4`, where each component correspond to a texture, so you can enable it for some of them and not the others. Set a component to `1` to enable it, and `0` to disable it.
+
+This algorithm makes the shader sample the texture a second time, at a different orientation and scale, at semi-random areas of the ground:
+
+![Screenshot of the warped checker pattern used to break repetitions](images/warped_checker_variations.png)
+
+Here you can see where each of the two texture variants are being rendered. The pattern is a warped checker, which is simple enough to be procedural (avoiding the use of a noise texture), but organic enough so it shouldn't create artifacts itself. The result is made seamless by using depth blending (see [Depth blending](#depth-blending)).
+
+Although it's still possible to notice repetition over larger distances, this can be better covered by using a fade to global map (see [Global map](#global-map)).
+In addition, many games don't present a naked terrain to players: there are usually many props on top of it, such as grass, vegetation, trees, rocks, buildings, fog etc. so overall tiling textures should not really be a big deal.
+
+
+### Painting only on slopes
+
+The texture painting tool has a special option to limit the brush based on the slope of the terrain. This helps painting cliffs only or flat grounds only, without having to aim. It can even be used to paint a big area in one go, by increasing the brush size.
+
+![Screenshot of the slope limit tool](images/slope_limit_tool.png)
+
+The control consists in a two-ways slider. You can drag two handles. The left handle controls minimum slope, the right handle controls maximum slope. The range between the two handles determines which slopes the brush will paint on.
+
+
+### Color tint
+
+You can color the terrain using the `Color` brush. This is pretty much modulating the albedo, which can help adding a touch of variety to the landscape. If you make custom shader tweaks, color can also be used for your own purpose if you need to.
+
+![Screenshot with color painting](images/color_painting.png)
+
+Depending on the shader, you may be able to choose which textures are affected by the colormap.
+
+
+### Global map
+
+For shading purposes, it can be useful to bake a global map of the terrain. A global map takes the average albedo of the ground all over the terrain, which allows other elements of the scene to use that without having to recompute the full blending process that the ground shader goes through. The current use cases for a global map is to tint grass, and use it as a distance fade in order to hide texture tiling in the very far distance. Together with the terrain's normal map it could also be used to make minimap previews.
+
+To bake a global map, select the `HTerrain` node, go to the `Terrain` menu and click `Bake global map`. This will produce a texture in the terrain data directory which will be used by the default shaders automatically, depending on your settings.
+
+If you use a custom shader, you can define a specific one to use for the global map, by assigning the `custom_globalmap_shader` property. This is usually a stripped-down version of the main ground shader, where only `ALBEDO` is important.
+
+!!! note
+ The globalmap is also used in the minimap to show the color of the terrain.
+
+
+Terrain generator
+-------------------
+
+Basic sculpting tools can be useful to get started or tweaking, but it's cumbersome to make a whole terrain only using them. For larger scale terrain modeling, procedural techniques are often preferred, and then adjusted later on.
+
+This plugin provides a simple procedural generator. To open it, click on the `HTerrain` node to see the `Terrain` menu, in which you select `generate...`. Note that you should have a properly setup terrain node before you can use it.
+
+![Screenshot of the terrain generator](images/generator.png)
+
+The generator is quite simple and combines a few common techniques to produce a heightmap. You can see a 3D preview which can be zoomed in with the mouse wheel and rotated by dragging holding middle click.
+
+### Height range
+
+`height range` and `base height` define which is the minimum and maximum heights of the terrain. The result might not be exactly reaching these boundaries, but it is useful to determine in which region the generator has to work in.
+
+### Perlin noise
+
+Perlin noise is very common in terrain generation, and this one is no exception. Multiple octaves (or layers) of noise are added together at varying strength, forming a good base that already looks like a good environment.
+
+The usual parameters are available:
+
+- `seed`: this chooses the random seed the perlin noise will be based on. Same number gives the same landscape.
+- `offset`: this chooses where in the landscape the terrain will be cropped into. You can also change that setting by panning the preview with the right mouse button held.
+- `scale`: expands or shrinks the length of the patterns. Higher scale gives lower-frequency relief.
+- `octaves`: how many layers of noise to use. The more octaves, the more details there will be.
+- `roughness`: this controls the strength of each octave relatively to the previous. The more you increase it, the more rough the terrain will be, as high-frequency octaves get a higher weight.
+
+Try to tweak each of them to get an idea of how they affect the final shape.
+
+### Erosion
+
+The generator features morphological erosion. Behind this barbaric name hides a simple image processing algorithm, ![described here](https://en.wikipedia.org/wiki/Erosion_(morphology)).
+In the context of terrains, what it does is to quickly fake real-life erosion, where rocks might slide along the slopes of the mountains over time, giving them a particular appearance. Perlin noise alone is nice, but with erosion it makes the result look much more realistic.
+
+![Screenshot with the effect of erosion](images/erosion_steps.png)
+
+It's also possible to use dilation, which gives a mesa-like appearance.
+
+![Screenshot with the effect of dilation](images/dilation.png)
+
+There is also a slope direction parameter, this one is experimental but it has a tendency to simulate wind, kind of "pushing" the ground in the specified direction. It can be tricky to find a good value for this one but I left it because it can give interesting results, like sand-like ripples, which are an emergent behavior.
+
+![Screenshot of slope erosion](images/erosion_slope.png)
+
+!!! note
+ Contrary to previous options, erosion is calculated over a bunch of shader passes. In Godot 3, it is only possible to wait for one frame to be rendered every 16 milliseconds, so the more erosion steps you have, the slower the preview will be. In the future it would be nice if Godot allowed multiple frames to be rendered on demand so the full power of the GPU could be used.
+
+### Applying
+
+Once you are happy with the result, you can click "Apply", which will calculate the generated terrain at full scale on your scene. This operation currently can't be undone, so if you want to go back you should make a backup.
+
+
+Import an existing terrain
+-----------------------------
+
+Besides using built-in tools to make your landscape, it can be convenient to import an existing one, which you might have made in specialized software such as WorldMachine, Scape or Lithosphere.
+
+### Import dialog
+
+To do this, select the `HTerrain` node, click on the `Terrain` menu and chose `Import`.
+This window allows you to import several kinds of data, such as heightmap but also splatmap or color map.
+
+![Screenshot of the importer](images/importer.png)
+
+There are a few things to check before you can successfully import a terrain though:
+
+- The resolution should be power of two + 1, and square. If it isn't, the plugin will attempt to crop it, which might be OK or not if you can deal with map borders that this will produce.
+- If you import a RAW heightmap, it has to be encoded using 16-bit unsigned integer format.
+- If you import a PNG heightmap, Godot can only load it as 8-bit depth, so it is not recommended for high-range terrains because it doesn't have enough height precision.
+
+This feature also can't be undone when executed, as all terrain data will be overwritten with the new one. If anything isn't correct, the tool will warn you before to prevent data loss.
+
+It is possible that the height range you specify doesn't works out that well after you see the result, so for now it is possible to just re-open the importer window, change the height scale and apply again.
+
+
+### 4-channel splatmaps caveat
+
+Importing a 4-channel splatmap requires an RGBA image, where each channel will be used to represent the weight of a texture. However, if you are creating a splatmap by going through an image editor, *you must make sure the color data is preserved*.
+
+Most image editors assume you create images to be seen. When you save a PNG, they assume fully-transparent areas don't need to store any color data, because they are invisible. The RGB channels are then compressed away, which can cause blocky artifacts when imported as a splatmap.
+
+To deal with this, make sure your editor has an option to turn this off. In Gimp, for example, this option is here:
+
+![Screenshot of the importer](images/gimp_png_preserve_colors.png)
+
+
+Detail layers
+---------------
+
+Once you have textured ground, you may want to add small detail objects to it, such as grass and small rocks.
+
+![Screenshot of two grass layers under the terrain node](images/detail_layers.png)
+
+### Painting details
+
+Grass is supported through `HTerrainDetailLayer` node. They can be created as children of the `HTerrain` node. Each layer represents one kind of detail, so you may have one layer for grass, and another for flowers, for example.
+
+Detail layers come in two parts:
+
+- A 8-bit density texture covering the whole terrain, also called a "detail map" at the moment. You can see how many maps the terrain has in the bottom panel after selecting the terrain.
+- A `HTerrainDetailLayer` node, which uses one of the detail maps to render instanced models based on the density.
+
+You can paint detail maps just like you paint anything else, using the same brush system. It uses opacity to either add more density, or act as an eraser with an opacity of zero.
+`HTerrainDetailLayer` nodes will then update in realtime, rendering more or less instances in places you painted.
+
+!!! note
+ A detail map can be used by more than one node (by setting the same index in their `layer_index` property), so you can have one for grass, another for flowers, and paint on the shared map to see both nodes update at the same time.
+
+
+### Shading options
+
+At the moment, detail layers only come with a single shader type, which is made for grass. More may be added in the future.
+
+You can choose which texture will be used, and it will be rendered using alpha-scissor. It is done that way because it allows drawing grass in the opaque render pass, which is cheaper than treating every single quad like a transparent object which would have to be depth-sorted to render properly. Alpha-to-coverage would look better, but isn't supported in Godot 3.
+
+Like the ground, detail layers use a custom shader that takes advantage of the heightmap to displace each instanced object at a proper position. Also, hardware instancing is used under the hood to allow for a very high number of items with low cost. Multimeshes are generated in chunks, and then instances are hidden from the vertex shader depending on density. For grass, it also uses the normal of the ground so there is no need to provide it. There are also shader options to tint objects with the global map, which can help a lot making grass to blend better with the environment.
+
+Finally, the shader fades in the distance by increasing the threshold of alpha scissor. This works better with a transparent texture. An alternative is to make it sink in the ground, but that's left to customization.
+
+For writing custom shaders, see [Custom detail shaders](#grass-shaders).
+
+### Meshes
+
+By default, detail layers draw simple quads on top of the ground. But it is possible to choose another kind of geometry, by assigning the `instance_mesh` property.
+Several meshes are bundled with the plugin, which you can find in `res://addons/zylann.hterrain/models/`.
+
+![Bundled grass models](images/grass_models.png)
+
+They are all thought for grass rendering. You can make your own for things that aren't grass, however there is no built-in shader for conventional objects at the moment (rocks, bits and bobs). So if you want normal shading you need to write a custom shader. That may be bundled too in the future.
+
+!!! note
+ Detail meshes must be `Mesh` resources, so the easiest way is to use the `OBJ` format. If you use `GLTF` or `FBX`, Godot will import it as a scene by default, so you may have to configure it to import as single mesh if possible.
+
+
+Custom shaders
+-----------------
+
+This plugin comes with default shaders, but you are allowed to modify them and change things to match your needs. The plugin does not expose materials directly because it needs to set built-in parameters that are always necessary, and some of them cannot be properly saved as material properties, if at all.
+
+### Ground shaders
+
+In order to write your own ground shader, select the `HTerrain` node, and change the shader type to `Custom`. Then, select the `custom shader` property and choose `New Shader`. This will create a new shader which is pre-filled with the same source code as the last built-in shader you had selected. Doing it this way can help seeing how every feature is done and find your own way into implementing customizations.
+
+The plugin does not actually hardcode its features based on its built-in shaders. Instead, it looks at which `uniform` parameters your shader defines, and adapts in consequence.
+A list of `uniform` parameters are recognized, some of which are required for heightmap rendering to work:
+
+Parameter name | Type | Format | Description
+------------------------------------|------------------|---------|--------------
+`u_terrain_heightmap` | `sampler2D` | `RH` | The heightmap, a 32-bit float texture which can be sampled in the red channel. Like the other following maps, you have to access it using cell coordinates, which can be computed as seen in the built-in shader.
+`u_terrain_normalmap` | `sampler2D` | `RGB8` | The precalculated normalmap of the terrain, which you can use instead of computing it from the heightmap
+`u_terrain_colormap` | `sampler2D` | `RGBA8` | The color map, which is the one modified by the color brush. The alpha channel is used for holes.
+`u_terrain_splatmap` | `sampler2D` | `RGBA8` | The classic 4-component splatmap, where each channel determines the weight of a given texture. The sum of each channel across all splatmaps must be 1.0.
+`u_terrain_splatmap_1` | `sampler2D` | `RGBA8` | Additional splatmap
+`u_terrain_splatmap_2` | `sampler2D` | `RGBA8` | Additional splatmap
+`u_terrain_splatmap_3` | `sampler2D` | `RGBA8` | Additional splatmap
+`u_terrain_globalmap` | `sampler2D` | `RGB8` | The global albedo map.
+`u_terrain_splat_index_map` | `sampler2D` | `RGB8` | An index map, used for texturing based on a `TextureArray`. the R, G and B components multiplied by 255.0 will provide the index of the texture.
+`u_terrain_splat_weight_map` | `sampler2D` | `RG8` | A 2-component weight map where a 3rd component can be obtained with `1.0 - r - g`, used for texturing based on a `TextureArray`. The sum of R and G must be 1.0.
+`u_ground_albedo_bump_0`...`3` | `sampler2D` | `RGBA8` | These are up to 4 albedo textures for the ground, which you have to blend using the splatmap. Their alpha channel can contain bump.
+`u_ground_normal_roughness_0`...`3` | `sampler2D` | `RGBA8` | Similar to albedo, these are up to 4 normal textures to blend using the splatmap. Their alpha channel can contain roughness.
+`u_ground_albedo_bump_array` | `sampler2DArray` | `RGBA8` | Equivalent of the previous individual albedo textures, as an array. The plugin knows you use this texturing technique by checking the existence of this parameter.
+`u_ground_normal_roughness_array` | `sampler2DArray` | `RGBA8` | Equivalent of the previous individual normalmap textures, as an array.
+`u_terrain_inverse_transform` | `mat4x4` | | A 4x4 matrix containing the inverse transform of the terrain. This is useful if you need to calculate the position of the current vertex in world coordinates in the vertex shader, as seen in the builtin shader.
+`u_terrain_normal_basis` | `mat3x3` | | A 3x3 matrix containing the basis used for transforming normals. It is not always needed, but if you use `map scale` it is required to keep them correct.
+
+You don't have to declare them all. It's fine if you omit some of them, which is good because it frees a slot in the limited amount of `uniforms`, especially for texture units.
+Other parameters are not used by the plugin, and are shown procedurally under the `Shader params` section of the `HTerrain` node.
+
+
+### Grass shaders
+
+Detail layers follow the same design as ground shaders. In order to make your own, select the `custom shader` property and assign it a new empty shader. This will also fork the built-in shader, which at the moment is specialized into rendering grass quads.
+
+They share the following parameters with ground shaders:
+
+- `u_terrain_heightmap`
+- `u_terrain_normalmap`
+- `u_terrain_globalmap`
+- `u_terrain_inverse_transform`
+
+And there also have specific parameters which you can use:
+
+Parameter name | Type | Format | Description
+------------------------------------|------------------|---------|--------------
+`u_terrain_detailmap` | `sampler2D` | `R8` | This one contains the grass density, from 0 to 1. Depending on this, you may hide instances by outputting degenerate triangles, or let them pass through. The builtin shader contains an example.
+`u_albedo_alpha` | `sampler2D` | `RGBA8` | This is the texture applied to the whole model, typically transparent grass.
+`u_view_distance` | `float` | | How far details are supposed to render. Beyond this range, the plugin will cull chunks away, so it is a good idea to use this in the shader to smoothly fade pixels in the distance to hide this process.
+`u_ambient_wind` | `vec2` | | Combined `vec2` parameter for ambient wind. `x` is the amplitude, and `y` is a time value. It is better to use it instead of directly `TIME` because it allows to animate speed without causing stutters.
+
+
+### Lookdev
+
+The plugin features an experimental debugging feature in the `Terrain` menu called "Lookdev". It temporarily replaces the ground shader with a simpler one which displays the raw value of a specific map. For example, you can see the actual values taken by a detail map by choosing one of them in the menu:
+
+![Screenshot of detail map seen with lookdev shader](images/lookdev_grass.png)
+
+It is very simple at the moment but it can also be used to display data maps which are not necessarily used for rendering. So you could also use it to paint them, even if they don't translate into a visual element in the game.
+
+To turn it off, select `Disabled` in the menu.
+
+![Screenshot of detail map seen with lookdev shader](images/lookdev_menu.png)
+
+!!! note
+ The heightmap cannot be seen with this feature because its values extend beyond usual color ranges.
+
+
+Scripting
+--------------
+
+### Overview
+
+Scripts relevant to in-game functionality are located under the plugin's root folder, `res://addons/zylann.hterrain/`.
+
+```
+res://
+- addons/
+ - zylann.hterrain/
+ - doc/
+ - models/ <-- Models used for grass
+ - native/ <-- GDNative library
+ - shaders/
+ - tools/ <-- Editor-specific stuff, don't use in game
+ - util/ <-- Various helper scripts
+
+ - hterrain.gd <-- The HTerrain node
+ - hterrain_data.gd <-- The HTerrainData resource
+ - hterrain_detail_layer.gd <-- The HTerrainDetailLayer node
+
+ - (other stuff used internally)
+```
+
+This plugin does not use global class names, so to use or hint one of these types, you may want to "const-import" them on top of your script, like so:
+
+```gdscript
+const HTerrain = preload("res://addons/zylann.hterrain/hterrain.gd")
+```
+
+There is no API documentation yet, so if you want to see which functions and properties are available, take a look at the source code in the editor.
+Functions and properties beginning with a `_` are private and should not be used directly.
+
+
+### Creating the terrain from script
+
+You can decide to create the terrain from a script. Here is an example:
+
+```gdscript
+extends Node
+
+const HTerrain = preload("res://addons/zylann.hterrain/hterrain.gd")
+const HTerrainData = preload("res://addons/zylann.hterrain/hterrain_data.gd")
+
+func _ready():
+ var data = HTerrainData.new()
+ data.resize(513)
+
+ var terrain = HTerrain.new()
+ terrain.set_data(data)
+ add_child(terrain)
+```
+
+### Modifying terrain from script
+
+The terrain is described by several types of large textures, such as heightmap, normal map, grass maps, color map and so on. Modifying the terrain boils down to modifying them using the `Image` API.
+
+For example, this code will tint the ground red at a specific position (in pixels, not world space):
+
+```gdscript
+const HTerrainData = preload("res://addons/zylann.hterrain/hterrain_data.gd")
+
+onready var _terrain = $Path/To/Terrain
+
+func test():
+ # Get the image
+ var data : HTerrainData = _terrain.get_data()
+ var colormap : Image = data.get_image(HTerrainData.CHANNEL_COLOR)
+
+ # Modify the image
+ var position = Vector2(42, 36)
+ colormap.lock()
+ colormap.set_pixel(position, Color(1, 0, 0))
+ colormap.unlock()
+
+ # Notify the terrain of our change
+ data.notify_region_changed(Rect2(position.x, position.y, 1, 1), HTerrainData.CHANNEL_COLOR)
+```
+
+The same goes for the heightmap and grass maps, however at time of writing, there are several issues with editing it in game:
+
+- Normals of the terrain don't automatically update, you have to calculate them yourself by also modifying the normalmap. This is a bit tedious and expensive, however it may be improved in the future. Alternatively you could compute them in shader, but it makes rendering a bit more expensive.
+- The collider won't update either, for the same reason mentioned in the [section about collisions in the editor](#Collisions). You can force it to update by calling `update_collider()` but it can cause a hiccup.
+
+
+### Procedural generation
+
+It is possible to generate the terrain data entirely from script. It may be quite slow if you don't take advantage of GPU techniques (such as using a compute viewport), but it's still useful to copy results to the terrain or editing it like the plugin does in the editor.
+
+Again, we can use the `Image` resource to modify pixels.
+Here is a full GDScript example generating a terrain from noise and 3 textures:
+
+```gdscript
+extends Node
+
+# Import classes
+const HTerrain = preload("res://addons/zylann.hterrain/hterrain.gd")
+const HTerrainData = preload("res://addons/zylann.hterrain/hterrain_data.gd")
+const HTerrainTextureSet = preload("res://addons/zylann.hterrain/hterrain_texture_set.gd")
+
+# You may want to change paths to your own textures
+var grass_texture = load("res://addons/zylann.hterrain_demo/textures/ground/grass_albedo_bump.png")
+var sand_texture = load("res://addons/zylann.hterrain_demo/textures/ground/sand_albedo_bump.png")
+var leaves_texture = load("res://addons/zylann.hterrain_demo/textures/ground/leaves_albedo_bump.png")
+
+
+func _ready():
+ # Create terrain resource and give it a size.
+ # It must be either 513, 1025, 2049 or 4097.
+ var terrain_data = HTerrainData.new()
+ terrain_data.resize(513)
+
+ var noise = OpenSimplexNoise.new()
+ var noise_multiplier = 50.0
+
+ # Get access to terrain maps
+ var heightmap: Image = terrain_data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var normalmap: Image = terrain_data.get_image(HTerrainData.CHANNEL_NORMAL)
+ var splatmap: Image = terrain_data.get_image(HTerrainData.CHANNEL_SPLAT)
+
+ heightmap.lock()
+ normalmap.lock()
+ splatmap.lock()
+
+ # Generate terrain maps
+ # Note: this is an example with some arbitrary formulas,
+ # you may want to come up with your owns
+ for z in heightmap.get_height():
+ for x in heightmap.get_width():
+ # Generate height
+ var h = noise_multiplier * noise.get_noise_2d(x, z)
+
+ # Getting normal by generating extra heights directly from noise,
+ # so map borders won't have seams in case you stitch them
+ var h_right = noise_multiplier * noise.get_noise_2d(x + 0.1, z)
+ var h_forward = noise_multiplier * noise.get_noise_2d(x, z + 0.1)
+ var normal = Vector3(h - h_right, 0.1, h_forward - h).normalized()
+
+ # Generate texture amounts
+ var splat = splatmap.get_pixel(x, z)
+ var slope = 4.0 * normal.dot(Vector3.UP) - 2.0
+ # Sand on the slopes
+ var sand_amount = clamp(1.0 - slope, 0.0, 1.0)
+ # Leaves below sea level
+ var leaves_amount = clamp(0.0 - h, 0.0, 1.0)
+ splat = splat.linear_interpolate(Color(0,1,0,0), sand_amount)
+ splat = splat.linear_interpolate(Color(0,0,1,0), leaves_amount)
+
+ heightmap.set_pixel(x, z, Color(h, 0, 0))
+ normalmap.set_pixel(x, z, HTerrainData.encode_normal(normal))
+ splatmap.set_pixel(x, z, splat)
+
+ heightmap.unlock()
+ normalmap.unlock()
+ splatmap.unlock()
+
+ # Commit modifications so they get uploaded to the graphics card
+ var modified_region = Rect2(Vector2(), heightmap.get_size())
+ terrain_data.notify_region_change(modified_region, HTerrainData.CHANNEL_HEIGHT)
+ terrain_data.notify_region_change(modified_region, HTerrainData.CHANNEL_NORMAL)
+ terrain_data.notify_region_change(modified_region, HTerrainData.CHANNEL_SPLAT)
+
+ # Create texture set
+ # NOTE: usually this is not made from script, it can be built with editor tools
+ var texture_set = HTerrainTextureSet.new()
+ texture_set.set_mode(HTerrainTextureSet.MODE_TEXTURES)
+ texture_set.insert_slot(-1)
+ texture_set.set_texture(0, HTerrainTextureSet.TYPE_ALBEDO_BUMP, grass_texture)
+ texture_set.insert_slot(-1)
+ texture_set.set_texture(1, HTerrainTextureSet.TYPE_ALBEDO_BUMP, sand_texture)
+ texture_set.insert_slot(-1)
+ texture_set.set_texture(2, HTerrainTextureSet.TYPE_ALBEDO_BUMP, leaves_texture)
+
+ # Create terrain node
+ var terrain = HTerrain.new()
+ terrain.set_shader_type(HTerrain.SHADER_CLASSIC4_LITE)
+ terrain.set_data(terrain_data)
+ terrain.set_texture_set(texture_set)
+ add_child(terrain)
+
+ # No need to call this, but you may need to if you edit the terrain later on
+ #terrain.update_collider()
+```
+
+
+Export
+----------
+
+The plugin should work normally in exported games, but there are some files you should be able to remove because they are editor-specific. This allows to reduce the size from the executable a little.
+
+Everything under `res://addons/zylann.hterrain/tools/` folder is required for the plugin to work in the editor, but it can be removed in exported games. You can specify this folder in your export presets:
+
+![Screenshot of the export window with tools folder ignored](images/ignore_tools_on_export.png)
+
+The documentation in `res://addons/zylann.hterrain/doc/` can also be removed, but this one contains a `.gdignore` file so hopefully Godot will automatically ignore it even in the editor.
+
+
+GDNative
+-----------
+
+This plugin contains an optional native component, which speeds up some operations such as sculpting the terrain. However, at time of writing, a prebuilt binary is built-in only on `Windows` and `Linux`, I'm not yet able to build for other platforms so you may need to do it yourself, until I can provide an official one.
+
+Before doing this, it's preferable to close the Godot editor so it won't lock the library files.
+Note that these steps are very similar to GDNative C++ development, which repeats parts of [Godot's documentation](https://docs.godotengine.org/en/3.2/tutorials/plugins/gdnative/gdnative-cpp-example.html).
+
+### Building instructions
+
+To build the library, you will need to install the following:
+
+- Python 3.6 or later
+- The SCons build system
+- A C++ compiler
+- The Git version control system
+
+#### If you got the plugin from the asset library
+
+You will need to download C++ bindings for Godot. Go to `res://addons/zylann.hterrain/native`, open a command prompt, and run the following commands:
+
+```
+git clone https://github.com/GodotNativeTools/godot-cpp
+cd godot-cpp
+git submodule update --init --recursive
+```
+
+#### If you cloned the plugin using Git
+
+In this case the C++ bindings submodule will already be there, and will need to be updated. Go to `res://addons/zylann.hterrain/native`, open a command prompt, and run the following commands:
+
+```
+git submodule update --init --recursive target=release
+```
+
+#### Build C++ bindings
+
+Now go to `res://addons/zylann.hterrain/native/cpp-bindings`, open a command prompt (or re-use the one you have already), and run this command:
+
+```
+scons platform=<yourplatform> generate_bindings=yes target=release
+```
+
+`yourplatform` must match the platform you want to build for. It should be one of the following:
+
+- `windows`
+- `linux`
+- `osx`
+
+#### Build the HTerrain library
+
+Go back to `res://addons/zylann.hterrain/native`, and run this command, which has similar options as the one we saw before:
+
+```
+scons platform=<yourplatform> target=release
+```
+
+This will produce a library file under the `bin/` folder.
+
+### Register the library
+
+Now the last step is to tell the plugin the library is available. In the `native/` folder, open the `hterrain.gdnlib` resource in a text editor, and add the path to the library under the `[entry]` category. Here is an example of how it should look like for several platforms:
+
+```
+[general]
+
+singleton=false
+load_once=true
+symbol_prefix="godot_"
+reloadable=false
+
+[entry]
+
+OSX.64 = "res://addons/zylann.hterrain/native/bin/osx64/libhterrain_native.dylib"
+OSX.32 = "res://addons/zylann.hterrain/native/bin/osx32/libhterrain_native.dylib"
+Windows.64 = "res://addons/zylann.hterrain/native/bin/win64/libhterrain_native.dll"
+X11.64 = "res://addons/zylann.hterrain/native/bin/linux/libhterrain_native.so"
+
+[dependencies]
+
+Windows.64=[ ]
+X11.64=[ ]
+```
+
+Finally, open the `factory.gd` script, and add an OS entry for your platform. The plugin should now be ready to use the native library.
+
+### Debugging
+
+If you get a crash or misbehavior, check logs first to make sure Godot was able to load the library. If you want to use a C++ debugger, you can repeat this setup, only replacing `release` with `debug` when running SCons. This will then allow you to attach to Godot and place breakpoints (which works best if you also use a debug Godot version).
+
+
+Troubleshooting
+-----------------
+
+We do the best we can on our free time to make this plugin usable, but it's possible bugs appear. Some of them are known issues. If you have a problem, please refer to the [issue tracker](https://github.com/Zylann/godot_heightmap_plugin/issues).
+
+
+### Before reporting any bug
+
+- Make sure you have the latest version of the plugin
+- Make sure it hasn't been reported already (including closed issues)
+- Check your Godot version. This plugin only works starting from Godot 3.1, and does not support 4.x yet. It is also possible that some issues exist in Godot 3.1 but could only be fixed in later versions.
+- Make sure you are using the GLES3 renderer. GLES2 is not supported.
+- Make sure your addons folder is located at `res://addons`, and does not contain uppercase letters. This might work on Windows but it will break after export.
+
+
+### If you report a new bug
+
+If none of the initial checks help and you want to post a new issue, do the following:
+
+- Check the console for messages, warnings and errors. These are helpful to diagnose the issue.
+- Try to reproduce the bug with precise reproduction steps, and indicate them
+- Provide a test project with those steps (unless it's reproducible from an empty project), so that we can reproduce the bug and fix it more easily. Github allows you to drag-and-drop zip files.
+- Indicate your OS, Godot version and graphics card model. Those are present in logs as well.
+
+
+### Terrain not saving / not up to date / not showing
+
+This issue happened a few times and had various causes so if the checks mentioned before don't help:
+
+- Check the contents of your terrain's data folder. It must contain a `.hterrain` file and a few textures.
+- If they are present, make sure Godot has imported those textures. If it didn't, unfocus the editor, and focus it back (you should see a short progress bar as it does it)
+- Check if you used Ctrl+Z (undo) after a non-undoable action, like described in [issue #101](https://github.com/Zylann/godot_heightmap_plugin/issues/101)
+- Make sure your `res://addons` folder is named `addons` *exactly lowercase*. It should not be named `Addons`. Plugins can fail if this convention is not respected.
+- If your problem relates to collisions in editor, update the collider using `Terrain -> Update Editor Collider`, because this one does not update automatically yet
+- Godot seems to randomly forget where the terrain saver is, but I need help to find out why because I could never reproduce it. See [issue #120](https://github.com/Zylann/godot_heightmap_plugin/issues/120)
+
+
+### Temporary files
+
+The plugin creates temporary files to avoid cluttering memory. They are necessary for some functionalities to work. Those files should be cleaned up automatically when you close the editor or if you turn off the plugin. However, if a crash occurs or something else goes wrong, they might not get removed. If you want to check them out, they are located in `user://hterrain_image_cache`.
+
+On Windows, that directory corresponds to `C:\Users\Username\AppData\Roaming\Godot\app_userdata\ProjectName\hterrain_image_cache`.
+
+See [Godot's documentation](https://docs.godotengine.org/en/stable/tutorials/io/data_paths.html#editor-data-paths) for other platforms.
diff --git a/game/addons/zylann.hterrain/doc/mkdocs.yml b/game/addons/zylann.hterrain/doc/mkdocs.yml
new file mode 100644
index 0000000..17a6625
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/mkdocs.yml
@@ -0,0 +1,11 @@
+site_name: HTerrain plugin documentation
+theme: readthedocs
+
+markdown_extensions:
+ # Makes permalinks appear on headings
+ - toc:
+ permalink: True
+ # Makes boxes for notes and warnings
+ - admonition
+ # Better highlighter which supports GDScript
+ - codehilite
diff --git a/game/addons/zylann.hterrain/doc/requirements.txt b/game/addons/zylann.hterrain/doc/requirements.txt
new file mode 100644
index 0000000..3143080
--- /dev/null
+++ b/game/addons/zylann.hterrain/doc/requirements.txt
@@ -0,0 +1 @@
+mkdocs>=1.1.2 \ No newline at end of file
diff --git a/game/addons/zylann.hterrain/hterrain.gd b/game/addons/zylann.hterrain/hterrain.gd
new file mode 100644
index 0000000..4a186a7
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain.gd
@@ -0,0 +1,1665 @@
+@tool
+extends Node3D
+
+const HT_NativeFactory = preload("./native/factory.gd")
+const HT_Mesher = preload("./hterrain_mesher.gd")
+const HT_Grid = preload("./util/grid.gd")
+const HTerrainData = preload("./hterrain_data.gd")
+const HTerrainChunk = preload("./hterrain_chunk.gd")
+const HTerrainChunkDebug = preload("./hterrain_chunk_debug.gd")
+const HT_Util = preload("./util/util.gd")
+const HTerrainCollider = preload("./hterrain_collider.gd")
+const HTerrainTextureSet = preload("./hterrain_texture_set.gd")
+const HT_Logger = preload("./util/logger.gd")
+
+const SHADER_CLASSIC4 = "Classic4"
+const SHADER_CLASSIC4_LITE = "Classic4Lite"
+const SHADER_LOW_POLY = "LowPoly"
+const SHADER_ARRAY = "Array"
+const SHADER_MULTISPLAT16 = "MultiSplat16"
+const SHADER_MULTISPLAT16_LITE = "MultiSplat16Lite"
+const SHADER_CUSTOM = "Custom"
+
+const MIN_MAP_SCALE = 0.01
+
+# Note, the `str()` syntax is no longer accepted in constants in Godot 4
+const _SHADER_TYPE_HINT_STRING = \
+ "Classic4," + \
+ "Classic4Lite," + \
+ "LowPoly," + \
+ "Array," + \
+ "MultiSplat16," + \
+ "MultiSplat16Lite," + \
+ "Custom"
+
+# TODO Had to downgrade this to support Godot 3.1.
+# Referring to other constants with this syntax isn't working...
+#const _SHADER_TYPE_HINT_STRING = str(
+# SHADER_CLASSIC4, ",",
+# SHADER_CLASSIC4_LITE, ",",
+# SHADER_LOW_POLY, ",",
+# SHADER_ARRAY, ",",
+# SHADER_CUSTOM
+#)
+
+const _builtin_shaders = {
+ SHADER_CLASSIC4: {
+ path = "res://addons/zylann.hterrain/shaders/simple4.gdshader",
+ global_path = "res://addons/zylann.hterrain/shaders/simple4_global.gdshader"
+ },
+ SHADER_CLASSIC4_LITE: {
+ path = "res://addons/zylann.hterrain/shaders/simple4_lite.gdshader",
+ global_path = "res://addons/zylann.hterrain/shaders/simple4_global.gdshader"
+ },
+ SHADER_LOW_POLY: {
+ path = "res://addons/zylann.hterrain/shaders/low_poly.gdshader",
+ global_path = "" # Not supported
+ },
+ SHADER_ARRAY: {
+ path = "res://addons/zylann.hterrain/shaders/array.gdshader",
+ global_path = "res://addons/zylann.hterrain/shaders/array_global.gdshader"
+ },
+ SHADER_MULTISPLAT16: {
+ path = "res://addons/zylann.hterrain/shaders/multisplat16.gdshader",
+ global_path = "res://addons/zylann.hterrain/shaders/multisplat16_global.gdshader"
+ },
+ SHADER_MULTISPLAT16_LITE: {
+ path = "res://addons/zylann.hterrain/shaders/multisplat16_lite.gdshader",
+ global_path = "res://addons/zylann.hterrain/shaders/multisplat16_global.gdshader"
+ }
+}
+
+const _NORMAL_BAKER_PATH = "res://addons/zylann.hterrain/tools/normalmap_baker.gd"
+const _LOOKDEV_SHADER_PATH = "res://addons/zylann.hterrain/shaders/lookdev.gdshader"
+
+const SHADER_PARAM_INVERSE_TRANSFORM = "u_terrain_inverse_transform"
+const SHADER_PARAM_NORMAL_BASIS = "u_terrain_normal_basis"
+
+const SHADER_PARAM_GROUND_PREFIX = "u_ground_" # + name + _0, _1, _2, _3...
+
+# Those parameters are filtered out in the inspector,
+# because they are not supposed to be set through it
+const _api_shader_params = {
+ "u_terrain_heightmap": true,
+ "u_terrain_normalmap": true,
+ "u_terrain_colormap": true,
+ "u_terrain_splatmap": true,
+ "u_terrain_splatmap_1": true,
+ "u_terrain_splatmap_2": true,
+ "u_terrain_splatmap_3": true,
+ "u_terrain_splat_index_map": true,
+ "u_terrain_splat_weight_map": true,
+ "u_terrain_globalmap": true,
+
+ "u_terrain_inverse_transform": true,
+ "u_terrain_normal_basis": true,
+
+ "u_ground_albedo_bump_0": true,
+ "u_ground_albedo_bump_1": true,
+ "u_ground_albedo_bump_2": true,
+ "u_ground_albedo_bump_3": true,
+
+ "u_ground_normal_roughness_0": true,
+ "u_ground_normal_roughness_1": true,
+ "u_ground_normal_roughness_2": true,
+ "u_ground_normal_roughness_3": true,
+
+ "u_ground_albedo_bump_array": true,
+ "u_ground_normal_roughness_array": true
+}
+
+const _api_shader_ground_albedo_params = {
+ "u_ground_albedo_bump_0": true,
+ "u_ground_albedo_bump_1": true,
+ "u_ground_albedo_bump_2": true,
+ "u_ground_albedo_bump_3": true
+}
+
+const _ground_texture_array_shader_params = [
+ "u_ground_albedo_bump_array",
+ "u_ground_normal_roughness_array"
+]
+
+const _splatmap_shader_params = [
+ "u_terrain_splatmap",
+ "u_terrain_splatmap_1",
+ "u_terrain_splatmap_2",
+ "u_terrain_splatmap_3"
+]
+
+const MIN_CHUNK_SIZE = 16
+const MAX_CHUNK_SIZE = 64
+
+# Same as HTerrainTextureSet.get_texture_type_name, used for shader parameter names.
+# Indexed by HTerrainTextureSet.TYPE_*
+const _ground_enum_to_name = [
+ "albedo_bump",
+ "normal_roughness"
+]
+
+const _DEBUG_AABB = false
+
+signal transform_changed(global_transform)
+
+@export_range(0.0, 1.0) var ambient_wind : float:
+ get:
+ return ambient_wind
+ set(amplitude):
+ if ambient_wind == amplitude:
+ return
+ ambient_wind = amplitude
+ for layer in _detail_layers:
+ layer.update_material()
+
+
+@export_range(2, 5) var lod_scale := 2.0:
+ get:
+ return lod_scale
+ set(value):
+ _lodder.set_split_scale(value)
+
+
+# Prefer using this instead of scaling the node's transform.
+# Node3D.scale isn't used because it's not suitable for terrains,
+# it would scale grass too and other environment objects.
+# TODO Replace with `size` in world units?
+@export var map_scale := Vector3(1, 1, 1):
+ get:
+ return map_scale
+ set(p_map_scale):
+ if map_scale == p_map_scale:
+ return
+ p_map_scale.x = maxf(p_map_scale.x, MIN_MAP_SCALE)
+ p_map_scale.y = maxf(p_map_scale.y, MIN_MAP_SCALE)
+ p_map_scale.z = maxf(p_map_scale.z, MIN_MAP_SCALE)
+ map_scale = p_map_scale
+ _on_transform_changed()
+
+
+@export var centered := false:
+ get:
+ return centered
+ set(p_centered):
+ if p_centered == centered:
+ return
+ centered = p_centered
+ _on_transform_changed()
+
+
+var _custom_shader : Shader = null
+var _custom_globalmap_shader : Shader = null
+var _shader_type := SHADER_CLASSIC4_LITE
+var _shader_uses_texture_array := false
+var _material := ShaderMaterial.new()
+var _material_params_need_update := false
+# Possible values are the same as the enum `GeometryInstance.SHADOW_CASTING_SETTING_*`.
+var _cast_shadow_setting := GeometryInstance3D.SHADOW_CASTING_SETTING_ON
+
+var _render_layer_mask := 1
+
+# Actual number of textures supported by the shader currently selected
+var _ground_texture_count_cache := 0
+
+var _used_splatmaps_count_cache := 0
+var _is_using_indexed_splatmap := false
+
+var _texture_set := HTerrainTextureSet.new()
+var _texture_set_migration_textures = null
+
+var _data: HTerrainData = null
+
+var _mesher := HT_Mesher.new()
+var _lodder = HT_NativeFactory.get_quad_tree_lod()
+var _viewer_pos_world := Vector3()
+
+# [lod][z][x] -> chunk
+# This container owns chunks
+var _chunks := []
+var _chunk_size: int = 32
+var _pending_chunk_updates := []
+
+var _detail_layers := []
+
+var _collision_enabled := true
+var _collider: HTerrainCollider = null
+var _collision_layer := 1
+var _collision_mask := 1
+
+# Stats & debug
+var _updated_chunks := 0
+var _logger = HT_Logger.get_for(self)
+
+# Editor-only
+var _normals_baker = null
+
+var _lookdev_enabled := false
+var _lookdev_material : ShaderMaterial
+
+
+func _init():
+ _logger.debug("Create HeightMap")
+ # This sets up the defaults. They may be overridden shortly after by the scene loader.
+
+ _lodder.set_callbacks(_cb_make_chunk, _cb_recycle_chunk, _cb_get_vertical_bounds)
+
+ set_notify_transform(true)
+
+ # TODO Temporary!
+ # This is a workaround for https://github.com/godotengine/godot/issues/24488
+ _material.set_shader_parameter("u_ground_uv_scale", 20)
+ _material.set_shader_parameter("u_ground_uv_scale_vec4", Color(20, 20, 20, 20))
+ _material.set_shader_parameter("u_depth_blending", true)
+
+ _material.shader = load(_builtin_shaders[_shader_type].path)
+
+ _texture_set.changed.connect(_on_texture_set_changed)
+
+ if _collision_enabled:
+ if _check_heightmap_collider_support():
+ _collider = HTerrainCollider.new(self, _collision_layer, _collision_mask)
+
+
+func _get_property_list():
+ # A lot of properties had to be exported like this instead of using `export`,
+ # because Godot 3 does not support easy categorization and lacks some hints
+ var props = [
+ {
+ # Terrain data is exposed only as a path in the editor,
+ # because it can only be saved if it has a directory selected.
+ # That property is not used in scene saving (data is instead).
+ "name": "data_directory",
+ "type": TYPE_STRING,
+ "usage": PROPERTY_USAGE_EDITOR,
+ "hint": PROPERTY_HINT_DIR
+ },
+ {
+ # The actual data resource is only exposed for storage.
+ # I had to name it so that Godot won't try to assign _data directly
+ # instead of using the setter I made...
+ "name": "_terrain_data",
+ "type": TYPE_OBJECT,
+ "usage": PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_RESOURCE_TYPE,
+ # This actually triggers `ERROR: Cannot get class`,
+ # if it were to be shown in the inspector.
+ # See https://github.com/godotengine/godot/pull/41264
+ "hint_string": "HTerrainData"
+ },
+ {
+ "name": "chunk_size",
+ "type": TYPE_INT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ #"hint": PROPERTY_HINT_ENUM,
+ "hint_string": "16, 32"
+ },
+ {
+ "name": "Collision",
+ "type": TYPE_NIL,
+ "usage": PROPERTY_USAGE_GROUP
+ },
+ {
+ "name": "collision_enabled",
+ "type": TYPE_BOOL,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE
+ },
+ {
+ "name": "collision_layer",
+ "type": TYPE_INT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS
+ },
+ {
+ "name": "collision_mask",
+ "type": TYPE_INT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS
+ },
+ {
+ "name": "Rendering",
+ "type": TYPE_NIL,
+ "usage": PROPERTY_USAGE_GROUP
+ },
+ {
+ "name": "shader_type",
+ "type": TYPE_STRING,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_ENUM,
+ "hint_string": _SHADER_TYPE_HINT_STRING
+ },
+ {
+ "name": "custom_shader",
+ "type": TYPE_OBJECT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_RESOURCE_TYPE,
+ "hint_string": "Shader"
+ },
+ {
+ "name": "custom_globalmap_shader",
+ "type": TYPE_OBJECT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_RESOURCE_TYPE,
+ "hint_string": "Shader"
+ },
+ {
+ "name": "texture_set",
+ "type": TYPE_OBJECT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_RESOURCE_TYPE,
+ "hint_string": "Resource"
+ # TODO Cannot properly hint the type of the resource in the inspector.
+ # This triggers `ERROR: Cannot get class 'HTerrainTextureSet'`
+ # See https://github.com/godotengine/godot/pull/41264
+ #"hint_string": "HTerrainTextureSet"
+ },
+ {
+ "name": "render_layers",
+ "type": TYPE_INT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_LAYERS_3D_RENDER
+ },
+ {
+ "name": "cast_shadow",
+ "type": TYPE_INT,
+ "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE,
+ "hint": PROPERTY_HINT_ENUM,
+ "hint_string": "Off,On,DoubleSided,ShadowsOnly"
+ }
+ ]
+
+ if _material.shader != null:
+ var shader_params := RenderingServer.get_shader_parameter_list(_material.shader.get_rid())
+ for p in shader_params:
+ if _api_shader_params.has(p.name):
+ continue
+ var cp := {}
+ for k in p:
+ cp[k] = p[k]
+ cp.name = str("shader_params/", p.name)
+ props.append(cp)
+
+ return props
+
+
+func _get(key: StringName):
+ if key == &"data_directory":
+ return _get_data_directory()
+
+ if key == &"_terrain_data":
+ if _data == null or _data.resource_path == "":
+ # Consider null if the data is not set or has no path,
+ # because in those cases we can't save the terrain properly
+ return null
+ else:
+ return _data
+
+ if key == &"texture_set":
+ return get_texture_set()
+
+ elif key == &"shader_type":
+ return get_shader_type()
+
+ elif key == &"custom_shader":
+ return get_custom_shader()
+
+ elif key == &"custom_globalmap_shader":
+ return _custom_globalmap_shader
+
+ elif key.begins_with("shader_params/"):
+ var param_name := key.substr(len("shader_params/"))
+ return get_shader_param(param_name)
+
+ elif key == &"chunk_size":
+ return _chunk_size
+
+ elif key == &"collision_enabled":
+ return _collision_enabled
+
+ elif key == &"collision_layer":
+ return _collision_layer
+
+ elif key == &"collision_mask":
+ return _collision_mask
+
+ elif key == &"render_layers":
+ return get_render_layer_mask()
+
+ elif key == &"cast_shadow":
+ return _cast_shadow_setting
+
+
+func _set(key: StringName, value):
+ if key == &"data_directory":
+ _set_data_directory(value)
+
+ # Can't use setget when the exported type is custom,
+ # because we were also are forced to use _get_property_list...
+ elif key == &"_terrain_data":
+ set_data(value)
+
+ elif key == &"texture_set":
+ set_texture_set(value)
+
+ # Legacy, left for migration from 1.4
+ var key_str := String(key)
+ if key_str.begins_with("ground/"):
+ for ground_texture_type in HTerrainTextureSet.TYPE_COUNT:
+ var type_name = _ground_enum_to_name[ground_texture_type]
+ if key_str.begins_with(str("ground/", type_name, "_")):
+ var i = key_str.substr(len(key_str) - 1).to_int()
+ if _texture_set_migration_textures == null:
+ _texture_set_migration_textures = []
+ while i >= len(_texture_set_migration_textures):
+ _texture_set_migration_textures.append([null, null])
+ var texs = _texture_set_migration_textures[i]
+ texs[ground_texture_type] = value
+
+ elif key == &"shader_type":
+ set_shader_type(value)
+
+ elif key == &"custom_shader":
+ set_custom_shader(value)
+
+ elif key == &"custom_globalmap_shader":
+ _custom_globalmap_shader = value
+
+ elif key.begins_with("shader_params/"):
+ var param_name := String(key).substr(len("shader_params/"))
+ set_shader_param(param_name, value)
+
+ elif key == &"chunk_size":
+ set_chunk_size(value)
+
+ elif key == &"collision_enabled":
+ set_collision_enabled(value)
+
+ elif key == &"collision_layer":
+ _collision_layer = value
+ if _collider != null:
+ _collider.set_collision_layer(value)
+
+ elif key == &"collision_mask":
+ _collision_mask = value
+ if _collider != null:
+ _collider.set_collision_mask(value)
+
+ elif key == &"render_layers":
+ return set_render_layer_mask(value)
+
+ elif key == &"cast_shadow":
+ set_cast_shadow(value)
+
+
+func get_texture_set() -> HTerrainTextureSet:
+ return _texture_set
+
+
+func set_texture_set(new_set: HTerrainTextureSet):
+ if _texture_set == new_set:
+ return
+
+ if _texture_set != null:
+ # TODO This causes `ERROR: Nonexistent signal 'changed' in [Resource:36653]` for some reason
+ _texture_set.changed.disconnect(_on_texture_set_changed)
+
+ _texture_set = new_set
+
+ if _texture_set != null:
+ _texture_set.changed.connect(_on_texture_set_changed)
+
+ _material_params_need_update = true
+
+
+func _on_texture_set_changed():
+ _material_params_need_update = true
+ HT_Util.update_configuration_warning(self, false)
+
+
+func get_shader_param(param_name: String):
+ return _material.get_shader_parameter(param_name)
+
+
+func set_shader_param(param_name: String, v):
+ _material.set_shader_parameter(param_name, v)
+
+
+func set_render_layer_mask(mask: int):
+ _render_layer_mask = mask
+ _for_all_chunks(HT_SetRenderLayerMaskAction.new(mask))
+
+
+func get_render_layer_mask() -> int:
+ return _render_layer_mask
+
+
+func set_cast_shadow(setting: int):
+ if setting == _cast_shadow_setting:
+ return
+ _cast_shadow_setting = setting
+ _for_all_chunks(HT_SetCastShadowSettingAction.new(setting))
+
+
+func get_cast_shadow() -> int:
+ return _cast_shadow_setting
+
+
+func _set_data_directory(dirpath: String):
+ if dirpath != _get_data_directory():
+ if dirpath == "":
+ set_data(null)
+ else:
+ var fpath := dirpath.path_join(HTerrainData.META_FILENAME)
+ if FileAccess.file_exists(fpath):
+ # Load existing
+ var d = load(fpath)
+ set_data(d)
+ else:
+ # Create new
+ var d := HTerrainData.new()
+ d.resource_path = fpath
+ set_data(d)
+ else:
+ _logger.warn("Setting twice the same terrain directory??")
+
+
+func _get_data_directory() -> String:
+ if _data != null:
+ return _data.resource_path.get_base_dir()
+ return ""
+
+
+func _check_heightmap_collider_support() -> bool:
+ return true
+ # var v = Engine.get_version_info()
+ # if v.major == 3 and v.minor == 0 and v.patch < 4:
+ # _logger.error("Heightmap collision shape not supported in this version of Godot,"
+ # + " please upgrade to 3.0.4 or later")
+ # return false
+ # return true
+
+
+func set_collision_enabled(enabled: bool):
+ if _collision_enabled != enabled:
+ _collision_enabled = enabled
+ if _collision_enabled:
+ if _check_heightmap_collider_support():
+ _collider = HTerrainCollider.new(self, _collision_layer, _collision_mask)
+ # Collision is not updated with data here,
+ # because loading is quite a mess at the moment...
+ # 1) This function can be called while no data has been set yet
+ # 2) I don't want to update the collider more times than necessary
+ # because it's expensive
+ # 3) I would prefer not defer that to the moment the terrain is
+ # added to the tree, because it would screw up threaded loading
+ else:
+ # Despite this object being a Reference,
+ # this should free it, as it should be the only reference
+ _collider = null
+
+
+func _for_all_chunks(action):
+ for lod in len(_chunks):
+ var grid = _chunks[lod]
+ for y in len(grid):
+ var row = grid[y]
+ for x in len(row):
+ var chunk = row[x]
+ if chunk != null:
+ action.exec(chunk)
+
+
+func get_chunk_size() -> int:
+ return _chunk_size
+
+
+func set_chunk_size(p_cs: int):
+ assert(typeof(p_cs) == TYPE_INT)
+ _logger.debug(str("Setting chunk size to ", p_cs))
+ var cs := HT_Util.next_power_of_two(p_cs)
+ if cs < MIN_CHUNK_SIZE:
+ cs = MIN_CHUNK_SIZE
+ if cs > MAX_CHUNK_SIZE:
+ cs = MAX_CHUNK_SIZE
+ if p_cs != cs:
+ _logger.debug(str("Chunk size snapped to ", cs))
+ if cs == _chunk_size:
+ return
+ _chunk_size = cs
+ _reset_ground_chunks()
+
+
+# Compat
+func set_map_scale(p_map_scale: Vector3):
+ map_scale = p_map_scale
+
+
+# Compat
+func set_centered(p_centered: bool):
+ centered = p_centered
+
+
+# Gets the global transform to apply to terrain geometry,
+# which is different from Node3D.global_transform gives.
+# global_transform must only have translation and rotation. Scale support is undefined.
+func get_internal_transform() -> Transform3D:
+ var gt := global_transform
+ var it := Transform3D(gt.basis * Basis().scaled(map_scale), gt.origin)
+ if centered and _data != null:
+ var half_size := 0.5 * (_data.get_resolution() - 1.0)
+ it.origin += it.basis * (-Vector3(half_size, 0, half_size))
+ return it
+
+
+func get_internal_transform_unscaled():
+ var gt := global_transform
+ if centered and _data != null:
+ var half_size := 0.5 * (_data.get_resolution() - 1.0)
+ gt.origin += gt.basis * (-Vector3(half_size, 0, half_size))
+ return gt
+
+
+# Converts a world-space position into a map-space position.
+# Map space X and Z coordinates correspond to pixel coordinates of the heightmap.
+func world_to_map(world_pos: Vector3) -> Vector3:
+ return get_internal_transform().affine_inverse() * world_pos
+
+
+func _notification(what: int):
+ match what:
+ NOTIFICATION_PREDELETE:
+ _logger.debug("Destroy HTerrain")
+ # Note: might get rid of a circular ref in GDScript port
+ _clear_all_chunks()
+
+ NOTIFICATION_ENTER_WORLD:
+ _logger.debug("Enter world")
+
+ if _texture_set_migration_textures != null and _texture_set.get_slots_count() == 0:
+ # Convert from 1.4 textures properties to HTerrainTextureSet
+ # TODO Unfortunately this might not always work,
+ # once again because Godot wants the editor's UndoRedo to have modified the
+ # resource for it to be saved... which sucks, sucks, and sucks.
+ # I'll never say it enough.
+ _texture_set.set_mode(HTerrainTextureSet.MODE_TEXTURES)
+ while _texture_set.get_slots_count() < len(_texture_set_migration_textures):
+ _texture_set.insert_slot(-1)
+ for slot_index in len(_texture_set_migration_textures):
+ var texs = _texture_set_migration_textures[slot_index]
+ for type in len(texs):
+ _texture_set.set_texture(slot_index, type, texs[type])
+ _texture_set_migration_textures = null
+
+ _for_all_chunks(HT_EnterWorldAction.new(get_world_3d()))
+ if _collider != null:
+ _collider.set_world(get_world_3d())
+ _collider.set_transform(get_internal_transform())
+
+ NOTIFICATION_EXIT_WORLD:
+ _logger.debug("Exit world")
+ _for_all_chunks(HT_ExitWorldAction.new())
+ if _collider != null:
+ _collider.set_world(null)
+
+ NOTIFICATION_TRANSFORM_CHANGED:
+ _on_transform_changed()
+
+ NOTIFICATION_VISIBILITY_CHANGED:
+ _logger.debug("Visibility changed")
+ _for_all_chunks(HT_VisibilityChangedAction.new(is_visible_in_tree()))
+
+
+func _on_transform_changed():
+ _logger.debug("Transform changed")
+
+ if not is_inside_tree():
+ # The transform and other properties can be set by the scene loader,
+ # before we enter the tree
+ return
+
+ var gt = get_internal_transform()
+
+ _for_all_chunks(HT_TransformChangedAction.new(gt))
+
+ _material_params_need_update = true
+
+ if _collider != null:
+ _collider.set_transform(gt)
+
+ transform_changed.emit(gt)
+
+
+func _enter_tree():
+ _logger.debug("Enter tree")
+
+ if Engine.is_editor_hint() and _normals_baker == null:
+ _normals_baker = load(_NORMAL_BAKER_PATH).new()
+ add_child(_normals_baker)
+ _normals_baker.set_terrain_data(_data)
+
+ set_process(true)
+
+
+func _clear_all_chunks():
+ # The lodder has to be cleared because otherwise it will reference dangling pointers
+ _lodder.clear()
+
+ #_for_all_chunks(DeleteChunkAction.new())
+
+ for i in len(_chunks):
+ _chunks[i].clear()
+
+
+func _get_chunk_at(pos_x: int, pos_y: int, lod: int) -> HTerrainChunk:
+ if lod < len(_chunks):
+ return HT_Grid.grid_get_or_default(_chunks[lod], pos_x, pos_y, null)
+ return null
+
+
+func get_data() -> HTerrainData:
+ return _data
+
+
+func has_data() -> bool:
+ return _data != null
+
+
+func set_data(new_data: HTerrainData):
+ assert(new_data == null or new_data is HTerrainData)
+
+ _logger.debug(str("Set new data ", new_data))
+
+ if _data == new_data:
+ return
+
+ if has_data():
+ _logger.debug("Disconnecting old HeightMapData")
+ _data.resolution_changed.disconnect(_on_data_resolution_changed)
+ _data.region_changed.disconnect(_on_data_region_changed)
+ _data.map_changed.disconnect(_on_data_map_changed)
+ _data.map_added.disconnect(_on_data_map_added)
+ _data.map_removed.disconnect(_on_data_map_removed)
+
+ if _normals_baker != null:
+ _normals_baker.set_terrain_data(null)
+ _normals_baker.queue_free()
+ _normals_baker = null
+
+ _data = new_data
+
+ # Note: the order of these two is important
+ _clear_all_chunks()
+
+ if has_data():
+ _logger.debug("Connecting new HeightMapData")
+
+ # This is a small UX improvement so that the user sees a default terrain
+ if is_inside_tree() and Engine.is_editor_hint():
+ if _data.get_resolution() == 0:
+ _data._edit_load_default()
+
+ if _collider != null:
+ _collider.create_from_terrain_data(_data)
+
+ _data.resolution_changed.connect(_on_data_resolution_changed)
+ _data.region_changed.connect(_on_data_region_changed)
+ _data.map_changed.connect(_on_data_map_changed)
+ _data.map_added.connect(_on_data_map_added)
+ _data.map_removed.connect(_on_data_map_removed)
+
+ if _normals_baker != null:
+ _normals_baker.set_terrain_data(_data)
+
+ _on_data_resolution_changed()
+
+ _material_params_need_update = true
+
+ HT_Util.update_configuration_warning(self, true)
+
+ _logger.debug("Set data done")
+
+
+# The collider might be used in editor for other tools (like snapping to floor),
+# so the whole collider can be updated in one go.
+# It may be slow for ingame use, so prefer calling it when appropriate.
+func update_collider():
+ assert(_collision_enabled)
+ assert(_collider != null)
+ _collider.create_from_terrain_data(_data)
+
+
+func _on_data_resolution_changed():
+ _reset_ground_chunks()
+
+
+func _reset_ground_chunks():
+ if _data == null:
+ return
+
+ _clear_all_chunks()
+
+ _pending_chunk_updates.clear()
+
+ _lodder.create_from_sizes(_chunk_size, _data.get_resolution())
+
+ _chunks.resize(_lodder.get_lod_count())
+
+ var cres := _data.get_resolution() / _chunk_size
+ var csize_x := cres
+ var csize_y := cres
+
+ for lod in _lodder.get_lod_count():
+ _logger.debug(str("Create grid for lod ", lod, ", ", csize_x, "x", csize_y))
+ var grid = HT_Grid.create_grid(csize_x, csize_y)
+ _chunks[lod] = grid
+ csize_x /= 2
+ csize_y /= 2
+
+ _mesher.configure(_chunk_size, _chunk_size, _lodder.get_lod_count())
+
+
+func _on_data_region_changed(min_x, min_y, size_x, size_y, channel):
+ # Testing only heights because it's the only channel that can impact geometry and LOD
+ if channel == HTerrainData.CHANNEL_HEIGHT:
+ set_area_dirty(min_x, min_y, size_x, size_y)
+
+ if _normals_baker != null:
+ _normals_baker.request_tiles_in_region(Vector2(min_x, min_y), Vector2(size_x, size_y))
+
+
+func _on_data_map_changed(type: int, index: int):
+ if type == HTerrainData.CHANNEL_DETAIL \
+ or type == HTerrainData.CHANNEL_HEIGHT \
+ or type == HTerrainData.CHANNEL_NORMAL \
+ or type == HTerrainData.CHANNEL_GLOBAL_ALBEDO:
+
+ for layer in _detail_layers:
+ layer.update_material()
+
+ if type != HTerrainData.CHANNEL_DETAIL:
+ _material_params_need_update = true
+
+
+func _on_data_map_added(type: int, index: int):
+ if type == HTerrainData.CHANNEL_DETAIL:
+ for layer in _detail_layers:
+ # Shift indexes up since one was inserted
+ if layer.layer_index >= index:
+ layer.layer_index += 1
+ layer.update_material()
+ else:
+ _material_params_need_update = true
+ HT_Util.update_configuration_warning(self, true)
+
+
+func _on_data_map_removed(type: int, index: int):
+ if type == HTerrainData.CHANNEL_DETAIL:
+ for layer in _detail_layers:
+ # Shift indexes down since one was removed
+ if layer.layer_index > index:
+ layer.layer_index -= 1
+ layer.update_material()
+ else:
+ _material_params_need_update = true
+ HT_Util.update_configuration_warning(self, true)
+
+
+func get_shader_type() -> String:
+ return _shader_type
+
+
+func set_shader_type(type: String):
+ if type == _shader_type:
+ return
+ _shader_type = type
+
+ if _shader_type == SHADER_CUSTOM:
+ _material.shader = _custom_shader
+ else:
+ _material.shader = load(_builtin_shaders[_shader_type].path)
+
+ _material_params_need_update = true
+
+ if Engine.is_editor_hint():
+ notify_property_list_changed()
+
+
+func get_custom_shader() -> Shader:
+ return _custom_shader
+
+
+func set_custom_shader(shader: Shader):
+ if _custom_shader == shader:
+ return
+
+ if _custom_shader != null:
+ _custom_shader.changed.disconnect(_on_custom_shader_changed)
+
+ if Engine.is_editor_hint() and shader != null and is_inside_tree():
+ # When the new shader is empty, allow to fork from the previous shader
+ if shader.code.is_empty():
+ _logger.debug("Populating custom shader with default code")
+ var src := _material.shader
+ if src == null:
+ src = load(_builtin_shaders[SHADER_CLASSIC4].path)
+ shader.code = src.code
+ # TODO If code isn't empty,
+ # verify existing parameters and issue a warning if important ones are missing
+
+ _custom_shader = shader
+
+ if _shader_type == SHADER_CUSTOM:
+ _material.shader = _custom_shader
+
+ if _custom_shader != null:
+ _custom_shader.changed.connect(_on_custom_shader_changed)
+ if _shader_type == SHADER_CUSTOM:
+ _material_params_need_update = true
+
+ if Engine.is_editor_hint():
+ notify_property_list_changed()
+
+
+func _on_custom_shader_changed():
+ _material_params_need_update = true
+
+
+func _update_material_params():
+ assert(_material != null)
+ _logger.debug("Updating terrain material params")
+
+ var terrain_textures := {}
+ var res := Vector2(-1, -1)
+
+ var lookdev_material : ShaderMaterial
+ if _lookdev_enabled:
+ lookdev_material = _get_lookdev_material()
+
+ # TODO Only get textures the shader supports
+
+ if has_data():
+ for map_type in HTerrainData.CHANNEL_COUNT:
+ var count := _data.get_map_count(map_type)
+ for i in count:
+ var param_name: String = HTerrainData.get_map_shader_param_name(map_type, i)
+ terrain_textures[param_name] = _data.get_texture(map_type, i)
+ res.x = _data.get_resolution()
+ res.y = res.x
+
+ # Set all parameters from the terrain system.
+
+ if is_inside_tree():
+ var gt := get_internal_transform()
+ var t := gt.affine_inverse()
+ _material.set_shader_parameter(SHADER_PARAM_INVERSE_TRANSFORM, t)
+
+ # This is needed to properly transform normals if the terrain is scaled
+ var normal_basis = gt.basis.inverse().transposed()
+ _material.set_shader_parameter(SHADER_PARAM_NORMAL_BASIS, normal_basis)
+
+ if lookdev_material != null:
+ lookdev_material.set_shader_parameter(SHADER_PARAM_INVERSE_TRANSFORM, t)
+ lookdev_material.set_shader_parameter(SHADER_PARAM_NORMAL_BASIS, normal_basis)
+
+ for param_name in terrain_textures:
+ var tex = terrain_textures[param_name]
+ _material.set_shader_parameter(param_name, tex)
+ if lookdev_material != null:
+ lookdev_material.set_shader_parameter(param_name, tex)
+
+ if _texture_set != null:
+ match _texture_set.get_mode():
+ HTerrainTextureSet.MODE_TEXTURES:
+ var slots_count := _texture_set.get_slots_count()
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ for slot_index in slots_count:
+ var texture := _texture_set.get_texture(slot_index, type)
+ var shader_param := _get_ground_texture_shader_param_name(type, slot_index)
+ _material.set_shader_parameter(shader_param, texture)
+
+ HTerrainTextureSet.MODE_TEXTURE_ARRAYS:
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var texture_array := _texture_set.get_texture_array(type)
+ var shader_params := _get_ground_texture_array_shader_param_name(type)
+ _material.set_shader_parameter(shader_params, texture_array)
+
+ _shader_uses_texture_array = false
+ _is_using_indexed_splatmap = false
+ _used_splatmaps_count_cache = 0
+
+ var shader := _material.shader
+ if shader != null:
+ var param_list := RenderingServer.get_shader_parameter_list(shader.get_rid())
+ _ground_texture_count_cache = 0
+ for p in param_list:
+ if _api_shader_ground_albedo_params.has(p.name):
+ _ground_texture_count_cache += 1
+ elif p.name == "u_ground_albedo_bump_array":
+ _shader_uses_texture_array = true
+ elif p.name == "u_terrain_splat_index_map":
+ _is_using_indexed_splatmap = true
+ elif p.name in _splatmap_shader_params:
+ _used_splatmaps_count_cache += 1
+
+
+# TODO Rename is_shader_using_texture_array()
+# Tells if the current shader is using a texture array.
+# This will only be valid once the material has been updated internally.
+# (for example it won't be valid before the terrain is added to the SceneTree)
+func is_using_texture_array() -> bool:
+ return _shader_uses_texture_array
+
+
+# Gets how many splatmaps the current shader is using.
+# This will only be valid once the material has been updated internally.
+# (for example it won't be valid before the terrain is added to the SceneTree)
+func get_used_splatmaps_count() -> int:
+ return _used_splatmaps_count_cache
+
+
+# Tells if the current shader is using a splatmap type based on indexes and weights.
+# This will only be valid once the material has been updated internally.
+# (for example it won't be valid before the terrain is added to the SceneTree)
+func is_using_indexed_splatmap() -> bool:
+ return _is_using_indexed_splatmap
+
+
+static func _get_common_shader_params(shader1: Shader, shader2: Shader) -> Array:
+ var shader1_param_names := {}
+ var common_params := []
+
+ var shader1_params := RenderingServer.get_shader_parameter_list(shader1.get_rid())
+ var shader2_params := RenderingServer.get_shader_parameter_list(shader2.get_rid())
+
+ for p in shader1_params:
+ shader1_param_names[p.name] = true
+
+ for p in shader2_params:
+ if shader1_param_names.has(p.name):
+ common_params.append(p.name)
+
+ return common_params
+
+
+# Helper used for globalmap baking
+func setup_globalmap_material(mat: ShaderMaterial):
+ mat.shader = get_globalmap_shader()
+ if mat.shader == null:
+ _logger.error("Could not find a shader to use for baking the global map.")
+ return
+ # Copy all parameters shaders have in common
+ var common_params = _get_common_shader_params(mat.shader, _material.shader)
+ for param_name in common_params:
+ var v = _material.get_shader_parameter(param_name)
+ mat.set_shader_parameter(param_name, v)
+
+
+# Gets which shader will be used to bake the globalmap
+func get_globalmap_shader() -> Shader:
+ if _shader_type == SHADER_CUSTOM:
+ if _custom_globalmap_shader != null:
+ return _custom_globalmap_shader
+ _logger.warn("The terrain uses a custom shader but doesn't have one for baking the "
+ + "global map. Will attempt to use a built-in shader.")
+ if is_using_texture_array():
+ return load(_builtin_shaders[SHADER_ARRAY].global_path) as Shader
+ return load(_builtin_shaders[SHADER_CLASSIC4].global_path) as Shader
+ return load(_builtin_shaders[_shader_type].global_path) as Shader
+
+
+# Compat
+func set_lod_scale(p_lod_scale: float):
+ lod_scale = p_lod_scale
+
+
+# Compat
+func get_lod_scale() -> float:
+ return lod_scale
+
+
+func get_lod_count() -> int:
+ return _lodder.get_lod_count()
+
+
+# 3
+# o---o
+# 0 | | 1
+# o---o
+# 2
+# Directions to go to neighbor chunks
+const s_dirs = [
+ [-1, 0], # SEAM_LEFT
+ [1, 0], # SEAM_RIGHT
+ [0, -1], # SEAM_BOTTOM
+ [0, 1] # SEAM_TOP
+]
+
+# 7 6
+# o---o---o
+# 0 | | 5
+# o o
+# 1 | | 4
+# o---o---o
+# 2 3
+#
+# Directions to go to neighbor chunks of higher LOD
+const s_rdirs = [
+ [-1, 0],
+ [-1, 1],
+ [0, 2],
+ [1, 2],
+ [2, 1],
+ [2, 0],
+ [1, -1],
+ [0, -1]
+]
+
+
+func _edit_update_viewer_position(camera: Camera3D):
+ _update_viewer_position(camera)
+
+
+func _update_viewer_position(camera: Camera3D):
+ if camera == null:
+ var viewport := get_viewport()
+ if viewport != null:
+ camera = viewport.get_camera_3d()
+
+ if camera == null:
+ return
+
+ if camera.projection == Camera3D.PROJECTION_ORTHOGONAL:
+ # In this mode, due to the fact Godot does not allow negative near plane,
+ # users have to pull the camera node very far away, but it confuses LOD
+ # into very low detail, while the seen area remains the same.
+ # So we need to base LOD on a different metric.
+ var cam_pos := camera.global_transform.origin
+ var cam_dir := -camera.global_transform.basis.z
+ var max_distance := camera.far * 1.2
+ var hit_cell_pos = cell_raycast(cam_pos, cam_dir, max_distance)
+
+ if hit_cell_pos != null:
+ var cell_to_world := get_internal_transform()
+ var h := _data.get_height_at(hit_cell_pos.x, hit_cell_pos.y)
+ _viewer_pos_world = cell_to_world * Vector3(hit_cell_pos.x, h, hit_cell_pos.y)
+
+ else:
+ _viewer_pos_world = camera.global_transform.origin
+
+
+func _process(delta: float):
+ if not Engine.is_editor_hint():
+ # In editor, the camera is only accessible from an editor plugin
+ _update_viewer_position(null)
+
+ if has_data():
+ if _data.is_locked():
+ # Can't use the data for now
+ return
+
+ if _data.get_resolution() != 0:
+ var gt := get_internal_transform()
+ # Viewer position such that 1 unit == 1 pixel in the heightmap
+ var viewer_pos_heightmap_local := gt.affine_inverse() * _viewer_pos_world
+ #var time_before = OS.get_ticks_msec()
+ _lodder.update(viewer_pos_heightmap_local)
+ #var time_elapsed = OS.get_ticks_msec() - time_before
+ #if Engine.get_frames_drawn() % 60 == 0:
+ # _logger.debug(str("Lodder time: ", time_elapsed))
+
+ if _data.get_map_count(HTerrainData.CHANNEL_DETAIL) > 0:
+ # Note: the detail system is not affected by map scale,
+ # so we have to send viewer position in world space
+ for layer in _detail_layers:
+ layer.process(delta, _viewer_pos_world)
+
+ _updated_chunks = 0
+
+ # Add more chunk updates for neighboring (seams):
+ # This adds updates to higher-LOD chunks around lower-LOD ones,
+ # because they might not needed to update by themselves, but the fact a neighbor
+ # chunk got joined or split requires them to create or revert seams
+ var precount = _pending_chunk_updates.size()
+ for i in precount:
+ var u: HT_PendingChunkUpdate = _pending_chunk_updates[i]
+
+ # In case the chunk got split
+ for d in 4:
+ var ncpos_x = u.pos_x + s_dirs[d][0]
+ var ncpos_y = u.pos_y + s_dirs[d][1]
+
+ var nchunk := _get_chunk_at(ncpos_x, ncpos_y, u.lod)
+ if nchunk != null and nchunk.is_active():
+ # Note: this will append elements to the array we are iterating on,
+ # but we iterate only on the previous count so it should be fine
+ _add_chunk_update(nchunk, ncpos_x, ncpos_y, u.lod)
+
+ # In case the chunk got joined
+ if u.lod > 0:
+ var cpos_upper_x := u.pos_x * 2
+ var cpos_upper_y := u.pos_y * 2
+ var nlod := u.lod - 1
+
+ for rd in 8:
+ var ncpos_upper_x = cpos_upper_x + s_rdirs[rd][0]
+ var ncpos_upper_y = cpos_upper_y + s_rdirs[rd][1]
+
+ var nchunk := _get_chunk_at(ncpos_upper_x, ncpos_upper_y, nlod)
+ if nchunk != null and nchunk.is_active():
+ _add_chunk_update(nchunk, ncpos_upper_x, ncpos_upper_y, nlod)
+
+ # Update chunks
+ var lvisible := is_visible_in_tree()
+ for i in len(_pending_chunk_updates):
+ var u: HT_PendingChunkUpdate = _pending_chunk_updates[i]
+ var chunk := _get_chunk_at(u.pos_x, u.pos_y, u.lod)
+ assert(chunk != null)
+ _update_chunk(chunk, u.lod, lvisible)
+ _updated_chunks += 1
+
+ _pending_chunk_updates.clear()
+
+ if _material_params_need_update:
+ _update_material_params()
+ HT_Util.update_configuration_warning(self, false)
+ _material_params_need_update = false
+
+ # DEBUG
+# if(_updated_chunks > 0):
+# _logger.debug(str("Updated {0} chunks".format(_updated_chunks)))
+
+
+func _update_chunk(chunk: HTerrainChunk, lod: int, p_visible: bool):
+ assert(has_data())
+
+ # Check for my own seams
+ var seams := 0
+ var cpos_x := chunk.cell_origin_x / (_chunk_size << lod)
+ var cpos_y := chunk.cell_origin_y / (_chunk_size << lod)
+ var cpos_lower_x := cpos_x / 2
+ var cpos_lower_y := cpos_y / 2
+
+ # Check for lower-LOD chunks around me
+ for d in 4:
+ var ncpos_lower_x = (cpos_x + s_dirs[d][0]) / 2
+ var ncpos_lower_y = (cpos_y + s_dirs[d][1]) / 2
+ if ncpos_lower_x != cpos_lower_x or ncpos_lower_y != cpos_lower_y:
+ var nchunk := _get_chunk_at(ncpos_lower_x, ncpos_lower_y, lod + 1)
+ if nchunk != null and nchunk.is_active():
+ seams |= (1 << d)
+
+ var mesh := _mesher.get_chunk(lod, seams)
+ chunk.set_mesh(mesh)
+
+ # Because chunks are rendered using vertex shader displacement,
+ # the renderer cannot rely on the mesh's AABB.
+ var s := _chunk_size << lod
+ var aabb := _data.get_region_aabb(chunk.cell_origin_x, chunk.cell_origin_y, s, s)
+ aabb.position.x = 0
+ aabb.position.z = 0
+ chunk.set_aabb(aabb)
+
+ chunk.set_visible(p_visible)
+ chunk.set_pending_update(false)
+
+
+func _add_chunk_update(chunk: HTerrainChunk, pos_x: int, pos_y: int, lod: int):
+ if chunk.is_pending_update():
+ #_logger.debug("Chunk update is already pending!")
+ return
+
+ assert(lod < len(_chunks))
+ assert(pos_x >= 0)
+ assert(pos_y >= 0)
+ assert(pos_y < len(_chunks[lod]))
+ assert(pos_x < len(_chunks[lod][pos_y]))
+
+ # No update pending for this chunk, create one
+ var u := HT_PendingChunkUpdate.new()
+ u.pos_x = pos_x
+ u.pos_y = pos_y
+ u.lod = lod
+ _pending_chunk_updates.push_back(u)
+
+ chunk.set_pending_update(true)
+
+ # TODO Neighboring chunks might need an update too
+ # because of normals and seams being updated
+
+
+# Used when editing an existing terrain
+func set_area_dirty(origin_in_cells_x: int, origin_in_cells_y: int, \
+ size_in_cells_x: int, size_in_cells_y: int):
+
+ var cpos0_x := origin_in_cells_x / _chunk_size
+ var cpos0_y := origin_in_cells_y / _chunk_size
+ var csize_x := (size_in_cells_x - 1) / _chunk_size + 1
+ var csize_y := (size_in_cells_y - 1) / _chunk_size + 1
+
+ # For each lod
+ for lod in _lodder.get_lod_count():
+ # Get grid and chunk size
+ var grid = _chunks[lod]
+ var s : int = _lodder.get_lod_factor(lod)
+
+ # Convert rect into this lod's coordinates:
+ # Pick min and max (included), divide them, then add 1 to max so it's excluded again
+ var min_x := cpos0_x / s
+ var min_y := cpos0_y / s
+ var max_x := (cpos0_x + csize_x - 1) / s + 1
+ var max_y := (cpos0_y + csize_y - 1) / s + 1
+
+ # Find which chunks are within
+ for cy in range(min_y, max_y):
+ for cx in range(min_x, max_x):
+ var chunk = HT_Grid.grid_get_or_default(grid, cx, cy, null)
+ if chunk != null and chunk.is_active():
+ _add_chunk_update(chunk, cx, cy, lod)
+
+
+# Called when a chunk is needed to be seen
+func _cb_make_chunk(cpos_x: int, cpos_y: int, lod: int):
+ # TODO What if cpos is invalid? _get_chunk_at will return NULL but that's still invalid
+ var chunk := _get_chunk_at(cpos_x, cpos_y, lod)
+
+ if chunk == null:
+ # This is the first time this chunk is required at this lod, generate it
+
+ var lod_factor : int = _lodder.get_lod_factor(lod)
+ var origin_in_cells_x := cpos_x * _chunk_size * lod_factor
+ var origin_in_cells_y := cpos_y * _chunk_size * lod_factor
+
+ var material = _material
+ if _lookdev_enabled:
+ material = _get_lookdev_material()
+
+ if _DEBUG_AABB:
+ chunk = HTerrainChunkDebug.new(
+ self, origin_in_cells_x, origin_in_cells_y, material)
+ else:
+ chunk = HTerrainChunk.new(self, origin_in_cells_x, origin_in_cells_y, material)
+ chunk.parent_transform_changed(get_internal_transform())
+
+ chunk.set_render_layer_mask(_render_layer_mask)
+ chunk.set_cast_shadow_setting(_cast_shadow_setting)
+
+ var grid = _chunks[lod]
+ var row = grid[cpos_y]
+ row[cpos_x] = chunk
+
+ # Make sure it gets updated
+ _add_chunk_update(chunk, cpos_x, cpos_y, lod)
+
+ chunk.set_active(true)
+ return chunk
+
+
+# Called when a chunk is no longer seen
+func _cb_recycle_chunk(chunk: HTerrainChunk, cx: int, cy: int, lod: int):
+ chunk.set_visible(false)
+ chunk.set_active(false)
+
+
+func _cb_get_vertical_bounds(cpos_x: int, cpos_y: int, lod: int):
+ var chunk_size : int = _chunk_size * _lodder.get_lod_factor(lod)
+ var origin_in_cells_x := cpos_x * chunk_size
+ var origin_in_cells_y := cpos_y * chunk_size
+ # This is a hack for speed,
+ # because the proper algorithm appears to be too slow for GDScript.
+ # It should be good enough for most common cases, unless you have super-sharp cliffs.
+ return _data.get_point_aabb(
+ origin_in_cells_x + chunk_size / 2,
+ origin_in_cells_y + chunk_size / 2)
+# var aabb = _data.get_region_aabb(
+# origin_in_cells_x, origin_in_cells_y, chunk_size, chunk_size)
+# return Vector2(aabb.position.y, aabb.end.y)
+
+
+# static func _get_height_or_default(im: Image, pos_x: int, pos_y: int):
+# if pos_x < 0 or pos_y < 0 or pos_x >= im.get_width() or pos_y >= im.get_height():
+# return 0.0
+# return im.get_pixel(pos_x, pos_y).r
+
+
+# Performs a raycast to the terrain without using the collision engine.
+# This is mostly useful in the editor, where the collider can't be updated in realtime.
+# Returns cell hit position as Vector2, or null if there was no hit.
+# TODO Cannot type hint nullable return value
+func cell_raycast(origin_world: Vector3, dir_world: Vector3, max_distance: float):
+ assert(typeof(origin_world) == TYPE_VECTOR3)
+ assert(typeof(dir_world) == TYPE_VECTOR3)
+ if not has_data():
+ return null
+ # Transform to local (takes map scale into account)
+ var to_local := get_internal_transform().affine_inverse()
+ var origin = to_local * origin_world
+ var dir = to_local.basis * dir_world
+ return _data.cell_raycast(origin, dir, max_distance)
+
+
+static func _get_ground_texture_shader_param_name(ground_texture_type: int, slot: int) -> String:
+ assert(typeof(slot) == TYPE_INT and slot >= 0)
+ _check_ground_texture_type(ground_texture_type)
+ return str(SHADER_PARAM_GROUND_PREFIX, _ground_enum_to_name[ground_texture_type], "_", slot)
+
+
+# @obsolete
+func get_ground_texture(slot: int, type: int) -> Texture:
+ _logger.error(
+ "HTerrain.get_ground_texture is obsolete, " +
+ "use HTerrain.get_texture_set().get_texture(slot, type) instead")
+ var shader_param = _get_ground_texture_shader_param_name(type, slot)
+ return _material.get_shader_parameter(shader_param)
+
+
+# @obsolete
+func set_ground_texture(slot: int, type: int, tex: Texture):
+ _logger.error(
+ "HTerrain.set_ground_texture is obsolete, " +
+ "use HTerrain.get_texture_set().set_texture(slot, type, texture) instead")
+ assert(tex == null or tex is Texture)
+ var shader_param := _get_ground_texture_shader_param_name(type, slot)
+ _material.set_shader_parameter(shader_param, tex)
+
+
+func _get_ground_texture_array_shader_param_name(type: int) -> String:
+ return _ground_texture_array_shader_params[type] as String
+
+
+# @obsolete
+func get_ground_texture_array(type: int) -> TextureLayered:
+ _logger.error(
+ "HTerrain.get_ground_texture_array is obsolete, " +
+ "use HTerrain.get_texture_set().get_texture_array(type) instead")
+ var param_name := _get_ground_texture_array_shader_param_name(type)
+ return _material.get_shader_parameter(param_name)
+
+
+# @obsolete
+func set_ground_texture_array(type: int, texture_array: TextureLayered):
+ _logger.error(
+ "HTerrain.set_ground_texture_array is obsolete, " +
+ "use HTerrain.get_texture_set().set_texture_array(type, texarray) instead")
+ var param_name := _get_ground_texture_array_shader_param_name(type)
+ _material.set_shader_parameter(param_name, texture_array)
+
+
+func _internal_add_detail_layer(layer):
+ assert(_detail_layers.find(layer) == -1)
+ _detail_layers.append(layer)
+
+
+func _internal_remove_detail_layer(layer):
+ assert(_detail_layers.find(layer) != -1)
+ _detail_layers.erase(layer)
+
+
+# Returns a list copy of all child HTerrainDetailLayer nodes.
+# The order in that list has no relevance.
+func get_detail_layers() -> Array:
+ return _detail_layers.duplicate()
+
+
+# @obsolete
+func set_detail_texture(slot, tex):
+ _logger.error(
+ "HTerrain.set_detail_texture is obsolete, use HTerrainDetailLayer.texture instead")
+
+
+# @obsolete
+func get_detail_texture(slot):
+ _logger.error(
+ "HTerrain.get_detail_texture is obsolete, use HTerrainDetailLayer.texture instead")
+
+
+# Compat
+func set_ambient_wind(amplitude: float):
+ ambient_wind = amplitude
+
+
+static func _check_ground_texture_type(ground_texture_type: int):
+ assert(typeof(ground_texture_type) == TYPE_INT)
+ assert(ground_texture_type >= 0 and ground_texture_type < HTerrainTextureSet.TYPE_COUNT)
+
+
+# @obsolete
+func get_ground_texture_slot_count() -> int:
+ _logger.error("get_ground_texture_slot_count is obsolete, " \
+ + "use get_cached_ground_texture_slot_count instead")
+ return get_max_ground_texture_slot_count()
+
+# @obsolete
+func get_max_ground_texture_slot_count() -> int:
+ _logger.error("get_ground_texture_slot_count is obsolete, " \
+ + "use get_cached_ground_texture_slot_count instead")
+ return get_cached_ground_texture_slot_count()
+
+
+# This is a cached value based on the actual number of texture parameters
+# in the current shader. It won't update immediately when the shader changes,
+# only after a frame. This is mostly used in the editor.
+func get_cached_ground_texture_slot_count() -> int:
+ return _ground_texture_count_cache
+
+
+func _edit_debug_draw(ci: CanvasItem):
+ _lodder.debug_draw_tree(ci)
+
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var warnings := PackedStringArray()
+
+ if _data == null:
+ warnings.append("The terrain is missing data.\n" \
+ + "Select the `Data Directory` property in the inspector to assign it.")
+
+ if _texture_set == null:
+ warnings.append("The terrain does not have a HTerrainTextureSet assigned\n" \
+ + "This is required if you want to paint textures on it.")
+
+ else:
+ var mode := _texture_set.get_mode()
+
+ if mode == HTerrainTextureSet.MODE_TEXTURES and is_using_texture_array():
+ warnings.append("The current shader needs texture arrays,\n" \
+ + "but the current HTerrainTextureSet is setup with individual textures.\n" \
+ + "You may need to switch it to TEXTURE_ARRAYS mode,\n" \
+ + "or re-import images in this mode with the import tool.")
+
+ elif mode == HTerrainTextureSet.MODE_TEXTURE_ARRAYS and not is_using_texture_array():
+ warnings.append("The current shader needs individual textures,\n" \
+ + "but the current HTerrainTextureSet is setup with texture arrays.\n" \
+ + "You may need to switch it to TEXTURES mode,\n" \
+ + "or re-import images in this mode with the import tool.")
+
+ # TODO Warn about unused data maps, have a tool to clean them up
+ return warnings
+
+
+func set_lookdev_enabled(enable: bool):
+ if _lookdev_enabled == enable:
+ return
+ _lookdev_enabled = enable
+ _material_params_need_update = true
+ if _lookdev_enabled:
+ _for_all_chunks(HT_SetMaterialAction.new(_get_lookdev_material()))
+ else:
+ _for_all_chunks(HT_SetMaterialAction.new(_material))
+
+
+func set_lookdev_shader_param(param_name: String, value):
+ var mat = _get_lookdev_material()
+ mat.set_shader_parameter(param_name, value)
+
+
+func is_lookdev_enabled() -> bool:
+ return _lookdev_enabled
+
+
+func _get_lookdev_material() -> ShaderMaterial:
+ if _lookdev_material == null:
+ _lookdev_material = ShaderMaterial.new()
+ _lookdev_material.shader = load(_LOOKDEV_SHADER_PATH)
+ return _lookdev_material
+
+
+class HT_PendingChunkUpdate:
+ var pos_x := 0
+ var pos_y := 0
+ var lod := 0
+
+
+class HT_EnterWorldAction:
+ var world : World3D = null
+ func _init(w):
+ world = w
+ func exec(chunk):
+ chunk.enter_world(world)
+
+
+class HT_ExitWorldAction:
+ func exec(chunk):
+ chunk.exit_world()
+
+
+class HT_TransformChangedAction:
+ var transform : Transform3D
+ func _init(t):
+ transform = t
+ func exec(chunk):
+ chunk.parent_transform_changed(transform)
+
+
+class HT_VisibilityChangedAction:
+ var visible := false
+ func _init(v):
+ visible = v
+ func exec(chunk):
+ chunk.set_visible(visible and chunk.is_active())
+
+
+#class HT_DeleteChunkAction:
+# func exec(chunk):
+# pass
+
+
+class HT_SetMaterialAction:
+ var material : Material = null
+ func _init(m):
+ material = m
+ func exec(chunk):
+ chunk.set_material(material)
+
+
+class HT_SetRenderLayerMaskAction:
+ var mask: int = 0
+ func _init(m: int):
+ mask = m
+ func exec(chunk):
+ chunk.set_render_layer_mask(mask)
+
+
+class HT_SetCastShadowSettingAction:
+ var setting := 0
+ func _init(s: int):
+ setting = s
+ func exec(chunk):
+ chunk.set_cast_shadow_setting(setting)
diff --git a/game/addons/zylann.hterrain/hterrain_chunk.gd b/game/addons/zylann.hterrain/hterrain_chunk.gd
new file mode 100644
index 0000000..400a057
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_chunk.gd
@@ -0,0 +1,125 @@
+@tool
+
+var cell_origin_x := 0
+var cell_origin_y := 0
+
+var _visible : bool
+# This is true when the chunk is meant to be displayed.
+# A chunk can be active and hidden (due to the terrain being hidden).
+var _active : bool
+
+var _pending_update : bool
+
+var _mesh_instance : RID
+# Need to keep a reference so that the mesh RID doesn't get freed
+# TODO Use RID directly, no need to keep all those meshes in memory
+var _mesh : Mesh = null
+
+
+# TODO p_parent is HTerrain, can't add type hint due to cyclic reference
+func _init(p_parent: Node3D, p_cell_x: int, p_cell_y: int, p_material: Material):
+ assert(p_parent is Node3D)
+ assert(typeof(p_cell_x) == TYPE_INT)
+ assert(typeof(p_cell_y) == TYPE_INT)
+ assert(p_material is Material)
+
+ cell_origin_x = p_cell_x
+ cell_origin_y = p_cell_y
+
+ var rs := RenderingServer
+
+ _mesh_instance = rs.instance_create()
+
+ if p_material != null:
+ rs.instance_geometry_set_material_override(_mesh_instance, p_material.get_rid())
+
+ var world := p_parent.get_world_3d()
+ if world != null:
+ rs.instance_set_scenario(_mesh_instance, world.get_scenario())
+
+ _visible = true
+ # TODO Is this needed?
+ rs.instance_set_visible(_mesh_instance, _visible)
+
+ _active = true
+ _pending_update = false
+
+
+func _notification(p_what: int):
+ if p_what == NOTIFICATION_PREDELETE:
+ if _mesh_instance != RID():
+ RenderingServer.free_rid(_mesh_instance)
+ _mesh_instance = RID()
+
+
+func is_active() -> bool:
+ return _active
+
+
+func set_active(a: bool):
+ _active = a
+
+
+func is_pending_update() -> bool:
+ return _pending_update
+
+
+func set_pending_update(p: bool):
+ _pending_update = p
+
+
+func enter_world(world: World3D):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_scenario(_mesh_instance, world.get_scenario())
+
+
+func exit_world():
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_scenario(_mesh_instance, RID())
+
+
+func parent_transform_changed(parent_transform: Transform3D):
+ assert(_mesh_instance != RID())
+ var local_transform := Transform3D(Basis(), Vector3(cell_origin_x, 0, cell_origin_y))
+ var world_transform := parent_transform * local_transform
+ RenderingServer.instance_set_transform(_mesh_instance, world_transform)
+
+
+func set_mesh(mesh: Mesh):
+ assert(_mesh_instance != RID())
+ if mesh == _mesh:
+ return
+ RenderingServer.instance_set_base(_mesh_instance, mesh.get_rid() if mesh != null else RID())
+ _mesh = mesh
+
+
+func set_material(material: Material):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_geometry_set_material_override( \
+ _mesh_instance, material.get_rid() if material != null else RID())
+
+
+func set_visible(visible: bool):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_visible(_mesh_instance, visible)
+ _visible = visible
+
+
+func is_visible() -> bool:
+ return _visible
+
+
+func set_aabb(aabb: AABB):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_custom_aabb(_mesh_instance, aabb)
+
+
+func set_render_layer_mask(mask: int):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_layer_mask(_mesh_instance, mask)
+
+
+func set_cast_shadow_setting(setting: int):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_geometry_set_cast_shadows_setting(_mesh_instance, setting)
+
diff --git a/game/addons/zylann.hterrain/hterrain_chunk_debug.gd b/game/addons/zylann.hterrain/hterrain_chunk_debug.gd
new file mode 100644
index 0000000..7bf3d41
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_chunk_debug.gd
@@ -0,0 +1,67 @@
+@tool
+extends "hterrain_chunk.gd"
+
+# I wrote this because Godot has no debug option to show AABBs.
+# https://github.com/godotengine/godot/issues/20722
+
+
+const HT_DirectMeshInstance = preload("./util/direct_mesh_instance.gd")
+const HT_Util = preload("./util/util.gd")
+
+
+var _debug_cube : HT_DirectMeshInstance = null
+var _aabb := AABB()
+var _parent_transform := Transform3D()
+
+
+func _init(p_parent: Node3D, p_cell_x: int, p_cell_y: int, p_material: Material):
+ super(p_parent, p_cell_x, p_cell_y, p_material)
+
+ var wirecube : Mesh
+ if not p_parent.has_meta("debug_wirecube_mesh"):
+ wirecube = HT_Util.create_wirecube_mesh()
+ var mat := StandardMaterial3D.new()
+ mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ wirecube.surface_set_material(0, mat)
+ # Cache the debug cube in the parent node to avoid re-creating each time
+ p_parent.set_meta("debug_wirecube_mesh", wirecube)
+ else:
+ wirecube = p_parent.get_meta("debug_wirecube_mesh")
+
+ _debug_cube = HT_DirectMeshInstance.new()
+ _debug_cube.set_mesh(wirecube)
+ _debug_cube.set_world(p_parent.get_world_3d())
+
+
+func enter_world(world: World3D):
+ super(world)
+ _debug_cube.enter_world(world)
+
+
+func exit_world():
+ super()
+ _debug_cube.exit_world()
+
+
+func parent_transform_changed(parent_transform: Transform3D):
+ super(parent_transform)
+ _parent_transform = parent_transform
+ _debug_cube.set_transform(_compute_aabb())
+
+
+func set_visible(visible: bool):
+ super(visible)
+ _debug_cube.set_visible(visible)
+
+
+func set_aabb(aabb: AABB):
+ super(aabb)
+ #aabb.position.y += 0.2*randf()
+ _aabb = aabb
+ _debug_cube.set_transform(_compute_aabb())
+
+
+func _compute_aabb():
+ var pos = Vector3(cell_origin_x, 0, cell_origin_y)
+ return _parent_transform * Transform3D(Basis().scaled(_aabb.size), pos + _aabb.position)
+
diff --git a/game/addons/zylann.hterrain/hterrain_collider.gd b/game/addons/zylann.hterrain/hterrain_collider.gd
new file mode 100644
index 0000000..7abb95f
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_collider.gd
@@ -0,0 +1,118 @@
+@tool
+
+const HT_Logger = preload("./util/logger.gd")
+const HTerrainData = preload("./hterrain_data.gd")
+
+var _shape_rid := RID()
+var _body_rid := RID()
+var _terrain_transform := Transform3D()
+var _terrain_data : HTerrainData = null
+var _logger = HT_Logger.get_for(self)
+
+
+func _init(attached_node: Node, initial_layer: int, initial_mask: int):
+ _logger.debug("HTerrainCollider: creating body")
+ assert(attached_node != null)
+ _shape_rid = PhysicsServer3D.heightmap_shape_create()
+ _body_rid = PhysicsServer3D.body_create()
+ PhysicsServer3D.body_set_mode(_body_rid, PhysicsServer3D.BODY_MODE_STATIC)
+
+ PhysicsServer3D.body_set_collision_layer(_body_rid, initial_layer)
+ PhysicsServer3D.body_set_collision_mask(_body_rid, initial_mask)
+
+ # TODO This is an attempt to workaround https://github.com/godotengine/godot/issues/24390
+ PhysicsServer3D.body_set_ray_pickable(_body_rid, false)
+
+ # Assigng dummy data
+ # TODO This is a workaround to https://github.com/godotengine/godot/issues/25304
+ PhysicsServer3D.shape_set_data(_shape_rid, {
+ "width": 2,
+ "depth": 2,
+ "heights": PackedFloat32Array([0, 0, 0, 0]),
+ "min_height": -1,
+ "max_height": 1
+ })
+
+ PhysicsServer3D.body_add_shape(_body_rid, _shape_rid)
+
+ # This makes collision hits report the provided object as `collider`
+ PhysicsServer3D.body_attach_object_instance_id(_body_rid, attached_node.get_instance_id())
+
+
+func set_collision_layer(layer: int):
+ PhysicsServer3D.body_set_collision_layer(_body_rid, layer)
+
+
+func set_collision_mask(mask: int):
+ PhysicsServer3D.body_set_collision_mask(_body_rid, mask)
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_PREDELETE:
+ _logger.debug("Destroy HTerrainCollider")
+ PhysicsServer3D.free_rid(_body_rid)
+ # The shape needs to be freed after the body, otherwise the engine crashes
+ PhysicsServer3D.free_rid(_shape_rid)
+
+
+func set_transform(transform: Transform3D):
+ assert(_body_rid != RID())
+ _terrain_transform = transform
+ _update_transform()
+
+
+func set_world(world: World3D):
+ assert(_body_rid != RID())
+ PhysicsServer3D.body_set_space(_body_rid, world.get_space() if world != null else RID())
+
+
+func create_from_terrain_data(terrain_data: HTerrainData):
+ assert(terrain_data != null)
+ assert(not terrain_data.is_locked())
+ _logger.debug("HTerrainCollider: setting up heightmap")
+
+ _terrain_data = terrain_data
+
+ var aabb := terrain_data.get_aabb()
+
+ var width := terrain_data.get_resolution()
+ var depth := terrain_data.get_resolution()
+ var height := aabb.size.y
+
+ var shape_data = {
+ "width": terrain_data.get_resolution(),
+ "depth": terrain_data.get_resolution(),
+ "heights": terrain_data.get_all_heights(),
+ "min_height": aabb.position.y,
+ "max_height": aabb.end.y
+ }
+
+ PhysicsServer3D.shape_set_data(_shape_rid, shape_data)
+
+ _update_transform(aabb)
+
+
+func _update_transform(aabb=null):
+ if _terrain_data == null:
+ _logger.debug("HTerrainCollider: terrain data not set yet")
+ return
+
+# if aabb == null:
+# aabb = _terrain_data.get_aabb()
+
+ var width := _terrain_data.get_resolution()
+ var depth := _terrain_data.get_resolution()
+ #var height = aabb.size.y
+
+ #_terrain_transform
+
+ var trans := Transform3D(Basis(), 0.5 * Vector3(width - 1, 0, depth - 1))
+
+ # And then apply the terrain transform
+ trans = _terrain_transform * trans
+
+ PhysicsServer3D.body_set_state(_body_rid, PhysicsServer3D.BODY_STATE_TRANSFORM, trans)
+ # Cannot use shape transform when scaling is involved,
+ # because Godot is undoing that scale for some reason.
+ # See https://github.com/Zylann/godot_heightmap_plugin/issues/70
+ #PhysicsServer.body_set_shape_transform(_body_rid, 0, trans)
diff --git a/game/addons/zylann.hterrain/hterrain_data.gd b/game/addons/zylann.hterrain/hterrain_data.gd
new file mode 100644
index 0000000..74597f5
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_data.gd
@@ -0,0 +1,1843 @@
+
+# Holds data of the terrain.
+# This is mostly a set of textures using specific formats, some precalculated, and metadata.
+
+@tool
+extends Resource
+
+const HT_Grid = preload("./util/grid.gd")
+const HT_Util = preload("./util/util.gd")
+const HT_Errors = preload("./util/errors.gd")
+const HT_Logger = preload("./util/logger.gd")
+const HT_ImageFileCache = preload("./util/image_file_cache.gd")
+const HT_XYZFormat = preload("./util/xyz_format.gd")
+
+# Note: indexes matters for saving, don't re-order
+# TODO Rename "CHANNEL" to "MAP", makes more sense and less confusing with RGBA channels
+const CHANNEL_HEIGHT = 0
+const CHANNEL_NORMAL = 1
+const CHANNEL_SPLAT = 2
+const CHANNEL_COLOR = 3
+const CHANNEL_DETAIL = 4
+const CHANNEL_GLOBAL_ALBEDO = 5
+const CHANNEL_SPLAT_INDEX = 6
+const CHANNEL_SPLAT_WEIGHT = 7
+const CHANNEL_COUNT = 8
+
+const _map_types = {
+ CHANNEL_HEIGHT: {
+ name = "height",
+ shader_param_name = "u_terrain_heightmap",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RF,
+ default_fill = Color(0, 0, 0, 1),
+ default_count = 1,
+ can_be_saved_as_png = false,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_NORMAL: {
+ name = "normal",
+ shader_param_name = "u_terrain_normalmap",
+ filter = true,
+ mipmaps = false,
+ # TODO RGB8 is a lie, we should use RGBA8 and pack something in A I guess
+ texture_format = Image.FORMAT_RGB8,
+ default_fill = Color(0.5, 0.5, 1.0),
+ default_count = 1,
+ can_be_saved_as_png = true,
+ authored = false,
+ srgb = false
+ },
+ CHANNEL_SPLAT: {
+ name = "splat",
+ shader_param_name = [
+ "u_terrain_splatmap", # not _0 for compatibility
+ "u_terrain_splatmap_1",
+ "u_terrain_splatmap_2",
+ "u_terrain_splatmap_3"
+ ],
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RGBA8,
+ default_fill = [Color(1, 0, 0, 0), Color(0, 0, 0, 0)],
+ default_count = 1,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_COLOR: {
+ name = "color",
+ shader_param_name = "u_terrain_colormap",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RGBA8,
+ default_fill = Color(1, 1, 1, 1),
+ default_count = 1,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = true
+ },
+ CHANNEL_DETAIL: {
+ name = "detail",
+ shader_param_name = "u_terrain_detailmap",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_R8,
+ default_fill = Color(0, 0, 0),
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_GLOBAL_ALBEDO: {
+ name = "global_albedo",
+ shader_param_name = "u_terrain_globalmap",
+ filter = true,
+ mipmaps = true,
+ texture_format = Image.FORMAT_RGB8,
+ default_fill = null,
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = false,
+ srgb = true
+ },
+ CHANNEL_SPLAT_INDEX: {
+ name = "splat_index",
+ shader_param_name = "u_terrain_splat_index_map",
+ filter = false,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RGB8,
+ default_fill = Color(0, 0, 0),
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ },
+ CHANNEL_SPLAT_WEIGHT: {
+ name = "splat_weight",
+ shader_param_name = "u_terrain_splat_weight_map",
+ filter = true,
+ mipmaps = false,
+ texture_format = Image.FORMAT_RG8,
+ default_fill = Color(1, 0, 0),
+ default_count = 0,
+ can_be_saved_as_png = true,
+ authored = true,
+ srgb = false
+ }
+}
+
+# Resolution is a power of two + 1
+const MAX_RESOLUTION = 4097
+const MIN_RESOLUTION = 65 # must be higher than largest chunk size
+const DEFAULT_RESOLUTION = 513
+const SUPPORTED_RESOLUTIONS = [65, 129, 257, 513, 1025, 2049, 4097]
+
+const VERTICAL_BOUNDS_CHUNK_SIZE = 16
+# TODO Have undo chunk size to emphasise the fact it's independent
+
+const META_EXTENSION = "hterrain"
+const META_FILENAME = "data.hterrain"
+const META_VERSION = "0.11"
+
+signal resolution_changed
+signal region_changed(x, y, w, h, channel)
+signal map_added(type, index)
+signal map_removed(type, index)
+signal map_changed(type, index)
+
+
+# A map is a texture covering the terrain.
+# The usage of a map depends on its type (heightmap, normalmap, splatmap...).
+class HT_Map:
+ var texture: Texture2D
+ # Reference used in case we need the data CPU-side
+ var image: Image
+ # ID used for saving, because when adding/removing maps,
+ # we shouldn't rename texture files just because the indexes change.
+ # This is mostly for internal keeping.
+ # The API still uses indexes that may shift if your remove a map.
+ var id := -1
+ # Should be set to true if the map has unsaved modifications.
+ var modified := true
+
+ func _init(p_id: int):
+ id = p_id
+
+
+var _resolution := 0
+
+# There can be multiple maps of the same type, though most of them are single
+# [map_type][instance_index] => map
+var _maps := [[]]
+
+# RGF image where R is min height and G is max height
+var _chunked_vertical_bounds := Image.new()
+
+var _locked := false
+
+var _edit_disable_apply_undo := false
+var _logger := HT_Logger.get_for(self)
+
+
+func _init():
+ # Initialize default maps
+ _set_default_maps()
+
+
+func _set_default_maps():
+ _maps.resize(CHANNEL_COUNT)
+ for c in CHANNEL_COUNT:
+ var maps := []
+ var n : int = _map_types[c].default_count
+ for i in n:
+ maps.append(HT_Map.new(i))
+ _maps[c] = maps
+
+
+func _edit_load_default():
+ _logger.debug("Loading default data")
+ _set_default_maps()
+ resize(DEFAULT_RESOLUTION)
+
+
+# Don't use the data if this getter returns false
+func is_locked() -> bool:
+ return _locked
+
+
+func get_resolution() -> int:
+ return _resolution
+
+
+# @obsolete
+func set_resolution(p_res):
+ _logger.error("`HTerrainData.set_resolution()` is obsolete, use `resize()` instead")
+ resize(p_res)
+
+
+# @obsolete
+func set_resolution2(p_res, update_normals):
+ _logger.error("`HTerrainData.set_resolution2()` is obsolete, use `resize()` instead")
+ resize(p_res, true, Vector2(-1, -1))
+
+
+# Resizes all maps of the terrain. This may take some time to complete.
+# Note that no upload to GPU is done, you have to do it once you're done with all changes,
+# by calling `notify_region_change` or `notify_full_change`.
+# p_res: new resolution. Must be a power of two + 1.
+# stretch: if true, the terrain will be stretched in X and Z axes.
+# If false, it will be cropped or expanded.
+# anchor: if stretch is false, decides which side or corner to crop/expand the terrain from.
+#
+# There is an off-by-one in the data,
+# so for example a map of 512x512 will actually have 513x513 cells.
+# Here is why:
+# If we had an even amount of cells, it would produce this situation when making LOD chunks:
+#
+# x---x---x---x x---x---x---x
+# | | | | | |
+# x---x---x---x x x x x
+# | | | | | |
+# x---x---x---x x---x---x---x
+# | | | | | |
+# x---x---x---x x x x x
+#
+# LOD 0 LOD 1
+#
+# We would be forced to ignore the last cells because they would produce an irregular chunk.
+# We need an off-by-one because quads making up chunks SHARE their consecutive vertices.
+# One quad needs at least 2x2 cells to exist.
+# Two quads of the heightmap share an edge, which needs a total of 3x3 cells, not 4x4.
+# One chunk has 16x16 quads, so it needs 17x17 cells,
+# not 16, where the last cell is shared with the next chunk.
+# As a result, a map of 4x4 chunks needs 65x65 cells, not 64x64.
+func resize(p_res: int, stretch := true, anchor := Vector2(-1, -1)):
+ assert(typeof(p_res) == TYPE_INT)
+ assert(typeof(stretch) == TYPE_BOOL)
+ assert(typeof(anchor) == TYPE_VECTOR2)
+
+ _logger.debug(str("set_resolution ", p_res))
+
+ if p_res == get_resolution():
+ return
+
+ p_res = clampi(p_res, MIN_RESOLUTION, MAX_RESOLUTION)
+
+ # Power of two is important for LOD.
+ # Also, grid data is off by one,
+ # because for an even number of quads you need an odd number of vertices.
+ # To prevent size from increasing at every deserialization,
+ # remove 1 before applying power of two.
+ p_res = HT_Util.next_power_of_two(p_res - 1) + 1
+
+ _resolution = p_res;
+
+ for channel in CHANNEL_COUNT:
+ var maps : Array = _maps[channel]
+
+ for index in len(maps):
+ _logger.debug(str("Resizing ", get_map_debug_name(channel, index), "..."))
+
+ var map : HT_Map = maps[index]
+ var im := map.image
+
+ if im == null:
+ _logger.debug("Image not in memory, creating it")
+ im = Image.create(_resolution, _resolution, false, get_channel_format(channel))
+
+ var fill_color = _get_map_default_fill_color(channel, index)
+ if fill_color != null:
+ _logger.debug(str("Fill with ", fill_color))
+ im.fill(fill_color)
+
+ else:
+ if stretch and not _map_types[channel].authored:
+ # Create a blank new image, it will be automatically computed later
+ im = Image.create(_resolution, _resolution, false, get_channel_format(channel))
+ else:
+ if stretch:
+ if im.get_format() == Image.FORMAT_RGB8:
+ # Can't directly resize this format
+ var float_heightmap := convert_heightmap_to_float(im, _logger)
+ float_heightmap.resize(_resolution, _resolution)
+ im = Image.create(
+ float_heightmap.get_width(),
+ float_heightmap.get_height(), im.has_mipmaps(), im.get_format())
+ convert_float_heightmap_to_rgb8(float_heightmap, im)
+ else:
+ # Assuming float or single-component fixed-point
+ im.resize(_resolution, _resolution)
+ else:
+ var fill_color = _get_map_default_fill_color(channel, index)
+ im = HT_Util.get_cropped_image(im, _resolution, _resolution, \
+ fill_color, anchor)
+
+ map.image = im
+ map.modified = true
+
+ _update_all_vertical_bounds()
+
+ resolution_changed.emit()
+
+
+# TODO Can't hint it, the return is a nullable Color
+static func _get_map_default_fill_color(map_type: int, map_index: int):
+ var config = _map_types[map_type].default_fill
+ if config == null:
+ # No fill required
+ return null
+ if typeof(config) == TYPE_COLOR:
+ # Standard color fill
+ return config
+ assert(typeof(config) == TYPE_ARRAY)
+ assert(len(config) == 2)
+ if map_index == 0:
+ # First map has this config
+ return config[0]
+ # Others have this
+ return config[1]
+
+
+# Gets the height at the given cell position.
+# This height is raw and doesn't account for scaling of the terrain node.
+# This function is relatively slow due to locking, so don't use it to fetch large areas.
+func get_height_at(x: int, y: int) -> float:
+ # Height data must be loaded in RAM
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+ match im.get_format():
+ Image.FORMAT_RF:
+ return HT_Util.get_pixel_clamped(im, x, y).r
+ Image.FORMAT_RGB8:
+ return decode_height_from_rgb8_unorm(HT_Util.get_pixel_clamped(im, x, y))
+ _:
+ _logger.error(str("Invalid heigthmap format ", im.get_format()))
+ return 0.0
+
+
+# Gets the height at the given floating-point cell position.
+# This height is raw and doesn't account for scaling of the terrain node.
+# This function is relatively slow due to locking, so don't use it to fetch large areas
+func get_interpolated_height_at(pos: Vector3) -> float:
+ # Height data must be loaded in RAM
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+ var map_type = _map_types[CHANNEL_HEIGHT]
+ assert(im.get_format() == map_type.texture_format)
+
+ # The function takes a Vector3 for convenience so it's easier to use in 3D scripting
+ var x0 := int(floorf(pos.x))
+ var y0 := int(floorf(pos.z))
+
+ var xf := pos.x - x0
+ var yf := pos.z - y0
+
+ var h00 : float
+ var h10 : float
+ var h01 : float
+ var h11 : float
+
+ match im.get_format():
+ Image.FORMAT_RF:
+ h00 = HT_Util.get_pixel_clamped(im, x0, y0).r
+ h10 = HT_Util.get_pixel_clamped(im, x0 + 1, y0).r
+ h01 = HT_Util.get_pixel_clamped(im, x0, y0 + 1).r
+ h11 = HT_Util.get_pixel_clamped(im, x0 + 1, y0 + 1).r
+
+ Image.FORMAT_RGB8:
+ var c00 := HT_Util.get_pixel_clamped(im, x0, y0)
+ var c10 := HT_Util.get_pixel_clamped(im, x0 + 1, y0)
+ var c01 := HT_Util.get_pixel_clamped(im, x0, y0 + 1)
+ var c11 := HT_Util.get_pixel_clamped(im, x0 + 1, y0 + 1)
+
+ h00 = decode_height_from_rgb8_unorm(c00)
+ h10 = decode_height_from_rgb8_unorm(c10)
+ h01 = decode_height_from_rgb8_unorm(c01)
+ h11 = decode_height_from_rgb8_unorm(c11)
+
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+ return 0.0
+
+ # Bilinear filter
+ var h := lerpf(lerpf(h00, h10, xf), lerpf(h01, h11, xf), yf)
+ return h
+
+# Gets all heights within the given rectangle in cells.
+# This height is raw and doesn't account for scaling of the terrain node.
+# Data is returned as a PackedFloat32Array.
+func get_heights_region(x0: int, y0: int, w: int, h: int) -> PackedFloat32Array:
+ var im = get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ var min_x := clampi(x0, 0, im.get_width())
+ var min_y := clampi(y0, 0, im.get_height())
+ var max_x := clampi(x0 + w, 0, im.get_width() + 1)
+ var max_y := clampi(y0 + h, 0, im.get_height() + 1)
+
+ var heights := PackedFloat32Array()
+
+ var area := (max_x - min_x) * (max_y - min_y)
+ if area == 0:
+ _logger.debug("Empty heights region!")
+ return heights
+
+ heights.resize(area)
+
+ var i := 0
+
+ if im.get_format() == Image.FORMAT_RF or im.get_format() == Image.FORMAT_RH:
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ heights[i] = im.get_pixel(x, y).r
+ i += 1
+
+ elif im.get_format() == Image.FORMAT_RGB8:
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var c := im.get_pixel(x, y)
+ heights[i] = decode_height_from_rgb8_unorm(c)
+ i += 1
+
+ else:
+ _logger.error(str("Unknown heightmap format! ", im.get_format()))
+
+ return heights
+
+
+# Gets all heights as an array indexed as [x + y * width].
+# This height is raw and doesn't account for scaling of the terrain node.
+func get_all_heights() -> PackedFloat32Array:
+ var im = get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+ if im.get_format() == Image.FORMAT_RF:
+ return im.get_data().to_float32_array()
+ else:
+ return get_heights_region(0, 0, _resolution, _resolution)
+
+
+# Call this function after you end modifying a map.
+# It will commit the change to the GPU so the change will take effect.
+# In the editor, it will also mark the map as modified so it will be saved when needed.
+# Finally, it will emit `region_changed`,
+# which allows other systems to catch up (like physics or grass)
+#
+# p_rect:
+# modified area.
+#
+# map_type:
+# which kind of map changed, see CHANNEL_* constants
+#
+# index:
+# index of the map that changed
+#
+# p_upload_to_texture:
+# the modified region will be copied from the map image to the texture.
+# If the change already occurred on GPU, you may set this to false.
+#
+# p_update_vertical_bounds:
+# if the modified map is the heightmap, vertical bounds will be updated.
+#
+func notify_region_change(
+ p_rect: Rect2,
+ p_map_type: int,
+ p_index := 0,
+ p_upload_to_texture := true,
+ p_update_vertical_bounds := true):
+
+ assert(p_map_type >= 0 and p_map_type < CHANNEL_COUNT)
+
+ var min_x := int(p_rect.position.x)
+ var min_y := int(p_rect.position.y)
+ var size_x := int(p_rect.size.x)
+ var size_y := int(p_rect.size.y)
+
+ if p_map_type == CHANNEL_HEIGHT and p_update_vertical_bounds:
+ assert(p_index == 0)
+ _update_vertical_bounds(min_x, min_y, size_x, size_y)
+
+ if p_upload_to_texture:
+ _upload_region(p_map_type, p_index, min_x, min_y, size_x, size_y)
+
+ _maps[p_map_type][p_index].modified = true
+
+ region_changed.emit(min_x, min_y, size_x, size_y, p_map_type)
+ changed.emit()
+
+
+func notify_full_change():
+ for maptype in range(CHANNEL_COUNT):
+ # Ignore normals because they get updated along with heights
+ if maptype == CHANNEL_NORMAL:
+ continue
+ var maps = _maps[maptype]
+ for index in len(maps):
+ notify_region_change(Rect2(0, 0, _resolution, _resolution), maptype, index)
+
+
+func _edit_set_disable_apply_undo(e: bool):
+ _edit_disable_apply_undo = e
+
+
+func _edit_apply_undo(undo_data: Dictionary, image_cache: HT_ImageFileCache):
+ if _edit_disable_apply_undo:
+ return
+
+ var chunk_positions: Array = undo_data["chunk_positions"]
+ var map_infos: Array = undo_data["maps"]
+ var chunk_size: int = undo_data["chunk_size"]
+
+ _logger.debug(str("Applying ", len(chunk_positions), " undo/redo chunks"))
+
+ # Validate input
+
+ for map_info in map_infos:
+ assert(map_info.map_type >= 0 and map_info.map_type < CHANNEL_COUNT)
+ assert(len(map_info.chunks) == len(chunk_positions))
+ for im_cache_id in map_info.chunks:
+ assert(typeof(im_cache_id) == TYPE_INT)
+
+ # Apply for each map
+ for map_info in map_infos:
+ var map_type := map_info.map_type as int
+ var map_index := map_info.map_index as int
+
+ var regions_changed := []
+
+ for chunk_index in len(map_info.chunks):
+ var cpos : Vector2 = chunk_positions[chunk_index]
+ var cpos_x := int(cpos.x)
+ var cpos_y := int(cpos.y)
+
+ var min_x := cpos_x * chunk_size
+ var min_y := cpos_y * chunk_size
+ var max_x := min_x + chunk_size
+ var max_y := min_y + chunk_size
+
+ var data_id = map_info.chunks[chunk_index]
+ var data := image_cache.load_image(data_id)
+ assert(data != null)
+
+ var dst_image := get_image(map_type, map_index)
+ assert(dst_image != null)
+
+ if _map_types[map_type].authored:
+ #_logger.debug(str("Apply undo chunk ", cpos, " to ", Vector2(min_x, min_y)))
+ var src_rect := Rect2i(0, 0, data.get_width(), data.get_height())
+ dst_image.blit_rect(data, src_rect, Vector2i(min_x, min_y))
+ else:
+ _logger.error(
+ str("Channel ", map_type, " is a calculated channel!, no undo on this one"))
+
+ # Defer this to a second pass,
+ # otherwise it causes order-dependent artifacts on the normal map
+ regions_changed.append([
+ Rect2(min_x, min_y, max_x - min_x, max_y - min_y), map_type, map_index])
+
+ for args in regions_changed:
+ notify_region_change(args[0], args[1], args[2])
+
+
+#static func _debug_dump_heightmap(src: Image, fpath: String):
+# var im = Image.new()
+# im.create(src.get_width(), src.get_height(), false, Image.FORMAT_RGB8)
+# im.lock()
+# src.lock()
+# for y in im.get_height():
+# for x in im.get_width():
+# var col = src.get_pixel(x, y)
+# var c = col.r - floor(col.r)
+# im.set_pixel(x, y, Color(c, 0.0, 0.0, 1.0))
+# im.unlock()
+# src.unlock()
+# im.save_png(fpath)
+
+
+# TODO Support map indexes
+# Used for undoing full-terrain changes
+func _edit_apply_maps_from_file_cache(image_file_cache: HT_ImageFileCache, map_ids: Dictionary):
+ if _edit_disable_apply_undo:
+ return
+ for map_type in map_ids:
+ var id = map_ids[map_type]
+ var src_im := image_file_cache.load_image(id)
+ if src_im == null:
+ continue
+ var index := 0
+ var dst_im := get_image(map_type, index)
+ var rect := Rect2i(0, 0, src_im.get_height(), src_im.get_height())
+ dst_im.blit_rect(src_im, rect, Vector2i())
+ notify_region_change(rect, map_type, index)
+
+
+func _upload_channel(channel: int, index: int):
+ _upload_region(channel, index, 0, 0, _resolution, _resolution)
+
+
+func _upload_region(channel: int, index: int, min_x: int, min_y: int, size_x: int, size_y: int):
+ #_logger.debug("Upload ", min_x, ", ", min_y, ", ", size_x, "x", size_y)
+ #var time_before = OS.get_ticks_msec()
+
+ var map : HT_Map = _maps[channel][index]
+
+ var image := map.image
+ assert(image != null)
+ assert(size_x > 0 and size_y > 0)
+
+ # TODO Actually, I think the input params should be valid in the first place...
+ if min_x < 0:
+ min_x = 0
+ if min_y < 0:
+ min_y = 0
+ if min_x + size_x > image.get_width():
+ size_x = image.get_width() - min_x
+ if min_y + size_y > image.get_height():
+ size_y = image.get_height() - min_y
+ if size_x <= 0 or size_y <= 0:
+ return
+
+ var texture := map.texture
+
+ if texture == null or not (texture is ImageTexture):
+ # The texture doesn't exist yet in an editable format
+ if texture != null and not (texture is ImageTexture):
+ _logger.debug(str(
+ "_upload_region was used but the texture isn't an ImageTexture. ",\
+ "The map ", channel, "[", index, "] will be reuploaded entirely."))
+ else:
+ _logger.debug(str(
+ "_upload_region was used but the texture is not created yet. ",\
+ "The map ", channel, "[", index, "] will be uploaded entirely."))
+
+ map.texture = ImageTexture.create_from_image(image)
+
+ # Need to notify because other systems may want to grab the new texture object
+ map_changed.emit(channel, index)
+
+ # TODO Unfortunately Texture2D.get_size() wasn't updated to use Vector2i in Godot 4
+ elif Vector2i(texture.get_size()) != image.get_size():
+ _logger.debug(str(
+ "_upload_region was used but the image size is different. ",\
+ "The map ", channel, "[", index, "] will be reuploaded entirely."))
+
+ map.texture = ImageTexture.create_from_image(image)
+
+ # Since Godot 4, need to notify because other systems may want to grab the new texture
+ # object. In Godot 3 it wasn't necessary because we were able to resize a texture without
+ # having to recreate it from scratch...
+ map_changed.emit(channel, index)
+
+ else:
+ HT_Util.update_texture_partial(texture, image,
+ Rect2i(min_x, min_y, size_x, size_y), Vector2i(min_x, min_y))
+
+ #_logger.debug(str("Channel updated ", channel))
+
+ #var time_elapsed = OS.get_ticks_msec() - time_before
+ #_logger.debug(str("Texture upload time: ", time_elapsed, "ms"))
+
+
+# Gets how many instances of a given map are present in the terrain data.
+# A return value of 0 means there is no such map, and querying for it might cause errors.
+func get_map_count(map_type: int) -> int:
+ if map_type < len(_maps):
+ return len(_maps[map_type])
+ return 0
+
+
+# TODO Deprecated
+func _edit_add_detail_map():
+ return _edit_add_map(CHANNEL_DETAIL)
+
+
+# TODO Deprecated
+func _edit_remove_detail_map(index):
+ _edit_remove_map(CHANNEL_DETAIL, index)
+
+
+func _edit_add_map(map_type: int) -> int:
+ # TODO Check minimum and maximum instances of a given map
+ _logger.debug(str("Adding map of type ", get_channel_name(map_type)))
+ while map_type >= len(_maps):
+ _maps.append([])
+ var maps = _maps[map_type]
+ var map = HT_Map.new(_get_free_id(map_type))
+ map.image = Image.create(_resolution, _resolution, false, get_channel_format(map_type))
+ var index = len(maps)
+ var default_color = _get_map_default_fill_color(map_type, index)
+ if default_color != null:
+ map.image.fill(default_color)
+ maps.append(map)
+ map_added.emit(map_type, index)
+ return index
+
+
+func _edit_insert_map_from_image_cache(map_type: int, index: int, image_cache, image_id: int):
+ if _edit_disable_apply_undo:
+ return
+ _logger.debug(str("Adding map of type ", get_channel_name(map_type),
+ " from an image at index ", index))
+ while map_type >= len(_maps):
+ _maps.append([])
+ var maps = _maps[map_type]
+ var map := HT_Map.new(_get_free_id(map_type))
+ map.image = image_cache.load_image(image_id)
+ maps.insert(index, map)
+ map_added.emit(map_type, index)
+
+
+func _edit_remove_map(map_type: int, index: int):
+ # TODO Check minimum and maximum instances of a given map
+ _logger.debug(str("Removing map ", get_channel_name(map_type), " at index ", index))
+ var maps : Array = _maps[map_type]
+ maps.remove_at(index)
+ map_removed.emit(map_type, index)
+
+
+func _get_free_id(map_type: int) -> int:
+ var maps = _maps[map_type]
+ var id = 0
+ while _get_map_by_id(map_type, id) != null:
+ id += 1
+ return id
+
+
+func _get_map_by_id(map_type: int, id: int) -> HT_Map:
+ var maps = _maps[map_type]
+ for map in maps:
+ if map.id == id:
+ return map
+ return null
+
+
+func get_image(map_type: int, index := 0) -> Image:
+ var maps = _maps[map_type]
+ return maps[index].image
+
+
+func get_texture(map_type: int, index := 0, writable := false) -> Texture:
+ # TODO Split into `get_texture` and `get_writable_texture`?
+
+ var maps : Array = _maps[map_type]
+ var map : HT_Map = maps[index]
+
+ if map.image != null:
+ if map.texture == null:
+ _upload_channel(map_type, index)
+ elif writable and not (map.texture is ImageTexture):
+ _upload_channel(map_type, index)
+ else:
+ if writable:
+ _logger.warn(str("Requested writable terrain texture ",
+ get_map_debug_name(map_type, index), ", but it's not available in this context"))
+
+ return map.texture
+
+
+func has_texture(map_type: int, index: int) -> bool:
+ var maps = _maps[map_type]
+ return index < len(maps)
+
+
+func get_aabb() -> AABB:
+ # TODO Why subtract 1? I forgot
+ # TODO Optimize for full region, this is actually quite costy
+ return get_region_aabb(0, 0, _resolution - 1, _resolution - 1)
+
+
+# Not so useful in itself, but GDScript is slow,
+# so I needed it to speed up the LOD hack I had to do to take height into account
+func get_point_aabb(cell_x: int, cell_y: int) -> Vector2:
+ assert(typeof(cell_x) == TYPE_INT)
+ assert(typeof(cell_y) == TYPE_INT)
+
+ var cx = cell_x / VERTICAL_BOUNDS_CHUNK_SIZE
+ var cy = cell_y / VERTICAL_BOUNDS_CHUNK_SIZE
+
+ if cx < 0:
+ cx = 0
+ if cy < 0:
+ cy = 0
+ if cx >= _chunked_vertical_bounds.get_width():
+ cx = _chunked_vertical_bounds.get_width() - 1
+ if cy >= _chunked_vertical_bounds.get_height():
+ cy = _chunked_vertical_bounds.get_height() - 1
+
+ var b := _chunked_vertical_bounds.get_pixel(cx, cy)
+ return Vector2(b.r, b.g)
+
+
+func get_region_aabb(origin_in_cells_x: int, origin_in_cells_y: int,
+ size_in_cells_x: int, size_in_cells_y: int) -> AABB:
+
+ assert(typeof(origin_in_cells_x) == TYPE_INT)
+ assert(typeof(origin_in_cells_y) == TYPE_INT)
+ assert(typeof(size_in_cells_x) == TYPE_INT)
+ assert(typeof(size_in_cells_y) == TYPE_INT)
+
+ # Get info from cached vertical bounds,
+ # which is a lot faster than directly fetching heights from the map.
+ # It's not 100% accurate, but enough for culling use case if chunk size is decently chosen.
+
+ var cmin_x := origin_in_cells_x / VERTICAL_BOUNDS_CHUNK_SIZE
+ var cmin_y := origin_in_cells_y / VERTICAL_BOUNDS_CHUNK_SIZE
+
+ var cmax_x := (origin_in_cells_x + size_in_cells_x - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+ var cmax_y := (origin_in_cells_y + size_in_cells_y - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+
+ cmin_x = clampi(cmin_x, 0, _chunked_vertical_bounds.get_width() - 1)
+ cmin_y = clampi(cmin_y, 0, _chunked_vertical_bounds.get_height() - 1)
+ cmax_x = clampi(cmax_x, 0, _chunked_vertical_bounds.get_width())
+ cmax_y = clampi(cmax_y, 0, _chunked_vertical_bounds.get_height())
+
+ var min_height := _chunked_vertical_bounds.get_pixel(cmin_x, cmin_y).r
+ var max_height = min_height
+
+ for y in range(cmin_y, cmax_y):
+ for x in range(cmin_x, cmax_x):
+ var b = _chunked_vertical_bounds.get_pixel(x, y)
+ min_height = minf(b.r, min_height)
+ max_height = maxf(b.g, max_height)
+
+ var aabb = AABB()
+ aabb.position = Vector3(origin_in_cells_x, min_height, origin_in_cells_y)
+ aabb.size = Vector3(size_in_cells_x, max_height - min_height, size_in_cells_y)
+
+ return aabb
+
+
+func _update_all_vertical_bounds():
+ var csize_x := _resolution / VERTICAL_BOUNDS_CHUNK_SIZE
+ var csize_y := _resolution / VERTICAL_BOUNDS_CHUNK_SIZE
+ _logger.debug(str("Updating all vertical bounds... (", csize_x , "x", csize_y, " chunks)"))
+ _chunked_vertical_bounds = Image.create(csize_x, csize_y, false, Image.FORMAT_RGF)
+ _update_vertical_bounds(0, 0, _resolution - 1, _resolution - 1)
+
+
+func update_vertical_bounds(p_rect: Rect2):
+ var min_x := int(p_rect.position.x)
+ var min_y := int(p_rect.position.y)
+ var size_x := int(p_rect.size.x)
+ var size_y := int(p_rect.size.y)
+
+ _update_vertical_bounds(min_x, min_y, size_x, size_y)
+
+
+func _update_vertical_bounds(origin_in_cells_x: int, origin_in_cells_y: int, \
+ size_in_cells_x: int, size_in_cells_y: int):
+
+ var cmin_x := origin_in_cells_x / VERTICAL_BOUNDS_CHUNK_SIZE
+ var cmin_y := origin_in_cells_y / VERTICAL_BOUNDS_CHUNK_SIZE
+
+ var cmax_x := (origin_in_cells_x + size_in_cells_x - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+ var cmax_y := (origin_in_cells_y + size_in_cells_y - 1) / VERTICAL_BOUNDS_CHUNK_SIZE + 1
+
+ cmin_x = clampi(cmin_x, 0, _chunked_vertical_bounds.get_width() - 1)
+ cmin_y = clampi(cmin_y, 0, _chunked_vertical_bounds.get_height() - 1)
+ cmax_x = clampi(cmax_x, 0, _chunked_vertical_bounds.get_width())
+ cmax_y = clampi(cmax_y, 0, _chunked_vertical_bounds.get_height())
+
+ # Note: chunks in _chunked_vertical_bounds share their edge cells and
+ # have an actual size of chunk size + 1.
+ var chunk_size_x := VERTICAL_BOUNDS_CHUNK_SIZE + 1
+ var chunk_size_y := VERTICAL_BOUNDS_CHUNK_SIZE + 1
+
+ for y in range(cmin_y, cmax_y):
+ var pmin_y := y * VERTICAL_BOUNDS_CHUNK_SIZE
+
+ for x in range(cmin_x, cmax_x):
+ var pmin_x := x * VERTICAL_BOUNDS_CHUNK_SIZE
+ var b = _compute_vertical_bounds_at(pmin_x, pmin_y, chunk_size_x, chunk_size_y)
+ _chunked_vertical_bounds.set_pixel(x, y, Color(b.x, b.y, 0))
+
+
+func _compute_vertical_bounds_at(
+ origin_x: int, origin_y: int, size_x: int, size_y: int) -> Vector2:
+
+ var heights := get_image(CHANNEL_HEIGHT)
+ assert(heights != null)
+ match heights.get_format():
+ Image.FORMAT_RF:
+ return _get_heights_range_f(heights, Rect2i(origin_x, origin_y, size_x, size_y))
+ Image.FORMAT_RGB8:
+ return _get_heights_range_rgb8(heights, Rect2i(origin_x, origin_y, size_x, size_y))
+ _:
+ _logger.error(str("Unknown heightmap format ", heights.get_format()))
+ return Vector2()
+
+
+static func _get_heights_range_rgb8(im: Image, rect: Rect2i) -> Vector2:
+ assert(im.get_format() == Image.FORMAT_RGB8)
+
+ rect = rect.intersection(Rect2i(0, 0, im.get_width(), im.get_height()))
+ var min_x := rect.position.x
+ var min_y := rect.position.y
+ var max_x := min_x + rect.size.x
+ var max_y := min_y + rect.size.y
+
+ var min_height := decode_height_from_rgb8_unorm(im.get_pixel(min_x, min_y))
+ var max_height := min_height
+
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var h := decode_height_from_rgb8_unorm(im.get_pixel(x, y))
+ min_height = minf(h, min_height)
+ max_height = maxf(h, max_height)
+
+ return Vector2(min_height, max_height)
+
+
+static func _get_heights_range_f(im: Image, rect: Rect2i) -> Vector2:
+ assert(im.get_format() == Image.FORMAT_RF)
+
+ rect = rect.intersection(Rect2i(0, 0, im.get_width(), im.get_height()))
+ var min_x := rect.position.x
+ var min_y := rect.position.y
+ var max_x := min_x + rect.size.x
+ var max_y := min_y + rect.size.y
+
+ var min_height := im.get_pixel(min_x, min_y).r
+ var max_height := min_height
+
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var h := im.get_pixel(x, y).r
+ min_height = minf(h, min_height)
+ max_height = maxf(h, max_height)
+
+ return Vector2(min_height, max_height)
+
+
+func save_data(data_dir: String) -> bool:
+ _logger.debug("Saving terrain data...")
+
+ _locked = true
+
+ _save_metadata(data_dir.path_join(META_FILENAME))
+
+ var map_count = _get_total_map_count()
+
+ var all_succeeded = true
+
+ var pi = 0
+ for map_type in CHANNEL_COUNT:
+ var maps : Array = _maps[map_type]
+
+ for index in len(maps):
+ var map : HT_Map = maps[index]
+ if not map.modified:
+ _logger.debug(str(
+ "Skipping non-modified ", get_map_debug_name(map_type, index)))
+ continue
+
+ _logger.debug(str("Saving map ", get_map_debug_name(map_type, index),
+ " as ", _get_map_filename(map_type, index), "..."))
+
+ all_succeeded = all_succeeded and _save_map(data_dir, map_type, index)
+
+ map.modified = false
+ pi += 1
+
+ # TODO Cleanup unused map files?
+
+ # TODO In editor, trigger reimport on generated assets
+ _locked = false
+
+ return all_succeeded
+
+
+func _is_any_map_modified() -> bool:
+ for maplist in _maps:
+ for map in maplist:
+ if map.modified:
+ return true
+ return false
+
+
+func _get_total_map_count() -> int:
+ var s = 0
+ for maps in _maps:
+ s += len(maps)
+ return s
+
+
+func _load_metadata(path: String):
+ var f = FileAccess.open(path, FileAccess.READ)
+ assert(f != null)
+ var text = f.get_as_text()
+ f = null # close file
+ var json = JSON.new()
+ assert(json.parse(text) == OK)
+ _deserialize_metadata(json.data)
+
+
+func _save_metadata(path: String):
+ var d = _serialize_metadata()
+ var text = JSON.stringify(d, "\t", true)
+ var f = FileAccess.open(path, FileAccess.WRITE)
+ var err = f.get_error()
+ assert(err == OK)
+ f.store_string(text)
+
+
+func _serialize_metadata() -> Dictionary:
+ var data := []
+ data.resize(len(_maps))
+
+ for i in range(len(_maps)):
+ var maps = _maps[i]
+ var maps_data := []
+
+ for j in range(len(maps)):
+ var map : HT_Map = maps[j]
+ maps_data.append({ "id": map.id })
+
+ data[i] = maps_data
+
+ return {
+ "version": META_VERSION,
+ "maps": data
+ }
+
+
+# Parse metadata that we'll then use to load the actual terrain
+# (How many maps, which files to load etc...)
+func _deserialize_metadata(dict: Dictionary) -> bool:
+ if not dict.has("version"):
+ _logger.error("Terrain metadata has no version")
+ return false
+
+ if dict.version != META_VERSION:
+ _logger.error("Terrain metadata version mismatch. Got {0}, expected {1}" \
+ .format([dict.version, META_VERSION]))
+ return false
+
+ var data = dict["maps"]
+ assert(len(data) <= len(_maps))
+
+ for i in len(data):
+ var maps = _maps[i]
+
+ var maps_data = data[i]
+ if len(maps) != len(maps_data):
+ maps.resize(len(maps_data))
+
+ for j in len(maps):
+ var map = maps[j]
+ # Cast because the data comes from json, where every number is double
+ var id := int(maps_data[j].id)
+ if map == null:
+ map = HT_Map.new(id)
+ maps[j] = map
+ else:
+ map.id = id
+
+ return true
+
+
+func load_data(dir_path: String):
+ _locked = true
+
+ _load_metadata(dir_path.path_join(META_FILENAME))
+
+ _logger.debug("Loading terrain data...")
+
+ var channel_instance_sum = _get_total_map_count()
+ var pi = 0
+
+ # Note: if we loaded all maps at once before uploading them to VRAM,
+ # it would take a lot more RAM than if we load them one by one
+ for map_type in len(_maps):
+ var maps = _maps[map_type]
+
+ for index in len(maps):
+ _logger.debug(str("Loading map ", get_map_debug_name(map_type, index),
+ " from ", _get_map_filename(map_type, index), "..."))
+
+ _load_map(dir_path, map_type, index)
+
+ # A map that was just loaded is considered not modified yet
+ maps[index].modified = false
+
+ pi += 1
+
+ _logger.debug("Calculating vertical bounds...")
+ _update_all_vertical_bounds()
+
+ _logger.debug("Notify resolution change...")
+
+ _locked = false
+ resolution_changed.emit()
+
+
+func get_data_dir() -> String:
+ # The HTerrainData resource represents the metadata and entry point for Godot.
+ # It should be placed within a folder dedicated for terrain storage.
+ # Other heavy data such as maps are stored next to that file.
+ return resource_path.get_base_dir()
+
+
+func _save_map(dir_path: String, map_type: int, index: int) -> bool:
+ var map : HT_Map = _maps[map_type][index]
+ var im := map.image
+ if im == null:
+ var tex := map.texture
+ if tex != null:
+ _logger.debug(str("Image not found for map ", map_type, ", downloading from VRAM"))
+ im = tex.get_image()
+ else:
+ _logger.debug(str("No data in map ", map_type, "[", index, "]"))
+ # This data doesn't have such map
+ return true
+
+ # The function says "absolute" but in reality it accepts paths like `res://x`,
+ # which from a user standpoint are not absolute. Also, `FileAccess.file_exists` exists but
+ # isn't named "absolute" :shrug:
+ if not DirAccess.dir_exists_absolute(dir_path):
+ var err := DirAccess.make_dir_absolute(dir_path)
+ if err != OK:
+ _logger.error("Could not create directory '{0}', error {1}" \
+ .format([dir_path, HT_Errors.get_message(err)]))
+ return false
+
+ var fpath := dir_path.path_join(_get_map_filename(map_type, index))
+
+ return _save_map_image(fpath, map_type, im)
+
+
+func _save_map_image(fpath: String, map_type: int, im: Image) -> bool:
+ if _channel_can_be_saved_as_png(map_type):
+ fpath += ".png"
+ var err := im.save_png(fpath)
+ if err != OK:
+ _logger.error("Could not save '{0}', error {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return false
+ _try_write_default_import_options(fpath, map_type, _logger)
+
+ else:
+ fpath += ".res"
+ var err := ResourceSaver.save(im, fpath)
+ if err != OK:
+ _logger.error("Could not save '{0}', error {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return false
+
+ return true
+
+
+static func _try_write_default_import_options(
+ fpath: String, channel: int, logger: HT_Logger.HT_LoggerBase):
+
+ var imp_fpath := fpath + ".import"
+ if FileAccess.file_exists(imp_fpath):
+ # Already exists
+ return
+
+ var map_info = _map_types[channel]
+ var srgb: bool = map_info.srgb
+
+ var defaults : Dictionary
+
+ if channel == CHANNEL_HEIGHT:
+ defaults = {
+ "remap": {
+ # Have the heightmap editable as an image by default
+ "importer": "image",
+ "type": "Image"
+ },
+ "deps": {
+ "source_file": fpath
+ }
+ }
+
+ else:
+ defaults = {
+ "remap": {
+ "importer": "texture",
+ "type": "CompressedTexture2D"
+ },
+ "deps": {
+ "source_file": fpath
+ },
+ "params": {
+ # Use lossless compression.
+ # Lossy ruins quality and makes the editor choke on big textures.
+ # TODO I would have used ImageTexture.COMPRESS_LOSSLESS,
+ # but apparently what is saved in the .import file does not match,
+ # and rather corresponds TO THE UI IN THE IMPORT DOCK :facepalm:
+ "compress/mode": 0,
+
+ "compress/hdr_compression": 0,
+ "compress/normal_map": 0,
+ # No mipmaps
+ "mipmaps/limit": 0,
+
+ # Most textures aren't color.
+ # Same here, this is mapping something from the import dock UI,
+ # and doesn't have any enum associated, just raw numbers in C++ code...
+ # 0 = "disabled", 1 = "enabled", 2 = "detect"
+ "flags/srgb": 2 if srgb else 0,
+
+ # No need for this, the meaning of alpha is never transparency
+ "process/fix_alpha_border": false,
+
+ # Don't try to be smart.
+ # This can actually overwrite the settings with defaults...
+ # https://github.com/godotengine/godot/issues/24220
+ "detect_3d/compress_to": 0,
+ }
+ }
+
+ HT_Util.write_import_file(defaults, imp_fpath, logger)
+
+
+func _load_map(dir: String, map_type: int, index: int) -> bool:
+ var fpath := dir.path_join(_get_map_filename(map_type, index))
+
+ # Maps must be configured before being loaded
+ var map : HT_Map = _maps[map_type][index]
+ # while len(_maps) <= map_type:
+ # _maps.append([])
+ # while len(_maps[map_type]) <= index:
+ # _maps[map_type].append(null)
+ # var map = _maps[map_type][index]
+ # if map == null:
+ # map = Map.new()
+ # _maps[map_type][index] = map
+
+ if _channel_can_be_saved_as_png(map_type):
+ fpath += ".png"
+ else:
+ fpath += ".res"
+
+ var tex = load(fpath)
+
+ var must_load_image_in_editor := true
+
+ # Short-term compatibility with RGB8 encoding from the godot4 branch
+ if Engine.is_editor_hint() and tex == null and map_type == CHANNEL_HEIGHT:
+ var legacy_fpath := fpath.get_basename() + ".png"
+ var temp = load(legacy_fpath)
+ if temp != null:
+ if temp is Texture2D:
+ temp = temp.get_image()
+ if temp is Image:
+ if temp.get_format() == Image.FORMAT_RGB8:
+ _logger.warn(str(
+ "Found a heightmap using legacy RGB8 format. It will be converted to RF. ",
+ "You may want to remove the old file: {0}").format([fpath]))
+ tex = convert_heightmap_to_float(temp, _logger)
+ _save_map_image(fpath.get_basename(), map_type, tex)
+
+ if tex != null and tex is Image:
+ # The texture is imported as Image,
+ # perhaps the user wants it to be accessible from RAM in game.
+ _logger.debug("Map {0} is imported as Image. An ImageTexture will be generated." \
+ .format([get_map_debug_name(map_type, index)]))
+ map.image = tex
+ tex = ImageTexture.create_from_image(map.image)
+ must_load_image_in_editor = false
+
+ map.texture = tex
+
+ if Engine.is_editor_hint():
+ if must_load_image_in_editor:
+ # But in the editor we want textures to be editable,
+ # so we have to automatically load the data also in RAM
+ if map.image == null:
+ map.image = Image.load_from_file(fpath)
+ else:
+ map.image.load(fpath)
+ _ensure_map_format(map.image, map_type, index)
+
+ if map_type == CHANNEL_HEIGHT:
+ _resolution = map.image.get_width()
+
+ return true
+
+
+func _ensure_map_format(im: Image, map_type: int, index: int):
+ var format := im.get_format()
+ var expected_format : int = _map_types[map_type].texture_format
+ if format != expected_format:
+ _logger.warn("Map {0} loaded as format {1}, expected {2}. Will be converted." \
+ .format([get_map_debug_name(map_type, index), format, expected_format]))
+ im.convert(expected_format)
+
+
+# Imports images into the terrain data by converting them to the internal format.
+# It is possible to omit some of them, in which case those already setup will be used.
+# This function is quite permissive, and will only fail if there is really no way to import.
+# It may involve cropping, so preliminary checks should be done to inform the user.
+#
+# TODO Plan is to make this function threaded, in case import takes too long.
+# So anything that could mess with the main thread should be avoided.
+# Eventually, it would be temporarily removed from the terrain node to work
+# in isolation during import.
+func _edit_import_maps(input: Dictionary) -> bool:
+ assert(typeof(input) == TYPE_DICTIONARY)
+
+ if input.has(CHANNEL_HEIGHT):
+ var params = input[CHANNEL_HEIGHT]
+ if not _import_heightmap(
+ params.path, params.min_height, params.max_height, params.big_endian):
+ return false
+
+ # TODO Import indexed maps?
+ var maptypes := [CHANNEL_COLOR, CHANNEL_SPLAT]
+
+ for map_type in maptypes:
+ if input.has(map_type):
+ var params = input[map_type]
+ if not _import_map(map_type, params.path):
+ return false
+
+ return true
+
+
+# Provided an arbitrary width and height,
+# returns the closest size the terrain actuallysupports
+static func get_adjusted_map_size(width: int, height: int) -> int:
+ var width_po2 = HT_Util.next_power_of_two(width - 1) + 1
+ var height_po2 = HT_Util.next_power_of_two(height - 1) + 1
+ var size_po2 = mini(width_po2, height_po2)
+ size_po2 = clampi(size_po2, MIN_RESOLUTION, MAX_RESOLUTION)
+ return size_po2
+
+
+func _import_heightmap(fpath: String, min_y: float, max_y: float, big_endian: bool) -> bool:
+ var ext := fpath.get_extension().to_lower()
+
+ if ext == "png":
+ # Godot can only load 8-bit PNG,
+ # so we have to bring it back to float in the wanted range
+
+ var src_image := Image.load_from_file(fpath)
+ # TODO No way to access the error code?
+ if src_image == null:
+ return false
+
+ var res := get_adjusted_map_size(src_image.get_width(), src_image.get_height())
+ if res != src_image.get_width():
+ src_image.crop(res, res)
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", res, "x", res, "..."))
+ resize(src_image.get_width(), false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ var hrange := max_y - min_y
+
+ var width := mini(im.get_width(), src_image.get_width())
+ var height := mini(im.get_height(), src_image.get_height())
+
+ _logger.debug("Converting to internal format...")
+
+ # Convert to internal format with range scaling
+ match im.get_format():
+ Image.FORMAT_RF:
+ for y in width:
+ for x in height:
+ var gs := src_image.get_pixel(x, y).r
+ var h := min_y + hrange * gs
+ im.set_pixel(x, y, Color(h, h, h))
+ Image.FORMAT_RGB8:
+ for y in width:
+ for x in height:
+ var gs := src_image.get_pixel(x, y).r
+ var h := min_y + hrange * gs
+ im.set_pixel(x, y, encode_height_to_rgb8_unorm(h))
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+
+ elif ext == "exr":
+ var src_image := Image.load_from_file(fpath)
+ # TODO No way to access the error code?
+ if src_image == null:
+ return false
+
+ var res := get_adjusted_map_size(src_image.get_width(), src_image.get_height())
+ if res != src_image.get_width():
+ src_image.crop(res, res)
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", res, "x", res, "..."))
+ resize(src_image.get_width(), false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ _logger.debug("Converting to internal format...")
+
+ match im.get_format():
+ Image.FORMAT_RF:
+ # See https://github.com/Zylann/godot_heightmap_plugin/issues/34
+ # Godot can load EXR but it always makes them have at least 3-channels.
+ # Heightmaps need only one, so we have to get rid of 2.
+ var height_format = _map_types[CHANNEL_HEIGHT].texture_format
+ src_image.convert(height_format)
+ im.blit_rect(src_image, Rect2i(0, 0, res, res), Vector2i())
+
+ Image.FORMAT_RGB8:
+ convert_float_heightmap_to_rgb8(src_image, im)
+
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+
+ elif ext == "raw":
+ # RAW files don't contain size, so we have to deduce it from 16-bit size.
+ # We also need to bring it back to float in the wanted range.
+
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ return false
+
+ var file_len := f.get_length()
+ var file_res := HT_Util.integer_square_root(file_len / 2)
+ if file_res == -1:
+ # Can't deduce size
+ return false
+
+ # TODO Need a way to know which endianess our system has!
+ # For now we have to make an assumption...
+ # This function is most supposed to execute in the editor.
+ # The editor officially runs on desktop architectures, which are
+ # generally little-endian.
+ if big_endian:
+ f.big_endian = true
+
+ var res := get_adjusted_map_size(file_res, file_res)
+
+ var width := res
+ var height := res
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", width, "x", height, "..."))
+ resize(res, false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ var hrange := max_y - min_y
+
+ _logger.debug("Converting to internal format...")
+
+ var rw := mini(res, file_res)
+ var rh := mini(res, file_res)
+
+ # Convert to internal format
+ var h := 0.0
+ for y in rh:
+ for x in rw:
+ var gs := float(f.get_16()) / 65535.0
+ h = min_y + hrange * float(gs)
+ match im.get_format():
+ Image.FORMAT_RF:
+ im.set_pixel(x, y, Color(h, 0, 0))
+ Image.FORMAT_RGB8:
+ im.set_pixel(x, y, encode_height_to_rgb8_unorm(h))
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+ return false
+
+ # Skip next pixels if the file is bigger than the accepted resolution
+ for x in range(rw, file_res):
+ f.get_16()
+
+ elif ext == "xyz":
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ return false
+
+ var bounds := HT_XYZFormat.load_bounds(f)
+ var res := get_adjusted_map_size(bounds.image_width, bounds.image_height)
+
+ var width := res
+ var height := res
+
+ _locked = true
+
+ _logger.debug(str("Resizing terrain to ", width, "x", height, "..."))
+ resize(res, false, Vector2())
+
+ var im := get_image(CHANNEL_HEIGHT)
+ assert(im != null)
+
+ im.fill(Color(0,0,0))
+
+ _logger.debug(str("Parsing XYZ file (this can take a while)..."))
+ f.seek(0)
+ var float_heightmap := Image.create(im.get_width(), im.get_height(), false, Image.FORMAT_RF)
+ HT_XYZFormat.load_heightmap(f, float_heightmap, bounds)
+
+ # Flipping because in Godot, for X to mean "east"/"right", Z must be backward,
+ # and we are using Z to map the Y axis of the heightmap image.
+ float_heightmap.flip_y()
+
+ match im.get_format():
+ Image.FORMAT_RF:
+ im.blit_rect(float_heightmap, Rect2i(0, 0, res, res), Vector2i())
+ Image.FORMAT_RGB8:
+ convert_float_heightmap_to_rgb8(float_heightmap, im)
+ _:
+ _logger.error(str("Invalid heightmap format ", im.get_format()))
+
+ # Note: when importing maps with non-compliant sizes and flipping,
+ # the result might not be aligned to global coordinates.
+ # If this is a problem, we could just offset the terrain to compensate?
+
+ else:
+ # File extension not recognized
+ return false
+
+ _locked = false
+
+ _logger.debug("Notify region change...")
+ notify_region_change(Rect2(0, 0, get_resolution(), get_resolution()), CHANNEL_HEIGHT)
+
+ return true
+
+
+func _import_map(map_type: int, path: String) -> bool:
+ # Heightmap requires special treatment
+ assert(map_type != CHANNEL_HEIGHT)
+
+ var im := Image.load_from_file(path)
+ # TODO No way to get the error code?
+ if im == null:
+ return false
+
+ var res := get_resolution()
+ if im.get_width() != res or im.get_height() != res:
+ im.crop(res, res)
+
+ if im.get_format() != get_channel_format(map_type):
+ im.convert(get_channel_format(map_type))
+
+ var map : HT_Map = _maps[map_type][0]
+ map.image = im
+
+ notify_region_change(Rect2(0, 0, im.get_width(), im.get_height()), map_type)
+ return true
+
+
+# TODO Workaround for https://github.com/Zylann/godot_heightmap_plugin/issues/101
+func _dummy_function():
+ pass
+
+
+static func _get_xz(v: Vector3) -> Vector2:
+ return Vector2(v.x, v.z)
+
+
+class HT_CellRaycastContext:
+ var begin_pos := Vector3()
+ var _cell_begin_pos_y := 0.0
+ var _cell_begin_pos_2d := Vector2()
+ var dir := Vector3()
+ var dir_2d := Vector2()
+ var vertical_bounds : Image
+ var hit = null # Vector3
+ var heightmap : Image
+ var broad_param_2d_to_3d := 1.0
+ var cell_param_2d_to_3d := 1.0
+ # TODO Can't call static functions of the enclosing class.....................
+ var decode_height_func : Callable
+ #var dbg
+
+ func broad_cb(cx: int, cz: int, enter_param: float, exit_param: float) -> bool:
+ if cx < 0 or cz < 0 or cz >= vertical_bounds.get_height() \
+ or cx >= vertical_bounds.get_width():
+ # The function may occasionally be called at boundary values
+ return false
+ var vb := vertical_bounds.get_pixel(cx, cz)
+ var begin := begin_pos + dir * (enter_param * broad_param_2d_to_3d)
+ var exit_y := begin_pos.y + dir.y * exit_param * broad_param_2d_to_3d
+ #_spawn_box(Vector3(cx * VERTICAL_BOUNDS_CHUNK_SIZE, \
+ # begin.y, cz * VERTICAL_BOUNDS_CHUNK_SIZE), 2.0)
+ if begin.y < vb.r or exit_y > vb.g:
+ # Not hitting this chunk
+ return false
+ # We may be hitting something in this chunk, perform a narrow phase
+ # through terrain cells
+ var distance_in_chunk_2d := (exit_param - enter_param) * VERTICAL_BOUNDS_CHUNK_SIZE
+ var cell_ray_origin_2d := Vector2(begin.x, begin.z)
+ _cell_begin_pos_y = begin.y
+ _cell_begin_pos_2d = cell_ray_origin_2d
+ var rhit = HT_Util.grid_raytrace_2d(
+ cell_ray_origin_2d, dir_2d, cell_cb, distance_in_chunk_2d)
+ return rhit != null
+
+ func cell_cb(cx: int, cz: int, enter_param: float, exit_param: float) -> bool:
+ var enter_pos := _cell_begin_pos_2d + dir_2d * enter_param
+ #var exit_pos := _cell_begin_pos_2d + dir_2d * exit_param
+
+ var enter_y := _cell_begin_pos_y + dir.y * enter_param * cell_param_2d_to_3d
+ var exit_y := _cell_begin_pos_y + dir.y * exit_param * cell_param_2d_to_3d
+
+ hit = _intersect_cell(heightmap, cx, cz, Vector3(enter_pos.x, enter_y, enter_pos.y), dir,
+ decode_height_func)
+
+ return hit != null
+
+ static func _intersect_cell(heightmap: Image, cx: int, cz: int,
+ begin_pos: Vector3, dir: Vector3, decode_func : Callable):
+
+ var c00 := HT_Util.get_pixel_clamped(heightmap, cx, cz)
+ var c10 := HT_Util.get_pixel_clamped(heightmap, cx + 1, cz)
+ var c01 := HT_Util.get_pixel_clamped(heightmap, cx, cz + 1)
+ var c11 := HT_Util.get_pixel_clamped(heightmap, cx + 1, cz + 1)
+
+ var h00 : float = decode_func.call(c00)
+ var h10 : float = decode_func.call(c10)
+ var h01 : float = decode_func.call(c01)
+ var h11 : float = decode_func.call(c11)
+
+ var p00 := Vector3(cx, h00, cz)
+ var p10 := Vector3(cx + 1, h10, cz)
+ var p01 := Vector3(cx, h01, cz + 1)
+ var p11 := Vector3(cx + 1, h11, cz + 1)
+
+ var th0 = Geometry3D.ray_intersects_triangle(begin_pos, dir, p00, p10, p11)
+ var th1 = Geometry3D.ray_intersects_triangle(begin_pos, dir, p00, p11, p01)
+
+ if th0 != null:
+ return th0
+ return th1
+
+# func _spawn_box(pos: Vector3, r: float):
+# if not Input.is_key_pressed(KEY_CONTROL):
+# return
+# var mi = MeshInstance.new()
+# mi.mesh = CubeMesh.new()
+# mi.translation = pos * dbg.map_scale
+# mi.scale = Vector3(r, r, r)
+# dbg.add_child(mi)
+# mi.owner = dbg.get_tree().edited_scene_root
+
+
+# Raycasts heightmap image directly without using a collider.
+# The coordinate system is such that Y is up, terrain minimum corner is at (0, 0),
+# and one heightmap pixel is one space unit.
+# TODO Cannot hint as `-> Vector2` because it can be null if there is no hit
+func cell_raycast(ray_origin: Vector3, ray_direction: Vector3, max_distance: float):
+ var heightmap := get_image(CHANNEL_HEIGHT)
+ if heightmap == null:
+ return null
+
+ var terrain_rect := Rect2(Vector2(), Vector2(_resolution, _resolution))
+
+ # Project and clip into 2D
+ var ray_origin_2d := _get_xz(ray_origin)
+ var ray_end_2d := _get_xz(ray_origin + ray_direction * max_distance)
+ var clipped_segment_2d := HT_Util.get_segment_clipped_by_rect(terrain_rect,
+ ray_origin_2d, ray_end_2d)
+ # TODO We could clip along Y too if we had total AABB cached somewhere
+
+ if len(clipped_segment_2d) == 0:
+ # Not hitting the terrain area
+ return null
+
+ var max_distance_2d := ray_origin_2d.distance_to(ray_end_2d)
+ if max_distance_2d < 0.001:
+ # TODO Direct vertical hit?
+ return null
+
+ # Get ratio along the segment where the first point was clipped
+ var begin_clip_param := ray_origin_2d.distance_to(clipped_segment_2d[0]) / max_distance_2d
+
+ var ray_direction_2d := _get_xz(ray_direction).normalized()
+
+ var ctx := HT_CellRaycastContext.new()
+ ctx.begin_pos = ray_origin + ray_direction * (begin_clip_param * max_distance)
+ ctx.dir = ray_direction
+ ctx.dir_2d = ray_direction_2d
+ ctx.vertical_bounds = _chunked_vertical_bounds
+ ctx.heightmap = heightmap
+ ctx.cell_param_2d_to_3d = max_distance / max_distance_2d
+ ctx.broad_param_2d_to_3d = ctx.cell_param_2d_to_3d * VERTICAL_BOUNDS_CHUNK_SIZE
+
+ match heightmap.get_format():
+ Image.FORMAT_RF:
+ ctx.decode_height_func = decode_height_from_f
+ Image.FORMAT_RGB8:
+ ctx.decode_height_func = decode_height_from_rgb8_unorm
+ _:
+ _logger.error(str("Invalid heightmap format ", heightmap.get_format()))
+ return null
+
+ #ctx.dbg = dbg
+
+ # Broad phase through cached vertical bound chunks
+ var broad_ray_origin = clipped_segment_2d[0] / VERTICAL_BOUNDS_CHUNK_SIZE
+ var broad_max_distance = \
+ clipped_segment_2d[0].distance_to(clipped_segment_2d[1]) / VERTICAL_BOUNDS_CHUNK_SIZE
+ var hit_bp = HT_Util.grid_raytrace_2d(broad_ray_origin, ray_direction_2d, ctx.broad_cb,
+ broad_max_distance)
+
+ if hit_bp == null:
+ # No hit
+ return null
+
+ return Vector2(ctx.hit.x, ctx.hit.z)
+
+
+static func encode_normal(n: Vector3) -> Color:
+ n = 0.5 * (n + Vector3.ONE)
+ return Color(n.x, n.z, n.y)
+
+
+static func get_channel_format(channel: int) -> int:
+ return _map_types[channel].texture_format as int
+
+
+# Note: PNG supports 16-bit channels, unfortunately Godot doesn't
+static func _channel_can_be_saved_as_png(channel: int) -> bool:
+ return _map_types[channel].can_be_saved_as_png
+
+
+static func get_channel_name(c: int) -> String:
+ return _map_types[c].name as String
+
+
+static func get_map_debug_name(map_type: int, index: int) -> String:
+ return str(get_channel_name(map_type), "[", index, "]")
+
+
+func _get_map_filename(map_type: int, index: int) -> String:
+ var name = get_channel_name(map_type)
+ var id = _maps[map_type][index].id
+ if id > 0:
+ name += str(id + 1)
+ return name
+
+
+static func get_map_shader_param_name(map_type: int, index: int) -> String:
+ var param_name = _map_types[map_type].shader_param_name
+ if typeof(param_name) == TYPE_STRING:
+ return param_name
+ return param_name[index]
+
+
+# TODO Can't type hint because it returns a nullable array
+#static func get_map_type_and_index_from_shader_param_name(p_name: String):
+# for map_type in _map_types:
+# var pn = _map_types[map_type].shader_param_name
+# if typeof(pn) == TYPE_STRING:
+# if pn == p_name:
+# return [map_type, 0]
+# else:
+# for i in len(pn):
+# if pn[i] == p_name:
+# return [map_type, i]
+# return null
+
+
+static func decode_height_from_f(c: Color) -> float:
+ return c.r
+
+
+const _V2_UNIT_STEPS = 1024.0
+const _V2_MIN = -8192.0
+const _V2_MAX = 8191.0
+const _V2_DF = 255.0 / _V2_UNIT_STEPS
+
+# This RGB8 encoding implementation is specific to this plugin.
+# It was used in the port to Godot 4.0 for a time, until it was found float
+# textures could be used.
+
+static func decode_height_from_rgb8_unorm(c: Color) -> float:
+ return (c.r * 0.25 + c.g * 64.0 + c.b * 16384.0) * (4.0 * _V2_DF) + _V2_MIN
+
+
+static func encode_height_to_rgb8_unorm(h: float) -> Color:
+ h -= _V2_MIN
+ var i := int(h * _V2_UNIT_STEPS)
+ var r := i % 256
+ var g := (i / 256) % 256
+ var b := i / 65536
+ return Color(r, g, b, 255.0) / 255.0
+
+
+static func convert_heightmap_to_float(src: Image, logger: HT_Logger.HT_LoggerBase) -> Image:
+ var src_format := src.get_format()
+
+ if src_format == Image.FORMAT_RH:
+ var im : Image = src.duplicate()
+ im.convert(Image.FORMAT_RF)
+ return im
+
+ if src_format == Image.FORMAT_RF:
+ return src.duplicate() as Image
+
+ if src_format == Image.FORMAT_RGB8:
+ var im := Image.create(src.get_width(), src.get_height(), false, Image.FORMAT_RF)
+ for y in src.get_height():
+ for x in src.get_width():
+ var c := src.get_pixel(x, y)
+ var h := decode_height_from_rgb8_unorm(c)
+ im.set_pixel(x, y, Color(h, h, h, 1.0))
+ return im
+
+ logger.error("Unknown source heightmap format!")
+ return null
+
+
+static func convert_float_heightmap_to_rgb8(src: Image, dst: Image):
+ assert(dst.get_format() == Image.FORMAT_RGB8)
+ assert(dst.get_size() == src.get_size())
+
+ for y in src.get_height():
+ for x in src.get_width():
+ var h = src.get_pixel(x, y).r
+ dst.set_pixel(x, y, encode_height_to_rgb8_unorm(h))
+
diff --git a/game/addons/zylann.hterrain/hterrain_detail_layer.gd b/game/addons/zylann.hterrain/hterrain_detail_layer.gd
new file mode 100644
index 0000000..2dee704
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_detail_layer.gd
@@ -0,0 +1,742 @@
+@tool
+extends Node3D
+
+# Child node of the terrain, used to render numerous small objects on the ground
+# such as grass or rocks. They do so by using a texture covering the terrain
+# (a "detail map"), which is found in the terrain data itself.
+# A terrain can have multiple detail maps, and you can choose which one will be
+# used with `layer_index`.
+# Details use instanced rendering within their own chunk grid, scattered around
+# the player. Importantly, the position and rotation of this node don't matter,
+# and they also do NOT scale with map scale. Indeed, scaling the heightmap
+# doesn't mean we want to scale grass blades (which is not a use case I know of).
+
+const HTerrainData = preload("./hterrain_data.gd")
+const HT_DirectMultiMeshInstance = preload("./util/direct_multimesh_instance.gd")
+const HT_DirectMeshInstance = preload("./util/direct_mesh_instance.gd")
+const HT_Util = preload("./util/util.gd")
+const HT_Logger = preload("./util/logger.gd")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+const DEFAULT_MESH_PATH = "res://addons/zylann.hterrain/models/grass_quad.obj"
+
+# Cannot use `const` because `HTerrain` depends on the current script
+var HTerrain = load("res://addons/zylann.hterrain/hterrain.gd")
+
+const CHUNK_SIZE = 32
+const DEFAULT_SHADER_PATH = "res://addons/zylann.hterrain/shaders/detail.gdshader"
+const DEBUG = false
+
+# These parameters are considered built-in,
+# they are managed internally so they are not directly exposed
+const _API_SHADER_PARAMS = {
+ "u_terrain_heightmap": true,
+ "u_terrain_detailmap": true,
+ "u_terrain_normalmap": true,
+ "u_terrain_globalmap": true,
+ "u_terrain_inverse_transform": true,
+ "u_terrain_normal_basis": true,
+ "u_albedo_alpha": true,
+ "u_view_distance": true,
+ "u_ambient_wind": true
+}
+
+# TODO Should be renamed `map_index`
+# Which detail map this layer will use
+@export var layer_index := 0:
+ get:
+ return layer_index
+ set(v):
+ if layer_index == v:
+ return
+ layer_index = v
+ if is_inside_tree():
+ _update_material()
+ HT_Util.update_configuration_warning(self, false)
+
+
+# Texture to render on the detail meshes.
+@export var texture : Texture:
+ get:
+ return texture
+ set(tex):
+ texture = tex
+ _material.set_shader_parameter("u_albedo_alpha", tex)
+
+
+# How far detail meshes can be seen.
+# TODO Improve speed of _get_chunk_aabb() so we can increase the limit
+# See https://github.com/Zylann/godot_heightmap_plugin/issues/155
+@export_range(1.0, 500.0) var view_distance := 100.0:
+ get:
+ return view_distance
+ set(v):
+ if view_distance == v:
+ return
+ view_distance = maxf(v, 1.0)
+ if is_inside_tree():
+ _update_material()
+
+
+# Custom shader to replace the default one.
+@export var custom_shader : Shader:
+ get:
+ return custom_shader
+ set(shader):
+ if custom_shader == shader:
+ return
+ custom_shader = shader
+ if custom_shader == null:
+ _material.shader = load(DEFAULT_SHADER_PATH)
+ else:
+ _material.shader = custom_shader
+
+ if Engine.is_editor_hint():
+ # Ability to fork default shader
+ if shader.code == "":
+ shader.code = _default_shader.code
+
+
+# Density modifier, to make more or less detail meshes appear overall.
+@export_range(0, 10) var density := 4.0:
+ get:
+ return density
+ set(v):
+ v = clampf(v, 0, 10)
+ if v == density:
+ return
+ density = v
+ _multimesh_need_regen = true
+
+
+# Mesh used for every detail instance (for example, every grass patch).
+# If not assigned, an internal quad mesh will be used.
+# I would have called it `mesh` but that's too broad and conflicts with local vars ._.
+@export var instance_mesh : Mesh:
+ get:
+ return instance_mesh
+ set(p_mesh):
+ if p_mesh == instance_mesh:
+ return
+ instance_mesh = p_mesh
+ _multimesh.mesh = _get_used_mesh()
+
+
+# Exposes rendering layers, similar to `VisualInstance.layers`
+# (IMO this annotation is not specific enough, something might be off...)
+@export_flags_3d_render var render_layers := 1:
+ get:
+ return render_layers
+ set(mask):
+ render_layers = mask
+ for k in _chunks:
+ var chunk = _chunks[k]
+ chunk.set_layer_mask(mask)
+
+
+# Exposes shadow casting setting.
+# Possible values are the same as the enum `GeometryInstance.SHADOW_CASTING_SETTING_*`.
+# TODO Casting to `int` should not be necessary! Had to do it otherwise GDScript complains...
+@export_enum("Off", "On", "DoubleSided", "ShadowsOnly") \
+ var cast_shadow := int(GeometryInstance3D.SHADOW_CASTING_SETTING_ON):
+ get:
+ return cast_shadow
+ set(option):
+ if option == cast_shadow:
+ return
+ cast_shadow = option
+ for k in _chunks:
+ var mmi : HT_DirectMultiMeshInstance = _chunks[k]
+ mmi.set_cast_shadow(option)
+
+
+var _material: ShaderMaterial = null
+var _default_shader: Shader = null
+
+# Vector2 => DirectMultiMeshInstance
+var _chunks := {}
+
+var _multimesh: MultiMesh
+var _multimesh_need_regen = true
+var _multimesh_instance_pool := []
+var _ambient_wind_time := 0.0
+#var _auto_pick_index_on_enter_tree := Engine.is_editor_hint()
+var _debug_wirecube_mesh: Mesh = null
+var _debug_cubes := []
+var _logger := HT_Logger.get_for(self)
+
+
+func _init():
+ _default_shader = load(DEFAULT_SHADER_PATH)
+ _material = ShaderMaterial.new()
+ _material.shader = _default_shader
+
+ _multimesh = MultiMesh.new()
+ _multimesh.transform_format = MultiMesh.TRANSFORM_3D
+ # TODO Godot 3 had the option to specify color format, but Godot 4 no longer does...
+ # I only need 8-bit, but Godot 4 uses 32-bit components colors...
+ #_multimesh.color_format = MultiMesh.COLOR_8BIT
+ _multimesh.use_colors = true
+
+
+func _enter_tree():
+ var terrain = _get_terrain()
+ if terrain != null:
+ terrain.transform_changed.connect(_on_terrain_transform_changed)
+
+ #if _auto_pick_index_on_enter_tree:
+ # _auto_pick_index_on_enter_tree = false
+ # _auto_pick_index()
+
+ terrain._internal_add_detail_layer(self)
+
+ _update_material()
+
+
+func _exit_tree():
+ var terrain = _get_terrain()
+ if terrain != null:
+ terrain.transform_changed.disconnect(_on_terrain_transform_changed)
+ terrain._internal_remove_detail_layer(self)
+ _update_material()
+ for k in _chunks.keys():
+ _recycle_chunk(k)
+ _chunks.clear()
+
+
+#func _auto_pick_index():
+# # Automatically pick an unused layer
+#
+# var terrain = _get_terrain()
+# if terrain == null:
+# return
+#
+# var terrain_data = terrain.get_data()
+# if terrain_data == null or terrain_data.is_locked():
+# return
+#
+# var auto_index := layer_index
+# var others = terrain.get_detail_layers()
+#
+# if len(others) > 0:
+# var used_layers := []
+# for other in others:
+# used_layers.append(other.layer_index)
+# used_layers.sort()
+#
+# auto_index = used_layers[-1]
+# for i in range(1, len(used_layers)):
+# if used_layers[i - 1] - used_layers[i] > 1:
+# # Found a hole, take it instead
+# auto_index = used_layers[i] - 1
+# break
+#
+# print("Auto picked ", auto_index, " ")
+# layer_index = auto_index
+
+
+func _get_property_list() -> Array:
+ # Dynamic properties coming from the shader
+ var props := []
+ if _material != null:
+ var shader_params = RenderingServer.get_shader_parameter_list(_material.shader.get_rid())
+ for p in shader_params:
+ if _API_SHADER_PARAMS.has(p.name):
+ continue
+ var cp := {}
+ for k in p:
+ cp[k] = p[k]
+ cp.name = str("shader_params/", p.name)
+ props.append(cp)
+ return props
+
+
+func _get(key: StringName):
+ var key_str := String(key)
+ if key_str.begins_with("shader_params/"):
+ var param_name = key_str.substr(len("shader_params/"))
+ return get_shader_param(param_name)
+
+
+func _set(key: StringName, v):
+ var key_str := String(key)
+ if key_str.begins_with("shader_params/"):
+ var param_name = key_str.substr(len("shader_params/"))
+ set_shader_param(param_name, v)
+
+
+func get_shader_param(param_name: String):
+ return _material.get_shader_parameter(param_name)
+
+
+func set_shader_param(param_name: String, v):
+ _material.set_shader_parameter(param_name, v)
+
+
+func _get_terrain():
+ if is_inside_tree():
+ return get_parent()
+ return null
+
+
+# Compat
+func set_texture(tex: Texture):
+ texture = tex
+
+
+# Compat
+func get_texture() -> Texture:
+ return texture
+
+
+# Compat
+func set_layer_index(v: int):
+ layer_index = v
+
+
+# Compat
+func get_layer_index() -> int:
+ return layer_index
+
+
+# Compat
+func set_view_distance(v: float):
+ return view_distance
+
+
+# Compat
+func get_view_distance() -> float:
+ return view_distance
+
+
+# Compat
+func set_custom_shader(shader: Shader):
+ custom_shader = shader
+
+
+# Compat
+func get_custom_shader() -> Shader:
+ return custom_shader
+
+
+# Compat
+func set_instance_mesh(p_mesh: Mesh):
+ instance_mesh = p_mesh
+
+
+# Compat
+func get_instance_mesh() -> Mesh:
+ return instance_mesh
+
+
+# Compat
+func set_render_layer_mask(mask: int):
+ render_layers = mask
+
+
+# Compat
+func get_render_layer_mask() -> int:
+ return render_layers
+
+
+func _get_used_mesh() -> Mesh:
+ if instance_mesh == null:
+ var mesh = load(DEFAULT_MESH_PATH) as Mesh
+ if mesh == null:
+ _logger.error(str("Failed to load default mesh: ", DEFAULT_MESH_PATH))
+ return mesh
+ return instance_mesh
+
+
+# Compat
+func set_density(v: float):
+ density = v
+
+
+# Compat
+func get_density() -> float:
+ return density
+
+
+# Updates texture references and values that come from the terrain itself.
+# This is typically used when maps are being swapped around in terrain data,
+# so we can restore texture references that may break.
+func update_material():
+ _update_material()
+ # Formerly update_ambient_wind, reset
+
+
+func _notification(what: int):
+ match what:
+ NOTIFICATION_ENTER_WORLD:
+ _set_world(get_world_3d())
+
+ NOTIFICATION_EXIT_WORLD:
+ _set_world(null)
+
+ NOTIFICATION_VISIBILITY_CHANGED:
+ _set_visible(visible)
+
+ NOTIFICATION_PREDELETE:
+ # Force DirectMeshInstances to be destroyed before the material.
+ # Otherwise it causes RenderingServer errors...
+ _chunks.clear()
+ _multimesh_instance_pool.clear()
+
+
+func _set_visible(v: bool):
+ for k in _chunks:
+ var chunk = _chunks[k]
+ chunk.set_visible(v)
+
+
+func _set_world(w: World3D):
+ for k in _chunks:
+ var chunk = _chunks[k]
+ chunk.set_world(w)
+
+
+func _on_terrain_transform_changed(gt: Transform3D):
+ _update_material()
+
+ var terrain = _get_terrain()
+ if terrain == null:
+ _logger.error("Detail layer is not child of a terrain!")
+ return
+
+ var terrain_transform : Transform3D = terrain.get_internal_transform()
+
+ # Update AABBs and transforms, because scale might have changed
+ for k in _chunks:
+ var mmi = _chunks[k]
+ var aabb = _get_chunk_aabb(terrain, Vector3(k.x * CHUNK_SIZE, 0, k.y * CHUNK_SIZE))
+ # Nullify XZ translation because that's done by transform already
+ aabb.position.x = 0
+ aabb.position.z = 0
+ mmi.set_aabb(aabb)
+ mmi.set_transform(_get_chunk_transform(terrain_transform, k.x, k.y))
+
+
+func process(delta: float, viewer_pos: Vector3):
+ var terrain = _get_terrain()
+ if terrain == null:
+ _logger.error("DetailLayer processing while terrain is null!")
+ return
+
+ if _multimesh_need_regen:
+ _regen_multimesh()
+ _multimesh_need_regen = false
+ # Crash workaround for Godot 3.1
+ # See https://github.com/godotengine/godot/issues/32500
+ for k in _chunks:
+ var mmi = _chunks[k]
+ mmi.set_multimesh(_multimesh)
+
+ # Detail layers are unaffected by ground map_scale
+ var terrain_transform_without_map_scale : Transform3D = \
+ terrain.get_internal_transform_unscaled()
+ var local_viewer_pos := terrain_transform_without_map_scale.affine_inverse() * viewer_pos
+
+ var viewer_cx = local_viewer_pos.x / CHUNK_SIZE
+ var viewer_cz = local_viewer_pos.z / CHUNK_SIZE
+
+ var cr = int(view_distance) / CHUNK_SIZE + 1
+
+ var cmin_x = viewer_cx - cr
+ var cmin_z = viewer_cz - cr
+ var cmax_x = viewer_cx + cr
+ var cmax_z = viewer_cz + cr
+
+ var map_res = terrain.get_data().get_resolution()
+ var map_scale = terrain.map_scale
+
+ var terrain_size_x = map_res * map_scale.x
+ var terrain_size_z = map_res * map_scale.z
+
+ var terrain_chunks_x = terrain_size_x / CHUNK_SIZE
+ var terrain_chunks_z = terrain_size_z / CHUNK_SIZE
+
+ cmin_x = clampi(cmin_x, 0, terrain_chunks_x)
+ cmin_z = clampi(cmin_z, 0, terrain_chunks_z)
+
+ if DEBUG and visible:
+ _debug_cubes.clear()
+ for cz in range(cmin_z, cmax_z):
+ for cx in range(cmin_x, cmax_x):
+ _add_debug_cube(terrain, _get_chunk_aabb(terrain, Vector3(cx, 0, cz) * CHUNK_SIZE))
+
+ for cz in range(cmin_z, cmax_z):
+ for cx in range(cmin_x, cmax_x):
+
+ var cpos2d = Vector2(cx, cz)
+ if _chunks.has(cpos2d):
+ continue
+
+ var aabb = _get_chunk_aabb(terrain, Vector3(cx, 0, cz) * CHUNK_SIZE)
+ var d = (aabb.position + 0.5 * aabb.size).distance_to(local_viewer_pos)
+
+ if d < view_distance:
+ _load_chunk(terrain_transform_without_map_scale, cx, cz, aabb)
+
+ var to_recycle = []
+
+ for k in _chunks:
+ var chunk = _chunks[k]
+ var aabb = _get_chunk_aabb(terrain, Vector3(k.x, 0, k.y) * CHUNK_SIZE)
+ var d = (aabb.position + 0.5 * aabb.size).distance_to(local_viewer_pos)
+ if d > view_distance:
+ to_recycle.append(k)
+
+ for k in to_recycle:
+ _recycle_chunk(k)
+
+ # Update time manually, so we can accelerate the animation when strength is increased,
+ # without causing phase jumps (which would be the case if we just scaled TIME)
+ var ambient_wind_frequency = 1.0 + 3.0 * terrain.ambient_wind
+ _ambient_wind_time += delta * ambient_wind_frequency
+ var awp = _get_ambient_wind_params()
+ _material.set_shader_parameter("u_ambient_wind", awp)
+
+
+# Gets local-space AABB of a detail chunk.
+# This only apply map_scale in Y, because details are not affected by X and Z map scale.
+func _get_chunk_aabb(terrain, lpos: Vector3):
+ var terrain_scale = terrain.map_scale
+ var terrain_data = terrain.get_data()
+ var origin_cells_x := int(lpos.x / terrain_scale.x)
+ var origin_cells_z := int(lpos.z / terrain_scale.z)
+ var size_cells_x := int(CHUNK_SIZE / terrain_scale.x)
+ var size_cells_z := int(CHUNK_SIZE / terrain_scale.z)
+
+ var aabb = terrain_data.get_region_aabb(
+ origin_cells_x, origin_cells_z, size_cells_x, size_cells_z)
+
+ aabb.position = Vector3(lpos.x, lpos.y + aabb.position.y * terrain_scale.y, lpos.z)
+ aabb.size = Vector3(CHUNK_SIZE, aabb.size.y * terrain_scale.y, CHUNK_SIZE)
+ return aabb
+
+
+func _get_chunk_transform(terrain_transform: Transform3D, cx: int, cz: int) -> Transform3D:
+ var lpos := Vector3(cx, 0, cz) * CHUNK_SIZE
+ # `terrain_transform` should be the terrain's internal transform, without `map_scale`.
+ var trans := Transform3D(
+ terrain_transform.basis,
+ terrain_transform.origin + terrain_transform.basis * lpos)
+ return trans
+
+
+func _load_chunk(terrain_transform_without_map_scale: Transform3D, cx: int, cz: int, aabb: AABB):
+ aabb.position.x = 0
+ aabb.position.z = 0
+
+ var mmi = null
+ if len(_multimesh_instance_pool) != 0:
+ mmi = _multimesh_instance_pool[-1]
+ _multimesh_instance_pool.pop_back()
+ else:
+ mmi = HT_DirectMultiMeshInstance.new()
+ mmi.set_world(get_world_3d())
+ mmi.set_multimesh(_multimesh)
+
+ var trans := _get_chunk_transform(terrain_transform_without_map_scale, cx, cz)
+
+ mmi.set_material_override(_material)
+ mmi.set_transform(trans)
+ mmi.set_aabb(aabb)
+ mmi.set_layer_mask(render_layers)
+ mmi.set_cast_shadow(cast_shadow)
+ mmi.set_visible(visible)
+
+ _chunks[Vector2(cx, cz)] = mmi
+
+
+func _recycle_chunk(cpos2d: Vector2):
+ var mmi = _chunks[cpos2d]
+ _chunks.erase(cpos2d)
+ mmi.set_visible(false)
+ _multimesh_instance_pool.append(mmi)
+
+
+func _get_ambient_wind_params() -> Vector2:
+ var aw = 0.0
+ var terrain = _get_terrain()
+ if terrain != null:
+ aw = terrain.ambient_wind
+ # amplitude, time
+ return Vector2(aw, _ambient_wind_time)
+
+
+func _update_material():
+ # Sets API shader properties. Custom properties are assumed to be set already
+ _logger.debug("Updating detail layer material")
+
+ var terrain_data = null
+ var terrain = _get_terrain()
+ var it = Transform3D()
+ var normal_basis = Basis()
+
+ if terrain != null:
+ var gt = terrain.get_internal_transform()
+ it = gt.affine_inverse()
+ terrain_data = terrain.get_data()
+ # This is needed to properly transform normals if the terrain is scaled.
+ # However we don't want to pick up rotation because it's already factored in the instance
+ #normal_basis = gt.basis.inverse().transposed()
+ normal_basis = Basis().scaled(terrain.map_scale).inverse().transposed()
+
+ var mat = _material
+
+ mat.set_shader_parameter("u_terrain_inverse_transform", it)
+ mat.set_shader_parameter("u_terrain_normal_basis", normal_basis)
+ mat.set_shader_parameter("u_albedo_alpha", texture)
+ mat.set_shader_parameter("u_view_distance", view_distance)
+ mat.set_shader_parameter("u_ambient_wind", _get_ambient_wind_params())
+
+ var heightmap_texture = null
+ var normalmap_texture = null
+ var detailmap_texture = null
+ var globalmap_texture = null
+
+ if terrain_data != null:
+ if terrain_data.is_locked():
+ _logger.error("Terrain data locked, can't update detail layer now")
+ return
+
+ heightmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+ normalmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_NORMAL)
+
+ if layer_index < terrain_data.get_map_count(HTerrainData.CHANNEL_DETAIL):
+ detailmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_DETAIL, layer_index)
+
+ if terrain_data.get_map_count(HTerrainData.CHANNEL_GLOBAL_ALBEDO) > 0:
+ globalmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+ else:
+ _logger.error("Terrain data is null, can't update detail layer completely")
+
+ mat.set_shader_parameter("u_terrain_heightmap", heightmap_texture)
+ mat.set_shader_parameter("u_terrain_detailmap", detailmap_texture)
+ mat.set_shader_parameter("u_terrain_normalmap", normalmap_texture)
+ mat.set_shader_parameter("u_terrain_globalmap", globalmap_texture)
+
+
+func _add_debug_cube(terrain: Node3D, aabb: AABB):
+ var world : World3D = terrain.get_world_3d()
+
+ if _debug_wirecube_mesh == null:
+ _debug_wirecube_mesh = HT_Util.create_wirecube_mesh()
+ var mat := StandardMaterial3D.new()
+ mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ _debug_wirecube_mesh.surface_set_material(0, mat)
+
+ var debug_cube := HT_DirectMeshInstance.new()
+ debug_cube.set_mesh(_debug_wirecube_mesh)
+ debug_cube.set_world(world)
+ #aabb.position.y += 0.2*randf()
+ debug_cube.set_transform(Transform3D(Basis().scaled(aabb.size), aabb.position))
+
+ _debug_cubes.append(debug_cube)
+
+
+func _regen_multimesh():
+ # We modify the existing multimesh instead of replacing it.
+ # DirectMultiMeshInstance does not keep a strong reference to them,
+ # so replacing would break pooled instances.
+ _generate_multimesh(CHUNK_SIZE, density, _get_used_mesh(), _multimesh)
+
+
+func is_layer_index_valid() -> bool:
+ var terrain = _get_terrain()
+ if terrain == null:
+ return false
+ var data = terrain.get_data()
+ if data == null:
+ return false
+ return layer_index >= 0 and layer_index < data.get_map_count(HTerrainData.CHANNEL_DETAIL)
+
+
+func _get_configuration_warnings() -> PackedStringArray:
+ var warnings := PackedStringArray()
+
+ var terrain = _get_terrain()
+ if not (is_instance_of(terrain, HTerrain)):
+ warnings.append("This node must be child of an HTerrain node")
+ return warnings
+
+ var data = terrain.get_data()
+ if data == null:
+ warnings.append("The terrain has no data")
+ return warnings
+
+ if data.get_map_count(HTerrainData.CHANNEL_DETAIL) == 0:
+ warnings.append("The terrain does not have any detail map")
+ return warnings
+
+ if layer_index < 0 or layer_index >= data.get_map_count(HTerrainData.CHANNEL_DETAIL):
+ warnings.append("Layer index is out of bounds")
+ return warnings
+
+ var tex = data.get_texture(HTerrainData.CHANNEL_DETAIL, layer_index)
+ if tex == null:
+ warnings.append("The terrain does not have a map assigned in slot {0}" \
+ .format([layer_index]))
+
+ return warnings
+
+
+# Compat
+func set_cast_shadow(option: int):
+ cast_shadow = option
+
+
+# Compat
+func get_cast_shadow() -> int:
+ return cast_shadow
+
+
+static func _generate_multimesh(resolution: int, density: float, mesh: Mesh, multimesh: MultiMesh):
+ assert(multimesh != null)
+
+ var position_randomness := 0.5
+ var scale_randomness := 0.0
+ #var color_randomness = 0.5
+
+ var cell_count := resolution * resolution
+ var idensity := int(density)
+ var random_instance_count := int(cell_count * (density - floorf(density)))
+ var total_instance_count := cell_count * idensity + random_instance_count
+
+ multimesh.instance_count = total_instance_count
+ multimesh.mesh = mesh
+
+ # First pass ensures uniform spread
+ var i := 0
+ for z in resolution:
+ for x in resolution:
+ for j in idensity:
+
+ var pos := Vector3(x, 0, z)
+ pos.x += randf_range(-position_randomness, position_randomness)
+ pos.z += randf_range(-position_randomness, position_randomness)
+
+ multimesh.set_instance_color(i, Color(1, 1, 1))
+ multimesh.set_instance_transform(i, \
+ Transform3D(_get_random_instance_basis(scale_randomness), pos))
+ i += 1
+
+ # Second pass adds the rest
+ for j in random_instance_count:
+ var pos = Vector3(randf_range(0, resolution), 0, randf_range(0, resolution))
+ multimesh.set_instance_color(i, Color(1, 1, 1))
+ multimesh.set_instance_transform(i, \
+ Transform3D(_get_random_instance_basis(scale_randomness), pos))
+ i += 1
+
+
+static func _get_random_instance_basis(scale_randomness: float) -> Basis:
+ var sr := randf_range(0, scale_randomness)
+ var s := 1.0 + (sr * sr * sr * sr * sr) * 50.0
+
+ var basis := Basis()
+ basis = basis.scaled(Vector3(1, s, 1))
+ basis = basis.rotated(Vector3(0, 1, 0), randf_range(0, PI))
+
+ return basis
diff --git a/game/addons/zylann.hterrain/hterrain_mesher.gd b/game/addons/zylann.hterrain/hterrain_mesher.gd
new file mode 100644
index 0000000..0b371e1
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_mesher.gd
@@ -0,0 +1,358 @@
+@tool
+
+#const HT_Logger = preload("./util/logger.gd")
+const HTerrainData = preload("./hterrain_data.gd")
+
+const SEAM_LEFT = 1
+const SEAM_RIGHT = 2
+const SEAM_BOTTOM = 4
+const SEAM_TOP = 8
+const SEAM_CONFIG_COUNT = 16
+
+
+# [seams_mask][lod]
+var _mesh_cache := []
+var _chunk_size_x := 16
+var _chunk_size_y := 16
+
+
+func configure(chunk_size_x: int, chunk_size_y: int, lod_count: int):
+ assert(typeof(chunk_size_x) == TYPE_INT)
+ assert(typeof(chunk_size_y) == TYPE_INT)
+ assert(typeof(lod_count) == TYPE_INT)
+
+ assert(chunk_size_x >= 2 or chunk_size_y >= 2)
+
+ _mesh_cache.resize(SEAM_CONFIG_COUNT)
+
+ if chunk_size_x == _chunk_size_x \
+ and chunk_size_y == _chunk_size_y and lod_count == len(_mesh_cache):
+ return
+
+ _chunk_size_x = chunk_size_x
+ _chunk_size_y = chunk_size_y
+
+ # TODO Will reduce the size of this cache, but need index buffer swap feature
+ for seams in SEAM_CONFIG_COUNT:
+ var slot := []
+ slot.resize(lod_count)
+ _mesh_cache[seams] = slot
+
+ for lod in lod_count:
+ slot[lod] = make_flat_chunk(_chunk_size_x, _chunk_size_y, 1 << lod, seams)
+
+
+func get_chunk(lod: int, seams: int) -> Mesh:
+ return _mesh_cache[seams][lod] as Mesh
+
+
+static func make_flat_chunk(quad_count_x: int, quad_count_y: int, stride: int, seams: int) -> Mesh:
+ var positions = PackedVector3Array()
+ positions.resize((quad_count_x + 1) * (quad_count_y + 1))
+
+ var i = 0
+ for y in quad_count_y + 1:
+ for x in quad_count_x + 1:
+ positions[i] = Vector3(x * stride, 0, y * stride)
+ i += 1
+
+ var indices := make_indices(quad_count_x, quad_count_y, seams)
+
+ var arrays := []
+ arrays.resize(Mesh.ARRAY_MAX);
+ arrays[Mesh.ARRAY_VERTEX] = positions
+ arrays[Mesh.ARRAY_INDEX] = indices
+
+ var mesh := ArrayMesh.new()
+ mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
+
+ return mesh
+
+
+# size: chunk size in quads (there are N+1 vertices)
+# seams: Bitfield for which seams are present
+static func make_indices(chunk_size_x: int, chunk_size_y: int, seams: int) -> PackedInt32Array:
+ var output_indices := PackedInt32Array()
+
+ if seams != 0:
+ # LOD seams can't be made properly on uneven chunk sizes
+ assert(chunk_size_x % 2 == 0 and chunk_size_y % 2 == 0)
+
+ var reg_origin_x := 0
+ var reg_origin_y := 0
+ var reg_size_x := chunk_size_x
+ var reg_size_y := chunk_size_y
+ var reg_hstride := 1
+
+ if seams & SEAM_LEFT:
+ reg_origin_x += 1;
+ reg_size_x -= 1;
+ reg_hstride += 1
+
+ if seams & SEAM_BOTTOM:
+ reg_origin_y += 1
+ reg_size_y -= 1
+
+ if seams & SEAM_RIGHT:
+ reg_size_x -= 1
+ reg_hstride += 1
+
+ if seams & SEAM_TOP:
+ reg_size_y -= 1
+
+ # Regular triangles
+ var ii := reg_origin_x + reg_origin_y * (chunk_size_x + 1)
+
+ for y in reg_size_y:
+ for x in reg_size_x:
+ var i00 := ii
+ var i10 := ii + 1
+ var i01 := ii + chunk_size_x + 1
+ var i11 := i01 + 1
+
+ # 01---11
+ # | /|
+ # | / |
+ # |/ |
+ # 00---10
+
+ # This flips the pattern to make the geometry orientation-free.
+ # Not sure if it helps in any way though
+ var flip = ((x + reg_origin_x) + (y + reg_origin_y) % 2) % 2 != 0
+
+ if flip:
+ output_indices.push_back( i00 )
+ output_indices.push_back( i10 )
+ output_indices.push_back( i01 )
+
+ output_indices.push_back( i10 )
+ output_indices.push_back( i11 )
+ output_indices.push_back( i01 )
+
+ else:
+ output_indices.push_back( i00 )
+ output_indices.push_back( i11 )
+ output_indices.push_back( i01 )
+
+ output_indices.push_back( i00 )
+ output_indices.push_back( i10 )
+ output_indices.push_back( i11 )
+
+ ii += 1
+ ii += reg_hstride
+
+ # Left seam
+ if seams & SEAM_LEFT:
+
+ # 4 . 5
+ # |\ .
+ # | \ .
+ # | \.
+ # (2)| 3
+ # | /.
+ # | / .
+ # |/ .
+ # 0 . 1
+
+ var i := 0
+ var n := chunk_size_y / 2
+
+ for j in n:
+ var i0 := i
+ var i1 := i + 1
+ var i3 := i + chunk_size_x + 2
+ var i4 := i + 2 * (chunk_size_x + 1)
+ var i5 := i4 + 1
+
+ output_indices.push_back( i0 )
+ output_indices.push_back( i3 )
+ output_indices.push_back( i4 )
+
+ if j != 0 or (seams & SEAM_BOTTOM) == 0:
+ output_indices.push_back( i0 )
+ output_indices.push_back( i1 )
+ output_indices.push_back( i3 )
+
+ if j != n - 1 or (seams & SEAM_TOP) == 0:
+ output_indices.push_back( i3 )
+ output_indices.push_back( i5 )
+ output_indices.push_back( i4 )
+
+ i = i4
+
+ if seams & SEAM_RIGHT:
+
+ # 4 . 5
+ # . /|
+ # . / |
+ # ./ |
+ # 2 |(3)
+ # .\ |
+ # . \ |
+ # . \|
+ # 0 . 1
+
+ var i := chunk_size_x - 1
+ var n := chunk_size_y / 2
+
+ for j in n:
+
+ var i0 := i
+ var i1 := i + 1
+ var i2 := i + chunk_size_x + 1
+ var i4 := i + 2 * (chunk_size_x + 1)
+ var i5 := i4 + 1
+
+ output_indices.push_back( i1 )
+ output_indices.push_back( i5 )
+ output_indices.push_back( i2 )
+
+ if j != 0 or (seams & SEAM_BOTTOM) == 0:
+ output_indices.push_back( i0 )
+ output_indices.push_back( i1 )
+ output_indices.push_back( i2 )
+
+ if j != n - 1 or (seams & SEAM_TOP) == 0:
+ output_indices.push_back( i2 )
+ output_indices.push_back( i5 )
+ output_indices.push_back( i4 )
+
+ i = i4;
+
+ if seams & SEAM_BOTTOM:
+
+ # 3 . 4 . 5
+ # . / \ .
+ # . / \ .
+ # ./ \.
+ # 0-------2
+ # (1)
+
+ var i := 0;
+ var n := chunk_size_x / 2;
+
+ for j in n:
+
+ var i0 := i
+ var i2 := i + 2
+ var i3 := i + chunk_size_x + 1
+ var i4 := i3 + 1
+ var i5 := i4 + 1
+
+ output_indices.push_back( i0 )
+ output_indices.push_back( i2 )
+ output_indices.push_back( i4 )
+
+ if j != 0 or (seams & SEAM_LEFT) == 0:
+ output_indices.push_back( i0 )
+ output_indices.push_back( i4 )
+ output_indices.push_back( i3 )
+
+ if j != n - 1 or (seams & SEAM_RIGHT) == 0:
+ output_indices.push_back( i2 )
+ output_indices.push_back( i5 )
+ output_indices.push_back( i4 )
+
+ i = i2
+
+ if seams & SEAM_TOP:
+
+ # (4)
+ # 3-------5
+ # .\ /.
+ # . \ / .
+ # . \ / .
+ # 0 . 1 . 2
+
+ var i := (chunk_size_y - 1) * (chunk_size_x + 1)
+ var n := chunk_size_x / 2
+
+ for j in n:
+
+ var i0 := i
+ var i1 := i + 1
+ var i2 := i + 2
+ var i3 := i + chunk_size_x + 1
+ var i5 := i3 + 2
+
+ output_indices.push_back( i3 )
+ output_indices.push_back( i1 )
+ output_indices.push_back( i5 )
+
+ if j != 0 or (seams & SEAM_LEFT) == 0:
+ output_indices.push_back( i0 )
+ output_indices.push_back( i1 )
+ output_indices.push_back( i3 )
+
+ if j != n - 1 or (seams & SEAM_RIGHT) == 0:
+ output_indices.push_back( i1 )
+ output_indices.push_back( i2 )
+ output_indices.push_back( i5 )
+
+ i = i2
+
+ return output_indices
+
+
+static func get_mesh_size(width: int, height: int) -> Dictionary:
+ return {
+ "vertices": width * height,
+ "triangles": (width - 1) * (height - 1) * 2
+ }
+
+
+# Makes a full mesh from a heightmap, without any LOD considerations.
+# Using this mesh for rendering is very expensive on large terrains.
+# Initially used as a workaround for Godot to use for navmesh generation.
+static func make_heightmap_mesh(heightmap: Image, stride: int, scale: Vector3,
+ logger = null) -> Mesh:
+
+ var size_x := heightmap.get_width() / stride
+ var size_z := heightmap.get_height() / stride
+
+ assert(size_x >= 2)
+ assert(size_z >= 2)
+
+ var positions := PackedVector3Array()
+ positions.resize(size_x * size_z)
+
+ var i := 0
+
+ if heightmap.get_format() == Image.FORMAT_RH or heightmap.get_format() == Image.FORMAT_RF:
+ for mz in size_z:
+ for mx in size_x:
+ var x := mx * stride
+ var z := mz * stride
+ var y := heightmap.get_pixel(x, z).r
+ positions[i] = Vector3(x, y, z) * scale
+ i += 1
+
+ elif heightmap.get_format() == Image.FORMAT_RGB8:
+ for mz in size_z:
+ for mx in size_x:
+ var x := mx * stride
+ var z := mz * stride
+ var c := heightmap.get_pixel(x, z)
+ var y := HTerrainData.decode_height_from_rgb8_unorm(c)
+ positions[i] = Vector3(x, y, z) * scale
+ i += 1
+
+ else:
+ logger.error("Unknown heightmap format!")
+ return null
+
+ var indices := make_indices(size_x - 1, size_z - 1, 0)
+
+ var arrays := []
+ arrays.resize(Mesh.ARRAY_MAX);
+ arrays[Mesh.ARRAY_VERTEX] = positions
+ arrays[Mesh.ARRAY_INDEX] = indices
+
+ if logger != null:
+ logger.debug(str("Generated mesh has ", len(positions),
+ " vertices and ", len(indices) / 3, " triangles"))
+
+ var mesh := ArrayMesh.new()
+ mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
+
+ return mesh
diff --git a/game/addons/zylann.hterrain/hterrain_resource_loader.gd b/game/addons/zylann.hterrain/hterrain_resource_loader.gd
new file mode 100644
index 0000000..ccc176b
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_resource_loader.gd
@@ -0,0 +1,35 @@
+@tool
+class_name HTerrainDataLoader
+extends ResourceFormatLoader
+
+
+const HTerrainData = preload("./hterrain_data.gd")
+
+
+func _get_recognized_extensions() -> PackedStringArray:
+ return PackedStringArray([HTerrainData.META_EXTENSION])
+
+
+func _get_resource_type(path: String) -> String:
+ var ext := path.get_extension().to_lower()
+ if ext == HTerrainData.META_EXTENSION:
+ return "Resource"
+ return ""
+
+
+# TODO Handle UIDs?
+# By default Godot will return INVALID_ID,
+# which makes this resource only tracked by path, like scripts
+#
+# func _get_resource_uid(path: String) -> int:
+# return ???
+
+
+func _handles_type(typename: StringName) -> bool:
+ return typename == &"Resource"
+
+
+func _load(path: String, original_path: String, use_sub_threads: bool, cache_mode: int):
+ var res = HTerrainData.new()
+ res.load_data(path.get_base_dir())
+ return res
diff --git a/game/addons/zylann.hterrain/hterrain_resource_saver.gd b/game/addons/zylann.hterrain/hterrain_resource_saver.gd
new file mode 100644
index 0000000..be757e8
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_resource_saver.gd
@@ -0,0 +1,29 @@
+@tool
+class_name HTerrainDataSaver
+extends ResourceFormatSaver
+
+
+const HTerrainData = preload("./hterrain_data.gd")
+
+
+func _get_recognized_extensions(res: Resource) -> PackedStringArray:
+ if res != null and res is HTerrainData:
+ return PackedStringArray([HTerrainData.META_EXTENSION])
+ return PackedStringArray()
+
+
+func _recognize(res: Resource) -> bool:
+ return res is HTerrainData
+
+
+func _save(resource: Resource, path: String, flags: int) -> Error:
+ if resource.save_data(path.get_base_dir()):
+ return OK
+ # This can occur if at least one map of the terrain fails to save.
+ # It doesnt necessarily mean the entire terrain failed to save.
+ return FAILED
+
+
+# TODO Handle UIDs
+# func _set_uid(path: String, uid: int) -> int:
+# ???
diff --git a/game/addons/zylann.hterrain/hterrain_texture_set.gd b/game/addons/zylann.hterrain/hterrain_texture_set.gd
new file mode 100644
index 0000000..9ae95f2
--- /dev/null
+++ b/game/addons/zylann.hterrain/hterrain_texture_set.gd
@@ -0,0 +1,253 @@
+@tool
+extends Resource
+
+const MODE_TEXTURES = 0
+const MODE_TEXTURE_ARRAYS = 1
+const MODE_COUNT = 2
+
+const _mode_names = ["Textures", "TextureArrays"]
+
+const SRC_TYPE_ALBEDO = 0
+const SRC_TYPE_BUMP = 1
+const SRC_TYPE_NORMAL = 2
+const SRC_TYPE_ROUGHNESS = 3
+const SRC_TYPE_COUNT = 4
+
+const _src_texture_type_names = ["albedo", "bump", "normal", "roughness"]
+
+# Ground texture types (used by the terrain system)
+const TYPE_ALBEDO_BUMP = 0
+const TYPE_NORMAL_ROUGHNESS = 1
+const TYPE_COUNT = 2
+
+const _texture_type_names = ["albedo_bump", "normal_roughness"]
+
+const _type_to_src_types = [
+ [SRC_TYPE_ALBEDO, SRC_TYPE_BUMP],
+ [SRC_TYPE_NORMAL, SRC_TYPE_ROUGHNESS]
+]
+
+const _src_default_color_codes = [
+ "#ff000000",
+ "#ff888888",
+ "#ff8888ff",
+ "#ffffffff"
+]
+
+# TODO We may get rid of modes in the future, and only use TextureArrays.
+# It exists for now for backward compatibility, but it makes the API a bit confusing
+var _mode := MODE_TEXTURES
+# [type][slot] -> StreamTexture or TextureArray
+var _textures := [[], []]
+
+
+static func get_texture_type_name(tt: int) -> String:
+ return _texture_type_names[tt]
+
+
+static func get_source_texture_type_name(tt: int) -> String:
+ return _src_texture_type_names[tt]
+
+
+static func get_source_texture_default_color_code(tt: int) -> String:
+ return _src_default_color_codes[tt]
+
+
+static func get_import_mode_name(mode: int) -> String:
+ return _mode_names[mode]
+
+
+static func get_src_types_from_type(t: int) -> Array:
+ return _type_to_src_types[t]
+
+
+static func get_max_slots_for_mode(mode: int) -> int:
+ match mode:
+ MODE_TEXTURES:
+ # This is a legacy mode, where shaders can only have up to 4
+ return 4
+ MODE_TEXTURE_ARRAYS:
+ # Will probably be lifted some day
+ return 16
+ return 0
+
+
+func _get_property_list() -> Array:
+ return [
+ {
+ "name": "mode",
+ "type": TYPE_INT,
+ "usage": PROPERTY_USAGE_STORAGE
+ },
+ {
+ "name": "textures",
+ "type": TYPE_ARRAY,
+ "usage": PROPERTY_USAGE_STORAGE
+ }
+ ]
+
+
+func _get(key: StringName):
+ if key == &"mode":
+ return _mode
+ if key == &"textures":
+ return _textures
+
+
+func _set(key: StringName, value):
+ if key == &"mode":
+ # Not using set_mode() here because otherwise it could reset stuff set before...
+ _mode = value
+ if key == &"textures":
+ _textures = value
+
+
+func get_slots_count() -> int:
+ if _mode == MODE_TEXTURES:
+ return get_texture_count()
+
+ elif _mode == MODE_TEXTURE_ARRAYS:
+ # TODO What if there are two texture arrays of different size?
+ var texarray : TextureLayered = _textures[TYPE_ALBEDO_BUMP][0]
+ if texarray == null:
+ texarray = _textures[TYPE_NORMAL_ROUGHNESS][0]
+ if texarray == null:
+ return 0
+ return texarray.get_layers()
+
+ else:
+ assert(false)
+ return 0
+
+
+func get_texture_count() -> int:
+ var texs = _textures[TYPE_ALBEDO_BUMP]
+ return len(texs)
+
+
+func get_texture(slot_index: int, ground_texture_type: int) -> Texture2D:
+ if _mode == MODE_TEXTURE_ARRAYS:
+ # Can't get a single texture at once
+ return null
+
+ elif _mode == MODE_TEXTURES:
+ var texs = _textures[ground_texture_type]
+ if slot_index >= len(texs):
+ return null
+ return texs[slot_index]
+
+ else:
+ assert(false)
+ return null
+
+
+func set_texture(slot_index: int, ground_texture_type: int, texture: Texture2D):
+ assert(_mode == MODE_TEXTURES)
+ var texs = _textures[ground_texture_type]
+ if texs[slot_index] != texture:
+ texs[slot_index] = texture
+ emit_changed()
+
+
+func get_texture_array(ground_texture_type: int) -> TextureLayered:
+ if _mode != MODE_TEXTURE_ARRAYS:
+ return null
+ var texs = _textures[ground_texture_type]
+ return texs[0]
+
+
+func set_texture_array(ground_texture_type: int, texarray: TextureLayered):
+ assert(_mode == MODE_TEXTURE_ARRAYS)
+ var texs = _textures[ground_texture_type]
+ if texs[0] != texarray:
+ texs[0] = texarray
+ emit_changed()
+
+
+# TODO This function only exists because of a flaw in UndoRedo
+# See https://github.com/godotengine/godot/issues/36895
+func set_texture_null(slot_index: int, ground_texture_type: int):
+ set_texture(slot_index, ground_texture_type, null)
+
+
+# TODO This function only exists because of a flaw in UndoRedo
+# See https://github.com/godotengine/godot/issues/36895
+func set_texture_array_null(ground_texture_type: int):
+ set_texture_array(ground_texture_type, null)
+
+
+func get_mode() -> int:
+ return _mode
+
+
+func set_mode(mode: int):
+ # This effectively clears slots
+ _mode = mode
+ clear()
+
+
+func clear():
+ match _mode:
+ MODE_TEXTURES:
+ for type in TYPE_COUNT:
+ _textures[type] = []
+ MODE_TEXTURE_ARRAYS:
+ for type in TYPE_COUNT:
+ _textures[type] = [null]
+ _:
+ assert(false)
+ emit_changed()
+
+
+func insert_slot(i: int) -> int:
+ assert(_mode == MODE_TEXTURES)
+ if i == -1:
+ i = get_texture_count()
+ for type in TYPE_COUNT:
+ _textures[type].insert(i, null)
+ emit_changed()
+ return i
+
+
+func remove_slot(i: int):
+ assert(_mode == MODE_TEXTURES)
+ if i == -1:
+ i = get_slots_count() - 1
+ for type in TYPE_COUNT:
+ _textures[type].remove(i)
+ emit_changed()
+
+
+func has_any_textures() -> bool:
+ for type in len(_textures):
+ var texs = _textures[type]
+ for i in len(texs):
+ if texs[i] != null:
+ return true
+ return false
+
+
+#func set_textures(textures: Array):
+# _textures = textures
+
+
+# Cannot type hint because it would cause circular dependency
+#func migrate_from_1_4(terrain):
+# var textures := []
+# for type in TYPE_COUNT:
+# textures.append([])
+#
+# if terrain.is_using_texture_array():
+# for type in TYPE_COUNT:
+# var tex : TextureArray = terrain.get_ground_texture_array(type)
+# textures[type] = [tex]
+# _mode = MODE_TEXTURE_ARRAYS
+#
+# else:
+# for index in terrain.get_max_ground_texture_slot_count():
+# for type in TYPE_COUNT:
+# var tex : Texture = terrain.get_ground_texture(type, index)
+# textures[type].append(tex)
+# _mode = MODE_TEXTURES
+#
+# _textures = textures
diff --git a/game/addons/zylann.hterrain/models/grass_quad.obj.import b/game/addons/zylann.hterrain/models/grass_quad.obj.import
new file mode 100644
index 0000000..fe93f30
--- /dev/null
+++ b/game/addons/zylann.hterrain/models/grass_quad.obj.import
@@ -0,0 +1,21 @@
+[remap]
+
+importer="wavefront_obj"
+importer_version=1
+type="Mesh"
+uid="uid://c1k6wgnjlvxpm"
+path="res://.godot/imported/grass_quad.obj-da067750350fe98ec466261b2aeaf486.mesh"
+
+[deps]
+
+files=["res://.godot/imported/grass_quad.obj-da067750350fe98ec466261b2aeaf486.mesh"]
+
+source_file="res://addons/zylann.hterrain/models/grass_quad.obj"
+dest_files=["res://.godot/imported/grass_quad.obj-da067750350fe98ec466261b2aeaf486.mesh", "res://.godot/imported/grass_quad.obj-da067750350fe98ec466261b2aeaf486.mesh"]
+
+[params]
+
+generate_tangents=true
+scale_mesh=Vector3(1, 1, 1)
+offset_mesh=Vector3(0, 0, 0)
+optimize_mesh=true
diff --git a/game/addons/zylann.hterrain/models/grass_quad_x2.obj.import b/game/addons/zylann.hterrain/models/grass_quad_x2.obj.import
new file mode 100644
index 0000000..926711a
--- /dev/null
+++ b/game/addons/zylann.hterrain/models/grass_quad_x2.obj.import
@@ -0,0 +1,21 @@
+[remap]
+
+importer="wavefront_obj"
+importer_version=1
+type="Mesh"
+uid="uid://dpef2d0qcn5d4"
+path="res://.godot/imported/grass_quad_x2.obj-2054c140f543f2a80e2eb921f865ea49.mesh"
+
+[deps]
+
+files=["res://.godot/imported/grass_quad_x2.obj-2054c140f543f2a80e2eb921f865ea49.mesh"]
+
+source_file="res://addons/zylann.hterrain/models/grass_quad_x2.obj"
+dest_files=["res://.godot/imported/grass_quad_x2.obj-2054c140f543f2a80e2eb921f865ea49.mesh", "res://.godot/imported/grass_quad_x2.obj-2054c140f543f2a80e2eb921f865ea49.mesh"]
+
+[params]
+
+generate_tangents=true
+scale_mesh=Vector3(1, 1, 1)
+offset_mesh=Vector3(0, 0, 0)
+optimize_mesh=true
diff --git a/game/addons/zylann.hterrain/models/grass_quad_x3.obj.import b/game/addons/zylann.hterrain/models/grass_quad_x3.obj.import
new file mode 100644
index 0000000..f358c1e
--- /dev/null
+++ b/game/addons/zylann.hterrain/models/grass_quad_x3.obj.import
@@ -0,0 +1,21 @@
+[remap]
+
+importer="wavefront_obj"
+importer_version=1
+type="Mesh"
+uid="uid://bhjb8bijf1ql3"
+path="res://.godot/imported/grass_quad_x3.obj-8691724bc5006b6f65d4e8742ffc84dc.mesh"
+
+[deps]
+
+files=["res://.godot/imported/grass_quad_x3.obj-8691724bc5006b6f65d4e8742ffc84dc.mesh"]
+
+source_file="res://addons/zylann.hterrain/models/grass_quad_x3.obj"
+dest_files=["res://.godot/imported/grass_quad_x3.obj-8691724bc5006b6f65d4e8742ffc84dc.mesh", "res://.godot/imported/grass_quad_x3.obj-8691724bc5006b6f65d4e8742ffc84dc.mesh"]
+
+[params]
+
+generate_tangents=true
+scale_mesh=Vector3(1, 1, 1)
+offset_mesh=Vector3(0, 0, 0)
+optimize_mesh=true
diff --git a/game/addons/zylann.hterrain/models/grass_quad_x4.obj.import b/game/addons/zylann.hterrain/models/grass_quad_x4.obj.import
new file mode 100644
index 0000000..79a4caa
--- /dev/null
+++ b/game/addons/zylann.hterrain/models/grass_quad_x4.obj.import
@@ -0,0 +1,21 @@
+[remap]
+
+importer="wavefront_obj"
+importer_version=1
+type="Mesh"
+uid="uid://cism8qe63t4tk"
+path="res://.godot/imported/grass_quad_x4.obj-c449a7d6c810ba1595ed30df9fbf3d28.mesh"
+
+[deps]
+
+files=["res://.godot/imported/grass_quad_x4.obj-c449a7d6c810ba1595ed30df9fbf3d28.mesh"]
+
+source_file="res://addons/zylann.hterrain/models/grass_quad_x4.obj"
+dest_files=["res://.godot/imported/grass_quad_x4.obj-c449a7d6c810ba1595ed30df9fbf3d28.mesh", "res://.godot/imported/grass_quad_x4.obj-c449a7d6c810ba1595ed30df9fbf3d28.mesh"]
+
+[params]
+
+generate_tangents=true
+scale_mesh=Vector3(1, 1, 1)
+offset_mesh=Vector3(0, 0, 0)
+optimize_mesh=true
diff --git a/game/addons/zylann.hterrain/native/.clang-format b/game/addons/zylann.hterrain/native/.clang-format
new file mode 100644
index 0000000..237fd9c
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/.clang-format
@@ -0,0 +1,127 @@
+# Commented out parameters are those with the same value as base LLVM style
+# We can uncomment them if we want to change their value, or enforce the
+# chosen value in case the base style changes (last sync: Clang 6.0.1).
+---
+### General config, applies to all languages ###
+BasedOnStyle: LLVM
+AccessModifierOffset: -4
+AlignAfterOpenBracket: DontAlign
+# AlignConsecutiveAssignments: false
+# AlignConsecutiveDeclarations: false
+# AlignEscapedNewlines: Right
+# AlignOperands: true
+AlignTrailingComments: false
+AllowAllParametersOfDeclarationOnNextLine: false
+# AllowShortBlocksOnASingleLine: false
+AllowShortCaseLabelsOnASingleLine: true
+AllowShortFunctionsOnASingleLine: Inline
+AllowShortIfStatementsOnASingleLine: true
+# AllowShortLoopsOnASingleLine: false
+# AlwaysBreakAfterDefinitionReturnType: None
+# AlwaysBreakAfterReturnType: None
+# AlwaysBreakBeforeMultilineStrings: false
+# AlwaysBreakTemplateDeclarations: false
+# BinPackArguments: true
+# BinPackParameters: true
+# BraceWrapping:
+# AfterClass: false
+# AfterControlStatement: false
+# AfterEnum: false
+# AfterFunction: false
+# AfterNamespace: false
+# AfterObjCDeclaration: false
+# AfterStruct: false
+# AfterUnion: false
+# AfterExternBlock: false
+# BeforeCatch: false
+# BeforeElse: false
+# IndentBraces: false
+# SplitEmptyFunction: true
+# SplitEmptyRecord: true
+# SplitEmptyNamespace: true
+# BreakBeforeBinaryOperators: None
+# BreakBeforeBraces: Attach
+# BreakBeforeInheritanceComma: false
+BreakBeforeTernaryOperators: false
+# BreakConstructorInitializersBeforeComma: false
+BreakConstructorInitializers: AfterColon
+# BreakStringLiterals: true
+ColumnLimit: 0
+# CommentPragmas: '^ IWYU pragma:'
+# CompactNamespaces: false
+ConstructorInitializerAllOnOneLineOrOnePerLine: true
+ConstructorInitializerIndentWidth: 8
+ContinuationIndentWidth: 8
+Cpp11BracedListStyle: false
+# DerivePointerAlignment: false
+# DisableFormat: false
+# ExperimentalAutoDetectBinPacking: false
+# FixNamespaceComments: true
+# ForEachMacros:
+# - foreach
+# - Q_FOREACH
+# - BOOST_FOREACH
+# IncludeBlocks: Preserve
+IncludeCategories:
+ - Regex: '".*"'
+ Priority: 1
+ - Regex: '^<.*\.h>'
+ Priority: 2
+ - Regex: '^<.*'
+ Priority: 3
+# IncludeIsMainRegex: '(Test)?$'
+IndentCaseLabels: true
+# IndentPPDirectives: None
+IndentWidth: 4
+# IndentWrappedFunctionNames: false
+# JavaScriptQuotes: Leave
+# JavaScriptWrapImports: true
+# KeepEmptyLinesAtTheStartOfBlocks: true
+# MacroBlockBegin: ''
+# MacroBlockEnd: ''
+# MaxEmptyLinesToKeep: 1
+# NamespaceIndentation: None
+# PenaltyBreakAssignment: 2
+# PenaltyBreakBeforeFirstCallParameter: 19
+# PenaltyBreakComment: 300
+# PenaltyBreakFirstLessLess: 120
+# PenaltyBreakString: 1000
+# PenaltyExcessCharacter: 1000000
+# PenaltyReturnTypeOnItsOwnLine: 60
+# PointerAlignment: Right
+# RawStringFormats:
+# - Delimiter: pb
+# Language: TextProto
+# BasedOnStyle: google
+# ReflowComments: true
+# SortIncludes: true
+# SortUsingDeclarations: true
+# SpaceAfterCStyleCast: false
+# SpaceAfterTemplateKeyword: true
+# SpaceBeforeAssignmentOperators: true
+# SpaceBeforeParens: ControlStatements
+# SpaceInEmptyParentheses: false
+# SpacesBeforeTrailingComments: 1
+# SpacesInAngles: false
+# SpacesInContainerLiterals: true
+# SpacesInCStyleCastParentheses: false
+# SpacesInParentheses: false
+# SpacesInSquareBrackets: false
+TabWidth: 4
+UseTab: Always
+---
+### C++ specific config ###
+Language: Cpp
+Standard: Cpp03
+---
+### ObjC specific config ###
+Language: ObjC
+Standard: Cpp03
+ObjCBlockIndentWidth: 4
+# ObjCSpaceAfterProperty: false
+# ObjCSpaceBeforeProtocolList: true
+---
+### Java specific config ###
+Language: Java
+# BreakAfterJavaFieldAnnotations: false
+...
diff --git a/game/addons/zylann.hterrain/native/.gitignore b/game/addons/zylann.hterrain/native/.gitignore
new file mode 100644
index 0000000..398d727
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/.gitignore
@@ -0,0 +1,4 @@
+# Build
+# Ignored locally because there are other folders in which we want to version OBJ files
+*.obj
+
diff --git a/game/addons/zylann.hterrain/native/SConstruct b/game/addons/zylann.hterrain/native/SConstruct
new file mode 100644
index 0000000..e36eb55
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/SConstruct
@@ -0,0 +1,119 @@
+#!python
+import os
+
+opts = Variables([], ARGUMENTS)
+
+# Gets the standard flags CC, CCX, etc.
+env = Environment(ENV = os.environ)
+
+# Define our options
+opts.Add(EnumVariable('target', "Compilation target", 'debug', ['debug', 'release']))
+opts.Add(EnumVariable('platform', "Compilation platform", '', ['', 'windows', 'linux', 'osx']))
+opts.Add(BoolVariable('use_llvm', "Use the LLVM / Clang compiler", 'no'))
+opts.Add(EnumVariable('macos_arch', "Target macOS architecture", 'universal', ['universal', 'x86_64', 'arm64']))
+
+# Hardcoded ones
+target_path = "bin/"
+TARGET_NAME = "hterrain_native"
+
+# Local dependency paths
+godot_headers_path = "godot-cpp/godot-headers/"
+cpp_bindings_path = "godot-cpp/"
+cpp_bindings_library = "libgodot-cpp"
+
+# only support 64 at this time
+bits = 64
+
+# Updates the environment with the option variables.
+opts.Update(env)
+
+# Process some arguments
+if env['use_llvm']:
+ env['CC'] = 'clang'
+ env['CXX'] = 'clang++'
+
+if env['platform'] == '':
+ print("No valid target platform selected.")
+ quit()
+
+# For the reference:
+# - CCFLAGS are compilation flags shared between C and C++
+# - CFLAGS are for C-specific compilation flags
+# - CXXFLAGS are for C++-specific compilation flags
+# - CPPFLAGS are for pre-processor flags
+# - CPPDEFINES are for pre-processor defines
+# - LINKFLAGS are for linking flags
+
+# Check our platform specifics
+if env['platform'] == "osx":
+ target_path += 'osx/'
+ cpp_bindings_library += '.osx'
+ if env['target'] == 'debug':
+ env.Append(CCFLAGS = ['-g', '-O2', '-arch', 'x86_64'])
+ env.Append(CXXFLAGS = ['-std=c++17'])
+ env.Append(LINKFLAGS = ['-arch', 'x86_64'])
+ else:
+ env.Append(CCFLAGS = ['-g', '-O3', '-arch', 'x86_64'])
+ env.Append(CXXFLAGS = ['-std=c++17'])
+ env.Append(LINKFLAGS = ['-arch', 'x86_64'])
+
+elif env['platform'] == "linux":
+ target_path += 'linux/'
+ cpp_bindings_library += '.linux'
+ if env['target'] == 'debug':
+ # -g3 means we want plenty of debug info, more than default
+ env.Append(CCFLAGS = ['-fPIC', '-g3', '-Og'])
+ env.Append(CXXFLAGS = ['-std=c++17'])
+ else:
+ env.Append(CCFLAGS = ['-fPIC', '-O3'])
+ env.Append(CXXFLAGS = ['-std=c++17'])
+ env.Append(LINKFLAGS = ['-s'])
+
+elif env['platform'] == "windows":
+ target_path += 'win64/'
+ cpp_bindings_library += '.windows'
+ # This makes sure to keep the session environment variables on windows,
+ # that way you can run scons in a vs 2017 prompt and it will find all the required tools
+ #env.Append(ENV = os.environ)
+
+ env.Append(CPPDEFINES = ['WIN32', '_WIN32', '_WINDOWS', '_CRT_SECURE_NO_WARNINGS'])
+ env.Append(CCFLAGS = ['-W3', '-GR'])
+ if env['target'] == 'debug':
+ env.Append(CPPDEFINES = ['_DEBUG'])
+ env.Append(CCFLAGS = ['-EHsc', '-MDd', '-ZI'])
+ env.Append(LINKFLAGS = ['-DEBUG'])
+ else:
+ env.Append(CPPDEFINES = ['NDEBUG'])
+ env.Append(CCFLAGS = ['-O2', '-EHsc', '-MD'])
+
+if env['target'] == 'debug':
+ cpp_bindings_library += '.debug'
+else:
+ cpp_bindings_library += '.release'
+
+if env['macos_arch'] == 'universal':
+ cpp_bindings_library += '.' + str(bits)
+else:
+ cpp_bindings_library += '.' + env['macos_arch']
+
+# make sure our binding library is properly included
+env.Append(CPPPATH = [
+ '.',
+ godot_headers_path,
+ cpp_bindings_path + 'include/',
+ cpp_bindings_path + 'include/core/',
+ cpp_bindings_path + 'include/gen/'
+])
+env.Append(LIBPATH = [cpp_bindings_path + 'bin/'])
+env.Append(LIBS = [cpp_bindings_library])
+
+# Add source files of our library
+env.Append(CPPPATH = ['src/'])
+sources = Glob('src/*.cpp')
+
+library = env.SharedLibrary(target = target_path + TARGET_NAME , source = sources)
+
+Default(library)
+
+# Generates help for the -h scons option.
+Help(opts.GenerateHelpText(env))
diff --git a/game/addons/zylann.hterrain/native/factory.gd b/game/addons/zylann.hterrain/native/factory.gd
new file mode 100644
index 0000000..d2dfe56
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/factory.gd
@@ -0,0 +1,55 @@
+@tool
+
+const NATIVE_PATH = "res://addons/zylann.hterrain/native/"
+
+const HT_ImageUtilsGeneric = preload("./image_utils_generic.gd")
+const HT_QuadTreeLodGeneric = preload("./quad_tree_lod_generic.gd")
+
+# No native code was ported when moving to Godot 4.
+# It may be changed too using GDExtension.
+
+# See https://docs.godotengine.org/en/stable/classes/class_os.html#class-os-method-get-name
+const _supported_os = {
+ # "Windows": true,
+ # "X11": true,
+ # "OSX": true
+}
+# See https://docs.godotengine.org/en/stable/tutorials/export/feature_tags.html
+const _supported_archs = ["x86_64"]
+
+
+static func _supports_current_arch() -> bool:
+ for arch in _supported_archs:
+ # This is misleading, we are querying features of the ENGINE, not the OS
+ if OS.has_feature(arch):
+ return true
+ return false
+
+
+static func is_native_available() -> bool:
+ if not _supports_current_arch():
+ return false
+ var os = OS.get_name()
+ if not _supported_os.has(os):
+ return false
+ # API changes can cause binary incompatibility
+ var v = Engine.get_version_info()
+ return v.major == 4 and v.minor == 0
+
+
+static func get_image_utils():
+ if is_native_available():
+ var HT_ImageUtilsNative = load(NATIVE_PATH + "image_utils.gdns")
+ # TODO Godot doesn't always return `null` when it fails so that `if` doesn't always help...
+ # See https://github.com/Zylann/godot_heightmap_plugin/issues/331
+ if HT_ImageUtilsNative != null:
+ return HT_ImageUtilsNative.new()
+ return HT_ImageUtilsGeneric.new()
+
+
+static func get_quad_tree_lod():
+ if is_native_available():
+ var HT_QuadTreeLod = load(NATIVE_PATH + "quad_tree_lod.gdns")
+ if HT_QuadTreeLod != null:
+ return HT_QuadTreeLod.new()
+ return HT_QuadTreeLodGeneric.new()
diff --git a/game/addons/zylann.hterrain/native/image_utils_generic.gd b/game/addons/zylann.hterrain/native/image_utils_generic.gd
new file mode 100644
index 0000000..2a8aed8
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/image_utils_generic.gd
@@ -0,0 +1,316 @@
+@tool
+
+# These functions are the same as the ones found in the GDNative library.
+# They are used if the user's platform is not supported.
+
+const HT_Util = preload("../util/util.gd")
+
+var _blur_buffer : Image
+
+
+func get_red_range(im: Image, rect: Rect2) -> Vector2:
+ rect = rect.intersection(Rect2(0, 0, im.get_width(), im.get_height()))
+ var min_x := int(rect.position.x)
+ var min_y := int(rect.position.y)
+ var max_x := min_x + int(rect.size.x)
+ var max_y := min_y + int(rect.size.y)
+
+ var min_height := im.get_pixel(min_x, min_y).r
+ var max_height := min_height
+
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var h = im.get_pixel(x, y).r
+ if h < min_height:
+ min_height = h
+ elif h > max_height:
+ max_height = h
+
+ return Vector2(min_height, max_height)
+
+
+func get_red_sum(im: Image, rect: Rect2) -> float:
+ rect = rect.intersection(Rect2(0, 0, im.get_width(), im.get_height()))
+ var min_x := int(rect.position.x)
+ var min_y := int(rect.position.y)
+ var max_x := min_x + int(rect.size.x)
+ var max_y := min_y + int(rect.size.y)
+
+ var sum := 0.0
+
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ sum += im.get_pixel(x, y).r
+
+ return sum
+
+
+func get_red_sum_weighted(im: Image, brush: Image, pos: Vector2, factor: float) -> float:
+ var min_x = int(pos.x)
+ var min_y = int(pos.y)
+ var max_x = min_x + brush.get_width()
+ var max_y = min_y + brush.get_height()
+ var min_noclamp_x = min_x
+ var min_noclamp_y = min_y
+
+ min_x = clampi(min_x, 0, im.get_width())
+ min_y = clampi(min_y, 0, im.get_height())
+ max_x = clampi(max_x, 0, im.get_width())
+ max_y = clampi(max_y, 0, im.get_height())
+
+ var sum = 0.0
+
+ for y in range(min_y, max_y):
+ var by = y - min_noclamp_y
+
+ for x in range(min_x, max_x):
+ var bx = x - min_noclamp_x
+
+ var shape_value = brush.get_pixel(bx, by).r
+ sum += im.get_pixel(x, y).r * shape_value * factor
+
+ return sum
+
+
+func add_red_brush(im: Image, brush: Image, pos: Vector2, factor: float):
+ var min_x = int(pos.x)
+ var min_y = int(pos.y)
+ var max_x = min_x + brush.get_width()
+ var max_y = min_y + brush.get_height()
+ var min_noclamp_x = min_x
+ var min_noclamp_y = min_y
+
+ min_x = clampi(min_x, 0, im.get_width())
+ min_y = clampi(min_y, 0, im.get_height())
+ max_x = clampi(max_x, 0, im.get_width())
+ max_y = clampi(max_y, 0, im.get_height())
+
+ for y in range(min_y, max_y):
+ var by = y - min_noclamp_y
+
+ for x in range(min_x, max_x):
+ var bx = x - min_noclamp_x
+
+ var shape_value = brush.get_pixel(bx, by).r
+ var r = im.get_pixel(x, y).r + shape_value * factor
+ im.set_pixel(x, y, Color(r, r, r))
+
+
+func lerp_channel_brush(im: Image, brush: Image, pos: Vector2,
+ factor: float, target_value: float, channel: int):
+
+ var min_x = int(pos.x)
+ var min_y = int(pos.y)
+ var max_x = min_x + brush.get_width()
+ var max_y = min_y + brush.get_height()
+ var min_noclamp_x = min_x
+ var min_noclamp_y = min_y
+
+ min_x = clampi(min_x, 0, im.get_width())
+ min_y = clampi(min_y, 0, im.get_height())
+ max_x = clampi(max_x, 0, im.get_width())
+ max_y = clampi(max_y, 0, im.get_height())
+
+ for y in range(min_y, max_y):
+ var by = y - min_noclamp_y
+
+ for x in range(min_x, max_x):
+ var bx = x - min_noclamp_x
+
+ var shape_value = brush.get_pixel(bx, by).r
+ var c = im.get_pixel(x, y)
+ c[channel] = lerp(c[channel], target_value, shape_value * factor)
+ im.set_pixel(x, y, c)
+
+
+func lerp_color_brush(im: Image, brush: Image, pos: Vector2,
+ factor: float, target_value: Color):
+
+ var min_x = int(pos.x)
+ var min_y = int(pos.y)
+ var max_x = min_x + brush.get_width()
+ var max_y = min_y + brush.get_height()
+ var min_noclamp_x = min_x
+ var min_noclamp_y = min_y
+
+ min_x = clampi(min_x, 0, im.get_width())
+ min_y = clampi(min_y, 0, im.get_height())
+ max_x = clampi(max_x, 0, im.get_width())
+ max_y = clampi(max_y, 0, im.get_height())
+
+ for y in range(min_y, max_y):
+ var by = y - min_noclamp_y
+
+ for x in range(min_x, max_x):
+ var bx = x - min_noclamp_x
+
+ var shape_value = brush.get_pixel(bx, by).r
+ var c = im.get_pixel(x, y).lerp(target_value, factor * shape_value)
+ im.set_pixel(x, y, c)
+
+
+func generate_gaussian_brush(im: Image) -> float:
+ var sum := 0.0
+ var center := Vector2(im.get_width() / 2, im.get_height() / 2)
+ var radius := minf(im.get_width(), im.get_height()) / 2.0
+
+ for y in im.get_height():
+ for x in im.get_width():
+ var d := Vector2(x, y).distance_to(center) / radius
+ var v := clampf(1.0 - d * d * d, 0.0, 1.0)
+ im.set_pixel(x, y, Color(v, v, v))
+ sum += v;
+
+ return sum
+
+
+func blur_red_brush(im: Image, brush: Image, pos: Vector2, factor: float):
+ factor = clampf(factor, 0.0, 1.0)
+
+ if _blur_buffer == null:
+ _blur_buffer = Image.new()
+ var buffer := _blur_buffer
+
+ var buffer_width := brush.get_width() + 2
+ var buffer_height := brush.get_height() + 2
+
+ if buffer_width != buffer.get_width() or buffer_height != buffer.get_height():
+ buffer.create(buffer_width, buffer_height, false, Image.FORMAT_RF)
+
+ var min_x := int(pos.x) - 1
+ var min_y := int(pos.y) - 1
+ var max_x := min_x + buffer.get_width()
+ var max_y := min_y + buffer.get_height()
+
+ var im_clamp_w = im.get_width() - 1
+ var im_clamp_h = im.get_height() - 1
+
+ # Copy pixels to temporary buffer
+ for y in range(min_y, max_y):
+ for x in range(min_x, max_x):
+ var ix := clampi(x, 0, im_clamp_w)
+ var iy := clampi(y, 0, im_clamp_h)
+ var c = im.get_pixel(ix, iy)
+ buffer.set_pixel(x - min_x, y - min_y, c)
+
+ min_x = int(pos.x)
+ min_y = int(pos.y)
+ max_x = min_x + brush.get_width()
+ max_y = min_y + brush.get_height()
+ var min_noclamp_x := min_x
+ var min_noclamp_y := min_y
+
+ min_x = clampi(min_x, 0, im.get_width())
+ min_y = clampi(min_y, 0, im.get_height())
+ max_x = clampi(max_x, 0, im.get_width())
+ max_y = clampi(max_y, 0, im.get_height())
+
+ # Apply blur
+ for y in range(min_y, max_y):
+ var by := y - min_noclamp_y
+
+ for x in range(min_x, max_x):
+ var bx := x - min_noclamp_x
+
+ var shape_value := brush.get_pixel(bx, by).r * factor
+
+ var p10 = buffer.get_pixel(bx + 1, by ).r
+ var p01 = buffer.get_pixel(bx, by + 1).r
+ var p11 = buffer.get_pixel(bx + 1, by + 1).r
+ var p21 = buffer.get_pixel(bx + 2, by + 1).r
+ var p12 = buffer.get_pixel(bx + 1, by + 2).r
+
+ var m = (p10 + p01 + p11 + p21 + p12) * 0.2
+ var p = lerpf(p11, m, shape_value * factor)
+
+ im.set_pixel(x, y, Color(p, p, p))
+
+
+func paint_indexed_splat(index_map: Image, weight_map: Image, brush: Image, pos: Vector2, \
+ texture_index: int, factor: float):
+
+ var min_x := pos.x
+ var min_y := pos.y
+ var max_x := min_x + brush.get_width()
+ var max_y := min_y + brush.get_height()
+ var min_noclamp_x := min_x
+ var min_noclamp_y := min_y
+
+ min_x = clampi(min_x, 0, index_map.get_width())
+ min_y = clampi(min_y, 0, index_map.get_height())
+ max_x = clampi(max_x, 0, index_map.get_width())
+ max_y = clampi(max_y, 0, index_map.get_height())
+
+ var texture_index_f := float(texture_index) / 255.0
+ var all_texture_index_f := Color(texture_index_f, texture_index_f, texture_index_f)
+ var ci := texture_index % 3
+ var cm := Color(-1, -1, -1)
+ cm[ci] = 1
+
+ for y in range(min_y, max_y):
+ var by := y - min_noclamp_y
+
+ for x in range(min_x, max_x):
+ var bx := x - min_noclamp_x
+
+ var shape_value := brush.get_pixel(bx, by).r * factor
+ if shape_value == 0.0:
+ continue
+
+ var i := index_map.get_pixel(x, y)
+ var w := weight_map.get_pixel(x, y)
+
+ # Decompress third weight to make computations easier
+ w[2] = 1.0 - w[0] - w[1]
+
+ # The index map tells which textures to blend.
+ # The weight map tells their blending amounts.
+ # This brings the limitation that up to 3 textures can blend at a time in a given pixel.
+ # Painting this in real time can be a challenge.
+
+ # The approach here is a compromise for simplicity.
+ # Each texture is associated a fixed component of the index map (R, G or B),
+ # so two neighbor pixels having the same component won't be guaranteed to blend.
+ # In other words, texture T will not be able to blend with T + N * k,
+ # where k is an integer, and N is the number of components in the index map (up to 4).
+ # It might still be able to blend due to a special case when an area is uniform,
+ # but not otherwise.
+
+ # Dynamic component assignment sounds like the alternative, however I wasn't able
+ # to find a painting algorithm that wasn't confusing, at least the current one is
+ # predictable.
+
+ # Need to use approximation because Color is float but GDScript uses doubles...
+ 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] > shape_value:
+ w -= cm * shape_value
+
+ elif w[ci] >= 0.0:
+ w[ci] = 0.0
+ i[ci] = texture_index_f
+
+ else:
+ # Pixel has our texture index, increase its weight
+ if w[ci] + shape_value < 1.0:
+ w += cm * shape_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 = Color(0, 0, 0)
+ w[ci] = 1.0
+ i = all_texture_index_f
+
+ # No `saturate` function in Color??
+ w[0] = clampf(w[0], 0.0, 1.0)
+ w[1] = clampf(w[1], 0.0, 1.0)
+ w[2] = clampf(w[2], 0.0, 1.0)
+
+ # Renormalize
+ w /= w[0] + w[1] + w[2]
+
+ index_map.set_pixel(x, y, i)
+ weight_map.set_pixel(x, y, w)
diff --git a/game/addons/zylann.hterrain/native/quad_tree_lod_generic.gd b/game/addons/zylann.hterrain/native/quad_tree_lod_generic.gd
new file mode 100644
index 0000000..8518965
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/quad_tree_lod_generic.gd
@@ -0,0 +1,188 @@
+@tool
+# Independent quad tree designed to handle LOD
+
+class HT_QTLQuad:
+ # Optional array of 4 HT_QTLQuad
+ var children = null
+
+ # TODO Use Vector2i
+ var origin_x : int = 0
+ var origin_y : int = 0
+
+ var data = null
+
+ func _init():
+ pass
+
+ func clear():
+ clear_children()
+ data = null
+
+ func clear_children():
+ children = null
+
+ func has_children() -> bool:
+ return children != null
+
+
+var _tree := HT_QTLQuad.new()
+var _max_depth : int = 0
+var _base_size : int = 16
+var _split_scale : float = 2.0
+
+var _make_func : Callable
+var _recycle_func : Callable
+var _vertical_bounds_func : Callable
+
+
+func set_callbacks(make_cb: Callable, recycle_cb: Callable, vbounds_cb: Callable):
+ _make_func = make_cb
+ _recycle_func = recycle_cb
+ _vertical_bounds_func = vbounds_cb
+
+
+func clear():
+ _join_all_recursively(_tree, _max_depth)
+ _max_depth = 0
+ _base_size = 0
+
+
+static func compute_lod_count(base_size: int, full_size: int) -> int:
+ var po : int = 0
+ while full_size > base_size:
+ full_size = full_size >> 1
+ po += 1
+ return po
+
+
+func create_from_sizes(base_size: int, full_size: int):
+ clear()
+ _base_size = base_size
+ _max_depth = compute_lod_count(base_size, full_size)
+
+
+func get_lod_count() -> int:
+ # TODO _max_depth is a maximum, not a count. Would be better for it to be a count (+1)
+ return _max_depth + 1
+
+
+# The higher, the longer LODs will spread and higher the quality.
+# The lower, the shorter LODs will spread and lower the quality.
+func set_split_scale(p_split_scale: float):
+ var MIN := 2.0
+ var MAX := 5.0
+
+ # Split scale must be greater than a threshold,
+ # otherwise lods will decimate too fast and it will look messy
+ _split_scale = clampf(p_split_scale, MIN, MAX)
+
+
+func get_split_scale() -> float:
+ return _split_scale
+
+
+func update(view_pos: Vector3):
+ _update(_tree, _max_depth, view_pos)
+
+ # This makes sure we keep seeing the lowest LOD,
+ # if the tree is cleared while we are far away
+ if not _tree.has_children() and _tree.data == null:
+ _tree.data = _make_chunk(_max_depth, 0, 0)
+
+
+func get_lod_factor(lod: int) -> int:
+ return 1 << lod
+
+
+func _update(quad: HT_QTLQuad, lod: int, view_pos: Vector3):
+ # This function should be called regularly over frames.
+
+ var lod_factor : int = get_lod_factor(lod)
+ var chunk_size : int = _base_size * lod_factor
+ var world_center := \
+ chunk_size * (Vector3(quad.origin_x, 0, quad.origin_y) + Vector3(0.5, 0, 0.5))
+
+ if _vertical_bounds_func.is_valid():
+ var vbounds : Vector2 = _vertical_bounds_func.call(quad.origin_x, quad.origin_y, lod)
+ world_center.y = (vbounds.x + vbounds.y) / 2.0
+
+ var split_distance := _base_size * lod_factor * _split_scale
+
+ if not quad.has_children():
+ if lod > 0 and world_center.distance_to(view_pos) < split_distance:
+ # Split
+ quad.children = [null, null, null, null]
+
+ for i in 4:
+ var child := HT_QTLQuad.new()
+ child.origin_x = quad.origin_x * 2 + (i & 1)
+ child.origin_y = quad.origin_y * 2 + ((i & 2) >> 1)
+ quad.children[i] = child
+ child.data = _make_chunk(lod - 1, child.origin_x, child.origin_y)
+ # If the quad needs to split more, we'll ask more recycling...
+
+ if quad.data != null:
+ _recycle_chunk(quad.data, quad.origin_x, quad.origin_y, lod)
+ quad.data = null
+
+ else:
+ var no_split_child := true
+
+ for child in quad.children:
+ _update(child, lod - 1, view_pos)
+ if child.has_children():
+ no_split_child = false
+
+ if no_split_child and world_center.distance_to(view_pos) > split_distance:
+ # Join
+ for i in 4:
+ var child : HT_QTLQuad = quad.children[i]
+ _recycle_chunk(child.data, child.origin_x, child.origin_y, lod - 1)
+ quad.clear_children()
+ quad.data = _make_chunk(lod, quad.origin_x, quad.origin_y)
+
+
+func _join_all_recursively(quad: HT_QTLQuad, lod: int):
+ if quad.has_children():
+ for i in 4:
+ _join_all_recursively(quad.children[i], lod - 1)
+
+ quad.clear_children()
+
+ elif quad.data != null:
+ _recycle_chunk(quad.data, quad.origin_x, quad.origin_y, lod)
+ quad.data = null
+
+
+func _make_chunk(lod: int, origin_x: int, origin_y: int):
+ var chunk = null
+ if _make_func.is_valid():
+ chunk = _make_func.call(origin_x, origin_y, lod)
+ return chunk
+
+
+func _recycle_chunk(chunk, origin_x: int, origin_y: int, lod: int):
+ if _recycle_func.is_valid():
+ _recycle_func.call(chunk, origin_x, origin_y, lod)
+
+
+func debug_draw_tree(ci: CanvasItem):
+ var quad := _tree
+ _debug_draw_tree_recursive(ci, quad, _max_depth, 0)
+
+
+func _debug_draw_tree_recursive(ci: CanvasItem, quad: HT_QTLQuad, lod_index: int, child_index: int):
+ if quad.has_children():
+ for i in 4:
+ _debug_draw_tree_recursive(ci, quad.children[i], lod_index - 1, i)
+ else:
+ var size : int = get_lod_factor(lod_index)
+ var checker : int = 0
+ if child_index == 1 or child_index == 2:
+ checker = 1
+ var chunk_indicator : int = 0
+ if quad.data != null:
+ chunk_indicator = 1
+ var r := Rect2(Vector2(quad.origin_x, quad.origin_y) * size, Vector2(size, size))
+ ci.draw_rect(r, Color(1.0 - lod_index * 0.2, 0.2 * checker, chunk_indicator, 1))
+
diff --git a/game/addons/zylann.hterrain/native/src/.gdignore b/game/addons/zylann.hterrain/native/src/.gdignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/.gdignore
diff --git a/game/addons/zylann.hterrain/native/src/gd_library.cpp b/game/addons/zylann.hterrain/native/src/gd_library.cpp
new file mode 100644
index 0000000..d39387f
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/gd_library.cpp
@@ -0,0 +1,30 @@
+#include "image_utils.h"
+#include "quad_tree_lod.h"
+
+extern "C" {
+
+void GDN_EXPORT godot_gdnative_init(godot_gdnative_init_options *o) {
+#ifdef _DEBUG
+ printf("godot_gdnative_init hterrain_native\n");
+#endif
+ godot::Godot::gdnative_init(o);
+}
+
+void GDN_EXPORT godot_gdnative_terminate(godot_gdnative_terminate_options *o) {
+#ifdef _DEBUG
+ printf("godot_gdnative_terminate hterrain_native\n");
+#endif
+ godot::Godot::gdnative_terminate(o);
+}
+
+void GDN_EXPORT godot_nativescript_init(void *handle) {
+#ifdef _DEBUG
+ printf("godot_nativescript_init hterrain_native\n");
+#endif
+ godot::Godot::nativescript_init(handle);
+
+ godot::register_tool_class<godot::ImageUtils>();
+ godot::register_tool_class<godot::QuadTreeLod>();
+}
+
+} // extern "C"
diff --git a/game/addons/zylann.hterrain/native/src/image_utils.cpp b/game/addons/zylann.hterrain/native/src/image_utils.cpp
new file mode 100644
index 0000000..9131307
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/image_utils.cpp
@@ -0,0 +1,364 @@
+#include "image_utils.h"
+#include "int_range_2d.h"
+#include "math_funcs.h"
+
+namespace godot {
+
+template <typename F>
+inline void generic_brush_op(Image &image, Image &brush, Vector2 p_pos, float factor, F op) {
+ IntRange2D range = IntRange2D::from_min_max(p_pos, brush.get_size());
+ int min_x_noclamp = range.min_x;
+ int min_y_noclamp = range.min_y;
+ range.clip(Vector2i(image.get_size()));
+
+ image.lock();
+ brush.lock();
+
+ for (int y = range.min_y; y < range.max_y; ++y) {
+ int by = y - min_y_noclamp;
+
+ for (int x = range.min_x; x < range.max_x; ++x) {
+ int bx = x - min_x_noclamp;
+
+ float b = brush.get_pixel(bx, by).r * factor;
+ op(image, x, y, b);
+ }
+ }
+
+ image.unlock();
+ brush.unlock();
+}
+
+ImageUtils::ImageUtils() {
+#ifdef _DEBUG
+ Godot::print("Constructing ImageUtils");
+#endif
+}
+
+ImageUtils::~ImageUtils() {
+#ifdef _DEBUG
+ // TODO Cannot print shit here, see https://github.com/godotengine/godot/issues/37417
+ // Means only the console will print this
+ //Godot::print("Destructing ImageUtils");
+ printf("Destructing ImageUtils\n");
+#endif
+}
+
+void ImageUtils::_init() {
+}
+
+Vector2 ImageUtils::get_red_range(Ref<Image> image_ref, Rect2 rect) const {
+ ERR_FAIL_COND_V(image_ref.is_null(), Vector2());
+ Image &image = **image_ref;
+
+ IntRange2D range(rect);
+ range.clip(Vector2i(image.get_size()));
+
+ image.lock();
+
+ float min_value = image.get_pixel(range.min_x, range.min_y).r;
+ float max_value = min_value;
+
+ for (int y = range.min_y; y < range.max_y; ++y) {
+ for (int x = range.min_x; x < range.max_x; ++x) {
+ float v = image.get_pixel(x, y).r;
+
+ if (v > max_value) {
+ max_value = v;
+ } else if (v < min_value) {
+ min_value = v;
+ }
+ }
+ }
+
+ image.unlock();
+
+ return Vector2(min_value, max_value);
+}
+
+float ImageUtils::get_red_sum(Ref<Image> image_ref, Rect2 rect) const {
+ ERR_FAIL_COND_V(image_ref.is_null(), 0.f);
+ Image &image = **image_ref;
+
+ IntRange2D range(rect);
+ range.clip(Vector2i(image.get_size()));
+
+ image.lock();
+
+ float sum = 0.f;
+
+ for (int y = range.min_y; y < range.max_y; ++y) {
+ for (int x = range.min_x; x < range.max_x; ++x) {
+ sum += image.get_pixel(x, y).r;
+ }
+ }
+
+ image.unlock();
+
+ return sum;
+}
+
+float ImageUtils::get_red_sum_weighted(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor) const {
+ ERR_FAIL_COND_V(image_ref.is_null(), 0.f);
+ ERR_FAIL_COND_V(brush_ref.is_null(), 0.f);
+ Image &image = **image_ref;
+ Image &brush = **brush_ref;
+
+ float sum = 0.f;
+ generic_brush_op(image, brush, p_pos, factor, [&sum](Image &image, int x, int y, float b) {
+ sum += image.get_pixel(x, y).r * b;
+ });
+
+ return sum;
+}
+
+void ImageUtils::add_red_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor) const {
+ ERR_FAIL_COND(image_ref.is_null());
+ ERR_FAIL_COND(brush_ref.is_null());
+ Image &image = **image_ref;
+ Image &brush = **brush_ref;
+
+ generic_brush_op(image, brush, p_pos, factor, [](Image &image, int x, int y, float b) {
+ float r = image.get_pixel(x, y).r + b;
+ image.set_pixel(x, y, Color(r, r, r));
+ });
+}
+
+void ImageUtils::lerp_channel_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor, float target_value, int channel) const {
+ ERR_FAIL_COND(image_ref.is_null());
+ ERR_FAIL_COND(brush_ref.is_null());
+ Image &image = **image_ref;
+ Image &brush = **brush_ref;
+
+ generic_brush_op(image, brush, p_pos, factor, [target_value, channel](Image &image, int x, int y, float b) {
+ Color c = image.get_pixel(x, y);
+ c[channel] = Math::lerp(c[channel], target_value, b);
+ image.set_pixel(x, y, c);
+ });
+}
+
+void ImageUtils::lerp_color_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor, Color target_value) const {
+ ERR_FAIL_COND(image_ref.is_null());
+ ERR_FAIL_COND(brush_ref.is_null());
+ Image &image = **image_ref;
+ Image &brush = **brush_ref;
+
+ generic_brush_op(image, brush, p_pos, factor, [target_value](Image &image, int x, int y, float b) {
+ const Color c = image.get_pixel(x, y).linear_interpolate(target_value, b);
+ image.set_pixel(x, y, c);
+ });
+}
+
+// TODO Smooth (each pixel being box-filtered, contrary to the existing smooth)
+
+float ImageUtils::generate_gaussian_brush(Ref<Image> image_ref) const {
+ ERR_FAIL_COND_V(image_ref.is_null(), 0.f);
+ Image &image = **image_ref;
+
+ int w = static_cast<int>(image.get_width());
+ int h = static_cast<int>(image.get_height());
+ Vector2 center(w / 2, h / 2);
+ float radius = Math::min(w, h) / 2;
+
+ ERR_FAIL_COND_V(radius <= 0.1f, 0.f);
+
+ float sum = 0.f;
+ image.lock();
+
+ for (int y = 0; y < h; ++y) {
+ for (int x = 0; x < w; ++x) {
+ float d = Vector2(x, y).distance_to(center) / radius;
+ float v = Math::clamp(1.f - d * d * d, 0.f, 1.f);
+ image.set_pixel(x, y, Color(v, v, v));
+ sum += v;
+ }
+ }
+
+ image.unlock();
+ return sum;
+}
+
+void ImageUtils::blur_red_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor) {
+ ERR_FAIL_COND(image_ref.is_null());
+ ERR_FAIL_COND(brush_ref.is_null());
+ Image &image = **image_ref;
+ Image &brush = **brush_ref;
+
+ factor = Math::clamp(factor, 0.f, 1.f);
+
+ // Relative to the image
+ IntRange2D buffer_range = IntRange2D::from_pos_size(p_pos, brush.get_size());
+ buffer_range.pad(1);
+
+ const int image_width = static_cast<int>(image.get_width());
+ const int image_height = static_cast<int>(image.get_height());
+
+ const int buffer_width = static_cast<int>(buffer_range.get_width());
+ const int buffer_height = static_cast<int>(buffer_range.get_height());
+ _blur_buffer.resize(buffer_width * buffer_height);
+
+ image.lock();
+
+ // Cache pixels, because they will be queried more than once and written to later
+ int buffer_i = 0;
+ for (int y = buffer_range.min_y; y < buffer_range.max_y; ++y) {
+ for (int x = buffer_range.min_x; x < buffer_range.max_x; ++x) {
+ const int ix = Math::clamp(x, 0, image_width - 1);
+ const int iy = Math::clamp(y, 0, image_height - 1);
+ _blur_buffer[buffer_i] = image.get_pixel(ix, iy).r;
+ ++buffer_i;
+ }
+ }
+
+ IntRange2D range = IntRange2D::from_min_max(p_pos, brush.get_size());
+ const int min_x_noclamp = range.min_x;
+ const int min_y_noclamp = range.min_y;
+ range.clip(Vector2i(image.get_size()));
+
+ const int buffer_offset_left = -1;
+ const int buffer_offset_right = 1;
+ const int buffer_offset_top = -buffer_width;
+ const int buffer_offset_bottom = buffer_width;
+
+ brush.lock();
+
+ // Apply blur
+ for (int y = range.min_y; y < range.max_y; ++y) {
+ const int brush_y = y - min_y_noclamp;
+
+ for (int x = range.min_x; x < range.max_x; ++x) {
+ const int brush_x = x - min_x_noclamp;
+
+ const float brush_value = brush.get_pixel(brush_x, brush_y).r * factor;
+
+ buffer_i = (brush_x + 1) + (brush_y + 1) * buffer_width;
+
+ const float p10 = _blur_buffer[buffer_i + buffer_offset_top];
+ const float p01 = _blur_buffer[buffer_i + buffer_offset_left];
+ const float p11 = _blur_buffer[buffer_i];
+ const float p21 = _blur_buffer[buffer_i + buffer_offset_right];
+ const float p12 = _blur_buffer[buffer_i + buffer_offset_bottom];
+
+ // Average
+ float m = (p10 + p01 + p11 + p21 + p12) * 0.2f;
+ float p = Math::lerp(p11, m, brush_value);
+
+ image.set_pixel(x, y, Color(p, p, p));
+ }
+ }
+
+ image.unlock();
+ brush.unlock();
+}
+
+void ImageUtils::paint_indexed_splat(Ref<Image> index_map_ref, Ref<Image> weight_map_ref,
+ Ref<Image> brush_ref, Vector2 p_pos, int texture_index, float factor) {
+
+ ERR_FAIL_COND(index_map_ref.is_null());
+ ERR_FAIL_COND(weight_map_ref.is_null());
+ ERR_FAIL_COND(brush_ref.is_null());
+ Image &index_map = **index_map_ref;
+ Image &weight_map = **weight_map_ref;
+ Image &brush = **brush_ref;
+
+ ERR_FAIL_COND(index_map.get_size() != weight_map.get_size());
+
+ factor = Math::clamp(factor, 0.f, 1.f);
+
+ IntRange2D range = IntRange2D::from_min_max(p_pos, brush.get_size());
+ const int min_x_noclamp = range.min_x;
+ const int min_y_noclamp = range.min_y;
+ range.clip(Vector2i(index_map.get_size()));
+
+ const float texture_index_f = float(texture_index) / 255.f;
+ const Color all_texture_index_f(texture_index_f, texture_index_f, texture_index_f);
+ const int ci = texture_index % 3;
+ Color cm(-1, -1, -1);
+ cm[ci] = 1;
+
+ brush.lock();
+ index_map.lock();
+ weight_map.lock();
+
+ for (int y = range.min_y; y < range.max_y; ++y) {
+ const int brush_y = y - min_y_noclamp;
+
+ for (int x = range.min_x; x < range.max_x; ++x) {
+ const int brush_x = x - min_x_noclamp;
+
+ const float brush_value = brush.get_pixel(brush_x, brush_y).r * factor;
+
+ if (brush_value == 0.f) {
+ continue;
+ }
+
+ Color i = index_map.get_pixel(x, y);
+ Color w = weight_map.get_pixel(x, y);
+
+ // Decompress third weight to make computations easier
+ w[2] = 1.f - w[0] - w[1];
+
+ if (std::abs(i[ci] - texture_index_f) > 0.001f) {
+ // 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 = Color(0, 0, 0);
+ w[ci] = 1.0;
+ i = all_texture_index_f;
+ }
+ }
+
+ // No `saturate` function in Color??
+ w[0] = Math::clamp(w[0], 0.f, 1.f);
+ w[1] = Math::clamp(w[1], 0.f, 1.f);
+ w[2] = Math::clamp(w[2], 0.f, 1.f);
+
+ // Renormalize
+ const float sum = w[0] + w[1] + w[2];
+ w[0] /= sum;
+ w[1] /= sum;
+ w[2] /= sum;
+
+ index_map.set_pixel(x, y, i);
+ weight_map.set_pixel(x, y, w);
+ }
+ }
+
+ brush.lock();
+ index_map.unlock();
+ weight_map.unlock();
+}
+
+void ImageUtils::_register_methods() {
+ register_method("get_red_range", &ImageUtils::get_red_range);
+ register_method("get_red_sum", &ImageUtils::get_red_sum);
+ register_method("get_red_sum_weighted", &ImageUtils::get_red_sum_weighted);
+ register_method("add_red_brush", &ImageUtils::add_red_brush);
+ register_method("lerp_channel_brush", &ImageUtils::lerp_channel_brush);
+ register_method("lerp_color_brush", &ImageUtils::lerp_color_brush);
+ register_method("generate_gaussian_brush", &ImageUtils::generate_gaussian_brush);
+ register_method("blur_red_brush", &ImageUtils::blur_red_brush);
+ register_method("paint_indexed_splat", &ImageUtils::paint_indexed_splat);
+}
+
+} // namespace godot
diff --git a/game/addons/zylann.hterrain/native/src/image_utils.h b/game/addons/zylann.hterrain/native/src/image_utils.h
new file mode 100644
index 0000000..5cbe399
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/image_utils.h
@@ -0,0 +1,38 @@
+#ifndef IMAGE_UTILS_H
+#define IMAGE_UTILS_H
+
+#include <core/Godot.hpp>
+#include <gen/Image.hpp>
+#include <gen/Reference.hpp>
+#include <vector>
+
+namespace godot {
+
+class ImageUtils : public Reference {
+ GODOT_CLASS(ImageUtils, Reference)
+public:
+ static void _register_methods();
+
+ ImageUtils();
+ ~ImageUtils();
+
+ void _init();
+
+ Vector2 get_red_range(Ref<Image> image_ref, Rect2 rect) const;
+ float get_red_sum(Ref<Image> image_ref, Rect2 rect) const;
+ float get_red_sum_weighted(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor) const;
+ void add_red_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor) const;
+ void lerp_channel_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor, float target_value, int channel) const;
+ void lerp_color_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor, Color target_value) const;
+ float generate_gaussian_brush(Ref<Image> image_ref) const;
+ void blur_red_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor);
+ void paint_indexed_splat(Ref<Image> index_map_ref, Ref<Image> weight_map_ref, Ref<Image> brush_ref, Vector2 p_pos, int texture_index, float factor);
+ //void erode_red_brush(Ref<Image> image_ref, Ref<Image> brush_ref, Vector2 p_pos, float factor);
+
+private:
+ std::vector<float> _blur_buffer;
+};
+
+} // namespace godot
+
+#endif // IMAGE_UTILS_H
diff --git a/game/addons/zylann.hterrain/native/src/int_range_2d.h b/game/addons/zylann.hterrain/native/src/int_range_2d.h
new file mode 100644
index 0000000..8072bea
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/int_range_2d.h
@@ -0,0 +1,59 @@
+#ifndef INT_RANGE_2D_H
+#define INT_RANGE_2D_H
+
+#include "math_funcs.h"
+#include "vector2i.h"
+#include <core/Rect2.hpp>
+
+struct IntRange2D {
+ int min_x;
+ int min_y;
+ int max_x;
+ int max_y;
+
+ static inline IntRange2D from_min_max(godot::Vector2 min_pos, godot::Vector2 max_pos) {
+ return IntRange2D(godot::Rect2(min_pos, max_pos));
+ }
+
+ static inline IntRange2D from_pos_size(godot::Vector2 min_pos, godot::Vector2 size) {
+ return IntRange2D(godot::Rect2(min_pos, size));
+ }
+
+ IntRange2D(godot::Rect2 rect) {
+ min_x = static_cast<int>(rect.position.x);
+ min_y = static_cast<int>(rect.position.y);
+ max_x = static_cast<int>(rect.position.x + rect.size.x);
+ max_y = static_cast<int>(rect.position.y + rect.size.y);
+ }
+
+ inline bool is_inside(Vector2i size) const {
+ return min_x >= size.x &&
+ min_y >= size.y &&
+ max_x <= size.x &&
+ max_y <= size.y;
+ }
+
+ inline void clip(Vector2i size) {
+ min_x = Math::clamp(min_x, 0, size.x);
+ min_y = Math::clamp(min_y, 0, size.y);
+ max_x = Math::clamp(max_x, 0, size.x);
+ max_y = Math::clamp(max_y, 0, size.y);
+ }
+
+ inline void pad(int p) {
+ min_x -= p;
+ min_y -= p;
+ max_x += p;
+ max_y += p;
+ }
+
+ inline int get_width() const {
+ return max_x - min_x;
+ }
+
+ inline int get_height() const {
+ return max_y - min_y;
+ }
+};
+
+#endif // INT_RANGE_2D_H
diff --git a/game/addons/zylann.hterrain/native/src/math_funcs.h b/game/addons/zylann.hterrain/native/src/math_funcs.h
new file mode 100644
index 0000000..34c9071
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/math_funcs.h
@@ -0,0 +1,28 @@
+#ifndef MATH_FUNCS_H
+#define MATH_FUNCS_H
+
+namespace Math {
+
+inline float lerp(float minv, float maxv, float t) {
+ return minv + t * (maxv - minv);
+}
+
+template <typename T>
+inline T clamp(T x, T minv, T maxv) {
+ if (x < minv) {
+ return minv;
+ }
+ if (x > maxv) {
+ return maxv;
+ }
+ return x;
+}
+
+template <typename T>
+inline T min(T a, T b) {
+ return a < b ? a : b;
+}
+
+} // namespace Math
+
+#endif // MATH_FUNCS_H
diff --git a/game/addons/zylann.hterrain/native/src/quad_tree_lod.cpp b/game/addons/zylann.hterrain/native/src/quad_tree_lod.cpp
new file mode 100644
index 0000000..592375e
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/quad_tree_lod.cpp
@@ -0,0 +1,242 @@
+#include "quad_tree_lod.h"
+
+namespace godot {
+
+void QuadTreeLod::set_callbacks(Ref<FuncRef> make_cb, Ref<FuncRef> recycle_cb, Ref<FuncRef> vbounds_cb) {
+ _make_func = make_cb;
+ _recycle_func = recycle_cb;
+ _vertical_bounds_func = vbounds_cb;
+}
+
+int QuadTreeLod::get_lod_count() {
+ // TODO make this a count, not max
+ return _max_depth + 1;
+}
+
+int QuadTreeLod::get_lod_factor(int lod) {
+ return 1 << lod;
+}
+
+int QuadTreeLod::compute_lod_count(int base_size, int full_size) {
+ int po = 0;
+ while (full_size > base_size) {
+ full_size = full_size >> 1;
+ po += 1;
+ }
+ return po;
+}
+
+// The higher, the longer LODs will spread and higher the quality.
+// The lower, the shorter LODs will spread and lower the quality.
+void QuadTreeLod::set_split_scale(real_t p_split_scale) {
+ real_t MIN = 2.0f;
+ real_t MAX = 5.0f;
+
+ // Split scale must be greater than a threshold,
+ // otherwise lods will decimate too fast and it will look messy
+ if (p_split_scale < MIN)
+ p_split_scale = MIN;
+ if (p_split_scale > MAX)
+ p_split_scale = MAX;
+
+ _split_scale = p_split_scale;
+}
+
+real_t QuadTreeLod::get_split_scale() {
+ return _split_scale;
+}
+
+void QuadTreeLod::clear() {
+ _join_all_recursively(ROOT, _max_depth);
+ _max_depth = 0;
+ _base_size = 0;
+}
+
+void QuadTreeLod::create_from_sizes(int base_size, int full_size) {
+ clear();
+ _base_size = base_size;
+ _max_depth = compute_lod_count(base_size, full_size);
+
+ // Total qty of nodes is (N^L - 1) / (N - 1). -1 for root, where N=num children, L=levels including the root
+ int node_count = ((static_cast<int>(pow(4, _max_depth+1)) - 1) / (4 - 1)) - 1;
+ _node_pool.resize(node_count); // e.g. ((4^6 -1) / 3 ) - 1 = 1364 excluding root
+
+ _free_indices.resize((node_count / 4)); // 1364 / 4 = 341
+ for (int i = 0; i < _free_indices.size(); i++) // i = 0 to 340, *4 = 0 to 1360
+ _free_indices[i] = 4 * i; // _node_pool[4*0 + i0] is first child, [4*340 + i3] is last
+}
+
+void QuadTreeLod::update(Vector3 view_pos) {
+ _update(ROOT, _max_depth, view_pos);
+
+ // This makes sure we keep seeing the lowest LOD,
+ // if the tree is cleared while we are far away
+ Quad *root = _get_root();
+ if (!root->has_children() && root->is_null())
+ root->set_data(_make_chunk(_max_depth, 0, 0));
+}
+
+void QuadTreeLod::debug_draw_tree(CanvasItem *ci) {
+ if (ci != nullptr)
+ _debug_draw_tree_recursive(ci, ROOT, _max_depth, 0);
+}
+
+// Intention is to only clear references to children
+void QuadTreeLod::_clear_children(unsigned int index) {
+ Quad *quad = _get_node(index);
+ if (quad->has_children()) {
+ _recycle_children(quad->first_child);
+ quad->first_child = NO_CHILDREN;
+ }
+}
+
+// Returns the index of the first_child. Allocates from _free_indices.
+unsigned int QuadTreeLod::_allocate_children() {
+ if (_free_indices.size() == 0) {
+ return NO_CHILDREN;
+ }
+
+ unsigned int i0 = _free_indices[_free_indices.size() - 1];
+ _free_indices.pop_back();
+ return i0;
+}
+
+// Pass the first_child index, not the parent index. Stores back in _free_indices.
+void QuadTreeLod::_recycle_children(unsigned int i0) {
+ // Debug check, there is no use case in recycling a node which is not a first child
+ CRASH_COND(i0 % 4 != 0);
+
+ for (int i = 0; i < 4; ++i) {
+ _node_pool[i0 + i].init();
+ }
+
+ _free_indices.push_back(i0);
+}
+
+Variant QuadTreeLod::_make_chunk(int lod, int origin_x, int origin_y) {
+ if (_make_func.is_valid()) {
+ return _make_func->call_func(origin_x, origin_y, lod);
+ } else {
+ return Variant();
+ }
+}
+
+void QuadTreeLod::_recycle_chunk(unsigned int quad_index, int lod) {
+ Quad *quad = _get_node(quad_index);
+ if (_recycle_func.is_valid()) {
+ _recycle_func->call_func(quad->get_data(), quad->origin_x, quad->origin_y, lod);
+ }
+}
+
+void QuadTreeLod::_join_all_recursively(unsigned int quad_index, int lod) {
+ Quad *quad = _get_node(quad_index);
+
+ if (quad->has_children()) {
+ for (int i = 0; i < 4; i++) {
+ _join_all_recursively(quad->first_child + i, lod - 1);
+ }
+ _clear_children(quad_index);
+
+ } else if (quad->is_valid()) {
+ _recycle_chunk(quad_index, lod);
+ quad->clear_data();
+ }
+}
+
+void QuadTreeLod::_update(unsigned int quad_index, int lod, Vector3 view_pos) {
+ // This function should be called regularly over frames.
+ Quad *quad = _get_node(quad_index);
+ int lod_factor = get_lod_factor(lod);
+ int chunk_size = _base_size * lod_factor;
+ Vector3 world_center = static_cast<real_t>(chunk_size) * (Vector3(static_cast<real_t>(quad->origin_x), 0.f, static_cast<real_t>(quad->origin_y)) + Vector3(0.5f, 0.f, 0.5f));
+
+ if (_vertical_bounds_func.is_valid()) {
+ Variant result = _vertical_bounds_func->call_func(quad->origin_x, quad->origin_y, lod);
+ ERR_FAIL_COND(result.get_type() != Variant::VECTOR2);
+ Vector2 vbounds = static_cast<Vector2>(result);
+ world_center.y = (vbounds.x + vbounds.y) / 2.0f;
+ }
+
+ int split_distance = _base_size * lod_factor * static_cast<int>(_split_scale);
+
+ if (!quad->has_children()) {
+ if (lod > 0 && world_center.distance_to(view_pos) < split_distance) {
+ // Split
+ unsigned int new_idx = _allocate_children();
+ ERR_FAIL_COND(new_idx == NO_CHILDREN);
+ quad->first_child = new_idx;
+
+ for (int i = 0; i < 4; i++) {
+ Quad *child = _get_node(quad->first_child + i);
+ child->origin_x = quad->origin_x * 2 + (i & 1);
+ child->origin_y = quad->origin_y * 2 + ((i & 2) >> 1);
+ child->set_data(_make_chunk(lod - 1, child->origin_x, child->origin_y));
+ // If the quad needs to split more, we'll ask more recycling...
+ }
+
+ if (quad->is_valid()) {
+ _recycle_chunk(quad_index, lod);
+ quad->clear_data();
+ }
+ }
+ } else {
+ bool no_split_child = true;
+
+ for (int i = 0; i < 4; i++) {
+ _update(quad->first_child + i, lod - 1, view_pos);
+
+ if (_get_node(quad->first_child + i)->has_children())
+ no_split_child = false;
+ }
+
+ if (no_split_child && world_center.distance_to(view_pos) > split_distance) {
+ // Join
+ for (int i = 0; i < 4; i++) {
+ _recycle_chunk(quad->first_child + i, lod - 1);
+ }
+ _clear_children(quad_index);
+ quad->set_data(_make_chunk(lod, quad->origin_x, quad->origin_y));
+ }
+ }
+} // _update
+
+void QuadTreeLod::_debug_draw_tree_recursive(CanvasItem *ci, unsigned int quad_index, int lod_index, int child_index) {
+ Quad *quad = _get_node(quad_index);
+
+ if (quad->has_children()) {
+ int ch_index = quad->first_child;
+ for (int i = 0; i < 4; i++) {
+ _debug_draw_tree_recursive(ci, ch_index + i, lod_index - 1, i);
+ }
+
+ } else {
+ real_t size = static_cast<real_t>(get_lod_factor(lod_index));
+ int checker = 0;
+ if (child_index == 1 || child_index == 2)
+ checker = 1;
+
+ int chunk_indicator = 0;
+ if (quad->is_valid())
+ chunk_indicator = 1;
+
+ Rect2 rect2(Vector2(static_cast<real_t>(quad->origin_x), static_cast<real_t>(quad->origin_y)) * size,
+ Vector2(size, size));
+ Color color(1.0f - static_cast<real_t>(lod_index) * 0.2f, 0.2f * static_cast<real_t>(checker), static_cast<real_t>(chunk_indicator), 1.0f);
+ ci->draw_rect(rect2, color);
+ }
+}
+
+void QuadTreeLod::_register_methods() {
+ register_method("set_callbacks", &QuadTreeLod::set_callbacks);
+ register_method("get_lod_count", &QuadTreeLod::get_lod_count);
+ register_method("get_lod_factor", &QuadTreeLod::get_lod_factor);
+ register_method("compute_lod_count", &QuadTreeLod::compute_lod_count);
+ register_method("set_split_scale", &QuadTreeLod::set_split_scale);
+ register_method("get_split_scale", &QuadTreeLod::get_split_scale);
+ register_method("clear", &QuadTreeLod::clear);
+ register_method("create_from_sizes", &QuadTreeLod::create_from_sizes);
+ register_method("update", &QuadTreeLod::update);
+ register_method("debug_draw_tree", &QuadTreeLod::debug_draw_tree);
+}
+
+} // namespace godot
diff --git a/game/addons/zylann.hterrain/native/src/quad_tree_lod.h b/game/addons/zylann.hterrain/native/src/quad_tree_lod.h
new file mode 100644
index 0000000..a4132ec
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/quad_tree_lod.h
@@ -0,0 +1,121 @@
+#ifndef QUAD_TREE_LOD_H
+#define QUAD_TREE_LOD_H
+
+#include <CanvasItem.hpp>
+#include <FuncRef.hpp>
+#include <Godot.hpp>
+
+#include <vector>
+
+namespace godot {
+
+class QuadTreeLod : public Reference {
+ GODOT_CLASS(QuadTreeLod, Reference)
+public:
+ static void _register_methods();
+
+ QuadTreeLod() {}
+ ~QuadTreeLod() {}
+
+ void _init() {}
+
+ void set_callbacks(Ref<FuncRef> make_cb, Ref<FuncRef> recycle_cb, Ref<FuncRef> vbounds_cb);
+ int get_lod_count();
+ int get_lod_factor(int lod);
+ int compute_lod_count(int base_size, int full_size);
+ void set_split_scale(real_t p_split_scale);
+ real_t get_split_scale();
+ void clear();
+ void create_from_sizes(int base_size, int full_size);
+ void update(Vector3 view_pos);
+ void debug_draw_tree(CanvasItem *ci);
+
+private:
+ static const unsigned int NO_CHILDREN = -1;
+ static const unsigned int ROOT = -1;
+
+ class Quad {
+ public:
+ unsigned int first_child = NO_CHILDREN;
+ int origin_x = 0;
+ int origin_y = 0;
+
+ Quad() {
+ init();
+ }
+
+ ~Quad() {
+ }
+
+ inline void init() {
+ first_child = NO_CHILDREN;
+ origin_x = 0;
+ origin_y = 0;
+ clear_data();
+ }
+
+ inline void clear_data() {
+ _data = Variant();
+ }
+
+ inline bool has_children() {
+ return first_child != NO_CHILDREN;
+ }
+
+ inline bool is_null() {
+ return _data.get_type() == Variant::NIL;
+ }
+
+ inline bool is_valid() {
+ return _data.get_type() != Variant::NIL;
+ }
+
+ inline Variant get_data() {
+ return _data;
+ }
+
+ inline void set_data(Variant p_data) {
+ _data = p_data;
+ }
+
+ private:
+ Variant _data; // Type is HTerrainChunk.gd : Object
+ };
+
+ Quad _root;
+ std::vector<Quad> _node_pool;
+ std::vector<unsigned int> _free_indices;
+
+ int _max_depth = 0;
+ int _base_size = 16;
+ real_t _split_scale = 2.0f;
+
+ Ref<FuncRef> _make_func;
+ Ref<FuncRef> _recycle_func;
+ Ref<FuncRef> _vertical_bounds_func;
+
+ inline Quad *_get_root() {
+ return &_root;
+ }
+
+ inline Quad *_get_node(unsigned int index) {
+ if (index == ROOT) {
+ return &_root;
+ } else {
+ return &_node_pool[index];
+ }
+ }
+
+ void _clear_children(unsigned int index);
+ unsigned int _allocate_children();
+ void _recycle_children(unsigned int i0);
+ Variant _make_chunk(int lod, int origin_x, int origin_y);
+ void _recycle_chunk(unsigned int quad_index, int lod);
+ void _join_all_recursively(unsigned int quad_index, int lod);
+ void _update(unsigned int quad_index, int lod, Vector3 view_pos);
+ void _debug_draw_tree_recursive(CanvasItem *ci, unsigned int quad_index, int lod_index, int child_index);
+}; // class QuadTreeLod
+
+} // namespace godot
+
+#endif // QUAD_TREE_LOD_H
diff --git a/game/addons/zylann.hterrain/native/src/vector2i.h b/game/addons/zylann.hterrain/native/src/vector2i.h
new file mode 100644
index 0000000..3eb60d6
--- /dev/null
+++ b/game/addons/zylann.hterrain/native/src/vector2i.h
@@ -0,0 +1,19 @@
+#ifndef VECTOR2I_H
+#define VECTOR2I_H
+
+#include <core/Vector2.hpp>
+
+struct Vector2i {
+ int x;
+ int y;
+
+ Vector2i(godot::Vector2 v) :
+ x(static_cast<int>(v.x)),
+ y(static_cast<int>(v.y)) {}
+
+ bool any_zero() const {
+ return x == 0 || y == 0;
+ }
+};
+
+#endif // VECTOR2I_H
diff --git a/game/addons/zylann.hterrain/plugin.cfg b/game/addons/zylann.hterrain/plugin.cfg
new file mode 100644
index 0000000..49e070c
--- /dev/null
+++ b/game/addons/zylann.hterrain/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Heightmap Terrain"
+description="Heightmap-based terrain"
+author="Marc Gilleron"
+version="1.7.0"
+script="tools/plugin.gd"
diff --git a/game/addons/zylann.hterrain/shaders/array.gdshader b/game/addons/zylann.hterrain/shaders/array.gdshader
new file mode 100644
index 0000000..fbbac13
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/array.gdshader
@@ -0,0 +1,170 @@
+shader_type spatial;
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_normalmap;
+// I had to remove source_color` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;
+uniform sampler2D u_terrain_splat_index_map;
+uniform sampler2D u_terrain_splat_weight_map;
+uniform sampler2D u_terrain_globalmap : source_color;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+uniform sampler2DArray u_ground_albedo_bump_array : source_color;
+uniform sampler2DArray u_ground_normal_roughness_array;
+
+// TODO Have UV scales for each texture in an array?
+uniform float u_ground_uv_scale;
+uniform float u_globalmap_blend_start;
+uniform float u_globalmap_blend_distance;
+uniform bool u_depth_blending = true;
+
+varying float v_hole;
+varying vec3 v_tint;
+varying vec2 v_ground_uv;
+varying float v_distance_to_camera;
+
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+vec3 get_depth_blended_weights(vec3 splat, vec3 bumps) {
+ float dh = 0.2;
+
+ vec3 h = bumps + splat;
+
+ // TODO Keep improving multilayer blending, there are still some edge cases...
+ // Mitigation: nullify layers with near-zero splat
+ h *= smoothstep(0, 0.05, splat);
+
+ vec3 d = h + dh;
+ d.r -= max(h.g, h.b);
+ d.g -= max(h.r, h.b);
+ d.b -= max(h.g, h.r);
+
+ vec3 w = clamp(d, 0, 1);
+ // Had to normalize, since this approach does not preserve components summing to 1
+ return w / (w.x + w.y + w.z);
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = (u_terrain_inverse_transform * wpos).xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_heightmap, 0));
+
+ // Height displacement
+ float h = sample_heightmap(u_terrain_heightmap, UV);
+ VERTEX.y = h;
+ wpos.y = h;
+
+ vec3 base_ground_uv = vec3(cell_coords.x, h * MODEL_MATRIX[1][1], cell_coords.y);
+ v_ground_uv = base_ground_uv.xz / u_ground_uv_scale;
+
+ // Putting this in vertex saves 2 fetches from the fragment shader,
+ // which is good for performance at a negligible quality cost,
+ // provided that geometry is a regular grid that decimates with LOD.
+ // (downside is LOD will also decimate tint and splat, but it's not bad overall)
+ vec4 tint = texture(u_terrain_colormap, UV);
+ v_hole = tint.a;
+ v_tint = tint.rgb;
+
+ // Need to use u_terrain_normal_basis to handle scaling.
+ NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+
+ v_distance_to_camera = distance(wpos.xyz, CAMERA_POSITION_WORLD);
+}
+
+void fragment() {
+ if (v_hole < 0.5) {
+ // TODO Add option to use vertex discarding instead, using NaNs
+ discard;
+ }
+
+ vec3 terrain_normal_world =
+ u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+ terrain_normal_world = normalize(terrain_normal_world);
+ vec3 normal = terrain_normal_world;
+
+ float globalmap_factor =
+ clamp((v_distance_to_camera - u_globalmap_blend_start) * u_globalmap_blend_distance, 0.0, 1.0);
+ globalmap_factor *= globalmap_factor; // slower start, faster transition but far away
+ vec3 global_albedo = texture(u_terrain_globalmap, UV).rgb;
+ ALBEDO = global_albedo;
+
+ // Doing this branch allows to spare a bunch of texture fetches for distant pixels.
+ // Eventually, there could be a split between near and far shaders in the future,
+ // if relevant on high-end GPUs
+ if (globalmap_factor < 1.0) {
+ vec4 tex_splat_indexes = texture(u_terrain_splat_index_map, UV);
+ vec4 tex_splat_weights = texture(u_terrain_splat_weight_map, UV);
+ // TODO Can't use texelFetch!
+ // https://github.com/godotengine/godot/issues/31732
+
+ vec3 splat_indexes = tex_splat_indexes.rgb * 255.0;
+ vec3 splat_weights = vec3(
+ tex_splat_weights.r,
+ tex_splat_weights.g,
+ 1.0 - tex_splat_weights.r - tex_splat_weights.g
+ );
+
+ vec4 ab0 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv, splat_indexes.x));
+ vec4 ab1 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv, splat_indexes.y));
+ vec4 ab2 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv, splat_indexes.z));
+
+ vec4 nr0 = texture(u_ground_normal_roughness_array, vec3(v_ground_uv, splat_indexes.x));
+ vec4 nr1 = texture(u_ground_normal_roughness_array, vec3(v_ground_uv, splat_indexes.y));
+ vec4 nr2 = texture(u_ground_normal_roughness_array, vec3(v_ground_uv, splat_indexes.z));
+
+ // TODO An #ifdef macro would be nice! Or copy/paste everything in a different shader...
+ if (u_depth_blending) {
+ splat_weights = get_depth_blended_weights(splat_weights, vec3(ab0.a, ab1.a, ab2.a));
+ }
+
+ ALBEDO = v_tint * (
+ ab0.rgb * splat_weights.x
+ + ab1.rgb * splat_weights.y
+ + ab2.rgb * splat_weights.z
+ );
+
+ ROUGHNESS =
+ nr0.a * splat_weights.x
+ + nr1.a * splat_weights.y
+ + nr2.a * splat_weights.z;
+
+ vec3 normal0 = unpack_normal(nr0);
+ vec3 normal1 = unpack_normal(nr1);
+ vec3 normal2 = unpack_normal(nr2);
+
+ vec3 ground_normal =
+ normal0 * splat_weights.x
+ + normal1 * splat_weights.y
+ + normal2 * splat_weights.z;
+
+ // Combine terrain normals with detail normals (not sure if correct but looks ok)
+ normal = normalize(vec3(
+ terrain_normal_world.x + ground_normal.x,
+ terrain_normal_world.y,
+ terrain_normal_world.z + ground_normal.z));
+
+ normal = mix(normal, terrain_normal_world, globalmap_factor);
+
+ ALBEDO = mix(ALBEDO, global_albedo, globalmap_factor);
+ //ALBEDO = vec3(splat_weight0, splat_weight1, splat_weight2);
+ ROUGHNESS = mix(ROUGHNESS, 1.0, globalmap_factor);
+ }
+
+ NORMAL = (VIEW_MATRIX * (vec4(normal, 0.0))).xyz;
+}
diff --git a/game/addons/zylann.hterrain/shaders/array_global.gdshader b/game/addons/zylann.hterrain/shaders/array_global.gdshader
new file mode 100644
index 0000000..b72dc53
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/array_global.gdshader
@@ -0,0 +1,87 @@
+// This shader is used to bake the global albedo map.
+// It exposes a subset of the main shader API, so uniform names were not modified.
+
+shader_type spatial;
+
+// I had to remove source_color` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;
+uniform sampler2D u_terrain_splat_index_map;
+uniform sampler2D u_terrain_splat_weight_map;
+
+uniform sampler2DArray u_ground_albedo_bump_array : source_color;
+
+// TODO Have UV scales for each texture in an array?
+uniform float u_ground_uv_scale;
+// Keep depth blending because it has a high effect on the final result
+uniform bool u_depth_blending = true;
+
+
+vec3 get_depth_blended_weights(vec3 splat, vec3 bumps) {
+ float dh = 0.2;
+
+ vec3 h = bumps + splat;
+
+ // TODO Keep improving multilayer blending, there are still some edge cases...
+ // Mitigation: nullify layers with near-zero splat
+ h *= smoothstep(0, 0.05, splat);
+
+ vec3 d = h + dh;
+ d.r -= max(h.g, h.b);
+ d.g -= max(h.r, h.b);
+ d.b -= max(h.g, h.r);
+
+ vec3 w = clamp(d, 0, 1);
+ // Had to normalize, since this approach does not preserve components summing to 1
+ return w / (w.x + w.y + w.z);
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = wpos.xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = (cell_coords / vec2(textureSize(u_terrain_splat_index_map, 0)));
+}
+
+void fragment() {
+ vec4 tint = texture(u_terrain_colormap, UV);
+ vec4 tex_splat_indexes = texture(u_terrain_splat_index_map, UV);
+ vec4 tex_splat_weights = texture(u_terrain_splat_weight_map, UV);
+ // TODO Can't use texelFetch!
+ // https://github.com/godotengine/godot/issues/31732
+
+ vec3 splat_indexes = tex_splat_indexes.rgb * 255.0;
+
+ // Get bump at normal resolution so depth blending is accurate
+ vec2 ground_uv = UV / u_ground_uv_scale;
+ float b0 = texture(u_ground_albedo_bump_array, vec3(ground_uv, splat_indexes.x)).a;
+ float b1 = texture(u_ground_albedo_bump_array, vec3(ground_uv, splat_indexes.y)).a;
+ float b2 = texture(u_ground_albedo_bump_array, vec3(ground_uv, splat_indexes.z)).a;
+
+ // Take the center of the highest mip as color, because we can't see details from far away.
+ vec2 ndc_center = vec2(0.5, 0.5);
+ vec3 a0 = textureLod(u_ground_albedo_bump_array, vec3(ndc_center, splat_indexes.x), 10.0).rgb;
+ vec3 a1 = textureLod(u_ground_albedo_bump_array, vec3(ndc_center, splat_indexes.y), 10.0).rgb;
+ vec3 a2 = textureLod(u_ground_albedo_bump_array, vec3(ndc_center, splat_indexes.z), 10.0).rgb;
+
+ vec3 splat_weights = vec3(
+ tex_splat_weights.r,
+ tex_splat_weights.g,
+ 1.0 - tex_splat_weights.r - tex_splat_weights.g
+ );
+
+ // TODO An #ifdef macro would be nice! Or copy/paste everything in a different shader...
+ if (u_depth_blending) {
+ splat_weights = get_depth_blended_weights(splat_weights, vec3(b0, b1, b2));
+ }
+
+ ALBEDO = tint.rgb * (
+ a0 * splat_weights.x
+ + a1 * splat_weights.y
+ + a2 * splat_weights.z
+ );
+}
diff --git a/game/addons/zylann.hterrain/shaders/detail.gdshader b/game/addons/zylann.hterrain/shaders/detail.gdshader
new file mode 100644
index 0000000..dbd1422
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/detail.gdshader
@@ -0,0 +1,107 @@
+shader_type spatial;
+render_mode cull_disabled;
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_detailmap;
+uniform sampler2D u_terrain_normalmap;
+uniform sampler2D u_terrain_globalmap : source_color;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+uniform sampler2D u_albedo_alpha : source_color;
+uniform float u_view_distance = 100.0;
+uniform float u_globalmap_tint_bottom : hint_range(0.0, 1.0);
+uniform float u_globalmap_tint_top : hint_range(0.0, 1.0);
+uniform float u_bottom_ao : hint_range(0.0, 1.0);
+uniform vec2 u_ambient_wind; // x: amplitude, y: time
+uniform vec3 u_instance_scale = vec3(1.0, 1.0, 1.0);
+uniform float u_roughness = 0.9;
+
+varying vec3 v_normal;
+varying vec2 v_map_uv;
+
+float get_hash(vec2 c) {
+ return fract(sin(dot(c.xy, vec2(12.9898,78.233))) * 43758.5453);
+}
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ n.z *= -1.0;
+ return n;
+}
+
+vec3 get_ambient_wind_displacement(vec2 uv, float hash) {
+ // TODO This is an initial basic implementation. It may be improved in the future, especially for strong wind.
+ float t = u_ambient_wind.y;
+ float amp = u_ambient_wind.x * (1.0 - uv.y);
+ // Main displacement
+ vec3 disp = amp * vec3(cos(t), 0, sin(t * 1.2));
+ // Fine displacement
+ float fine_disp_frequency = 2.0;
+ disp += 0.2 * amp * vec3(cos(t * (fine_disp_frequency + hash)), 0, sin(t * (fine_disp_frequency + hash) * 1.2));
+ return disp;
+}
+
+float get_height(sampler2D heightmap, vec2 uv) {
+ return sample_heightmap(heightmap, uv);
+}
+
+void vertex() {
+ vec4 obj_pos = MODEL_MATRIX * vec4(0, 1, 0, 1);
+ vec3 cell_coords = (u_terrain_inverse_transform * obj_pos).xyz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords.xz += vec2(0.5);
+
+ vec2 map_uv = cell_coords.xz / vec2(textureSize(u_terrain_heightmap, 0));
+ v_map_uv = map_uv;
+
+ //float density = 0.5 + 0.5 * sin(4.0*TIME); // test
+ float density = texture(u_terrain_detailmap, map_uv).r;
+ float hash = get_hash(obj_pos.xz);
+
+ if (density > hash) {
+ vec3 normal = normalize(
+ u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, map_uv)));
+
+ // Snap model to the terrain
+ float height = get_height(u_terrain_heightmap, map_uv) / cell_coords.y;
+ VERTEX *= u_instance_scale;
+ VERTEX.y += height;
+
+ VERTEX += get_ambient_wind_displacement(UV, hash);
+
+ // Fade alpha with distance
+ vec3 wpos = (MODEL_MATRIX * vec4(VERTEX, 1)).xyz;
+ float dr = distance(wpos, CAMERA_POSITION_WORLD) / u_view_distance;
+ COLOR.a = clamp(1.0 - dr * dr * dr, 0.0, 1.0);
+
+ // When using billboards,
+ // the normal is the same as the terrain regardless of face orientation
+ v_normal = normal;
+
+ } else {
+ // Discard, output degenerate triangles
+ VERTEX = vec3(0, 0, 0);
+ }
+}
+
+void fragment() {
+ NORMAL = (VIEW_MATRIX * (MODEL_MATRIX * vec4(v_normal, 0.0))).xyz;
+ ALPHA_SCISSOR_THRESHOLD = 0.5;
+ ROUGHNESS = u_roughness;
+
+ vec4 col = texture(u_albedo_alpha, UV);
+ ALPHA = col.a * COLOR.a;// - clamp(1.4 - UV.y, 0.0, 1.0);//* 0.5 + 0.5*cos(2.0*TIME);
+
+ ALBEDO = COLOR.rgb * col.rgb;
+
+ // Blend with ground color
+ float nh = sqrt(max(1.0 - UV.y, 0.0));
+ ALBEDO = mix(ALBEDO, texture(u_terrain_globalmap, v_map_uv).rgb, mix(u_globalmap_tint_bottom, u_globalmap_tint_top, nh));
+
+ // Fake bottom AO
+ ALBEDO = ALBEDO * mix(1.0, 1.0 - u_bottom_ao, UV.y * UV.y);
+}
diff --git a/game/addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc b/game/addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc
new file mode 100644
index 0000000..c023e52
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc
@@ -0,0 +1,66 @@
+
+// Use functions from this file everywhere a heightmap is used,
+// so it is easy to track and change the format
+
+float sample_heightmap(sampler2D spl, vec2 pos) {
+ // RF
+ return texture(spl, pos).r;
+}
+
+vec4 encode_height_to_viewport(float h) {
+ //return vec4(encode_height_to_rgb8_unorm(h), 1.0);
+
+ // Encode regular floats into an assumed RGBA8 output color.
+ // This is used because Godot 4.0 doesn't support RF viewports,
+ // and the irony is, even if float viewports get supported, it's likely it will end up RGBAF,
+ // which is wasting bandwidth because we are only interested in R...
+ uint u = floatBitsToUint(h);
+ return vec4(
+ float((u >> 0u) & 255u),
+ float((u >> 8u) & 255u),
+ float((u >> 16u) & 255u),
+ float((u >> 24u) & 255u)
+ ) / vec4(255.0);
+}
+
+float decode_height_from_viewport(vec4 c) {
+ uint u = uint(c.r * 255.0)
+ | (uint(c.g * 255.0) << 8u)
+ | (uint(c.b * 255.0) << 16u)
+ | (uint(c.a * 255.0) << 24u);
+ return uintBitsToFloat(u);
+}
+
+float sample_height_from_viewport(sampler2D screen, vec2 uv) {
+ ivec2 ts = textureSize(screen, 0);
+ vec2 norm_to_px = vec2(ts);
+
+ // Convert to pixels and apply a small offset so we interpolate from pixel centers
+ vec2 uv_px_f = uv * norm_to_px - vec2(0.5);
+
+ ivec2 uv_px = ivec2(uv_px_f);
+
+ // Get interpolation pixel positions
+ ivec2 p00 = uv_px;
+ ivec2 p10 = uv_px + ivec2(1, 0);
+ ivec2 p01 = uv_px + ivec2(0, 1);
+ ivec2 p11 = uv_px + ivec2(1, 1);
+
+ // Get pixels
+ vec4 c00 = texelFetch(screen, p00, 0);
+ vec4 c10 = texelFetch(screen, p10, 0);
+ vec4 c01 = texelFetch(screen, p01, 0);
+ vec4 c11 = texelFetch(screen, p11, 0);
+
+ // Decode heights
+ float h00 = decode_height_from_viewport(c00);
+ float h10 = decode_height_from_viewport(c10);
+ float h01 = decode_height_from_viewport(c01);
+ float h11 = decode_height_from_viewport(c11);
+
+ // Linear filter
+ vec2 f = fract(uv_px_f);
+ float h = mix(mix(h00, h10, f.x), mix(h01, h11, f.x), f.y);
+
+ return h;
+}
diff --git a/game/addons/zylann.hterrain/shaders/include/heightmap_rgb8_encoding.gdshaderinc b/game/addons/zylann.hterrain/shaders/include/heightmap_rgb8_encoding.gdshaderinc
new file mode 100644
index 0000000..bb30ebe
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/include/heightmap_rgb8_encoding.gdshaderinc
@@ -0,0 +1,57 @@
+
+const float V2_UNIT_STEPS = 1024.0;
+const float V2_MIN = -8192.0;
+const float V2_MAX = 8191.0;
+const float V2_DF = 255.0 / V2_UNIT_STEPS;
+
+float decode_height_from_rgb8_unorm_2(vec3 c) {
+ return (c.r * 0.25 + c.g * 64.0 + c.b * 16384.0) * (4.0 * V2_DF) + V2_MIN;
+}
+
+vec3 encode_height_to_rgb8_unorm_2(float h) {
+ // TODO Check if this has float precision issues
+ // TODO Modulo operator might be a performance/compatibility issue
+ h -= V2_MIN;
+ int i = int(h * V2_UNIT_STEPS);
+ int r = i % 256;
+ int g = (i / 256) % 256;
+ int b = i / 65536;
+ return vec3(float(r), float(g), float(b)) / 255.0;
+}
+
+float decode_height_from_rgb8_unorm(vec3 c) {
+ return decode_height_from_rgb8_unorm_2(c);
+}
+
+vec3 encode_height_to_rgb8_unorm(float h) {
+ return encode_height_to_rgb8_unorm_2(h);
+}
+
+// TODO Remove for now?
+// Bilinear filtering appears to work well enough without doing this.
+// There are some artifacts, but we could easily live with them,
+// and I suspect they could be easy to patch somehow in the encoding/decoding.
+//
+// In case bilinear filtering is required.
+// This is slower than if we had a native float format.
+// Unfortunately, Godot 4 removed support for 2D HDR viewports. They were used
+// to edit this format natively. Using compute shaders would force users to
+// have Vulkan. So we had to downgrade performance a bit using a technique from the GLES2 era...
+float sample_height_bilinear_rgb8_unorm(sampler2D heightmap, vec2 uv) {
+ vec2 ts = vec2(textureSize(heightmap, 0));
+ vec2 p00f = uv * ts;
+ ivec2 p00 = ivec2(p00f);
+
+ vec3 s00 = texelFetch(heightmap, p00, 0).rgb;
+ vec3 s10 = texelFetch(heightmap, p00 + ivec2(1, 0), 0).rgb;
+ vec3 s01 = texelFetch(heightmap, p00 + ivec2(0, 1), 0).rgb;
+ vec3 s11 = texelFetch(heightmap, p00 + ivec2(1, 1), 0).rgb;
+
+ float h00 = decode_height_from_rgb8_unorm(s00);
+ float h10 = decode_height_from_rgb8_unorm(s10);
+ float h01 = decode_height_from_rgb8_unorm(s01);
+ float h11 = decode_height_from_rgb8_unorm(s11);
+
+ vec2 f = p00f - vec2(p00);
+ return mix(mix(h00, h10, f.x), mix(h01, h11, f.x), f.y);
+}
diff --git a/game/addons/zylann.hterrain/shaders/lookdev.gdshader b/game/addons/zylann.hterrain/shaders/lookdev.gdshader
new file mode 100644
index 0000000..fede393
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/lookdev.gdshader
@@ -0,0 +1,71 @@
+shader_type spatial;
+
+// Development shader used to debug or help authoring.
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_normalmap;
+uniform sampler2D u_terrain_colormap;
+uniform sampler2D u_map; // This map will control color
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+varying float v_hole;
+
+
+vec3 unpack_normal(vec4 rgba) {
+ // If we consider texture space starts from top-left corner and Y goes down,
+ // then Y+ in pixel space corresponds to Z+ in terrain space,
+ // while X+ also corresponds to X+ in terrain space.
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = (u_terrain_inverse_transform * wpos).xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_heightmap, 0));
+
+ // Height displacement
+ float h = sample_heightmap(u_terrain_heightmap, UV);
+ VERTEX.y = h;
+ wpos.y = h;
+
+ // Putting this in vertex saves 2 fetches from the fragment shader,
+ // which is good for performance at a negligible quality cost,
+ // provided that geometry is a regular grid that decimates with LOD.
+ // (downside is LOD will also decimate tint and splat, but it's not bad overall)
+ vec4 tint = texture(u_terrain_colormap, UV);
+ v_hole = tint.a;
+
+ // Need to use u_terrain_normal_basis to handle scaling.
+ NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+}
+
+void fragment() {
+ if (v_hole < 0.5) {
+ // TODO Add option to use vertex discarding instead, using NaNs
+ discard;
+ }
+
+ vec3 terrain_normal_world =
+ u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+ terrain_normal_world = normalize(terrain_normal_world);
+ vec3 normal = terrain_normal_world;
+
+ vec4 value = texture(u_map, UV);
+ // TODO Blend toward checker pattern to show the alpha channel
+
+ ALBEDO = value.rgb;
+ ROUGHNESS = 0.5;
+ NORMAL = (VIEW_MATRIX * (vec4(normal, 0.0))).xyz;
+}
diff --git a/game/addons/zylann.hterrain/shaders/low_poly.gdshader b/game/addons/zylann.hterrain/shaders/low_poly.gdshader
new file mode 100644
index 0000000..6c98104
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/low_poly.gdshader
@@ -0,0 +1,63 @@
+shader_type spatial;
+
+// This is a very simple shader for a low-poly coloured visual, without textures
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_normalmap;
+// I had to remove `hint_albedo` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;// : hint_albedo;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+varying flat vec4 v_tint;
+
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+void vertex() {
+ vec2 cell_coords = (u_terrain_inverse_transform * MODEL_MATRIX * vec4(VERTEX, 1)).xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_heightmap, 0));
+
+ // Height displacement
+ float h = sample_heightmap(u_terrain_heightmap, UV);
+ VERTEX.y = h;
+
+ // Putting this in vertex saves 2 fetches from the fragment shader,
+ // which is good for performance at a negligible quality cost,
+ // provided that geometry is a regular grid that decimates with LOD.
+ // (downside is LOD will also decimate tint and splat, but it's not bad overall)
+ v_tint = texture(u_terrain_colormap, UV);
+
+ // Need to use u_terrain_normal_basis to handle scaling.
+ NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+}
+
+void fragment() {
+ if (v_tint.a < 0.5) {
+ // TODO Add option to use vertex discarding instead, using NaNs
+ discard;
+ }
+
+ vec3 terrain_normal_world =
+ u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+ terrain_normal_world = normalize(terrain_normal_world);
+
+ ALBEDO = v_tint.rgb;
+ ROUGHNESS = 1.0;
+ NORMAL = normalize(cross(dFdy(VERTEX), dFdx(VERTEX)));
+}
+
diff --git a/game/addons/zylann.hterrain/shaders/multisplat16.gdshader b/game/addons/zylann.hterrain/shaders/multisplat16.gdshader
new file mode 100644
index 0000000..6e08052
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/multisplat16.gdshader
@@ -0,0 +1,373 @@
+shader_type spatial;
+
+// WIP
+// This shader uses a texture array with multiple splatmaps, allowing up to 16 textures.
+// Only the 4 textures having highest blending weight are sampled.
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_normalmap;
+// I had to remove source_color` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;
+uniform sampler2D u_terrain_splatmap;
+uniform sampler2D u_terrain_splatmap_1;
+uniform sampler2D u_terrain_splatmap_2;
+uniform sampler2D u_terrain_splatmap_3;
+uniform sampler2D u_terrain_globalmap : source_color;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+uniform sampler2DArray u_ground_albedo_bump_array : source_color;
+uniform sampler2DArray u_ground_normal_roughness_array;
+
+uniform float u_ground_uv_scale = 20.0;
+uniform bool u_depth_blending = true;
+uniform float u_globalmap_blend_start;
+uniform float u_globalmap_blend_distance;
+uniform bool u_tile_reduction = false;
+
+varying float v_hole;
+varying vec3 v_tint;
+varying vec2 v_terrain_uv;
+varying vec3 v_ground_uv;
+varying float v_distance_to_camera;
+
+// TODO Can't put this in a constant: https://github.com/godotengine/godot/issues/44145
+//const int TEXTURE_COUNT = 16;
+
+
+vec3 unpack_normal(vec4 rgba) {
+ // If we consider texture space starts from top-left corner and Y goes down,
+ // then Y+ in pixel space corresponds to Z+ in terrain space,
+ // while X+ also corresponds to X+ in terrain space.
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+vec4 pack_normal(vec3 n, float a) {
+ n.z *= -1.0;
+ return vec4((n.xzy + vec3(1.0)) * 0.5, a);
+}
+
+// Blends weights according to the bump of detail textures,
+// so for example it allows to have sand fill the gaps between pebbles
+vec4 get_depth_blended_weights(vec4 splat, vec4 bumps) {
+ float dh = 0.2;
+
+ vec4 h = bumps + splat;
+
+ // TODO Keep improving multilayer blending, there are still some edge cases...
+ // Mitigation: nullify layers with near-zero splat
+ h *= smoothstep(0, 0.05, splat);
+
+ vec4 d = h + dh;
+ d.r -= max(h.g, max(h.b, h.a));
+ d.g -= max(h.r, max(h.b, h.a));
+ d.b -= max(h.g, max(h.r, h.a));
+ d.a -= max(h.g, max(h.b, h.r));
+
+ return clamp(d, 0, 1);
+}
+
+vec3 get_triplanar_blend(vec3 world_normal) {
+ vec3 blending = abs(world_normal);
+ blending = normalize(max(blending, vec3(0.00001))); // Force weights to sum to 1.0
+ float b = blending.x + blending.y + blending.z;
+ return blending / vec3(b, b, b);
+}
+
+vec4 texture_triplanar(sampler2D tex, vec3 world_pos, vec3 blend) {
+ vec4 xaxis = texture(tex, world_pos.yz);
+ vec4 yaxis = texture(tex, world_pos.xz);
+ vec4 zaxis = texture(tex, world_pos.xy);
+ // blend the results of the 3 planar projections.
+ return xaxis * blend.x + yaxis * blend.y + zaxis * blend.z;
+}
+
+void get_splat_weights(vec2 uv, out vec4 out_high_indices, out vec4 out_high_weights) {
+ vec4 ew0 = texture(u_terrain_splatmap, uv);
+ vec4 ew1 = texture(u_terrain_splatmap_1, uv);
+ vec4 ew2 = texture(u_terrain_splatmap_2, uv);
+ vec4 ew3 = texture(u_terrain_splatmap_3, uv);
+
+ float weights[16] = {
+ ew0.r, ew0.g, ew0.b, ew0.a,
+ ew1.r, ew1.g, ew1.b, ew1.a,
+ ew2.r, ew2.g, ew2.b, ew2.a,
+ ew3.r, ew3.g, ew3.b, ew3.a
+ };
+
+// float weights_sum = 0.0;
+// for (int i = 0; i < 16; ++i) {
+// weights_sum += weights[i];
+// }
+// for (int i = 0; i < 16; ++i) {
+// weights_sum /= weights_sum;
+// }
+// weights_sum=1.1;
+
+ // Now we have to pick the 4 highest weights and use them to blend textures.
+
+ // Using arrays because Godot's shader version doesn't support dynamic indexing of vectors
+ // TODO We should not need to initialize, but apparently we don't always find 4 weights
+ int high_indices_array[4] = {0, 0, 0, 0};
+ float high_weights_array[4] = {0.0, 0.0, 0.0, 0.0};
+ int count = 0;
+ // We know weights are supposed to be normalized.
+ // That means the highest value of the pivot above which we can find 4 results
+ // is 1.0 / 4.0. However that would mean exactly 4 textures have exactly that weight,
+ // which is very unlikely. If we consider 1.0 / 5.0, we are a bit more likely to find
+ // 4 results, and finding 5 results remains almost impossible.
+ float pivot = /*weights_sum*/1.0 / 5.0;
+
+ for (int i = 0; i < 16; ++i) {
+ if (weights[i] > pivot) {
+ high_weights_array[count] = weights[i];
+ high_indices_array[count] = i;
+ weights[i] = 0.0;
+ ++count;
+ }
+ }
+
+ while (count < 4 && pivot > 0.0) {
+ float max_weight = 0.0;
+ int max_index = 0;
+
+ for (int i = 0; i < 16; ++i) {
+ if (/*weights[i] <= pivot && */weights[i] > max_weight) {
+ max_weight = weights[i];
+ max_index = i;
+ weights[i] = 0.0;
+ }
+ }
+
+ high_indices_array[count] = max_index;
+ high_weights_array[count] = max_weight;
+ ++count;
+ pivot = max_weight;
+ }
+
+ out_high_weights = vec4(
+ high_weights_array[0], high_weights_array[1],
+ high_weights_array[2], high_weights_array[3]);
+
+ out_high_indices = vec4(
+ float(high_indices_array[0]), float(high_indices_array[1]),
+ float(high_indices_array[2]), float(high_indices_array[3]));
+
+ out_high_weights /=
+ out_high_weights.r + out_high_weights.g + out_high_weights.b + out_high_weights.a;
+}
+
+vec4 depth_blend2(vec4 a_value, float a_bump, vec4 b_value, float b_bump, float t) {
+ // https://www.gamasutra.com
+ // /blogs/AndreyMishkinis/20130716/196339/Advanced_Terrain_Texture_Splatting.php
+ float d = 0.1;
+ float ma = max(a_bump + (1.0 - t), b_bump + t) - d;
+ float ba = max(a_bump + (1.0 - t) - ma, 0.0);
+ float bb = max(b_bump + t - ma, 0.0);
+ return (a_value * ba + b_value * bb) / (ba + bb);
+}
+
+vec2 rotate(vec2 v, float cosa, float sina) {
+ return vec2(cosa * v.x - sina * v.y, sina * v.x + cosa * v.y);
+}
+
+vec4 texture_array_antitile(sampler2DArray albedo_tex, sampler2DArray normal_tex, vec3 uv,
+ out vec4 out_normal) {
+
+ float frequency = 2.0;
+ float scale = 1.3;
+ float sharpness = 0.7;
+
+ // Rotate and scale UV
+ float rot = 3.14 * 0.6;
+ float cosa = cos(rot);
+ float sina = sin(rot);
+ vec3 uv2 = vec3(rotate(uv.xy, cosa, sina) * scale, uv.z);
+
+ vec4 col0 = texture(albedo_tex, uv);
+ vec4 col1 = texture(albedo_tex, uv2);
+ vec4 nrm0 = texture(normal_tex, uv);
+ vec4 nrm1 = texture(normal_tex, uv2);
+ //col0 = vec4(0.0, 0.5, 0.5, 1.0); // Highlights variations
+
+ // Normals have to be rotated too since we are rotating the texture...
+ // TODO Probably not the most efficient but understandable for now
+ vec3 n = unpack_normal(nrm1);
+ // Had to negate the Y axis for some reason. I never remember the myriad of conventions around
+ n.xz = rotate(n.xz, cosa, -sina);
+ nrm1 = pack_normal(n, nrm1.a);
+
+ // Periodically alternate between the two versions using a warped checker pattern
+ float t = 1.1 + 0.5
+ * sin(uv2.x * frequency + sin(uv.x) * 2.0)
+ * cos(uv2.y * frequency + sin(uv.y) * 2.0); // Result in [0..2]
+ t = smoothstep(sharpness, 2.0 - sharpness, t);
+
+ // Using depth blend because classic alpha blending smoothes out details.
+ out_normal = depth_blend2(nrm0, col0.a, nrm1, col1.a, t);
+ return depth_blend2(col0, col0.a, col1, col1.a, t);
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = (u_terrain_inverse_transform * wpos).xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_heightmap, 0));
+
+ // Height displacement
+ float h = sample_heightmap(u_terrain_heightmap, UV);
+ VERTEX.y = h;
+ wpos.y = h;
+
+ vec3 base_ground_uv = vec3(cell_coords.x, h * MODEL_MATRIX[1][1], cell_coords.y);
+ v_ground_uv = base_ground_uv / u_ground_uv_scale;
+
+ // Putting this in vertex saves a fetch from the fragment shader,
+ // which is good for performance at a negligible quality cost,
+ // provided that geometry is a regular grid that decimates with LOD.
+ // (downside is LOD will also decimate it, but it's not bad overall)
+ vec4 tint = texture(u_terrain_colormap, UV);
+ v_hole = tint.a;
+ v_tint = tint.rgb;
+
+ // Need to use u_terrain_normal_basis to handle scaling.
+ NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+
+ v_distance_to_camera = distance(wpos.xyz, CAMERA_POSITION_WORLD);
+}
+
+void fragment() {
+ if (v_hole < 0.5) {
+ // TODO Add option to use vertex discarding instead, using NaNs
+ discard;
+ }
+
+ vec3 terrain_normal_world =
+ u_terrain_normal_basis * (unpack_normal(texture(u_terrain_normalmap, UV)));
+ terrain_normal_world = normalize(terrain_normal_world);
+ vec3 normal = terrain_normal_world;
+
+ float globalmap_factor = clamp((v_distance_to_camera - u_globalmap_blend_start)
+ * u_globalmap_blend_distance, 0.0, 1.0);
+ globalmap_factor *= globalmap_factor; // slower start, faster transition but far away
+ vec3 global_albedo = texture(u_terrain_globalmap, UV).rgb;
+ ALBEDO = global_albedo;
+
+ // Doing this branch allows to spare a bunch of texture fetches for distant pixels.
+ // Eventually, there could be a split between near and far shaders in the future,
+ // if relevant on high-end GPUs
+ if (globalmap_factor < 1.0) {
+ vec4 high_indices;
+ vec4 high_weights;
+ get_splat_weights(UV, high_indices, high_weights);
+
+ vec4 ab0, ab1, ab2, ab3;
+ vec4 nr0, nr1, nr2, nr3;
+
+ if (u_tile_reduction) {
+ ab0 = texture_array_antitile(
+ u_ground_albedo_bump_array, u_ground_normal_roughness_array,
+ vec3(v_ground_uv.xz, high_indices.x), nr0);
+ ab1 = texture_array_antitile(
+ u_ground_albedo_bump_array, u_ground_normal_roughness_array,
+ vec3(v_ground_uv.xz, high_indices.y), nr1);
+ ab2 = texture_array_antitile(
+ u_ground_albedo_bump_array, u_ground_normal_roughness_array,
+ vec3(v_ground_uv.xz, high_indices.z), nr2);
+ ab3 = texture_array_antitile(
+ u_ground_albedo_bump_array, u_ground_normal_roughness_array,
+ vec3(v_ground_uv.xz, high_indices.w), nr3);
+
+ } else {
+ ab0 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.x));
+ ab1 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.y));
+ ab2 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.z));
+ ab3 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.w));
+
+ nr0 = texture(u_ground_normal_roughness_array, vec3(v_ground_uv.xz, high_indices.x));
+ nr1 = texture(u_ground_normal_roughness_array, vec3(v_ground_uv.xz, high_indices.y));
+ nr2 = texture(u_ground_normal_roughness_array, vec3(v_ground_uv.xz, high_indices.z));
+ nr3 = texture(u_ground_normal_roughness_array, vec3(v_ground_uv.xz, high_indices.w));
+ }
+
+ vec3 col0 = ab0.rgb * v_tint;
+ vec3 col1 = ab1.rgb * v_tint;
+ vec3 col2 = ab2.rgb * v_tint;
+ vec3 col3 = ab3.rgb * v_tint;
+
+ vec4 rough = vec4(nr0.a, nr1.a, nr2.a, nr3.a);
+
+ vec3 normal0 = unpack_normal(nr0);
+ vec3 normal1 = unpack_normal(nr1);
+ vec3 normal2 = unpack_normal(nr2);
+ vec3 normal3 = unpack_normal(nr3);
+
+ vec4 w;
+ // TODO An #ifdef macro would be nice! Or copy/paste everything in a different shader...
+ if (u_depth_blending) {
+ w = get_depth_blended_weights(high_weights, vec4(ab0.a, ab1.a, ab2.a, ab3.a));
+ } else {
+ w = high_weights;
+ }
+
+ float w_sum = (w.r + w.g + w.b + w.a);
+
+ ALBEDO = (
+ w.r * col0.rgb +
+ w.g * col1.rgb +
+ w.b * col2.rgb +
+ w.a * col3.rgb) / w_sum;
+
+ ROUGHNESS = (
+ w.r * rough.r +
+ w.g * rough.g +
+ w.b * rough.b +
+ w.a * rough.a) / w_sum;
+
+ vec3 ground_normal = /*u_terrain_normal_basis **/ (
+ w.r * normal0 +
+ w.g * normal1 +
+ w.b * normal2 +
+ w.a * normal3) / w_sum;
+ // If no splat textures are defined, normal vectors will default to (1,1,1),
+ // which is incorrect, and causes the terrain to be shaded wrongly in some directions.
+ // However, this should not be a problem to fix in the shader,
+ // because there MUST be at least one splat texture set.
+ //ground_normal = normalize(ground_normal);
+ // TODO Make the plugin insert a default normalmap if it's empty
+
+ // Combine terrain normals with detail normals (not sure if correct but looks ok)
+ normal = normalize(vec3(
+ terrain_normal_world.x + ground_normal.x,
+ terrain_normal_world.y,
+ terrain_normal_world.z + ground_normal.z));
+
+ normal = mix(normal, terrain_normal_world, globalmap_factor);
+
+ ALBEDO = mix(ALBEDO, global_albedo, globalmap_factor);
+ ROUGHNESS = mix(ROUGHNESS, 1.0, globalmap_factor);
+
+// if(count < 3) {
+// ALBEDO = vec3(1.0, 0.0, 0.0);
+// }
+ // Show splatmap weights
+ //ALBEDO = w.rgb;
+ }
+ // Highlight all pixels undergoing no splatmap at all
+// else {
+// ALBEDO = vec3(1.0, 0.0, 0.0);
+// }
+
+ NORMAL = (VIEW_MATRIX * (vec4(normal, 0.0))).xyz;
+}
diff --git a/game/addons/zylann.hterrain/shaders/multisplat16_global.gdshader b/game/addons/zylann.hterrain/shaders/multisplat16_global.gdshader
new file mode 100644
index 0000000..dbe60c5
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/multisplat16_global.gdshader
@@ -0,0 +1,173 @@
+shader_type spatial;
+
+// This shader uses a texture array with multiple splatmaps, allowing up to 16 textures.
+// Only the 4 textures having highest blending weight are sampled.
+
+// I had to remove source_color` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;
+uniform sampler2D u_terrain_splatmap;
+uniform sampler2D u_terrain_splatmap_1;
+uniform sampler2D u_terrain_splatmap_2;
+uniform sampler2D u_terrain_splatmap_3;
+
+uniform sampler2DArray u_ground_albedo_bump_array : source_color;
+
+uniform float u_ground_uv_scale = 20.0;
+uniform bool u_depth_blending = true;
+
+// TODO Can't put this in a constant: https://github.com/godotengine/godot/issues/44145
+//const int TEXTURE_COUNT = 16;
+
+
+// Blends weights according to the bump of detail textures,
+// so for example it allows to have sand fill the gaps between pebbles
+vec4 get_depth_blended_weights(vec4 splat, vec4 bumps) {
+ float dh = 0.2;
+
+ vec4 h = bumps + splat;
+
+ // TODO Keep improving multilayer blending, there are still some edge cases...
+ // Mitigation: nullify layers with near-zero splat
+ h *= smoothstep(0, 0.05, splat);
+
+ vec4 d = h + dh;
+ d.r -= max(h.g, max(h.b, h.a));
+ d.g -= max(h.r, max(h.b, h.a));
+ d.b -= max(h.g, max(h.r, h.a));
+ d.a -= max(h.g, max(h.b, h.r));
+
+ return clamp(d, 0, 1);
+}
+
+void get_splat_weights(vec2 uv, out vec4 out_high_indices, out vec4 out_high_weights) {
+ vec4 ew0 = texture(u_terrain_splatmap, uv);
+ vec4 ew1 = texture(u_terrain_splatmap_1, uv);
+ vec4 ew2 = texture(u_terrain_splatmap_2, uv);
+ vec4 ew3 = texture(u_terrain_splatmap_3, uv);
+
+ float weights[16] = {
+ ew0.r, ew0.g, ew0.b, ew0.a,
+ ew1.r, ew1.g, ew1.b, ew1.a,
+ ew2.r, ew2.g, ew2.b, ew2.a,
+ ew3.r, ew3.g, ew3.b, ew3.a
+ };
+
+// float weights_sum = 0.0;
+// for (int i = 0; i < 16; ++i) {
+// weights_sum += weights[i];
+// }
+// for (int i = 0; i < 16; ++i) {
+// weights_sum /= weights_sum;
+// }
+// weights_sum=1.1;
+
+ // Now we have to pick the 4 highest weights and use them to blend textures.
+
+ // Using arrays because Godot's shader version doesn't support dynamic indexing of vectors
+ // TODO We should not need to initialize, but apparently we don't always find 4 weights
+ int high_indices_array[4] = {0, 0, 0, 0};
+ float high_weights_array[4] = {0.0, 0.0, 0.0, 0.0};
+ int count = 0;
+ // We know weights are supposed to be normalized.
+ // That means the highest value of the pivot above which we can find 4 results
+ // is 1.0 / 4.0. However that would mean exactly 4 textures have exactly that weight,
+ // which is very unlikely. If we consider 1.0 / 5.0, we are a bit more likely to find
+ // 4 results, and finding 5 results remains almost impossible.
+ float pivot = /*weights_sum*/1.0 / 5.0;
+
+ for (int i = 0; i < 16; ++i) {
+ if (weights[i] > pivot) {
+ high_weights_array[count] = weights[i];
+ high_indices_array[count] = i;
+ weights[i] = 0.0;
+ ++count;
+ }
+ }
+
+ while (count < 4 && pivot > 0.0) {
+ float max_weight = 0.0;
+ int max_index = 0;
+
+ for (int i = 0; i < 16; ++i) {
+ if (/*weights[i] <= pivot && */weights[i] > max_weight) {
+ max_weight = weights[i];
+ max_index = i;
+ weights[i] = 0.0;
+ }
+ }
+
+ high_indices_array[count] = max_index;
+ high_weights_array[count] = max_weight;
+ ++count;
+ pivot = max_weight;
+ }
+
+ out_high_weights = vec4(
+ high_weights_array[0], high_weights_array[1],
+ high_weights_array[2], high_weights_array[3]);
+
+ out_high_indices = vec4(
+ float(high_indices_array[0]), float(high_indices_array[1]),
+ float(high_indices_array[2]), float(high_indices_array[3]));
+
+ out_high_weights /=
+ out_high_weights.r + out_high_weights.g + out_high_weights.b + out_high_weights.a;
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = wpos.xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_splatmap, 0));
+}
+
+void fragment() {
+ // These were moved from vertex to fragment,
+ // so we can generate part of the global map with just one quad and we get full quality
+ vec3 tint = texture(u_terrain_colormap, UV).rgb;
+ vec4 splat = texture(u_terrain_splatmap, UV);
+
+ vec4 high_indices;
+ vec4 high_weights;
+ get_splat_weights(UV, high_indices, high_weights);
+
+ // Get bump at normal resolution so depth blending is accurate
+ vec2 ground_uv = UV / u_ground_uv_scale;
+ float b0 = texture(u_ground_albedo_bump_array, vec3(ground_uv, high_indices.x)).a;
+ float b1 = texture(u_ground_albedo_bump_array, vec3(ground_uv, high_indices.y)).a;
+ float b2 = texture(u_ground_albedo_bump_array, vec3(ground_uv, high_indices.z)).a;
+ float b3 = texture(u_ground_albedo_bump_array, vec3(ground_uv, high_indices.w)).a;
+
+ // Take the center of the highest mip as color, because we can't see details from far away.
+ vec2 ndc_center = vec2(0.5, 0.5);
+ vec3 a0 = textureLod(u_ground_albedo_bump_array, vec3(ndc_center, high_indices.x), 10.0).rgb;
+ vec3 a1 = textureLod(u_ground_albedo_bump_array, vec3(ndc_center, high_indices.y), 10.0).rgb;
+ vec3 a2 = textureLod(u_ground_albedo_bump_array, vec3(ndc_center, high_indices.z), 10.0).rgb;
+ vec3 a3 = textureLod(u_ground_albedo_bump_array, vec3(ndc_center, high_indices.w), 10.0).rgb;
+
+ vec3 col0 = a0 * tint;
+ vec3 col1 = a1 * tint;
+ vec3 col2 = a2 * tint;
+ vec3 col3 = a3 * tint;
+
+ vec4 w;
+ // TODO An #ifdef macro would be nice! Or copy/paste everything in a different shader...
+ if (u_depth_blending) {
+ w = get_depth_blended_weights(high_weights, vec4(b0, b1, b2, b3));
+ } else {
+ w = high_weights;
+ }
+
+ float w_sum = (w.r + w.g + w.b + w.a);
+
+ ALBEDO = (
+ w.r * col0.rgb +
+ w.g * col1.rgb +
+ w.b * col2.rgb +
+ w.a * col3.rgb) / w_sum;
+}
diff --git a/game/addons/zylann.hterrain/shaders/multisplat16_lite.gdshader b/game/addons/zylann.hterrain/shaders/multisplat16_lite.gdshader
new file mode 100644
index 0000000..b667496
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/multisplat16_lite.gdshader
@@ -0,0 +1,254 @@
+shader_type spatial;
+
+// WIP
+// This shader uses a texture array with multiple splatmaps, allowing up to 16 textures.
+// Only the 4 textures having highest blending weight are sampled.
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_normalmap;
+// I had to remove source_color` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;
+uniform sampler2D u_terrain_splatmap;
+uniform sampler2D u_terrain_splatmap_1;
+uniform sampler2D u_terrain_splatmap_2;
+uniform sampler2D u_terrain_splatmap_3;
+uniform sampler2D u_terrain_globalmap : source_color;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+uniform sampler2DArray u_ground_albedo_bump_array : source_color;
+
+uniform float u_ground_uv_scale = 20.0;
+uniform bool u_depth_blending = true;
+uniform float u_globalmap_blend_start;
+uniform float u_globalmap_blend_distance;
+
+varying float v_hole;
+varying vec3 v_tint;
+varying vec2 v_terrain_uv;
+varying vec3 v_ground_uv;
+varying float v_distance_to_camera;
+
+// TODO Can't put this in a constant: https://github.com/godotengine/godot/issues/44145
+//const int TEXTURE_COUNT = 16;
+
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+// Blends weights according to the bump of detail textures,
+// so for example it allows to have sand fill the gaps between pebbles
+vec4 get_depth_blended_weights(vec4 splat, vec4 bumps) {
+ float dh = 0.2;
+
+ vec4 h = bumps + splat;
+
+ // TODO Keep improving multilayer blending, there are still some edge cases...
+ // Mitigation: nullify layers with near-zero splat
+ h *= smoothstep(0, 0.05, splat);
+
+ vec4 d = h + dh;
+ d.r -= max(h.g, max(h.b, h.a));
+ d.g -= max(h.r, max(h.b, h.a));
+ d.b -= max(h.g, max(h.r, h.a));
+ d.a -= max(h.g, max(h.b, h.r));
+
+ return clamp(d, 0, 1);
+}
+
+vec3 get_triplanar_blend(vec3 world_normal) {
+ vec3 blending = abs(world_normal);
+ blending = normalize(max(blending, vec3(0.00001))); // Force weights to sum to 1.0
+ float b = blending.x + blending.y + blending.z;
+ return blending / vec3(b, b, b);
+}
+
+vec4 texture_triplanar(sampler2D tex, vec3 world_pos, vec3 blend) {
+ vec4 xaxis = texture(tex, world_pos.yz);
+ vec4 yaxis = texture(tex, world_pos.xz);
+ vec4 zaxis = texture(tex, world_pos.xy);
+ // blend the results of the 3 planar projections.
+ return xaxis * blend.x + yaxis * blend.y + zaxis * blend.z;
+}
+
+void get_splat_weights(vec2 uv, out vec4 out_high_indices, out vec4 out_high_weights) {
+ vec4 ew0 = texture(u_terrain_splatmap, uv);
+ vec4 ew1 = texture(u_terrain_splatmap_1, uv);
+ vec4 ew2 = texture(u_terrain_splatmap_2, uv);
+ vec4 ew3 = texture(u_terrain_splatmap_3, uv);
+
+ float weights[16] = {
+ ew0.r, ew0.g, ew0.b, ew0.a,
+ ew1.r, ew1.g, ew1.b, ew1.a,
+ ew2.r, ew2.g, ew2.b, ew2.a,
+ ew3.r, ew3.g, ew3.b, ew3.a
+ };
+
+// float weights_sum = 0.0;
+// for (int i = 0; i < 16; ++i) {
+// weights_sum += weights[i];
+// }
+// for (int i = 0; i < 16; ++i) {
+// weights_sum /= weights_sum;
+// }
+// weights_sum=1.1;
+
+ // Now we have to pick the 4 highest weights and use them to blend textures.
+
+ // Using arrays because Godot's shader version doesn't support dynamic indexing of vectors
+ // TODO We should not need to initialize, but apparently we don't always find 4 weights
+ int high_indices_array[4] = {0, 0, 0, 0};
+ float high_weights_array[4] = {0.0, 0.0, 0.0, 0.0};
+ int count = 0;
+ // We know weights are supposed to be normalized.
+ // That means the highest value of the pivot above which we can find 4 results
+ // is 1.0 / 4.0. However that would mean exactly 4 textures have exactly that weight,
+ // which is very unlikely. If we consider 1.0 / 5.0, we are a bit more likely to find
+ // 4 results, and finding 5 results remains almost impossible.
+ float pivot = /*weights_sum*/1.0 / 5.0;
+
+ for (int i = 0; i < 16; ++i) {
+ if (weights[i] > pivot) {
+ high_weights_array[count] = weights[i];
+ high_indices_array[count] = i;
+ weights[i] = 0.0;
+ ++count;
+ }
+ }
+
+ while (count < 4 && pivot > 0.0) {
+ float max_weight = 0.0;
+ int max_index = 0;
+
+ for (int i = 0; i < 16; ++i) {
+ if (/*weights[i] <= pivot && */weights[i] > max_weight) {
+ max_weight = weights[i];
+ max_index = i;
+ weights[i] = 0.0;
+ }
+ }
+
+ high_indices_array[count] = max_index;
+ high_weights_array[count] = max_weight;
+ ++count;
+ pivot = max_weight;
+ }
+
+ out_high_weights = vec4(
+ high_weights_array[0], high_weights_array[1],
+ high_weights_array[2], high_weights_array[3]);
+
+ out_high_indices = vec4(
+ float(high_indices_array[0]), float(high_indices_array[1]),
+ float(high_indices_array[2]), float(high_indices_array[3]));
+
+ out_high_weights /=
+ out_high_weights.r + out_high_weights.g + out_high_weights.b + out_high_weights.a;
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = (u_terrain_inverse_transform * wpos).xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_heightmap, 0));
+
+ // Height displacement
+ float h = sample_heightmap(u_terrain_heightmap, UV);
+ VERTEX.y = h;
+ wpos.y = h;
+
+ vec3 base_ground_uv = vec3(cell_coords.x, h * MODEL_MATRIX[1][1], cell_coords.y);
+ v_ground_uv = base_ground_uv / u_ground_uv_scale;
+
+ // Putting this in vertex saves a fetch from the fragment shader,
+ // which is good for performance at a negligible quality cost,
+ // provided that geometry is a regular grid that decimates with LOD.
+ // (downside is LOD will also decimate it, but it's not bad overall)
+ vec4 tint = texture(u_terrain_colormap, UV);
+ v_hole = tint.a;
+ v_tint = tint.rgb;
+
+ // Need to use u_terrain_normal_basis to handle scaling.
+ NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+
+ v_distance_to_camera = distance(wpos.xyz, CAMERA_POSITION_WORLD);
+}
+
+void fragment() {
+ if (v_hole < 0.5) {
+ // TODO Add option to use vertex discarding instead, using NaNs
+ discard;
+ }
+
+ vec3 terrain_normal_world =
+ u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+ terrain_normal_world = normalize(terrain_normal_world);
+
+ float globalmap_factor = clamp((v_distance_to_camera - u_globalmap_blend_start)
+ * u_globalmap_blend_distance, 0.0, 1.0);
+ globalmap_factor *= globalmap_factor; // slower start, faster transition but far away
+ vec3 global_albedo = texture(u_terrain_globalmap, UV).rgb;
+ ALBEDO = global_albedo;
+
+ // Doing this branch allows to spare a bunch of texture fetches for distant pixels.
+ // Eventually, there could be a split between near and far shaders in the future,
+ // if relevant on high-end GPUs
+ if (globalmap_factor < 1.0) {
+ vec4 high_indices;
+ vec4 high_weights;
+ get_splat_weights(UV, high_indices, high_weights);
+
+ vec4 ab0 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.x));
+ vec4 ab1 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.y));
+ vec4 ab2 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.z));
+ vec4 ab3 = texture(u_ground_albedo_bump_array, vec3(v_ground_uv.xz, high_indices.w));
+
+ vec3 col0 = ab0.rgb * v_tint;
+ vec3 col1 = ab1.rgb * v_tint;
+ vec3 col2 = ab2.rgb * v_tint;
+ vec3 col3 = ab3.rgb * v_tint;
+
+ vec4 w;
+ // TODO An #ifdef macro would be nice! Or copy/paste everything in a different shader...
+ if (u_depth_blending) {
+ w = get_depth_blended_weights(high_weights, vec4(ab0.a, ab1.a, ab2.a, ab3.a));
+ } else {
+ w = high_weights;
+ }
+
+ float w_sum = (w.r + w.g + w.b + w.a);
+
+ ALBEDO = (
+ w.r * col0.rgb +
+ w.g * col1.rgb +
+ w.b * col2.rgb +
+ w.a * col3.rgb) / w_sum;
+
+ ALBEDO = mix(ALBEDO, global_albedo, globalmap_factor);
+ ROUGHNESS = mix(ROUGHNESS, 1.0, globalmap_factor);
+
+// if(count < 3) {
+// ALBEDO = vec3(1.0, 0.0, 0.0);
+// }
+ // Show splatmap weights
+ //ALBEDO = w.rgb;
+ }
+ // Highlight all pixels undergoing no splatmap at all
+// else {
+// ALBEDO = vec3(1.0, 0.0, 0.0);
+// }
+
+ NORMAL = (VIEW_MATRIX * (vec4(terrain_normal_world, 0.0))).xyz;
+}
diff --git a/game/addons/zylann.hterrain/shaders/simple4.gdshader b/game/addons/zylann.hterrain/shaders/simple4.gdshader
new file mode 100644
index 0000000..6c28b77
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/simple4.gdshader
@@ -0,0 +1,329 @@
+shader_type spatial;
+
+// This is the reference shader of the plugin, and has the most features.
+// it should be preferred for high-end graphics cards.
+// For less features but lower-end targets, see the lite version.
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_normalmap;
+// I had to remove `hint_albedo` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;
+uniform sampler2D u_terrain_splatmap;
+uniform sampler2D u_terrain_globalmap : source_color;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+// the reason bump is preferred with albedo is, roughness looks better with normal maps.
+// If we want no normal mapping, roughness would only give flat mirror surfaces,
+// while bump still allows to do depth-blending for free.
+uniform sampler2D u_ground_albedo_bump_0 : source_color;
+uniform sampler2D u_ground_albedo_bump_1 : source_color;
+uniform sampler2D u_ground_albedo_bump_2 : source_color;
+uniform sampler2D u_ground_albedo_bump_3 : source_color;
+
+uniform sampler2D u_ground_normal_roughness_0;
+uniform sampler2D u_ground_normal_roughness_1;
+uniform sampler2D u_ground_normal_roughness_2;
+uniform sampler2D u_ground_normal_roughness_3;
+
+// Had to give this uniform a suffix, because it's declared as a simple float
+// in other shaders, and its type cannot be inferred by the plugin.
+// See https://github.com/godotengine/godot/issues/24488
+uniform vec4 u_ground_uv_scale_per_texture = vec4(20.0, 20.0, 20.0, 20.0);
+
+uniform bool u_depth_blending = true;
+uniform bool u_triplanar = false;
+// Each component corresponds to a ground texture. Set greater than zero to enable.
+uniform vec4 u_tile_reduction = vec4(0.0, 0.0, 0.0, 0.0);
+
+uniform float u_globalmap_blend_start;
+uniform float u_globalmap_blend_distance;
+
+uniform vec4 u_colormap_opacity_per_texture = vec4(1.0, 1.0, 1.0, 1.0);
+
+varying float v_hole;
+varying vec3 v_tint0;
+varying vec3 v_tint1;
+varying vec3 v_tint2;
+varying vec3 v_tint3;
+varying vec4 v_splat;
+varying vec2 v_ground_uv0;
+varying vec2 v_ground_uv1;
+varying vec2 v_ground_uv2;
+varying vec3 v_ground_uv3;
+varying float v_distance_to_camera;
+
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+vec4 pack_normal(vec3 n, float a) {
+ n.z *= -1.0;
+ return vec4((n.xzy + vec3(1.0)) * 0.5, a);
+}
+
+// Blends weights according to the bump of detail textures,
+// so for example it allows to have sand fill the gaps between pebbles
+vec4 get_depth_blended_weights(vec4 splat, vec4 bumps) {
+ float dh = 0.2;
+
+ vec4 h = bumps + splat;
+
+ // TODO Keep improving multilayer blending, there are still some edge cases...
+ // Mitigation: nullify layers with near-zero splat
+ h *= smoothstep(0, 0.05, splat);
+
+ vec4 d = h + dh;
+ d.r -= max(h.g, max(h.b, h.a));
+ d.g -= max(h.r, max(h.b, h.a));
+ d.b -= max(h.g, max(h.r, h.a));
+ d.a -= max(h.g, max(h.b, h.r));
+
+ return clamp(d, 0, 1);
+}
+
+vec3 get_triplanar_blend(vec3 world_normal) {
+ vec3 blending = abs(world_normal);
+ blending = normalize(max(blending, vec3(0.00001))); // Force weights to sum to 1.0
+ float b = blending.x + blending.y + blending.z;
+ return blending / vec3(b, b, b);
+}
+
+vec4 texture_triplanar(sampler2D tex, vec3 world_pos, vec3 blend) {
+ vec4 xaxis = texture(tex, world_pos.yz);
+ vec4 yaxis = texture(tex, world_pos.xz);
+ vec4 zaxis = texture(tex, world_pos.xy);
+ // blend the results of the 3 planar projections.
+ return xaxis * blend.x + yaxis * blend.y + zaxis * blend.z;
+}
+
+vec4 depth_blend2(vec4 a_value, float a_bump, vec4 b_value, float b_bump, float t) {
+ // https://www.gamasutra.com
+ // /blogs/AndreyMishkinis/20130716/196339/Advanced_Terrain_Texture_Splatting.php
+ float d = 0.1;
+ float ma = max(a_bump + (1.0 - t), b_bump + t) - d;
+ float ba = max(a_bump + (1.0 - t) - ma, 0.0);
+ float bb = max(b_bump + t - ma, 0.0);
+ return (a_value * ba + b_value * bb) / (ba + bb);
+}
+
+vec2 rotate(vec2 v, float cosa, float sina) {
+ return vec2(cosa * v.x - sina * v.y, sina * v.x + cosa * v.y);
+}
+
+vec4 texture_antitile(sampler2D albedo_tex, sampler2D normal_tex, vec2 uv, out vec4 out_normal) {
+ float frequency = 2.0;
+ float scale = 1.3;
+ float sharpness = 0.7;
+
+ // Rotate and scale UV
+ float rot = 3.14 * 0.6;
+ float cosa = cos(rot);
+ float sina = sin(rot);
+ vec2 uv2 = rotate(uv, cosa, sina) * scale;
+
+ vec4 col0 = texture(albedo_tex, uv);
+ vec4 col1 = texture(albedo_tex, uv2);
+ vec4 nrm0 = texture(normal_tex, uv);
+ vec4 nrm1 = texture(normal_tex, uv2);
+ //col0 = vec4(0.0, 0.5, 0.5, 1.0); // Highlights variations
+
+ // Normals have to be rotated too since we are rotating the texture...
+ // TODO Probably not the most efficient but understandable for now
+ vec3 n = unpack_normal(nrm1);
+ // Had to negate the Y axis for some reason. I never remember the myriad of conventions around
+ n.xz = rotate(n.xz, cosa, -sina);
+ nrm1 = pack_normal(n, nrm1.a);
+
+ // Periodically alternate between the two versions using a warped checker pattern
+ float t = 1.2 +
+ sin(uv2.x * frequency + sin(uv.x) * 2.0)
+ * cos(uv2.y * frequency + sin(uv.y) * 2.0); // Result in [0..2]
+ t = smoothstep(sharpness, 2.0 - sharpness, t);
+
+ // Using depth blend because classic alpha blending smoothes out details.
+ out_normal = depth_blend2(nrm0, col0.a, nrm1, col1.a, t);
+ return depth_blend2(col0, col0.a, col1, col1.a, t);
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = (u_terrain_inverse_transform * wpos).xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_heightmap, 0));
+
+ // Height displacement
+ float h = sample_heightmap(u_terrain_heightmap, UV);
+ VERTEX.y = h;
+ wpos.y = h;
+
+ vec3 base_ground_uv = vec3(cell_coords.x, h * MODEL_MATRIX[1][1], cell_coords.y);
+ v_ground_uv0 = base_ground_uv.xz / u_ground_uv_scale_per_texture.x;
+ v_ground_uv1 = base_ground_uv.xz / u_ground_uv_scale_per_texture.y;
+ v_ground_uv2 = base_ground_uv.xz / u_ground_uv_scale_per_texture.z;
+ v_ground_uv3 = base_ground_uv / u_ground_uv_scale_per_texture.w;
+
+ // Putting this in vertex saves 2 fetches from the fragment shader,
+ // which is good for performance at a negligible quality cost,
+ // provided that geometry is a regular grid that decimates with LOD.
+ // (downside is LOD will also decimate tint and splat, but it's not bad overall)
+ vec4 tint = texture(u_terrain_colormap, UV);
+ v_hole = tint.a;
+ v_tint0 = mix(vec3(1.0), tint.rgb, u_colormap_opacity_per_texture.x);
+ v_tint1 = mix(vec3(1.0), tint.rgb, u_colormap_opacity_per_texture.y);
+ v_tint2 = mix(vec3(1.0), tint.rgb, u_colormap_opacity_per_texture.z);
+ v_tint3 = mix(vec3(1.0), tint.rgb, u_colormap_opacity_per_texture.w);
+ v_splat = texture(u_terrain_splatmap, UV);
+
+ // Need to use u_terrain_normal_basis to handle scaling.
+ NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+
+ v_distance_to_camera = distance(wpos.xyz, CAMERA_POSITION_WORLD);
+}
+
+void fragment() {
+ if (v_hole < 0.5) {
+ // TODO Add option to use vertex discarding instead, using NaNs
+ discard;
+ }
+
+ vec3 terrain_normal_world =
+ u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+ terrain_normal_world = normalize(terrain_normal_world);
+ vec3 normal = terrain_normal_world;
+
+ float globalmap_factor = clamp((v_distance_to_camera - u_globalmap_blend_start)
+ * u_globalmap_blend_distance, 0.0, 1.0);
+ globalmap_factor *= globalmap_factor; // slower start, faster transition but far away
+ vec3 global_albedo = texture(u_terrain_globalmap, UV).rgb;
+ ALBEDO = global_albedo;
+
+ // Doing this branch allows to spare a bunch of texture fetches for distant pixels.
+ // Eventually, there could be a split between near and far shaders in the future,
+ // if relevant on high-end GPUs
+ if (globalmap_factor < 1.0) {
+ vec4 ab0, ab1, ab2, ab3;
+ vec4 nr0, nr1, nr2, nr3;
+
+ if (u_triplanar) {
+ // Only do triplanar on one texture slot,
+ // because otherwise it would be very expensive and cost many more ifs.
+ // I chose the last slot because first slot is the default on new splatmaps,
+ // and that's a feature used for cliffs, which are usually designed later.
+
+ vec3 blending = get_triplanar_blend(terrain_normal_world);
+
+ ab3 = texture_triplanar(u_ground_albedo_bump_3, v_ground_uv3, blending);
+ nr3 = texture_triplanar(u_ground_normal_roughness_3, v_ground_uv3, blending);
+
+ } else {
+ if (u_tile_reduction[3] > 0.0) {
+ ab3 = texture_antitile(
+ u_ground_albedo_bump_3, u_ground_normal_roughness_3, v_ground_uv3.xz, nr3);
+ } else {
+ ab3 = texture(u_ground_albedo_bump_3, v_ground_uv3.xz);
+ nr3 = texture(u_ground_normal_roughness_3, v_ground_uv3.xz);
+ }
+ }
+
+ if (u_tile_reduction[0] > 0.0) {
+ ab0 = texture_antitile(
+ u_ground_albedo_bump_0, u_ground_normal_roughness_0, v_ground_uv0, nr0);
+ } else {
+ ab0 = texture(u_ground_albedo_bump_0, v_ground_uv0);
+ nr0 = texture(u_ground_normal_roughness_0, v_ground_uv0);
+ }
+ if (u_tile_reduction[1] > 0.0) {
+ ab1 = texture_antitile(
+ u_ground_albedo_bump_1, u_ground_normal_roughness_1, v_ground_uv1, nr1);
+ } else {
+ ab1 = texture(u_ground_albedo_bump_1, v_ground_uv1);
+ nr1 = texture(u_ground_normal_roughness_1, v_ground_uv1);
+ }
+ if (u_tile_reduction[2] > 0.0) {
+ ab2 = texture_antitile(
+ u_ground_albedo_bump_2, u_ground_normal_roughness_2, v_ground_uv2, nr2);
+ } else {
+ ab2 = texture(u_ground_albedo_bump_2, v_ground_uv2);
+ nr2 = texture(u_ground_normal_roughness_2, v_ground_uv2);
+ }
+
+ vec3 col0 = ab0.rgb * v_tint0;
+ vec3 col1 = ab1.rgb * v_tint1;
+ vec3 col2 = ab2.rgb * v_tint2;
+ vec3 col3 = ab3.rgb * v_tint3;
+
+ vec4 rough = vec4(nr0.a, nr1.a, nr2.a, nr3.a);
+
+ vec3 normal0 = unpack_normal(nr0);
+ vec3 normal1 = unpack_normal(nr1);
+ vec3 normal2 = unpack_normal(nr2);
+ vec3 normal3 = unpack_normal(nr3);
+
+ vec4 w;
+ // TODO An #ifdef macro would be nice! Or copy/paste everything in a different shader...
+ if (u_depth_blending) {
+ w = get_depth_blended_weights(v_splat, vec4(ab0.a, ab1.a, ab2.a, ab3.a));
+ } else {
+ w = v_splat.rgba;
+ }
+
+ float w_sum = (w.r + w.g + w.b + w.a);
+
+ ALBEDO = (
+ w.r * col0.rgb +
+ w.g * col1.rgb +
+ w.b * col2.rgb +
+ w.a * col3.rgb) / w_sum;
+
+ ROUGHNESS = (
+ w.r * rough.r +
+ w.g * rough.g +
+ w.b * rough.b +
+ w.a * rough.a) / w_sum;
+
+ vec3 ground_normal = /*u_terrain_normal_basis **/ (
+ w.r * normal0 +
+ w.g * normal1 +
+ w.b * normal2 +
+ w.a * normal3) / w_sum;
+ // If no splat textures are defined, normal vectors will default to (1,1,1),
+ // which is incorrect, and causes the terrain to be shaded wrongly in some directions.
+ // However, this should not be a problem to fix in the shader,
+ // because there MUST be at least one splat texture set.
+ //ground_normal = normalize(ground_normal);
+ // TODO Make the plugin insert a default normalmap if it's empty
+
+ // Combine terrain normals with detail normals (not sure if correct but looks ok)
+ normal = normalize(vec3(
+ terrain_normal_world.x + ground_normal.x,
+ terrain_normal_world.y,
+ terrain_normal_world.z + ground_normal.z));
+
+ normal = mix(normal, terrain_normal_world, globalmap_factor);
+
+ ALBEDO = mix(ALBEDO, global_albedo, globalmap_factor);
+ ROUGHNESS = mix(ROUGHNESS, 1.0, globalmap_factor);
+
+ // Show splatmap weights
+ //ALBEDO = w.rgb;
+ }
+ // Highlight all pixels undergoing no splatmap at all
+// else {
+// ALBEDO = vec3(1.0, 0.0, 0.0);
+// }
+
+ NORMAL = (VIEW_MATRIX * (vec4(normal, 0.0))).xyz;
+}
diff --git a/game/addons/zylann.hterrain/shaders/simple4_global.gdshader b/game/addons/zylann.hterrain/shaders/simple4_global.gdshader
new file mode 100644
index 0000000..f504c48
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/simple4_global.gdshader
@@ -0,0 +1,83 @@
+shader_type spatial;
+
+// This shader is used to bake the global albedo map.
+// It exposes a subset of the main shader API, so uniform names were not modified.
+
+// I had to remove `hint_albedo` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;// : hint_albedo;
+uniform sampler2D u_terrain_splatmap;
+
+uniform sampler2D u_ground_albedo_bump_0 : source_color;
+uniform sampler2D u_ground_albedo_bump_1 : source_color;
+uniform sampler2D u_ground_albedo_bump_2 : source_color;
+uniform sampler2D u_ground_albedo_bump_3 : source_color;
+
+// Keep depth blending because it has a high effect on the final result
+uniform bool u_depth_blending = true;
+uniform float u_ground_uv_scale = 20.0;
+
+
+vec4 get_depth_blended_weights(vec4 splat, vec4 bumps) {
+ float dh = 0.2;
+
+ vec4 h = bumps + splat;
+
+ h *= smoothstep(0, 0.05, splat);
+
+ vec4 d = h + dh;
+ d.r -= max(h.g, max(h.b, h.a));
+ d.g -= max(h.r, max(h.b, h.a));
+ d.b -= max(h.g, max(h.r, h.a));
+ d.a -= max(h.g, max(h.b, h.r));
+
+ return clamp(d, 0, 1);
+}
+
+void vertex() {
+ vec4 wpos = MODEL_MATRIX * vec4(VERTEX, 1);
+ vec2 cell_coords = wpos.xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results (#183)
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = (cell_coords / vec2(textureSize(u_terrain_splatmap, 0)));
+}
+
+void fragment() {
+ // These were moved from vertex to fragment,
+ // so we can generate part of the global map with just one quad and we get full quality
+ vec4 tint = texture(u_terrain_colormap, UV);
+ vec4 splat = texture(u_terrain_splatmap, UV);
+
+ // Get bump at normal resolution so depth blending is accurate
+ vec2 ground_uv = UV / u_ground_uv_scale;
+ float b0 = texture(u_ground_albedo_bump_0, ground_uv).a;
+ float b1 = texture(u_ground_albedo_bump_1, ground_uv).a;
+ float b2 = texture(u_ground_albedo_bump_2, ground_uv).a;
+ float b3 = texture(u_ground_albedo_bump_3, ground_uv).a;
+
+ // Take the center of the highest mip as color, because we can't see details from far away.
+ vec2 ndc_center = vec2(0.5, 0.5);
+ vec3 col0 = textureLod(u_ground_albedo_bump_0, ndc_center, 10.0).rgb;
+ vec3 col1 = textureLod(u_ground_albedo_bump_1, ndc_center, 10.0).rgb;
+ vec3 col2 = textureLod(u_ground_albedo_bump_2, ndc_center, 10.0).rgb;
+ vec3 col3 = textureLod(u_ground_albedo_bump_3, ndc_center, 10.0).rgb;
+
+ vec4 w;
+ if (u_depth_blending) {
+ w = get_depth_blended_weights(splat, vec4(b0, b1, b2, b3));
+ } else {
+ w = splat.rgba;
+ }
+
+ float w_sum = (w.r + w.g + w.b + w.a);
+
+ ALBEDO = tint.rgb * (
+ w.r * col0 +
+ w.g * col1 +
+ w.b * col2 +
+ w.a * col3) / w_sum;
+}
+
diff --git a/game/addons/zylann.hterrain/shaders/simple4_lite.gdshader b/game/addons/zylann.hterrain/shaders/simple4_lite.gdshader
new file mode 100644
index 0000000..dcd660c
--- /dev/null
+++ b/game/addons/zylann.hterrain/shaders/simple4_lite.gdshader
@@ -0,0 +1,211 @@
+shader_type spatial;
+
+// This is a shader with less textures, in case the main one doesn't run on your GPU.
+// It's mostly a big copy/paste, because Godot doesn't support #include or #ifdef...
+
+#include "include/heightmap.gdshaderinc"
+
+uniform sampler2D u_terrain_heightmap;
+uniform sampler2D u_terrain_normalmap;
+// I had to remove `hint_albedo` from colormap in Godot 3 because it makes sRGB conversion kick in,
+// which snowballs to black when doing GPU painting on that texture...
+uniform sampler2D u_terrain_colormap;// : hint_albedo;
+uniform sampler2D u_terrain_splatmap;
+uniform mat4 u_terrain_inverse_transform;
+uniform mat3 u_terrain_normal_basis;
+
+uniform sampler2D u_ground_albedo_bump_0 : source_color;
+uniform sampler2D u_ground_albedo_bump_1 : source_color;
+uniform sampler2D u_ground_albedo_bump_2 : source_color;
+uniform sampler2D u_ground_albedo_bump_3 : source_color;
+
+uniform float u_ground_uv_scale = 20.0;
+uniform bool u_depth_blending = true;
+uniform bool u_triplanar = false;
+// Each component corresponds to a ground texture. Set greater than zero to enable.
+uniform vec4 u_tile_reduction = vec4(0.0, 0.0, 0.0, 0.0);
+
+varying vec4 v_tint;
+varying vec4 v_splat;
+varying vec3 v_ground_uv;
+
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+// Blends weights according to the bump of detail textures,
+// so for example it allows to have sand fill the gaps between pebbles
+vec4 get_depth_blended_weights(vec4 splat, vec4 bumps) {
+ float dh = 0.2;
+
+ vec4 h = bumps + splat;
+
+ // TODO Keep improving multilayer blending, there are still some edge cases...
+ // Mitigation: nullify layers with near-zero splat
+ h *= smoothstep(0, 0.05, splat);
+
+ vec4 d = h + dh;
+ d.r -= max(h.g, max(h.b, h.a));
+ d.g -= max(h.r, max(h.b, h.a));
+ d.b -= max(h.g, max(h.r, h.a));
+ d.a -= max(h.g, max(h.b, h.r));
+
+ return clamp(d, 0, 1);
+}
+
+vec3 get_triplanar_blend(vec3 world_normal) {
+ vec3 blending = abs(world_normal);
+ blending = normalize(max(blending, vec3(0.00001))); // Force weights to sum to 1.0
+ float b = blending.x + blending.y + blending.z;
+ return blending / vec3(b, b, b);
+}
+
+vec4 texture_triplanar(sampler2D tex, vec3 world_pos, vec3 blend) {
+ vec4 xaxis = texture(tex, world_pos.yz);
+ vec4 yaxis = texture(tex, world_pos.xz);
+ vec4 zaxis = texture(tex, world_pos.xy);
+ // blend the results of the 3 planar projections.
+ return xaxis * blend.x + yaxis * blend.y + zaxis * blend.z;
+}
+
+vec4 depth_blend2(vec4 a, vec4 b, float t) {
+ // https://www.gamasutra.com
+ // /blogs/AndreyMishkinis/20130716/196339/Advanced_Terrain_Texture_Splatting.php
+ float d = 0.1;
+ float ma = max(a.a + (1.0 - t), b.a + t) - d;
+ float ba = max(a.a + (1.0 - t) - ma, 0.0);
+ float bb = max(b.a + t - ma, 0.0);
+ return (a * ba + b * bb) / (ba + bb);
+}
+
+vec4 texture_antitile(sampler2D tex, vec2 uv) {
+ float frequency = 2.0;
+ float scale = 1.3;
+ float sharpness = 0.7;
+
+ // Rotate and scale UV
+ float rot = 3.14 * 0.6;
+ float cosa = cos(rot);
+ float sina = sin(rot);
+ vec2 uv2 = vec2(cosa * uv.x - sina * uv.y, sina * uv.x + cosa * uv.y) * scale;
+
+ vec4 col0 = texture(tex, uv);
+ vec4 col1 = texture(tex, uv2);
+ //col0 = vec4(0.0, 0.0, 1.0, 1.0);
+ // Periodically alternate between the two versions using a warped checker pattern
+ float t = 0.5 + 0.5
+ * sin(uv2.x * frequency + sin(uv.x) * 2.0)
+ * cos(uv2.y * frequency + sin(uv.y) * 2.0);
+ // Using depth blend because classic alpha blending smoothes out details
+ return depth_blend2(col0, col1, smoothstep(0.5 * sharpness, 1.0 - 0.5 * sharpness, t));
+}
+
+void vertex() {
+ vec2 cell_coords = (u_terrain_inverse_transform * MODEL_MATRIX * vec4(VERTEX, 1)).xz;
+ // Must add a half-offset so that we sample the center of pixels,
+ // otherwise bilinear filtering of the textures will give us mixed results.
+ cell_coords += vec2(0.5);
+
+ // Normalized UV
+ UV = cell_coords / vec2(textureSize(u_terrain_heightmap, 0));
+
+ // Height displacement
+ float h = sample_heightmap(u_terrain_heightmap, UV);
+ VERTEX.y = h;
+
+ v_ground_uv = vec3(cell_coords.x, h * MODEL_MATRIX[1][1], cell_coords.y) / u_ground_uv_scale;
+
+ // Putting this in vertex saves 2 fetches from the fragment shader,
+ // which is good for performance at a negligible quality cost,
+ // provided that geometry is a regular grid that decimates with LOD.
+ // (downside is LOD will also decimate tint and splat, but it's not bad overall)
+ v_tint = texture(u_terrain_colormap, UV);
+ v_splat = texture(u_terrain_splatmap, UV);
+
+ // Need to use u_terrain_normal_basis to handle scaling.
+ NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+}
+
+void fragment() {
+ if (v_tint.a < 0.5) {
+ // TODO Add option to use vertex discarding instead, using NaNs
+ discard;
+ }
+
+ vec3 terrain_normal_world =
+ u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV));
+ terrain_normal_world = normalize(terrain_normal_world);
+
+ // TODO Detail should only be rasterized on nearby chunks (needs proximity management to switch shaders)
+
+ vec2 ground_uv = v_ground_uv.xz;
+
+ vec4 ab0, ab1, ab2, ab3;
+ if (u_triplanar) {
+ // Only do triplanar on one texture slot,
+ // because otherwise it would be very expensive and cost many more ifs.
+ // I chose the last slot because first slot is the default on new splatmaps,
+ // and that's a feature used for cliffs, which are usually designed later.
+
+ vec3 blending = get_triplanar_blend(terrain_normal_world);
+
+ ab3 = texture_triplanar(u_ground_albedo_bump_3, v_ground_uv, blending);
+
+ } else {
+ if (u_tile_reduction[3] > 0.0) {
+ ab3 = texture(u_ground_albedo_bump_3, ground_uv);
+ } else {
+ ab3 = texture_antitile(u_ground_albedo_bump_3, ground_uv);
+ }
+ }
+
+ if (u_tile_reduction[0] > 0.0) {
+ ab0 = texture_antitile(u_ground_albedo_bump_0, ground_uv);
+ } else {
+ ab0 = texture(u_ground_albedo_bump_0, ground_uv);
+ }
+ if (u_tile_reduction[1] > 0.0) {
+ ab1 = texture_antitile(u_ground_albedo_bump_1, ground_uv);
+ } else {
+ ab1 = texture(u_ground_albedo_bump_1, ground_uv);
+ }
+ if (u_tile_reduction[2] > 0.0) {
+ ab2 = texture_antitile(u_ground_albedo_bump_2, ground_uv);
+ } else {
+ ab2 = texture(u_ground_albedo_bump_2, ground_uv);
+ }
+
+ vec3 col0 = ab0.rgb;
+ vec3 col1 = ab1.rgb;
+ vec3 col2 = ab2.rgb;
+ vec3 col3 = ab3.rgb;
+
+ vec4 w;
+ // TODO An #ifdef macro would be nice! Or copy/paste everything in a different shader...
+ if (u_depth_blending) {
+ w = get_depth_blended_weights(v_splat, vec4(ab0.a, ab1.a, ab2.a, ab3.a));
+ } else {
+ w = v_splat.rgba;
+ }
+
+ float w_sum = (w.r + w.g + w.b + w.a);
+
+ ALBEDO = v_tint.rgb * (
+ w.r * col0.rgb +
+ w.g * col1.rgb +
+ w.b * col2.rgb +
+ w.a * col3.rgb) / w_sum;
+
+ ROUGHNESS = 1.0;
+
+ NORMAL = (VIEW_MATRIX * (vec4(terrain_normal_world, 0.0))).xyz;
+
+ //ALBEDO = w.rgb;
+ //ALBEDO = v_ground_uv.xyz;
+}
+
diff --git a/game/addons/zylann.hterrain/tools/about/about_dialog.gd b/game/addons/zylann.hterrain/tools/about/about_dialog.gd
new file mode 100644
index 0000000..20ec8f8
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/about/about_dialog.gd
@@ -0,0 +1,28 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../util/util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Errors = preload("../../util/errors.gd")
+
+const PLUGIN_CFG_PATH = "res://addons/zylann.hterrain/plugin.cfg"
+
+
+@onready var _about_rich_text_label : RichTextLabel = $VB/HB2/TC/About
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ var plugin_cfg = ConfigFile.new()
+ var err := plugin_cfg.load(PLUGIN_CFG_PATH)
+ if err != OK:
+ _logger.error("Could not load {0}: {1}" \
+ .format([PLUGIN_CFG_PATH, HT_Errors.get_message(err)]))
+ return
+ var version = plugin_cfg.get_value("plugin", "version", "--.--.--")
+
+ _about_rich_text_label.text = _about_rich_text_label.text.format({"version": version})
diff --git a/game/addons/zylann.hterrain/tools/about/about_dialog.tscn b/game/addons/zylann.hterrain/tools/about/about_dialog.tscn
new file mode 100644
index 0000000..efeda7a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/about/about_dialog.tscn
@@ -0,0 +1,85 @@
+[gd_scene load_steps=4 format=3 uid="uid://cvuubd08805oa"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/about/about_dialog.gd" id="1"]
+[ext_resource type="Texture2D" uid="uid://sdaddk8wxjin" path="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg" id="2"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd" id="3"]
+
+[node name="AboutDialog" type="AcceptDialog"]
+size = Vector2i(516, 357)
+script = ExtResource("1")
+
+[node name="VB" type="VBoxContainer" parent="."]
+custom_minimum_size = Vector2(500, 300)
+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="HB2" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="TextureRect" type="TextureRect" parent="VB/HB2"]
+layout_mode = 2
+texture = ExtResource("2")
+stretch_mode = 2
+
+[node name="TC" type="TabContainer" parent="VB/HB2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="About" type="RichTextLabel" parent="VB/HB2/TC"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+bbcode_enabled = true
+text = "Version: {version}
+Author: Marc Gilleron
+Repository: https://github.com/Zylann/godot_heightmap_plugin
+Issue tracker: https://github.com/Zylann/godot_heightmap_plugin/issues
+
+Gold supporters:
+
+Aaron Franke (aaronfranke)
+
+Silver supporters:
+
+TheConceptBoy
+Chris Bolton (yochrisbolton)
+Gamerfiend (Snowminx)
+greenlion (Justin Swanhart)
+segfault-god (jp.owo.Manda)
+RonanZe
+Phyronnaz
+NoFr1ends (Lynx)
+
+Bronze supporters:
+
+rcorre (Ryan Roden-Corrent)
+duchainer (RaphaĂŤl DuchaĂŽne)
+MadMartian
+stackdump (stackdump.eth)
+Treer
+MrGreaterThan
+lenis0012
+"
+script = ExtResource("3")
+
+[node name="License" type="RichTextLabel" parent="VB/HB2/TC"]
+visible = false
+layout_mode = 2
+text = "Copyright (c) 2016-2023 Marc Gilleron
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+"
+
+[node name="HB" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+alignment = 1
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)
diff --git a/game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader b/game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader
new file mode 100644
index 0000000..b0c97da
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/bump2normal_tex.gdshader
@@ -0,0 +1,25 @@
+shader_type canvas_item;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+vec4 pack_normal(vec3 n) {
+ return vec4((0.5 * (n + 1.0)).xzy, 1.0);
+}
+
+float get_height(sampler2D tex, vec2 uv) {
+ return sample_heightmap(tex, uv);
+}
+
+void fragment() {
+ vec2 uv = UV;
+ vec2 ps = TEXTURE_PIXEL_SIZE;
+ float left = get_height(TEXTURE, uv + vec2(-ps.x, 0));
+ float right = get_height(TEXTURE, uv + vec2(ps.x, 0));
+ float back = get_height(TEXTURE, uv + vec2(0, -ps.y));
+ float fore = get_height(TEXTURE, uv + vec2(0, ps.y));
+ vec3 n = normalize(vec3(left - right, 2.0, fore - back));
+ COLOR = pack_normal(n);
+ // DEBUG
+ //COLOR.r = fract(TIME * 100.0);
+}
+
diff --git a/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd
new file mode 100644
index 0000000..cb3288e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd
@@ -0,0 +1,200 @@
+@tool
+extends Control
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HTerrainDetailLayer = preload("../../hterrain_detail_layer.gd")
+const HT_ImageFileCache = preload("../../util/image_file_cache.gd")
+const HT_EditorUtil = preload("../util/editor_util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+const PLACEHOLDER_ICON_TEXTURE = "res://addons/zylann.hterrain/tools/icons/icon_grass.svg"
+const DETAIL_LAYER_ICON_TEXTURE = \
+ "res://addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg"
+
+signal detail_selected(index)
+# Emitted when the tool added or removed a detail map
+signal detail_list_changed
+
+@onready var _item_list : ItemList = $ItemList
+@onready var _confirmation_dialog : ConfirmationDialog = $ConfirmationDialog
+
+var _terrain : HTerrain = null
+var _dialog_target := -1
+var _undo_redo_manager : EditorUndoRedoManager
+var _image_cache : HT_ImageFileCache
+var _logger = HT_Logger.get_for(self)
+
+
+func set_terrain(terrain):
+ if _terrain == terrain:
+ return
+ _terrain = terrain
+ _update_list()
+
+
+func set_undo_redo(ur: EditorUndoRedoManager):
+ assert(ur != null)
+ _undo_redo_manager = ur
+
+
+func set_image_cache(image_cache: HT_ImageFileCache):
+ _image_cache = image_cache
+
+
+func set_layer_index(i: int):
+ _item_list.select(i, true)
+
+
+func _update_list():
+ _item_list.clear()
+
+ if _terrain == null:
+ return
+
+ var layer_nodes = _terrain.get_detail_layers()
+ var layer_nodes_by_index := {}
+ for layer in layer_nodes:
+ if not layer_nodes_by_index.has(layer.layer_index):
+ layer_nodes_by_index[layer.layer_index] = []
+ layer_nodes_by_index[layer.layer_index].append(layer.name)
+
+ var data = _terrain.get_data()
+ if data != null:
+ # Display layers from what terrain data actually contains,
+ # because layer nodes are just what makes them rendered and aren't much restricted.
+ var layer_count = data.get_map_count(HTerrainData.CHANNEL_DETAIL)
+ var placeholder_icon = HT_EditorUtil.load_texture(PLACEHOLDER_ICON_TEXTURE, _logger)
+
+ for i in layer_count:
+ # TODO Show a preview icon
+ _item_list.add_item(str("Map ", i), placeholder_icon)
+
+ if layer_nodes_by_index.has(i):
+ # TODO How to keep names updated with node names?
+ var names := ", ".join(PackedStringArray(layer_nodes_by_index[i]))
+ if len(names) == 1:
+ _item_list.set_item_tooltip(i, "Used by " + names)
+ else:
+ _item_list.set_item_tooltip(i, "Used by " + names)
+ # Remove custom color
+ # TODO Use fg version when available in Godot 3.1, I want to only highlight text
+ _item_list.set_item_custom_bg_color(i, Color(0, 0, 0, 0))
+ else:
+ # TODO Use fg version when available in Godot 3.1, I want to only highlight text
+ _item_list.set_item_custom_bg_color(i, Color(1.0, 0.2, 0.2, 0.3))
+ _item_list.set_item_tooltip(i, "This map isn't used by any layer. " \
+ + "Add a HTerrainDetailLayer node as child of the terrain.")
+
+
+func _on_Add_pressed():
+ _add_layer()
+
+
+func _on_Remove_pressed():
+ var selected = _item_list.get_selected_items()
+ if len(selected) == 0:
+ return
+ _dialog_target = _item_list.get_selected_items()[0]
+ _confirmation_dialog.title = "Removing detail map {0}".format([_dialog_target])
+ _confirmation_dialog.popup_centered()
+
+
+func _on_ConfirmationDialog_confirmed():
+ _remove_layer(_dialog_target)
+
+
+func _add_layer():
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ assert(_undo_redo_manager != null)
+ var terrain_data : HTerrainData = _terrain.get_data()
+
+ # First, create node and map image
+ var node := HTerrainDetailLayer.new()
+ # TODO Workarounds for https://github.com/godotengine/godot/issues/21410
+ var detail_layer_icon := HT_EditorUtil.load_texture(DETAIL_LAYER_ICON_TEXTURE, _logger)
+ node.set_meta("_editor_icon", detail_layer_icon)
+ node.name = "HTerrainDetailLayer"
+ var map_index := terrain_data._edit_add_map(HTerrainData.CHANNEL_DETAIL)
+ var map_image := terrain_data.get_image(HTerrainData.CHANNEL_DETAIL)
+ var map_image_cache_id := _image_cache.save_image(map_image)
+ node.layer_index = map_index
+
+ var undo_redo := _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_terrain))
+
+ # Then, create an action
+ undo_redo.create_action("Add Detail Layer {0}".format([map_index]))
+
+ undo_redo.add_do_method(terrain_data._edit_insert_map_from_image_cache.bind(
+ HTerrainData.CHANNEL_DETAIL, map_index, _image_cache, map_image_cache_id))
+ undo_redo.add_do_method(_terrain.add_child.bind(node))
+ undo_redo.add_do_property(node, "owner", get_tree().edited_scene_root)
+ undo_redo.add_do_method(self._update_list)
+ undo_redo.add_do_reference(node)
+
+ undo_redo.add_undo_method(_terrain.remove_child.bind(node))
+ undo_redo.add_undo_method(
+ terrain_data._edit_remove_map.bind(HTerrainData.CHANNEL_DETAIL, map_index))
+ undo_redo.add_undo_method(self._update_list)
+
+ # Yet another instance of this hack, to prevent UndoRedo from running some of the functions,
+ # which we had to run already
+ terrain_data._edit_set_disable_apply_undo(true)
+ undo_redo.commit_action()
+ terrain_data._edit_set_disable_apply_undo(false)
+
+ #_update_list()
+ detail_list_changed.emit()
+
+ var index := node.layer_index
+ _item_list.select(index)
+ # select() doesn't trigger the signal
+ detail_selected.emit(index)
+
+
+func _remove_layer(map_index: int):
+ var terrain_data : HTerrainData = _terrain.get_data()
+
+ # First, cache image data
+ var image := terrain_data.get_image(HTerrainData.CHANNEL_DETAIL, map_index)
+ var image_id := _image_cache.save_image(image)
+ var nodes = _terrain.get_detail_layers()
+ var using_nodes := []
+ # Nodes using this map will be removed from the tree
+ for node in nodes:
+ if node.layer_index == map_index:
+ using_nodes.append(node)
+
+ var undo_redo := _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_terrain))
+
+ undo_redo.create_action("Remove Detail Layer {0}".format([map_index]))
+
+ undo_redo.add_do_method(
+ terrain_data._edit_remove_map.bind(HTerrainData.CHANNEL_DETAIL, map_index))
+ for node in using_nodes:
+ undo_redo.add_do_method(_terrain.remove_child.bind(node))
+ undo_redo.add_do_method(self._update_list)
+
+ undo_redo.add_undo_method(terrain_data._edit_insert_map_from_image_cache.bind(
+ HTerrainData.CHANNEL_DETAIL, map_index, _image_cache, image_id))
+ for node in using_nodes:
+ undo_redo.add_undo_method(_terrain.add_child.bind(node))
+ undo_redo.add_undo_property(node, "owner", get_tree().edited_scene_root)
+ undo_redo.add_undo_reference(node)
+ undo_redo.add_undo_method(self._update_list)
+
+ undo_redo.commit_action()
+
+ #_update_list()
+ detail_list_changed.emit()
+
+
+func _on_ItemList_item_selected(index):
+ detail_selected.emit(index)
+
+
+
diff --git a/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn
new file mode 100644
index 0000000..cc5b995
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn
@@ -0,0 +1,48 @@
+[gd_scene load_steps=2 format=3 uid="uid://do3c3jse5p7hx"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/detail_editor/detail_editor.gd" id="1"]
+
+[node name="DetailEditor" type="Control"]
+custom_minimum_size = Vector2(200, 0)
+layout_mode = 3
+anchors_preset = 0
+offset_right = 189.0
+offset_bottom = 109.0
+script = ExtResource("1")
+
+[node name="ItemList" type="ItemList" parent="."]
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_bottom = -26.0
+max_columns = 0
+same_column_width = true
+icon_mode = 0
+fixed_icon_size = Vector2i(32, 32)
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+layout_mode = 0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -24.0
+
+[node name="Add" type="Button" parent="HBoxContainer"]
+layout_mode = 2
+text = "Add"
+
+[node name="Remove" type="Button" parent="HBoxContainer"]
+layout_mode = 2
+text = "Remove"
+
+[node name="Label" type="Label" parent="HBoxContainer"]
+layout_mode = 2
+text = "Details"
+
+[node name="ConfirmationDialog" type="ConfirmationDialog" parent="."]
+dialog_text = "Are you sure you want to remove this detail map?"
+
+[connection signal="item_selected" from="ItemList" to="." method="_on_ItemList_item_selected"]
+[connection signal="pressed" from="HBoxContainer/Add" to="." method="_on_Add_pressed"]
+[connection signal="pressed" from="HBoxContainer/Remove" to="." method="_on_Remove_pressed"]
+[connection signal="confirmed" from="ConfirmationDialog" to="." method="_on_ConfirmationDialog_confirmed"]
diff --git a/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd
new file mode 100644
index 0000000..b989099
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd
@@ -0,0 +1,221 @@
+@tool
+extends AcceptDialog
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Util = preload("../../util/util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+
+const FORMAT_RH = 0
+const FORMAT_RF = 1
+const FORMAT_R16 = 2
+const FORMAT_PNG8 = 3
+const FORMAT_EXRH = 4
+const FORMAT_EXRF = 5
+const FORMAT_COUNT = 6
+
+@onready var _output_path_line_edit := $VB/Grid/OutputPath/HeightmapPathLineEdit as LineEdit
+@onready var _format_selector := $VB/Grid/FormatSelector as OptionButton
+@onready var _height_range_min_spinbox := $VB/Grid/HeightRange/HeightRangeMin as SpinBox
+@onready var _height_range_max_spinbox := $VB/Grid/HeightRange/HeightRangeMax as SpinBox
+@onready var _export_button := $VB/Buttons/ExportButton as Button
+@onready var _show_in_explorer_checkbox := $VB/ShowInExplorerCheckbox as CheckBox
+
+var _terrain : HTerrain = null
+var _file_dialog : EditorFileDialog = null
+var _format_names := []
+var _format_extensions := []
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ # Godot 4 decided to not have a plain WindowDialog class...
+ # there is Window but it's way too unfriendly...
+ get_ok_button().hide()
+
+
+func _ready():
+ _format_names.resize(FORMAT_COUNT)
+ _format_extensions.resize(FORMAT_COUNT)
+
+ _format_names[FORMAT_RH] = "16-bit RAW float"
+ _format_names[FORMAT_RF] = "32-bit RAW float"
+ _format_names[FORMAT_R16] = "16-bit RAW unsigned"
+ _format_names[FORMAT_PNG8] = "8-bit PNG greyscale"
+ _format_names[FORMAT_EXRH] = "16-bit float greyscale EXR"
+ _format_names[FORMAT_EXRF] = "32-bit float greyscale EXR"
+
+ _format_extensions[FORMAT_RH] = "raw"
+ _format_extensions[FORMAT_RF] = "raw"
+ _format_extensions[FORMAT_R16] = "raw"
+ _format_extensions[FORMAT_PNG8] = "png"
+ _format_extensions[FORMAT_EXRH] = "exr"
+ _format_extensions[FORMAT_EXRF] = "exr"
+
+ if not HT_Util.is_in_edited_scene(self):
+ for i in len(_format_names):
+ _format_selector.get_popup().add_item(_format_names[i], i)
+
+
+func setup_dialogs(base_control: Control):
+ assert(_file_dialog == null)
+ var fd := EditorFileDialog.new()
+ fd.file_mode = EditorFileDialog.FILE_MODE_SAVE_FILE
+ fd.unresizable = false
+ fd.access = EditorFileDialog.ACCESS_FILESYSTEM
+ fd.file_selected.connect(_on_FileDialog_file_selected)
+ add_child(fd)
+ _file_dialog = fd
+
+ _update_file_extension()
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+func _exit_tree():
+ if _file_dialog != null:
+ _file_dialog.queue_free()
+ _file_dialog = null
+
+
+func _on_FileDialog_file_selected(fpath: String):
+ _output_path_line_edit.text = fpath
+
+
+func _auto_adjust_height_range():
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ var aabb := _terrain.get_data().get_aabb()
+ _height_range_min_spinbox.value = aabb.position.y
+ _height_range_max_spinbox.value = aabb.position.y + aabb.size.y
+
+
+func _export() -> bool:
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ var src_heightmap: Image = _terrain.get_data().get_image(HTerrainData.CHANNEL_HEIGHT)
+ var fpath := _output_path_line_edit.text.strip_edges()
+
+ # TODO Is `selected` an ID or an index? I need an ID, it works by chance for now.
+ var format := _format_selector.selected
+
+ var height_min := _height_range_min_spinbox.value
+ var height_max := _height_range_max_spinbox.value
+
+ if height_min == height_max:
+ _logger.error("Cannot export, height range is zero")
+ return false
+
+ if height_min > height_max:
+ _logger.error("Cannot export, height min is greater than max")
+ return false
+
+ var save_error := OK
+
+ var float_heightmap := HTerrainData.convert_heightmap_to_float(src_heightmap, _logger)
+
+ if format == FORMAT_PNG8:
+ var hscale := 1.0 / (height_max - height_min)
+ var im := Image.create(
+ src_heightmap.get_width(), src_heightmap.get_height(), false, Image.FORMAT_R8)
+
+ for y in src_heightmap.get_height():
+ for x in src_heightmap.get_width():
+ var h := clampf((float_heightmap.get_pixel(x, y).r - height_min) * hscale, 0.0, 1.0)
+ im.set_pixel(x, y, Color(h, h, h))
+
+ save_error = im.save_png(fpath)
+
+ elif format == FORMAT_EXRH:
+ float_heightmap.convert(Image.FORMAT_RH)
+ save_error = float_heightmap.save_exr(fpath, true)
+
+ elif format == FORMAT_EXRF:
+ save_error = float_heightmap.save_exr(fpath, true)
+
+ else: # RAW
+ var f := FileAccess.open(fpath, FileAccess.WRITE)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ _print_file_error(fpath, err)
+ return false
+
+ if format == FORMAT_RH:
+ float_heightmap.convert(Image.FORMAT_RH)
+ f.store_buffer(float_heightmap.get_data())
+
+ elif format == FORMAT_RF:
+ f.store_buffer(float_heightmap.get_data())
+
+ elif format == FORMAT_R16:
+ var hscale := 65535.0 / (height_max - height_min)
+ for y in float_heightmap.get_height():
+ for x in float_heightmap.get_width():
+ var h := int((float_heightmap.get_pixel(x, y).r - height_min) * hscale)
+ if h < 0:
+ h = 0
+ elif h > 65535:
+ h = 65535
+ if x % 50 == 0:
+ _logger.debug(str(h))
+ f.store_16(h)
+
+ if save_error == OK:
+ _logger.debug("Exported heightmap as \"{0}\"".format([fpath]))
+ return true
+ else:
+ _print_file_error(fpath, save_error)
+ return false
+
+
+func _update_file_extension():
+ if _format_selector.selected == -1:
+ _format_selector.selected = 0
+ # This recursively calls the current function
+ return
+
+ # TODO Is `selected` an ID or an index? I need an ID, it works by chance for now.
+ var format = _format_selector.selected
+
+ var ext : String = _format_extensions[format]
+ _file_dialog.clear_filters()
+ _file_dialog.add_filter(str("*.", ext, " ; ", ext.to_upper(), " files"))
+
+ var fpath := _output_path_line_edit.text.strip_edges()
+ if fpath != "":
+ _output_path_line_edit.text = str(fpath.get_basename(), ".", ext)
+
+
+func _print_file_error(fpath: String, err: int):
+ _logger.error("Could not save path {0}, error: {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_ExportButton_pressed():
+ if _export():
+ hide()
+ if _show_in_explorer_checkbox.button_pressed:
+ OS.shell_open(_output_path_line_edit.text.strip_edges().get_base_dir())
+
+
+func _on_HeightmapPathLineEdit_text_changed(new_text: String):
+ _export_button.disabled = (new_text.strip_edges() == "")
+
+
+func _on_HeightmapPathBrowseButton_pressed():
+ _file_dialog.popup_centered_ratio()
+
+
+func _on_FormatSelector_item_selected(id):
+ _update_file_extension()
+
+
+func _on_HeightRangeAutoButton_pressed():
+ _auto_adjust_height_range()
diff --git a/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn
new file mode 100644
index 0000000..a702181
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn
@@ -0,0 +1,125 @@
+[gd_scene load_steps=3 format=3 uid="uid://bcocysgmum5ag"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/exporter/export_image_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="2"]
+
+[node name="ExportImageDialog" type="AcceptDialog"]
+size = Vector2i(500, 340)
+min_size = Vector2i(500, 250)
+script = ExtResource("1")
+
+[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 = -18.0
+
+[node name="Grid" type="GridContainer" parent="VB"]
+layout_mode = 2
+columns = 2
+
+[node name="OutputPathLabel" type="Label" parent="VB/Grid"]
+layout_mode = 2
+text = "Output path:"
+
+[node name="OutputPath" type="HBoxContainer" parent="VB/Grid"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="HeightmapPathLineEdit" type="LineEdit" parent="VB/Grid/OutputPath"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="HeightmapPathBrowseButton" type="Button" parent="VB/Grid/OutputPath"]
+layout_mode = 2
+text = "..."
+
+[node name="FormatLabel" type="Label" parent="VB/Grid"]
+layout_mode = 2
+text = "Format:"
+
+[node name="FormatSelector" type="OptionButton" parent="VB/Grid"]
+layout_mode = 2
+
+[node name="HeightRangeLabel" type="Label" parent="VB/Grid"]
+layout_mode = 2
+text = "Height range:"
+
+[node name="HeightRange" type="HBoxContainer" parent="VB/Grid"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/Grid/HeightRange"]
+layout_mode = 2
+text = "Min"
+
+[node name="HeightRangeMin" type="SpinBox" parent="VB/Grid/HeightRange"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+min_value = -10000.0
+max_value = 10000.0
+step = 0.0
+value = -2000.0
+
+[node name="Label2" type="Label" parent="VB/Grid/HeightRange"]
+layout_mode = 2
+text = "Max"
+
+[node name="HeightRangeMax" type="SpinBox" parent="VB/Grid/HeightRange"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+min_value = -10000.0
+max_value = 10000.0
+step = 0.0
+value = 2000.0
+
+[node name="HeightRangeAutoButton" type="Button" parent="VB/Grid/HeightRange"]
+layout_mode = 2
+size_flags_horizontal = 3
+text = "Auto"
+
+[node name="ShowInExplorerCheckbox" type="CheckBox" parent="VB"]
+layout_mode = 2
+text = "Show in explorer after export"
+
+[node name="Spacer" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 16)
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB"]
+layout_mode = 2
+text = "Note: height range is needed for integer image formats, as they can't directly represent the real height. 8-bit formats may cause precision loss."
+autowrap_mode = 2
+
+[node name="Spacer2" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 16)
+layout_mode = 2
+
+[node name="Buttons" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+alignment = 1
+
+[node name="ExportButton" type="Button" parent="VB/Buttons"]
+layout_mode = 2
+text = "Export"
+
+[node name="CancelButton" type="Button" parent="VB/Buttons"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("2")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 492.0
+offset_bottom = 322.0
+
+[connection signal="text_changed" from="VB/Grid/OutputPath/HeightmapPathLineEdit" to="." method="_on_HeightmapPathLineEdit_text_changed"]
+[connection signal="pressed" from="VB/Grid/OutputPath/HeightmapPathBrowseButton" to="." method="_on_HeightmapPathBrowseButton_pressed"]
+[connection signal="item_selected" from="VB/Grid/FormatSelector" to="." method="_on_FormatSelector_item_selected"]
+[connection signal="pressed" from="VB/Grid/HeightRange/HeightRangeAutoButton" to="." method="_on_HeightRangeAutoButton_pressed"]
+[connection signal="pressed" from="VB/Buttons/ExportButton" to="." method="_on_ExportButton_pressed"]
+[connection signal="pressed" from="VB/Buttons/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd
new file mode 100644
index 0000000..f567e02
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.gd
@@ -0,0 +1,54 @@
+@tool
+extends AcceptDialog
+
+signal generate_selected(lod)
+
+const HTerrain = preload("../hterrain.gd")
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+const HT_Util = preload("../util/util.gd")
+
+@onready var _preview_label : Label = $VBoxContainer/PreviewLabel
+@onready var _lod_spinbox : SpinBox = $VBoxContainer/HBoxContainer/LODSpinBox
+
+var _terrain : HTerrain = null
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ if visible and _terrain != null:
+ _update_preview()
+
+
+func _on_LODSpinBox_value_changed(value):
+ _update_preview()
+
+
+func _update_preview():
+ assert(_terrain != null)
+ assert(_terrain.get_data() != null)
+ var resolution := _terrain.get_data().get_resolution()
+ var stride := int(_lod_spinbox.value)
+ resolution /= stride
+ var s := HTerrainMesher.get_mesh_size(resolution, resolution)
+ _preview_label.text = str( \
+ HT_Util.format_integer(s.vertices), " vertices, ", \
+ HT_Util.format_integer(s.triangles), " triangles")
+
+
+func _on_Generate_pressed():
+ var stride := int(_lod_spinbox.value)
+ generate_selected.emit(stride)
+ hide()
+
+
+func _on_Cancel_pressed():
+ hide()
+
diff --git a/game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn
new file mode 100644
index 0000000..12f3d4a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn
@@ -0,0 +1,69 @@
+[gd_scene load_steps=3 format=3 uid="uid://ci0da54goyo5o"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/generate_mesh_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="2"]
+
+[node name="GenerateMeshDialog" type="AcceptDialog"]
+title = "Generate full mesh"
+size = Vector2i(448, 234)
+min_size = Vector2i(448, 186)
+script = ExtResource("1")
+
+[node name="VBoxContainer" 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 = -18.0
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "LOD"
+
+[node name="LODSpinBox" type="SpinBox" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+min_value = 1.0
+max_value = 16.0
+value = 1.0
+
+[node name="PreviewLabel" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+text = "9999 vertices, 9999 triangles"
+
+[node name="Spacer" type="Control" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+text = "Note: generating a full mesh from the terrain may result in a huge amount of vertices for a single object. It is preferred to do this for small terrains, or as a temporary workaround to generate a navmesh."
+autowrap_mode = 2
+
+[node name="Buttons" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="Generate" type="Button" parent="VBoxContainer/Buttons"]
+layout_mode = 2
+text = "Generate"
+
+[node name="Cancel" type="Button" parent="VBoxContainer/Buttons"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("2")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 440.0
+offset_bottom = 216.0
+
+[connection signal="value_changed" from="VBoxContainer/HBoxContainer/LODSpinBox" to="." method="_on_LODSpinBox_value_changed"]
+[connection signal="pressed" from="VBoxContainer/Buttons/Generate" to="." method="_on_Generate_pressed"]
+[connection signal="pressed" from="VBoxContainer/Buttons/Cancel" to="." method="_on_Cancel_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/generator/generator_dialog.gd b/game/addons/zylann.hterrain/tools/generator/generator_dialog.gd
new file mode 100644
index 0000000..bbb7069
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/generator_dialog.gd
@@ -0,0 +1,562 @@
+@tool
+extends AcceptDialog
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HTerrainMesher = preload("../../hterrain_mesher.gd")
+const HT_Util = preload("../../util/util.gd")
+const HT_TextureGenerator = preload("./texture_generator.gd")
+const HT_TextureGeneratorPass = preload("./texture_generator_pass.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_ImageFileCache = preload("../../util/image_file_cache.gd")
+const HT_Inspector = preload("../inspector/inspector.gd")
+const HT_TerrainPreview = preload("../terrain_preview.gd")
+const HT_ProgressWindow = preload("../progress_window.gd")
+
+const HT_ProgressWindowScene = preload("../progress_window.tscn")
+
+# TODO Power of two is assumed here.
+# I wonder why it doesn't have the off by one terrain textures usually have
+const MAX_VIEWPORT_RESOLUTION = 512
+
+#signal progress_notified(info) # { "progress": real, "message": string, "finished": bool }
+
+@onready var _inspector_container : Control = $VBoxContainer/Editor/Settings
+@onready var _inspector : HT_Inspector = $VBoxContainer/Editor/Settings/Inspector
+@onready var _preview : HT_TerrainPreview = $VBoxContainer/Editor/Preview/TerrainPreview
+@onready var _progress_bar : ProgressBar = $VBoxContainer/Editor/Preview/ProgressBar
+
+var _dummy_texture = load("res://addons/zylann.hterrain/tools/icons/empty.png")
+var _terrain : HTerrain = null
+var _applying := false
+var _generator : HT_TextureGenerator
+var _generated_textures := [null, null]
+var _dialog_visible := false
+var _undo_map_ids := {}
+var _image_cache : HT_ImageFileCache = null
+var _undo_redo_manager : EditorUndoRedoManager
+var _logger := HT_Logger.get_for(self)
+var _viewport_resolution := MAX_VIEWPORT_RESOLUTION
+var _progress_window : HT_ProgressWindow
+
+
+static func get_shader(shader_name: String) -> Shader:
+ var path := "res://addons/zylann.hterrain/tools/generator/shaders"\
+ .path_join(str(shader_name, ".gdshader"))
+ return load(path) as Shader
+
+
+func _init():
+ # Godot 4 does not have a plain WindowDialog class... there is Window but it's too unfriendly...
+ get_ok_button().hide()
+
+ _progress_window = HT_ProgressWindowScene.instantiate()
+ add_child(_progress_window)
+
+
+func _ready():
+ _inspector.set_prototype({
+ "seed": {
+ "type": TYPE_INT,
+ "randomizable": true,
+ "range": { "min": -100, "max": 100 },
+ "slidable": false
+ },
+ "offset": {
+ "type": TYPE_VECTOR2
+ },
+ "base_height": {
+ "type": TYPE_FLOAT,
+ "range": {"min": -500.0, "max": 500.0, "step": 0.1 },
+ "default_value": -50.0
+ },
+ "height_range": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 0.0, "max": 2000.0, "step": 0.1 },
+ "default_value": 150.0
+ },
+ "scale": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 1.0, "max": 1000.0, "step": 1.0},
+ "default_value": 100.0
+ },
+ "roughness": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 0.0, "max": 1.0, "step": 0.01},
+ "default_value": 0.4
+ },
+ "curve": {
+ "type": TYPE_FLOAT,
+ "range": {"min": 1.0, "max": 10.0, "step": 0.1},
+ "default_value": 1.0
+ },
+ "octaves": {
+ "type": TYPE_INT,
+ "range": {"min": 1, "max": 10, "step": 1},
+ "default_value": 6
+ },
+ "erosion_steps": {
+ "type": TYPE_INT,
+ "range": {"min": 0, "max": 100, "step": 1},
+ "default_value": 0
+ },
+ "erosion_weight": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0 },
+ "default_value": 0.5
+ },
+ "erosion_slope_factor": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0 },
+ "default_value": 0.0
+ },
+ "erosion_slope_direction": {
+ "type": TYPE_VECTOR2,
+ "default_value": Vector2(0, 0)
+ },
+ "erosion_slope_invert": {
+ "type": TYPE_BOOL,
+ "default_value": false
+ },
+ "dilation": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0 },
+ "default_value": 0.0
+ },
+ "island_weight": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0, "step": 0.01 },
+ "default_value": 0.0
+ },
+ "island_sharpness": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0, "step": 0.01 },
+ "default_value": 0.0
+ },
+ "island_height_ratio": {
+ "type": TYPE_FLOAT,
+ "range": { "min": -1.0, "max": 1.0, "step": 0.01 },
+ "default_value": -1.0
+ },
+ "island_shape": {
+ "type": TYPE_FLOAT,
+ "range": { "min": 0.0, "max": 1.0, "step": 0.01 },
+ "default_value": 0.0
+ },
+ "additive_heightmap": {
+ "type": TYPE_BOOL,
+ "default_value": false
+ },
+ "show_sea": {
+ "type": TYPE_BOOL,
+ "default_value": true
+ },
+ "shadows": {
+ "type": TYPE_BOOL,
+ "default_value": true
+ }
+ })
+
+ _generator = HT_TextureGenerator.new()
+ _generator.set_resolution(Vector2i(_viewport_resolution, _viewport_resolution))
+ # Setup the extra pixels we want on max edges for terrain
+ # TODO I wonder if it's not better to let the generator shaders work in pixels
+ # instead of NDC, rather than putting a padding system there
+ _generator.set_output_padding([0, 1, 0, 1])
+ _generator.output_generated.connect(_on_TextureGenerator_output_generated)
+ _generator.completed.connect(_on_TextureGenerator_completed)
+ _generator.progress_reported.connect(_on_TextureGenerator_progress_reported)
+ add_child(_generator)
+
+ # TEST
+ if not Engine.is_editor_hint():
+ call_deferred("popup_centered")
+
+
+func apply_dpi_scale(dpi_scale: float):
+ min_size *= dpi_scale
+ _inspector_container.custom_minimum_size *= dpi_scale
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+ _adjust_viewport_resolution()
+
+
+func _adjust_viewport_resolution():
+ if _terrain == null:
+ return
+ var data = _terrain.get_data()
+ if data == null:
+ return
+ var terrain_resolution := data.get_resolution()
+
+ # By default we want to work with a large enough viewport to generate tiles,
+ # but we should pick a smaller size if the terrain is smaller than that...
+ var vp_res := MAX_VIEWPORT_RESOLUTION
+ while vp_res > terrain_resolution:
+ vp_res /= 2
+
+ _generator.set_resolution(Vector2(vp_res, vp_res))
+ _viewport_resolution = vp_res
+
+
+func set_image_cache(image_cache: HT_ImageFileCache):
+ _image_cache = image_cache
+
+
+func set_undo_redo(ur: EditorUndoRedoManager):
+ _undo_redo_manager = ur
+
+
+func _notification(what: int):
+ match what:
+ NOTIFICATION_VISIBILITY_CHANGED:
+ # We don't want any of this to run in an edited scene
+ if HT_Util.is_in_edited_scene(self):
+ return
+ # Since Godot 4 visibility can be changed even between _enter_tree and _ready
+ if _preview == null:
+ return
+
+ if visible:
+ # TODO https://github.com/godotengine/godot/issues/18160
+ if _dialog_visible:
+ return
+ _dialog_visible = true
+
+ _adjust_viewport_resolution()
+
+ _preview.set_sea_visible(_inspector.get_value("show_sea"))
+ _preview.set_shadows_enabled(_inspector.get_value("shadows"))
+
+ _update_generator(true)
+
+ else:
+# if not _applying:
+# _destroy_viewport()
+ _preview.cleanup()
+ for i in len(_generated_textures):
+ _generated_textures[i] = null
+ _dialog_visible = false
+
+
+func _update_generator(preview: bool):
+ var scale : float = _inspector.get_value("scale")
+ # Scale is inverted in the shader
+ if absf(scale) < 0.01:
+ scale = 0.0
+ else:
+ scale = 1.0 / scale
+ scale *= _viewport_resolution
+
+ var preview_scale := 4.0 # As if 2049x2049
+ var sectors := []
+ var terrain_size := 513
+
+ var additive_heightmap : Texture2D = null
+
+ # For testing
+ if not Engine.is_editor_hint() and _terrain == null:
+ sectors.append(Vector2(0, 0))
+
+ # Get preview scale and sectors to generate.
+ # Allowing null terrain to make it testable.
+ if _terrain != null and _terrain.get_data() != null:
+ var terrain_data := _terrain.get_data()
+ terrain_size = terrain_data.get_resolution()
+
+ if _inspector.get_value("additive_heightmap"):
+ additive_heightmap = terrain_data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
+ if preview:
+ # When previewing the resolution does not span the entire terrain,
+ # so we apply a scale to some of the passes to make it cover it all.
+ preview_scale = float(terrain_size) / float(_viewport_resolution)
+ sectors.append(Vector2(0, 0))
+
+ else:
+ if additive_heightmap != null:
+ # We have to duplicate the heightmap because we are going to write
+ # into it during the generation process.
+ # It would be fine when we don't read outside of a generated tile,
+ # but we actually do that for erosion: neighboring pixels are read
+ # again, and if they were modified by a previous tile it will
+ # disrupt generation, so we need to use a copy of the original.
+ additive_heightmap = additive_heightmap.duplicate()
+
+ # When we get to generate it fully, sectors are used,
+ # so the size or shape of the terrain doesn't matter
+ preview_scale = 1.0
+
+ var cw := terrain_size / _viewport_resolution
+ var ch := terrain_size / _viewport_resolution
+
+ for y in ch:
+ for x in cw:
+ sectors.append(Vector2(x, y))
+
+ var erosion_iterations := int(_inspector.get_value("erosion_steps"))
+ erosion_iterations /= int(preview_scale)
+
+ _generator.clear_passes()
+
+ # Terrain textures need to have an off-by-one on their max edge,
+ # which is shared with the other sectors.
+ var base_offset_ndc = _inspector.get_value("offset")
+ #var sector_size_offby1_ndc = float(VIEWPORT_RESOLUTION - 1) / padded_viewport_resolution
+
+ for i in len(sectors):
+ var sector = sectors[i]
+ #var offset = sector * sector_size_offby1_ndc - Vector2(pad_offset_ndc, pad_offset_ndc)
+
+# var offset_px = sector * (VIEWPORT_RESOLUTION - 1) - Vector2(pad_offset_px, pad_offset_px)
+# var offset_ndc = offset_px / padded_viewport_resolution
+ var progress := float(i) / len(sectors)
+ var p := HT_TextureGeneratorPass.new()
+ p.clear = true
+ p.shader = get_shader("perlin_noise")
+ # This pass generates the shapes of the terrain so will have to account for offset
+ p.tile_pos = sector
+ p.params = {
+ "u_octaves": _inspector.get_value("octaves"),
+ "u_seed": _inspector.get_value("seed"),
+ "u_scale": scale,
+ "u_offset": base_offset_ndc,
+ "u_base_height": _inspector.get_value("base_height") / preview_scale,
+ "u_height_range": _inspector.get_value("height_range") / preview_scale,
+ "u_roughness": _inspector.get_value("roughness"),
+ "u_curve": _inspector.get_value("curve"),
+ "u_island_weight": _inspector.get_value("island_weight"),
+ "u_island_sharpness": _inspector.get_value("island_sharpness"),
+ "u_island_height_ratio": _inspector.get_value("island_height_ratio"),
+ "u_island_shape": _inspector.get_value("island_shape"),
+ "u_additive_heightmap": additive_heightmap,
+ "u_additive_heightmap_factor": \
+ (1.0 if additive_heightmap != null else 0.0) / preview_scale,
+ "u_terrain_size": terrain_size / preview_scale,
+ "u_tile_size": _viewport_resolution
+ }
+ _generator.add_pass(p)
+
+ if erosion_iterations > 0:
+ p = HT_TextureGeneratorPass.new()
+ p.shader = get_shader("erode")
+ # TODO More erosion config
+ p.params = {
+ "u_slope_factor": _inspector.get_value("erosion_slope_factor"),
+ "u_slope_invert": _inspector.get_value("erosion_slope_invert"),
+ "u_slope_up": _inspector.get_value("erosion_slope_direction"),
+ "u_weight": _inspector.get_value("erosion_weight"),
+ "u_dilation": _inspector.get_value("dilation")
+ }
+ p.iterations = erosion_iterations
+ p.padding = p.iterations
+ _generator.add_pass(p)
+
+ _generator.add_output({
+ "maptype": HTerrainData.CHANNEL_HEIGHT,
+ "sector": sector,
+ "progress": progress
+ })
+
+ p = HT_TextureGeneratorPass.new()
+ p.shader = get_shader("bump2normal")
+ p.padding = 1
+ _generator.add_pass(p)
+
+ _generator.add_output({
+ "maptype": HTerrainData.CHANNEL_NORMAL,
+ "sector": sector,
+ "progress": progress
+ })
+
+ # TODO AO generation
+ # TODO Splat generation
+ _generator.run()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_ApplyButton_pressed():
+ # We used to hide the dialog when the Apply button is clicked, and then texture generation took
+ # place in an offscreen viewport in multiple tiled stages, with a progress window being shown.
+ # But in Godot 4, it turns out SubViewports never update if they are child of a hidden Window,
+ # even if they are set to UPDATE_ALWAYS...
+ #hide()
+
+ _apply()
+
+
+func _on_Inspector_property_changed(key, value):
+ match key:
+ "show_sea":
+ _preview.set_sea_visible(value)
+ "shadows":
+ _preview.set_shadows_enabled(value)
+ _:
+ _update_generator(true)
+
+
+func _on_TerrainPreview_dragged(relative: Vector2, button_mask: int):
+ if button_mask & MOUSE_BUTTON_MASK_LEFT:
+ var offset : Vector2 = _inspector.get_value("offset")
+ offset += relative
+ _inspector.set_value("offset", offset)
+
+
+func _apply():
+ if _terrain == null:
+ _logger.error("cannot apply, terrain is null")
+ return
+
+ var data := _terrain.get_data()
+ if data == null:
+ _logger.error("cannot apply, terrain data is null")
+ return
+
+ var dst_heights := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ if dst_heights == null:
+ _logger.error("terrain heightmap image isn't loaded")
+ return
+
+ var dst_normals := data.get_image(HTerrainData.CHANNEL_NORMAL)
+ if dst_normals == null:
+ _logger.error("terrain normal image isn't loaded")
+ return
+
+ _applying = true
+
+ _undo_map_ids[HTerrainData.CHANNEL_HEIGHT] = _image_cache.save_image(dst_heights)
+ _undo_map_ids[HTerrainData.CHANNEL_NORMAL] = _image_cache.save_image(dst_normals)
+
+ _update_generator(false)
+
+
+func _on_TextureGenerator_progress_reported(info: Dictionary):
+ if _applying:
+ return
+ var p := 0.0
+ if info.pass_index == 1:
+ p = float(info.iteration) / float(info.iteration_count)
+ _progress_bar.show()
+ _progress_bar.ratio = p
+
+
+func _on_TextureGenerator_output_generated(image: Image, info: Dictionary):
+ # TODO We should check the terrain's image format,
+ # but that would prevent from testing in isolation...
+ if info.maptype == HTerrainData.CHANNEL_HEIGHT:
+ # Hack to workaround Godot 4.0 not supporting RF viewports. Heights are packed as floats
+ # into RGBA8 components.
+ assert(image.get_format() == Image.FORMAT_RGBA8)
+ image = Image.create_from_data(image.get_width(), image.get_height(), false,
+ Image.FORMAT_RF, image.get_data())
+
+ if not _applying:
+ # Update preview
+ # TODO Improve TextureGenerator so we can get a ViewportTexture per output?
+ var tex = _generated_textures[info.maptype]
+ if tex == null:
+ tex = ImageTexture.create_from_image(image)
+ _generated_textures[info.maptype] = tex
+ else:
+ tex.update(image)
+
+ var num_set := 0
+ for v in _generated_textures:
+ if v != null:
+ num_set += 1
+ if num_set == len(_generated_textures):
+ _preview.setup( \
+ _generated_textures[HTerrainData.CHANNEL_HEIGHT],
+ _generated_textures[HTerrainData.CHANNEL_NORMAL])
+ else:
+ assert(_terrain != null)
+ var data := _terrain.get_data()
+ assert(data != null)
+ var dst := data.get_image(info.maptype)
+ assert(dst != null)
+# print("Tile ", info.sector)
+# image.save_png(str("debug_generator_tile_",
+# info.sector.x, "_", info.sector.y, "_map", info.maptype, ".png"))
+
+ # Converting in case Viewport texture isn't the format we expect for this map.
+ # Note, in Godot 4 it seems the chosen renderer also influences what you get.
+ # Forward+ non-transparent viewport gives RGB8, but Compatibility gives RGBA8.
+ # I don't know if it's expected or is a bug...
+ # Also, since RF heightmaps we use RGBA8 so we can pack floats in pixels, because
+ # Godot 4.0 does not support RF viewports. But that also means the same viewport may be
+ # re-used for other maps that don't need to be RGBA8.
+ if image.get_format() != dst.get_format():
+ image.convert(dst.get_format())
+
+ dst.blit_rect(image, \
+ Rect2i(0, 0, image.get_width(), image.get_height()), \
+ info.sector * _viewport_resolution)
+
+ _notify_progress({
+ "progress": info.progress,
+ "message": "Calculating sector ("
+ + str(info.sector.x) + ", " + str(info.sector.y) + ")"
+ })
+
+# if info.maptype == HTerrainData.CHANNEL_NORMAL:
+# image.save_png(str("normal_sector_", info.sector.x, "_", info.sector.y, ".png"))
+
+
+func _on_TextureGenerator_completed():
+ _progress_bar.hide()
+
+ if not _applying:
+ return
+ _applying = false
+
+ assert(_terrain != null)
+ var data : HTerrainData = _terrain.get_data()
+ var resolution := data.get_resolution()
+ data.notify_region_change(Rect2(0, 0, resolution, resolution), HTerrainData.CHANNEL_HEIGHT)
+
+ var redo_map_ids := {}
+ for map_type in _undo_map_ids:
+ redo_map_ids[map_type] = _image_cache.save_image(data.get_image(map_type))
+
+ var undo_redo := _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(data))
+
+ data._edit_set_disable_apply_undo(true)
+ undo_redo.create_action("Generate terrain")
+ undo_redo.add_do_method(data._edit_apply_maps_from_file_cache.bind(_image_cache, redo_map_ids))
+ undo_redo.add_undo_method(
+ data._edit_apply_maps_from_file_cache.bind(_image_cache, _undo_map_ids))
+ undo_redo.commit_action()
+ data._edit_set_disable_apply_undo(false)
+
+ _notify_progress({ "finished": true })
+ _logger.debug("Done")
+
+ hide()
+
+
+func _notify_progress(info: Dictionary):
+ _progress_window.handle_progress(info)
+
+
+func _process(delta):
+ if _applying:
+ # HACK to workaround a peculiar behavior of Viewports in Godot 4.
+ # Apparently Godot 4 will not update Viewports set to UPDATE_ALWAYS when the editor decides
+ # it doesn't need to redraw ("low processor mode", what makes the editor redraw only with
+ # changes). That wasn't the case in Godot 3, but I guess it is now.
+ # That means when we click Apply, the viewport will not update in particular when doing
+ # erosion passes, because the action of clicking Apply doesn't lead to as many redraws as
+ # changing preview parameters in the UI (those cause redraws for different reasons).
+ # So let's poke the renderer by redrawing something...
+ #
+ # This also piles on top of the workaround in which we keep the window visible when
+ # applying! So the window has one more reason to stay visible...
+ #
+ _preview.queue_redraw()
+
diff --git a/game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn b/game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn
new file mode 100644
index 0000000..5d911e3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/generator_dialog.tscn
@@ -0,0 +1,84 @@
+[gd_scene load_steps=5 format=3 uid="uid://cgfo1ocbdi1ug"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/generator/generator_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/inspector/inspector.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/terrain_preview.tscn" id="3"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="4"]
+
+[node name="GeneratorDialog" type="AcceptDialog"]
+title = "Generate terrain"
+size = Vector2i(1100, 780)
+min_size = Vector2i(1100, 620)
+script = ExtResource("1")
+
+[node name="VBoxContainer" 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 = -18.0
+
+[node name="Editor" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Settings" type="VBoxContainer" parent="VBoxContainer/Editor"]
+custom_minimum_size = Vector2(420, 0)
+layout_mode = 2
+
+[node name="Inspector" parent="VBoxContainer/Editor/Settings" instance=ExtResource("2")]
+layout_mode = 2
+
+[node name="Preview" type="Control" parent="VBoxContainer/Editor"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="TerrainPreview" parent="VBoxContainer/Editor/Preview" instance=ExtResource("3")]
+layout_mode = 1
+anchors_preset = 15
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="Label" type="Label" parent="VBoxContainer/Editor/Preview"]
+layout_mode = 0
+offset_left = 5.0
+offset_top = 4.0
+offset_right = 207.0
+offset_bottom = 18.0
+text = "LMB: offset, MMB: rotate"
+
+[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer/Editor/Preview"]
+layout_mode = 1
+anchors_preset = -1
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -35.0
+step = 1.0
+
+[node name="Choices" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="ApplyButton" type="Button" parent="VBoxContainer/Choices"]
+layout_mode = 2
+text = "Apply"
+
+[node name="CancelButton" type="Button" parent="VBoxContainer/Choices"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("4")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 1092.0
+offset_bottom = 762.0
+
+[connection signal="property_changed" from="VBoxContainer/Editor/Settings/Inspector" to="." method="_on_Inspector_property_changed"]
+[connection signal="dragged" from="VBoxContainer/Editor/Preview/TerrainPreview" to="." method="_on_TerrainPreview_dragged"]
+[connection signal="pressed" from="VBoxContainer/Choices/ApplyButton" to="." method="_on_ApplyButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/Choices/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader b/game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader
new file mode 100644
index 0000000..7dd9714
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/shaders/bump2normal.gdshader
@@ -0,0 +1,27 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+//uniform sampler2D u_screen_texture : hint_screen_texture;
+uniform sampler2D u_previous_pass;
+
+vec4 pack_normal(vec3 n) {
+ return vec4((0.5 * (n + 1.0)).xzy, 1.0);
+}
+
+float get_height(sampler2D tex, vec2 uv) {
+ return sample_height_from_viewport(tex, uv);
+}
+
+void fragment() {
+ vec2 uv = SCREEN_UV;
+ vec2 ps = SCREEN_PIXEL_SIZE;
+ float left = get_height(u_previous_pass, uv + vec2(-ps.x, 0));
+ float right = get_height(u_previous_pass, uv + vec2(ps.x, 0));
+ float back = get_height(u_previous_pass, uv + vec2(0, -ps.y));
+ float fore = get_height(u_previous_pass, uv + vec2(0, ps.y));
+ vec3 n = normalize(vec3(left - right, 2.0, fore - back));
+ COLOR = pack_normal(n);
+}
+
diff --git a/game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader b/game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader
new file mode 100644
index 0000000..6f45e1c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/shaders/erode.gdshader
@@ -0,0 +1,76 @@
+shader_type canvas_item;
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform vec2 u_slope_up = vec2(0, 0);
+uniform float u_slope_factor = 1.0;
+uniform bool u_slope_invert = false;
+uniform float u_weight = 0.5;
+uniform float u_dilation = 0.0;
+//uniform sampler2D u_screen_texture : hint_screen_texture;
+uniform sampler2D u_previous_pass;
+
+float get_height(sampler2D tex, vec2 uv) {
+ return sample_height_from_viewport(tex, uv);
+}
+
+void fragment() {
+ float r = 3.0;
+
+ // Divide so the shader stays neighbor dependent 1 pixel across.
+ // For this to work, filtering must be enabled.
+ vec2 eps = SCREEN_PIXEL_SIZE / (0.99 * r);
+
+ vec2 uv = SCREEN_UV;
+ float h = get_height(u_previous_pass, 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(float(x), float(y));
+ float nh = get_height(u_previous_pass, 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, u_weight);
+ dh = mix(h, dh, u_weight);
+
+ float ph = mix(eh, dh, u_dilation);
+
+ if (u_slope_factor > 0.0) {
+ vec2 ps = SCREEN_PIXEL_SIZE;
+
+ float left = get_height(u_previous_pass, uv + vec2(-ps.x, 0.0));
+ float right = get_height(u_previous_pass, uv + vec2(ps.x, 0.0));
+ float top = get_height(u_previous_pass, uv + vec2(0.0, ps.y));
+ float bottom = get_height(u_previous_pass, uv + vec2(0.0, -ps.y));
+
+ vec3 normal = normalize(vec3(left - right, ps.x + ps.y, bottom - top));
+ vec3 up = normalize(vec3(u_slope_up.x, 1.0, u_slope_up.y));
+
+ float f = max(dot(normal, up), 0);
+ if (u_slope_invert) {
+ f = 1.0 - f;
+ }
+
+ ph = mix(h, ph, mix(1.0, f, u_slope_factor));
+ //COLOR = vec4(f, f, f, 1.0);
+ }
+
+ //COLOR = vec4(0.5 * normal + 0.5, 1.0);
+
+ //eh = 0.5 * (eh + texture(SCREEN_TEXTURE, uv + mp * ps * k).r);
+ //eh = mix(h, eh, (1.0 - h) / r);
+
+ COLOR = encode_height_to_viewport(ph);
+}
diff --git a/game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader b/game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader
new file mode 100644
index 0000000..7609f7c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.gdshader
@@ -0,0 +1,211 @@
+shader_type canvas_item;
+// Required only because we use all 4 channels to encode floats into RGBA8
+render_mode blend_disabled;
+
+#include "res://addons/zylann.hterrain/shaders/include/heightmap.gdshaderinc"
+
+uniform vec2 u_offset;
+uniform float u_scale = 0.02;
+uniform float u_base_height = 0.0;
+uniform float u_height_range = 100.0;
+uniform int u_seed;
+uniform int u_octaves = 5;
+uniform float u_roughness = 0.5;
+uniform float u_curve = 1.0;
+uniform float u_terrain_size = 513.0;
+uniform float u_tile_size = 513.0;
+uniform sampler2D u_additive_heightmap;
+uniform float u_additive_heightmap_factor = 0.0;
+uniform vec2 u_uv_offset;
+uniform vec2 u_uv_scale = vec2(1.0, 1.0);
+
+uniform float u_island_weight = 0.0;
+// 0: smooth transition, 1: sharp transition
+uniform float u_island_sharpness = 0.0;
+// 0: edge is min height (island), 1: edge is max height (canyon)
+uniform float u_island_height_ratio = 0.0;
+// 0: round, 1: square
+uniform float u_island_shape = 0.0;
+
+////////////////////////////////////////////////////////////////////////////////
+// Perlin noise source:
+// https://github.com/curly-brace/Godot-3.0-Noise-Shaders
+//
+// GLSL textureless classic 2D noise \"cnoise\",
+// with an RSL-style periodic variant \"pnoise\".
+// Author: Stefan Gustavson (stefan.gustavson@liu.se)
+// Version: 2011-08-22
+//
+// Many thanks to Ian McEwan of Ashima Arts for the
+// ideas for permutation and gradient selection.
+//
+// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
+// Distributed under the MIT license. See LICENSE file.
+// https://github.com/stegu/webgl-noise
+//
+
+vec4 mod289(vec4 x) {
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
+}
+
+vec4 permute(vec4 x) {
+ return mod289(((x * 34.0) + 1.0) * x);
+}
+
+vec4 taylorInvSqrt(vec4 r) {
+ return 1.79284291400159 - 0.85373472095314 * r;
+}
+
+vec2 fade(vec2 t) {
+ return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
+}
+
+// Classic Perlin noise
+float cnoise(vec2 P) {
+ vec4 Pi = floor(vec4(P, P)) + vec4(0.0, 0.0, 1.0, 1.0);
+ vec4 Pf = fract(vec4(P, P)) - vec4(0.0, 0.0, 1.0, 1.0);
+ Pi = mod289(Pi); // To avoid truncation effects in permutation
+ vec4 ix = Pi.xzxz;
+ vec4 iy = Pi.yyww;
+ vec4 fx = Pf.xzxz;
+ vec4 fy = Pf.yyww;
+
+ vec4 i = permute(permute(ix) + iy);
+
+ vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0 ;
+ vec4 gy = abs(gx) - 0.5 ;
+ vec4 tx = floor(gx + 0.5);
+ gx = gx - tx;
+
+ vec2 g00 = vec2(gx.x,gy.x);
+ vec2 g10 = vec2(gx.y,gy.y);
+ vec2 g01 = vec2(gx.z,gy.z);
+ vec2 g11 = vec2(gx.w,gy.w);
+
+ vec4 norm = taylorInvSqrt(vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
+ g00 *= norm.x;
+ g01 *= norm.y;
+ g10 *= norm.z;
+ g11 *= norm.w;
+
+ float n00 = dot(g00, vec2(fx.x, fy.x));
+ float n10 = dot(g10, vec2(fx.y, fy.y));
+ float n01 = dot(g01, vec2(fx.z, fy.z));
+ float n11 = dot(g11, vec2(fx.w, fy.w));
+
+ vec2 fade_xy = fade(Pf.xy);
+ vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
+ float n_xy = mix(n_x.x, n_x.y, fade_xy.y);
+ return 2.3 * n_xy;
+}
+////////////////////////////////////////////////////////////////////////////////
+
+float get_fractal_noise(vec2 uv) {
+ float scale = 1.0;
+ float sum = 0.0;
+ float amp = 0.0;
+ int octaves = u_octaves;
+ float p = 1.0;
+ uv.x += float(u_seed) * 61.0;
+
+ for (int i = 0; i < octaves; ++i) {
+ sum += p * cnoise(uv * scale);
+ amp += p;
+ scale *= 2.0;
+ p *= u_roughness;
+ }
+
+ float gs = sum / amp;
+ return gs;
+}
+
+// x is a ratio in 0..1
+float get_island_curve(float x) {
+ return smoothstep(min(0.999, u_island_sharpness), 1.0, x);
+// float exponent = 1.0 + 10.0 * u_island_sharpness;
+// return pow(abs(x), exponent);
+}
+
+float smooth_union(float a, float b, float k) {
+ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
+ return mix(b, a, h) - k * h * (1.0 - h);
+}
+
+float squareish_distance(vec2 a, vec2 b, float r, float s) {
+ vec2 v = b - a;
+ // TODO This is brute force but this is the first attempt that gave me a "rounded square" distance,
+ // where the "roundings" remained constant over distance (not the case with standard box SDF)
+ float da = -smooth_union(v.x+s, v.y+s, r)+s;
+ float db = -smooth_union(s-v.x, s-v.y, r)+s;
+ float dc = -smooth_union(s-v.x, v.y+s, r)+s;
+ float dd = -smooth_union(v.x+s, s-v.y, r)+s;
+ return max(max(da, db), max(dc, dd));
+}
+
+// This is too sharp
+//float squareish_distance(vec2 a, vec2 b) {
+// vec2 v = b - a;
+// // Manhattan distance would produce a "diamond-shaped distance".
+// // This gives "square-shaped" distance.
+// return max(abs(v.x), abs(v.y));
+//}
+
+float get_island_distance(vec2 pos, vec2 center, float terrain_size) {
+ float rd = distance(pos, center);
+ float sd = squareish_distance(pos, center, terrain_size * 0.1, terrain_size);
+ return mix(rd, sd, u_island_shape);
+}
+
+// pos is in terrain space
+float get_height(vec2 pos) {
+ float h = 0.0;
+
+ {
+ // Noise (0..1)
+ // Offset and scale for the noise itself
+ vec2 uv_noise = (pos / u_terrain_size + u_offset) * u_scale;
+ h = 0.5 + 0.5 * get_fractal_noise(uv_noise);
+ }
+
+ // Curve
+ {
+ h = pow(h, u_curve);
+ }
+
+ // Island
+ {
+ float terrain_size = u_terrain_size;
+ vec2 island_center = vec2(0.5 * terrain_size);
+ float island_height_ratio = 0.5 + 0.5 * u_island_height_ratio;
+ float island_distance = get_island_distance(pos, island_center, terrain_size);
+ float distance_ratio = clamp(island_distance / (0.5 * terrain_size), 0.0, 1.0);
+ float island_ratio = u_island_weight * get_island_curve(distance_ratio);
+ h = mix(h, island_height_ratio, island_ratio);
+ }
+
+ // Height remapping
+ {
+ h = u_base_height + h * u_height_range;
+ }
+
+ // Additive heightmap
+ {
+ vec2 uv = pos / u_terrain_size;
+ float ah = sample_heightmap(u_additive_heightmap, uv);
+ h += u_additive_heightmap_factor * ah;
+ }
+
+ return h;
+}
+
+void fragment() {
+ // Handle screen padding: transform UV back into generation space.
+ // This is in tile space actually...? it spans 1 unit across the viewport,
+ // and starts from 0 when tile (0,0) is generated.
+ // Maybe we could change this into world units instead?
+ vec2 uv_tile = (SCREEN_UV + u_uv_offset) * u_uv_scale;
+
+ float h = get_height(uv_tile * u_tile_size);
+
+ COLOR = encode_height_to_viewport(h);
+}
diff --git a/game/addons/zylann.hterrain/tools/generator/texture_generator.gd b/game/addons/zylann.hterrain/tools/generator/texture_generator.gd
new file mode 100644
index 0000000..0caaf7c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/texture_generator.gd
@@ -0,0 +1,331 @@
+# Holds a viewport on which several passes may run to generate a final image.
+# Passes can have different shaders and re-use what was drawn by a previous pass.
+# TODO I'd like to make such a system working as a graph of passes for more possibilities.
+
+@tool
+extends Node
+
+const HT_Util = preload("res://addons/zylann.hterrain/util/util.gd")
+const HT_TextureGeneratorPass = preload("./texture_generator_pass.gd")
+const HT_Logger = preload("../../util/logger.gd")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+const DUMMY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal progress_reported(info)
+# Emitted when an output is generated.
+signal output_generated(image, metadata)
+# Emitted when all passes are complete
+signal completed
+
+class HT_TextureGeneratorViewport:
+ var viewport : SubViewport
+ var ci : TextureRect
+
+var _passes := []
+var _resolution := Vector2i(512, 512)
+var _output_padding := [0, 0, 0, 0]
+
+# Since Godot 4.0, we use ping-pong viewports because `hint_screen_texture` always returns `1.0`
+# for transparent pixels, which is wrong, but sadly appears to be intented...
+# https://github.com/godotengine/godot/issues/78207
+var _viewports : Array[HT_TextureGeneratorViewport] = [null, null]
+var _viewport_index := 0
+
+var _dummy_texture : Texture2D
+var _running := false
+var _rerun := false
+#var _tiles = PoolVector2Array([Vector2()])
+
+var _running_passes := []
+var _running_pass_index := 0
+var _running_iteration := 0
+var _shader_material : ShaderMaterial = null
+#var _uv_offset = 0 # Offset de to padding
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ _dummy_texture = load(DUMMY_TEXTURE_PATH)
+ if _dummy_texture == null:
+ _logger.error(str("Failed to load dummy texture ", DUMMY_TEXTURE_PATH))
+
+ for viewport_index in len(_viewports):
+ var viewport = SubViewport.new()
+ # We render with 2D shaders, but we don't want the parent world to interfere
+ viewport.own_world_3d = true
+ viewport.world_3d = World3D.new()
+ viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+ # Require RGBA8 so we can pack heightmap floats into pixels
+ viewport.transparent_bg = true
+ add_child(viewport)
+
+ var ci := TextureRect.new()
+ ci.stretch_mode = TextureRect.STRETCH_SCALE
+ ci.texture = _dummy_texture
+ viewport.add_child(ci)
+
+ var vp := HT_TextureGeneratorViewport.new()
+ vp.viewport = viewport
+ vp.ci = ci
+ _viewports[viewport_index] = vp
+
+ _shader_material = ShaderMaterial.new()
+
+ set_process(false)
+
+
+func is_running() -> bool:
+ return _running
+
+
+func clear_passes():
+ _passes.clear()
+
+
+func add_pass(p: HT_TextureGeneratorPass):
+ assert(_passes.find(p) == -1)
+ assert(p.iterations > 0)
+ _passes.append(p)
+
+
+func add_output(meta):
+ assert(len(_passes) > 0)
+ var p = _passes[-1]
+ p.output = true
+ p.metadata = meta
+
+
+# Sets at which base resolution the generator will work on.
+# In tiled rendering, this is the resolution of one tile.
+# The internal viewport may be larger if some passes need more room,
+# and the resulting images might include some of these pixels if output padding is used.
+func set_resolution(res: Vector2i):
+ assert(not _running)
+ _resolution = res
+
+
+# Tell image outputs to include extra pixels on the edges.
+# This extends the resolution of images compared to the base resolution.
+# The initial use case for this is to generate terrain tiles where edge pixels are
+# shared with the neighor tiles.
+func set_output_padding(p: Array):
+ assert(typeof(p) == TYPE_ARRAY)
+ assert(len(p) == 4)
+ for v in p:
+ assert(typeof(v) == TYPE_INT)
+ _output_padding = p
+
+
+func run():
+ assert(len(_passes) > 0)
+
+ if _running:
+ _rerun = true
+ return
+
+ for vp in _viewports:
+ assert(vp.viewport != null)
+ assert(vp.ci != null)
+
+ # Copy passes
+ var passes := []
+ passes.resize(len(_passes))
+ for i in len(_passes):
+ passes[i] = _passes[i].duplicate()
+ _running_passes = passes
+
+ # Pad pixels according to largest padding
+ var largest_padding := 0
+ for p in passes:
+ if p.padding > largest_padding:
+ largest_padding = p.padding
+ for v in _output_padding:
+ if v > largest_padding:
+ largest_padding = v
+ var padded_size := _resolution + 2 * Vector2i(largest_padding, largest_padding)
+
+# _uv_offset = Vector2( \
+# float(largest_padding) / padded_size.x,
+# float(largest_padding) / padded_size.y)
+
+ for vp in _viewports:
+ vp.ci.size = padded_size
+ vp.viewport.size = padded_size
+
+ # First viewport index doesn't matter.
+ # Maybe one issue of resetting it to zero would be that the previous run
+ # could have ended with the same viewport that will be sent as a texture as
+ # one of the uniforms of the shader material, which causes an error in the
+ # renderer because it's not allowed to use a viewport texture while
+ # rendering on the same viewport
+# _viewport_index = 0
+
+ var first_vp := _viewports[_viewport_index]
+ first_vp.viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+ # I don't trust `UPDATE_ONCE`, it also doesn't reset so we never know if it actually works...
+ # https://github.com/godotengine/godot/issues/33351
+ first_vp.viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
+
+ for vp in _viewports:
+ if vp != first_vp:
+ vp.viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+
+ _running_pass_index = 0
+ _running_iteration = 0
+ _running = true
+ set_process(true)
+
+
+func _process(delta: float):
+ # TODO because of https://github.com/godotengine/godot/issues/7894
+ if not is_processing():
+ return
+
+ var prev_vpi := 0 if _viewport_index == 1 else 1
+ var prev_vp := _viewports[prev_vpi]
+
+ if _running_pass_index > 0:
+ var prev_pass : HT_TextureGeneratorPass = _running_passes[_running_pass_index - 1]
+ if prev_pass.output:
+ _create_output_image(prev_pass.metadata, prev_vp)
+
+ if _running_pass_index >= len(_running_passes):
+ _running = false
+
+ completed.emit()
+
+ if _rerun:
+ # run() was requested again before we complete...
+ # this will happen very frequently because we are forced to wait multiple frames
+ # before getting a result
+ _rerun = false
+ run()
+ else:
+ # Done
+ for vp in _viewports:
+ vp.viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+ set_process(false)
+ return
+
+ var p : HT_TextureGeneratorPass = _running_passes[_running_pass_index]
+
+ var vp := _viewports[_viewport_index]
+ vp.viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
+ prev_vp.viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+
+ if _running_iteration == 0:
+ _setup_pass(p, vp)
+
+ _setup_iteration(vp, prev_vp)
+
+ _report_progress(_running_passes, _running_pass_index, _running_iteration)
+ # Wait one frame for render, and this for EVERY iteration and every pass,
+ # because Godot doesn't provide any way to run multiple feedback render passes in one go.
+ _running_iteration += 1
+
+ if _running_iteration == p.iterations:
+ _running_iteration = 0
+ _running_pass_index += 1
+
+ # Swap viewport for next pass
+ _viewport_index = (_viewport_index + 1) % 2
+
+ # The viewport should render after the tree was processed
+
+
+# Called at the beginning of each pass
+func _setup_pass(p: HT_TextureGeneratorPass, vp: HT_TextureGeneratorViewport):
+ if p.texture != null:
+ vp.ci.texture = p.texture
+ else:
+ vp.ci.texture = _dummy_texture
+
+ if p.shader != null:
+ if _shader_material == null:
+ _shader_material = ShaderMaterial.new()
+ _shader_material.shader = p.shader
+
+ vp.ci.material = _shader_material
+
+ if p.params != null:
+ for param_name in p.params:
+ _shader_material.set_shader_parameter(param_name, p.params[param_name])
+
+ var vp_size_f := Vector2(vp.viewport.size)
+ var res_f := Vector2(_resolution)
+ var scale_ndc := vp_size_f / res_f
+ var pad_offset_ndc := ((vp_size_f - res_f) / 2.0) / vp_size_f
+ var offset_ndc := -pad_offset_ndc + p.tile_pos / scale_ndc
+
+ # Because padding may be used around the generated area,
+ # the shader can use these predefined parameters,
+ # and apply the following to SCREEN_UV to adjust its calculations:
+ # vec2 uv = (SCREEN_UV + u_uv_offset) * u_uv_scale;
+
+ if p.params == null or not p.params.has("u_uv_scale"):
+ _shader_material.set_shader_parameter("u_uv_scale", scale_ndc)
+
+ if p.params == null or not p.params.has("u_uv_offset"):
+ _shader_material.set_shader_parameter("u_uv_offset", offset_ndc)
+
+ else:
+ vp.ci.material = null
+
+ if p.clear:
+ vp.viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
+
+
+# Called for every iteration of every pass
+func _setup_iteration(vp: HT_TextureGeneratorViewport, prev_vp: HT_TextureGeneratorViewport):
+ assert(vp != prev_vp)
+ if _shader_material != null:
+ _shader_material.set_shader_parameter("u_previous_pass", prev_vp.viewport.get_texture())
+
+
+func _create_output_image(metadata, vp: HT_TextureGeneratorViewport):
+ var tex := vp.viewport.get_texture()
+ var src := tex.get_image()
+# src.save_png(str("ddd_tgen_output", metadata.maptype, ".png"))
+
+ # Pick the center of the image
+ var subrect := Rect2i( \
+ (src.get_width() - _resolution.x) / 2, \
+ (src.get_height() - _resolution.y) / 2, \
+ _resolution.x, _resolution.y)
+
+ # Make sure we are pixel-perfect. If not, padding is odd
+# assert(int(subrect.position.x) == subrect.position.x)
+# assert(int(subrect.position.y) == subrect.position.y)
+
+ subrect.position.x -= _output_padding[0]
+ subrect.position.y -= _output_padding[2]
+ subrect.size.x += _output_padding[0] + _output_padding[1]
+ subrect.size.y += _output_padding[2] + _output_padding[3]
+
+ var dst : Image
+ if subrect == Rect2i(0, 0, src.get_width(), src.get_height()):
+ dst = src
+ else:
+ # Note: size MUST match at this point.
+ # If it doesn't, the viewport has not been configured properly,
+ # or padding has been modified while the generator was running
+ dst = Image.create( \
+ _resolution.x + _output_padding[0] + _output_padding[1], \
+ _resolution.y + _output_padding[2] + _output_padding[3], \
+ false, src.get_format())
+ dst.blit_rect(src, subrect, Vector2i())
+
+ output_generated.emit(dst, metadata)
+
+
+func _report_progress(passes: Array, pass_index: int, iteration: int):
+ var p = passes[pass_index]
+ progress_reported.emit({
+ "name": p.debug_name,
+ "pass_index": pass_index,
+ "pass_count": len(passes),
+ "iteration": iteration,
+ "iteration_count": p.iterations
+ })
+
diff --git a/game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd b/game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd
new file mode 100644
index 0000000..76745f1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/generator/texture_generator_pass.gd
@@ -0,0 +1,43 @@
+
+# Name of the pass, for debug purposes
+var debug_name := ""
+# The viewport will be cleared at this pass
+var clear := false
+# Which main texture should be drawn.
+# If not set, a default texture will be drawn.
+# Note that it won't matter if the shader disregards it,
+# and will only serve to provide UVs, due to https://github.com/godotengine/godot/issues/7298.
+var texture : Texture = null
+# Which shader to use
+var shader : Shader = null
+# Parameters for the shader
+# TODO Use explicit Dictionary, dont allow null
+var params = null
+# How many pixels to pad the viewport on all edges, in case neighboring matters.
+# Outputs won't have that padding, but can pick part of it in case output padding is used.
+var padding := 0
+# How many times this pass must be run
+var iterations := 1
+# If not empty, the viewport will be downloaded as an image before the next pass
+var output := false
+# Sent along the output
+var metadata = null
+# Used for tiled rendering, where each tile has the base resolution,
+# in case the viewport cannot be made big enough to cover the final image,
+# of if you are generating a pseudo-infinite terrain.
+# TODO Have an API for this?
+var tile_pos := Vector2()
+
+func duplicate():
+ var p = get_script().new()
+ p.debug_name = debug_name
+ p.clear = clear
+ p.texture = texture
+ p.shader = shader
+ p.params = params
+ p.padding = padding
+ p.iterations = iterations
+ p.output = output
+ p.metadata = metadata
+ p.tile_pos = tile_pos
+ return p
diff --git a/game/addons/zylann.hterrain/tools/globalmap_baker.gd b/game/addons/zylann.hterrain/tools/globalmap_baker.gd
new file mode 100644
index 0000000..6faa0eb
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/globalmap_baker.gd
@@ -0,0 +1,168 @@
+
+# Bakes a global albedo map using the same shader the terrain uses,
+# but renders top-down in orthographic mode.
+
+@tool
+extends Node
+
+const HTerrain = preload("../hterrain.gd")
+const HTerrainData = preload("../hterrain_data.gd")
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+
+# Must be power of two
+const DEFAULT_VIEWPORT_SIZE = 512
+
+signal progress_notified(info)
+signal permanent_change_performed(message)
+
+var _terrain : HTerrain = null
+var _viewport : SubViewport = null
+var _viewport_size := DEFAULT_VIEWPORT_SIZE
+var _plane : MeshInstance3D = null
+var _camera : Camera3D = null
+var _sectors := []
+var _sector_index := 0
+
+
+func _ready():
+ set_process(false)
+
+
+func bake(terrain: HTerrain):
+ assert(terrain != null)
+ var data := terrain.get_data()
+ assert(data != null)
+ _terrain = terrain
+
+ var splatmap := data.get_texture(HTerrainData.CHANNEL_SPLAT)
+ var colormap := data.get_texture(HTerrainData.CHANNEL_COLOR)
+
+ var terrain_size := data.get_resolution()
+
+ if _viewport == null:
+ _setup_scene(terrain_size)
+
+ var cw := terrain_size / _viewport_size
+ var ch := terrain_size / _viewport_size
+ for y in ch:
+ for x in cw:
+ _sectors.append(Vector2(x, y))
+
+ var mat := _plane.material_override
+ _terrain.setup_globalmap_material(mat)
+
+ _sector_index = 0
+ set_process(true)
+
+
+func _setup_scene(terrain_size: int):
+ assert(_viewport == null)
+
+ _viewport_size = DEFAULT_VIEWPORT_SIZE
+ while _viewport_size > terrain_size:
+ _viewport_size /= 2
+
+ _viewport = SubViewport.new()
+ _viewport.size = Vector2(_viewport_size + 1, _viewport_size + 1)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS
+ # _viewport.render_target_v_flip = true
+ _viewport.world_3d = World3D.new()
+ _viewport.own_world_3d = true
+ _viewport.debug_draw = Viewport.DEBUG_DRAW_UNSHADED
+
+ var mat := ShaderMaterial.new()
+
+ _plane = MeshInstance3D.new()
+ # Make a very small mesh, vertex precision isn't required
+ var plane_res := 4
+ _plane.mesh = \
+ HTerrainMesher.make_flat_chunk(plane_res, plane_res, _viewport_size / plane_res, 0)
+ _plane.material_override = mat
+ _viewport.add_child(_plane)
+
+ _camera = Camera3D.new()
+ _camera.projection = Camera3D.PROJECTION_ORTHOGONAL
+ _camera.size = _viewport.size.x
+ _camera.near = 0.1
+ _camera.far = 10.0
+ _camera.current = true
+ _camera.rotation = Vector3(deg_to_rad(-90), 0, 0)
+ _viewport.add_child(_camera)
+
+ add_child(_viewport)
+
+
+func _cleanup_scene():
+ _viewport.queue_free()
+ _viewport = null
+ _plane = null
+ _camera = null
+
+
+func _process(delta):
+ if not is_processing():
+ return
+
+ if _sector_index > 0:
+ _grab_image(_sectors[_sector_index - 1])
+
+ if _sector_index >= len(_sectors):
+ set_process(false)
+ _finish()
+ progress_notified.emit({ "finished": true })
+ else:
+ _setup_pass(_sectors[_sector_index])
+ _report_progress()
+ _sector_index += 1
+
+
+func _report_progress():
+ var sector = _sectors[_sector_index]
+ progress_notified.emit({
+ "progress": float(_sector_index) / len(_sectors),
+ "message": "Calculating sector (" + str(sector.x) + ", " + str(sector.y) + ")"
+ })
+
+
+func _setup_pass(sector: Vector2):
+ # Note: we implicitely take off-by-one pixels into account
+ var origin := sector * _viewport_size
+ var center := origin + 0.5 * Vector2(_viewport.size)
+
+ # The heightmap is left empty, so will default to white, which is a height of 1.
+ # The camera must be placed above the terrain to see it.
+ _camera.position = Vector3(center.x, 2.0, center.y)
+ _plane.position = Vector3(origin.x, 0.0, origin.y)
+
+
+func _grab_image(sector: Vector2):
+ var tex := _viewport.get_texture()
+ var src := tex.get_image()
+
+ assert(_terrain != null)
+ var data := _terrain.get_data()
+ assert(data != null)
+
+ if data.get_map_count(HTerrainData.CHANNEL_GLOBAL_ALBEDO) == 0:
+ data._edit_add_map(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+
+ var dst := data.get_image(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+
+ src.convert(dst.get_format())
+ var origin = sector * _viewport_size
+ dst.blit_rect(src, Rect2i(0, 0, src.get_width(), src.get_height()), origin)
+
+
+func _finish():
+ assert(_terrain != null)
+ var data := _terrain.get_data() as HTerrainData
+ assert(data != null)
+ var dst := data.get_image(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+
+ data.notify_region_change(Rect2(0, 0, dst.get_width(), dst.get_height()),
+ HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+ permanent_change_performed.emit("Bake globalmap")
+
+ _cleanup_scene()
+ _terrain = null
diff --git a/game/addons/zylann.hterrain/tools/icons/empty.png b/game/addons/zylann.hterrain/tools/icons/empty.png
new file mode 100644
index 0000000..0a59813
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/empty.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/icons/empty.png.import b/game/addons/zylann.hterrain/tools/icons/empty.png.import
new file mode 100644
index 0000000..267b65d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/empty.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://vo2brkj3udel"
+path="res://.godot/imported/empty.png-31363f083c9c4e2e8e54cf64f3716737.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/empty.png"
+dest_files=["res://.godot/imported/empty.png-31363f083c9c4e2e8e54cf64f3716737.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/icons/icon_anchor_bottom.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg
new file mode 100644
index 0000000..f490172
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_bottom.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 8,14 4,8 H 6 V 2 h 4 v 6 h 2 z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import
new file mode 100644
index 0000000..a5e8479
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://l16qrsohg7jj"
+path="res://.godot/imported/icon_anchor_bottom.svg-963f115d31a41c38349ab03453cf2ef5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg"
+dest_files=["res://.godot/imported/icon_anchor_bottom.svg-963f115d31a41c38349ab03453cf2ef5.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg
new file mode 100644
index 0000000..05f255a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_bottom_left.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 3.0307757,12.969224 1.414214,-7.071068 1.414213,1.4142142 4.2426413,-4.2426412 2.828427,2.828427 -4.2426407,4.242641 1.4142137,1.414214 z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import
new file mode 100644
index 0000000..861cc99
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://30wsjmeemngg"
+path="res://.godot/imported/icon_anchor_bottom_left.svg-c59f20ff71f725e47b5fc556b5ef93c4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg"
+dest_files=["res://.godot/imported/icon_anchor_bottom_left.svg-c59f20ff71f725e47b5fc556b5ef93c4.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg
new file mode 100644
index 0000000..33e97ef
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_bottom_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 13,12.969224 5.9289322,11.55501 7.3431464,10.140797 3.1005052,5.8981558 5.9289322,3.0697288 10.171573,7.3123695 11.585787,5.8981558 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import
new file mode 100644
index 0000000..b24ab20
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cwb02kd3gr5v1"
+path="res://.godot/imported/icon_anchor_bottom_right.svg-23dd5f1d1c7021fe105f8bde603dcc4d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg"
+dest_files=["res://.godot/imported/icon_anchor_bottom_right.svg-23dd5f1d1c7021fe105f8bde603dcc4d.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg
new file mode 100644
index 0000000..8ab613a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_center.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
+ d="M 2,2 H 14 V 14 H 2 Z"
+ id="path830"
+ inkscape:connector-curvature="0" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import
new file mode 100644
index 0000000..a0d694c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bkphpwgf3vlqy"
+path="res://.godot/imported/icon_anchor_center.svg-d48605c4035ec4a02ae8159aea6db85f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_center.svg"
+dest_files=["res://.godot/imported/icon_anchor_center.svg-d48605c4035ec4a02ae8159aea6db85f.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg
new file mode 100644
index 0000000..7fd1a68
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_left.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 2,8 8,4 v 2 h 6 v 4 H 8 v 2 z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import
new file mode 100644
index 0000000..9d5b149
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cmydngdawudhq"
+path="res://.godot/imported/icon_anchor_left.svg-77f3e03e6fbadfd7e4dc1ab3661e6e7c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_left.svg"
+dest_files=["res://.godot/imported/icon_anchor_left.svg-77f3e03e6fbadfd7e4dc1ab3661e6e7c.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg
new file mode 100644
index 0000000..8ad32f1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 14,8 8,12 V 10 H 2 V 6 H 8 V 4 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import
new file mode 100644
index 0000000..42b5b1c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cut3xadorcxqt"
+path="res://.godot/imported/icon_anchor_right.svg-90e3a37e8d38587bac01703849f8b9f7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_right.svg"
+dest_files=["res://.godot/imported/icon_anchor_right.svg-90e3a37e8d38587bac01703849f8b9f7.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg
new file mode 100644
index 0000000..e15ed1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_top.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 8,2 4,6 h -2 v 6 H 6 V 8 H 4 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import
new file mode 100644
index 0000000..50e96dc
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cprd188dc3fc7"
+path="res://.godot/imported/icon_anchor_top.svg-f1dcf93e569fe43b280b5dc072ee78e5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top.svg"
+dest_files=["res://.godot/imported/icon_anchor_top.svg-f1dcf93e569fe43b280b5dc072ee78e5.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg
new file mode 100644
index 0000000..cd82a6f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_top_left.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 3.0307755,2.9999998 10.101844,4.4142138 8.6876298,5.8284268 12.930271,10.071068 10.101844,12.899495 5.8592027,8.6568544 4.4449891,10.071068 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import
new file mode 100644
index 0000000..832b11e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bugm51ryp0urq"
+path="res://.godot/imported/icon_anchor_top_left.svg-aea4438056394f9967bf74b13799fedc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg"
+dest_files=["res://.godot/imported/icon_anchor_top_left.svg-aea4438056394f9967bf74b13799fedc.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg
new file mode 100644
index 0000000..4619493
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_anchor_top_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.7923218"
+ inkscape:cy="7.6837119"
+ inkscape:window-x="156"
+ inkscape:window-y="100"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 13,3 11.585786,10.071068 10.171573,8.6568543 5.9289319,12.899495 3.1005047,10.071068 7.3431454,5.8284272 5.9289319,4.4142136 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import
new file mode 100644
index 0000000..146b057
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bebc1xsqguspq"
+path="res://.godot/imported/icon_anchor_top_right.svg-e9f520f41c9c20cc5e64aca56427ca01.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg"
+dest_files=["res://.godot/imported/icon_anchor_top_right.svg-e9f520f41c9c20cc5e64aca56427ca01.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg
new file mode 100644
index 0000000..f5d8156
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_detail_layer_node.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:zoom="41.7193"
+ inkscape:cx="4.6131224"
+ inkscape:cy="7.1663773"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2"
+ inkscape:object-paths="false"
+ inkscape:snap-smooth-nodes="false"
+ inkscape:object-nodes="true"
+ inkscape:snap-grids="false"
+ inkscape:snap-to-guides="true">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 3,15 C 3,15 3.1390243,13.835072 2.827418,12.420858 2.5397814,11.150462 1,9 1,9 1,9 2.9698195,10.552501 3.5930323,11.679078 4.5757909,13.021382 5,15 5,15 Z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 5,15 C 5,15 5.2636669,12.287637 5,10.513877 4.8082422,8.8359968 3.4793944,5 3.4793944,5 c 0,0 1.8082423,2.5214194 2.5206056,5 1,3.235912 1,5 1,5 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 7,15 C 7,15 7.5561735,6.4731267 7.5731227,5.3205843 7.6578684,4.2019402 8,1.1355932 8,1.1355932 c 0,0 0.3992971,3.1361329 0.4501446,4.2886753 C 8.5857378,7.0852854 9,15 9,15 Z"
+ id="path4146"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 9,15 C 9,15 9.5761564,11.857317 10.007611,10.562952 10.747482,8.4130973 12,6 12,6 12,6 11.115351,9.5993511 10.923593,11.181353 10.659926,13.003052 11,15 11,15 Z"
+ id="path4148"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 11,15 c 0,0 -0.08106,-0.693375 0.733907,-2.395225 C 12.596817,10.735137 15,9 15,9 15,9 13.425134,10.982994 12.925634,12.583274 12.434254,14.309094 13,15 13,15 Z"
+ id="path4148-1"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import
new file mode 100644
index 0000000..941c747
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://6jmdwj3vn6m0"
+path="res://.godot/imported/icon_detail_layer_node.svg-70daba484432569847b1d2fe22768af3.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg"
+dest_files=["res://.godot/imported/icon_detail_layer_node.svg-70daba484432569847b1d2fe22768af3.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_grass.svg b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg
new file mode 100644
index 0000000..7866628
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_grass.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:zoom="41.7193"
+ inkscape:cx="8.5082021"
+ inkscape:cy="7.1663773"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2"
+ inkscape:object-paths="false"
+ inkscape:snap-smooth-nodes="false"
+ inkscape:object-nodes="true"
+ inkscape:snap-grids="false"
+ inkscape:snap-to-guides="true">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 3,15 C 3,15 3.1390243,13.835072 2.827418,12.420858 2.5397814,11.150462 1,9 1,9 1,9 2.9698195,10.552501 3.5930323,11.679078 4.5757909,13.021382 5,15 5,15 Z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 5,15 C 5,15 5.2636669,12.287637 5,10.513877 4.8082422,8.8359968 3.4793944,5 3.4793944,5 c 0,0 1.8082423,2.5214194 2.5206056,5 1,3.235912 1,5 1,5 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 7,15 C 7,15 7.5561735,6.4731267 7.5731227,5.3205843 7.6578684,4.2019402 8,1.1355932 8,1.1355932 c 0,0 0.3992971,3.1361329 0.4501446,4.2886753 C 8.5857378,7.0852854 9,15 9,15 Z"
+ id="path4146"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 9,15 C 9,15 9.5761564,11.857317 10.007611,10.562952 10.747482,8.4130973 12,6 12,6 12,6 11.115351,9.5993511 10.923593,11.181353 10.659926,13.003052 11,15 11,15 Z"
+ id="path4148"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 11,15 c 0,0 -0.08106,-0.693375 0.733907,-2.395225 C 12.596817,10.735137 15,9 15,9 15,9 13.425134,10.982994 12.925634,12.583274 12.434254,14.309094 13,15 13,15 Z"
+ id="path4148-1"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import
new file mode 100644
index 0000000..ad48667
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_grass.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://coicfqoi4spg0"
+path="res://.godot/imported/icon_grass.svg-6a20eb11bc23d46b8a4c0f365f95554b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_grass.svg"
+dest_files=["res://.godot/imported/icon_grass.svg-6a20eb11bc23d46b8a4c0f365f95554b.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg
new file mode 100644
index 0000000..2b20f6a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_color.svg">
+ <metadata
+ id="metadata24">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs22" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1144"
+ inkscape:window-height="700"
+ id="namedview20"
+ showgrid="false"
+ inkscape:zoom="29.5"
+ inkscape:cx="7.9557659"
+ inkscape:cy="7.5167559"
+ inkscape:window-x="196"
+ inkscape:window-y="116"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2" />
+ <g
+ transform="translate(0 -1036.4)"
+ id="g4">
+ <path
+ transform="translate(0 1036.4)"
+ d="m13.303 1c-0.4344 0-0.86973 0.16881-1.2012 0.50586l-1.4688 1.4941h4.3418c0.082839-0.52789-0.072596-1.0872-0.47266-1.4941-0.33144-0.33705-0.76482-0.50586-1.1992-0.50586z"
+ fill="#ff7070"
+ id="path6" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m10.633 3l-1.9668 2h4.8008l1.0352-1.0527c0.2628-0.2673 0.41824-0.60049 0.47266-0.94727h-4.3418z"
+ fill="#ffeb70"
+ id="path8" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m8.666 5l-1.9648 2h4.7988l1.9668-2h-4.8008z"
+ fill="#9dff70"
+ id="path10" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m6.7012 7l-1.4004 1.4238 0.56641 0.57617h3.668l1.9648-2h-4.7988z"
+ fill="#70ffb9"
+ id="path12" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m5.8672 9l1.834 1.8652 1.834-1.8652h-3.668zm-1.752 0.57812c-0.48501-0.048725-0.90521 0.12503-1.1953 0.45508-0.21472 0.24426-0.35243 0.57797-0.39844 0.9668h3.5625c-0.10223-0.1935-0.22224-0.37965-0.38281-0.54297-0.55011-0.55955-1.1009-0.83018-1.5859-0.87891z"
+ fill="#70deff"
+ id="path14" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m1.3242 13c0.18414 0.24071 0.43707 0.53374 0.83789 0.94141 0.88382 0.899 2.6552 0.67038 3.5391-0.22852 0.20747-0.21103 0.36064-0.45476 0.4707-0.71289h-4.8477z"
+ fill="#ff70ac"
+ id="path16" />
+ <path
+ transform="translate(0 1036.4)"
+ d="m2.5215 11c-0.0105 0.088737-0.021484 0.17696-0.021484 0.27148 0 1.3947-2.2782 0.28739-1.1758 1.7285h4.8477c0.27363-0.64173 0.24047-1.3785-0.087891-2h-3.5625z"
+ fill="#9f70ff"
+ id="path18" />
+ </g>
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import
new file mode 100644
index 0000000..a72fade
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://t1m58v6iitwb"
+path="res://.godot/imported/icon_heightmap_color.svg-2b3375697cab4a6c7b8d933fc7f2b982.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg"
+dest_files=["res://.godot/imported/icon_heightmap_color.svg-2b3375697cab4a6c7b8d933fc7f2b982.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg
new file mode 100644
index 0000000..03c0caf
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_data.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="29.5"
+ inkscape:cx="8.3604136"
+ inkscape:cy="6.4516445"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#ffd684;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-6 4,-7 3,6 2,-2 5,5 0,4 z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import
new file mode 100644
index 0000000..9db362d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cd13q0qxqt8cd"
+path="res://.godot/imported/icon_heightmap_data.svg-00236b6035ce13dd687a19d98237bdbd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg"
+dest_files=["res://.godot/imported/icon_heightmap_data.svg-00236b6035ce13dd687a19d98237bdbd.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg
new file mode 100644
index 0000000..22824a2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_heightmap_erode.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="22.627417"
+ inkscape:cx="6.6927042"
+ inkscape:cy="3.6274493"
+ inkscape:window-x="213"
+ inkscape:window-y="134"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 1,15 V 11 H 1.40625 C 2.40625,11 4,9 5,8 c 3,3 4,-2 5,-3 2,4 4.44321,5.97721 4.752221,6 H 15 v 4 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 7.4375,5.34375 6,7"
+ id="path4518"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 3.4375,5.34375 2,7"
+ id="path4518-0"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 3.4375,1.96875 2,3.625"
+ id="path4518-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 7.4375,1.9375 6,3.59375"
+ id="path4518-7"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 14.03125,5.34375 12.59375,7"
+ id="path4518-5"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#e0e0e0;stroke-width:0.69999999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ d="M 13,2 11.5625,3.65625"
+ id="path4518-3"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import
new file mode 100644
index 0000000..c1424e6
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ci3jif8wx2tig"
+path="res://.godot/imported/icon_heightmap_erode.svg-fad285f0810d69bec16027ac0257c223.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg"
+dest_files=["res://.godot/imported/icon_heightmap_erode.svg-fad285f0810d69bec16027ac0257c223.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg
new file mode 100644
index 0000000..c4d3268
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg
@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_flatten.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="9.7058237"
+ inkscape:cy="8.5274381"
+ inkscape:window-x="365"
+ inkscape:window-y="160"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5378"
+ width="1"
+ height="1"
+ x="1"
+ y="14" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5380"
+ width="1"
+ height="1"
+ x="1"
+ y="12" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5382"
+ width="1"
+ height="1"
+ x="1"
+ y="10" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5384"
+ width="1"
+ height="1"
+ x="1"
+ y="8" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5386"
+ width="1"
+ height="1"
+ x="1"
+ y="6" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5388"
+ width="1"
+ height="1"
+ x="1"
+ y="4" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5390"
+ width="1"
+ height="1"
+ x="1"
+ y="2" />
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect5394"
+ width="12"
+ height="7"
+ x="3"
+ y="8" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import
new file mode 100644
index 0000000..9c1c4bf
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://6bnt8lvvabf1"
+path="res://.godot/imported/icon_heightmap_flatten.svg-3d183c33fce9f34c419c53418ef26264.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg"
+dest_files=["res://.godot/imported/icon_heightmap_flatten.svg-3d183c33fce9f34c419c53418ef26264.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg
new file mode 100644
index 0000000..d7c27e9
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_heightmap_level.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="45.254834"
+ inkscape:cx="4.155788"
+ inkscape:cy="7.8347394"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 1,6 C 2,8 4,8.9722718 8,8.9722718 12,8.9722718 14,10 15,12 v 3 H 1 Z"
+ id="path5360"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cscccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 6.8785322,3.363427 c 0,0 2.1754294,2.616635 4.0821628,3.2996386 0,0 1.356257,0.5732925 2.410224,0.2317564 0,0 0.585083,-0.023232 0.850573,-0.894822 C 14.572655,4.057836 13.309359,3.7206006 13.309359,3.7206006 11.88355,3.1179083 11.514409,4.5359712 11.514409,4.5359712 10.709977,5.2535353 6.8785322,3.363427 6.8785322,3.363427 Z"
+ id="path4136"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import
new file mode 100644
index 0000000..29294ec
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c6trw4wyo86x4"
+path="res://.godot/imported/icon_heightmap_level.svg-0abbb78afcf28f4da15188c85861a768.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg"
+dest_files=["res://.godot/imported/icon_heightmap_level.svg-0abbb78afcf28f4da15188c85861a768.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg
new file mode 100644
index 0000000..87101c3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_lower.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="7.4686221"
+ inkscape:cy="9.0616705"
+ inkscape:window-x="70"
+ inkscape:window-y="136"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 1,15 1,5 2,5 c 2.0108349,-0.022097 2.9891651,5 5,5 l 2,0 c 2.032932,-0.022097 3.011262,-4.9779029 5,-5 l 1,0 0,10 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import
new file mode 100644
index 0000000..97163b3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://da02ckrgusaoe"
+path="res://.godot/imported/icon_heightmap_lower.svg-5bb5cae46ea03f9d65d6c497a65882db.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg"
+dest_files=["res://.godot/imported/icon_heightmap_lower.svg-5bb5cae46ea03f9d65d6c497a65882db.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg
new file mode 100644
index 0000000..751b683
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_mask.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1589"
+ inkscape:window-height="856"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="6.8076485"
+ inkscape:cy="7.7003818"
+ inkscape:window-x="137"
+ inkscape:window-y="97"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 11.49953,1.171875 c -1.166824,0 -2.3207558,1.1527185 -2.3336474,1.7003605 l 0,2.9105135 -3.5004705,3.4581554 -2.3336469,-2.305437 c -5.615338,5.7635926 0,11.2633516 5.8341174,5.7635926 l -2.333647,-2.305437 3.5004704,-3.4581556 3.062153,0 C 13.820285,6.9354674 15,5.7917546 15,4.6300305 Z m -0.960556,2.305437 c 0,0 -0.003,-0.7398046 0.73179,-0.7780123 l 2.253012,2.2556182 c 0.01289,0.6877366 -0.857423,0.6367931 -0.857423,0.6367931 l -2.140272,0.012736 z m -5.12495,5.9773548 0.7999793,0.7969852 -2.4970952,1.875551 z"
+ id="path4134"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccccccccccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import
new file mode 100644
index 0000000..7305e92
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://btb4kkafvn67j"
+path="res://.godot/imported/icon_heightmap_mask.svg-3fad663c59a229c1c6c17c4e8d5bad09.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg"
+dest_files=["res://.godot/imported/icon_heightmap_mask.svg-3fad663c59a229c1c6c17c4e8d5bad09.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg
new file mode 100644
index 0000000..00fb889
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_node.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="29.5"
+ inkscape:cx="3.7841424"
+ inkscape:cy="7.6041869"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-6 4,-7 3,6 2,-2 5,5 0,4 z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import
new file mode 100644
index 0000000..aa38f33
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ofs3osrmxfi"
+path="res://.godot/imported/icon_heightmap_node.svg-0b776ad0015c7d9d9553b161b36e70fe.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg"
+dest_files=["res://.godot/imported/icon_heightmap_node.svg-0b776ad0015c7d9d9553b161b36e70fe.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg
new file mode 100644
index 0000000..00fb889
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_node.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="29.5"
+ inkscape:cx="3.7841424"
+ inkscape:cy="7.6041869"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4140" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#fc9c9c;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-6 4,-7 3,6 2,-2 5,5 0,4 z"
+ id="path4142"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import
new file mode 100644
index 0000000..3478c92
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://sdaddk8wxjin"
+path="res://.godot/imported/icon_heightmap_node_large.svg-4b8ff9077cb0d8dc06efcf638cce1edb.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg"
+dest_files=["res://.godot/imported/icon_heightmap_node_large.svg-4b8ff9077cb0d8dc06efcf638cce1edb.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
+svg/scale=8.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg
new file mode 100644
index 0000000..72503fa
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg
@@ -0,0 +1,5 @@
+<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g transform="translate(0 -1036.4)">
+<path d="m2.9208 1046.4c-0.26373 0.3-0.4204 0.7296-0.4204 1.2383 0 1.6277-3.1381-0.1781-0.33757 2.6703 0.88382 0.899 2.6544 0.6701 3.5382-0.2288 0.88384-0.899 0.88382-2.3565 0-3.2554-1.1002-1.1191-2.2001-1.0845-2.7803-0.4244zm2.3802-1.6103 2.4005 2.4416 6.8014-6.9177c0.66286-0.6742 0.66286-1.7673 0-2.4415-0.66288-0.6741-1.7376-0.6741-2.4005 0z" fill="#e0e0e0" fill-opacity=".99608"/>
+</g>
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import
new file mode 100644
index 0000000..8d325c1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cgw6r4eg1msvn"
+path="res://.godot/imported/icon_heightmap_paint.svg-ad4c1d13ab344959f8e60b793d52d80d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg"
+dest_files=["res://.godot/imported/icon_heightmap_paint.svg-ad4c1d13ab344959f8e60b793d52d80d.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg
new file mode 100644
index 0000000..0e3033f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_raise.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="9.7284466"
+ inkscape:cy="8.2340886"
+ inkscape:window-x="213"
+ inkscape:window-y="134"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-5 1,0 C 4.0108349,10 5.0112622,5 7,5 l 2,0 c 2.010835,-0.022097 3.033359,5 5,5 l 1,0 0,5 z"
+ id="path4144"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import
new file mode 100644
index 0000000..b856d27
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://0polw88tpkl4"
+path="res://.godot/imported/icon_heightmap_raise.svg-16ae516b9460ce83d04d965ed6b9989a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg"
+dest_files=["res://.godot/imported/icon_heightmap_raise.svg-16ae516b9460ce83d04d965ed6b9989a.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg
new file mode 100644
index 0000000..c9d586a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_smooth.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="16"
+ inkscape:cx="-4.9969866"
+ inkscape:cy="6.6592524"
+ inkscape:window-x="365"
+ inkscape:window-y="160"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 1,15 0,-4 c 7.46875,-6 14,0 14,0 l 0,4 z"
+ id="path5360"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#e0e0e0;fill-opacity:0.99607843;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 6.364123,3.8274558 c 0,0 2.1754294,2.616635 4.082163,3.2996386 0,0 1.356257,0.5732925 2.410224,0.2317564 0,0 0.585083,-0.023232 0.850573,-0.894822 C 14.058246,4.5218648 12.79495,4.1846294 12.79495,4.1846294 11.369141,3.5819371 11,5 11,5 10.195568,5.7175641 6.364123,3.8274558 6.364123,3.8274558 Z"
+ id="path4136"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import
new file mode 100644
index 0000000..a7bff5d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b6hlgjb4fa31c"
+path="res://.godot/imported/icon_heightmap_smooth.svg-1216ccdd3a408b8769b0a0964b7bd3f9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg"
+dest_files=["res://.godot/imported/icon_heightmap_smooth.svg-1216ccdd3a408b8769b0a0964b7bd3f9.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg
new file mode 100644
index 0000000..24f39a4
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="icon_heightmap_unmask.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="8.4714029"
+ inkscape:cy="7.9844314"
+ inkscape:window-x="70"
+ inkscape:window-y="136"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect4140"
+ width="12"
+ height="12"
+ x="2"
+ y="2"
+ ry="2" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import
new file mode 100644
index 0000000..48dc9ea
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b4ya0po3a4nqa"
+path="res://.godot/imported/icon_heightmap_unmask.svg-f88c0addb6f444beecc364dd218d67e9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg"
+dest_files=["res://.godot/imported/icon_heightmap_unmask.svg-f88c0addb6f444beecc364dd218d67e9.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg
new file mode 100644
index 0000000..0cc022a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="64"
+ version="1.1"
+ viewBox="0 0 16 64"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_long_arrow_down.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="11.313709"
+ inkscape:cx="-2.0957389"
+ inkscape:cy="34.028204"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 9,4 0,45 h 3 L 7,60 2,49 H 5 L 5,4 Z"
+ id="path816-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import
new file mode 100644
index 0000000..70f4a55
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b6l5dys0awbwd"
+path="res://.godot/imported/icon_long_arrow_down.svg-baa34c94eaf2f9f3533b079350dd260b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg"
+dest_files=["res://.godot/imported/icon_long_arrow_down.svg-baa34c94eaf2f9f3533b079350dd260b.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg
new file mode 100644
index 0000000..d4e8c2b
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="64"
+ height="16"
+ version="1.1"
+ viewBox="0 0 64 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_long_arrow_right.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="16"
+ inkscape:cx="26.365888"
+ inkscape:cy="17.303376"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 4,6 H 49 V 3 L 60,8 49,13 V 10 H 4 Z"
+ id="path816-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import
new file mode 100644
index 0000000..4d77e3d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://d3vie0tj3ry6k"
+path="res://.godot/imported/icon_long_arrow_right.svg-2e9c5428ca49af0df04372d4de12fdd2.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg"
+dest_files=["res://.godot/imported/icon_long_arrow_right.svg-2e9c5428ca49af0df04372d4de12fdd2.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg
new file mode 100644
index 0000000..34e2ab7
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_minimap_out_of_range_position.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="45.254834"
+ inkscape:cx="9.6162478"
+ inkscape:cy="7.4237281"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <circle
+ style="opacity:1;fill:#8eb3f3;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.9997896;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="path1480"
+ cx="7.9972649"
+ cy="8"
+ r="5" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import
new file mode 100644
index 0000000..0e73229
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cc47smy24m368"
+path="res://.godot/imported/icon_minimap_out_of_range_position.svg-be0d8e592b6594137b0f40434b64f771.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg"
+dest_files=["res://.godot/imported/icon_minimap_out_of_range_position.svg-be0d8e592b6594137b0f40434b64f771.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg
new file mode 100644
index 0000000..468c52d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="16"
+ height="16"
+ version="1.1"
+ viewBox="0 0 16 16"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_minimap_position.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="22.627417"
+ inkscape:cx="4.7772707"
+ inkscape:cy="9.4483902"
+ inkscape:window-x="-8"
+ inkscape:window-y="32"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <path
+ style="fill:#8eb3f3;fill-opacity:1;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 14,8 2,13 4,8 2,3 Z"
+ id="path816"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import
new file mode 100644
index 0000000..5b864e3
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c1el0dmyvaaij"
+path="res://.godot/imported/icon_minimap_position.svg-09c3263e8852c7010dcfa0a85245403d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_minimap_position.svg"
+dest_files=["res://.godot/imported/icon_minimap_position.svg-09c3263e8852c7010dcfa0a85245403d.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg
new file mode 100644
index 0000000..b5a004d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="8"
+ height="8"
+ version="1.1"
+ viewBox="0 0 8 8"
+ id="svg2"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="icon_small_circle.svg">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1461"
+ inkscape:window-height="826"
+ id="namedview8"
+ showgrid="true"
+ inkscape:snap-grids="true"
+ inkscape:snap-global="true"
+ inkscape:snap-bbox="false"
+ inkscape:snap-page="true"
+ inkscape:object-nodes="true"
+ inkscape:zoom="32"
+ inkscape:cx="3.3932779"
+ inkscape:cy="7.9844314"
+ inkscape:window-x="70"
+ inkscape:window-y="136"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="svg2">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4142" />
+ </sodipodi:namedview>
+ <rect
+ style="fill:#e0e0e0;fill-opacity:0.99607843;stroke:none;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99607843"
+ id="rect4140"
+ width="6"
+ height="6"
+ x="1"
+ y="1"
+ ry="2.5" />
+</svg>
diff --git a/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import
new file mode 100644
index 0000000..92cf893
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdkcgtv1r5j31"
+path="res://.godot/imported/icon_small_circle.svg-758362406034e77f78350899f9b2cf34.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/icon_small_circle.svg"
+dest_files=["res://.godot/imported/icon_small_circle.svg-758362406034e77f78350899f9b2cf34.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/zylann.hterrain/tools/icons/white.png b/game/addons/zylann.hterrain/tools/icons/white.png
new file mode 100644
index 0000000..dee54f4
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/white.png
Binary files differ
diff --git a/game/addons/zylann.hterrain/tools/icons/white.png.import b/game/addons/zylann.hterrain/tools/icons/white.png.import
new file mode 100644
index 0000000..a0d73bf
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/icons/white.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dnee27oabcvy2"
+path.s3tc="res://.godot/imported/white.png-06b7d7f95e74cd7f8357ec25a73870fb.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/zylann.hterrain/tools/icons/white.png"
+dest_files=["res://.godot/imported/white.png-06b7d7f95e74cd7f8357ec25a73870fb.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+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=0
diff --git a/game/addons/zylann.hterrain/tools/importer/importer_dialog.gd b/game/addons/zylann.hterrain/tools/importer/importer_dialog.gd
new file mode 100644
index 0000000..5f57bb1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/importer/importer_dialog.gd
@@ -0,0 +1,312 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../util/util.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HT_XYZFormat = preload("../../util/xyz_format.gd")
+const HT_Inspector = preload("../inspector/inspector.gd")
+
+signal permanent_change_performed(message)
+
+@onready var _inspector : HT_Inspector = $VBoxContainer/Inspector
+@onready var _errors_label : Label = $VBoxContainer/ColorRect/ScrollContainer/VBoxContainer/Errors
+@onready var _warnings_label : Label = \
+ $VBoxContainer/ColorRect/ScrollContainer/VBoxContainer/Warnings
+
+const RAW_LITTLE_ENDIAN = 0
+const RAW_BIG_ENDIAN = 1
+
+var _terrain : HTerrain = null
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _ready():
+ _inspector.set_prototype({
+ "heightmap": {
+ "type": TYPE_STRING,
+ "usage": "file",
+ "exts": ["raw", "png", "exr", "xyz"]
+ },
+ "raw_endianess": {
+ "type": TYPE_INT,
+ "usage": "enum",
+ "enum_items": ["Little Endian", "Big Endian"],
+ "enabled": false
+ },
+ "min_height": {
+ "type": TYPE_FLOAT,
+ "range": {"min": -2000.0, "max": 2000.0, "step": 0.01},
+ "default_value": 0.0
+ },
+ "max_height": {
+ "type": TYPE_FLOAT,
+ "range": {"min": -2000.0, "max": 2000.0, "step": 0.01},
+ "default_value": 400.0
+ },
+ "splatmap": {
+ "type": TYPE_STRING,
+ "usage": "file",
+ "exts": ["png"]
+ },
+ "colormap": {
+ "type": TYPE_STRING,
+ "usage": "file",
+ "exts": ["png"]
+ }
+ })
+
+ # Testing
+# _errors_label.text = "- Hello World!"
+# _warnings_label.text = "- Yolo Jesus!"
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ # Checking a node set in _ready,
+ # because visibility can also change between _enter_tree and _ready...
+ if visible and _inspector != null:
+ _clear_feedback()
+
+
+static func _format_feedbacks(feed):
+ var a := []
+ for s in feed:
+ a.append("- " + s)
+ return "\n".join(PackedStringArray(a))
+
+
+func _clear_feedback():
+ _errors_label.text = ""
+ _warnings_label.text = ""
+
+
+class HT_ErrorCheckReport:
+ var errors := []
+ var warnings := []
+
+
+func _show_feedback(res: HT_ErrorCheckReport):
+ for e in res.errors:
+ _logger.error(e)
+
+ for w in res.warnings:
+ _logger.warn(w)
+
+ _clear_feedback()
+
+ if len(res.errors) > 0:
+ _errors_label.text = _format_feedbacks(res.errors)
+
+ if len(res.warnings) > 0:
+ _warnings_label.text = _format_feedbacks(res.warnings)
+
+
+func _on_CheckButton_pressed():
+ var res := _validate_form()
+ _show_feedback(res)
+
+
+func _on_ImportButton_pressed():
+ assert(_terrain != null and _terrain.get_data() != null)
+
+ # Verify input to inform the user of potential issues
+ var res := _validate_form()
+ _show_feedback(res)
+
+ if len(res.errors) != 0:
+ _logger.debug("Cannot import due to errors, aborting")
+ return
+
+ var params := {}
+
+ var heightmap_path = _inspector.get_value("heightmap")
+ if heightmap_path != "":
+ var endianess = _inspector.get_value("raw_endianess")
+ params[HTerrainData.CHANNEL_HEIGHT] = {
+ "path": heightmap_path,
+ "min_height": _inspector.get_value("min_height"),
+ "max_height": _inspector.get_value("max_height"),
+ "big_endian": endianess == RAW_BIG_ENDIAN
+ }
+
+ var colormap_path = _inspector.get_value("colormap")
+ if colormap_path != "":
+ params[HTerrainData.CHANNEL_COLOR] = {
+ "path": colormap_path
+ }
+
+ var splatmap_path = _inspector.get_value("splatmap")
+ if splatmap_path != "":
+ params[HTerrainData.CHANNEL_SPLAT] = {
+ "path": splatmap_path
+ }
+
+ var data = _terrain.get_data()
+ data._edit_import_maps(params)
+ emit_signal("permanent_change_performed", "Import maps")
+
+ _logger.debug("Terrain import finished")
+ hide()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_Inspector_property_changed(key: String, value):
+ if key == "heightmap":
+ var is_raw = value.get_extension().to_lower() == "raw"
+ _inspector.set_property_enabled("raw_endianess", is_raw)
+
+
+func _validate_form() -> HT_ErrorCheckReport:
+ var res := HT_ErrorCheckReport.new()
+
+ var heightmap_path : String = _inspector.get_value("heightmap")
+ var splatmap_path : String = _inspector.get_value("splatmap")
+ var colormap_path : String = _inspector.get_value("colormap")
+
+ if colormap_path == "" and heightmap_path == "" and splatmap_path == "":
+ res.errors.append("No maps specified.")
+ return res
+
+ # If a heightmap is specified, it will override the size of the existing terrain.
+ # If not specified, maps will have to match the resolution of the existing terrain.
+ var heightmap_size := _terrain.get_data().get_resolution()
+
+ if heightmap_path != "":
+ var min_height = _inspector.get_value("min_height")
+ var max_height = _inspector.get_value("max_height")
+
+ if min_height >= max_height:
+ res.errors.append("Minimum height must be lower than maximum height")
+ # Returning early because min and max can be slided,
+ # so we avoid loading other maps every time to do further checks.
+ return res
+
+ var image_size_result = _load_image_size(heightmap_path, _logger)
+ if image_size_result.error_code != OK:
+ res.errors.append(str("Cannot open heightmap file: ", image_size_result.to_string()))
+ return res
+
+ var adjusted_size = HTerrainData.get_adjusted_map_size(
+ image_size_result.width, image_size_result.height)
+
+ if adjusted_size != image_size_result.width:
+ res.warnings.append(
+ "The square resolution deduced from heightmap file size is not power of two + 1.\n" + \
+ "The heightmap will be cropped.")
+
+ heightmap_size = adjusted_size
+
+ if splatmap_path != "":
+ _check_map_size(splatmap_path, "splatmap", heightmap_size, res, _logger)
+
+ if colormap_path != "":
+ _check_map_size(colormap_path, "colormap", heightmap_size, res, _logger)
+
+ return res
+
+
+static func _check_map_size(path: String, map_name: String, heightmap_size: int,
+ res: HT_ErrorCheckReport, logger):
+
+ var size_result := _load_image_size(path, logger)
+ if size_result.error_code != OK:
+ res.errors.append(str("Cannot open splatmap file: ", size_result.to_string()))
+ return
+ var adjusted_size := HTerrainData.get_adjusted_map_size(size_result.width, size_result.height)
+ if adjusted_size != heightmap_size:
+ res.errors.append(str(
+ "The ", map_name,
+ " must have the same resolution as the heightmap (", heightmap_size, ")"))
+ else:
+ if adjusted_size != size_result.width:
+ res.warnings.append(str(
+ "The square resolution deduced from ", map_name,
+ " file size is not power of two + 1.\nThe ",
+ map_name, " will be cropped."))
+
+
+class HT_ImageSizeResult:
+ var width := 0
+ var height := 0
+ var error_code := OK
+ var error_message := ""
+
+ func to_string() -> String:
+ if error_message != "":
+ return error_message
+ return HT_Errors.get_message(error_code)
+
+
+static func _load_image_size(path: String, logger) -> HT_ImageSizeResult:
+ var ext := path.get_extension().to_lower()
+ var result := HT_ImageSizeResult.new()
+
+ if ext == "png" or ext == "exr":
+ # Godot can load these formats natively
+ var im := Image.new()
+ var err := im.load(path)
+ if err != OK:
+ logger.error("An error occurred loading image '{0}', code {1}".format([path, err]))
+ result.error_code = err
+ return result
+
+ result.width = im.get_width()
+ result.height = im.get_height()
+ return result
+
+ elif ext == "raw":
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ logger.error("Error opening file {0}".format([path]))
+ result.error_code = err
+ return result
+
+ # Assume the raw data is square in 16-bit format,
+ # so its size is function of file length
+ var flen := f.get_length()
+ f = null
+ var size_px = HT_Util.integer_square_root(flen / 2)
+ if size_px == -1:
+ result.error_code = ERR_INVALID_DATA
+ result.error_message = "RAW image is not square"
+ return result
+
+ logger.debug("Deduced RAW heightmap resolution: {0}*{1}, for a length of {2}" \
+ .format([size_px, size_px, flen]))
+
+ result.width = size_px
+ result.height = size_px
+ return result
+
+ elif ext == "xyz":
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ logger.error("Error opening file {0}".format([path]))
+ result.error_code = err
+ return result
+
+ var bounds := HT_XYZFormat.load_bounds(f)
+
+ result.width = bounds.image_width
+ result.height = bounds.image_height
+ return result
+
+ else:
+ result.error_code = ERR_FILE_UNRECOGNIZED
+ return result
diff --git a/game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn b/game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn
new file mode 100644
index 0000000..0c2d267
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/importer/importer_dialog.tscn
@@ -0,0 +1,87 @@
+[gd_scene load_steps=4 format=3 uid="uid://on7x7xkovsc8"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/importer/importer_dialog.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/inspector/inspector.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="3"]
+
+[node name="WindowDialog" type="AcceptDialog"]
+title = "Import maps"
+size = Vector2i(500, 433)
+min_size = Vector2i(500, 380)
+script = ExtResource("1")
+
+[node name="VBoxContainer" 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 = -18.0
+
+[node name="Label" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+text = "Select maps to import. Leave empty if you don't need some."
+
+[node name="Spacer" type="Control" parent="VBoxContainer"]
+custom_minimum_size = Vector2(0, 16)
+layout_mode = 2
+
+[node name="Inspector" parent="VBoxContainer" instance=ExtResource("2")]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="ColorRect" type="ColorRect" parent="VBoxContainer"]
+custom_minimum_size = Vector2(0, 100)
+layout_mode = 2
+color = Color(0, 0, 0, 0.417529)
+
+[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer/ColorRect"]
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+
+[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/ColorRect/ScrollContainer"]
+layout_mode = 2
+
+[node name="Errors" type="Label" parent="VBoxContainer/ColorRect/ScrollContainer/VBoxContainer"]
+self_modulate = Color(1, 0.203125, 0.203125, 1)
+layout_mode = 2
+
+[node name="Warnings" type="Label" parent="VBoxContainer/ColorRect/ScrollContainer/VBoxContainer"]
+self_modulate = Color(1, 0.901428, 0.257813, 1)
+layout_mode = 2
+
+[node name="Spacer2" type="Control" parent="VBoxContainer"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="ButtonsArea" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+mouse_filter = 0
+alignment = 1
+
+[node name="CheckButton" type="Button" parent="VBoxContainer/ButtonsArea"]
+layout_mode = 2
+text = "Check"
+
+[node name="ImportButton" type="Button" parent="VBoxContainer/ButtonsArea"]
+layout_mode = 2
+text = "Import"
+
+[node name="CancelButton" type="Button" parent="VBoxContainer/ButtonsArea"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("3")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 492.0
+offset_bottom = 415.0
+
+[connection signal="property_changed" from="VBoxContainer/Inspector" to="." method="_on_Inspector_property_changed"]
+[connection signal="pressed" from="VBoxContainer/ButtonsArea/CheckButton" to="." method="_on_CheckButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/ButtonsArea/ImportButton" to="." method="_on_ImportButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/ButtonsArea/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/inspector/inspector.gd b/game/addons/zylann.hterrain/tools/inspector/inspector.gd
new file mode 100644
index 0000000..6039cee
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/inspector/inspector.gd
@@ -0,0 +1,479 @@
+
+# GDScript implementation of an inspector.
+# It generates controls for a provided list of properties,
+# which is easier to maintain than placing them by hand and connecting things in the editor.
+
+@tool
+extends Control
+
+const USAGE_FILE = "file"
+const USAGE_ENUM = "enum"
+
+signal property_changed(key, value)
+
+# Used for most simple types
+class HT_InspectorEditor:
+ var control = null
+ var getter := Callable()
+ var setter := Callable()
+ var key_label : Label
+
+
+# Used when the control cannot hold the actual value
+class HT_InspectorResourceEditor extends HT_InspectorEditor:
+ var value = null
+ var label = null
+
+ func get_value():
+ return value
+
+ func set_value(v):
+ value = v
+ label.text = "null" if v == null else v.resource_path
+
+
+class HT_InspectorVectorEditor extends HT_InspectorEditor:
+ signal value_changed(v)
+
+ var value := Vector2()
+ var xed = null
+ var yed = null
+
+ func get_value():
+ return value
+
+ func set_value(v):
+ xed.value = v.x
+ yed.value = v.y
+ value = v
+
+ func _component_changed(v, i):
+ value[i] = v
+ value_changed.emit(value)
+
+
+# TODO Rename _schema
+var _prototype = null
+var _edit_signal := true
+# name => editor
+var _editors := {}
+
+# Had to separate the container because otherwise I can't open dialogs properly...
+@onready var _grid_container = get_node("GridContainer")
+@onready var _file_dialog = get_node("OpenFileDialog")
+
+
+func _ready():
+ _file_dialog.visibility_changed.connect(
+ call_deferred.bind("_on_file_dialog_visibility_changed"))
+# Test
+# set_prototype({
+# "seed": {
+# "type": TYPE_INT,
+# "randomizable": true
+# },
+# "base_height": {
+# "type": TYPE_REAL,
+# "range": {"min": -1000.0, "max": 1000.0, "step": 0.1}
+# },
+# "height_range": {
+# "type": TYPE_REAL,
+# "range": {"min": -1000.0, "max": 1000.0, "step": 0.1 },
+# "default_value": 500.0
+# },
+# "streamed": {
+# "type": TYPE_BOOL
+# },
+# "texture": {
+# "type": TYPE_OBJECT,
+# "object_type": Resource
+# }
+# })
+
+
+# TODO Rename clear_schema
+func clear_prototype():
+ _editors.clear()
+ var i = _grid_container.get_child_count() - 1
+ while i >= 0:
+ var child = _grid_container.get_child(i)
+ _grid_container.remove_child(child)
+ child.call_deferred("free")
+ i -= 1
+ _prototype = null
+
+
+func get_value(key: String):
+ var editor = _editors[key]
+ return editor.getter.call()
+
+
+func get_values():
+ var values = {}
+ for key in _editors:
+ var editor = _editors[key]
+ values[key] = editor.getter.call()
+ return values
+
+
+func set_value(key: String, value):
+ var editor = _editors[key]
+ editor.setter.call(value)
+
+
+func set_values(values: Dictionary):
+ for key in values:
+ if _editors.has(key):
+ var editor = _editors[key]
+ var v = values[key]
+ editor.setter.call(v)
+
+
+# TODO Rename set_schema
+func set_prototype(proto: Dictionary):
+ clear_prototype()
+
+ for key in proto:
+ var prop = proto[key]
+
+ var label := Label.new()
+ label.text = str(key).capitalize()
+ _grid_container.add_child(label)
+
+ var editor := _make_editor(key, prop)
+ editor.key_label = label
+
+ if prop.has("default_value"):
+ editor.setter.call(prop.default_value)
+
+ _editors[key] = editor
+
+ if prop.has("enabled"):
+ set_property_enabled(key, prop.enabled)
+
+ _grid_container.add_child(editor.control)
+
+ _prototype = proto
+
+
+func trigger_all_modified():
+ for key in _prototype:
+ var value = _editors[key].getter.call_func()
+ property_changed.emit(key, value)
+
+
+func set_property_enabled(prop_name: String, enabled: bool):
+ var ed = _editors[prop_name]
+
+ if ed.control is BaseButton:
+ ed.control.disabled = not enabled
+
+ elif ed.control is SpinBox:
+ ed.control.editable = enabled
+
+ elif ed.control is LineEdit:
+ ed.control.editable = enabled
+
+ # TODO Support more editors
+
+ var col = ed.key_label.modulate
+ if enabled:
+ col.a = 1.0
+ else:
+ col.a = 0.5
+ ed.key_label.modulate = col
+
+
+func _make_editor(key: String, prop: Dictionary) -> HT_InspectorEditor:
+ var ed : HT_InspectorEditor = null
+
+ var editor : Control = null
+ var getter : Callable
+ var setter : Callable
+ var extra = null
+
+ match prop.type:
+ TYPE_INT, \
+ TYPE_FLOAT:
+ var pre = null
+ if prop.has("randomizable") and prop.randomizable:
+ editor = HBoxContainer.new()
+ pre = Button.new()
+ pre.pressed.connect(_randomize_property_pressed.bind(key))
+ pre.text = "Randomize"
+ editor.add_child(pre)
+
+ if prop.type == TYPE_INT and prop.has("usage") and prop.usage == USAGE_ENUM:
+ # Enumerated value
+ assert(prop.has("enum_items"))
+ var option_button := OptionButton.new()
+
+ for i in len(prop.enum_items):
+ var item = prop.enum_items[i]
+ option_button.add_item(item)
+
+ # TODO We assume index, actually
+ getter = option_button.get_selected_id
+ setter = option_button.select
+ option_button.item_selected.connect(_property_edited.bind(key))
+
+ editor = option_button
+
+ else:
+ # Numeric value
+ var spinbox := SpinBox.new()
+ # Spinboxes have shit UX when not expanded...
+ spinbox.custom_minimum_size = Vector2(120, 16)
+ _setup_range_control(spinbox, prop)
+ spinbox.value_changed.connect(_property_edited.bind(key))
+
+ # TODO In case the type is INT, the getter should return an integer!
+ getter = spinbox.get_value
+ setter = spinbox.set_value
+
+ var show_slider = prop.has("range") \
+ and not (prop.has("slidable") \
+ and prop.slidable == false)
+
+ if show_slider:
+ if editor == null:
+ editor = HBoxContainer.new()
+ var slider := HSlider.new()
+ # Need to give some size because otherwise the slider is hard to click...
+ slider.custom_minimum_size = Vector2(32, 16)
+ _setup_range_control(slider, prop)
+ slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ spinbox.share(slider)
+ editor.add_child(slider)
+ editor.add_child(spinbox)
+ else:
+ spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ if editor == null:
+ editor = spinbox
+ else:
+ editor.add_child(spinbox)
+
+ TYPE_STRING:
+ if prop.has("usage") and prop.usage == USAGE_FILE:
+ editor = HBoxContainer.new()
+
+ var line_edit := LineEdit.new()
+ line_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ editor.add_child(line_edit)
+
+ var exts = []
+ if prop.has("exts"):
+ exts = prop.exts
+
+ var load_button := Button.new()
+ load_button.text = "..."
+ load_button.pressed.connect(_on_ask_load_file.bind(key, exts))
+ editor.add_child(load_button)
+
+ line_edit.text_submitted.connect(_property_edited.bind(key))
+ getter = line_edit.get_text
+ setter = line_edit.set_text
+
+ else:
+ editor = LineEdit.new()
+ editor.text_submitted.connect(_property_edited.bind(key))
+ getter = editor.get_text
+ setter = editor.set_text
+
+ TYPE_COLOR:
+ editor = ColorPickerButton.new()
+ editor.color_changed.connect(_property_edited.bind(key))
+ getter = editor.get_pick_color
+ setter = editor.set_pick_color
+
+ TYPE_BOOL:
+ editor = CheckBox.new()
+ editor.toggled.connect(_property_edited.bind(key))
+ getter = editor.is_pressed
+ setter = editor.set_pressed
+
+ TYPE_OBJECT:
+ # TODO How do I even check inheritance if I work on the class themselves, not instances?
+ if prop.object_type == Resource:
+ editor = HBoxContainer.new()
+
+ var label := Label.new()
+ label.text = "null"
+ label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ label.clip_text = true
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ editor.add_child(label)
+
+ var load_button := Button.new()
+ load_button.text = "Load..."
+ load_button.pressed.connect(_on_ask_load_texture.bind(key))
+ editor.add_child(load_button)
+
+ var clear_button := Button.new()
+ clear_button.text = "Clear"
+ clear_button.pressed.connect(_on_ask_clear_texture.bind(key))
+ editor.add_child(clear_button)
+
+ ed = HT_InspectorResourceEditor.new()
+ ed.label = label
+ getter = ed.get_value
+ setter = ed.set_value
+
+ TYPE_VECTOR2:
+ editor = HBoxContainer.new()
+
+ ed = HT_InspectorVectorEditor.new()
+
+ var xlabel := Label.new()
+ xlabel.text = "x"
+ editor.add_child(xlabel)
+ var xed := SpinBox.new()
+ xed.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ xed.step = 0.01
+ xed.min_value = -10000
+ xed.max_value = 10000
+ # TODO This will fire twice (for each coordinate), hmmm...
+ xed.value_changed.connect(ed._component_changed.bind(0))
+ editor.add_child(xed)
+
+ var ylabel := Label.new()
+ ylabel.text = "y"
+ editor.add_child(ylabel)
+ var yed = SpinBox.new()
+ yed.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ yed.step = 0.01
+ yed.min_value = -10000
+ yed.max_value = 10000
+ yed.value_changed.connect(ed._component_changed.bind(1))
+ editor.add_child(yed)
+
+ ed.xed = xed
+ ed.yed = yed
+ ed.value_changed.connect(_property_edited.bind(key))
+ getter = ed.get_value
+ setter = ed.set_value
+
+ _:
+ editor = Label.new()
+ editor.text = "<not editable>"
+ getter = _dummy_getter
+ setter = _dummy_setter
+
+ if not(editor is CheckButton):
+ editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+
+ if ed == null:
+ # Default
+ ed = HT_InspectorEditor.new()
+ ed.control = editor
+ ed.getter = getter
+ ed.setter = setter
+
+ return ed
+
+
+static func _setup_range_control(range_control: Range, prop):
+ if prop.type == TYPE_INT:
+ range_control.step = 1
+ range_control.rounded = true
+ else:
+ range_control.step = 0.1
+ if prop.has("range"):
+ range_control.min_value = prop.range.min
+ range_control.max_value = prop.range.max
+ if prop.range.has("step"):
+ range_control.step = prop.range.step
+ else:
+ # Where is INT_MAX??
+ range_control.min_value = -0x7fffffff
+ range_control.max_value = 0x7fffffff
+
+
+func _property_edited(value, key):
+ if _edit_signal:
+ property_changed.emit(key, value)
+
+
+func _randomize_property_pressed(key):
+ var prop = _prototype[key]
+ var v = 0
+
+ # TODO Support range step
+ match prop.type:
+ TYPE_INT:
+ if prop.has("range"):
+ v = randi() % (prop.range.max - prop.range.min) + prop.range.min
+ else:
+ v = randi() - 0x7fffffff
+ TYPE_FLOAT:
+ if prop.has("range"):
+ v = randf_range(prop.range.min, prop.range.max)
+ else:
+ v = randf()
+
+ _editors[key].setter.call(v)
+
+
+func _dummy_getter():
+ pass
+
+
+func _dummy_setter(v):
+ # TODO Could use extra data to store the value anyways?
+ pass
+
+
+func _on_ask_load_texture(key):
+ _open_file_dialog(["*.png ; PNG files"], _on_texture_selected.bind(key),
+ FileDialog.ACCESS_RESOURCES)
+
+
+func _open_file_dialog(filters: Array, callback: Callable, access: int):
+ _file_dialog.access = access
+ _file_dialog.clear_filters()
+ for filter in filters:
+ _file_dialog.add_filter(filter)
+
+ # Can't just use one-shot signals because the dialog could be closed without choosing a file...
+# if not _file_dialog.file_selected.is_connected(callback):
+# _file_dialog.file_selected.connect(callback, Object.CONNECT_ONE_SHOT)
+ _file_dialog.file_selected.connect(callback)
+
+ _file_dialog.popup_centered_ratio(0.7)
+
+
+func _on_file_dialog_visibility_changed():
+ if _file_dialog.visible == false:
+ # Disconnect listeners automatically,
+ # so we can re-use the same dialog with different listeners
+ var cons = _file_dialog.get_signal_connection_list("file_selected")
+ for con in cons:
+ _file_dialog.file_selected.disconnect(con.callable)
+
+
+func _on_texture_selected(path: String, key):
+ var tex = load(path)
+ if tex == null:
+ return
+ var ed = _editors[key]
+ ed.setter.call(tex)
+ _property_edited(tex, key)
+
+
+func _on_ask_clear_texture(key):
+ var ed = _editors[key]
+ ed.setter.call(null)
+ _property_edited(null, key)
+
+
+func _on_ask_load_file(key, exts):
+ var filters := []
+ for ext in exts:
+ filters.append(str("*.", ext, " ; ", ext.to_upper(), " files"))
+ _open_file_dialog(filters, _on_file_selected.bind(key), FileDialog.ACCESS_FILESYSTEM)
+
+
+func _on_file_selected(path, key):
+ var ed = _editors[key]
+ ed.setter.call(path)
+ _property_edited(path, key)
diff --git a/game/addons/zylann.hterrain/tools/inspector/inspector.tscn b/game/addons/zylann.hterrain/tools/inspector/inspector.tscn
new file mode 100644
index 0000000..4bd1395
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/inspector/inspector.tscn
@@ -0,0 +1,15 @@
+[gd_scene load_steps=2 format=3 uid="uid://dfjip6c4olemn"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/inspector/inspector.gd" id="1"]
+
+[node name="Inspector" type="VBoxContainer"]
+script = ExtResource("1")
+
+[node name="GridContainer" type="GridContainer" parent="."]
+layout_mode = 2
+columns = 2
+
+[node name="OpenFileDialog" type="FileDialog" parent="."]
+title = "Open a File"
+ok_button_text = "Open"
+file_mode = 0
diff --git a/game/addons/zylann.hterrain/tools/load_texture_dialog.gd b/game/addons/zylann.hterrain/tools/load_texture_dialog.gd
new file mode 100644
index 0000000..6d1e874
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/load_texture_dialog.gd
@@ -0,0 +1,22 @@
+@tool
+extends EditorFileDialog
+
+
+func _init():
+ #access = EditorFileDialog.ACCESS_RESOURCES
+ file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ # TODO I actually want a dialog to load a texture, not specifically a PNG...
+ add_filter("*.png ; PNG files")
+ add_filter("*.jpg ; JPG files")
+ unresizable = false
+ access = EditorFileDialog.ACCESS_RESOURCES
+ close_requested.connect(call_deferred.bind("_on_close"))
+
+
+func _on_close():
+ # Disconnect listeners automatically,
+ # so we can re-use the same dialog with different listeners
+ var cons = get_signal_connection_list("file_selected")
+ for con in cons:
+ file_selected.disconnect(con.callable)
+
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap.gd b/game/addons/zylann.hterrain/tools/minimap/minimap.gd
new file mode 100644
index 0000000..f71295a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap.gd
@@ -0,0 +1,140 @@
+@tool
+extends Control
+
+const HT_Util = preload("../../util/util.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+const HT_MinimapOverlay = preload("./minimap_overlay.gd")
+
+const HT_MinimapShader = preload("./minimap_normal.gdshader")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_WhiteTexture = preload("../icons/white.png")
+const WHITE_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/white.png"
+
+const MODE_QUADTREE = 0
+const MODE_NORMAL = 1
+
+@onready var _popup_menu : PopupMenu = $PopupMenu
+@onready var _color_rect : ColorRect = $ColorRect
+@onready var _overlay : HT_MinimapOverlay = $Overlay
+
+var _terrain : HTerrain = null
+var _mode := MODE_NORMAL
+var _camera_transform := Transform3D()
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ _set_mode(_mode)
+
+ _popup_menu.add_item("Quadtree mode", MODE_QUADTREE)
+ _popup_menu.add_item("Normal mode", MODE_NORMAL)
+
+
+func set_terrain(node: HTerrain):
+ if _terrain != node:
+ _terrain = node
+ set_process(_terrain != null)
+
+
+func set_camera_transform(ct: Transform3D):
+ if _camera_transform == ct:
+ return
+ if _terrain == null:
+ return
+ var data = _terrain.get_data()
+ if data == null:
+ return
+ var to_local := _terrain.get_internal_transform().affine_inverse()
+ var pos := _get_xz(to_local * _camera_transform.origin)
+ var size := Vector2(data.get_resolution(), data.get_resolution())
+ pos /= size
+ var dir := _get_xz(to_local.basis * (-_camera_transform.basis.z)).normalized()
+ _overlay.set_cursor_position_normalized(pos, dir)
+ _camera_transform = ct
+
+
+static func _get_xz(v: Vector3) -> Vector2:
+ return Vector2(v.x, v.z)
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ match event.button_index:
+ MOUSE_BUTTON_RIGHT:
+ _popup_menu.position = get_screen_position() + event.position
+ _popup_menu.popup()
+ MOUSE_BUTTON_LEFT:
+ # Teleport there?
+ pass
+
+
+func _process(delta):
+ if _terrain != null:
+ if _mode == MODE_QUADTREE:
+ queue_redraw()
+ else:
+ _update_normal_material()
+
+
+func _set_mode(mode: int):
+ if mode == MODE_QUADTREE:
+ _color_rect.hide()
+ else:
+ var mat := ShaderMaterial.new()
+ mat.shader = HT_MinimapShader
+ _color_rect.material = mat
+ _color_rect.show()
+ _update_normal_material()
+ _mode = mode
+ queue_redraw()
+
+
+func _update_normal_material():
+ if _terrain == null:
+ return
+ var data : HTerrainData = _terrain.get_data()
+ if data == null:
+ return
+
+ var normalmap = data.get_texture(HTerrainData.CHANNEL_NORMAL)
+ _set_if_changed(_color_rect.material, "u_normalmap", normalmap)
+
+ var globalmap : Texture
+ if data.has_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO, 0):
+ globalmap = data.get_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO)
+ if globalmap == null:
+ globalmap = load(WHITE_TEXTURE_PATH)
+ _set_if_changed(_color_rect.material, "u_globalmap", globalmap)
+
+
+# Need to check if it has changed, otherwise Godot's update spinner
+# indicates that the editor keeps redrawing every frame,
+# which is not intended and consumes more power.
+static func _set_if_changed(sm: ShaderMaterial, param: String, v):
+ if sm.get_shader_parameter(param) != v:
+ sm.set_shader_parameter(param, v)
+
+
+func _draw():
+ if _terrain == null:
+ return
+
+ if _mode == MODE_QUADTREE:
+ var lod_count := _terrain.get_lod_count()
+
+ if lod_count > 0:
+ # Fit drawing to rect
+
+ var qsize = 1 << (lod_count - 1)
+ var vsize := size
+ draw_set_transform(Vector2(0, 0), 0, Vector2(vsize.x / qsize, vsize.y / qsize))
+
+ _terrain._edit_debug_draw(self)
+
+
+func _on_PopupMenu_id_pressed(id: int):
+ _set_mode(id)
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap.tscn b/game/addons/zylann.hterrain/tools/minimap/minimap.tscn
new file mode 100644
index 0000000..b2a7ef5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap.tscn
@@ -0,0 +1,56 @@
+[gd_scene load_steps=7 format=3 uid="uid://cba6k3hrwhrke"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/minimap/minimap.gd" id="1"]
+[ext_resource type="Shader" path="res://addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader" id="2"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/minimap/minimap_overlay.gd" id="3"]
+[ext_resource type="Texture2D" uid="uid://c1el0dmyvaaij" path="res://addons/zylann.hterrain/tools/icons/icon_minimap_position.svg" id="4"]
+[ext_resource type="Texture2D" uid="uid://cc47smy24m368" path="res://addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg" id="5"]
+
+[sub_resource type="ShaderMaterial" id="1"]
+shader = ExtResource("2")
+shader_parameter/u_light_direction = Vector3(0.5, -0.7, 0.2)
+
+[node name="Minimap" type="Control"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 3
+anchors_preset = 0
+offset_right = 100.0
+offset_bottom = 100.0
+script = ExtResource("1")
+
+[node name="PopupMenu" type="PopupMenu" parent="."]
+
+[node name="ColorRect" type="ColorRect" parent="."]
+material = SubResource("1")
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+
+[node name="X" type="ColorRect" parent="."]
+layout_mode = 0
+mouse_filter = 2
+color = Color(0.929412, 0.290196, 0.290196, 0.627451)
+
+[node name="Z" type="ColorRect" parent="."]
+layout_mode = 0
+mouse_filter = 2
+color = Color(0.0784314, 0.501961, 1, 0.627451)
+
+[node name="Y" type="ColorRect" parent="."]
+layout_mode = 0
+mouse_filter = 2
+color = Color(0.207843, 0.835294, 0.152941, 0.627451)
+
+[node name="Overlay" type="Control" parent="."]
+anchors_preset = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+script = ExtResource("3")
+cursor_texture = ExtResource("4")
+out_of_range_texture = ExtResource("5")
+
+[node name="Cursor" type="Sprite2D" parent="Overlay"]
+
+[connection signal="id_pressed" from="PopupMenu" to="." method="_on_PopupMenu_id_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader b/game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader
new file mode 100644
index 0000000..0ea3120
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap_normal.gdshader
@@ -0,0 +1,24 @@
+shader_type canvas_item;
+
+uniform sampler2D u_normalmap;
+uniform sampler2D u_globalmap;
+uniform vec3 u_light_direction = vec3(0.5, -0.7, 0.2);
+
+vec3 unpack_normal(vec4 rgba) {
+ return rgba.xzy * 2.0 - vec3(1.0);
+}
+
+void fragment() {
+ vec3 albedo = texture(u_globalmap, UV).rgb;
+ // Undo sRGB
+ // TODO I don't know what is correct tbh, this didn't work well
+ //albedo *= pow(albedo, vec3(0.4545));
+ //albedo *= pow(albedo, vec3(1.0 / 0.4545));
+ albedo = sqrt(albedo);
+
+ vec3 normal = unpack_normal(texture(u_normalmap, UV));
+ float g = max(-dot(u_light_direction, normal), 0.0);
+
+ COLOR = vec4(albedo * g, 1.0);
+}
+
diff --git a/game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd b/game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd
new file mode 100644
index 0000000..93c6695
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd
@@ -0,0 +1,24 @@
+@tool
+extends Control
+
+
+@export var cursor_texture : Texture
+@export var out_of_range_texture : Texture
+
+@onready var _sprite : Sprite2D = $Cursor
+
+var _pos := Vector2()
+var _rot := 0.0
+
+
+func set_cursor_position_normalized(pos_norm: Vector2, dir: Vector2):
+ if Rect2(0, 0, 1, 1).has_point(pos_norm):
+ _sprite.texture = cursor_texture
+ else:
+ pos_norm.x = clampf(pos_norm.x, 0.0, 1.0)
+ pos_norm.y = clampf(pos_norm.y, 0.0, 1.0)
+ _sprite.texture = out_of_range_texture
+
+ _sprite.position = pos_norm * size
+ _sprite.rotation = dir.angle()
+
diff --git a/game/addons/zylann.hterrain/tools/minimap/ratio_container.gd b/game/addons/zylann.hterrain/tools/minimap/ratio_container.gd
new file mode 100644
index 0000000..1bd6aca
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/minimap/ratio_container.gd
@@ -0,0 +1,32 @@
+# Simple container keeping its children under the same aspect ratio
+
+@tool
+extends Container
+
+
+@export var ratio := 1.0
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_SORT_CHILDREN:
+ _sort_children2()
+
+
+# TODO Function with ugly name to workaround a Godot 3.1 issue
+# See https://github.com/godotengine/godot/pull/38396
+func _sort_children2():
+ for i in get_child_count():
+ var child = get_child(i)
+ if not (child is Control):
+ continue
+ var w := size.x
+ var h := size.x / ratio
+
+ if h > size.y:
+ h = size.y
+ w = h * ratio
+
+ var rect := Rect2(0, 0, w, h)
+
+ fit_child_in_rect(child, rect)
+
diff --git a/game/addons/zylann.hterrain/tools/normalmap_baker.gd b/game/addons/zylann.hterrain/tools/normalmap_baker.gd
new file mode 100644
index 0000000..e5af37d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/normalmap_baker.gd
@@ -0,0 +1,148 @@
+
+# Bakes normals asynchronously in the editor as the heightmap gets modified.
+# It uses the heightmap texture to change the normalmap image, which is then uploaded like an edit.
+# This is probably not a nice method GPU-wise, but it's way faster than GDScript.
+
+@tool
+extends Node
+
+const HTerrainData = preload("../hterrain_data.gd")
+
+const VIEWPORT_SIZE = 64
+
+const STATE_PENDING = 0
+const STATE_PROCESSING = 1
+
+var _viewport : SubViewport = null
+var _ci : Sprite2D = null
+var _pending_tiles_grid := {}
+var _pending_tiles_queue := []
+var _processing_tile = null
+var _terrain_data : HTerrainData = null
+
+
+func _init():
+ assert(VIEWPORT_SIZE <= HTerrainData.MIN_RESOLUTION)
+ _viewport = SubViewport.new()
+ _viewport.size = Vector2(VIEWPORT_SIZE + 2, VIEWPORT_SIZE + 2)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
+ _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS
+ # We only render 2D, but we don't want the parent world to interfere
+ _viewport.world_3d = World3D.new()
+ _viewport.own_world_3d = true
+ add_child(_viewport)
+
+ var mat = ShaderMaterial.new()
+ mat.shader = load("res://addons/zylann.hterrain/tools/bump2normal_tex.gdshader")
+
+ _ci = Sprite2D.new()
+ _ci.centered = false
+ _ci.material = mat
+ _viewport.add_child(_ci)
+
+ set_process(false)
+
+
+func set_terrain_data(data: HTerrainData):
+ if data == _terrain_data:
+ return
+
+ _pending_tiles_grid.clear()
+ _pending_tiles_queue.clear()
+ _processing_tile = null
+ _ci.texture = null
+ set_process(false)
+
+ if data == null:
+ _terrain_data.map_changed.disconnect(_on_terrain_data_map_changed)
+ _terrain_data.resolution_changed.disconnect(_on_terrain_data_resolution_changed)
+
+ _terrain_data = data
+
+ if _terrain_data != null:
+ _terrain_data.map_changed.connect(_on_terrain_data_map_changed)
+ _terrain_data.resolution_changed.connect(_on_terrain_data_resolution_changed)
+ _ci.texture = data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
+
+func _on_terrain_data_map_changed(maptype: int, index: int):
+ if maptype == HTerrainData.CHANNEL_HEIGHT:
+ _ci.texture = _terrain_data.get_texture(HTerrainData.CHANNEL_HEIGHT)
+
+
+func _on_terrain_data_resolution_changed():
+ # TODO Workaround issue https://github.com/godotengine/godot/issues/24463
+ _ci.queue_redraw()
+
+
+# TODO Use Vector2i
+func request_tiles_in_region(min_pos: Vector2, size: Vector2):
+ assert(is_inside_tree())
+ assert(_terrain_data != null)
+ var res = _terrain_data.get_resolution()
+
+ min_pos -= Vector2(1, 1)
+ var max_pos = min_pos + size + Vector2(1, 1)
+ var tmin = (min_pos / VIEWPORT_SIZE).floor()
+ var tmax = (max_pos / VIEWPORT_SIZE).ceil()
+ var ntx = res / VIEWPORT_SIZE
+ var nty = res / VIEWPORT_SIZE
+ tmin.x = clamp(tmin.x, 0, ntx)
+ tmin.y = clamp(tmin.y, 0, nty)
+ tmax.x = clamp(tmax.x, 0, ntx)
+ tmax.y = clamp(tmax.y, 0, nty)
+
+ for y in range(tmin.y, tmax.y):
+ for x in range(tmin.x, tmax.x):
+ request_tile(Vector2(x, y))
+
+
+# TODO Use Vector2i
+func request_tile(tpos: Vector2):
+ assert(tpos == tpos.round())
+ if _pending_tiles_grid.has(tpos):
+ var state = _pending_tiles_grid[tpos]
+ if state == STATE_PENDING:
+ return
+ _pending_tiles_grid[tpos] = STATE_PENDING
+ _pending_tiles_queue.push_front(tpos)
+ set_process(true)
+
+
+func _process(delta):
+ if not is_processing():
+ return
+
+ if _processing_tile != null and _terrain_data != null:
+ var src = _viewport.get_texture().get_image()
+ var dst = _terrain_data.get_image(HTerrainData.CHANNEL_NORMAL)
+
+ src.convert(dst.get_format())
+ #src.save_png(str("test_", _processing_tile.x, "_", _processing_tile.y, ".png"))
+ var pos = _processing_tile * VIEWPORT_SIZE
+ var w = src.get_width() - 1
+ var h = src.get_height() - 1
+ dst.blit_rect(src, Rect2i(1, 1, w, h), pos)
+ _terrain_data.notify_region_change(Rect2(pos.x, pos.y, w, h), HTerrainData.CHANNEL_NORMAL)
+
+ if _pending_tiles_grid[_processing_tile] == STATE_PROCESSING:
+ _pending_tiles_grid.erase(_processing_tile)
+ _processing_tile = null
+
+ if _has_pending_tiles():
+ var tpos = _pending_tiles_queue[-1]
+ _pending_tiles_queue.pop_back()
+ # The sprite will be much larger than the viewport due to the size of the heightmap.
+ # We move it around so the part inside the viewport will correspond to the tile.
+ _ci.position = -VIEWPORT_SIZE * tpos + Vector2(1, 1)
+ _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
+ _processing_tile = tpos
+ _pending_tiles_grid[tpos] = STATE_PROCESSING
+ else:
+ set_process(false)
+
+
+func _has_pending_tiles():
+ return len(_pending_tiles_queue) > 0
+
+
diff --git a/game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd b/game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd
new file mode 100644
index 0000000..4032212
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd
@@ -0,0 +1,86 @@
+@tool
+
+const HT_Logger = preload("../../util/logger.gd")
+const HT_Errors = preload("../../util/errors.gd")
+const HT_Result = preload("../util/result.gd")
+
+const _transform_params = [
+ "normalmap_flip_y"
+]
+
+
+# sources: {
+# "a": "path_to_image_where_red_channel_will_be_stored_in_alpha.png",
+# "rgb": "path_to_image_where_rgb_channels_will_be_stored.png"
+# "rgba": "path_to_image.png",
+# "rgb": "#hexcolor"
+# }
+static func generate_image(sources: Dictionary, resolution: int, logger) -> HT_Result:
+ var image := Image.create(resolution, resolution, true, Image.FORMAT_RGBA8)
+
+ var flip_normalmap_y := false
+
+ # TODO Accelerate with GDNative
+ for key in sources:
+ if key in _transform_params:
+ continue
+
+ var src_path : String = sources[key]
+
+ logger.debug(str("Processing source \"", src_path, "\""))
+
+ var src_image : Image
+ if src_path.begins_with("#"):
+ # Plain color
+ var col = Color(src_path)
+ src_image = Image.create(resolution, resolution, false, Image.FORMAT_RGBA8)
+ src_image.fill(col)
+
+ else:
+ # File
+ src_image = Image.new()
+ var err := src_image.load(src_path)
+ if err != OK:
+ return HT_Result.new(false, "Could not open file \"{0}\": {1}" \
+ .format([src_path, HT_Errors.get_message(err)])) \
+ .with_value(err)
+ src_image.decompress()
+
+ src_image.resize(image.get_width(), image.get_height())
+
+ # TODO Support more channel configurations
+ if key == "rgb":
+ for y in image.get_height():
+ for x in image.get_width():
+ var dst_col := image.get_pixel(x, y)
+ var a := dst_col.a
+ dst_col = src_image.get_pixel(x, y)
+ dst_col.a = a
+ image.set_pixel(x, y, dst_col)
+
+ elif key == "a":
+ for y in image.get_height():
+ for x in image.get_width():
+ var dst_col := image.get_pixel(x, y)
+ dst_col.a = src_image.get_pixel(x, y).r
+ image.set_pixel(x, y, dst_col)
+
+ elif key == "rgba":
+ # Meh
+ image.blit_rect(src_image,
+ Rect2i(0, 0, image.get_width(), image.get_height()), Vector2i())
+
+ if sources.has("normalmap_flip_y") and sources.normalmap_flip_y:
+ _flip_normalmap_y(image)
+
+ return HT_Result.new(true).with_value(image)
+
+
+static func _flip_normalmap_y(image: Image):
+ for y in image.get_height():
+ for x in image.get_width():
+ var col := image.get_pixel(x, y)
+ col.g = 1.0 - col.g
+ image.set_pixel(x, y, col)
+
+
diff --git a/game/addons/zylann.hterrain/tools/panel.gd b/game/addons/zylann.hterrain/tools/panel.gd
new file mode 100644
index 0000000..307e008
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/panel.gd
@@ -0,0 +1,75 @@
+@tool
+extends Control
+
+const HT_DetailEditor = preload("./detail_editor/detail_editor.gd")
+
+
+# Emitted when a texture item is selected
+signal texture_selected(index)
+signal edit_texture_pressed(index)
+signal import_textures_pressed
+
+# Emitted when a detail item is selected (grass painting)
+signal detail_selected(index)
+signal detail_list_changed
+
+
+@onready var _minimap = $HSplitContainer/HSplitContainer/MinimapContainer/Minimap
+@onready var _brush_editor = $HSplitContainer/BrushEditor
+@onready var _texture_editor = $HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor
+@onready var _detail_editor : HT_DetailEditor = \
+ $HSplitContainer/HSplitContainer/HSplitContainer/DetailEditor
+
+
+func setup_dialogs(base_control: Control):
+ _brush_editor.setup_dialogs(base_control)
+
+
+func set_terrain(terrain):
+ _minimap.set_terrain(terrain)
+ _texture_editor.set_terrain(terrain)
+ _detail_editor.set_terrain(terrain)
+
+
+func set_undo_redo(undo_manager: EditorUndoRedoManager):
+ _detail_editor.set_undo_redo(undo_manager)
+
+
+func set_image_cache(image_cache):
+ _detail_editor.set_image_cache(image_cache)
+
+
+func set_camera_transform(cam_transform: Transform3D):
+ _minimap.set_camera_transform(cam_transform)
+
+
+func set_terrain_painter(terrain_painter):
+ _brush_editor.set_terrain_painter(terrain_painter)
+
+
+func _on_TextureEditor_texture_selected(index):
+ texture_selected.emit(index)
+
+
+func _on_DetailEditor_detail_selected(index):
+ detail_selected.emit(index)
+
+
+func set_brush_editor_display_mode(mode):
+ _brush_editor.set_display_mode(mode)
+
+
+func set_detail_layer_index(index):
+ _detail_editor.set_layer_index(index)
+
+
+func _on_DetailEditor_detail_list_changed():
+ detail_list_changed.emit()
+
+
+func _on_TextureEditor_import_pressed():
+ import_textures_pressed.emit()
+
+
+func _on_TextureEditor_edit_pressed(index: int):
+ edit_texture_pressed.emit(index)
diff --git a/game/addons/zylann.hterrain/tools/panel.tscn b/game/addons/zylann.hterrain/tools/panel.tscn
new file mode 100644
index 0000000..33e856f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/panel.tscn
@@ -0,0 +1,58 @@
+[gd_scene load_steps=7 format=3 uid="uid://dtdgawtpwfaft"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/panel.gd" id="1"]
+[ext_resource type="PackedScene" uid="uid://bd42ig216p216" path="res://addons/zylann.hterrain/tools/brush/brush_editor.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn" id="3"]
+[ext_resource type="PackedScene" uid="uid://do3c3jse5p7hx" path="res://addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn" id="4"]
+[ext_resource type="PackedScene" uid="uid://cba6k3hrwhrke" path="res://addons/zylann.hterrain/tools/minimap/minimap.tscn" id="5"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/minimap/ratio_container.gd" id="6"]
+
+[node name="Panel" type="Control"]
+custom_minimum_size = Vector2(400, 120)
+layout_mode = 3
+anchors_preset = 0
+offset_right = 1012.0
+offset_bottom = 120.0
+script = ExtResource("1")
+
+[node name="HSplitContainer" type="HSplitContainer" parent="."]
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 4.0
+offset_top = 4.0
+offset_right = -6.0
+offset_bottom = -4.0
+split_offset = 60
+
+[node name="BrushEditor" parent="HSplitContainer" instance=ExtResource("2")]
+layout_mode = 2
+
+[node name="HSplitContainer" type="HSplitContainer" parent="HSplitContainer"]
+layout_mode = 2
+
+[node name="HSplitContainer" type="HSplitContainer" parent="HSplitContainer/HSplitContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+split_offset = 300
+
+[node name="TextureEditor" parent="HSplitContainer/HSplitContainer/HSplitContainer" instance=ExtResource("3")]
+layout_mode = 2
+size_flags_horizontal = 1
+
+[node name="DetailEditor" parent="HSplitContainer/HSplitContainer/HSplitContainer" instance=ExtResource("4")]
+layout_mode = 2
+
+[node name="MinimapContainer" type="Container" parent="HSplitContainer/HSplitContainer"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+script = ExtResource("6")
+
+[node name="Minimap" parent="HSplitContainer/HSplitContainer/MinimapContainer" instance=ExtResource("5")]
+layout_mode = 2
+
+[connection signal="edit_pressed" from="HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor" to="." method="_on_TextureEditor_edit_pressed"]
+[connection signal="import_pressed" from="HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor" to="." method="_on_TextureEditor_import_pressed"]
+[connection signal="texture_selected" from="HSplitContainer/HSplitContainer/HSplitContainer/TextureEditor" to="." method="_on_TextureEditor_texture_selected"]
+[connection signal="detail_list_changed" from="HSplitContainer/HSplitContainer/HSplitContainer/DetailEditor" to="." method="_on_DetailEditor_detail_list_changed"]
+[connection signal="detail_selected" from="HSplitContainer/HSplitContainer/HSplitContainer/DetailEditor" to="." method="_on_DetailEditor_detail_selected"]
diff --git a/game/addons/zylann.hterrain/tools/plugin.gd b/game/addons/zylann.hterrain/tools/plugin.gd
new file mode 100644
index 0000000..741ce8d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/plugin.gd
@@ -0,0 +1,884 @@
+@tool # https://www.youtube.com/watch?v=Y7JG63IuaWs
+
+extends EditorPlugin
+
+const HTerrain = preload("../hterrain.gd")
+const HTerrainDetailLayer = preload("../hterrain_detail_layer.gd")
+const HTerrainData = preload("../hterrain_data.gd")
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+const HTerrainTextureSet = preload("../hterrain_texture_set.gd")
+const HT_PreviewGenerator = preload("./preview_generator.gd")
+const HT_TerrainPainter = preload("./brush/terrain_painter.gd")
+const HT_BrushDecal = preload("./brush/decal.gd")
+const HT_Util = preload("../util/util.gd")
+const HT_EditorUtil = preload("./util/editor_util.gd")
+const HT_LoadTextureDialog = preload("./load_texture_dialog.gd")
+const HT_GlobalMapBaker = preload("./globalmap_baker.gd")
+const HT_ImageFileCache = preload("../util/image_file_cache.gd")
+const HT_Logger = preload("../util/logger.gd")
+const HT_EditPanel = preload("./panel.gd")
+const HT_GeneratorDialog = preload("./generator/generator_dialog.gd")
+const HT_TextureSetEditor = preload("./texture_editor/set_editor/texture_set_editor.gd")
+const HT_TextureSetImportEditor = \
+ preload("./texture_editor/set_editor/texture_set_import_editor.gd")
+const HT_ProgressWindow = preload("./progress_window.gd")
+
+const HT_EditPanelScene = preload("./panel.tscn")
+const HT_ProgressWindowScene = preload("./progress_window.tscn")
+const HT_GeneratorDialogScene = preload("./generator/generator_dialog.tscn")
+const HT_ImportDialogScene = preload("./importer/importer_dialog.tscn")
+const HT_GenerateMeshDialogScene = preload("./generate_mesh_dialog.tscn")
+const HT_ResizeDialogScene = preload("./resize_dialog/resize_dialog.tscn")
+const HT_ExportImageDialogScene = preload("./exporter/export_image_dialog.tscn")
+const HT_TextureSetEditorScene = preload("./texture_editor/set_editor/texture_set_editor.tscn")
+const HT_TextureSetImportEditorScene = \
+ preload("./texture_editor/set_editor/texture_set_import_editor.tscn")
+const HT_AboutDialogScene = preload("./about/about_dialog.tscn")
+
+const DOCUMENTATION_URL = "https://hterrain-plugin.readthedocs.io/en/latest"
+
+const MENU_IMPORT_MAPS = 0
+const MENU_GENERATE = 1
+const MENU_BAKE_GLOBALMAP = 2
+const MENU_RESIZE = 3
+const MENU_UPDATE_EDITOR_COLLIDER = 4
+const MENU_GENERATE_MESH = 5
+const MENU_EXPORT_HEIGHTMAP = 6
+const MENU_LOOKDEV = 7
+const MENU_DOCUMENTATION = 8
+const MENU_ABOUT = 9
+
+
+# TODO Rename _terrain
+var _node : HTerrain = null
+
+# GUI
+var _panel : HT_EditPanel = null
+var _toolbar : Container = null
+var _toolbar_brush_buttons := {}
+var _generator_dialog : HT_GeneratorDialog = null
+# TODO Rename _import_terrain_dialog
+var _import_dialog = null
+var _export_image_dialog = null
+
+# This window is only used for operations not triggered by an existing dialog.
+# In Godot it has been solved by automatically reparenting the dialog:
+# https://github.com/godotengine/godot/pull/71209
+# But `get_exclusive_child()` is not exposed. So dialogs triggering a progress
+# dialog may need their own child instance...
+var _progress_window : HT_ProgressWindow = null
+
+var _generate_mesh_dialog = null
+var _preview_generator : HT_PreviewGenerator = null
+var _resize_dialog = null
+var _about_dialog = null
+var _menu_button : MenuButton
+var _lookdev_menu : PopupMenu
+var _texture_set_editor : HT_TextureSetEditor = null
+var _texture_set_import_editor : HT_TextureSetImportEditor = null
+
+var _globalmap_baker : HT_GlobalMapBaker = null
+var _terrain_had_data_previous_frame := false
+var _image_cache : HT_ImageFileCache
+var _terrain_painter : HT_TerrainPainter = null
+var _brush_decal : HT_BrushDecal = null
+var _mouse_pressed := false
+#var _pending_paint_action = null
+var _pending_paint_commit := false
+
+var _logger := HT_Logger.get_for(self)
+
+
+func get_icon(icon_name: String) -> Texture2D:
+ return HT_EditorUtil.load_texture(
+ "res://addons/zylann.hterrain/tools/icons/icon_" + icon_name + ".svg", _logger)
+
+
+func _enter_tree():
+ _logger.debug("HTerrain plugin Enter tree")
+
+ var dpi_scale = get_editor_interface().get_editor_scale()
+ _logger.debug(str("DPI scale: ", dpi_scale))
+
+ add_custom_type("HTerrain", "Node3D", HTerrain, get_icon("heightmap_node"))
+ add_custom_type("HTerrainDetailLayer", "Node3D", HTerrainDetailLayer,
+ get_icon("detail_layer_node"))
+ add_custom_type("HTerrainData", "Resource", HTerrainData, get_icon("heightmap_data"))
+ # TODO Proper texture
+ add_custom_type("HTerrainTextureSet", "Resource", HTerrainTextureSet, null)
+
+ _preview_generator = HT_PreviewGenerator.new()
+ get_editor_interface().get_resource_previewer().add_preview_generator(_preview_generator)
+
+ _terrain_painter = HT_TerrainPainter.new()
+ _terrain_painter.set_brush_size(5)
+ _terrain_painter.get_brush().size_changed.connect(_on_brush_size_changed)
+ add_child(_terrain_painter)
+
+ _brush_decal = HT_BrushDecal.new()
+ _brush_decal.set_size(_terrain_painter.get_brush_size())
+
+ _image_cache = HT_ImageFileCache.new("user://temp_hterrain_image_cache")
+
+ var editor_interface := get_editor_interface()
+ var base_control := editor_interface.get_base_control()
+
+ _panel = HT_EditPanelScene.instantiate()
+ HT_Util.apply_dpi_scale(_panel, dpi_scale)
+ _panel.hide()
+ add_control_to_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_BOTTOM, _panel)
+ # Apparently _ready() still isn't called at this point...
+ _panel.call_deferred("set_terrain_painter", _terrain_painter)
+ _panel.call_deferred("setup_dialogs", base_control)
+ _panel.set_undo_redo(get_undo_redo())
+ _panel.set_image_cache(_image_cache)
+ _panel.detail_selected.connect(_on_detail_selected)
+ _panel.texture_selected.connect(_on_texture_selected)
+ _panel.detail_list_changed.connect(_update_brush_buttons_availability)
+ _panel.edit_texture_pressed.connect(_on_Panel_edit_texture_pressed)
+ _panel.import_textures_pressed.connect(_on_Panel_import_textures_pressed)
+
+ _toolbar = HBoxContainer.new()
+ add_control_to_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_MENU, _toolbar)
+ _toolbar.hide()
+
+ var menu := MenuButton.new()
+ menu.set_text("Terrain")
+ menu.get_popup().add_item("Import maps...", MENU_IMPORT_MAPS)
+ menu.get_popup().add_item("Generate...", MENU_GENERATE)
+ menu.get_popup().add_item("Resize...", MENU_RESIZE)
+ menu.get_popup().add_item("Bake global map", MENU_BAKE_GLOBALMAP)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Update Editor Collider", MENU_UPDATE_EDITOR_COLLIDER)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Generate mesh (heavy)", MENU_GENERATE_MESH)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Export heightmap", MENU_EXPORT_HEIGHTMAP)
+ menu.get_popup().add_separator()
+ _lookdev_menu = PopupMenu.new()
+ _lookdev_menu.name = "LookdevMenu"
+ _lookdev_menu.about_to_popup.connect(_on_lookdev_menu_about_to_show)
+ _lookdev_menu.id_pressed.connect(_on_lookdev_menu_id_pressed)
+ menu.get_popup().add_child(_lookdev_menu)
+ menu.get_popup().add_submenu_item("Lookdev", _lookdev_menu.name, MENU_LOOKDEV)
+ menu.get_popup().id_pressed.connect(_menu_item_selected)
+ menu.get_popup().add_separator()
+ menu.get_popup().add_item("Documentation", MENU_DOCUMENTATION)
+ menu.get_popup().add_item("About HTerrain...", MENU_ABOUT)
+ _toolbar.add_child(menu)
+ _menu_button = menu
+
+ var mode_icons := {}
+ mode_icons[HT_TerrainPainter.MODE_RAISE] = get_icon("heightmap_raise")
+ mode_icons[HT_TerrainPainter.MODE_LOWER] = get_icon("heightmap_lower")
+ mode_icons[HT_TerrainPainter.MODE_SMOOTH] = get_icon("heightmap_smooth")
+ mode_icons[HT_TerrainPainter.MODE_FLATTEN] = get_icon("heightmap_flatten")
+ mode_icons[HT_TerrainPainter.MODE_SPLAT] = get_icon("heightmap_paint")
+ mode_icons[HT_TerrainPainter.MODE_COLOR] = get_icon("heightmap_color")
+ mode_icons[HT_TerrainPainter.MODE_DETAIL] = get_icon("grass")
+ mode_icons[HT_TerrainPainter.MODE_MASK] = get_icon("heightmap_mask")
+ mode_icons[HT_TerrainPainter.MODE_LEVEL] = get_icon("heightmap_level")
+ mode_icons[HT_TerrainPainter.MODE_ERODE] = get_icon("heightmap_erode")
+
+ var mode_tooltips := {}
+ mode_tooltips[HT_TerrainPainter.MODE_RAISE] = "Raise height"
+ mode_tooltips[HT_TerrainPainter.MODE_LOWER] = "Lower height"
+ mode_tooltips[HT_TerrainPainter.MODE_SMOOTH] = "Smooth height"
+ mode_tooltips[HT_TerrainPainter.MODE_FLATTEN] = "Flatten (flatten to a specific height)"
+ mode_tooltips[HT_TerrainPainter.MODE_SPLAT] = "Texture paint"
+ mode_tooltips[HT_TerrainPainter.MODE_COLOR] = "Color paint"
+ mode_tooltips[HT_TerrainPainter.MODE_DETAIL] = "Grass paint"
+ mode_tooltips[HT_TerrainPainter.MODE_MASK] = "Cut holes"
+ mode_tooltips[HT_TerrainPainter.MODE_LEVEL] = "Level (smoothly flattens to average)"
+ mode_tooltips[HT_TerrainPainter.MODE_ERODE] = "Erode"
+
+ _toolbar.add_child(VSeparator.new())
+
+ # I want modes to be in that order in the GUI
+ var ordered_brush_modes := [
+ HT_TerrainPainter.MODE_RAISE,
+ HT_TerrainPainter.MODE_LOWER,
+ HT_TerrainPainter.MODE_SMOOTH,
+ HT_TerrainPainter.MODE_LEVEL,
+ HT_TerrainPainter.MODE_FLATTEN,
+ HT_TerrainPainter.MODE_ERODE,
+ HT_TerrainPainter.MODE_SPLAT,
+ HT_TerrainPainter.MODE_COLOR,
+ HT_TerrainPainter.MODE_DETAIL,
+ HT_TerrainPainter.MODE_MASK
+ ]
+
+ var mode_group := ButtonGroup.new()
+
+ for mode in ordered_brush_modes:
+ var button := Button.new()
+ button.flat = true
+ button.icon = mode_icons[mode]
+ button.tooltip_text = mode_tooltips[mode]
+ button.set_toggle_mode(true)
+ button.set_button_group(mode_group)
+
+ if mode == _terrain_painter.get_mode():
+ button.button_pressed = true
+
+ button.pressed.connect(_on_mode_selected.bind(mode))
+ _toolbar.add_child(button)
+
+ _toolbar_brush_buttons[mode] = button
+
+ _generator_dialog = HT_GeneratorDialogScene.instantiate()
+ _generator_dialog.set_image_cache(_image_cache)
+ _generator_dialog.set_undo_redo(get_undo_redo())
+ base_control.add_child(_generator_dialog)
+ _generator_dialog.apply_dpi_scale(dpi_scale)
+
+ _import_dialog = HT_ImportDialogScene.instantiate()
+ _import_dialog.permanent_change_performed.connect(_on_permanent_change_performed)
+ HT_Util.apply_dpi_scale(_import_dialog, dpi_scale)
+ base_control.add_child(_import_dialog)
+
+ _progress_window = HT_ProgressWindowScene.instantiate()
+ base_control.add_child(_progress_window)
+
+ _generate_mesh_dialog = HT_GenerateMeshDialogScene.instantiate()
+ _generate_mesh_dialog.generate_selected.connect(_on_GenerateMeshDialog_generate_selected)
+ HT_Util.apply_dpi_scale(_generate_mesh_dialog, dpi_scale)
+ base_control.add_child(_generate_mesh_dialog)
+
+ _resize_dialog = HT_ResizeDialogScene.instantiate()
+ _resize_dialog.permanent_change_performed.connect(_on_permanent_change_performed)
+ HT_Util.apply_dpi_scale(_resize_dialog, dpi_scale)
+ base_control.add_child(_resize_dialog)
+
+ _globalmap_baker = HT_GlobalMapBaker.new()
+ _globalmap_baker.progress_notified.connect(_progress_window.handle_progress)
+ _globalmap_baker.permanent_change_performed.connect(_on_permanent_change_performed)
+ add_child(_globalmap_baker)
+
+ _export_image_dialog = HT_ExportImageDialogScene.instantiate()
+ HT_Util.apply_dpi_scale(_export_image_dialog, dpi_scale)
+ base_control.add_child(_export_image_dialog)
+ # Need to call deferred because in the specific case where you start the editor
+ # with the plugin enabled, _ready won't be called at this point
+ _export_image_dialog.call_deferred("setup_dialogs", base_control)
+
+ _about_dialog = HT_AboutDialogScene.instantiate()
+ HT_Util.apply_dpi_scale(_about_dialog, dpi_scale)
+ base_control.add_child(_about_dialog)
+
+ _texture_set_editor = HT_TextureSetEditorScene.instantiate()
+ _texture_set_editor.set_undo_redo(get_undo_redo())
+ HT_Util.apply_dpi_scale(_texture_set_editor, dpi_scale)
+ base_control.add_child(_texture_set_editor)
+ _texture_set_editor.call_deferred("setup_dialogs", base_control)
+
+ _texture_set_import_editor = HT_TextureSetImportEditorScene.instantiate()
+ _texture_set_import_editor.set_undo_redo(get_undo_redo())
+ _texture_set_import_editor.set_editor_file_system(
+ get_editor_interface().get_resource_filesystem())
+ HT_Util.apply_dpi_scale(_texture_set_import_editor, dpi_scale)
+ base_control.add_child(_texture_set_import_editor)
+ _texture_set_import_editor.call_deferred("setup_dialogs", base_control)
+
+ _texture_set_editor.import_selected.connect(_on_TextureSetEditor_import_selected)
+
+
+func _exit_tree():
+ _logger.debug("HTerrain plugin Exit tree")
+
+ # Make sure we release all references to edited stuff
+ _edit(null)
+
+ _panel.queue_free()
+ _panel = null
+
+ _toolbar.queue_free()
+ _toolbar = null
+
+ _generator_dialog.queue_free()
+ _generator_dialog = null
+
+ _import_dialog.queue_free()
+ _import_dialog = null
+
+ _progress_window.queue_free()
+ _progress_window = null
+
+ _generate_mesh_dialog.queue_free()
+ _generate_mesh_dialog = null
+
+ _resize_dialog.queue_free()
+ _resize_dialog = null
+
+ _export_image_dialog.queue_free()
+ _export_image_dialog = null
+
+ _about_dialog.queue_free()
+ _about_dialog = null
+
+ _texture_set_editor.queue_free()
+ _texture_set_editor = null
+
+ _texture_set_import_editor.queue_free()
+ _texture_set_import_editor = null
+
+ get_editor_interface().get_resource_previewer().remove_preview_generator(_preview_generator)
+ _preview_generator = null
+
+ # TODO Manual clear cuz it can't do it automatically due to a Godot bug
+ _image_cache.clear()
+
+ # TODO https://github.com/godotengine/godot/issues/6254#issuecomment-246139694
+ # This was supposed to be automatic, but was never implemented it seems...
+ remove_custom_type("HTerrain")
+ remove_custom_type("HTerrainDetailLayer")
+ remove_custom_type("HTerrainData")
+ remove_custom_type("HTerrainTextureSet")
+
+
+func _handles(object):
+ return _get_terrain_from_object(object) != null
+
+
+func _edit(object):
+ _logger.debug(str("Edit ", object))
+
+ var node = _get_terrain_from_object(object)
+
+ if _node != null:
+ _node.tree_exited.disconnect(_terrain_exited_scene)
+
+ _node = node
+
+ if _node != null:
+ _node.tree_exited.connect(_terrain_exited_scene)
+
+ _update_brush_buttons_availability()
+
+ _panel.set_terrain(_node)
+ _generator_dialog.set_terrain(_node)
+ _import_dialog.set_terrain(_node)
+ _terrain_painter.set_terrain(_node)
+ _brush_decal.set_terrain(_node)
+ _generate_mesh_dialog.set_terrain(_node)
+ _resize_dialog.set_terrain(_node)
+ _export_image_dialog.set_terrain(_node)
+
+ if object is HTerrainDetailLayer:
+ # Auto-select layer for painting
+ if object.is_layer_index_valid():
+ _panel.set_detail_layer_index(object.get_layer_index())
+ _on_detail_selected(object.get_layer_index())
+
+ _update_toolbar_menu_availability()
+
+
+static func _get_terrain_from_object(object):
+ if object != null and object is Node3D:
+ if not object.is_inside_tree():
+ return null
+ if object is HTerrain:
+ return object
+ if object is HTerrainDetailLayer and object.get_parent() is HTerrain:
+ return object.get_parent()
+ return null
+
+
+func _update_brush_buttons_availability():
+ if _node == null:
+ return
+ if _node.get_data() != null:
+ var data = _node.get_data()
+ var has_details = (data.get_map_count(HTerrainData.CHANNEL_DETAIL) > 0)
+
+ if has_details:
+ var button = _toolbar_brush_buttons[HT_TerrainPainter.MODE_DETAIL]
+ button.disabled = false
+ else:
+ var button = _toolbar_brush_buttons[HT_TerrainPainter.MODE_DETAIL]
+ if button.button_pressed:
+ _select_brush_mode(HT_TerrainPainter.MODE_RAISE)
+ button.disabled = true
+
+
+func _update_toolbar_menu_availability():
+ var data_available := false
+ if _node != null and _node.get_data() != null:
+ data_available = true
+ var popup : PopupMenu = _menu_button.get_popup()
+ for i in popup.get_item_count():
+ #var id = popup.get_item_id(i)
+ # Turn off items if there is no data for them to work on
+ if data_available:
+ popup.set_item_disabled(i, false)
+ popup.set_item_tooltip(i, "")
+ else:
+ popup.set_item_disabled(i, true)
+ popup.set_item_tooltip(i, "Terrain has no data")
+
+
+func _make_visible(visible: bool):
+ _panel.set_visible(visible)
+ _toolbar.set_visible(visible)
+ _brush_decal.update_visibility()
+
+ # TODO Workaround https://github.com/godotengine/godot/issues/6459
+ # When the user selects another node,
+ # I want the plugin to release its references to the terrain.
+ # This is important because if we don't do that, some modified resources will still be
+ # loaded in memory, so if the user closes the scene and reopens it later, the changes will
+ # still be partially present, and this is not expected.
+ if not visible:
+ _edit(null)
+
+
+# TODO Can't hint return as `Vector2?` because it's nullable
+func _get_pointed_cell_position(mouse_position: Vector2, p_camera: Camera3D):# -> Vector2:
+ # Need to do an extra conversion in case the editor viewport is in half-resolution mode
+ var viewport := p_camera.get_viewport()
+ var viewport_container : Control = viewport.get_parent()
+ var screen_pos = mouse_position * Vector2(viewport.size) / viewport_container.size
+
+ var origin = p_camera.project_ray_origin(screen_pos)
+ var dir = p_camera.project_ray_normal(screen_pos)
+
+ var ray_distance := p_camera.far * 1.2
+ return _node.cell_raycast(origin, dir, ray_distance)
+
+
+func _forward_3d_gui_input(p_camera: Camera3D, p_event: InputEvent) -> int:
+ if _node == null || _node.get_data() == null:
+ return AFTER_GUI_INPUT_PASS
+
+ _node._edit_update_viewer_position(p_camera)
+ _panel.set_camera_transform(p_camera.global_transform)
+
+ var captured_event = false
+
+ if p_event is InputEventMouseButton:
+ var mb = p_event
+
+ if mb.button_index == MOUSE_BUTTON_LEFT or mb.button_index == MOUSE_BUTTON_RIGHT:
+ if mb.pressed == false:
+ _mouse_pressed = false
+
+ # Need to check modifiers before capturing the event,
+ # because they are used in navigation schemes
+ if (not mb.ctrl_pressed) and (not mb.alt_pressed) and mb.button_index == MOUSE_BUTTON_LEFT:
+ if mb.pressed:
+ # TODO Allow to paint on click
+ # TODO `pressure` is not available in button press events
+ # So I have to assume zero to avoid discontinuities with move events
+ #_terrain_painter.paint_input(hit_pos_in_cells, 0.0)
+ _mouse_pressed = true
+
+ captured_event = true
+
+ if not _mouse_pressed:
+ # Just finished painting
+ _pending_paint_commit = true
+ _terrain_painter.get_brush().on_paint_end()
+
+ if _terrain_painter.get_mode() == HT_TerrainPainter.MODE_FLATTEN \
+ and _terrain_painter.has_meta("pick_height") \
+ and _terrain_painter.get_meta("pick_height"):
+ _terrain_painter.set_meta("pick_height", false)
+ # Pick height
+ var hit_pos_in_cells = _get_pointed_cell_position(mb.position, p_camera)
+ if hit_pos_in_cells != null:
+ var h = _node.get_data().get_height_at(
+ int(hit_pos_in_cells.x), int(hit_pos_in_cells.y))
+ _logger.debug("Picking height {0}".format([h]))
+ _terrain_painter.set_flatten_height(h)
+
+ elif p_event is InputEventMouseMotion:
+ var mm = p_event
+ var hit_pos_in_cells = _get_pointed_cell_position(mm.position, p_camera)
+ if hit_pos_in_cells != null:
+ _brush_decal.set_position(Vector3(hit_pos_in_cells.x, 0, hit_pos_in_cells.y))
+
+ if _mouse_pressed:
+ if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
+ _terrain_painter.paint_input(hit_pos_in_cells, mm.pressure)
+ captured_event = true
+
+ # This is in case the data or textures change as the user edits the terrain,
+ # to keep the decal working without having to noodle around with nested signals
+ _brush_decal.update_visibility()
+
+ if captured_event:
+ return AFTER_GUI_INPUT_STOP
+ return AFTER_GUI_INPUT_PASS
+
+
+func _process(delta: float):
+ if _node == null:
+ return
+
+ var has_data = (_node.get_data() != null)
+
+ if _pending_paint_commit:
+ if has_data:
+ if not _terrain_painter.is_operation_pending():
+ _pending_paint_commit = false
+ if _terrain_painter.has_modified_chunks():
+ _logger.debug("Paint completed")
+ var changes : Dictionary = _terrain_painter.commit()
+ _paint_completed(changes)
+ else:
+ _pending_paint_commit = false
+
+ # Poll presence of data resource
+ if has_data != _terrain_had_data_previous_frame:
+ _terrain_had_data_previous_frame = has_data
+ _update_toolbar_menu_availability()
+
+
+func _paint_completed(changes: Dictionary):
+ var time_before = Time.get_ticks_msec()
+
+ var heightmap_data = _node.get_data()
+ assert(heightmap_data != null)
+
+ var chunk_positions : Array = changes.chunk_positions
+ # Should not create an UndoRedo action if nothing changed
+ assert(len(chunk_positions) > 0)
+ var changed_maps : Array = changes.maps
+
+ var action_name := "Modify HTerrainData "
+ for i in len(changed_maps):
+ var mm = changed_maps[i]
+ var map_debug_name := HTerrainData.get_map_debug_name(mm.map_type, mm.map_index)
+ if i > 0:
+ action_name += " and "
+ action_name += map_debug_name
+
+ var redo_maps := []
+ var undo_maps := []
+ var chunk_size := _terrain_painter.get_undo_chunk_size()
+
+ for map in changed_maps:
+ # Cache images to disk so RAM does not continuously go up (or at least much slower)
+ for chunks in [map.chunk_initial_datas, map.chunk_final_datas]:
+ for i in len(chunks):
+ var im : Image = chunks[i]
+ chunks[i] = _image_cache.save_image(im)
+
+ redo_maps.append({
+ "map_type": map.map_type,
+ "map_index": map.map_index,
+ "chunks": map.chunk_final_datas
+ })
+ undo_maps.append({
+ "map_type": map.map_type,
+ "map_index": map.map_index,
+ "chunks": map.chunk_initial_datas
+ })
+
+ var undo_data := {
+ "chunk_positions": chunk_positions,
+ "chunk_size": chunk_size,
+ "maps": undo_maps
+ }
+ var redo_data := {
+ "chunk_positions": chunk_positions,
+ "chunk_size": chunk_size,
+ "maps": redo_maps
+ }
+
+# {
+# chunk_positions: [Vector2, Vector2, ...]
+# chunk_size: int
+# maps: [
+# {
+# map_type: int
+# map_index: int
+# chunks: [
+# int, int, ...
+# ]
+# },
+# ...
+# ]
+# }
+
+ var ur := get_undo_redo()
+
+ ur.create_action(action_name)
+ ur.add_do_method(heightmap_data, "_edit_apply_undo", redo_data, _image_cache)
+ ur.add_undo_method(heightmap_data, "_edit_apply_undo", undo_data, _image_cache)
+
+ # Small hack here:
+ # commit_actions executes the do method, however terrain modifications are heavy ones,
+ # so we don't really want to re-run an update in every chunk that was modified during painting.
+ # The data is already in its final state,
+ # so we just prevent the resource from applying changes here.
+ heightmap_data._edit_set_disable_apply_undo(true)
+ ur.commit_action()
+ heightmap_data._edit_set_disable_apply_undo(false)
+
+ var time_spent = Time.get_ticks_msec() - time_before
+ _logger.debug(str(action_name, " | ", len(chunk_positions), " chunks | ", time_spent, " ms"))
+
+
+func _terrain_exited_scene():
+ _logger.debug("HTerrain exited the scene")
+ _edit(null)
+
+
+func _menu_item_selected(id: int):
+ _logger.debug(str("Menu item selected ", id))
+
+ match id:
+ MENU_IMPORT_MAPS:
+ _import_dialog.popup_centered()
+
+ MENU_GENERATE:
+ _generator_dialog.popup_centered()
+
+ MENU_BAKE_GLOBALMAP:
+ var data = _node.get_data()
+ if data != null:
+ _globalmap_baker.bake(_node)
+
+ MENU_RESIZE:
+ _resize_dialog.popup_centered()
+
+ MENU_UPDATE_EDITOR_COLLIDER:
+ # This is for editor tools to be able to use terrain collision.
+ # It's not automatic because keeping this collider up to date is
+ # expensive, but not too bad IMO because that feature is not often
+ # used in editor for now.
+ # If users complain too much about this, there are ways to improve it:
+ #
+ # 1) When the terrain gets deselected, update the terrain collider
+ # in a thread automatically. This is still expensive but should
+ # be easy to do.
+ #
+ # 2) Bullet actually support modifying the heights dynamically,
+ # as long as we stay within min and max bounds,
+ # so PR a change to the Godot heightmap collider to support passing
+ # a Float Image directly, and make it so the data is in sync
+ # (no CoW plz!!). It's trickier than 1) but almost free.
+ #
+ _node.update_collider()
+
+ MENU_GENERATE_MESH:
+ if _node != null and _node.get_data() != null:
+ _generate_mesh_dialog.popup_centered()
+
+ MENU_EXPORT_HEIGHTMAP:
+ if _node != null and _node.get_data() != null:
+ _export_image_dialog.popup_centered()
+
+ MENU_LOOKDEV:
+ # No actions here, it's a submenu
+ pass
+
+ MENU_DOCUMENTATION:
+ OS.shell_open(DOCUMENTATION_URL)
+
+ MENU_ABOUT:
+ _about_dialog.popup_centered()
+
+
+func _on_lookdev_menu_about_to_show():
+ _lookdev_menu.clear()
+ _lookdev_menu.add_check_item("Disabled")
+ _lookdev_menu.set_item_checked(0, not _node.is_lookdev_enabled())
+ _lookdev_menu.add_separator()
+ var terrain_data : HTerrainData = _node.get_data()
+ if terrain_data == null:
+ _lookdev_menu.add_item("No terrain data")
+ _lookdev_menu.set_item_disabled(0, true)
+ else:
+ for map_type in HTerrainData.CHANNEL_COUNT:
+ var count := terrain_data.get_map_count(map_type)
+ for map_index in count:
+ var map_name := HTerrainData.get_map_debug_name(map_type, map_index)
+ var lookdev_item_index := _lookdev_menu.get_item_count()
+ _lookdev_menu.add_item(map_name, lookdev_item_index)
+ _lookdev_menu.set_item_metadata(lookdev_item_index, {
+ "map_type": map_type,
+ "map_index": map_index
+ })
+
+
+func _on_lookdev_menu_id_pressed(id: int):
+ var meta = _lookdev_menu.get_item_metadata(id)
+ if meta == null:
+ _node.set_lookdev_enabled(false)
+ else:
+ _node.set_lookdev_enabled(true)
+ var data : HTerrainData = _node.get_data()
+ var map_texture = data.get_texture(meta.map_type, meta.map_index)
+ _node.set_lookdev_shader_param("u_map", map_texture)
+ _lookdev_menu.set_item_checked(0, not _node.is_lookdev_enabled())
+
+
+func _on_mode_selected(mode: int):
+ _logger.debug(str("On mode selected ", mode))
+ _terrain_painter.set_mode(mode)
+ _panel.set_brush_editor_display_mode(mode)
+
+
+func _on_texture_selected(index: int):
+ # Switch to texture paint mode when a texture is selected
+ _select_brush_mode(HT_TerrainPainter.MODE_SPLAT)
+ _terrain_painter.set_texture_index(index)
+
+
+func _on_detail_selected(index: int):
+ # Switch to detail paint mode when a detail item is selected
+ _select_brush_mode(HT_TerrainPainter.MODE_DETAIL)
+ _terrain_painter.set_detail_index(index)
+
+
+func _select_brush_mode(mode: int):
+ _toolbar_brush_buttons[mode].button_pressed = true
+ _on_mode_selected(mode)
+
+
+static func get_size_from_raw_length(flen: int):
+ var side_len = roundf(sqrt(float(flen/2)))
+ return int(side_len)
+
+
+func _on_GenerateMeshDialog_generate_selected(lod: int):
+ var data := _node.get_data()
+ if data == null:
+ _logger.error("Terrain has no data, cannot generate mesh")
+ return
+ var heightmap := data.get_image(HTerrainData.CHANNEL_HEIGHT)
+ var scale := _node.map_scale
+ var mesh := HTerrainMesher.make_heightmap_mesh(heightmap, lod, scale, _logger)
+ var mi := MeshInstance3D.new()
+ mi.name = str(_node.name, "_FullMesh")
+ mi.mesh = mesh
+ mi.transform = _node.transform
+ _node.get_parent().add_child(mi)
+ mi.set_owner(get_editor_interface().get_edited_scene_root())
+
+
+# TODO Workaround for https://github.com/Zylann/godot_heightmap_plugin/issues/101
+func _on_permanent_change_performed(message: String):
+ var data := _node.get_data()
+ if data == null:
+ _logger.error("Terrain has no data, cannot mark it as changed")
+ return
+ var ur := get_undo_redo()
+ ur.create_action(message)
+ ur.add_do_method(data, "_dummy_function")
+ #ur.add_undo_method(data, "_dummy_function")
+ ur.commit_action()
+
+
+func _on_brush_size_changed(size):
+ _brush_decal.set_size(size)
+
+
+func _on_Panel_edit_texture_pressed(index: int):
+ var ts := _node.get_texture_set()
+ _texture_set_editor.set_texture_set(ts)
+ _texture_set_editor.select_slot(index)
+ _texture_set_editor.popup_centered()
+
+
+func _on_TextureSetEditor_import_selected():
+ _open_texture_set_import_editor()
+
+
+func _on_Panel_import_textures_pressed():
+ _open_texture_set_import_editor()
+
+
+func _open_texture_set_import_editor():
+ var ts := _node.get_texture_set()
+ _texture_set_import_editor.set_texture_set(ts)
+ _texture_set_import_editor.popup_centered()
+
+
+################
+# DEBUG LAND
+
+# TEST
+#func _physics_process(delta):
+# if Input.is_key_pressed(KEY_KP_0):
+# _debug_spawn_collider_indicators()
+
+
+func _debug_spawn_collider_indicators():
+ var root = get_editor_interface().get_edited_scene_root()
+ var terrain := HT_Util.find_first_node(root, HTerrain) as HTerrain
+ if terrain == null:
+ return
+
+ var test_root : Node3D
+ if not terrain.has_node("__DEBUG"):
+ test_root = Node3D.new()
+ test_root.name = "__DEBUG"
+ terrain.add_child(test_root)
+ else:
+ test_root = terrain.get_node("__DEBUG")
+
+ var space_state := terrain.get_world_3d().direct_space_state
+ var hit_material := StandardMaterial3D.new()
+ hit_material.albedo_color = Color(0, 1, 1)
+ var cube := BoxMesh.new()
+
+ for zi in 16:
+ for xi in 16:
+ var hit_name := str(xi, "_", zi)
+ var pos := Vector3(xi * 16, 1000, zi * 16)
+
+ var query := PhysicsRayQueryParameters3D.new()
+ query.from = pos
+ query.to = pos + Vector3(0, -2000, 0)
+
+ var hit := space_state.intersect_ray(query)
+
+ var mi : MeshInstance3D
+ if not test_root.has_node(hit_name):
+ mi = MeshInstance3D.new()
+ mi.name = hit_name
+ mi.material_override = hit_material
+ mi.mesh = cube
+ test_root.add_child(mi)
+ else:
+ mi = test_root.get_node(hit_name)
+ if hit.is_empty():
+ mi.hide()
+ else:
+ mi.show()
+ mi.position = hit.position
+
+
+func _spawn_vertical_bound_boxes():
+ var data := _node.get_data()
+# var sy = data._chunked_vertical_bounds_size_y
+# var sx = data._chunked_vertical_bounds_size_x
+ var mat := StandardMaterial3D.new()
+ mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ mat.albedo_color = Color(1,1,1,0.2)
+ for cy in range(30, 60):
+ for cx in range(30, 60):
+ var vb := data._chunked_vertical_bounds.get_pixel(cx, cy)
+ var minv := vb.r
+ var maxv := vb.g
+ var mi := MeshInstance3D.new()
+ mi.mesh = BoxMesh.new()
+ var cs := HTerrainData.VERTICAL_BOUNDS_CHUNK_SIZE
+ mi.mesh.size = Vector3(cs, maxv - minv, cs)
+ mi.position = Vector3(
+ (float(cx) + 0.5) * cs,
+ minv + mi.mesh.size.y * 0.5,
+ (float(cy) + 0.5) * cs)
+ mi.position *= _node.map_scale
+ mi.scale = _node.map_scale
+ mi.material_override = mat
+ _node.add_child(mi)
+ mi.owner = get_editor_interface().get_edited_scene_root()
+
+# if p_event is InputEventKey:
+# if p_event.pressed == false:
+# if p_event.scancode == KEY_SPACE and p_event.control:
+# _spawn_vertical_bound_boxes()
diff --git a/game/addons/zylann.hterrain/tools/preview_generator.gd b/game/addons/zylann.hterrain/tools/preview_generator.gd
new file mode 100644
index 0000000..4b6e3db
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/preview_generator.gd
@@ -0,0 +1,65 @@
+@tool
+extends EditorResourcePreviewGenerator
+
+const HTerrainData = preload("../hterrain_data.gd")
+const HT_Errors = preload("../util/errors.gd")
+const HT_Logger = preload("../util/logger.gd")
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _generate(res: Resource, size: Vector2i, metadata: Dictionary) -> Texture2D:
+ if res == null or not (res is HTerrainData):
+ return null
+ var normalmap = res.get_image(HTerrainData.CHANNEL_NORMAL)
+ if normalmap == null:
+ return null
+ return _generate_internal(normalmap, size)
+
+
+func _generate_from_path(path: String, size: Vector2i, metadata: Dictionary) -> Texture2D:
+ if not path.ends_with("." + HTerrainData.META_EXTENSION):
+ return null
+ var data_dir := path.get_base_dir()
+ var normals_fname := str(HTerrainData.get_channel_name(HTerrainData.CHANNEL_NORMAL), ".png")
+ var normals_path := data_dir.path_join(normals_fname)
+ var normals := Image.new()
+ var err := normals.load(normals_path)
+ if err != OK:
+ _logger.error("Could not load '{0}', error {1}" \
+ .format([normals_path, HT_Errors.get_message(err)]))
+ return null
+ return _generate_internal(normals, size)
+
+
+func _handles(type: String) -> bool:
+ return type == "Resource"
+
+
+static func _generate_internal(normals: Image, size: Vector2) -> Texture2D:
+ var im := Image.create(size.x, size.y, false, Image.FORMAT_RGB8)
+
+ var light_dir := Vector3(-1, -0.5, -1).normalized()
+
+ for y in im.get_height():
+ for x in im.get_width():
+
+ var fx := float(x) / float(im.get_width())
+ var fy := float(im.get_height() - y - 1) / float(im.get_height())
+ var mx := int(fx * normals.get_width())
+ var my := int(fy * normals.get_height())
+
+ var n := _decode_normal(normals.get_pixel(mx, my))
+
+ var ndot := -n.dot(light_dir)
+ var gs := clampf(0.5 * ndot + 0.5, 0.0, 1.0)
+ var col := Color(gs, gs, gs, 1.0)
+
+ im.set_pixel(x, y, col)
+
+ var tex := ImageTexture.create_from_image(im)
+ return tex
+
+
+static func _decode_normal(c: Color) -> Vector3:
+ return Vector3(2.0 * c.r - 1.0, 2.0 * c.b - 1.0, 2.0 * c.g - 1.0)
diff --git a/game/addons/zylann.hterrain/tools/progress_window.gd b/game/addons/zylann.hterrain/tools/progress_window.gd
new file mode 100644
index 0000000..791717f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/progress_window.gd
@@ -0,0 +1,32 @@
+@tool
+extends AcceptDialog
+
+
+#onready var _label = get_node("VBoxContainer/Label")
+@onready var _progress_bar : ProgressBar = $VBoxContainer/ProgressBar
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _show_progress(message, progress):
+ self.title = message
+ _progress_bar.ratio = progress
+
+
+func handle_progress(info: Dictionary):
+ if info.has("finished") and info.finished:
+ hide()
+
+ else:
+ if not visible:
+ popup_centered()
+
+ var message = ""
+ if info.has("message"):
+ message = info.message
+
+ _show_progress(info.message, info.progress)
+ # TODO Have builtin modal progress bar
+ # https://github.com/godotengine/godot/issues/17763
diff --git a/game/addons/zylann.hterrain/tools/progress_window.tscn b/game/addons/zylann.hterrain/tools/progress_window.tscn
new file mode 100644
index 0000000..e6d7d19
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/progress_window.tscn
@@ -0,0 +1,22 @@
+[gd_scene load_steps=2 format=3 uid="uid://b0f3h46ugtni6"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/progress_window.gd" id="1"]
+
+[node name="WindowDialog" type="AcceptDialog"]
+title = ""
+size = Vector2i(400, 100)
+min_size = Vector2i(400, 40)
+script = ExtResource("1")
+
+[node name="VBoxContainer" 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 = -8.0
+
+[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer"]
+layout_mode = 2
+step = 1.0
diff --git a/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd
new file mode 100644
index 0000000..617e457
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd
@@ -0,0 +1,174 @@
+@tool
+extends AcceptDialog
+
+const HT_Util = preload("../../util/util.gd")
+const HT_Logger = preload("../../util/logger.gd")
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainData = preload("../../hterrain_data.gd")
+
+const ANCHOR_TOP_LEFT = 0
+const ANCHOR_TOP = 1
+const ANCHOR_TOP_RIGHT = 2
+const ANCHOR_LEFT = 3
+const ANCHOR_CENTER = 4
+const ANCHOR_RIGHT = 5
+const ANCHOR_BOTTOM_LEFT = 6
+const ANCHOR_BOTTOM = 7
+const ANCHOR_BOTTOM_RIGHT = 8
+const ANCHOR_COUNT = 9
+
+const _anchor_dirs = [
+ [-1, -1],
+ [0, -1],
+ [1, -1],
+ [-1, 0],
+ [0, 0],
+ [1, 0],
+ [-1, 1],
+ [0, 1],
+ [1, 1]
+]
+
+const _anchor_icon_names = [
+ "anchor_top_left",
+ "anchor_top",
+ "anchor_top_right",
+ "anchor_left",
+ "anchor_center",
+ "anchor_right",
+ "anchor_bottom_left",
+ "anchor_bottom",
+ "anchor_bottom_right"
+]
+
+signal permanent_change_performed(message)
+
+@onready var _resolution_dropdown : OptionButton = $VBoxContainer/GridContainer/ResolutionDropdown
+@onready var _stretch_checkbox : CheckBox = $VBoxContainer/GridContainer/StretchCheckBox
+@onready var _anchor_control : Control = $VBoxContainer/GridContainer/HBoxContainer/AnchorControl
+
+const _resolutions = HTerrainData.SUPPORTED_RESOLUTIONS
+
+var _anchor_buttons := []
+var _anchor_buttons_grid := {}
+var _anchor_button_group : ButtonGroup = null
+var _selected_anchor = ANCHOR_TOP_LEFT
+var _logger = HT_Logger.get_for(self)
+
+var _terrain : HTerrain = null
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+static func _get_icon(name) -> Texture2D:
+ return load("res://addons/zylann.hterrain/tools/icons/icon_" + name + ".svg")
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+ # TEST
+ #show()
+
+ for i in len(_resolutions):
+ _resolution_dropdown.add_item(str(_resolutions[i]), i)
+
+ _anchor_button_group = ButtonGroup.new()
+ _anchor_buttons.resize(ANCHOR_COUNT)
+ var x := 0
+ var y := 0
+ for i in _anchor_control.get_child_count():
+ var child_node = _anchor_control.get_child(i)
+ assert(child_node is Button)
+ var child := child_node as Button
+ child.toggle_mode = true
+ child.custom_minimum_size = child.size
+ child.icon = null
+ child.pressed.connect(_on_AnchorButton_pressed.bind(i, x, y))
+ child.button_group = _anchor_button_group
+ _anchor_buttons[i] = child
+ _anchor_buttons_grid[Vector2(x, y)] = child
+ x += 1
+ if x >= 3:
+ x = 0
+ y += 1
+
+ _anchor_buttons[_selected_anchor].button_pressed = true
+ # The signal apparently doesn't trigger in this case
+ _on_AnchorButton_pressed(_selected_anchor, 0, 0)
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ if visible:
+ # Select current resolution
+ if _terrain != null and _terrain.get_data() != null:
+ var res := _terrain.get_data().get_resolution()
+ for i in len(_resolutions):
+ if res == _resolutions[i]:
+ _resolution_dropdown.select(i)
+ break
+
+
+func _on_AnchorButton_pressed(anchor0: int, x0: int, y0: int):
+ _selected_anchor = anchor0
+
+ for button in _anchor_buttons:
+ button.icon = null
+
+ for anchor in ANCHOR_COUNT:
+ var d = _anchor_dirs[anchor]
+ var nx = x0 + d[0]
+ var ny = y0 + d[1]
+ var k = Vector2(nx, ny)
+ if not _anchor_buttons_grid.has(k):
+ continue
+ var button : Button = _anchor_buttons_grid[k]
+ var icon := _get_icon(_anchor_icon_names[anchor])
+ button.icon = icon
+
+
+func _set_anchor_control_active(active: bool):
+ for button in _anchor_buttons:
+ button.disabled = not active
+
+
+func _on_ResolutionDropdown_item_selected(id):
+ pass
+
+
+func _on_StretchCheckBox_toggled(button_pressed: bool):
+ _set_anchor_control_active(not button_pressed)
+
+
+func _on_ApplyButton_pressed():
+ var stretch = _stretch_checkbox.button_pressed
+ var res = _resolutions[_resolution_dropdown.get_selected_id()]
+ var dir = _anchor_dirs[_selected_anchor]
+ _apply(res, stretch, Vector2(dir[0], dir[1]))
+ hide()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _apply(p_resolution: int, p_stretch: bool, p_anchor: Vector2):
+ if _terrain == null:
+ _logger.error("Cannot apply resize, terrain is not set")
+ return
+
+ var data = _terrain.get_data()
+ if data == null:
+ _logger.error("Cannot apply resize, terrain has no data")
+ return
+
+ data.resize(p_resolution, p_stretch, p_anchor)
+ data.notify_full_change()
+ permanent_change_performed.emit("Resize terrain")
diff --git a/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn
new file mode 100644
index 0000000..c0678ae
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn
@@ -0,0 +1,159 @@
+[gd_scene load_steps=7 format=3 uid="uid://gt402qqhab7j"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd" id="1"]
+[ext_resource type="Texture2D" uid="uid://b4ya0po3a4nqa" path="res://addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg" id="2"]
+[ext_resource type="Texture2D" uid="uid://d3vie0tj3ry6k" path="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg" id="3"]
+[ext_resource type="Texture2D" uid="uid://b6l5dys0awbwd" path="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg" id="4"]
+[ext_resource type="Texture2D" uid="uid://bdkcgtv1r5j31" path="res://addons/zylann.hterrain/tools/icons/icon_small_circle.svg" id="5"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="6"]
+
+[node name="ResizeDialog" type="AcceptDialog"]
+title = "Resize terrain"
+size = Vector2i(300, 201)
+min_size = Vector2i(300, 200)
+script = ExtResource("1")
+
+[node name="VBoxContainer" 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 = -18.0
+
+[node name="GridContainer" type="GridContainer" parent="VBoxContainer"]
+layout_mode = 2
+columns = 2
+
+[node name="Label" type="Label" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+text = "Resolution"
+
+[node name="ResolutionDropdown" type="OptionButton" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+toggle_mode = false
+
+[node name="Label3" type="Label" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+text = "Stretch"
+
+[node name="StretchCheckBox" type="CheckBox" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+
+[node name="Label2" type="Label" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+text = "Direction"
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer"]
+layout_mode = 2
+
+[node name="AnchorControl" type="GridContainer" parent="VBoxContainer/GridContainer/HBoxContainer"]
+layout_mode = 2
+columns = 3
+
+[node name="TopLeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="TopButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="TopRightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="LeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="CenterButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="RightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="ButtomLeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="ButtomButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="BottomRightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl"]
+layout_mode = 2
+icon = ExtResource("2")
+
+[node name="Reference" type="Control" parent="VBoxContainer/GridContainer/HBoxContainer"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+
+[node name="XArrow" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+modulate = Color(1, 0.292969, 0.292969, 1)
+layout_mode = 0
+anchor_right = 1.0
+offset_left = 9.0
+offset_bottom = 16.0
+texture = ExtResource("3")
+
+[node name="ZArrow" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+modulate = Color(0.292969, 0.602295, 1, 1)
+layout_mode = 0
+anchor_bottom = 1.0
+offset_top = 10.0
+offset_right = 16.0
+texture = ExtResource("4")
+
+[node name="ZLabel" type="Label" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+layout_mode = 0
+offset_left = 14.0
+offset_top = 54.0
+offset_right = 22.0
+offset_bottom = 68.0
+text = "Z"
+
+[node name="XLabel" type="Label" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+layout_mode = 0
+offset_left = 52.0
+offset_top = 14.0
+offset_right = 60.0
+offset_bottom = 28.0
+text = "X"
+
+[node name="Origin" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference"]
+layout_mode = 0
+offset_left = 3.0
+offset_top = 4.0
+offset_right = 11.0
+offset_bottom = 12.0
+texture = ExtResource("5")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="ApplyButton" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "Apply (no undo)"
+
+[node name="CancelButton" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "Cancel"
+
+[node name="DialogFitter" parent="." instance=ExtResource("6")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 292.0
+offset_bottom = 183.0
+
+[connection signal="item_selected" from="VBoxContainer/GridContainer/ResolutionDropdown" to="." method="_on_ResolutionDropdown_item_selected"]
+[connection signal="toggled" from="VBoxContainer/GridContainer/StretchCheckBox" to="." method="_on_StretchCheckBox_toggled"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/ApplyButton" to="." method="_on_ApplyButton_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/CancelButton" to="." method="_on_CancelButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/terrain_preview.gd b/game/addons/zylann.hterrain/tools/terrain_preview.gd
new file mode 100644
index 0000000..1c64b6d
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/terrain_preview.gd
@@ -0,0 +1,143 @@
+@tool
+extends SubViewportContainer
+
+const PREVIEW_MESH_LOD = 2
+
+const HTerrainMesher = preload("../hterrain_mesher.gd")
+const HT_Util = preload("../util/util.gd")
+
+signal dragged(relative, button_mask)
+
+@onready var _viewport : SubViewport = $Viewport
+@onready var _mesh_instance : MeshInstance3D = $Viewport/MeshInstance
+@onready var _camera : Camera3D = $Viewport/Camera
+@onready var _light : DirectionalLight3D = $Viewport/DirectionalLight
+
+# Use the simplest shader
+var _shader : Shader = load("res://addons/zylann.hterrain/shaders/simple4_lite.gdshader")
+var _yaw := 0.0
+var _pitch := -PI / 6.0
+var _distance := 0.0
+var _default_distance := 0.0
+var _sea_outline : MeshInstance3D = null
+var _sea_plane : MeshInstance3D = null
+var _mesh_resolution := 0
+
+
+func _ready():
+ if _sea_outline == null:
+ var mesh := HT_Util.create_wirecube_mesh()
+ var mat2 := StandardMaterial3D.new()
+ mat2.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ mat2.albedo_color = Color(0, 0.5, 1)
+ mesh.surface_set_material(0, mat2)
+ _sea_outline = MeshInstance3D.new()
+ _sea_outline.mesh = mesh
+ _viewport.add_child(_sea_outline)
+
+ if _sea_plane == null:
+ var mesh := PlaneMesh.new()
+ mesh.size = Vector2(1, 1)
+ var mat2 := StandardMaterial3D.new()
+ mat2.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ mat2.albedo_color = Color(0, 0.5, 1, 0.5)
+ mat2.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ mesh.material = mat2
+ _sea_plane = MeshInstance3D.new()
+ _sea_plane.mesh = mesh
+ _sea_plane.hide()
+ _viewport.add_child(_sea_plane)
+
+
+func setup(heights_texture: Texture2D, normals_texture: Texture2D):
+ var terrain_size := heights_texture.get_width()
+ var mesh_resolution := terrain_size / PREVIEW_MESH_LOD
+
+ if _mesh_resolution != mesh_resolution or not (_mesh_instance.mesh is ArrayMesh):
+ _mesh_resolution = mesh_resolution
+ var mesh := HTerrainMesher.make_flat_chunk(
+ _mesh_resolution, _mesh_resolution, PREVIEW_MESH_LOD, 0)
+ _mesh_instance.mesh = mesh
+ _default_distance = _mesh_instance.get_aabb().size.x
+ _distance = _default_distance
+ #_mesh_instance.translation -= 0.5 * Vector3(terrain_size, 0, terrain_size)
+ _update_camera()
+
+ var mat : ShaderMaterial = _mesh_instance.mesh.surface_get_material(0)
+
+ if mat == null:
+ mat = ShaderMaterial.new()
+ mat.shader = _shader
+ _mesh_instance.mesh.surface_set_material(0, mat)
+
+ mat.set_shader_parameter("u_terrain_heightmap", heights_texture)
+ mat.set_shader_parameter("u_terrain_normalmap", normals_texture)
+ mat.set_shader_parameter("u_terrain_inverse_transform", Transform3D())
+ mat.set_shader_parameter("u_terrain_normal_basis", Basis())
+
+ var aabb := _mesh_instance.get_aabb()
+ _sea_outline.scale = aabb.size
+
+ aabb = _mesh_instance.get_aabb()
+ _sea_plane.scale = Vector3(aabb.size.x, 1, aabb.size.z)
+ _sea_plane.position = Vector3(aabb.size.x, 0, aabb.size.z) / 2.0
+
+
+func set_sea_visible(visible: bool):
+ _sea_plane.visible = visible
+
+
+func set_shadows_enabled(enabled: bool):
+ _light.shadow_enabled = enabled
+
+
+func _update_camera():
+ var aabb := _mesh_instance.get_aabb()
+ var target := aabb.position + 0.5 * aabb.size
+ var trans := Transform3D()
+ trans.basis = Basis(Quaternion(Vector3(0, 1, 0), _yaw) * Quaternion(Vector3(1, 0, 0), _pitch))
+ var back := trans.basis.z
+ trans.origin = target + back * _distance
+ _camera.transform = trans
+
+
+func cleanup():
+ if _mesh_instance != null:
+ var mat : ShaderMaterial = _mesh_instance.mesh.surface_get_material(0)
+ assert(mat != null)
+ mat.set_shader_parameter("u_terrain_heightmap", null)
+ mat.set_shader_parameter("u_terrain_normalmap", null)
+
+
+func _gui_input(event: InputEvent):
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ if event is InputEventMouseMotion:
+ if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE:
+ var d : Vector2 = 0.01 * event.relative
+ _yaw -= d.x
+ _pitch -= d.y
+ _update_camera()
+ else:
+ var rel : Vector2 = 0.01 * event.relative
+ # Align dragging to view rotation
+ rel = -rel.rotated(-_yaw)
+ dragged.emit(rel, event.button_mask)
+
+ elif event is InputEventMouseButton:
+ if event.pressed:
+
+ var factor := 1.2
+ var max_factor := 10.0
+ var min_distance := _default_distance / max_factor
+ var max_distance := _default_distance
+
+ # Zoom in/out
+ if event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
+ _distance = clampf(_distance * factor, min_distance, max_distance)
+ _update_camera()
+
+ elif event.button_index == MOUSE_BUTTON_WHEEL_UP:
+ _distance = clampf(_distance / factor, min_distance, max_distance)
+ _update_camera()
diff --git a/game/addons/zylann.hterrain/tools/terrain_preview.tscn b/game/addons/zylann.hterrain/tools/terrain_preview.tscn
new file mode 100644
index 0000000..0b87a16
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/terrain_preview.tscn
@@ -0,0 +1,38 @@
+[gd_scene load_steps=3 format=3 uid="uid://bue2flijnxa3p"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/terrain_preview.gd" id="1"]
+
+[sub_resource type="PlaneMesh" id="3"]
+size = Vector2(256, 256)
+
+[node name="TerrainPreview" type="SubViewportContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 0
+stretch = true
+script = ExtResource("1")
+
+[node name="Viewport" type="SubViewport" parent="."]
+own_world_3d = true
+handle_input_locally = false
+size = Vector2i(1152, 648)
+render_target_update_mode = 4
+
+[node name="DirectionalLight" type="DirectionalLight3D" parent="Viewport"]
+transform = Transform3D(-0.901211, 0.315056, -0.297588, 0, 0.686666, 0.726973, 0.433381, 0.655156, -0.618831, 0, 0, 0)
+light_bake_mode = 1
+shadow_enabled = true
+shadow_bias = 0.5
+directional_shadow_mode = 1
+directional_shadow_max_distance = 1000.0
+
+[node name="MeshInstance" type="MeshInstance3D" parent="Viewport"]
+mesh = SubResource("3")
+
+[node name="Camera" type="Camera3D" parent="Viewport"]
+transform = Transform3D(-1, 3.31486e-08, -8.08945e-08, 0, 0.925325, 0.379176, 8.74228e-08, 0.379176, -0.925325, -2.25312e-05, 145.456, -348.286)
+current = true
+fov = 70.0
+near = 1.0
+far = 1000.0
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader
new file mode 100644
index 0000000..7ac1b98
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader
@@ -0,0 +1,6 @@
+shader_type canvas_item;
+
+void fragment() {
+ float a = texture(TEXTURE, UV).a;
+ COLOR = vec4(a, a, a, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres
new file mode 100644
index 0000000..34ef863
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres
@@ -0,0 +1,9 @@
+[gd_resource type="ShaderMaterial" load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader" type="Shader" id=1]
+
+[resource]
+
+render_priority = 0
+shader = ExtResource( 1 )
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader
new file mode 100644
index 0000000..384e1a2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+
+uniform sampler2DArray u_texture_array;
+uniform float u_index;
+
+void fragment() {
+ vec4 col = texture(u_texture_array, vec3(UV.x, UV.y, u_index));
+ COLOR = vec4(col.a, col.a, col.a, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader
new file mode 100644
index 0000000..dcc1fda
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_color.gdshader
@@ -0,0 +1,7 @@
+shader_type canvas_item;
+
+void fragment() {
+ // TODO Have an option to "undo" sRGB, for funzies?
+ vec4 col = texture(TEXTURE, UV);
+ COLOR = vec4(col.rgb, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres b/game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres
new file mode 100644
index 0000000..094400f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres
@@ -0,0 +1,6 @@
+[gd_resource type="ShaderMaterial" load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/display_color.gdshader" type="Shader" id=1]
+
+[resource]
+shader = ExtResource( 1 )
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader
new file mode 100644
index 0000000..d2e4f5c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_color_slice.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+
+uniform sampler2DArray u_texture_array;
+uniform float u_index;
+
+void fragment() {
+ vec4 col = texture(u_texture_array, vec3(UV.x, UV.y, u_index));
+ COLOR = vec4(col.rgb, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader b/game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader
new file mode 100644
index 0000000..665af53
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/display_normal.gdshader
@@ -0,0 +1,28 @@
+shader_type canvas_item;
+
+uniform float u_strength = 1.0;
+uniform bool u_flip_y = false;
+
+vec3 unpack_normal(vec4 rgba) {
+ vec3 n = rgba.xzy * 2.0 - vec3(1.0);
+ // Had to negate Z because it comes from Y in the normal map,
+ // and OpenGL-style normal maps are Y-up.
+ n.z *= -1.0;
+ return n;
+}
+
+vec3 pack_normal(vec3 n) {
+ n.z *= -1.0;
+ return 0.5 * (n.xzy + vec3(1.0));
+}
+
+void fragment() {
+ vec4 col = texture(TEXTURE, UV);
+ vec3 n = unpack_normal(col);
+ n = normalize(mix(n, vec3(-n.x, n.y, -n.z), 0.5 - 0.5 * u_strength));
+ if (u_flip_y) {
+ n.z = -n.z;
+ }
+ col.rgb = pack_normal(n);
+ COLOR = vec4(col.rgb, 1.0);
+}
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd b/game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd
new file mode 100644
index 0000000..4425134
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/flow_container.gd
@@ -0,0 +1,41 @@
+@tool
+extends Container
+
+const SEPARATION = 2
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_SORT_CHILDREN:
+ _sort_children2()
+
+
+# TODO Function with ugly name to workaround a Godot 3.1 issue
+# See https://github.com/godotengine/godot/pull/38396
+func _sort_children2():
+ var max_x := size.x - SEPARATION
+ var pos := Vector2(SEPARATION, SEPARATION)
+ var line_height := 0
+
+ for i in get_child_count():
+ var child_node = get_child(i)
+ if not child_node is Control:
+ continue
+ var child := child_node as Control
+
+ var rect := child.get_rect()
+
+ if rect.size.y > line_height:
+ line_height = rect.size.y
+
+ if pos.x + rect.size.x > max_x:
+ pos.x = SEPARATION
+ pos.y += line_height + SEPARATION
+ line_height = rect.size.y
+
+ rect.position = pos
+ fit_child_in_rect(child, rect)
+
+ pos.x += rect.size.x + SEPARATION
+
+ custom_minimum_size.y = pos.y + line_height
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd
new file mode 100644
index 0000000..834accb
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd
@@ -0,0 +1,60 @@
+@tool
+extends Control
+
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_EmptyTexture = preload("../../icons/empty.png")
+const EMPTY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal load_pressed
+signal clear_pressed
+
+
+@onready var _label : Label = $Label
+@onready var _texture_rect : TextureRect = $TextureRect
+
+@onready var _buttons = [
+ $LoadButton,
+ $ClearButton
+]
+
+var _material : Material
+var _is_empty := true
+
+
+func set_label(text: String):
+ _label.text = text
+
+
+func set_texture(tex: Texture):
+ if tex == null:
+ _texture_rect.texture = load(EMPTY_TEXTURE_PATH)
+ _texture_rect.material = null
+ _is_empty = true
+ else:
+ _texture_rect.texture = tex
+ _texture_rect.material = _material
+ _is_empty = false
+
+
+func set_texture_tooltip(msg: String):
+ _texture_rect.tooltip_text = msg
+
+
+func _on_LoadButton_pressed():
+ load_pressed.emit()
+
+
+func _on_ClearButton_pressed():
+ clear_pressed.emit()
+
+
+func set_material(mat: Material):
+ _material = mat
+ if not _is_empty:
+ _texture_rect.material = _material
+
+
+func set_enabled(enabled: bool):
+ for b in _buttons:
+ b.disabled = not enabled
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn
new file mode 100644
index 0000000..9907e0c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn
@@ -0,0 +1,29 @@
+[gd_scene load_steps=2 format=3 uid="uid://dqgaomu3tr1ym"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd" id="2"]
+
+[node name="SourceFileItem" type="VBoxContainer"]
+offset_right = 128.0
+offset_bottom = 194.0
+script = ExtResource("2")
+
+[node name="Label" type="Label" parent="."]
+layout_mode = 2
+text = "Albedo"
+
+[node name="TextureRect" type="TextureRect" parent="."]
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="LoadButton" type="Button" parent="."]
+layout_mode = 2
+text = "Load..."
+
+[node name="ClearButton" type="Button" parent="."]
+layout_mode = 2
+text = "Clear"
+
+[connection signal="pressed" from="LoadButton" to="." method="_on_LoadButton_pressed"]
+[connection signal="pressed" from="ClearButton" to="." method="_on_ClearButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd
new file mode 100644
index 0000000..9d11bef
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd
@@ -0,0 +1,529 @@
+@tool
+extends AcceptDialog
+
+const HTerrainTextureSet = preload("../../../hterrain_texture_set.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_Util = preload("../../../util/util.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+
+const HT_ColorShader = preload("../display_color.gdshader")
+const HT_ColorSliceShader = preload("../display_color_slice.gdshader")
+const HT_AlphaShader = preload("../display_alpha.gdshader")
+const HT_AlphaSliceShader = preload("../display_alpha_slice.gdshader")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_EmptyTexture = preload("../../icons/empty.png")
+const EMPTY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal import_selected
+
+@onready var _slots_list : ItemList = $VB/HS/VB/SlotsList
+@onready var _albedo_preview : TextureRect = $VB/HS/VB2/GC/AlbedoPreview
+@onready var _bump_preview : TextureRect = $VB/HS/VB2/GC/BumpPreview
+@onready var _normal_preview : TextureRect = $VB/HS/VB2/GC/NormalPreview
+@onready var _roughness_preview : TextureRect = $VB/HS/VB2/GC/RoughnessPreview
+@onready var _load_albedo_button : Button = $VB/HS/VB2/GC/LoadAlbedo
+@onready var _load_normal_button : Button = $VB/HS/VB2/GC/LoadNormal
+@onready var _clear_albedo_button : Button = $VB/HS/VB2/GC/ClearAlbedo
+@onready var _clear_normal_button : Button = $VB/HS/VB2/GC/ClearNormal
+@onready var _mode_selector : OptionButton = $VB/HS/VB2/GC2/ModeSelector
+@onready var _add_slot_button : Button = $VB/HS/VB/HB/AddSlot
+@onready var _remove_slot_button : Button = $VB/HS/VB/HB/RemoveSlot
+
+var _texture_set : HTerrainTextureSet
+var _undo_redo_manager : EditorUndoRedoManager
+
+var _mode_confirmation_dialog : ConfirmationDialog
+var _delete_slot_confirmation_dialog : ConfirmationDialog
+var _load_texture_dialog : ConfirmationDialog
+var _load_texture_array_dialog : ConfirmationDialog
+var _load_texture_type := -1
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _init():
+ get_ok_button().hide()
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+ for id in HTerrainTextureSet.MODE_COUNT:
+ var mode_name = HTerrainTextureSet.get_import_mode_name(id)
+ _mode_selector.add_item(mode_name, id)
+
+
+func setup_dialogs(parent: Node):
+ var d = HT_EditorUtil.create_open_texture_dialog()
+ d.file_selected.connect(_on_LoadTextureDialog_file_selected)
+ _load_texture_dialog = d
+ add_child(d)
+
+ d = HT_EditorUtil.create_open_texture_array_dialog()
+ d.file_selected.connect(_on_LoadTextureArrayDialog_file_selected)
+ _load_texture_array_dialog = d
+ add_child(d)
+
+ d = ConfirmationDialog.new()
+ d.confirmed.connect(_on_ModeConfirmationDialog_confirmed)
+ # This is ridiculous.
+ # See https://github.com/godotengine/godot/issues/17460
+# d.connect("modal_closed", self, "_on_ModeConfirmationDialog_cancelled")
+# d.get_close_button().connect("pressed", self, "_on_ModeConfirmationDialog_cancelled")
+# d.get_cancel().connect("pressed", self, "_on_ModeConfirmationDialog_cancelled")
+ _mode_confirmation_dialog = d
+ add_child(d)
+
+
+func _notification(what: int):
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ if what == NOTIFICATION_EXIT_TREE:
+ # Have to check for null in all of them,
+ # because otherwise it breaks in the scene editor...
+ if _load_texture_dialog != null:
+ _load_texture_dialog.queue_free()
+ if _load_texture_array_dialog != null:
+ _load_texture_array_dialog.queue_free()
+
+ if what == NOTIFICATION_VISIBILITY_CHANGED:
+ if not visible:
+ # Cleanup referenced resources
+ set_texture_set(null)
+
+
+func set_undo_redo(ur: EditorUndoRedoManager):
+ _undo_redo_manager = ur
+
+
+func set_texture_set(texture_set: HTerrainTextureSet):
+ if _texture_set == texture_set:
+ return
+
+ if _texture_set != null:
+ _texture_set.changed.disconnect(_on_texture_set_changed)
+
+ _texture_set = texture_set
+
+ if _texture_set != null:
+ _texture_set.changed.connect(_on_texture_set_changed)
+ _update_ui_from_data()
+
+
+func _on_texture_set_changed():
+ _update_ui_from_data()
+
+
+func _update_ui_from_data():
+ var prev_selected_items = _slots_list.get_selected_items()
+
+ _slots_list.clear()
+
+ var slots_count := _texture_set.get_slots_count()
+ for slot_index in slots_count:
+ _slots_list.add_item("Texture {0}".format([slot_index]))
+
+ _set_selected_id(_mode_selector, _texture_set.get_mode())
+
+ if _slots_list.get_item_count() > 0:
+ if len(prev_selected_items) > 0:
+ var i : int = prev_selected_items[0]
+ if i >= _slots_list.get_item_count():
+ i = _slots_list.get_item_count() - 1
+ _select_slot(i)
+ else:
+ _select_slot(0)
+ else:
+ _clear_previews()
+
+ var max_slots := HTerrainTextureSet.get_max_slots_for_mode(_texture_set.get_mode())
+ _add_slot_button.disabled = slots_count >= max_slots
+ _remove_slot_button.disabled = slots_count == 0
+
+ var buttons := [
+ _load_albedo_button,
+ _load_normal_button,
+ _clear_albedo_button,
+ _clear_normal_button
+ ]
+
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _add_slot_button.visible = true
+ _remove_slot_button.visible = true
+ _load_albedo_button.text = "Load..."
+ _load_normal_button.text = "Load..."
+
+ for b in buttons:
+ b.disabled = (slots_count == 0)
+
+ else:
+ _add_slot_button.visible = false
+ _remove_slot_button.visible = false
+ _load_albedo_button.text = "Load Array..."
+ _load_normal_button.text = "Load Array..."
+
+ for b in buttons:
+ b.disabled = false
+
+
+static func _set_selected_id(ob: OptionButton, id: int):
+ for i in ob.get_item_count():
+ if ob.get_item_id(i) == id:
+ ob.selected = i
+ break
+
+
+func select_slot(slot_index: int):
+ var count = _texture_set.get_slots_count()
+ if count > 0:
+ if slot_index >= count:
+ slot_index = count - 1
+ _select_slot(slot_index)
+
+
+func _clear_previews():
+ var empty_texture : Texture2D = load(EMPTY_TEXTURE_PATH)
+ if empty_texture == null:
+ _logger.error(str("Failed to load empty texture ", EMPTY_TEXTURE_PATH))
+
+ _albedo_preview.texture = empty_texture
+ _bump_preview.texture = empty_texture
+ _normal_preview.texture = empty_texture
+ _roughness_preview.texture = empty_texture
+
+ _albedo_preview.tooltip_text = _get_resource_path_or_empty(null)
+ _bump_preview.tooltip_text = _get_resource_path_or_empty(null)
+ _normal_preview.tooltip_text = _get_resource_path_or_empty(null)
+ _roughness_preview.tooltip_text = _get_resource_path_or_empty(null)
+
+
+func _select_slot(slot_index: int):
+ assert(slot_index >= 0)
+ assert(slot_index < _texture_set.get_slots_count())
+
+ var empty_texture : Texture2D = load(EMPTY_TEXTURE_PATH)
+ if empty_texture == null:
+ _logger.error(str("Failed to load empty texture ", EMPTY_TEXTURE_PATH))
+
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ var albedo_tex := \
+ _texture_set.get_texture(slot_index, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ var normal_tex := \
+ _texture_set.get_texture(slot_index, HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+ _albedo_preview.texture = albedo_tex if albedo_tex != null else empty_texture
+ _bump_preview.texture = albedo_tex if albedo_tex != null else empty_texture
+ _normal_preview.texture = normal_tex if normal_tex != null else empty_texture
+ _roughness_preview.texture = normal_tex if normal_tex != null else empty_texture
+
+ _albedo_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _bump_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _normal_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+ _roughness_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+
+ _albedo_preview.material.shader = HT_ColorShader
+ _bump_preview.material.shader = HT_AlphaShader
+ _normal_preview.material.shader = HT_ColorShader
+ _roughness_preview.material.shader = HT_AlphaShader
+
+ _albedo_preview.material.set_shader_parameter("u_texture_array", null)
+ _bump_preview.material.set_shader_parameter("u_texture_array", null)
+ _normal_preview.material.set_shader_parameter("u_texture_array", null)
+ _roughness_preview.material.set_shader_parameter("u_texture_array", null)
+
+ else:
+ var albedo_tex := _texture_set.get_texture_array(HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ var normal_tex := _texture_set.get_texture_array(HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+ _albedo_preview.texture = empty_texture
+ _bump_preview.texture = empty_texture
+ _normal_preview.texture = empty_texture
+ _roughness_preview.texture = empty_texture
+
+ _albedo_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _bump_preview.tooltip_text = _get_resource_path_or_empty(albedo_tex)
+ _normal_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+ _roughness_preview.tooltip_text = _get_resource_path_or_empty(normal_tex)
+
+ _albedo_preview.material.shader = HT_ColorSliceShader
+ _bump_preview.material.shader = HT_AlphaSliceShader
+ _normal_preview.material.shader = \
+ HT_ColorSliceShader if normal_tex != null else HT_ColorShader
+ _roughness_preview.material.shader = \
+ HT_AlphaSliceShader if normal_tex != null else HT_AlphaShader
+
+ _albedo_preview.material.set_shader_parameter("u_texture_array", albedo_tex)
+ _bump_preview.material.set_shader_parameter("u_texture_array", albedo_tex)
+ _normal_preview.material.set_shader_parameter("u_texture_array", normal_tex)
+ _roughness_preview.material.set_shader_parameter("u_texture_array", normal_tex)
+
+ _albedo_preview.material.set_shader_parameter("u_index", slot_index)
+ _bump_preview.material.set_shader_parameter("u_index", slot_index)
+ _normal_preview.material.set_shader_parameter("u_index", slot_index)
+ _roughness_preview.material.set_shader_parameter("u_index", slot_index)
+
+ _slots_list.select(slot_index)
+
+
+static func _get_resource_path_or_empty(res: Resource) -> String:
+ if res != null:
+ return res.resource_path
+ return "<empty>"
+
+
+func _on_ImportButton_pressed():
+ import_selected.emit()
+
+
+func _on_CloseButton_pressed():
+ hide()
+
+
+func _get_undo_redo_for_texture_set() -> UndoRedo:
+ return _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_texture_set))
+
+
+func _on_AddSlot_pressed():
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES)
+ var slot_index = _texture_set.get_slots_count()
+ var ur := _get_undo_redo_for_texture_set()
+ ur.create_action("HTerrainTextureSet: add slot")
+ ur.add_do_method(_texture_set.insert_slot.bind(-1))
+ ur.add_undo_method(_texture_set.remove_slot.bind(slot_index))
+ ur.commit_action()
+
+
+func _on_RemoveSlot_pressed():
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES)
+
+ var slot_index = _slots_list.get_selected_items()[0]
+ var textures = []
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ textures.append(_texture_set.get_texture(slot_index, type))
+
+ var ur := _get_undo_redo_for_texture_set()
+
+ ur.create_action("HTerrainTextureSet: remove slot")
+
+ ur.add_do_method(_texture_set.remove_slot.bind(slot_index))
+
+ ur.add_undo_method(_texture_set.insert_slot.bind(slot_index))
+ for type in len(textures):
+ var texture = textures[type]
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture == null:
+ ur.add_undo_method(_texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_undo_method(_texture_set.set_texture.bind(slot_index, type, texture))
+
+ ur.commit_action()
+
+
+func _on_SlotsList_item_selected(index: int):
+ _select_slot(index)
+
+
+func _open_load_texture_dialog(type: int):
+ _load_texture_type = type
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _load_texture_dialog.popup_centered_ratio()
+ else:
+ _load_texture_array_dialog.popup_centered_ratio()
+
+
+func _on_LoadAlbedo_pressed():
+ _open_load_texture_dialog(HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+
+
+func _on_LoadNormal_pressed():
+ _open_load_texture_dialog(HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+
+func _set_texture_action(slot_index: int, texture: Texture, type: int):
+ var prev_texture = _texture_set.get_texture(slot_index, type)
+
+ var ur := _get_undo_redo_for_texture_set()
+
+ ur.create_action("HTerrainTextureSet: load texture")
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture == null:
+ ur.add_do_method(_texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_do_method(_texture_set.set_texture.bind(slot_index, type, texture))
+ ur.add_do_method(self._select_slot.bind(slot_index))
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if prev_texture == null:
+ ur.add_undo_method(_texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_undo_method(_texture_set.set_texture.bind(slot_index, type, prev_texture))
+ ur.add_undo_method(self._select_slot.bind(slot_index))
+
+ ur.commit_action()
+
+
+func _set_texture_array_action(slot_index: int, texture_array: TextureLayered, type: int):
+ var prev_texture_array = _texture_set.get_texture_array(type)
+
+ var ur := _get_undo_redo_for_texture_set()
+
+ ur.create_action("HTerrainTextureSet: load texture array")
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture_array == null:
+ ur.add_do_method(_texture_set.set_texture_array_null.bind(type))
+ # Can't select a slot after this because there won't be any after the array is removed
+ else:
+ ur.add_do_method(_texture_set.set_texture_array.bind(type, texture_array))
+ ur.add_do_method(self._select_slot.bind(slot_index))
+
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if prev_texture_array == null:
+ ur.add_undo_method(_texture_set.set_texture_array_null.bind(type))
+ # Can't select a slot after this because there won't be any after the array is removed
+ else:
+ ur.add_undo_method(_texture_set.set_texture_array.bind(type, prev_texture_array))
+ ur.add_undo_method(self._select_slot.bind(slot_index))
+
+ ur.commit_action()
+
+
+func _on_LoadTextureDialog_file_selected(fpath: String):
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES)
+ var texture = load(fpath)
+ assert(texture != null)
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ _set_texture_action(slot_index, texture, _load_texture_type)
+
+
+func _on_LoadTextureArrayDialog_file_selected(fpath: String):
+ assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURE_ARRAYS)
+ var texture_array = load(fpath)
+ assert(texture_array != null)
+ # It's possible no slot exists at the moment,
+ # because there could be no texture array already set.
+ # The number of slots in the new array might also be different.
+ # So in this case we'll default to selecting the first slot.
+ var slot_index := 0
+ _set_texture_array_action(slot_index, texture_array, _load_texture_type)
+
+
+func _on_ClearAlbedo_pressed():
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _set_texture_action(slot_index, null, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ else:
+ _set_texture_array_action(slot_index, null, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+
+
+func _on_ClearNormal_pressed():
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _set_texture_action(slot_index, null, HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+ else:
+ _set_texture_array_action(slot_index, null, HTerrainTextureSet.TYPE_NORMAL_ROUGHNESS)
+
+
+func _on_ModeSelector_item_selected(index: int):
+ var id = _mode_selector.get_selected_id()
+ if id == _texture_set.get_mode():
+ return
+
+ # Early-cancel the change in OptionButton, so we won't need to rely on
+ # the (inexistent) cancel signal from ConfirmationDialog
+ _set_selected_id(_mode_selector, _texture_set.get_mode())
+
+ if not _texture_set.has_any_textures():
+ _switch_mode_action()
+
+ else:
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ _mode_confirmation_dialog.window_title = "Switch to TextureArrays"
+ _mode_confirmation_dialog.dialog_text = \
+ "This will unload all textures currently setup. Do you want to continue?"
+ _mode_confirmation_dialog.popup_centered()
+
+ else:
+ _mode_confirmation_dialog.window_title = "Switch to Textures"
+ _mode_confirmation_dialog.dialog_text = \
+ "This will unload all textures currently setup. Do you want to continue?"
+ _mode_confirmation_dialog.popup_centered()
+
+
+func _on_ModeConfirmationDialog_confirmed():
+ _switch_mode_action()
+
+
+func _switch_mode_action():
+ var mode := _texture_set.get_mode()
+ var ur := _get_undo_redo_for_texture_set()
+
+ if mode == HTerrainTextureSet.MODE_TEXTURES:
+ ur.create_action("HTerrainTextureSet: switch to TextureArrays")
+ ur.add_do_method(_texture_set.set_mode.bind(HTerrainTextureSet.MODE_TEXTURE_ARRAYS))
+ backup_for_undo(_texture_set, ur)
+
+ else:
+ ur.create_action("HTerrainTextureSet: switch to Textures")
+ ur.add_do_method(_texture_set.set_mode.bind(HTerrainTextureSet.MODE_TEXTURES))
+ backup_for_undo(_texture_set, ur)
+
+ ur.commit_action()
+
+
+static func backup_for_undo(texture_set: HTerrainTextureSet, ur: UndoRedo):
+ var mode := texture_set.get_mode()
+
+ ur.add_undo_method(texture_set.clear)
+ ur.add_undo_method(texture_set.set_mode.bind(mode))
+
+ if mode == HTerrainTextureSet.MODE_TEXTURES:
+ # Backup slots
+ var slot_count := texture_set.get_slots_count()
+ var type_textures := []
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var textures := []
+ for slot_index in slot_count:
+ textures.append(texture_set.get_texture(slot_index, type))
+ type_textures.append(textures)
+
+ for type in len(type_textures):
+ var textures = type_textures[type]
+ for slot_index in len(textures):
+ ur.add_undo_method(texture_set.insert_slot.bind(slot_index))
+ var texture = textures[slot_index]
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture == null:
+ ur.add_undo_method(texture_set.set_texture_null.bind(slot_index, type))
+ else:
+ ur.add_undo_method(texture_set.set_texture.bind(slot_index, type, texture))
+
+ else:
+ # Backup slots
+ var type_textures := []
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ type_textures.append(texture_set.get_texture_array(type))
+
+ for type in len(type_textures):
+ var texture_array = type_textures[type]
+ # TODO This branch only exists because of a flaw in UndoRedo
+ # See https://github.com/godotengine/godot/issues/36895
+ if texture_array == null:
+ ur.add_undo_method(texture_set.set_texture_array_null.bind(type))
+ else:
+ ur.add_undo_method(texture_set.set_texture_array.bind(type, texture_array))
+
+
+#func _on_ModeConfirmationDialog_cancelled():
+# print("Cancelled")
+# _set_selected_id(_mode_selector, _texture_set.get_mode())
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn
new file mode 100644
index 0000000..f69c89a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn
@@ -0,0 +1,194 @@
+[gd_scene load_steps=9 format=3 uid="uid://c0e7ifnoygvr6"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd" id="1"]
+[ext_resource type="Shader" path="res://addons/zylann.hterrain/tools/texture_editor/display_alpha.gdshader" id="2"]
+[ext_resource type="Shader" path="res://addons/zylann.hterrain/tools/texture_editor/display_color.gdshader" id="3"]
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="5"]
+
+[sub_resource type="ShaderMaterial" id="1"]
+shader = ExtResource("3")
+
+[sub_resource type="ShaderMaterial" id="2"]
+shader = ExtResource("2")
+
+[sub_resource type="ShaderMaterial" id="3"]
+shader = ExtResource("3")
+
+[sub_resource type="ShaderMaterial" id="4"]
+shader = ExtResource("2")
+
+[node name="TextureSetEditor" type="AcceptDialog"]
+title = "TextureSet Editor"
+size = Vector2i(666, 341)
+min_size = Vector2i(652, 320)
+script = ExtResource("1")
+
+[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 = -18.0
+
+[node name="HS" type="HSplitContainer" parent="VB"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="VB" type="VBoxContainer" parent="VB/HS"]
+layout_mode = 2
+
+[node name="SlotsList" type="ItemList" parent="VB/HS/VB"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="HB" type="HBoxContainer" parent="VB/HS/VB"]
+layout_mode = 2
+
+[node name="AddSlot" type="Button" parent="VB/HS/VB/HB"]
+layout_mode = 2
+text = "+"
+
+[node name="Control" type="Control" parent="VB/HS/VB/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="RemoveSlot" type="Button" parent="VB/HS/VB/HB"]
+layout_mode = 2
+text = "-"
+
+[node name="VB2" type="VBoxContainer" parent="VB/HS"]
+layout_mode = 2
+
+[node name="GC" type="GridContainer" parent="VB/HS/VB2"]
+layout_mode = 2
+columns = 4
+
+[node name="AlbedoLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Albedo"
+
+[node name="AlbedoExtraLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "+ alpha bump"
+
+[node name="NormalLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Normal"
+
+[node name="NormalExtraLabel" type="Label" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "+ alpha roughness"
+
+[node name="AlbedoPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("1")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="BumpPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("2")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="NormalPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("3")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="RoughnessPreview" type="TextureRect" parent="VB/HS/VB2/GC"]
+material = SubResource("4")
+custom_minimum_size = Vector2(128, 128)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 1
+
+[node name="LoadAlbedo" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Load..."
+
+[node name="Spacer" type="Control" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+
+[node name="LoadNormal" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Load..."
+
+[node name="Spacer2" type="Control" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+
+[node name="ClearAlbedo" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Clear"
+
+[node name="Spacer3" type="Control" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+
+[node name="ClearNormal" type="Button" parent="VB/HS/VB2/GC"]
+layout_mode = 2
+text = "Clear"
+
+[node name="HSeparator" type="Control" parent="VB/HS/VB2"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+
+[node name="GC2" type="HBoxContainer" parent="VB/HS/VB2"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="VB/HS/VB2/GC2"]
+layout_mode = 2
+text = "Mode"
+
+[node name="ModeSelector" type="OptionButton" parent="VB/HS/VB2/GC2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Spacer" type="Control" parent="VB/HS/VB2/GC2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Spacer" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="VB"]
+layout_mode = 2
+alignment = 1
+
+[node name="ImportButton" type="Button" parent="VB/HB"]
+layout_mode = 2
+text = "Import..."
+
+[node name="CloseButton" type="Button" parent="VB/HB"]
+layout_mode = 2
+text = "Close"
+
+[node name="Spacer2" type="Control" parent="VB"]
+custom_minimum_size = Vector2(0, 2)
+layout_mode = 2
+
+[node name="DialogFitter" parent="." instance=ExtResource("5")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 658.0
+offset_bottom = 323.0
+
+[connection signal="item_selected" from="VB/HS/VB/SlotsList" to="." method="_on_SlotsList_item_selected"]
+[connection signal="pressed" from="VB/HS/VB/HB/AddSlot" to="." method="_on_AddSlot_pressed"]
+[connection signal="pressed" from="VB/HS/VB/HB/RemoveSlot" to="." method="_on_RemoveSlot_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/LoadAlbedo" to="." method="_on_LoadAlbedo_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/LoadNormal" to="." method="_on_LoadNormal_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/ClearAlbedo" to="." method="_on_ClearAlbedo_pressed"]
+[connection signal="pressed" from="VB/HS/VB2/GC/ClearNormal" to="." method="_on_ClearNormal_pressed"]
+[connection signal="item_selected" from="VB/HS/VB2/GC2/ModeSelector" to="." method="_on_ModeSelector_item_selected"]
+[connection signal="pressed" from="VB/HB/ImportButton" to="." method="_on_ImportButton_pressed"]
+[connection signal="pressed" from="VB/HB/CloseButton" to="." method="_on_CloseButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd
new file mode 100644
index 0000000..f865f63
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd
@@ -0,0 +1,920 @@
+@tool
+extends AcceptDialog
+
+const HTerrainTextureSet = preload("../../../hterrain_texture_set.gd")
+const HT_Logger = preload("../../../util/logger.gd")
+const HT_EditorUtil = preload("../../util/editor_util.gd")
+const HT_Errors = preload("../../../util/errors.gd")
+const HT_TextureSetEditor = preload("./texture_set_editor.gd")
+const HT_Result = preload("../../util/result.gd")
+const HT_Util = preload("../../../util/util.gd")
+const HT_PackedTextureUtil = preload("../../packed_textures/packed_texture_util.gd")
+const ResourceImporterTexture_Unexposed = preload("../../util/resource_importer_texture.gd")
+const ResourceImporterTextureLayered_Unexposed = preload(
+ "../../util/resource_importer_texture_layered.gd")
+
+const HT_NormalMapPreviewShader = preload("../display_normal.gdshader")
+
+const COMPRESS_RAW = 0
+const COMPRESS_LOSSLESS = 1
+const COMPRESS_LOSSY = 1
+const COMPRESS_VRAM = 2
+const COMPRESS_COUNT = 3
+
+const _compress_names = ["Raw", "Lossless", "Lossy", "VRAM"]
+
+# Indexed by HTerrainTextureSet.SRC_TYPE_* constants
+const _smart_pick_file_keywords = [
+ ["albedo", "color", "col", "diffuse"],
+ ["bump", "height", "depth", "displacement", "disp"],
+ ["normal", "norm", "nrm"],
+ ["roughness", "rough", "rgh"]
+]
+
+signal import_finished
+
+@onready var _texture_editors = [
+ $Import/HS/VB2/HB/Albedo,
+ $Import/HS/VB2/HB/Bump,
+ $Import/HS/VB2/HB/Normal,
+ $Import/HS/VB2/HB/Roughness
+]
+
+@onready var _slots_list : ItemList = $Import/HS/VB/SlotsList
+
+# TODO Some shortcuts to import options were disabled in the GUI because of Godot issues.
+# If users want to customize that, they need to do it on the files directly.
+#
+# There is no script API in Godot to choose the import settings of a generated file.
+# They always start with the defaults, and the only implemented case is for the import dock.
+# It appeared possible to reverse-engineer and write a .import file as done in HTerrainData,
+# however when I tried this with custom importers, Godot stopped importing after scan(),
+# and the resources could not load. However, selecting them each and clicking "Reimport"
+# did import them fine. Unfortunately, this short-circuits the workflow.
+# Since I have no idea what's going on with this reverse-engineering, I had to drop those options.
+# Godot needs an API to import specific files and choose settings before the first import.
+#
+# Godot 4: now we'll really need it, let's enable and we'll see if it works
+# when we can test the workflow...
+const _WRITE_IMPORT_FILES = true
+
+@onready var _import_mode_selector : OptionButton = $Import/GC/ImportModeSelector
+@onready var _compression_selector : OptionButton = $Import/GC/CompressionSelector
+@onready var _resolution_spinbox : SpinBox = $Import/GC/ResolutionSpinBox
+@onready var _mipmaps_checkbox : CheckBox = $Import/GC/MipmapsCheckbox
+@onready var _add_slot_button : Button = $Import/HS/VB/HB/AddSlotButton
+@onready var _remove_slot_button : Button = $Import/HS/VB/HB/RemoveSlotButton
+@onready var _import_directory_line_edit : LineEdit = $Import/HB2/ImportDirectoryLineEdit
+@onready var _normalmap_flip_checkbox : CheckBox = $Import/HS/VB2/HB/Normal/NormalMapFlipY
+
+var _texture_set : HTerrainTextureSet
+var _undo_redo_manager : EditorUndoRedoManager
+var _logger = HT_Logger.get_for(self)
+
+# This is normally an `EditorFileDialog`. I can't type-hint this one properly,
+# because when I test this UI in isolation, I can't use `EditorFileDialog`.
+var _load_texture_dialog : ConfirmationDialog
+var _load_texture_type : int = -1
+var _error_popup : AcceptDialog
+var _info_popup : AcceptDialog
+var _delete_confirmation_popup : ConfirmationDialog
+var _open_dir_dialog : ConfirmationDialog
+var _editor_file_system : EditorFileSystem
+var _normalmap_material : ShaderMaterial
+
+var _import_mode := HTerrainTextureSet.MODE_TEXTURES
+
+class HT_TextureSetImportEditorSlot:
+ # Array of strings.
+ # Can be either path to images, hexadecimal colors starting with #, or empty string for "null".
+ var texture_paths := []
+ var flip_normalmap_y := false
+
+ func _init():
+ for i in HTerrainTextureSet.SRC_TYPE_COUNT:
+ texture_paths.append("")
+
+# Array of HT_TextureSetImportEditorSlot
+var _slots_data := []
+
+var _import_settings := {
+ "mipmaps": true,
+ "compression": COMPRESS_VRAM,
+ "resolution": 512
+}
+
+
+func _init():
+ get_ok_button().hide()
+
+ # Default data
+ _slots_data.clear()
+ for i in 4:
+ _slots_data.append(HT_TextureSetImportEditorSlot.new())
+
+
+func _ready():
+ if HT_Util.is_in_edited_scene(self):
+ return
+
+ for src_type in len(_texture_editors):
+ var ed = _texture_editors[src_type]
+ var typename = HTerrainTextureSet.get_source_texture_type_name(src_type)
+ ed.set_label(typename.capitalize())
+ ed.load_pressed.connect(_on_texture_load_pressed.bind(src_type))
+ ed.clear_pressed.connect(_on_texture_clear_pressed.bind(src_type))
+
+ for import_mode in HTerrainTextureSet.MODE_COUNT:
+ var n = HTerrainTextureSet.get_import_mode_name(import_mode)
+ _import_mode_selector.add_item(n, import_mode)
+
+ for compress_mode in COMPRESS_COUNT:
+ var n = _compress_names[compress_mode]
+ _compression_selector.add_item(n, compress_mode)
+
+ _normalmap_material = ShaderMaterial.new()
+ _normalmap_material.shader = HT_NormalMapPreviewShader
+ _texture_editors[HTerrainTextureSet.SRC_TYPE_NORMAL].set_material(_normalmap_material)
+
+
+func setup_dialogs(parent: Node):
+ var d = HT_EditorUtil.create_open_image_dialog()
+ d.file_selected.connect(_on_LoadTextureDialog_file_selected)
+ _load_texture_dialog = d
+ add_child(d)
+
+ d = AcceptDialog.new()
+ d.title = "Import error"
+ _error_popup = d
+ add_child(_error_popup)
+
+ d = AcceptDialog.new()
+ d.title = "Info"
+ _info_popup = d
+ add_child(_info_popup)
+
+ d = ConfirmationDialog.new()
+ d.confirmed.connect(_on_delete_confirmation_popup_confirmed)
+ _delete_confirmation_popup = d
+ add_child(_delete_confirmation_popup)
+
+ d = HT_EditorUtil.create_open_dir_dialog()
+ d.title = "Choose import directory"
+ d.dir_selected.connect(_on_OpenDirDialog_dir_selected)
+ _open_dir_dialog = d
+ add_child(_open_dir_dialog)
+
+ _update_ui_from_data()
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_EXIT_TREE:
+ # Have to check for null in all of them,
+ # because otherwise it breaks in the scene editor...
+ if _load_texture_dialog != null:
+ _load_texture_dialog.queue_free()
+ if _error_popup != null:
+ _error_popup.queue_free()
+ if _delete_confirmation_popup != null:
+ _delete_confirmation_popup.queue_free()
+ if _open_dir_dialog != null:
+ _open_dir_dialog.queue_free()
+ if _info_popup != null:
+ _info_popup.queue_free()
+
+
+# TODO Is it still necessary for an import tab?
+func set_undo_redo(ur: EditorUndoRedoManager):
+ _undo_redo_manager = ur
+
+
+func set_editor_file_system(efs: EditorFileSystem):
+ _editor_file_system = efs
+
+
+func set_texture_set(texture_set: HTerrainTextureSet):
+ if _texture_set == texture_set:
+ # TODO What if the set was actually modified since?
+ return
+ _texture_set = texture_set
+
+ _slots_data.clear()
+
+ if _texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES:
+ var slots_count = _texture_set.get_slots_count()
+
+ for slot_index in slots_count:
+ var slot := HT_TextureSetImportEditorSlot.new()
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var texture = _texture_set.get_texture(slot_index, type)
+
+ if texture == null or texture.resource_path == "":
+ continue
+
+ if not texture.resource_path.ends_with(".packed_tex"):
+ continue
+
+ var import_data := _parse_json_file(texture.resource_path)
+ if import_data.is_empty() or not import_data.has("src"):
+ continue
+
+ var src_types = HTerrainTextureSet.get_src_types_from_type(type)
+
+ var src_data = import_data["src"]
+ if src_data.has("rgb"):
+ slot.texture_paths[src_types[0]] = src_data["rgb"]
+ if src_data.has("a"):
+ slot.texture_paths[src_types[1]] = src_data["a"]
+
+ _slots_data.append(slot)
+
+ else:
+ var slots_count := _texture_set.get_slots_count()
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var texture_array := _texture_set.get_texture_array(type)
+
+ if texture_array == null or texture_array.resource_path == "":
+ continue
+
+ if not texture_array.resource_path.ends_with(".packed_texarr"):
+ continue
+
+ var import_data := _parse_json_file(texture_array.resource_path)
+ if import_data.is_empty() or not import_data.has("layers"):
+ continue
+
+ var layers_data = import_data["layers"]
+
+ for slot_index in len(layers_data):
+ var src_data = layers_data[slot_index]
+
+ var src_types = HTerrainTextureSet.get_src_types_from_type(type)
+
+ while slot_index >= len(_slots_data):
+ var slot = HT_TextureSetImportEditorSlot.new()
+ _slots_data.append(slot)
+
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+
+ if src_data.has("rgb"):
+ slot.texture_paths[src_types[0]] = src_data["rgb"]
+ if src_data.has("a"):
+ slot.texture_paths[src_types[1]] = src_data["a"]
+
+ # TODO If the set doesn't have a file, use terrain path by default?
+ if texture_set.resource_path != "":
+ var dir = texture_set.resource_path.get_base_dir()
+ _import_directory_line_edit.text = dir
+
+ _update_ui_from_data()
+
+
+func _parse_json_file(fpath: String) -> Dictionary:
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ _logger.error("Could not load {0}: {1}".format([fpath, HT_Errors.get_message(err)]))
+ return {}
+
+ var json_text := f.get_as_text()
+ var json := JSON.new()
+ var json_err := json.parse(json_text)
+ if json_err != OK:
+ _logger.error("Failed to parse {0}: {1}".format([fpath, json.get_error_message()]))
+ return {}
+
+ return json.data
+
+
+func _update_ui_from_data():
+ var prev_selected_items := _slots_list.get_selected_items()
+
+ _slots_list.clear()
+
+ for slot_index in len(_slots_data):
+ _slots_list.add_item("Texture {0}".format([slot_index]))
+
+ _resolution_spinbox.value = _import_settings.resolution
+ _mipmaps_checkbox.button_pressed = _import_settings.mipmaps
+ _set_selected_id(_compression_selector, _import_settings.compression)
+ _set_selected_id(_import_mode_selector, _import_mode)
+
+ var has_slots : bool = _slots_list.get_item_count() > 0
+
+ for ed in _texture_editors:
+ ed.set_enabled(has_slots)
+ _normalmap_flip_checkbox.disabled = not has_slots
+
+ if has_slots:
+ if len(prev_selected_items) > 0:
+ var i : int = prev_selected_items[0]
+ if i >= _slots_list.get_item_count():
+ i = _slots_list.get_item_count() - 1
+ _select_slot(i)
+ else:
+ _select_slot(0)
+ else:
+ for type in HTerrainTextureSet.SRC_TYPE_COUNT:
+ _set_ui_slot_texture_from_path("", type)
+
+ var max_slots := HTerrainTextureSet.get_max_slots_for_mode(_import_mode)
+ _add_slot_button.disabled = (len(_slots_data) >= max_slots)
+ _remove_slot_button.disabled = (len(_slots_data) == 0)
+
+
+static func _set_selected_id(ob: OptionButton, id: int):
+ for i in ob.get_item_count():
+ if ob.get_item_id(i) == id:
+ ob.selected = i
+ break
+
+
+func _select_slot(slot_index: int):
+ assert(slot_index >= 0)
+ assert(slot_index < len(_slots_data))
+ var slot = _slots_data[slot_index]
+
+ for type in HTerrainTextureSet.SRC_TYPE_COUNT:
+ var im_path : String = slot.texture_paths[type]
+ _set_ui_slot_texture_from_path(im_path, type)
+
+ _slots_list.select(slot_index)
+
+ _normalmap_flip_checkbox.button_pressed = slot.flip_normalmap_y
+ _normalmap_material.set_shader_parameter("u_flip_y", slot.flip_normalmap_y)
+
+
+func _set_ui_slot_texture_from_path(im_path: String, type: int):
+ var ed = _texture_editors[type]
+
+ if im_path == "":
+ ed.set_texture(null)
+ ed.set_texture_tooltip("<empty>")
+ return
+
+ var im : Image
+
+ if im_path.begins_with("#") and im_path.find(".") == -1:
+ # The path is actually a preset for a uniform color.
+ # This is a feature of packed texture descriptor files.
+ # Make a small placeholder image.
+ var color := Color(im_path)
+ im = Image.create(4, 4, false, Image.FORMAT_RGBA8)
+ im.fill(color)
+
+ else:
+ # Regular path
+ im = Image.new()
+ var err := im.load(im_path)
+ if err != OK:
+ _logger.error(str("Unable to load image from ", im_path))
+ # TODO Different icon for images that can't load?
+ ed.set_texture(null)
+ ed.set_texture_tooltip("<empty>")
+ return
+
+ var tex := ImageTexture.create_from_image(im)
+ ed.set_texture(tex)
+ ed.set_texture_tooltip(im_path)
+
+
+func _set_source_image(fpath: String, type: int):
+ _set_ui_slot_texture_from_path(fpath, type)
+
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ #var prev_path = _texture_set.get_source_image_path(slot_index, type)
+
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+ slot.texture_paths[type] = fpath
+
+
+func _set_import_property(key: String, value):
+ var prev_value = _import_settings[key]
+ # This is needed, notably because CheckBox emits a signal too when we set it from code...
+ if prev_value == value:
+ return
+
+ _import_settings[key] = value
+
+
+func _on_texture_load_pressed(type: int):
+ _load_texture_type = type
+ _load_texture_dialog.popup_centered_ratio()
+
+
+func _on_LoadTextureDialog_file_selected(fpath: String):
+ _set_source_image(fpath, _load_texture_type)
+
+ if _load_texture_type == HTerrainTextureSet.SRC_TYPE_ALBEDO:
+ _smart_pick_files(fpath)
+
+
+# Attempts to load source images of other types by looking at how the albedo file was named
+func _smart_pick_files(albedo_fpath: String):
+ var albedo_words = _smart_pick_file_keywords[HTerrainTextureSet.SRC_TYPE_ALBEDO]
+
+ var albedo_fname := albedo_fpath.get_file()
+ var albedo_fname_lower = albedo_fname.to_lower()
+ var fname_pattern = ""
+
+ for albedo_word in albedo_words:
+ var i = albedo_fname_lower.find(albedo_word, 0)
+ if i != -1:
+ fname_pattern = \
+ albedo_fname.substr(0, i) + "{0}" + albedo_fname.substr(i + len(albedo_word))
+ break
+
+ if fname_pattern == "":
+ return
+
+ var dirpath := albedo_fpath.get_base_dir()
+ var fnames := _get_files_in_directory(dirpath, _logger)
+
+ var types := [
+ HTerrainTextureSet.SRC_TYPE_BUMP,
+ HTerrainTextureSet.SRC_TYPE_NORMAL,
+ HTerrainTextureSet.SRC_TYPE_ROUGHNESS
+ ]
+
+ var slot_index : int = _slots_list.get_selected_items()[0]
+
+ for type in types:
+ var slot = _slots_data[slot_index]
+ if slot.texture_paths[type] != "":
+ # Already set, don't overwrite unwantedly
+ continue
+
+ var keywords = _smart_pick_file_keywords[type]
+
+ for key in keywords:
+ var expected_fname = fname_pattern.format([key])
+
+ var found := false
+
+ for i in len(fnames):
+ var fname : String = fnames[i]
+
+ # TODO We should probably ignore extensions?
+ if fname.to_lower() == expected_fname.to_lower():
+ var fpath = dirpath.path_join(fname)
+ _set_source_image(fpath, type)
+ found = true
+ break
+
+ if found:
+ break
+
+
+static func _get_files_in_directory(dirpath: String, logger) -> Array:
+ var dir := DirAccess.open(dirpath)
+ var err := DirAccess.get_open_error()
+ if err != OK:
+ logger.error("Could not open directory {0}: {1}" \
+ .format([dirpath, HT_Errors.get_message(err)]))
+ return []
+
+ dir.include_hidden = false
+ dir.include_navigational = false
+
+ err = dir.list_dir_begin()
+ if err != OK:
+ logger.error("Could not probe directory {0}: {1}" \
+ .format([dirpath, HT_Errors.get_message(err)]))
+ return []
+
+ var files := []
+ var fname := dir.get_next()
+ while fname != "":
+ if not dir.current_is_dir():
+ files.append(fname)
+ fname = dir.get_next()
+
+ return files
+
+
+func _on_texture_clear_pressed(type: int):
+ _set_source_image("", type)
+
+
+func _on_SlotsList_item_selected(index: int):
+ _select_slot(index)
+
+
+func _on_ImportModeSelector_item_selected(index: int):
+ var mode : int = _import_mode_selector.get_item_id(index)
+ if mode != _import_mode:
+ #_set_import_property("mode", mode)
+ _import_mode = mode
+ _update_ui_from_data()
+
+
+func _on_CompressionSelector_item_selected(index: int):
+ var compression : int = _compression_selector.get_item_id(index)
+ _set_import_property("compression", compression)
+
+
+func _on_MipmapsCheckbox_toggled(button_pressed: bool):
+ _set_import_property("mipmaps", button_pressed)
+
+
+func _on_ResolutionSpinBox_value_changed(value):
+ _set_import_property("resolution", int(value))
+
+
+func _on_TextureArrayPrefixLineEdit_text_changed(new_text: String):
+ _set_import_property("output_prefix", new_text)
+
+
+func _on_AddSlotButton_pressed():
+ var i := len(_slots_data)
+ _slots_data.append(HT_TextureSetImportEditorSlot.new())
+ _update_ui_from_data()
+ _select_slot(i)
+
+
+func _on_RemoveSlotButton_pressed():
+ if _slots_list.get_item_count() == 0:
+ return
+ var selected_item = _slots_list.get_selected_items()[0]
+ _delete_confirmation_popup.title = "Delete slot {0}".format([selected_item])
+ _delete_confirmation_popup.dialog_text = "Delete import slot {0}?".format([selected_item])
+ _delete_confirmation_popup.popup_centered()
+
+
+func _on_delete_confirmation_popup_confirmed():
+ var selected_item : int = _slots_list.get_selected_items()[0]
+ _slots_data.remove_at(selected_item)
+ _update_ui_from_data()
+
+
+func _on_CancelButton_pressed():
+ hide()
+
+
+func _on_BrowseImportDirectory_pressed():
+ _open_dir_dialog.popup_centered_ratio()
+
+
+func _on_ImportDirectoryLineEdit_text_changed(new_text: String):
+ pass
+
+
+func _on_OpenDirDialog_dir_selected(dir_path: String):
+ _import_directory_line_edit.text = dir_path
+
+
+func _show_error(message: String):
+ _error_popup.dialog_text = message
+ _error_popup.popup_centered()
+
+
+func _on_NormalMapFlipY_toggled(button_pressed: bool):
+ var slot_index : int = _slots_list.get_selected_items()[0]
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+ slot.flip_normalmap_y = button_pressed
+ _normalmap_material.set_shader_parameter("u_flip_y", slot.flip_normalmap_y)
+
+
+# class ButtonDisabler:
+# var _button : Button
+
+# func _init(b: Button):
+# _button = b
+# _button.disabled = true
+
+# func _notification(what: int):
+# if what == NOTIFICATION_PREDELETE:
+# _button.disabled = false
+
+
+func _get_undo_redo_for_texture_set() -> UndoRedo:
+ return _undo_redo_manager.get_history_undo_redo(
+ _undo_redo_manager.get_object_history_id(_texture_set))
+
+
+func _on_ImportButton_pressed():
+ if _texture_set == null:
+ _show_error("No HTerrainTextureSet selected.")
+ return
+
+ var import_dir := _import_directory_line_edit.text.strip_edges()
+
+ var prefix := ""
+ if _texture_set.resource_path != "":
+ prefix = _texture_set.resource_path.get_file().get_basename() + "_"
+
+ var files_data_result : HT_Result
+ if _import_mode == HTerrainTextureSet.MODE_TEXTURES:
+ files_data_result = _generate_packed_images(import_dir, prefix)
+ else:
+ files_data_result = _generate_packed_texarray_images(import_dir, prefix)
+
+ if not files_data_result.success:
+ _show_error(files_data_result.get_message())
+ return
+
+ var files_data : Array = files_data_result.value
+
+ if len(files_data) == 0:
+ _show_error("There are no files to save.\nYou must setup at least one slot of textures.")
+ return
+
+ for fd in files_data:
+ var dir_path : String = fd.path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir_path):
+ _show_error("The directory {0} could not be found.".format([dir_path]))
+ return
+
+ if _WRITE_IMPORT_FILES:
+ for fd in files_data:
+ var import_fpath = fd.path + ".import"
+ if not HT_Util.write_import_file(fd.import_file_data, import_fpath, _logger):
+ _show_error("Failed to write file {0}: {1}".format([import_fpath]))
+ return
+
+ if _editor_file_system == null:
+ _show_error("EditorFileSystem is not setup, can't trigger import system.")
+ return
+
+ # ______
+ # .-" "-.
+ # / \
+ # _ | | _
+ # ( \ |, .-. .-. ,| / )
+ # > "=._ | )(__/ \__)( | _.=" <
+ # (_/"=._"=._ |/ /\ \| _.="_.="\_)
+ # "=._ (_ ^^ _)"_.="
+ # "=\__|IIIIII|__/="
+ # _.="| \IIIIII/ |"=._
+ # _ _.="_.="\ /"=._"=._ _
+ # ( \_.="_.=" `--------` "=._"=._/ )
+ # > _.=" "=._ <
+ # (_/ \_)
+ #
+ # TODO What I need here is a way to trigger the import of specific files!
+ # It exists, but is not exposed, so I have to rely on a VERY fragile and hacky use of scan()...
+ # I'm not even sure it works tbh. It's terrible.
+ # See https://github.com/godotengine/godot-proposals/issues/1615
+ _editor_file_system.scan()
+ while _editor_file_system.is_scanning():
+ _logger.debug("Waiting for scan to complete...")
+ await get_tree().process_frame
+ if not is_inside_tree():
+ # oops?
+ return
+ _logger.debug("Scanning complete")
+ # Looks like import takes place AFTER scanning, so let's yield some more...
+ for fd in len(files_data) * 2:
+ _logger.debug("Yielding some more")
+ await get_tree().process_frame
+
+ var failed_resource_paths := []
+
+ # Using UndoRedo is mandatory for Godot to consider the resource as modified...
+ # ...yet if files get deleted, that won't be undoable anyways, but whatever :shrug:
+ var ur := _get_undo_redo_for_texture_set()
+
+ # Check imported textures
+ if _import_mode == HTerrainTextureSet.MODE_TEXTURES:
+ for fd in files_data:
+ var texture : Texture2D = load(fd.path)
+ if texture == null:
+ failed_resource_paths.append(fd.path)
+ continue
+ fd.texture = texture
+
+ else:
+ for fd in files_data:
+ var texture_array : TextureLayered = load(fd.path)
+ if texture_array == null:
+ failed_resource_paths.append(fd.path)
+ continue
+ fd.texture_array = texture_array
+
+ if len(failed_resource_paths) > 0:
+ var failed_list := "\n".join(PackedStringArray(failed_resource_paths))
+ _show_error("Some resources failed to load:\n" + failed_list)
+ return
+
+ # All is OK, commit action to modify the texture set with imported textures
+
+ if _import_mode == HTerrainTextureSet.MODE_TEXTURES:
+ ur.create_action("HTerrainTextureSet: import textures")
+
+ HT_TextureSetEditor.backup_for_undo(_texture_set, ur)
+
+ ur.add_do_method(_texture_set.clear)
+ ur.add_do_method(_texture_set.set_mode.bind(_import_mode))
+
+ for i in len(_slots_data):
+ ur.add_do_method(_texture_set.insert_slot.bind(-1))
+ for fd in files_data:
+ ur.add_do_method(_texture_set.set_texture.bind(fd.slot_index, fd.type, fd.texture))
+
+ else:
+ ur.create_action("HTerrainTextureSet: import texture arrays")
+
+ HT_TextureSetEditor.backup_for_undo(_texture_set, ur)
+
+ ur.add_do_method(_texture_set.clear)
+ ur.add_do_method(_texture_set.set_mode.bind(_import_mode))
+
+ for fd in files_data:
+ ur.add_do_method(_texture_set.set_texture_array.bind(fd.type, fd.texture_array))
+
+ ur.commit_action()
+
+ _logger.debug("Done importing")
+
+ _info_popup.dialog_text = "Importing complete!"
+ _info_popup.popup_centered()
+
+ import_finished.emit()
+
+
+class HT_PackedImageInfo:
+ var path := "" # Where the packed image is saved
+ var slot_index : int # Slot in texture set, when using individual textures
+ var type : int # 0:Albedo+Bump, 1:Normal+Roughness
+ var import_file_data := {} # Data to write into the .import file (when enabled...)
+ var image : Image
+ var is_default := false
+ var texture : Texture2D
+ var texture_array : TextureLayered
+
+
+# Shared code between the two import modes
+func _generate_packed_images2() -> HT_Result:
+ var resolution : int = _import_settings.resolution
+ var images_infos := []
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var src_types := HTerrainTextureSet.get_src_types_from_type(type)
+
+ for slot_index in len(_slots_data):
+ var slot : HT_TextureSetImportEditorSlot = _slots_data[slot_index]
+
+ # Albedo or Normal
+ var src0 : String = slot.texture_paths[src_types[0]]
+ # Bump or Roughness
+ var src1 : String = slot.texture_paths[src_types[1]]
+
+ if src0 == "":
+ if src_types[0] == HTerrainTextureSet.SRC_TYPE_ALBEDO:
+ return HT_Result.new(false,
+ "Albedo texture is missing in slot {0}".format([slot_index]))
+
+ var is_default := (src0 == "" and src1 == "")
+
+ if src0 == "":
+ src0 = HTerrainTextureSet.get_source_texture_default_color_code(src_types[0])
+ if src1 == "":
+ src1 = HTerrainTextureSet.get_source_texture_default_color_code(src_types[1])
+
+ var pack_sources := {
+ "rgb": src0,
+ "a": src1
+ }
+
+ if HTerrainTextureSet.SRC_TYPE_NORMAL in src_types and slot.flip_normalmap_y:
+ pack_sources["normalmap_flip_y"] = true
+
+ var packed_image_result := HT_PackedTextureUtil.generate_image(
+ pack_sources, resolution, _logger)
+ if not packed_image_result.success:
+ return packed_image_result
+ var packed_image : Image = packed_image_result.value
+
+ var fd := HT_PackedImageInfo.new()
+ fd.slot_index = slot_index
+ fd.type = type
+ fd.image = packed_image
+ fd.is_default = is_default
+
+ images_infos.append(fd)
+
+ return HT_Result.new(true).with_value(images_infos)
+
+
+func _generate_packed_images(import_dir: String, prefix: String) -> HT_Result:
+ var images_infos_result := _generate_packed_images2()
+ if not images_infos_result.success:
+ return images_infos_result
+ var images_infos : Array = images_infos_result.value
+
+ for info_index in len(images_infos):
+ var info : HT_PackedImageInfo = images_infos[info_index]
+
+ var type_name := HTerrainTextureSet.get_texture_type_name(info.type)
+ var fpath := import_dir.path_join(
+ str(prefix, "slot", info.slot_index, "_", type_name, ".png"))
+
+ var err := info.image.save_png(fpath)
+ if err != OK:
+ return HT_Result.new(false,
+ "Could not save image {0}, {1}".format([fpath, HT_Errors.get_message(err)]))
+
+ info.path = fpath
+ info.import_file_data = {
+ "remap": {
+ "importer": "texture",
+ "type": "CompressedTexture2D"
+ },
+ "deps": {
+ "source_file": fpath
+ },
+ "params": {
+ "compress/mode": ResourceImporterTexture_Unexposed.COMPRESS_VRAM_COMPRESSED,
+ "compress/high_quality": false,
+ "compress/lossy_quality": 0.7,
+ "mipmaps/generate": true,
+ "mipmaps/limit": -1,
+ "roughness/mode": ResourceImporterTexture_Unexposed.ROUGHNESS_DISABLED,
+ "process/fix_alpha_border": false
+ }
+ }
+
+ return HT_Result.new(true).with_value(images_infos)
+
+
+static func _assemble_texarray_images(images: Array[Image], resolution: Vector2i) -> Image:
+ # Godot expects some kind of grid. Let's be lazy and do a grid with only one row.
+ var atlas := Image.create(resolution.x * len(images), resolution.y, false, Image.FORMAT_RGBA8)
+ for index in len(images):
+ var image : Image = images[index]
+ if image.get_size() != resolution:
+ image.resize(resolution.x, resolution.y, Image.INTERPOLATE_BILINEAR)
+ atlas.blit_rect(image,
+ Rect2i(0, 0, image.get_width(), image.get_height()),
+ Vector2i(index * resolution.x, 0))
+ return atlas
+
+
+func _generate_packed_texarray_images(import_dir: String, prefix: String) -> HT_Result:
+ var images_infos_result := _generate_packed_images2()
+ if not images_infos_result.success:
+ return images_infos_result
+ var individual_images_infos : Array = images_infos_result.value
+
+ var resolution : int = _import_settings.resolution
+
+ var texarray_images_infos := []
+ var slot_count := len(_slots_data)
+
+ for type in HTerrainTextureSet.TYPE_COUNT:
+ var texarray_images : Array[Image] = []
+ texarray_images.resize(slot_count)
+
+ var fully_defaulted_slots := 0
+
+ for i in slot_count:
+ var info : HT_PackedImageInfo = individual_images_infos[type * slot_count + i]
+ if info.type == type:
+ texarray_images[i] = info.image
+ if info.is_default:
+ fully_defaulted_slots += 1
+
+ if fully_defaulted_slots == len(texarray_images):
+ # No need to generate this file at all
+ continue
+
+ var texarray_image := _assemble_texarray_images(texarray_images,
+ Vector2i(resolution, resolution))
+
+ var type_name := HTerrainTextureSet.get_texture_type_name(type)
+ var fpath := import_dir.path_join(str(prefix, type_name, "_array.png"))
+
+ var err := texarray_image.save_png(fpath)
+ if err != OK:
+ return HT_Result.new(false,
+ "Could not save image {0}, {1}".format([fpath, HT_Errors.get_message(err)]))
+
+ var texarray_image_info := HT_PackedImageInfo.new()
+ texarray_image_info.type = type
+ texarray_image_info.path = fpath
+ texarray_image_info.import_file_data = {
+ "remap": {
+ "importer": "2d_array_texture",
+ "type": "CompressedTexture2DArray"
+ },
+ "deps": {
+ "source_file": fpath
+ },
+ "params": {
+ "compress/mode": ResourceImporterTextureLayered_Unexposed.COMPRESS_VRAM_COMPRESSED,
+ "compress/high_quality": false,
+ "compress/lossy_quality": 0.7,
+ "mipmaps/generate": true,
+ "mipmaps/limit": -1,
+ "process/fix_alpha_border": false,
+ "slices/horizontal": len(texarray_images),
+ "slices/vertical": 1
+ }
+ }
+
+ texarray_images_infos.append(texarray_image_info)
+
+ return HT_Result.new(true).with_value(texarray_images_infos)
+
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn
new file mode 100644
index 0000000..549f7a1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn
@@ -0,0 +1,218 @@
+[gd_scene load_steps=4 format=3 uid="uid://3indvrto5vd5"]
+
+[ext_resource type="PackedScene" path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" id="1"]
+[ext_resource type="PackedScene" uid="uid://dqgaomu3tr1ym" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn" id="3"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd" id="4"]
+
+[node name="TextureSetImportEditor" type="AcceptDialog"]
+title = "Texture Set Import Tool"
+size = Vector2i(652, 623)
+min_size = Vector2i(652, 480)
+script = ExtResource("4")
+
+[node name="Import" 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 = -18.0
+
+[node name="HS" type="HSplitContainer" parent="Import"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="VB" type="VBoxContainer" parent="Import/HS"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Label" type="Label" parent="Import/HS/VB"]
+visible = false
+layout_mode = 2
+text = "Slots"
+
+[node name="SlotsList" type="ItemList" parent="Import/HS/VB"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+size_flags_vertical = 3
+item_count = 7
+item_0/text = "Item 0"
+item_1/text = "Item 1"
+item_2/text = "Item 2"
+item_3/text = "Item 3"
+item_4/text = "Item 4"
+item_5/text = "Item 5"
+item_6/text = "Item 6"
+
+[node name="HB" type="HBoxContainer" parent="Import/HS/VB"]
+layout_mode = 2
+
+[node name="AddSlotButton" type="Button" parent="Import/HS/VB/HB"]
+layout_mode = 2
+text = "+"
+
+[node name="Control" type="Control" parent="Import/HS/VB/HB"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="RemoveSlotButton" type="Button" parent="Import/HS/VB/HB"]
+layout_mode = 2
+text = "-"
+
+[node name="VB2" type="VBoxContainer" parent="Import/HS"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="Import/HS/VB2"]
+visible = false
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="Import/HS/VB2"]
+layout_mode = 2
+
+[node name="Albedo" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="Bump" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="Normal" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="NormalMapFlipY" type="CheckBox" parent="Import/HS/VB2/HB/Normal"]
+layout_mode = 2
+text = "Flip Y"
+
+[node name="Roughness" parent="Import/HS/VB2/HB" instance=ExtResource("3")]
+layout_mode = 2
+
+[node name="Control" type="Control" parent="Import/HS/VB2"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+
+[node name="Control2" type="Control" parent="Import/HS/VB2"]
+custom_minimum_size = Vector2(0, 4)
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Label3" type="Label" parent="Import/HS/VB2"]
+modulate = Color(0.564706, 0.564706, 0.564706, 1)
+layout_mode = 2
+text = "These images should remain accessible for import to work.
+Tip: you can place them in a folder with a `.gdignore` file, so they won't take space in your exported game."
+autowrap_mode = 2
+
+[node name="Spacer3" type="Control" parent="Import"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="HSeparator" type="HSeparator" parent="Import"]
+layout_mode = 2
+
+[node name="GC" type="GridContainer" parent="Import"]
+layout_mode = 2
+columns = 4
+
+[node name="Label2" type="Label" parent="Import/GC"]
+layout_mode = 2
+text = "Import mode: "
+
+[node name="ImportModeSelector" type="OptionButton" parent="Import/GC"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="MipmapsCheckbox" type="CheckBox" parent="Import/GC"]
+visible = false
+layout_mode = 2
+text = "Mipmaps"
+
+[node name="Spacer2" type="Control" parent="Import/GC"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="Import/GC"]
+visible = false
+layout_mode = 2
+text = "Compression:"
+
+[node name="CompressionSelector" type="OptionButton" parent="Import/GC"]
+visible = false
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="FilterCheckBox" type="CheckBox" parent="Import/GC"]
+visible = false
+layout_mode = 2
+text = "Filter"
+
+[node name="Spacer" type="Control" parent="Import/GC"]
+layout_mode = 2
+
+[node name="Label3" type="Label" parent="Import/GC"]
+layout_mode = 2
+text = "Resolution:"
+
+[node name="ResolutionSpinBox" type="SpinBox" parent="Import/GC"]
+layout_mode = 2
+min_value = 1.0
+max_value = 4096.0
+value = 1.0
+
+[node name="HB2" type="HBoxContainer" parent="Import"]
+layout_mode = 2
+
+[node name="Label2" type="Label" parent="Import/HB2"]
+layout_mode = 2
+text = "Import directory"
+
+[node name="ImportDirectoryLineEdit" type="LineEdit" parent="Import/HB2"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="BrowseImportDirectory" type="Button" parent="Import/HB2"]
+layout_mode = 2
+text = "..."
+
+[node name="Spacer" type="Control" parent="Import"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="HB" type="HBoxContainer" parent="Import"]
+layout_mode = 2
+alignment = 1
+
+[node name="ImportButton" type="Button" parent="Import/HB"]
+layout_mode = 2
+text = "Import to TextureSet"
+
+[node name="CancelButton" type="Button" parent="Import/HB"]
+layout_mode = 2
+text = "Close"
+
+[node name="Spacer2" type="Control" parent="Import"]
+custom_minimum_size = Vector2(0, 8)
+layout_mode = 2
+
+[node name="DialogFitter" parent="." instance=ExtResource("1")]
+layout_mode = 3
+anchors_preset = 0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 644.0
+offset_bottom = 605.0
+
+[connection signal="item_selected" from="Import/HS/VB/SlotsList" to="." method="_on_SlotsList_item_selected"]
+[connection signal="pressed" from="Import/HS/VB/HB/AddSlotButton" to="." method="_on_AddSlotButton_pressed"]
+[connection signal="pressed" from="Import/HS/VB/HB/RemoveSlotButton" to="." method="_on_RemoveSlotButton_pressed"]
+[connection signal="toggled" from="Import/HS/VB2/HB/Normal/NormalMapFlipY" to="." method="_on_NormalMapFlipY_toggled"]
+[connection signal="item_selected" from="Import/GC/ImportModeSelector" to="." method="_on_ImportModeSelector_item_selected"]
+[connection signal="toggled" from="Import/GC/MipmapsCheckbox" to="." method="_on_MipmapsCheckbox_toggled"]
+[connection signal="item_selected" from="Import/GC/CompressionSelector" to="." method="_on_CompressionSelector_item_selected"]
+[connection signal="toggled" from="Import/GC/FilterCheckBox" to="." method="_on_FilterCheckBox_toggled"]
+[connection signal="value_changed" from="Import/GC/ResolutionSpinBox" to="." method="_on_ResolutionSpinBox_value_changed"]
+[connection signal="text_changed" from="Import/HB2/ImportDirectoryLineEdit" to="." method="_on_ImportDirectoryLineEdit_text_changed"]
+[connection signal="pressed" from="Import/HB2/BrowseImportDirectory" to="." method="_on_BrowseImportDirectory_pressed"]
+[connection signal="pressed" from="Import/HB/ImportButton" to="." method="_on_ImportButton_pressed"]
+[connection signal="pressed" from="Import/HB/CancelButton" to="." method="_on_CancelButton_pressed"]
+
+[editable path="Import/HS/VB2/HB/Normal"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd
new file mode 100644
index 0000000..e3e337f
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd
@@ -0,0 +1,134 @@
+@tool
+extends Control
+
+const HTerrain = preload("../../hterrain.gd")
+const HTerrainTextureSet = preload("../../hterrain_texture_set.gd")
+const HT_TextureList = preload("./texture_list.gd")
+const HT_Logger = preload("../../util/logger.gd")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+const EMPTY_ICON_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+signal texture_selected(index)
+signal edit_pressed(index)
+signal import_pressed
+
+@onready var _textures_list: HT_TextureList = $TextureList
+@onready var _buttons_container : HBoxContainer = $HBoxContainer
+
+var _terrain : HTerrain = null
+var _texture_set : HTerrainTextureSet = null
+
+var _texture_list_need_update := false
+var _empty_icon : Texture2D
+
+var _logger = HT_Logger.get_for(self)
+
+
+func _ready():
+ _empty_icon = load(EMPTY_ICON_TEXTURE_PATH)
+ if _empty_icon == null:
+ _logger.error(str("Failed to load empty icon ", EMPTY_ICON_TEXTURE_PATH))
+
+ # Default amount, will be updated when a terrain is assigned
+ _textures_list.clear()
+ for i in range(4):
+ _textures_list.add_item(str(i), _empty_icon)
+
+
+func set_terrain(terrain: HTerrain):
+ _terrain = terrain
+
+
+static func _get_slot_count(terrain: HTerrain) -> int:
+ var texture_set = terrain.get_texture_set()
+ if texture_set == null:
+ return 0
+ return texture_set.get_slots_count()
+
+
+func _process(delta: float):
+ var texture_set = null
+ if _terrain != null:
+ texture_set = _terrain.get_texture_set()
+
+ if _texture_set != texture_set:
+ if _texture_set != null:
+ _texture_set.changed.disconnect(_on_texture_set_changed)
+
+ _texture_set = texture_set
+
+ if _texture_set != null:
+ _texture_set.changed.connect(_on_texture_set_changed)
+
+ _update_texture_list()
+
+ if _texture_list_need_update:
+ _update_texture_list()
+ _texture_list_need_update = false
+
+
+func _on_texture_set_changed():
+ _texture_list_need_update = true
+
+
+func _update_texture_list():
+ _textures_list.clear()
+
+ if _terrain == null:
+ _set_buttons_active(false)
+ return
+ var texture_set := _terrain.get_texture_set()
+ if texture_set == null:
+ _set_buttons_active(false)
+ return
+ _set_buttons_active(true)
+
+ var slots_count := texture_set.get_slots_count()
+
+ match texture_set.get_mode():
+ HTerrainTextureSet.MODE_TEXTURES:
+ for slot_index in slots_count:
+ var texture := texture_set.get_texture(
+ slot_index, HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ var hint := _get_slot_hint_name(slot_index, _terrain.get_shader_type())
+ if texture == null:
+ texture = _empty_icon
+ _textures_list.add_item(hint, texture)
+
+ HTerrainTextureSet.MODE_TEXTURE_ARRAYS:
+ var texture_array = texture_set.get_texture_array(HTerrainTextureSet.TYPE_ALBEDO_BUMP)
+ for slot_index in slots_count:
+ var hint := _get_slot_hint_name(slot_index, _terrain.get_shader_type())
+ _textures_list.add_item(hint, texture_array, slot_index)
+
+
+func _set_buttons_active(active: bool):
+ for i in _buttons_container.get_child_count():
+ var child = _buttons_container.get_child(i)
+ if child is Button:
+ child.disabled = not active
+
+
+static func _get_slot_hint_name(i: int, stype: String) -> String:
+ if i == 3 and (stype == HTerrain.SHADER_CLASSIC4 or stype == HTerrain.SHADER_CLASSIC4_LITE):
+ return "cliff"
+ return str(i)
+
+
+func _on_TextureList_item_selected(index: int):
+ texture_selected.emit(index)
+
+
+func _on_TextureList_item_activated(index: int):
+ edit_pressed.emit(index)
+
+
+func _on_EditButton_pressed():
+ var selected_slot := _textures_list.get_selected_item()
+ if selected_slot == -1:
+ selected_slot = 0
+ edit_pressed.emit(selected_slot)
+
+
+func _on_ImportButton_pressed():
+ import_pressed.emit()
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn
new file mode 100644
index 0000000..885f344
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn
@@ -0,0 +1,48 @@
+[gd_scene load_steps=3 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_editor.gd" type="Script" id=1]
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_list.tscn" type="PackedScene" id=2]
+
+[node name="TextureEditor" type="Control"]
+offset_right = 352.0
+offset_bottom = 104.0
+custom_minimum_size = Vector2( 100, 0 )
+size_flags_horizontal = 3
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="TextureList" parent="." instance=ExtResource( 2 )]
+offset_bottom = -26.0
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -24.0
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="EditButton" type="Button" parent="HBoxContainer"]
+offset_right = 48.0
+offset_bottom = 24.0
+text = "Edit..."
+
+[node name="ImportButton" type="Button" parent="HBoxContainer"]
+offset_left = 52.0
+offset_right = 120.0
+offset_bottom = 24.0
+text = "Import..."
+
+[node name="Label" type="Label" parent="HBoxContainer"]
+offset_left = 124.0
+offset_top = 5.0
+offset_right = 179.0
+offset_bottom = 19.0
+text = "Textures"
+[connection signal="item_activated" from="TextureList" to="." method="_on_TextureList_item_activated"]
+[connection signal="item_selected" from="TextureList" to="." method="_on_TextureList_item_selected"]
+[connection signal="pressed" from="HBoxContainer/EditButton" to="." method="_on_EditButton_pressed"]
+[connection signal="pressed" from="HBoxContainer/ImportButton" to="." method="_on_ImportButton_pressed"]
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd
new file mode 100644
index 0000000..7391f85
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.gd
@@ -0,0 +1,79 @@
+
+# I needed a custom container for this because textures edited by this plugin are often
+# unfit to display in a GUI, they need to go through a shader (either discarding alpha,
+# or picking layers of a TextureArray). Unfortunately, ItemList does not have custom item drawing,
+# and items cannot have individual shaders.
+# I could create new textures just for that but it would be expensive.
+
+@tool
+extends ScrollContainer
+
+const HT_TextureListItemScene = preload("./texture_list_item.tscn")
+const HT_TextureListItem = preload("./texture_list_item.gd")
+
+signal item_selected(index)
+signal item_activated(index)
+
+@onready var _container : Container = $Container
+
+
+var _selected_item := -1
+
+
+# TEST
+#func _ready():
+# add_item("First", load("res://addons/zylann.hterrain_demo/textures/ground/bricks_albedo_bump.png"), 0)
+# add_item("Second", load("res://addons/zylann.hterrain_demo/textures/ground/grass_albedo_bump.png"), 0)
+# add_item("Third", load("res://addons/zylann.hterrain_demo/textures/ground/leaves_albedo_bump.png"), 0)
+# add_item("Fourth", load("res://addons/zylann.hterrain_demo/textures/ground/sand_albedo_bump.png"), 0)
+# var texture_array = load("res://tests/texarray/textures/array_albedo_atlas.png")
+# add_item("Ninth", texture_array, 2)
+# add_item("Sixth", texture_array, 3)
+
+
+# Note: the texture can be a TextureArray, which does not inherit Texture
+func add_item(text: String, texture: Texture, texture_layer: int = 0):
+ var item : HT_TextureListItem = HT_TextureListItemScene.instantiate()
+ _container.add_child(item)
+ item.set_text(text)
+ item.set_texture(texture, texture_layer)
+
+
+func get_item_count() -> int:
+ return _container.get_child_count()
+
+
+func set_item_texture(index: int, tex: Texture, layer: int = 0):
+ var child : HT_TextureListItem = _container.get_child(index)
+ child.set_texture(tex, layer)
+
+
+func get_selected_item() -> int:
+ return _selected_item
+
+
+func clear():
+ for i in _container.get_child_count():
+ var child = _container.get_child(i)
+ if child is Control:
+ child.queue_free()
+ _selected_item = -1
+
+
+func _on_item_selected(item: HT_TextureListItem):
+ _selected_item = item.get_index()
+ for i in _container.get_child_count():
+ var child = _container.get_child(i)
+ if child is HT_TextureListItem and child != item:
+ child.set_selected(false, false)
+ item_selected.emit(_selected_item)
+
+
+func _on_item_activated(item: HT_TextureListItem):
+ item_activated.emit(item.get_index())
+
+
+func _draw():
+ # TODO Draw same background as Panel
+ # Draw a background
+ draw_rect(get_rect(), Color(0,0,0,0.3))
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn
new file mode 100644
index 0000000..df5e1e2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn
@@ -0,0 +1,20 @@
+[gd_scene load_steps=3 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_list.gd" type="Script" id=1]
+[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/flow_container.gd" type="Script" id=2]
+
+[node name="TextureList" type="ScrollContainer"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+scroll_horizontal_enabled = false
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="Container" type="Container" parent="."]
+offset_right = 800.0
+offset_bottom = 82.0
+custom_minimum_size = Vector2( 0, 82 )
+size_flags_horizontal = 3
+script = ExtResource( 2 )
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd
new file mode 100644
index 0000000..098f349
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd
@@ -0,0 +1,72 @@
+@tool
+extends PanelContainer
+# Had to use PanelContainer, because due to variable font sizes in the editor,
+# the contents of the VBoxContainer can vary in size, and so in height.
+# Which means the entire item can have variable size, not just because of DPI.
+# In such cases, the hierarchy must be made of containers that grow based on their children.
+
+const HT_ColorMaterial = preload("./display_color_material.tres")
+const HT_ColorSliceShader = preload("./display_color_slice.gdshader")
+# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported
+#const HT_DummyTexture = preload("../icons/empty.png")
+const DUMMY_TEXTURE_PATH = "res://addons/zylann.hterrain/tools/icons/empty.png"
+
+@onready var _texture_rect : TextureRect = $VB/TextureRect
+@onready var _label : Label = $VB/Label
+
+
+var _selected := false
+
+
+func set_text(text: String):
+ _label.text = text
+
+
+func set_texture(texture: Texture, texture_layer: int):
+ if texture is TextureLayered:
+ var mat = _texture_rect.material
+ if mat == null or not (mat is ShaderMaterial):
+ mat = ShaderMaterial.new()
+ mat.shader = HT_ColorSliceShader
+ _texture_rect.material = mat
+ mat.set_shader_parameter("u_texture_array", texture)
+ mat.set_shader_parameter("u_index", texture_layer)
+ _texture_rect.texture = load(DUMMY_TEXTURE_PATH)
+ else:
+ _texture_rect.texture = texture
+ _texture_rect.material = HT_ColorMaterial
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ grab_focus()
+ set_selected(true, true)
+ if event.double_click:
+ # Don't do this at home.
+ # I do it here because this script is very related to its container anyways.
+ get_parent().get_parent()._on_item_activated(self)
+
+
+func set_selected(selected: bool, notify: bool):
+ if selected == _selected:
+ return
+ _selected = selected
+ queue_redraw()
+ if _selected:
+ _label.modulate = Color(0,0,0)
+ else:
+ _label.modulate = Color(1,1,1)
+ if notify:
+ get_parent().get_parent()._on_item_selected(self)
+
+
+func _draw():
+ var color : Color
+ if _selected:
+ color = get_theme_color("accent_color", "Editor")
+ else:
+ color = Color(0.0, 0.0, 0.0, 0.5)
+ # Draw background
+ draw_rect(Rect2(Vector2(), size), color)
diff --git a/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn
new file mode 100644
index 0000000..4340f55
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn
@@ -0,0 +1,25 @@
+[gd_scene load_steps=2 format=3 uid="uid://daugk4kdnx6vy"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd" id="2"]
+
+[node name="TextureListItem" type="PanelContainer"]
+custom_minimum_size = Vector2(64, 80)
+offset_right = 64.0
+offset_bottom = 80.0
+focus_mode = 1
+script = ExtResource("2")
+
+[node name="VB" type="VBoxContainer" parent="."]
+layout_mode = 2
+mouse_filter = 2
+
+[node name="TextureRect" type="TextureRect" parent="VB"]
+custom_minimum_size = Vector2(60, 60)
+layout_mode = 2
+size_flags_vertical = 3
+mouse_filter = 2
+expand_mode = 1
+
+[node name="Label" type="Label" parent="VB"]
+layout_mode = 2
+text = "Texture"
diff --git a/game/addons/zylann.hterrain/tools/util/dialog_fitter.gd b/game/addons/zylann.hterrain/tools/util/dialog_fitter.gd
new file mode 100644
index 0000000..59bb194
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/dialog_fitter.gd
@@ -0,0 +1,53 @@
+
+# If you make a container-based UI inside a WindowDialog, there is a chance it will overflow
+# because WindowDialogs don't adjust by themselves. This happens when the user has a different
+# font size than yours, and can cause controls to be unusable (like buttons at the bottom).
+# This script adjusts the size of the parent WindowDialog based on the first Container it finds
+# when the node becomes visible.
+
+@tool
+# Needs to be a Control, otherwise we don't receive the notification...
+extends Control
+
+const HT_Util = preload("../../util/util.gd")
+
+
+func _notification(what: int):
+ if HT_Util.is_in_edited_scene(self):
+ return
+ if is_inside_tree() and what == Control.NOTIFICATION_VISIBILITY_CHANGED:
+ #print("Visible ", is_visible_in_tree(), ", ", visible)
+ call_deferred("_fit_to_contents")
+
+
+func _fit_to_contents():
+ var dialog : Window = get_parent()
+ for child in dialog.get_children():
+ if child is Container:
+ var child_rect : Rect2 = child.get_global_rect()
+ var dialog_rect := Rect2(Vector2(), dialog.size)
+ #print("Dialog: ", dialog_rect, ", contents: ", child_rect, " ", child.get_path())
+ if not dialog_rect.encloses(child_rect):
+ var margin : Vector2 = child.get_rect().position
+ #print("Fitting ", dialog.get_path(), " from ", dialog.rect_size,
+ # " to ", child_rect.size + margin * 2.0)
+ dialog.min_size = child_rect.size + margin * 2.0
+
+
+#func _process(delta):
+# update()
+
+# DEBUG
+#func _draw():
+# var self_global_pos = get_global_rect().position
+#
+# var dialog : Control = get_parent()
+# var dialog_rect := dialog.get_global_rect()
+# dialog_rect.position -= self_global_pos
+# draw_rect(dialog_rect, Color(1,1,0), false)
+#
+# for child in dialog.get_children():
+# if child is Container:
+# var child_rect : Rect2 = child.get_global_rect()
+# child_rect.position -= self_global_pos
+# draw_rect(child_rect, Color(1,1,0,0.1))
diff --git a/game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn b/game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn
new file mode 100644
index 0000000..2e3b00c
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/dialog_fitter.tscn
@@ -0,0 +1,10 @@
+[gd_scene load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/util/dialog_fitter.gd" type="Script" id=1]
+
+[node name="DialogFitter" type="Control"]
+mouse_filter = 2
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
diff --git a/game/addons/zylann.hterrain/tools/util/editor_util.gd b/game/addons/zylann.hterrain/tools/util/editor_util.gd
new file mode 100644
index 0000000..a6d9eff
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/editor_util.gd
@@ -0,0 +1,104 @@
+
+# Editor-specific utilities.
+# This script cannot be loaded in an exported game.
+
+@tool
+
+
+# This is normally an `EditorFileDialog`. I can't type-hint this one properly,
+# because when I test UI in isolation, I can't use `EditorFileDialog`.
+static func create_open_file_dialog() -> ConfirmationDialog:
+ var d
+ if Engine.is_editor_hint():
+ # TODO Workaround bug when editor-only classes are created in source code, even if not run
+ # https://github.com/godotengine/godot/issues/73525
+# d = EditorFileDialog.new()
+ d = ClassDB.instantiate(&"EditorFileDialog")
+ d.file_mode = EditorFileDialog.FILE_MODE_OPEN_FILE
+ d.access = EditorFileDialog.ACCESS_RESOURCES
+ else:
+ d = FileDialog.new()
+ d.file_mode = FileDialog.FILE_MODE_OPEN_FILE
+ d.access = FileDialog.ACCESS_RESOURCES
+ d.unresizable = false
+ return d
+
+
+static func create_open_dir_dialog() -> ConfirmationDialog:
+ var d
+ if Engine.is_editor_hint():
+ # TODO Workaround bug when editor-only classes are created in source code, even if not run
+ # https://github.com/godotengine/godot/issues/73525
+# d = EditorFileDialog.new()
+ d = ClassDB.instantiate(&"EditorFileDialog")
+ d.file_mode = EditorFileDialog.FILE_MODE_OPEN_DIR
+ d.access = EditorFileDialog.ACCESS_RESOURCES
+ else:
+ d = FileDialog.new()
+ d.file_mode = FileDialog.FILE_MODE_OPEN_DIR
+ d.access = FileDialog.ACCESS_RESOURCES
+ d.unresizable = false
+ return d
+
+
+# If you want to open using Image.load()
+static func create_open_image_dialog() -> ConfirmationDialog:
+ var d = create_open_file_dialog()
+ _add_image_filters(d)
+ return d
+
+
+# If you want to open using load(),
+# although it might still fail if the file is imported as Image...
+static func create_open_texture_dialog() -> ConfirmationDialog:
+ var d = create_open_file_dialog()
+ _add_texture_filters(d)
+ return d
+
+
+static func create_open_texture_array_dialog() -> ConfirmationDialog:
+ var d = create_open_file_dialog()
+ _add_texture_array_filters(d)
+ return d
+
+# TODO Post a proposal, we need a file dialog filtering on resource types, not on file extensions!
+
+static func _add_image_filters(file_dialog):
+ file_dialog.add_filter("*.png ; PNG files")
+ file_dialog.add_filter("*.jpg ; JPG files")
+ #file_dialog.add_filter("*.exr ; EXR files")
+
+
+static func _add_texture_filters(file_dialog):
+ _add_image_filters(file_dialog)
+ # Godot
+ file_dialog.add_filter("*.ctex ; CompressedTexture files")
+ # Packed textures
+ file_dialog.add_filter("*.packed_tex ; HTerrainPackedTexture files")
+
+
+static func _add_texture_array_filters(file_dialog):
+ _add_image_filters(file_dialog)
+ # Godot
+ file_dialog.add_filter("*.ctexarray ; TextureArray files")
+ # Packed textures
+ file_dialog.add_filter("*.packed_texarr ; HTerrainPackedTextureArray files")
+
+
+# Tries to load a texture with the ResourceLoader, and if it fails, attempts
+# to load it manually as an ImageTexture
+static func load_texture(path: String, logger) -> Texture:
+ var tex : Texture = load(path)
+ if tex != null:
+ return tex
+ # This can unfortunately happen when the editor didn't import assets yet.
+ # See https://github.com/godotengine/godot/issues/17483
+ logger.error(str("Failed to load texture ", path, ", attempting to load manually"))
+ var im := Image.new()
+ var err = im.load(path)
+ if err != OK:
+ logger.error(str("Failed to load image ", path))
+ return null
+ var itex := ImageTexture.create_from_image(im)
+ return itex
+
diff --git a/game/addons/zylann.hterrain/tools/util/interval_slider.gd b/game/addons/zylann.hterrain/tools/util/interval_slider.gd
new file mode 100644
index 0000000..481d08b
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/interval_slider.gd
@@ -0,0 +1,197 @@
+
+# Slider with two handles representing an interval.
+
+@tool
+extends Control
+
+const VALUE_LOW = 0
+const VALUE_HIGH = 1
+const VALUE_COUNT = 2
+
+const FG_MARGIN = 1
+
+signal changed
+
+var _min_value := 0.0
+var _max_value := 1.0
+var _values = [0.2, 0.6]
+var _grabbing := false
+
+
+func _get_property_list():
+ return [
+ {
+ "name": "min_value",
+ "type": TYPE_FLOAT,
+ "usage": PROPERTY_USAGE_EDITOR
+ },
+ {
+ "name": "max_value",
+ "type": TYPE_FLOAT,
+ "usage": PROPERTY_USAGE_EDITOR
+ },
+ {
+ "name": "range",
+ "type": TYPE_VECTOR2,
+ "usage": PROPERTY_USAGE_STORAGE
+ }
+ ]
+
+
+func _get(key: StringName):
+ match key:
+ &"min_value":
+ return _min_value
+ &"max_value":
+ return _max_value
+ &"range":
+ return Vector2(_min_value, _max_value)
+
+
+func _set(key: StringName, value):
+ match key:
+ &"min_value":
+ _min_value = min(value, _max_value)
+ queue_redraw()
+ &"max_value":
+ _max_value = max(value, _min_value)
+ queue_redraw()
+ &"range":
+ _min_value = value.x
+ _max_value = value.y
+
+
+func set_values(low: float, high: float):
+ if low > high:
+ low = high
+ if high < low:
+ high = low
+ _values[VALUE_LOW] = low
+ _values[VALUE_HIGH] = high
+ queue_redraw()
+
+
+func set_value(i: int, v: float, notify_change: bool):
+ var min_value = _min_value
+ var max_value = _max_value
+
+ match i:
+ VALUE_LOW:
+ max_value = _values[VALUE_HIGH]
+ VALUE_HIGH:
+ min_value = _values[VALUE_LOW]
+ _:
+ assert(false)
+
+ v = clampf(v, min_value, max_value)
+ if v != _values[i]:
+ _values[i] = v
+ queue_redraw()
+ if notify_change:
+ changed.emit()
+
+
+func get_value(i: int) -> float:
+ return _values[i]
+
+
+func get_low_value() -> float:
+ return _values[VALUE_LOW]
+
+
+func get_high_value() -> float:
+ return _values[VALUE_HIGH]
+
+
+func get_ratio(i: int) -> float:
+ return _value_to_ratio(_values[i])
+
+
+func get_low_ratio() -> float:
+ return get_ratio(VALUE_LOW)
+
+
+func get_high_ratio() -> float:
+ return get_ratio(VALUE_HIGH)
+
+
+func _ratio_to_value(r: float) -> float:
+ return r * (_max_value - _min_value) + _min_value
+
+
+func _value_to_ratio(v: float) -> float:
+ if absf(_max_value - _min_value) < 0.001:
+ return 0.0
+ return (v - _min_value) / (_max_value - _min_value)
+
+
+func _get_closest_index(ratio: float) -> int:
+ var distance_low := absf(ratio - get_low_ratio())
+ var distance_high := absf(ratio - get_high_ratio())
+ if distance_low < distance_high:
+ return VALUE_LOW
+ return VALUE_HIGH
+
+
+func _set_from_pixel(px: float):
+ var r := (px - FG_MARGIN) / (size.x - FG_MARGIN * 2.0)
+ var i := _get_closest_index(r)
+ var v := _ratio_to_value(r)
+ set_value(i, v, true)
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _grabbing = true
+ _set_from_pixel(event.position.x)
+ else:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _grabbing = false
+
+ elif event is InputEventMouseMotion:
+ if _grabbing:
+ _set_from_pixel(event.position.x)
+
+
+func _draw():
+ var grabber_width := 3
+ var background_v_margin := 0
+ var foreground_margin := FG_MARGIN
+ var grabber_color := Color(0.8, 0.8, 0.8)
+ var interval_color := Color(0.4,0.4,0.4)
+ var background_color := Color(0.1, 0.1, 0.1)
+
+ var control_rect := Rect2(Vector2(), size)
+
+ var bg_rect := Rect2(
+ control_rect.position.x,
+ control_rect.position.y + background_v_margin,
+ control_rect.size.x,
+ control_rect.size.y - 2 * background_v_margin)
+ draw_rect(bg_rect, background_color)
+
+ var fg_rect := control_rect.grow(-foreground_margin)
+
+ var low_ratio := get_low_ratio()
+ var high_ratio := get_high_ratio()
+
+ var low_x := fg_rect.position.x + low_ratio * fg_rect.size.x
+ var high_x := fg_rect.position.x + high_ratio * fg_rect.size.x
+
+ var interval_rect := Rect2(
+ low_x, fg_rect.position.y, high_x - low_x, fg_rect.size.y)
+ draw_rect(interval_rect, interval_color)
+
+ low_x = fg_rect.position.x + low_ratio * (fg_rect.size.x - grabber_width)
+ high_x = fg_rect.position.x + high_ratio * (fg_rect.size.x - grabber_width)
+
+ for x in [low_x, high_x]:
+ var grabber_rect := Rect2(
+ x,
+ fg_rect.position.y,
+ grabber_width,
+ fg_rect.size.y)
+ draw_rect(grabber_rect, grabber_color)
+
diff --git a/game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd b/game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd
new file mode 100644
index 0000000..b24f0e2
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/resource_importer_texture.gd
@@ -0,0 +1,19 @@
+@tool
+
+# Stuff not exposed by Godot for making .import files
+
+const COMPRESS_LOSSLESS = 0
+const COMPRESS_LOSSY = 1
+const COMPRESS_VRAM_COMPRESSED = 2
+const COMPRESS_VRAM_UNCOMPRESSED = 3
+const COMPRESS_BASIS_UNIVERSAL = 4
+
+const ROUGHNESS_DETECT = 0
+const ROUGHNESS_DISABLED = 1
+# Godot internally subtracts 2 to magically obtain a `Image.RoughnessChannel` enum
+# (also not exposed)
+const ROUGHNESS_RED = 2
+const ROUGHNESS_GREEN = 3
+const ROUGHNESS_BLUE = 4
+const ROUGHNESS_ALPHA = 5
+const ROUGHNESS_GRAY = 6
diff --git a/game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd b/game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd
new file mode 100644
index 0000000..ff77b1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/resource_importer_texture_layered.gd
@@ -0,0 +1,9 @@
+@tool
+
+# Stuff not exposed by Godot for making .import files
+
+const COMPRESS_LOSSLESS = 0
+const COMPRESS_LOSSY = 1
+const COMPRESS_VRAM_COMPRESSED = 2
+const COMPRESS_VRAM_UNCOMPRESSED = 3
+const COMPRESS_BASIS_UNIVERSAL = 4
diff --git a/game/addons/zylann.hterrain/tools/util/result.gd b/game/addons/zylann.hterrain/tools/util/result.gd
new file mode 100644
index 0000000..5e897f1
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/result.gd
@@ -0,0 +1,36 @@
+# Data structure to hold the result of a function that can be expected to fail.
+# The use case is to report errors back to the GUI and act accordingly,
+# instead of forgetting them to the console or having the script break on an assertion.
+# This is a C-like way of things, where the result can bubble, and does not require globals.
+
+@tool
+
+# Replace `success` with `error : int`?
+var success := false
+var value = null
+var message := ""
+var inner_result = null
+
+
+func _init(p_success: bool, p_message := "", p_inner = null):
+ success = p_success
+ message = p_message
+ inner_result = p_inner
+
+
+# TODO Can't type-hint self return
+func with_value(v):
+ value = v
+ return self
+
+
+func get_message() -> String:
+ var msg := message
+ if inner_result != null:
+ msg += "\n"
+ msg += inner_result.get_message()
+ return msg
+
+
+func is_ok() -> bool:
+ return success
diff --git a/game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd b/game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd
new file mode 100644
index 0000000..242fe6e
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd
@@ -0,0 +1,11 @@
+@tool
+extends RichTextLabel
+
+
+func _init():
+ meta_clicked.connect(_on_meta_clicked)
+
+
+func _on_meta_clicked(meta):
+ OS.shell_open(meta)
+
diff --git a/game/addons/zylann.hterrain/tools/util/spin_slider.gd b/game/addons/zylann.hterrain/tools/util/spin_slider.gd
new file mode 100644
index 0000000..fa690d5
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/spin_slider.gd
@@ -0,0 +1,385 @@
+@tool
+extends Control
+
+const FG_MARGIN = 2
+const MAX_DECIMALS_VISUAL = 3
+
+signal value_changed(value)
+
+
+var _value := 0.0
+@export var value: float:
+ get:
+ return _value
+ set(v):
+ set_value_no_notify(v)
+
+
+var _min_value := 0.0
+@export var min_value: float:
+ get:
+ return _min_value
+ set(v):
+ set_min_value(v)
+
+
+var _max_value := 100.0
+@export var max_value: float:
+ get:
+ return _max_value
+ set(v):
+ set_max_value(v)
+
+
+var _prefix := ""
+@export var prefix: String:
+ get:
+ return _prefix
+ set(v):
+ set_prefix(v)
+
+
+var _suffix := ""
+@export var suffix: String:
+ get:
+ return _suffix
+ set(v):
+ set_suffix(v)
+
+
+var _rounded := false
+@export var rounded: bool:
+ get:
+ return _rounded
+ set(v):
+ set_rounded(v)
+
+
+var _centered := true
+@export var centered: bool:
+ get:
+ return _centered
+ set(v):
+ set_centered(v)
+
+
+var _allow_greater := false
+@export var allow_greater: bool:
+ get:
+ return _allow_greater
+ set(v):
+ set_allow_greater(v)
+
+
+# There is still a limit when typing a larger value, but this one is to prevent software
+# crashes or freezes. The regular min and max values are for slider UX. Exceeding it should be
+# a corner case.
+var _greater_max_value := 10000.0
+@export var greater_max_value: float:
+ get:
+ return _greater_max_value
+ set(v):
+ set_greater_max_value(v)
+
+
+var _label : Label
+var _label2 : Label
+var _line_edit : LineEdit
+var _ignore_line_edit := false
+var _pressing := false
+var _grabbing := false
+var _press_pos := Vector2()
+
+
+func _init():
+ custom_minimum_size = Vector2(32, 28)
+
+ _label = Label.new()
+ _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _label.clip_text = true
+ #_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _label.anchor_top = 0
+ _label.anchor_left = 0
+ _label.anchor_right = 1
+ _label.anchor_bottom = 1
+ _label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _label.add_theme_color_override("font_color_shadow", Color(0,0,0,0.5))
+ _label.add_theme_constant_override("shadow_offset_x", 1)
+ _label.add_theme_constant_override("shadow_offset_y", 1)
+ add_child(_label)
+
+ _label2 = Label.new()
+ _label2.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
+ _label2.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _label2.clip_text = true
+ #_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _label2.anchor_top = 0
+ _label2.anchor_left = 0
+ _label2.anchor_right = 1
+ _label2.anchor_bottom = 1
+ _label2.offset_left = 8
+ _label2.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _label2.add_theme_color_override("font_color_shadow", Color(0,0,0,0.5))
+ _label2.add_theme_constant_override("shadow_offset_x", 1)
+ _label2.add_theme_constant_override("shadow_offset_y", 1)
+ _label2.hide()
+ add_child(_label2)
+
+ _line_edit = LineEdit.new()
+ _line_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _line_edit.anchor_top = 0
+ _line_edit.anchor_left = 0
+ _line_edit.anchor_right = 1
+ _line_edit.anchor_bottom = 1
+ _line_edit.gui_input.connect(_on_LineEdit_gui_input)
+ _line_edit.focus_exited.connect(_on_LineEdit_focus_exited)
+ _line_edit.text_submitted.connect(_on_LineEdit_text_submitted)
+ _line_edit.hide()
+ add_child(_line_edit)
+
+ mouse_default_cursor_shape = Control.CURSOR_HSIZE
+
+
+func _ready():
+ pass # Replace with function body.
+
+
+func set_centered(p_centered: bool):
+ _centered = p_centered
+ if _centered:
+ _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _label.offset_right = 0
+ _label2.hide()
+ else:
+ _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ _label.offset_right = -8
+ _label2.show()
+ queue_redraw()
+
+
+func is_centered() -> bool:
+ return _centered
+
+
+func set_value_no_notify(v: float):
+ set_value(v, false, false)
+
+
+func set_value(v: float, notify_change: bool, use_slider_maximum: bool = false):
+ if _allow_greater and not use_slider_maximum:
+ v = clampf(v, _min_value, _greater_max_value)
+ else:
+ v = clampf(v, _min_value, _max_value)
+
+ if v != _value:
+ _value = v
+
+ queue_redraw()
+
+ if notify_change:
+ value_changed.emit(get_value())
+
+
+func get_value():
+ if _rounded:
+ return int(roundf(_value))
+ return _value
+
+
+func set_min_value(minv: float):
+ _min_value = minv
+ #queue_redraw()
+
+
+func get_min_value() -> float:
+ return _min_value
+
+
+func set_max_value(maxv: float):
+ _max_value = maxv
+ #queue_redraw()
+
+
+func get_max_value() -> float:
+ return _max_value
+
+
+func set_greater_max_value(gmax: float):
+ _greater_max_value = gmax
+
+
+func get_greater_max_value() -> float:
+ return _greater_max_value
+
+
+func set_rounded(b: bool):
+ _rounded = b
+ queue_redraw()
+
+
+func is_rounded() -> bool:
+ return _rounded
+
+
+func set_prefix(p_prefix: String):
+ _prefix = p_prefix
+ queue_redraw()
+
+
+func get_prefix() -> String:
+ return _prefix
+
+
+func set_suffix(p_suffix: String):
+ _suffix = p_suffix
+ queue_redraw()
+
+
+func get_suffix() -> String:
+ return _suffix
+
+
+func set_allow_greater(allow: bool):
+ _allow_greater = allow
+
+
+func is_allowing_greater() -> bool:
+ return _allow_greater
+
+
+func _set_from_pixel(px: float):
+ var r := (px - FG_MARGIN) / (size.x - FG_MARGIN * 2.0)
+ var v := _ratio_to_value(r)
+ set_value(v, true, true)
+
+
+func get_ratio() -> float:
+ return _value_to_ratio(get_value())
+
+
+func _ratio_to_value(r: float) -> float:
+ return r * (_max_value - _min_value) + _min_value
+
+
+func _value_to_ratio(v: float) -> float:
+ if absf(_max_value - _min_value) < 0.001:
+ return 0.0
+ return (v - _min_value) / (_max_value - _min_value)
+
+
+func _on_LineEdit_gui_input(event: InputEvent):
+ if event is InputEventKey:
+ if event.pressed:
+ if event.keycode == KEY_ESCAPE:
+ _ignore_line_edit = true
+ _hide_line_edit()
+ grab_focus()
+ _ignore_line_edit = false
+
+
+func _on_LineEdit_focus_exited():
+ if _ignore_line_edit:
+ return
+ _enter_text()
+
+
+func _on_LineEdit_text_submitted(text: String):
+ _enter_text()
+
+
+func _enter_text():
+ var s = _line_edit.text.strip_edges()
+ if s.is_valid_float():
+ var v := s.to_float()
+ if not _allow_greater:
+ v = minf(v, _max_value)
+ set_value(v, true, false)
+ _hide_line_edit()
+
+
+func _hide_line_edit():
+ _line_edit.hide()
+ _label.show()
+ queue_redraw()
+
+
+func _show_line_edit():
+ _line_edit.show()
+ _line_edit.text = str(get_value())
+ _line_edit.select_all()
+ _line_edit.grab_focus()
+ _label.hide()
+ queue_redraw()
+
+
+func _gui_input(event: InputEvent):
+ if event is InputEventMouseButton:
+ if event.pressed:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _press_pos = event.position
+ _pressing = true
+ else:
+ if event.button_index == MOUSE_BUTTON_LEFT:
+ _pressing = false
+ if _grabbing:
+ _grabbing = false
+ _set_from_pixel(event.position.x)
+ else:
+ _show_line_edit()
+
+ elif event is InputEventMouseMotion:
+ if _pressing and _press_pos.distance_to(event.position) > 2.0:
+ _grabbing = true
+ if _grabbing:
+ _set_from_pixel(event.position.x)
+
+
+func _draw():
+ if _line_edit.visible:
+ return
+
+ #var grabber_width := 3
+ var background_v_margin := 0
+ var foreground_margin := FG_MARGIN
+ #var grabber_color := Color(0.8, 0.8, 0.8)
+ var interval_color := Color(0.4,0.4,0.4)
+ var background_color := Color(0.1, 0.1, 0.1)
+
+ var control_rect := Rect2(Vector2(), size)
+
+ var bg_rect := Rect2(
+ control_rect.position.x,
+ control_rect.position.y + background_v_margin,
+ control_rect.size.x,
+ control_rect.size.y - 2 * background_v_margin)
+ draw_rect(bg_rect, background_color)
+
+ var fg_rect := control_rect.grow(-foreground_margin)
+ # Clamping the ratio because the value can be allowed to exceed the slider's boundaries
+ var ratio := clampf(get_ratio(), 0.0, 1.0)
+ fg_rect.size.x *= ratio
+ draw_rect(fg_rect, interval_color)
+
+ var value_text := str(get_value())
+
+ var dot_pos := value_text.find(".")
+ if dot_pos != -1:
+ var decimal_count := len(value_text) - dot_pos
+ if decimal_count > MAX_DECIMALS_VISUAL:
+ value_text = value_text.substr(0, dot_pos + MAX_DECIMALS_VISUAL + 1)
+
+ if _centered:
+ var text := value_text
+ if _prefix != "":
+ text = str(_prefix, " ", text)
+ if _suffix != "":
+ text = str(text, " ", _suffix)
+ _label.text = text
+
+ else:
+ _label2.text = _prefix
+ var text := value_text
+ if _suffix != "":
+ text = str(text, " ", _suffix)
+ _label.text = text
diff --git a/game/addons/zylann.hterrain/tools/util/spin_slider.tscn b/game/addons/zylann.hterrain/tools/util/spin_slider.tscn
new file mode 100644
index 0000000..70a0da8
--- /dev/null
+++ b/game/addons/zylann.hterrain/tools/util/spin_slider.tscn
@@ -0,0 +1,13 @@
+[gd_scene load_steps=2 format=2]
+
+[ext_resource path="res://addons/zylann.hterrain/tools/util/spin_slider.gd" type="Script" id=1]
+
+[node name="SpinSlider" type="Control"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+rect_min_size = Vector2( 32, 28 )
+mouse_default_cursor_shape = 10
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
diff --git a/game/addons/zylann.hterrain/util/direct_mesh_instance.gd b/game/addons/zylann.hterrain/util/direct_mesh_instance.gd
new file mode 100644
index 0000000..17c89c9
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/direct_mesh_instance.gd
@@ -0,0 +1,65 @@
+@tool
+
+# Implementation of MeshInstance which doesn't use the scene tree
+
+var _mesh_instance := RID()
+# Need to keep a reference so that the mesh RID doesn't get freed
+var _mesh : Mesh
+
+
+func _init():
+ var rs = RenderingServer
+ _mesh_instance = rs.instance_create()
+ rs.instance_set_visible(_mesh_instance, true)
+
+
+func _notification(p_what: int):
+ if p_what == NOTIFICATION_PREDELETE:
+ if _mesh_instance != RID():
+ RenderingServer.free_rid(_mesh_instance)
+ _mesh_instance = RID()
+
+
+func enter_world(world: World3D):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_scenario(_mesh_instance, world.get_scenario())
+
+
+func exit_world():
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_scenario(_mesh_instance, RID())
+
+
+func set_world(world: World3D):
+ if world != null:
+ enter_world(world)
+ else:
+ exit_world()
+
+
+func set_transform(world_transform: Transform3D):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_transform(_mesh_instance, world_transform)
+
+
+func set_mesh(mesh: Mesh):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_base(_mesh_instance, mesh.get_rid() if mesh != null else RID())
+ _mesh = mesh
+
+
+func set_material(material: Material):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_geometry_set_material_override( \
+ _mesh_instance, material.get_rid() if material != null else RID())
+
+
+func set_visible(visible: bool):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_visible(_mesh_instance, visible)
+
+
+func set_aabb(aabb: AABB):
+ assert(_mesh_instance != RID())
+ RenderingServer.instance_set_custom_aabb(_mesh_instance, aabb)
+
diff --git a/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd b/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd
new file mode 100644
index 0000000..dbb899b
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/direct_multimesh_instance.gd
@@ -0,0 +1,48 @@
+@tool
+
+# Implementation of MultiMeshInstance which doesn't use the scene tree
+
+var _multimesh_instance := RID()
+
+
+func _init():
+ _multimesh_instance = RenderingServer.instance_create()
+
+
+func _notification(what: int):
+ if what == NOTIFICATION_PREDELETE:
+ RenderingServer.free_rid(_multimesh_instance)
+
+
+func set_world(world: World3D):
+ RenderingServer.instance_set_scenario(
+ _multimesh_instance, world.get_scenario() if world != null else RID())
+
+
+func set_visible(visible: bool):
+ RenderingServer.instance_set_visible(_multimesh_instance, visible)
+
+
+func set_transform(trans: Transform3D):
+ RenderingServer.instance_set_transform(_multimesh_instance, trans)
+
+
+func set_multimesh(mm: MultiMesh):
+ RenderingServer.instance_set_base(_multimesh_instance, mm.get_rid() if mm != null else RID())
+
+
+func set_material_override(material: Material):
+ RenderingServer.instance_geometry_set_material_override( \
+ _multimesh_instance, material.get_rid() if material != null else RID())
+
+
+func set_aabb(aabb: AABB):
+ RenderingServer.instance_set_custom_aabb(_multimesh_instance, aabb)
+
+
+func set_layer_mask(mask: int):
+ RenderingServer.instance_set_layer_mask(_multimesh_instance, mask)
+
+
+func set_cast_shadow(cast_shadow: int):
+ RenderingServer.instance_geometry_set_cast_shadows_setting(_multimesh_instance, cast_shadow)
diff --git a/game/addons/zylann.hterrain/util/errors.gd b/game/addons/zylann.hterrain/util/errors.gd
new file mode 100644
index 0000000..c6a2c63
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/errors.gd
@@ -0,0 +1,58 @@
+@tool
+
+# Taken from https://docs.godotengine.org/en/3.0/classes/class_@globalscope.html#enum-globalscope-error
+const _names = {
+ OK: "ok",
+ FAILED: "Generic error.",
+ ERR_UNAVAILABLE: "Unavailable error",
+ ERR_UNCONFIGURED: "Unconfigured error",
+ ERR_UNAUTHORIZED: "Unauthorized error",
+ ERR_PARAMETER_RANGE_ERROR: "Parameter range error",
+ ERR_OUT_OF_MEMORY: "Out of memory (OOM) error",
+ ERR_FILE_NOT_FOUND: "File Not found error",
+ ERR_FILE_BAD_DRIVE: "File Bad drive error",
+ ERR_FILE_BAD_PATH: "File Bad path error",
+ ERR_FILE_NO_PERMISSION: "File No permission error",
+ ERR_FILE_ALREADY_IN_USE: "File Already in use error",
+ ERR_FILE_CANT_OPEN: "File Can't open error",
+ ERR_FILE_CANT_WRITE: "File Can't write error",
+ ERR_FILE_CANT_READ: "File Can't read error",
+ ERR_FILE_UNRECOGNIZED: "File Unrecognized error",
+ ERR_FILE_CORRUPT: "File Corrupt error",
+ ERR_FILE_MISSING_DEPENDENCIES: "File Missing dependencies error",
+ ERR_FILE_EOF: "File End of file (EOF) error",
+ ERR_CANT_OPEN: "Can't open error",
+ ERR_CANT_CREATE: "Can't create error",
+ ERR_QUERY_FAILED: "Query failed error",
+ ERR_ALREADY_IN_USE: "Already in use error",
+ ERR_LOCKED: "Locked error",
+ ERR_TIMEOUT: "Timeout error",
+ ERR_CANT_CONNECT: "Can't connect",
+ ERR_CANT_RESOLVE: "Can't resolve",
+ ERR_CONNECTION_ERROR: "Connection error",
+ ERR_CANT_ACQUIRE_RESOURCE: "Can't acquire resource error",
+ ERR_CANT_FORK: "Can't fork",
+ ERR_INVALID_DATA: "Invalid data error",
+ ERR_INVALID_PARAMETER: "Invalid parameter error",
+ ERR_ALREADY_EXISTS: "Already exists error",
+ ERR_DOES_NOT_EXIST: "Does not exist error",
+ ERR_DATABASE_CANT_READ: "Database Read error",
+ ERR_DATABASE_CANT_WRITE: "Database Write error",
+ ERR_COMPILATION_FAILED: "Compilation failed error",
+ ERR_METHOD_NOT_FOUND: "Method not found error",
+ ERR_LINK_FAILED: "Linking failed error",
+ ERR_SCRIPT_FAILED: "Script failed error",
+ ERR_CYCLIC_LINK: "Cycling link (import cycle) error",
+ ERR_INVALID_DECLARATION: "Invalid declaration",
+ ERR_DUPLICATE_SYMBOL: "Duplicate symbol",
+ ERR_PARSE_ERROR: "Parse error",
+ ERR_BUSY: "Busy error",
+ ERR_SKIP: "Skip error",
+ ERR_HELP: "Help error",
+ ERR_BUG: "Bug error",
+ ERR_PRINTER_ON_FIRE: "The printer is on fire"
+}
+
+static func get_message(err_code: int):
+ return str("[", err_code, "]: ", _names[err_code])
+
diff --git a/game/addons/zylann.hterrain/util/grid.gd b/game/addons/zylann.hterrain/util/grid.gd
new file mode 100644
index 0000000..b28e83b
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/grid.gd
@@ -0,0 +1,203 @@
+
+# Note: `tool` is optional but without it there are no error reporting in the editor
+@tool
+
+# TODO Remove grid_ prefixes, context is already given by the script itself
+
+
+# Performs a positive integer division rounded to upper (4/2 = 2, 5/3 = 2)
+static func up_div(a: int, b: int):
+ if a % b != 0:
+ return a / b + 1
+ return a / b
+
+
+# Creates a 2D array as an array of arrays.
+# if v is provided, all cells will contain the same value.
+# if v is a funcref, it will be executed to fill the grid cell per cell.
+static func create_grid(w: int, h: int, v=null):
+ var is_create_func = typeof(v) == TYPE_CALLABLE
+ var grid := []
+ grid.resize(h)
+ for y in range(grid.size()):
+ var row := []
+ row.resize(w)
+ if is_create_func:
+ for x in range(row.size()):
+ row[x] = v.call(x, y)
+ else:
+ for x in range(row.size()):
+ row[x] = v
+ grid[y] = row
+ return grid
+
+
+# Creates a 2D array that is a copy of another 2D array
+static func clone_grid(other_grid):
+ var grid := []
+ grid.resize(other_grid.size())
+ for y in range(0, grid.size()):
+ var row := []
+ var other_row = other_grid[y]
+ row.resize(other_row.size())
+ grid[y] = row
+ for x in range(0, row.size()):
+ row[x] = other_row[x]
+ return grid
+
+
+# Resizes a 2D array and allows to set or call functions for each deleted and created cells.
+# This is especially useful if cells contain objects and you don't want to loose existing data.
+static func resize_grid(grid, new_width, new_height, create_func=null, delete_func=null):
+ # Check parameters
+ assert(new_width >= 0 and new_height >= 0)
+ assert(grid != null)
+ if delete_func != null:
+ assert(typeof(delete_func) == TYPE_CALLABLE)
+ # `create_func` can also be a default value
+ var is_create_func = typeof(create_func) == TYPE_CALLABLE
+
+ # Get old size (supposed to be rectangular!)
+ var old_height = grid.size()
+ var old_width = 0
+ if grid.size() != 0:
+ old_width = grid[0].size()
+
+ # Delete old rows
+ if new_height < old_height:
+ if delete_func != null:
+ for y in range(new_height, grid.size()):
+ var row = grid[y]
+ for x in len(row):
+ var elem = row[x]
+ delete_func.call(elem)
+ grid.resize(new_height)
+
+ # Delete old columns
+ if new_width < old_width:
+ for y in len(grid):
+ var row = grid[y]
+ if delete_func != null:
+ for x in range(new_width, row.size()):
+ var elem = row[x]
+ delete_func.call(elem)
+ row.resize(new_width)
+
+ # Create new columns
+ if new_width > old_width:
+ for y in len(grid):
+ var row = grid[y]
+ row.resize(new_width)
+ if is_create_func:
+ for x in range(old_width, new_width):
+ row[x] = create_func.call(x,y)
+ else:
+ for x in range(old_width, new_width):
+ row[x] = create_func
+
+ # Create new rows
+ if new_height > old_height:
+ grid.resize(new_height)
+ for y in range(old_height, new_height):
+ var row = []
+ row.resize(new_width)
+ grid[y] = row
+ if is_create_func:
+ for x in new_width:
+ row[x] = create_func.call(x,y)
+ else:
+ for x in new_width:
+ row[x] = create_func
+
+ # Debug test check
+ assert(grid.size() == new_height)
+ for y in len(grid):
+ assert(len(grid[y]) == new_width)
+
+
+# Retrieves the minimum and maximum values from a grid
+static func grid_min_max(grid):
+ if grid.size() == 0 or grid[0].size() == 0:
+ return [0,0]
+ var vmin = grid[0][0]
+ var vmax = vmin
+ for y in len(grid):
+ var row = grid[y]
+ for x in len(row):
+ var v = row[x]
+ if v > vmax:
+ vmax = v
+ elif v < vmin:
+ vmin = v
+ return [vmin, vmax]
+
+
+# Copies a sub-region of a grid as a new grid. No boundary check!
+static func grid_extract_area(src_grid, x0, y0, w, h):
+ var dst = create_grid(w, h)
+ for y in h:
+ var dst_row = dst[y]
+ var src_row = src_grid[y0+y]
+ for x in w:
+ dst_row[x] = src_row[x0+x]
+ return dst
+
+
+# Extracts data and crops the result if the requested rect crosses the bounds
+static func grid_extract_area_safe_crop(src_grid, x0, y0, w, h):
+ # Return empty is completely out of bounds
+ var gw = src_grid.size()
+ if gw == 0:
+ return []
+ var gh = src_grid[0].size()
+ if x0 >= gw or y0 >= gh:
+ return []
+
+ # Crop min pos
+ if x0 < 0:
+ w += x0
+ x0 = 0
+ if y0 < 0:
+ h += y0
+ y0 = 0
+
+ # Crop max pos
+ if x0 + w >= gw:
+ w = gw-x0
+ if y0 + h >= gh:
+ h = gh-y0
+
+ return grid_extract_area(src_grid, x0, y0, w, h)
+
+
+# Sets values from a grid inside another grid. No boundary check!
+static func grid_paste(src_grid, dst_grid, x0, y0):
+ for y in range(0, src_grid.size()):
+ var src_row = src_grid[y]
+ var dst_row = dst_grid[y0+y]
+ for x in range(0, src_row.size()):
+ dst_row[x0+x] = src_row[x]
+
+
+# Tests if two grids are the same size and contain the same values
+static func grid_equals(a, b):
+ if a.size() != b.size():
+ return false
+ for y in a.size():
+ var a_row = a[y]
+ var b_row = b[y]
+ if a_row.size() != b_row.size():
+ return false
+ for x in b_row.size():
+ if a_row[x] != b_row[x]:
+ return false
+ return true
+
+
+static func grid_get_or_default(grid, x, y, defval=null):
+ if y >= 0 and y < len(grid):
+ var row = grid[y]
+ if x >= 0 and x < len(row):
+ return row[x]
+ return defval
+
diff --git a/game/addons/zylann.hterrain/util/image_file_cache.gd b/game/addons/zylann.hterrain/util/image_file_cache.gd
new file mode 100644
index 0000000..e39e4cd
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/image_file_cache.gd
@@ -0,0 +1,291 @@
+@tool
+
+# Used to store temporary images on disk.
+# This is useful for undo/redo as image edition can quickly fill up memory.
+
+# Image data is stored in archive files together,
+# because when dealing with many images it speeds up filesystem I/O on Windows.
+# If the file exceeds a predefined size, a new one is created.
+# Writing to disk is performed from a thread, to leave the main thread responsive.
+# However if you want to obtain an image back while it didn't save yet, the main thread will block.
+# When the application or plugin is closed, the files get cleared.
+
+const HT_Logger = preload("./logger.gd")
+const HT_Errors = preload("./errors.gd")
+
+const CACHE_FILE_SIZE_THRESHOLD = 1048576
+# For debugging
+const USE_THREAD = true
+
+var _cache_dir := ""
+var _next_id := 0
+var _session_id := ""
+var _cache_image_info := {}
+var _logger = HT_Logger.get_for(self)
+var _current_cache_file_index := 0
+var _cache_file_offset := 0
+
+var _saving_thread := Thread.new()
+var _save_queue := []
+var _save_queue_mutex := Mutex.new()
+var _save_semaphore := Semaphore.new()
+var _save_thread_running := false
+
+
+func _init(cache_dir: String):
+ assert(cache_dir != "")
+ _cache_dir = cache_dir
+ var rng := RandomNumberGenerator.new()
+ rng.randomize()
+ for i in 16:
+ _session_id += str(rng.randi() % 10)
+ _logger.debug(str("Image cache session ID: ", _session_id))
+ if not DirAccess.dir_exists_absolute(_cache_dir):
+ var err := DirAccess.make_dir_absolute(_cache_dir)
+ if err != OK:
+ _logger.error("Could not create directory {0}: {1}" \
+ .format([_cache_dir, HT_Errors.get_message(err)]))
+ _save_thread_running = true
+ if USE_THREAD:
+ _saving_thread.start(_save_thread_func)
+
+
+# TODO Cannot cleanup the cache in destructor!
+# Godot doesn't allow me to call clear()...
+# https://github.com/godotengine/godot/issues/31166
+func _notification(what: int):
+ if what == NOTIFICATION_PREDELETE:
+ #clear()
+ _save_thread_running = false
+ _save_semaphore.post()
+ if USE_THREAD:
+ _saving_thread.wait_to_finish()
+
+
+func _create_new_cache_file(fpath: String):
+ var f := FileAccess.open(fpath, FileAccess.WRITE)
+ if f == null:
+ var err = FileAccess.get_open_error()
+ _logger.error("Failed to create new cache file {0}: {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return
+
+
+func _get_current_cache_file_name() -> String:
+ return _cache_dir.path_join(str(_session_id, "_", _current_cache_file_index, ".cache"))
+
+
+func save_image(im: Image) -> int:
+ assert(im != null)
+ if im.has_mipmaps():
+ # TODO Add support for this? Didn't need it so far
+ _logger.error("Caching an image with mipmaps, this isn't supported")
+
+ var fpath := _get_current_cache_file_name()
+ if _next_id == 0:
+ # First file
+ _create_new_cache_file(fpath)
+
+ var id := _next_id
+ _next_id += 1
+
+ var item := {
+ # Duplicate the image so we are sure nothing funny will happen to it
+ # while the thread saves it
+ "image": im.duplicate(),
+ "path": fpath,
+ "data_offset": _cache_file_offset,
+ "saved": false
+ }
+
+ _cache_file_offset += _get_image_data_size(im)
+ if _cache_file_offset >= CACHE_FILE_SIZE_THRESHOLD:
+ _cache_file_offset = 0
+ _current_cache_file_index += 1
+ _create_new_cache_file(_get_current_cache_file_name())
+
+ _cache_image_info[id] = item
+
+ _save_queue_mutex.lock()
+ _save_queue.append(item)
+ _save_queue_mutex.unlock()
+
+ _save_semaphore.post()
+
+ if not USE_THREAD:
+ var before = Time.get_ticks_msec()
+ while len(_save_queue) > 0:
+ _save_thread_func()
+ if Time.get_ticks_msec() - before > 10_000:
+ _logger.error("Taking to long to empty save queue in non-threaded mode!")
+
+ return id
+
+
+static func _get_image_data_size(im: Image) -> int:
+ return 1 + 4 + 4 + 4 + len(im.get_data())
+
+
+static func _write_image(f: FileAccess, im: Image):
+ f.store_8(im.get_format())
+ f.store_32(im.get_width())
+ f.store_32(im.get_height())
+ var data : PackedByteArray = im.get_data()
+ f.store_32(len(data))
+ f.store_buffer(data)
+
+
+static func _read_image(f: FileAccess) -> Image:
+ var format := f.get_8()
+ var width := f.get_32()
+ var height := f.get_32()
+ var data_size := f.get_32()
+ var data := f.get_buffer(data_size)
+ var im := Image.create_from_data(width, height, false, format, data)
+ return im
+
+
+func load_image(id: int) -> Image:
+ var info := _cache_image_info[id] as Dictionary
+
+ var timeout := 5.0
+ var time_before := Time.get_ticks_msec()
+ # We could just grab `image`, because the thread only reads it.
+ # However it's still not safe to do that if we write or even lock it,
+ # so we have to assume it still has ownership of it.
+ while not info.saved:
+ OS.delay_msec(8.0)
+ _logger.debug("Waiting for cached image {0}...".format([id]))
+ if Time.get_ticks_msec() - time_before > timeout:
+ _logger.error("Could not get image {0} from cache. Something went wrong.".format([id]))
+ return null
+
+ var fpath := info.path as String
+
+ var f := FileAccess.open(fpath, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ _logger.error("Could not load cached image from {0}: {1}" \
+ .format([fpath, HT_Errors.get_message(err)]))
+ return null
+
+ f.seek(info.data_offset)
+ var im = _read_image(f)
+ f = null # close file
+
+ assert(im != null)
+ return im
+
+
+func clear():
+ _logger.debug("Clearing image cache")
+
+ var dir := DirAccess.open(_cache_dir)
+ if dir == null:
+ #var err = DirAccess.get_open_error()
+ _logger.error("Could not open image file cache directory '{0}'" \
+ .format([_cache_dir]))
+ return
+
+ dir.include_hidden = false
+ dir.include_navigational = false
+
+ var err := dir.list_dir_begin()
+ if err != OK:
+ _logger.error("Could not start list_dir_begin in '{0}'".format([_cache_dir]))
+ return
+
+ # Delete all cache files
+ while true:
+ var fpath := dir.get_next()
+ if fpath == "":
+ break
+ if fpath.ends_with(".cache"):
+ _logger.debug(str("Deleting ", fpath))
+ err = dir.remove(fpath)
+ if err != OK:
+ _logger.error("Failed to delete cache file '{0}': {1}" \
+ .format([_cache_dir.path_join(fpath), HT_Errors.get_message(err)]))
+
+ _cache_image_info.clear()
+
+
+func _save_thread_func():
+ # Threads keep a reference to the object of the function they run.
+ # So if the object is a Reference, and that reference owns the thread... we get a cycle.
+ # We can break the cycle by removing 1 to the count inside the thread.
+ # The thread's reference will never die unexpectedly because we stop and destroy the thread
+ # in the destructor of the reference.
+ # If that workaround explodes one day, another way could be to use an intermediary instance
+ # extending Object, and run a function on that instead.
+ #
+ # I added this in Godot 3, and it seems to still be relevant in Godot 4 because if I don't
+ # do it, objects are leaking.
+ #
+ # BUT it seems to end up triggering a crash in debug Godot builds due to unrefing RefCounted
+ # with refcount == 0, so I guess it's wrong now?
+ # So basically, either I do it and I risk a crash,
+ # or I don't do it and then it causes a leak...
+ # TODO Make this shit use `Object`
+ #
+ # if USE_THREAD:
+ # unreference()
+
+ while _save_thread_running:
+ _save_queue_mutex.lock()
+ var to_save := _save_queue.duplicate(false)
+ _save_queue.clear()
+ _save_queue_mutex.unlock()
+
+ if len(to_save) == 0:
+ if USE_THREAD:
+ _save_semaphore.wait()
+ continue
+
+ var f : FileAccess
+ var path := ""
+
+ for item in to_save:
+ # Keep re-using the same file if we did not change path.
+ # It makes I/Os faster.
+ if item.path != path:
+ # Close previous file
+ f = null
+
+ path = item.path
+
+ f = FileAccess.open(path, FileAccess.READ_WRITE)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ call_deferred("_on_error", "Could not open file {0}: {1}" \
+ .format([path, HT_Errors.get_message(err)]))
+ path = ""
+ continue
+
+ f.seek(item.data_offset)
+ _write_image(f, item.image)
+ # Notify main thread.
+ # The thread does not modify data, only reads it.
+ call_deferred("_on_image_saved", item)
+
+ # Workaround some weird behavior in Godot 4:
+ # when the next loop runs, `f` IS NOT CLEANED UP. A reference is still held before `var f`
+ # is reached, which means the file is still locked while the thread is waiting on the
+ # semaphore... so I have to explicitely "close" the file here.
+ f = null
+
+ if not USE_THREAD:
+ break
+
+
+func _on_error(msg: String):
+ _logger.error(msg)
+
+
+func _on_image_saved(item: Dictionary):
+ _logger.debug(str("Saved ", item.path))
+ item.saved = true
+ # Should remove image from memory (for usually being last reference)
+ item.image = null
+
+
diff --git a/game/addons/zylann.hterrain/util/logger.gd b/game/addons/zylann.hterrain/util/logger.gd
new file mode 100644
index 0000000..fcc78a3
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/logger.gd
@@ -0,0 +1,34 @@
+@tool
+
+class HT_LoggerBase:
+ var _context := ""
+
+ func _init(p_context):
+ _context = p_context
+
+ func debug(msg: String):
+ pass
+
+ func warn(msg: String):
+ push_warning("{0}: {1}".format([_context, msg]))
+
+ func error(msg: String):
+ push_error("{0}: {1}".format([_context, msg]))
+
+
+class HT_LoggerVerbose extends HT_LoggerBase:
+ func _init(p_context: String):
+ super(p_context)
+
+ func debug(msg: String):
+ print(_context, ": ", msg)
+
+
+static func get_for(owner: Object) -> HT_LoggerBase:
+ # Note: don't store the owner. If it's a Reference, it could create a cycle
+ var script : Script = owner.get_script()
+ var context := script.resource_path.get_file()
+ if OS.is_stdout_verbose():
+ return HT_LoggerVerbose.new(context)
+ return HT_LoggerBase.new(context)
+
diff --git a/game/addons/zylann.hterrain/util/util.gd b/game/addons/zylann.hterrain/util/util.gd
new file mode 100644
index 0000000..e2cd32c
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/util.gd
@@ -0,0 +1,549 @@
+@tool
+
+const HT_Errors = preload("./errors.gd")
+
+
+# Godot has this internally but doesn't expose it
+static func next_power_of_two(x: int) -> int:
+ x -= 1
+ x |= x >> 1
+ x |= x >> 2
+ x |= x >> 4
+ x |= x >> 8
+ x |= x >> 16
+ x += 1
+ return x
+
+
+# CubeMesh doesn't have a wireframe option
+static func create_wirecube_mesh(color = Color(1,1,1)) -> Mesh:
+ var positions := PackedVector3Array([
+ Vector3(0, 0, 0),
+ Vector3(1, 0, 0),
+ Vector3(1, 0, 1),
+ Vector3(0, 0, 1),
+ Vector3(0, 1, 0),
+ Vector3(1, 1, 0),
+ Vector3(1, 1, 1),
+ Vector3(0, 1, 1),
+ ])
+ var colors := PackedColorArray([
+ color, color, color, color,
+ color, color, color, color,
+ ])
+ var indices := PackedInt32Array([
+ 0, 1,
+ 1, 2,
+ 2, 3,
+ 3, 0,
+
+ 4, 5,
+ 5, 6,
+ 6, 7,
+ 7, 4,
+
+ 0, 4,
+ 1, 5,
+ 2, 6,
+ 3, 7
+ ])
+ var arrays := []
+ arrays.resize(Mesh.ARRAY_MAX)
+ arrays[Mesh.ARRAY_VERTEX] = positions
+ arrays[Mesh.ARRAY_COLOR] = colors
+ arrays[Mesh.ARRAY_INDEX] = indices
+ var mesh := ArrayMesh.new()
+ mesh.add_surface_from_arrays(Mesh.PRIMITIVE_LINES, arrays)
+ return mesh
+
+
+static func integer_square_root(x: int) -> int:
+ assert(typeof(x) == TYPE_INT)
+ var r := int(roundf(sqrt(x)))
+ if r * r == x:
+ return r
+ # Does not exist
+ return -1
+
+
+# Formats integer using a separator between each 3-digit group
+static func format_integer(n: int, sep := ",") -> String:
+ assert(typeof(n) == TYPE_INT)
+
+ var negative := false
+ if n < 0:
+ negative = true
+ n = -n
+
+ var s = ""
+ while n >= 1000:
+ s = str(sep, str(n % 1000).pad_zeros(3), s)
+ n /= 1000
+
+ if negative:
+ return str("-", str(n), s)
+ else:
+ return str(str(n), s)
+
+
+# Goes up all parents until a node of the given class is found
+static func get_node_in_parents(node: Node, klass) -> Node:
+ while node != null:
+ node = node.get_parent()
+ if node != null and is_instance_of(node, klass):
+ return node
+ return null
+
+
+# Goes down all children until a node of the given class is found
+static func find_first_node(node: Node, klass) -> Node:
+ if is_instance_of(node, klass):
+ return node
+ for i in node.get_child_count():
+ var child := node.get_child(i)
+ var found_node := find_first_node(child, klass)
+ if found_node != null:
+ return found_node
+ return null
+
+
+static func is_in_edited_scene(node: Node) -> bool:
+ if not node.is_inside_tree():
+ return false
+ var edited_scene := node.get_tree().edited_scene_root
+ if node == edited_scene:
+ return true
+ return edited_scene != null and edited_scene.is_ancestor_of(node)
+
+
+# Get an extended or cropped version of an image,
+# with optional anchoring to decide in which direction to extend or crop.
+# New pixels are filled with the provided fill color.
+static func get_cropped_image(src: Image, width: int, height: int,
+ fill_color=null, anchor=Vector2(-1, -1)) -> Image:
+
+ width = int(width)
+ height = int(height)
+ if width == src.get_width() and height == src.get_height():
+ return src
+ var im := Image.create(width, height, false, src.get_format())
+ if fill_color != null:
+ im.fill(fill_color)
+ var p = get_cropped_image_params(
+ src.get_width(), src.get_height(), width, height, anchor)
+ im.blit_rect(src, p.src_rect, p.dst_pos)
+ return im
+
+
+static func get_cropped_image_params(src_w: int, src_h: int, dst_w: int, dst_h: int,
+ anchor: Vector2) -> Dictionary:
+
+ var rel_anchor := (anchor + Vector2(1, 1)) / 2.0
+
+ var dst_x := (dst_w - src_w) * rel_anchor.x
+ var dst_y := (dst_h - src_h) * rel_anchor.y
+
+ var src_x := 0
+ var src_y := 0
+
+ if dst_x < 0:
+ src_x -= dst_x
+ src_w -= dst_x
+ dst_x = 0
+
+ if dst_y < 0:
+ src_y -= dst_y
+ src_h -= dst_y
+ dst_y = 0
+
+ if dst_x + src_w >= dst_w:
+ src_w = dst_w - dst_x
+
+ if dst_y + src_h >= dst_h:
+ src_h = dst_h - dst_y
+
+ return {
+ "src_rect": Rect2i(src_x, src_y, src_w, src_h),
+ "dst_pos": Vector2i(dst_x, dst_y)
+ }
+
+# TODO Workaround for https://github.com/godotengine/godot/issues/24488
+# TODO Simplify in Godot 3.1 if that's still not fixed,
+# using https://github.com/godotengine/godot/pull/21806
+# And actually that function does not even work.
+#static func get_shader_param_or_default(mat: Material, name: String):
+# assert(mat.shader != null)
+# var v = mat.get_shader_param(name)
+# if v != null:
+# return v
+# var params = VisualServer.shader_get_param_list(mat.shader)
+# for p in params:
+# if p.name == name:
+# match p.type:
+# TYPE_OBJECT:
+# return null
+# # I should normally check default values,
+# # however they are not accessible
+# TYPE_BOOL:
+# return false
+# TYPE_REAL:
+# return 0.0
+# TYPE_VECTOR2:
+# return Vector2()
+# TYPE_VECTOR3:
+# return Vector3()
+# TYPE_COLOR:
+# return Color()
+# return null
+
+
+# Generic way to apply editor scale to a plugin UI scene.
+# It is slower than doing it manually on specific controls.
+# Takes a node as root because since Godot 4 Window dialogs are no longer Controls.
+static func apply_dpi_scale(root: Node, dpi_scale: float):
+ if dpi_scale == 1.0:
+ return
+ var to_process := [root]
+ while len(to_process) > 0:
+ var node : Node = to_process[-1]
+ to_process.pop_back()
+ if node is Window:
+ node.size = Vector2(node.size) * dpi_scale
+ elif node is Viewport or node is SubViewport:
+ continue
+ elif node is Control:
+ if node.custom_minimum_size != Vector2(0, 0):
+ node.custom_minimum_size = node.custom_minimum_size * dpi_scale
+ var parent = node.get_parent()
+ if parent != null:
+ if not (parent is Container):
+ node.offset_bottom *= dpi_scale
+ node.offset_left *= dpi_scale
+ node.offset_top *= dpi_scale
+ node.offset_right *= dpi_scale
+ for i in node.get_child_count():
+ to_process.append(node.get_child(i))
+
+
+# TODO AABB has `intersects_segment` but doesn't provide the hit point
+# So we have to rely on a less efficient method.
+# Returns a list of intersections between an AABB and a segment, sorted
+# by distance to the beginning of the segment.
+static func get_aabb_intersection_with_segment(aabb: AABB,
+ segment_begin: Vector3, segment_end: Vector3) -> Array:
+
+ var hits := []
+
+ if not aabb.intersects_segment(segment_begin, segment_end):
+ return hits
+
+ var hit
+
+ var x_rect := Rect2(aabb.position.y, aabb.position.z, aabb.size.y, aabb.size.z)
+
+ hit = Plane(Vector3(1, 0, 0), aabb.position.x) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and x_rect.has_point(Vector2(hit.y, hit.z)):
+ hits.append(hit)
+
+ hit = Plane(Vector3(1, 0, 0), aabb.end.x) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and x_rect.has_point(Vector2(hit.y, hit.z)):
+ hits.append(hit)
+
+ var y_rect := Rect2(aabb.position.x, aabb.position.z, aabb.size.x, aabb.size.z)
+
+ hit = Plane(Vector3(0, 1, 0), aabb.position.y) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and y_rect.has_point(Vector2(hit.x, hit.z)):
+ hits.append(hit)
+
+ hit = Plane(Vector3(0, 1, 0), aabb.end.y) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and y_rect.has_point(Vector2(hit.x, hit.z)):
+ hits.append(hit)
+
+ var z_rect := Rect2(aabb.position.x, aabb.position.y, aabb.size.x, aabb.size.y)
+
+ hit = Plane(Vector3(0, 0, 1), aabb.position.z) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and z_rect.has_point(Vector2(hit.x, hit.y)):
+ hits.append(hit)
+
+ hit = Plane(Vector3(0, 0, 1), aabb.end.z) \
+ .intersects_segment(segment_begin, segment_end)
+ if hit != null and z_rect.has_point(Vector2(hit.x, hit.y)):
+ hits.append(hit)
+
+ if len(hits) == 2:
+ # The segment has two hit points. Sort them by distance
+ var d0 = hits[0].distance_squared_to(segment_begin)
+ var d1 = hits[1].distance_squared_to(segment_begin)
+ if d0 > d1:
+ var temp = hits[0]
+ hits[0] = hits[1]
+ hits[1] = temp
+ else:
+ assert(len(hits) < 2)
+
+ return hits
+
+
+class HT_GridRaytraceResult2D:
+ var hit_cell_pos: Vector2
+ var prev_cell_pos: Vector2
+
+
+# Iterates through a virtual 2D grid of unit-sized square cells,
+# and executes an action on each cell intersecting the given segment,
+# ordered from begin to end.
+# One of my most re-used pieces of code :)
+#
+# Initially inspired by http://www.cse.yorku.ca/~amana/research/grid.pdf
+#
+# Ported from https://github.com/bulletphysics/bullet3/blob/
+# 687780af6b491056700cfb22cab57e61aeec6ab8/src/BulletCollision/CollisionShapes/
+# btHeightfieldTerrainShape.cpp#L418
+#
+static func grid_raytrace_2d(ray_origin: Vector2, ray_direction: Vector2,
+ quad_predicate: Callable, max_distance: float) -> HT_GridRaytraceResult2D:
+
+ if max_distance < 0.0001:
+ # Consider the ray is too small to hit anything
+ return null
+
+ var xi_step := 0
+ if ray_direction.x > 0:
+ xi_step = 1
+ elif ray_direction.x < 0:
+ xi_step = -1
+
+ var yi_step := 0
+ if ray_direction.y > 0:
+ yi_step = 1
+ elif ray_direction.y < 0:
+ yi_step = -1
+
+ var infinite := 9999999.0
+
+ var param_delta_x := infinite
+ if xi_step != 0:
+ param_delta_x = 1.0 / absf(ray_direction.x)
+
+ var param_delta_y := infinite
+ if yi_step != 0:
+ param_delta_y = 1.0 / absf(ray_direction.y)
+
+ # pos = param * dir
+ # At which value of `param` we will cross a x-axis lane?
+ var param_cross_x := infinite
+ # At which value of `param` we will cross a y-axis lane?
+ var param_cross_y := infinite
+
+ # param_cross_x and param_cross_z are initialized as being the first cross
+ # X initialization
+ if xi_step != 0:
+ if xi_step == 1:
+ param_cross_x = (ceilf(ray_origin.x) - ray_origin.x) * param_delta_x
+ else:
+ param_cross_x = (ray_origin.x - floorf(ray_origin.x)) * param_delta_x
+ else:
+ # Will never cross on X
+ param_cross_x = infinite
+
+ # Y initialization
+ if yi_step != 0:
+ if yi_step == 1:
+ param_cross_y = (ceilf(ray_origin.y) - ray_origin.y) * param_delta_y
+ else:
+ param_cross_y = (ray_origin.y - floorf(ray_origin.y)) * param_delta_y
+ else:
+ # Will never cross on Y
+ param_cross_y = infinite
+
+ var x := int(floorf(ray_origin.x))
+ var y := int(floorf(ray_origin.y))
+
+ # Workaround cases where the ray starts at an integer position
+ if param_cross_x == 0.0:
+ param_cross_x += param_delta_x
+ # If going backwards, we should ignore the position we would get by the above flooring,
+ # because the ray is not heading in that direction
+ if xi_step == -1:
+ x -= 1
+
+ if param_cross_y == 0.0:
+ param_cross_y += param_delta_y
+ if yi_step == -1:
+ y -= 1
+
+ var prev_x := x
+ var prev_y := y
+ var param := 0.0
+ var prev_param := 0.0
+
+ while true:
+ prev_x = x
+ prev_y = y
+ prev_param = param
+
+ if param_cross_x < param_cross_y:
+ # X lane
+ x += xi_step
+ # Assign before advancing the param,
+ # to be in sync with the initialization step
+ param = param_cross_x
+ param_cross_x += param_delta_x
+
+ else:
+ # Y lane
+ y += yi_step
+ param = param_cross_y
+ param_cross_y += param_delta_y
+
+ if param > max_distance:
+ param = max_distance
+ # quad coordinates, enter param, exit/end param
+ if quad_predicate.call(prev_x, prev_y, prev_param, param):
+ var res := HT_GridRaytraceResult2D.new()
+ res.hit_cell_pos = Vector2(x, y)
+ res.prev_cell_pos = Vector2(prev_x, prev_y)
+ return res
+ else:
+ break
+
+ elif quad_predicate.call(prev_x, prev_y, prev_param, param):
+ var res := HT_GridRaytraceResult2D.new()
+ res.hit_cell_pos = Vector2(x, y)
+ res.prev_cell_pos = Vector2(prev_x, prev_y)
+ return res
+
+ return null
+
+
+static func get_segment_clipped_by_rect(rect: Rect2,
+ segment_begin: Vector2, segment_end: Vector2) -> Array:
+
+ # /
+ # A-----/---B A-----+---B
+ # | / | => | / |
+ # | / | | / |
+ # C--/------D C--+------D
+ # /
+
+ if rect.has_point(segment_begin) and rect.has_point(segment_end):
+ return [segment_begin, segment_end]
+
+ var a := rect.position
+ var b := Vector2(rect.end.x, rect.position.y)
+ var c := Vector2(rect.position.x, rect.end.y)
+ var d := rect.end
+
+ var ab = Geometry2D.segment_intersects_segment(segment_begin, segment_end, a, b)
+ var cd = Geometry2D.segment_intersects_segment(segment_begin, segment_end, c, d)
+ var ac = Geometry2D.segment_intersects_segment(segment_begin, segment_end, a, c)
+ var bd = Geometry2D.segment_intersects_segment(segment_begin, segment_end, b, d)
+
+ var hits = []
+ if ab != null:
+ hits.append(ab)
+ if cd != null:
+ hits.append(cd)
+ if ac != null:
+ hits.append(ac)
+ if bd != null:
+ hits.append(bd)
+
+ # Now we need to order the hits from begin to end
+ if len(hits) == 1:
+ if rect.has_point(segment_begin):
+ hits = [segment_begin, hits[0]]
+ elif rect.has_point(segment_end):
+ hits = [hits[0], segment_end]
+ else:
+ # TODO This has a tendency to happen with integer coordinates...
+ # How can you get only 1 hit and have no end of the segment
+ # inside of the rectangle? Float precision shit? Assume no hit...
+ return []
+
+ elif len(hits) == 2:
+ var d0 = hits[0].distance_squared_to(segment_begin)
+ var d1 = hits[1].distance_squared_to(segment_begin)
+ if d0 > d1:
+ hits = [hits[1], hits[0]]
+
+ return hits
+
+
+static func get_pixel_clamped(im: Image, x: int, y: int) -> Color:
+ x = clampi(x, 0, im.get_width() - 1)
+ y = clampi(y, 0, im.get_height() - 1)
+ return im.get_pixel(x, y)
+
+
+static func update_configuration_warning(node: Node, recursive: bool):
+ if not Engine.is_editor_hint():
+ return
+ node.update_configuration_warnings()
+ if recursive:
+ for i in node.get_child_count():
+ var child = node.get_child(i)
+ update_configuration_warning(child, true)
+
+
+static func write_import_file(settings: Dictionary, imp_fpath: String, logger) -> bool:
+ # TODO Should use ConfigFile instead
+ var f := FileAccess.open(imp_fpath, FileAccess.WRITE)
+ if f == null:
+ var err = FileAccess.get_open_error()
+ logger.error("Could not open '{0}' for write, error {1}" \
+ .format([imp_fpath, HT_Errors.get_message(err)]))
+ return false
+
+ for section in settings:
+ f.store_line(str("[", section, "]"))
+ f.store_line("")
+ var params = settings[section]
+ for key in params:
+ var v = params[key]
+ var sv
+ match typeof(v):
+ TYPE_STRING:
+ sv = str('"', v.replace('"', '\"'), '"')
+ TYPE_BOOL:
+ sv = "true" if v else "false"
+ _:
+ sv = str(v)
+ f.store_line(str(key, "=", sv))
+ f.store_line("")
+
+ return true
+
+
+static func update_texture_partial(
+ tex: ImageTexture, im: Image, src_rect: Rect2i, dst_pos: Vector2i):
+
+ # ..ooo@@@XXX%%%xx..
+ # .oo@@XXX%x%xxx.. ` .
+ # .o@XX%%xx.. ` .
+ # o@X%.. ..ooooooo
+ # .@X%x. ..o@@^^ ^^@@o
+ # .ooo@@@@@@ooo.. ..o@@^ @X%
+ # o@@^^^ ^^^@@@ooo.oo@@^ %
+ # xzI -*-- ^^^o^^ --*- %
+ # @@@o ooooooo^@@^o^@X^@oooooo .X%x
+ # I@@@@@@@@@XX%%xx ( o@o )X%x@ROMBASED@@@X%x
+ # I@@@@XX%%xx oo@@@@X% @@X%x ^^^@@@@@@@X%x
+ # @X%xx o@@@@@@@X% @@XX%%x ) ^^@X%x
+ # ^ xx o@@@@@@@@Xx ^ @XX%%x xxx
+ # o@@^^^ooo I^^ I^o ooo . x
+ # oo @^ IX I ^X @^ oo
+ # IX U . V IX
+ # V . . V
+ #
+
+ # TODO Optimize: Godot 4 has lost the ability to update textures partially!
+ var fuck = tex.get_image()
+ fuck.blit_rect(im, src_rect, dst_pos)
+ tex.update(fuck)
+
diff --git a/game/addons/zylann.hterrain/util/xyz_format.gd b/game/addons/zylann.hterrain/util/xyz_format.gd
new file mode 100644
index 0000000..86e4a1a
--- /dev/null
+++ b/game/addons/zylann.hterrain/util/xyz_format.gd
@@ -0,0 +1,109 @@
+@tool
+
+# XYZ files are text files containing a list of 3D points.
+# They can be found in GIS software as an export format for heightmaps.
+# In order to turn it into a heightmap we may calculate bounds first
+# to find the origin and then set points in an image.
+
+
+class HT_XYZBounds:
+ # Note: it is important for these to be double-precision floats,
+ # GIS data can have large coordinates
+ var min_x := 0.0
+ var min_y := 0.0
+
+ var max_x := 0.0
+ var max_y := 0.0
+
+ var line_count := 0
+
+ var image_width := 0
+ var image_height := 0
+
+
+# TODO `split_float` returns 32-bit floats, despite internally parsing doubles...
+# Despite that, I still use it here because it doesn't seem to cause issues and is faster.
+# If it becomes an issue, we'll have to switch to `split` and casting to `float`.
+
+static func load_bounds(f: FileAccess) -> HT_XYZBounds:
+ # It is faster to get line and split floats than using CSV functions
+ var line := f.get_line()
+ var floats := line.split_floats(" ")
+
+ # We only care about X and Y, it makes less operations to do in the loop.
+ # Z is the height and will remain as-is at the end.
+ var min_pos_x := floats[0]
+ var min_pos_y := floats[1]
+
+ var max_pos_x := min_pos_x
+ var max_pos_y := min_pos_y
+
+ # Start at 1 because we just read the first line
+ var line_count := 1
+
+ # We know the file is a series of float triplets
+ while not f.eof_reached():
+ line = f.get_line()
+
+ # The last line can be empty
+ if len(line) < 2:
+ break
+
+ floats = line.split_floats(" ")
+
+ var pos_x := floats[0]
+ var pos_y := floats[1]
+
+ min_pos_x = minf(min_pos_x, pos_x)
+ min_pos_y = minf(min_pos_y, pos_y)
+
+ max_pos_x = maxf(max_pos_x, pos_x)
+ max_pos_y = maxf(max_pos_y, pos_y)
+
+ line_count += 1
+
+ var bounds := HT_XYZBounds.new()
+ bounds.min_x = min_pos_x
+ bounds.min_y = min_pos_y
+ bounds.max_x = max_pos_x
+ bounds.max_y = max_pos_y
+ bounds.line_count = line_count
+ bounds.image_width = int(max_pos_x - min_pos_x) + 1
+ bounds.image_height = int(max_pos_y - min_pos_y) + 1
+ return bounds
+
+
+# Loads points into an image with existing dimensions and format.
+# `f` must be positioned at the beginning of the series of points.
+# If `bounds` is `null`, it will be computed.
+static func load_heightmap(f: FileAccess, dst_image: Image, bounds: HT_XYZBounds):
+ # We are not going to read the entire file directly in memory, because it can be really big.
+ # Instead we'll parse it directly and the only thing we retain in memory is the heightmap.
+ # This can be really slow on big files. If we can assume the file is square and points
+ # separated by 1 unit each in a grid pattern, it could be a bit faster, but
+ # parsing points from text really is the main bottleneck (40 seconds to load a 2000x2000 file!).
+
+ # Bounds can be precalculated
+ if bounds == null:
+ var file_begin := f.get_position()
+ bounds = load_bounds(f)
+ f.seek(file_begin)
+
+ # Put min coordinates on the GDScript stack so they are faster to access
+ var min_pos_x := bounds.min_x
+ var min_pos_y := bounds.min_y
+ var line_count := bounds.line_count
+
+ for i in line_count:
+ var line := f.get_line()
+ var floats := line.split_floats(" ")
+ var x := int(floats[0] - min_pos_x)
+ var y := int(floats[1] - min_pos_y)
+
+ # Make sure the coordinate is inside the image,
+ # due to float imprecision or potentially non-grid-aligned points.
+ # Could use `Rect2` to check faster but it uses floats.
+ # `Rect2i` would be better but is only available in Godot 4.
+ if x >= 0 and y >= 0 and x < dst_image.get_width() and y < dst_image.get_height():
+ dst_image.set_pixel(x, y, Color(floats[2], 0, 0))
+
diff --git a/game/project.godot b/game/project.godot
index f8bd428..a3174f5 100644
--- a/game/project.godot
+++ b/game/project.godot
@@ -43,7 +43,7 @@ window/stretch/aspect="ignore"
[editor_plugins]
-enabled=PackedStringArray("res://addons/keychain/plugin.cfg", "res://addons/openvic-plugin/plugin.cfg")
+enabled=PackedStringArray("res://addons/keychain/plugin.cfg", "res://addons/openvic-plugin/plugin.cfg", "res://addons/zylann.hterrain/plugin.cfg")
[gui]
diff --git a/game/src/Game/GameSession/MapView.tscn b/game/src/Game/GameSession/MapView.tscn
index fb4ac07..9d1d20c 100644
--- a/game/src/Game/GameSession/MapView.tscn
+++ b/game/src/Game/GameSession/MapView.tscn
@@ -1,7 +1,15 @@
-[gd_scene load_steps=5 format=3 uid="uid://dkehmdnuxih2r"]
+[gd_scene load_steps=9 format=3 uid="uid://dkehmdnuxih2r"]
[ext_resource type="Script" path="res://src/Game/GameSession/MapView.gd" id="1_exccw"]
[ext_resource type="Shader" path="res://src/Game/GameSession/TerrainMap.gdshader" id="1_upocn"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/hterrain.gd" id="3_6qmnv"]
+[ext_resource type="Resource" path="res://testing/dataft/data.hterrain" id="4_cd8x3"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/hterrain_texture_set.gd" id="5_t42pt"]
+
+[sub_resource type="Resource" id="Resource_l6jw3"]
+script = ExtResource("5_t42pt")
+mode = 0
+textures = [[], []]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_tayeg"]
render_priority = 0
@@ -23,6 +31,26 @@ _map_mesh_instance = NodePath("MapMeshInstance")
transform = Transform3D(1, 0, 0, 0, 0.707107, 0.707107, 0, -0.707107, 0.707107, 0.25, 1.5, -2.75)
near = 0.01
+[node name="HTerrain" type="Node3D" parent="."]
+transform = Transform3D(0.01, 0, 0, 0, 0.01, 0, 0, 0, 0.01, 0, 1, 0)
+script = ExtResource("3_6qmnv")
+centered = true
+_terrain_data = ExtResource("4_cd8x3")
+chunk_size = 32
+collision_enabled = true
+collision_layer = 1
+collision_mask = 1
+shader_type = "Classic4Lite"
+custom_shader = null
+custom_globalmap_shader = null
+texture_set = SubResource("Resource_l6jw3")
+render_layers = 1
+cast_shadow = 1
+shader_params/u_ground_uv_scale = 20
+shader_params/u_depth_blending = true
+shader_params/u_triplanar = null
+shader_params/u_tile_reduction = null
+
[node name="MapMeshInstance" type="MeshInstance3D" parent="."]
editor_description = "FS-343"
transform = Transform3D(10, 0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0)
diff --git a/game/testing/21.gdshader b/game/testing/21.gdshader
new file mode 100644
index 0000000..3db3341
--- /dev/null
+++ b/game/testing/21.gdshader
@@ -0,0 +1,76 @@
+shader_type spatial;
+
+
+uniform float speed : hint_range(-1,1) = 0.0;
+
+//colors
+uniform sampler2D noise1; //add Godot noise here
+uniform sampler2D noise2; //add Godot noise here
+uniform sampler2D normalmap : hint_normal; //add Godot noise here, enable as_normalmap
+uniform vec4 color : hint_color;
+uniform vec4 edge_color : hint_color;
+
+//foam
+uniform float edge_scale = 0.25;
+uniform float near = 0.1;
+uniform float far = 100f;
+
+//waves
+uniform vec2 wave_strengh = vec2(0.5, 0.25);
+uniform vec2 wave_frequency = vec2(12.0, 12.0);
+uniform vec2 time_factor = vec2(1.0, 2.0);
+
+
+
+float rim(float depth){
+ depth = 2f * depth - 1f;
+ return near * far / (far + depth * (near - far));
+}
+
+
+float waves(vec2 pos, float time){
+ return (wave_strengh.y * sin(pos.y * wave_frequency.y + time * time_factor.y)) + (wave_strengh.x * sin(pos.x * wave_frequency.x + time * time_factor.x));
+}
+
+
+void vertex(){
+ VERTEX.y += waves(VERTEX.xy, TIME);
+}
+
+
+void fragment(){
+ float time = TIME * speed;
+ vec3 n1 = texture(noise1, UV + time).rgb;
+ vec3 n2 = texture(noise2, UV - time * 0.2).rgb;
+
+ vec2 uv_movement = UV * 4f;
+ uv_movement += TIME * speed * 4f;
+
+ float sum = (n1.r + n2.r) - 1f;
+
+
+ float z_depth = rim(texture(DEPTH_TEXTURE, SCREEN_UV).x);
+ float z_pos = rim(FRAGCOORD.z);
+ float diff = z_depth - z_pos;
+
+ vec2 displacement = vec2(sum * 0.05);
+ diff += displacement.x * 50f;
+
+
+ vec4 col = mix(edge_color, color, step(edge_scale, diff));
+
+ vec4 alpha = vec4(1.0);
+ alpha = texture(SCREEN_TEXTURE, SCREEN_UV + displacement);
+
+
+ float fin = 0.0;
+ if (sum > 0.0 && sum < 0.4) fin = 0.1;
+ if (sum > 0.4 && sum < 0.8) fin = 0.0;
+ if (sum > 0.8) fin = 1f;
+
+ ALBEDO = vec3(fin) + mix(alpha.rgb, col.rgb, color.a);
+
+ NORMALMAP = texture(normalmap, uv_movement).rgb;
+ ROUGHNESS = 0.1;
+ SPECULAR = 1f;
+} \ No newline at end of file
diff --git a/game/testing/Buoyancy-in-Godot-4-master/.gitattributes b/game/testing/Buoyancy-in-Godot-4-master/.gitattributes
new file mode 100644
index 0000000..8ad74f7
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/.gitattributes
@@ -0,0 +1,2 @@
+# Normalize EOL for all files that Git considers text files.
+* text=auto eol=lf
diff --git a/game/testing/Buoyancy-in-Godot-4-master/.gitignore b/game/testing/Buoyancy-in-Godot-4-master/.gitignore
new file mode 100644
index 0000000..4709183
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/.gitignore
@@ -0,0 +1,2 @@
+# Godot 4+ specific ignores
+.godot/
diff --git a/game/testing/Buoyancy-in-Godot-4-master/Cube.gd b/game/testing/Buoyancy-in-Godot-4-master/Cube.gd
new file mode 100644
index 0000000..075e532
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/Cube.gd
@@ -0,0 +1,34 @@
+extends RigidBody3D
+
+@export var float_force := 1.0
+@export var water_drag := 0.05
+@export var water_angular_drag := 0.05
+
+@onready var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
+@onready var water = get_node('/root/Main/Water')
+
+@onready var probes = $ProbeContainer.get_children()
+
+var submerged := false
+
+# Called when the node enters the scene tree for the first time.
+func _ready():
+ pass # Replace with function body.
+
+
+# Called every frame. 'delta' is the elapsed time since the previous frame.
+func _process(delta):
+ pass
+
+func _physics_process(delta):
+ submerged = false
+ for p in probes:
+ var depth = water.get_height(p.global_position) - p.global_position.y
+ if depth > 0:
+ submerged = true
+ apply_force(Vector3.UP * float_force * gravity * depth, p.global_position - global_position)
+
+func _integrate_forces(state: PhysicsDirectBodyState3D):
+ if submerged:
+ state.linear_velocity *= 1 - water_drag
+ state.angular_velocity *= 1 - water_angular_drag
diff --git a/game/testing/Buoyancy-in-Godot-4-master/README.md b/game/testing/Buoyancy-in-Godot-4-master/README.md
new file mode 100644
index 0000000..9e0e449
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/README.md
@@ -0,0 +1,3 @@
+# Simple Buoyancy implemented in Godot 4.0 RC
+
+Water shader from here: https://stayathomedev.com/?utm_source=youtube&utm_medium=desc&utm_content=watershader
diff --git a/game/testing/Buoyancy-in-Godot-4-master/Water.gd b/game/testing/Buoyancy-in-Godot-4-master/Water.gd
new file mode 100644
index 0000000..b5e2fe7
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/Water.gd
@@ -0,0 +1,32 @@
+extends MeshInstance3D
+
+var material: ShaderMaterial
+var noise: Image
+
+var noise_scale: float
+var wave_speed: float
+var height_scale: float
+
+var time: float
+
+# Called when the node enters the scene tree for the first time.
+func _ready():
+ material = mesh.surface_get_material(0)
+ noise = material.get_shader_parameter("wave").noise.get_seamless_image(512, 512)
+ noise_scale = material.get_shader_parameter("noise_scale")
+ wave_speed = material.get_shader_parameter("wave_speed")
+ height_scale = material.get_shader_parameter("height_scale")
+
+
+
+# Called every frame. 'delta' is the elapsed time since the previous frame.
+func _process(delta):
+ time += delta
+ material.set_shader_parameter("wave_time", time)
+
+func get_height(world_position: Vector3) -> float:
+ var uv_x = wrapf(world_position.x / noise_scale + time * wave_speed, 0, 1)
+ var uv_y = wrapf(world_position.z / noise_scale + time * wave_speed, 0, 1)
+
+ var pixel_pos = Vector2(uv_x * noise.get_width(), uv_y * noise.get_height())
+ return global_position.y + noise.get_pixelv(pixel_pos).r * height_scale;
diff --git a/game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exr b/game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exr
new file mode 100644
index 0000000..3343d2c
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exr
Binary files differ
diff --git a/game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exr.import b/game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exr.import
new file mode 100644
index 0000000..3f1eca0
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/assets/kloppenheim_06_puresky_4k.exr.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bay3ak8k8pevv"
+path.bptc="res://.godot/imported/kloppenheim_06_puresky_4k.exr-b16989c6338774c04a5c25df6ac154bf.bptc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://assets/kloppenheim_06_puresky_4k.exr"
+dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.exr-b16989c6338774c04a5c25df6ac154bf.bptc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+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=0
diff --git a/game/testing/Buoyancy-in-Godot-4-master/assets/shaders/water.gdshader b/game/testing/Buoyancy-in-Godot-4-master/assets/shaders/water.gdshader
new file mode 100644
index 0000000..5fba7f9
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/assets/shaders/water.gdshader
@@ -0,0 +1,81 @@
+shader_type spatial;
+render_mode depth_draw_always;
+
+uniform sampler2D SCREEN_TEXTURE: hint_screen_texture, filter_linear_mipmap;
+uniform sampler2D DEPTH_TEXTURE: hint_depth_texture, filter_linear_mipmap;
+
+uniform vec3 albedo : source_color;
+uniform vec3 albedo2 : source_color;
+uniform vec4 color_deep : source_color;
+uniform vec4 color_shallow : source_color;
+
+uniform float metallic : hint_range(0.0, 1.0) = 0;
+uniform float roughness : hint_range(0.0, 1.0) = 0.02;
+
+uniform sampler2D texture_normal;
+uniform sampler2D texture_normal2;
+uniform sampler2D wave;
+
+uniform float wave_time = 0;
+uniform vec2 wave_direction = vec2(2.0,0.0);
+uniform vec2 wave_2_direction = vec2(0.0,1.0);
+uniform float time_scale : hint_range(0.0, 0.2, 0.005) = 0.025;
+uniform float wave_speed = 2.0;
+uniform float noise_scale = 10.0;
+uniform float height_scale = 0.15;
+uniform float beers_law = 2.0;
+uniform float depth_offset = -0.75;
+
+varying float height;
+varying vec3 world_pos;
+
+uniform float edge_scale = 0.1;
+uniform float near = 0.5;
+uniform float far = 100.0;
+uniform vec3 edge_color : source_color;
+
+float fresnel(float amount, vec3 normal, vec3 view)
+{
+ return pow((1.0 - clamp(dot(normalize(normal), normalize(view)), 0.0, 1.0 )), amount);
+}
+
+float edge(float depth) {
+ return near * far / (far + depth * (near - far));
+}
+
+void vertex() {
+ world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+ height = texture(wave, world_pos.xz / noise_scale + wave_time * wave_speed).r;
+ VERTEX.y += height * height_scale;
+}
+
+void fragment() {
+ float depth_texture = texture(DEPTH_TEXTURE, SCREEN_UV).r;
+ float depth = PROJECTION_MATRIX[3][2] / (depth_texture + PROJECTION_MATRIX[2][2]);
+ depth = depth + VERTEX.z;
+ float depth_blend = exp((depth + depth_offset) * -beers_law);
+ depth_blend = clamp(1.0 - depth_blend, 0.0, 1.0);
+
+ vec3 screen_color = textureLod(SCREEN_TEXTURE, SCREEN_UV, depth_blend * 2.5).rgb;
+ vec3 depth_color = mix(color_shallow.rgb, color_deep.rgb, depth_blend);
+ vec3 color = mix(screen_color * depth_color, depth_color * 0.25, depth_blend * 0.5);
+
+ float z_depth = edge(texture(DEPTH_TEXTURE, SCREEN_UV).x);
+ float z_pos = edge(FRAGCOORD.z);
+ float z_dif = z_depth - z_pos;
+
+ vec2 time = (TIME * wave_direction) * time_scale;
+ vec2 time2 = (TIME * wave_2_direction) * time_scale;
+
+ vec3 normal_blend = mix(texture(texture_normal, world_pos.xz / noise_scale + time).rgb, texture(texture_normal2, world_pos.xz / noise_scale + time2).rgb, 0.5);
+
+ float fresnel = fresnel(5.0, NORMAL, VIEW);
+ vec3 surface_color = mix(albedo, albedo2, fresnel);
+ vec3 depth_color_adj = mix(edge_color, color, step(edge_scale, z_dif));
+
+ ALBEDO = clamp(surface_color + depth_color_adj,vec3(0),vec3(1.0));
+ ALPHA = 1.0;
+ METALLIC = metallic;
+ ROUGHNESS = roughness;
+ NORMAL_MAP = normal_blend;
+} \ No newline at end of file
diff --git a/game/testing/Buoyancy-in-Godot-4-master/icon.svg b/game/testing/Buoyancy-in-Godot-4-master/icon.svg
new file mode 100644
index 0000000..adc26df
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/icon.svg
@@ -0,0 +1 @@
+<svg height="128" width="128" xmlns="http://www.w3.org/2000/svg"><g transform="translate(32 32)"><path d="m-16-32c-8.86 0-16 7.13-16 15.99v95.98c0 8.86 7.13 15.99 16 15.99h96c8.86 0 16-7.13 16-15.99v-95.98c0-8.85-7.14-15.99-16-15.99z" fill="#363d52"/><path d="m-16-32c-8.86 0-16 7.13-16 15.99v95.98c0 8.86 7.13 15.99 16 15.99h96c8.86 0 16-7.13 16-15.99v-95.98c0-8.85-7.14-15.99-16-15.99zm0 4h96c6.64 0 12 5.35 12 11.99v95.98c0 6.64-5.35 11.99-12 11.99h-96c-6.64 0-12-5.35-12-11.99v-95.98c0-6.64 5.36-11.99 12-11.99z" fill-opacity=".4"/></g><g stroke-width="9.92746" transform="matrix(.10073078 0 0 .10073078 12.425923 2.256365)"><path d="m0 0s-.325 1.994-.515 1.976l-36.182-3.491c-2.879-.278-5.115-2.574-5.317-5.459l-.994-14.247-27.992-1.997-1.904 12.912c-.424 2.872-2.932 5.037-5.835 5.037h-38.188c-2.902 0-5.41-2.165-5.834-5.037l-1.905-12.912-27.992 1.997-.994 14.247c-.202 2.886-2.438 5.182-5.317 5.46l-36.2 3.49c-.187.018-.324-1.978-.511-1.978l-.049-7.83 30.658-4.944 1.004-14.374c.203-2.91 2.551-5.263 5.463-5.472l38.551-2.75c.146-.01.29-.016.434-.016 2.897 0 5.401 2.166 5.825 5.038l1.959 13.286h28.005l1.959-13.286c.423-2.871 2.93-5.037 5.831-5.037.142 0 .284.005.423.015l38.556 2.75c2.911.209 5.26 2.562 5.463 5.472l1.003 14.374 30.645 4.966z" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 919.24059 771.67186)"/><path d="m0 0v-47.514-6.035-5.492c.108-.001.216-.005.323-.015l36.196-3.49c1.896-.183 3.382-1.709 3.514-3.609l1.116-15.978 31.574-2.253 2.175 14.747c.282 1.912 1.922 3.329 3.856 3.329h38.188c1.933 0 3.573-1.417 3.855-3.329l2.175-14.747 31.575 2.253 1.115 15.978c.133 1.9 1.618 3.425 3.514 3.609l36.182 3.49c.107.01.214.014.322.015v4.711l.015.005v54.325c5.09692 6.4164715 9.92323 13.494208 13.621 19.449-5.651 9.62-12.575 18.217-19.976 26.182-6.864-3.455-13.531-7.369-19.828-11.534-3.151 3.132-6.7 5.694-10.186 8.372-3.425 2.751-7.285 4.768-10.946 7.118 1.09 8.117 1.629 16.108 1.846 24.448-9.446 4.754-19.519 7.906-29.708 10.17-4.068-6.837-7.788-14.241-11.028-21.479-3.842.642-7.702.88-11.567.926v.006c-.027 0-.052-.006-.075-.006-.024 0-.049.006-.073.006v-.006c-3.872-.046-7.729-.284-11.572-.926-3.238 7.238-6.956 14.642-11.03 21.479-10.184-2.264-20.258-5.416-29.703-10.17.216-8.34.755-16.331 1.848-24.448-3.668-2.35-7.523-4.367-10.949-7.118-3.481-2.678-7.036-5.24-10.188-8.372-6.297 4.165-12.962 8.079-19.828 11.534-7.401-7.965-14.321-16.562-19.974-26.182 4.4426579-6.973692 9.2079702-13.9828876 13.621-19.449z" fill="#478cbf" transform="matrix(4.162611 0 0 -4.162611 104.69892 525.90697)"/><path d="m0 0-1.121-16.063c-.135-1.936-1.675-3.477-3.611-3.616l-38.555-2.751c-.094-.007-.188-.01-.281-.01-1.916 0-3.569 1.406-3.852 3.33l-2.211 14.994h-31.459l-2.211-14.994c-.297-2.018-2.101-3.469-4.133-3.32l-38.555 2.751c-1.936.139-3.476 1.68-3.611 3.616l-1.121 16.063-32.547 3.138c.015-3.498.06-7.33.06-8.093 0-34.374 43.605-50.896 97.781-51.086h.066.067c54.176.19 97.766 16.712 97.766 51.086 0 .777.047 4.593.063 8.093z" fill="#478cbf" transform="matrix(4.162611 0 0 -4.162611 784.07144 817.24284)"/><path d="m0 0c0-12.052-9.765-21.815-21.813-21.815-12.042 0-21.81 9.763-21.81 21.815 0 12.044 9.768 21.802 21.81 21.802 12.048 0 21.813-9.758 21.813-21.802" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 389.21484 625.67104)"/><path d="m0 0c0-7.994-6.479-14.473-14.479-14.473-7.996 0-14.479 6.479-14.479 14.473s6.483 14.479 14.479 14.479c8 0 14.479-6.485 14.479-14.479" fill="#414042" transform="matrix(4.162611 0 0 -4.162611 367.36686 631.05679)"/><path d="m0 0c-3.878 0-7.021 2.858-7.021 6.381v20.081c0 3.52 3.143 6.381 7.021 6.381s7.028-2.861 7.028-6.381v-20.081c0-3.523-3.15-6.381-7.028-6.381" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 511.99336 724.73954)"/><path d="m0 0c0-12.052 9.765-21.815 21.815-21.815 12.041 0 21.808 9.763 21.808 21.815 0 12.044-9.767 21.802-21.808 21.802-12.05 0-21.815-9.758-21.815-21.802" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 634.78706 625.67104)"/><path d="m0 0c0-7.994 6.477-14.473 14.471-14.473 8.002 0 14.479 6.479 14.479 14.473s-6.477 14.479-14.479 14.479c-7.994 0-14.471-6.485-14.471-14.479" fill="#414042" transform="matrix(4.162611 0 0 -4.162611 656.64056 631.05679)"/></g></svg>
diff --git a/game/testing/Buoyancy-in-Godot-4-master/icon.svg.import b/game/testing/Buoyancy-in-Godot-4-master/icon.svg.import
new file mode 100644
index 0000000..30883ef
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/icon.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bin4yeou0ftcw"
+path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.svg"
+dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/testing/Buoyancy-in-Godot-4-master/main.tscn b/game/testing/Buoyancy-in-Godot-4-master/main.tscn
new file mode 100644
index 0000000..bd54991
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/main.tscn
@@ -0,0 +1,143 @@
+[gd_scene load_steps=18 format=3 uid="uid://b64rk3r14m5br"]
+
+[ext_resource type="Texture2D" uid="uid://bay3ak8k8pevv" path="res://assets/kloppenheim_06_puresky_4k.exr" id="1_cyo0m"]
+[ext_resource type="Shader" path="res://assets/shaders/water.gdshader" id="2_twn8n"]
+[ext_resource type="Script" path="res://Water.gd" id="3_t5r3v"]
+[ext_resource type="Script" path="res://Cube.gd" id="3_wnrpb"]
+
+[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_vcvt7"]
+panorama = ExtResource("1_cyo0m")
+
+[sub_resource type="Sky" id="Sky_wc0b5"]
+sky_material = SubResource("PanoramaSkyMaterial_vcvt7")
+
+[sub_resource type="Environment" id="Environment_w5hlc"]
+background_mode = 2
+sky = SubResource("Sky_wc0b5")
+tonemap_mode = 2
+glow_enabled = true
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_hy6fq"]
+fractal_type = 2
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_brvoh"]
+seamless = true
+as_normal_map = true
+bump_strength = 1.5
+noise = SubResource("FastNoiseLite_hy6fq")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_hs2gb"]
+noise_type = 3
+fractal_type = 2
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_u5h0n"]
+seamless = true
+as_normal_map = true
+bump_strength = 1.5
+noise = SubResource("FastNoiseLite_hs2gb")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_jbi1g"]
+noise_type = 3
+frequency = 0.001
+fractal_type = 2
+fractal_octaves = 3
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_7k2l2"]
+seamless = true
+noise = SubResource("FastNoiseLite_jbi1g")
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_0n3y0"]
+render_priority = 0
+shader = ExtResource("2_twn8n")
+shader_parameter/albedo = Color(0, 0.321569, 0.431373, 1)
+shader_parameter/albedo2 = Color(0, 0.47451, 0.764706, 1)
+shader_parameter/color_deep = Color(0.105882, 0.294118, 0.329412, 1)
+shader_parameter/color_shallow = Color(0, 0.552941, 0.65098, 1)
+shader_parameter/metallic = 0.0
+shader_parameter/roughness = 0.02
+shader_parameter/wave_time = 0.0
+shader_parameter/wave_direction = Vector2(2, 0)
+shader_parameter/wave_2_direction = Vector2(0, 1)
+shader_parameter/time_scale = 0.025
+shader_parameter/wave_speed = 0.2
+shader_parameter/noise_scale = 10.0
+shader_parameter/height_scale = 2.0
+shader_parameter/beers_law = 0.089
+shader_parameter/depth_offset = -0.75
+shader_parameter/edge_scale = 0.362
+shader_parameter/near = 0.5
+shader_parameter/far = 100.0
+shader_parameter/edge_color = Color(1, 1, 1, 1)
+shader_parameter/texture_normal = SubResource("NoiseTexture2D_brvoh")
+shader_parameter/texture_normal2 = SubResource("NoiseTexture2D_u5h0n")
+shader_parameter/wave = SubResource("NoiseTexture2D_7k2l2")
+
+[sub_resource type="PlaneMesh" id="PlaneMesh_0xwda"]
+material = SubResource("ShaderMaterial_0n3y0")
+size = Vector2(500, 500)
+subdivide_width = 500
+subdivide_depth = 500
+
+[sub_resource type="BoxMesh" id="BoxMesh_km0el"]
+size = Vector3(5, 1, 10)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_lu3w5"]
+size = Vector3(5, 1, 10)
+
+[node name="Main" type="Node3D"]
+
+[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
+environment = SubResource("Environment_w5hlc")
+
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
+transform = Transform3D(-0.940098, 0.0859291, -0.329897, -0.23722, -0.859886, 0.452021, -0.244833, 0.503202, 0.828761, 0, 0, 0)
+shadow_enabled = true
+
+[node name="Water" type="MeshInstance3D" parent="."]
+mesh = SubResource("PlaneMesh_0xwda")
+script = ExtResource("3_t5r3v")
+
+[node name="Camera3D" type="Camera3D" parent="."]
+transform = Transform3D(0.0305324, 0.321287, -0.94649, 0, 0.946931, 0.321437, 0.999534, -0.00981422, 0.0289121, -11.6034, 4.14286, 1.45379)
+
+[node name="Cube" type="RigidBody3D" parent="."]
+transform = Transform3D(-0.761663, 0, -0.647974, 0, 1, 0, 0.647974, 0, -0.761663, 0, 3.13922, 0)
+mass = 10.0
+script = ExtResource("3_wnrpb")
+float_force = 1.3
+water_angular_drag = 0.1
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="Cube"]
+mesh = SubResource("BoxMesh_km0el")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Cube"]
+shape = SubResource("BoxShape3D_lu3w5")
+
+[node name="ProbeContainer" type="Node3D" parent="Cube"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.590401, 0)
+
+[node name="Probe" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.5, 0, 5)
+
+[node name="Probe2" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.5, 0, 0)
+
+[node name="Probe3" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.5, 0, -5)
+
+[node name="Probe4" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 5)
+
+[node name="Probe5" type="Marker3D" parent="Cube/ProbeContainer"]
+
+[node name="Probe6" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -5)
+
+[node name="Probe7" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.5, 0, 5)
+
+[node name="Probe8" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.5, 0, 0)
+
+[node name="Probe9" type="Marker3D" parent="Cube/ProbeContainer"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.5, 0, -5)
diff --git a/game/testing/Buoyancy-in-Godot-4-master/project.godot b/game/testing/Buoyancy-in-Godot-4-master/project.godot
new file mode 100644
index 0000000..b10c22f
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/project.godot
@@ -0,0 +1,25 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Buoyancy YT"
+run/main_scene="res://main.tscn"
+config/features=PackedStringArray("4.1", "Forward Plus")
+config/icon="res://icon.svg"
+
+[display]
+
+window/size/viewport_width=1920
+window/size/viewport_height=1080
+
+[rendering]
+
+anti_aliasing/quality/msaa_3d=3
diff --git a/game/testing/Buoyancy-in-Godot-4-master/water.tscn b/game/testing/Buoyancy-in-Godot-4-master/water.tscn
new file mode 100644
index 0000000..c336ccc
--- /dev/null
+++ b/game/testing/Buoyancy-in-Godot-4-master/water.tscn
@@ -0,0 +1,69 @@
+[gd_scene load_steps=11 format=3 uid="uid://cty2qgt0i5f4w"]
+
+[ext_resource type="Shader" path="res://assets/shaders/water.gdshader" id="1_oeaqx"]
+[ext_resource type="Script" path="res://Water.gd" id="2_qv0cn"]
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_hy6fq"]
+fractal_type = 2
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_brvoh"]
+seamless = true
+as_normal_map = true
+bump_strength = 1.5
+noise = SubResource("FastNoiseLite_hy6fq")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_hs2gb"]
+noise_type = 3
+fractal_type = 2
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_u5h0n"]
+seamless = true
+as_normal_map = true
+bump_strength = 1.5
+noise = SubResource("FastNoiseLite_hs2gb")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_jbi1g"]
+noise_type = 3
+frequency = 0.001
+fractal_type = 2
+fractal_octaves = 3
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_7k2l2"]
+seamless = true
+noise = SubResource("FastNoiseLite_jbi1g")
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_0n3y0"]
+render_priority = 0
+shader = ExtResource("1_oeaqx")
+shader_parameter/albedo = Color(0, 0.321569, 0.431373, 1)
+shader_parameter/albedo2 = Color(0, 0.47451, 0.764706, 1)
+shader_parameter/color_deep = Color(0.105882, 0.294118, 0.329412, 1)
+shader_parameter/color_shallow = Color(0, 0.552941, 0.65098, 1)
+shader_parameter/metallic = 0.0
+shader_parameter/roughness = 0.02
+shader_parameter/wave_time = 0.0
+shader_parameter/wave_direction = Vector2(2, 0)
+shader_parameter/wave_2_direction = Vector2(0, 1)
+shader_parameter/time_scale = 0.025
+shader_parameter/wave_speed = 0.2
+shader_parameter/noise_scale = 10.0
+shader_parameter/height_scale = 2.0
+shader_parameter/beers_law = 0.089
+shader_parameter/depth_offset = -0.75
+shader_parameter/edge_scale = 0.362
+shader_parameter/near = 0.5
+shader_parameter/far = 100.0
+shader_parameter/edge_color = Color(1, 1, 1, 1)
+shader_parameter/texture_normal = SubResource("NoiseTexture2D_brvoh")
+shader_parameter/texture_normal2 = SubResource("NoiseTexture2D_u5h0n")
+shader_parameter/wave = SubResource("NoiseTexture2D_7k2l2")
+
+[sub_resource type="PlaneMesh" id="PlaneMesh_0xwda"]
+material = SubResource("ShaderMaterial_0n3y0")
+size = Vector2(500, 500)
+subdivide_width = 500
+subdivide_depth = 500
+
+[node name="Water" type="MeshInstance3D"]
+mesh = SubResource("PlaneMesh_0xwda")
+script = ExtResource("2_qv0cn")
diff --git a/game/testing/dataft/color.png b/game/testing/dataft/color.png
new file mode 100644
index 0000000..3be5b60
--- /dev/null
+++ b/game/testing/dataft/color.png
Binary files differ
diff --git a/game/testing/dataft/color.png.import b/game/testing/dataft/color.png.import
new file mode 100644
index 0000000..3591560
--- /dev/null
+++ b/game/testing/dataft/color.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bcp5vyhccrty3"
+path="res://.godot/imported/color.png-c4690dd6325517712e8b70b22ca954a9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://testing/dataft/color.png"
+dest_files=["res://.godot/imported/color.png-c4690dd6325517712e8b70b22ca954a9.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=0
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=false
+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=0
diff --git a/game/testing/dataft/data.hterrain b/game/testing/dataft/data.hterrain
new file mode 100644
index 0000000..f137b21
--- /dev/null
+++ b/game/testing/dataft/data.hterrain
@@ -0,0 +1,29 @@
+{
+ "maps": [
+ [
+ {
+ "id": 0
+ }
+ ],
+ [
+ {
+ "id": 0
+ }
+ ],
+ [
+ {
+ "id": 0
+ }
+ ],
+ [
+ {
+ "id": 0
+ }
+ ],
+ [],
+ [],
+ [],
+ []
+ ],
+ "version": "0.11"
+} \ No newline at end of file
diff --git a/game/testing/dataft/height.res b/game/testing/dataft/height.res
new file mode 100644
index 0000000..40c9f23
--- /dev/null
+++ b/game/testing/dataft/height.res
Binary files differ
diff --git a/game/testing/dataft/normal.png b/game/testing/dataft/normal.png
new file mode 100644
index 0000000..7b066a2
--- /dev/null
+++ b/game/testing/dataft/normal.png
Binary files differ
diff --git a/game/testing/dataft/normal.png.import b/game/testing/dataft/normal.png.import
new file mode 100644
index 0000000..14a20f4
--- /dev/null
+++ b/game/testing/dataft/normal.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b63bln6bdm576"
+path="res://.godot/imported/normal.png-708bd8892e3c7ce2a513aa428c5cccdd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://testing/dataft/normal.png"
+dest_files=["res://.godot/imported/normal.png-708bd8892e3c7ce2a513aa428c5cccdd.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=0
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=false
+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=0
diff --git a/game/testing/dataft/splat.png b/game/testing/dataft/splat.png
new file mode 100644
index 0000000..fd566ba
--- /dev/null
+++ b/game/testing/dataft/splat.png
Binary files differ
diff --git a/game/testing/dataft/splat.png.import b/game/testing/dataft/splat.png.import
new file mode 100644
index 0000000..94c2e0d
--- /dev/null
+++ b/game/testing/dataft/splat.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bw8ut45mlcecc"
+path="res://.godot/imported/splat.png-d72060e3fb78fb964805e101bd1cf6cc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://testing/dataft/splat.png"
+dest_files=["res://.godot/imported/splat.png-d72060e3fb78fb964805e101bd1cf6cc.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=0
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=false
+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=0
diff --git a/game/testing/exp.png b/game/testing/exp.png
new file mode 100644
index 0000000..745cbcf
--- /dev/null
+++ b/game/testing/exp.png
Binary files differ
diff --git a/game/testing/exp.png.import b/game/testing/exp.png.import
new file mode 100644
index 0000000..65405da
--- /dev/null
+++ b/game/testing/exp.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://drwpyov040ogl"
+path="res://.godot/imported/exp.png-bcdceaa0bfebe20561d97ab9562c0911.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://testing/exp.png"
+dest_files=["res://.godot/imported/exp.png-bcdceaa0bfebe20561d97ab9562c0911.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/testing/mountains-nowater.png b/game/testing/mountains-nowater.png
new file mode 100644
index 0000000..dad49ea
--- /dev/null
+++ b/game/testing/mountains-nowater.png
Binary files differ
diff --git a/game/testing/mountains-nowater.png.import b/game/testing/mountains-nowater.png.import
new file mode 100644
index 0000000..52e22ec
--- /dev/null
+++ b/game/testing/mountains-nowater.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cgyx24o6h3xr1"
+path="res://.godot/imported/mountains-nowater.png-b369ed175a3654361b5c3beacaa34fbd.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://testing/mountains-nowater.png"
+dest_files=["res://.godot/imported/mountains-nowater.png-b369ed175a3654361b5c3beacaa34fbd.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/testing/mountains.png b/game/testing/mountains.png
new file mode 100644
index 0000000..f0cefc5
--- /dev/null
+++ b/game/testing/mountains.png
Binary files differ
diff --git a/game/testing/mountains.png.import b/game/testing/mountains.png.import
new file mode 100644
index 0000000..c3a266c
--- /dev/null
+++ b/game/testing/mountains.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c0epbaco34anq"
+path="res://.godot/imported/mountains.png-3e801c8f292838974fa9af6a76a7e490.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://testing/mountains.png"
+dest_files=["res://.godot/imported/mountains.png-3e801c8f292838974fa9af6a76a7e490.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/testing/mountains1.png b/game/testing/mountains1.png
new file mode 100644
index 0000000..9ea59cf
--- /dev/null
+++ b/game/testing/mountains1.png
Binary files differ
diff --git a/game/testing/mountains1.png.import b/game/testing/mountains1.png.import
new file mode 100644
index 0000000..ba0d0d9
--- /dev/null
+++ b/game/testing/mountains1.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dl45nhftyvbof"
+path.s3tc="res://.godot/imported/mountains1.png-625a23815c4f70ce0909a570b9dc103f.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://testing/mountains1.png"
+dest_files=["res://.godot/imported/mountains1.png-625a23815c4f70ce0909a570b9dc103f.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+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=0
diff --git a/game/testing/mountains1.raw b/game/testing/mountains1.raw
new file mode 100644
index 0000000..fff244f
--- /dev/null
+++ b/game/testing/mountains1.raw
Binary files differ
diff --git a/game/testing/mountains1.raw.pal b/game/testing/mountains1.raw.pal
new file mode 100644
index 0000000..3c466ff
--- /dev/null
+++ b/game/testing/mountains1.raw.pal
@@ -0,0 +1,4 @@
+ !!!"""###$$$&&&(((***+++---...000111222///444666777999;;;===>>>???@@@<<<:::888555333,,,CCCEEEFFFHHHIIIJJJGGGDDDAAABBBMMMOOOQQQRRRSSSLLLKKKNNNPPPTTTUUUWWWXXXYYYZZZ\\\]]]^^^___)))'''%%%VVV[[[```aaabbbcccdddeeefffgggiiihhh jjjkkklll mmm nnnooo
+
+
+pppqqqrrrssstttuuuvvvwwwxxxyyyzzz{{{}}}|||€€€~~~‚‚‚ƒƒƒ………„„„†††‡‡‡ˆˆˆ‰‰‰ ‹‹‹ŒŒŒŠŠŠŽŽŽ‘‘‘”””•••–––˜˜˜“““’’’ššš›››œœœ™™™žžžŸŸŸ   ———ĄĄĄ˘˘˘ŁŁŁŚŚŚ¤¤¤ĽĽĽ§§§¨¨¨ŞŞŞŹŹŹŤŤŤŽŽŽ­­­ŠŠŠŻŻŻ°°°łłłąąą¸¸¸˛˛˛´´´ľľľśśśˇˇˇšššşşşźźź˝˝˝ťťťžžžżżżŔŔŔÁÁÁÂÂÂĂĂĂÄÄÄĹĹĹĆĆĆÇÇÇËËËĘĘĘ \ No newline at end of file
diff --git a/game/testing/terrain.png b/game/testing/terrain.png
new file mode 100644
index 0000000..eecedb3
--- /dev/null
+++ b/game/testing/terrain.png
Binary files differ
diff --git a/game/testing/terrain.png.import b/game/testing/terrain.png.import
new file mode 100644
index 0000000..184e17a
--- /dev/null
+++ b/game/testing/terrain.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://74rtygmbogbi"
+path="res://.godot/imported/terrain.png-ffe6c2f8d5e846f787c1312f09317a21.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://testing/terrain.png"
+dest_files=["res://.godot/imported/terrain.png-ffe6c2f8d5e846f787c1312f09317a21.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/testing/testing.gdshader b/game/testing/testing.gdshader
new file mode 100644
index 0000000..bdc87df
--- /dev/null
+++ b/game/testing/testing.gdshader
@@ -0,0 +1,11 @@
+shader_type spatial;
+uniform sampler2D heightmap;
+uniform float height_ration = 1.0;
+
+void fragment() {
+ // Place fragment code here.
+}
+
+void vertex() {
+ VERTEX.y = texture(heightmap, UV).r * height_ration;
+}
diff --git a/game/testing/testing.tscn b/game/testing/testing.tscn
new file mode 100644
index 0000000..993e318
--- /dev/null
+++ b/game/testing/testing.tscn
@@ -0,0 +1,115 @@
+[gd_scene load_steps=18 format=3 uid="uid://bh6us7qfprpc6"]
+
+[ext_resource type="Script" path="res://addons/zylann.hterrain/hterrain.gd" id="1_vnlln"]
+[ext_resource type="Resource" path="res://testing/dataft/data.hterrain" id="2_7sfpq"]
+[ext_resource type="Script" path="res://addons/zylann.hterrain/hterrain_texture_set.gd" id="3_l4lvw"]
+[ext_resource type="Shader" path="res://testing/testing.gdshader" id="4_d4jdj"]
+[ext_resource type="Texture2D" uid="uid://dl45nhftyvbof" path="res://testing/mountains1.png" id="5_u3vhh"]
+[ext_resource type="Shader" path="res://testing/water.gdshader" id="6_y3s8d"]
+
+[sub_resource type="Resource" id="Resource_hwcui"]
+script = ExtResource("3_l4lvw")
+mode = 0
+textures = [[], []]
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_u8as5"]
+render_priority = 0
+shader = ExtResource("4_d4jdj")
+shader_parameter/height_ration = 5.0
+shader_parameter/heightmap = ExtResource("5_u3vhh")
+
+[sub_resource type="PlaneMesh" id="PlaneMesh_cr2e3"]
+size = Vector2(200, 200)
+subdivide_width = 100
+subdivide_depth = 100
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_i35q3"]
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_2oefh"]
+seamless = true
+as_normal_map = true
+noise = SubResource("FastNoiseLite_i35q3")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_7emex"]
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_cga1x"]
+seamless = true
+as_normal_map = true
+noise = SubResource("FastNoiseLite_7emex")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_oe6vd"]
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_esb1b"]
+seamless = true
+noise = SubResource("FastNoiseLite_oe6vd")
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_k47d8"]
+render_priority = 0
+shader = ExtResource("6_y3s8d")
+shader_parameter/albedo = Color(0, 0, 0.0823529, 1)
+shader_parameter/albedo2 = Color(0, 0, 0.301961, 1)
+shader_parameter/color_shallow = Color(0, 0, 0, 1)
+shader_parameter/metallic = 0.483
+shader_parameter/roughness = 0.02
+shader_parameter/wave_time = 0.0
+shader_parameter/wave_direction = Vector2(2, 0)
+shader_parameter/wave_2_direction = Vector2(0, 1)
+shader_parameter/time_scale = 0.025
+shader_parameter/wave_speed = 2.0
+shader_parameter/noise_scale = 10.0
+shader_parameter/height_scale = 0.15
+shader_parameter/beers_law = 0.0
+shader_parameter/depth_offset = 0.0
+shader_parameter/edge_scale = 0.1
+shader_parameter/near = 0.1
+shader_parameter/far = 10000.0
+shader_parameter/edge_color = Color(0.415686, 0.490196, 0.8, 1)
+shader_parameter/texture_normal = SubResource("NoiseTexture2D_2oefh")
+shader_parameter/texture_normal2 = SubResource("NoiseTexture2D_cga1x")
+shader_parameter/wave = SubResource("NoiseTexture2D_esb1b")
+
+[sub_resource type="QuadMesh" id="QuadMesh_8bjqq"]
+size = Vector2(5000, 5000)
+subdivide_width = 100
+subdivide_depth = 100
+orientation = 1
+
+[node name="testing" type="Node"]
+
+[node name="HTerrain" type="Node3D" parent="."]
+script = ExtResource("1_vnlln")
+_terrain_data = ExtResource("2_7sfpq")
+chunk_size = 32
+collision_enabled = true
+collision_layer = 1
+collision_mask = 1
+shader_type = "Classic4"
+custom_shader = null
+custom_globalmap_shader = null
+texture_set = SubResource("Resource_hwcui")
+render_layers = 1
+cast_shadow = 0
+shader_params/u_ground_uv_scale_per_texture = null
+shader_params/u_depth_blending = true
+shader_params/u_triplanar = null
+shader_params/u_tile_reduction = null
+shader_params/u_globalmap_blend_start = null
+shader_params/u_globalmap_blend_distance = null
+shader_params/u_colormap_opacity_per_texture = null
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 346.259, 0, 97.7753)
+visible = false
+material_override = SubResource("ShaderMaterial_u8as5")
+mesh = SubResource("PlaneMesh_cr2e3")
+
+[node name="StaticBody3D" type="StaticBody3D" parent="MeshInstance3D"]
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="MeshInstance3D/StaticBody3D"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.555969, -7.62939e-06, -0.0280457)
+
+[node name="MeshInstance3D2" type="MeshInstance3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
+material_override = SubResource("ShaderMaterial_k47d8")
+cast_shadow = 0
+mesh = SubResource("QuadMesh_8bjqq")
diff --git a/game/testing/water.gdshader b/game/testing/water.gdshader
new file mode 100644
index 0000000..2e1ebd0
--- /dev/null
+++ b/game/testing/water.gdshader
@@ -0,0 +1,80 @@
+shader_type spatial;
+render_mode depth_draw_always;
+
+uniform sampler2D SCREEN_TEXTURE: hint_screen_texture, filter_linear_mipmap;
+uniform sampler2D DEPTH_TEXTURE: hint_depth_texture, filter_linear_mipmap;
+
+uniform vec3 albedo : source_color;
+uniform vec3 albedo2 : source_color;
+uniform vec4 color_shallow : source_color;
+
+uniform float metallic : hint_range(0.0, 1.0) = 0;
+uniform float roughness : hint_range(0.0, 1.0) = 0.02;
+
+uniform sampler2D texture_normal;
+uniform sampler2D texture_normal2;
+uniform sampler2D wave;
+
+uniform float wave_time = 0;
+uniform vec2 wave_direction = vec2(2.0,0.0);
+uniform vec2 wave_2_direction = vec2(0.0,1.0);
+uniform float time_scale : hint_range(0.0, 0.2, 0.005) = 0.025;
+uniform float wave_speed = 2.0;
+uniform float noise_scale = 10.0;
+uniform float height_scale = 0.15;
+uniform float beers_law = 2.0;
+uniform float depth_offset = -0.75;
+
+varying float height;
+varying vec3 world_pos;
+
+uniform float edge_scale = 0.1;
+uniform float near = 0.5;
+uniform float far = 100.0;
+uniform vec3 edge_color : source_color;
+
+float fresnel(float amount, vec3 normal, vec3 view)
+{
+ return pow((1.0 - clamp(dot(normalize(normal), normalize(view)), 0.0, 1.0 )), amount);
+}
+
+float edge(float depth) {
+ return near * far / (far + depth * (near - far));
+}
+
+void vertex() {
+ world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+ height = texture(wave, world_pos.xz / noise_scale + wave_time * wave_speed).r;
+ VERTEX.y += height * height_scale;
+}
+
+void fragment() {
+ float depth_texture = texture(DEPTH_TEXTURE, SCREEN_UV).r;
+ float depth = PROJECTION_MATRIX[3][2] / (depth_texture + PROJECTION_MATRIX[2][2]);
+ depth = depth + VERTEX.z;
+ float depth_blend = exp((depth + depth_offset) * -beers_law);
+ depth_blend = clamp(1.0 - depth_blend, 0.0, 1.0);
+
+ vec3 screen_color = textureLod(SCREEN_TEXTURE, SCREEN_UV, depth_blend * 2.5).rgb;
+ vec3 depth_color = mix(color_shallow.rgb, albedo.rgb, depth_blend);
+ vec3 color = mix(screen_color * depth_color, depth_color * 0.25, depth_blend * 0.5);
+
+ float z_depth = edge(texture(DEPTH_TEXTURE, SCREEN_UV).x);
+ float z_pos = edge(FRAGCOORD.z);
+ float z_dif = z_depth - z_pos;
+
+ vec2 time = (TIME * wave_direction) * time_scale;
+ vec2 time2 = (TIME * wave_2_direction) * time_scale;
+
+ vec3 normal_blend = mix(texture(texture_normal, world_pos.xz / noise_scale + time).rgb, texture(texture_normal2, world_pos.xz / noise_scale + time2).rgb, 0.5);
+
+ float fresnel = fresnel(5.0, NORMAL, VIEW);
+ vec3 surface_color = mix(albedo, albedo2, fresnel);
+ vec3 depth_color_adj = mix(edge_color, color, step(edge_scale, z_dif));
+
+ ALBEDO = clamp(surface_color + depth_color_adj,vec3(0),vec3(1.0));
+ ALPHA = 1.0;
+ METALLIC = metallic;
+ ROUGHNESS = roughness;
+ NORMAL_MAP = normal_blend;
+} \ No newline at end of file
diff --git a/game/testing/water.tscn b/game/testing/water.tscn
new file mode 100644
index 0000000..c336ccc
--- /dev/null
+++ b/game/testing/water.tscn
@@ -0,0 +1,69 @@
+[gd_scene load_steps=11 format=3 uid="uid://cty2qgt0i5f4w"]
+
+[ext_resource type="Shader" path="res://assets/shaders/water.gdshader" id="1_oeaqx"]
+[ext_resource type="Script" path="res://Water.gd" id="2_qv0cn"]
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_hy6fq"]
+fractal_type = 2
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_brvoh"]
+seamless = true
+as_normal_map = true
+bump_strength = 1.5
+noise = SubResource("FastNoiseLite_hy6fq")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_hs2gb"]
+noise_type = 3
+fractal_type = 2
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_u5h0n"]
+seamless = true
+as_normal_map = true
+bump_strength = 1.5
+noise = SubResource("FastNoiseLite_hs2gb")
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_jbi1g"]
+noise_type = 3
+frequency = 0.001
+fractal_type = 2
+fractal_octaves = 3
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_7k2l2"]
+seamless = true
+noise = SubResource("FastNoiseLite_jbi1g")
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_0n3y0"]
+render_priority = 0
+shader = ExtResource("1_oeaqx")
+shader_parameter/albedo = Color(0, 0.321569, 0.431373, 1)
+shader_parameter/albedo2 = Color(0, 0.47451, 0.764706, 1)
+shader_parameter/color_deep = Color(0.105882, 0.294118, 0.329412, 1)
+shader_parameter/color_shallow = Color(0, 0.552941, 0.65098, 1)
+shader_parameter/metallic = 0.0
+shader_parameter/roughness = 0.02
+shader_parameter/wave_time = 0.0
+shader_parameter/wave_direction = Vector2(2, 0)
+shader_parameter/wave_2_direction = Vector2(0, 1)
+shader_parameter/time_scale = 0.025
+shader_parameter/wave_speed = 0.2
+shader_parameter/noise_scale = 10.0
+shader_parameter/height_scale = 2.0
+shader_parameter/beers_law = 0.089
+shader_parameter/depth_offset = -0.75
+shader_parameter/edge_scale = 0.362
+shader_parameter/near = 0.5
+shader_parameter/far = 100.0
+shader_parameter/edge_color = Color(1, 1, 1, 1)
+shader_parameter/texture_normal = SubResource("NoiseTexture2D_brvoh")
+shader_parameter/texture_normal2 = SubResource("NoiseTexture2D_u5h0n")
+shader_parameter/wave = SubResource("NoiseTexture2D_7k2l2")
+
+[sub_resource type="PlaneMesh" id="PlaneMesh_0xwda"]
+material = SubResource("ShaderMaterial_0n3y0")
+size = Vector2(500, 500)
+subdivide_width = 500
+subdivide_depth = 500
+
+[node name="Water" type="MeshInstance3D"]
+mesh = SubResource("PlaneMesh_0xwda")
+script = ExtResource("2_qv0cn")