diff --git a/addons/zylann.hterrain/LICENSE.md b/addons/zylann.hterrain/LICENSE.md new file mode 100644 index 0000000..fe7c3f8 --- /dev/null +++ b/addons/zylann.hterrain/LICENSE.md @@ -0,0 +1,11 @@ +HeightMap terrain for Godot Engine +------------------------------------ + +Copyright (c) 2016-2020 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/addons/zylann.hterrain/doc/.gdignore b/addons/zylann.hterrain/doc/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/addons/zylann.hterrain/doc/docs/images/alpha_blending_and_depth_blending.png b/addons/zylann.hterrain/doc/docs/images/alpha_blending_and_depth_blending.png new file mode 100644 index 0000000..e26ecfa Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/alpha_blending_and_depth_blending.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/bad_array_blending.png b/addons/zylann.hterrain/doc/docs/images/bad_array_blending.png new file mode 100644 index 0000000..7cf6d49 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/bad_array_blending.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/brush_editor.png b/addons/zylann.hterrain/doc/docs/images/brush_editor.png new file mode 100644 index 0000000..6641352 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/brush_editor.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/channel_packer.png b/addons/zylann.hterrain/doc/docs/images/channel_packer.png new file mode 100644 index 0000000..1edd264 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/channel_packer.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/choose_bullet_physics.png b/addons/zylann.hterrain/doc/docs/images/choose_bullet_physics.png new file mode 100644 index 0000000..0ee70ae Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/choose_bullet_physics.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/color_painting.png b/addons/zylann.hterrain/doc/docs/images/color_painting.png new file mode 100644 index 0000000..c8f7ad1 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/color_painting.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/data_directory_property.png b/addons/zylann.hterrain/doc/docs/images/data_directory_property.png new file mode 100644 index 0000000..29ecfc7 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/data_directory_property.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/default_terrain.png b/addons/zylann.hterrain/doc/docs/images/default_terrain.png new file mode 100644 index 0000000..106cedf Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/default_terrain.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/detail_layers.png b/addons/zylann.hterrain/doc/docs/images/detail_layers.png new file mode 100644 index 0000000..c0e3975 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/detail_layers.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/dilation.png b/addons/zylann.hterrain/doc/docs/images/dilation.png new file mode 100644 index 0000000..820dbd2 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/dilation.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/erosion_slope.png b/addons/zylann.hterrain/doc/docs/images/erosion_slope.png new file mode 100644 index 0000000..698ac62 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/erosion_slope.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/erosion_steps.png b/addons/zylann.hterrain/doc/docs/images/erosion_steps.png new file mode 100644 index 0000000..9498f70 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/erosion_steps.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/generator.png b/addons/zylann.hterrain/doc/docs/images/generator.png new file mode 100644 index 0000000..44012b6 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/generator.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/gimp_png_preserve_colors.png b/addons/zylann.hterrain/doc/docs/images/gimp_png_preserve_colors.png new file mode 100644 index 0000000..c1a1729 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/gimp_png_preserve_colors.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/grass_models.png b/addons/zylann.hterrain/doc/docs/images/grass_models.png new file mode 100644 index 0000000..2e9370f Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/grass_models.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/hole_painting.png b/addons/zylann.hterrain/doc/docs/images/hole_painting.png new file mode 100644 index 0000000..c9ad329 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/hole_painting.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/ignore_tools_on_export.png b/addons/zylann.hterrain/doc/docs/images/ignore_tools_on_export.png new file mode 100644 index 0000000..702facd Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/ignore_tools_on_export.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/importer.png b/addons/zylann.hterrain/doc/docs/images/importer.png new file mode 100644 index 0000000..84f372f Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/importer.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/inspector_texture_set.png b/addons/zylann.hterrain/doc/docs/images/inspector_texture_set.png new file mode 100644 index 0000000..3cca8f8 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/inspector_texture_set.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/inspector_texture_set_save.png b/addons/zylann.hterrain/doc/docs/images/inspector_texture_set_save.png new file mode 100644 index 0000000..e4c48a1 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/inspector_texture_set_save.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/lod_geometry.png b/addons/zylann.hterrain/doc/docs/images/lod_geometry.png new file mode 100644 index 0000000..4b898be Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/lod_geometry.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/lookdev_grass.png b/addons/zylann.hterrain/doc/docs/images/lookdev_grass.png new file mode 100644 index 0000000..3c5c939 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/lookdev_grass.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/lookdev_menu.png b/addons/zylann.hterrain/doc/docs/images/lookdev_menu.png new file mode 100644 index 0000000..17b61c8 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/lookdev_menu.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/lots_of_textures_blending.png b/addons/zylann.hterrain/doc/docs/images/lots_of_textures_blending.png new file mode 100644 index 0000000..0ef702b Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/lots_of_textures_blending.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/low_poly.png b/addons/zylann.hterrain/doc/docs/images/low_poly.png new file mode 100644 index 0000000..d518309 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/low_poly.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/normalmap_conventions.png b/addons/zylann.hterrain/doc/docs/images/normalmap_conventions.png new file mode 100644 index 0000000..ef9e4b1 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/normalmap_conventions.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/overview.png b/addons/zylann.hterrain/doc/docs/images/overview.png new file mode 100644 index 0000000..b84815a Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/overview.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/panel_import_button.png b/addons/zylann.hterrain/doc/docs/images/panel_import_button.png new file mode 100644 index 0000000..d0eb5b7 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/panel_import_button.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/panel_textures_edit_button.png b/addons/zylann.hterrain/doc/docs/images/panel_textures_edit_button.png new file mode 100644 index 0000000..58b48b1 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/panel_textures_edit_button.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/pbr_textures.png b/addons/zylann.hterrain/doc/docs/images/pbr_textures.png new file mode 100644 index 0000000..ffc3188 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/pbr_textures.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/resize_tool.png b/addons/zylann.hterrain/doc/docs/images/resize_tool.png new file mode 100644 index 0000000..c78350e Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/resize_tool.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/sculpting_tools.png b/addons/zylann.hterrain/doc/docs/images/sculpting_tools.png new file mode 100644 index 0000000..dcfddf3 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/sculpting_tools.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/single_sampling_and_triplanar_sampling.png b/addons/zylann.hterrain/doc/docs/images/single_sampling_and_triplanar_sampling.png new file mode 100644 index 0000000..1eb05e9 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/single_sampling_and_triplanar_sampling.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/slope_limit_tool.png b/addons/zylann.hterrain/doc/docs/images/slope_limit_tool.png new file mode 100644 index 0000000..1320d58 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/slope_limit_tool.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/splatmap_and_textured_result.png b/addons/zylann.hterrain/doc/docs/images/splatmap_and_textured_result.png new file mode 100644 index 0000000..5c9f008 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/splatmap_and_textured_result.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_array_import_dock.png b/addons/zylann.hterrain/doc/docs/images/texture_array_import_dock.png new file mode 100644 index 0000000..17e0ea1 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_array_import_dock.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_atlas_example.png b/addons/zylann.hterrain/doc/docs/images/texture_atlas_example.png new file mode 100644 index 0000000..6a04498 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_atlas_example.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_dialog.png b/addons/zylann.hterrain/doc/docs/images/texture_dialog.png new file mode 100644 index 0000000..431b5c9 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_dialog.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_set_editor.png b/addons/zylann.hterrain/doc/docs/images/texture_set_editor.png new file mode 100644 index 0000000..f80d478 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_set_editor.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool.png b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool.png new file mode 100644 index 0000000..bf5daf0 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_directory.png b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_directory.png new file mode 100644 index 0000000..1ce61f0 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_directory.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_success.png b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_success.png new file mode 100644 index 0000000..5a75cf8 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_success.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_texture_types.png b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_texture_types.png new file mode 100644 index 0000000..55ce92a Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_set_import_tool_texture_types.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/texture_slots.png b/addons/zylann.hterrain/doc/docs/images/texture_slots.png new file mode 100644 index 0000000..cfebe7b Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/texture_slots.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/tiling_artifacts.png b/addons/zylann.hterrain/doc/docs/images/tiling_artifacts.png new file mode 100644 index 0000000..37b9d2c Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/tiling_artifacts.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/tiling_reduction.png b/addons/zylann.hterrain/doc/docs/images/tiling_reduction.png new file mode 100644 index 0000000..af36cb3 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/tiling_reduction.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/transition_array_blending.png b/addons/zylann.hterrain/doc/docs/images/transition_array_blending.png new file mode 100644 index 0000000..45d2c8e Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/transition_array_blending.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/update_editor_collider.png b/addons/zylann.hterrain/doc/docs/images/update_editor_collider.png new file mode 100644 index 0000000..7c58aa5 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/update_editor_collider.png differ diff --git a/addons/zylann.hterrain/doc/docs/images/warped_checker_variations.png b/addons/zylann.hterrain/doc/docs/images/warped_checker_variations.png new file mode 100644 index 0000000..4e756f6 Binary files /dev/null and b/addons/zylann.hterrain/doc/docs/images/warped_checker_variations.png differ diff --git a/addons/zylann.hterrain/doc/docs/index.md b/addons/zylann.hterrain/doc/docs/index.md new file mode 100644 index 0000000..fa0dbd4 --- /dev/null +++ b/addons/zylann.hterrain/doc/docs/index.md @@ -0,0 +1,1150 @@ +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) + + +### How to install + +You will need to use Godot 3.1 or later. It is best to use latest stable 3.x version (Godot 4 is not supported yet). + +#### 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/ + +``` + +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 developped 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 `strength` 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. +This plugin depends on the **Bullet Physics** integration in Godot, which does have a height-field collider. **Godot Physics** does not support it, so you may want to make sure Bullet is enabled in your project settings: + +![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) + + +#### 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. +- `ARRAY`: more modern shader using texture arrays, which comes with a few 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). + +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. +- `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 has 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 throught `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 half-precision 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 mentionned 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= 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= 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 mentionned 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) +- 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/addons/zylann.hterrain/doc/mkdocs.yml b/addons/zylann.hterrain/doc/mkdocs.yml new file mode 100644 index 0000000..3ece331 --- /dev/null +++ b/addons/zylann.hterrain/doc/mkdocs.yml @@ -0,0 +1,15 @@ +site_name: HTerrain plugin documentation +theme: readthedocs + +# I had to specify this even though it's supposed to be the default +# See https://github.com/mkdocs/mkdocs/issues/2145#issuecomment-735342512 +docs_dir: docs + +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/addons/zylann.hterrain/doc/requirements.txt b/addons/zylann.hterrain/doc/requirements.txt new file mode 100644 index 0000000..3143080 --- /dev/null +++ b/addons/zylann.hterrain/doc/requirements.txt @@ -0,0 +1 @@ +mkdocs>=1.1.2 \ No newline at end of file diff --git a/addons/zylann.hterrain/hterrain.gd b/addons/zylann.hterrain/hterrain.gd new file mode 100644 index 0000000..163245b --- /dev/null +++ b/addons/zylann.hterrain/hterrain.gd @@ -0,0 +1,1548 @@ +tool +extends Spatial + +const QuadTreeLod = preload("./util/quad_tree_lod.gd") +const Mesher = preload("./hterrain_mesher.gd") +const 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 Util = preload("./util/util.gd") +const HTerrainCollider = preload("./hterrain_collider.gd") +const HTerrainTextureSet = preload("./hterrain_texture_set.gd") +const 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 + +const _SHADER_TYPE_HINT_STRING = str( + "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.shader", + global_path = "res://addons/zylann.hterrain/shaders/simple4_global.shader" + }, + SHADER_CLASSIC4_LITE: { + path = "res://addons/zylann.hterrain/shaders/simple4_lite.shader", + global_path = "res://addons/zylann.hterrain/shaders/simple4_global.shader" + }, + SHADER_LOW_POLY: { + path = "res://addons/zylann.hterrain/shaders/low_poly.shader", + global_path = "" # Not supported + }, + SHADER_ARRAY: { + path = "res://addons/zylann.hterrain/shaders/array.shader", + global_path = "res://addons/zylann.hterrain/shaders/array_global.shader" + }, + SHADER_MULTISPLAT16: { + path = "res://addons/zylann.hterrain/shaders/multisplat16.shader", + global_path = "res://addons/zylann.hterrain/shaders/multisplat16_global.shader" + }, + SHADER_MULTISPLAT16_LITE: { + path = "res://addons/zylann.hterrain/shaders/multisplat16_lite.shader", + global_path = "res://addons/zylann.hterrain/shaders/multisplat16_global.shader" + } +} + +const _NORMAL_BAKER_PATH = "res://addons/zylann.hterrain/tools/normalmap_baker.gd" +const _LOOKDEV_SHADER_PATH = "res://addons/zylann.hterrain/shaders/lookdev.shader" + +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(float, 0.0, 1.0) var ambient_wind := 0.0 setget set_ambient_wind +export(int, 2, 5) var lod_scale := 2.0 setget set_lod_scale, get_lod_scale + +# TODO Replace with `size` in world units? +# Prefer using this instead of scaling the node's transform. +# Spatial.scale isn't used because it's not suitable for terrains, +# it would scale grass too and other environment objects. +export var map_scale := Vector3(1, 1, 1) setget set_map_scale + +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 + +# 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 := Mesher.new() +var _lodder := QuadTreeLod.new() +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 = 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 overriden shortly after by the scene loader. + + _lodder.set_callbacks( \ + funcref(self, "_cb_make_chunk"), \ + funcref(self,"_cb_recycle_chunk"), \ + funcref(self, "_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_param("u_ground_uv_scale", 20) + _material.set_shader_param("u_ground_uv_scale_vec4", Color(20, 20, 20, 20)) + _material.set_shader_param("u_depth_blending", true) + + _material.shader = load(_builtin_shaders[_shader_type].path) + + _texture_set.connect("changed", self, "_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": "Shader", + "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" + } + ] + + if _material.shader != null: + var shader_params := VisualServer.shader_get_param_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: String): + 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.right(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 + + +func _set(key: String, 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 + if key.begins_with("ground/"): + for ground_texture_type in HTerrainTextureSet.TYPE_COUNT: + var type_name = _ground_enum_to_name[ground_texture_type] + if key.begins_with(str("ground/", type_name, "_")): + var i = key.right(len(key) - 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 = key.right(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) + + +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.disconnect("changed", self, "_on_texture_set_changed") + + _texture_set = new_set + + if _texture_set != null: + _texture_set.connect("changed", self, "_on_texture_set_changed") + + _material_params_need_update = true + + +func _on_texture_set_changed(): + _material_params_need_update = true + Util.update_configuration_warning(self, false) + + +func get_shader_param(param_name: String): + return _material.get_shader_param(param_name) + + +func set_shader_param(param_name: String, v): + _material.set_shader_param(param_name, v) + + +func _set_data_directory(dirpath: String): + if dirpath != _get_data_directory(): + if dirpath == "": + set_data(null) + else: + var fpath := dirpath.plus_file(HTerrainData.META_FILENAME) + var f := File.new() + if f.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: + 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 range(len(_chunks)): + var grid = _chunks[lod] + for y in range(len(grid)): + var row = grid[y] + for x in range(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 = 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() + + +func set_map_scale(p_map_scale: Vector3): + if map_scale == p_map_scale: + return + p_map_scale.x = max(p_map_scale.x, MIN_MAP_SCALE) + p_map_scale.y = max(p_map_scale.y, MIN_MAP_SCALE) + p_map_scale.z = max(p_map_scale.z, MIN_MAP_SCALE) + map_scale = p_map_scale + _on_transform_changed() + + +# Gets the global transform to apply to terrain geometry, +# which is different from Spatial.global_transform gives +# (that one must only have translation) +func get_internal_transform() -> Transform: + # Terrain can only be self-scaled and translated, + return Transform(Basis().scaled(map_scale), global_transform.origin) + + +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(EnterWorldAction.new(get_world())) + if _collider != null: + _collider.set_world(get_world()) + _collider.set_transform(get_internal_transform()) + + NOTIFICATION_EXIT_WORLD: + _logger.debug("Exit world") + _for_all_chunks(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(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(TransformChangedAction.new(gt)) + + _material_params_need_update = true + + if _collider != null: + _collider.set_transform(gt) + + emit_signal("transform_changed", gt) + + +func _enter_tree(): + _logger.debug("Enter tree") + + if Engine.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 range(len(_chunks)): + _chunks[i].clear() + + +func _get_chunk_at(pos_x: int, pos_y: int, lod: int) -> HTerrainChunk: + if lod < len(_chunks): + return 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.disconnect("resolution_changed", self, "_on_data_resolution_changed") + _data.disconnect("region_changed", self, "_on_data_region_changed") + _data.disconnect("map_changed", self, "_on_data_map_changed") + _data.disconnect("map_added", self, "_on_data_map_added") + _data.disconnect("map_removed", self, "_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.connect("resolution_changed", self, "_on_data_resolution_changed") + _data.connect("region_changed", self, "_on_data_region_changed") + _data.connect("map_changed", self, "_on_data_map_changed") + _data.connect("map_added", self, "_on_data_map_added") + _data.connect("map_removed", self, "_on_data_map_removed") + + if _normals_baker != null: + _normals_baker.set_terrain_data(_data) + + _on_data_resolution_changed() + + _material_params_need_update = true + + 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 range(_lodder.get_lod_count()): + _logger.debug(str("Create grid for lod ", lod, ", ", csize_x, "x", csize_y)) + var grid = 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 + 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 + 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.editor_hint: + property_list_changed_notify() + + +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.disconnect("changed", self, "_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.get_code().empty(): + _logger.debug("Populating custom shader with default code") + var src := _material.shader + if src == null: + src = load(_builtin_shaders[SHADER_CLASSIC4].path) + shader.set_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.connect("changed", self, "_on_custom_shader_changed") + if _shader_type == SHADER_CUSTOM: + _material_params_need_update = true + + if Engine.editor_hint: + property_list_changed_notify() + + +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 sytem. + + if is_inside_tree(): + var gt = get_internal_transform() + var t = gt.affine_inverse() + _material.set_shader_param(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_param(SHADER_PARAM_NORMAL_BASIS, normal_basis) + + if lookdev_material != null: + lookdev_material.set_shader_param(SHADER_PARAM_INVERSE_TRANSFORM, t) + lookdev_material.set_shader_param(SHADER_PARAM_NORMAL_BASIS, normal_basis) + + for param_name in terrain_textures: + var tex = terrain_textures[param_name] + _material.set_shader_param(param_name, tex) + if lookdev_material != null: + lookdev_material.set_shader_param(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_param(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_param(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 := VisualServer.shader_get_param_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 := VisualServer.shader_get_param_list(shader1.get_rid()) + var shader2_params := VisualServer.shader_get_param_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_param(param_name) + mat.set_shader_param(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 + + +func set_lod_scale(lod_scale: float): + _lodder.set_split_scale(lod_scale) + + +func get_lod_scale() -> float: + return _lodder.get_split_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: Camera): + _update_viewer_position(camera) + + +func _update_viewer_position(camera: Camera): + if camera == null: + var viewport := get_viewport() + if viewport != null: + camera = viewport.get_camera() + + if camera == null: + return + + if camera.projection == Camera.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) + var viewer_pos := _viewer_pos_world + + 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() + var local_viewer_pos := gt.affine_inverse() * viewer_pos + #var time_before = OS.get_ticks_msec() + _lodder.update(local_viewer_pos) + #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) + + _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 range(precount): + var u: 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 range(len(_pending_chunk_updates)): + var u: 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() + 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 := 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 range(_lodder.get_lod_count()): + # Get grid and chunk size + var grid = _chunks[lod] + var s := _lodder.get_lod_size(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 = 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 := _lodder.get_lod_size(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()) + + 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 := _chunk_size * _lodder.get_lod_size(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.xform(origin_world) + var dir = to_local.basis.xform(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_param(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_param(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) -> TextureArray: + _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_param(param_name) + + +# @obsolete +func set_ground_texture_array(type: int, texture_array: TextureArray): + _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_param(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") + + +func set_ambient_wind(amplitude: float): + if ambient_wind == amplitude: + return + ambient_wind = amplitude + for layer in _detail_layers: + layer.update_material() + + +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_warning(): + if _data == null: + return "The terrain is missing data.\n" \ + + "Select the `Data Directory` property in the inspector to assign it." + + if _texture_set == null: + return "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(): + return "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(): + return "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 "" + + +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(SetMaterialAction.new(_get_lookdev_material())) + else: + _for_all_chunks(SetMaterialAction.new(_material)) + + +func set_lookdev_shader_param(param_name: String, value): + var mat = _get_lookdev_material() + mat.set_shader_param(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 PendingChunkUpdate: + var pos_x := 0 + var pos_y := 0 + var lod := 0 + + +class EnterWorldAction: + var world : World = null + func _init(w): + world = w + func exec(chunk): + chunk.enter_world(world) + + +class ExitWorldAction: + func exec(chunk): + chunk.exit_world() + + +class TransformChangedAction: + var transform : Transform + func _init(t): + transform = t + func exec(chunk): + chunk.parent_transform_changed(transform) + + +class VisibilityChangedAction: + var visible := false + func _init(v): + visible = v + func exec(chunk): + chunk.set_visible(visible and chunk.is_active()) + + +#class DeleteChunkAction: +# func exec(chunk): +# pass + + +class SetMaterialAction: + var material : Material = null + func _init(m): + material = m + func exec(chunk): + chunk.set_material(material) + + diff --git a/addons/zylann.hterrain/hterrain_chunk.gd b/addons/zylann.hterrain/hterrain_chunk.gd new file mode 100644 index 0000000..0cd9e7e --- /dev/null +++ b/addons/zylann.hterrain/hterrain_chunk.gd @@ -0,0 +1,115 @@ +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, p_cell_x: int, p_cell_y: int, p_material: Material): + assert(p_parent is Spatial) + 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 vs = VisualServer + + _mesh_instance = vs.instance_create() + + if p_material != null: + vs.instance_geometry_set_material_override(_mesh_instance, p_material.get_rid()) + + var world = p_parent.get_world() + if world != null: + vs.instance_set_scenario(_mesh_instance, world.get_scenario()) + + _visible = true + # TODO Is this needed? + vs.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(): + VisualServer.free_rid(_mesh_instance) + _mesh_instance = RID() + + +func is_active() -> bool: + return _active + + +func set_active(a): + _active = a + + +func is_pending_update() -> bool: + return _pending_update + + +func set_pending_update(p): + _pending_update = p + + +func enter_world(world): + assert(_mesh_instance != RID()) + VisualServer.instance_set_scenario(_mesh_instance, world.get_scenario()) + + +func exit_world(): + assert(_mesh_instance != RID()) + VisualServer.instance_set_scenario(_mesh_instance, RID()) + + +func parent_transform_changed(parent_transform): + assert(_mesh_instance != RID()) + var local_transform = Transform(Basis(), Vector3(cell_origin_x, 0, cell_origin_y)) + var world_transform = parent_transform * local_transform + VisualServer.instance_set_transform(_mesh_instance, world_transform) + + +func set_mesh(mesh: Mesh): + assert(_mesh_instance != RID()) + if mesh == _mesh: + return + VisualServer.instance_set_base(_mesh_instance, mesh.get_rid() if mesh != null else RID()) + _mesh = mesh + + +func set_material(material: Material): + assert(_mesh_instance != RID()) + VisualServer.instance_geometry_set_material_override( \ + _mesh_instance, material.get_rid() if material != null else RID()) + + +func set_visible(visible: bool): + assert(_mesh_instance != RID()) + VisualServer.instance_set_visible(_mesh_instance, visible) + _visible = visible + + +func is_visible() -> bool: + return _visible + + +func set_aabb(aabb: AABB): + assert(_mesh_instance != RID()) + VisualServer.instance_set_custom_aabb(_mesh_instance, aabb) + diff --git a/addons/zylann.hterrain/hterrain_chunk_debug.gd b/addons/zylann.hterrain/hterrain_chunk_debug.gd new file mode 100644 index 0000000..0d2e7fa --- /dev/null +++ b/addons/zylann.hterrain/hterrain_chunk_debug.gd @@ -0,0 +1,64 @@ +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 DirectMeshInstance = preload("./util/direct_mesh_instance.gd") +const Util = preload("./util/util.gd") + + +var _debug_cube = null +var _aabb = AABB() +var _parent_transform = Transform() + + +func _init(p_parent, p_cell_x, p_cell_y, p_material).(p_parent, p_cell_x, p_cell_y, p_material): + var wirecube + if not p_parent.has_meta("debug_wirecube_mesh"): + wirecube = Util.create_wirecube_mesh() + var mat = SpatialMaterial.new() + mat.flags_unshaded = true + wirecube.surface_set_material(0, mat) + p_parent.set_meta("debug_wirecube_mesh", wirecube) + else: + wirecube = p_parent.get_meta("debug_wirecube_mesh") + + _debug_cube = DirectMeshInstance.new() + _debug_cube.set_mesh(wirecube) + _debug_cube.set_world(p_parent.get_world()) + + +func enter_world(world): + .enter_world(world) + _debug_cube.enter_world(world) + + +func exit_world(): + .exit_world() + _debug_cube.exit_world() + + +func parent_transform_changed(parent_transform): + .parent_transform_changed(parent_transform) + _parent_transform = parent_transform + _debug_cube.set_transform(_compute_aabb()) + + +func set_visible(visible): + .set_visible(visible) + _debug_cube.set_visible(visible) + + +func set_aabb(aabb): + .set_aabb(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 * Transform(Basis().scaled(_aabb.size), pos + _aabb.position) + diff --git a/addons/zylann.hterrain/hterrain_collider.gd b/addons/zylann.hterrain/hterrain_collider.gd new file mode 100644 index 0000000..e762173 --- /dev/null +++ b/addons/zylann.hterrain/hterrain_collider.gd @@ -0,0 +1,123 @@ +tool + +const Logger = preload("./util/logger.gd") + +var _shape_rid = RID() +var _body_rid = RID() +var _terrain_transform = Transform() +var _terrain_data = null +var _logger = 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 = PhysicsServer.shape_create(PhysicsServer.SHAPE_HEIGHTMAP) + _body_rid = PhysicsServer.body_create(PhysicsServer.BODY_MODE_STATIC) + + PhysicsServer.body_set_collision_layer(_body_rid, initial_layer) + PhysicsServer.body_set_collision_mask(_body_rid, initial_mask) + + # TODO This is an attempt to workaround https://github.com/godotengine/godot/issues/24390 + PhysicsServer.body_set_ray_pickable(_body_rid, false) + + # TODO This is a workaround to https://github.com/godotengine/godot/issues/25304 + PhysicsServer.shape_set_data(_shape_rid, { + "width": 2, + "depth": 2, + "heights": PoolRealArray([0, 0, 0, 0]), + "min_height": -1, + "max_height": 1 + }) + + PhysicsServer.body_add_shape(_body_rid, _shape_rid) + + # This makes collision hits report the provided object as `collider` + PhysicsServer.body_attach_object_instance_id(_body_rid, attached_node.get_instance_id()) + + +func set_collision_layer(layer: int): + PhysicsServer.body_set_collision_layer(_body_rid, layer) + + +func set_collision_mask(mask: int): + PhysicsServer.body_set_collision_mask(_body_rid, mask) + + +func _notification(what): + if what == NOTIFICATION_PREDELETE: + _logger.debug("Destroy HTerrainCollider") + PhysicsServer.free_rid(_body_rid) + # The shape needs to be freed after the body, otherwise the engine crashes + PhysicsServer.free_rid(_shape_rid) + + +func set_transform(transform): + assert(_body_rid != RID()) + _terrain_transform = transform + _update_transform() + + +func set_world(world): + assert(_body_rid != RID()) + PhysicsServer.body_set_space(_body_rid, world.get_space() if world != null else RID()) + + +func create_from_terrain_data(terrain_data): + 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 + } + + PhysicsServer.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 + var v = Engine.get_version_info() + if v.major == 3 and v.minor <= 1: + # Bullet centers the shape to its overall AABB so we need to move it to match the visuals + trans = Transform(Basis(), 0.5 * Vector3(width, height, depth) + Vector3(0, aabb.position.y, 0)) + else: + # In 3.2, vertical centering changed. + # https://github.com/godotengine/godot/pull/28326 + trans = Transform(Basis(), 0.5 * Vector3(width - 1, 0, depth - 1)) + + # And then apply the terrain transform + trans = _terrain_transform * trans + + PhysicsServer.body_set_state(_body_rid, PhysicsServer.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/addons/zylann.hterrain/hterrain_data.gd b/addons/zylann.hterrain/hterrain_data.gd new file mode 100644 index 0000000..f745cb4 --- /dev/null +++ b/addons/zylann.hterrain/hterrain_data.gd @@ -0,0 +1,1648 @@ + +# Holds data of the terrain. +# This is mostly a set of textures using specific formats, some precalculated, and metadata. + +tool +extends Resource + +const Grid = preload("./util/grid.gd") +const Util = preload("./util/util.gd") +const Errors = preload("./util/errors.gd") +const NativeFactory = preload("./native/factory.gd") +const Logger = preload("./util/logger.gd") +const ImageFileCache = preload("./util/image_file_cache.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", + texture_flags = Texture.FLAG_FILTER, + texture_format = Image.FORMAT_RH, + default_fill = null, + default_count = 1, + can_be_saved_as_png = false, + authored = true, + srgb = false + }, + CHANNEL_NORMAL: { + name = "normal", + shader_param_name = "u_terrain_normalmap", + texture_flags = Texture.FLAG_FILTER, + 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" + ], + texture_flags = Texture.FLAG_FILTER, + 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", + texture_flags = Texture.FLAG_FILTER, + 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", + texture_flags = Texture.FLAG_FILTER, + 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", + texture_flags = Texture.FLAG_FILTER | Texture.FLAG_MIPMAPS, + 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", + texture_flags = 0, + 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", + texture_flags = Texture.FLAG_FILTER, + 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 minimum 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 Map: + var texture: Texture + # 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 _image_utils = NativeFactory.get_image_utils() + +var _edit_disable_apply_undo := false +var _logger = 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 = _map_types[c].default_count + for i in range(n): + maps.append(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 = Util.clamp_int(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 = Util.next_power_of_two(p_res - 1) + 1 + + _resolution = p_res; + + for channel in range(CHANNEL_COUNT): + var maps := _maps[channel] as Array + + for index in len(maps): + _logger.debug(str("Resizing ", get_map_debug_name(channel, index), "...")) + + var map := maps[index] as Map + var im := map.image + + if im == null: + _logger.debug("Image not in memory, creating it") + im = Image.new() + im.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) + + map.image = im + + else: + if stretch and not _map_types[channel].authored: + im.create(_resolution, _resolution, false, get_channel_format(channel)) + else: + if stretch: + im.resize(_resolution, _resolution) + else: + var fill_color = _get_map_default_fill_color(channel, index) + map.image = Util.get_cropped_image(im, _resolution, _resolution, \ + fill_color, anchor) + + map.modified = true + + _update_all_vertical_bounds() + + emit_signal("resolution_changed") + + +# 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) + + im.lock(); + var h = Util.get_pixel_clamped(im, x, y).r; + im.unlock(); + return h; + + +# 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) + + # The function takes a Vector3 for convenience so it's easier to use in 3D scripting + var x0 := int(floor(pos.x)) + var y0 := int(floor(pos.z)) + + var xf := pos.x - x0 + var yf := pos.z - y0 + + im.lock() + var h00 = Util.get_pixel_clamped(im, x0, y0).r + var h10 = Util.get_pixel_clamped(im, x0 + 1, y0).r + var h01 = Util.get_pixel_clamped(im, x0, y0 + 1).r + var h11 = Util.get_pixel_clamped(im, x0 + 1, y0 + 1).r + im.unlock() + + # Bilinear filter + var h = lerp(lerp(h00, h10, xf), lerp(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 PoolRealArray. +func get_heights_region(x0: int, y0: int, w: int, h: int) -> PoolRealArray: + var im = get_image(CHANNEL_HEIGHT) + assert(im != null) + + var min_x := Util.clamp_int(x0, 0, im.get_width()) + var min_y := Util.clamp_int(y0, 0, im.get_height()) + var max_x := Util.clamp_int(x0 + w, 0, im.get_width() + 1) + var max_y := Util.clamp_int(y0 + h, 0, im.get_height() + 1) + + var heights := PoolRealArray() + + var area = (max_x - min_x) * (max_y - min_y) + if area == 0: + _logger.debug("Empty heights region!") + return heights + + heights.resize(area) + + im.lock() + + var i := 0 + 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 + + im.unlock() + + return heights + + +# Gets all heights. +# This height is raw and doesn't account for scaling of the terrain node. +# Data is returned as a PoolRealArray. +func get_all_heights() -> PoolRealArray: + 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 +# +# 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 + + emit_signal("region_changed", min_x, min_y, size_x, size_y, p_map_type) + emit_signal("changed") + + +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: 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 := Rect2(0, 0, data.get_width(), data.get_height()) + dst_image.blit_rect(data, src_rect, Vector2(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, map_ids: Dictionary): + if _edit_disable_apply_undo: + return + for map_type in map_ids: + var id = map_ids[map_type] + var src_im = image_file_cache.load_image(id) + if src_im == null: + continue + var index := 0 + var dst_im := get_image(map_type, index) + var rect = Rect2(0, 0, src_im.get_height(), src_im.get_height()) + dst_im.blit_rect(src_im, rect, Vector2()) + notify_region_change(rect, map_type, index) + + +func _upload_channel(channel: int, index: int): + _upload_region(channel, index, 0, 0, _resolution, _resolution) + + +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 : 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 flags = _map_types[channel].texture_flags + + 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.")) + + texture = ImageTexture.new() + texture.create_from_image(image, flags) + + map.texture = texture + + # Need to notify because other systems may want to grab the new texture object + emit_signal("map_changed", channel, index) + + elif 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.")) + texture.create_from_image(image, flags) + + else: + if VisualServer.has_method("texture_set_data_partial"): + VisualServer.texture_set_data_partial( \ + texture.get_rid(), image, \ + min_x, min_y, \ + size_x, size_y, \ + min_x, min_y, \ + 0, 0) + else: + # Godot 3.0.6 and earlier... + # It is slow. + + # ..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 + # + texture.create_from_image(image, flags) + + #_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 = Map.new(_get_free_id(map_type)) + map.image = Image.new() + map.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) + emit_signal("map_added", 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 = Map.new(_get_free_id(map_type)) + map.image = image_cache.load_image(image_id) + maps.insert(index, map) + emit_signal("map_added", 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 = _maps[map_type] + maps.remove(index) + emit_signal("map_removed", 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) -> 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: + var maps : Array = _maps[map_type] + var map : 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 + + _chunked_vertical_bounds.lock() + var b := _chunked_vertical_bounds.get_pixel(cx, cy) + _chunked_vertical_bounds.unlock() + 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 = Util.clamp_int(cmin_x, 0, _chunked_vertical_bounds.get_width() - 1) + cmin_y = Util.clamp_int(cmin_y, 0, _chunked_vertical_bounds.get_height() - 1) + cmax_x = Util.clamp_int(cmax_x, 0, _chunked_vertical_bounds.get_width()) + cmax_y = Util.clamp_int(cmax_y, 0, _chunked_vertical_bounds.get_height()) + + _chunked_vertical_bounds.lock() + + 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 = min(b.r, min_height) + max_height = max(b.g, max_height) + + _chunked_vertical_bounds.unlock() + + 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.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 = Util.clamp_int(cmin_x, 0, _chunked_vertical_bounds.get_width() - 1) + cmin_y = Util.clamp_int(cmin_y, 0, _chunked_vertical_bounds.get_height() - 1) + cmax_x = Util.clamp_int(cmax_x, 0, _chunked_vertical_bounds.get_width()) + cmax_y = Util.clamp_int(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 + + _chunked_vertical_bounds.lock() + + 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)) + + _chunked_vertical_bounds.unlock() + + +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) + return _image_utils.get_red_range(heights, Rect2(origin_x, origin_y, size_x, size_y)) + + +func save_data(data_dir: String): + _logger.debug("Saving terrain data...") + + _locked = true + + _save_metadata(data_dir.plus_file(META_FILENAME)) + + var map_count = _get_total_map_count() + + var pi = 0 + for map_type in range(CHANNEL_COUNT): + var maps = _maps[map_type] + + for index in range(len(maps)): + var map = _maps[map_type][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), "...")) + + _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 + + +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 = File.new() + var err = f.open(path, File.READ) + assert(err == OK) + var text = f.get_as_text() + f.close() + var res = JSON.parse(text) + assert(res.error == OK) + _deserialize_metadata(res.result) + + +func _save_metadata(path: String): + var f = File.new() + var d = _serialize_metadata() + var text = JSON.print(d, "\t", true) + var err = f.open(path, File.WRITE) + assert(err == OK) + f.store_string(text) + f.close() + + +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 = 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 range(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 range(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 = Map.new(id) + maps[j] = map + else: + map.id = id + + return true + + +func load_data(dir_path: String): + _locked = true + + _load_metadata(dir_path.plus_file(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 range(len(_maps)): + var maps = _maps[map_type] + + for index in range(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[map_type][index].modified = false + + pi += 1 + + _logger.debug("Calculating vertical bounds...") + _update_all_vertical_bounds() + + _logger.debug("Notify resolution change...") + + _locked = false + emit_signal("resolution_changed") + + +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 = _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_data() + else: + _logger.debug(str("No data in map ", map_type, "[", index, "]")) + # This data doesn't have such map + return true + + var dir = Directory.new() + if not dir.dir_exists(dir_path): + dir.make_dir(dir_path) + + var fpath = dir_path.plus_file(_get_map_filename(map_type, index)) + + if _channel_can_be_saved_as_png(map_type): + fpath += ".png" + im.save_png(fpath) + _try_write_default_import_options(fpath, map_type, _logger) + + else: + fpath += ".res" + var err = ResourceSaver.save(fpath, im) + if err != OK: + _logger.error("Could not save '{0}', error {1}" \ + .format([fpath, Errors.get_message(err)])) + return false + _try_delete_0_8_0_heightmap(fpath.get_basename(), _logger) + + return true + + +static func _try_write_default_import_options(fpath: String, channel: int, logger): + var imp_fpath = fpath + ".import" + var f := File.new() + if f.file_exists(imp_fpath): + # Already exists + return + + var map_info = _map_types[channel] + var texture_flags: int = map_info.texture_flags + var filter := (texture_flags & Texture.FLAG_FILTER) != 0 + var srgb: bool = map_info.srgb + + var defaults = { + "remap": { + "importer": "texture", + "type": "StreamTexture" + }, + "deps": { + "source_file": fpath + }, + "params": { + # Don't compress. It 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_mode": 0, + "compress/normal_map": 0, + "flags/mipmaps": false, + "flags/filter": filter, + + # 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": false, + } + } + + Util.write_import_file(defaults, imp_fpath, logger) + + +func _load_map(dir: String, map_type: int, index: int) -> bool: + var fpath = dir.plus_file(_get_map_filename(map_type, index)) + + # Maps must be configured before being loaded + var 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" + # In this particular case, we can use Godot ResourceLoader directly, + # if the texture got imported. + + if Engine.editor_hint: + # 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.new() + map.image.load(fpath) + _ensure_map_format(map.image, map_type, index) + + var tex = load(fpath) + map.texture = tex + + else: + var im = _try_load_0_8_0_heightmap(fpath, map_type, map.image, _logger) + if typeof(im) == TYPE_BOOL: + return false + if im == null: + fpath += ".res" + im = load(fpath) + if im == null: + _logger.error("Could not load '{0}'".format([fpath])) + return false + + _resolution = im.get_width() + + map.image = im + _ensure_map_format(map.image, map_type, index) + _upload_channel(map_type, index) + + return true + + +func _ensure_map_format(im: Image, map_type: int, index: int): + var format = im.get_format() + var expected_format = _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) + + +# Legacy +# TODO Drop after a few versions +static func _try_load_0_8_0_heightmap(fpath: String, channel: int, existing_image: Image, logger): + fpath += ".bin" + var f = File.new() + if not f.file_exists(fpath): + return null + var err = f.open(fpath, File.READ) + if err != OK: + logger.error("Could not open '{0}' for reading, error {1}" \ + .format([fpath, Errors.get_message(err)])) + return false + + var width = f.get_32() + var height = f.get_32() + var pixel_size = f.get_32() + var data_size = width * height * pixel_size + var data = f.get_buffer(data_size) + if data.size() != data_size: + logger.error("Unexpected end of buffer, expected size {0}, got {1}" \ + .format([data_size, data.size()])) + return false + + var im = existing_image + if im == null: + im = Image.new() + im.create_from_data(width, height, false, get_channel_format(channel), data) + return im + + +static func _try_delete_0_8_0_heightmap(fpath: String, logger): + fpath += ".bin" + var d = Directory.new() + if d.file_exists(fpath): + var err = d.remove(fpath) + if err != OK: + logger.error("Could not erase file '{0}', error {1}" \ + .format([fpath, Errors.get_message(err)])) + + +# 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 = Util.next_power_of_two(width - 1) + 1 + var height_po2 = Util.next_power_of_two(height - 1) + 1 + var size_po2 = Util.min_int(width_po2, height_po2) + size_po2 = Util.clamp_int(size_po2, MIN_RESOLUTION, MAX_RESOLUTION) + return size_po2 + + +func _import_heightmap(fpath: String, min_y: int, max_y: int, 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.new() + var err := src_image.load(fpath) + if err != OK: + 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 = Util.min_int(im.get_width(), src_image.get_width()) + var height = Util.min_int(im.get_height(), src_image.get_height()) + + _logger.debug("Converting to internal format...") + + im.lock() + src_image.lock() + + # Convert to internal format (from RGBA8 to RH16) with range scaling + for y in range(0, width): + for x in range(0, height): + var gs := src_image.get_pixel(x, y).r + var h := min_y + hrange * gs + im.set_pixel(x, y, Color(h, 0, 0)) + + src_image.unlock() + im.unlock() + + elif ext == "exr": + var src_image := Image.new() + var err := src_image.load(fpath) + if err != OK: + 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...") + + # 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, Rect2(0, 0, res, res), Vector2()) + + 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 := File.new() + var err := f.open(fpath, File.READ) + if err != OK: + return false + + var file_len = f.get_len() + var file_res = 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.endian_swap = 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...") + + im.lock() + + var rw := Util.min_int(res, file_res) + var rh := Util.min_int(res, file_res) + + # Convert to internal format (from bytes to RH16) + var h := 0.0 + for y in range(0, rh): + for x in range(0, rw): + var gs := float(f.get_16()) / 65535.0 + h = min_y + hrange * float(gs) + im.set_pixel(x, y, Color(h, 0, 0)) + # Skip next pixels if the file is bigger than the accepted resolution + for x in range(rw, file_res): + f.get_16() + + im.unlock() + + 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.new() + var err = im.load(path) + if err != OK: + 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 = _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 _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 cell_cb_funcref : FuncRef + var broad_param_2d_to_3d := 1.0 + var cell_param_2d_to_3d := 1.0 + #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 = Util.grid_raytrace_2d( + cell_ray_origin_2d, dir_2d, cell_cb_funcref, 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) + + return hit != null + + static func _intersect_cell(heightmap: Image, cx: int, cz: int, + begin_pos: Vector3, dir: Vector3): + + var h00 := Util.get_pixel_clamped(heightmap, cx, cz).r + var h10 := Util.get_pixel_clamped(heightmap, cx + 1, cz).r + var h01 := Util.get_pixel_clamped(heightmap, cx, cz + 1).r + var h11 := Util.get_pixel_clamped(heightmap, cx + 1, cz + 1).r + + 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 = Geometry.ray_intersects_triangle(begin_pos, dir, p00, p10, p11) + var th1 = Geometry.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 := 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 := _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 + # We are lucky FuncRef does not keep a strong reference to the object + ctx.cell_cb_funcref = funcref(ctx, "cell_cb") + 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 + #ctx.dbg = dbg + + heightmap.lock() + _chunked_vertical_bounds.lock() + + # 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 = Util.grid_raytrace_2d(broad_ray_origin, ray_direction_2d, + funcref(ctx, "broad_cb"), broad_max_distance) + + heightmap.unlock() + _chunked_vertical_bounds.unlock() + + 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 + diff --git a/addons/zylann.hterrain/hterrain_detail_layer.gd b/addons/zylann.hterrain/hterrain_detail_layer.gd new file mode 100644 index 0000000..76c39ff --- /dev/null +++ b/addons/zylann.hterrain/hterrain_detail_layer.gd @@ -0,0 +1,613 @@ +tool +extends Spatial + +# 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 DirectMultiMeshInstance = preload("./util/direct_multimesh_instance.gd") +const DirectMeshInstance = preload("./util/direct_mesh_instance.gd") +const Util = preload("./util/util.gd") +const Logger = preload("./util/logger.gd") +const DefaultMesh = preload("./models/grass_quad.obj") +var HTerrain = load("res://addons/zylann.hterrain/hterrain.gd") + +const CHUNK_SIZE = 32 +const DEFAULT_SHADER_PATH = "res://addons/zylann.hterrain/shaders/detail.shader" +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(int) var layer_index := 0 setget set_layer_index, get_layer_index + +# Texture to render on the detail meshes. +export(Texture) var texture : Texture setget set_texture, get_texture + +# 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(float, 1.0, 500.0) var view_distance := 100.0 setget set_view_distance, get_view_distance + +# Custom shader to replace the default one. +export(Shader) var custom_shader : Shader setget set_custom_shader, get_custom_shader + +# Density modifier, to make more or less detail meshes appear overall. +export(float, 0, 10) var density := 4.0 setget set_density, get_density + +# 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(Mesh) var instance_mesh : Mesh setget set_instance_mesh, get_instance_mesh + +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.editor_hint +var _debug_wirecube_mesh: Mesh = null +var _debug_cubes := [] +var _logger := 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 + _multimesh.color_format = MultiMesh.COLOR_8BIT + + +func _enter_tree(): + var terrain = _get_terrain() + if terrain != null: + terrain.connect("transform_changed", self, "_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.disconnect("transform_changed", self, "_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 = VisualServer.shader_get_param_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: String): + if key.begins_with("shader_params/"): + var param_name = key.right(len("shader_params/")) + return get_shader_param(param_name) + + +func _set(key: String, v): + if key.begins_with("shader_params/"): + var param_name = key.right(len("shader_params/")) + set_shader_param(param_name, v) + + +func get_shader_param(param_name: String): + return _material.get_shader_param(param_name) + + +func set_shader_param(param_name: String, v): + _material.set_shader_param(param_name, v) + + +func _get_terrain(): + if is_inside_tree(): + return get_parent() + return null + + +func set_texture(tex: Texture): + texture = tex + _material.set_shader_param("u_albedo_alpha", tex) + + +func get_texture() -> Texture: + return texture + + +func set_layer_index(v: int): + if layer_index == v: + return + layer_index = v + if is_inside_tree(): + _update_material() + Util.update_configuration_warning(self, false) + + +func get_layer_index() -> int: + return layer_index + + +func set_view_distance(v: float): + if view_distance == v: + return + view_distance = max(v, 1.0) + if is_inside_tree(): + _update_material() + + +func get_view_distance() -> float: + return view_distance + + +func set_custom_shader(shader: 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.editor_hint: + # Ability to fork default shader + if shader.code == "": + shader.code = _default_shader.code + + +func get_custom_shader() -> Shader: + return custom_shader + + +func set_instance_mesh(p_mesh: Mesh): + if p_mesh == instance_mesh: + return + instance_mesh = p_mesh + _multimesh.mesh = _get_used_mesh() + + +func get_instance_mesh() -> Mesh: + return instance_mesh + + +func _get_used_mesh() -> Mesh: + if instance_mesh == null: + return DefaultMesh + return instance_mesh + + +func set_density(v: float): + v = clamp(v, 0, 10) + if v == density: + return + density = v + _multimesh_need_regen = true + + +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()) + + NOTIFICATION_EXIT_WORLD: + _set_world(null) + + NOTIFICATION_VISIBILITY_CHANGED: + _set_visible(visible) + + +func _set_visible(v: bool): + for k in _chunks: + var chunk = _chunks[k] + chunk.set_visible(v) + + +func _set_world(w: World): + for k in _chunks: + var chunk = _chunks[k] + chunk.set_world(w) + + +func _on_terrain_transform_changed(gt: Transform): + _update_material() + + var terrain = _get_terrain() + if terrain == null: + _logger.error("Detail layer is not child of a terrain!") + return + + # Update AABBs, 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) + + +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) + + var local_viewer_pos = viewer_pos - terrain.translation + + 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 + + if cmin_x < 0: + cmin_x = 0 + if cmin_z < 0: + cmin_z = 0 + if cmax_x > terrain_chunks_x: + cmax_x = terrain_chunks_x + if cmax_z > terrain_chunks_z: + cmax_z = 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, 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_param("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 _load_chunk(terrain, cx: int, cz: int, aabb: AABB): + var lpos = Vector3(cx, 0, cz) * CHUNK_SIZE + # Terrain scale is not used on purpose. Rotation is not supported. + var trans = Transform(Basis(), terrain.get_internal_transform().origin + lpos) + + # Nullify XZ translation because that's done by transform already + 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 = DirectMultiMeshInstance.new() + mmi.set_world(terrain.get_world()) + mmi.set_multimesh(_multimesh) + + mmi.set_material_override(_material) + mmi.set_transform(trans) + mmi.set_aabb(aabb) + 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 = Transform() + 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 + normal_basis = gt.basis.inverse().transposed() + + var mat = _material + + mat.set_shader_param("u_terrain_inverse_transform", it) + mat.set_shader_param("u_terrain_normal_basis", normal_basis) + mat.set_shader_param("u_albedo_alpha", texture) + mat.set_shader_param("u_view_distance", view_distance) + mat.set_shader_param("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_param("u_terrain_heightmap", heightmap_texture) + mat.set_shader_param("u_terrain_detailmap", detailmap_texture) + mat.set_shader_param("u_terrain_normalmap", normalmap_texture) + mat.set_shader_param("u_terrain_globalmap", globalmap_texture) + + +func _add_debug_cube(terrain, aabb: AABB): + var world = terrain.get_world() + + if _debug_wirecube_mesh == null: + _debug_wirecube_mesh = Util.create_wirecube_mesh() + var mat = SpatialMaterial.new() + mat.flags_unshaded = true + _debug_wirecube_mesh.surface_set_material(0, mat) + + var debug_cube = DirectMeshInstance.new() + debug_cube.set_mesh(_debug_wirecube_mesh) + debug_cube.set_world(world) + #aabb.position.y += 0.2*randf() + debug_cube.set_transform(Transform(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_warning() -> String: + var terrain = _get_terrain() + if not (terrain is HTerrain): + return "This node must be child of an HTerrain node" + var data = terrain.get_data() + if data == null: + return "The terrain has no data" + if data.get_map_count(HTerrainData.CHANNEL_DETAIL) == 0: + return "The terrain does not have any detail map" + if layer_index < 0 or layer_index >= data.get_map_count(HTerrainData.CHANNEL_DETAIL): + return "Layer index is out of bounds" + var tex = data.get_texture(HTerrainData.CHANNEL_DETAIL, layer_index) + if tex == null: + return "The terrain does not have a map assigned in slot {0}".format([layer_index]) + return "" + + +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 - floor(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 += rand_range(-position_randomness, position_randomness) + pos.z += rand_range(-position_randomness, position_randomness) + + multimesh.set_instance_color(i, Color(1, 1, 1)) + multimesh.set_instance_transform(i, \ + Transform(_get_random_instance_basis(scale_randomness), pos)) + i += 1 + + # Second pass adds the rest + for j in random_instance_count: + var pos = Vector3(rand_range(0, resolution), 0, rand_range(0, resolution)) + multimesh.set_instance_color(i, Color(1, 1, 1)) + multimesh.set_instance_transform(i, \ + Transform(_get_random_instance_basis(scale_randomness), pos)) + i += 1 + + +static func _get_random_instance_basis(scale_randomness: float) -> Basis: + var sr = rand_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), rand_range(0, PI)) + + return basis diff --git a/addons/zylann.hterrain/hterrain_mesher.gd b/addons/zylann.hterrain/hterrain_mesher.gd new file mode 100644 index 0000000..2d012a1 --- /dev/null +++ b/addons/zylann.hterrain/hterrain_mesher.gd @@ -0,0 +1,351 @@ +tool + +const Logger = preload("./util/logger.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 range(SEAM_CONFIG_COUNT): + + var slot = [] + slot.resize(lod_count) + _mesh_cache[seams] = slot + + for lod in range(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 = PoolVector3Array() + positions.resize((quad_count_x + 1) * (quad_count_y + 1)) + + var i = 0 + for y in range(quad_count_y + 1): + for x in range(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) -> PoolIntArray: + + var output_indices := PoolIntArray() + + 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 range(reg_size_y): + for x in range(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 range(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 range(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 range(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 range(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 := PoolVector3Array() + positions.resize(size_x * size_z) + + heightmap.lock() + + var i := 0 + 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 + + heightmap.unlock() + + 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/addons/zylann.hterrain/hterrain_resource_loader.gd b/addons/zylann.hterrain/hterrain_resource_loader.gd new file mode 100644 index 0000000..5c048e8 --- /dev/null +++ b/addons/zylann.hterrain/hterrain_resource_loader.gd @@ -0,0 +1,27 @@ +tool +class_name HTerrainDataLoader +extends ResourceFormatLoader + + +const HTerrainData = preload("./hterrain_data.gd") + + +func get_recognized_extensions(): + return PoolStringArray([HTerrainData.META_EXTENSION]) + + +func get_resource_type(path): + var ext = path.get_extension().to_lower() + if ext == HTerrainData.META_EXTENSION: + return "Resource" + return "" + + +func handles_type(typename): + return typename == "Resource" + + +func load(path, original_path): + var res = HTerrainData.new() + res.load_data(path.get_base_dir()) + return res diff --git a/addons/zylann.hterrain/hterrain_resource_saver.gd b/addons/zylann.hterrain/hterrain_resource_saver.gd new file mode 100644 index 0000000..ba6a17d --- /dev/null +++ b/addons/zylann.hterrain/hterrain_resource_saver.gd @@ -0,0 +1,20 @@ +tool +class_name HTerrainDataSaver +extends ResourceFormatSaver + + +const HTerrainData = preload("./hterrain_data.gd") + + +func get_recognized_extensions(res): + if res != null and res is HTerrainData: + return PoolStringArray([HTerrainData.META_EXTENSION]) + return PoolStringArray() + + +func recognize(res): + return res is HTerrainData + + +func save(path, resource, flags): + resource.save_data(path.get_base_dir()) diff --git a/addons/zylann.hterrain/hterrain_texture_set.gd b/addons/zylann.hterrain/hterrain_texture_set.gd new file mode 100644 index 0000000..d322a8d --- /dev/null +++ b/addons/zylann.hterrain/hterrain_texture_set.gd @@ -0,0 +1,243 @@ +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: String): + if key == "mode": + return _mode + if key == "textures": + return _textures + + +func _set(key: String, 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() + # TODO What if there are two texture arrays of different size? + var texarray = _textures[TYPE_ALBEDO_BUMP][0] + if texarray == null: + var count = 0 + texarray = _textures[TYPE_NORMAL_ROUGHNESS][0] + if texarray == null: + return 0 + return texarray.get_depth() + + +func get_texture_count() -> int: + var texs = _textures[TYPE_ALBEDO_BUMP] + return len(texs) + + +func get_texture(slot_index: int, ground_texture_type: int) -> Texture: + if _mode != MODE_TEXTURES: + return null + var texs = _textures[ground_texture_type] + if slot_index >= len(texs): + return null + return texs[slot_index] + + +func set_texture(slot_index: int, ground_texture_type: int, texture: Texture): + 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) -> TextureArray: + 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: TextureArray): + 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] + 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 emit_changed(): + emit_signal("changed") + + +#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/addons/zylann.hterrain/models/grass_quad.obj b/addons/zylann.hterrain/models/grass_quad.obj new file mode 100644 index 0000000..2215279 --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad.obj @@ -0,0 +1,14 @@ +# Blender v2.80 (sub 75) OBJ File: 'grass.blend' +# www.blender.org +o Cube +v 0.000000 1.000000 -0.500000 +v 0.000000 0.000000 -0.500000 +v 0.000000 1.000000 0.500000 +v 0.000000 0.000000 0.500000 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vn 1.0000 0.0000 0.0000 +s off +f 2/1/1 1/2/1 3/3/1 4/4/1 diff --git a/addons/zylann.hterrain/models/grass_quad.obj.import b/addons/zylann.hterrain/models/grass_quad.obj.import new file mode 100644 index 0000000..6c380ce --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad.obj.import @@ -0,0 +1,19 @@ +[remap] + +importer="wavefront_obj" +type="Mesh" +path="res://.import/grass_quad.obj-da067750350fe98ec466261b2aeaf486.mesh" + +[deps] + +files=[ "res://.import/grass_quad.obj-da067750350fe98ec466261b2aeaf486.mesh" ] + +source_file="res://addons/zylann.hterrain/models/grass_quad.obj" +dest_files=[ "res://.import/grass_quad.obj-da067750350fe98ec466261b2aeaf486.mesh", "res://.import/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/addons/zylann.hterrain/models/grass_quad_x2.obj b/addons/zylann.hterrain/models/grass_quad_x2.obj new file mode 100644 index 0000000..37c4707 --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad_x2.obj @@ -0,0 +1,24 @@ +# Blender v2.80 (sub 75) OBJ File: 'grass_x2.blend' +# www.blender.org +o Cube +v 0.000000 1.000000 -0.500000 +v 0.000000 0.000000 -0.500000 +v 0.000000 1.000000 0.500000 +v 0.000000 0.000000 0.500000 +v -0.500000 1.000000 0.000000 +v -0.500000 0.000000 0.000000 +v 0.500000 1.000000 0.000000 +v 0.500000 0.000000 0.000000 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vn 1.0000 0.0000 0.0000 +vn 0.0000 0.0000 -1.0000 +s off +f 2/1/1 1/2/1 3/3/1 4/4/1 +f 6/5/2 5/6/2 7/7/2 8/8/2 diff --git a/addons/zylann.hterrain/models/grass_quad_x2.obj.import b/addons/zylann.hterrain/models/grass_quad_x2.obj.import new file mode 100644 index 0000000..bf8a56a --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad_x2.obj.import @@ -0,0 +1,19 @@ +[remap] + +importer="wavefront_obj" +type="Mesh" +path="res://.import/grass_quad_x2.obj-2054c140f543f2a80e2eb921f865ea49.mesh" + +[deps] + +files=[ "res://.import/grass_quad_x2.obj-2054c140f543f2a80e2eb921f865ea49.mesh" ] + +source_file="res://addons/zylann.hterrain/models/grass_quad_x2.obj" +dest_files=[ "res://.import/grass_quad_x2.obj-2054c140f543f2a80e2eb921f865ea49.mesh", "res://.import/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/addons/zylann.hterrain/models/grass_quad_x3.obj b/addons/zylann.hterrain/models/grass_quad_x3.obj new file mode 100644 index 0000000..a373832 --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad_x3.obj @@ -0,0 +1,34 @@ +# Blender v2.80 (sub 75) OBJ File: 'grass_x3.blend' +# www.blender.org +o Cube +v 0.000000 1.000000 -0.500000 +v 0.000000 0.000000 -0.500000 +v 0.000000 1.000000 0.500000 +v 0.000000 0.000000 0.500000 +v -0.433013 1.000000 -0.250000 +v -0.433013 0.000000 -0.250000 +v 0.433013 1.000000 0.250000 +v 0.433013 0.000000 0.250000 +v -0.433013 1.000000 0.250000 +v -0.433013 0.000000 0.250000 +v 0.433013 1.000000 -0.250000 +v 0.433013 0.000000 -0.250000 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vn 1.0000 0.0000 0.0000 +vn 0.5000 0.0000 -0.8660 +vn -0.5000 0.0000 -0.8660 +s off +f 2/1/1 1/2/1 3/3/1 4/4/1 +f 6/5/2 5/6/2 7/7/2 8/8/2 +f 10/9/3 9/10/3 11/11/3 12/12/3 diff --git a/addons/zylann.hterrain/models/grass_quad_x3.obj.import b/addons/zylann.hterrain/models/grass_quad_x3.obj.import new file mode 100644 index 0000000..4f8a41e --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad_x3.obj.import @@ -0,0 +1,19 @@ +[remap] + +importer="wavefront_obj" +type="Mesh" +path="res://.import/grass_quad_x3.obj-8691724bc5006b6f65d4e8742ffc84dc.mesh" + +[deps] + +files=[ "res://.import/grass_quad_x3.obj-8691724bc5006b6f65d4e8742ffc84dc.mesh" ] + +source_file="res://addons/zylann.hterrain/models/grass_quad_x3.obj" +dest_files=[ "res://.import/grass_quad_x3.obj-8691724bc5006b6f65d4e8742ffc84dc.mesh", "res://.import/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/addons/zylann.hterrain/models/grass_quad_x4.obj b/addons/zylann.hterrain/models/grass_quad_x4.obj new file mode 100644 index 0000000..b182be6 --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad_x4.obj @@ -0,0 +1,42 @@ +# Blender v2.80 (sub 75) OBJ File: 'grass_x4.blend' +# www.blender.org +o Cube +v 0.250000 1.000000 -0.500000 +v 0.250000 0.000000 -0.500000 +v 0.250000 1.000000 0.500000 +v 0.250000 0.000000 0.500000 +v 0.500000 0.000000 -0.250000 +v 0.500000 1.000000 -0.250000 +v -0.500000 0.000000 -0.250000 +v -0.500000 1.000000 -0.250000 +v -0.250000 0.000000 0.500000 +v -0.250000 1.000000 0.500000 +v -0.250000 0.000000 -0.500000 +v -0.250000 1.000000 -0.500000 +v 0.500000 0.000000 0.250000 +v 0.500000 1.000000 0.250000 +v -0.500000 0.000000 0.250000 +v -0.500000 1.000000 0.250000 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vt 0.999900 0.000100 +vt 0.999900 0.999900 +vt 0.000100 0.999900 +vt 0.000100 0.000100 +vn 1.0000 0.0000 0.0000 +vn 0.0000 0.0000 -1.0000 +s off +f 2/1/1 1/2/1 3/3/1 4/4/1 +f 7/5/2 8/6/2 6/7/2 5/8/2 +f 11/9/1 12/10/1 10/11/1 9/12/1 +f 15/13/2 16/14/2 14/15/2 13/16/2 diff --git a/addons/zylann.hterrain/models/grass_quad_x4.obj.import b/addons/zylann.hterrain/models/grass_quad_x4.obj.import new file mode 100644 index 0000000..e1e497d --- /dev/null +++ b/addons/zylann.hterrain/models/grass_quad_x4.obj.import @@ -0,0 +1,19 @@ +[remap] + +importer="wavefront_obj" +type="Mesh" +path="res://.import/grass_quad_x4.obj-c449a7d6c810ba1595ed30df9fbf3d28.mesh" + +[deps] + +files=[ "res://.import/grass_quad_x4.obj-c449a7d6c810ba1595ed30df9fbf3d28.mesh" ] + +source_file="res://addons/zylann.hterrain/models/grass_quad_x4.obj" +dest_files=[ "res://.import/grass_quad_x4.obj-c449a7d6c810ba1595ed30df9fbf3d28.mesh", "res://.import/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/addons/zylann.hterrain/native/.clang-format b/addons/zylann.hterrain/native/.clang-format new file mode 100644 index 0000000..237fd9c --- /dev/null +++ b/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/addons/zylann.hterrain/native/.gitignore b/addons/zylann.hterrain/native/.gitignore new file mode 100644 index 0000000..ec67d10 --- /dev/null +++ b/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/addons/zylann.hterrain/native/SConstruct b/addons/zylann.hterrain/native/SConstruct new file mode 100644 index 0000000..3e9ece8 --- /dev/null +++ b/addons/zylann.hterrain/native/SConstruct @@ -0,0 +1,113 @@ +#!python +import os + +opts = Variables([], ARGUMENTS) + +# Gets the standard flags CC, CCX, etc. +env = DefaultEnvironment() + +# 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')) + +# 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(LINKFLAGS = ['-arch', 'x86_64']) + else: + env.Append(CCFLAGS = ['-g', '-O3', '-arch', 'x86_64']) + 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' + +cpp_bindings_library += '.' + str(bits) + +# 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/addons/zylann.hterrain/native/bin/linux/libhterrain_native.so b/addons/zylann.hterrain/native/bin/linux/libhterrain_native.so new file mode 100644 index 0000000..0e2b925 Binary files /dev/null and b/addons/zylann.hterrain/native/bin/linux/libhterrain_native.so differ diff --git a/addons/zylann.hterrain/native/bin/win64/hterrain_native.dll b/addons/zylann.hterrain/native/bin/win64/hterrain_native.dll new file mode 100644 index 0000000..c12bc7e Binary files /dev/null and b/addons/zylann.hterrain/native/bin/win64/hterrain_native.dll differ diff --git a/addons/zylann.hterrain/native/factory.gd b/addons/zylann.hterrain/native/factory.gd new file mode 100644 index 0000000..801a357 --- /dev/null +++ b/addons/zylann.hterrain/native/factory.gd @@ -0,0 +1,29 @@ + +const NATIVE_PATH = "res://addons/zylann.hterrain/native/" + +const ImageUtilsGeneric = preload("./image_utils_generic.gd") + +# See https://docs.godotengine.org/en/3.2/classes/class_os.html#class-os-method-get-name +const _supported_os = { + "Windows": true, + "X11": true, + #"OSX": true +} + + +static func is_native_available() -> bool: + 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 == 3 and v.minor == 2 + + +static func get_image_utils(): + if is_native_available(): + var ImageUtilsNative = load(NATIVE_PATH + "image_utils.gdns") + if ImageUtilsNative != null: + return ImageUtilsNative.new() + return ImageUtilsGeneric.new() + diff --git a/addons/zylann.hterrain/native/hterrain.gdnlib b/addons/zylann.hterrain/native/hterrain.gdnlib new file mode 100644 index 0000000..ffec5eb --- /dev/null +++ b/addons/zylann.hterrain/native/hterrain.gdnlib @@ -0,0 +1,17 @@ +[general] + +singleton = false +load_once = true +symbol_prefix = "godot_" +reloadable = false + +[entry] + +Windows.64 = "res://addons/zylann.hterrain/native/bin/win64/hterrain_native.dll" +X11.64 = "res://addons/zylann.hterrain/native/bin/linux/libhterrain_native.so" + +[dependencies] + +X11.64 = [] +Windows.64 = [] +OSX.64 = [] diff --git a/addons/zylann.hterrain/native/image_utils.gdns b/addons/zylann.hterrain/native/image_utils.gdns new file mode 100644 index 0000000..b9fd801 --- /dev/null +++ b/addons/zylann.hterrain/native/image_utils.gdns @@ -0,0 +1,8 @@ +[gd_resource type="NativeScript" load_steps=2 format=2] + +[ext_resource path="res://addons/zylann.hterrain/native/hterrain.gdnlib" type="GDNativeLibrary" id=1] + +[resource] +resource_name = "image_utils" +class_name = "ImageUtils" +library = ExtResource( 1 ) diff --git a/addons/zylann.hterrain/native/image_utils_generic.gd b/addons/zylann.hterrain/native/image_utils_generic.gd new file mode 100644 index 0000000..a6a35dc --- /dev/null +++ b/addons/zylann.hterrain/native/image_utils_generic.gd @@ -0,0 +1,369 @@ + +# 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 Util = preload("../util/util.gd") + +var _blur_buffer : Image + + +func get_red_range(im: Image, rect: Rect2) -> Vector2: + rect = rect.clip(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) + + im.lock() + + 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 + + im.unlock() + + return Vector2(min_height, max_height) + + +func get_red_sum(im: Image, rect: Rect2) -> float: + rect = rect.clip(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 + + im.lock() + + for y in range(min_y, max_y): + for x in range(min_x, max_x): + sum += im.get_pixel(x, y).r + + im.unlock() + + return sum + + +func get_red_sum_weighted(im: Image, brush: Image, pos: Vector2, + var 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 = Util.clamp_int(min_x, 0, im.get_width()) + min_y = Util.clamp_int(min_y, 0, im.get_height()) + max_x = Util.clamp_int(max_x, 0, im.get_width()) + max_y = Util.clamp_int(max_y, 0, im.get_height()) + + var sum = 0.0 + + im.lock() + brush.lock() + + 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 + + im.lock() + brush.unlock() + + return sum + + +func add_red_brush(im: Image, brush: Image, pos: Vector2, var 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 = Util.clamp_int(min_x, 0, im.get_width()) + min_y = Util.clamp_int(min_y, 0, im.get_height()) + max_x = Util.clamp_int(max_x, 0, im.get_width()) + max_y = Util.clamp_int(max_y, 0, im.get_height()) + + im.lock() + brush.lock() + + 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)) + + im.lock() + brush.unlock() + + +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 = Util.clamp_int(min_x, 0, im.get_width()) + min_y = Util.clamp_int(min_y, 0, im.get_height()) + max_x = Util.clamp_int(max_x, 0, im.get_width()) + max_y = Util.clamp_int(max_y, 0, im.get_height()) + + im.lock() + brush.lock() + + 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) + + im.lock() + brush.unlock() + + +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 = Util.clamp_int(min_x, 0, im.get_width()) + min_y = Util.clamp_int(min_y, 0, im.get_height()) + max_x = Util.clamp_int(max_x, 0, im.get_width()) + max_y = Util.clamp_int(max_y, 0, im.get_height()) + + im.lock() + brush.lock() + + 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).linear_interpolate(target_value, factor * shape_value) + im.set_pixel(x, y, c) + + im.lock() + brush.unlock() + + +func generate_gaussian_brush(im: Image) -> float: + var sum := 0.0 + var center := Vector2(im.get_width() / 2, im.get_height() / 2) + var radius := min(im.get_width(), im.get_height()) / 2.0 + + im.lock() + + for y in im.get_height(): + for x in im.get_width(): + var d := Vector2(x, y).distance_to(center) / radius + var v := clamp(1.0 - d * d * d, 0.0, 1.0) + im.set_pixel(x, y, Color(v, v, v)) + sum += v; + + im.unlock() + return sum + + +func blur_red_brush(im: Image, brush: Image, pos: Vector2, factor: float): + factor = clamp(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) + + im.lock() + buffer.lock() + + 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 := clamp(x, 0, im_clamp_w) + var iy := clamp(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 = Util.clamp_int(min_x, 0, im.get_width()) + min_y = Util.clamp_int(min_y, 0, im.get_height()) + max_x = Util.clamp_int(max_x, 0, im.get_width()) + max_y = Util.clamp_int(max_y, 0, im.get_height()) + + brush.lock() + + # 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 = lerp(p11, m, shape_value * factor) + + im.set_pixel(x, y, Color(p, p, p)) + + im.unlock() + buffer.unlock() + brush.unlock() + + +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 = Util.clamp_int(min_x, 0, index_map.get_width()) + min_y = Util.clamp_int(min_y, 0, index_map.get_height()) + max_x = Util.clamp_int(max_x, 0, index_map.get_width()) + max_y = Util.clamp_int(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 + + index_map.lock() + weight_map.lock() + brush.lock() + + 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] = 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 + w /= w[0] + w[1] + w[2] + + index_map.set_pixel(x, y, i) + weight_map.set_pixel(x, y, w) + + index_map.lock() + weight_map.lock() + brush.unlock() diff --git a/addons/zylann.hterrain/native/src/.gdignore b/addons/zylann.hterrain/native/src/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/addons/zylann.hterrain/native/src/gd_library.cpp b/addons/zylann.hterrain/native/src/gd_library.cpp new file mode 100644 index 0000000..d953903 --- /dev/null +++ b/addons/zylann.hterrain/native/src/gd_library.cpp @@ -0,0 +1,28 @@ +#include "image_utils.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(); +} + +} // extern "C" diff --git a/addons/zylann.hterrain/native/src/image_utils.cpp b/addons/zylann.hterrain/native/src/image_utils.cpp new file mode 100644 index 0000000..0306474 --- /dev/null +++ b/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 +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.lock(); +} + +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_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_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_ref, Ref 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_ref, Ref 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_ref, Ref 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_ref, Ref 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_ref) const { + ERR_FAIL_COND_V(image_ref.is_null(), 0.f); + Image &image = **image_ref; + + int w = static_cast(image.get_width()); + int h = static_cast(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_ref, Ref 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(image.get_width()); + const int image_height = static_cast(image.get_height()); + + const int buffer_width = static_cast(buffer_range.get_width()); + const int buffer_height = static_cast(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.lock(); +} + +void ImageUtils::paint_indexed_splat(Ref index_map_ref, Ref weight_map_ref, + Ref 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/addons/zylann.hterrain/native/src/image_utils.h b/addons/zylann.hterrain/native/src/image_utils.h new file mode 100644 index 0000000..bff0178 --- /dev/null +++ b/addons/zylann.hterrain/native/src/image_utils.h @@ -0,0 +1,38 @@ +#ifndef IMAGE_UTILS_H +#define IMAGE_UTILS_H + +#include +#include +#include +#include + +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_ref, Rect2 rect) const; + float get_red_sum(Ref image_ref, Rect2 rect) const; + float get_red_sum_weighted(Ref image_ref, Ref brush_ref, Vector2 p_pos, float factor) const; + void add_red_brush(Ref image_ref, Ref brush_ref, Vector2 p_pos, float factor) const; + void lerp_channel_brush(Ref image_ref, Ref brush_ref, Vector2 p_pos, float factor, float target_value, int channel) const; + void lerp_color_brush(Ref image_ref, Ref brush_ref, Vector2 p_pos, float factor, Color target_value) const; + float generate_gaussian_brush(Ref image_ref) const; + void blur_red_brush(Ref image_ref, Ref brush_ref, Vector2 p_pos, float factor); + void paint_indexed_splat(Ref index_map_ref, Ref weight_map_ref, Ref brush_ref, Vector2 p_pos, int texture_index, float factor); + //void erode_red_brush(Ref image_ref, Ref brush_ref, Vector2 p_pos, float factor); + +private: + std::vector _blur_buffer; +}; + +} // namespace godot + +#endif // IMAGE_UTILS_H diff --git a/addons/zylann.hterrain/native/src/int_range_2d.h b/addons/zylann.hterrain/native/src/int_range_2d.h new file mode 100644 index 0000000..1c73f32 --- /dev/null +++ b/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 + +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(rect.position.x); + min_y = static_cast(rect.position.y); + max_x = static_cast(rect.position.x + rect.size.x); + max_y = static_cast(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/addons/zylann.hterrain/native/src/math_funcs.h b/addons/zylann.hterrain/native/src/math_funcs.h new file mode 100644 index 0000000..daff418 --- /dev/null +++ b/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 +inline T clamp(T x, T minv, T maxv) { + if (x < minv) { + return minv; + } + if (x > maxv) { + return maxv; + } + return x; +} + +template +inline T min(T a, T b) { + return a < b ? a : b; +} + +} // namespace Math + +#endif // MATH_FUNCS_H diff --git a/addons/zylann.hterrain/native/src/vector2i.h b/addons/zylann.hterrain/native/src/vector2i.h new file mode 100644 index 0000000..014d4d8 --- /dev/null +++ b/addons/zylann.hterrain/native/src/vector2i.h @@ -0,0 +1,19 @@ +#ifndef VECTOR2I_H +#define VECTOR2I_H + +#include + +struct Vector2i { + int x; + int y; + + Vector2i(godot::Vector2 v) : + x(static_cast(v.x)), + y(static_cast(v.y)) {} + + bool any_zero() const { + return x == 0 || y == 0; + } +}; + +#endif // VECTOR2I_H diff --git a/addons/zylann.hterrain/plugin.cfg b/addons/zylann.hterrain/plugin.cfg new file mode 100644 index 0000000..fcecf6c --- /dev/null +++ b/addons/zylann.hterrain/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Heightmap Terrain" +description="Heightmap-based terrain" +author="Marc Gilleron" +version="1.5.2" +script="tools/plugin.gd" diff --git a/addons/zylann.hterrain/shaders/array.shader b/addons/zylann.hterrain/shaders/array.shader new file mode 100644 index 0000000..f4d3d6d --- /dev/null +++ b/addons/zylann.hterrain/shaders/array.shader @@ -0,0 +1,169 @@ +shader_type spatial; + +uniform sampler2D u_terrain_heightmap; +uniform sampler2D u_terrain_normalmap; +// I had to remove `hint_albedo` from colormap 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 : hint_albedo; +uniform mat4 u_terrain_inverse_transform; +uniform mat3 u_terrain_normal_basis; + +uniform sampler2DArray u_ground_albedo_bump_array : hint_albedo; +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 = WORLD_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 = texture(u_terrain_heightmap, UV).r; + VERTEX.y = h; + wpos.y = h; + + vec3 base_ground_uv = vec3(cell_coords.x, h * WORLD_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. + // For some reason I also had to invert Z when sampling terrain normals... not sure why + NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV)); + + v_distance_to_camera = distance(wpos.xyz, CAMERA_MATRIX[3].xyz); +} + +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 = (INV_CAMERA_MATRIX * (vec4(normal, 0.0))).xyz; +} diff --git a/addons/zylann.hterrain/shaders/array_global.shader b/addons/zylann.hterrain/shaders/array_global.shader new file mode 100644 index 0000000..f8675ec --- /dev/null +++ b/addons/zylann.hterrain/shaders/array_global.shader @@ -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 `hint_albedo` from colormap 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 : hint_albedo; + +// 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 = WORLD_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/addons/zylann.hterrain/shaders/detail.shader b/addons/zylann.hterrain/shaders/detail.shader new file mode 100644 index 0000000..76c5ead --- /dev/null +++ b/addons/zylann.hterrain/shaders/detail.shader @@ -0,0 +1,96 @@ +shader_type spatial; +render_mode cull_disabled; + +uniform sampler2D u_terrain_heightmap; +uniform sampler2D u_terrain_detailmap; +uniform sampler2D u_terrain_normalmap; +uniform sampler2D u_terrain_globalmap : hint_albedo; +uniform mat4 u_terrain_inverse_transform; +uniform mat3 u_terrain_normal_basis; + +uniform sampler2D u_albedo_alpha : hint_albedo; +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); + +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; +} + +void vertex() { + vec4 obj_pos = WORLD_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) { + // Snap model to the terrain + float height = texture(u_terrain_heightmap, map_uv).r / cell_coords.y; + VERTEX *= u_instance_scale; + VERTEX.y += height; + + VERTEX += get_ambient_wind_displacement(UV, hash); + + // Fade alpha with distance + vec3 wpos = (WORLD_MATRIX * vec4(VERTEX, 1)).xyz; + float dr = distance(wpos, CAMERA_MATRIX[3].xyz) / 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 = normalize(u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, map_uv))); + + } else { + // Discard, output degenerate triangles + VERTEX = vec3(0, 0, 0); + } +} + +void fragment() { + NORMAL = (INV_CAMERA_MATRIX * (WORLD_MATRIX * vec4(v_normal, 0.0))).xyz; + ALPHA_SCISSOR = 0.5; + ROUGHNESS = 1.0; + + 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(1.0 - UV.y); + 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/addons/zylann.hterrain/shaders/lookdev.shader b/addons/zylann.hterrain/shaders/lookdev.shader new file mode 100644 index 0000000..f71e81d --- /dev/null +++ b/addons/zylann.hterrain/shaders/lookdev.shader @@ -0,0 +1,64 @@ +shader_type spatial; + +// Development shader used to debug or help authoring. + +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) { + return rgba.xzy * 2.0 - vec3(1.0); +} + +void vertex() { + vec4 wpos = WORLD_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 = texture(u_terrain_heightmap, UV).r; + 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. + // For some reason I also had to invert Z when sampling terrain normals... not sure why + NORMAL = u_terrain_normal_basis + * (unpack_normal(texture(u_terrain_normalmap, UV)) * vec3(1, 1, -1)); +} + +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)) * vec3(1,1,-1)); + 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 = (INV_CAMERA_MATRIX * (vec4(normal, 0.0))).xyz; +} diff --git a/addons/zylann.hterrain/shaders/low_poly.shader b/addons/zylann.hterrain/shaders/low_poly.shader new file mode 100644 index 0000000..3b372d8 --- /dev/null +++ b/addons/zylann.hterrain/shaders/low_poly.shader @@ -0,0 +1,61 @@ +shader_type spatial; + +// This is a very simple shader for a low-poly coloured visual, without textures + +uniform sampler2D u_terrain_heightmap; +uniform sampler2D u_terrain_normalmap; +// I had to remove `hint_albedo` from colormap 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 * WORLD_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 = texture(u_terrain_heightmap, UV).r; + 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(dFdx(VERTEX), dFdy(VERTEX))); +} + diff --git a/addons/zylann.hterrain/shaders/multisplat16.shader b/addons/zylann.hterrain/shaders/multisplat16.shader new file mode 100644 index 0000000..7748126 --- /dev/null +++ b/addons/zylann.hterrain/shaders/multisplat16.shader @@ -0,0 +1,369 @@ +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. + +uniform sampler2D u_terrain_heightmap; +uniform sampler2D u_terrain_normalmap; +// I had to remove `hint_albedo` from colormap 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 : hint_albedo; +uniform mat4 u_terrain_inverse_transform; +uniform mat3 u_terrain_normal_basis; + +uniform sampler2DArray u_ground_albedo_bump_array : hint_albedo; +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) { + 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 = WORLD_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 = texture(u_terrain_heightmap, UV).r; + VERTEX.y = h; + wpos.y = h; + + vec3 base_ground_uv = vec3(cell_coords.x, h * WORLD_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. + // For some reason I also had to invert Z when sampling terrain normals... not sure why + NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV)); + + v_distance_to_camera = distance(wpos.xyz, CAMERA_MATRIX[3].xyz); +} + +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)) * vec3(1,1,-1)); + 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 = (INV_CAMERA_MATRIX * (vec4(normal, 0.0))).xyz; +} diff --git a/addons/zylann.hterrain/shaders/multisplat16_global.shader b/addons/zylann.hterrain/shaders/multisplat16_global.shader new file mode 100644 index 0000000..338f00b --- /dev/null +++ b/addons/zylann.hterrain/shaders/multisplat16_global.shader @@ -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 `hint_albedo` from colormap 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 : hint_albedo; + +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 = WORLD_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/addons/zylann.hterrain/shaders/multisplat16_lite.shader b/addons/zylann.hterrain/shaders/multisplat16_lite.shader new file mode 100644 index 0000000..0cc2013 --- /dev/null +++ b/addons/zylann.hterrain/shaders/multisplat16_lite.shader @@ -0,0 +1,253 @@ +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. + +uniform sampler2D u_terrain_heightmap; +uniform sampler2D u_terrain_normalmap; +// I had to remove `hint_albedo` from colormap 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 : hint_albedo; +uniform mat4 u_terrain_inverse_transform; +uniform mat3 u_terrain_normal_basis; + +uniform sampler2DArray u_ground_albedo_bump_array : hint_albedo; + +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 = WORLD_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 = texture(u_terrain_heightmap, UV).r; + VERTEX.y = h; + wpos.y = h; + + vec3 base_ground_uv = vec3(cell_coords.x, h * WORLD_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. + // For some reason I also had to invert Z when sampling terrain normals... not sure why + NORMAL = u_terrain_normal_basis * unpack_normal(texture(u_terrain_normalmap, UV)); + + v_distance_to_camera = distance(wpos.xyz, CAMERA_MATRIX[3].xyz); +} + +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)) * vec3(1,1,-1)); + 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 = (INV_CAMERA_MATRIX * (vec4(terrain_normal_world, 0.0))).xyz; +} diff --git a/addons/zylann.hterrain/shaders/simple4.shader b/addons/zylann.hterrain/shaders/simple4.shader new file mode 100644 index 0000000..a9cc9ca --- /dev/null +++ b/addons/zylann.hterrain/shaders/simple4.shader @@ -0,0 +1,327 @@ +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. + +uniform sampler2D u_terrain_heightmap; +uniform sampler2D u_terrain_normalmap; +// I had to remove `hint_albedo` from colormap 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 : hint_albedo; +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 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_1 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_2 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_3 : hint_albedo; + +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 = WORLD_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 = texture(u_terrain_heightmap, UV).r; + VERTEX.y = h; + wpos.y = h; + + vec3 base_ground_uv = vec3(cell_coords.x, h * WORLD_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_MATRIX[3].xyz); +} + +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 = (INV_CAMERA_MATRIX * (vec4(normal, 0.0))).xyz; +} diff --git a/addons/zylann.hterrain/shaders/simple4_global.shader b/addons/zylann.hterrain/shaders/simple4_global.shader new file mode 100644 index 0000000..ad1c728 --- /dev/null +++ b/addons/zylann.hterrain/shaders/simple4_global.shader @@ -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 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 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_1 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_2 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_3 : hint_albedo; + +// 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 = WORLD_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/addons/zylann.hterrain/shaders/simple4_lite.shader b/addons/zylann.hterrain/shaders/simple4_lite.shader new file mode 100644 index 0000000..bb0403a --- /dev/null +++ b/addons/zylann.hterrain/shaders/simple4_lite.shader @@ -0,0 +1,209 @@ +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... + +uniform sampler2D u_terrain_heightmap; +uniform sampler2D u_terrain_normalmap; +// I had to remove `hint_albedo` from colormap 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 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_1 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_2 : hint_albedo; +uniform sampler2D u_ground_albedo_bump_3 : hint_albedo; + +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 * WORLD_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 = texture(u_terrain_heightmap, UV).r; + VERTEX.y = h; + + v_ground_uv = vec3(cell_coords.x, h * WORLD_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 = (INV_CAMERA_MATRIX * (vec4(terrain_normal_world, 0.0))).xyz; + + //ALBEDO = w.rgb; + //ALBEDO = v_ground_uv.xyz; +} + diff --git a/addons/zylann.hterrain/tools/about/about_dialog.gd b/addons/zylann.hterrain/tools/about/about_dialog.gd new file mode 100644 index 0000000..d76852c --- /dev/null +++ b/addons/zylann.hterrain/tools/about/about_dialog.gd @@ -0,0 +1,33 @@ +tool +extends WindowDialog + +const Util = preload("../../util/util.gd") +const Logger = preload("../../util/logger.gd") +const Errors = preload("../../util/errors.gd") + +const PLUGIN_CFG_PATH = "res://addons/zylann.hterrain/plugin.cfg" + + +onready var _about_rich_text_label = $VB/HB2/TC/About + +var _logger = Logger.get_for(self) + + +func _ready(): + if 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, Errors.get_message(err)])) + return + var version = plugin_cfg.get_value("plugin", "version", "--.--.--") + + _about_rich_text_label.bbcode_text = _about_rich_text_label.bbcode_text \ + .format({"version": version}) + + +func _on_Ok_pressed(): + hide() diff --git a/addons/zylann.hterrain/tools/about/about_dialog.tscn b/addons/zylann.hterrain/tools/about/about_dialog.tscn new file mode 100644 index 0000000..367ec82 --- /dev/null +++ b/addons/zylann.hterrain/tools/about/about_dialog.tscn @@ -0,0 +1,108 @@ +[gd_scene load_steps=4 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/about/about_dialog.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg" type="Texture" id=2] +[ext_resource path="res://addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd" type="Script" id=3] + +[node name="AboutDialog" type="WindowDialog"] +margin_right = 630.0 +margin_bottom = 250.0 +rect_min_size = Vector2( 630, 250 ) +window_title = "About the HTerrain plugin" +resizable = true +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="VB" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HB2" type="HBoxContainer" parent="VB"] +margin_right = 614.0 +margin_bottom = 210.0 +size_flags_vertical = 3 + +[node name="TextureRect" type="TextureRect" parent="VB/HB2"] +margin_right = 128.0 +margin_bottom = 210.0 +texture = ExtResource( 2 ) + +[node name="TC" type="TabContainer" parent="VB/HB2"] +margin_left = 132.0 +margin_right = 614.0 +margin_bottom = 210.0 +size_flags_horizontal = 3 +tab_align = 0 + +[node name="About" type="RichTextLabel" parent="VB/HB2/TC"] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 4.0 +margin_top = 32.0 +margin_right = -4.0 +margin_bottom = -4.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +bbcode_enabled = true +bbcode_text = "[b]Version:[/b] {version} +[b]Author:[/b] Marc Gilleron +[b]Repository:[/b] [url]https://github.com/Zylann/godot_heightmap_plugin[/url] +[b]Issue tracker:[/b] [url]https://github.com/Zylann/godot_heightmap_plugin/issues[/url] + +[b]Donors:[/b] +wacyym +Sergey Lapin (slapin) +Jonas (NoFr1ends) +lenis0012 +" +text = "Version: {version} +Author: Marc Gilleron +Repository: https://github.com/Zylann/godot_heightmap_plugin +Issue tracker: https://github.com/Zylann/godot_heightmap_plugin/issues + +Donors: +wacyym +Sergey Lapin (slapin) +Jonas (NoFr1ends) +lenis0012 +" +script = ExtResource( 3 ) + +[node name="License" type="RichTextLabel" parent="VB/HB2/TC"] +visible = false +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 4.0 +margin_top = 32.0 +margin_right = -4.0 +margin_bottom = -4.0 +text = "Copyright (c) 2016-2020 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"] +margin_top = 214.0 +margin_right = 614.0 +margin_bottom = 234.0 +alignment = 1 + +[node name="Ok" type="Button" parent="VB/HB"] +margin_left = 292.0 +margin_right = 322.0 +margin_bottom = 20.0 +text = "Ok" +[connection signal="pressed" from="VB/HB/Ok" to="." method="_on_Ok_pressed"] diff --git a/addons/zylann.hterrain/tools/brush/brush_editor.gd b/addons/zylann.hterrain/tools/brush/brush_editor.gd new file mode 100644 index 0000000..1834e9b --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/brush_editor.gd @@ -0,0 +1,236 @@ +tool +extends Control + +const Brush = preload("./terrain_painter.gd") +const Errors = preload("../../util/errors.gd") +#const NativeFactory = preload("../../native/factory.gd") +const Logger = preload("../../util/logger.gd") + +const SHAPES_DIR = "addons/zylann.hterrain/tools/brush/shapes" +const DEFAULT_BRUSH = "round2.exr" + +onready var _size_slider := $GridContainer/BrushSizeControl/Slider as Slider +onready var _size_value_label := $GridContainer/BrushSizeControl/Label as Label +#onready var _size_label = _params_container.get_node("BrushSizeLabel") + +onready var _opacity_slider = $GridContainer/BrushOpacityControl/Slider +onready var _opacity_value_label = $GridContainer/BrushOpacityControl/Label +onready var _opacity_control = $GridContainer/BrushOpacityControl +onready var _opacity_label = $GridContainer/BrushOpacityLabel + +onready var _flatten_height_container = $GridContainer/HB +onready var _flatten_height_box = $GridContainer/HB/FlattenHeightControl +onready var _flatten_height_label = $GridContainer/FlattenHeightLabel +onready var _flatten_height_pick_button = $GridContainer/HB/FlattenHeightPickButton + +onready var _color_picker = $GridContainer/ColorPickerButton +onready var _color_label = $GridContainer/ColorLabel + +onready var _density_slider = $GridContainer/DensitySlider +onready var _density_label = $GridContainer/DensityLabel + +onready var _holes_label = $GridContainer/HoleLabel +onready var _holes_checkbox = $GridContainer/HoleCheckbox + +onready var _slope_limit_label = $GridContainer/SlopeLimitLabel +onready var _slope_limit_control = $GridContainer/SlopeLimit + +onready var _shape_texture_rect = get_node("BrushShapeButton/TextureRect") + +var _brush : Brush +var _load_image_dialog = null +var _logger = 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.connect("value_changed", self, "_on_size_slider_value_changed") + _opacity_slider.connect("value_changed", self, "_on_opacity_slider_value_changed") + _flatten_height_box.connect("value_changed", self, "_on_flatten_height_box_value_changed") + _color_picker.connect("color_changed", self, "_on_color_picker_color_changed") + _density_slider.connect("value_changed", self, "_on_density_slider_changed") + _holes_checkbox.connect("toggled", self, "_on_holes_checkbox_toggled") + _slope_limit_control.connect("changed", self, "_on_slope_limit_changed") + + _size_slider.max_value = 200 + #if NativeFactory.is_native_available(): + # _size_slider.max_value = 200 + #else: + # _size_slider.max_value = 50 + + +func setup_dialogs(base_control: Control): + assert(_load_image_dialog == null) + _load_image_dialog = EditorFileDialog.new() + _load_image_dialog.mode = EditorFileDialog.MODE_OPEN_FILE + _load_image_dialog.add_filter("*.exr ; EXR files") + _load_image_dialog.resizable = true + _load_image_dialog.access = EditorFileDialog.ACCESS_FILESYSTEM + _load_image_dialog.current_dir = SHAPES_DIR + _load_image_dialog.connect("file_selected", self, "_on_LoadImageDialog_file_selected") + base_control.add_child(_load_image_dialog) + + +func _exit_tree(): + if _load_image_dialog != null: + _load_image_dialog.queue_free() + _load_image_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_brush(brush: Brush): + if _brush != null: + _brush.disconnect("changed", self, "_on_brush_changed") + + _brush = brush + + if brush != 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(_brush != null) + + if _brush != null: + # Initial params + _size_slider.value = brush.get_brush_size() + _opacity_slider.ratio = brush.get_opacity() + _flatten_height_box.value = brush.get_flatten_height() + _color_picker.get_picker().color = brush.get_color() + _density_slider.value = brush.get_detail_density() + _holes_checkbox.pressed = not brush.get_mask_flag() + + var low = rad2deg(brush.get_slope_limit_low_angle()) + var high = rad2deg(brush.get_slope_limit_high_angle()) + _slope_limit_control.set_values(low, high) + + set_display_mode(brush.get_mode()) + _set_brush_shape_from_file(SHAPES_DIR.plus_file(DEFAULT_BRUSH)) + + _brush.connect("changed", self, "_on_brush_properties_changed") + + +func _on_brush_properties_changed(): + _flatten_height_box.value = _brush.get_flatten_height() + _flatten_height_pick_button.pressed = false + + +func set_display_mode(mode: int): + var show_flatten := mode == Brush.MODE_FLATTEN + var show_color := mode == Brush.MODE_COLOR + var show_density := mode == Brush.MODE_DETAIL + var show_opacity := mode != Brush.MODE_MASK + var show_holes := mode == Brush.MODE_MASK + var show_slope_limit := mode == Brush.MODE_SPLAT + + _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.pressed = false + + +func _on_size_slider_value_changed(v: float): + if _brush != null: + _brush.set_brush_size(int(v)) + _size_value_label.text = str(v) + + +func _on_opacity_slider_value_changed(v: float): + if _brush != null: + _brush.set_opacity(_opacity_slider.ratio) + _opacity_value_label.text = str(v) + + +func _on_flatten_height_box_value_changed(v: float): + if _brush != null: + _brush.set_flatten_height(v) + + +func _on_color_picker_color_changed(v: Color): + if _brush != null: + _brush.set_color(v) + + +func _on_density_slider_changed(v: float): + if _brush != null: + _brush.set_detail_density(v) + + +func _on_holes_checkbox_toggled(v: bool): + if _brush != null: + # When checked, we draw holes. When unchecked, we clear holes + _brush.set_mask_flag(not v) + + +func _on_BrushShapeButton_pressed(): + _load_image_dialog.popup_centered_ratio(0.7) + + +func _on_LoadImageDialog_file_selected(path: String): + _set_brush_shape_from_file(path) + + +func _set_brush_shape_from_file(path: String): + var im := Image.new() + var err := im.load(path) + if err != OK: + _logger.error("Could not load image at '{0}', error {1}" \ + .format([path, Errors.get_message(err)])) + return + + var tex := ImageTexture.new() + tex.create_from_image(im, Texture.FLAG_FILTER) + + if _brush != null: + var im2 := im + var v := Engine.get_version_info() + if v.major == 3 and v.minor < 1: + # Forcing image brushes would ruin resized ones, + # due to https://github.com/godotengine/godot/issues/24244 + if path.find(SHAPES_DIR.plus_file(DEFAULT_BRUSH)) != -1: + im2 = null + + _brush.set_brush_texture(tex) + + _shape_texture_rect.texture = tex + + +func _on_FlattenHeightPickButton_pressed(): + _brush.set_meta("pick_height", true) + + +func _on_slope_limit_changed(): + var low = deg2rad(_slope_limit_control.get_low_value()) + var high = deg2rad(_slope_limit_control.get_high_value()) + _brush.set_slope_limit_angles(low, high) diff --git a/addons/zylann.hterrain/tools/brush/brush_editor.tscn b/addons/zylann.hterrain/tools/brush/brush_editor.tscn new file mode 100644 index 0000000..286adef --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/brush_editor.tscn @@ -0,0 +1,189 @@ +[gd_scene load_steps=5 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/brush/brush_editor.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/empty.png" type="Texture" id=2] +[ext_resource path="res://addons/zylann.hterrain/tools/util/interval_slider.gd" type="Script" id=3] + +[sub_resource type="CanvasItemMaterial" id=1] +blend_mode = 1 + +[node name="BrushEditor" type="Control"] +margin_right = 307.0 +margin_bottom = 109.0 +rect_min_size = Vector2( 200, 0 ) +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="BrushShapeButton" type="Button" parent="."] +anchor_bottom = 1.0 +margin_right = 12.0 +rect_min_size = Vector2( 50, 0 ) + +[node name="TextureRect" type="TextureRect" parent="BrushShapeButton"] +material = SubResource( 1 ) +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +texture = ExtResource( 2 ) +expand = true +stretch_mode = 6 + +[node name="GridContainer" type="GridContainer" parent="."] +anchor_right = 1.0 +margin_left = 54.0 +margin_right = -7.0 +margin_bottom = 144.0 +size_flags_horizontal = 3 +columns = 2 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="BrushSizeLabel" type="Label" parent="GridContainer"] +margin_top = 1.0 +margin_right = 89.0 +margin_bottom = 15.0 +text = "Brush size" + +[node name="BrushSizeControl" type="HBoxContainer" parent="GridContainer"] +margin_left = 93.0 +margin_right = 246.0 +margin_bottom = 16.0 +mouse_filter = 0 +size_flags_horizontal = 3 + +[node name="Slider" type="HSlider" parent="GridContainer/BrushSizeControl"] +margin_right = 119.0 +margin_bottom = 16.0 +size_flags_horizontal = 3 +size_flags_vertical = 1 +min_value = 2.0 +max_value = 200.0 +value = 2.0 +exp_edit = true +rounded = true + +[node name="Label" type="Label" parent="GridContainer/BrushSizeControl"] +margin_left = 123.0 +margin_top = 1.0 +margin_right = 153.0 +margin_bottom = 15.0 +rect_min_size = Vector2( 30, 0 ) +text = "999" +align = 2 + +[node name="BrushOpacityLabel" type="Label" parent="GridContainer"] +margin_top = 21.0 +margin_right = 89.0 +margin_bottom = 35.0 +text = "Brush opacity" + +[node name="BrushOpacityControl" type="HBoxContainer" parent="GridContainer"] +margin_left = 93.0 +margin_top = 20.0 +margin_right = 246.0 +margin_bottom = 36.0 +size_flags_horizontal = 3 + +[node name="Slider" type="HSlider" parent="GridContainer/BrushOpacityControl"] +margin_right = 119.0 +margin_bottom = 16.0 +size_flags_horizontal = 3 +size_flags_vertical = 1 + +[node name="Label" type="Label" parent="GridContainer/BrushOpacityControl"] +margin_left = 123.0 +margin_top = 1.0 +margin_right = 153.0 +margin_bottom = 15.0 +rect_min_size = Vector2( 30, 0 ) +text = "999" +align = 2 + +[node name="FlattenHeightLabel" type="Label" parent="GridContainer"] +margin_top = 45.0 +margin_right = 89.0 +margin_bottom = 59.0 +text = "Flatten height" + +[node name="HB" type="HBoxContainer" parent="GridContainer"] +margin_left = 93.0 +margin_top = 40.0 +margin_right = 246.0 +margin_bottom = 64.0 + +[node name="FlattenHeightControl" type="SpinBox" parent="GridContainer/HB"] +margin_right = 111.0 +margin_bottom = 24.0 +size_flags_horizontal = 3 +min_value = -500.0 +max_value = 500.0 + +[node name="FlattenHeightPickButton" type="Button" parent="GridContainer/HB"] +margin_left = 115.0 +margin_right = 153.0 +margin_bottom = 24.0 +toggle_mode = true +text = "Pick" + +[node name="ColorLabel" type="Label" parent="GridContainer"] +margin_top = 71.0 +margin_right = 89.0 +margin_bottom = 85.0 +text = "Color" + +[node name="ColorPickerButton" type="ColorPickerButton" parent="GridContainer"] +margin_left = 93.0 +margin_top = 68.0 +margin_right = 246.0 +margin_bottom = 88.0 +toggle_mode = false +color = Color( 1, 1, 1, 1 ) + +[node name="DensityLabel" type="Label" parent="GridContainer"] +margin_top = 97.0 +margin_right = 89.0 +margin_bottom = 111.0 +text = "Detail density" + +[node name="DensitySlider" type="HSlider" parent="GridContainer"] +margin_left = 93.0 +margin_top = 92.0 +margin_right = 246.0 +margin_bottom = 116.0 +rect_min_size = Vector2( 0, 24 ) +max_value = 1.0 +step = 0.1 + +[node name="HoleLabel" type="Label" parent="GridContainer"] +margin_top = 125.0 +margin_right = 89.0 +margin_bottom = 139.0 +text = "Draw holes" + +[node name="HoleCheckbox" type="CheckBox" parent="GridContainer"] +margin_left = 93.0 +margin_top = 120.0 +margin_right = 246.0 +margin_bottom = 144.0 + +[node name="SlopeLimitLabel" type="Label" parent="GridContainer"] +margin_top = 149.0 +margin_right = 89.0 +margin_bottom = 163.0 +text = "Slope limit" + +[node name="SlopeLimit" type="Control" parent="GridContainer"] +margin_left = 93.0 +margin_top = 148.0 +margin_right = 246.0 +margin_bottom = 164.0 +rect_min_size = Vector2( 0, 16 ) +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/addons/zylann.hterrain/tools/brush/decal.gd b/addons/zylann.hterrain/tools/brush/decal.gd new file mode 100644 index 0000000..63d1dac --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/decal.gd @@ -0,0 +1,120 @@ +tool +# Shows a cursor on top of the terrain to preview where the brush will paint + +const DirectMeshInstance = preload("../../util/direct_mesh_instance.gd") +const HTerrainData = preload("../../hterrain_data.gd") +const Util = preload("../../util/util.gd") + +var _mesh_instance = null +var _mesh = null +var _material = ShaderMaterial.new() +#var _debug_mesh = CubeMesh.new() +#var _debug_mesh_instance = null + +var _terrain = null + + +func _init(): + _material.shader = load("res://addons/zylann.hterrain/tools/brush/decal.shader") + _mesh_instance = 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): + _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 + if ss > 50: + ss /= 2 + if 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): + var inv = terrain_global_trans.affine_inverse() + _material.set_shader_param("u_terrain_inverse_transform", inv) + + var normal_basis = terrain_global_trans.basis.inverse().transposed() + _material.set_shader_param("u_terrain_normal_basis", normal_basis) + + +func set_terrain(terrain): + if _terrain == terrain: + return + + if _terrain != null: + _terrain.disconnect("transform_changed", self, "_on_terrain_transform_changed") + _mesh_instance.exit_world() + #_debug_mesh_instance.exit_world() + + _terrain = terrain + + if _terrain != null: + _terrain.connect("transform_changed", self, "_on_terrain_transform_changed") + _on_terrain_transform_changed(_terrain.get_internal_transform()) + _mesh_instance.enter_world(terrain.get_world()) + #_debug_mesh_instance.enter_world(terrain.get_world()) + + update_visibility() + + +func set_position(p_local_pos): + 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 = Transform(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_param("u_terrain_heightmap", null) + _mesh_instance.set_visible(false) + #_debug_mesh_instance.set_visible(false) + else: + _material.set_shader_param("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/addons/zylann.hterrain/tools/brush/decal.shader b/addons/zylann.hterrain/tools/brush/decal.shader new file mode 100644 index 0000000..8de0ca1 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/decal.shader @@ -0,0 +1,35 @@ +shader_type spatial; +render_mode unshaded;//, depth_test_disable; + +uniform sampler2D u_terrain_heightmap; +uniform mat4 u_terrain_inverse_transform; +uniform mat3 u_terrain_normal_basis; + +void vertex() { + vec2 cell_coords = (u_terrain_inverse_transform * WORLD_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 = texture(u_terrain_heightmap, uv + vec2(-ps.x, 0)).r * k; + float right = texture(u_terrain_heightmap, uv + vec2(ps.x, 0)).r * k; + float back = texture(u_terrain_heightmap, uv + vec2(0, -ps.y)).r * k; + float fore = texture(u_terrain_heightmap, uv + vec2(0, ps.y)).r * k; + vec3 n = normalize(vec3(left - right, 2.0, back - fore)); + + n = u_terrain_normal_basis * n; + + float h = texture(u_terrain_heightmap, uv).r; + 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/addons/zylann.hterrain/tools/brush/painter.gd b/addons/zylann.hterrain/tools/brush/painter.gd new file mode 100644 index 0000000..4bec9fc --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/painter.gd @@ -0,0 +1,289 @@ + +# 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, only the painting 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 Logger = preload("../../util/logger.gd") +const Util = preload("../../util/util.gd") + +const UNDO_CHUNK_SIZE = 64 +const BRUSH_TEXTURE_SHADER_PARAM = "u_brush_texture" + +# 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, + Image.FORMAT_RH, + Image.FORMAT_RGH, + Image.FORMAT_RGBH, + Image.FORMAT_RGBAH +] + +var _viewport : Viewport +var _viewport_sprite : Sprite +var _brush_size := 32 +var _brush_position := Vector2() +var _brush_texture : Texture +var _last_brush_position := Vector2() +var _brush_material := ShaderMaterial.new() +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 = Logger.get_for(self) + + +func _ready(): + if Util.is_in_edited_scene(self): + return + _viewport = Viewport.new() + _viewport.size = Vector2(_brush_size, _brush_size) + _viewport.render_target_update_mode = Viewport.UPDATE_ONCE + _viewport.render_target_v_flip = true + _viewport.render_target_clear_mode = Viewport.CLEAR_MODE_ONLY_NEXT_FRAME + _viewport.hdr = false + _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 + + _viewport_sprite = Sprite.new() + _viewport_sprite.centered = false + _viewport_sprite.material = _brush_material + _viewport.add_child(_viewport_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_sprite.texture = _texture + if image != null: + _viewport.hdr = image.get_format() in _hdr_formats + #print("PAINTER VIEWPORT HDR: ", _viewport.hdr) + + +func set_brush_size(new_size: int): + _brush_size = new_size + + +func get_brush_size() -> int: + return _brush_size + + +func set_brush_texture(texture: Texture): + _brush_material.set_shader_param(BRUSH_TEXTURE_SHADER_PARAM, texture) + + +func set_brush_shader(shader: Shader): + if _brush_material.shader != shader: + _brush_material.shader = shader + + +func set_brush_shader_param(p: String, v): + _modified_shader_params[p] = true + _brush_material.set_shader_param(p, v) + + +func clear_brush_shader_params(): + for key in _modified_shader_params: + _brush_material.set_shader_param(key, null) + _modified_shader_params.clear() + + +# You must call this from an `_input` function or similar. +func paint_input(center_pos: Vector2): + var vp_size = 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 + + # Need to floor the position in case the brush has an odd size + var brush_pos := (center_pos - Vector2(_brush_size, _brush_size) * 0.5).round() + _viewport.render_target_update_mode = Viewport.UPDATE_ONCE + _viewport_sprite.position = -brush_pos + _brush_position = brush_pos + _cmd_paint = true + + # 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 = _brush_size / _texture.get_width() + rect.a = _brush_size / _texture.get_height() + _brush_material.set_shader_param("u_texture_rect", rect) + + +# 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 data := _viewport.get_texture().get_data() + data.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 = max(-brush_pos.x, 0) + var src_y : int = max(-brush_pos.y, 0) + var src_w : int = min(max(_brush_size - src_x, 0), _texture.get_width() - dst_x) + var src_h : int = min(max(_brush_size - 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) + VisualServer.texture_set_data_partial( + _texture.get_rid(), data, src_x, src_y, src_w, src_h, dst_x, dst_y, 0, 0) + emit_signal("texture_region_changed", 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): + _modified_chunks[Vector2(cx, cy)] = true + + +func _commit_modified_chunks() -> Dictionary: + var time_before := OS.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_data() + 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 = min(cs, _image.get_width() - x) + var h : int = min(cs, _image.get_height() - y) + + var rect := Rect2(x, y, w, h) + var initial_data := _image.get_rect(rect) + var final_data := final_image.get_rect(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 := OS.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/addons/zylann.hterrain/tools/brush/shaders/alpha.shader b/addons/zylann.hterrain/tools/brush/shaders/alpha.shader new file mode 100644 index 0000000..a348a72 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/alpha.shader @@ -0,0 +1,13 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; +uniform float u_value = 1.0; + +void fragment() { + float brush_value = texture(u_brush_texture, SCREEN_UV).r; + + vec4 src = texture(TEXTURE, UV); + COLOR = vec4(src.rgb, mix(src.a, u_value, u_factor * brush_value)); +} diff --git a/addons/zylann.hterrain/tools/brush/shaders/color.shader b/addons/zylann.hterrain/tools/brush/shaders/color.shader new file mode 100644 index 0000000..0333058 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/color.shader @@ -0,0 +1,21 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; +uniform vec4 u_color = vec4(1.0); + +void fragment() { + float brush_value = texture(u_brush_texture, SCREEN_UV).r; + + vec4 src = texture(TEXTURE, 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 * u_factor), src.a); + COLOR = col; +} diff --git a/addons/zylann.hterrain/tools/brush/shaders/erode.shader b/addons/zylann.hterrain/tools/brush/shaders/erode.shader new file mode 100644 index 0000000..987a266 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/erode.shader @@ -0,0 +1,50 @@ +shader_type canvas_item; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; +uniform vec4 u_color = vec4(1.0); + +// float get_noise(vec2 pos) { +// return fract(sin(dot(pos.xy ,vec2(12.9898,78.233))) * 43758.5453); +// } + +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 = texture(heightmap, uv).r; + 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 = texture(heightmap, uv + p * eps).r; + + 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 = texture(u_brush_texture, SCREEN_UV).r * u_factor; + float ph = erode(TEXTURE, UV, TEXTURE_PIXEL_SIZE, brush_value); + //ph += brush_value * 0.35; + COLOR = vec4(ph, ph, ph, 1.0); +} diff --git a/addons/zylann.hterrain/tools/brush/shaders/flatten.shader b/addons/zylann.hterrain/tools/brush/shaders/flatten.shader new file mode 100644 index 0000000..38f5044 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/flatten.shader @@ -0,0 +1,14 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; +uniform float u_flatten_value; + +void fragment() { + float brush_value = texture(u_brush_texture, SCREEN_UV).r; + + float src_h = texture(TEXTURE, UV).r; + float h = mix(src_h, u_flatten_value, u_factor * brush_value); + COLOR = vec4(h, 0.0, 0.0, 1.0); +} diff --git a/addons/zylann.hterrain/tools/brush/shaders/level.shader b/addons/zylann.hterrain/tools/brush/shaders/level.shader new file mode 100644 index 0000000..8d0b885 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/level.shader @@ -0,0 +1,33 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; +uniform vec4 u_texture_rect; + +// TODO Could actually level to whatever height the brush was at the beginning of the stroke? + +void fragment() { + float brush_value = texture(u_brush_texture, SCREEN_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_texture_rect.xy); + vec2 uv_max = vec2(u_texture_rect.xy + u_texture_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 = texture(TEXTURE, vec2(x, y)).r; + dst_h += h; + } + } + dst_h /= (5.0 * 5.0); + + // TODO I have no idea if this will check out + float src_h = texture(TEXTURE, UV).r; + float h = mix(src_h, dst_h, u_factor * brush_value); + COLOR = vec4(h, 0.0, 0.0, 1.0); +} diff --git a/addons/zylann.hterrain/tools/brush/shaders/raise.shader b/addons/zylann.hterrain/tools/brush/shaders/raise.shader new file mode 100644 index 0000000..6cbc41a --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/raise.shader @@ -0,0 +1,13 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; + +void fragment() { + float brush_value = texture(u_brush_texture, SCREEN_UV).r; + + float src_h = texture(TEXTURE, UV).r; + float h = src_h + u_factor * brush_value; + COLOR = vec4(h, 0.0, 0.0, 1.0); +} diff --git a/addons/zylann.hterrain/tools/brush/shaders/smooth.shader b/addons/zylann.hterrain/tools/brush/shaders/smooth.shader new file mode 100644 index 0000000..c79dfdf --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/smooth.shader @@ -0,0 +1,19 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; + +void fragment() { + float brush_value = texture(u_brush_texture, SCREEN_UV).r; + + vec2 offset = TEXTURE_PIXEL_SIZE; + float src_nx = texture(TEXTURE, UV - vec2(offset.x, 0.0)).r; + float src_px = texture(TEXTURE, UV + vec2(offset.x, 0.0)).r; + float src_ny = texture(TEXTURE, UV - vec2(0.0, offset.y)).r; + float src_py = texture(TEXTURE, UV + vec2(0.0, offset.y)).r; + float src_h = texture(TEXTURE, UV).r; + float dst_h = (src_h + src_nx + src_px + src_ny + src_py) * 0.2; + float h = mix(src_h, dst_h, u_factor * brush_value); + COLOR = vec4(h, 0.0, 0.0, 1.0); +} diff --git a/addons/zylann.hterrain/tools/brush/shaders/splat16.shader b/addons/zylann.hterrain/tools/brush/shaders/splat16.shader new file mode 100644 index 0000000..120b8c4 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/splat16.shader @@ -0,0 +1,68 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 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; + +float sum(vec4 v) { + return v.x + v.y + v.z + v.w; +} + +vec3 get_normal(sampler2D heightmap, vec2 pos) { + vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0)); + float hnx = texture(heightmap, pos + vec2(-ps.x, 0.0)).r; + float hpx = texture(heightmap, pos + vec2(ps.x, 0.0)).r; + float hny = texture(heightmap, pos + vec2(0.0, -ps.y)).r; + float hpy = texture(heightmap, pos + vec2(0.0, ps.y)).r; + 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 = texture(u_brush_texture, SCREEN_UV).r * u_factor; + + vec3 normal = get_normal(u_heightmap, 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(TEXTURE, UV); + vec4 src1 = texture(u_other_splatmap_1, UV); + vec4 src2 = texture(u_other_splatmap_2, UV); + vec4 src3 = texture(u_other_splatmap_3, 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/addons/zylann.hterrain/tools/brush/shaders/splat4.shader b/addons/zylann.hterrain/tools/brush/shaders/splat4.shader new file mode 100644 index 0000000..a5178a5 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/splat4.shader @@ -0,0 +1,50 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 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; + +vec3 get_normal(sampler2D heightmap, vec2 pos) { + vec2 ps = vec2(1.0) / vec2(textureSize(heightmap, 0)); + float hnx = texture(heightmap, pos + vec2(-ps.x, 0.0)).r; + float hpx = texture(heightmap, pos + vec2(ps.x, 0.0)).r; + float hny = texture(heightmap, pos + vec2(0.0, -ps.y)).r; + float hpy = texture(heightmap, pos + vec2(0.0, ps.y)).r; + 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 = texture(u_brush_texture, SCREEN_UV).r * u_factor; + + vec3 normal = get_normal(u_heightmap, UV); + brush_value = apply_slope_limit(brush_value, normal, u_normal_min_y, u_normal_max_y); + + vec4 src_splat = texture(TEXTURE, 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/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.shader b/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.shader new file mode 100644 index 0000000..c4d30b5 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shaders/splat_indexed.shader @@ -0,0 +1,82 @@ +shader_type canvas_item; +render_mode blend_disabled; + +uniform sampler2D u_brush_texture; +uniform float u_factor = 1.0; +uniform int u_texture_index; +uniform int u_mode; // 0: index, 1: weight +uniform sampler2D u_index_map; +uniform sampler2D u_weight_map; + +void fragment() { + float brush_value = texture(u_brush_texture, SCREEN_UV).r * clamp(u_factor, 0.0, 1.0); + + vec4 iv = texture(u_index_map, UV); + vec4 wv = texture(u_weight_map, 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/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr b/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr new file mode 100644 index 0000000..14b66d6 Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import b/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import new file mode 100644 index 0000000..538d1ca --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/acrylic1.exr-8a4b622f104c607118d296791ee118f3.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/acrylic1.exr" +dest_files=[ "res://.import/acrylic1.exr-8a4b622f104c607118d296791ee118f3.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/round0.exr b/addons/zylann.hterrain/tools/brush/shapes/round0.exr new file mode 100644 index 0000000..e91d97e Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/round0.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import b/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import new file mode 100644 index 0000000..3d648ae --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/round0.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/round0.exr-fc6d691e8892911b1b4496769ee75dbb.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/round0.exr" +dest_files=[ "res://.import/round0.exr-fc6d691e8892911b1b4496769ee75dbb.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/round1.exr b/addons/zylann.hterrain/tools/brush/shapes/round1.exr new file mode 100644 index 0000000..f6931ba Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/round1.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import b/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import new file mode 100644 index 0000000..48e1891 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/round1.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/round1.exr-8050cfbed31968e6ce8bd055fbaa6897.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/round1.exr" +dest_files=[ "res://.import/round1.exr-8050cfbed31968e6ce8bd055fbaa6897.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/round2.exr b/addons/zylann.hterrain/tools/brush/shapes/round2.exr new file mode 100644 index 0000000..477ab7e Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/round2.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import b/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import new file mode 100644 index 0000000..c7ed7a9 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/round2.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/round2.exr-2a843db3bf131f2b2f5964ce65600f42.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/round2.exr" +dest_files=[ "res://.import/round2.exr-2a843db3bf131f2b2f5964ce65600f42.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/round3.exr b/addons/zylann.hterrain/tools/brush/shapes/round3.exr new file mode 100644 index 0000000..b466f92 Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/round3.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import b/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import new file mode 100644 index 0000000..4ee8ad4 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/round3.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/round3.exr-77a9cdd9a592eb6010dc1db702d42c3a.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/round3.exr" +dest_files=[ "res://.import/round3.exr-77a9cdd9a592eb6010dc1db702d42c3a.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/smoke.exr b/addons/zylann.hterrain/tools/brush/shapes/smoke.exr new file mode 100644 index 0000000..021947b Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/smoke.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import b/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import new file mode 100644 index 0000000..636f8b0 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/smoke.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/smoke.exr-0061a0a2acdf1ca295ec547e4b8c920d.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/smoke.exr" +dest_files=[ "res://.import/smoke.exr-0061a0a2acdf1ca295ec547e4b8c920d.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/texture1.exr b/addons/zylann.hterrain/tools/brush/shapes/texture1.exr new file mode 100644 index 0000000..f456b77 Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/texture1.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import b/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import new file mode 100644 index 0000000..83c6c4b --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/texture1.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/texture1.exr-0fac1840855f814972ea5666743101fc.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/texture1.exr" +dest_files=[ "res://.import/texture1.exr-0fac1840855f814972ea5666743101fc.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/thing.exr b/addons/zylann.hterrain/tools/brush/shapes/thing.exr new file mode 100644 index 0000000..49d341e Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/thing.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import b/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import new file mode 100644 index 0000000..6c956f0 --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/thing.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/thing.exr-8e88d861fe83e5e870fa01faee694c73.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/thing.exr" +dest_files=[ "res://.import/thing.exr-8e88d861fe83e5e870fa01faee694c73.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr b/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr new file mode 100644 index 0000000..d65bc6e Binary files /dev/null and b/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr differ diff --git a/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import b/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import new file mode 100644 index 0000000..d1f387a --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/vegetation1.exr-0573f4c73944e2dd8f3202b8930ac625.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/brush/shapes/vegetation1.exr" +dest_files=[ "res://.import/vegetation1.exr-0573f4c73944e2dd8f3202b8930ac625.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/brush/terrain_painter.gd b/addons/zylann.hterrain/tools/brush/terrain_painter.gd new file mode 100644 index 0000000..113dc3c --- /dev/null +++ b/addons/zylann.hterrain/tools/brush/terrain_painter.gd @@ -0,0 +1,556 @@ +extends Node + +const Painter = preload("./painter.gd") +const HTerrain = preload("../../hterrain.gd") +const HTerrainData = preload("../../hterrain_data.gd") +const Logger = preload("../../util/logger.gd") + +const RaiseShader = preload("./shaders/raise.shader") +const SmoothShader = preload("./shaders/smooth.shader") +const LevelShader = preload("./shaders/level.shader") +const FlattenShader = preload("./shaders/flatten.shader") +const ErodeShader = preload("./shaders/erode.shader") +const Splat4Shader = preload("./shaders/splat4.shader") +const Splat16Shader = preload("./shaders/splat16.shader") +const SplatIndexedShader = preload("./shaders/splat_indexed.shader") +const ColorShader = preload("./shaders/color.shader") +const AlphaShader = preload("./shaders/alpha.shader") + +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 ModifiedMap: + var map_type := 0 + var map_index := 0 + var painter_index := 0 + +signal changed + +var _painters := [] + +var _brush_size := 32 +var _opacity := 1.0 +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 = Logger.get_for(self) + + +func _init(): + for i in 4: + var p = Painter.new() + # The name is just for debugging + p.set_name(str("Painter", i)) + p.set_brush_size(_brush_size) + p.connect("texture_region_changed", self, "_on_painter_texture_region_changed", [i]) + add_child(p) + _painters.append(p) + + +func get_brush_size() -> int: + return _brush_size + + +func set_brush_size(s: int): + if _brush_size == s: + return + _brush_size = s + for p in _painters: + p.set_brush_size(_brush_size) + emit_signal("changed") + + +func set_brush_texture(texture: Texture): + for p in _painters: + p.set_brush_texture(texture) + + +func get_opacity() -> float: + return _opacity + + +func set_opacity(opacity: float): + _opacity = opacity + + +func set_flatten_height(h: float): + if h == _flatten_height: + return + _flatten_height = h + emit_signal("changed") + + +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 = clamp(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 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 + + for mm in _modified_maps: + var painter : 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) + + 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 +func paint_input(position: Vector2): + assert(_terrain.get_data() != null) + var data = _terrain.get_data() + assert(not data.is_locked()) + + _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) + + +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 = 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_size) + var delta := factor * _opacity * (2.0 / 60.0) * raise_strength + + var p : Painter = _painters[0] + + p.set_brush_shader(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 = ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_HEIGHT + mm.map_index = 0 + mm.painter_index = 0 + _modified_maps = [mm] + + var p : Painter = _painters[0] + + p.set_brush_shader(SmoothShader) + p.set_brush_shader_param("u_factor", _opacity * (10.0 / 60.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 = ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_HEIGHT + mm.map_index = 0 + mm.painter_index = 0 + _modified_maps = [mm] + + var p : Painter = _painters[0] + + p.set_brush_shader(FlattenShader) + p.set_brush_shader_param("u_factor", _opacity) + 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 = ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_HEIGHT + mm.map_index = 0 + mm.painter_index = 0 + _modified_maps = [mm] + + var p : Painter = _painters[0] + + p.set_brush_shader(LevelShader) + p.set_brush_shader_param("u_factor", _opacity * (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 = ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_HEIGHT + mm.map_index = 0 + mm.painter_index = 0 + _modified_maps = [mm] + + var p : Painter = _painters[0] + + p.set_brush_shader(ErodeShader) + p.set_brush_shader_param("u_factor", _opacity) + 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 = ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_SPLAT + mm.map_index = 0 + mm.painter_index = 0 + _modified_maps = [mm] + + var p : Painter = _painters[0] + var splat = Color(0.0, 0.0, 0.0, 0.0) + splat[_texture_index] = 1.0; + p.set_brush_shader(Splat4Shader) + p.set_brush_shader_param("u_factor", _opacity) + p.set_brush_shader_param("u_splat", splat) + 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) + 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 = ModifiedMap.new() + mm.map_type = map_types[mode] + mm.map_index = 0 + mm.painter_index = mode + _modified_maps.append(mm) + + var p : Painter = _painters[mode] + + p.set_brush_shader(SplatIndexedShader) + p.set_brush_shader_param("u_mode", mode) + p.set_brush_shader_param("u_factor", _opacity) + 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 := ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_SPLAT + mm.map_index = i + mm.painter_index = i + _modified_maps.append(mm) + + var p : Painter = _painters[i] + + var other_splatmaps = [] + for tex in textures: + if tex != texture: + other_splatmaps.append(tex) + + p.set_brush_shader(Splat16Shader) + p.set_brush_shader_param("u_factor", _opacity) + 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]) + 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) + 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 := ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_COLOR + mm.map_index = 0 + mm.painter_index = 0 + _modified_maps = [mm] + + var p : 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(ColorShader) + p.set_brush_shader_param("u_factor", _opacity) + p.set_brush_shader_param("u_color", _color) + 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 := ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_COLOR + mm.map_index = 0 + mm.painter_index = 0 + _modified_maps = [mm] + + var p : Painter = _painters[0] + + p.set_brush_shader(AlphaShader) + p.set_brush_shader_param("u_factor", _opacity) + 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 mm := ModifiedMap.new() + mm.map_type = HTerrainData.CHANNEL_DETAIL + mm.map_index = _detail_index + mm.painter_index = 0 + _modified_maps = [mm] + + var p : Painter = _painters[0] + var c := Color(_detail_density, _detail_density, _detail_density, 1.0) + + # TODO Don't use this shader + p.set_brush_shader(ColorShader) + p.set_brush_shader_param("u_factor", _opacity) + p.set_brush_shader_param("u_color", c) + p.set_image(image, texture) + p.paint_input(position) diff --git a/addons/zylann.hterrain/tools/bump2normal_tex.shader b/addons/zylann.hterrain/tools/bump2normal_tex.shader new file mode 100644 index 0000000..1790dde --- /dev/null +++ b/addons/zylann.hterrain/tools/bump2normal_tex.shader @@ -0,0 +1,19 @@ +shader_type canvas_item; + +vec4 pack_normal(vec3 n) { + return vec4((0.5 * (n + 1.0)).xzy, 1.0); +} + +void fragment() { + vec2 uv = UV; + vec2 ps = TEXTURE_PIXEL_SIZE; + float left = texture(TEXTURE, uv + vec2(-ps.x, 0)).r; + float right = texture(TEXTURE, uv + vec2(ps.x, 0)).r; + float back = texture(TEXTURE, uv + vec2(0, -ps.y)).r; + float fore = texture(TEXTURE, uv + vec2(0, ps.y)).r; + vec3 n = normalize(vec3(left - right, 2.0, fore - back)); + COLOR = pack_normal(n); + // DEBUG + //COLOR.r = fract(TIME * 100.0); +} + diff --git a/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd b/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd new file mode 100644 index 0000000..95e3375 --- /dev/null +++ b/addons/zylann.hterrain/tools/detail_editor/detail_editor.gd @@ -0,0 +1,183 @@ +tool +extends Control + +const HTerrainData = preload("../../hterrain_data.gd") +const HTerrainDetailLayer = preload("../../hterrain_detail_layer.gd") +const ImageFileCache = preload("../../util/image_file_cache.gd") + +signal detail_selected(index) +# Emitted when the tool added or removed a detail map +signal detail_list_changed + +onready var _item_list = $ItemList +onready var _confirmation_dialog = $ConfirmationDialog + +var _terrain = null +var _dialog_target = -1 +var _placeholder_icon = load("res://addons/zylann.hterrain/tools/icons/icon_grass.svg") +var _detail_layer_icon = load("res://addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg") +var _undo_redo : UndoRedo +var _image_cache : ImageFileCache + + +func set_terrain(terrain): + if _terrain == terrain: + return + _terrain = terrain + _update_list() + + +func set_undo_redo(ur: UndoRedo): + _undo_redo = ur + + +func set_image_cache(image_cache: ImageFileCache): + _image_cache = image_cache + + +func set_layer_index(i): + _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) + 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 = PoolStringArray(layer_nodes_by_index[i]).join(", ") + 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.window_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 != 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 + 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 + + # 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", + HTerrainData.CHANNEL_DETAIL, map_index, _image_cache, map_image_cache_id) + _undo_redo.add_do_method(_terrain, "add_child", 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", node) + _undo_redo.add_undo_method( + terrain_data, "_edit_remove_map", 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() + emit_signal("detail_list_changed") + + var index = node.layer_index + _item_list.select(index) + # select() doesn't trigger the signal + emit_signal("detail_selected", 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) + + _undo_redo.create_action("Remove Detail Layer {0}".format([map_index])) + + _undo_redo.add_do_method( + terrain_data, "_edit_remove_map", HTerrainData.CHANNEL_DETAIL, map_index) + for node in using_nodes: + _undo_redo.add_do_method(_terrain, "remove_child", node) + _undo_redo.add_do_method(self, "_update_list") + + _undo_redo.add_undo_method(terrain_data, "_edit_insert_map_from_image_cache", + HTerrainData.CHANNEL_DETAIL, map_index, _image_cache, image_id) + for node in using_nodes: + _undo_redo.add_undo_method(_terrain, "add_child", 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() + emit_signal("detail_list_changed") + + +func _on_ItemList_item_selected(index): + emit_signal("detail_selected", index) + + + diff --git a/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn b/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn new file mode 100644 index 0000000..49b65e8 --- /dev/null +++ b/addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn @@ -0,0 +1,55 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/detail_editor/detail_editor.gd" type="Script" id=1] + +[node name="DetailEditor" type="Control"] +margin_right = 189.0 +margin_bottom = 109.0 +rect_min_size = Vector2( 200, 0 ) +script = ExtResource( 1 ) + +[node name="ItemList" type="ItemList" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_bottom = -26.0 +max_columns = 0 +same_column_width = true +icon_mode = 0 +fixed_icon_size = Vector2( 32, 32 ) + +[node name="HBoxContainer" type="HBoxContainer" parent="."] +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_top = -24.0 + +[node name="Add" type="Button" parent="HBoxContainer"] +margin_right = 37.0 +margin_bottom = 24.0 +text = "Add" + +[node name="Remove" type="Button" parent="HBoxContainer"] +margin_left = 41.0 +margin_right = 105.0 +margin_bottom = 24.0 +text = "Remove" + +[node name="Label" type="Label" parent="HBoxContainer"] +margin_left = 109.0 +margin_top = 5.0 +margin_right = 154.0 +margin_bottom = 19.0 +text = "Details" + +[node name="ConfirmationDialog" type="ConfirmationDialog" parent="."] +margin_left = 77.0 +margin_top = 523.0 +margin_right = 411.0 +margin_bottom = 598.0 +window_title = "Please confirm…" +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/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd b/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd new file mode 100644 index 0000000..8f7d33f --- /dev/null +++ b/addons/zylann.hterrain/tools/exporter/export_image_dialog.gd @@ -0,0 +1,209 @@ +tool +extends WindowDialog + +const HTerrainData = preload("../../hterrain_data.gd") +const Errors = preload("../../util/errors.gd") +const Util = preload("../../util/util.gd") +const Logger = preload("../../util/logger.gd") + +const FORMAT_RH = 0 +const FORMAT_R16 = 1 +const FORMAT_PNG8 = 2 +const FORMAT_EXRH = 3 +const FORMAT_COUNT = 4 + +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 = null +var _file_dialog : EditorFileDialog = null +var _format_names := [] +var _format_extensions := [] +var _logger = Logger.get_for(self) + + +func _ready(): + _format_names.resize(FORMAT_COUNT) + _format_extensions.resize(FORMAT_COUNT) + + _format_names[FORMAT_RH] = "16-bit RAW float (native)" + _format_names[FORMAT_R16] = "16-bit RAW unsigned" + _format_names[FORMAT_PNG8] = "8-bit PNG" + _format_names[FORMAT_EXRH] = "16-bit float greyscale EXR (native)" + + _format_extensions[FORMAT_RH] = "raw" + _format_extensions[FORMAT_R16] = "raw" + _format_extensions[FORMAT_PNG8] = "png" + _format_extensions[FORMAT_EXRH] = "exr" + + if not 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): + assert(_file_dialog == null) + var fd := EditorFileDialog.new() + fd.mode = EditorFileDialog.MODE_SAVE_FILE + fd.resizable = true + fd.access = EditorFileDialog.ACCESS_FILESYSTEM + fd.connect("file_selected", self, "_on_FileDialog_file_selected") + base_control.add_child(fd) + _file_dialog = fd + + _update_file_extension() + + +func set_terrain(terrain): + _terrain = terrain + + +func _exit_tree(): + if _file_dialog != null: + _file_dialog.queue_free() + _file_dialog = null + + +func _on_FileDialog_file_selected(fpath): + _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 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 + + if format == FORMAT_PNG8: + var hscale = 1.0 / (height_max - height_min) + var im = Image.new() + im.create(heightmap.get_width(), heightmap.get_height(), false, Image.FORMAT_R8) + + im.lock() + heightmap.lock() + + for y in heightmap.get_height(): + for x in heightmap.get_width(): + var h = clamp((heightmap.get_pixel(x, y).r - height_min) * hscale, 0.0, 1.0) + im.set_pixel(x, y, Color(h, h, h)) + + im.unlock() + heightmap.unlock() + + save_error = im.save_png(fpath) + + elif format == FORMAT_EXRH: + save_error = heightmap.save_exr(fpath, true) + + else: + var f = File.new() + var err = f.open(fpath, File.WRITE) + if err != OK: + _print_file_error(fpath, err) + return false + + if format == FORMAT_RH: + # Native format + f.store_buffer(heightmap.get_data()) + + elif format == FORMAT_R16: + var hscale = 65535.0 / (height_max - height_min) + heightmap.lock() + for y in heightmap.get_height(): + for x in heightmap.get_width(): + var h = int((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) + heightmap.unlock() + + f.close() + + 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 = _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, err): + _logger.error("Could not save path {0}, error: {1}" \ + .format([fpath, Errors.get_message(err)])) + + +func _on_CancelButton_pressed(): + hide() + + +func _on_ExportButton_pressed(): + if _export(): + hide() + if _show_in_explorer_checkbox.pressed: + OS.shell_open(_output_path_line_edit.text.strip_edges().get_base_dir()) + + +func _on_HeightmapPathLineEdit_text_changed(new_text): + _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/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn b/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn new file mode 100644 index 0000000..65da1cb --- /dev/null +++ b/addons/zylann.hterrain/tools/exporter/export_image_dialog.tscn @@ -0,0 +1,164 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/exporter/export_image_dialog.gd" type="Script" id=1] + +[node name="ExportImageDialog" type="WindowDialog"] +margin_left = 77.0 +margin_top = 64.0 +margin_right = 577.0 +margin_bottom = 264.0 +rect_min_size = Vector2( 500, 250 ) +window_title = "Export heightmap as image" +resizable = true +script = ExtResource( 1 ) + +[node name="VB" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 + +[node name="Grid" type="GridContainer" parent="VB"] +margin_right = 484.0 +margin_bottom = 76.0 +custom_constants/hseparation = 16 +columns = 2 + +[node name="OutputPathLabel" type="Label" parent="VB/Grid"] +margin_top = 5.0 +margin_right = 85.0 +margin_bottom = 19.0 +text = "Output path:" + +[node name="OutputPath" type="HBoxContainer" parent="VB/Grid"] +margin_left = 101.0 +margin_right = 484.0 +margin_bottom = 24.0 +size_flags_horizontal = 3 + +[node name="HeightmapPathLineEdit" type="LineEdit" parent="VB/Grid/OutputPath"] +margin_right = 355.0 +margin_bottom = 24.0 +size_flags_horizontal = 3 + +[node name="HeightmapPathBrowseButton" type="Button" parent="VB/Grid/OutputPath"] +margin_left = 359.0 +margin_right = 383.0 +margin_bottom = 24.0 +text = "..." + +[node name="FormatLabel" type="Label" parent="VB/Grid"] +margin_top = 31.0 +margin_right = 85.0 +margin_bottom = 45.0 +text = "Format:" + +[node name="FormatSelector" type="OptionButton" parent="VB/Grid"] +margin_left = 101.0 +margin_top = 28.0 +margin_right = 484.0 +margin_bottom = 48.0 + +[node name="HeightRangeLabel" type="Label" parent="VB/Grid"] +margin_top = 57.0 +margin_right = 85.0 +margin_bottom = 71.0 +text = "Height range:" + +[node name="HeightRange" type="HBoxContainer" parent="VB/Grid"] +margin_left = 101.0 +margin_top = 52.0 +margin_right = 484.0 +margin_bottom = 76.0 + +[node name="Label" type="Label" parent="VB/Grid/HeightRange"] +margin_top = 5.0 +margin_right = 24.0 +margin_bottom = 19.0 +text = "Min" + +[node name="HeightRangeMin" type="SpinBox" parent="VB/Grid/HeightRange"] +margin_left = 28.0 +margin_right = 128.0 +margin_bottom = 24.0 +rect_min_size = Vector2( 100, 0 ) +min_value = -10000.0 +max_value = 10000.0 +step = 0.0 +value = -2000.0 + +[node name="Label2" type="Label" parent="VB/Grid/HeightRange"] +margin_left = 132.0 +margin_top = 5.0 +margin_right = 158.0 +margin_bottom = 19.0 +text = "Max" + +[node name="HeightRangeMax" type="SpinBox" parent="VB/Grid/HeightRange"] +margin_left = 162.0 +margin_right = 262.0 +margin_bottom = 24.0 +rect_min_size = Vector2( 100, 0 ) +min_value = -10000.0 +max_value = 10000.0 +step = 0.0 +value = 2000.0 + +[node name="HeightRangeAutoButton" type="Button" parent="VB/Grid/HeightRange"] +margin_left = 266.0 +margin_right = 383.0 +margin_bottom = 24.0 +size_flags_horizontal = 3 +text = "Auto" + +[node name="ShowInExplorerCheckbox" type="CheckBox" parent="VB"] +margin_top = 80.0 +margin_right = 484.0 +margin_bottom = 104.0 +text = "Show in explorer after export" + +[node name="Spacer" type="Control" parent="VB"] +margin_top = 108.0 +margin_right = 484.0 +margin_bottom = 124.0 +rect_min_size = Vector2( 0, 16 ) + +[node name="Label" type="Label" parent="VB"] +margin_top = 128.0 +margin_right = 484.0 +margin_bottom = 159.0 +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 = true + +[node name="Spacer2" type="Control" parent="VB"] +margin_top = 163.0 +margin_right = 484.0 +margin_bottom = 179.0 +rect_min_size = Vector2( 0, 16 ) + +[node name="Buttons" type="HBoxContainer" parent="VB"] +margin_top = 183.0 +margin_right = 484.0 +margin_bottom = 203.0 +custom_constants/separation = 32 +alignment = 1 + +[node name="ExportButton" type="Button" parent="VB/Buttons"] +margin_left = 173.0 +margin_right = 225.0 +margin_bottom = 20.0 +text = "Export" + +[node name="CancelButton" type="Button" parent="VB/Buttons"] +margin_left = 257.0 +margin_right = 311.0 +margin_bottom = 20.0 +text = "Cancel" +[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/addons/zylann.hterrain/tools/generate_mesh_dialog.gd b/addons/zylann.hterrain/tools/generate_mesh_dialog.gd new file mode 100644 index 0000000..0a43e78 --- /dev/null +++ b/addons/zylann.hterrain/tools/generate_mesh_dialog.gd @@ -0,0 +1,49 @@ +tool +extends WindowDialog + +signal generate_selected(lod) + +const HTerrainMesher = preload("../hterrain_mesher.gd") +const Util = preload("../util/util.gd") + +onready var _preview_label = $VBoxContainer/PreviewLabel +onready var _lod_spinbox = $VBoxContainer/HBoxContainer/LODSpinBox + +var _terrain = null + + +func set_terrain(terrain): + _terrain = terrain + + +func _notification(what): + if what == NOTIFICATION_VISIBILITY_CHANGED: + if visible: + _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( \ + Util.format_integer(s.vertices), " vertices, ", \ + Util.format_integer(s.triangles), " triangles") + + +func _on_Generate_pressed(): + var stride = int(_lod_spinbox.value) + emit_signal("generate_selected", stride) + hide() + + +func _on_Cancel_pressed(): + hide() + diff --git a/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn b/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn new file mode 100644 index 0000000..230c6e7 --- /dev/null +++ b/addons/zylann.hterrain/tools/generate_mesh_dialog.tscn @@ -0,0 +1,243 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/generate_mesh_dialog.gd" type="Script" id=1] + +[node name="GenerateMeshDialog" type="WindowDialog" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 57.0 +margin_top = 83.0 +margin_right = 505.0 +margin_bottom = 269.0 +rect_min_size = Vector2( 448, 186 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +popup_exclusive = false +window_title = "Generate full mesh" +resizable = false +script = ExtResource( 1 ) +_sections_unfolded = [ "Rect" ] + +[node name="VBoxContainer" type="VBoxContainer" parent="." index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +alignment = 0 +_sections_unfolded = [ "Margin" ] + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_right = 432.0 +margin_bottom = 24.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +alignment = 0 + +[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 5.0 +margin_right = 28.0 +margin_bottom = 19.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "LOD" +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="LODSpinBox" type="SpinBox" parent="VBoxContainer/HBoxContainer" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 32.0 +margin_right = 432.0 +margin_bottom = 24.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 3 +size_flags_vertical = 1 +min_value = 1.0 +max_value = 16.0 +step = 1.0 +page = 0.0 +value = 1.0 +exp_edit = false +rounded = false +editable = true +prefix = "" +suffix = "" +_sections_unfolded = [ "Size Flags" ] + +[node name="PreviewLabel" type="Label" parent="VBoxContainer" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 28.0 +margin_right = 432.0 +margin_bottom = 42.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "9999 vertices, 9999 triangles" +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="Spacer" type="Control" parent="VBoxContainer" index="2"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 46.0 +margin_right = 432.0 +margin_bottom = 54.0 +rect_min_size = Vector2( 0, 8 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +_sections_unfolded = [ "Rect" ] + +[node name="Label" type="Label" parent="VBoxContainer" index="3"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 58.0 +margin_right = 432.0 +margin_bottom = 123.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +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 = true +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 +_sections_unfolded = [ "custom_colors" ] + +[node name="Buttons" type="HBoxContainer" parent="VBoxContainer" index="4"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 127.0 +margin_right = 432.0 +margin_bottom = 147.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +custom_constants/separation = 32 +alignment = 1 +_sections_unfolded = [ "custom_constants" ] + +[node name="Generate" type="Button" parent="VBoxContainer/Buttons" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 137.0 +margin_right = 208.0 +margin_bottom = 20.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Generate" +flat = false +align = 1 + +[node name="Cancel" type="Button" parent="VBoxContainer/Buttons" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 240.0 +margin_right = 294.0 +margin_bottom = 20.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Cancel" +flat = false +align = 1 + +[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/addons/zylann.hterrain/tools/generator/generator_dialog.gd b/addons/zylann.hterrain/tools/generator/generator_dialog.gd new file mode 100644 index 0000000..513506e --- /dev/null +++ b/addons/zylann.hterrain/tools/generator/generator_dialog.gd @@ -0,0 +1,443 @@ +tool +extends WindowDialog + +const HTerrainData = preload("../../hterrain_data.gd") +const HTerrainMesher = preload("../../hterrain_mesher.gd") +const Util = preload("../../util/util.gd") +const TextureGenerator = preload("texture_generator.gd") +const Logger = preload("../../util/logger.gd") + +# 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 = $VBoxContainer/Editor/Settings +onready var _inspector = $VBoxContainer/Editor/Settings/Inspector +onready var _preview = $VBoxContainer/Editor/Preview/TerrainPreview +onready var _progress_bar = $VBoxContainer/Editor/Preview/ProgressBar + +var _dummy_texture = load("res://addons/zylann.hterrain/tools/icons/empty.png") +var _terrain = null +var _applying := false +var _generator : TextureGenerator +var _generated_textures := [null, null] +var _dialog_visible := false +var _undo_map_ids := {} +var _image_cache = null +var _undo_redo : UndoRedo +var _logger := Logger.get_for(self) +var _viewport_resolution := MAX_VIEWPORT_RESOLUTION + + +static func get_shader(shader_name: String) -> Shader: + var path := "res://addons/zylann.hterrain/tools/generator/shaders"\ + .plus_file(str(shader_name, ".shader")) + return load(path) as Shader + + +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_REAL, + "range": {"min": -500.0, "max": 500.0, "step": 0.1 }, + "default_value": -50.0 + }, + "height_range": { + "type": TYPE_REAL, + "range": {"min": 0.0, "max": 2000.0, "step": 0.1 }, + "default_value": 150.0 + }, + "scale": { + "type": TYPE_REAL, + "range": {"min": 1.0, "max": 1000.0, "step": 1.0}, + "default_value": 100.0 + }, + "roughness": { + "type": TYPE_REAL, + "range": {"min": 0.0, "max": 1.0, "step": 0.01}, + "default_value": 0.4 + }, + "curve": { + "type": TYPE_REAL, + "range": {"min": 1.0, "max": 10.0, "step": 0.1}, + "default_value": 1.0 + }, + "octaves": { + "type": TYPE_INT, + "range": {"min": 1, "max": 10, "step": 1}, + "default_value": 6 + }, + "erosion_steps": { + "type": TYPE_INT, + "range": {"min": 0, "max": 100, "step": 1}, + "default_value": 0 + }, + "erosion_weight": { + "type": TYPE_REAL, + "range": { "min": 0.0, "max": 1.0 }, + "default_value": 0.5 + }, + "erosion_slope_factor": { + "type": TYPE_REAL, + "range": { "min": 0.0, "max": 1.0 }, + "default_value": 0.0 + }, + "erosion_slope_direction": { + "type": TYPE_VECTOR2, + "default_value": Vector2(0, 0) + }, + "erosion_slope_invert": { + "type": TYPE_BOOL, + "default_value": false + }, + "dilation": { + "type": TYPE_REAL, + "range": { "min": 0.0, "max": 1.0 }, + "default_value": 0.0 + }, + "show_sea": { + "type": TYPE_BOOL, + "default_value": true + }, + "shadows": { + "type": TYPE_BOOL, + "default_value": true + } + }) + + _generator = TextureGenerator.new() + _generator.set_resolution(Vector2(_viewport_resolution, _viewport_resolution)) + # Setup the extra pixels we want on max edges for terrain + # TODO I wonder if it's not better to let the generator shaders work in pixels + # instead of NDC, rather than putting a padding system there + _generator.set_output_padding([0, 1, 0, 1]) + _generator.connect("output_generated", self, "_on_TextureGenerator_output_generated") + _generator.connect("completed", self, "_on_TextureGenerator_completed") + _generator.connect("progress_reported", self, "_on_TextureGenerator_progress_reported") + add_child(_generator) + + +func apply_dpi_scale(dpi_scale: float): + rect_min_size *= dpi_scale + _inspector_container.rect_min_size *= dpi_scale + + +# TEST +#func _input(event): +# if Engine.editor_hint: +# return +# if event is InputEventKey and event.pressed and not visible: +# call_deferred("popup_centered") + + +func set_terrain(terrain): + _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): + _image_cache = image_cache + + +func set_undo_redo(ur: UndoRedo): + _undo_redo = ur + + +func _notification(what: int): + match what: + NOTIFICATION_VISIBILITY_CHANGED: + # We don't want any of this to run in an edited scene + if Util.is_in_edited_scene(self): + return + + 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 = _inspector.get_value("scale") + # Scale is inverted in the shader + if abs(scale) < 0.01: + scale = 0.0 + else: + scale = 1.0 / scale + scale *= _viewport_resolution + + var preview_scale := 4.0 # As if 2049x2049 + var sectors := [] + + # Get preview scale and sectors to generate. + # Allowing null terrain to make it testable. + if _terrain != null and _terrain.get_data() != null: + var terrain_size = _terrain.get_data().get_resolution() + + 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: + # 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 := TextureGenerator.Pass.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 * preview_scale, + "u_offset": base_offset_ndc / preview_scale, + "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") + } + _generator.add_pass(p) + + if erosion_iterations > 0: + p = TextureGenerator.Pass.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 = TextureGenerator.Pass.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(): + 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, button_mask): + if button_mask & BUTTON_MASK_LEFT: + var offset = _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): + 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.new() + tex.create_from_image(image, Texture.FLAG_FILTER) + _generated_textures[info.maptype] = tex + + 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) + + image.convert(dst.get_format()) + + dst.blit_rect(image, \ + Rect2(0, 0, image.get_width(), image.get_height()), \ + info.sector * _viewport_resolution) + + emit_signal("progress_notified", { + "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)) + + data._edit_set_disable_apply_undo(true) + _undo_redo.create_action("Generate terrain") + _undo_redo.add_do_method( + data, "_edit_apply_maps_from_file_cache", _image_cache, redo_map_ids) + _undo_redo.add_undo_method( + data, "_edit_apply_maps_from_file_cache", _image_cache, _undo_map_ids) + _undo_redo.commit_action() + data._edit_set_disable_apply_undo(false) + + emit_signal("progress_notified", { "finished": true }) + _logger.debug("Done") + diff --git a/addons/zylann.hterrain/tools/generator/generator_dialog.tscn b/addons/zylann.hterrain/tools/generator/generator_dialog.tscn new file mode 100644 index 0000000..3a69430 --- /dev/null +++ b/addons/zylann.hterrain/tools/generator/generator_dialog.tscn @@ -0,0 +1,89 @@ +[gd_scene load_steps=4 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/generator/generator_dialog.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/inspector/inspector.tscn" type="PackedScene" id=2] +[ext_resource path="res://addons/zylann.hterrain/tools/terrain_preview.tscn" type="PackedScene" id=3] + +[node name="GeneratorDialog" type="WindowDialog"] +margin_left = 22.0 +margin_top = 32.0 +margin_right = 1122.0 +margin_bottom = 632.0 +rect_min_size = Vector2( 1100, 600 ) +window_title = "Generate terrain" +resizable = true +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 +custom_constants/separation = 16 + +[node name="Editor" type="HBoxContainer" parent="VBoxContainer"] +margin_right = 1084.0 +margin_bottom = 548.0 +size_flags_vertical = 3 + +[node name="Settings" type="Control" parent="VBoxContainer/Editor"] +margin_right = 420.0 +margin_bottom = 548.0 +rect_min_size = Vector2( 420, 0 ) + +[node name="Inspector" parent="VBoxContainer/Editor/Settings" instance=ExtResource( 2 )] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_right = 0.0 +margin_bottom = 0.0 + +[node name="Preview" type="Control" parent="VBoxContainer/Editor"] +margin_left = 424.0 +margin_right = 1084.0 +margin_bottom = 548.0 +size_flags_horizontal = 3 + +[node name="TerrainPreview" parent="VBoxContainer/Editor/Preview" instance=ExtResource( 3 )] + +[node name="Label" type="Label" parent="VBoxContainer/Editor/Preview"] +margin_left = 5.0 +margin_top = 4.0 +margin_right = 207.0 +margin_bottom = 18.0 +custom_colors/font_color = Color( 1, 1, 1, 0.453608 ) +text = "LMB: offset, MMB: rotate" + +[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer/Editor/Preview"] +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_top = -16.0 +step = 1.0 + +[node name="Choices" type="HBoxContainer" parent="VBoxContainer"] +margin_top = 564.0 +margin_right = 1084.0 +margin_bottom = 584.0 +custom_constants/separation = 32 +alignment = 1 + +[node name="ApplyButton" type="Button" parent="VBoxContainer/Choices"] +margin_left = 475.0 +margin_right = 523.0 +margin_bottom = 20.0 +text = "Apply" + +[node name="CancelButton" type="Button" parent="VBoxContainer/Choices"] +margin_left = 555.0 +margin_right = 609.0 +margin_bottom = 20.0 +text = "Cancel" +[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/addons/zylann.hterrain/tools/generator/shaders/bump2normal.shader b/addons/zylann.hterrain/tools/generator/shaders/bump2normal.shader new file mode 100644 index 0000000..b07d01f --- /dev/null +++ b/addons/zylann.hterrain/tools/generator/shaders/bump2normal.shader @@ -0,0 +1,17 @@ +shader_type canvas_item; + +vec4 pack_normal(vec3 n) { + return vec4((0.5 * (n + 1.0)).xzy, 1.0); +} + +void fragment() { + vec2 uv = SCREEN_UV; + vec2 ps = SCREEN_PIXEL_SIZE; + float left = texture(SCREEN_TEXTURE, uv + vec2(-ps.x, 0)).r; + float right = texture(SCREEN_TEXTURE, uv + vec2(ps.x, 0)).r; + float back = texture(SCREEN_TEXTURE, uv + vec2(0, -ps.y)).r; + float fore = texture(SCREEN_TEXTURE, uv + vec2(0, ps.y)).r; + vec3 n = normalize(vec3(left - right, 2.0, fore - back)); + COLOR = pack_normal(n); +} + diff --git a/addons/zylann.hterrain/tools/generator/shaders/erode.shader b/addons/zylann.hterrain/tools/generator/shaders/erode.shader new file mode 100644 index 0000000..96a8168 --- /dev/null +++ b/addons/zylann.hterrain/tools/generator/shaders/erode.shader @@ -0,0 +1,67 @@ +shader_type canvas_item; + +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; + +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 = texture(SCREEN_TEXTURE, uv).r; + 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 = texture(SCREEN_TEXTURE, uv + p * eps).r; + + 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 = texture(SCREEN_TEXTURE, uv + vec2(-ps.x, 0.0)).r; + float right = texture(SCREEN_TEXTURE, uv + vec2(ps.x, 0.0)).r; + float top = texture(SCREEN_TEXTURE, uv + vec2(0.0, ps.y)).r; + float bottom = texture(SCREEN_TEXTURE, uv + vec2(0.0, -ps.y)).r; + + 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 = vec4(ph, ph, ph, 1.0); +} diff --git a/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.shader b/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.shader new file mode 100644 index 0000000..6e67cbd --- /dev/null +++ b/addons/zylann.hterrain/tools/generator/shaders/perlin_noise.shader @@ -0,0 +1,124 @@ +shader_type canvas_item; + +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 vec2 u_uv_offset; +uniform vec2 u_uv_scale = vec2(1.0, 1.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; +} + +float get_height(vec2 uv) { + float h = 0.5 + 0.5 * get_fractal_noise(uv); + h = pow(h, u_curve); + h = u_base_height + h * u_height_range; + return h; +} + +void fragment() { + vec2 uv = SCREEN_UV; + + // Handle screen padding: transform UV back into generation space + uv = (uv + u_uv_offset) * u_uv_scale; + + // Offset and scale for the noise itself + uv = (uv + u_offset) * u_scale; + + float h = get_height(uv); + COLOR = vec4(h, h, h, 1.0); +} diff --git a/addons/zylann.hterrain/tools/generator/texture_generator.gd b/addons/zylann.hterrain/tools/generator/texture_generator.gd new file mode 100644 index 0000000..a739b4f --- /dev/null +++ b/addons/zylann.hterrain/tools/generator/texture_generator.gd @@ -0,0 +1,310 @@ +# 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 + +class Pass: + # 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 = null + # Which shader to use + var shader = null + # Parameters for the shader + 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 + + +const Util = preload("res://addons/zylann.hterrain/util/util.gd") + +signal progress_reported(info) +# Emitted when an output is generated. +signal output_generated(image, metadata) +# Emitted when all passes are complete +signal completed + +var _passes := [] +var _resolution := Vector2(512, 512) +var _output_padding := [0, 0, 0, 0] +var _viewport : Viewport = null +var _ci : TextureRect = null +var _dummy_texture = load("res://addons/zylann.hterrain/tools/icons/empty.png") +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 + + +func _ready(): + assert(_viewport == null) + assert(_ci == null) + + _viewport = Viewport.new() + _viewport.own_world = true + _viewport.world = World.new() + _viewport.render_target_v_flip = true + _viewport.render_target_update_mode = Viewport.UPDATE_DISABLED + add_child(_viewport) + + _ci = TextureRect.new() + _ci.expand = true + _ci.texture = _dummy_texture + _viewport.add_child(_ci) + + _shader_material = ShaderMaterial.new() + + set_process(false) + + +func is_running() -> bool: + return _running + + +func clear_passes(): + _passes.clear() + + +func add_pass(p: Pass): + 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: Vector2): + 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 + + assert(_viewport != null) + assert(_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 * Vector2(largest_padding, largest_padding) + +# _uv_offset = Vector2( \ +# float(largest_padding) / padded_size.x, +# float(largest_padding) / padded_size.y) + + _ci.rect_size = padded_size + + _viewport.size = padded_size + _viewport.render_target_update_mode = Viewport.UPDATE_ALWAYS + _viewport.render_target_clear_mode = Viewport.CLEAR_MODE_ONLY_NEXT_FRAME + + _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 + + if _running_pass_index > 0: + var prev_pass = _running_passes[_running_pass_index - 1] + if prev_pass.output: + _create_output_image(prev_pass.metadata) + + if _running_pass_index >= len(_running_passes): + _running = false + + emit_signal("completed") + + 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: + _viewport.render_target_update_mode = Viewport.UPDATE_DISABLED + set_process(false) + return + + var p = _running_passes[_running_pass_index] + + if _running_iteration == 0: + _setup_pass(p) + + _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 + + # The viewport should render after the tree was processed + + +func _setup_pass(p: Pass): + if p.texture != null: + _ci.texture = p.texture + else: + _ci.texture = _dummy_texture + + if p.shader != null: + if _shader_material == null: + _shader_material = ShaderMaterial.new() + _shader_material.shader = p.shader + + _ci.material = _shader_material + + if p.params != null: + for param_name in p.params: + _shader_material.set_shader_param(param_name, p.params[param_name]) + + var scale_ndc = _viewport.size / _resolution + var pad_offset_ndc = ((_viewport.size - _resolution) / 2) / _viewport.size + 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_param("u_uv_scale", scale_ndc) + + if p.params == null or not p.params.has("u_uv_offset"): + _shader_material.set_shader_param("u_uv_offset", offset_ndc) + + else: + _ci.material = null + + if p.clear: + _viewport.render_target_clear_mode = Viewport.CLEAR_MODE_ONLY_NEXT_FRAME + + +func _create_output_image(metadata): + var tex := _viewport.get_texture() + var src := tex.get_data() + + # Pick the center of the image + var subrect := Rect2( \ + (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 + if subrect == Rect2(0, 0, src.get_width(), src.get_height()): + dst = src + else: + dst = Image.new() + # 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.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, Vector2()) + + emit_signal("output_generated", dst, metadata) + + +func _report_progress(passes: Array, pass_index: int, iteration: int): + var p = passes[pass_index] + emit_signal("progress_reported", { + "name": p.debug_name, + "pass_index": pass_index, + "pass_count": len(passes), + "iteration": iteration, + "iteration_count": p.iterations + }) + diff --git a/addons/zylann.hterrain/tools/globalmap_baker.gd b/addons/zylann.hterrain/tools/globalmap_baker.gd new file mode 100644 index 0000000..36012d0 --- /dev/null +++ b/addons/zylann.hterrain/tools/globalmap_baker.gd @@ -0,0 +1,167 @@ + +# 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 : Viewport = null +var _viewport_size := DEFAULT_VIEWPORT_SIZE +var _plane : MeshInstance = null +var _camera : Camera = 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 = Viewport.new() + _viewport.size = Vector2(_viewport_size + 1, _viewport_size + 1) + _viewport.render_target_update_mode = Viewport.UPDATE_ALWAYS + _viewport.render_target_clear_mode = Viewport.CLEAR_MODE_ALWAYS + _viewport.render_target_v_flip = true + _viewport.world = World.new() + _viewport.own_world = true + _viewport.debug_draw = Viewport.DEBUG_DRAW_UNSHADED + + var mat := ShaderMaterial.new() + + _plane = MeshInstance.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 = Camera.new() + _camera.projection = Camera.PROJECTION_ORTHOGONAL + _camera.size = _viewport.size.x + _camera.near = 0.1 + _camera.far = 10.0 + _camera.current = true + _camera.rotation_degrees = Vector3(-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() + emit_signal("progress_notified", { "finished": true }) + else: + _setup_pass(_sectors[_sector_index]) + _report_progress() + _sector_index += 1 + + +func _report_progress(): + var sector = _sectors[_sector_index] + emit_signal("progress_notified", { + "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 * _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.translation = Vector3(center.x, 2.0, center.y) + _plane.translation = Vector3(origin.x, 0.0, origin.y) + + +func _grab_image(sector: Vector2): + var tex := _viewport.get_texture() + var src := tex.get_data() + + 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, Rect2(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) + emit_signal("permanent_change_performed", "Bake globalmap") + + _cleanup_scene() + _terrain = null diff --git a/addons/zylann.hterrain/tools/icons/empty.png b/addons/zylann.hterrain/tools/icons/empty.png new file mode 100644 index 0000000..0a59813 Binary files /dev/null and b/addons/zylann.hterrain/tools/icons/empty.png differ diff --git a/addons/zylann.hterrain/tools/icons/empty.png.import b/addons/zylann.hterrain/tools/icons/empty.png.import new file mode 100644 index 0000000..0d0525e --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/empty.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/empty.png-31363f083c9c4e2e8e54cf64f3716737.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/empty.png" +dest_files=[ "res://.import/empty.png-31363f083c9c4e2e8e54cf64f3716737.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg new file mode 100644 index 0000000..f490172 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import new file mode 100644 index 0000000..fe9f14c --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_bottom.svg-963f115d31a41c38349ab03453cf2ef5.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom.svg" +dest_files=[ "res://.import/icon_anchor_bottom.svg-963f115d31a41c38349ab03453cf2ef5.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg new file mode 100644 index 0000000..05f255a --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import new file mode 100644 index 0000000..020b7dc --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_bottom_left.svg-c59f20ff71f725e47b5fc556b5ef93c4.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom_left.svg" +dest_files=[ "res://.import/icon_anchor_bottom_left.svg-c59f20ff71f725e47b5fc556b5ef93c4.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg new file mode 100644 index 0000000..33e97ef --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import new file mode 100644 index 0000000..e1a2a64 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_bottom_right.svg-23dd5f1d1c7021fe105f8bde603dcc4d.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_bottom_right.svg" +dest_files=[ "res://.import/icon_anchor_bottom_right.svg-23dd5f1d1c7021fe105f8bde603dcc4d.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg new file mode 100644 index 0000000..8ab613a --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg @@ -0,0 +1,65 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import new file mode 100644 index 0000000..711ed29 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_center.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_center.svg-d48605c4035ec4a02ae8159aea6db85f.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_center.svg" +dest_files=[ "res://.import/icon_anchor_center.svg-d48605c4035ec4a02ae8159aea6db85f.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg new file mode 100644 index 0000000..7fd1a68 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import new file mode 100644 index 0000000..d61c8f6 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_left.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_left.svg-77f3e03e6fbadfd7e4dc1ab3661e6e7c.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_left.svg" +dest_files=[ "res://.import/icon_anchor_left.svg-77f3e03e6fbadfd7e4dc1ab3661e6e7c.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg new file mode 100644 index 0000000..8ad32f1 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import new file mode 100644 index 0000000..119d31b --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_right.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_right.svg-90e3a37e8d38587bac01703849f8b9f7.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_right.svg" +dest_files=[ "res://.import/icon_anchor_right.svg-90e3a37e8d38587bac01703849f8b9f7.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg new file mode 100644 index 0000000..e15ed1a --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import new file mode 100644 index 0000000..76615b4 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_top.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_top.svg-f1dcf93e569fe43b280b5dc072ee78e5.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top.svg" +dest_files=[ "res://.import/icon_anchor_top.svg-f1dcf93e569fe43b280b5dc072ee78e5.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg new file mode 100644 index 0000000..cd82a6f --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import new file mode 100644 index 0000000..926a3e5 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_top_left.svg-aea4438056394f9967bf74b13799fedc.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top_left.svg" +dest_files=[ "res://.import/icon_anchor_top_left.svg-aea4438056394f9967bf74b13799fedc.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg b/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg new file mode 100644 index 0000000..4619493 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import b/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import new file mode 100644 index 0000000..d3d02bb --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_anchor_top_right.svg-e9f520f41c9c20cc5e64aca56427ca01.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_anchor_top_right.svg" +dest_files=[ "res://.import/icon_anchor_top_right.svg-e9f520f41c9c20cc5e64aca56427ca01.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg b/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg new file mode 100644 index 0000000..f5d8156 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg @@ -0,0 +1,90 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import b/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import new file mode 100644 index 0000000..742d884 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_detail_layer_node.svg-70daba484432569847b1d2fe22768af3.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_detail_layer_node.svg" +dest_files=[ "res://.import/icon_detail_layer_node.svg-70daba484432569847b1d2fe22768af3.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_grass.svg b/addons/zylann.hterrain/tools/icons/icon_grass.svg new file mode 100644 index 0000000..7866628 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_grass.svg @@ -0,0 +1,90 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_grass.svg.import b/addons/zylann.hterrain/tools/icons/icon_grass.svg.import new file mode 100644 index 0000000..3b3bac1 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_grass.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_grass.svg-6a20eb11bc23d46b8a4c0f365f95554b.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_grass.svg" +dest_files=[ "res://.import/icon_grass.svg-6a20eb11bc23d46b8a4c0f365f95554b.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg new file mode 100644 index 0000000..2b20f6a --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg @@ -0,0 +1,90 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import new file mode 100644 index 0000000..cd24ab5 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_color.svg-2b3375697cab4a6c7b8d933fc7f2b982.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_color.svg" +dest_files=[ "res://.import/icon_heightmap_color.svg-2b3375697cab4a6c7b8d933fc7f2b982.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg new file mode 100644 index 0000000..03c0caf --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import new file mode 100644 index 0000000..08186a4 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_data.svg-00236b6035ce13dd687a19d98237bdbd.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_data.svg" +dest_files=[ "res://.import/icon_heightmap_data.svg-00236b6035ce13dd687a19d98237bdbd.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg new file mode 100644 index 0000000..22824a2 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg @@ -0,0 +1,102 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import new file mode 100644 index 0000000..4cc08ba --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_erode.svg-fad285f0810d69bec16027ac0257c223.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_erode.svg" +dest_files=[ "res://.import/icon_heightmap_erode.svg-fad285f0810d69bec16027ac0257c223.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg new file mode 100644 index 0000000..c4d3268 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg @@ -0,0 +1,116 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import new file mode 100644 index 0000000..f28feb8 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_flatten.svg-3d183c33fce9f34c419c53418ef26264.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_flatten.svg" +dest_files=[ "res://.import/icon_heightmap_flatten.svg-3d183c33fce9f34c419c53418ef26264.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg new file mode 100644 index 0000000..d7c27e9 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg @@ -0,0 +1,72 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import new file mode 100644 index 0000000..5226d14 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_level.svg-0abbb78afcf28f4da15188c85861a768.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_level.svg" +dest_files=[ "res://.import/icon_heightmap_level.svg-0abbb78afcf28f4da15188c85861a768.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg new file mode 100644 index 0000000..87101c3 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import new file mode 100644 index 0000000..81e12fd --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_lower.svg-5bb5cae46ea03f9d65d6c497a65882db.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_lower.svg" +dest_files=[ "res://.import/icon_heightmap_lower.svg-5bb5cae46ea03f9d65d6c497a65882db.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg new file mode 100644 index 0000000..751b683 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import new file mode 100644 index 0000000..f3d95be --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_mask.svg-3fad663c59a229c1c6c17c4e8d5bad09.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_mask.svg" +dest_files=[ "res://.import/icon_heightmap_mask.svg-3fad663c59a229c1c6c17c4e8d5bad09.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg new file mode 100644 index 0000000..00fb889 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import new file mode 100644 index 0000000..69a3e77 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_node.svg-0b776ad0015c7d9d9553b161b36e70fe.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node.svg" +dest_files=[ "res://.import/icon_heightmap_node.svg-0b776ad0015c7d9d9553b161b36e70fe.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg new file mode 100644 index 0000000..00fb889 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import new file mode 100644 index 0000000..4e13b3d --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_node_large.svg-4b8ff9077cb0d8dc06efcf638cce1edb.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_node_large.svg" +dest_files=[ "res://.import/icon_heightmap_node_large.svg-4b8ff9077cb0d8dc06efcf638cce1edb.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=8.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg new file mode 100644 index 0000000..72503fa --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import new file mode 100644 index 0000000..1106b53 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_paint.svg-ad4c1d13ab344959f8e60b793d52d80d.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_paint.svg" +dest_files=[ "res://.import/icon_heightmap_paint.svg-ad4c1d13ab344959f8e60b793d52d80d.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg new file mode 100644 index 0000000..0e3033f --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import new file mode 100644 index 0000000..28401b4 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_raise.svg-16ae516b9460ce83d04d965ed6b9989a.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_raise.svg" +dest_files=[ "res://.import/icon_heightmap_raise.svg-16ae516b9460ce83d04d965ed6b9989a.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg new file mode 100644 index 0000000..c9d586a --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg @@ -0,0 +1,72 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import new file mode 100644 index 0000000..b6fde47 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_smooth.svg-1216ccdd3a408b8769b0a0964b7bd3f9.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_smooth.svg" +dest_files=[ "res://.import/icon_heightmap_smooth.svg-1216ccdd3a408b8769b0a0964b7bd3f9.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg b/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg new file mode 100644 index 0000000..24f39a4 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg @@ -0,0 +1,68 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import b/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import new file mode 100644 index 0000000..cccbc43 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_heightmap_unmask.svg-f88c0addb6f444beecc364dd218d67e9.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg" +dest_files=[ "res://.import/icon_heightmap_unmask.svg-f88c0addb6f444beecc364dd218d67e9.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg b/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg new file mode 100644 index 0000000..0cc022a --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import b/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import new file mode 100644 index 0000000..becec24 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_long_arrow_down.svg-baa34c94eaf2f9f3533b079350dd260b.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg" +dest_files=[ "res://.import/icon_long_arrow_down.svg-baa34c94eaf2f9f3533b079350dd260b.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg b/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg new file mode 100644 index 0000000..d4e8c2b --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import b/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import new file mode 100644 index 0000000..8e8d9da --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_long_arrow_right.svg-2e9c5428ca49af0df04372d4de12fdd2.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg" +dest_files=[ "res://.import/icon_long_arrow_right.svg-2e9c5428ca49af0df04372d4de12fdd2.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg b/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg new file mode 100644 index 0000000..34e2ab7 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import b/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import new file mode 100644 index 0000000..efdc56a --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_minimap_out_of_range_position.svg-be0d8e592b6594137b0f40434b64f771.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg" +dest_files=[ "res://.import/icon_minimap_out_of_range_position.svg-be0d8e592b6594137b0f40434b64f771.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg b/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg new file mode 100644 index 0000000..468c52d --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import b/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import new file mode 100644 index 0000000..68bd3d1 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_minimap_position.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_minimap_position.svg-09c3263e8852c7010dcfa0a85245403d.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_minimap_position.svg" +dest_files=[ "res://.import/icon_minimap_position.svg-09c3263e8852c7010dcfa0a85245403d.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/icon_small_circle.svg b/addons/zylann.hterrain/tools/icons/icon_small_circle.svg new file mode 100644 index 0000000..b5a004d --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_small_circle.svg @@ -0,0 +1,68 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import b/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import new file mode 100644 index 0000000..6b03b07 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/icon_small_circle.svg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon_small_circle.svg-758362406034e77f78350899f9b2cf34.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/icon_small_circle.svg" +dest_files=[ "res://.import/icon_small_circle.svg-758362406034e77f78350899f9b2cf34.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/icons/white.png b/addons/zylann.hterrain/tools/icons/white.png new file mode 100644 index 0000000..dee54f4 Binary files /dev/null and b/addons/zylann.hterrain/tools/icons/white.png differ diff --git a/addons/zylann.hterrain/tools/icons/white.png.import b/addons/zylann.hterrain/tools/icons/white.png.import new file mode 100644 index 0000000..6879ae2 --- /dev/null +++ b/addons/zylann.hterrain/tools/icons/white.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/white.png-06b7d7f95e74cd7f8357ec25a73870fb.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/zylann.hterrain/tools/icons/white.png" +dest_files=[ "res://.import/white.png-06b7d7f95e74cd7f8357ec25a73870fb.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/zylann.hterrain/tools/importer/importer_dialog.gd b/addons/zylann.hterrain/tools/importer/importer_dialog.gd new file mode 100644 index 0000000..c1097fb --- /dev/null +++ b/addons/zylann.hterrain/tools/importer/importer_dialog.gd @@ -0,0 +1,267 @@ +tool +extends WindowDialog + +const Util = preload("../../util/util.gd") +const HTerrainData = preload("../../hterrain_data.gd") +const Errors = preload("../../util/errors.gd") +const Logger = preload("../../util/logger.gd") + +signal permanent_change_performed(message) + +onready var _inspector = $VBoxContainer/Inspector +onready var _errors_label = $VBoxContainer/ColorRect/ScrollContainer/VBoxContainer/Errors +onready var _warnings_label = $VBoxContainer/ColorRect/ScrollContainer/VBoxContainer/Warnings + +const RAW_LITTLE_ENDIAN = 0 +const RAW_BIG_ENDIAN = 1 + +var _terrain = null +var _logger = Logger.get_for(self) + + +func _ready(): + _inspector.set_prototype({ + "heightmap": { + "type": TYPE_STRING, + "usage": "file", + "exts": ["raw", "png", "exr"] + }, + "raw_endianess": { + "type": TYPE_INT, + "usage": "enum", + "enum_items": ["Little Endian", "Big Endian"], + "enabled": false + }, + "min_height": { + "type": TYPE_REAL, + "range": {"min": -2000.0, "max": 2000.0, "step": 1.0}, + "default_value": 0.0 + }, + "max_height": { + "type": TYPE_REAL, + "range": {"min": -2000.0, "max": 2000.0, "step": 1.0}, + "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): + _terrain = terrain + + +func _notification(what: int): + if what == NOTIFICATION_VISIBILITY_CHANGED: + if visible and is_inside_tree(): + _clear_feedback() + + +static func _format_feedbacks(feed): + var a = [] + for s in feed: + a.append("- " + s) + return PoolStringArray(a).join("\n") + + +func _clear_feedback(): + _errors_label.text = "" + _warnings_label.text = "" + + +func _show_feedback(res): + 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(): + var res = { + "errors": [], + "warnings": [] + } + + var heightmap_path = _inspector.get_value("heightmap") + var splatmap_path = _inspector.get_value("splatmap") + var colormap_path = _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 everytime to do further checks + return res + + var size = _load_image_size(heightmap_path, _logger) + if size.has("error"): + res.errors.append(str("Cannot open heightmap file: ", _error_to_string(size.error))) + return res + + var adjusted_size = HTerrainData.get_adjusted_map_size(size.width, size.height) + + if adjusted_size != size.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, map_name, heightmap_size, res, logger): + var size = _load_image_size(path, logger) + if size.has("error"): + res.errors.append("Cannot open splatmap file: ", _error_to_string(size.error)) + return + var adjusted_size = HTerrainData.get_adjusted_map_size(size.width, size.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.width: + res.warnings.append( + "The square resolution deduced from ", map_name, + " file size is not power of two + 1.\nThe ", + map_name, " will be cropped.") + + +static func _load_image_size(path, logger): + var ext = path.get_extension().to_lower() + + if ext == "png" or ext == "exr": + 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])) + return { "error": err } + + return { "width": im.get_width(), "height": im.get_height() } + + elif ext == "raw": + var f = File.new() + var err = f.open(path, File.READ) + if err != OK: + logger.error("Error opening file {0}".format([path])) + return { "error": err } + + # Assume the raw data is square in 16-bit format, + # so its size is function of file length + var flen = f.get_len() + f.close() + var size = Util.integer_square_root(flen / 2) + if size == -1: + return { "error": "RAW image is not square" } + + logger.debug("Deduced RAW heightmap resolution: {0}*{1}, for a length of {2}" \ + .format([size, size, flen])) + + return { "width": size, "height": size } + + else: + return { "error": ERR_FILE_UNRECOGNIZED } + + +static func _error_to_string(err): + if typeof(err) == TYPE_STRING: + return err + return str("code ", err, ": ", Errors.get_message(err)) diff --git a/addons/zylann.hterrain/tools/importer/importer_dialog.tscn b/addons/zylann.hterrain/tools/importer/importer_dialog.tscn new file mode 100644 index 0000000..2cf339c --- /dev/null +++ b/addons/zylann.hterrain/tools/importer/importer_dialog.tscn @@ -0,0 +1,99 @@ +[gd_scene load_steps=3 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/importer/importer_dialog.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/inspector/inspector.tscn" type="PackedScene" id=2] + +[node name="WindowDialog" type="WindowDialog"] +visible = true +margin_left = 223.0 +margin_top = 87.0 +margin_right = 747.0 +margin_bottom = 463.0 +rect_min_size = Vector2( 500, 380 ) +window_title = "Import maps" +resizable = true +script = ExtResource( 1 ) + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 + +[node name="Label" type="Label" parent="VBoxContainer"] +margin_right = 508.0 +margin_bottom = 14.0 +text = "Select maps to import. Leave empty if you don't need some." + +[node name="Spacer" type="Control" parent="VBoxContainer"] +margin_top = 18.0 +margin_right = 508.0 +margin_bottom = 34.0 +rect_min_size = Vector2( 0, 16 ) + +[node name="Inspector" parent="VBoxContainer" instance=ExtResource( 2 )] +margin_top = 38.0 +margin_right = 508.0 +margin_bottom = 224.0 +size_flags_vertical = 3 + +[node name="ColorRect" type="ColorRect" parent="VBoxContainer"] +margin_top = 228.0 +margin_right = 508.0 +margin_bottom = 328.0 +rect_min_size = Vector2( 0, 100 ) +color = Color( 0, 0, 0, 0.417529 ) + +[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer/ColorRect"] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/ColorRect/ScrollContainer"] +margin_bottom = 32.0 + +[node name="Errors" type="Label" parent="VBoxContainer/ColorRect/ScrollContainer/VBoxContainer"] +self_modulate = Color( 1, 0.203125, 0.203125, 1 ) +margin_bottom = 14.0 + +[node name="Warnings" type="Label" parent="VBoxContainer/ColorRect/ScrollContainer/VBoxContainer"] +self_modulate = Color( 1, 0.901428, 0.257813, 1 ) +margin_top = 18.0 +margin_bottom = 32.0 + +[node name="Spacer2" type="Control" parent="VBoxContainer"] +margin_top = 332.0 +margin_right = 508.0 +margin_bottom = 340.0 +rect_min_size = Vector2( 0, 8 ) + +[node name="ButtonsArea" type="HBoxContainer" parent="VBoxContainer"] +margin_top = 344.0 +margin_right = 508.0 +margin_bottom = 364.0 +mouse_filter = 0 +custom_constants/separation = 32 +alignment = 1 + +[node name="CheckButton" type="Button" parent="VBoxContainer/ButtonsArea"] +margin_left = 142.0 +margin_right = 192.0 +margin_bottom = 20.0 +text = "Check" + +[node name="ImportButton" type="Button" parent="VBoxContainer/ButtonsArea"] +margin_left = 224.0 +margin_right = 280.0 +margin_bottom = 20.0 +text = "Import" + +[node name="CancelButton" type="Button" parent="VBoxContainer/ButtonsArea"] +margin_left = 312.0 +margin_right = 366.0 +margin_bottom = 20.0 +text = "Cancel" +[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/addons/zylann.hterrain/tools/inspector/inspector.gd b/addons/zylann.hterrain/tools/inspector/inspector.gd new file mode 100644 index 0000000..3a1a37c --- /dev/null +++ b/addons/zylann.hterrain/tools/inspector/inspector.gd @@ -0,0 +1,471 @@ + +# 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 Editor: + var control = null + var getter = null + var setter = null + var key_label : Label + + +# Used when the control cannot hold the actual value +class ResourceEditor extends Editor: + 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 VectorEditor extends Editor: + 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 + emit_signal("value_changed", 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") + + +# Test +#func _ready(): +# 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() + + +func get_values(): + var values = {} + for key in _editors: + var editor = _editors[key] + values[key] = editor.getter.call_func() + return values + + +func set_value(key: String, value): + var editor = _editors[key] + editor.setter.call_func(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_func(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_func(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() + emit_signal("property_changed", 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): + var ed = null + + var editor = null + var getter = null + var setter = null + var extra = null + + match prop.type: + TYPE_INT, \ + TYPE_REAL: + var pre = null + if prop.has("randomizable") and prop.randomizable: + editor = HBoxContainer.new() + pre = Button.new() + pre.connect("pressed", self, "_randomize_property_pressed", [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 = funcref(option_button, "get_selected_id") + setter = funcref(option_button, "select") + option_button.connect("item_selected", self, "_property_edited", [key]) + + editor = option_button + + else: + # Numeric value + var spinbox = SpinBox.new() + # Spinboxes have shit UX when not expanded... + spinbox.rect_min_size = Vector2(120, 16) + _setup_range_control(spinbox, prop) + spinbox.connect("value_changed", self, "_property_edited", [key]) + + # TODO In case the type is INT, the getter should return an integer! + getter = funcref(spinbox, "get_value") + setter = funcref(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.rect_min_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.connect("pressed", self, "_on_ask_load_file", [key, exts]) + editor.add_child(load_button) + + line_edit.connect("text_entered", self, "_property_edited", [key]) + getter = funcref(line_edit, "get_text") + setter = funcref(line_edit, "set_text") + + else: + editor = LineEdit.new() + editor.connect("text_entered", self, "_property_edited", [key]) + getter = funcref(editor, "get_text") + setter = funcref(editor, "set_text") + + TYPE_COLOR: + editor = ColorPickerButton.new() + editor.connect("color_changed", self, "_property_edited", [key]) + getter = funcref(editor, "get_pick_color") + setter = funcref(editor, "set_pick_color") + + TYPE_BOOL: + editor = CheckBox.new() + editor.connect("toggled", self, "_property_edited", [key]) + getter = funcref(editor, "is_pressed") + setter = funcref(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.align = Label.ALIGN_RIGHT + editor.add_child(label) + + var load_button = Button.new() + load_button.text = "Load..." + load_button.connect("pressed", self, "_on_ask_load_texture", [key]) + editor.add_child(load_button) + + var clear_button = Button.new() + clear_button.text = "Clear" + clear_button.connect("pressed", self, "_on_ask_clear_texture", [key]) + editor.add_child(clear_button) + + ed = ResourceEditor.new() + ed.label = label + getter = funcref(ed, "get_value") + setter = funcref(ed, "set_value") + + TYPE_VECTOR2: + editor = HBoxContainer.new() + + ed = VectorEditor.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.connect("value_changed", ed, "_component_changed", [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.connect("value_changed", ed, "_component_changed", [1]) + editor.add_child(yed) + + ed.xed = xed + ed.yed = yed + ed.connect("value_changed", self, "_property_edited", [key]) + getter = funcref(ed, "get_value") + setter = funcref(ed, "set_value") + + _: + editor = Label.new() + editor.text = "" + getter = funcref(self, "_dummy_getter") + setter = funcref(self, "_dummy_setter") + + if not(editor is CheckButton): + editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + if ed == null: + # Default + ed = Editor.new() + ed.control = editor + ed.getter = getter + ed.setter = setter + + return ed + + +static func _setup_range_control(range_control, 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: + emit_signal("property_changed", 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_REAL: + if prop.has("range"): + v = rand_range(prop.range.min, prop.range.max) + else: + v = randf() + + _editors[key].setter.call_func(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", [key], FileDialog.ACCESS_RESOURCES) + + +func _open_file_dialog(filters, callback, binds, access): + _file_dialog.access = access + _file_dialog.clear_filters() + for filter in filters: + _file_dialog.add_filter(filter) + _file_dialog.connect("popup_hide", self, "call_deferred", ["_on_file_dialog_close"], CONNECT_ONESHOT) + _file_dialog.connect("file_selected", self, callback, binds) + _file_dialog.popup_centered_ratio(0.7) + + +func _on_file_dialog_close(): + # 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.disconnect("file_selected", con.target, con.method) + + +func _on_texture_selected(path, key): + var tex = load(path) + if tex == null: + return + var ed = _editors[key] + ed.setter.call_func(tex) + _property_edited(tex, key) + + +func _on_ask_clear_texture(key): + var ed = _editors[key] + ed.setter.call_func(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", [key], FileDialog.ACCESS_FILESYSTEM) + + +func _on_file_selected(path, key): + var ed = _editors[key] + ed.setter.call_func(path) + _property_edited(path, key) diff --git a/addons/zylann.hterrain/tools/inspector/inspector.tscn b/addons/zylann.hterrain/tools/inspector/inspector.tscn new file mode 100644 index 0000000..b5f2491 --- /dev/null +++ b/addons/zylann.hterrain/tools/inspector/inspector.tscn @@ -0,0 +1,71 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/inspector/inspector.gd" type="Script" id=1] + +[node name="Inspector" type="Control" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_right = 348.0 +margin_bottom = 383.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +script = ExtResource( 1 ) +_sections_unfolded = [ "custom_constants" ] + +[node name="GridContainer" type="GridContainer" parent="." index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +custom_constants/vseparation = 4 +custom_constants/hseparation = 8 +columns = 2 +_sections_unfolded = [ "Anchor", "Margin", "custom_constants" ] + +[node name="OpenFileDialog" type="FileDialog" parent="." index="1"] + +visible = false +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 261.0 +margin_top = 150.0 +margin_right = 710.0 +margin_bottom = 426.0 +rect_min_size = Vector2( 400, 300 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +popup_exclusive = false +window_title = "Ouvrir un fichier" +resizable = true +dialog_hide_on_ok = false +mode_overrides_title = true +mode = 0 +access = 0 +filters = PoolStringArray( ) +show_hidden_files = false +current_dir = "res://" +current_file = "" +current_path = "res://" +_sections_unfolded = [ "Rect" ] + + diff --git a/addons/zylann.hterrain/tools/load_texture_dialog.gd b/addons/zylann.hterrain/tools/load_texture_dialog.gd new file mode 100644 index 0000000..aaf2298 --- /dev/null +++ b/addons/zylann.hterrain/tools/load_texture_dialog.gd @@ -0,0 +1,22 @@ +tool +extends EditorFileDialog + + +func _init(): + #access = EditorFileDialog.ACCESS_RESOURCES + mode = EditorFileDialog.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") + resizable = true + access = EditorFileDialog.ACCESS_RESOURCES + connect("popup_hide", self, "call_deferred", ["_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: + disconnect("file_selected", con.target, con.method) + diff --git a/addons/zylann.hterrain/tools/minimap/minimap.gd b/addons/zylann.hterrain/tools/minimap/minimap.gd new file mode 100644 index 0000000..41b62d3 --- /dev/null +++ b/addons/zylann.hterrain/tools/minimap/minimap.gd @@ -0,0 +1,134 @@ +tool +extends Control + +const Util = preload("../../util/util.gd") +const HTerrainData = preload("../../hterrain_data.gd") + +const MinimapShader = preload("./minimap_normal.shader") +const WhiteTexture = preload("../icons/white.png") + +const MODE_QUADTREE = 0 +const MODE_NORMAL = 1 + +onready var _popup_menu = $PopupMenu +onready var _color_rect = $ColorRect +onready var _overlay = $Overlay + +var _terrain = null +var _mode := MODE_NORMAL +var _camera_transform := Transform() + + +func _ready(): + if 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): + if _terrain != node: + _terrain = node + set_process(_terrain != null) + + +func set_camera_transform(ct: Transform): + 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.xform(_camera_transform.origin)) + var size := Vector2(data.get_resolution(), data.get_resolution()) + pos /= size + var dir := _get_xz(to_local.basis.xform(-_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: + BUTTON_RIGHT: + _popup_menu.rect_position = get_global_mouse_position() + _popup_menu.popup() + BUTTON_LEFT: + # Teleport there? + pass + + +func _process(delta): + if _terrain != null: + if _mode == MODE_QUADTREE: + update() + else: + _update_normal_material() + + +func _set_mode(mode: int): + if mode == MODE_QUADTREE: + _color_rect.hide() + else: + var mat = ShaderMaterial.new() + mat.shader = MinimapShader + _color_rect.material = mat + _color_rect.show() + _update_normal_material() + _mode = mode + update() + + +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 = WhiteTexture + if data.has_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO, 0): + globalmap = data.get_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO) + _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 intented and consumes more power +static func _set_if_changed(sm: ShaderMaterial, param: String, v): + if sm.get_shader_param(param) != v: + sm.set_shader_param(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 size = 1 << (lod_count - 1) + var vsize = rect_size + draw_set_transform(Vector2(0, 0), 0, Vector2(vsize.x / size, vsize.y / size)) + + _terrain._edit_debug_draw(self) + + +func _on_PopupMenu_id_pressed(id: int): + _set_mode(id) diff --git a/addons/zylann.hterrain/tools/minimap/minimap.tscn b/addons/zylann.hterrain/tools/minimap/minimap.tscn new file mode 100644 index 0000000..e1d5bb6 --- /dev/null +++ b/addons/zylann.hterrain/tools/minimap/minimap.tscn @@ -0,0 +1,78 @@ +[gd_scene load_steps=7 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/minimap/minimap.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/minimap/minimap_normal.shader" type="Shader" id=2] +[ext_resource path="res://addons/zylann.hterrain/tools/minimap/minimap_overlay.gd" type="Script" id=3] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/icon_minimap_position.svg" type="Texture" id=4] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/icon_minimap_out_of_range_position.svg" type="Texture" id=5] + +[sub_resource type="ShaderMaterial" id=1] +shader = ExtResource( 2 ) +shader_param/u_light_direction = Vector3( 0.5, -0.7, 0.2 ) + +[node name="Minimap" type="Control"] +margin_right = 100.0 +margin_bottom = 104.0 +rect_min_size = Vector2( 100, 0 ) +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="PopupMenu" type="PopupMenu" parent="."] +margin_right = 20.0 +margin_bottom = 20.0 + +[node name="ColorRect" type="ColorRect" parent="."] +material = SubResource( 1 ) +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="X" type="ColorRect" parent="."] +margin_right = 20.0 +margin_bottom = 2.0 +rect_min_size = Vector2( 0, 2 ) +mouse_filter = 2 +color = Color( 0.929412, 0.290196, 0.290196, 0.627451 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Z" type="ColorRect" parent="."] +margin_right = 2.0 +margin_bottom = 20.0 +rect_min_size = Vector2( 2, 0 ) +mouse_filter = 2 +color = Color( 0.0784314, 0.501961, 1, 0.627451 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Y" type="ColorRect" parent="."] +margin_right = 2.0 +margin_bottom = 2.0 +rect_min_size = Vector2( 2, 2 ) +mouse_filter = 2 +color = Color( 0.207843, 0.835294, 0.152941, 0.627451 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Overlay" type="Control" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +rect_clip_content = true +mouse_filter = 2 +script = ExtResource( 3 ) +__meta__ = { +"_edit_use_anchors_": false +} +cursor_texture = ExtResource( 4 ) +out_of_range_texture = ExtResource( 5 ) + +[node name="Cursor" type="Sprite" parent="Overlay"] +[connection signal="id_pressed" from="PopupMenu" to="." method="_on_PopupMenu_id_pressed"] diff --git a/addons/zylann.hterrain/tools/minimap/minimap_normal.shader b/addons/zylann.hterrain/tools/minimap/minimap_normal.shader new file mode 100644 index 0000000..0ea3120 --- /dev/null +++ b/addons/zylann.hterrain/tools/minimap/minimap_normal.shader @@ -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/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd b/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd new file mode 100644 index 0000000..38af6f9 --- /dev/null +++ b/addons/zylann.hterrain/tools/minimap/minimap_overlay.gd @@ -0,0 +1,24 @@ +tool +extends Control + + +export(Texture) var cursor_texture +export(Texture) var out_of_range_texture + +onready var _sprite = $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 = clamp(pos_norm.x, 0.0, 1.0) + pos_norm.y = clamp(pos_norm.y, 0.0, 1.0) + _sprite.texture = out_of_range_texture + + _sprite.position = pos_norm * rect_size + _sprite.rotation = dir.angle() + diff --git a/addons/zylann.hterrain/tools/minimap/ratio_container.gd b/addons/zylann.hterrain/tools/minimap/ratio_container.gd new file mode 100644 index 0000000..4f9bae8 --- /dev/null +++ b/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(float) 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 = rect_size.x + var h = rect_size.x / ratio + + if h > rect_size.y: + h = rect_size.y + w = h * ratio + + var rect := Rect2(0, 0, w, h) + + fit_child_in_rect(child, rect) + diff --git a/addons/zylann.hterrain/tools/normalmap_baker.gd b/addons/zylann.hterrain/tools/normalmap_baker.gd new file mode 100644 index 0000000..4d63b26 --- /dev/null +++ b/addons/zylann.hterrain/tools/normalmap_baker.gd @@ -0,0 +1,146 @@ + +# 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 = null +var _ci = null +var _pending_tiles_grid = {} +var _pending_tiles_queue = [] +var _processing_tile = null +var _terrain_data = null + + +func _init(): + assert(VIEWPORT_SIZE <= HTerrainData.MIN_RESOLUTION) + _viewport = Viewport.new() + _viewport.size = Vector2(VIEWPORT_SIZE + 2, VIEWPORT_SIZE + 2) + _viewport.render_target_update_mode = Viewport.UPDATE_DISABLED + _viewport.render_target_clear_mode = Viewport.CLEAR_MODE_ALWAYS + _viewport.render_target_v_flip = true + _viewport.world = World.new() + _viewport.own_world = true + add_child(_viewport) + + var mat = ShaderMaterial.new() + mat.shader = load("res://addons/zylann.hterrain/tools/bump2normal_tex.shader") + + _ci = Sprite.new() + _ci.centered = false + _ci.material = mat + _viewport.add_child(_ci) + + set_process(false) + + +func set_terrain_data(data): + 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.disconnect("map_changed", self, "_on_terrain_data_map_changed") + _terrain_data.disconnect("resolution_changed", self, "_on_terrain_data_resolution_changed") + + _terrain_data = data + + if _terrain_data != null: + _terrain_data.connect("map_changed", self, "_on_terrain_data_map_changed") + _terrain_data.connect("resolution_changed", self, "_on_terrain_data_resolution_changed") + _ci.texture = data.get_texture(HTerrainData.CHANNEL_HEIGHT) + + +func _on_terrain_data_map_changed(maptype, index): + 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.update() + + +func request_tiles_in_region(min_pos, size): + 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)) + + +func request_tile(tpos): + 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_data() + 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, Rect2(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 = Viewport.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/addons/zylann.hterrain/tools/packed_textures/packed_texture_array_importer.gd b/addons/zylann.hterrain/tools/packed_textures/packed_texture_array_importer.gd new file mode 100644 index 0000000..c4c80ff --- /dev/null +++ b/addons/zylann.hterrain/tools/packed_textures/packed_texture_array_importer.gd @@ -0,0 +1,143 @@ +tool +extends EditorImportPlugin + +const TextureLayeredImporter = preload("./texture_layered_importer.gd") +const PackedTextureUtil = preload("./packed_texture_util.gd") +const Errors = preload("../../util/errors.gd") +const Result = preload("../util/result.gd") +const Logger = preload("../../util/logger.gd") + +const IMPORTER_NAME = "hterrain_packed_texture_array_importer" +const RESOURCE_TYPE = "TextureArray" + +var _logger = Logger.get_for(self) + + +func get_importer_name() -> String: + return IMPORTER_NAME + + +func get_visible_name() -> String: + # This shows up next to "Import As:" + return "HTerrainPackedTextureArray" + + +func get_recognized_extensions() -> Array: + return ["packed_texarr"] + + +func get_save_extension() -> String: + return "texarr" + + +func get_resource_type() -> String: + return RESOURCE_TYPE + + +func get_preset_count() -> int: + return 1 + + +func get_preset_name(preset_index: int) -> String: + return "" + + +func get_import_options(preset_index: int) -> Array: + return [ + { + "name": "compress/mode", + "default_value": TextureLayeredImporter.COMPRESS_VIDEO_RAM, + "property_hint": PROPERTY_HINT_ENUM, + "hint_string": TextureLayeredImporter.COMPRESS_HINT_STRING + }, + { + "name": "flags/repeat", + "default_value": TextureLayeredImporter.REPEAT_ENABLED, + "property_hint": PROPERTY_HINT_ENUM, + "hint_string": TextureLayeredImporter.REPEAT_HINT_STRING + }, + { + "name": "flags/filter", + "default_value": true + }, + { + "name": "flags/mipmaps", + "default_value": true + } + ] + + +func get_option_visibility(option: String, options: Dictionary) -> bool: + return true + + +func import(p_source_path: String, p_save_path: String, options: Dictionary, + r_platform_variants: Array, r_gen_files: Array) -> int: + + var result := _import(p_source_path, p_save_path, options, r_platform_variants, r_gen_files) + + if not result.success: + _logger.error(result.get_message()) + # TODO Show detailed error in a popup if result is negative + + var code : int = result.value + return code + + +func _import(p_source_path: String, p_save_path: String, options: Dictionary, + r_platform_variants: Array, r_gen_files: Array) -> Result: + + var f := File.new() + var err := f.open(p_source_path, File.READ) + if err != OK: + return Result.new(false, "Could not open file {0}: {1}" \ + .format([p_source_path, Errors.get_message(err)])) \ + .with_value(err) + var text := f.get_as_text() + f.close() + + var json_result := JSON.parse(text) + if json_result.error != OK: + return Result.new(false, "Failed to parse file {0}: {1}" \ + .format([p_source_path, json_result.error_string])) \ + .with_value(json_result.error) + var json_data : Dictionary = json_result.result + + var resolution : int = int(json_data.resolution) + var contains_albedo : bool = json_data.get("contains_albedo", false) + var layers = json_data.get("layers") + + var images = [] + + for layer_index in len(layers): + var sources = layers[layer_index] + var result = PackedTextureUtil.generate_image(sources, resolution, _logger) + + if not result.success: + return Result.new(false, + "While importing layer {0}".format([layer_index]), result) \ + .with_value(result.value) + + var im : Image = result.value + images.append(im) + + var result = TextureLayeredImporter.import( + p_source_path, + images, + p_save_path, + r_platform_variants, + r_gen_files, + contains_albedo, + get_visible_name(), + options["compress/mode"], + options["flags/repeat"], + options["flags/filter"], + options["flags/mipmaps"]) + + if not result.success: + return Result.new(false, + "While importing {0}".format([p_source_path]), result) \ + .with_value(result.value) + + return Result.new(true).with_value(OK) + diff --git a/addons/zylann.hterrain/tools/packed_textures/packed_texture_importer.gd b/addons/zylann.hterrain/tools/packed_textures/packed_texture_importer.gd new file mode 100644 index 0000000..9adeaa9 --- /dev/null +++ b/addons/zylann.hterrain/tools/packed_textures/packed_texture_importer.gd @@ -0,0 +1,138 @@ +tool +extends EditorImportPlugin + +const StreamTextureImporter = preload("./stream_texture_importer.gd") +const PackedTextureUtil = preload("./packed_texture_util.gd") +const Errors = preload("../../util/errors.gd") +const Result = preload("../util/result.gd") +const Logger = preload("../../util/logger.gd") + +const IMPORTER_NAME = "hterrain_packed_texture_importer" +const RESOURCE_TYPE = "StreamTexture" + +var _logger = Logger.get_for(self) + + +func get_importer_name() -> String: + return IMPORTER_NAME + + +func get_visible_name() -> String: + # This shows up next to "Import As:" + return "HTerrainPackedTexture" + + +func get_recognized_extensions() -> Array: + return ["packed_tex"] + + +func get_save_extension() -> String: + return "stex" + + +func get_resource_type() -> String: + return RESOURCE_TYPE + + +func get_preset_count() -> int: + return 1 + + +func get_preset_name(preset_index: int) -> String: + return "" + + +func get_import_options(preset_index: int) -> Array: + return [ + { + "name": "compress/mode", + "default_value": StreamTextureImporter.COMPRESS_VIDEO_RAM, + "property_hint": PROPERTY_HINT_ENUM, + "hint_string": StreamTextureImporter.COMPRESS_HINT_STRING + }, + { + "name": "flags/repeat", + "default_value": StreamTextureImporter.REPEAT_ENABLED, + "property_hint": PROPERTY_HINT_ENUM, + "hint_string": StreamTextureImporter.REPEAT_HINT_STRING + }, + { + "name": "flags/filter", + "default_value": true + }, + { + "name": "flags/mipmaps", + "default_value": true + } + ] + + +func get_option_visibility(option: String, options: Dictionary) -> bool: + return true + + +func import(p_source_path: String, p_save_path: String, options: Dictionary, + r_platform_variants: Array, r_gen_files: Array) -> int: + + var result := _import(p_source_path, p_save_path, options, r_platform_variants, r_gen_files) + + if not result.success: + _logger.error(result.get_message()) + # TODO Show detailed error in a popup if result is negative + + var code : int = result.value + return code + + +func _import(p_source_path: String, p_save_path: String, options: Dictionary, + r_platform_variants: Array, r_gen_files: Array) -> Result: + + var f := File.new() + var err := f.open(p_source_path, File.READ) + if err != OK: + return Result.new(false, "Could not open file {0}: {1}" \ + .format([p_source_path, Errors.get_message(err)])) \ + .with_value(err) + var text := f.get_as_text() + f.close() + + var json_result := JSON.parse(text) + if json_result.error != OK: + return Result.new(false, "Failed to parse file {0}: {1}" \ + .format([p_source_path, json_result.error_string])) \ + .with_value(json_result.error) + var json_data : Dictionary = json_result.result + + var resolution : int = int(json_data.resolution) + var contains_albedo : bool = json_data.get("contains_albedo", false) + var sources = json_data.get("src") + + var result := PackedTextureUtil.generate_image(sources, resolution, _logger) + + if not result.success: + return Result.new(false, + "While importing {0}".format([p_source_path]), result) \ + .with_value(result.value) + + var image : Image = result.value + + result = StreamTextureImporter.import( + p_source_path, + image, + p_save_path, + r_platform_variants, + r_gen_files, + contains_albedo, + get_visible_name(), + options["compress/mode"], + options["flags/repeat"], + options["flags/filter"], + options["flags/mipmaps"]) + + if not result.success: + return Result.new(false, + "While importing {0}".format([p_source_path]), result) \ + .with_value(result.value) + + return Result.new(true).with_value(OK) + diff --git a/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd b/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd new file mode 100644 index 0000000..302a056 --- /dev/null +++ b/addons/zylann.hterrain/tools/packed_textures/packed_texture_util.gd @@ -0,0 +1,88 @@ +tool + +const Logger = preload("../../util/logger.gd") +const Errors = preload("../../util/errors.gd") +const Result = preload("../util/result.gd") + +const _transform_params = [ + "normalmap_flip_y" +] + + +static func generate_image(sources: Dictionary, resolution: int, logger) -> Result: + var image := Image.new() + image.create(resolution, resolution, true, Image.FORMAT_RGBA8) + + image.lock() + + 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.new() + if src_path.begins_with("#"): + # Plain color + var col = Color(src_path) + src_image.create(resolution, resolution, false, Image.FORMAT_RGBA8) + src_image.fill(col) + + else: + # File + var err := src_image.load(src_path) + if err != OK: + return Result.new(false, "Could not open file \"{0}\": {1}" \ + .format([src_path, Errors.get_message(err)])) \ + .with_value(err) + src_image.decompress() + + src_image.resize(image.get_width(), image.get_height()) + src_image.lock() + + # 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, + Rect2(0, 0, image.get_width(), image.get_height()), Vector2()) + + src_image.unlock() + + image.unlock() + + if sources.has("normalmap_flip_y") and sources.normalmap_flip_y: + _flip_normalmap_y(image) + + return Result.new(true).with_value(image) + + +static func _flip_normalmap_y(image: Image): + image.lock() + 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) + image.unlock() + diff --git a/addons/zylann.hterrain/tools/packed_textures/stream_texture_importer.gd b/addons/zylann.hterrain/tools/packed_textures/stream_texture_importer.gd new file mode 100644 index 0000000..bc9e346 --- /dev/null +++ b/addons/zylann.hterrain/tools/packed_textures/stream_texture_importer.gd @@ -0,0 +1,433 @@ +tool + +# TODO Godot does not have an API to make custom texture importers easier. +# So we have to re-implement the entire logic of `ResourceImporterTexture`. +# See https://github.com/godotengine/godot/issues/24381 + +const Result = preload("../util/result.gd") +const Errors = preload("../../util/errors.gd") +const Util = preload("../../util/util.gd") + +const COMPRESS_LOSSLESS = 0 +const COMPRESS_LOSSY = 1 +const COMPRESS_VIDEO_RAM = 2 +const COMPRESS_UNCOMPRESSED = 3 + +const COMPRESS_HINT_STRING = "Lossless,Lossy,VRAM,Uncompressed" + +const REPEAT_NONE = 0 +const REPEAT_ENABLED = 1 +const REPEAT_MIRRORED = 2 + +const REPEAT_HINT_STRING = "None,Enabled,Mirrored" + +# StreamTexture.FormatBits, not exposed to GDScript +const StreamTexture_FORMAT_MASK_IMAGE_FORMAT = (1 << 20) - 1 +const StreamTexture_FORMAT_BIT_LOSSLESS = 1 << 20 +const StreamTexture_FORMAT_BIT_LOSSY = 1 << 21 +const StreamTexture_FORMAT_BIT_STREAM = 1 << 22 +const StreamTexture_FORMAT_BIT_HAS_MIPMAPS = 1 << 23 +const StreamTexture_FORMAT_BIT_DETECT_3D = 1 << 24 +const StreamTexture_FORMAT_BIT_DETECT_SRGB = 1 << 25 +const StreamTexture_FORMAT_BIT_DETECT_NORMAL = 1 << 26 + + +static func import( + p_source_path: String, + image: Image, + p_save_path: String, + r_platform_variants: Array, + r_gen_files: Array, + p_contains_albedo: bool, + importer_name: String, + p_compress_mode: int, + p_repeat: int, + p_filter: bool, + p_mipmaps: bool) -> Result: + + var compress_mode := p_compress_mode + var lossy := 0.7 + var repeat := p_repeat + var filter := p_filter + var mipmaps := p_mipmaps + var anisotropic := false + var srgb := 1 if p_contains_albedo else 2 + var fix_alpha_border := false + var premult_alpha := false + var invert_color := false + var stream := false + var size_limit := 0 + var hdr_as_srgb := false + var normal := 0 + var scale := 1.0 + var force_rgbe := false + var bptc_ldr := 0 + var detect_3d := false + + var formats_imported := [] + + var tex_flags := 0 + if repeat > 0: + tex_flags |= Texture.FLAG_REPEAT + if repeat == 2: + tex_flags |= Texture.FLAG_MIRRORED_REPEAT + if filter: + tex_flags |= Texture.FLAG_FILTER + if mipmaps or compress_mode == COMPRESS_VIDEO_RAM: + tex_flags |= Texture.FLAG_MIPMAPS + if anisotropic: + tex_flags |= Texture.FLAG_ANISOTROPIC_FILTER + if srgb == 1: + tex_flags |= Texture.FLAG_CONVERT_TO_LINEAR + + if size_limit > 0 and (image.get_width() > size_limit or image.get_height() > size_limit): + #limit size + if image.get_width() >= image.get_height(): + var new_width := size_limit + var new_height := image.get_height() * new_width / image.get_width() + + image.resize(new_width, new_height, Image.INTERPOLATE_CUBIC) + + else: + var new_height := size_limit + var new_width := image.get_width() * new_height / image.get_height() + + image.resize(new_width, new_height, Image.INTERPOLATE_CUBIC) + + if normal == 1: + image.normalize() + + if fix_alpha_border: + image.fix_alpha_edges() + + if premult_alpha: + image.premultiply_alpha() + + if invert_color: + var height = image.get_height() + var width = image.get_width() + + image.lock() + for i in width: + for j in height: + image.set_pixel(i, j, image.get_pixel(i, j).inverted()) + + image.unlock() + + var detect_srgb := srgb == 2 + var detect_normal := normal == 0 + var force_normal := normal == 1 + + if compress_mode == COMPRESS_VIDEO_RAM: + #must import in all formats, + #in order of priority (so platform choses the best supported one. IE, etc2 over etc). + #Android, GLES 2.x + + var ok_on_pc := false + var is_hdr := \ + (image.get_format() >= Image.FORMAT_RF and image.get_format() <= Image.FORMAT_RGBE9995) + var is_ldr := \ + (image.get_format() >= Image.FORMAT_L8 and image.get_format() <= Image.FORMAT_RGBA5551) + var can_bptc : bool = ProjectSettings.get("rendering/vram_compression/import_bptc") + var can_s3tc : bool = ProjectSettings.get("rendering/vram_compression/import_s3tc") + + if can_bptc: +# return Result.new(false, "{0} cannot handle BPTC compression on {1}, " + +# "because the required logic is not exposed to the script API. " + +# "If you don't aim to export for a platform requiring BPTC, " + +# "you can turn it off in your ProjectSettings." \ +# .format([importer_name, p_source_path])) \ +# .with_value(ERR_UNAVAILABLE) + + # Can't do this optimization because not exposed to GDScript +# var channels = image.get_detected_channels() +# if is_hdr: +# if channels == Image.DETECTED_LA or channels == Image.DETECTED_RGBA: +# can_bptc = false +# elif is_ldr: +# #handle "RGBA Only" setting +# if bptc_ldr == 1 and channels != Image.DETECTED_LA \ +# and channels != Image.DETECTED_RGBA: +# can_bptc = false +# + formats_imported.push_back("bptc") + + if not can_bptc and is_hdr and not force_rgbe: + #convert to ldr if this can't be stored hdr + image.convert(Image.FORMAT_RGBA8) + + if can_bptc or can_s3tc: + _save_stex( + image, + p_save_path + ".s3tc.stex", + compress_mode, + lossy, + Image.COMPRESS_BPTC if can_bptc else Image.COMPRESS_S3TC, + mipmaps, + tex_flags, + stream, + detect_3d, + detect_srgb, + force_rgbe, + detect_normal, + force_normal, + false) + r_platform_variants.push_back("s3tc") + formats_imported.push_back("s3tc") + ok_on_pc = true + + if ProjectSettings.get("rendering/vram_compression/import_etc2"): + _save_stex( + image, + p_save_path + ".etc2.stex", + compress_mode, + lossy, + Image.COMPRESS_ETC2, + mipmaps, + tex_flags, + stream, + detect_3d, + detect_srgb, + force_rgbe, + detect_normal, + force_normal, + true) + r_platform_variants.push_back("etc2") + formats_imported.push_back("etc2") + + if ProjectSettings.get("rendering/vram_compression/import_etc"): + _save_stex( + image, + p_save_path + ".etc.stex", + compress_mode, + lossy, + Image.COMPRESS_ETC, + mipmaps, + tex_flags, + stream, + detect_3d, + detect_srgb, + force_rgbe, + detect_normal, + force_normal, + true) + r_platform_variants.push_back("etc") + formats_imported.push_back("etc") + + if ProjectSettings.get("rendering/vram_compression/import_pvrtc"): + _save_stex( + image, + p_save_path + ".pvrtc.stex", + compress_mode, + lossy, + Image.COMPRESS_PVRTC4, + mipmaps, + tex_flags, + stream, + detect_3d, + detect_srgb, + force_rgbe, + detect_normal, + force_normal, + true) + r_platform_variants.push_back("pvrtc") + formats_imported.push_back("pvrtc") + + if not ok_on_pc: + # TODO This warning is normally printed by `EditorNode::add_io_error`, + # which doesn't seem to be exposed to the script API + return Result.new(false, + "No suitable PC VRAM compression enabled in Project Settings. " + + "The texture {0} will not display correctly on PC.".format([p_source_path])) \ + .with_value(ERR_INVALID_PARAMETER) + + else: + #import normally + _save_stex( + image, + p_save_path + ".stex", + compress_mode, + lossy, + Image.COMPRESS_S3TC, #this is ignored, + mipmaps, + tex_flags, + stream, + detect_3d, + detect_srgb, + force_rgbe, + detect_normal, + force_normal, + false) + + # TODO I have no idea what this part means, but it's not exposed to the script API either. +# if (r_metadata) { +# Dictionary metadata; +# metadata["vram_texture"] = compress_mode == COMPRESS_VIDEO_RAM; +# if (formats_imported.size()) { +# metadata["imported_formats"] = formats_imported; +# } +# *r_metadata = metadata; +# } + + return Result.new(true).with_value(OK) + + +static func _save_stex( + p_image: Image, + p_fpath: String, + p_compress_mode: int, # ResourceImporterTexture.CompressMode + p_lossy_quality: float, + p_vram_compression: int, # Image.CompressMode + p_mipmaps: bool, + p_texture_flags: int, + p_streamable: bool, + p_detect_3d: bool, + p_detect_srgb: bool, + p_force_rgbe: bool, + p_detect_normal: bool, + p_force_normal: bool, + p_force_po2_for_compressed: bool + ) -> Result: + + # Need to work on a copy because we will modify it, + # but the calling code may have to call this function multiple times + p_image = p_image.duplicate() + + var f = File.new() + var err = f.open(p_fpath, File.WRITE) + if err != OK: + return Result.new(false, "Could not open file {0}:\n{1}" \ + .format([p_fpath, Errors.get_message(err)])) + + f.store_8(ord('G')) + f.store_8(ord('D')) + f.store_8(ord('S')) + f.store_8(ord('T')) # godot streamable texture + + var resize_to_po2 := false + + if p_compress_mode == COMPRESS_VIDEO_RAM and p_force_po2_for_compressed \ + and (p_mipmaps or p_texture_flags & Texture.FLAG_REPEAT): + resize_to_po2 = true + f.store_16(Util.next_power_of_two(p_image.get_width())) + f.store_16(p_image.get_width()) + f.store_16(Util.next_power_of_two(p_image.get_height())) + f.store_16(p_image.get_height()) + else: + f.store_16(p_image.get_width()) + f.store_16(0) + f.store_16(p_image.get_height()) + f.store_16(0) + + f.store_32(p_texture_flags) + + var format := 0 + + if p_streamable: + format |= StreamTexture_FORMAT_BIT_STREAM + if p_mipmaps: + format |= StreamTexture_FORMAT_BIT_HAS_MIPMAPS # mipmaps bit + if p_detect_3d: + format |= StreamTexture_FORMAT_BIT_DETECT_3D + if p_detect_srgb: + format |= StreamTexture_FORMAT_BIT_DETECT_SRGB + if p_detect_normal: + format |= StreamTexture_FORMAT_BIT_DETECT_NORMAL + + if (p_compress_mode == COMPRESS_LOSSLESS or p_compress_mode == COMPRESS_LOSSY) \ + and p_image.get_format() > Image.FORMAT_RGBA8: + p_compress_mode = COMPRESS_UNCOMPRESSED # these can't go as lossy + + match p_compress_mode: + COMPRESS_LOSSLESS: + # Not required for our use case +# var image : Image = p_image.duplicate() +# if p_mipmaps: +# image.generate_mipmaps() +# else: +# image.clear_mipmaps() + var image := p_image + + var mmc := _get_required_mipmap_count(image) + + format |= StreamTexture_FORMAT_BIT_LOSSLESS + f.store_32(format) + f.store_32(mmc) + + for i in mmc: + if i > 0: + image.shrink_x2() + #var data = Image::lossless_packer(image); + # This is actually PNG... + var data = image.save_png_to_buffer() + f.store_32(data.size() + 4) + f.store_8(ord('P')) + f.store_8(ord('N')) + f.store_8(ord('G')) + f.store_8(ord(' ')) + f.store_buffer(data) + + COMPRESS_LOSSY: + return Result.new(false, + "Saving a StreamTexture with lossy compression cannot be achieved by scripts.\n" + + "Godot would need to either allow to save an image as WEBP to a buffer,\n" + + "or expose `ResourceImporterTexture::_save_stex` so custom importers\n" + + "would be easier to make.") + + COMPRESS_VIDEO_RAM: + var image : Image = p_image.duplicate() + if resize_to_po2: + image.resize_to_po2() + + if p_mipmaps: + image.generate_mipmaps(p_force_normal) + + if p_force_rgbe \ + and image.get_format() >= Image.FORMAT_R8 \ + and image.get_format() <= Image.FORMAT_RGBE9995: + image.convert(Image.FORMAT_RGBE9995) + else: + var csource := Image.COMPRESS_SOURCE_GENERIC + if p_force_normal: + csource = Image.COMPRESS_SOURCE_NORMAL + elif p_texture_flags & VisualServer.TEXTURE_FLAG_CONVERT_TO_LINEAR: + csource = Image.COMPRESS_SOURCE_SRGB + + image.compress(p_vram_compression, csource, p_lossy_quality) + + format |= image.get_format() + + f.store_32(format) + + var data = image.get_data(); + f.store_buffer(data) + + COMPRESS_UNCOMPRESSED: + + var image := p_image.duplicate() + if p_mipmaps: + image.generate_mipmaps() + else: + image.clear_mipmaps() + + format |= image.get_format() + f.store_32(format) + + var data = image.get_data() + f.store_buffer(data) + + _: + return Result.new(false, "Invalid compress mode specified: {0}" \ + .format([p_compress_mode])) + + return Result.new(true) + + +# TODO Godot doesn't expose `Image.get_mipmap_count()` +# And the implementation involves shittons of unexposed code, +# so we have to fallback on a simplified version +static func _get_required_mipmap_count(image: Image) -> int: + var dim := max(image.get_width(), image.get_height()) + return int(log(dim) / log(2) + 1) + + diff --git a/addons/zylann.hterrain/tools/packed_textures/texture_layered_importer.gd b/addons/zylann.hterrain/tools/packed_textures/texture_layered_importer.gd new file mode 100644 index 0000000..1eba88d --- /dev/null +++ b/addons/zylann.hterrain/tools/packed_textures/texture_layered_importer.gd @@ -0,0 +1,338 @@ +tool + +# TODO Godot does not have an API to make custom texture importers easier. +# So we have to re-implement the entire logic of `ResourceImporterLayeredTexture`. +# See https://github.com/godotengine/godot/issues/24381 + +const Result = preload("../util/result.gd") +const Errors = preload("../../util/errors.gd") +const Util = preload("../../util/util.gd") + +const COMPRESS_LOSSLESS = 0 +const COMPRESS_VIDEO_RAM = 1 +const COMPRESS_UNCOMPRESSED = 2 +# For some reason lossy TextureArrays are not implemented in Godot -_- + +const COMPRESS_HINT_STRING = "Lossless,VRAM,Uncompressed" + +const REPEAT_NONE = 0 +const REPEAT_ENABLED = 1 +const REPEAT_MIRRORED = 2 + +const REPEAT_HINT_STRING = "None,Enabled,Mirrored" + +# TODO COMPRESS_SOURCE_LAYERED is not exposed +# https://github.com/godotengine/godot/issues/43387 +const Image_COMPRESS_SOURCE_LAYERED = 3 + + +static func import( + p_source_path: String, + p_images: Array, + p_save_path: String, + r_platform_variants: Array, + r_gen_files: Array, + p_contains_albedo: bool, + importer_name: String, + p_compress_mode: int, + p_repeat: int, + p_filter: bool, + p_mipmaps: bool) -> Result: + + var compress_mode := p_compress_mode + var no_bptc_if_rgb := false#p_options["compress/no_bptc_if_rgb"]; + var repeat := p_repeat + var filter := p_filter + var mipmaps := p_mipmaps + var srgb := 1 if p_contains_albedo else 2#p_options["flags/srgb"]; +# int hslices = p_options["slices/horizontal"]; +# int vslices = p_options["slices/vertical"]; + + var tex_flags := 0 + if repeat > 0: + tex_flags |= Texture.FLAG_REPEAT + if repeat == 2: + tex_flags |= Texture.FLAG_MIRRORED_REPEAT + if filter: + tex_flags |= Texture.FLAG_FILTER + if mipmaps or compress_mode == COMPRESS_VIDEO_RAM: + tex_flags |= Texture.FLAG_MIPMAPS + if srgb == 1: + tex_flags |= Texture.FLAG_CONVERT_TO_LINEAR + +# Vector > slices; +# +# int slice_w = image->get_width() / hslices; +# int slice_h = image->get_height() / vslices; + + # Can't do any of this in our case... + #optimize +# if compress_mode == COMPRESS_VIDEO_RAM: + #if using video ram, optimize +# if srgb: + #remove alpha if not needed, so compression is more efficient +# if image.get_format() == Image.FORMAT_RGBA8 and !image.detect_alpha(): +# image.convert(Image.FORMAT_RGB8) + +# else: +# pass + # Not exposed to GDScript... + #image.optimize_channels() + + var extension := "texarr" + var formats_imported := [] + + if compress_mode == COMPRESS_VIDEO_RAM: + #must import in all formats, + #in order of priority (so platform choses the best supported one. IE, etc2 over etc). + #Android, GLES 2.x + + var ok_on_pc := false + var encode_bptc := false + + if ProjectSettings.get("rendering/vram_compression/import_bptc"): +# return Result.new(false, "{0} cannot handle BPTC compression on {1}, " + +# "because the required logic is not exposed to the script API. " + +# "If you don't aim to export for a platform requiring BPTC, " + +# "you can turn it off in your ProjectSettings." \ +# .format([importer_name, p_source_path])) \ +# .with_value(ERR_UNAVAILABLE) + + # Can't do this optimization because not exposed to GDScript +# var encode_bptc := true +# if no_bptc_if_rgb: +# var channels := image.get_detected_channels() +# if channels != Image.DETECTED_LA and channels != Image.DETECTED_RGBA: +# encode_bptc = false + + formats_imported.push_back("bptc"); + + if encode_bptc: + var result = _save_tex( + p_images, + p_save_path + ".bptc." + extension, + compress_mode, + Image.COMPRESS_BPTC, + mipmaps, + tex_flags) + + if not result.success: + return result + + r_platform_variants.push_back("bptc") + ok_on_pc = true + + if ProjectSettings.get("rendering/vram_compression/import_s3tc"): + var result = _save_tex( + p_images, + p_save_path + ".s3tc." + extension, + compress_mode, + Image.COMPRESS_S3TC, + mipmaps, + tex_flags) + + if not result.success: + return result + + r_platform_variants.push_back("s3tc") + ok_on_pc = true + formats_imported.push_back("s3tc") + + if ProjectSettings.get("rendering/vram_compression/import_etc2"): + var result = _save_tex( + p_images, + p_save_path + ".etc2." + extension, + compress_mode, + Image.COMPRESS_ETC2, + mipmaps, + tex_flags) + + if not result.success: + return result + + r_platform_variants.push_back("etc2") + formats_imported.push_back("etc2") + + if ProjectSettings.get("rendering/vram_compression/import_etc"): + var result = _save_tex( + p_images, + p_save_path + ".etc." + extension, + compress_mode, + Image.COMPRESS_ETC, + mipmaps, + tex_flags) + + if not result.success: + return result + + r_platform_variants.push_back("etc") + formats_imported.push_back("etc") + + if ProjectSettings.get("rendering/vram_compression/import_pvrtc"): + var result = _save_tex( + p_images, + p_save_path + ".pvrtc." + extension, + compress_mode, + Image.COMPRESS_PVRTC4, + mipmaps, + tex_flags) + + if not result.success: + return result + + r_platform_variants.push_back("pvrtc") + formats_imported.push_back("pvrtc") + + if not ok_on_pc: + # TODO This warning is normally printed by `EditorNode::add_io_error`, + # which doesn't seem to be exposed to the script API + return Result.new(false, + "No suitable PC VRAM compression enabled in Project Settings. " + + "The texture {0} will not display correctly on PC.".format([p_source_path])) \ + .with_value(ERR_INVALID_PARAMETER) + + else: + #import normally + var result = _save_tex( + p_images, + p_save_path + "." + extension, + compress_mode, + Image.COMPRESS_S3TC, #this is ignored + mipmaps, + tex_flags) + + if not result.success: + return result + +# if (r_metadata) { +# Dictionary metadata; +# metadata["vram_texture"] = compress_mode == COMPRESS_VIDEO_RAM; +# if (formats_imported.size()) { +# metadata["imported_formats"] = formats_imported; +# } +# *r_metadata = metadata; +# } + + return Result.new(true).with_value(OK) + + +# The input image can be modified +static func _save_tex( + p_images: Array, + p_to_path: String, + p_compress_mode: int, + p_vram_compression: int, # Image.CompressMode + p_mipmaps: bool, + p_texture_flags: int + ) -> Result: + + # We only do TextureArrays for now + var is_3d = false + + var f := File.new() + var err := f.open(p_to_path, File.WRITE) + f.store_8(ord('G')) + f.store_8(ord('D')) + if is_3d: + f.store_8(ord('3')) + else: + f.store_8(ord('A')) + f.store_8(ord('T')) # godot streamable texture + + var slice_count := len(p_images) + + f.store_32(p_images[0].get_width()) + f.store_32(p_images[0].get_height()) + f.store_32(slice_count) # depth + f.store_32(p_texture_flags) + var image_format : int = p_images[0].get_format() + var image_size : Vector2 = p_images[0].get_size() + if p_compress_mode != COMPRESS_VIDEO_RAM: + # vram needs to do a first compression to tell what the format is, for the rest its ok + f.store_32(image_format) + f.store_32(p_compress_mode) # 0 - lossless (PNG), 1 - vram, 2 - uncompressed + + if (p_compress_mode == COMPRESS_LOSSLESS) and image_format > Image.FORMAT_RGBA8: + p_compress_mode = COMPRESS_UNCOMPRESSED # these can't go as lossy + + for i in slice_count: + var image : Image = p_images[i] + + if image.get_format() != image_format: + return Result.new(false, "Layer {0} has different format, got {1}, expected {2}" \ + .format([i, image.get_format(), image_format])).with_value(ERR_INVALID_DATA) + + if image.get_size() != image_size: + return Result.new(false, "Layer {0} has different size, got {1}, expected {2}" \ + .format([i, image.get_size(), image_size])).with_value(ERR_INVALID_DATA) + + # We need to operate on a copy, + # because the calling code can invoke the function multiple times + image = image.duplicate() + + match p_compress_mode: + COMPRESS_LOSSLESS: + # We save each mip as PNG so we dont need to do that. + # The engine code does it anyways :shrug: (see below why...) +# var image = p_images[i].duplicate() +# if p_mipmaps: +# image.generate_mipmaps() +# else: +# image.clear_mipmaps() + + var mmc := _get_required_mipmap_count(image) + f.store_32(mmc) + + for j in mmc: + if j > 0: + # TODO This function does something fishy behind the scenes: + # It assumes mipmaps are downscaled versions of the image. + # This is not necessarily true. + # See https://www.kotaku.com.au/2018/03/ + # how-nintendo-did-the-water-effects-in-super-mario-sunshine/ + image.shrink_x2() + + #var data = Image::lossless_packer(image); + var data = image.save_png_to_buffer() + f.store_32(data.size() + 4) + f.store_8(ord('P')) + f.store_8(ord('N')) + f.store_8(ord('G')) + f.store_8(ord(' ')) + f.store_buffer(data) + + COMPRESS_VIDEO_RAM: +# var image : Image = p_images[i]->duplicate(); + image.generate_mipmaps(false) + + var csource := Image_COMPRESS_SOURCE_LAYERED + image.compress(p_vram_compression, csource, 0.7) + + if i == 0: + #hack so we can properly tell the format + f.store_32(image.get_format()) + f.store_32(p_compress_mode); # 0 - lossless (PNG), 1 - vram, 2 - uncompressed + + var data := image.get_data() + f.store_buffer(data) + + COMPRESS_UNCOMPRESSED: +# Ref image = p_images[i]->duplicate(); + + if p_mipmaps: + image.generate_mipmaps() + else: + image.clear_mipmaps() + + var data := image.get_data() + f.store_buffer(data) + + return Result.new(true) + + +# TODO Godot doesn't expose `Image.get_mipmap_count()` +# And the implementation involves shittons of unexposed code, +# so we have to fallback on a simplified version +static func _get_required_mipmap_count(image: Image) -> int: + var dim := max(image.get_width(), image.get_height()) + return int(log(dim) / log(2) + 1) diff --git a/addons/zylann.hterrain/tools/panel.gd b/addons/zylann.hterrain/tools/panel.gd new file mode 100644 index 0000000..2492654 --- /dev/null +++ b/addons/zylann.hterrain/tools/panel.gd @@ -0,0 +1,72 @@ +tool +extends Control + + +# 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 = $HSplitContainer/HSplitContainer/HSplitContainer/DetailEditor + + +func setup_dialogs(base_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(ur: UndoRedo): + _detail_editor.set_undo_redo(ur) + + +func set_image_cache(image_cache): + _detail_editor.set_image_cache(image_cache) + + +func set_camera_transform(cam_transform: Transform): + _minimap.set_camera_transform(cam_transform) + + +func set_brush(brush): + _brush_editor.set_brush(brush) + + +func _on_TextureEditor_texture_selected(index): + emit_signal("texture_selected", index) + + +func _on_DetailEditor_detail_selected(index): + emit_signal("detail_selected", 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(): + emit_signal("detail_list_changed") + + +func _on_TextureEditor_import_pressed(): + emit_signal("import_textures_pressed") + + +func _on_TextureEditor_edit_pressed(index: int): + emit_signal("edit_texture_pressed", index) diff --git a/addons/zylann.hterrain/tools/panel.tscn b/addons/zylann.hterrain/tools/panel.tscn new file mode 100644 index 0000000..d1b97d2 --- /dev/null +++ b/addons/zylann.hterrain/tools/panel.tscn @@ -0,0 +1,70 @@ +[gd_scene load_steps=7 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/panel.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/brush/brush_editor.tscn" type="PackedScene" id=2] +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn" type="PackedScene" id=3] +[ext_resource path="res://addons/zylann.hterrain/tools/detail_editor/detail_editor.tscn" type="PackedScene" id=4] +[ext_resource path="res://addons/zylann.hterrain/tools/minimap/minimap.tscn" type="PackedScene" id=5] +[ext_resource path="res://addons/zylann.hterrain/tools/minimap/ratio_container.gd" type="Script" id=6] + +[node name="Panel" type="Control"] +margin_right = 881.0 +margin_bottom = 120.0 +rect_min_size = Vector2( 400, 120 ) +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HSplitContainer" type="HSplitContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 4.0 +margin_top = 4.0 +margin_right = -6.0 +margin_bottom = -4.0 +split_offset = 60 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="BrushEditor" parent="HSplitContainer" instance=ExtResource( 2 )] +margin_right = 260.0 +margin_bottom = 112.0 + +[node name="HSplitContainer" type="HSplitContainer" parent="HSplitContainer"] +margin_left = 272.0 +margin_right = 871.0 +margin_bottom = 112.0 + +[node name="HSplitContainer" type="HSplitContainer" parent="HSplitContainer/HSplitContainer"] +margin_right = 487.0 +margin_bottom = 112.0 +size_flags_horizontal = 3 +split_offset = 150 + +[node name="TextureEditor" parent="HSplitContainer/HSplitContainer/HSplitContainer" instance=ExtResource( 3 )] +margin_right = 250.0 +margin_bottom = 112.0 +size_flags_horizontal = 1 + +[node name="DetailEditor" parent="HSplitContainer/HSplitContainer/HSplitContainer" instance=ExtResource( 4 )] +margin_left = 262.0 +margin_right = 487.0 +margin_bottom = 112.0 + +[node name="MinimapContainer" type="Container" parent="HSplitContainer/HSplitContainer"] +margin_left = 499.0 +margin_right = 599.0 +margin_bottom = 112.0 +rect_min_size = Vector2( 100, 0 ) +script = ExtResource( 6 ) +ratio = 1.0 + +[node name="Minimap" parent="HSplitContainer/HSplitContainer/MinimapContainer" instance=ExtResource( 5 )] +margin_bottom = 100.0 +[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/addons/zylann.hterrain/tools/plugin.gd b/addons/zylann.hterrain/tools/plugin.gd new file mode 100644 index 0000000..4a2bde4 --- /dev/null +++ b/addons/zylann.hterrain/tools/plugin.gd @@ -0,0 +1,891 @@ +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 PackedTextureImporter = preload("./packed_textures/packed_texture_importer.gd") +const PackedTextureArrayImporter = preload("./packed_textures/packed_texture_array_importer.gd") +const PreviewGenerator = preload("./preview_generator.gd") +const Brush = preload("./brush/terrain_painter.gd") +const BrushDecal = preload("./brush/decal.gd") +const Util = preload("../util/util.gd") +const EditorUtil = preload("./util/editor_util.gd") +const LoadTextureDialog = preload("./load_texture_dialog.gd") +const GlobalMapBaker = preload("./globalmap_baker.gd") +const ImageFileCache = preload("../util/image_file_cache.gd") +const Logger = preload("../util/logger.gd") + +# TODO Suffix with Scene +const EditPanel = preload("./panel.tscn") +const ProgressWindow = preload("./progress_window.tscn") +const GeneratorDialog = preload("./generator/generator_dialog.tscn") +const ImportDialog = preload("./importer/importer_dialog.tscn") +const GenerateMeshDialog = preload("./generate_mesh_dialog.tscn") +const ResizeDialog = preload("./resize_dialog/resize_dialog.tscn") +const ExportImageDialog = preload("./exporter/export_image_dialog.tscn") +const TextureSetEditor = preload("./texture_editor/set_editor/texture_set_editor.tscn") +const TextureSetImportEditor = preload("./texture_editor/set_editor/texture_set_import_editor.tscn") +const 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 = null +var _toolbar = null +var _toolbar_brush_buttons = {} +var _generator_dialog = null +# TODO Rename _import_terrain_dialog +var _import_dialog = null +var _export_image_dialog = null +var _progress_window = null +var _generate_mesh_dialog = null +var _preview_generator = null +var _resize_dialog = null +var _about_dialog = null +var _menu_button : MenuButton +var _lookdev_menu : PopupMenu +var _texture_set_editor = null +var _texture_set_import_editor = null + +var _globalmap_baker = null +var _terrain_had_data_previous_frame = false +var _image_cache : ImageFileCache + +# Import +var _packed_texture_importer := PackedTextureImporter.new() +var _packed_texture_array_importer := PackedTextureArrayImporter.new() + +var _brush : Brush = null +var _brush_decal : BrushDecal = null +var _mouse_pressed := false +#var _pending_paint_action = null +var _pending_paint_commit := false + +var _logger = Logger.get_for(self) + + +static func get_icon(name: String) -> Texture: + return load("res://addons/zylann.hterrain/tools/icons/icon_" + name + ".svg") as Texture + + +func _enter_tree(): + _logger.debug("HTerrain plugin Enter tree") + + var dpi_scale = EditorUtil.get_dpi_scale(get_editor_interface().get_editor_settings()) + _logger.debug(str("DPI scale: ", dpi_scale)) + + add_custom_type("HTerrain", "Spatial", HTerrain, get_icon("heightmap_node")) + add_custom_type("HTerrainDetailLayer", "Spatial", 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) + + add_import_plugin(_packed_texture_importer) + add_import_plugin(_packed_texture_array_importer) + + _preview_generator = PreviewGenerator.new() + get_editor_interface().get_resource_previewer().add_preview_generator(_preview_generator) + + _brush = Brush.new() + _brush.set_brush_size(5) + _brush.connect("changed", self, "_on_brush_changed") + add_child(_brush) + + _brush_decal = BrushDecal.new() + _brush_decal.set_size(_brush.get_brush_size()) + + _image_cache = ImageFileCache.new("user://temp_hterrain_image_cache") + + var editor_interface := get_editor_interface() + var base_control := editor_interface.get_base_control() + + _panel = EditPanel.instance() + 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_brush", _brush) + _panel.call_deferred("setup_dialogs", base_control) + _panel.set_undo_redo(get_undo_redo()) + _panel.set_image_cache(_image_cache) + _panel.connect("detail_selected", self, "_on_detail_selected") + _panel.connect("texture_selected", self, "_on_texture_selected") + _panel.connect("detail_list_changed", self, "_update_brush_buttons_availability") + _panel.connect("edit_texture_pressed", self, "_on_Panel_edit_texture_pressed") + _panel.connect("import_textures_pressed", self, "_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.connect("about_to_show", self, "_on_lookdev_menu_about_to_show") + _lookdev_menu.connect("id_pressed", self, "_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().connect("id_pressed", self, "_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[Brush.MODE_RAISE] = get_icon("heightmap_raise") + mode_icons[Brush.MODE_LOWER] = get_icon("heightmap_lower") + mode_icons[Brush.MODE_SMOOTH] = get_icon("heightmap_smooth") + mode_icons[Brush.MODE_FLATTEN] = get_icon("heightmap_flatten") + # TODO Have different icons + mode_icons[Brush.MODE_SPLAT] = get_icon("heightmap_paint") + mode_icons[Brush.MODE_COLOR] = get_icon("heightmap_color") + mode_icons[Brush.MODE_DETAIL] = get_icon("grass") + mode_icons[Brush.MODE_MASK] = get_icon("heightmap_mask") + mode_icons[Brush.MODE_LEVEL] = get_icon("heightmap_level") + mode_icons[Brush.MODE_ERODE] = get_icon("heightmap_erode") + + var mode_tooltips := {} + mode_tooltips[Brush.MODE_RAISE] = "Raise height" + mode_tooltips[Brush.MODE_LOWER] = "Lower height" + mode_tooltips[Brush.MODE_SMOOTH] = "Smooth height" + mode_tooltips[Brush.MODE_FLATTEN] = "Flatten (flatten to a specific height)" + mode_tooltips[Brush.MODE_SPLAT] = "Texture paint" + mode_tooltips[Brush.MODE_COLOR] = "Color paint" + mode_tooltips[Brush.MODE_DETAIL] = "Grass paint" + mode_tooltips[Brush.MODE_MASK] = "Cut holes" + mode_tooltips[Brush.MODE_LEVEL] = "Level (smoothly flattens to average)" + mode_tooltips[Brush.MODE_ERODE] = "Erode" + + _toolbar.add_child(VSeparator.new()) + + # I want modes to be in that order in the GUI + var ordered_brush_modes := [ + Brush.MODE_RAISE, + Brush.MODE_LOWER, + Brush.MODE_SMOOTH, + Brush.MODE_LEVEL, + Brush.MODE_FLATTEN, + Brush.MODE_ERODE, + Brush.MODE_SPLAT, + Brush.MODE_COLOR, + Brush.MODE_DETAIL, + Brush.MODE_MASK + ] + + var mode_group := ButtonGroup.new() + + for mode in ordered_brush_modes: + var button := ToolButton.new() + button.icon = mode_icons[mode] + button.set_tooltip(mode_tooltips[mode]) + button.set_toggle_mode(true) + button.set_button_group(mode_group) + + if mode == _brush.get_mode(): + button.set_pressed(true) + + button.connect("pressed", self, "_on_mode_selected", [mode]) + _toolbar.add_child(button) + + _toolbar_brush_buttons[mode] = button + + _generator_dialog = GeneratorDialog.instance() + _generator_dialog.connect("progress_notified", self, "_terrain_progress_notified") + _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 = ImportDialog.instance() + _import_dialog.connect("permanent_change_performed", self, "_on_permanent_change_performed") + Util.apply_dpi_scale(_import_dialog, dpi_scale) + base_control.add_child(_import_dialog) + + _progress_window = ProgressWindow.instance() + base_control.add_child(_progress_window) + + _generate_mesh_dialog = GenerateMeshDialog.instance() + _generate_mesh_dialog.connect( + "generate_selected", self, "_on_GenerateMeshDialog_generate_selected") + Util.apply_dpi_scale(_generate_mesh_dialog, dpi_scale) + base_control.add_child(_generate_mesh_dialog) + + _resize_dialog = ResizeDialog.instance() + _resize_dialog.connect("permanent_change_performed", self, "_on_permanent_change_performed") + Util.apply_dpi_scale(_resize_dialog, dpi_scale) + base_control.add_child(_resize_dialog) + + _globalmap_baker = GlobalMapBaker.new() + _globalmap_baker.connect("progress_notified", self, "_terrain_progress_notified") + _globalmap_baker.connect("permanent_change_performed", self, "_on_permanent_change_performed") + add_child(_globalmap_baker) + + _export_image_dialog = ExportImageDialog.instance() + 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 = AboutDialogScene.instance() + Util.apply_dpi_scale(_about_dialog, dpi_scale) + base_control.add_child(_about_dialog) + + _texture_set_editor = TextureSetEditor.instance() + _texture_set_editor.set_undo_redo(get_undo_redo()) + 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 = TextureSetImportEditor.instance() + _texture_set_import_editor.set_undo_redo(get_undo_redo()) + _texture_set_import_editor.set_editor_file_system( + get_editor_interface().get_resource_filesystem()) + 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.connect("import_selected", self, "_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") + + remove_import_plugin(_packed_texture_importer) + _packed_texture_importer = null + + remove_import_plugin(_packed_texture_array_importer) + _packed_texture_array_importer = null + + +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.disconnect("tree_exited", self, "_terrain_exited_scene") + + _node = node + + if _node != null: + _node.connect("tree_exited", self, "_terrain_exited_scene") + + _update_brush_buttons_availability() + + _panel.set_terrain(_node) + _generator_dialog.set_terrain(_node) + _import_dialog.set_terrain(_node) + _brush.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 Spatial: + 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[Brush.MODE_DETAIL] + button.disabled = false + else: + var button = _toolbar_brush_buttons[Brush.MODE_DETAIL] + if button.pressed: + _select_brush_mode(Brush.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: Camera):# -> 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 = viewport.get_parent() + var screen_pos = mouse_position * viewport.size / viewport_container.rect_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_spatial_gui_input(p_camera: Camera, p_event: InputEvent) -> bool: + if _node == null || _node.get_data() == null: + return false + + _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 == BUTTON_LEFT or mb.button_index == 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.control) and (not mb.alt) and mb.button_index == BUTTON_LEFT: + if mb.pressed: + _mouse_pressed = true + + captured_event = true + + if not _mouse_pressed: + # Just finished painting + _pending_paint_commit = true + + if _brush.get_mode() == Brush.MODE_FLATTEN and _brush.has_meta("pick_height") \ + and _brush.get_meta("pick_height"): + _brush.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])) + _brush.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(BUTTON_LEFT): + _brush.paint_input(hit_pos_in_cells) + 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() + + return captured_event + + +func _process(delta: float): + if _node == null: + return + + var has_data = (_node.get_data() != null) + + if _pending_paint_commit: + if has_data: + if _brush.has_modified_chunks() and not _brush.is_operation_pending(): + _pending_paint_commit = false + _logger.debug("Paint completed") + var changes : Dictionary = _brush.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 = OS.get_ticks_msec() + + var heightmap_data = _node.get_data() + assert(heightmap_data != null) + + var chunk_positions : Array = changes.chunk_positions + 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 := _brush.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 = OS.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)) + _brush.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(Brush.MODE_SPLAT) + _brush.set_texture_index(index) + + +func _on_detail_selected(index: int): + # Switch to detail paint mode when a detail item is selected + _select_brush_mode(Brush.MODE_DETAIL) + _brush.set_detail_index(index) + + +func _select_brush_mode(mode: int): + _toolbar_brush_buttons[mode].pressed = true + _on_mode_selected(mode) + + +static func get_size_from_raw_length(flen: int): + var side_len = round(sqrt(float(flen/2))) + return int(side_len) + + +func _terrain_progress_notified(info: Dictionary): + if info.has("finished") and info.finished: + _progress_window.hide() + + else: + if not _progress_window.visible: + _progress_window.popup_centered() + + var message = "" + if info.has("message"): + message = info.message + + _progress_window.show_progress(info.message, info.progress) + # TODO Have builtin modal progress bar + # https://github.com/godotengine/godot/issues/17763 + + +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 := MeshInstance.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_changed(): + _brush_decal.set_size(_brush.get_brush_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 := Util.find_first_node(root, HTerrain) as HTerrain + if terrain == null: + return + + var test_root : Spatial + if not terrain.has_node("__DEBUG"): + test_root = Spatial.new() + test_root.name = "__DEBUG" + terrain.add_child(test_root) + else: + test_root = terrain.get_node("__DEBUG") + + var space_state := terrain.get_world().direct_space_state + var hit_material = SpatialMaterial.new() + hit_material.albedo_color = Color(0, 1, 1) + var cube = CubeMesh.new() + + for zi in 16: + for xi in 16: + var hit_name = str(xi, "_", zi) + var pos = Vector3(xi * 16, 1000, zi * 16) + var hit = space_state.intersect_ray(pos, pos + Vector3(0, -2000, 0)) + var mi : MeshInstance + if not test_root.has_node(hit_name): + mi = MeshInstance.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.empty(): + mi.hide() + else: + mi.show() + mi.translation = 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 = SpatialMaterial.new() + mat.flags_transparent = true + mat.albedo_color = Color(1,1,1,0.2) + data._chunked_vertical_bounds.lock() + 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 = MeshInstance.new() + mi.mesh = CubeMesh.new() + var cs = HTerrainData.VERTICAL_BOUNDS_CHUNK_SIZE + mi.mesh.size = Vector3(cs, maxv - minv, cs) + mi.translation = Vector3( + (float(cx) + 0.5) * cs, + minv + mi.mesh.size.y * 0.5, + (float(cy) + 0.5) * cs) + mi.translation *= _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() + + data._chunked_vertical_bounds.unlock() + +# 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/addons/zylann.hterrain/tools/preview_generator.gd b/addons/zylann.hterrain/tools/preview_generator.gd new file mode 100644 index 0000000..0710101 --- /dev/null +++ b/addons/zylann.hterrain/tools/preview_generator.gd @@ -0,0 +1,74 @@ +tool +extends EditorResourcePreviewGenerator + +const HTerrainData = preload("../hterrain_data.gd") +const Errors = preload("../util/errors.gd") +const Logger = preload("../util/logger.gd") + +var _logger = Logger.get_for(self) + + +func generate(res: Resource, size: Vector2) -> Texture: + 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(normalmap, size) + + +func generate_from_path(path: String, size: Vector2) -> Texture: + 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.plus_file(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, Errors.get_message(err)])) + return null + return _generate(normals, size) + + +func handles(type: String) -> bool: + return type == "Resource" + + +static func _generate(normals: Image, size: Vector2) -> Texture: + var im := Image.new() + im.create(size.x, size.y, false, Image.FORMAT_RGB8) + + im.lock() + normals.lock() + + 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 := clamp(0.5 * ndot + 0.5, 0.0, 1.0) + var col := Color(gs, gs, gs, 1.0) + + im.set_pixel(x, y, col) + + im.unlock(); + normals.unlock(); + + var tex = ImageTexture.new() + tex.create_from_image(im, 0) + + 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/addons/zylann.hterrain/tools/progress_window.gd b/addons/zylann.hterrain/tools/progress_window.gd new file mode 100644 index 0000000..e50c332 --- /dev/null +++ b/addons/zylann.hterrain/tools/progress_window.gd @@ -0,0 +1,12 @@ +tool +extends Control + + +#onready var _label = get_node("VBoxContainer/Label") +onready var _progress_bar = $VBoxContainer/ProgressBar + + +func show_progress(message, progress): + self.window_title = message + _progress_bar.ratio = progress + diff --git a/addons/zylann.hterrain/tools/progress_window.tscn b/addons/zylann.hterrain/tools/progress_window.tscn new file mode 100644 index 0000000..f78657a --- /dev/null +++ b/addons/zylann.hterrain/tools/progress_window.tscn @@ -0,0 +1,70 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/progress_window.gd" type="Script" id=1] + +[node name="WindowDialog" type="WindowDialog" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 241.0 +margin_top = 216.0 +margin_right = 641.0 +margin_bottom = 256.0 +rect_min_size = Vector2( 400, 40 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +popup_exclusive = false +window_title = "" +resizable = false +script = ExtResource( 1 ) +_sections_unfolded = [ "Rect" ] + +[node name="VBoxContainer" type="VBoxContainer" parent="." index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +alignment = 0 +_sections_unfolded = [ "Anchor", "Margin" ] + +[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_right = 384.0 +margin_bottom = 16.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 0 +min_value = 0.0 +max_value = 100.0 +step = 1.0 +page = 0.0 +value = 0.0 +exp_edit = false +rounded = false +percent_visible = true + + diff --git a/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd b/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd new file mode 100644 index 0000000..c10d63a --- /dev/null +++ b/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd @@ -0,0 +1,168 @@ +tool +extends WindowDialog + +const Util = preload("../../util/util.gd") +const Logger = preload("../../util/logger.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 = $VBoxContainer/GridContainer/ResolutionDropdown +onready var _stretch_checkbox = $VBoxContainer/GridContainer/StretchCheckBox +onready var _anchor_control = $VBoxContainer/GridContainer/HBoxContainer/AnchorControl + +const _resolutions = HTerrainData.SUPPORTED_RESOLUTIONS + +var _anchor_buttons = [] +var _anchor_buttons_grid = {} +var _anchor_button_group = null +var _selected_anchor = ANCHOR_TOP_LEFT +var _logger = Logger.get_for(self) + +var _terrain = null + + +func set_terrain(terrain): + _terrain = terrain + + +static func _get_icon(name): + return load("res://addons/zylann.hterrain/tools/icons/icon_" + name + ".svg") + + +func _ready(): + if 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 = _anchor_control.get_child(i) + assert(child is Button) + child.toggle_mode = true + child.rect_min_size = child.rect_size + child.icon = null + child.connect("pressed", self, "_on_AnchorButton_pressed", [i, x, y]) + child.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].pressed = true + # The signal apparently doesn't trigger in this case + _on_AnchorButton_pressed(_selected_anchor, 0, 0) + + +func _notification(what): + 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, x0, y0): + _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 = _anchor_buttons_grid[k] + var icon = _get_icon(_anchor_icon_names[anchor]) + button.icon = icon + + +func _set_anchor_control_active(active): + for button in _anchor_buttons: + button.disabled = not active + + +func _on_ResolutionDropdown_item_selected(id): + pass + + +func _on_StretchCheckBox_toggled(button_pressed): + _set_anchor_control_active(not button_pressed) + + +func _on_ApplyButton_pressed(): + var stretch = _stretch_checkbox.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, p_stretch, p_anchor): + 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() + emit_signal("permanent_change_performed", "Resize terrain") diff --git a/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn b/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn new file mode 100644 index 0000000..9419792 --- /dev/null +++ b/addons/zylann.hterrain/tools/resize_dialog/resize_dialog.tscn @@ -0,0 +1,624 @@ +[gd_scene load_steps=6 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/resize_dialog/resize_dialog.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/icon_heightmap_unmask.svg" type="Texture" id=2] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_right.svg" type="Texture" id=3] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/icon_long_arrow_down.svg" type="Texture" id=4] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/icon_small_circle.svg" type="Texture" id=5] + +[node name="ResizeDialog" type="WindowDialog" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 130.0 +margin_top = 126.0 +margin_right = 430.0 +margin_bottom = 326.0 +rect_min_size = Vector2( 300, 200 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +popup_exclusive = false +window_title = "Resize terrain" +resizable = false +script = ExtResource( 1 ) + +[node name="VBoxContainer" type="VBoxContainer" parent="." index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +custom_constants/separation = 24 +alignment = 0 +_sections_unfolded = [ "Margin", "custom_constants" ] + +[node name="GridContainer" type="GridContainer" parent="VBoxContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_right = 284.0 +margin_bottom = 126.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +columns = 2 + +[node name="Label" type="Label" parent="VBoxContainer/GridContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 3.0 +margin_right = 68.0 +margin_bottom = 17.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "Resolution" +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="ResolutionDropdown" type="OptionButton" parent="VBoxContainer/GridContainer" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 72.0 +margin_right = 284.0 +margin_bottom = 20.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 3 +size_flags_vertical = 1 +toggle_mode = false +action_mode = 0 +enabled_focus_mode = 2 +shortcut = null +group = null +flat = false +align = 0 +items = [ ] +selected = -1 +_sections_unfolded = [ "Size Flags" ] + +[node name="Label3" type="Label" parent="VBoxContainer/GridContainer" index="2"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 29.0 +margin_right = 68.0 +margin_bottom = 43.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "Stretch" +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="StretchCheckBox" type="CheckBox" parent="VBoxContainer/GridContainer" index="3"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 72.0 +margin_top = 24.0 +margin_right = 284.0 +margin_bottom = 48.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = true +enabled_focus_mode = 2 +shortcut = null +group = null +flat = false +align = 0 + +[node name="Label2" type="Label" parent="VBoxContainer/GridContainer" index="4"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 82.0 +margin_right = 68.0 +margin_bottom = 96.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "Direction" +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer" index="5"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 72.0 +margin_top = 52.0 +margin_right = 284.0 +margin_bottom = 126.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +alignment = 0 + +[node name="AnchorControl" type="GridContainer" parent="VBoxContainer/GridContainer/HBoxContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_right = 92.0 +margin_bottom = 74.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +columns = 3 + +[node name="TopLeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_right = 28.0 +margin_bottom = 22.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="TopButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 32.0 +margin_right = 60.0 +margin_bottom = 22.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="TopRightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="2"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 64.0 +margin_right = 92.0 +margin_bottom = 22.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="LeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="3"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 26.0 +margin_right = 28.0 +margin_bottom = 48.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="CenterButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="4"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 32.0 +margin_top = 26.0 +margin_right = 60.0 +margin_bottom = 48.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="RightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="5"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 64.0 +margin_top = 26.0 +margin_right = 92.0 +margin_bottom = 48.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="ButtomLeftButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="6"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 52.0 +margin_right = 28.0 +margin_bottom = 74.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="ButtomButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="7"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 32.0 +margin_top = 52.0 +margin_right = 60.0 +margin_bottom = 74.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="BottomRightButton" type="Button" parent="VBoxContainer/GridContainer/HBoxContainer/AnchorControl" index="8"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 64.0 +margin_top = 52.0 +margin_right = 92.0 +margin_bottom = 74.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +icon = ExtResource( 2 ) +flat = false +align = 1 + +[node name="Reference" type="Control" parent="VBoxContainer/GridContainer/HBoxContainer" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 96.0 +margin_right = 196.0 +margin_bottom = 74.0 +rect_min_size = Vector2( 100, 0 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +_sections_unfolded = [ "Rect" ] + +[node name="XArrow" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference" index="0"] + +modulate = Color( 1, 0.292969, 0.292969, 1 ) +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 0.0 +margin_left = 9.0 +margin_bottom = 16.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +texture = ExtResource( 3 ) +stretch_mode = 0 +_sections_unfolded = [ "Visibility" ] + +[node name="ZArrow" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference" index="1"] + +modulate = Color( 0.292969, 0.602295, 1, 1 ) +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 1.0 +margin_top = 10.0 +margin_right = 16.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +texture = ExtResource( 4 ) +stretch_mode = 0 +_sections_unfolded = [ "Visibility" ] + +[node name="ZLabel" type="Label" parent="VBoxContainer/GridContainer/HBoxContainer/Reference" index="2"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 14.0 +margin_top = 54.0 +margin_right = 22.0 +margin_bottom = 68.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "Z" +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="XLabel" type="Label" parent="VBoxContainer/GridContainer/HBoxContainer/Reference" index="3"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 52.0 +margin_top = 14.0 +margin_right = 60.0 +margin_bottom = 28.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "X" +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="Origin" type="TextureRect" parent="VBoxContainer/GridContainer/HBoxContainer/Reference" index="4"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 3.0 +margin_top = 4.0 +margin_right = 11.0 +margin_bottom = 12.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +texture = ExtResource( 5 ) +stretch_mode = 0 +_sections_unfolded = [ "Anchor", "Margin" ] + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 150.0 +margin_right = 284.0 +margin_bottom = 170.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 1 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +custom_constants/separation = 16 +alignment = 1 +_sections_unfolded = [ "Rect", "custom_constants" ] + +[node name="ApplyButton" type="Button" parent="VBoxContainer/HBoxContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 51.0 +margin_right = 163.0 +margin_bottom = 20.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Apply (no undo)" +flat = false +align = 1 + +[node name="CancelButton" type="Button" parent="VBoxContainer/HBoxContainer" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 179.0 +margin_right = 233.0 +margin_bottom = 20.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Cancel" +flat = false +align = 1 + +[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/addons/zylann.hterrain/tools/terrain_preview.gd b/addons/zylann.hterrain/tools/terrain_preview.gd new file mode 100644 index 0000000..77e8f2c --- /dev/null +++ b/addons/zylann.hterrain/tools/terrain_preview.gd @@ -0,0 +1,143 @@ +tool +extends ViewportContainer + +const PREVIEW_MESH_LOD = 2 + +const HTerrainMesher = preload("../hterrain_mesher.gd") +const Util = preload("../util/util.gd") + +signal dragged(relative, button_mask) + +onready var _viewport = $Viewport +onready var _mesh_instance = $Viewport/MeshInstance +onready var _camera = $Viewport/Camera +onready var _light = $Viewport/DirectionalLight + +# Use the simplest shader +var _shader = load("res://addons/zylann.hterrain/shaders/simple4_lite.shader") +var _yaw := 0.0 +var _pitch := -PI / 6.0 +var _distance := 0.0 +var _default_distance := 0.0 +var _sea_outline : MeshInstance = null +var _sea_plane : MeshInstance = null +var _mesh_resolution := 0 + + +func _ready(): + if _sea_outline == null: + var mesh = Util.create_wirecube_mesh() + var mat2 = SpatialMaterial.new() + mat2.flags_unshaded = true + mat2.albedo_color = Color(0, 0.5, 1) + mesh.surface_set_material(0, mat2) + _sea_outline = MeshInstance.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 = SpatialMaterial.new() + mat2.flags_unshaded = true + mat2.albedo_color = Color(0, 0.5, 1, 0.5) + mat2.flags_transparent = true + mesh.material = mat2 + _sea_plane = MeshInstance.new() + _sea_plane.mesh = mesh + _sea_plane.hide() + _viewport.add_child(_sea_plane) + + +func setup(heights_texture: Texture, normals_texture: Texture): + 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 = _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_param("u_terrain_heightmap", heights_texture) + mat.set_shader_param("u_terrain_normalmap", normals_texture) + mat.set_shader_param("u_terrain_inverse_transform", Transform()) + mat.set_shader_param("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.translation = 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 = Transform() + trans.basis = Basis(Quat(Vector3(0, 1, 0), _yaw) * Quat(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 = _mesh_instance.mesh.surface_get_material(0) + assert(mat != null) + mat.set_shader_param("u_terrain_heightmap", null) + mat.set_shader_param("u_terrain_normalmap", null) + + +func _gui_input(event: InputEvent): + if Util.is_in_edited_scene(self): + return + + if event is InputEventMouseMotion: + if event.button_mask & BUTTON_MASK_MIDDLE: + var d = 0.01 * event.relative + _yaw -= d.x + _pitch -= d.y + _update_camera() + else: + var rel = 0.01 * event.relative + # Align dragging to view rotation + rel = -rel.rotated(-_yaw) + emit_signal("dragged", 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 == BUTTON_WHEEL_DOWN: + _distance = clamp(_distance * factor, min_distance, max_distance) + _update_camera() + + elif event.button_index == BUTTON_WHEEL_UP: + _distance = clamp(_distance / factor, min_distance, max_distance) + _update_camera() diff --git a/addons/zylann.hterrain/tools/terrain_preview.tscn b/addons/zylann.hterrain/tools/terrain_preview.tscn new file mode 100644 index 0000000..fdf4505 --- /dev/null +++ b/addons/zylann.hterrain/tools/terrain_preview.tscn @@ -0,0 +1,198 @@ +[gd_scene load_steps=5 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/terrain_preview.gd" type="Script" id=1] + +[sub_resource type="Environment" id=1] + +background_mode = 1 +background_sky_custom_fov = 0.0 +background_color = Color( 0.132813, 0.132813, 0.132813, 1 ) +background_energy = 1.0 +background_canvas_max_layer = 0 +ambient_light_color = Color( 0, 0, 0, 1 ) +ambient_light_energy = 1.0 +ambient_light_sky_contribution = 1.0 +fog_enabled = false +fog_color = Color( 0.5, 0.6, 0.7, 1 ) +fog_sun_color = Color( 1, 0.9, 0.7, 1 ) +fog_sun_amount = 0.0 +fog_depth_enabled = true +fog_depth_begin = 10.0 +fog_depth_curve = 1.0 +fog_transmit_enabled = false +fog_transmit_curve = 1.0 +fog_height_enabled = false +fog_height_min = 0.0 +fog_height_max = 100.0 +fog_height_curve = 1.0 +tonemap_mode = 0 +tonemap_exposure = 1.0 +tonemap_white = 1.0 +auto_exposure_enabled = false +auto_exposure_scale = 0.4 +auto_exposure_min_luma = 0.05 +auto_exposure_max_luma = 8.0 +auto_exposure_speed = 0.5 +ss_reflections_enabled = false +ss_reflections_max_steps = 64 +ss_reflections_fade_in = 0.15 +ss_reflections_fade_out = 2.0 +ss_reflections_depth_tolerance = 0.2 +ss_reflections_roughness = true +ssao_enabled = false +ssao_radius = 1.0 +ssao_intensity = 1.0 +ssao_radius2 = 0.0 +ssao_intensity2 = 1.0 +ssao_bias = 0.01 +ssao_light_affect = 0.0 +ssao_color = Color( 0, 0, 0, 1 ) +ssao_quality = 0 +ssao_blur = 3 +ssao_edge_sharpness = 4.0 +dof_blur_far_enabled = false +dof_blur_far_distance = 10.0 +dof_blur_far_transition = 5.0 +dof_blur_far_amount = 0.1 +dof_blur_far_quality = 1 +dof_blur_near_enabled = false +dof_blur_near_distance = 2.0 +dof_blur_near_transition = 1.0 +dof_blur_near_amount = 0.1 +dof_blur_near_quality = 1 +glow_enabled = false +glow_levels/1 = false +glow_levels/2 = false +glow_levels/3 = true +glow_levels/4 = false +glow_levels/5 = true +glow_levels/6 = false +glow_levels/7 = false +glow_intensity = 0.8 +glow_strength = 1.0 +glow_bloom = 0.0 +glow_blend_mode = 2 +glow_hdr_threshold = 1.0 +glow_hdr_scale = 2.0 +glow_bicubic_upscale = false +adjustment_enabled = false +adjustment_brightness = 1.0 +adjustment_contrast = 1.0 +adjustment_saturation = 1.0 +_sections_unfolded = [ "Background" ] + +[sub_resource type="World" id=2] + +environment = SubResource( 1 ) + +[sub_resource type="PlaneMesh" id=3] + +custom_aabb = AABB( 0, 0, 0, 0, 0, 0 ) +size = Vector2( 256, 256 ) +subdivide_width = 0 +subdivide_depth = 0 + +[node name="TerrainPreview" type="ViewportContainer" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +stretch = true +stretch_shrink = 1 +script = ExtResource( 1 ) + +[node name="Viewport" type="Viewport" parent="." index="0"] + +arvr = false +size = Vector2( 1024, 600 ) +own_world = false +world = SubResource( 2 ) +transparent_bg = false +msaa = 0 +hdr = false +disable_3d = false +usage = 2 +debug_draw = 0 +render_target_v_flip = false +render_target_clear_mode = 0 +render_target_update_mode = 3 +audio_listener_enable_2d = false +audio_listener_enable_3d = false +physics_object_picking = false +gui_disable_input = false +gui_snap_controls_to_pixels = true +shadow_atlas_size = 512 +shadow_atlas_quad_0 = 2 +shadow_atlas_quad_1 = 2 +shadow_atlas_quad_2 = 3 +shadow_atlas_quad_3 = 4 +_sections_unfolded = [ "Rendering", "Shadow Atlas" ] + +[node name="DirectionalLight" type="DirectionalLight" parent="Viewport" index="0"] + +transform = Transform( -0.901211, 0.315056, -0.297588, 0, 0.686666, 0.726973, 0.433381, 0.655156, -0.618831, 0, 0, 0 ) +layers = 1 +light_color = Color( 1, 1, 1, 1 ) +light_energy = 1.0 +light_indirect_energy = 1.0 +light_negative = false +light_specular = 0.5 +light_bake_mode = 1 +light_cull_mask = -1 +shadow_enabled = true +shadow_color = Color( 0.266667, 0.266667, 0.266667, 1 ) +shadow_bias = 0.5 +shadow_contact = 0.0 +shadow_reverse_cull_face = false +editor_only = false +directional_shadow_mode = 1 +directional_shadow_split_1 = 0.1 +directional_shadow_split_2 = 0.2 +directional_shadow_split_3 = 0.5 +directional_shadow_blend_splits = false +directional_shadow_normal_bias = 0.8 +directional_shadow_bias_split_scale = 0.25 +directional_shadow_depth_range = 0 +directional_shadow_max_distance = 1000.0 +_sections_unfolded = [ "Directional Shadow", "Light", "Shadow" ] + +[node name="MeshInstance" type="MeshInstance" parent="Viewport" index="1"] + +layers = 1 +material_override = null +cast_shadow = 1 +extra_cull_margin = 0.0 +use_in_baked_light = false +lod_min_distance = 0.0 +lod_min_hysteresis = 0.0 +lod_max_distance = 0.0 +lod_max_hysteresis = 0.0 +mesh = SubResource( 3 ) +skeleton = NodePath("..") +material/0 = null + +[node name="Camera" type="Camera" parent="Viewport" index="2"] + +transform = Transform( -1, 3.31486e-008, -8.08945e-008, 0, 0.925325, 0.379176, 8.74228e-008, 0.379176, -0.925325, -2.25312e-005, 145.456, -348.286 ) +keep_aspect = 1 +cull_mask = 1048575 +environment = null +h_offset = 0.0 +v_offset = 0.0 +doppler_tracking = 0 +projection = 0 +current = true +fov = 70.0 +size = 1.0 +near = 1.0 +far = 1000.0 +_sections_unfolded = [ "Transform" ] + + diff --git a/addons/zylann.hterrain/tools/texture_editor/display_alpha.shader b/addons/zylann.hterrain/tools/texture_editor/display_alpha.shader new file mode 100644 index 0000000..7ac1b98 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/display_alpha.shader @@ -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/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres b/addons/zylann.hterrain/tools/texture_editor/display_alpha_material.tres new file mode 100644 index 0000000..de32b59 --- /dev/null +++ b/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.shader" type="Shader" id=1] + +[resource] + +render_priority = 0 +shader = ExtResource( 1 ) + diff --git a/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.shader b/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.shader new file mode 100644 index 0000000..384e1a2 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/display_alpha_slice.shader @@ -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/addons/zylann.hterrain/tools/texture_editor/display_color.shader b/addons/zylann.hterrain/tools/texture_editor/display_color.shader new file mode 100644 index 0000000..dcc1fda --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/display_color.shader @@ -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/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres b/addons/zylann.hterrain/tools/texture_editor/display_color_material.tres new file mode 100644 index 0000000..793dab4 --- /dev/null +++ b/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.shader" type="Shader" id=1] + +[resource] +shader = ExtResource( 1 ) diff --git a/addons/zylann.hterrain/tools/texture_editor/display_color_slice.shader b/addons/zylann.hterrain/tools/texture_editor/display_color_slice.shader new file mode 100644 index 0000000..d2e4f5c --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/display_color_slice.shader @@ -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/addons/zylann.hterrain/tools/texture_editor/display_normal.shader b/addons/zylann.hterrain/tools/texture_editor/display_normal.shader new file mode 100644 index 0000000..665af53 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/display_normal.shader @@ -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/addons/zylann.hterrain/tools/texture_editor/flow_container.gd b/addons/zylann.hterrain/tools/texture_editor/flow_container.gd new file mode 100644 index 0000000..61e5dcd --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/flow_container.gd @@ -0,0 +1,40 @@ +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 := rect_size.x - SEPARATION + var pos := Vector2(SEPARATION, SEPARATION) + var line_height := 0 + + for i in get_child_count(): + var child = get_child(i) + if not child is Control: + continue + + 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 + + rect_min_size.y = pos.y + line_height + diff --git a/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd b/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd new file mode 100644 index 0000000..ecb678b --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd @@ -0,0 +1,55 @@ +tool +extends Control + +const EmptyTexture = preload("../../icons/empty.png") + +signal load_pressed +signal clear_pressed + + +onready var _label = $Label +onready var _texture_rect = $TextureRect + +onready var _buttons = [ + $LoadButton, + $ClearButton +] + +var _material : Material + + +func set_label(text: String): + _label.text = text + + +func set_texture(tex: Texture): + if tex == null: + _texture_rect.texture = EmptyTexture + _texture_rect.material = null + else: + _texture_rect.texture = tex + _texture_rect.material = _material + + +func set_texture_tooltip(msg: String): + _texture_rect.hint_tooltip = msg + + +func _on_LoadButton_pressed(): + emit_signal("load_pressed") + + +func _on_ClearButton_pressed(): + emit_signal("clear_pressed") + + +func set_material(mat: Material): + _material = mat + if _texture_rect.texture != EmptyTexture: + _texture_rect.material = _material + + +func set_enabled(enabled: bool): + for b in _buttons: + b.disabled = not enabled + diff --git a/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn b/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn new file mode 100644 index 0000000..ca0b84b --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn @@ -0,0 +1,40 @@ +[gd_scene load_steps=3 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/icons/empty.png" type="Texture" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.gd" type="Script" id=2] + +[node name="SourceFileItem" type="VBoxContainer"] +margin_right = 128.0 +margin_bottom = 194.0 +script = ExtResource( 2 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Label" type="Label" parent="."] +margin_right = 128.0 +margin_bottom = 14.0 +text = "Albedo" + +[node name="TextureRect" type="TextureRect" parent="."] +margin_top = 18.0 +margin_right = 128.0 +margin_bottom = 146.0 +rect_min_size = Vector2( 128, 128 ) +texture = ExtResource( 1 ) +expand = true +stretch_mode = 1 + +[node name="LoadButton" type="Button" parent="."] +margin_top = 150.0 +margin_right = 128.0 +margin_bottom = 170.0 +text = "Load..." + +[node name="ClearButton" type="Button" parent="."] +margin_top = 174.0 +margin_right = 128.0 +margin_bottom = 194.0 +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/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd new file mode 100644 index 0000000..94e036f --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd @@ -0,0 +1,491 @@ +tool +extends Control + +const HTerrainTextureSet = preload("../../../hterrain_texture_set.gd") +const EditorUtil = preload("../../util/editor_util.gd") +const Util = preload("../../../util/util.gd") + +const ColorShader = preload("../display_color.shader") +const ColorSliceShader = preload("../display_color_slice.shader") +const AlphaShader = preload("../display_alpha.shader") +const AlphaSliceShader = preload("../display_alpha_slice.shader") +const EmptyTexture = preload("../../icons/empty.png") + +signal import_selected + +onready var _slots_list = $VB/HS/VB/SlotsList +onready var _albedo_preview = $VB/HS/VB2/GC/AlbedoPreview +onready var _bump_preview = $VB/HS/VB2/GC/BumpPreview +onready var _normal_preview = $VB/HS/VB2/GC/NormalPreview +onready var _roughness_preview = $VB/HS/VB2/GC/RoughnessPreview +onready var _load_albedo_button = $VB/HS/VB2/GC/LoadAlbedo +onready var _load_normal_button = $VB/HS/VB2/GC/LoadNormal +onready var _clear_albedo_button = $VB/HS/VB2/GC/ClearAlbedo +onready var _clear_normal_button = $VB/HS/VB2/GC/ClearNormal +onready var _mode_selector = $VB/HS/VB2/GC2/ModeSelector +onready var _add_slot_button = $VB/HS/VB/HB/AddSlot +onready var _remove_slot_button = $VB/HS/VB/HB/RemoveSlot + +var _texture_set : HTerrainTextureSet +var _undo_redo : UndoRedo + +var _mode_confirmation_dialog : ConfirmationDialog +var _delete_slot_confirmation_dialog : ConfirmationDialog +var _load_texture_dialog : WindowDialog +var _load_texture_array_dialog : WindowDialog +var _load_texture_type := -1 + + +func _ready(): + if 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 = EditorUtil.create_open_texture_dialog() + d.connect("file_selected", self, "_on_LoadTextureDialog_file_selected") + _load_texture_dialog = d + parent.add_child(d) + + d = EditorUtil.create_open_texture_array_dialog() + d.connect("file_selected", self, "_on_LoadTextureArrayDialog_file_selected") + _load_texture_array_dialog = d + parent.add_child(d) + + d = ConfirmationDialog.new() + d.connect("confirmed", self, "_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 + parent.add_child(d) + + +func _notification(what: int): + if 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 is_visible_in_tree(): + set_texture_set(null) + + +func set_undo_redo(ur: UndoRedo): + _undo_redo = ur + + +func set_texture_set(texture_set: HTerrainTextureSet): + if _texture_set == texture_set: + return + + if _texture_set != null: + _texture_set.disconnect("changed", self, "_on_texture_set_changed") + + _texture_set = texture_set + + if _texture_set != null: + _texture_set.connect("changed", self, "_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(): + _albedo_preview.texture = EmptyTexture + _bump_preview.texture = EmptyTexture + _normal_preview.texture = EmptyTexture + _roughness_preview.texture = EmptyTexture + + _albedo_preview.hint_tooltip = _get_resource_path_or_empty(null) + _bump_preview.hint_tooltip = _get_resource_path_or_empty(null) + _normal_preview.hint_tooltip = _get_resource_path_or_empty(null) + _roughness_preview.hint_tooltip = _get_resource_path_or_empty(null) + + +func _select_slot(slot_index: int): + assert(slot_index >= 0) + assert(slot_index < _texture_set.get_slots_count()) + + 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 EmptyTexture + _bump_preview.texture = albedo_tex if albedo_tex != null else EmptyTexture + _normal_preview.texture = normal_tex if normal_tex != null else EmptyTexture + _roughness_preview.texture = normal_tex if normal_tex != null else EmptyTexture + + _albedo_preview.hint_tooltip = _get_resource_path_or_empty(albedo_tex) + _bump_preview.hint_tooltip = _get_resource_path_or_empty(albedo_tex) + _normal_preview.hint_tooltip = _get_resource_path_or_empty(normal_tex) + _roughness_preview.hint_tooltip = _get_resource_path_or_empty(normal_tex) + + _albedo_preview.material.shader = ColorShader + _bump_preview.material.shader = AlphaShader + _normal_preview.material.shader = ColorShader + _roughness_preview.material.shader = AlphaShader + + _albedo_preview.material.set_shader_param("u_texture_array", null) + _bump_preview.material.set_shader_param("u_texture_array", null) + _normal_preview.material.set_shader_param("u_texture_array", null) + _roughness_preview.material.set_shader_param("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 = EmptyTexture + _bump_preview.texture = EmptyTexture + _normal_preview.texture = EmptyTexture + _roughness_preview.texture = EmptyTexture + + _albedo_preview.hint_tooltip = _get_resource_path_or_empty(albedo_tex) + _bump_preview.hint_tooltip = _get_resource_path_or_empty(albedo_tex) + _normal_preview.hint_tooltip = _get_resource_path_or_empty(normal_tex) + _roughness_preview.hint_tooltip = _get_resource_path_or_empty(normal_tex) + + _albedo_preview.material.shader = ColorSliceShader + _bump_preview.material.shader = AlphaSliceShader + _normal_preview.material.shader = ColorSliceShader if normal_tex != null else ColorShader + _roughness_preview.material.shader = AlphaSliceShader if normal_tex != null else AlphaShader + + _albedo_preview.material.set_shader_param("u_texture_array", albedo_tex) + _bump_preview.material.set_shader_param("u_texture_array", albedo_tex) + _normal_preview.material.set_shader_param("u_texture_array", normal_tex) + _roughness_preview.material.set_shader_param("u_texture_array", normal_tex) + + _albedo_preview.material.set_shader_param("u_index", slot_index) + _bump_preview.material.set_shader_param("u_index", slot_index) + _normal_preview.material.set_shader_param("u_index", slot_index) + _roughness_preview.material.set_shader_param("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 "" + + +func _on_ImportButton_pressed(): + emit_signal("import_selected") + + +func _on_CloseButton_pressed(): + hide() + + +func _on_AddSlot_pressed(): + assert(_texture_set.get_mode() == HTerrainTextureSet.MODE_TEXTURES) + var slot_index = _texture_set.get_slots_count() + _undo_redo.create_action("HTerrainTextureSet: add slot") + _undo_redo.add_do_method(_texture_set, "insert_slot", -1) + _undo_redo.add_undo_method(_texture_set, "remove_slot", slot_index) + _undo_redo.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)) + + _undo_redo.create_action("HTerrainTextureSet: remove slot") + + _undo_redo.add_do_method(_texture_set, "remove_slot", slot_index) + + _undo_redo.add_undo_method(_texture_set, "insert_slot", 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: + _undo_redo.add_undo_method(_texture_set, "set_texture_null", slot_index, type) + else: + _undo_redo.add_undo_method(_texture_set, "set_texture", slot_index, type, texture) + + _undo_redo.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) + + _undo_redo.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: + _undo_redo.add_do_method(_texture_set, "set_texture_null", slot_index, type) + else: + _undo_redo.add_do_method(_texture_set, "set_texture", slot_index, type, texture) + _undo_redo.add_do_method(self, "_select_slot", 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: + _undo_redo.add_undo_method(_texture_set, "set_texture_null", slot_index, type) + else: + _undo_redo.add_undo_method(_texture_set, "set_texture", slot_index, type, prev_texture) + _undo_redo.add_undo_method(self, "_select_slot", slot_index) + + _undo_redo.commit_action() + + +func _set_texture_array_action(slot_index: int, texture_array: TextureArray, type: int): + var prev_texture_array = _texture_set.get_texture_array(type) + + _undo_redo.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: + _undo_redo.add_do_method(_texture_set, "set_texture_array_null", type) + else: + _undo_redo.add_do_method(_texture_set, "set_texture_array", type, texture_array) + _undo_redo.add_do_method(self, "_select_slot", 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: + _undo_redo.add_undo_method(_texture_set, "set_texture_array_null", type) + else: + _undo_redo.add_undo_method(_texture_set, "set_texture_array", type, prev_texture_array) + _undo_redo.add_undo_method(self, "_select_slot", slot_index) + + _undo_redo.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) + var slot_index : int = _slots_list.get_selected_items()[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 := _undo_redo + + if mode == HTerrainTextureSet.MODE_TEXTURES: + ur.create_action("HTerrainTextureSet: switch to TextureArrays") + ur.add_do_method(_texture_set, "set_mode", 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", 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", 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", 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", slot_index, type) + else: + ur.add_undo_method(texture_set, "set_texture", 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", type) + else: + ur.add_undo_method(texture_set, "set_texture_array", type, texture_array) + + +#func _on_ModeConfirmationDialog_cancelled(): +# print("Cancelled") +# _set_selected_id(_mode_selector, _texture_set.get_mode()) + diff --git a/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn new file mode 100644 index 0000000..992e309 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.tscn @@ -0,0 +1,271 @@ +[gd_scene load_steps=10 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_editor.gd" type="Script" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/display_alpha.shader" type="Shader" id=2] +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/display_color.shader" type="Shader" id=3] +[ext_resource path="res://addons/zylann.hterrain/tools/icons/empty.png" type="Texture" id=4] +[ext_resource path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" type="PackedScene" 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="WindowDialog"] +visible = true +margin_right = 652.0 +margin_bottom = 320.0 +rect_min_size = Vector2( 652, 320 ) +window_title = "TextureSet Editor" +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="VB" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HS" type="HSplitContainer" parent="VB"] +margin_right = 636.0 +margin_bottom = 266.0 +size_flags_vertical = 3 + +[node name="VB" type="VBoxContainer" parent="VB/HS"] +margin_right = 100.0 +margin_bottom = 266.0 + +[node name="SlotsList" type="ItemList" parent="VB/HS/VB"] +margin_right = 100.0 +margin_bottom = 242.0 +rect_min_size = Vector2( 100, 0 ) +size_flags_vertical = 3 + +[node name="HB" type="HBoxContainer" parent="VB/HS/VB"] +margin_top = 246.0 +margin_right = 100.0 +margin_bottom = 266.0 + +[node name="AddSlot" type="Button" parent="VB/HS/VB/HB"] +margin_right = 20.0 +margin_bottom = 20.0 +text = "+" + +[node name="Control" type="Control" parent="VB/HS/VB/HB"] +margin_left = 24.0 +margin_right = 79.0 +margin_bottom = 20.0 +size_flags_horizontal = 3 + +[node name="RemoveSlot" type="Button" parent="VB/HS/VB/HB"] +margin_left = 83.0 +margin_right = 100.0 +margin_bottom = 20.0 +text = "-" + +[node name="VB2" type="VBoxContainer" parent="VB/HS"] +margin_left = 112.0 +margin_right = 636.0 +margin_bottom = 266.0 + +[node name="GC" type="GridContainer" parent="VB/HS/VB2"] +margin_right = 524.0 +margin_bottom = 194.0 +columns = 4 + +[node name="AlbedoLabel" type="Label" parent="VB/HS/VB2/GC"] +margin_right = 128.0 +margin_bottom = 14.0 +text = "Albedo" + +[node name="AlbedoExtraLabel" type="Label" parent="VB/HS/VB2/GC"] +margin_left = 132.0 +margin_right = 260.0 +margin_bottom = 14.0 +text = "+ alpha bump" + +[node name="NormalLabel" type="Label" parent="VB/HS/VB2/GC"] +margin_left = 264.0 +margin_right = 392.0 +margin_bottom = 14.0 +text = "Normal" + +[node name="NormalExtraLabel" type="Label" parent="VB/HS/VB2/GC"] +margin_left = 396.0 +margin_right = 524.0 +margin_bottom = 14.0 +text = "+ alpha roughness" + +[node name="AlbedoPreview" type="TextureRect" parent="VB/HS/VB2/GC"] +material = SubResource( 1 ) +margin_top = 18.0 +margin_right = 128.0 +margin_bottom = 146.0 +rect_min_size = Vector2( 128, 128 ) +texture = ExtResource( 4 ) +expand = true +stretch_mode = 1 + +[node name="BumpPreview" type="TextureRect" parent="VB/HS/VB2/GC"] +material = SubResource( 2 ) +margin_left = 132.0 +margin_top = 18.0 +margin_right = 260.0 +margin_bottom = 146.0 +rect_min_size = Vector2( 128, 128 ) +texture = ExtResource( 4 ) +expand = true +stretch_mode = 1 + +[node name="NormalPreview" type="TextureRect" parent="VB/HS/VB2/GC"] +material = SubResource( 3 ) +margin_left = 264.0 +margin_top = 18.0 +margin_right = 392.0 +margin_bottom = 146.0 +rect_min_size = Vector2( 128, 128 ) +texture = ExtResource( 4 ) +expand = true +stretch_mode = 1 + +[node name="RoughnessPreview" type="TextureRect" parent="VB/HS/VB2/GC"] +material = SubResource( 4 ) +margin_left = 396.0 +margin_top = 18.0 +margin_right = 524.0 +margin_bottom = 146.0 +rect_min_size = Vector2( 128, 128 ) +texture = ExtResource( 4 ) +expand = true +stretch_mode = 1 + +[node name="LoadAlbedo" type="Button" parent="VB/HS/VB2/GC"] +margin_top = 150.0 +margin_right = 128.0 +margin_bottom = 170.0 +text = "Load..." + +[node name="Spacer" type="Control" parent="VB/HS/VB2/GC"] +margin_left = 132.0 +margin_top = 150.0 +margin_right = 260.0 +margin_bottom = 170.0 + +[node name="LoadNormal" type="Button" parent="VB/HS/VB2/GC"] +margin_left = 264.0 +margin_top = 150.0 +margin_right = 392.0 +margin_bottom = 170.0 +text = "Load..." + +[node name="Spacer2" type="Control" parent="VB/HS/VB2/GC"] +margin_left = 396.0 +margin_top = 150.0 +margin_right = 524.0 +margin_bottom = 170.0 + +[node name="ClearAlbedo" type="Button" parent="VB/HS/VB2/GC"] +margin_top = 174.0 +margin_right = 128.0 +margin_bottom = 194.0 +text = "Clear" + +[node name="Spacer3" type="Control" parent="VB/HS/VB2/GC"] +margin_left = 132.0 +margin_top = 174.0 +margin_right = 260.0 +margin_bottom = 194.0 + +[node name="ClearNormal" type="Button" parent="VB/HS/VB2/GC"] +margin_left = 264.0 +margin_top = 174.0 +margin_right = 392.0 +margin_bottom = 194.0 +text = "Clear" + +[node name="HSeparator" type="Control" parent="VB/HS/VB2"] +margin_top = 198.0 +margin_right = 524.0 +margin_bottom = 202.0 +rect_min_size = Vector2( 0, 4 ) + +[node name="GC2" type="HBoxContainer" parent="VB/HS/VB2"] +margin_top = 206.0 +margin_right = 524.0 +margin_bottom = 226.0 + +[node name="Label" type="Label" parent="VB/HS/VB2/GC2"] +margin_top = 3.0 +margin_right = 36.0 +margin_bottom = 17.0 +text = "Mode" + +[node name="ModeSelector" type="OptionButton" parent="VB/HS/VB2/GC2"] +margin_left = 40.0 +margin_right = 280.0 +margin_bottom = 20.0 +size_flags_horizontal = 3 +text = "Textures" + +[node name="Spacer" type="Control" parent="VB/HS/VB2/GC2"] +margin_left = 284.0 +margin_right = 524.0 +margin_bottom = 20.0 +size_flags_horizontal = 3 + +[node name="Spacer" type="Control" parent="VB"] +margin_top = 270.0 +margin_right = 636.0 +margin_bottom = 274.0 +rect_min_size = Vector2( 0, 4 ) + +[node name="HB" type="HBoxContainer" parent="VB"] +margin_top = 278.0 +margin_right = 636.0 +margin_bottom = 298.0 +custom_constants/separation = 16 +alignment = 1 + +[node name="ImportButton" type="Button" parent="VB/HB"] +margin_left = 252.0 +margin_right = 320.0 +margin_bottom = 20.0 +text = "Import..." + +[node name="CloseButton" type="Button" parent="VB/HB"] +margin_left = 336.0 +margin_right = 383.0 +margin_bottom = 20.0 +text = "Close" + +[node name="Spacer2" type="Control" parent="VB"] +margin_top = 302.0 +margin_right = 636.0 +margin_bottom = 304.0 +rect_min_size = Vector2( 0, 2 ) + +[node name="DialogFitter" parent="." instance=ExtResource( 5 )] +[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/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd new file mode 100644 index 0000000..9ed6704 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd @@ -0,0 +1,896 @@ +tool +extends Control + +const HTerrainTextureSet = preload("../../../hterrain_texture_set.gd") +const Logger = preload("../../../util/logger.gd") +const EditorUtil = preload("../../util/editor_util.gd") +const Errors = preload("../../../util/errors.gd") +const TextureSetEditor = preload("./texture_set_editor.gd") +const Result = preload("../../util/result.gd") +const Util = preload("../../../util/util.gd") +const StreamTextureImporter = preload("../../packed_textures/stream_texture_importer.gd") +const TextureLayeredImporter = preload("../../packed_textures/texture_layered_importer.gd") +const PackedTextureImporter = preload("../../packed_textures/packed_texture_importer.gd") +const PackedTextureArrayImporter = preload("../../packed_textures/packed_texture_array_importer.gd") + +const NormalMapPreviewShader = preload("../display_normal.shader") + +const COMPRESS_RAW = 0 +const COMPRESS_LOSSLESS = 1 +# Lossy is not available because the required functions are not exposed to GDScript, +# and is not implemented on TextureArrays +#const COMPRESS_LOSSY = 1 +const COMPRESS_VRAM = 2 +const COMPRESS_COUNT = 3 + +const _compress_names = ["Raw", "Lossless", "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 = $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. +const _WRITE_IMPORT_FILES = false + +onready var _import_mode_selector = $Import/GC/ImportModeSelector +onready var _compression_selector = $Import/GC/CompressionSelector +onready var _resolution_spinbox = $Import/GC/ResolutionSpinBox +onready var _mipmaps_checkbox = $Import/GC/MipmapsCheckbox +onready var _filter_checkbox = $Import/GC/FilterCheckBox +onready var _add_slot_button = $Import/HS/VB/HB/AddSlotButton +onready var _remove_slot_button = $Import/HS/VB/HB/RemoveSlotButton +onready var _import_directory_line_edit : LineEdit = $Import/HB2/ImportDirectoryLineEdit +onready var _normalmap_flip_checkbox = $Import/HS/VB2/HB/Normal/NormalMapFlipY + +var _texture_set : HTerrainTextureSet +var _undo_redo : UndoRedo +var _logger = 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 : WindowDialog +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 Slot: + var texture_paths = [] + var flip_normalmap_y := false + + func _init(): + for i in HTerrainTextureSet.SRC_TYPE_COUNT: + texture_paths.append("") + +var _slots_data = [] + +var _import_settings = { + "mipmaps": true, + "filter": true, + "compression": COMPRESS_VRAM, + "resolution": 512 +} + + +func _init(): + # Default data + _slots_data.clear() + for i in 4: + _slots_data.append(Slot.new()) + + +func _ready(): + if 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.connect("load_pressed", self, "_on_texture_load_pressed", [src_type]) + ed.connect("clear_pressed", self, "_on_texture_clear_pressed", [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 = NormalMapPreviewShader + _texture_editors[HTerrainTextureSet.SRC_TYPE_NORMAL].set_material(_normalmap_material) + + +func setup_dialogs(parent: Node): + var d = EditorUtil.create_open_image_dialog() + d.connect("file_selected", self, "_on_LoadTextureDialog_file_selected") + _load_texture_dialog = d + parent.add_child(d) + + d = AcceptDialog.new() + d.window_title = "Import error" + _error_popup = d + parent.add_child(_error_popup) + + d = AcceptDialog.new() + d.window_title = "Info" + _info_popup = d + parent.add_child(_info_popup) + + d = ConfirmationDialog.new() + d.connect("confirmed", self, "_on_delete_confirmation_popup_confirmed") + _delete_confirmation_popup = d + parent.add_child(_delete_confirmation_popup) + + d = EditorUtil.create_open_dir_dialog() + d.window_title = "Choose import directory" + d.connect("dir_selected", self, "_on_OpenDirDialog_dir_selected") + _open_dir_dialog = d + parent.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: UndoRedo): + _undo_redo = 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 := Slot.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.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.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 = Slot.new() + _slots_data[slot_index] = slot + + var slot = _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 doesnt 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 := File.new() + var err := f.open(fpath, File.READ) + if err != OK: + _logger.error("Could not load {0}: {1}".format([fpath, Errors.get_message(err)])) + return {} + + var json_text := f.get_as_text() + var json_result := JSON.parse(json_text) + if json_result.error != OK: + _logger.error("Failed to parse {0}: {1}".format([fpath, json_result.error_string])) + return {} + + return json_result.result + + +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.pressed = _import_settings.mipmaps + _filter_checkbox.pressed = _import_settings.filter + _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.pressed = slot.flip_normalmap_y + _normalmap_material.set_shader_param("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("") + return + + var 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("") + return + + var tex := ImageTexture.new() + tex.create_from_image(im, 0) + 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 : Slot = _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.left(i) + "{0}" + albedo_fname.right(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.plus_file(fname) + _set_source_image(fpath, type) + found = true + break + + if found: + break + + +static func _get_files_in_directory(dirpath: String, logger) -> Array: + var dir := Directory.new() + var err := dir.open(dirpath) + if err != OK: + logger.error("Could not open directory {0}: {1}" \ + .format([dirpath, Errors.get_message(err)])) + return [] + + err = dir.list_dir_begin(true, true) + if err != OK: + logger.error("Could not probe directory {0}: {1}" \ + .format([dirpath, 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(Slot.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.window_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(selected_item) + _update_ui_from_data() + + +func _on_FilterCheckBox_toggled(button_pressed: bool): + _set_import_property("filter", button_pressed) + + +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 : Slot = _slots_data[slot_index] + slot.flip_normalmap_y = button_pressed + _normalmap_material.set_shader_param("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 _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 + if _import_mode == HTerrainTextureSet.MODE_TEXTURES: + files_data_result = _generate_packed_textures_files_data(import_dir, prefix) + else: + files_data_result = _generate_save_packed_texture_arrays_files_data(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 + + var dir := Directory.new() + for fd in files_data: + var dir_path : String = fd.path.get_base_dir() + if not dir.dir_exists(dir_path): + _show_error("The directory {0} could not be found.".format([dir_path])) + return + + for fd in files_data: + var json := JSON.print(fd.data, "\t", true) + if json == "": + _show_error("A problem occurred while serializing data for {0}".format([fd.path])) + return + + var f := File.new() + var err := f.open(fd.path, File.WRITE) + if err != OK: + _show_error("Could not write file {0}: {1}".format([fd.path])) + return + + f.store_string(json) + f.close() + + if _WRITE_IMPORT_FILES: + var import_fpath = fd.path + ".import" + if not Util.write_import_file(fd.import_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...") + yield(get_tree(), "idle_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") + yield(get_tree(), "idle_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 := _undo_redo + + # Check imported textures + if _import_mode == HTerrainTextureSet.MODE_TEXTURES: + for fd in files_data: + var texture = load(fd.path) + if texture == null: + failed_resource_paths.append(fd.path) + continue + assert(texture is Texture) + fd["texture"] = texture + + else: + for fd in files_data: + var texture_array = load(fd.path) + if texture_array == null: + failed_resource_paths.append(fd.path) + assert(texture_array is TextureArray) + fd["texture_array"] = texture_array + + if len(failed_resource_paths) > 0: + var failed_list = PoolStringArray(failed_resource_paths).join("\n") + _show_error("Some resources failed to load:\n" + failed_list) + + else: + # 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") + + TextureSetEditor.backup_for_undo(_texture_set, ur) + + ur.add_do_method(_texture_set, "clear") + ur.add_do_method(_texture_set, "set_mode", _import_mode) + + for i in len(_slots_data): + ur.add_do_method(_texture_set, "insert_slot", -1) + for fd in files_data: + ur.add_do_method(_texture_set, "set_texture", fd.slot_index, fd.type, fd.texture) + + else: + ur.create_action("HTerrainTextureSet: import texture arrays") + + TextureSetEditor.backup_for_undo(_texture_set, ur) + + ur.add_do_method(_texture_set, "clear") + ur.add_do_method(_texture_set, "set_mode", _import_mode) + + for fd in files_data: + ur.add_do_method(_texture_set, "set_texture_array", fd.type, fd.texture_array) + + ur.commit_action() + + _logger.debug("Done importing") + + _info_popup.dialog_text = "Importing complete!" + _info_popup.popup_centered() + + emit_signal("import_finished") + + +func _generate_packed_textures_files_data(import_dir: String, prefix: String) -> Result: + var files := [] + + var importer_compress_mode := 0 + match _import_settings.compression: + COMPRESS_VRAM: + importer_compress_mode = StreamTextureImporter.COMPRESS_VIDEO_RAM + COMPRESS_LOSSLESS: + importer_compress_mode = StreamTextureImporter.COMPRESS_LOSSLESS + COMPRESS_RAW: + importer_compress_mode = StreamTextureImporter.COMPRESS_RAW + _: + return Result.new(false, "Unknown compress mode {0}, might be a bug" \ + .format([_import_settings.compression])) + + 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 : Slot = _slots_data[slot_index] + + var src0 : String = slot.texture_paths[src_types[0]] + var src1 : String = slot.texture_paths[src_types[1]] + + if src0 == "": + if src_types[0] == HTerrainTextureSet.SRC_TYPE_ALBEDO: + return Result.new(false, + "Albedo texture is missing in slot {0}".format([slot_index])) + + 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 json_data := { + "contains_albedo": type == HTerrainTextureSet.TYPE_ALBEDO_BUMP, + "resolution": _import_settings.resolution, + "src": { + "rgb": src0, + "a": src1 + } + } + + if HTerrainTextureSet.SRC_TYPE_NORMAL in src_types and slot.flip_normalmap_y: + json_data.src["normalmap_flip_y"] = true + + var type_name := HTerrainTextureSet.get_texture_type_name(type) + var fpath = import_dir.plus_file( + str(prefix, "slot", slot_index, "_", type_name, ".packed_tex")) + + files.append({ + "slot_index": slot_index, + "type": type, + "path": fpath, + "data": json_data, + + # This is for .import files + "import_data": { + "remap": { + "importer": PackedTextureImporter.IMPORTER_NAME, + "type": PackedTextureImporter.RESOURCE_TYPE + }, + "deps": { + "source_file": fpath + }, + "params": { + "compress/mode": importer_compress_mode, + "flags/mipmaps": _import_settings.mipmaps, + "flags/filter": _import_settings.filter, + "flags/repeat": StreamTextureImporter.REPEAT_ENABLED + } + } + }) + + return Result.new(true).with_value(files) + + +func _generate_save_packed_texture_arrays_files_data(import_dir: String, prefix: String) -> Result: + var files := [] + + var importer_compress_mode := 0 + match _import_settings.compression: + COMPRESS_VRAM: + importer_compress_mode = TextureLayeredImporter.COMPRESS_VIDEO_RAM + COMPRESS_LOSSLESS: + importer_compress_mode = TextureLayeredImporter.COMPRESS_LOSSLESS + COMPRESS_RAW: + importer_compress_mode = TextureLayeredImporter.COMPRESS_RAW + _: + return Result.new(false, "Unknown compress mode {0}, might be a bug" \ + .format([_import_settings.compression])) + + for type in HTerrainTextureSet.TYPE_COUNT: + var src_types := HTerrainTextureSet.get_src_types_from_type(type) + + var json_data := { + "contains_albedo": type == HTerrainTextureSet.TYPE_ALBEDO_BUMP, + "resolution": _import_settings.resolution, + } + var layers_data := [] + + var fully_defaulted_slots := 0 + + for slot_index in len(_slots_data): + var slot : Slot = _slots_data[slot_index] + + var src0 : String = slot.texture_paths[src_types[0]] + var src1 : String = slot.texture_paths[src_types[1]] + + if src0 == "": + if src_types[0] == HTerrainTextureSet.SRC_TYPE_ALBEDO: + return Result.new(false, + "Albedo texture is missing in slot {0}".format([slot_index])) + + if src0 == "" and src1 == "": + fully_defaulted_slots += 1 + + 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 layer = { + "rgb": src0, + "a": src1 + } + + if HTerrainTextureSet.SRC_TYPE_NORMAL in src_types and slot.flip_normalmap_y: + layer["normalmap_flip_y"] = slot.flip_normalmap_y + + layers_data.append(layer) + + if fully_defaulted_slots == len(_slots_data): + # No need to generate this file at all + continue + + json_data["layers"] = layers_data + + var type_name := HTerrainTextureSet.get_texture_type_name(type) + var fpath := import_dir.plus_file(str(prefix, type_name, ".packed_texarr")) + + files.append({ + "type": type, + "path": fpath, + "data": json_data, + + # This is for .import files + "import_data": { + "remap": { + "importer": PackedTextureArrayImporter.IMPORTER_NAME, + "type": PackedTextureArrayImporter.RESOURCE_TYPE + }, + "deps": { + "source_file": fpath + }, + "params": { + "compress/mode": importer_compress_mode, + "flags/mipmaps": _import_settings.mipmaps, + "flags/filter": _import_settings.filter, + "flags/repeat": TextureLayeredImporter.REPEAT_ENABLED + } + } + }) + + return Result.new(true).with_value(files) diff --git a/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn new file mode 100644 index 0000000..bffd270 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.tscn @@ -0,0 +1,290 @@ +[gd_scene load_steps=4 format=2] + +[ext_resource path="res://addons/zylann.hterrain/tools/util/dialog_fitter.tscn" type="PackedScene" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/source_file_item_editor.tscn" type="PackedScene" id=3] +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/set_editor/texture_set_import_editor.gd" type="Script" id=4] + +[node name="TextureSetImportEditor" type="WindowDialog"] +visible = true +margin_right = 652.0 +margin_bottom = 480.0 +rect_min_size = Vector2( 652, 480 ) +window_title = "Texture Set Import Tool" +script = ExtResource( 4 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Import" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_top = 8.0 +margin_right = -8.0 +margin_bottom = -8.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HS" type="HSplitContainer" parent="Import"] +margin_right = 636.0 +margin_bottom = 316.0 +size_flags_vertical = 3 + +[node name="VB" type="VBoxContainer" parent="Import/HS"] +margin_right = 100.0 +margin_bottom = 316.0 +size_flags_vertical = 3 + +[node name="Label" type="Label" parent="Import/HS/VB"] +visible = false +margin_right = 100.0 +margin_bottom = 14.0 +text = "Slots" + +[node name="SlotsList" type="ItemList" parent="Import/HS/VB"] +margin_right = 100.0 +margin_bottom = 292.0 +rect_min_size = Vector2( 100, 0 ) +size_flags_vertical = 3 +items = [ "Item 0", null, false, "Item 1", null, false, "Item 2", null, false, "Item 3", null, false, "Item 4", null, false, "Item 5", null, false, "Item 6", null, false ] + +[node name="HB" type="HBoxContainer" parent="Import/HS/VB"] +margin_top = 296.0 +margin_right = 100.0 +margin_bottom = 316.0 + +[node name="AddSlotButton" type="Button" parent="Import/HS/VB/HB"] +margin_right = 20.0 +margin_bottom = 20.0 +text = "+" + +[node name="Control" type="Control" parent="Import/HS/VB/HB"] +margin_left = 24.0 +margin_right = 79.0 +margin_bottom = 20.0 +size_flags_horizontal = 3 + +[node name="RemoveSlotButton" type="Button" parent="Import/HS/VB/HB"] +margin_left = 83.0 +margin_right = 100.0 +margin_bottom = 20.0 +text = "-" + +[node name="VB2" type="VBoxContainer" parent="Import/HS"] +margin_left = 112.0 +margin_right = 636.0 +margin_bottom = 316.0 + +[node name="Label" type="Label" parent="Import/HS/VB2"] +visible = false +margin_right = 524.0 +margin_bottom = 14.0 + +[node name="HB" type="HBoxContainer" parent="Import/HS/VB2"] +margin_right = 524.0 +margin_bottom = 222.0 + +[node name="Albedo" parent="Import/HS/VB2/HB" instance=ExtResource( 3 )] +margin_bottom = 222.0 + +[node name="Bump" parent="Import/HS/VB2/HB" instance=ExtResource( 3 )] +margin_left = 132.0 +margin_right = 260.0 +margin_bottom = 222.0 + +[node name="Normal" parent="Import/HS/VB2/HB" instance=ExtResource( 3 )] +margin_left = 264.0 +margin_right = 392.0 +margin_bottom = 222.0 + +[node name="NormalMapFlipY" type="CheckBox" parent="Import/HS/VB2/HB/Normal"] +margin_top = 198.0 +margin_right = 128.0 +margin_bottom = 222.0 +text = "Flip Y" + +[node name="Roughness" parent="Import/HS/VB2/HB" instance=ExtResource( 3 )] +margin_left = 396.0 +margin_right = 524.0 +margin_bottom = 222.0 + +[node name="Control" type="Control" parent="Import/HS/VB2"] +margin_top = 226.0 +margin_right = 524.0 +margin_bottom = 230.0 +rect_min_size = Vector2( 0, 4 ) + +[node name="Control2" type="Control" parent="Import/HS/VB2"] +margin_top = 234.0 +margin_right = 524.0 +margin_bottom = 264.0 +rect_min_size = Vector2( 0, 4 ) +size_flags_vertical = 3 + +[node name="Label3" type="Label" parent="Import/HS/VB2"] +modulate = Color( 0.564706, 0.564706, 0.564706, 1 ) +margin_top = 268.0 +margin_right = 524.0 +margin_bottom = 316.0 +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 = true + +[node name="Spacer3" type="Control" parent="Import"] +margin_top = 320.0 +margin_right = 636.0 +margin_bottom = 328.0 +rect_min_size = Vector2( 0, 8 ) + +[node name="HSeparator" type="HSeparator" parent="Import"] +margin_top = 332.0 +margin_right = 636.0 +margin_bottom = 336.0 + +[node name="GC" type="GridContainer" parent="Import"] +margin_top = 340.0 +margin_right = 636.0 +margin_bottom = 388.0 +custom_constants/hseparation = 8 +columns = 4 + +[node name="Label2" type="Label" parent="Import/GC"] +margin_top = 3.0 +margin_right = 93.0 +margin_bottom = 17.0 +text = "Import mode: " + +[node name="ImportModeSelector" type="OptionButton" parent="Import/GC"] +margin_left = 101.0 +margin_right = 360.0 +margin_bottom = 20.0 +size_flags_horizontal = 3 + +[node name="MipmapsCheckbox" type="CheckBox" parent="Import/GC"] +visible = false +margin_left = 325.0 +margin_right = 412.0 +margin_bottom = 24.0 +text = "Mipmaps" + +[node name="Spacer2" type="Control" parent="Import/GC"] +margin_left = 368.0 +margin_right = 627.0 +margin_bottom = 20.0 +size_flags_horizontal = 3 + +[node name="Label" type="Label" parent="Import/GC"] +visible = false +margin_left = 547.0 +margin_top = 3.0 +margin_right = 635.0 +margin_bottom = 17.0 +text = "Compression:" + +[node name="CompressionSelector" type="OptionButton" parent="Import/GC"] +visible = false +margin_top = 24.0 +margin_right = 174.0 +margin_bottom = 48.0 +size_flags_horizontal = 3 + +[node name="FilterCheckBox" type="CheckBox" parent="Import/GC"] +visible = false +margin_left = 182.0 +margin_top = 24.0 +margin_right = 356.0 +margin_bottom = 48.0 +text = "Filter" + +[node name="Spacer" type="Control" parent="Import/GC"] +margin_left = 635.0 +margin_right = 635.0 +margin_bottom = 20.0 + +[node name="Label3" type="Label" parent="Import/GC"] +margin_top = 29.0 +margin_right = 93.0 +margin_bottom = 43.0 +text = "Resolution:" + +[node name="ResolutionSpinBox" type="SpinBox" parent="Import/GC"] +margin_left = 101.0 +margin_top = 24.0 +margin_right = 360.0 +margin_bottom = 48.0 +min_value = 1.0 +max_value = 4096.0 +value = 1.0 + +[node name="HB2" type="HBoxContainer" parent="Import"] +margin_top = 392.0 +margin_right = 636.0 +margin_bottom = 416.0 + +[node name="Label2" type="Label" parent="Import/HB2"] +margin_top = 5.0 +margin_right = 105.0 +margin_bottom = 19.0 +text = "Import directory" + +[node name="ImportDirectoryLineEdit" type="LineEdit" parent="Import/HB2"] +margin_left = 109.0 +margin_right = 608.0 +margin_bottom = 24.0 +hint_tooltip = "Files will be generated in this directory." +size_flags_horizontal = 3 + +[node name="BrowseImportDirectory" type="Button" parent="Import/HB2"] +margin_left = 612.0 +margin_right = 636.0 +margin_bottom = 24.0 +text = "..." + +[node name="Spacer" type="Control" parent="Import"] +margin_top = 420.0 +margin_right = 636.0 +margin_bottom = 428.0 +rect_min_size = Vector2( 0, 8 ) + +[node name="HB" type="HBoxContainer" parent="Import"] +margin_top = 432.0 +margin_right = 636.0 +margin_bottom = 452.0 +custom_constants/separation = 16 +alignment = 1 + +[node name="ImportButton" type="Button" parent="Import/HB"] +margin_left = 214.0 +margin_right = 359.0 +margin_bottom = 20.0 +text = "Import to TextureSet" + +[node name="CancelButton" type="Button" parent="Import/HB"] +margin_left = 375.0 +margin_right = 422.0 +margin_bottom = 20.0 +text = "Close" + +[node name="Spacer2" type="Control" parent="Import"] +margin_top = 456.0 +margin_right = 636.0 +margin_bottom = 464.0 +rect_min_size = Vector2( 0, 8 ) + +[node name="DialogFitter" parent="." instance=ExtResource( 1 )] +[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/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd b/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd new file mode 100644 index 0000000..be148a9 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/texture_editor.gd @@ -0,0 +1,126 @@ +tool +extends Control + +const HTerrain = preload("../../hterrain.gd") +const HTerrainTextureSet = preload("../../hterrain_texture_set.gd") +const TextureList = preload("./texture_list.gd") + +signal texture_selected(index) +signal edit_pressed(index) +signal import_pressed + +onready var _textures_list: TextureList = $TextureList +onready var _buttons_container = $HBoxContainer + +var _terrain : HTerrain = null +var _texture_set : HTerrainTextureSet = null + +var _texture_list_need_update := false +var _empty_icon = load("res://addons/zylann.hterrain/tools/icons/empty.png") + + +func _ready(): + # 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 + _textures_list.clear() + + +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.disconnect("changed", self, "_on_texture_set_changed") + + _texture_set = texture_set + + if _texture_set != null: + _texture_set.connect("changed", self, "_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): + emit_signal("texture_selected", index) + + +func _on_TextureList_item_activated(index: int): + emit_signal("edit_pressed", index) + + +func _on_EditButton_pressed(): + var selected_slot := _textures_list.get_selected_item() + if selected_slot == -1: + selected_slot = 0 + emit_signal("edit_pressed", selected_slot) + + +func _on_ImportButton_pressed(): + emit_signal("import_pressed") diff --git a/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn b/addons/zylann.hterrain/tools/texture_editor/texture_editor.tscn new file mode 100644 index 0000000..a716a55 --- /dev/null +++ b/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"] +margin_right = 352.0 +margin_bottom = 104.0 +rect_min_size = Vector2( 100, 0 ) +size_flags_horizontal = 3 +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="TextureList" parent="." instance=ExtResource( 2 )] +margin_bottom = -26.0 + +[node name="HBoxContainer" type="HBoxContainer" parent="."] +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_top = -24.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="EditButton" type="Button" parent="HBoxContainer"] +margin_right = 48.0 +margin_bottom = 24.0 +text = "Edit..." + +[node name="ImportButton" type="Button" parent="HBoxContainer"] +margin_left = 52.0 +margin_right = 120.0 +margin_bottom = 24.0 +text = "Import..." + +[node name="Label" type="Label" parent="HBoxContainer"] +margin_left = 124.0 +margin_top = 5.0 +margin_right = 179.0 +margin_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/addons/zylann.hterrain/tools/texture_editor/texture_list.gd b/addons/zylann.hterrain/tools/texture_editor/texture_list.gd new file mode 100644 index 0000000..70afe7a --- /dev/null +++ b/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 TextureListItemScene = preload("./texture_list_item.tscn") +const TextureListItem = preload("./texture_list_item.gd") + +signal item_selected(index) +signal item_activated(index) + +onready var _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: Resource, texture_layer: int = 0): + var item = TextureListItemScene.instance() + _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: Resource, layer: int = 0): + var child = _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: TextureListItem): + _selected_item = item.get_index() + for i in _container.get_child_count(): + var child = _container.get_child(i) + if child is TextureListItem and child != item: + child.set_selected(false, false) + emit_signal("item_selected", _selected_item) + + +func _on_item_activated(item: TextureListItem): + emit_signal("item_activated", 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/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn b/addons/zylann.hterrain/tools/texture_editor/texture_list.tscn new file mode 100644 index 0000000..bd09e96 --- /dev/null +++ b/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="."] +margin_right = 800.0 +margin_bottom = 82.0 +rect_min_size = Vector2( 0, 82 ) +size_flags_horizontal = 3 +script = ExtResource( 2 ) diff --git a/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd b/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd new file mode 100644 index 0000000..9c586ef --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd @@ -0,0 +1,70 @@ +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. + +onready var _texture_rect = $VB/TextureRect +onready var _label = $VB/Label + +const ColorMaterial = preload("./display_color_material.tres") +const ColorSliceShader = preload("./display_color_slice.shader") +const DummyTexture = preload("../icons/empty.png") + + +var _selected := false + + +func set_text(text: String): + _label.text = text + + +func set_texture(texture: Resource, texture_layer: int): + if texture is TextureArray: + var mat = _texture_rect.material + if mat == null or not (mat is ShaderMaterial): + mat = ShaderMaterial.new() + mat.shader = ColorSliceShader + _texture_rect.material = mat + mat.set_shader_param("u_texture_array", texture) + mat.set_shader_param("u_index", texture_layer) + _texture_rect.texture = DummyTexture + else: + _texture_rect.texture = texture + _texture_rect.material = ColorMaterial + + +func _gui_input(event: InputEvent): + if event is InputEventMouseButton: + if event.pressed: + if event.button_index == BUTTON_LEFT: + grab_focus() + set_selected(true, true) + if event.doubleclick: + # 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 + update() + 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_color("accent_color", "Editor") + else: + color = Color(0.0, 0.0, 0.0, 0.5) + # Draw background + draw_rect(Rect2(Vector2(), rect_size), color) diff --git a/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn b/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn new file mode 100644 index 0000000..4d451f8 --- /dev/null +++ b/addons/zylann.hterrain/tools/texture_editor/texture_list_item.tscn @@ -0,0 +1,55 @@ +[gd_scene load_steps=4 format=2] + +[ext_resource path="res://icon.png" type="Texture" id=1] +[ext_resource path="res://addons/zylann.hterrain/tools/texture_editor/texture_list_item.gd" type="Script" id=2] + +[sub_resource type="StyleBoxEmpty" id=1] +content_margin_left = 2.0 +content_margin_right = 2.0 +content_margin_top = 2.0 +content_margin_bottom = 2.0 + +[node name="TextureListItem" type="PanelContainer"] +margin_right = 64.0 +margin_bottom = 80.0 +rect_min_size = Vector2( 64, 80 ) +focus_mode = 1 +custom_styles/panel = SubResource( 1 ) +script = ExtResource( 2 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="VB" type="VBoxContainer" parent="."] +margin_left = 2.0 +margin_top = 2.0 +margin_right = 62.0 +margin_bottom = 78.0 +mouse_filter = 2 +custom_constants/separation = 0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="TextureRect" type="TextureRect" parent="VB"] +margin_right = 60.0 +margin_bottom = 62.0 +rect_min_size = Vector2( 60, 60 ) +mouse_filter = 2 +size_flags_vertical = 3 +texture = ExtResource( 1 ) +expand = true +stretch_mode = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Label" type="Label" parent="VB"] +margin_top = 62.0 +margin_right = 60.0 +margin_bottom = 76.0 +text = "Texture" +align = 1 +__meta__ = { +"_edit_use_anchors_": false +} diff --git a/addons/zylann.hterrain/tools/util/dialog_fitter.gd b/addons/zylann.hterrain/tools/util/dialog_fitter.gd new file mode 100644 index 0000000..501848c --- /dev/null +++ b/addons/zylann.hterrain/tools/util/dialog_fitter.gd @@ -0,0 +1,34 @@ + +# 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 Util = preload("../../util/util.gd") + + +func _notification(what: int): + if 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 : Control = get_parent() + for child in dialog.get_children(): + if child is Container: + var child_rect : Rect2 = child.get_global_rect() + var dialog_rect := dialog.get_global_rect() + #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.rect_size = child_rect.size + margin * 2.0 diff --git a/addons/zylann.hterrain/tools/util/dialog_fitter.tscn b/addons/zylann.hterrain/tools/util/dialog_fitter.tscn new file mode 100644 index 0000000..2e3b00c --- /dev/null +++ b/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/addons/zylann.hterrain/tools/util/editor_util.gd b/addons/zylann.hterrain/tools/util/editor_util.gd new file mode 100644 index 0000000..72d4f6b --- /dev/null +++ b/addons/zylann.hterrain/tools/util/editor_util.gd @@ -0,0 +1,108 @@ + +# Editor-specific utilities. +# This script cannot be loaded in an exported game. + +tool + +# TODO There is no script API to access editor scale +# Ported from https://github.com/godotengine/godot/blob/ +# 5fede4a81c67961c6fb2309b9b0ceb753d143566/editor/editor_node.cpp#L5515-L5554 +static func get_dpi_scale(editor_settings: EditorSettings) -> float: + var display_scale = editor_settings.get("interface/editor/display_scale") + var custom_display_scale = editor_settings.get("interface/editor/custom_display_scale") + var edscale := 0.0 + + match display_scale: + 0: + # Try applying a suitable display scale automatically + var screen = OS.current_screen + var large = OS.get_screen_dpi(screen) >= 192 and OS.get_screen_size(screen).x > 2000 + edscale = 2.0 if large else 1.0 + 1: + edscale = 0.75 + 2: + edscale = 1.0 + 3: + edscale = 1.25 + 4: + edscale = 1.5 + 5: + edscale = 1.75 + 6: + edscale = 2.0 + _: + edscale = custom_display_scale + + return edscale + + +# 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.editor_hint: + d = EditorFileDialog.new() + d.mode = EditorFileDialog.MODE_OPEN_FILE + d.access = EditorFileDialog.ACCESS_RESOURCES + else: + # Duh. I need to be able to test it. + d = FileDialog.new() + d.mode = FileDialog.MODE_OPEN_FILE + d.access = FileDialog.ACCESS_RESOURCES + d.resizable = true + return d + + +static func create_open_dir_dialog() -> ConfirmationDialog: + var d + if Engine.editor_hint: + d = EditorFileDialog.new() + d.mode = EditorFileDialog.MODE_OPEN_DIR + d.access = EditorFileDialog.ACCESS_RESOURCES + else: + # Duh. I need to be able to test it. + d = FileDialog.new() + d.mode = FileDialog.MODE_OPEN_DIR + d.access = FileDialog.ACCESS_RESOURCES + d.resizable = true + 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) + file_dialog.add_filter("*.stex ; StreamTexture files") + file_dialog.add_filter("*.packed_tex ; HTerrainPackedTexture files") + + +static func _add_texture_array_filters(file_dialog): + _add_image_filters(file_dialog) + file_dialog.add_filter("*.texarr ; TextureArray files") + file_dialog.add_filter("*.packed_texarr ; HTerrainPackedTextureArray files") diff --git a/addons/zylann.hterrain/tools/util/interval_slider.gd b/addons/zylann.hterrain/tools/util/interval_slider.gd new file mode 100644 index 0000000..5e77cfe --- /dev/null +++ b/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_REAL, + "usage": PROPERTY_USAGE_EDITOR + }, + { + "name": "max_value", + "type": TYPE_REAL, + "usage": PROPERTY_USAGE_EDITOR + }, + { + "name": "range", + "type": TYPE_VECTOR2, + "usage": PROPERTY_USAGE_STORAGE + } + ] + + +func _get(key: String): + match key: + "min_value": + return _min_value + "max_value": + return _max_value + "range": + return Vector2(_min_value, _max_value) + + +func _set(key: String, value): + match key: + "min_value": + _min_value = min(value, _max_value) + update() + "max_value": + _max_value = max(value, _min_value) + update() + "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 + update() + + +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 = clamp(v, min_value, max_value) + if v != _values[i]: + _values[i] = v + update() + if notify_change: + emit_signal("changed") + + +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 abs(_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 := abs(ratio - get_low_ratio()) + var distance_high := abs(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) / (rect_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): + if event is InputEventMouseButton: + if event.pressed: + if event.button_index == BUTTON_LEFT: + _grabbing = true + _set_from_pixel(event.position.x) + else: + if event.button_index == 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(), rect_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/addons/zylann.hterrain/tools/util/result.gd b/addons/zylann.hterrain/tools/util/result.gd new file mode 100644 index 0000000..c76a668 --- /dev/null +++ b/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/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd b/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd new file mode 100644 index 0000000..abf7ae1 --- /dev/null +++ b/addons/zylann.hterrain/tools/util/rich_text_label_hyperlinks.gd @@ -0,0 +1,11 @@ +tool +extends RichTextLabel + + +func _ready(): + connect("meta_clicked", self, "_on_meta_clicked") + + +func _on_meta_clicked(meta): + OS.shell_open(meta) + diff --git a/addons/zylann.hterrain/util/direct_mesh_instance.gd b/addons/zylann.hterrain/util/direct_mesh_instance.gd new file mode 100644 index 0000000..62395d8 --- /dev/null +++ b/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 = null +# Need to keep a reference so that the mesh RID doesn't get freed +var _mesh = null + + +func _init(): + var vs = VisualServer + _mesh_instance = vs.instance_create() + vs.instance_set_visible(_mesh_instance, true) + + +func _notification(p_what): + if p_what == NOTIFICATION_PREDELETE: + if _mesh_instance != RID(): + VisualServer.free_rid(_mesh_instance) + _mesh_instance = RID() + + +func enter_world(world): + assert(_mesh_instance != RID()) + VisualServer.instance_set_scenario(_mesh_instance, world.get_scenario()) + + +func exit_world(): + assert(_mesh_instance != RID()) + VisualServer.instance_set_scenario(_mesh_instance, RID()) + + +func set_world(world): + if world != null: + enter_world(world) + else: + exit_world() + + +func set_transform(world_transform): + assert(_mesh_instance != RID()) + VisualServer.instance_set_transform(_mesh_instance, world_transform) + + +func set_mesh(mesh): + assert(_mesh_instance != RID()) + VisualServer.instance_set_base(_mesh_instance, mesh.get_rid() if mesh != null else RID()) + _mesh = mesh + + +func set_material(material): + assert(_mesh_instance != RID()) + VisualServer.instance_geometry_set_material_override( \ + _mesh_instance, material.get_rid() if material != null else RID()) + + +func set_visible(visible): + assert(_mesh_instance != RID()) + VisualServer.instance_set_visible(_mesh_instance, visible) + + +func set_aabb(aabb): + assert(_mesh_instance != RID()) + VisualServer.instance_set_custom_aabb(_mesh_instance, aabb) + diff --git a/addons/zylann.hterrain/util/direct_multimesh_instance.gd b/addons/zylann.hterrain/util/direct_multimesh_instance.gd new file mode 100644 index 0000000..ab3dd62 --- /dev/null +++ b/addons/zylann.hterrain/util/direct_multimesh_instance.gd @@ -0,0 +1,40 @@ + +# Implementation of MultiMeshInstance which doesn't use the scene tree + +var _multimesh_instance := RID() + + +func _init(): + _multimesh_instance = VisualServer.instance_create() + + +func _notification(what: int): + if what == NOTIFICATION_PREDELETE: + VisualServer.free_rid(_multimesh_instance) + + +func set_world(world: World): + VisualServer.instance_set_scenario( + _multimesh_instance, world.get_scenario() if world != null else RID()) + + +func set_visible(visible: bool): + VisualServer.instance_set_visible(_multimesh_instance, visible) + + +func set_transform(trans: Transform): + VisualServer.instance_set_transform(_multimesh_instance, trans) + + +func set_multimesh(mm: MultiMesh): + VisualServer.instance_set_base(_multimesh_instance, mm.get_rid() if mm != null else RID()) + + +func set_material_override(material: Material): + VisualServer.instance_geometry_set_material_override( \ + _multimesh_instance, material.get_rid() if material != null else RID()) + + +func set_aabb(aabb: AABB): + VisualServer.instance_set_custom_aabb(_multimesh_instance, aabb) + diff --git a/addons/zylann.hterrain/util/errors.gd b/addons/zylann.hterrain/util/errors.gd new file mode 100644 index 0000000..344d4cd --- /dev/null +++ b/addons/zylann.hterrain/util/errors.gd @@ -0,0 +1,50 @@ +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_PARSE_ERROR: "Parse 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_ACQUIRE_RESOURCE: "Can't acquire resource error", + 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_BUSY: "Busy error", + ERR_HELP: "Help error", + ERR_BUG: "Bug error" +} + +static func get_message(err_code): + return str("[", err_code, "]: ", _names[err_code]) + diff --git a/addons/zylann.hterrain/util/grid.gd b/addons/zylann.hterrain/util/grid.gd new file mode 100644 index 0000000..41a168f --- /dev/null +++ b/addons/zylann.hterrain/util/grid.gd @@ -0,0 +1,202 @@ + +# 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, b): + 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, h, v=null): + var is_create_func = typeof(v) == TYPE_OBJECT and v is FuncRef + 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_func(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_OBJECT and delete_func is FuncRef) + var is_create_func = typeof(create_func) == TYPE_OBJECT and create_func is FuncRef + + # 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 range(0, row.size()): + var elem = row[x] + delete_func.call_func(elem) + grid.resize(new_height) + + # Delete old columns + if new_width < old_width: + for y in range(0, grid.size()): + var row = grid[y] + if delete_func != null: + for x in range(new_width, row.size()): + var elem = row[x] + delete_func.call_func(elem) + row.resize(new_width) + + # Create new columns + if new_width > old_width: + for y in range(0, grid.size()): + 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_func(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 range(0, new_width): + row[x] = create_func.call_func(x,y) + else: + for x in range(0, new_width): + row[x] = create_func + + # Debug test check + assert(grid.size() == new_height) + for y in range(0, grid.size()): + assert(grid[y].size() == 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 range(0, grid.size()): + var row = grid[y] + for x in range(0, row.size()): + 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 range(0, h): + var dst_row = dst[y] + var src_row = src_grid[y0+y] + for x in range(0, 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 range(0, a.size()): + var a_row = a[y] + var b_row = b[y] + if a_row.size() != b_row.size(): + return false + for x in range(0, 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/addons/zylann.hterrain/util/image_file_cache.gd b/addons/zylann.hterrain/util/image_file_cache.gd new file mode 100644 index 0000000..881a5bd --- /dev/null +++ b/addons/zylann.hterrain/util/image_file_cache.gd @@ -0,0 +1,252 @@ + +# 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 Logger = preload("./logger.gd") + +const CACHE_FILE_SIZE_THRESHOLD = 1048576 + +var _cache_dir := "" +var _next_id := 0 +var _session_id := "" +var _cache_image_info := {} +var _logger = 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)) + var dir := Directory.new() + if not dir.dir_exists(_cache_dir): + var err = dir.make_dir(_cache_dir) + if err != OK: + _logger.error("Could not create directory {0}, error {1}" \ + .format([_cache_dir, err])) + _save_thread_running = true + _saving_thread.start(self, "_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() + _saving_thread.wait_to_finish() + + +func _create_new_cache_file(fpath: String): + var f := File.new() + var err := f.open(fpath, File.WRITE) + if err != OK: + _logger.error("Failed to create new cache file {0}, error {1}".format([fpath, err])) + return + f.close() + + +func _get_current_cache_file_name() -> String: + return _cache_dir.plus_file(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() + + 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: File, im: Image): + f.store_8(im.get_format()) + f.store_32(im.get_width()) + f.store_32(im.get_height()) + var data := im.get_data() + f.store_32(len(data)) + f.store_buffer(data) + + +static func _read_image(f: File) -> 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.new() + im.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 = OS.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 OS.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 := File.new() + var err = f.open(fpath, File.READ) + if err != OK: + _logger.error("Could not load cached image from {0}, error {1}" \ + .format([fpath, err])) + return null + + f.seek(info.data_offset) + var im = _read_image(f) + f.close() + + assert(im != null) + return im + + +func clear(): + _logger.debug("Clearing image cache") + + var dir := Directory.new() + var err := dir.open(_cache_dir) + if err != OK: + _logger.error("Could not open image file cache directory '{0}'" \ + .format([_cache_dir])) + return + + err = dir.list_dir_begin(true, true) + 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}'" \ + .format([_cache_dir.plus_file(fpath)])) + + _cache_image_info.clear() + + +func _save_thread_func(_unused_userdata): + # Threads keep a reference to the function they run. + # So if it's 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 + 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: + _save_semaphore.wait() + continue + + var f := File.new() + 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: + path = item.path + if f.is_open(): + f.close() + var err := f.open(path, File.READ_WRITE) + if err != OK: + call_deferred("_on_error", "Could not open file {0}, error {1}" \ + .format([path, err])) + 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) + + +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/addons/zylann.hterrain/util/logger.gd b/addons/zylann.hterrain/util/logger.gd new file mode 100644 index 0000000..17bbd52 --- /dev/null +++ b/addons/zylann.hterrain/util/logger.gd @@ -0,0 +1,32 @@ + +class Base: + 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 Verbose extends Base: + func _init(p_context: String).(p_context): + pass + + func debug(msg: String): + print(_context, ": ", msg) + + +static func get_for(owner: Object) -> Base: + # Note: don't store the owner. If it's a Reference, it could create a cycle + var context = owner.get_script().resource_path.get_file() + if OS.is_stdout_verbose(): + return Verbose.new(context) + return Base.new(context) + diff --git a/addons/zylann.hterrain/util/quad_tree_lod.gd b/addons/zylann.hterrain/util/quad_tree_lod.gd new file mode 100644 index 0000000..00f5fd3 --- /dev/null +++ b/addons/zylann.hterrain/util/quad_tree_lod.gd @@ -0,0 +1,196 @@ +tool +# Independent quad tree designed to handle LOD + +class Quad: + var children = null + var origin_x := 0 + var origin_y := 0 + var data = null + + func _init(): + pass + + func clear(): + clear_children() + data = null + + func clear_children(): + children = null + + func has_children(): + return children != null + + +var _tree := Quad.new() +var _max_depth := 0 +var _base_size := 16 +var _split_scale := 2.0 + +var _make_func : FuncRef = null +var _recycle_func : FuncRef = null +var _vertical_bounds_func : FuncRef = null + + +func set_callbacks(make_cb: FuncRef, recycle_cb: FuncRef, vbounds_cb: FuncRef): + _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 = 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 + if p_split_scale < MIN: + p_split_scale = MIN + if p_split_scale > MAX: + p_split_scale = MAX + + _split_scale = float(p_split_scale) + + +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) + + +# TODO Should be renamed get_lod_factor +func get_lod_size(lod: int) -> int: + return 1 << lod + + +func _update(quad: Quad, lod: int, view_pos: Vector3): + # This function should be called regularly over frames. + + var lod_factor := get_lod_size(lod) + var chunk_size := _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 != null: + var vbounds = _vertical_bounds_func.call_func(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 := Quad.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 + if quad.has_children(): + for i in 4: + var child = quad.children[i] + _recycle_chunk(child.data, child.origin_x, child.origin_y, lod - 1) + quad.data = null + quad.clear_children() + + assert(quad.data == null) + quad.data = _make_chunk(lod, quad.origin_x, quad.origin_y) + + +func _join_all_recursively(quad: Quad, lod: int): + if quad.has_children(): + for i in range(4): + var child = quad.children[i] + _join_all_recursively(child, 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 != null: + chunk = _make_func.call_func(origin_x, origin_y, lod) + return chunk + + +func _recycle_chunk(chunk, origin_x: int, origin_y: int, lod: int): + if _recycle_func != null: + _recycle_func.call_func(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: Quad, lod_index: int, child_index: int): + if quad.has_children(): + for i in range(0, quad.children.size()): + var child = quad.children[i] + _debug_draw_tree_recursive(ci, child, lod_index - 1, i) + else: + var size := get_lod_size(lod_index) + var checker := 0 + if child_index == 1 or child_index == 2: + checker = 1 + var chunk_indicator := 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/addons/zylann.hterrain/util/util.gd b/addons/zylann.hterrain/util/util.gd new file mode 100644 index 0000000..684e167 --- /dev/null +++ b/addons/zylann.hterrain/util/util.gd @@ -0,0 +1,561 @@ +tool + +const 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 + + +# Godot doesn't expose Vector2i, and Vector2 has float limitations +#static func encode_v2i(x: int, y: int): +# return (x & 0xffff) | ((y << 16) & 0xffff0000) + + +# Godot doesn't expose Vector2i, and Vector2 has float limitations +#static func decode_v2i(k: int) -> Array: +# return [ +# k & 0xffff, +# (k >> 16) & 0xffff +# ] + + +# `min` turns numbers into float +static func min_int(a: int, b: int) -> int: + return a if a < b else b + + +# `max` turns numbers into float +static func max_int(a: int, b: int) -> int: + return a if a > b else b + + +# `clamp` turns numbers into float +static func clamp_int(x: int, a: int, b: int) -> int: + if x < a: + return a + if x > b: + return b + return x + + +# CubeMesh doesn't have a wireframe option +static func create_wirecube_mesh(color = Color(1,1,1)) -> Mesh: + var positions := PoolVector3Array([ + 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 := PoolColorArray([ + color, color, color, color, + color, color, color, color, + ]) + var indices := PoolIntArray([ + 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(round(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 node is 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 node is 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_a_parent_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.new() + im.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": Rect2(src_x, src_y, src_w, src_h), + "dst_pos": Vector2(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. +static func apply_dpi_scale(root: Control, 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 Viewport: + continue + if node is Control: + if node.rect_min_size != Vector2(0, 0): + node.rect_min_size *= dpi_scale + var parent = node.get_parent() + if parent != null: + if not (parent is Container): + node.margin_bottom *= dpi_scale + node.margin_left *= dpi_scale + node.margin_top *= dpi_scale + node.margin_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 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: FuncRef, max_distance: float) -> 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 / abs(ray_direction.x) + + var param_delta_y := infinite + if yi_step != 0: + param_delta_y = 1.0 / abs(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 = (ceil(ray_origin.x) - ray_origin.x) * param_delta_x + else: + param_cross_x = (ray_origin.x - floor(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 = (ceil(ray_origin.y) - ray_origin.y) * param_delta_y + else: + param_cross_y = (ray_origin.y - floor(ray_origin.y)) * param_delta_y + else: + # Will never cross on Y + param_cross_y = infinite + + var x := int(floor(ray_origin.x)) + var y := int(floor(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_func(prev_x, prev_y, prev_param, param): + var res := 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_func(prev_x, prev_y, prev_param, param): + var res := 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 = Geometry.segment_intersects_segment_2d(segment_begin, segment_end, a, b) + var cd = Geometry.segment_intersects_segment_2d(segment_begin, segment_end, c, d) + var ac = Geometry.segment_intersects_segment_2d(segment_begin, segment_end, a, c) + var bd = Geometry.segment_intersects_segment_2d(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: + if x < 0: + x = 0 + elif x >= im.get_width(): + x = im.get_width() - 1 + + if y < 0: + y = 0 + elif y >= im.get_height(): + y = im.get_height() - 1 + + return im.get_pixel(x, y) + + +static func update_configuration_warning(node: Node, recursive: bool): + if not Engine.editor_hint: + return + # Godot 3.1 and older doesn't have this function + if node.has_method("update_configuration_warning"): + node.call("update_configuration_warning") + 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 := File.new() + var err := f.open(imp_fpath, File.WRITE) + if err != OK: + logger.error("Could not open '{0}' for write, error {1}" \ + .format([imp_fpath, 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("") + + f.close() + return true