diff --git a/addons/sky_3d/LICENSE.txt b/addons/sky_3d/LICENSE.txt
new file mode 100644
index 0000000..8351979
--- /dev/null
+++ b/addons/sky_3d/LICENSE.txt
@@ -0,0 +1,26 @@
+MIT License
+
+Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+Copyright (c) 2021 J. Cuéllar
+
+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.
+
+----
+
+Third party assets have their own licenses found under assets/thirdparty.
diff --git a/addons/sky_3d/README.md b/addons/sky_3d/README.md
new file mode 100644
index 0000000..ff35c53
--- /dev/null
+++ b/addons/sky_3d/README.md
@@ -0,0 +1,3 @@
+# Sky3D
+
+See the README in the code repository at https://github.com/TokisanGames/Sky3D.
diff --git a/addons/sky_3d/ThirdParty.md b/addons/sky_3d/ThirdParty.md
new file mode 100644
index 0000000..82894de
--- /dev/null
+++ b/addons/sky_3d/ThirdParty.md
@@ -0,0 +1,8 @@
+## Attribution for Third Party Assets
+
+`Milkyway.jpg` "[The Milky Way panorama](https://www.eso.org/public/images/eso0932a/)" by ESO/S. Brunier, licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/
+).
+[Read more](/addons/sky_3d/assets/thirdparty/textures/milkyway/LICENSE.md)
+
+`MoonMap.png` Copyright (c) 2019 GPoSM, MIT License
+[Read more](/addons/sky_3d/assets/thirdparty/textures/moon/LICENSE.md)
diff --git a/addons/sky_3d/assets/resources/MoonRender.tscn b/addons/sky_3d/assets/resources/MoonRender.tscn
new file mode 100644
index 0000000..162fb37
--- /dev/null
+++ b/addons/sky_3d/assets/resources/MoonRender.tscn
@@ -0,0 +1,33 @@
+[gd_scene load_steps=5 format=3 uid="uid://hyy7u72h77"]
+
+[ext_resource type="Shader" path="res://addons/sky_3d/shaders/SimpleMoon.gdshader" id="1_h4nhh"]
+[ext_resource type="Texture2D" uid="uid://2r8ylu6rg5dp" path="res://addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png" id="2_fnh72"]
+
+
+
+[sub_resource type="ShaderMaterial" id="ShaderMaterial_5qbl8"]
+render_priority = 0
+shader = ExtResource("1_h4nhh")
+shader_parameter/_sun_direction = Vector3(-0.976421, -0.0982674, -0.192214)
+shader_parameter/_texture = ExtResource("2_fnh72")
+
+[sub_resource type="SphereMesh" id="2"]
+
+[node name="MoonRender" type="SubViewport"]
+own_world_3d = true
+transparent_bg = true
+msaa_3d = 3
+render_target_update_mode = 4
+
+[node name="MoonTransform" type="Node3D" parent="."]
+
+[node name="Camera3D" type="Camera3D" parent="MoonTransform"]
+transform = Transform3D(-1, 0, 3.25841e-07, 0, 1, 0, -3.25841e-07, 0, -1, 0, 0, 0)
+projection = 1
+size = 2.59
+
+[node name="Mesh" type="MeshInstance3D" parent="MoonTransform/Camera3D"]
+transform = Transform3D(8.74228e-08, -2, -7.78829e-07, -2, -8.74228e-08, 6.77626e-20, -3.40438e-14, 7.78829e-07, -2, -4.00785e-07, 0, -1.23)
+material_override = SubResource("ShaderMaterial_5qbl8")
+cast_shadow = 0
+mesh = SubResource("2")
diff --git a/addons/sky_3d/assets/resources/SNoise.tres b/addons/sky_3d/assets/resources/SNoise.tres
new file mode 100644
index 0000000..403196e
--- /dev/null
+++ b/addons/sky_3d/assets/resources/SNoise.tres
@@ -0,0 +1,7 @@
+[gd_resource type="NoiseTexture2D" load_steps=2 format=3 uid="uid://cfqk60lpl5ljv"]
+
+[sub_resource type="FastNoiseLite" id="1"]
+
+[resource]
+seamless = true
+noise = SubResource("1")
diff --git a/addons/sky_3d/assets/resources/SunMoonLightFade.tres b/addons/sky_3d/assets/resources/SunMoonLightFade.tres
new file mode 100644
index 0000000..1f9f2db
--- /dev/null
+++ b/addons/sky_3d/assets/resources/SunMoonLightFade.tres
@@ -0,0 +1,5 @@
+[gd_resource type="Curve" format=3 uid="uid://70482fhm3qg7"]
+
+[resource]
+_data = [Vector2(0, 0), 0.0, 0.0, 0, 0, Vector2(0.617886, 0), 0.0, 0.0467088, 1, 0, Vector2(0.699187, 1), 0.0, 0.0, 0, 0, Vector2(1, 1), 0.0, 0.0, 0, 0]
+point_count = 4
diff --git a/addons/sky_3d/assets/textures/SkyIcon.png b/addons/sky_3d/assets/textures/SkyIcon.png
new file mode 100644
index 0000000..21cf426
--- /dev/null
+++ b/addons/sky_3d/assets/textures/SkyIcon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:223f2de7d03b46ac3bfad4e6df489153a148cef6eecd94f8404eb9a8775a2367
+size 860
diff --git a/addons/sky_3d/assets/textures/SkyIcon.png.import b/addons/sky_3d/assets/textures/SkyIcon.png.import
new file mode 100644
index 0000000..5e9c96c
--- /dev/null
+++ b/addons/sky_3d/assets/textures/SkyIcon.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bwooawylkf7f2"
+path="res://.godot/imported/SkyIcon.png-7b3cbfa68354965cdbfdba8b95dacf67.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/sky_3d/assets/textures/SkyIcon.png"
+dest_files=["res://.godot/imported/SkyIcon.png-7b3cbfa68354965cdbfdba8b95dacf67.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/sky_3d/assets/textures/noise.jpg b/addons/sky_3d/assets/textures/noise.jpg
new file mode 100644
index 0000000..2a715f5
Binary files /dev/null and b/addons/sky_3d/assets/textures/noise.jpg differ
diff --git a/addons/sky_3d/assets/textures/noise.jpg.import b/addons/sky_3d/assets/textures/noise.jpg.import
new file mode 100644
index 0000000..0b2a782
--- /dev/null
+++ b/addons/sky_3d/assets/textures/noise.jpg.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://djpfuyxkryegn"
+path.s3tc="res://.godot/imported/noise.jpg-b47d5d36d9abfdaec2a09c140a78ba24.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/sky_3d/assets/textures/noise.jpg"
+dest_files=["res://.godot/imported/noise.jpg-b47d5d36d9abfdaec2a09c140a78ba24.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/addons/sky_3d/assets/textures/noise2.png b/addons/sky_3d/assets/textures/noise2.png
new file mode 100644
index 0000000..d655f31
--- /dev/null
+++ b/addons/sky_3d/assets/textures/noise2.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6a2479230d29ea9e7e7b447eaedb12175143fae01e2ced63751b2828f9665193
+size 145149
diff --git a/addons/sky_3d/assets/textures/noise2.png.import b/addons/sky_3d/assets/textures/noise2.png.import
new file mode 100644
index 0000000..cfdee0a
--- /dev/null
+++ b/addons/sky_3d/assets/textures/noise2.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bd7u40ws8s57f"
+path.s3tc="res://.godot/imported/noise2.png-37a5cbd5451b4b9c2c18ab32099e6965.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/sky_3d/assets/textures/noise2.png"
+dest_files=["res://.godot/imported/noise2.png-37a5cbd5451b4b9c2c18ab32099e6965.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/sky_3d/assets/textures/noiseClouds.png b/addons/sky_3d/assets/textures/noiseClouds.png
new file mode 100644
index 0000000..06bc4b7
--- /dev/null
+++ b/addons/sky_3d/assets/textures/noiseClouds.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c7e202bec424f46e3d6183886e621c4121e0a2d120370602016b8cf9fdb2136f
+size 125654
diff --git a/addons/sky_3d/assets/textures/noiseClouds.png.import b/addons/sky_3d/assets/textures/noiseClouds.png.import
new file mode 100644
index 0000000..75c7159
--- /dev/null
+++ b/addons/sky_3d/assets/textures/noiseClouds.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cecwdqjol0ckd"
+path.s3tc="res://.godot/imported/noiseClouds.png-07511094ef8b72a49cc860b35c60d0fd.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/sky_3d/assets/textures/noiseClouds.png"
+dest_files=["res://.godot/imported/noiseClouds.png-07511094ef8b72a49cc860b35c60d0fd.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/addons/sky_3d/assets/thirdparty/textures/milkyway/LICENSE.md b/addons/sky_3d/assets/thirdparty/textures/milkyway/LICENSE.md
new file mode 100644
index 0000000..740539e
--- /dev/null
+++ b/addons/sky_3d/assets/thirdparty/textures/milkyway/LICENSE.md
@@ -0,0 +1,40 @@
+# Attribution
+`Milkyway.jpg` "[The Milky Way panorama](https://www.eso.org/public/images/eso0932a/)" by ESO/S. Brunier, licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/
+).
+
+`StarField.jpg` is the same image modified to hide all but the brightest stars.
+
+Your game credits should include attribution similar to the above with a link to the license where reasonable.
+
+e.g. `The Milky Way panorama by ESO/S. Brunier, licensed under CC BY 4.0 and used in original and modified forms.`
+
+Higher resolutions and more information are available at: https://www.eso.org/public/images/eso0932a/
+
+Licensing questions answered here: https://www.eso.org/public/copyright/
+
+## License
+[Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/)
+
+
+## Relevant FAQs
+
+[From ESO](https://www.eso.org/public/copyright/)
+
+```
+Q: I am making an electronic game. Where should I place the credit?
+A: As long as the credit is clearly visible to all users and reproduced in full, for instance in a splashscreen, all is fine.
+```
+
+[From CC](https://wiki.creativecommons.org/wiki/Recommended_practices_for_attribution#This_is_a_great_attribution)
+
+```
+This is a great attribution
+"Creative Commons 10th Birthday Celebration San Francisco"(source link) by Timothy Vollmer is licensed under CC BY 4.0 (license link)
+
+Let’s go through TASL:
+Title? "Creative Commons 10th Birthday Celebration San Francisco"
+Author? "Timothy Vollmer" - linked to his profile page
+Source? "Creative Commons 10th Birthday Celebration San Francisco" - linked to original Flickr page
+License? "CC BY 4.0" - linked to license deed
+Most importantly, this attribution reasonably includes all the relevant information provided by the author.
+```
diff --git a/addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg b/addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg
new file mode 100644
index 0000000..7b8e7a9
Binary files /dev/null and b/addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg differ
diff --git a/addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg.import b/addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg.import
new file mode 100644
index 0000000..45c6d1d
--- /dev/null
+++ b/addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c1vwcdcdvb74a"
+path.s3tc="res://.godot/imported/Milkyway.jpg-a06044e407cf6953ab6e46a2f0f17f3a.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg"
+dest_files=["res://.godot/imported/Milkyway.jpg-a06044e407cf6953ab6e46a2f0f17f3a.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg b/addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg
new file mode 100644
index 0000000..a865944
Binary files /dev/null and b/addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg differ
diff --git a/addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg.import b/addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg.import
new file mode 100644
index 0000000..4053f5c
--- /dev/null
+++ b/addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bm7dot7t7u1q4"
+path.s3tc="res://.godot/imported/StarField.jpg-9d224e20fcbe8fdc7ff5028dae269839.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg"
+dest_files=["res://.godot/imported/StarField.jpg-9d224e20fcbe8fdc7ff5028dae269839.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/addons/sky_3d/assets/thirdparty/textures/moon/LICENSE.md b/addons/sky_3d/assets/thirdparty/textures/moon/LICENSE.md
new file mode 100644
index 0000000..daf0439
--- /dev/null
+++ b/addons/sky_3d/assets/thirdparty/textures/moon/LICENSE.md
@@ -0,0 +1,23 @@
+MIT License
+
+Copyright (c) 2019 GPoSM
+
+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.
+
+Source: https://www.highend3d.com/downloads/3d-textures/c/16k-earth-w-4k-moon-free#dialog_license
diff --git a/addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png b/addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png
new file mode 100644
index 0000000..8bd2fc4
--- /dev/null
+++ b/addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5eec6a4b631e9948f5c10be8b1d417bb8fca294b6321d2a993fc7198b62c384a
+size 6120738
diff --git a/addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png.import b/addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png.import
new file mode 100644
index 0000000..cff29f1
--- /dev/null
+++ b/addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://2r8ylu6rg5dp"
+path.s3tc="res://.godot/imported/MoonMap.png-81d542c3fafff4e5b235a177af6f3221.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png"
+dest_files=["res://.godot/imported/MoonMap.png-81d542c3fafff4e5b235a177af6f3221.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/addons/sky_3d/plugin.cfg b/addons/sky_3d/plugin.cfg
new file mode 100644
index 0000000..307189f
--- /dev/null
+++ b/addons/sky_3d/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Sky3D"
+description="Atmospheric Day/Night Cycle"
+author="J. Cuéllar, Cory Petkovsek, Contributors"
+version="2.0"
+script="src/Plugin.gd"
diff --git a/addons/sky_3d/shaders/AtmFog.gdshader b/addons/sky_3d/shaders/AtmFog.gdshader
new file mode 100644
index 0000000..0f82693
--- /dev/null
+++ b/addons/sky_3d/shaders/AtmFog.gdshader
@@ -0,0 +1,212 @@
+// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+// Copyright (c) 2021 J. Cuellar
+
+shader_type spatial;
+render_mode blend_mix, cull_disabled, unshaded;
+
+uniform vec2 _color_correction_params;
+
+uniform float _fog_density = .0001;
+uniform float _fog_rayleigh_depth = .115;
+uniform float _fog_mie_depth = 0.;
+uniform float _fog_falloff = 3.;
+uniform float _fog_start = 0.;
+uniform float _fog_end = 1000.;
+uniform vec3 _sun_direction = vec3(.25, -.25, .25);
+uniform vec3 _moon_direction = vec3(.45, -.25, .45);
+
+uniform float _atm_darkness = .5;
+uniform float _atm_sun_intensity = 30.;
+uniform vec4 _atm_day_tint: source_color = vec4(.78, .85, .98, 1.);
+uniform vec4 _atm_horizon_light_tint: source_color = vec4(.98, .73, .49, 1.);
+uniform vec4 _atm_night_tint: source_color = vec4(.16, .2, .25, 1.);
+uniform vec3 _atm_level_params = vec3(1., 0., 0.);
+uniform float _atm_thickness = .7;
+
+uniform vec3 _atm_beta_ray;
+uniform vec3 _atm_beta_mie;
+
+uniform vec3 _atm_sun_partial_mie_phase;
+uniform vec4 _atm_sun_mie_tint: source_color = vec4(1.);
+uniform float _atm_sun_mie_intensity = 1.;
+
+uniform vec3 _atm_moon_partial_mie_phase;
+uniform vec4 _atm_moon_mie_tint: source_color = vec4(.13, .18, .29, 1.);
+uniform float _atm_moon_mie_intensity = .7;
+
+uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;
+uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap;
+
+// Inc
+//------------------------------------------------------------------------------
+const float kPI = 3.1415927f;
+const float kINV_PI = 0.3183098f;
+const float kHALF_PI = 1.5707963f;
+const float kINV_HALF_PI = 0.6366198f;
+const float kQRT_PI = 0.7853982f;
+const float kINV_QRT_PI = 1.2732395f;
+const float kPI4 = 12.5663706f;
+const float kINV_PI4 = 0.0795775f;
+const float k3PI16 = 0.1193662f;
+const float kTAU = 6.2831853f;
+const float kINV_TAU = 0.1591549f;
+const float kE = 2.7182818f;
+
+float saturate(float value){
+ return clamp(value, 0.0, 1.0);
+}
+
+vec3 saturateRGB(vec3 value){
+ return clamp(value.rgb, 0.0, 1.0);
+}
+
+// pow3
+vec3 contrastLevel(vec3 vec, float level){
+ return mix(vec, vec * vec * vec, level);
+}
+
+vec3 tonemapPhoto(vec3 color, float exposure, float level){
+ color.rgb *= exposure;
+ return mix(color.rgb, 1.0 - exp(-color.rgb), level);
+}
+
+vec2 equirectUV(vec3 norm){
+ vec2 ret;
+ ret.x = (atan(norm.x, norm.z) + kPI) * kINV_TAU;
+ ret.y = acos(norm.y) * kINV_PI;
+ return ret;
+}
+
+// Atmosphere Inc
+//------------------------------------------------------------------------------
+const float RAYLEIGH_ZENITH_LENGTH = 8.4e3;
+const float MIE_ZENITH_LENGTH = 1.25e3;
+
+float rayleighPhase(float mu){
+ return k3PI16 * (1.0 + mu * mu);
+}
+
+float miePhase(float mu, vec3 partial){
+ return kPI4 * (partial.x) * (pow(partial.y - partial.z * mu, -1.5));
+}
+
+// Simplifield for more performance
+void simpleOpticalDepth(float y, out float sr, out float sm){
+ y = max(0.03, y + 0.03) + _atm_level_params.y;
+ y = 1.0 / (y * _atm_level_params.x);
+ sr = y * RAYLEIGH_ZENITH_LENGTH;
+ sm = y * MIE_ZENITH_LENGTH;
+}
+
+// Paper based
+void opticalDepth(float y, out float sr, out float sm){
+ y = max(0.0, y);
+ y = saturate(y * _atm_level_params.x);
+
+ float zenith = acos(y);
+ zenith = cos(zenith) + 0.15 * pow(93.885 - ((zenith * 180.0) / kPI), -1.253);
+ zenith = 1.0 / (zenith + _atm_level_params.y);
+
+ sr = zenith * RAYLEIGH_ZENITH_LENGTH;
+ sm = zenith * MIE_ZENITH_LENGTH;
+}
+
+vec3 atmosphericScattering(float sr, float sm, vec2 mu, vec3 mult, float depth){
+ vec3 betaMie = _atm_beta_mie;
+ vec3 betaRay = _atm_beta_ray * _atm_thickness;
+
+ vec3 extcFactor = saturateRGB(exp(-(betaRay * sr + betaMie * sm)));
+
+ float extcFF = mix(saturate(_atm_thickness * 0.5), 1.0, mult.x);
+ vec3 finalExtcFactor = mix(1.0 - extcFactor, (1.0 - extcFactor) * extcFactor, extcFF);
+
+ float rayleighPhase = rayleighPhase(mu.x);
+ vec3 BRT = betaRay * rayleighPhase * saturate(depth * _fog_rayleigh_depth);
+ vec3 BMT = betaMie * miePhase(mu.x, _atm_sun_partial_mie_phase);
+ BMT *= _atm_sun_mie_intensity * _atm_sun_mie_tint.rgb * saturate(depth * _fog_mie_depth);
+
+ vec3 BRMT = (BRT + BMT) / (betaRay + betaMie);
+ vec3 scatter = _atm_sun_intensity * (BRMT * finalExtcFactor) * _atm_day_tint.rgb * mult.y;
+ scatter = mix(scatter, scatter * (1.0 - extcFactor), _atm_darkness);
+
+ vec3 lcol = mix(_atm_day_tint.rgb, _atm_horizon_light_tint.rgb, mult.x);
+ vec3 nscatter = (1.0 - extcFactor) * _atm_night_tint.rgb * saturate(depth * _fog_rayleigh_depth);
+ nscatter += miePhase(mu.y, _atm_moon_partial_mie_phase) *
+ _atm_moon_mie_tint.rgb * _atm_moon_mie_intensity * 0.005 * saturate(depth * _fog_mie_depth);
+
+ return (scatter * lcol) + nscatter;
+}
+
+// Fog
+//------------------------------------------------------------------------------
+float fogExp(float depth, float density){
+ return 1.0 - saturate(exp2(-depth * density));
+}
+
+float fogFalloff(float y, float zeroLevel, float falloff){
+ return saturate(exp(-(y + zeroLevel) * falloff));
+}
+
+float fogDistance(float depth){
+ float d = depth;
+ d = (_fog_end - d) / (_fog_end - _fog_start);
+ return saturate(1.0 - d);
+}
+
+void computeCoords(vec2 uv, float depth, mat4 camMat, mat4 invProjMat,
+ out vec3 viewDir, out vec3 worldPos){
+
+ vec3 ndc = vec3(uv * 2.0 - 1.0, depth);
+
+ // ViewDir
+ vec4 view = invProjMat * vec4(ndc, 1.0);
+ viewDir = view.xyz / view.w;
+
+ // worldPos
+ view = camMat * view;
+ view.xyz /= view.w;
+ view.xyz -= (camMat * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
+ worldPos = view.xyz;
+}
+
+// Varyings
+//------------------------------------------------------------------------------
+varying mat4 camera_matrix;
+varying vec4 angle_mult;
+varying flat int angle;
+
+void vertex(){
+ POSITION = vec4(VERTEX.xy, 1.0, 1.0);
+ angle = 0; //ignore; without this the line below errors in 4.3-stable
+ angle_mult.x = saturate(1.0 - _sun_direction.y);
+ angle_mult.y = saturate(_sun_direction.y + 0.45);
+ angle_mult.z = saturate(-_sun_direction.y + 0.30);
+ angle_mult.w = saturate(-_sun_direction.y + 0.60);
+ camera_matrix = INV_VIEW_MATRIX;
+}
+
+void fragment(){
+ float depthRaw = texture(DEPTH_TEXTURE, SCREEN_UV).r;
+
+ vec3 view; vec3 worldPos;
+ computeCoords(SCREEN_UV, depthRaw, camera_matrix, INV_PROJECTION_MATRIX, view, worldPos);
+ worldPos = normalize(worldPos);
+
+ float linearDepth = -view.z;
+ float fogFactor = fogExp(linearDepth, _fog_density);
+ fogFactor *= fogFalloff(worldPos.y, 0.0, _fog_falloff);
+ fogFactor *= fogDistance(linearDepth);
+
+ vec2 mu = vec2(dot(_sun_direction, worldPos), dot(_moon_direction, worldPos));
+ float sr; float sm; simpleOpticalDepth(worldPos.y + _atm_level_params.z, sr, sm);
+ vec3 scatter = atmosphericScattering(sr, sm, mu.xy, angle_mult.xyz, linearDepth);
+
+ vec3 tint = scatter;
+ vec4 fogColor = vec4(tint.rgb, fogFactor);
+ fogColor = vec4((fogColor.rgb), saturate(fogColor.a));
+ fogColor.rgb = tonemapPhoto(fogColor.rgb, _color_correction_params.y, _color_correction_params.x);
+
+ ALBEDO = fogColor.rgb;
+ ALPHA = fogColor.a;
+ //ALPHA = (depthRaw) < 0.999999999999 ? fogColor.a: 0.0; // Exclude sky.
+}
\ No newline at end of file
diff --git a/addons/sky_3d/shaders/AtmFog.gdshader.uid b/addons/sky_3d/shaders/AtmFog.gdshader.uid
new file mode 100644
index 0000000..9266df4
--- /dev/null
+++ b/addons/sky_3d/shaders/AtmFog.gdshader.uid
@@ -0,0 +1 @@
+uid://bn8sv775a1kvs
diff --git a/addons/sky_3d/shaders/CloudsCumulus.gdshader b/addons/sky_3d/shaders/CloudsCumulus.gdshader
new file mode 100644
index 0000000..34d511a
--- /dev/null
+++ b/addons/sky_3d/shaders/CloudsCumulus.gdshader
@@ -0,0 +1,198 @@
+// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+// Copyright (c) 2021 J. Cuellar
+// This shader is based on DanilS clouds shader with MIT License
+// See https://github.com/danilw/godot-utils-and-other/tree/master/Dynamic%20sky%20and%20reflection
+
+shader_type spatial;
+render_mode unshaded, blend_mix, depth_draw_never, cull_front, skip_vertex_transform;
+
+uniform vec3 _sun_direction;
+uniform vec3 _moon_direction;
+uniform float _clouds_coverage;
+uniform float _clouds_thickness;
+uniform float _clouds_absorption;
+uniform float _clouds_noise_freq;
+uniform float _clouds_sky_tint_fade;
+uniform float _clouds_intensity;
+uniform float _clouds_size;
+uniform float _clouds_speed;
+uniform vec3 _clouds_direction;
+uniform sampler2D _clouds_texture;
+
+uniform vec4 _clouds_day_color: source_color;
+uniform vec4 _clouds_horizon_light_color: source_color;
+uniform vec4 _clouds_night_color: source_color;
+
+const int kCLOUDS_STEP = 10;
+
+uniform vec3 _clouds_partial_mie_phase;
+uniform float _clouds_mie_intensity;
+uniform vec4 _atm_sun_mie_tint;
+uniform vec4 _atm_moon_mie_tint: source_color;
+
+const float kPI = 3.1415927f;
+const float kINV_PI = 0.3183098f;
+const float kHALF_PI = 1.5707963f;
+const float kINV_HALF_PI = 0.6366198f;
+const float kQRT_PI = 0.7853982f;
+const float kINV_QRT_PI = 1.2732395f;
+const float kPI4 = 12.5663706f;
+const float kINV_PI4 = 0.0795775f;
+const float k3PI16 = 0.1193662f;
+const float kTAU = 6.2831853f;
+const float kINV_TAU = 0.1591549f;
+const float kE = 2.7182818f;
+
+float saturate(float value){
+ return clamp(value, 0.0, 1.0);
+}
+
+vec3 saturateRGB(vec3 value){
+ return clamp(value.rgb, 0.0, 1.0);
+}
+
+float pow3(float real){
+ return real * real * real;
+}
+
+float noiseClouds(vec3 p){
+ vec3 pos = vec3(p * 0.01);
+ pos.z *= 256.0;
+ vec2 offset = vec2(0.317, 0.123);
+ vec4 uv= vec4(0.0);
+ uv.xy = pos.xy + offset * floor(pos.z);
+ uv.zw = uv.xy + offset;
+ float x1 = textureLod(_clouds_texture, uv.xy, 0.0).r;
+ float x2 = textureLod(_clouds_texture, uv.zw, 0.0).r;
+ return mix(x1, x2, fract(pos.z));
+}
+
+float cloudsFBM(vec3 p, float l){
+ float ret;
+ ret = 0.51749673 * noiseClouds(p);
+ p *= l;
+ ret += 0.25584929 * noiseClouds(p);
+ p *= l;
+ ret += 0.12527603 * noiseClouds(p);
+ p *= l;
+ ret += 0.06255931 * noiseClouds(p);
+ return ret;
+}
+
+float noiseCloudsFBM(vec3 p, float freq){
+ return cloudsFBM(p, freq);
+}
+
+float remap(float value, float fromMin, float fromMax, float toMin, float toMax){
+ return toMin + (value - fromMin) * (toMax - toMin) / (fromMax - fromMin);
+}
+
+float cloudsDensity(vec3 p, vec3 offset, float t){
+ vec3 pos = p * 0.0212242 - offset;
+ float dens = noiseCloudsFBM(pos, _clouds_noise_freq);
+ dens += dens;
+
+ float cov = 1.0-_clouds_coverage;
+ cov = smoothstep(0.00, (cov * 3.5) + t, dens);
+ dens *= cov;
+ dens = remap(dens, 1.0-cov, 1.0, 0.0, 1.0);
+
+ return saturate(dens);
+}
+
+bool IntersectSphere(float r, vec3 origin, vec3 dir, out float t, out vec3 nrm)
+{
+ origin += vec3(0.0, 450.0, 0.0);
+ float a = dot(dir, dir);
+ float b = 2.0 * dot(origin, dir);
+ float c = dot(origin, origin) - r * r;
+ float d = b * b - 4.0 * a * c;
+ if(d < 0.0) return false;
+
+ d = sqrt(d);
+ a *= 2.0;
+ float t1 = 0.5 * (-b + d);
+ float t2 = 0.5 * (-b - d);
+
+ if(t1<0.0) t1 = t2;
+ if(t2 < 0.0) t2 = t1;
+ t1 = min(t1, t2);
+
+ if(t1 < 0.0) return false;
+ nrm = origin + t1 * dir;
+ t = t1;
+
+ return true;
+}
+
+float miePhase(float mu, vec3 partial){
+ return kPI4 * (partial.x) * (pow(partial.y - partial.z * mu, -1.5));
+}
+
+vec4 renderClouds2(vec3 ro, vec3 rd, float tm, float am){
+ vec4 ret = vec4(0, 0, 0, 0);
+ vec3 wind = _clouds_direction * (tm * _clouds_speed);
+ float a = 0.0;
+
+ // n and tt doesnt need to be initialized since it would be set by IntersectSphere
+ vec3 n; float tt;
+ if(IntersectSphere(500, ro, rd, tt, n))
+ {
+ float marchStep = float(kCLOUDS_STEP) * _clouds_thickness;
+ vec3 dirStep = rd / rd.y * marchStep;
+ vec3 pos = n * _clouds_size;
+
+ vec2 mu = vec2(dot(_sun_direction, rd), dot(_moon_direction, rd));
+ vec3 mph = ((miePhase(mu.x, _clouds_partial_mie_phase) * _atm_sun_mie_tint.rgb) +
+ miePhase(mu.y, _clouds_partial_mie_phase) * am);
+
+ vec4 t = vec4(1.0);
+ t.rgb += (mph.rgb * _clouds_mie_intensity);
+
+ for(int i = 0; i < kCLOUDS_STEP; i++)
+ {
+ float h = float(i) * 0.1; // / float(kCLOUDS_STEP);
+
+ float density = cloudsDensity(pos, wind, h);
+ float sh = saturate(exp(-_clouds_absorption * density * marchStep));
+ t *= sh;
+ ret += (t * (exp(h) * 0.571428571) * density * marchStep);
+ a += (1.0 - sh) * (1.0 - a);
+ pos += dirStep;
+ }
+ return vec4(ret.rgb * _clouds_intensity, a);
+ }
+ return vec4(ret.rgb * _clouds_intensity, a);
+}
+
+
+varying vec4 world_pos;
+varying vec4 moon_coords;
+varying vec3 deep_space_coords;
+varying vec4 angle_mult;
+
+void vertex(){
+ vec4 vert = vec4(VERTEX, 0.0);
+ POSITION = PROJECTION_MATRIX * MODELVIEW_MATRIX * vert;
+ POSITION.z = 0.0000001;
+
+ world_pos = (MODEL_MATRIX * vert);
+ angle_mult.x = saturate(1.0 - _sun_direction.y);
+ angle_mult.y = saturate(_sun_direction.y + 0.45);
+ angle_mult.z = saturate(-_sun_direction.y + 0.30);
+ angle_mult.w = saturate(-_sun_direction.y + 0.60);
+}
+
+void fragment(){
+ vec3 ray = normalize(world_pos).xyz;
+ float horizonBlend = saturate((ray.y+0.01) * 50.0);
+
+ vec4 clouds = renderClouds2(vec3(0.0, 0.0, 0.0), ray, TIME, angle_mult.z);
+ clouds.a = saturate(clouds.a);
+ clouds.rgb *= mix(mix(_clouds_day_color.rgb, _clouds_horizon_light_color.rgb, angle_mult.x),
+ _clouds_night_color.rgb, angle_mult.w);
+ clouds.a = mix(0.0, clouds.a, horizonBlend);
+
+ ALBEDO = clouds.rgb;
+ ALPHA = pow3(clouds.a);
+}
diff --git a/addons/sky_3d/shaders/CloudsCumulus.gdshader.uid b/addons/sky_3d/shaders/CloudsCumulus.gdshader.uid
new file mode 100644
index 0000000..90b883c
--- /dev/null
+++ b/addons/sky_3d/shaders/CloudsCumulus.gdshader.uid
@@ -0,0 +1 @@
+uid://brbic4veeaixo
diff --git a/addons/sky_3d/shaders/LICENSE b/addons/sky_3d/shaders/LICENSE
new file mode 100644
index 0000000..9405a8e
--- /dev/null
+++ b/addons/sky_3d/shaders/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 J. Cuéllar
+
+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/sky_3d/shaders/PerVertexSky.gdshader b/addons/sky_3d/shaders/PerVertexSky.gdshader
new file mode 100644
index 0000000..91e0d3a
--- /dev/null
+++ b/addons/sky_3d/shaders/PerVertexSky.gdshader
@@ -0,0 +1,299 @@
+// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+// Copyright (c) 2021 J. Cuellar
+
+shader_type spatial;
+render_mode unshaded, depth_draw_never, cull_front, skip_vertex_transform;
+
+// Color correction
+uniform vec2 _color_correction_params;
+uniform vec4 _ground_color: source_color;
+uniform float _horizon_level;
+
+// uniforms
+uniform vec3 _sun_direction;
+uniform vec3 _moon_direction;
+
+uniform float _moon_size;
+uniform mat3 _moon_matrix;
+uniform mat3 _deep_space_matrix;
+
+// Atmospheric Scattering
+uniform float _atm_darkness;
+uniform float _atm_sun_intensity;
+uniform vec4 _atm_day_tint: source_color;
+uniform vec4 _atm_horizon_light_tint: source_color;
+uniform vec4 _atm_night_tint: source_color;
+uniform vec3 _atm_level_params;
+uniform float _atm_thickness;
+
+// Sun mie phase
+uniform vec4 _atm_sun_mie_tint: source_color;
+uniform float _atm_sun_mie_intensity;
+uniform vec4 _atm_moon_mie_tint: source_color;
+uniform float _atm_moon_mie_intensity;
+uniform vec3 _atm_beta_ray;
+uniform vec3 _atm_beta_mie;
+uniform vec3 _atm_sun_partial_mie_phase;
+uniform vec3 _atm_moon_partial_mie_phase;
+
+// Sun disk
+uniform vec4 _sun_disk_color: source_color;
+uniform float _sun_disk_intensity;
+uniform float _sun_disk_size;
+
+uniform vec4 _moon_color: source_color;
+uniform sampler2D _moon_texture: source_color, repeat_disable;
+
+// Background
+uniform sampler2D _background_texture: source_color;
+uniform vec4 _background_color: source_color;
+
+// Stars Field
+uniform vec4 _stars_field_color: source_color;
+uniform sampler2D _stars_field_texture: source_color;
+uniform float _stars_scintillation;
+uniform float _stars_scintillation_speed;
+
+uniform sampler2D _noise_tex: source_color;
+
+// Clouds
+uniform float _clouds_coverage;
+uniform float _clouds_thickness;
+uniform float _clouds_absorption;
+uniform float _clouds_sky_tint_fade;
+uniform float _clouds_intensity;
+uniform float _clouds_size;
+uniform vec2 _clouds_uv;
+uniform float _clouds_speed;
+uniform vec2 _clouds_direction;
+uniform vec4 _clouds_day_color: source_color;
+uniform vec4 _clouds_horizon_light_color: source_color;
+uniform vec4 _clouds_night_color: source_color;
+uniform sampler2D _clouds_texture;
+
+// Inc
+//------------------------------------------------------------------------------
+const float kPI = 3.1415927f;
+const float kINV_PI = 0.3183098f;
+const float kHALF_PI = 1.5707963f;
+const float kINV_HALF_PI = 0.6366198f;
+const float kQRT_PI = 0.7853982f;
+const float kINV_QRT_PI = 1.2732395f;
+const float kPI4 = 12.5663706f;
+const float kINV_PI4 = 0.0795775f;
+const float k3PI16 = 0.1193662f;
+const float kTAU = 6.2831853f;
+const float kINV_TAU = 0.1591549f;
+const float kE = 2.7182818f;
+
+float saturate(float value){
+ return clamp(value, 0.0, 1.0);
+}
+
+vec3 saturateRGB(vec3 value){
+ return clamp(value.rgb, 0.0, 1.0);
+}
+
+// pow3
+vec3 contrastLevel(vec3 vec, float level){
+ return mix(vec, vec * vec * vec, level);
+}
+
+vec3 tonemapPhoto(vec3 color, float exposure, float level){
+ color.rgb *= exposure;
+ return mix(color.rgb, 1.0 - exp(-color.rgb), level);
+}
+
+vec2 equirectUV(vec3 norm){
+ vec2 ret;
+ ret.x = (atan(norm.x, norm.z) + kPI) * kINV_TAU;
+ ret.y = acos(norm.y) * kINV_PI;
+ return ret;
+}
+
+/*
+float random(vec2 uv){
+ float ret = dot(uv, vec2(12.9898, 78.233));
+ return fract(43758.5453 * sin(ret));
+}
+*/
+
+float disk(vec3 norm, vec3 coords, lowp float size){
+ float dist = length(norm - coords);
+ return 1.0 - step(size, dist);
+}
+
+// Atmosphere Inc
+//------------------------------------------------------------------------------
+const float RAYLEIGH_ZENITH_LENGTH = 8.4e3;
+const float MIE_ZENITH_LENGTH = 1.25e3;
+
+float rayleighPhase(float mu){
+ return k3PI16 * (1.0 + mu * mu);
+}
+
+float miePhase(float mu, vec3 partial){
+ return kPI4 * (partial.x) * (pow(partial.y - partial.z * mu, -1.5));
+}
+
+// Simplifield for more performance
+void simpleOpticalDepth(float y, out float sr, out float sm){
+ y = max(0.03, y + 0.03) + _atm_level_params.y;
+ y = 1.0 / (y * _atm_level_params.x);
+ sr = y * RAYLEIGH_ZENITH_LENGTH;
+ sm = y * MIE_ZENITH_LENGTH;
+}
+
+// Paper based
+void opticalDepth(float y, out float sr, out float sm){
+ y = max(0.0, y);
+ y = saturate(y * _atm_level_params.x);
+
+ float zenith = acos(y);
+ zenith = cos(zenith) + 0.15 * pow(93.885 - ((zenith * 180.0) / kPI), -1.253);
+ zenith = 1.0 / (zenith + _atm_level_params.y);
+
+ sr = zenith * RAYLEIGH_ZENITH_LENGTH;
+ sm = zenith * MIE_ZENITH_LENGTH;
+}
+
+vec3 atmosphericScattering(float sr, float sm, vec2 mu, vec3 mult){
+ vec3 betaMie = _atm_beta_mie;
+ vec3 betaRay = _atm_beta_ray * _atm_thickness;
+
+ vec3 extcFactor = saturateRGB(exp(-(betaRay * sr + betaMie * sm)));
+
+ float extcFF = mix(saturate(_atm_thickness * 0.5), 1.0, mult.x);
+ vec3 finalExtcFactor = mix(1.0 - extcFactor, (1.0 - extcFactor) * extcFactor, extcFF);
+
+ float rayleighPhase = rayleighPhase(mu.x);
+ vec3 BRT = betaRay * rayleighPhase;
+ vec3 BMT = betaMie * miePhase(mu.x, _atm_sun_partial_mie_phase);
+ BMT *= _atm_sun_mie_intensity * _atm_sun_mie_tint.rgb;
+
+ vec3 BRMT = (BRT + BMT) / (betaRay + betaMie);
+ vec3 scatter = _atm_sun_intensity * (BRMT * finalExtcFactor) * _atm_day_tint.rgb * mult.y;
+ scatter = mix(scatter, scatter * (1.0 - extcFactor), _atm_darkness);
+
+ vec3 lcol = mix(_atm_day_tint.rgb, _atm_horizon_light_tint.rgb, mult.x);
+ vec3 nscatter = (1.0 - extcFactor) * _atm_night_tint.rgb;
+ nscatter += miePhase(mu.y, _atm_moon_partial_mie_phase) *
+ _atm_moon_mie_tint.rgb * _atm_moon_mie_intensity * 0.005;
+
+ return (scatter * lcol) + nscatter;
+}
+
+// Clouds
+//------------------------------------------------------------------------------
+float noiseClouds(vec2 coords, vec2 offset){
+ float speed = TIME * _clouds_speed * .5; // .5 matches cumulus clouds
+ vec2 wind = offset * speed;
+ vec2 wind2 = (offset + offset) * speed;
+ float a = textureLod(_clouds_texture, coords.xy * _clouds_uv - wind, 0.0).r;
+ float b = textureLod(_clouds_texture, coords.xy * _clouds_uv - wind2, 0.0).r;
+ return ((a + b) * 0.5);
+}
+
+float cloudsDensity(vec2 p, vec2 offset){
+ float d = noiseClouds(p, offset);
+ float c = 1.0 - _clouds_coverage;
+ d = d - c;
+ return saturate(d);
+}
+
+vec4 renderClouds(vec3 pos){
+ pos.xy = pos.xz / pos.y;
+ pos *= _clouds_size;
+ float density = cloudsDensity(pos.xy, _clouds_direction);
+ float sh = saturate(exp(-_clouds_absorption * density));
+ float a = saturate(density * _clouds_thickness);
+ return vec4(vec3(density*sh) * _clouds_intensity, a);
+}
+
+// Varyings
+//------------------------------------------------------------------------------
+varying vec4 world_pos;
+varying vec4 moon_coords;
+varying vec3 deep_space_coords;
+varying vec4 angle_mult;
+varying vec3 scatter;
+
+void vertex(){
+ vec4 vert = vec4(VERTEX, 0.0);
+ POSITION = PROJECTION_MATRIX * MODELVIEW_MATRIX * vert;
+ POSITION.z = 0.00000001;
+
+ world_pos = MODEL_MATRIX * vert;
+ moon_coords.xyz = (_moon_matrix * VERTEX) / _moon_size + 0.5;
+ moon_coords.w = dot(world_pos.xyz, _moon_direction.xyz);
+ deep_space_coords.xyz = (_deep_space_matrix * VERTEX).xyz;
+
+ angle_mult.x = saturate(1.0 - _sun_direction.y);
+ angle_mult.y = saturate(_sun_direction.y + 0.45);
+ angle_mult.z = saturate(-_sun_direction.y + 0.30);
+ angle_mult.w = saturate(-_sun_direction.y + 0.60);
+
+ // Atmosphere
+ vec3 worldPos = normalize(world_pos).xyz;
+ vec2 mu = vec2(dot(_sun_direction, worldPos), dot(_moon_direction, worldPos));
+ float sr, sm;
+ simpleOpticalDepth(worldPos.y + _atm_level_params.z + _horizon_level, sr, sm);
+ scatter = atmosphericScattering(sr, sm, mu.xy, angle_mult.xyz);
+}
+
+void fragment(){
+ vec3 col = vec3(0.0);
+ vec3 worldPos = normalize(world_pos).xyz;
+ vec3 cloudsPos = worldPos;
+ worldPos.y += _horizon_level;
+ float horizonBlend = saturate((worldPos.y - 0.03) * 3.0);
+ col.rgb += scatter.rgb;
+
+ // Near Space
+ vec3 nearSpace = vec3(0.0);
+ vec3 sunDisk = disk(worldPos, _sun_direction, _sun_disk_size) *
+ _sun_disk_color.rgb * scatter.rgb;
+ sunDisk *= _sun_disk_intensity;
+
+ // Moon
+ vec4 moon = texture(_moon_texture, vec2(-moon_coords.x+1.0, moon_coords.y));
+ moon.rgb = contrastLevel(moon.rgb * _moon_color.rgb, _moon_color.a);
+ moon *= saturate(moon_coords.w);
+ float moonMask = saturate(1.0 - moon.a);
+ nearSpace = moon.rgb + (sunDisk.rgb * moonMask);
+ col.rgb += nearSpace;
+
+ vec3 deepSpace = vec3(0.0);
+ vec2 deepSpaceUV = equirectUV(normalize(deep_space_coords));
+
+ // Background
+ vec3 deepSpaceBackground = textureLod(_background_texture, deepSpaceUV, 0.0).rgb;
+ deepSpaceBackground *= _background_color.rgb;
+ deepSpaceBackground = contrastLevel(deepSpaceBackground, _background_color.a);
+ deepSpace.rgb += deepSpaceBackground.rgb * moonMask;
+
+ // Stars Field
+ float starsScintillation = textureLod(_noise_tex, UV + (TIME * _stars_scintillation_speed), 0.0).r;
+ starsScintillation = mix(1.0, starsScintillation * 1.5, _stars_scintillation);
+
+ vec3 starsField = textureLod(_stars_field_texture, deepSpaceUV, 0.0).rgb * _stars_field_color.rgb;
+ starsField = saturateRGB(mix(starsField.rgb, starsField.rgb * starsScintillation, _stars_scintillation));
+ //deepSpace.rgb -= saturate(starsField.r*10.0);
+ deepSpace.rgb += starsField.rgb * moonMask;
+ deepSpace.rgb *= angle_mult.z;
+ col.rgb += deepSpace.rgb * horizonBlend;
+
+ // Clouds
+ vec4 clouds = renderClouds(cloudsPos);
+ clouds.a = saturate(clouds.a);
+ clouds.rgb *= mix(mix(_clouds_day_color.rgb, _clouds_horizon_light_color.rgb, angle_mult.x),
+ _clouds_night_color.rgb, angle_mult.w);
+
+ clouds.a = mix(0.0, clouds.a, horizonBlend);
+ col.rgb = mix(col.rgb, clouds.rgb + mix(vec3(0.0), scatter, _clouds_sky_tint_fade), clouds.a);
+ col.rgb = mix(col.rgb, _ground_color.rgb * scatter, saturate((-worldPos.y - _atm_level_params.z)*100.0));
+
+ col.rgb = tonemapPhoto(col.rgb, _color_correction_params.y, _color_correction_params.x);
+
+ ALBEDO = col.rgb;
+}
diff --git a/addons/sky_3d/shaders/PerVertexSky.gdshader.uid b/addons/sky_3d/shaders/PerVertexSky.gdshader.uid
new file mode 100644
index 0000000..5998699
--- /dev/null
+++ b/addons/sky_3d/shaders/PerVertexSky.gdshader.uid
@@ -0,0 +1 @@
+uid://esppe8gjxn2o
diff --git a/addons/sky_3d/shaders/SimpleMoon.gdshader b/addons/sky_3d/shaders/SimpleMoon.gdshader
new file mode 100644
index 0000000..5ea7986
--- /dev/null
+++ b/addons/sky_3d/shaders/SimpleMoon.gdshader
@@ -0,0 +1,22 @@
+// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+// Copyright (c) 2021 J. Cuellar
+
+shader_type spatial;
+render_mode unshaded;
+
+uniform sampler2D _texture;
+uniform vec3 _sun_direction;
+
+float saturate(float v){
+ return clamp(v, 0.0, 1.0);
+}
+
+varying vec3 normal;
+void vertex(){
+ normal = (MODEL_MATRIX * vec4(VERTEX, 0.0)).xyz;
+}
+
+void fragment(){
+ float l = saturate(max(0.0, dot(_sun_direction, normal)) * 2.0);
+ ALBEDO = texture(_texture, UV).rgb * l;
+}
diff --git a/addons/sky_3d/shaders/SimpleMoon.gdshader.uid b/addons/sky_3d/shaders/SimpleMoon.gdshader.uid
new file mode 100644
index 0000000..cfbfa09
--- /dev/null
+++ b/addons/sky_3d/shaders/SimpleMoon.gdshader.uid
@@ -0,0 +1 @@
+uid://b32kr6wc23uit
diff --git a/addons/sky_3d/shaders/Sky.gdshader b/addons/sky_3d/shaders/Sky.gdshader
new file mode 100644
index 0000000..6c04706
--- /dev/null
+++ b/addons/sky_3d/shaders/Sky.gdshader
@@ -0,0 +1,299 @@
+// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+// Copyright (c) 2021 J. Cuellar
+
+shader_type spatial;
+render_mode unshaded, depth_draw_never, cull_front, skip_vertex_transform;
+
+// Color correction
+uniform vec2 _color_correction_params;
+uniform vec4 _ground_color: source_color;
+uniform float _horizon_level;
+
+// uniforms
+uniform vec3 _sun_direction;
+uniform vec3 _moon_direction;
+
+uniform float _moon_size;
+uniform mat3 _moon_matrix;
+uniform mat3 _deep_space_matrix;
+
+// Atmospheric Scattering
+uniform float _atm_darkness;
+uniform float _atm_sun_intensity;
+uniform vec4 _atm_day_tint: source_color;
+uniform vec4 _atm_horizon_light_tint: source_color;
+uniform vec4 _atm_night_tint: source_color;
+uniform vec3 _atm_level_params;
+uniform float _atm_thickness;
+
+// Sun mie phase
+uniform vec4 _atm_sun_mie_tint: source_color;
+uniform float _atm_sun_mie_intensity;
+uniform vec4 _atm_moon_mie_tint: source_color;
+uniform float _atm_moon_mie_intensity;
+uniform vec3 _atm_beta_ray;
+uniform vec3 _atm_beta_mie;
+uniform vec3 _atm_sun_partial_mie_phase;
+uniform vec3 _atm_moon_partial_mie_phase;
+
+// Sun disk
+uniform vec4 _sun_disk_color: source_color;
+uniform float _sun_disk_intensity;
+uniform float _sun_disk_size;
+
+uniform vec4 _moon_color: source_color;
+uniform sampler2D _moon_texture: source_color, repeat_disable;
+
+// Background
+uniform sampler2D _background_texture: source_color;
+uniform vec4 _background_color: source_color;
+
+// Stars Field
+uniform vec4 _stars_field_color: source_color;
+uniform sampler2D _stars_field_texture: source_color;
+uniform float _stars_scintillation;
+uniform float _stars_scintillation_speed;
+
+uniform sampler2D _noise_tex: source_color;
+
+// Clouds
+uniform float _clouds_coverage;
+uniform float _clouds_thickness;
+uniform float _clouds_absorption;
+uniform float _clouds_sky_tint_fade;
+uniform float _clouds_intensity;
+uniform float _clouds_size;
+uniform vec2 _clouds_uv;
+uniform float _clouds_speed;
+uniform vec2 _clouds_direction;
+uniform vec4 _clouds_day_color: source_color;
+uniform vec4 _clouds_horizon_light_color: source_color;
+uniform vec4 _clouds_night_color: source_color;
+uniform sampler2D _clouds_texture;
+
+
+// Inc
+//------------------------------------------------------------------------------
+const float kPI = 3.1415927f;
+const float kINV_PI = 0.3183098f;
+const float kHALF_PI = 1.5707963f;
+const float kINV_HALF_PI = 0.6366198f;
+const float kQRT_PI = 0.7853982f;
+const float kINV_QRT_PI = 1.2732395f;
+const float kPI4 = 12.5663706f;
+const float kINV_PI4 = 0.0795775f;
+const float k3PI16 = 0.1193662f;
+const float kTAU = 6.2831853f;
+const float kINV_TAU = 0.1591549f;
+const float kE = 2.7182818f;
+
+float saturate(float value){
+ return clamp(value, 0.0, 1.0);
+}
+
+vec3 saturateRGB(vec3 value){
+ return clamp(value.rgb, 0.0, 1.0);
+}
+
+// pow3
+vec3 contrastLevel(vec3 vec, float level){
+ return mix(vec, vec * vec * vec, level);
+}
+
+vec3 tonemapPhoto(vec3 color, float exposure, float level){
+ color.rgb *= exposure;
+ return mix(color.rgb, 1.0 - exp(-color.rgb), level);
+}
+
+vec2 equirectUV(vec3 norm){
+ vec2 ret;
+ ret.x = (atan(norm.x, norm.z) + kPI) * kINV_TAU;
+ ret.y = acos(norm.y) * kINV_PI;
+ return ret;
+}
+
+/*
+float random(vec2 uv){
+ float ret = dot(uv, vec2(12.9898, 78.233));
+ return fract(43758.5453 * sin(ret));
+}
+*/
+
+float disk(vec3 norm, vec3 coords, lowp float size){
+ float dist = length(norm - coords);
+ return 1.0 - step(size, dist);
+}
+
+// Atmosphere Inc
+//------------------------------------------------------------------------------
+const float RAYLEIGH_ZENITH_LENGTH = 8.4e3;
+const float MIE_ZENITH_LENGTH = 1.25e3;
+
+float rayleighPhase(float mu){
+ return k3PI16 * (1.0 + mu * mu);
+}
+
+float miePhase(float mu, vec3 partial){
+ return kPI4 * (partial.x) * (pow(partial.y - partial.z * mu, -1.5));
+}
+
+// Simplifield for more performance
+void simpleOpticalDepth(float y, out float sr, out float sm){
+ y = max(0.03, y + 0.03) + _atm_level_params.y;
+ y = 1.0 / (y * _atm_level_params.x);
+ sr = y * RAYLEIGH_ZENITH_LENGTH;
+ sm = y * MIE_ZENITH_LENGTH;
+}
+
+// Paper based
+void opticalDepth(float y, out float sr, out float sm){
+ y = max(0.0, y);
+ y = saturate(y * _atm_level_params.x);
+
+ float zenith = acos(y);
+ zenith = cos(zenith) + 0.15 * pow(93.885 - ((zenith * 180.0) / kPI), -1.253);
+ zenith = 1.0 / (zenith + _atm_level_params.y);
+
+ sr = zenith * RAYLEIGH_ZENITH_LENGTH;
+ sm = zenith * MIE_ZENITH_LENGTH;
+}
+
+vec3 atmosphericScattering(float sr, float sm, vec2 mu, vec3 mult){
+ vec3 betaMie = _atm_beta_mie;
+ vec3 betaRay = _atm_beta_ray * _atm_thickness;
+
+ vec3 extcFactor = saturateRGB(exp(-(betaRay * sr + betaMie * sm)));
+
+ float extcFF = mix(saturate(_atm_thickness * 0.5), 1.0, mult.x);
+ vec3 finalExtcFactor = mix(1.0 - extcFactor, (1.0 - extcFactor) * extcFactor, extcFF);
+ float rayleighPhase = rayleighPhase(mu.x);
+ vec3 BRT = betaRay * rayleighPhase;
+ vec3 BMT = betaMie * miePhase(mu.x, _atm_sun_partial_mie_phase);
+ BMT *= _atm_sun_mie_intensity * _atm_sun_mie_tint.rgb;
+
+ vec3 BRMT = (BRT + BMT) / (betaRay + betaMie);
+ vec3 scatter = _atm_sun_intensity * (BRMT * finalExtcFactor) * _atm_day_tint.rgb * mult.y;
+ scatter = mix(scatter, scatter * (1.0 - extcFactor), _atm_darkness);
+
+ vec3 lcol = mix(_atm_day_tint.rgb, _atm_horizon_light_tint.rgb, mult.x);
+ vec3 nscatter = (1.0 - extcFactor) * _atm_night_tint.rgb;
+ nscatter += miePhase(mu.y, _atm_moon_partial_mie_phase) *
+ _atm_moon_mie_tint.rgb * _atm_moon_mie_intensity * 0.005;
+
+ return (scatter * lcol) + nscatter;
+}
+
+// Clouds
+//------------------------------------------------------------------------------
+float noiseClouds(vec2 coords, vec2 offset){
+ float speed = TIME * _clouds_speed * .5; // .5 matches cumulus clouds
+ vec2 wind = offset * speed;
+ vec2 wind2 = (offset + offset) * speed;
+ float a = textureLod(_clouds_texture, coords.xy * _clouds_uv - wind, 0.0).r;
+ float b = textureLod(_clouds_texture, coords.xy * _clouds_uv - wind2, 0.0).r;
+ return ((a + b) * 0.5);
+}
+
+float cloudsDensity(vec2 p, vec2 offset){
+ float d = noiseClouds(p, offset);
+ float c = 1.0 - _clouds_coverage;
+ d = d - c;
+ //d += d;
+ return saturate(d);
+}
+
+vec4 renderClouds(vec3 pos){
+ pos.xy = pos.xz / pos.y;
+ pos *= _clouds_size;
+ float density = cloudsDensity(pos.xy, _clouds_direction);
+ float sh = saturate(exp(-_clouds_absorption * density));
+ float a = saturate(density * _clouds_thickness);
+ return vec4(vec3(density*sh) * _clouds_intensity, a);
+}
+
+// Varyings
+//------------------------------------------------------------------------------
+varying vec4 world_pos;
+varying vec4 moon_coords;
+varying vec3 deep_space_coords;
+varying vec4 angle_mult;
+
+void vertex(){
+ vec4 vert = vec4(VERTEX, 0.0);
+ POSITION = PROJECTION_MATRIX * MODELVIEW_MATRIX * vert;
+ POSITION.z = 0.00000001;
+
+ world_pos = MODEL_MATRIX * vert;
+ moon_coords.xyz = (_moon_matrix * VERTEX) / _moon_size + 0.5;
+ moon_coords.w = dot(world_pos.xyz, _moon_direction.xyz);
+ deep_space_coords.xyz = (_deep_space_matrix * VERTEX).xyz;
+
+ angle_mult.x = saturate(1.0 - _sun_direction.y);
+ angle_mult.y = saturate(_sun_direction.y + 0.45);
+ angle_mult.z = saturate(-_sun_direction.y + 0.30);
+ angle_mult.w = saturate(-_sun_direction.y + 0.60);
+}
+
+void fragment(){
+ vec3 col = vec3(0.0);
+ vec3 worldPos = normalize(world_pos).xyz;
+ vec3 cloudsPos = worldPos;
+
+ // Atmosphere
+ vec2 mu = vec2(dot(_sun_direction, worldPos), dot(_moon_direction, worldPos));
+ float sr, sm;
+ simpleOpticalDepth(worldPos.y + _atm_level_params.z + _horizon_level, sr, sm);
+
+ worldPos.y += _horizon_level;
+ float horizonBlend = saturate((worldPos.y - 0.03) * 3.0);
+
+ vec3 scatter = atmosphericScattering(sr, sm, mu.xy, angle_mult.xyz);
+ col.rgb += scatter.rgb;
+
+ // Near Space
+ vec3 nearSpace = vec3(0.0);
+ vec3 sunDisk = disk(worldPos, _sun_direction, _sun_disk_size) *
+ _sun_disk_color.rgb * scatter.rgb;
+ sunDisk *= _sun_disk_intensity;
+
+ // Moon
+ vec4 moon = texture(_moon_texture, vec2(-moon_coords.x+1.0, moon_coords.y));
+ moon.rgb = contrastLevel(moon.rgb * _moon_color.rgb, _moon_color.a);
+ moon *= saturate(moon_coords.w);
+ float moonMask = saturate(1.0 - moon.a);
+ nearSpace = moon.rgb + (sunDisk.rgb * moonMask);
+ col.rgb += nearSpace;
+
+ vec3 deepSpace = vec3(0.0);
+ vec2 deepSpaceUV = equirectUV(normalize(deep_space_coords));
+
+ // Background
+ vec3 deepSpaceBackground = textureLod(_background_texture, deepSpaceUV, 0.0).rgb;
+ deepSpaceBackground *= _background_color.rgb;
+ deepSpaceBackground = contrastLevel(deepSpaceBackground, _background_color.a);
+ deepSpace.rgb += deepSpaceBackground.rgb * moonMask;
+
+ // Stars Field
+ float starsScintillation = textureLod(_noise_tex, UV + (TIME * _stars_scintillation_speed), 0.0).r;
+ starsScintillation = mix(1.0, starsScintillation * 1.5, _stars_scintillation);
+
+ vec3 starsField = textureLod(_stars_field_texture, deepSpaceUV, 0.0).rgb * _stars_field_color.rgb;
+ starsField = saturateRGB(mix(starsField.rgb, starsField.rgb * starsScintillation, _stars_scintillation));
+ //deepSpace.rgb -= saturate(starsField.r*10.0);
+ deepSpace.rgb += starsField.rgb * moonMask;
+ deepSpace.rgb *= angle_mult.z;
+ col.rgb += deepSpace.rgb * horizonBlend;
+
+ // Clouds
+ vec4 clouds = renderClouds(cloudsPos);
+ clouds.a = saturate(clouds.a);
+ clouds.rgb *= mix(mix(_clouds_day_color.rgb, _clouds_horizon_light_color.rgb, angle_mult.x),
+ _clouds_night_color.rgb, angle_mult.w);
+
+ clouds.a = mix(0.0, clouds.a, horizonBlend);
+ col.rgb = mix(col.rgb, clouds.rgb + mix(vec3(0.0), scatter, _clouds_sky_tint_fade), clouds.a);
+ col.rgb = mix(col.rgb, _ground_color.rgb * scatter, saturate((-worldPos.y - _atm_level_params.z)*100.0));
+
+ col.rgb = tonemapPhoto(col.rgb, _color_correction_params.y, _color_correction_params.x);
+ ALBEDO = col.rgb;
+}
diff --git a/addons/sky_3d/shaders/Sky.gdshader.uid b/addons/sky_3d/shaders/Sky.gdshader.uid
new file mode 100644
index 0000000..8e53eca
--- /dev/null
+++ b/addons/sky_3d/shaders/Sky.gdshader.uid
@@ -0,0 +1 @@
+uid://cgjdgr8w1b5y
diff --git a/addons/sky_3d/src/DateTimeUtil.gd b/addons/sky_3d/src/DateTimeUtil.gd
new file mode 100644
index 0000000..aa39397
--- /dev/null
+++ b/addons/sky_3d/src/DateTimeUtil.gd
@@ -0,0 +1,28 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+class_name DateTimeUtil
+
+
+const TOTAL_HOURS: int = 24
+
+
+static func compute_leap_year(year: int) -> bool:
+ return (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0);
+
+
+static func hour_to_total_hours(hour: int) -> float:
+ return float(hour)
+
+
+static func hour_minutes_to_total_hours(hour: int, minutes: int) -> float:
+ return float(hour) + float(minutes) / 60.0
+
+
+static func hours_to_total_hours(hour:int, minutes: int, seconds: int) -> float:
+ return float(hour) + float(minutes) / 60.0 + float(seconds) / 3600.0
+
+
+static func full_time_to_total_hours(hour: int, minutes: int, seconds: int, milliseconds: int) -> float:
+ return float(hour) + float(minutes) / 60.0 + float(seconds) / 3600.0 + \
+ float(milliseconds) / 3600000.0
diff --git a/addons/sky_3d/src/DateTimeUtil.gd.uid b/addons/sky_3d/src/DateTimeUtil.gd.uid
new file mode 100644
index 0000000..25fa09a
--- /dev/null
+++ b/addons/sky_3d/src/DateTimeUtil.gd.uid
@@ -0,0 +1 @@
+uid://rb48skheqwhk
diff --git a/addons/sky_3d/src/OrbitalElements.gd b/addons/sky_3d/src/OrbitalElements.gd
new file mode 100644
index 0000000..f87e41a
--- /dev/null
+++ b/addons/sky_3d/src/OrbitalElements.gd
@@ -0,0 +1,31 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+class_name OrbitalElements
+
+# Handles Orbital elements for planetary system
+
+var N: float # Longitude of the ascending node.
+var i: float # The Inclination to the ecliptic.
+var w: float # Argument of perihelion.
+var a: float # Semi-major axis, or mean distance from sun.
+var e: float # Eccentricity.
+var M: float # Mean anomaly.
+
+
+func get_orbital_elements(index: int, timeScale: float) -> void:
+ if index == 0: # Sun
+ N = 0.0
+ i = 0.0
+ w = 282.9404 + 4.70935e-5 * timeScale
+ a = 0.0
+ e = 0.016709 - 1.151e-9 * timeScale
+ M = 356.0470 + 0.9856002585 * timeScale
+ else: # Moon
+ N = 125.1228 - 0.0529538083 * timeScale
+ i = 5.1454
+ w = 318.0634 + 0.1643573223 * timeScale
+ a = 60.2666
+ e = 0.054900
+ M = 115.3654 + 13.0649929509 * timeScale
+
diff --git a/addons/sky_3d/src/OrbitalElements.gd.uid b/addons/sky_3d/src/OrbitalElements.gd.uid
new file mode 100644
index 0000000..51083f8
--- /dev/null
+++ b/addons/sky_3d/src/OrbitalElements.gd.uid
@@ -0,0 +1 @@
+uid://d0fv2uybi1xxl
diff --git a/addons/sky_3d/src/Plugin.gd b/addons/sky_3d/src/Plugin.gd
new file mode 100644
index 0000000..069899b
--- /dev/null
+++ b/addons/sky_3d/src/Plugin.gd
@@ -0,0 +1,24 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+@tool
+extends EditorPlugin
+
+const __sky3d_script: String = "res://addons/sky_3d/src/Sky3D.gd"
+const __sky3d_icon: String = "res://addons/sky_3d/assets/textures/SkyIcon.png"
+const __skydome_script: String = "res://addons/sky_3d/src/Skydome.gd"
+const __skydome_icon: String = "res://addons/sky_3d/assets/textures/SkyIcon.png"
+const __time_of_day_script: String = "res://addons/sky_3d/src/TimeOfDay.gd"
+const __time_of_day_icon: String = "res://addons/sky_3d/assets/textures/SkyIcon.png"
+
+
+func _enter_tree() -> void:
+ add_custom_type("Sky3D", "Node", load(__sky3d_script), load(__sky3d_icon))
+ add_custom_type("Skydome", "Node", load(__skydome_script), load(__skydome_icon))
+ add_custom_type("TimeOfDay", "Node", load(__time_of_day_script), load(__time_of_day_icon))
+
+
+func _exit_tree() -> void:
+ remove_custom_type("Sky3D")
+ remove_custom_type("Skydome")
+ remove_custom_type("TimeOfDay")
diff --git a/addons/sky_3d/src/Plugin.gd.uid b/addons/sky_3d/src/Plugin.gd.uid
new file mode 100644
index 0000000..b0cd308
--- /dev/null
+++ b/addons/sky_3d/src/Plugin.gd.uid
@@ -0,0 +1 @@
+uid://bag65s8e7l2tq
diff --git a/addons/sky_3d/src/ScatterLib.gd b/addons/sky_3d/src/ScatterLib.gd
new file mode 100644
index 0000000..adfc108
--- /dev/null
+++ b/addons/sky_3d/src/ScatterLib.gd
@@ -0,0 +1,51 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+class_name ScatterLib
+
+# Atmospheric Scattering Lib.
+# References:
+# - Preetham and Hoffman Paper:
+# See: https://developer.amd.com/wordpress/media/2012/10/ATI-LightScattering.pdf
+
+const n: float = 1.0003 # Index of the air refraction
+const n2: float = 1.00060009 # Index of the air refraction ˆ 2
+const N: float = 2.545e25 # Molecular Density
+const pn: float = 0.035 # Depolatization factor for standard air.
+
+
+static func compute_wavelenghts_lambda(value: Vector3) -> Vector3:
+ return value * 1e-9
+
+
+static func compute_wavlenghts(lambda: Vector3) -> Vector3:
+ var k = 4.0
+ var ret: Vector3 = lambda
+ ret.x = pow(lambda.x, k)
+ ret.y = pow(lambda.y, k)
+ ret.z = pow(lambda.z, k)
+ return ret
+
+
+static func compute_beta_ray(wavelenghts: Vector3) -> Vector3:
+ var kr: float = (8.0 * pow(PI, 3.0) * pow(n2 - 1.0, 2.0) * (6.0 + 3.0 * pn))
+ var ret: Vector3 = 3.0 * N * wavelenghts * (6.0 - 7.0 * pn)
+ ret.x = kr / ret.x
+ ret.y = kr / ret.y
+ ret.z = kr / ret.z
+ return ret
+
+
+static func compute_beta_mie(mie: float, turbidity: float) -> Vector3:
+ var k: float = 434e-6
+ return Vector3.ONE * mie * turbidity * k
+
+
+static func get_partial_mie_phase(g: float) -> Vector3:
+ var g2: float = g * g
+ var ret: Vector3
+ # ret.x = ((1.0 - g2) / (2.0 + g2))
+ ret.x = 1.0 - g2
+ ret.y = 1.0 + g2
+ ret.z = 2.0 * g
+ return ret
diff --git a/addons/sky_3d/src/ScatterLib.gd.uid b/addons/sky_3d/src/ScatterLib.gd.uid
new file mode 100644
index 0000000..f75c14c
--- /dev/null
+++ b/addons/sky_3d/src/ScatterLib.gd.uid
@@ -0,0 +1 @@
+uid://dus2oqhioyqwy
diff --git a/addons/sky_3d/src/Sky3D.gd b/addons/sky_3d/src/Sky3D.gd
new file mode 100644
index 0000000..eacecf3
--- /dev/null
+++ b/addons/sky_3d/src/Sky3D.gd
@@ -0,0 +1,587 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+## Sky3D is an Atmosphereic Day/Night Cycle for Godot 4.
+##
+## It manages time, moving the sun, moon, and stars, and consolidates environmental lighting controls.
+## To use it, remove any WorldEnvironment node from you scene, then add a new Sky3D node.
+## Explore and configure the settings in the Sky3D, SunLight, MoonLight, TimeOfDay, and Skydome nodes.
+
+@tool
+class_name Sky3D
+extends WorldEnvironment
+
+## Emitted when the environment variable has changed.
+signal environment_changed
+
+## The Sun DirectionalLight.
+var sun: DirectionalLight3D
+## The Moon DirectionalLight.
+var moon: DirectionalLight3D
+## The TimeOfDay node.
+var tod: TimeOfDay
+## The Skydome node.
+var sky: Skydome
+
+
+## Enables all rendering and time tracking.
+@export var sky3d_enabled: bool = true : set = set_sky3d_enabled
+
+func set_sky3d_enabled(value: bool) -> void:
+ sky3d_enabled = value
+ if value:
+ show_sky()
+ resume()
+ else:
+ hide_sky()
+ pause()
+
+
+#####################
+## Visibility
+#####################
+
+@export_group("Visibility")
+
+
+## Enables the sky shader. Disable sky, lights, fog for a black sky or call hide_sky().
+@export var sky_enabled: bool = true : set = set_sky_enabled
+
+func set_sky_enabled(value: bool) -> void:
+ sky_enabled = value
+ if not sky:
+ return
+ sky.sky_visible = value
+ sky.clouds_cumulus_visible = clouds_enabled and value
+
+
+## Enables the Sun and Moon DirectionalLights.
+@export var lights_enabled: bool = true : set = set_lights_enabled
+
+func set_lights_enabled(value: bool) -> void:
+ lights_enabled = value
+ if not sky:
+ return
+ sky.sun_light_enable = value
+ sky.moon_light_enable = value
+ sky.__sun_light_node.visible = value && sky.__sun_light_node.light_energy > 0
+ sky.__moon_light_node.visible = value && sky.__moon_light_node.light_energy > 0
+
+
+## Enables the screen space fog shader.
+@export var fog_enabled: bool = true : set = set_fog_enabled
+
+func set_fog_enabled(value: bool) -> void:
+ fog_enabled = value
+ if sky:
+ sky.fog_visible = value
+
+
+## Enables the 2D and cumulus cloud layers.
+@export var clouds_enabled: bool = true : set = set_clouds_enabled
+
+func set_clouds_enabled(value: bool) -> void:
+ clouds_enabled = value
+ if not sky:
+ return
+ sky.clouds_cumulus_visible = value
+ sky.clouds_thickness = float(value) * 1.7
+ # TODO should create an on/off in skydome so disabling this doesn't change the enabled value
+
+
+## Disables rendering of sky, fog, and lights
+func hide_sky() -> void:
+ sky_enabled = false
+ lights_enabled = false
+ fog_enabled = false
+
+
+## Enables rendering of sky, fog, and lights
+func show_sky() -> void:
+ sky_enabled = true
+ lights_enabled = true
+ fog_enabled = true
+
+
+#####################
+## Time
+#####################
+
+@export_group("Time")
+
+
+## Move time forward in the editor.
+@export var enable_editor_time: bool = true : set = set_editor_time_enabled
+
+func set_editor_time_enabled(value: bool) -> void:
+ enable_editor_time = value
+ if tod:
+ tod.update_in_editor = value
+
+
+## Move time forward in game.
+@export var enable_game_time: bool = true : set = set_game_time_enabled
+
+func set_game_time_enabled(value: bool) -> void:
+ enable_game_time = value
+ if tod:
+ tod.update_in_game = value
+
+
+## The time right now in hours, 0-23.99. Larger and smaller values will wrap
+@export_range(0.0, 23.99, .01, "or_greater", "or_less") var current_time: float = 8.0 : set = set_current_time
+
+func set_current_time(value: float) -> void:
+ current_time = value
+ if tod and tod.total_hours != current_time:
+ tod.total_hours = value
+
+
+## The length of a full day in real minutes. +/-1440 (24 hours), forward or backwards.
+@export_range(-1440,1440,.1) var minutes_per_day: float = 15.0 : set = set_minutes_per_day
+
+func set_minutes_per_day(value):
+ minutes_per_day = value
+ if tod:
+ tod.total_cycle_in_minutes = value
+
+
+## Frequency of updates. Set to 0.016 for 60fps.
+@export_range(0.016, 10.0) var update_interval: float = 0.1 : set = set_update_interval
+
+func set_update_interval(value: float) -> void:
+ update_interval = value
+ if tod:
+ tod.update_interval = value
+
+
+## Tracks if the sun is above the horizon.
+var _is_day: bool = true
+
+
+## Returns true if the sun is above the horizon.
+func is_day() -> bool:
+ return _is_day
+
+
+## Returns true if the sun is below the horizon.
+func is_night() -> bool:
+ return not _is_day
+
+
+## Pauses time calculation.
+func pause() -> void:
+ if tod:
+ tod.pause()
+
+
+## Resumes time calculation.
+func resume() -> void:
+ if tod:
+ tod.resume()
+
+
+func _on_timeofday_updated(time: float) -> void:
+ if tod:
+ minutes_per_day = tod.total_cycle_in_minutes
+ current_time = tod.total_hours
+ update_interval = tod.update_interval
+ update_day_night()
+
+
+## Recalculates if it's currently day or night. Adjusts night ambient light if changing state or forced.
+func update_day_night(force: bool = false) -> void:
+ if not (sky and environment):
+ return
+
+ # If day transitioning to night
+ if abs(sky.sun_altitude) > 87 and (_is_day or force):
+ _is_day = false
+ var tween: Tween = get_tree().create_tween()
+ tween.set_parallel(true)
+ var contrib: float = minf(night_ambient_min, sky_contribution) if night_ambient else sky_contribution
+ tween.tween_property(environment, "ambient_light_sky_contribution", contrib, ambient_tween_time)
+ tween.tween_property(environment.sky.sky_material, "energy_multiplier", 1., ambient_tween_time)
+
+ # Else if night transitioning to day
+ elif abs(sky.sun_altitude) <= 87 and (not _is_day or force):
+ _is_day = true
+ var tween: Tween = get_tree().create_tween()
+ tween.set_parallel(true)
+ tween.tween_property(environment, "ambient_light_sky_contribution", sky_contribution, ambient_tween_time)
+ tween.tween_property(environment.sky.sky_material, "energy_multiplier", reflected_energy, ambient_tween_time)
+
+
+#####################
+## Lighting
+#####################
+
+@export_group("Lighting")
+
+
+## Exposure used for the tonemapper. See Evironment.tonemap_exposure
+@export_range(0,16,.005) var tonemap_exposure: float = 1.0: set = set_tonemap_exposure
+
+func set_tonemap_exposure(value: float) -> void:
+ if environment:
+ tonemap_exposure = value
+ environment.tonemap_exposure = value
+
+
+## Strength of skydome and fog.
+@export_range(0,16,.005) var skydome_energy: float = 1.3: set = set_skydome_energy
+
+func set_skydome_energy(value: float) -> void:
+ if sky:
+ skydome_energy = value
+ sky.exposure = value
+ sky.clouds_cumulus_intensity = value * .769 # (1/1.3 default sun energy)
+
+
+## Exposure of camera connected to Environment.camera_attributes.
+@export_range(0,16,.005) var camera_exposure: float = 1.0: set = set_camera_exposure
+
+func set_camera_exposure(value: float) -> void:
+ if camera_attributes:
+ camera_exposure = value
+ camera_attributes.exposure_multiplier = value
+
+
+## Maximum strength of Sun DirectionalLight, visible during the day.
+@export_range(0,16,.005) var sun_energy: float = 1.0: set = set_sun_energy
+
+func set_sun_energy(value: float) -> void:
+ sun_energy = value
+ if sky:
+ sky.sun_light_energy = value
+
+
+## Opacity of Sun DirectionalLight shadow.
+@export_range(0,1,.005) var sun_shadow_opacity: float = 1.0: set = set_sun_shadow_opacity
+
+func set_sun_shadow_opacity(value: float) -> void:
+ sun_shadow_opacity = value
+ if sun:
+ sun.shadow_opacity = value
+
+
+## Strength of refelcted light from the PhysicalSky. See PhysicalSkyMaterial.energy_multiplier
+@export_range(0,128,.005) var reflected_energy: float = 1.0: set = set_reflected_energy
+
+func set_reflected_energy(value: float) -> void:
+ if environment:
+ reflected_energy = value
+ if environment.sky:
+ environment.sky.sky_material.energy_multiplier = value
+
+
+## Ratio of ambient light to sky light. See Environment.ambient_light_sky_contribution.
+@export_range(0,1,.005) var sky_contribution: float = 1.0: set = set_sky_contribution
+
+func set_sky_contribution(value: float) -> void:
+ if environment:
+ sky_contribution = value
+ environment.ambient_light_sky_contribution = value
+ update_day_night(true)
+
+
+## Strength of ambient light. Works outside of Reflection Probe / GI volumes and sky_contribution < 1.
+## See Environment.ambient_light_energy.
+@export_range(0,16,.005) var ambient_energy: float = 1.0: set = set_ambient_energy
+
+func set_ambient_energy(value: float) -> void:
+ if environment:
+ ambient_energy = value
+ environment.ambient_light_energy = value
+ update_day_night(true)
+
+
+@export_subgroup("Auto Exposure")
+
+
+## Enables CameraAttributes.auto_exposure_enabled.
+@export var auto_exposure: bool = false: set = set_auto_exposure_enabled
+
+func set_auto_exposure_enabled(value: bool) -> void:
+ if camera_attributes:
+ auto_exposure = value
+ camera_attributes.auto_exposure_enabled = value
+
+
+## Sets CameraAttributes.auto_exposure_scale.
+@export_range(0.01,16,.005) var auto_exposure_scale: float = 0.2: set = set_auto_exposure_scale
+
+func set_auto_exposure_scale(value: float) -> void:
+ if camera_attributes:
+ auto_exposure_scale = value
+ camera_attributes.auto_exposure_scale = value
+
+
+## Sets CameraAttributesPractical.auto_exposure_min_sensitivity.
+@export_range(0,1600,.5) var auto_exposure_min: float = 0.0: set = set_auto_exposure_min
+
+func set_auto_exposure_min(value: float) -> void:
+ if camera_attributes:
+ auto_exposure_min = value
+ camera_attributes.auto_exposure_min_sensitivity = value
+
+
+## Sets CameraAttributesPractical.auto_exposure_max_sensitivity.
+@export_range(30,64000,.5) var auto_exposure_max: float = 800.0: set = set_auto_exposure_max
+
+func set_auto_exposure_max(value: float) -> void:
+ if camera_attributes:
+ auto_exposure_max = value
+ camera_attributes.auto_exposure_max_sensitivity = value
+
+
+## Sets CameraAttributes.auto_exposure_speed.
+@export_range(0.1,64,.1) var auto_exposure_speed: float = 0.5: set = set_auto_exposure_speed
+
+func set_auto_exposure_speed(value: float) -> void:
+ if camera_attributes:
+ auto_exposure_speed = value
+ camera_attributes.auto_exposure_speed = value
+
+
+@export_subgroup("Night")
+
+
+## Maximum strength of Moon DirectionalLight, visible at night.
+@export_range(0,16,.005) var moon_energy: float = .3: set = set_moon_energy
+
+func set_moon_energy(value: float) -> void:
+ moon_energy = value
+ if moon:
+ sky.moon_light_energy = value
+
+
+## Opacity of Moon DirectionalLight shadow.
+@export_range(0,1,.005) var moon_shadow_opacity: float = 1.0: set = set_moon_shadow_opacity
+
+func set_moon_shadow_opacity(value: float) -> void:
+ moon_shadow_opacity = value
+ if moon:
+ moon.shadow_opacity = value
+
+
+## Enables a different ambient light setting at night.
+@export var night_ambient: bool = true: set = set_night_ambient
+
+func set_night_ambient(value: bool) -> void:
+ night_ambient = value
+ update_day_night(true)
+
+
+## Strength of ambient light at night. Sky_contribution must be < 1. See Environment.ambient_light_energy.
+@export_range(0,1,.005) var night_ambient_min: float = .7: set = set_night_ambient_min
+
+func set_night_ambient_min(value: float) -> void:
+ night_ambient_min = value
+ if night_ambient:
+ update_day_night(true)
+
+
+## Transition time for ambient light change, typically transitioning between day and night.
+@export_range(0,30,.05) var ambient_tween_time: float = 3.: set = set_ambient_tween_time
+
+func set_ambient_tween_time(value: float) -> void:
+ ambient_tween_time = value
+
+
+#####################
+## Setup
+#####################
+
+
+func _notification(what: int) -> void:
+ # Must be after _init and before _enter_tree to properly set vars like 'sky' for setters
+ if what in [ NOTIFICATION_SCENE_INSTANTIATED, NOTIFICATION_ENTER_TREE ]:
+ _initialize()
+
+
+func _initialize() -> void:
+ # Create default environment
+ if environment == null:
+ environment = Environment.new()
+ environment.background_mode = Environment.BG_SKY
+ environment.sky = Sky.new()
+ environment.sky.sky_material = PhysicalSkyMaterial.new()
+ environment.sky.sky_material.use_debanding = false
+ environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
+ environment.ambient_light_sky_contribution = 0.7
+ environment.ambient_light_energy = 1.0
+ environment.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
+ environment.tonemap_mode = Environment.TONE_MAPPER_ACES
+ environment.tonemap_white = 6
+ emit_signal("environment_changed", environment)
+
+ # Create default camera attributes
+ if camera_attributes == null:
+ camera_attributes = CameraAttributesPractical.new()
+
+ # Assign children nodes
+
+ if has_node("SunLight"):
+ sun = $SunLight
+ elif is_inside_tree():
+ sun = DirectionalLight3D.new()
+ sun.name = "SunLight"
+ add_child(sun, true)
+ sun.owner = get_tree().edited_scene_root
+ sun.shadow_enabled = true
+
+ if has_node("MoonLight"):
+ moon = $MoonLight
+ elif is_inside_tree():
+ moon = DirectionalLight3D.new()
+ moon.name = "MoonLight"
+ add_child(moon, true)
+ moon.owner = get_tree().edited_scene_root
+ moon.shadow_enabled = true
+
+ if has_node("Skydome"):
+ sky = $Skydome
+ sky.environment = environment
+ elif is_inside_tree():
+ sky = Skydome.new()
+ sky.name = "Skydome"
+ add_child(sky, true)
+ sky.owner = get_tree().edited_scene_root
+ sky.sun_light_path = "../SunLight"
+ sky.moon_light_path = "../MoonLight"
+ sky.environment = environment
+
+ if has_node("TimeOfDay"):
+ tod = $TimeOfDay
+ elif is_inside_tree():
+ tod = TimeOfDay.new()
+ tod.name = "TimeOfDay"
+ add_child(tod, true)
+ tod.owner = get_tree().edited_scene_root
+ tod.dome_path = "../Skydome"
+ if tod and not tod.time_changed.is_connected(_on_timeofday_updated):
+ tod.time_changed.connect(_on_timeofday_updated)
+
+
+func _enter_tree() -> void:
+ update_day_night(true)
+
+
+func _set(property: StringName, value: Variant) -> bool:
+ match property:
+ "environment":
+ sky.environment = value
+ environment = value
+ emit_signal("environment_changed", environment)
+ return true
+ return false
+
+
+#####################
+## Constants
+#####################
+
+# Node names
+const SKY_INSTANCE:= "_SkyMeshI"
+const FOG_INSTANCE:= "_FogMeshI"
+const MOON_INSTANCE:= "MoonRender"
+const CLOUDS_C_INSTANCE:= "_CloudsCumulusI"
+
+# Shaders
+const _sky_shader: Shader = preload("res://addons/sky_3d/shaders/Sky.gdshader")
+const _pv_sky_shader: Shader = preload("res://addons/sky_3d/shaders/PerVertexSky.gdshader")
+const _clouds_cumulus_shader: Shader = preload("res://addons/sky_3d/shaders/CloudsCumulus.gdshader")
+const _fog_shader: Shader = preload("res://addons/sky_3d/shaders/AtmFog.gdshader")
+
+# Scenes
+const _moon_render: PackedScene = preload("res://addons/sky_3d/assets/resources/MoonRender.tscn")
+
+# Textures
+const _moon_texture: Texture2D = preload("res://addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png")
+const _background_texture: Texture2D = preload("res://addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg")
+const _stars_field_texture: Texture2D = preload("res://addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg")
+const _sun_moon_curve_fade: Curve = preload("res://addons/sky_3d/assets/resources/SunMoonLightFade.tres")
+const _stars_field_noise: Texture2D = preload("res://addons/sky_3d/assets/textures/noise.jpg")
+const _clouds_texture: Texture2D = preload("res://addons/sky_3d/assets/resources/SNoise.tres")
+const _clouds_cumulus_texture: Texture2D = preload("res://addons/sky_3d/assets/textures/noiseClouds.png")
+
+# Skydome
+const DEFAULT_POSITION:= Vector3(0.0000001, 0.0000001, 0.0000001)
+
+# Coords
+const SUN_DIR_P:= "_sun_direction"
+const MOON_DIR_P:= "_moon_direction"
+const MOON_MATRIX:= "_moon_matrix"
+
+# General
+const TEXTURE_P:= "_texture"
+const COLOR_CORRECTION_P:= "_color_correction_params"
+const GROUND_COLOR_P:= "_ground_color"
+const NOISE_TEX:= "_noise_tex"
+const HORIZON_LEVEL = "_horizon_level"
+
+# Atmosphere
+const ATM_DARKNESS_P:= "_atm_darkness"
+const ATM_BETA_RAY_P:= "_atm_beta_ray"
+const ATM_SUN_INTENSITY_P:= "_atm_sun_intensity"
+const ATM_DAY_TINT_P:= "_atm_day_tint"
+const ATM_HORIZON_LIGHT_TINT_P:= "_atm_horizon_light_tint"
+
+const ATM_NIGHT_TINT_P:= "_atm_night_tint"
+const ATM_LEVEL_PARAMS_P:= "_atm_level_params"
+const ATM_THICKNESS_P:= "_atm_thickness"
+const ATM_BETA_MIE_P:= "_atm_beta_mie"
+
+const ATM_SUN_MIE_TINT_P:= "_atm_sun_mie_tint"
+const ATM_SUN_MIE_INTENSITY_P:= "_atm_sun_mie_intensity"
+const ATM_SUN_PARTIAL_MIE_PHASE_P:= "_atm_sun_partial_mie_phase"
+
+const ATM_MOON_MIE_TINT_P:= "_atm_moon_mie_tint"
+const ATM_MOON_MIE_INTENSITY_P:= "_atm_moon_mie_intensity"
+const ATM_MOON_PARTIAL_MIE_PHASE_P:= "_atm_moon_partial_mie_phase"
+
+# Fog
+const ATM_FOG_DENSITY_P:= "_fog_density"
+const ATM_FOG_RAYLEIGH_DEPTH_P:= "_fog_rayleigh_depth"
+const ATM_FOG_MIE_DEPTH_P:= "_fog_mie_depth"
+const ATM_FOG_FALLOFF:= "_fog_falloff"
+const ATM_FOG_START:= "_fog_start"
+const ATM_FOG_END:= "_fog_end"
+
+# Near Space
+const SUN_DISK_COLOR_P:= "_sun_disk_color"
+const SUN_DISK_INTENSITY_P:= "_sun_disk_intensity"
+const SUN_DISK_SIZE_P:= "_sun_disk_size"
+const MOON_COLOR_P:= "_moon_color"
+const MOON_SIZE_P:= "_moon_size"
+const MOON_TEXTURE_P:= "_moon_texture"
+
+# Deep Space
+const DEEP_SPACE_MATRIX_P:= "_deep_space_matrix"
+const BG_COL_P:= "_background_color"
+const BG_TEXTURE_P:= "_background_texture"
+const STARS_COLOR_P:= "_stars_field_color"
+const STARS_TEXTURE_P:= "_stars_field_texture"
+const STARS_SC_P:= "_stars_scintillation"
+const STARS_SC_SPEED_P:= "_stars_scintillation_speed"
+
+# Clouds
+const CLOUDS_THICKNESS:= "_clouds_thickness"
+const CLOUDS_COVERAGE:= "_clouds_coverage"
+const CLOUDS_ABSORPTION:= "_clouds_absorption"
+const CLOUDS_SKY_TINT_FADE:= "_clouds_sky_tint_fade"
+const CLOUDS_INTENSITY:= "_clouds_intensity"
+const CLOUDS_SIZE:= "_clouds_size"
+const CLOUDS_NOISE_FREQ:= "_clouds_noise_freq"
+
+const CLOUDS_UV:= "_clouds_uv"
+const CLOUDS_DIRECTION:= "_clouds_direction"
+const CLOUDS_SPEED:= "_clouds_speed"
+const CLOUDS_TEXTURE:= "_clouds_texture"
+
+const CLOUDS_DAY_COLOR:= "_clouds_day_color"
+const CLOUDS_HORIZON_LIGHT_COLOR:= "_clouds_horizon_light_color"
+const CLOUDS_NIGHT_COLOR:= "_clouds_night_color"
+const CLOUDS_MIE_INTENSITY:= "_clouds_mie_intensity"
+const CLOUDS_PARTIAL_MIE_PHASE:= "_clouds_partial_mie_phase"
diff --git a/addons/sky_3d/src/Sky3D.gd.uid b/addons/sky_3d/src/Sky3D.gd.uid
new file mode 100644
index 0000000..5187df1
--- /dev/null
+++ b/addons/sky_3d/src/Sky3D.gd.uid
@@ -0,0 +1 @@
+uid://bmywk4wvcp0lr
diff --git a/addons/sky_3d/src/Skydome.gd b/addons/sky_3d/src/Skydome.gd
new file mode 100644
index 0000000..d71632b
--- /dev/null
+++ b/addons/sky_3d/src/Skydome.gd
@@ -0,0 +1,1865 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+@tool
+class_name Skydome
+extends Node
+
+
+signal sun_direction_changed(value)
+signal sun_transform_changed(value)
+signal moon_direction_changed(value)
+signal moon_transform_changed(value)
+signal day_night_changed(value)
+signal lights_changed
+
+
+enum SkyQuality {
+ Low, High
+}
+
+enum MoonResolution {
+ R64, R128, R256, R512, R1024,
+}
+
+
+var is_scene_built: bool
+var sky_mesh: MeshInstance3D
+var sky_sphere: SphereMesh
+var moon_render: Node
+var clouds_cumulus_mesh: MeshInstance3D
+var fog_mesh: MeshInstance3D
+
+var sky_material: Material
+var moon_material: Material
+var clouds_cumulus_material: Material
+var fog_material: Material
+
+
+func _ready() -> void:
+ build_scene()
+
+ # Update properties
+ # General
+ update_sky_visible()
+ update_dome_radius()
+ update_color_correction_params()
+ update_ground_color()
+ update_sky_layers()
+ update_sky_render_priority()
+ update_horizon_level()
+
+ # Coords
+ update_sun_coords()
+ update_moon_coords()
+
+ # Atmosphere
+ update_atm_quality()
+ update_beta_ray()
+ update_atm_darkness()
+ update_atm_sun_intensity()
+ update_atm_day_tint()
+ update_atm_horizon_light_tint()
+ update_night_intensity()
+ update_atm_level_params()
+ update_atm_thickness()
+ update_beta_mie()
+ update_atm_sun_mie_tint()
+ update_atm_sun_mie_intensity()
+ update_atm_sun_mie_anisotropy()
+ update_atm_moon_mie_tint()
+ update_atm_moon_mie_intensity()
+ update_atm_moon_mie_anisotropy()
+
+ # Fog
+ update_fog_visible()
+ update_fog_atm_level_params_offset()
+ update_fog_density()
+ update_fog_start()
+ update_fog_end()
+ update_fog_rayleigh_depth()
+ update_fog_mie_depth()
+ update_fog_falloff()
+ update_fog_layers()
+ update_fog_render_priority()
+
+ # Near space
+ update_sun_light_path()
+ update_sun_disk_color()
+ update_sun_disk_intensity()
+ update_sun_disk_size()
+ update_moon_color()
+ update_moon_light_path()
+ update_moon_size()
+ set_enable_set_moon_texture(enable_set_moon_texture)
+ update_moon_texture()
+ update_moon_resolution()
+
+ # Near space lighting
+ update_sun_light_color()
+ update_sun_light_energy()
+ update_moon_light_color()
+ update_moon_light_energy()
+
+ # Deep space
+ update_deep_space_basis()
+ set_set_background_texture(set_background_texture)
+ update_background_color()
+ update_background_texture()
+ update_stars_field_color()
+ set_set_stars_field_texture(set_stars_field_texture)
+ update_stars_field_texture()
+ update_stars_scintillation()
+ update_stars_scintillation_speed()
+
+ # Clouds
+ update_clouds_thickness()
+ update_clouds_coverage()
+ update_clouds_absorption()
+ update_clouds_sky_tint_fade()
+ update_clouds_intensity()
+ update_clouds_size()
+ update_clouds_uv()
+ update_clouds_direction()
+ update_clouds_speed()
+ set_set_clouds_texture(set_clouds_texture)
+ update_clouds_texture()
+
+ # Clouds cumulus
+ update_clouds_cumulus_visible()
+ update_clouds_cumulus_day_color()
+ update_clouds_cumulus_horizon_light_color()
+ update_clouds_cumulus_night_color()
+ update_clouds_cumulus_thickness()
+ update_clouds_cumulus_coverage()
+ update_clouds_cumulus_absorption()
+ update_clouds_cumulus_noise_freq()
+ update_clouds_cumulus_intensity()
+ update_clouds_cumulus_mie_intensity()
+ update_clouds_cumulus_mie_anisotropy()
+ update_clouds_cumulus_size()
+ update_clouds_cumulus_direction()
+ update_clouds_cumulus_speed()
+ set_set_clouds_cumulus_texture(set_clouds_cumulus_texture)
+ update_clouds_cumulus_texture()
+
+ # Environment
+ __update_environment()
+
+
+func build_scene() -> void:
+ if is_scene_built:
+ return
+
+ # Sky Mesh
+ sky_mesh = MeshInstance3D.new()
+ sky_mesh.name = Sky3D.SKY_INSTANCE
+ sky_sphere = SphereMesh.new()
+ sky_mesh.mesh = sky_sphere
+ sky_material = ShaderMaterial.new()
+ sky_material.shader = Sky3D._pv_sky_shader
+ sky_material.set_shader_parameter(Sky3D.NOISE_TEX, Sky3D._stars_field_noise)
+ sky_mesh.material_override = sky_material
+ __setup_mesh_instance(sky_mesh, Sky3D.DEFAULT_POSITION)
+ add_child(sky_mesh)
+
+ # Moon Render
+ moon_render = Sky3D._moon_render.instantiate()
+ moon_render.name = Sky3D.MOON_INSTANCE
+ var moon_mesh = moon_render.get_node("MoonTransform/Camera3D/Mesh") as MeshInstance3D
+ moon_material = moon_mesh.material_override
+ add_child(moon_render)
+
+ # Clouds Cumulus Mesh
+ clouds_cumulus_mesh = MeshInstance3D.new()
+ clouds_cumulus_mesh.name = Sky3D.CLOUDS_C_INSTANCE
+ var clouds_cumulus_sphere = SphereMesh.new()
+ clouds_cumulus_sphere.radial_segments = 8
+ clouds_cumulus_sphere.rings = 8
+ clouds_cumulus_mesh.mesh = clouds_cumulus_sphere
+ clouds_cumulus_material = ShaderMaterial.new()
+ clouds_cumulus_material.shader = Sky3D._clouds_cumulus_shader
+ clouds_cumulus_mesh.material_override = clouds_cumulus_material
+ __setup_mesh_instance(clouds_cumulus_mesh, Sky3D.DEFAULT_POSITION)
+ add_child(clouds_cumulus_mesh)
+
+ fog_mesh = MeshInstance3D.new()
+ fog_mesh.name = Sky3D.FOG_INSTANCE
+ var fog_screen_quad = QuadMesh.new()
+ var size: Vector2
+ size.x = 2.0
+ size.y = 2.0
+ fog_screen_quad.size = size
+ fog_mesh.mesh = fog_screen_quad
+ fog_material = ShaderMaterial.new()
+ fog_material.shader = Sky3D._fog_shader
+ fog_material.render_priority = 127
+ fog_mesh.material_override = fog_material
+ __setup_mesh_instance(fog_mesh, Vector3.ZERO)
+ add_child(fog_mesh)
+
+ is_scene_built = true
+
+
+func __setup_mesh_instance(target: MeshInstance3D, origin: Vector3) -> void:
+ target.transform.origin = origin
+ target.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
+ target.custom_aabb = AABB(Vector3(-1e31, -1e31, -1e31), Vector3(2e31, 2e31, 2e31))
+
+
+#####################
+## Global
+#####################
+
+var sky_visible: bool = true: set = set_sky_visible
+var dome_radius: float = 10.0: set = set_dome_radius
+var tonemap_level: float = 0.0: set = set_tonemap_level
+var exposure: float = 1.3: set = set_exposure
+var ground_color:= Color(0.3, 0.3, 0.3, 1.0): set = set_ground_color
+var sky_layers: int = 4: set = set_sky_layers
+var sky_render_priority: int = -128: set = set_sky_render_priority
+var horizon_level: float = 0.0: set = set_horizon_level
+
+
+func set_sky_visible(value: bool) -> void:
+ if value == sky_visible:
+ return
+ sky_visible = value
+ update_sky_visible()
+
+
+func update_sky_visible() -> void:
+ if !is_scene_built:
+ return
+ sky_mesh.visible = sky_visible
+
+
+func set_dome_radius(value: float) -> void:
+ if value == dome_radius:
+ return
+ dome_radius = value
+ update_dome_radius()
+
+
+func update_dome_radius() -> void:
+ if !is_scene_built:
+ return
+ sky_mesh.scale = dome_radius * Vector3.ONE
+ clouds_cumulus_mesh.scale = dome_radius * Vector3.ONE
+
+
+func set_tonemap_level(value: float) -> void:
+ if value == tonemap_level:
+ return
+ tonemap_level = value
+ update_color_correction_params()
+
+
+func set_exposure(value: float) -> void:
+ if value == exposure:
+ return
+ exposure = value
+ update_color_correction_params()
+
+
+func update_color_correction_params() -> void:
+ if !is_scene_built:
+ return
+ var p: Vector2
+ p.x = tonemap_level
+ p.y = exposure
+ sky_material.set_shader_parameter(Sky3D.COLOR_CORRECTION_P, p)
+ fog_material.set_shader_parameter(Sky3D.COLOR_CORRECTION_P, p)
+
+
+
+func set_ground_color(value: Color) -> void:
+ if value == ground_color:
+ return
+ ground_color = value
+ update_ground_color()
+
+
+func update_ground_color() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.GROUND_COLOR_P, ground_color)
+
+
+func set_sky_layers(value: int) -> void:
+ if sky_layers == value:
+ return
+ sky_layers = value
+ update_sky_layers()
+
+
+func update_sky_layers() -> void:
+ if !is_scene_built:
+ return
+ sky_mesh.layers = sky_layers
+ clouds_cumulus_mesh.layers = sky_layers
+
+
+func set_sky_render_priority(value: int) -> void:
+ if value == sky_render_priority:
+ return
+ sky_render_priority = value
+ update_sky_render_priority()
+
+
+func update_sky_render_priority() -> void:
+ if !is_scene_built:
+ return
+ sky_material.render_priority = sky_render_priority
+ clouds_cumulus_material.render_priority = sky_render_priority + 1
+
+
+func set_horizon_level(value: float) -> void:
+ if value == horizon_level:
+ return
+ horizon_level = value
+ update_horizon_level()
+
+
+func update_horizon_level() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.HORIZON_LEVEL, horizon_level)
+
+
+#####################
+## Sun Coords
+#####################
+
+var sun_azimuth: float = 0.0: set = set_sun_azimuth
+var sun_altitude: float = -27.387: set = set_sun_altitude
+var __finish_set_sun_pos: bool = false
+var __sun_transform:= Transform3D()
+
+
+func set_sun_azimuth(value: float) -> void:
+ if value == sun_azimuth:
+ return
+ sun_azimuth = value
+ update_sun_coords()
+
+
+func set_sun_altitude(value: float) -> void:
+ if value == sun_altitude:
+ return
+ sun_altitude = value
+ update_sun_coords()
+
+
+func get_sun_transform() -> Transform3D:
+ return __sun_transform
+
+
+func sun_direction() -> Vector3:
+ return __sun_transform.origin - Sky3D.DEFAULT_POSITION
+
+
+func update_sun_coords() -> void:
+ if !is_scene_built:
+ return
+
+ var azimuth: float = sun_azimuth * TOD_Math.DEG_TO_RAD
+ var altitude: float = sun_altitude * TOD_Math.DEG_TO_RAD
+
+ __finish_set_sun_pos = false
+ if not __finish_set_sun_pos:
+ __sun_transform.origin = TOD_Math.to_orbit(altitude, azimuth)
+ __finish_set_sun_pos = true
+
+ if __finish_set_sun_pos:
+ __sun_transform = __sun_transform.looking_at(Sky3D.DEFAULT_POSITION, Vector3.LEFT)
+
+ __set_day_state(altitude)
+ emit_signal("sun_transform_changed", __sun_transform)
+ emit_signal("sun_transform_changed", sun_direction())
+
+ sky_material.set_shader_parameter(Sky3D.SUN_DIR_P, sun_direction())
+ fog_material.set_shader_parameter(Sky3D.SUN_DIR_P, sun_direction())
+ moon_material.set_shader_parameter(Sky3D.SUN_DIR_P, sun_direction())
+ clouds_cumulus_material.set_shader_parameter(Sky3D.SUN_DIR_P, sun_direction())
+
+ if __sun_light_node != null:
+ #if __sun_light_node.light_energy > 0.0 && (abs(sun_altitude) < 90.0
+ if __sun_light_node.light_energy > 0.0:
+ __sun_light_node.transform = __sun_transform
+
+ update_night_intensity()
+ update_sun_light_color()
+ update_sun_light_energy()
+ update_moon_light_energy()
+ __update_environment()
+
+
+#####################
+## Moon Coords
+#####################
+
+var moon_azimuth: float = 5.0: set = set_moon_azimuth
+var moon_altitude: float = -80.0: set = set_moon_altitude
+var __finish_set_moon_pos = false
+var __moon_transform:= Transform3D()
+
+
+func set_moon_azimuth(value: float) -> void:
+ if value == moon_azimuth:
+ return
+ moon_azimuth = value
+ update_moon_coords()
+
+
+func set_moon_altitude(value: float) -> void:
+ if value == moon_altitude:
+ return
+ moon_altitude = value
+ update_moon_coords()
+
+
+func get_moon_transform() -> Transform3D:
+ return __moon_transform
+
+
+func moon_direction() -> Vector3:
+ return __moon_transform.origin - Sky3D.DEFAULT_POSITION
+
+
+func update_moon_coords() -> void:
+ if !is_scene_built:
+ return
+
+ var azimuth: float = moon_azimuth * TOD_Math.DEG_TO_RAD
+ var altitude: float = moon_altitude * TOD_Math.DEG_TO_RAD
+
+ __finish_set_moon_pos = false
+ if not __finish_set_moon_pos:
+ __moon_transform.origin = TOD_Math.to_orbit(altitude, azimuth)
+ __finish_set_moon_pos = true
+
+ if __finish_set_moon_pos:
+ __moon_transform = __moon_transform.looking_at(Sky3D.DEFAULT_POSITION, Vector3.LEFT)
+
+ emit_signal("moon_transform_changed", __moon_transform)
+ emit_signal("moon_direction_changed", moon_direction())
+
+ sky_material.set_shader_parameter(Sky3D.MOON_DIR_P, moon_direction())
+ fog_material.set_shader_parameter(Sky3D.MOON_DIR_P, moon_direction())
+ moon_material.set_shader_parameter(Sky3D.MOON_DIR_P, moon_direction())
+ clouds_cumulus_material.set_shader_parameter(Sky3D.MOON_DIR_P, moon_direction())
+ sky_material.set_shader_parameter(Sky3D.MOON_MATRIX, __moon_transform.basis.inverse())
+
+ var moon_instance_transform = moon_render.get_node("MoonTransform") as Node3D
+ moon_instance_transform.transform = __moon_transform
+
+ if __moon_light_node != null:
+ #if __moon_light_node.light_energy > 0.0 && (abs(moon_altitude) < 90.0):
+ if __moon_light_node.light_energy > 0.0:
+ __moon_light_node.transform = __moon_transform
+
+ __moon_light_altitude_mult = TOD_Math.saturate(moon_direction().y)
+
+ update_night_intensity()
+ set_moon_light_color(moon_light_color)
+ update_moon_light_energy()
+ __update_environment()
+
+
+#####################
+## Atmosphere
+#####################
+
+var atm_quality: int = 1: set = set_atm_quality
+var atm_wavelenghts:= Vector3(680.0, 550.0, 440.0): set = set_atm_wavelenghts
+var atm_darkness: float = 0.5: set = set_atm_darkness
+var atm_sun_intensity: float = 18.0: set = set_atm_sun_intensity
+var atm_day_tint:= Color(0.807843, 0.909804, 1.0): set = set_atm_day_tint
+var atm_horizon_light_tint:= Color(0.980392, 0.635294, 0.462745, 1.0): set = set_atm_horizon_light_tint
+var atm_enable_moon_scatter_mode: bool = false: set = set_atm_enable_moon_scatter_mode
+var atm_night_tint:= Color(0.168627, 0.2, 0.25098, 1.0): set = set_atm_night_tint
+var atm_level_params:= Vector3(1.0, 0.0, 0.0): set = set_atm_level_params
+var atm_thickness: float = 0.7: set = set_atm_thickness
+var atm_mie: float = 0.07: set = set_atm_mie
+var atm_turbidity: float = 0.001: set = set_atm_turbidity
+var atm_sun_mie_tint:= Color(1.0, 1.0, 1.0, 1.0): set = set_atm_sun_mie_tint
+var atm_sun_mie_intensity: float = 1.0: set = set_atm_sun_mie_intensity
+var atm_sun_mie_anisotropy: float = 0.8: set = set_atm_sun_mie_anisotropy
+var atm_moon_mie_tint:= Color(0.137255, 0.184314, 0.292196): set = set_atm_moon_mie_tint
+var atm_moon_mie_intensity: float = 0.7: set = set_atm_moon_mie_intensity
+var atm_moon_mie_anisotropy: float = 0.8: set = set_atm_moon_mie_anisotropy
+
+
+func set_atm_quality(value: int) -> void:
+ if value == atm_quality:
+ return
+ atm_quality = value
+ update_atm_quality()
+
+
+func update_atm_quality() -> void:
+ if !is_scene_built:
+ return
+ if atm_quality == SkyQuality.Low:
+ sky_material.shader = Sky3D._sky_shader
+ sky_sphere.radial_segments = 16
+ sky_sphere.rings = 8
+ else:
+ sky_material.shader = Sky3D._pv_sky_shader
+ sky_sphere.radial_segments = 64
+ sky_sphere.rings = 64
+
+
+func set_atm_wavelenghts(value : Vector3) -> void:
+ if value == atm_wavelenghts:
+ return
+ atm_wavelenghts = value
+ update_beta_ray()
+
+
+func update_beta_ray() -> void:
+ if !is_scene_built:
+ return
+
+ var wll = ScatterLib.compute_wavelenghts_lambda(atm_wavelenghts)
+ var wls = ScatterLib.compute_wavlenghts(wll)
+ var betaRay = ScatterLib.compute_beta_ray(wls)
+ sky_material.set_shader_parameter(Sky3D.ATM_BETA_RAY_P, betaRay)
+ fog_material.set_shader_parameter(Sky3D.ATM_BETA_RAY_P, betaRay)
+
+
+func set_atm_darkness(value: float) -> void:
+ if value == atm_darkness:
+ return
+ atm_darkness = value
+ update_atm_darkness()
+
+
+func update_atm_darkness() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_DARKNESS_P, atm_darkness)
+ fog_material.set_shader_parameter(Sky3D.ATM_DARKNESS_P, atm_darkness)
+
+
+func set_atm_sun_intensity(value: float) -> void:
+ if value == atm_sun_intensity:
+ return
+ atm_sun_intensity = value
+ update_atm_sun_intensity()
+
+
+func update_atm_sun_intensity() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_SUN_INTENSITY_P, atm_sun_intensity)
+ fog_material.set_shader_parameter(Sky3D.ATM_SUN_INTENSITY_P, atm_sun_intensity)
+
+
+func set_atm_day_tint(value: Color) -> void:
+ if value == atm_day_tint:
+ return
+ atm_day_tint = value
+ update_atm_day_tint()
+
+
+func update_atm_day_tint() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_DAY_TINT_P, atm_day_tint)
+ fog_material.set_shader_parameter(Sky3D.ATM_DAY_TINT_P, atm_day_tint)
+
+
+func set_atm_horizon_light_tint(value: Color) -> void:
+ if value == atm_horizon_light_tint:
+ return
+ atm_horizon_light_tint = value
+ update_atm_horizon_light_tint()
+
+
+func update_atm_horizon_light_tint() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_HORIZON_LIGHT_TINT_P, atm_horizon_light_tint)
+ fog_material.set_shader_parameter(Sky3D.ATM_HORIZON_LIGHT_TINT_P, atm_horizon_light_tint)
+
+
+func set_atm_enable_moon_scatter_mode(value: bool) -> void:
+ if value == atm_enable_moon_scatter_mode:
+ return
+ atm_enable_moon_scatter_mode = value
+ update_night_intensity()
+
+
+func set_atm_night_tint(value: Color) -> void:
+ if value == atm_night_tint:
+ return
+ atm_night_tint = value
+ update_night_intensity()
+
+
+func update_night_intensity() -> void:
+ if !is_scene_built:
+ return
+
+ var tint: Color = atm_night_tint * atm_night_intensity()
+ sky_material.set_shader_parameter(Sky3D.ATM_NIGHT_TINT_P, tint)
+ fog_material.set_shader_parameter(Sky3D.ATM_NIGHT_TINT_P, atm_night_tint * fog_atm_night_intensity())
+
+ set_atm_moon_mie_intensity(atm_moon_mie_intensity)
+
+
+func set_atm_level_params(value: Vector3) -> void:
+ if value == atm_level_params:
+ return
+ atm_level_params = value
+ update_atm_level_params()
+
+
+func update_atm_level_params() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_LEVEL_PARAMS_P, atm_level_params)
+ fog_material.set_shader_parameter(Sky3D.ATM_LEVEL_PARAMS_P, atm_level_params + fog_atm_level_params_offset)
+
+
+func set_atm_thickness(value: float) -> void:
+ if value == atm_thickness:
+ return
+ atm_thickness = value
+ update_atm_thickness()
+
+
+func update_atm_thickness() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_THICKNESS_P, atm_thickness)
+ fog_material.set_shader_parameter(Sky3D.ATM_THICKNESS_P, atm_thickness)
+
+
+func set_atm_mie(value: float) -> void:
+ if value == atm_mie:
+ return
+ atm_mie = value
+ update_beta_mie()
+
+
+func set_atm_turbidity(value: float) -> void:
+ if value == atm_turbidity:
+ return
+ atm_turbidity = value
+ update_beta_mie()
+
+
+func update_beta_mie() -> void:
+ if !is_scene_built:
+ return
+
+ var bm = ScatterLib.compute_beta_mie(atm_mie, atm_turbidity)
+ sky_material.set_shader_parameter(Sky3D.ATM_BETA_MIE_P, bm)
+ fog_material.set_shader_parameter(Sky3D.ATM_BETA_MIE_P, bm)
+
+
+func set_atm_sun_mie_tint(value: Color) -> void:
+ if value == atm_sun_mie_tint:
+ return
+ atm_sun_mie_tint = value
+ update_atm_sun_mie_tint()
+
+
+func update_atm_sun_mie_tint() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_SUN_MIE_TINT_P, atm_sun_mie_tint)
+ fog_material.set_shader_parameter(Sky3D.ATM_SUN_MIE_TINT_P, atm_sun_mie_tint)
+ clouds_cumulus_material.set_shader_parameter(Sky3D.ATM_SUN_MIE_TINT_P, atm_sun_mie_tint)
+
+
+func set_atm_sun_mie_intensity(value: float) -> void:
+ if value == atm_sun_mie_intensity:
+ return
+ atm_sun_mie_intensity = value
+ update_atm_sun_mie_intensity()
+
+
+func update_atm_sun_mie_intensity() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_SUN_MIE_INTENSITY_P, atm_sun_mie_intensity)
+ fog_material.set_shader_parameter(Sky3D.ATM_SUN_MIE_INTENSITY_P, atm_sun_mie_intensity)
+
+
+func set_atm_sun_mie_anisotropy(value: float) -> void:
+ if value == atm_sun_mie_anisotropy:
+ return
+ atm_sun_mie_anisotropy = value
+ update_atm_sun_mie_anisotropy()
+
+
+func update_atm_sun_mie_anisotropy() -> void:
+ if !is_scene_built:
+ return
+ var partial = ScatterLib.get_partial_mie_phase(atm_sun_mie_anisotropy)
+ sky_material.set_shader_parameter(Sky3D.ATM_SUN_PARTIAL_MIE_PHASE_P, partial)
+ fog_material.set_shader_parameter(Sky3D.ATM_SUN_PARTIAL_MIE_PHASE_P, partial)
+
+
+func set_atm_moon_mie_tint(value: Color) -> void:
+ if value == atm_moon_mie_tint:
+ return
+ atm_moon_mie_tint = value
+ update_atm_moon_mie_tint()
+
+
+func update_atm_moon_mie_tint() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_MOON_MIE_TINT_P, atm_moon_mie_tint)
+ fog_material.set_shader_parameter(Sky3D.ATM_MOON_MIE_TINT_P, atm_moon_mie_tint)
+ clouds_cumulus_material.set_shader_parameter(Sky3D.ATM_MOON_MIE_TINT_P, atm_moon_mie_tint)
+
+
+func set_atm_moon_mie_intensity(value: float) -> void:
+ if value == atm_moon_mie_intensity:
+ return
+ atm_moon_mie_intensity = value
+ update_atm_sun_mie_intensity()
+
+
+func update_atm_moon_mie_intensity() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.ATM_MOON_MIE_INTENSITY_P, atm_moon_mie_intensity * atm_moon_phases_mult())
+ fog_material.set_shader_parameter(Sky3D.ATM_MOON_MIE_INTENSITY_P, atm_moon_mie_intensity * atm_moon_phases_mult())
+
+
+func set_atm_moon_mie_anisotropy(value: float) -> void:
+ if value == atm_moon_mie_anisotropy:
+ return
+ atm_moon_mie_anisotropy = value
+ update_atm_moon_mie_anisotropy()
+
+
+func update_atm_moon_mie_anisotropy() -> void:
+ if !is_scene_built:
+ return
+ var partial = ScatterLib.get_partial_mie_phase(atm_moon_mie_anisotropy)
+ sky_material.set_shader_parameter(Sky3D.ATM_MOON_PARTIAL_MIE_PHASE_P, partial)
+ fog_material.set_shader_parameter(Sky3D.ATM_MOON_PARTIAL_MIE_PHASE_P, partial)
+
+
+func atm_moon_phases_mult() -> float:
+ if not atm_enable_moon_scatter_mode:
+ return atm_night_intensity()
+ return TOD_Math.saturate(-sun_direction().dot(moon_direction()) + 0.60)
+
+
+func atm_night_intensity() -> float:
+ if not atm_enable_moon_scatter_mode:
+ return TOD_Math.saturate(-sun_direction().y + 0.30)
+ return TOD_Math.saturate(moon_direction().y) * atm_moon_phases_mult()
+
+
+func fog_atm_night_intensity() -> float:
+ if not atm_enable_moon_scatter_mode:
+ return TOD_Math.saturate(-sun_direction().y + 0.70)
+ return TOD_Math.saturate(-sun_direction().y) * atm_moon_phases_mult()
+
+
+#####################
+## Fog
+#####################
+
+var fog_visible: bool = true: set = set_fog_visible
+var fog_atm_level_params_offset:= Vector3(0.0, 0.0, -1.0): set = set_fog_atm_level_params_offset
+var fog_density: float = 0.00015: set = set_fog_density
+var fog_start: float = 0.0: set = set_fog_start
+var fog_end: float = 1000: set = set_fog_end
+var fog_rayleigh_depth: float = 0.116: set = set_fog_rayleigh_depth
+var fog_mie_depth: float = 0.0001: set = set_fog_mie_depth
+var fog_falloff: float = 3.0: set = set_fog_falloff
+var fog_layers: int = 524288: set = set_fog_layers
+var fog_render_priority: int = 123: set = set_fog_render_priority
+
+
+func set_fog_visible(value: bool) -> void:
+ if value == fog_visible:
+ return
+ fog_visible = value
+ update_fog_visible()
+
+
+func update_fog_visible() -> void:
+ if !is_scene_built:
+ return
+ fog_mesh.visible = fog_visible
+
+
+func set_fog_atm_level_params_offset(value: Vector3) -> void:
+ if value == fog_atm_level_params_offset:
+ return
+ fog_atm_level_params_offset = value
+ update_fog_atm_level_params_offset()
+
+
+func update_fog_atm_level_params_offset() -> void:
+ if !is_scene_built:
+ return
+ fog_material.set_shader_parameter(Sky3D.ATM_LEVEL_PARAMS_P, atm_level_params + fog_atm_level_params_offset)
+
+
+func set_fog_density(value: float) -> void:
+ if value == fog_density:
+ return
+ fog_density = value
+ update_fog_density()
+
+
+func update_fog_density() -> void:
+ if !is_scene_built:
+ return
+ fog_material.set_shader_parameter(Sky3D.ATM_FOG_DENSITY_P, fog_density)
+
+
+func set_fog_start(value: float) -> void:
+ if value == fog_start:
+ return
+ fog_start = value
+ update_fog_start()
+
+
+func update_fog_start() -> void:
+ if !is_scene_built:
+ return
+ fog_material.set_shader_parameter(Sky3D.ATM_FOG_START, fog_start)
+
+
+func set_fog_end(value: float) -> void:
+ if value == fog_end:
+ return
+ fog_end = value
+ update_fog_end()
+
+
+func update_fog_end() -> void:
+ if !is_scene_built:
+ return
+ fog_material.set_shader_parameter(Sky3D.ATM_FOG_END, fog_end)
+
+
+func set_fog_rayleigh_depth(value: float) -> void:
+ if value == fog_rayleigh_depth:
+ return
+ fog_rayleigh_depth = value
+ update_fog_rayleigh_depth()
+
+
+func update_fog_rayleigh_depth() -> void:
+ if !is_scene_built:
+ return
+ fog_material.set_shader_parameter(Sky3D.ATM_FOG_RAYLEIGH_DEPTH_P, fog_rayleigh_depth)
+
+
+func set_fog_mie_depth(value: float) -> void:
+ if value == fog_mie_depth:
+ return
+ fog_mie_depth = value
+ update_fog_mie_depth()
+
+
+func update_fog_mie_depth() -> void:
+ if !is_scene_built:
+ return
+ fog_material.set_shader_parameter(Sky3D.ATM_FOG_MIE_DEPTH_P, fog_mie_depth)
+
+
+func set_fog_falloff(value: float) -> void:
+ if value == fog_falloff:
+ return
+ fog_falloff = value
+ update_fog_falloff()
+
+
+func update_fog_falloff() -> void:
+ if !is_scene_built:
+ return
+ fog_material.set_shader_parameter(Sky3D.ATM_FOG_FALLOFF, fog_falloff)
+
+
+func set_fog_layers(value: int) -> void:
+ if value == fog_layers:
+ return
+ fog_layers = value
+ update_fog_layers()
+
+
+func update_fog_layers() -> void:
+ if !is_scene_built:
+ return
+ fog_mesh.layers = fog_layers
+
+
+func set_fog_render_priority(value: int) -> void:
+ if value == fog_render_priority:
+ return
+ fog_render_priority = value
+ update_fog_render_priority()
+
+
+func update_fog_render_priority() -> void:
+ if !is_scene_built:
+ return
+ fog_material.render_priority = fog_render_priority
+
+
+#####################
+## Near space
+#####################
+
+var sun_disk_color:= Color(0.996094, 0.541334, 0.140076): set = set_sun_disk_color
+var sun_disk_intensity: float = 2.0: set = set_sun_disk_intensity
+var sun_disk_size: float = 0.015: set = set_sun_disk_size
+var moon_color:= Color.WHITE: set = set_moon_color
+var moon_size: float = 0.07: set = set_moon_size
+var enable_set_moon_texture = false: set = set_enable_set_moon_texture
+var moon_texture: Texture2D = null: set = set_moon_texture
+var moon_resolution: int = MoonResolution.R256: set = set_moon_resolution
+
+
+func set_sun_disk_color(value: Color) -> void:
+ if value == sun_disk_color:
+ return
+ sun_disk_color = value
+ update_sun_disk_color()
+
+
+func update_sun_disk_color() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.SUN_DISK_COLOR_P, sun_disk_color)
+
+
+func set_sun_disk_intensity(value: float) -> void:
+ if value == sun_disk_intensity:
+ return
+ sun_disk_intensity = value
+ update_sun_disk_intensity()
+
+
+func update_sun_disk_intensity() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.SUN_DISK_INTENSITY_P, sun_disk_intensity)
+
+
+func set_sun_disk_size(value: float) -> void:
+ if value == sun_disk_size:
+ return
+ sun_disk_size = value
+ update_sun_disk_size()
+
+
+func update_sun_disk_size() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.SUN_DISK_SIZE_P, sun_disk_size)
+
+
+func set_moon_color(value: Color) -> void:
+ if value == moon_color:
+ return
+ moon_color = value
+ update_moon_color()
+
+
+func update_moon_color() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.MOON_COLOR_P, moon_color)
+
+
+func set_moon_size(value: float) -> void:
+ if value == moon_size:
+ return
+ moon_size = value
+ update_moon_size()
+
+
+func update_moon_size() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.MOON_SIZE_P, moon_size)
+
+
+func set_enable_set_moon_texture(value: bool) -> void:
+ enable_set_moon_texture = value
+ if not value:
+ set_moon_texture(Sky3D._moon_texture)
+ notify_property_list_changed()
+
+
+func set_moon_texture(value: Texture2D) -> void:
+ if value == moon_texture:
+ return
+ moon_texture = value
+ update_moon_texture()
+
+
+func update_moon_texture() -> void:
+ if !is_scene_built:
+ return
+ moon_material.set_shader_parameter(Sky3D.TEXTURE_P, moon_texture)
+
+
+func set_moon_resolution(value: int) -> void:
+ if value == moon_resolution:
+ return
+ moon_resolution = value
+ update_moon_resolution()
+
+
+func update_moon_resolution() -> void:
+ if !is_scene_built:
+ return
+ match moon_resolution:
+ MoonResolution.R64: moon_render.size = Vector2.ONE * 64
+ MoonResolution.R128: moon_render.size = Vector2.ONE * 128
+ MoonResolution.R256: moon_render.size = Vector2.ONE * 256
+ MoonResolution.R512: moon_render.size = Vector2.ONE * 512
+ MoonResolution.R1024: moon_render.size = Vector2.ONE * 1024
+ sky_material.set_shader_parameter(Sky3D.MOON_TEXTURE_P, moon_render.get_texture())
+
+
+#####################
+## Sun
+#####################
+
+# Original sun light (0.984314, 0.843137, 0.788235)
+# Original sun horizon (1.0, 0.384314, 0.243137, 1.0)
+
+@export var sun_light_enable: bool = true
+var sun_light_color:= Color.WHITE : set = set_sun_light_color
+var sun_horizon_light_color:= Color(.98, 0.523, 0.294, 1.0): set = set_sun_horizon_light_color
+var sun_light_energy: float = 1.0: set = set_sun_light_energy
+var __sun_light_node: DirectionalLight3D = null
+var sun_light_path: NodePath: set = set_sun_light_path
+
+
+func set_sun_light_color(value: Color) -> void:
+ if value == sun_light_color:
+ return
+ sun_light_color = value
+ update_sun_light_color()
+
+
+func update_sun_light_color() -> void:
+ if __sun_light_node == null:
+ return
+ var sun_light_altitude_mult: float = TOD_Math.saturate(sun_direction().y * 2)
+ __sun_light_node.light_color = TOD_Math.plerp_color(sun_horizon_light_color, sun_light_color, sun_light_altitude_mult)
+
+
+func set_sun_horizon_light_color(value: Color) -> void:
+ if value == sun_horizon_light_color:
+ return
+ sun_horizon_light_color = value
+ update_sun_light_color()
+
+
+func set_sun_light_energy(value: float) -> void:
+ if value == sun_light_energy:
+ return
+ sun_light_energy = value
+ update_sun_light_energy()
+
+
+func update_sun_light_energy() -> void:
+ if __sun_light_node != null:
+ # Light energy should depend on how much of the sun disk is visible
+ var y = sun_direction().y
+ var sun_light_factor: float = TOD_Math.saturate((y + sun_disk_size) / (2 * sun_disk_size));
+ __sun_light_node.light_energy = TOD_Math.lerp_f(0.0, sun_light_energy, sun_light_factor)
+
+
+func set_sun_light_path(value: NodePath) -> void:
+ sun_light_path = value
+ update_sun_light_path()
+ update_sun_coords()
+
+
+func update_sun_light_path() -> void:
+ if sun_light_path != null:
+ __sun_light_node = get_node_or_null(sun_light_path) as DirectionalLight3D
+ else:
+ __sun_light_node = null
+
+
+#####################
+## Moon
+#####################
+
+@export var moon_light_enable: bool = true
+var moon_light_color:= Color(0.572549, 0.776471, 0.956863, 1.0): set = set_moon_light_color
+var moon_light_energy: float = 0.3: set = set_moon_light_energy
+var moon_light_path: NodePath: set = set_moon_light_path
+var __moon_light_node: DirectionalLight3D
+var __moon_light_altitude_mult: float = 0.0
+
+
+func set_moon_light_color(value: Color) -> void:
+ if value == moon_light_color:
+ return
+ moon_light_color = value
+ update_moon_light_color()
+
+
+func update_moon_light_color() -> void:
+ if __moon_light_node == null:
+ return
+ __moon_light_node.light_color = moon_light_color
+
+
+func set_moon_light_energy(value: float) -> void:
+ moon_light_energy = value
+ update_moon_light_energy()
+
+
+func update_moon_light_energy() -> void:
+ if __moon_light_node == null:
+ return
+
+ var l: float = TOD_Math.lerp_f(0.0, moon_light_energy, __moon_light_altitude_mult)
+ l*= atm_moon_phases_mult()
+
+ var fade = (1.0 - sun_direction().y) * 0.5
+ __moon_light_node.light_energy = l * Sky3D._sun_moon_curve_fade.sample_baked(fade)
+
+
+func set_moon_light_path(value: NodePath) -> void:
+ moon_light_path = value
+ update_moon_light_path()
+ update_moon_coords()
+
+
+func update_moon_light_path() -> void:
+ if moon_light_path != null:
+ __moon_light_node = get_node_or_null(moon_light_path) as DirectionalLight3D
+ else:
+ __moon_light_node = null
+
+
+#####################
+## Deep space
+#####################
+
+var deep_space_euler:= Vector3(-0.752, 2.56, 0.0): set = set_deep_space_euler
+var deep_space_quat:= Quaternion.IDENTITY: set = set_deep_space_quat
+var __deep_space_basis: Basis
+var background_color:= Color(0.709804, 0.709804, 0.709804, 0.854902): set = set_background_color
+
+
+func set_deep_space_euler(value: Vector3) -> void:
+ deep_space_euler = value
+ __deep_space_basis = Basis.from_euler(value)
+ update_deep_space_basis()
+ var quat: Quaternion = __deep_space_basis.get_rotation_quaternion()
+ if deep_space_quat.angle_to(quat) < 0.01:
+ return
+ deep_space_quat = quat
+
+
+func set_deep_space_quat(value: Quaternion) -> void:
+ deep_space_quat = value
+ __deep_space_basis = Basis(value)
+ update_deep_space_basis()
+ var euler: Vector3 = __deep_space_basis.get_euler()
+ if deep_space_euler.angle_to(euler) < 0.01:
+ return
+ deep_space_euler = euler
+
+
+func update_deep_space_basis() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.DEEP_SPACE_MATRIX_P, __deep_space_basis)
+
+
+func set_background_color(value: Color) -> void:
+ if value == background_color:
+ return
+ background_color = value
+ update_background_color()
+
+
+func update_background_color() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.BG_COL_P, background_color)
+
+
+var set_background_texture: bool = false: set = set_set_background_texture
+var background_texture: Texture2D = null: set = _set_background_texture
+var stars_field_color:= Color.WHITE: set = set_stars_field_color
+var set_stars_field_texture: bool = false: set = set_set_stars_field_texture
+func set_set_background_texture(value: bool) -> void:
+ set_background_texture = value
+ if not value:
+ _set_background_texture(Sky3D._background_texture)
+ notify_property_list_changed()
+
+
+func update_background_texture() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.BG_TEXTURE_P, background_texture)
+
+
+func _set_background_texture(value: Texture2D) -> void:
+ if value == background_texture:
+ return
+ background_texture = value
+ update_background_texture()
+
+
+func update_stars_field_color() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.STARS_COLOR_P, stars_field_color)
+
+
+func set_stars_field_color(value: Color) -> void:
+ if value == stars_field_color:
+ return
+ stars_field_color = value
+ update_stars_field_color()
+
+
+func set_set_stars_field_texture(value: bool) -> void:
+ set_stars_field_texture = value
+ if not value:
+ _set_stars_field_texture(Sky3D._stars_field_texture)
+ notify_property_list_changed()
+
+
+func update_stars_field_texture() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.STARS_TEXTURE_P, stars_field_texture)
+
+
+var stars_field_texture: Texture2D = null: set = _set_stars_field_texture
+var stars_scintillation: float = 0.75: set = set_stars_scintillation
+var stars_scintillation_speed: float = 0.01: set = set_stars_scintillation_speed
+func _set_stars_field_texture(value: Texture2D) -> void:
+ if value == stars_field_texture:
+ return
+ stars_field_texture = value
+ update_stars_field_texture()
+
+
+func update_stars_scintillation() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.STARS_SC_P, stars_scintillation)
+
+
+func set_stars_scintillation(value: float) -> void:
+ if value == stars_scintillation:
+ return
+ stars_scintillation = value
+ update_stars_scintillation()
+
+
+func update_stars_scintillation_speed() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.STARS_SC_SPEED_P, stars_scintillation_speed)
+
+
+func set_stars_scintillation_speed(value: float) -> void:
+ if value == stars_scintillation_speed:
+ return
+ stars_scintillation_speed = value
+ update_stars_scintillation_speed()
+
+
+#####################
+## 2D Clouds
+#####################
+
+var clouds_thickness: float = 1.7: set = set_clouds_thickness
+var clouds_coverage: float = 0.5: set = set_clouds_coverage
+var clouds_absorption: float = 2.0: set = set_clouds_absorption
+var clouds_sky_tint_fade: float = 0.5: set = set_clouds_sky_tint_fade
+var clouds_intensity: float = 10.0: set = set_clouds_intensity
+var clouds_size: float = 2.0: set = set_clouds_size
+var clouds_uv:= Vector2(0.16, 0.11): set = set_clouds_uv
+var clouds_direction:= Vector2(0.25, 0.25): set = set_clouds_direction
+var clouds_speed: float = 0.07: set = set_clouds_speed
+var set_clouds_texture: bool = false: set = set_set_clouds_texture
+var clouds_texture: Texture2D = null: set = _set_clouds_texture
+
+
+func set_clouds_thickness(value: float) -> void:
+ if value == clouds_thickness:
+ return
+ clouds_thickness = value
+ update_clouds_thickness()
+
+
+func update_clouds_thickness() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_THICKNESS, clouds_thickness)
+
+
+func set_clouds_coverage(value: float) -> void:
+ if value == clouds_coverage:
+ return
+ clouds_coverage = value
+ update_clouds_coverage()
+
+
+func update_clouds_coverage() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_COVERAGE, clouds_coverage)
+
+
+func set_clouds_absorption(value: float) -> void:
+ if value == clouds_absorption:
+ return
+ clouds_absorption = value
+ update_clouds_absorption()
+
+
+func update_clouds_absorption() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_ABSORPTION, clouds_absorption)
+
+
+func set_clouds_sky_tint_fade(value: float) -> void:
+ if value == clouds_sky_tint_fade:
+ return
+ clouds_sky_tint_fade = value
+ update_clouds_sky_tint_fade()
+
+
+func update_clouds_sky_tint_fade() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_SKY_TINT_FADE, clouds_sky_tint_fade)
+
+
+func set_clouds_intensity(value: float) -> void:
+ if value == clouds_intensity:
+ return
+ clouds_intensity = value
+ update_clouds_intensity()
+
+
+func update_clouds_intensity() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_INTENSITY, clouds_intensity)
+
+
+func set_clouds_size(value: float) -> void:
+ if value == clouds_size:
+ return
+ clouds_size = value
+ update_clouds_size()
+
+
+func update_clouds_size() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_SIZE, clouds_size)
+
+
+func set_clouds_uv(value: Vector2) -> void:
+ if value == clouds_uv:
+ return
+ clouds_uv = value
+ update_clouds_uv()
+
+
+func update_clouds_uv() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_UV, clouds_uv)
+
+
+func set_clouds_direction(value: Vector2) -> void:
+ if value == clouds_direction:
+ return
+ clouds_direction = value
+ update_clouds_direction()
+
+
+func update_clouds_direction() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_DIRECTION, clouds_direction)
+
+
+func set_clouds_speed(value: float) -> void:
+ if value == clouds_speed:
+ return
+ clouds_speed = value
+ update_clouds_speed()
+
+
+func update_clouds_speed() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_SPEED, clouds_speed)
+
+
+func set_set_clouds_texture(value: bool) -> void:
+ set_clouds_texture = value
+ if not value:
+ _set_clouds_texture(Sky3D._clouds_texture)
+ notify_property_list_changed()
+
+
+func _set_clouds_texture(value: Texture2D) -> void:
+ if value == clouds_texture:
+ return
+ clouds_texture = value
+ update_clouds_texture()
+
+
+func update_clouds_texture() -> void:
+ if !is_scene_built:
+ return
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_TEXTURE, clouds_texture)
+
+
+#####################
+## Cumulus Clouds
+#####################
+
+var clouds_cumulus_visible: bool = true: set = set_clouds_cumulus_visible
+var clouds_cumulus_day_color:= Color(0.823529, 0.87451, 1.0, 1.0): set = set_clouds_cumulus_day_color
+var clouds_cumulus_horizon_light_color:= Color(.98, 0.43, 0.15, 1.0): set = set_clouds_cumulus_horizon_light_color
+var clouds_cumulus_night_color:= Color(0.090196, 0.094118, 0.129412, 1.0): set = set_clouds_cumulus_night_color
+var clouds_cumulus_thickness: float = 0.0243: set = set_clouds_cumulus_thickness
+var clouds_cumulus_coverage: float = 0.55: set = set_clouds_cumulus_coverage
+var clouds_cumulus_absorption: float = 2.0: set = set_clouds_cumulus_absorption
+var clouds_cumulus_noise_freq: float = 2.7: set = set_clouds_cumulus_noise_freq
+var clouds_cumulus_intensity: float = 1.0: set = set_clouds_cumulus_intensity
+var clouds_cumulus_mie_intensity: float = 1.0: set = set_clouds_cumulus_mie_intensity
+var clouds_cumulus_mie_anisotropy: float = 0.206: set = set_clouds_cumulus_mie_anisotropy
+var clouds_cumulus_size: float = 0.5: set = set_clouds_cumulus_size
+var clouds_cumulus_direction:= Vector3(0.25, 0.1, 0.25): set = set_clouds_cumulus_direction
+var clouds_cumulus_speed: float = 0.05: set = set_clouds_cumulus_speed
+var set_clouds_cumulus_texture: bool = false: set = set_set_clouds_cumulus_texture
+var clouds_cumulus_texture: Texture2D = null: set = _set_clouds_cumulus_texture
+
+
+func set_clouds_cumulus_visible(value: bool) -> void:
+ if value == clouds_cumulus_visible:
+ return
+ clouds_cumulus_visible = value
+ update_clouds_cumulus_visible()
+
+
+func update_clouds_cumulus_visible() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_mesh.visible = clouds_cumulus_visible
+
+
+func set_clouds_cumulus_day_color(value: Color) -> void:
+ if value == clouds_cumulus_day_color:
+ return
+ clouds_cumulus_day_color = value
+ update_clouds_cumulus_day_color()
+
+
+func update_clouds_cumulus_day_color() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_DAY_COLOR, clouds_cumulus_day_color)
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_DAY_COLOR, clouds_cumulus_day_color)
+
+
+func set_clouds_cumulus_horizon_light_color(value: Color) -> void:
+ if value == clouds_cumulus_horizon_light_color:
+ return
+ clouds_cumulus_horizon_light_color = value
+ update_clouds_cumulus_horizon_light_color()
+
+
+func update_clouds_cumulus_horizon_light_color() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_HORIZON_LIGHT_COLOR, clouds_cumulus_horizon_light_color)
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_HORIZON_LIGHT_COLOR, clouds_cumulus_horizon_light_color)
+
+
+func set_clouds_cumulus_night_color(value: Color) -> void:
+ if value == clouds_cumulus_night_color:
+ return
+ clouds_cumulus_night_color = value
+ update_clouds_cumulus_night_color()
+
+
+func update_clouds_cumulus_night_color() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_NIGHT_COLOR, clouds_cumulus_night_color)
+ sky_material.set_shader_parameter(Sky3D.CLOUDS_NIGHT_COLOR, clouds_cumulus_night_color)
+
+
+func set_clouds_cumulus_thickness(value: float) -> void:
+ if value == clouds_cumulus_thickness:
+ return
+ clouds_cumulus_thickness = value
+ update_clouds_cumulus_thickness()
+
+
+func update_clouds_cumulus_thickness() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_THICKNESS, clouds_cumulus_thickness)
+
+
+func set_clouds_cumulus_coverage(value: float) -> void:
+ if value == clouds_cumulus_coverage:
+ return
+ clouds_cumulus_coverage = value
+ update_clouds_cumulus_coverage()
+
+
+func update_clouds_cumulus_coverage() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_COVERAGE, clouds_cumulus_coverage)
+
+
+func set_clouds_cumulus_absorption(value: float) -> void:
+ if value == clouds_cumulus_absorption:
+ return
+ clouds_cumulus_absorption = value
+ update_clouds_cumulus_absorption()
+
+
+func update_clouds_cumulus_absorption() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_ABSORPTION, clouds_cumulus_absorption)
+
+
+func set_clouds_cumulus_noise_freq(value: float) -> void:
+ if value == clouds_cumulus_noise_freq:
+ return
+ clouds_cumulus_noise_freq = value
+ update_clouds_cumulus_noise_freq()
+
+
+func update_clouds_cumulus_noise_freq() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_NOISE_FREQ, clouds_cumulus_noise_freq)
+
+
+func set_clouds_cumulus_intensity(value: float) -> void:
+ if value == clouds_cumulus_intensity:
+ return
+ clouds_cumulus_intensity = value
+ update_clouds_cumulus_intensity()
+
+
+func update_clouds_cumulus_intensity() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_INTENSITY, clouds_cumulus_intensity)
+
+
+func set_clouds_cumulus_mie_intensity(value: float) -> void:
+ if value == clouds_cumulus_mie_intensity:
+ return
+ clouds_cumulus_mie_intensity = value
+ update_clouds_cumulus_mie_intensity()
+
+
+func update_clouds_cumulus_mie_intensity() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_MIE_INTENSITY, clouds_cumulus_mie_intensity)
+
+
+func set_clouds_cumulus_mie_anisotropy(value: float) -> void:
+ if value == clouds_cumulus_mie_anisotropy:
+ return
+ clouds_cumulus_mie_anisotropy = value
+ update_clouds_cumulus_mie_anisotropy()
+
+
+func update_clouds_cumulus_mie_anisotropy() -> void:
+ if !is_scene_built:
+ return
+ var partial = ScatterLib.get_partial_mie_phase(clouds_cumulus_mie_anisotropy)
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_PARTIAL_MIE_PHASE, partial)
+
+
+func set_clouds_cumulus_size(value: float) -> void:
+ if value == clouds_cumulus_size:
+ return
+ clouds_cumulus_size = value
+ update_clouds_cumulus_size()
+
+
+func update_clouds_cumulus_size() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_SIZE, clouds_cumulus_size)
+
+
+func set_clouds_cumulus_direction(value: Vector3) -> void:
+ if value == clouds_cumulus_direction:
+ return
+ clouds_cumulus_direction = value
+ update_clouds_cumulus_direction()
+
+
+func update_clouds_cumulus_direction() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_DIRECTION, clouds_cumulus_direction)
+
+
+func set_clouds_cumulus_speed(value: float) -> void:
+ if value == clouds_cumulus_speed:
+ return
+ clouds_cumulus_speed = value
+ update_clouds_cumulus_speed()
+
+
+func update_clouds_cumulus_speed() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_SPEED, clouds_cumulus_speed)
+
+
+func set_set_clouds_cumulus_texture(value: bool) -> void:
+ set_clouds_cumulus_texture = value
+ if not value:
+ _set_clouds_cumulus_texture(Sky3D._clouds_cumulus_texture)
+ notify_property_list_changed()
+
+
+func _set_clouds_cumulus_texture(value: Texture2D) -> void:
+ if value == clouds_cumulus_texture:
+ return
+ clouds_cumulus_texture = value
+ update_clouds_cumulus_texture()
+
+
+func update_clouds_cumulus_texture() -> void:
+ if !is_scene_built:
+ return
+ clouds_cumulus_material.set_shader_parameter(Sky3D.CLOUDS_TEXTURE, clouds_cumulus_texture)
+
+
+#####################
+## Environment
+#####################
+
+var __enable_environment: bool = false
+var environment: Environment = null: set = set_environment
+
+
+func set_environment(value: Environment) -> void:
+ environment = value
+ __enable_environment = true if environment != null else false
+ if __enable_environment:
+ __update_environment()
+
+
+func __update_environment() -> void:
+ if not __enable_environment or not __sun_light_node:
+ return
+ var factor = TOD_Math.saturate(-sun_direction().y + 0.60)
+ var col = TOD_Math.plerp_color(__sun_light_node.light_color, atm_night_tint * atm_night_intensity(), factor)
+ col.a = 1.
+ col.v = clamp(col.v, .35, 1.)
+ environment.ambient_light_color = col
+
+
+
+#####################
+## Lighting
+#####################
+
+var __day: bool: get = is_day
+
+
+func is_day() -> bool:
+ return __day == true
+
+
+func __set_day_state(v: float, threshold: float = 1.80) -> void:
+ # Signal when day has changed to night, or back
+ if __day == true and abs(v) > threshold:
+ __day = false
+ emit_signal("day_night_changed", __day)
+ elif __day == false and abs(v) <= threshold:
+ __day = true
+ emit_signal("day_night_changed", __day)
+
+ # Adjust lights and signal. Happens at a different time from "day time" above
+ if __sun_light_node and __moon_light_node:
+ if sun_light_enable and not __sun_light_node.visible and __sun_light_node.light_energy > 0.0:
+ __sun_light_node.visible = true
+ __moon_light_node.visible = false
+ emit_signal("lights_changed")
+ elif moon_light_enable and not __moon_light_node.visible and __moon_light_node.light_energy > 0.0:
+ __sun_light_node.visible = false
+ __moon_light_node.visible = true
+ emit_signal("lights_changed")
+
+
+func _get_property_list() -> Array:
+ var ret:= Array()
+ ret.push_back({name = "Skydome", type = TYPE_NIL, usage = PROPERTY_USAGE_CATEGORY})
+
+ # Global
+ ret.push_back({name = "Global", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "sky_visible", type = TYPE_BOOL})
+ ret.push_back({name = "dome_radius", type = TYPE_FLOAT})
+ ret.push_back({name = "tonemap_level", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "exposure", type = TYPE_FLOAT})
+ ret.push_back({name = "ground_color", type = TYPE_COLOR})
+ ret.push_back({name = "sky_layers", type = TYPE_INT, hint = PROPERTY_HINT_LAYERS_3D_RENDER})
+ ret.push_back({name = "sky_render_priority", type = TYPE_INT, hint = PROPERTY_HINT_RANGE, hint_string = "-128, 127"})
+ ret.push_back({name = "horizon_level", type = TYPE_FLOAT})
+
+ # Sun
+ ret.push_back({name = "Sun", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "sun_altitude", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "-180.0, 180.0"})
+ ret.push_back({name = "sun_azimuth", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "-180.0, 180.0"})
+ ret.push_back({name = "sun_disk_color", type = TYPE_COLOR})
+ ret.push_back({name = "sun_disk_intensity", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 100.0"})
+ ret.push_back({name = "sun_disk_size", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 0.5"})
+ ret.push_back({name = "sun_light_path", type = TYPE_NODE_PATH})
+ ret.push_back({name = "sun_light_color", type = TYPE_COLOR})
+ ret.push_back({name = "sun_horizon_light_color", type = TYPE_COLOR})
+ ret.push_back({name = "sun_light_energy", type = TYPE_FLOAT})
+
+ # Moon
+ ret.push_back({name = "Moon", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "moon_altitude", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "-180.0, 180.0"})
+ ret.push_back({name = "moon_azimuth", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "-180.0, 180.0"})
+ ret.push_back({name = "moon_color", type = TYPE_COLOR})
+ ret.push_back({name = "moon_size", type = TYPE_FLOAT})
+ ret.push_back({name = "enable_set_moon_texture", type = TYPE_BOOL})
+
+ if enable_set_moon_texture:
+ ret.push_back({name = "moon_texture", type = TYPE_OBJECT, hint = PROPERTY_HINT_FILE, hint_string = "Texture2D"})
+
+ ret.push_back({name = "moon_resolution", type = TYPE_INT, hint = PROPERTY_HINT_ENUM, hint_string = "64, 128, 256, 512, 1024"})
+ ret.push_back({name = "moon_light_path", type = TYPE_NODE_PATH})
+
+ ret.push_back({name = "moon_light_color", type = TYPE_COLOR})
+ ret.push_back({name = "moon_light_energy", type = TYPE_FLOAT})
+
+ # Deep space
+ ret.push_back({name = "DeepSpace", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "deep_space_euler", type = TYPE_VECTOR3})
+ ret.push_back({name = "background_color", type = TYPE_COLOR})
+ ret.push_back({name = "set_background_texture", type = TYPE_BOOL})
+
+ if set_background_texture:
+ ret.push_back({name = "background_texture", type = TYPE_OBJECT, hint = PROPERTY_HINT_GLOBAL_FILE, hint_string = "Texture2D"})
+
+ ret.push_back({name = "stars_field_color", type = TYPE_COLOR})
+ ret.push_back({name = "set_stars_field_texture", type = TYPE_BOOL})
+
+ if set_stars_field_texture:
+ ret.push_back({name = "stats_field_texture", type = TYPE_OBJECT, hint = PROPERTY_HINT_FILE, hint_string = "Texture2D"})
+
+ ret.push_back({name = "stars_scintillation", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "stars_scintillation_speed", type = TYPE_FLOAT})
+
+ # Atmosphere
+ ret.push_back({name = "Atmosphere", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP, hint_string = "atm_"})
+ ret.push_back({name = "atm_quality", type = TYPE_INT, hint = PROPERTY_HINT_ENUM, hint_string = "PerPixel,PerVertex"})
+ ret.push_back({name = "atm_wavelenghts", type = TYPE_VECTOR3})
+ ret.push_back({name = "atm_darkness", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "atm_sun_intensity", type = TYPE_FLOAT})
+ ret.push_back({name = "atm_day_tint", type = TYPE_COLOR})
+ ret.push_back({name = "atm_horizon_light_tint", type = TYPE_COLOR})
+ ret.push_back({name = "atm_enable_moon_scatter_mode", type = TYPE_BOOL})
+ ret.push_back({name = "atm_night_tint", type = TYPE_COLOR})
+ ret.push_back({name = "atm_level_params", type = TYPE_VECTOR3})
+ ret.push_back({name = "atm_thickness", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 100.0"})
+ ret.push_back({name = "atm_mie", type = TYPE_FLOAT})
+ ret.push_back({name = "atm_turbidity", type = TYPE_FLOAT})
+ ret.push_back({name = "atm_sun_mie_tint", type = TYPE_COLOR})
+ ret.push_back({name = "atm_sun_mie_intensity", type = TYPE_FLOAT})
+ ret.push_back({name = "atm_sun_mie_anisotropy", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 0.9999999"})
+
+ ret.push_back({name = "atm_moon_mie_tint", type = TYPE_COLOR})
+ ret.push_back({name = "atm_moon_mie_intensity", type = TYPE_FLOAT})
+ ret.push_back({name = "atm_moon_mie_anisotropy", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 0.9999999"})
+
+ # Fog
+ ret.push_back({name = "Fog", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP, hint_string = "fog_"})
+ ret.push_back({name = "fog_visible", type = TYPE_BOOL})
+ ret.push_back({name = "fog_atm_level_params_offset", type = TYPE_VECTOR3})
+ ret.push_back({name = "fog_density", type = TYPE_FLOAT, hint = PROPERTY_HINT_EXP_EASING, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "fog_rayleigh_depth", type = TYPE_FLOAT, hint = PROPERTY_HINT_EXP_EASING, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "fog_mie_depth", type = TYPE_FLOAT, hint = PROPERTY_HINT_EXP_EASING, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "fog_falloff", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 10.0"})
+ ret.push_back({name = "fog_start", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 5000.0"})
+ ret.push_back({name = "fog_end", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 5000.0"})
+ ret.push_back({name = "fog_layers", type = TYPE_INT, hint = PROPERTY_HINT_LAYERS_3D_RENDER})
+ ret.push_back({name = "fog_render_priority", type = TYPE_INT})
+
+ # 2D Clouds
+ ret.push_back({name = "2D Clouds", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "clouds_thickness", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_coverage", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "clouds_absorption", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_sky_tint_fade", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "clouds_intensity", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_size", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_uv", type = TYPE_VECTOR2})
+ ret.push_back({name = "clouds_direction", type = TYPE_VECTOR2})
+ ret.push_back({name = "clouds_speed", type = TYPE_FLOAT})
+ ret.push_back({name = "set_clouds_texture", type = TYPE_BOOL})
+
+ if set_clouds_texture:
+ ret.push_back({name = "clouds_texture", type = TYPE_OBJECT, hint = PROPERTY_HINT_FILE, hint_string = "Texture2D"})
+
+ # Clouds cumulus
+ ret.push_back({name = "Clouds Cumulus", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "clouds_cumulus_visible", type = TYPE_BOOL})
+ ret.push_back({name = "clouds_cumulus_day_color", type = TYPE_COLOR})
+ ret.push_back({name = "clouds_cumulus_horizon_light_color", type = TYPE_COLOR})
+ ret.push_back({name = "clouds_cumulus_night_color", type = TYPE_COLOR})
+ ret.push_back({name = "clouds_cumulus_thickness", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_cumulus_coverage", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 1.0"})
+ ret.push_back({name = "clouds_cumulus_absorption", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_cumulus_noise_freq", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 3.0"})
+ ret.push_back({name = "clouds_cumulus_intensity", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_cumulus_mie_intensity", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_cumulus_mie_anisotropy", type = TYPE_FLOAT, hint = PROPERTY_HINT_RANGE, hint_string = "0.0, 0.999999"})
+ ret.push_back({name = "clouds_cumulus_size", type = TYPE_FLOAT})
+ ret.push_back({name = "clouds_cumulus_direction", type = TYPE_VECTOR3})
+ ret.push_back({name = "clouds_cumulus_speed", type = TYPE_FLOAT})
+ ret.push_back({name = "set_clouds_cumulus_texture", type = TYPE_BOOL})
+
+ if set_clouds_cumulus_texture:
+ ret.push_back({name = "clouds_cumulus_texture", type = TYPE_OBJECT, hint = PROPERTY_HINT_FILE, hint_string = "Texture2D"})
+
+ # Lighting
+ ret.push_back({name = "Lighting", type = TYPE_NIL, usage = PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "environment", type = TYPE_OBJECT, hint = PROPERTY_HINT_RESOURCE_TYPE, hint_string = "Resource"})
+
+ return ret
diff --git a/addons/sky_3d/src/Skydome.gd.uid b/addons/sky_3d/src/Skydome.gd.uid
new file mode 100644
index 0000000..aae0638
--- /dev/null
+++ b/addons/sky_3d/src/Skydome.gd.uid
@@ -0,0 +1 @@
+uid://27fj74ofndim
diff --git a/addons/sky_3d/src/TOD_Math.gd b/addons/sky_3d/src/TOD_Math.gd
new file mode 100644
index 0000000..29c8dc4
--- /dev/null
+++ b/addons/sky_3d/src/TOD_Math.gd
@@ -0,0 +1,77 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+class_name TOD_Math
+
+
+const RAD_TO_DEG: float = 57.2957795
+const DEG_TO_RAD: float = 0.0174533
+
+
+static func saturate(value: float) -> float:
+ return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
+
+
+static func saturate_vec3(value: Vector3) -> Vector3:
+ var ret: Vector3
+ ret.x = 0.0 if value.x < 0.0 else 1.0 if value.x > 1.0 else value.x
+ ret.y = 0.0 if value.y < 0.0 else 1.0 if value.y > 1.0 else value.y
+ ret.z = 0.0 if value.z < 0.0 else 1.0 if value.z > 1.0 else value.z
+ return ret
+
+
+static func saturate_color(value: Color) -> Color:
+ var ret: Color
+ ret.r = 0.0 if value.r < 0.0 else 1.0 if value.r > 1.0 else value.r
+ ret.g = 0.0 if value.g < 0.0 else 1.0 if value.g > 1.0 else value.g
+ ret.b = 0.0 if value.b < 0.0 else 1.0 if value.b > 1.0 else value.b
+ ret.a = 0.0 if value.a < 0.0 else 1.0 if value.a > 1.0 else value.a
+ return ret
+
+
+static func rev(val: float) -> float:
+ return val - int(floor(val / 360.0)) * 360.0
+
+
+static func lerp_f(from: float, to: float, t: float) -> float:
+ return (1 - t) * from + t * to
+
+
+static func plerp_vec3(from: Vector3, to: Vector3, t: float) -> Vector3:
+ var ret: Vector3
+ ret.x = (1 - t) * from.x + t * to.x
+ ret.y = (1 - t) * from.y + t * to.y
+ ret.z = (1 - t) * from.z + t * to.z
+ return ret
+
+
+static func plerp_color(from: Color, to: Color, t: float) -> Color:
+ var ret: Color
+ ret.r = (1 - t) * from.r + t * to.r
+ ret.g = (1 - t) * from.g + t * to.g
+ ret.b = (1 - t) * from.b + t * to.b
+ ret.a = (1 - t) * from.a + t * to.a
+ return ret
+
+
+static func distance(a: Vector3, b: Vector3) -> float:
+ var ret: float
+ var x: float = a.x - b.x
+ var y: float = a.y - b.y
+ var z: float = a.z - b.z
+ ret = x * x + y * y + z * z
+ return sqrt(ret)
+
+
+static func to_orbit(theta: float, pi: float, radius: float = 1.0) -> Vector3:
+ var ret: Vector3
+ var sinTheta: float = sin(theta)
+ var cosTheta: float = cos(theta)
+ var sinPI: float = sin(pi)
+ var cosPI: float = cos(pi)
+
+ ret.x = sinTheta * sinPI
+ ret.y = cosTheta
+ ret.z = sinTheta * cosPI
+ return ret * radius
+
diff --git a/addons/sky_3d/src/TOD_Math.gd.uid b/addons/sky_3d/src/TOD_Math.gd.uid
new file mode 100644
index 0000000..b1dd8ee
--- /dev/null
+++ b/addons/sky_3d/src/TOD_Math.gd.uid
@@ -0,0 +1 @@
+uid://dtsesagkhvc07
diff --git a/addons/sky_3d/src/TimeOfDay.gd b/addons/sky_3d/src/TimeOfDay.gd
new file mode 100644
index 0000000..d9bedc0
--- /dev/null
+++ b/addons/sky_3d/src/TimeOfDay.gd
@@ -0,0 +1,592 @@
+# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
+# Copyright (c) 2021 J. Cuellar
+
+@tool
+class_name TimeOfDay
+extends Node
+
+
+signal time_changed(value)
+signal day_changed(value)
+signal month_changed(value)
+signal year_changed(value)
+
+var _update_timer: Timer
+var _last_update: int = 0
+
+
+@export var update_in_game: bool = true :
+ set(value):
+ update_in_game = value
+ if not Engine.is_editor_hint():
+ if update_in_game:
+ resume()
+ else:
+ pause()
+
+
+@export var update_in_editor: bool = true :
+ set(value):
+ update_in_editor = value
+ if Engine.is_editor_hint():
+ if update_in_editor:
+ resume()
+ else:
+ pause()
+
+
+@export_range(.016, 10) var update_interval: float = 0.1 :
+ set(value):
+ update_interval = clamp(value, .016, 10)
+ if is_instance_valid(_update_timer):
+ _update_timer.wait_time = update_interval
+ resume()
+
+
+func _init() -> void:
+ set_total_hours(total_hours)
+ set_day(day)
+ set_month(month)
+ set_year(year)
+ set_latitude(latitude)
+ set_longitude(longitude)
+ set_utc(utc)
+
+
+func _ready() -> void:
+ set_dome_path(dome_path)
+
+ _update_timer = Timer.new()
+ _update_timer.name = "Timer"
+ add_child(_update_timer)
+ _update_timer.timeout.connect(_on_timeout)
+ _update_timer.wait_time = update_interval
+ resume()
+
+
+func _on_timeout() -> void:
+ if system_sync:
+ __get_date_time_os()
+ else:
+ var delta: float = .001 * (Time.get_ticks_msec() - _last_update)
+ __progress_time(delta)
+ __update_celestial_coords()
+ _last_update = Time.get_ticks_msec()
+
+
+func pause() -> void:
+ if is_instance_valid(_update_timer):
+ _update_timer.stop()
+
+
+func resume() -> void:
+ if is_instance_valid(_update_timer):
+ # Assume resuming from a pause, so timer only gets one tick
+ _last_update = Time.get_ticks_msec() - update_interval
+ if (Engine.is_editor_hint() and update_in_editor) or \
+ (not Engine.is_editor_hint() and update_in_game):
+ _update_timer.start()
+
+
+#####################
+## Target
+#####################
+
+var __dome: Skydome = null
+var __dome_found: bool = false
+var dome_path: NodePath: set = set_dome_path
+
+
+func set_dome_path(value: NodePath) -> void:
+ dome_path = value
+ if value != null:
+ __dome = get_node_or_null(value) as Skydome
+
+ __dome_found = false if __dome == null else true
+ __update_celestial_coords()
+
+
+#####################
+## DateTime
+#####################
+
+var date_time_os: Dictionary
+var system_sync: bool = false
+var total_cycle_in_minutes: float = 15.0
+var total_hours: float = 7.0 : set = set_total_hours
+var day: int = 1: set = set_day
+var month: int = 1: set = set_month
+var year: int = 2025: set = set_year
+
+
+func set_total_hours(value: float) -> void:
+ if total_hours != value:
+ total_hours = value
+ while total_hours > 23.9999:
+ total_hours -= 24
+ day += 1
+ while total_hours < 0.0000:
+ total_hours += 24
+ day -= 1
+ emit_signal("time_changed", total_hours)
+ __update_celestial_coords()
+
+
+func set_day(value: int) -> void:
+ if day != value:
+ day = value
+ while day > max_days_per_month():
+ day -= max_days_per_month()
+ month += 1
+ while day < 1:
+ month -= 1
+ day += max_days_per_month()
+ emit_signal("day_changed", day)
+ __update_celestial_coords()
+
+
+func set_month(value: int) -> void:
+ if month != value:
+ month = value
+ while month > 12:
+ month -= 12
+ year += 1
+ while month < 1:
+ month += 12
+ year -= 1
+ emit_signal("month_changed", month)
+ __update_celestial_coords()
+
+
+func set_year(value: int) -> void:
+ if year != value:
+ year = value
+ emit_signal("year_changed", year)
+ __update_celestial_coords()
+
+
+func is_learp_year() -> bool:
+ return DateTimeUtil.compute_leap_year(year)
+
+
+func max_days_per_month() -> int:
+ match month:
+ 1, 3, 5, 7, 8, 10, 12:
+ return 31
+ 2:
+ return 29 if is_learp_year() else 28
+ return 30
+
+
+func time_cycle_duration() -> float:
+ return total_cycle_in_minutes * 60.0
+
+
+func is_begin_of_time() -> bool:
+ return year == 1 && month == 1 && day == 1
+
+
+func is_end_of_time() -> bool:
+ return year == 9999 && month == 12 && day == 31
+
+
+#####################
+## Planetary
+#####################
+
+enum CelestialCalculationsMode{
+ Simple = 0,
+ Realistic
+}
+
+var celestials_calculations: int = 1: set = set_celestials_calculations
+var latitude: float = 16.0: set = set_latitude
+var longitude: float = 108.0: set = set_longitude
+var utc: float = 7.0: set = set_utc
+var compute_moon_coords: bool = true: set = set_compute_moon_coords
+var compute_deep_space_coords: bool = true: set = set_compute_deep_space_coords
+var moon_coords_offset := Vector2(0.0, 0.0): set = set_moon_coords_offset
+var __sun_coords := Vector2.ZERO
+var __moon_coords := Vector2.ZERO
+var __sun_distance: float
+var __true_sun_longitude: float
+var __mean_sun_longitude: float
+var __sideral_time: float
+var __local_sideral_time: float
+var __sun_orbital_elements := OrbitalElements.new()
+var __moon_orbital_elements := OrbitalElements.new()
+
+
+func set_celestials_calculations(value: int) -> void:
+ celestials_calculations = value
+ __update_celestial_coords()
+ notify_property_list_changed()
+
+
+func set_latitude(value: float) -> void:
+ latitude = value
+ __update_celestial_coords()
+
+
+func set_longitude(value: float) -> void:
+ longitude = value
+ __update_celestial_coords()
+
+
+func set_utc(value: float) -> void:
+ utc = value
+ __update_celestial_coords()
+
+
+func set_compute_moon_coords(value: bool) -> void:
+ compute_moon_coords = value
+ __update_celestial_coords()
+ notify_property_list_changed()
+
+
+func set_compute_deep_space_coords(value: bool) -> void:
+ compute_deep_space_coords = value
+ __update_celestial_coords()
+
+
+func set_moon_coords_offset(value: Vector2) -> void:
+ moon_coords_offset = value
+ __update_celestial_coords()
+
+
+func __get_latitude_rad() -> float:
+ return latitude * TOD_Math.DEG_TO_RAD
+
+
+func __get_total_hours_utc() -> float:
+ return total_hours - utc
+
+
+func __get_time_scale() -> float:
+ return (367.0 * year - (7.0 * (year + ((month + 9.0) / 12.0))) / 4.0 +\
+ (275.0 * month) / 9.0 + day - 730530.0) + total_hours / 24.0
+
+
+func __get_oblecl() -> float:
+ return (23.4393 - 2.563e-7 * __get_time_scale()) * TOD_Math.DEG_TO_RAD
+
+
+#####################
+## DateTime
+#####################
+
+
+func set_time(hour: int, minute: int, second: int) -> void:
+ set_total_hours(DateTimeUtil.hours_to_total_hours(hour, minute, second))
+
+
+func set_from_datetime_dict(datetime_dict: Dictionary) -> void:
+ set_year(datetime_dict.year)
+ set_month(datetime_dict.month)
+ set_day(datetime_dict.day)
+ set_time(datetime_dict.hour, datetime_dict.minute, datetime_dict.second)
+
+
+func get_datetime_dict() -> Dictionary:
+ var datetime_dict := {
+ "year": year,
+ "month": month,
+ "day": day,
+ "hour": floor(total_hours),
+ "minute": floor(fmod(total_hours, 1.0) * 60.0),
+ "second": floor(fmod(total_hours * 60.0, 1.0) * 60.0)
+ }
+ return datetime_dict
+
+
+func set_from_unix_timestamp(timestamp: int) -> void:
+ set_from_datetime_dict(Time.get_datetime_dict_from_unix_time(timestamp))
+
+
+func get_unix_timestamp() -> int:
+ return Time.get_unix_time_from_datetime_dict(get_datetime_dict())
+
+
+func __progress_time(delta: float) -> void:
+ if not is_zero_approx(time_cycle_duration()):
+ set_total_hours(total_hours + delta / time_cycle_duration() * DateTimeUtil.TOTAL_HOURS)
+
+
+func __get_date_time_os() -> void:
+ date_time_os = Time.get_datetime_dict_from_system()
+ set_time(date_time_os.hour, date_time_os.minute, date_time_os.second)
+ set_day(date_time_os.day)
+ set_month(date_time_os.month)
+ set_year(date_time_os.year)
+
+
+#####################
+## Planetary
+#####################
+
+
+func __update_celestial_coords() -> void:
+ if not __dome_found:
+ return
+
+ match celestials_calculations:
+ CelestialCalculationsMode.Simple:
+ __compute_simple_sun_coords()
+ __dome.sun_altitude = __sun_coords.y
+ __dome.sun_azimuth = __sun_coords.x
+ if compute_moon_coords:
+ __compute_simple_moon_coords()
+ __dome.moon_altitude = __moon_coords.y
+ __dome.moon_azimuth = __moon_coords.x
+
+ if compute_deep_space_coords:
+ var x = Quaternion.from_euler(Vector3( (90 + latitude) * TOD_Math.DEG_TO_RAD, 0.0, 0.0))
+ var y = Quaternion.from_euler(Vector3(0.0, 0.0, __sun_coords.y * TOD_Math.DEG_TO_RAD))
+ __dome.deep_space_quat = x * y
+
+ CelestialCalculationsMode.Realistic:
+ __compute_realistic_sun_coords()
+ __dome.sun_altitude = -__sun_coords.y * TOD_Math.RAD_TO_DEG
+ __dome.sun_azimuth = -__sun_coords.x * TOD_Math.RAD_TO_DEG
+ if compute_moon_coords:
+ __compute_realistic_moon_coords()
+ __dome.moon_altitude = -__moon_coords.y * TOD_Math.RAD_TO_DEG
+ __dome.moon_azimuth = -__moon_coords.x * TOD_Math.RAD_TO_DEG
+
+ if compute_deep_space_coords:
+ var x = Quaternion.from_euler(Vector3( (90 + latitude) * TOD_Math.DEG_TO_RAD, 0.0, 0.0) )
+ var y = Quaternion.from_euler(Vector3(0.0, 0.0, (180.0 - __local_sideral_time * TOD_Math.RAD_TO_DEG) * TOD_Math.DEG_TO_RAD))
+ __dome.deep_space_quat = x * y
+
+
+func __compute_simple_sun_coords() -> void:
+ var altitude = (__get_total_hours_utc() + (TOD_Math.DEG_TO_RAD * longitude)) * (360/24)
+ __sun_coords.y = (180.0 - altitude)
+ __sun_coords.x = latitude
+
+
+func __compute_simple_moon_coords() -> void:
+ __moon_coords.y = (180.0 - __sun_coords.y) + moon_coords_offset.y
+ __moon_coords.x = (180.0 + __sun_coords.x) + moon_coords_offset.x
+
+
+func __compute_realistic_sun_coords() -> void:
+ # Orbital Elements
+ __sun_orbital_elements.get_orbital_elements(0, __get_time_scale())
+ __sun_orbital_elements.M = TOD_Math.rev(__sun_orbital_elements.M)
+
+ # Mean anomaly in radians
+ var MRad: float = TOD_Math.DEG_TO_RAD * __sun_orbital_elements.M
+
+ # Eccentric Anomaly
+ var E: float = __sun_orbital_elements.M + TOD_Math.RAD_TO_DEG * __sun_orbital_elements.e *\
+ sin(MRad) * (1 + __sun_orbital_elements.e * cos(MRad))
+
+ var ERad: float = E * TOD_Math.DEG_TO_RAD
+
+ # Rectangular coordinates of the sun in the plane of the ecliptic
+ var xv: float = cos(ERad) - __sun_orbital_elements.e
+ var yv: float = sin(ERad) * sqrt(1 - __sun_orbital_elements.e * __sun_orbital_elements.e)
+
+ # Distance and true anomaly
+ # Convert to distance and true anomaly(r = radians, v = degrees)
+ var r: float = sqrt(xv * xv + yv * yv)
+ var v: float = TOD_Math.RAD_TO_DEG * atan2(yv, xv)
+ __sun_distance = r
+
+ # True longitude
+ var lonSun: float = v + __sun_orbital_elements.w
+ lonSun = TOD_Math.rev(lonSun)
+
+ var lonSunRad = TOD_Math.DEG_TO_RAD * lonSun
+ __true_sun_longitude = lonSunRad
+
+ ## Ecliptic and ecuatorial coords
+
+ # Ecliptic rectangular coords
+ var xs: float = r * cos(lonSunRad)
+ var ys: float = r * sin(lonSunRad)
+
+ # Ecliptic rectangular coordinates rotate these to equatorial coordinates
+ var obleclCos: float = cos(__get_oblecl())
+ var obleclSin: float = sin(__get_oblecl())
+
+ var xe: float = xs
+ var ye: float = ys * obleclCos - 0.0 * obleclSin
+ var ze: float = ys * obleclSin + 0.0 * obleclCos
+
+ # Ascencion and declination
+ var RA: float = TOD_Math.RAD_TO_DEG * atan2(ye, xe) / 15 # right ascension.
+ var decl: float = atan2(ze, sqrt(xe * xe + ye * ye)) # declination
+
+ # Mean longitude
+ var L: float = __sun_orbital_elements.w + __sun_orbital_elements.M
+ L = TOD_Math.rev(L)
+
+ __mean_sun_longitude = L
+
+ # Sideral time and hour angle
+ var GMST0: float = ((L/15) + 12)
+ __sideral_time = GMST0 + __get_total_hours_utc() + longitude / 15 # +15/15
+ __local_sideral_time = TOD_Math.DEG_TO_RAD * __sideral_time * 15
+
+ var HA: float = (__sideral_time - RA) * 15
+ var HARAD: float = TOD_Math.DEG_TO_RAD * HA
+
+ # Hour angle and declination in rectangular coords
+ # HA and Decl in rectangular coords
+ var declCos: float = cos(decl)
+ var x = cos(HARAD) * declCos # X Axis points to the celestial equator in the south.
+ var y = sin(HARAD) * declCos # Y axis points to the horizon in the west.
+ var z = sin(decl) # Z axis points to the north celestial pole.
+
+ # Rotate the rectangualar coordinates system along of the Y axis
+ var sinLat: float = sin(latitude * TOD_Math.DEG_TO_RAD)
+ var cosLat: float = cos(latitude * TOD_Math.DEG_TO_RAD)
+ var xhor: float = x * sinLat - z * cosLat
+ var yhor: float = y
+ var zhor: float = x * cosLat + z * sinLat
+
+ # Azimuth and altitude
+ __sun_coords.x = atan2(yhor, xhor) + PI
+ __sun_coords.y = (PI * 0.5) - asin(zhor) # atan2(zhor, sqrt(xhor * xhor + yhor * yhor))
+
+
+func __compute_realistic_moon_coords() -> void:
+ # Orbital Elements
+ __moon_orbital_elements.get_orbital_elements(1, __get_time_scale())
+ __moon_orbital_elements.N = TOD_Math.rev(__moon_orbital_elements.N)
+ __moon_orbital_elements.w = TOD_Math.rev(__moon_orbital_elements.w)
+ __moon_orbital_elements.M = TOD_Math.rev(__moon_orbital_elements.M)
+
+ var NRad: float = TOD_Math.DEG_TO_RAD * __moon_orbital_elements.N
+ var IRad: float = TOD_Math.DEG_TO_RAD * __moon_orbital_elements.i
+ var MRad: float = TOD_Math.DEG_TO_RAD * __moon_orbital_elements.M
+
+ # Eccentric anomaly
+ var E: float = __moon_orbital_elements.M + TOD_Math.RAD_TO_DEG * __moon_orbital_elements.e * sin(MRad) *\
+ (1 + __sun_orbital_elements.e * cos(MRad))
+
+ var ERad = TOD_Math.DEG_TO_RAD * E
+
+ # Rectangular coords and true anomaly
+ # Rectangular coordinates of the sun in the plane of the ecliptic
+ var xv: float = __moon_orbital_elements.a * (cos(ERad) - __moon_orbital_elements.e)
+ var yv: float = __moon_orbital_elements.a * (sin(ERad) * sqrt(1 - __moon_orbital_elements.e * \
+ __moon_orbital_elements.e)) * sin(ERad)
+
+ # Convert to distance and true anomaly(r = radians, v = degrees)
+ var r: float = sqrt(xv * xv + yv * yv)
+ var v: float = TOD_Math.RAD_TO_DEG * atan2(yv, xv)
+ v = TOD_Math.rev(v)
+
+ var l: float = TOD_Math.DEG_TO_RAD * v + __moon_orbital_elements.w
+
+ var cosL: float = cos(l)
+ var sinL: float = sin(l)
+ var cosNRad: float = cos(NRad)
+ var sinNRad: float = sin(NRad)
+ var cosIRad: float = cos(IRad)
+
+ var xeclip: float = r * (cosNRad * cosL - sinNRad * sinL * cosIRad)
+ var yeclip: float = r * (sinNRad * cosL + cosNRad * sinL * cosIRad)
+ var zeclip: float = r * (sinL * sin(IRad))
+
+ # Geocentric coords
+ # Geocentric position for the moon and Heliocentric position for the planets
+ var lonecl: float = TOD_Math.RAD_TO_DEG * atan2(yeclip, xeclip)
+ lonecl = TOD_Math.rev(lonecl)
+
+ var latecl: float = TOD_Math.RAD_TO_DEG * atan2(zeclip, sqrt(xeclip * xeclip + yeclip * yeclip))
+
+ # Get true sun longitude
+ var lonsun: float = __true_sun_longitude
+
+ # Ecliptic longitude and latitude in radians
+ var loneclRad: float = TOD_Math.DEG_TO_RAD * lonecl
+ var lateclRad: float = TOD_Math.DEG_TO_RAD * latecl
+
+ var nr: float = 1.0
+ var xh: float = nr * cos(loneclRad) * cos(lateclRad)
+ var yh: float = nr * sin(loneclRad) * cos(lateclRad)
+ var zh: float = nr * sin(lateclRad)
+
+ # Geocentric coords
+ var xs: float = 0.0
+ var ys: float = 0.0
+
+ # Convert the geocentric position to heliocentric position
+ var xg: float = xh + xs
+ var yg: float = yh + ys
+ var zg: float = zh
+
+ # Ecuatorial coords
+ # Cobert xg, yg un equatorial coords
+ var obleclCos: float = cos(__get_oblecl())
+ var obleclSin: float = sin(__get_oblecl())
+
+ var xe: float = xg
+ var ye: float = yg * obleclCos - zg * obleclSin
+ var ze: float = yg * obleclSin + zg * obleclCos
+
+ # Right ascention
+ var RA: float = TOD_Math.RAD_TO_DEG * atan2(ye, xe)
+ RA = TOD_Math.rev(RA)
+
+ # Declination
+ var decl: float = TOD_Math.RAD_TO_DEG * atan2(ze, sqrt(xe * xe + ye * ye))
+ var declRad: float = TOD_Math.DEG_TO_RAD * decl
+
+ # Sideral time and hour angle
+ var HA: float = ((__sideral_time * 15) - RA)
+ HA = TOD_Math.rev(HA)
+ var HARAD: float = TOD_Math.DEG_TO_RAD * HA
+
+ # HA y Decl in rectangular coordinates
+ var declCos: float = cos(declRad)
+ var xr: float = cos(HARAD) * declCos
+ var yr: float = sin(HARAD) * declCos
+ var zr: float = sin(declRad)
+
+ # Rotate the rectangualar coordinates system along of the Y axis(radians)
+ var sinLat: float = sin(__get_latitude_rad())
+ var cosLat: float = cos(__get_latitude_rad())
+
+ var xhor: float = xr * sinLat - zr * cosLat
+ var yhor: float = yr
+ var zhor: float = xr * cosLat + zr * sinLat
+
+ # Azimuth and altitude
+ __moon_coords.x = atan2(yhor, xhor) + PI
+ __moon_coords.y = (PI *0.5) - atan2(zhor, sqrt(xhor * xhor + yhor * yhor)) # Mathf.Asin(zhor)
+
+
+func _get_property_list() -> Array:
+ var ret: Array
+ ret.push_back({name = "Time Of Day", type=TYPE_NIL, usage=PROPERTY_USAGE_CATEGORY})
+
+ ret.push_back({name = "Target", type=TYPE_NIL, usage=PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "dome_path", type=TYPE_NODE_PATH})
+
+ ret.push_back({name = "DateTime", type=TYPE_NIL, usage=PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "system_sync", type=TYPE_BOOL})
+
+ ret.push_back({name = "total_cycle_in_minutes", type=TYPE_FLOAT})
+ ret.push_back({name = "total_hours", type=TYPE_FLOAT, hint=PROPERTY_HINT_RANGE, hint_string="0.0, 24.0"})
+ ret.push_back({name = "day", type=TYPE_INT, hint=PROPERTY_HINT_RANGE, hint_string="0, 31"})
+ ret.push_back({name = "month", type=TYPE_INT, hint=PROPERTY_HINT_RANGE, hint_string="0, 12"})
+ ret.push_back({name = "year", type=TYPE_INT, hint=PROPERTY_HINT_RANGE, hint_string="-9999, 9999"})
+
+ ret.push_back({name = "Planetary And Location", type=TYPE_NIL, usage=PROPERTY_USAGE_GROUP})
+ ret.push_back({name = "celestials_calculations", type=TYPE_INT, hint=PROPERTY_HINT_ENUM, hint_string="Simple, Realistic"})
+ ret.push_back({name = "compute_moon_coords", type=TYPE_BOOL})
+
+ if celestials_calculations == 0 && compute_moon_coords:
+ ret.push_back({name = "moon_coords_offset", type=TYPE_VECTOR2})
+
+ ret.push_back({name = "compute_deep_space_coords", type=TYPE_BOOL})
+ ret.push_back({name = "latitude", type=TYPE_FLOAT, hint=PROPERTY_HINT_RANGE, hint_string="-90.0, 90.0"})
+ ret.push_back({name = "longitude", type=TYPE_FLOAT, hint=PROPERTY_HINT_RANGE, hint_string="-180.0, 180.0"})
+ ret.push_back({name = "utc", type=TYPE_FLOAT, hint=PROPERTY_HINT_RANGE, hint_string="-12.0, 12.0"})
+
+ return ret
diff --git a/addons/sky_3d/src/TimeOfDay.gd.uid b/addons/sky_3d/src/TimeOfDay.gd.uid
new file mode 100644
index 0000000..6666a5b
--- /dev/null
+++ b/addons/sky_3d/src/TimeOfDay.gd.uid
@@ -0,0 +1 @@
+uid://bm0hx4mklpml
diff --git a/addons/terrain_3d/LICENSE.txt b/addons/terrain_3d/LICENSE.txt
new file mode 100644
index 0000000..368567b
--- /dev/null
+++ b/addons/terrain_3d/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+
+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.
\ No newline at end of file
diff --git a/addons/terrain_3d/README.md b/addons/terrain_3d/README.md
new file mode 100644
index 0000000..a49fc8e
--- /dev/null
+++ b/addons/terrain_3d/README.md
@@ -0,0 +1,47 @@
+
+
+# Terrain3D
+A high performance, editable terrain system for Godot 4.
+
+
+## Features
+* Written in C++ as a GDExtension addon, which works with official builds of Godot Engine
+* Can be accessed by GDScript, C#, and any language Godot supports
+* Geomorphing Geometric Clipmap Mesh Terrain, as used in The Witcher 3. See [System Architecture](https://terrain3d.readthedocs.io/en/stable/docs/system_architecture.html)
+* Terrains as small as 64x64m up to 65.5x65.5km (4295km^2) in variable sized regions
+* Up to 32 textures
+* Up to 10 levels of detail for the terrain mesh
+* Foliage instancing, with up to 10 levels of detail, and a shadow impostor
+* Sculpting, holes, texture painting, texture detiling, painting colors and wetness
+* Imports heightmaps from [HTerrain](https://github.com/Zylann/godot_heightmap_plugin/), Gaea, World Creator, World Machine, Unity, Unreal and any tool that can export a heightmap. See [heightmaps](https://terrain3d.readthedocs.io/en/stable/docs/heightmaps.html)
+
+
+## Getting Started
+
+1. Read the [Installation & Upgrades](https://terrain3d.readthedocs.io/en/stable/docs/installation.html) instructions.
+
+2. For support, read [Getting Help](https://terrain3d.readthedocs.io/en/stable/docs/getting_help.html) and join our [Discord server](https://tokisan.com/discord).
+
+3. Watch the [tutorial videos](https://terrain3d.readthedocs.io/en/stable/docs/tutorial_videos.html).
+
+
+## Credit
+Developed for the Godot community by:
+
+|||
+|--|--|
+| **Cory Petkovsek, Tokisan Games** | [
](https://twitter.com/TokisanGames) [
](https://github.com/TokisanGames) [
](https://tokisan.com/) [
](https://tokisan.com/discord) [
](https://www.youtube.com/@TokisanGames)|
+| **Roope Palmroos, Outobugi Games** | [
](https://twitter.com/outobugi) [
](https://github.com/outobugi) [
](https://outobugi.com/) [
](https://www.youtube.com/@outobugi)|
+
+And other contributors displayed on the right of the github page and in [AUTHORS.md](https://terrain3d.readthedocs.io/en/stable/docs/authors.html).
+
+
+## Contributing
+
+Please see [CONTRIBUTING.md](https://github.com/TokisanGames/Terrain3D/blob/main/CONTRIBUTING.md) if you would like to help make Terrain3D the best terrain system for Godot.
+
+
+## License
+
+This addon has been released under the [MIT License](https://github.com/TokisanGames/Terrain3D/blob/main/LICENSE.txt).
+
diff --git a/addons/terrain_3d/bin/libterrain.android.debug.arm32.so b/addons/terrain_3d/bin/libterrain.android.debug.arm32.so
new file mode 100644
index 0000000..d56e1a0
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.android.debug.arm32.so differ
diff --git a/addons/terrain_3d/bin/libterrain.android.debug.arm64.so b/addons/terrain_3d/bin/libterrain.android.debug.arm64.so
new file mode 100644
index 0000000..7dc0b98
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.android.debug.arm64.so differ
diff --git a/addons/terrain_3d/bin/libterrain.android.release.arm32.so b/addons/terrain_3d/bin/libterrain.android.release.arm32.so
new file mode 100644
index 0000000..a5dd4ab
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.android.release.arm32.so differ
diff --git a/addons/terrain_3d/bin/libterrain.android.release.arm64.so b/addons/terrain_3d/bin/libterrain.android.release.arm64.so
new file mode 100644
index 0000000..cf1c42d
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.android.release.arm64.so differ
diff --git a/addons/terrain_3d/bin/libterrain.ios.debug.universal.dylib b/addons/terrain_3d/bin/libterrain.ios.debug.universal.dylib
new file mode 100644
index 0000000..9ef876d
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.ios.debug.universal.dylib differ
diff --git a/addons/terrain_3d/bin/libterrain.ios.release.universal.dylib b/addons/terrain_3d/bin/libterrain.ios.release.universal.dylib
new file mode 100644
index 0000000..40423b6
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.ios.release.universal.dylib differ
diff --git a/addons/terrain_3d/bin/libterrain.linux.debug.x86_64.so b/addons/terrain_3d/bin/libterrain.linux.debug.x86_64.so
new file mode 100644
index 0000000..1813074
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.linux.debug.x86_64.so differ
diff --git a/addons/terrain_3d/bin/libterrain.linux.release.x86_64.so b/addons/terrain_3d/bin/libterrain.linux.release.x86_64.so
new file mode 100644
index 0000000..9a594ac
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.linux.release.x86_64.so differ
diff --git a/addons/terrain_3d/bin/libterrain.macos.debug.framework/libterrain.macos.debug b/addons/terrain_3d/bin/libterrain.macos.debug.framework/libterrain.macos.debug
new file mode 100644
index 0000000..847389b
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.macos.debug.framework/libterrain.macos.debug differ
diff --git a/addons/terrain_3d/bin/libterrain.macos.release.framework/libterrain.macos.release b/addons/terrain_3d/bin/libterrain.macos.release.framework/libterrain.macos.release
new file mode 100644
index 0000000..cf57663
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.macos.release.framework/libterrain.macos.release differ
diff --git a/addons/terrain_3d/bin/libterrain.web.debug.wasm32.wasm b/addons/terrain_3d/bin/libterrain.web.debug.wasm32.wasm
new file mode 100644
index 0000000..b77fbd7
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.web.debug.wasm32.wasm differ
diff --git a/addons/terrain_3d/bin/libterrain.web.release.wasm32.wasm b/addons/terrain_3d/bin/libterrain.web.release.wasm32.wasm
new file mode 100644
index 0000000..3fdea6f
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.web.release.wasm32.wasm differ
diff --git a/addons/terrain_3d/bin/libterrain.windows.debug.x86_64.dll b/addons/terrain_3d/bin/libterrain.windows.debug.x86_64.dll
new file mode 100644
index 0000000..23b7f23
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.windows.debug.x86_64.dll differ
diff --git a/addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll b/addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll
new file mode 100644
index 0000000..cdff9a4
Binary files /dev/null and b/addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll differ
diff --git a/addons/terrain_3d/brushes/.gdignore b/addons/terrain_3d/brushes/.gdignore
new file mode 100644
index 0000000..e69de29
diff --git a/addons/terrain_3d/brushes/acrylic1.exr b/addons/terrain_3d/brushes/acrylic1.exr
new file mode 100644
index 0000000..14b66d6
Binary files /dev/null and b/addons/terrain_3d/brushes/acrylic1.exr differ
diff --git a/addons/terrain_3d/brushes/circle0.exr b/addons/terrain_3d/brushes/circle0.exr
new file mode 100644
index 0000000..78937f1
Binary files /dev/null and b/addons/terrain_3d/brushes/circle0.exr differ
diff --git a/addons/terrain_3d/brushes/circle1.exr b/addons/terrain_3d/brushes/circle1.exr
new file mode 100644
index 0000000..e91d97e
Binary files /dev/null and b/addons/terrain_3d/brushes/circle1.exr differ
diff --git a/addons/terrain_3d/brushes/circle2.exr b/addons/terrain_3d/brushes/circle2.exr
new file mode 100644
index 0000000..f6931ba
Binary files /dev/null and b/addons/terrain_3d/brushes/circle2.exr differ
diff --git a/addons/terrain_3d/brushes/circle3.exr b/addons/terrain_3d/brushes/circle3.exr
new file mode 100644
index 0000000..477ab7e
Binary files /dev/null and b/addons/terrain_3d/brushes/circle3.exr differ
diff --git a/addons/terrain_3d/brushes/circle4.exr b/addons/terrain_3d/brushes/circle4.exr
new file mode 100644
index 0000000..b466f92
Binary files /dev/null and b/addons/terrain_3d/brushes/circle4.exr differ
diff --git a/addons/terrain_3d/brushes/hill1.exr b/addons/terrain_3d/brushes/hill1.exr
new file mode 100644
index 0000000..a668ab6
Binary files /dev/null and b/addons/terrain_3d/brushes/hill1.exr differ
diff --git a/addons/terrain_3d/brushes/hill2.exr b/addons/terrain_3d/brushes/hill2.exr
new file mode 100644
index 0000000..068e22b
Binary files /dev/null and b/addons/terrain_3d/brushes/hill2.exr differ
diff --git a/addons/terrain_3d/brushes/mountain1.exr b/addons/terrain_3d/brushes/mountain1.exr
new file mode 100644
index 0000000..be71748
Binary files /dev/null and b/addons/terrain_3d/brushes/mountain1.exr differ
diff --git a/addons/terrain_3d/brushes/mountain2.exr b/addons/terrain_3d/brushes/mountain2.exr
new file mode 100644
index 0000000..ca30373
Binary files /dev/null and b/addons/terrain_3d/brushes/mountain2.exr differ
diff --git a/addons/terrain_3d/brushes/mountain3.exr b/addons/terrain_3d/brushes/mountain3.exr
new file mode 100644
index 0000000..ca07a1e
Binary files /dev/null and b/addons/terrain_3d/brushes/mountain3.exr differ
diff --git a/addons/terrain_3d/brushes/mountain4.exr b/addons/terrain_3d/brushes/mountain4.exr
new file mode 100644
index 0000000..f8197fe
Binary files /dev/null and b/addons/terrain_3d/brushes/mountain4.exr differ
diff --git a/addons/terrain_3d/brushes/peak1.exr b/addons/terrain_3d/brushes/peak1.exr
new file mode 100644
index 0000000..49d341e
Binary files /dev/null and b/addons/terrain_3d/brushes/peak1.exr differ
diff --git a/addons/terrain_3d/brushes/peak2.exr b/addons/terrain_3d/brushes/peak2.exr
new file mode 100644
index 0000000..db74297
Binary files /dev/null and b/addons/terrain_3d/brushes/peak2.exr differ
diff --git a/addons/terrain_3d/brushes/peak3.exr b/addons/terrain_3d/brushes/peak3.exr
new file mode 100644
index 0000000..9383681
Binary files /dev/null and b/addons/terrain_3d/brushes/peak3.exr differ
diff --git a/addons/terrain_3d/brushes/ring1.exr b/addons/terrain_3d/brushes/ring1.exr
new file mode 100644
index 0000000..6396804
Binary files /dev/null and b/addons/terrain_3d/brushes/ring1.exr differ
diff --git a/addons/terrain_3d/brushes/smoke.exr b/addons/terrain_3d/brushes/smoke.exr
new file mode 100644
index 0000000..021947b
Binary files /dev/null and b/addons/terrain_3d/brushes/smoke.exr differ
diff --git a/addons/terrain_3d/brushes/square1.exr b/addons/terrain_3d/brushes/square1.exr
new file mode 100644
index 0000000..3aff9cd
Binary files /dev/null and b/addons/terrain_3d/brushes/square1.exr differ
diff --git a/addons/terrain_3d/brushes/square2.exr b/addons/terrain_3d/brushes/square2.exr
new file mode 100644
index 0000000..230113c
Binary files /dev/null and b/addons/terrain_3d/brushes/square2.exr differ
diff --git a/addons/terrain_3d/brushes/square3.exr b/addons/terrain_3d/brushes/square3.exr
new file mode 100644
index 0000000..6da88b8
Binary files /dev/null and b/addons/terrain_3d/brushes/square3.exr differ
diff --git a/addons/terrain_3d/brushes/square4.exr b/addons/terrain_3d/brushes/square4.exr
new file mode 100644
index 0000000..350cd7d
Binary files /dev/null and b/addons/terrain_3d/brushes/square4.exr differ
diff --git a/addons/terrain_3d/brushes/square5.exr b/addons/terrain_3d/brushes/square5.exr
new file mode 100644
index 0000000..f0832e0
Binary files /dev/null and b/addons/terrain_3d/brushes/square5.exr differ
diff --git a/addons/terrain_3d/brushes/stones.exr b/addons/terrain_3d/brushes/stones.exr
new file mode 100644
index 0000000..7ef5977
Binary files /dev/null and b/addons/terrain_3d/brushes/stones.exr differ
diff --git a/addons/terrain_3d/brushes/terrain1.exr b/addons/terrain_3d/brushes/terrain1.exr
new file mode 100644
index 0000000..8366b52
Binary files /dev/null and b/addons/terrain_3d/brushes/terrain1.exr differ
diff --git a/addons/terrain_3d/brushes/terrain2.exr b/addons/terrain_3d/brushes/terrain2.exr
new file mode 100644
index 0000000..d11a069
Binary files /dev/null and b/addons/terrain_3d/brushes/terrain2.exr differ
diff --git a/addons/terrain_3d/brushes/terrain3.exr b/addons/terrain_3d/brushes/terrain3.exr
new file mode 100644
index 0000000..61bfe0f
Binary files /dev/null and b/addons/terrain_3d/brushes/terrain3.exr differ
diff --git a/addons/terrain_3d/brushes/terrain4.exr b/addons/terrain_3d/brushes/terrain4.exr
new file mode 100644
index 0000000..e8f4ce4
Binary files /dev/null and b/addons/terrain_3d/brushes/terrain4.exr differ
diff --git a/addons/terrain_3d/brushes/terrain5.exr b/addons/terrain_3d/brushes/terrain5.exr
new file mode 100644
index 0000000..ad3fe5b
Binary files /dev/null and b/addons/terrain_3d/brushes/terrain5.exr differ
diff --git a/addons/terrain_3d/brushes/terrain6.exr b/addons/terrain_3d/brushes/terrain6.exr
new file mode 100644
index 0000000..0a15419
Binary files /dev/null and b/addons/terrain_3d/brushes/terrain6.exr differ
diff --git a/addons/terrain_3d/brushes/texture1.exr b/addons/terrain_3d/brushes/texture1.exr
new file mode 100644
index 0000000..f456b77
Binary files /dev/null and b/addons/terrain_3d/brushes/texture1.exr differ
diff --git a/addons/terrain_3d/brushes/texture2.exr b/addons/terrain_3d/brushes/texture2.exr
new file mode 100644
index 0000000..2624d3b
Binary files /dev/null and b/addons/terrain_3d/brushes/texture2.exr differ
diff --git a/addons/terrain_3d/brushes/texture3.exr b/addons/terrain_3d/brushes/texture3.exr
new file mode 100644
index 0000000..690fe5e
Binary files /dev/null and b/addons/terrain_3d/brushes/texture3.exr differ
diff --git a/addons/terrain_3d/brushes/texture4.exr b/addons/terrain_3d/brushes/texture4.exr
new file mode 100644
index 0000000..a2d96aa
Binary files /dev/null and b/addons/terrain_3d/brushes/texture4.exr differ
diff --git a/addons/terrain_3d/brushes/texture5.exr b/addons/terrain_3d/brushes/texture5.exr
new file mode 100644
index 0000000..62aad60
Binary files /dev/null and b/addons/terrain_3d/brushes/texture5.exr differ
diff --git a/addons/terrain_3d/brushes/vegetation1.exr b/addons/terrain_3d/brushes/vegetation1.exr
new file mode 100644
index 0000000..d65bc6e
Binary files /dev/null and b/addons/terrain_3d/brushes/vegetation1.exr differ
diff --git a/addons/terrain_3d/extras/hex_grid.gdshaderinc b/addons/terrain_3d/extras/hex_grid.gdshaderinc
new file mode 100644
index 0000000..0fcd552
--- /dev/null
+++ b/addons/terrain_3d/extras/hex_grid.gdshaderinc
@@ -0,0 +1,67 @@
+// Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+// This shader snippet draws a hex grid
+
+// To use it, add this line to the top of your shader:
+// #include "res://addons/terrain_3d/extras/hex_grid.gdshaderinc"
+
+// And this line at the bottom of your shader:
+// draw_hex_grid(uv2, _region_texel_size, w_normal, ALBEDO);
+
+mat2 rotate2d(float _angle) {
+ return mat2(vec2(cos(_angle),-sin(_angle)), vec2(sin(_angle), cos(_angle)));
+}
+
+void draw_hex_grid(vec2 uv, float texel_size, vec3 normal, inout vec3 albedo) {
+ float hex_size = 0.02;
+ float line_thickness = 0.04;
+
+ vec2 guv = (uv - vec2(0.5 * texel_size)) / hex_size;
+
+ // Convert UV to axial hex coordinates
+ float q = (sqrt(3.0) / 3.0 * guv.x - 1.0 / 3.0 * guv.y);
+ float r = (2.0 / 3.0 * guv.y);
+
+ // Cube coordinates for the hex (q, r, -q-r)
+ float x = q;
+ float z = r;
+ float y = -x - z;
+
+ // Round to the nearest hex center
+ vec3 rounded = round(vec3(x, y, z));
+ vec3 diff = abs(vec3(x, y, z) - rounded);
+
+ // Fix rounding errors
+ if (diff.x > diff.y && diff.x > diff.z) {
+ rounded.x = -rounded.y - rounded.z;
+ } else if (diff.y > diff.z) {
+ rounded.y = -rounded.x - rounded.z;
+ } else {
+ rounded.z = -rounded.x - rounded.y;
+ }
+
+ // Find the hex center in UV space
+ vec2 hex_center = vec2(
+ sqrt(3.0) * rounded.x + sqrt(3.0) / 2.0 * rounded.z,
+ 3.0 / 2.0 * rounded.z
+ );
+
+ // Relative position within the hex
+ vec2 local_pos = guv - hex_center;
+ vec2 lines_uv = local_pos;
+ float line = 1.0;
+
+ for (int i = 0; i < 6; i++) {
+ vec2 luv = lines_uv * rotate2d(radians(60.0 * float(i) + 30.0));
+ float dist = abs(dot(luv + vec2(0.90), vec2(0.0, 1.0)));
+ line = min(line, dist);
+ }
+
+ // Filter lines by slope
+ float slope = 4.; // Can also assign to (auto_slope * 4.) to match grass placement
+ float slope_factor = clamp(dot(vec3(0., 1., 0.), slope * (normal - 1.) + 1.), 0., 1.);
+
+ // Draw hex grid
+ albedo = mix(albedo, vec3(1.0), smoothstep(line_thickness + 0.02, line_thickness, line) * slope_factor);
+ // Draw Hex center dot
+ albedo = mix(albedo, vec3(0.0, 0.5, 0.5), smoothstep(0.11, 0.10, length(local_pos)) * slope_factor);
+}
\ No newline at end of file
diff --git a/addons/terrain_3d/extras/hex_grid.gdshaderinc.uid b/addons/terrain_3d/extras/hex_grid.gdshaderinc.uid
new file mode 100644
index 0000000..feee7f6
--- /dev/null
+++ b/addons/terrain_3d/extras/hex_grid.gdshaderinc.uid
@@ -0,0 +1 @@
+uid://mri8pfoj2mfk
diff --git a/addons/terrain_3d/extras/import_sgt.gd b/addons/terrain_3d/extras/import_sgt.gd
new file mode 100644
index 0000000..44bb685
--- /dev/null
+++ b/addons/terrain_3d/extras/import_sgt.gd
@@ -0,0 +1,42 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Import From SimpleGrassTextured
+#
+# This script demonstrates how to import transforms from SimpleGrassTextured. To use it:
+#
+# 1. Setup the mesh asset you wish to use in the asset dock.
+# 1. Select your Terrain3D node.
+# 1. In the inspector, click Script (very bottom) and Quick Load import_sgt.gd.
+# 1. At the very top, assign your SimpleGrassTextured node.
+# 1. Input the desired mesh asset ID.
+# 1. Click import. The output window and console will report when finished.
+# 1. Clear the script from your Terrain3D node, and save your scene.
+#
+# The instance transforms are now stored in your region files.
+#
+# Use clear_instances to erase all instances that match the assign_mesh_id.
+#
+# The add_transforms function (called by add_multimesh) applies the height_offset specified in the
+# Terrain3DMeshAsset.
+# Once the transforms are imported, you can reassign any mesh you like into this mesh slot.
+
+@tool
+extends Terrain3D
+
+@export var simple_grass_textured: MultiMeshInstance3D
+@export var assign_mesh_id: int
+@export var import: bool = false : set = import_sgt
+@export var clear_instances: bool = false : set = clear_multimeshes
+
+
+func clear_multimeshes(value: bool) -> void:
+ get_instancer().clear_by_mesh(assign_mesh_id)
+
+
+func import_sgt(value: bool) -> void:
+ var sgt_mm: MultiMesh = simple_grass_textured.multimesh
+ var global_xform: Transform3D = simple_grass_textured.global_transform
+ print("Starting to import %d instances from SimpleGrassTextured using mesh id %d" % [ sgt_mm.instance_count, assign_mesh_id])
+ var time: int = Time.get_ticks_msec()
+ get_instancer().add_multimesh(assign_mesh_id, sgt_mm, simple_grass_textured.global_transform)
+ print("Import complete in %.2f seconds" % [ float(Time.get_ticks_msec() - time)/1000. ])
+
diff --git a/addons/terrain_3d/extras/import_sgt.gd.uid b/addons/terrain_3d/extras/import_sgt.gd.uid
new file mode 100644
index 0000000..00b3868
--- /dev/null
+++ b/addons/terrain_3d/extras/import_sgt.gd.uid
@@ -0,0 +1 @@
+uid://bllcuwetve45k
diff --git a/addons/terrain_3d/extras/lightweight.gdshader b/addons/terrain_3d/extras/lightweight.gdshader
new file mode 100644
index 0000000..b0716a1
--- /dev/null
+++ b/addons/terrain_3d/extras/lightweight.gdshader
@@ -0,0 +1,400 @@
+shader_type spatial;
+render_mode blend_mix,depth_draw_opaque,cull_back,diffuse_burley,specular_schlick_ggx,skip_vertex_transform;
+
+/* This is an example stripped down shader with maximum performance in mind.
+ * Only Autoshader/Base/Over/Blend/Holes/Colormap are supported.
+ * All terrain normal calculations take place in vetex() as well as control map reads
+ * for the bilinear blend, when not skippable have moved to vertex() too.
+ *
+ * A single controlmap lookup in fragment is added at distances where the vertices spread too wide.
+ */
+
+// Defined Constants
+#define SKIP_PASS 0
+#define VERTEX_PASS 1
+#define FRAGMENT_PASS 2
+
+#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
+ #define fma(a, b, c) ((a) * (b) + (c))
+ #define dFdxCoarse(a) dFdx(a)
+ #define dFdyCoarse(a) dFdy(a)
+#endif
+
+// Private uniforms
+uniform vec3 _camera_pos = vec3(0.f);
+uniform float _mesh_size = 48.f;
+uniform uint _background_mode = 1u; // NONE = 0, FLAT = 1, NOISE = 2
+uniform uint _mouse_layer = 0x80000000u; // Layer 32
+uniform float _vertex_spacing = 1.0;
+uniform float _vertex_density = 1.0; // = 1/_vertex_spacing
+uniform float _region_size = 1024.0;
+uniform float _region_texel_size = 0.0009765625; // = 1/1024
+uniform int _region_map_size = 32;
+uniform int _region_map[1024];
+uniform vec2 _region_locations[1024];
+uniform float _texture_normal_depth_array[32];
+uniform float _texture_ao_strength_array[32];
+uniform float _texture_roughness_mod_array[32];
+uniform float _texture_uv_scale_array[32];
+uniform vec4 _texture_color_array[32];
+uniform highp sampler2DArray _height_maps : repeat_disable;
+uniform highp sampler2DArray _control_maps : repeat_disable;
+uniform highp sampler2DArray _color_maps : source_color, filter_linear_mipmap, repeat_disable;
+uniform highp sampler2DArray _texture_array_albedo : source_color, filter_linear_mipmap, repeat_enable;
+uniform highp sampler2DArray _texture_array_normal : hint_normal, filter_linear_mipmap, repeat_enable;
+
+
+// Public uniforms
+uniform float auto_slope : hint_range(0, 10) = 1.0;
+uniform float auto_height_reduction : hint_range(0, 1) = 0.1;
+uniform int auto_base_texture : hint_range(0, 31) = 0;
+uniform int auto_overlay_texture : hint_range(0, 31) = 1;
+
+uniform bool height_blending = true;
+uniform bool world_space_normal_blend = true;
+uniform float blend_sharpness : hint_range(0, 1) = 0.87;
+
+// Varyings & Types
+
+struct Material {
+ vec4 alb_ht;
+ vec4 nrm_rg;
+ int base;
+ int over;
+ float blend;
+ float nrm_depth;
+ float ao_str;
+};
+
+
+varying vec3 v_vertex;
+varying vec3 v_normal;
+varying flat uint v_control[4];
+varying flat int v_lerp;
+varying mat3 v_tbn;
+
+////////////////////////
+// Vertex
+////////////////////////
+
+// Takes in world space XZ (UV) coordinates & search depth (only applicable for background mode none)
+// Returns ivec3 with:
+// XY: (0 to _region_size - 1) coordinates within a region
+// Z: layer index used for texturearrays, -1 if not in a region
+ivec3 get_index_coord(const vec2 uv, const int search) {
+ vec2 r_uv = round(uv);
+ vec2 o_uv = mod(r_uv,_region_size);
+ ivec2 pos;
+ int bounds, layer_index = -1;
+ for (int i = -1; i < clamp(search, SKIP_PASS, FRAGMENT_PASS); i++) {
+ if ((layer_index == -1 && _background_mode == 0u ) || i < 0) {
+ r_uv -= i == -1 ? vec2(0.0) : vec2(float(o_uv.x <= o_uv.y), float(o_uv.y <= o_uv.x));
+ pos = ivec2(floor((r_uv) * _region_texel_size)) + (_region_map_size / 2);
+ bounds = int(uint(pos.x | pos.y) < uint(_region_map_size));
+ layer_index = (_region_map[ pos.y * _region_map_size + pos.x ] * bounds - 1);
+ }
+ }
+ return ivec3(ivec2(mod(r_uv,_region_size)), layer_index);
+}
+
+// Takes in descaled (world_space / region_size) world to region space XZ (UV2) coordinates, returns vec3 with:
+// XY: (0. to 1.) coordinates within a region
+// Z: layer index used for texturearrays, -1 if not in a region
+vec3 get_index_uv(const vec2 uv2) {
+ ivec2 pos = ivec2(floor(uv2)) + (_region_map_size / 2);
+ int bounds = int(uint(pos.x | pos.y) < uint(_region_map_size));
+ int layer_index = _region_map[ pos.y * _region_map_size + pos.x ] * bounds - 1;
+ return vec3(uv2 - _region_locations[layer_index], float(layer_index));
+}
+
+void vertex() {
+ // Get vertex of flat plane in world coordinates and set world UV
+ v_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+
+ // Camera distance to vertex on flat plane
+ float v_vertex_xz_dist = length(v_vertex.xz - _camera_pos.xz);
+
+ // Geomorph vertex, set end and start for linear height interpolate
+ float scale = MODEL_MATRIX[0][0];
+ float vertex_lerp = smoothstep(0.55, 0.95, (v_vertex_xz_dist / scale - _mesh_size - 4.0) / (_mesh_size - 2.0));
+ vec2 v_fract = fract(VERTEX.xz * 0.5) * 2.0;
+ // For LOD0 morph from a regular grid to an alternating grid to align with LOD1+
+ vec2 shift = (scale < _vertex_spacing + 1e-6) ? // LOD0 or not
+ // Shift from regular to symetric
+ mix(v_fract, vec2(v_fract.x, -v_fract.y),
+ round(fract(round(mod(v_vertex.z * _vertex_density, 4.0)) *
+ round(mod(v_vertex.x * _vertex_density, 4.0)) * 0.25))
+ ) :
+ // Symetric shift
+ v_fract * round((fract(v_vertex.xz * 0.25 / scale) - 0.5) * 4.0);
+ vec2 start_pos = v_vertex.xz * _vertex_density;
+ vec2 end_pos = (v_vertex.xz - shift * scale) * _vertex_density;
+ v_vertex.xz -= shift * scale * vertex_lerp;
+
+ // UV coordinates in world space. Values are 0 to _region_size within regions
+ UV = v_vertex.xz * _vertex_density;
+
+ // UV coordinates in region space + texel offset. Values are 0 to 1 within regions
+ UV2 = fma(UV, vec2(_region_texel_size), vec2(0.5 * _region_texel_size));
+
+ const vec3 offsets = vec3(0, 1, 2);
+ ivec3 indexUV[4];
+ // control map lookups in vertex, used for bilinear blend in fragment.
+ indexUV[0] = get_index_coord(start_pos + offsets.xy, VERTEX_PASS);
+ indexUV[1] = get_index_coord(start_pos + offsets.yy, VERTEX_PASS);
+ indexUV[2] = get_index_coord(start_pos + offsets.yx, VERTEX_PASS);
+ indexUV[3] = get_index_coord(start_pos + offsets.xx, VERTEX_PASS);
+ // Mask off Scale/Rotation/Navigation bits to 0, as they are not used.
+ #define CONTROL_MASK 0xFFFFC07Du
+ v_control[0] = floatBitsToUint(texelFetch(_control_maps, indexUV[0], 0)).r & CONTROL_MASK;
+ v_control[1] = floatBitsToUint(texelFetch(_control_maps, indexUV[1], 0)).r & CONTROL_MASK;
+ v_control[2] = floatBitsToUint(texelFetch(_control_maps, indexUV[2], 0)).r & CONTROL_MASK;
+ v_control[3] = floatBitsToUint(texelFetch(_control_maps, indexUV[3], 0)).r & CONTROL_MASK;
+ bool full_auto = !bool((v_control[0] & v_control[1] & v_control[2] & v_control[3]) & 0x1u);
+ bool identical = !(
+ (v_control[0] == v_control[1]) &&
+ (v_control[1] == v_control[2]) &&
+ (v_control[2] == v_control[3]));
+ // Verticies are close enough, full auto shader, or all 4 indicies match, skip bilinear blend in fragment.
+ v_lerp = scale < _vertex_spacing + 1e-3 && vertex_lerp < 1e-3 && (full_auto || identical) ? 1 : 0;
+
+ // Discard vertices for Holes. 1 lookup
+ bool hole = bool(v_control[3] >>2u & 0x1u);
+
+ // Show holes to all cameras except mouse camera (on exactly 1 layer)
+ if ( !(CAMERA_VISIBLE_LAYERS == _mouse_layer) &&
+ (hole || (_background_mode == 0u && indexUV[3].z == -1))) {
+ v_vertex.x = 0. / 0.;
+ } else {
+ // Set final vertex height & calculate vertex normals. 3 lookups
+ ivec3 uv_a = get_index_coord(start_pos, VERTEX_PASS);
+ ivec3 uv_b = get_index_coord(end_pos, VERTEX_PASS);
+ float h = mix(texelFetch(_height_maps, uv_a, 0).r,texelFetch(_height_maps, uv_b, 0).r,vertex_lerp);
+ float u = mix(texelFetch(_height_maps, get_index_coord(start_pos + vec2(1,0), VERTEX_PASS), 0).r,
+ texelFetch(_height_maps, get_index_coord(end_pos + vec2(1,0), VERTEX_PASS), 0).r, vertex_lerp);
+ float v = mix(texelFetch(_height_maps, get_index_coord(start_pos + vec2(0,1), VERTEX_PASS), 0).r,
+ texelFetch(_height_maps, get_index_coord(end_pos + vec2(0,1), VERTEX_PASS), 0).r, vertex_lerp);
+ v_vertex.y = h;
+ v_normal = vec3(h - u, _vertex_spacing, h - v);
+ }
+
+ // Convert model space to view space w/ skip_vertex_transform render mode
+ VERTEX = (VIEW_MATRIX * vec4(v_vertex, 1.0)).xyz;
+
+ // Apply terrain normals
+ vec3 w_normal = normalize(v_normal);
+ vec3 w_tangent = normalize(cross(w_normal, vec3(0.0, 0.0, 1.0)));
+ vec3 w_binormal = normalize(cross(w_normal, w_tangent));
+
+ v_tbn = mat3(w_tangent, w_normal, w_binormal);
+
+ NORMAL = normalize((VIEW_MATRIX * vec4(w_normal, 0.0)).xyz);
+ BINORMAL = normalize((VIEW_MATRIX * vec4(w_binormal, 0.0)).xyz);
+ TANGENT = normalize((VIEW_MATRIX * vec4(w_tangent, 0.0)).xyz);
+}
+
+////////////////////////
+// Fragment
+////////////////////////
+
+vec3 unpack_normal(vec4 rgba) {
+ return fma(rgba.xzy, vec3(2.0), vec3(-1.0));
+}
+
+vec3 pack_normal(vec3 n) {
+ return fma(normalize(n.xzy), vec3(0.5), vec3(0.5));
+}
+
+vec4 height_blend4(vec4 a_value, float a_height, vec4 b_value, float b_height, float blend) {
+ if(height_blending) {
+ float ma = max(a_height + (1.0 - blend), b_height + blend) - (1.001 - blend_sharpness);
+ float b1 = max(a_height + (1.0 - blend) - ma, 0.0);
+ float b2 = max(b_height + blend - ma, 0.0);
+ return (a_value * b1 + b_value * b2) / (b1 + b2);
+ } else {
+ float contrast = 1.0 - blend_sharpness;
+ float factor = (blend - contrast) / contrast;
+ return mix(a_value, b_value, clamp(factor, 0.0, 1.0));
+ }
+}
+
+float height_blend1(float a_value, float a_height, float b_value, float b_height, float blend) {
+ if(height_blending) {
+ float ma = max(a_height + (1.0 - blend), b_height + blend) - (1.001 - blend_sharpness);
+ float b1 = max(a_height + (1.0 - blend) - ma, 0.0);
+ float b2 = max(b_height + blend - ma, 0.0);
+ return (a_value * b1 + b_value * b2) / (b1 + b2);
+ } else {
+ float contrast = 1.0 - blend_sharpness;
+ float factor = (blend - contrast) / contrast;
+ return mix(a_value, b_value, clamp(factor, 0.0, 1.0));
+ }
+}
+
+// 2-4 lookups ( 2-6 with dual scaling )
+void get_material(vec4 ddxy, uint control, vec3 iuv_center, out Material out_mat) {
+ out_mat = Material(vec4(0.), vec4(0.), 0, 0, 0.0, 0.0, 0.0);
+ int region = int(iuv_center.z);
+ vec2 base_uv = v_vertex.xz * 0.5;
+ ddxy *= 0.5;
+
+ // Enable Autoshader if outside regions or painted in regions, otherwise manual painted
+ bool auto_shader = region < 0 || bool(control & 0x1u);
+ out_mat.base = int(auto_shader) * auto_base_texture + int(!auto_shader) * int(control >>27u & 0x1Fu);
+ out_mat.over = int(auto_shader) * auto_overlay_texture + int(!auto_shader) * int(control >> 22u & 0x1Fu);
+ out_mat.blend = float(auto_shader) * clamp(
+ (auto_slope * 2. * ( v_tbn[1].y - 1.) + 1.)
+ - auto_height_reduction * .01 * v_vertex.y // Reduce as vertices get higher
+ , 0., 1.) +
+ float(!auto_shader) * float(control >>14u & 0xFFu) * 0.003921568627450; // 1./255.0
+
+ out_mat.nrm_depth = _texture_normal_depth_array[out_mat.base];
+ out_mat.ao_str = _texture_ao_strength_array[out_mat.base];
+
+ vec2 matUV = base_uv;
+ vec4 albedo_ht = vec4(0.);
+ vec4 normal_rg = vec4(0.5, 0.5, 1.0, 1.0);
+ vec4 albedo_far = vec4(0.);
+ vec4 normal_far = vec4(0.5, 0.5, 1.0, 1.0);
+ float mat_scale = _texture_uv_scale_array[out_mat.base];
+ vec4 base_dd = ddxy;
+
+ if (out_mat.blend < 1.0) {
+ // 2 lookups
+ //each time we change scale, recalculate antitiling from baseline to maintain continuity.
+ matUV = base_uv * mat_scale;
+ base_dd *= mat_scale;
+ albedo_ht = textureGrad(_texture_array_albedo, vec3(matUV, float(out_mat.base)), base_dd.xy, base_dd.zw);
+ normal_rg = textureGrad(_texture_array_normal, vec3(matUV, float(out_mat.base)), base_dd.xy, base_dd.zw);
+
+ // Unpack & rotate base normal for blending
+ normal_rg.xyz = unpack_normal(normal_rg);
+ }
+ // Apply color to base
+ albedo_ht.rgb *= _texture_color_array[out_mat.base].rgb;
+
+ // Apply Roughness modifier to base
+ normal_rg.a = clamp(normal_rg.a + _texture_roughness_mod_array[out_mat.base], 0., 1.);
+
+ out_mat.alb_ht = albedo_ht;
+ out_mat.nrm_rg = normal_rg;
+
+ if (out_mat.blend > 0.) {
+ // 2 lookups
+ // Setup overlay texture to blend
+ float mat_scale2 = _texture_uv_scale_array[out_mat.over];
+ vec2 matUV2 = base_uv * mat_scale2;
+ vec4 over_dd = ddxy * mat_scale2;
+ vec4 albedo_ht2 = textureGrad(_texture_array_albedo, vec3(matUV2, float(out_mat.over)), over_dd.xy, over_dd.zw);
+ vec4 normal_rg2 = textureGrad(_texture_array_normal, vec3(matUV2, float(out_mat.over)), over_dd.xy, over_dd.zw);
+
+ // Unpack & rotate overlay normal for blending
+ normal_rg2.xyz = unpack_normal(normal_rg2);
+
+ // Apply color to overlay
+ albedo_ht2.rgb *= _texture_color_array[out_mat.over].rgb;
+
+ // Apply Roughness modifier to overlay
+ normal_rg2.a = clamp(normal_rg2.a + _texture_roughness_mod_array[out_mat.over], 0., 1.);
+
+ // apply world space normal weighting from base, to overlay layer
+ // Its a matrix Mult, but the value is rather high, so not cutting this one.
+ if (world_space_normal_blend) {
+ albedo_ht2.a *= bool(control >>3u & 0x1u) ? 1.0 : clamp((v_tbn * normal_rg.xyz).y, 0.0, 1.0);
+ }
+
+ // Blend overlay and base
+ out_mat.alb_ht = height_blend4(albedo_ht, albedo_ht.a, albedo_ht2, albedo_ht2.a, out_mat.blend);
+ out_mat.nrm_rg = height_blend4(normal_rg, albedo_ht.a, normal_rg2, albedo_ht2.a, out_mat.blend);
+ out_mat.nrm_depth = height_blend1(_texture_normal_depth_array[out_mat.base], albedo_ht.a,
+ _texture_normal_depth_array[out_mat.over], albedo_ht2.a, out_mat.blend);
+ out_mat.ao_str = height_blend1(_texture_ao_strength_array[out_mat.base], albedo_ht.a,
+ _texture_ao_strength_array[out_mat.over], albedo_ht2.a, out_mat.blend);
+ }
+ return;
+}
+
+void fragment() {
+ // Recover UVs
+ vec2 uv = UV;
+ vec2 uv2 = UV2;
+
+ vec3 base_ddx = dFdxCoarse(v_vertex);
+ vec3 base_ddy = dFdyCoarse(v_vertex);
+ vec4 base_derivatives = vec4(base_ddx.xz, base_ddy.xz);
+ float region_mip = log2(max(length(base_ddx.xz), length(base_ddy.xz)) * _vertex_density);
+
+ // Colormap. 1 lookup
+ // For speed sake, we'll live with cross region artifacts.
+ #define COLOR_MAP vec4(1.0, 1.0, 1.0, 0.5)
+ vec3 region_uv = get_index_uv(uv2);
+ vec4 color_map = region_uv.z > -1.0 ? textureLod(_color_maps, region_uv, region_mip) : COLOR_MAP;
+
+ Material mat[4];
+ uint control = floatBitsToUint(texelFetch(_control_maps, get_index_coord(floor(uv), FRAGMENT_PASS), 0)).r;
+ get_material(base_derivatives, control, region_uv, mat[3]);
+
+ vec4 albedo_height = mat[3].alb_ht;
+ vec4 normal_rough = mat[3].nrm_rg;
+ float normal_map_depth = mat[3].nrm_depth;
+ float ao_strength = mat[3].ao_str;
+
+ // Only do blend if we really have to.
+ if (v_lerp == 1) {
+ get_material(base_derivatives, v_control[0], region_uv, mat[0]);
+ get_material(base_derivatives, v_control[1], region_uv, mat[1]);
+ get_material(base_derivatives, v_control[2], region_uv, mat[2]);
+
+ // we dont need weights before this point when using vertex normals.
+ vec2 weight = fract(uv);
+ vec2 invert = 1.0 - weight;
+ vec4 weights = vec4(
+ invert.x * weight.y, // 0
+ weight.x * weight.y, // 1
+ weight.x * invert.y, // 2
+ invert.x * invert.y // 3
+ );
+
+ // Interpolate Albedo/Height/Normal/Roughness
+ albedo_height =
+ mat[0].alb_ht * weights[0] +
+ mat[1].alb_ht * weights[1] +
+ mat[2].alb_ht * weights[2] +
+ mat[3].alb_ht * weights[3] ;
+
+ normal_rough =
+ mat[0].nrm_rg * weights[0] +
+ mat[1].nrm_rg * weights[1] +
+ mat[2].nrm_rg * weights[2] +
+ mat[3].nrm_rg * weights[3] ;
+
+ normal_map_depth =
+ mat[0].nrm_depth * weights[0] +
+ mat[1].nrm_depth * weights[1] +
+ mat[2].nrm_depth * weights[2] +
+ mat[3].nrm_depth * weights[3] ;
+
+ ao_strength =
+ mat[0].ao_str * weights[0] +
+ mat[1].ao_str * weights[1] +
+ mat[2].ao_str * weights[2] +
+ mat[3].ao_str * weights[3] ;
+ }
+
+ // Wetness/roughness modifier, converting 0 - 1 range to -1 to 1 range
+ float roughness = fma(color_map.a - 0.5, 2.0, normal_rough.a);
+
+ // Apply PBR
+ ALBEDO = albedo_height.rgb * color_map.rgb;
+ ROUGHNESS = roughness;
+ SPECULAR = 1. - normal_rough.a;
+ NORMAL_MAP = pack_normal(normal_rough.rgb);
+ NORMAL_MAP_DEPTH = normal_map_depth;
+
+ // Higher and/or facing up, less occluded.
+ // This is also virtually free.
+ float ao = (1.0 - (albedo_height.a * log(2.1 - ao_strength))) * (1.0 - normal_rough.y);
+ AO = clamp(1.0 - ao * ao_strength, albedo_height.a, 1.0);
+ AO_LIGHT_AFFECT = albedo_height.a;
+
+}
diff --git a/addons/terrain_3d/extras/lightweight.gdshader.uid b/addons/terrain_3d/extras/lightweight.gdshader.uid
new file mode 100644
index 0000000..b4cd1db
--- /dev/null
+++ b/addons/terrain_3d/extras/lightweight.gdshader.uid
@@ -0,0 +1 @@
+uid://bbx2xhanpq5l3
diff --git a/addons/terrain_3d/extras/lowpoly_colormap.gdshader b/addons/terrain_3d/extras/lowpoly_colormap.gdshader
new file mode 100644
index 0000000..ab14af2
--- /dev/null
+++ b/addons/terrain_3d/extras/lowpoly_colormap.gdshader
@@ -0,0 +1,163 @@
+// Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+// This is an example of a minimal, low-poly style shader colored by the color map and wetness tools.
+// No textures are needed or used in this shader.
+
+shader_type spatial;
+render_mode blend_mix,depth_draw_opaque,cull_back,diffuse_burley,specular_schlick_ggx,skip_vertex_transform;
+
+// Defined Constants
+#define SKIP_PASS 0
+#define VERTEX_PASS 1
+#define FRAGMENT_PASS 2
+
+#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
+ #define fma(a, b, c) ((a) * (b) + (c))
+ #define dFdxCoarse(a) dFdx(a)
+ #define dFdyCoarse(a) dFdy(a)
+#endif
+
+// Private uniforms
+uniform vec3 _camera_pos = vec3(0.f);
+uniform float _mesh_size = 48.f;
+uniform uint _background_mode = 1u; // NONE = 0, FLAT = 1, NOISE = 2
+uniform uint _mouse_layer = 0x80000000u; // Layer 32
+uniform float _vertex_spacing = 1.0;
+uniform float _vertex_density = 1.0; // = 1/_vertex_spacing
+uniform float _region_size = 1024.0;
+uniform float _region_texel_size = 0.0009765625; // = 1/1024
+uniform int _region_map_size = 32;
+uniform int _region_map[1024];
+uniform vec2 _region_locations[1024];
+uniform highp sampler2DArray _height_maps : repeat_disable;
+uniform highp sampler2DArray _control_maps : repeat_disable;
+uniform highp sampler2DArray _color_maps : source_color, filter_nearest_mipmap, repeat_disable;
+
+// Public uniforms
+uniform vec3 default_albedo : source_color = vec3(.38, .35, .3);
+uniform float default_roughness : hint_range(0.0, 1.0, 0.01) = 0.8;
+
+// Varyings & Types
+// Some are required for editor functions
+varying float v_vertex_xz_dist;
+varying vec3 v_vertex;
+
+////////////////////////
+// Vertex
+////////////////////////
+
+// Takes in world space XZ (UV) coordinates & search depth (only applicable for background mode none)
+// Returns ivec3 with:
+// XY: (0 to _region_size - 1) coordinates within a region
+// Z: layer index used for texturearrays, -1 if not in a region
+ivec3 get_index_coord(const vec2 uv, const int search) {
+ vec2 r_uv = round(uv);
+ vec2 o_uv = mod(r_uv,_region_size);
+ ivec2 pos;
+ int bounds, layer_index = -1;
+ for (int i = -1; i < clamp(search, SKIP_PASS, FRAGMENT_PASS); i++) {
+ if ((layer_index == -1 && _background_mode == 0u ) || i < 0) {
+ r_uv -= i == -1 ? vec2(0.0) : vec2(float(o_uv.x <= o_uv.y), float(o_uv.y <= o_uv.x));
+ pos = ivec2(floor((r_uv) * _region_texel_size)) + (_region_map_size / 2);
+ bounds = int(uint(pos.x | pos.y) < uint(_region_map_size));
+ layer_index = (_region_map[ pos.y * _region_map_size + pos.x ] * bounds - 1);
+ }
+ }
+ return ivec3(ivec2(mod(r_uv,_region_size)), layer_index);
+}
+
+// Takes in descaled (world_space / region_size) world to region space XZ (UV2) coordinates, returns vec3 with:
+// XY: (0. to 1.) coordinates within a region
+// Z: layer index used for texturearrays, -1 if not in a region
+vec3 get_index_uv(const vec2 uv2) {
+ ivec2 pos = ivec2(floor(uv2)) + (_region_map_size / 2);
+ int bounds = int(uint(pos.x | pos.y) < uint(_region_map_size));
+ int layer_index = _region_map[ pos.y * _region_map_size + pos.x ] * bounds - 1;
+ return vec3(uv2 - _region_locations[layer_index], float(layer_index));
+}
+
+void vertex() {
+ // Get vertex of flat plane in world coordinates and set world UV
+ v_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+
+ // Camera distance to vertex on flat plane
+ v_vertex_xz_dist = length(v_vertex.xz - _camera_pos.xz);
+
+ // Geomorph vertex, set end and start for linear height interpolate
+ float scale = MODEL_MATRIX[0][0];
+ float vertex_lerp = smoothstep(0.55, 0.95, (v_vertex_xz_dist / scale - _mesh_size - 4.0) / (_mesh_size - 2.0));
+ vec2 v_fract = fract(VERTEX.xz * 0.5) * 2.0;
+ // For LOD0 morph from a regular grid to an alternating grid to align with LOD1+
+ vec2 shift = (scale < _vertex_spacing + 1e-6) ? // LOD0 or not
+ // Shift from regular to symetric
+ mix(v_fract, vec2(v_fract.x, -v_fract.y),
+ round(fract(round(mod(v_vertex.z * _vertex_density, 4.0)) *
+ round(mod(v_vertex.x * _vertex_density, 4.0)) * 0.25))
+ ) :
+ // Symetric shift
+ v_fract * round((fract(v_vertex.xz * 0.25 / scale) - 0.5) * 4.0);
+ vec2 start_pos = v_vertex.xz * _vertex_density;
+ vec2 end_pos = (v_vertex.xz - shift * scale) * _vertex_density;
+ v_vertex.xz -= shift * scale * vertex_lerp;
+
+ // UV coordinates in world space. Values are 0 to _region_size within regions
+ UV = v_vertex.xz * _vertex_density;
+
+ // UV coordinates in region space + texel offset. Values are 0 to 1 within regions
+ UV2 = fma(UV, vec2(_region_texel_size), vec2(0.5 * _region_texel_size));
+
+ // Discard vertices for Holes. 1 lookup
+ ivec3 region = get_index_coord(start_pos, VERTEX_PASS);
+ uint control = floatBitsToUint(texelFetch(_control_maps, region, 0)).r;
+ bool hole = bool(control >>2u & 0x1u);
+
+ // Show holes to all cameras except mouse camera (on exactly 1 layer)
+ if ( !(CAMERA_VISIBLE_LAYERS == _mouse_layer) &&
+ (hole || (_background_mode == 0u && region.z < 0))) {
+ v_vertex.x = 0. / 0.;
+ } else {
+ // Interpolate Geomorph Start & End, set height. 2 Lookups.
+ ivec3 uv_a = get_index_coord(start_pos, VERTEX_PASS);
+ ivec3 uv_b = get_index_coord(end_pos, VERTEX_PASS);
+ float h = mix(texelFetch(_height_maps, uv_a, 0).r, texelFetch(_height_maps, uv_b, 0).r, vertex_lerp);
+ v_vertex.y = h;
+ }
+
+ // Convert model space to view space w/ skip_vertex_transform render mode
+ VERTEX = (VIEW_MATRIX * vec4(v_vertex, 1.0)).xyz;
+ NORMAL = normalize((MODELVIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);
+ BINORMAL = normalize((MODELVIEW_MATRIX * vec4(BINORMAL, 0.0)).xyz);
+ TANGENT = normalize((MODELVIEW_MATRIX * vec4(TANGENT, 0.0)).xyz);
+}
+
+////////////////////////
+// Fragment
+////////////////////////
+
+void fragment() {
+ // Recover UVs
+ vec2 uv = UV;
+ vec2 uv2 = UV2;
+
+ // Apply terrain normals
+ vec3 ddx = dFdxCoarse(VERTEX);
+ vec3 ddy = dFdyCoarse(VERTEX);
+ NORMAL = normalize(cross(ddy, ddx));
+ TANGENT = normalize(cross(NORMAL, vec3(0.0, 0.0, 1.0)));
+ BINORMAL = normalize(cross(NORMAL, TANGENT));
+
+ // Determine if we're in a region or not (region_uv.z>0)
+ vec3 region_uv = get_index_uv(uv2);
+
+ // Colormap. 1 lookup
+ float lod = log2(max(length(ddx.xz), length(ddy.xz)) * _vertex_density);
+ vec4 color_map = region_uv.z > -1.0 ?
+ textureLod(_color_maps, region_uv, lod) : vec4(1., 1., 1., .5);
+
+ // Wetness/roughness modifier, converting 0 - 1 range to -1 to 1 range
+ float roughness = fma(color_map.a - 0.5, 2.0, default_roughness);
+
+ // Apply PBR
+ ALBEDO = default_albedo * color_map.rgb;
+ ROUGHNESS = roughness;
+ SPECULAR = 1.0 - roughness;
+}
diff --git a/addons/terrain_3d/extras/lowpoly_colormap.gdshader.uid b/addons/terrain_3d/extras/lowpoly_colormap.gdshader.uid
new file mode 100644
index 0000000..81c4d01
--- /dev/null
+++ b/addons/terrain_3d/extras/lowpoly_colormap.gdshader.uid
@@ -0,0 +1 @@
+uid://bda7fq1rh3nmv
diff --git a/addons/terrain_3d/extras/lowpoly_minimum.gdshader b/addons/terrain_3d/extras/lowpoly_minimum.gdshader
new file mode 100644
index 0000000..3c2109a
--- /dev/null
+++ b/addons/terrain_3d/extras/lowpoly_minimum.gdshader
@@ -0,0 +1,134 @@
+// Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+// This shader is a version of minimum.gdshader with flat normals for a low poly look.
+// Increase vertex_spacing for a better result.
+
+shader_type spatial;
+render_mode blend_mix,depth_draw_opaque,cull_back,diffuse_burley,specular_schlick_ggx,skip_vertex_transform;
+
+// Defined Constants
+#define SKIP_PASS 0
+#define VERTEX_PASS 1
+#define FRAGMENT_PASS 2
+
+#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
+ #define fma(a, b, c) ((a) * (b) + (c))
+ #define dFdxCoarse(a) dFdx(a)
+ #define dFdyCoarse(a) dFdy(a)
+#endif
+
+// Private uniforms
+uniform vec3 _camera_pos = vec3(0.f);
+uniform float _mesh_size = 48.f;
+uniform uint _background_mode = 1u; // NONE = 0, FLAT = 1, NOISE = 2
+uniform uint _mouse_layer = 0x80000000u; // Layer 32
+uniform float _vertex_spacing = 1.0;
+uniform float _vertex_density = 1.0; // = 1/_vertex_spacing
+uniform float _region_size = 1024.0;
+uniform float _region_texel_size = 0.0009765625; // = 1/1024
+uniform int _region_map_size = 32;
+uniform int _region_map[1024];
+uniform vec2 _region_locations[1024];
+uniform highp sampler2DArray _height_maps : repeat_disable;
+uniform highp sampler2DArray _control_maps : repeat_disable;
+
+// Varyings & Types
+// Some are required for editor functions
+varying float v_vertex_xz_dist;
+varying vec3 v_vertex;
+
+////////////////////////
+// Vertex
+////////////////////////
+
+// Takes in world space XZ (UV) coordinates & search depth (only applicable for background mode none)
+// Returns ivec3 with:
+// XY: (0 to _region_size - 1) coordinates within a region
+// Z: layer index used for texturearrays, -1 if not in a region
+ivec3 get_index_coord(const vec2 uv, const int search) {
+ vec2 r_uv = round(uv);
+ vec2 o_uv = mod(r_uv,_region_size);
+ ivec2 pos;
+ int bounds, layer_index = -1;
+ for (int i = -1; i < clamp(search, SKIP_PASS, FRAGMENT_PASS); i++) {
+ if ((layer_index == -1 && _background_mode == 0u ) || i < 0) {
+ r_uv -= i == -1 ? vec2(0.0) : vec2(float(o_uv.x <= o_uv.y), float(o_uv.y <= o_uv.x));
+ pos = ivec2(floor((r_uv) * _region_texel_size)) + (_region_map_size / 2);
+ bounds = int(uint(pos.x | pos.y) < uint(_region_map_size));
+ layer_index = (_region_map[ pos.y * _region_map_size + pos.x ] * bounds - 1);
+ }
+ }
+ return ivec3(ivec2(mod(r_uv,_region_size)), layer_index);
+}
+
+void vertex() {
+ // Get vertex of flat plane in world coordinates and set world UV
+ v_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+
+ // Camera distance to vertex on flat plane
+ v_vertex_xz_dist = length(v_vertex.xz - _camera_pos.xz);
+
+ // Geomorph vertex, set end and start for linear height interpolate
+ float scale = MODEL_MATRIX[0][0];
+ float vertex_lerp = smoothstep(0.55, 0.95, (v_vertex_xz_dist / scale - _mesh_size - 4.0) / (_mesh_size - 2.0));
+ vec2 v_fract = fract(VERTEX.xz * 0.5) * 2.0;
+ // For LOD0 morph from a regular grid to an alternating grid to align with LOD1+
+ vec2 shift = (scale < _vertex_spacing + 1e-6) ? // LOD0 or not
+ // Shift from regular to symetric
+ mix(v_fract, vec2(v_fract.x, -v_fract.y),
+ round(fract(round(mod(v_vertex.z * _vertex_density, 4.0)) *
+ round(mod(v_vertex.x * _vertex_density, 4.0)) * 0.25))
+ ) :
+ // Symetric shift
+ v_fract * round((fract(v_vertex.xz * 0.25 / scale) - 0.5) * 4.0);
+ vec2 start_pos = v_vertex.xz * _vertex_density;
+ vec2 end_pos = (v_vertex.xz - shift * scale) * _vertex_density;
+ v_vertex.xz -= shift * scale * vertex_lerp;
+
+ // UV coordinates in world space. Values are 0 to _region_size within regions
+ UV = v_vertex.xz * _vertex_density;
+
+ // UV coordinates in region space + texel offset. Values are 0 to 1 within regions
+ UV2 = fma(UV, vec2(_region_texel_size), vec2(0.5 * _region_texel_size));
+
+ // Discard vertices for Holes. 1 lookup
+ ivec3 region = get_index_coord(start_pos, VERTEX_PASS);
+ uint control = floatBitsToUint(texelFetch(_control_maps, region, 0)).r;
+ bool hole = bool(control >>2u & 0x1u);
+
+ // Show holes to all cameras except mouse camera (on exactly 1 layer)
+ if ( !(CAMERA_VISIBLE_LAYERS == _mouse_layer) &&
+ (hole || (_background_mode == 0u && region.z < 0))) {
+ v_vertex.x = 0. / 0.;
+ } else {
+ // Interpolate Geomorph Start & End, set height. 2 Lookups.
+ ivec3 uv_a = get_index_coord(start_pos, VERTEX_PASS);
+ ivec3 uv_b = get_index_coord(end_pos, VERTEX_PASS);
+ float h = mix(texelFetch(_height_maps, uv_a, 0).r, texelFetch(_height_maps, uv_b, 0).r, vertex_lerp);
+ v_vertex.y = h;
+ }
+
+ // Convert model space to view space w/ skip_vertex_transform render mode
+ VERTEX = (VIEW_MATRIX * vec4(v_vertex, 1.0)).xyz;
+ NORMAL = normalize((MODELVIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);
+ BINORMAL = normalize((MODELVIEW_MATRIX * vec4(BINORMAL, 0.0)).xyz);
+ TANGENT = normalize((MODELVIEW_MATRIX * vec4(TANGENT, 0.0)).xyz);
+}
+
+////////////////////////
+// Fragment
+////////////////////////
+
+void fragment() {
+ // Recover UVs
+ vec2 uv = UV;
+ vec2 uv2 = UV2;
+
+ // Apply terrain normals
+ NORMAL = normalize(cross(dFdyCoarse(VERTEX),dFdxCoarse(VERTEX)));
+ TANGENT = normalize(cross(NORMAL, vec3(0.0, 0.0, 1.0)));
+ BINORMAL = normalize(cross(NORMAL, TANGENT));
+
+ // Apply PBR
+ ALBEDO = vec3(.2);
+ ROUGHNESS = .7;
+}
diff --git a/addons/terrain_3d/extras/lowpoly_minimum.gdshader.uid b/addons/terrain_3d/extras/lowpoly_minimum.gdshader.uid
new file mode 100644
index 0000000..530e49b
--- /dev/null
+++ b/addons/terrain_3d/extras/lowpoly_minimum.gdshader.uid
@@ -0,0 +1 @@
+uid://x11v7w7v8hqa
diff --git a/addons/terrain_3d/extras/minimum.gdshader b/addons/terrain_3d/extras/minimum.gdshader
new file mode 100644
index 0000000..9505182
--- /dev/null
+++ b/addons/terrain_3d/extras/minimum.gdshader
@@ -0,0 +1,208 @@
+// Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+// This shader is the minimum needed to allow the terrain to function, without any texturing.
+
+shader_type spatial;
+render_mode blend_mix,depth_draw_opaque,cull_back,diffuse_burley,specular_schlick_ggx,skip_vertex_transform;
+
+// Defined Constants
+#define SKIP_PASS 0
+#define VERTEX_PASS 1
+#define FRAGMENT_PASS 2
+
+#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
+ #define fma(a, b, c) ((a) * (b) + (c))
+ #define dFdxCoarse(a) dFdx(a)
+ #define dFdyCoarse(a) dFdy(a)
+#endif
+
+// Private uniforms
+// Commented uniforms aren't needed for this shader, but are available for your own needs.
+uniform vec3 _camera_pos = vec3(0.f);
+uniform float _mesh_size = 48.f;
+uniform uint _background_mode = 1u; // NONE = 0, FLAT = 1, NOISE = 2
+uniform uint _mouse_layer = 0x80000000u; // Layer 32
+uniform float _vertex_spacing = 1.0;
+uniform float _vertex_density = 1.0; // = 1/_vertex_spacing
+uniform float _region_size = 1024.0;
+uniform float _region_texel_size = 0.0009765625; // = 1/1024
+uniform int _region_map_size = 32;
+uniform int _region_map[1024];
+uniform vec2 _region_locations[1024];
+//uniform float _texture_uv_scale_array[32];
+//uniform float _texture_detile_array[32];
+//uniform vec4 _texture_color_array[32];
+uniform highp sampler2DArray _height_maps : repeat_disable;
+uniform highp sampler2DArray _control_maps : repeat_disable;
+//uniform highp sampler2DArray _color_maps : source_color, filter_linear_mipmap_anisotropic, repeat_disable;
+//uniform highp sampler2DArray _texture_array_albedo : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
+//uniform highp sampler2DArray _texture_array_normal : hint_normal, filter_linear_mipmap_anisotropic, repeat_enable;
+
+// Varyings & Types
+// Some are required for editor functions
+varying float v_vertex_xz_dist;
+varying vec3 v_vertex;
+
+////////////////////////
+// Vertex
+////////////////////////
+
+// Takes in world space XZ (UV) coordinates & search depth (only applicable for background mode none)
+// Returns ivec3 with:
+// XY: (0 to _region_size - 1) coordinates within a region
+// Z: layer index used for texturearrays, -1 if not in a region
+ivec3 get_index_coord(const vec2 uv, const int search) {
+ vec2 r_uv = round(uv);
+ vec2 o_uv = mod(r_uv,_region_size);
+ ivec2 pos;
+ int bounds, layer_index = -1;
+ for (int i = -1; i < clamp(search, SKIP_PASS, FRAGMENT_PASS); i++) {
+ if ((layer_index == -1 && _background_mode == 0u ) || i < 0) {
+ r_uv -= i == -1 ? vec2(0.0) : vec2(float(o_uv.x <= o_uv.y), float(o_uv.y <= o_uv.x));
+ pos = ivec2(floor((r_uv) * _region_texel_size)) + (_region_map_size / 2);
+ bounds = int(uint(pos.x | pos.y) < uint(_region_map_size));
+ layer_index = (_region_map[ pos.y * _region_map_size + pos.x ] * bounds - 1);
+ }
+ }
+ return ivec3(ivec2(mod(r_uv,_region_size)), layer_index);
+}
+
+void vertex() {
+ // Get vertex of flat plane in world coordinates and set world UV
+ v_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
+
+ // Camera distance to vertex on flat plane
+ v_vertex_xz_dist = length(v_vertex.xz - _camera_pos.xz);
+
+ // Geomorph vertex, set end and start for linear height interpolate
+ float scale = MODEL_MATRIX[0][0];
+ float vertex_lerp = smoothstep(0.55, 0.95, (v_vertex_xz_dist / scale - _mesh_size - 4.0) / (_mesh_size - 2.0));
+ vec2 v_fract = fract(VERTEX.xz * 0.5) * 2.0;
+ // For LOD0 morph from a regular grid to an alternating grid to align with LOD1+
+ vec2 shift = (scale < _vertex_spacing + 1e-6) ? // LOD0 or not
+ // Shift from regular to symetric
+ mix(v_fract, vec2(v_fract.x, -v_fract.y),
+ round(fract(round(mod(v_vertex.z * _vertex_density, 4.0)) *
+ round(mod(v_vertex.x * _vertex_density, 4.0)) * 0.25))
+ ) :
+ // Symetric shift
+ v_fract * round((fract(v_vertex.xz * 0.25 / scale) - 0.5) * 4.0);
+ vec2 start_pos = v_vertex.xz * _vertex_density;
+ vec2 end_pos = (v_vertex.xz - shift * scale) * _vertex_density;
+ v_vertex.xz -= shift * scale * vertex_lerp;
+
+ // UV coordinates in world space. Values are 0 to _region_size within regions
+ UV = v_vertex.xz * _vertex_density;
+
+ // UV coordinates in region space + texel offset. Values are 0 to 1 within regions
+ UV2 = fma(UV, vec2(_region_texel_size), vec2(0.5 * _region_texel_size));
+
+ // Discard vertices for Holes. 1 lookup
+ ivec3 v_region = get_index_coord(start_pos, VERTEX_PASS);
+ uint control = floatBitsToUint(texelFetch(_control_maps, v_region, 0)).r;
+ bool hole = bool(control >>2u & 0x1u);
+
+ // Show holes to all cameras except mouse camera (on exactly 1 layer)
+ if ( !(CAMERA_VISIBLE_LAYERS == _mouse_layer) &&
+ (hole || (_background_mode == 0u && v_region.z < 0))) {
+ v_vertex.x = 0. / 0.;
+ } else {
+ // Interpolate Geomorph Start & End, set height. 2 Lookups.
+ ivec3 uv_a = get_index_coord(start_pos, VERTEX_PASS);
+ ivec3 uv_b = get_index_coord(end_pos, VERTEX_PASS);
+ float h = mix(texelFetch(_height_maps, uv_a, 0).r, texelFetch(_height_maps, uv_b, 0).r, vertex_lerp);
+ v_vertex.y = h;
+ }
+
+ // Convert model space to view space w/ skip_vertex_transform render mode
+ VERTEX = (VIEW_MATRIX * vec4(v_vertex, 1.0)).xyz;
+ NORMAL = normalize((MODELVIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);
+ BINORMAL = normalize((MODELVIEW_MATRIX * vec4(BINORMAL, 0.0)).xyz);
+ TANGENT = normalize((MODELVIEW_MATRIX * vec4(TANGENT, 0.0)).xyz);
+}
+
+////////////////////////
+// Fragment
+////////////////////////
+
+void fragment() {
+ // Recover UVs
+ vec2 uv = UV;
+ vec2 uv2 = UV2;
+
+ // Lookup offsets, ID and blend weight
+ const vec3 offsets = vec3(0, 1, 2);
+ vec2 index_id = floor(uv);
+ vec2 weight = fract(uv);
+ vec2 invert = 1.0 - weight;
+ vec4 weights = vec4(
+ invert.x * weight.y, // 0
+ weight.x * weight.y, // 1
+ weight.x * invert.y, // 2
+ invert.x * invert.y // 3
+ );
+
+ vec3 base_ddx = dFdxCoarse(v_vertex);
+ vec3 base_ddy = dFdyCoarse(v_vertex);
+ vec4 base_derivatives = vec4(base_ddx.xz, base_ddy.xz);
+ // Calculate the effective mipmap for regionspace, and if less than 0,
+ // skip all extra lookups required for bilinear blend.
+ float region_mip = log2(max(length(base_ddx.xz), length(base_ddy.xz)) * _vertex_density);
+ bool bilerp = region_mip < 0.0;
+
+ ivec3 indexUV[4];
+ // control map lookups, used for some normal lookups as well
+ indexUV[0] = get_index_coord(index_id + offsets.xy, FRAGMENT_PASS);
+ indexUV[1] = get_index_coord(index_id + offsets.yy, FRAGMENT_PASS);
+ indexUV[2] = get_index_coord(index_id + offsets.yx, FRAGMENT_PASS);
+ indexUV[3] = get_index_coord(index_id + offsets.xx, FRAGMENT_PASS);
+
+ // Terrain normals
+ vec3 index_normal[4];
+ float h[8];
+ // allows additional derivatives, eg world noise, brush previews etc
+ float u = 0.0;
+ float v = 0.0;
+
+ // Re-use the indexUVs for the first lookups, skipping some math. 3 lookups
+ h[3] = texelFetch(_height_maps, indexUV[3], 0).r; // 0 (0,0)
+ h[2] = texelFetch(_height_maps, indexUV[2], 0).r; // 1 (1,0)
+ h[0] = texelFetch(_height_maps, indexUV[0], 0).r; // 2 (0,1)
+ index_normal[3] = normalize(vec3(h[3] - h[2] + u, _vertex_spacing, h[3] - h[0] + v));
+
+ // Set flat world normal - overriden if bilerp is true
+ vec3 w_normal = index_normal[3];
+
+ // Branching smooth normals must be done seperatley for correct normals at all 4 index ids
+ if (bilerp) {
+ // 5 lookups
+ // Fetch the additional required height values for smooth normals
+ h[1] = texelFetch(_height_maps, indexUV[1], 0).r; // 3 (1,1)
+ h[4] = texelFetch(_height_maps, get_index_coord(index_id + offsets.yz, FRAGMENT_PASS), 0).r; // 4 (1,2)
+ h[5] = texelFetch(_height_maps, get_index_coord(index_id + offsets.zy, FRAGMENT_PASS), 0).r; // 5 (2,1)
+ h[6] = texelFetch(_height_maps, get_index_coord(index_id + offsets.zx, FRAGMENT_PASS), 0).r; // 6 (2,0)
+ h[7] = texelFetch(_height_maps, get_index_coord(index_id + offsets.xz, FRAGMENT_PASS), 0).r; // 7 (0,2)
+
+ // Calculate the normal for the remaining index ids.
+ index_normal[0] = normalize(vec3(h[0] - h[1] + u, _vertex_spacing, h[0] - h[7] + v));
+ index_normal[1] = normalize(vec3(h[1] - h[5] + u, _vertex_spacing, h[1] - h[4] + v));
+ index_normal[2] = normalize(vec3(h[2] - h[6] + u, _vertex_spacing, h[2] - h[1] + v));
+
+ // Set interpolated world normal
+ w_normal =
+ index_normal[0] * weights[0] +
+ index_normal[1] * weights[1] +
+ index_normal[2] * weights[2] +
+ index_normal[3] * weights[3] ;
+ }
+
+ // Apply terrain normals
+ vec3 w_tangent = normalize(cross(w_normal, vec3(0.0, 0.0, 1.0)));
+ vec3 w_binormal = normalize(cross(w_normal, w_tangent));
+ NORMAL = mat3(VIEW_MATRIX) * w_normal;
+ TANGENT = mat3(VIEW_MATRIX) * w_tangent;
+ BINORMAL = mat3(VIEW_MATRIX) * w_binormal;
+
+ // Apply PBR
+ ALBEDO = vec3(.2);
+ ROUGHNESS = .7;
+}
diff --git a/addons/terrain_3d/extras/minimum.gdshader.uid b/addons/terrain_3d/extras/minimum.gdshader.uid
new file mode 100644
index 0000000..8d390b2
--- /dev/null
+++ b/addons/terrain_3d/extras/minimum.gdshader.uid
@@ -0,0 +1 @@
+uid://01qauauvd8aa
diff --git a/addons/terrain_3d/extras/project_on_terrain3d.gd b/addons/terrain_3d/extras/project_on_terrain3d.gd
new file mode 100644
index 0000000..d812352
--- /dev/null
+++ b/addons/terrain_3d/extras/project_on_terrain3d.gd
@@ -0,0 +1,96 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# This script is an addon for HungryProton's Scatter https://github.com/HungryProton/scatter
+# It provides a `Project on Terrain3D` modifier, which allows Scatter
+# to detect the terrain height from Terrain3D without using collision.
+#
+# Copy this file into /addons/proton_scatter/src/modifiers
+# Then uncomment everything below (select, press CTRL+K)
+# In the editor, add this modifier to Scatter, then set your Terrain3D node
+
+#@tool
+#extends "base_modifier.gd"
+#
+#
+#signal projection_completed
+#
+#
+#@export var terrain_node : NodePath
+#@export var align_with_collision_normal := false
+#@export_range(0.0, 90.0, 0.1) var max_slope = 90.0
+#
+#var _terrain: Terrain3D
+#
+#
+#func _init() -> void:
+ #display_name = "Project On Terrain3D"
+ #category = "Edit"
+ #can_restrict_height = false
+ #global_reference_frame_available = true
+ #local_reference_frame_available = true
+ #individual_instances_reference_frame_available = true
+ #use_global_space_by_default()
+#
+ #documentation.add_paragraph(
+ #"This is a duplicate of `Project on Colliders` that queries the terrain system
+ #for height and sets the transform height appropriately.
+#
+ #This modifier must have terrain_node set to a Terrain3D node.")
+#
+ #var p := documentation.add_parameter("Terrain Node")
+ #p.set_type("NodePath")
+ #p.set_description("Set your Terrain3D node.")
+ #
+ #p = documentation.add_parameter("Align with collision normal")
+ #p.set_type("bool")
+ #p.set_description(
+ #"Rotate the transform to align it with the collision normal in case
+ #the ray cast hit a collider.")
+#
+#
+#func _process_transforms(transforms, domain, _seed) -> void:
+ #if transforms.is_empty():
+ #return
+#
+ #if terrain_node:
+ #_terrain = domain.get_root().get_node_or_null(terrain_node)
+#
+ #if not _terrain:
+ #warning += """No Terrain3D node found"""
+ #return
+#
+ #if not _terrain.data:
+ #warning += """Terrain3DData is not initialized"""
+ #return
+#
+ ## Get global transform
+ #var gt: Transform3D = domain.get_global_transform()
+ #var gt_inverse := gt.affine_inverse()
+ #var new_transforms_array: Array[Transform3D] = []
+ #var remapped_max_slope: float = remap(max_slope, 0.0, 90.0, 0.0, 1.0)
+ #for i in transforms.list.size():
+ #var t: Transform3D = transforms.list[i]
+ #
+ #var location: Vector3 = (gt * t).origin
+ #var height: float = _terrain.data.get_height(location)
+ #var normal: Vector3 = _terrain.data.get_normal(location)
+ #
+ #if align_with_collision_normal and not is_nan(normal.x):
+ #t.basis.y = normal
+ #t.basis.x = -t.basis.z.cross(normal)
+ #t.basis = t.basis.orthonormalized()
+#
+ #if abs(Vector3.UP.dot(normal)) >= (1.0 - remapped_max_slope):
+ #t.origin.y = gt.origin.y if is_nan(height) else height - gt.origin.y
+ #new_transforms_array.push_back(t)
+#
+ #transforms.list.clear()
+ #transforms.list.append_array(new_transforms_array)
+#
+ #if transforms.is_empty():
+ #warning += """All transforms have been removed. Possible reasons include: \n
+ #+ No collider is close enough to the shapes.
+ #+ Ray length is too short.
+ #+ Ray direction is incorrect.
+ #+ Collision mask is not set properly.
+ #+ Max slope is too low.
+ #"""
diff --git a/addons/terrain_3d/extras/project_on_terrain3d.gd.uid b/addons/terrain_3d/extras/project_on_terrain3d.gd.uid
new file mode 100644
index 0000000..6911309
--- /dev/null
+++ b/addons/terrain_3d/extras/project_on_terrain3d.gd.uid
@@ -0,0 +1 @@
+uid://g3opjh3m3iww
diff --git a/addons/terrain_3d/extras/region_mover.gd b/addons/terrain_3d/extras/region_mover.gd
new file mode 100644
index 0000000..78dcbd8
--- /dev/null
+++ b/addons/terrain_3d/extras/region_mover.gd
@@ -0,0 +1,52 @@
+# This script can be used to move your regions by an offset.
+# Eventually this tool will find its way into a built in UI
+#
+# Attach it to your Terrain3D node
+# Save and reload your scene
+# Select your Terrain3D node
+# Enter a valid `offset` where all regions will be within -16, +15
+# Run it
+# It should unload the regions, rename files, and reload them
+# Clear the script and resave your scene
+
+
+@tool
+extends Terrain3D
+
+
+@export var offset: Vector2i
+@export var run: bool = false : set = start_rename
+
+
+func start_rename(val: bool = false) -> void:
+ if val == false or offset == Vector2i.ZERO:
+ return
+
+ var dir_name: String = data_directory
+ data_directory = ""
+ var dir := DirAccess.open(dir_name)
+ if not dir:
+ print("An error occurred when trying to access the path: ", data_directory)
+ return
+
+ var affected_files: PackedStringArray
+ var files: PackedStringArray = dir.get_files()
+ for file_name in files:
+ if file_name.match("terrain3d*.res") and not dir.current_is_dir():
+ var region_loc: Vector2i = Terrain3DUtil.filename_to_location(file_name)
+ var new_loc: Vector2i = region_loc + offset
+ if new_loc.x < -16 or new_loc.x > 15 or new_loc.y < -16 or new_loc.y > 15:
+ push_error("New location %.0v out of bounds for region %.0v. Aborting" % [ new_loc, region_loc ])
+ return
+ var new_name: String = "tmp_" + Terrain3DUtil.location_to_filename(new_loc)
+ dir.rename(file_name, new_name)
+ affected_files.push_back(new_name)
+ print("File: %s renamed to: %s" % [ file_name, new_name ])
+
+ for file_name in affected_files:
+ var new_name: String = file_name.trim_prefix("tmp_")
+ dir.rename(file_name, new_name)
+ print("File: %s renamed to: %s" % [ file_name, new_name ])
+
+ data_directory = dir_name
+ EditorInterface.get_resource_filesystem().scan()
diff --git a/addons/terrain_3d/extras/region_mover.gd.uid b/addons/terrain_3d/extras/region_mover.gd.uid
new file mode 100644
index 0000000..0c9b4e5
--- /dev/null
+++ b/addons/terrain_3d/extras/region_mover.gd.uid
@@ -0,0 +1 @@
+uid://bngnvtbm6ifkk
diff --git a/addons/terrain_3d/icons/autoshader.svg b/addons/terrain_3d/icons/autoshader.svg
new file mode 100644
index 0000000..5e6ee1a
--- /dev/null
+++ b/addons/terrain_3d/icons/autoshader.svg
@@ -0,0 +1,194 @@
+
+
diff --git a/addons/terrain_3d/icons/autoshader.svg.import b/addons/terrain_3d/icons/autoshader.svg.import
new file mode 100644
index 0000000..1551553
--- /dev/null
+++ b/addons/terrain_3d/icons/autoshader.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdwolwswwy8wr"
+path="res://.godot/imported/autoshader.svg-9998e61bbc6afd5b134b767acd17a425.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/autoshader.svg"
+dest_files=["res://.godot/imported/autoshader.svg-9998e61bbc6afd5b134b767acd17a425.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/color_paint.svg b/addons/terrain_3d/icons/color_paint.svg
new file mode 100644
index 0000000..317b742
--- /dev/null
+++ b/addons/terrain_3d/icons/color_paint.svg
@@ -0,0 +1,175 @@
+
+
diff --git a/addons/terrain_3d/icons/color_paint.svg.import b/addons/terrain_3d/icons/color_paint.svg.import
new file mode 100644
index 0000000..56d0d5d
--- /dev/null
+++ b/addons/terrain_3d/icons/color_paint.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://krrmpalen8xu"
+path="res://.godot/imported/color_paint.svg-2a416ebf35da04135017e5c6ef53ea57.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/color_paint.svg"
+dest_files=["res://.godot/imported/color_paint.svg-2a416ebf35da04135017e5c6ef53ea57.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/height_add.svg b/addons/terrain_3d/icons/height_add.svg
new file mode 100644
index 0000000..299a1b8
--- /dev/null
+++ b/addons/terrain_3d/icons/height_add.svg
@@ -0,0 +1,28 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/height_add.svg.import b/addons/terrain_3d/icons/height_add.svg.import
new file mode 100644
index 0000000..e1bbbad
--- /dev/null
+++ b/addons/terrain_3d/icons/height_add.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bcmbqryggekg1"
+path="res://.godot/imported/height_add.svg-9e680ce71fa4c541748e081b99167369.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/height_add.svg"
+dest_files=["res://.godot/imported/height_add.svg-9e680ce71fa4c541748e081b99167369.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/height_div.svg b/addons/terrain_3d/icons/height_div.svg
new file mode 100644
index 0000000..79a6111
--- /dev/null
+++ b/addons/terrain_3d/icons/height_div.svg
@@ -0,0 +1,196 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/height_div.svg.import b/addons/terrain_3d/icons/height_div.svg.import
new file mode 100644
index 0000000..c894274
--- /dev/null
+++ b/addons/terrain_3d/icons/height_div.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://danh7tb2v6rx7"
+path="res://.godot/imported/height_div.svg-449a465f9fdd11ab59f2f1c78815408c.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/height_div.svg"
+dest_files=["res://.godot/imported/height_div.svg-449a465f9fdd11ab59f2f1c78815408c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/height_flat.svg b/addons/terrain_3d/icons/height_flat.svg
new file mode 100644
index 0000000..98973fe
--- /dev/null
+++ b/addons/terrain_3d/icons/height_flat.svg
@@ -0,0 +1,28 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/height_flat.svg.import b/addons/terrain_3d/icons/height_flat.svg.import
new file mode 100644
index 0000000..fd0e685
--- /dev/null
+++ b/addons/terrain_3d/icons/height_flat.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://crj0xfyiyr45u"
+path="res://.godot/imported/height_flat.svg-be726a006bf06e05a7a8867510f3996e.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/height_flat.svg"
+dest_files=["res://.godot/imported/height_flat.svg-be726a006bf06e05a7a8867510f3996e.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/height_mul.svg b/addons/terrain_3d/icons/height_mul.svg
new file mode 100644
index 0000000..ef4f92e
--- /dev/null
+++ b/addons/terrain_3d/icons/height_mul.svg
@@ -0,0 +1,201 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/height_mul.svg.import b/addons/terrain_3d/icons/height_mul.svg.import
new file mode 100644
index 0000000..b20aadc
--- /dev/null
+++ b/addons/terrain_3d/icons/height_mul.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bu3q0645kb3el"
+path="res://.godot/imported/height_mul.svg-2dca20fa42a85408713e9bfe411f3c79.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/height_mul.svg"
+dest_files=["res://.godot/imported/height_mul.svg-2dca20fa42a85408713e9bfe411f3c79.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/height_slope.svg b/addons/terrain_3d/icons/height_slope.svg
new file mode 100644
index 0000000..fe2903d
--- /dev/null
+++ b/addons/terrain_3d/icons/height_slope.svg
@@ -0,0 +1,105 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/height_slope.svg.import b/addons/terrain_3d/icons/height_slope.svg.import
new file mode 100644
index 0000000..ed55b14
--- /dev/null
+++ b/addons/terrain_3d/icons/height_slope.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://0cd7so4kw7da"
+path="res://.godot/imported/height_slope.svg-e20540c5538d0c57a9d229a772b3d1b3.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/height_slope.svg"
+dest_files=["res://.godot/imported/height_slope.svg-e20540c5538d0c57a9d229a772b3d1b3.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/height_smooth.svg b/addons/terrain_3d/icons/height_smooth.svg
new file mode 100644
index 0000000..81154c3
--- /dev/null
+++ b/addons/terrain_3d/icons/height_smooth.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/height_smooth.svg.import b/addons/terrain_3d/icons/height_smooth.svg.import
new file mode 100644
index 0000000..eef9e53
--- /dev/null
+++ b/addons/terrain_3d/icons/height_smooth.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://chrbx4xnxyiel"
+path="res://.godot/imported/height_smooth.svg-d8fc43572f5984eef64c886a49988c06.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/height_smooth.svg"
+dest_files=["res://.godot/imported/height_smooth.svg-d8fc43572f5984eef64c886a49988c06.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/height_sub.svg b/addons/terrain_3d/icons/height_sub.svg
new file mode 100644
index 0000000..bb0d2a7
--- /dev/null
+++ b/addons/terrain_3d/icons/height_sub.svg
@@ -0,0 +1,28 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/height_sub.svg.import b/addons/terrain_3d/icons/height_sub.svg.import
new file mode 100644
index 0000000..4dc17eb
--- /dev/null
+++ b/addons/terrain_3d/icons/height_sub.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://mo3hnbk3ffjs"
+path="res://.godot/imported/height_sub.svg-1a14a9bb856f3db0faa02dba3c807b50.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/height_sub.svg"
+dest_files=["res://.godot/imported/height_sub.svg-1a14a9bb856f3db0faa02dba3c807b50.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/holes.svg b/addons/terrain_3d/icons/holes.svg
new file mode 100644
index 0000000..da639d9
--- /dev/null
+++ b/addons/terrain_3d/icons/holes.svg
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/holes.svg.import b/addons/terrain_3d/icons/holes.svg.import
new file mode 100644
index 0000000..8117195
--- /dev/null
+++ b/addons/terrain_3d/icons/holes.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bsmaxekrmnuy2"
+path="res://.godot/imported/holes.svg-a7cb97bb50d7879cd274646e207b9213.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/holes.svg"
+dest_files=["res://.godot/imported/holes.svg-a7cb97bb50d7879cd274646e207b9213.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/layers.svg b/addons/terrain_3d/icons/layers.svg
new file mode 100644
index 0000000..5c9c4c9
--- /dev/null
+++ b/addons/terrain_3d/icons/layers.svg
@@ -0,0 +1,102 @@
+
+
diff --git a/addons/terrain_3d/icons/layers.svg.import b/addons/terrain_3d/icons/layers.svg.import
new file mode 100644
index 0000000..559cd21
--- /dev/null
+++ b/addons/terrain_3d/icons/layers.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cs1la1mashf2e"
+path="res://.godot/imported/layers.svg-4a679bb626c5179d3773f33e77e4a5e4.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/layers.svg"
+dest_files=["res://.godot/imported/layers.svg-4a679bb626c5179d3773f33e77e4a5e4.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/multimesh.svg b/addons/terrain_3d/icons/multimesh.svg
new file mode 100644
index 0000000..a62e5f9
--- /dev/null
+++ b/addons/terrain_3d/icons/multimesh.svg
@@ -0,0 +1,40 @@
+
+
diff --git a/addons/terrain_3d/icons/multimesh.svg.import b/addons/terrain_3d/icons/multimesh.svg.import
new file mode 100644
index 0000000..1493feb
--- /dev/null
+++ b/addons/terrain_3d/icons/multimesh.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cjlcl5lf20ve0"
+path="res://.godot/imported/multimesh.svg-5487b93b04ddbaae37b5d3e91f10750b.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/multimesh.svg"
+dest_files=["res://.godot/imported/multimesh.svg-5487b93b04ddbaae37b5d3e91f10750b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/navigation.svg b/addons/terrain_3d/icons/navigation.svg
new file mode 100644
index 0000000..1056202
--- /dev/null
+++ b/addons/terrain_3d/icons/navigation.svg
@@ -0,0 +1,42 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/navigation.svg.import b/addons/terrain_3d/icons/navigation.svg.import
new file mode 100644
index 0000000..d8ac263
--- /dev/null
+++ b/addons/terrain_3d/icons/navigation.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://f3po5pogkv2b"
+path="res://.godot/imported/navigation.svg-1e4cf210c589be8d2911c522d4a17d78.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/navigation.svg"
+dest_files=["res://.godot/imported/navigation.svg-1e4cf210c589be8d2911c522d4a17d78.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/picker_checked.svg b/addons/terrain_3d/icons/picker_checked.svg
new file mode 100644
index 0000000..653d57e
--- /dev/null
+++ b/addons/terrain_3d/icons/picker_checked.svg
@@ -0,0 +1,43 @@
+
+
diff --git a/addons/terrain_3d/icons/picker_checked.svg.import b/addons/terrain_3d/icons/picker_checked.svg.import
new file mode 100644
index 0000000..ff53b15
--- /dev/null
+++ b/addons/terrain_3d/icons/picker_checked.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bg8x6o32ggt88"
+path="res://.godot/imported/picker_checked.svg-81f35b6ae38bccc8aa9e7ae22b530168.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/picker_checked.svg"
+dest_files=["res://.godot/imported/picker_checked.svg-81f35b6ae38bccc8aa9e7ae22b530168.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/region_add.svg b/addons/terrain_3d/icons/region_add.svg
new file mode 100644
index 0000000..56d7c92
--- /dev/null
+++ b/addons/terrain_3d/icons/region_add.svg
@@ -0,0 +1,95 @@
+
+
diff --git a/addons/terrain_3d/icons/region_add.svg.import b/addons/terrain_3d/icons/region_add.svg.import
new file mode 100644
index 0000000..e50c30b
--- /dev/null
+++ b/addons/terrain_3d/icons/region_add.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c0tn453fsckv5"
+path="res://.godot/imported/region_add.svg-a05dc161a452dd3e024f9835a737d9f0.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/region_add.svg"
+dest_files=["res://.godot/imported/region_add.svg-a05dc161a452dd3e024f9835a737d9f0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/region_remove.svg b/addons/terrain_3d/icons/region_remove.svg
new file mode 100644
index 0000000..315657d
--- /dev/null
+++ b/addons/terrain_3d/icons/region_remove.svg
@@ -0,0 +1,102 @@
+
+
diff --git a/addons/terrain_3d/icons/region_remove.svg.import b/addons/terrain_3d/icons/region_remove.svg.import
new file mode 100644
index 0000000..6ded856
--- /dev/null
+++ b/addons/terrain_3d/icons/region_remove.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cbpo5eamf3bx2"
+path="res://.godot/imported/region_remove.svg-5710e8aeb34f1eaa06e637634f4a7d16.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/region_remove.svg"
+dest_files=["res://.godot/imported/region_remove.svg-5710e8aeb34f1eaa06e637634f4a7d16.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/terrain3d.svg b/addons/terrain_3d/icons/terrain3d.svg
new file mode 100644
index 0000000..ca8c661
--- /dev/null
+++ b/addons/terrain_3d/icons/terrain3d.svg
@@ -0,0 +1,153 @@
+
+
diff --git a/addons/terrain_3d/icons/terrain3d.svg.import b/addons/terrain_3d/icons/terrain3d.svg.import
new file mode 100644
index 0000000..c077de1
--- /dev/null
+++ b/addons/terrain_3d/icons/terrain3d.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bnsydn4jkyeyn"
+path="res://.godot/imported/terrain3d.svg-eb45756f1a003759fda81eaa1db10769.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/terrain3d.svg"
+dest_files=["res://.godot/imported/terrain3d.svg-eb45756f1a003759fda81eaa1db10769.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/texture_paint.svg b/addons/terrain_3d/icons/texture_paint.svg
new file mode 100644
index 0000000..908d3f4
--- /dev/null
+++ b/addons/terrain_3d/icons/texture_paint.svg
@@ -0,0 +1,151 @@
+
+
diff --git a/addons/terrain_3d/icons/texture_paint.svg.import b/addons/terrain_3d/icons/texture_paint.svg.import
new file mode 100644
index 0000000..8a09d92
--- /dev/null
+++ b/addons/terrain_3d/icons/texture_paint.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://duo8valena3a2"
+path="res://.godot/imported/texture_paint.svg-72da4fd2096377e625a8fe09cdacb0e4.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/texture_paint.svg"
+dest_files=["res://.godot/imported/texture_paint.svg-72da4fd2096377e625a8fe09cdacb0e4.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/texture_spray.svg b/addons/terrain_3d/icons/texture_spray.svg
new file mode 100644
index 0000000..bfc3e82
--- /dev/null
+++ b/addons/terrain_3d/icons/texture_spray.svg
@@ -0,0 +1,165 @@
+
+
diff --git a/addons/terrain_3d/icons/texture_spray.svg.import b/addons/terrain_3d/icons/texture_spray.svg.import
new file mode 100644
index 0000000..c4acc6f
--- /dev/null
+++ b/addons/terrain_3d/icons/texture_spray.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://16yfxe7xe703"
+path="res://.godot/imported/texture_spray.svg-326fee11cf418653e621bc222a470861.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/texture_spray.svg"
+dest_files=["res://.godot/imported/texture_spray.svg-326fee11cf418653e621bc222a470861.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/icons/wetness.svg b/addons/terrain_3d/icons/wetness.svg
new file mode 100644
index 0000000..90c3d03
--- /dev/null
+++ b/addons/terrain_3d/icons/wetness.svg
@@ -0,0 +1,41 @@
+
+
+
+
diff --git a/addons/terrain_3d/icons/wetness.svg.import b/addons/terrain_3d/icons/wetness.svg.import
new file mode 100644
index 0000000..9379330
--- /dev/null
+++ b/addons/terrain_3d/icons/wetness.svg.import
@@ -0,0 +1,38 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bcgg0srmqsh3n"
+path="res://.godot/imported/wetness.svg-9b2ddec096ab7734492b77b20c75c82b.ctex"
+metadata={
+"has_editor_variant": true,
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/terrain_3d/icons/wetness.svg"
+dest_files=["res://.godot/imported/wetness.svg-9b2ddec096ab7734492b77b20c75c82b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=true
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/terrain_3d/menu/bake_lod_dialog.gd b/addons/terrain_3d/menu/bake_lod_dialog.gd
new file mode 100644
index 0000000..ada4fb2
--- /dev/null
+++ b/addons/terrain_3d/menu/bake_lod_dialog.gd
@@ -0,0 +1,30 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Bake LOD Dialog for Terrain3D
+@tool
+extends ConfirmationDialog
+
+var lod: int = 0
+var description: String = ""
+
+
+func _ready() -> void:
+ set_unparent_when_invisible(true)
+ about_to_popup.connect(_on_about_to_popup)
+ visibility_changed.connect(_on_visibility_changed)
+ %LodBox.value_changed.connect(_on_lod_box_value_changed)
+
+
+func _on_about_to_popup() -> void:
+ lod = %LodBox.value
+
+
+func _on_visibility_changed() -> void:
+ # Change text on the autowrap label only when the popup is visible.
+ # Works around Godot issue #47005:
+ # https://github.com/godotengine/godot/issues/47005
+ if visible:
+ %DescriptionLabel.text = description
+
+
+func _on_lod_box_value_changed(p_value: float) -> void:
+ lod = %LodBox.value
diff --git a/addons/terrain_3d/menu/bake_lod_dialog.gd.uid b/addons/terrain_3d/menu/bake_lod_dialog.gd.uid
new file mode 100644
index 0000000..08c194f
--- /dev/null
+++ b/addons/terrain_3d/menu/bake_lod_dialog.gd.uid
@@ -0,0 +1 @@
+uid://cqmt8f5x5c2ad
diff --git a/addons/terrain_3d/menu/bake_lod_dialog.tscn b/addons/terrain_3d/menu/bake_lod_dialog.tscn
new file mode 100644
index 0000000..bad26f1
--- /dev/null
+++ b/addons/terrain_3d/menu/bake_lod_dialog.tscn
@@ -0,0 +1,43 @@
+[gd_scene load_steps=2 format=3 uid="uid://bhvrrmb8bk1bt"]
+
+[ext_resource type="Script" path="res://addons/terrain_3d/menu/bake_lod_dialog.gd" id="1_sf76d"]
+
+[node name="bake_lod_dialog" type="ConfirmationDialog"]
+title = "Bake Terrain3D Mesh"
+position = Vector2i(0, 36)
+size = Vector2i(400, 155)
+visible = true
+script = ExtResource("1_sf76d")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 392.0
+offset_bottom = 106.0
+theme_override_constants/margin_left = 10
+theme_override_constants/margin_top = 10
+theme_override_constants/margin_right = 10
+theme_override_constants/margin_bottom = 10
+
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+theme_override_constants/separation = 20
+
+[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "LOD:"
+
+[node name="LodBox" type="SpinBox" parent="MarginContainer/VBoxContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+max_value = 8.0
+value = 4.0
+
+[node name="DescriptionLabel" type="Label" parent="MarginContainer/VBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+autowrap_mode = 2
diff --git a/addons/terrain_3d/menu/baker.gd b/addons/terrain_3d/menu/baker.gd
new file mode 100644
index 0000000..08064b6
--- /dev/null
+++ b/addons/terrain_3d/menu/baker.gd
@@ -0,0 +1,398 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Baker for Terrain3D
+extends Node
+
+const BakeLodDialog: PackedScene = preload("res://addons/terrain_3d/menu/bake_lod_dialog.tscn")
+const BAKE_MESH_DESCRIPTION: String = "This will create a child MeshInstance3D. LOD4+ is recommended. LOD0 is slow and dense with vertices every 1 unit. It is not an optimal mesh."
+const BAKE_OCCLUDER_DESCRIPTION: String = "This will create a child OccluderInstance3D. LOD4+ is recommended and will take 5+ seconds per region to generate. LOD0 is unnecessarily dense and slow."
+const SET_UP_NAVIGATION_DESCRIPTION: String = "This operation will:
+
+- Create a NavigationRegion3D node,
+- Assign it a blank NavigationMesh resource,
+- Move the Terrain3D node to be a child of the new node,
+- And bake the nav mesh.
+
+Once setup is complete, you can modify the settings on your nav mesh, and rebake
+without having to run through the setup again.
+
+If preferred, this setup can be canceled and the steps performed manually. For
+the best results, adjust the settings on the NavigationMesh resource to match
+the settings of your navigation agents and collisions."
+
+var plugin: EditorPlugin
+var bake_method: Callable
+var bake_lod_dialog: ConfirmationDialog
+var confirm_dialog: ConfirmationDialog
+
+
+func _enter_tree() -> void:
+ bake_lod_dialog = BakeLodDialog.instantiate()
+ bake_lod_dialog.hide()
+ bake_lod_dialog.confirmed.connect(func(): bake_method.call())
+ bake_lod_dialog.set_unparent_when_invisible(true)
+
+ confirm_dialog = ConfirmationDialog.new()
+ confirm_dialog.hide()
+ confirm_dialog.confirmed.connect(func(): bake_method.call())
+ confirm_dialog.set_unparent_when_invisible(true)
+
+
+func _exit_tree() -> void:
+ bake_lod_dialog.queue_free()
+ confirm_dialog.queue_free()
+
+
+func bake_mesh_popup() -> void:
+ if plugin.terrain:
+ bake_method = _bake_mesh
+ bake_lod_dialog.description = BAKE_MESH_DESCRIPTION
+ EditorInterface.popup_dialog_centered(bake_lod_dialog)
+
+
+func _bake_mesh() -> void:
+ if plugin.terrain.data.get_region_count() == 0:
+ push_error("Terrain3D has no active regions to bake")
+ return
+ var mesh: Mesh = plugin.terrain.bake_mesh(bake_lod_dialog.lod, Terrain3DData.HEIGHT_FILTER_NEAREST)
+ if !mesh:
+ push_error("Failed to bake mesh from Terrain3D")
+ return
+
+ var undo: EditorUndoRedoManager = plugin.get_undo_redo()
+ undo.create_action("Terrain3D Bake ArrayMesh")
+
+ var mesh_instance := plugin.terrain.get_node_or_null(^"MeshInstance3D") as MeshInstance3D
+ if !mesh_instance:
+ mesh_instance = MeshInstance3D.new()
+ mesh_instance.name = &"MeshInstance3D"
+ mesh_instance.set_skeleton_path(NodePath())
+ mesh_instance.mesh = mesh
+
+ undo.add_do_method(plugin.terrain, &"add_child", mesh_instance, true)
+ undo.add_undo_method(plugin.terrain, &"remove_child", mesh_instance)
+ undo.add_do_property(mesh_instance, &"owner", EditorInterface.get_edited_scene_root())
+ undo.add_do_reference(mesh_instance)
+
+ else:
+ undo.add_do_property(mesh_instance, &"mesh", mesh)
+ undo.add_undo_property(mesh_instance, &"mesh", mesh_instance.mesh)
+
+ if mesh_instance.mesh.resource_path:
+ var path := mesh_instance.mesh.resource_path
+ undo.add_do_method(mesh, &"take_over_path", path)
+ undo.add_undo_method(mesh_instance.mesh, &"take_over_path", path)
+ undo.add_do_method(ResourceSaver, &"save", mesh)
+ undo.add_undo_method(ResourceSaver, &"save", mesh_instance.mesh)
+
+ undo.commit_action()
+
+
+func bake_occluder_popup() -> void:
+ if plugin.terrain:
+ bake_method = _bake_occluder
+ bake_lod_dialog.description = BAKE_OCCLUDER_DESCRIPTION
+ EditorInterface.popup_dialog_centered(bake_lod_dialog)
+
+
+func _bake_occluder() -> void:
+ if plugin.terrain.data.get_region_count() == 0:
+ push_error("Terrain3D has no active regions to bake")
+ return
+ var mesh: Mesh = plugin.terrain.bake_mesh(bake_lod_dialog.lod, Terrain3DData.HEIGHT_FILTER_MINIMUM)
+ if !mesh:
+ push_error("Failed to bake mesh from Terrain3D")
+ return
+ assert(mesh.get_surface_count() == 1)
+
+ var undo: EditorUndoRedoManager = plugin.get_undo_redo()
+ undo.create_action("Terrain3D Bake Occluder3D")
+
+ var occluder := ArrayOccluder3D.new()
+ var arrays: Array = mesh.surface_get_arrays(0)
+ assert(arrays.size() > Mesh.ARRAY_INDEX)
+ assert(arrays[Mesh.ARRAY_INDEX] != null)
+ occluder.set_arrays(arrays[Mesh.ARRAY_VERTEX], arrays[Mesh.ARRAY_INDEX])
+
+ var occluder_instance := plugin.terrain.get_node_or_null(^"OccluderInstance3D") as OccluderInstance3D
+ if !occluder_instance:
+ occluder_instance = OccluderInstance3D.new()
+ occluder_instance.name = &"OccluderInstance3D"
+ occluder_instance.occluder = occluder
+
+ undo.add_do_method(plugin.terrain, &"add_child", occluder_instance, true)
+ undo.add_undo_method(plugin.terrain, &"remove_child", occluder_instance)
+ undo.add_do_property(occluder_instance, &"owner", EditorInterface.get_edited_scene_root())
+ undo.add_do_reference(occluder_instance)
+
+ else:
+ undo.add_do_property(occluder_instance, &"occluder", occluder)
+ undo.add_undo_property(occluder_instance, &"occluder", occluder_instance.occluder)
+
+ if occluder_instance.occluder.resource_path:
+ var path := occluder_instance.occluder.resource_path
+ undo.add_do_method(occluder, &"take_over_path", path)
+ undo.add_undo_method(occluder_instance.occluder, &"take_over_path", path)
+ undo.add_do_method(ResourceSaver, &"save", occluder)
+ undo.add_undo_method(ResourceSaver, &"save", occluder_instance.occluder)
+
+ undo.commit_action()
+
+
+func find_nav_region_terrains(p_nav_region: NavigationRegion3D) -> Array[Terrain3D]:
+ var result: Array[Terrain3D] = []
+ if not p_nav_region.navigation_mesh:
+ return result
+
+ var source_mode: NavigationMesh.SourceGeometryMode
+ source_mode = p_nav_region.navigation_mesh.geometry_source_geometry_mode
+ if source_mode == NavigationMesh.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN:
+ result.append_array(p_nav_region.find_children("", "Terrain3D", true, true))
+ return result
+
+ var group_nodes: Array = p_nav_region.get_tree().get_nodes_in_group(p_nav_region.navigation_mesh.geometry_source_group_name)
+ for node in group_nodes:
+ if node is Terrain3D:
+ result.push_back(node)
+ if source_mode == NavigationMesh.SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN:
+ result.append_array(node.find_children("", "Terrain3D", true, true))
+
+ return result
+
+
+func find_terrain_nav_regions(p_terrain: Terrain3D) -> Array[NavigationRegion3D]:
+ var result: Array[NavigationRegion3D] = []
+ var root: Node = EditorInterface.get_edited_scene_root()
+ if not root:
+ return result
+ for nav_region in root.find_children("", "NavigationRegion3D", true, true):
+ if find_nav_region_terrains(nav_region).has(p_terrain):
+ result.push_back(nav_region)
+ return result
+
+
+func bake_nav_mesh() -> void:
+ if plugin.nav_region:
+ # A NavigationRegion3D is selected. We only need to bake that one navmesh.
+ _bake_nav_region_nav_mesh(plugin.nav_region)
+ print("Terrain3DNavigation: Finished baking 1 NavigationMesh.")
+
+ elif plugin.terrain:
+ if plugin.terrain.data.get_region_count() == 0:
+ push_error("Terrain3D has no active regions to bake")
+ return
+ # A Terrain3D is selected. There are potentially multiple navmeshes to bake and we need to
+ # find them all. (The multiple navmesh use-case is likely on very large scenes with lots of
+ # geometry. Each navmesh in this case would define its own, non-overlapping, baking AABB, to
+ # cut down on the amount of geometry to bake. In a large open-world RPG, for instance, there
+ # could be a navmesh for each town.)
+ var nav_regions: Array[NavigationRegion3D] = find_terrain_nav_regions(plugin.terrain)
+ for nav_region in nav_regions:
+ _bake_nav_region_nav_mesh(nav_region)
+ print("Terrain3DNavigation: Finished baking %d NavigationMesh(es)." % nav_regions.size())
+
+
+func _bake_nav_region_nav_mesh(p_nav_region: NavigationRegion3D) -> void:
+ var nav_mesh: NavigationMesh = p_nav_region.navigation_mesh
+ assert(nav_mesh != null)
+
+ var source_geometry_data := NavigationMeshSourceGeometryData3D.new()
+ NavigationServer3D.parse_source_geometry_data(nav_mesh, source_geometry_data, p_nav_region)
+
+ for terrain in find_nav_region_terrains(p_nav_region):
+ var aabb: AABB = nav_mesh.filter_baking_aabb
+ aabb.position += nav_mesh.filter_baking_aabb_offset
+ aabb = p_nav_region.global_transform * aabb
+ var faces: PackedVector3Array = terrain.generate_nav_mesh_source_geometry(aabb)
+ if not faces.is_empty():
+ source_geometry_data.add_faces(faces, Transform3D.IDENTITY)
+
+ NavigationServer3D.bake_from_source_geometry_data(nav_mesh, source_geometry_data)
+
+ _postprocess_nav_mesh(nav_mesh)
+
+ # Assign null first to force the debug display to actually update:
+ p_nav_region.set_navigation_mesh(null)
+ p_nav_region.set_navigation_mesh(nav_mesh)
+
+ # Trigger save to disk if it is saved as an external file
+ if not nav_mesh.get_path().is_empty():
+ ResourceSaver.save(nav_mesh, nav_mesh.get_path(), ResourceSaver.FLAG_COMPRESS)
+
+ # Let other editor plugins and tool scripts know the nav mesh was just baked:
+ p_nav_region.bake_finished.emit()
+
+
+func _postprocess_nav_mesh(p_nav_mesh: NavigationMesh) -> void:
+ # Post-process the nav mesh to work around Godot issue #85548
+
+ # Round all the vertices in the nav_mesh to the nearest cell_size/cell_height so that it doesn't
+ # contain any edges shorter than cell_size/cell_height (one cause of #85548).
+ var vertices: PackedVector3Array = _postprocess_nav_mesh_round_vertices(p_nav_mesh)
+
+ # Rounding vertices can collapse some edges to 0 length. We remove these edges, and any polygons
+ # that have been reduced to 0 area.
+ var polygons: Array[PackedInt32Array] = _postprocess_nav_mesh_remove_empty_polygons(p_nav_mesh, vertices)
+
+ # Another cause of #85548 is baking producing overlapping polygons. We remove these.
+ _postprocess_nav_mesh_remove_overlapping_polygons(p_nav_mesh, vertices, polygons)
+
+ p_nav_mesh.clear_polygons()
+ p_nav_mesh.set_vertices(vertices)
+ for polygon in polygons:
+ p_nav_mesh.add_polygon(polygon)
+
+
+func _postprocess_nav_mesh_round_vertices(p_nav_mesh: NavigationMesh) -> PackedVector3Array:
+ assert(p_nav_mesh != null)
+ assert(p_nav_mesh.cell_size > 0.0)
+ assert(p_nav_mesh.cell_height > 0.0)
+
+ var cell_size: Vector3 = Vector3(p_nav_mesh.cell_size, p_nav_mesh.cell_height, p_nav_mesh.cell_size)
+
+ # Round a little harder to avoid rounding errors with non-power-of-two cell_size/cell_height
+ # causing the navigation map to put two non-matching edges in the same cell:
+ var round_factor := cell_size * 1.001
+
+ var vertices: PackedVector3Array = p_nav_mesh.get_vertices()
+ for i in range(vertices.size()):
+ vertices[i] = (vertices[i] / round_factor).floor() * round_factor
+ return vertices
+
+
+func _postprocess_nav_mesh_remove_empty_polygons(p_nav_mesh: NavigationMesh, p_vertices: PackedVector3Array) -> Array[PackedInt32Array]:
+ var polygons: Array[PackedInt32Array] = []
+
+ for i in range(p_nav_mesh.get_polygon_count()):
+ var old_polygon: PackedInt32Array = p_nav_mesh.get_polygon(i)
+ var new_polygon: PackedInt32Array = []
+
+ # Remove duplicate vertices (introduced by rounding) from the polygon:
+ var polygon_vertices: PackedVector3Array = []
+ for index in old_polygon:
+ var vertex: Vector3 = p_vertices[index]
+ if polygon_vertices.has(vertex):
+ continue
+ polygon_vertices.push_back(vertex)
+ new_polygon.push_back(index)
+
+ # If we removed some vertices, we might be able to remove the polygon too:
+ if new_polygon.size() <= 2:
+ continue
+ polygons.push_back(new_polygon)
+
+ return polygons
+
+
+func _postprocess_nav_mesh_remove_overlapping_polygons(p_nav_mesh: NavigationMesh, p_vertices: PackedVector3Array, p_polygons: Array[PackedInt32Array]) -> void:
+ # Occasionally, a baked nav mesh comes out with overlapping polygons:
+ # https://github.com/godotengine/godot/issues/85548#issuecomment-1839341071
+ # Until the bug is fixed in the engine, this function attempts to detect and remove overlapping
+ # polygons.
+
+ # This function has to make a choice of which polygon to remove when an overlap is detected,
+ # because in this case the nav mesh is ambiguous. To do this it uses a heuristic:
+ # (1) an 'overlap' is defined as an edge that is shared by 3 or more polygons.
+ # (2) a 'bad polygon' is defined as a polygon that contains 2 or more 'overlaps'.
+ # The function removes the 'bad polygons', which in practice seems to be enough to remove all
+ # overlaps without creating holes in the nav mesh.
+
+ var cell_size: Vector3 = Vector3(p_nav_mesh.cell_size, p_nav_mesh.cell_height, p_nav_mesh.cell_size)
+
+ # `edges` is going to map edges (vertex pairs) to arrays of polygons that contain that edge.
+ var edges: Dictionary = {}
+
+ for polygon_index in range(p_polygons.size()):
+ var polygon: PackedInt32Array = p_polygons[polygon_index]
+ for j in range(polygon.size()):
+ var vertex: Vector3 = p_vertices[polygon[j]]
+ var next_vertex: Vector3 = p_vertices[polygon[(j + 1) % polygon.size()]]
+
+ # edge_key is a key we can use in the edges dictionary that uniquely identifies the
+ # edge. We use cell coordinates here (Vector3i) because with a non-power-of-two
+ # cell_size, rounding errors can cause Vector3 vertices to not be equal.
+ # Array.sort IS defined for vector types - see the Godot docs. It's necessary here
+ # because polygons that share an edge can have their vertices in a different order.
+ var edge_key: Array = [Vector3i(vertex / cell_size), Vector3i(next_vertex / cell_size)]
+ edge_key.sort()
+
+ if !edges.has(edge_key):
+ edges[edge_key] = []
+ edges[edge_key].push_back(polygon_index)
+
+ var overlap_count: Dictionary = {}
+ for connections in edges.values():
+ if connections.size() <= 2:
+ continue
+ for polygon_index in connections:
+ overlap_count[polygon_index] = overlap_count.get(polygon_index, 0) + 1
+
+ var bad_polygons: Array = []
+ for polygon_index in overlap_count.keys():
+ if overlap_count[polygon_index] >= 2:
+ bad_polygons.push_back(polygon_index)
+
+ bad_polygons.sort()
+ for i in range(bad_polygons.size() - 1, -1, -1):
+ p_polygons.remove_at(bad_polygons[i])
+
+
+func set_up_navigation_popup() -> void:
+ if plugin.terrain:
+ bake_method = _set_up_navigation
+ confirm_dialog.dialog_text = SET_UP_NAVIGATION_DESCRIPTION
+ EditorInterface.popup_dialog_centered(confirm_dialog)
+
+
+func _set_up_navigation() -> void:
+ assert(plugin.terrain)
+ if plugin.terrain == EditorInterface.get_edited_scene_root():
+ push_error("Terrain3D Navigation setup not possible if Terrain3D node is scene root")
+ return
+ if plugin.terrain.data.get_region_count() == 0:
+ push_error("Terrain3D has no active regions")
+ return
+ var terrain: Terrain3D = plugin.terrain
+
+ var nav_region := NavigationRegion3D.new()
+ nav_region.name = &"NavigationRegion3D"
+ nav_region.navigation_mesh = NavigationMesh.new()
+
+ var undo_redo: EditorUndoRedoManager = plugin.get_undo_redo()
+
+ undo_redo.create_action("Terrain3D Set up Navigation")
+ undo_redo.add_do_method(self, &"_do_set_up_navigation", nav_region, terrain)
+ undo_redo.add_undo_method(self, &"_undo_set_up_navigation", nav_region, terrain)
+ undo_redo.add_do_reference(nav_region)
+ undo_redo.commit_action()
+
+ EditorInterface.inspect_object(nav_region)
+ assert(plugin.nav_region == nav_region)
+
+ bake_nav_mesh()
+
+
+func _do_set_up_navigation(p_nav_region: NavigationRegion3D, p_terrain: Terrain3D) -> void:
+ var parent: Node = p_terrain.get_parent()
+ var index: int = p_terrain.get_index()
+ var t_owner: Node = p_terrain.owner
+
+ parent.add_child(p_nav_region, true)
+ p_terrain.reparent(p_nav_region)
+ parent.move_child(p_nav_region, index)
+
+ p_nav_region.owner = t_owner
+ p_terrain.owner = t_owner
+
+
+func _undo_set_up_navigation(p_nav_region: NavigationRegion3D, p_terrain: Terrain3D) -> void:
+ assert(p_terrain.get_parent() == p_nav_region)
+
+ var parent: Node = p_nav_region.get_parent()
+ var index: int = p_nav_region.get_index()
+ var t_owner: Node = p_nav_region.get_owner()
+
+ p_terrain.reparent(parent)
+ parent.remove_child(p_nav_region)
+ parent.move_child(p_terrain, index)
+
+ p_terrain.owner = t_owner
diff --git a/addons/terrain_3d/menu/baker.gd.uid b/addons/terrain_3d/menu/baker.gd.uid
new file mode 100644
index 0000000..0f2ed94
--- /dev/null
+++ b/addons/terrain_3d/menu/baker.gd.uid
@@ -0,0 +1 @@
+uid://4ulaeevj5jvi
diff --git a/addons/terrain_3d/menu/channel_packer.gd b/addons/terrain_3d/menu/channel_packer.gd
new file mode 100644
index 0000000..f2699d7
--- /dev/null
+++ b/addons/terrain_3d/menu/channel_packer.gd
@@ -0,0 +1,463 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Channel Packer for Terrain3D
+extends RefCounted
+
+const WINDOW_SCENE: String = "res://addons/terrain_3d/menu/channel_packer.tscn"
+const TEMPLATE_PATH: String = "res://addons/terrain_3d/menu/channel_packer_import_template.txt"
+const DRAG_DROP_SCRIPT: String = "res://addons/terrain_3d/menu/channel_packer_dragdrop.gd"
+enum {
+ INFO,
+ WARN,
+ ERROR,
+}
+
+enum {
+ IMAGE_ALBEDO,
+ IMAGE_HEIGHT,
+ IMAGE_NORMAL,
+ IMAGE_ROUGHNESS
+}
+
+var plugin: EditorPlugin
+var window: Window
+var save_file_dialog: EditorFileDialog
+var open_file_dialog: EditorFileDialog
+var invert_green_checkbox: CheckBox
+var invert_smooth_checkbox: CheckBox
+var invert_height_checkbox: CheckBox
+var lumin_height_button: Button
+var generate_mipmaps_checkbox: CheckBox
+var high_quality_checkbox: CheckBox
+var align_normals_checkbox: CheckBox
+var resize_toggle_checkbox: CheckBox
+var resize_option_box: SpinBox
+var height_channel: Array[Button]
+var height_channel_selected: int = 0
+var roughness_channel: Array[Button]
+var roughness_channel_selected: int = 0
+var last_opened_directory: String
+var last_saved_directory: String
+var packing_albedo: bool = false
+var queue_pack_normal_roughness: bool = false
+var images: Array[Image] = [null, null, null, null]
+var status_label: Label
+var no_op: Callable = func(): pass
+var last_file_selected_fn: Callable = no_op
+var normal_vector: Vector3
+
+
+func pack_textures_popup() -> void:
+ if window != null:
+ window.show()
+ window.grab_focus()
+ window.move_to_center()
+ return
+ window = (load(WINDOW_SCENE) as PackedScene).instantiate()
+ window.close_requested.connect(_on_close_requested)
+ window.window_input.connect(func(event:InputEvent):
+ if event is InputEventKey:
+ if event.pressed and event.keycode == KEY_ESCAPE:
+ _on_close_requested()
+ )
+ window.find_child("CloseButton").pressed.connect(_on_close_requested)
+
+ status_label = window.find_child("StatusLabel") as Label
+ invert_green_checkbox = window.find_child("InvertGreenChannelCheckBox") as CheckBox
+ invert_smooth_checkbox = window.find_child("InvertSmoothCheckBox") as CheckBox
+ invert_height_checkbox = window.find_child("ConvertDepthToHeight") as CheckBox
+ lumin_height_button = window.find_child("LuminanceAsHeightButton") as Button
+ generate_mipmaps_checkbox = window.find_child("GenerateMipmapsCheckBox") as CheckBox
+ high_quality_checkbox = window.find_child("HighQualityCheckBox") as CheckBox
+ align_normals_checkbox = window.find_child("AlignNormalsCheckBox") as CheckBox
+ resize_toggle_checkbox = window.find_child("ResizeToggle") as CheckBox
+ resize_option_box = window.find_child("ResizeOptionButton") as SpinBox
+ height_channel = [
+ window.find_child("HeightChannelR") as Button,
+ window.find_child("HeightChannelG") as Button,
+ window.find_child("HeightChannelB") as Button,
+ window.find_child("HeightChannelA") as Button
+ ]
+ roughness_channel = [
+ window.find_child("RoughnessChannelR") as Button,
+ window.find_child("RoughnessChannelG") as Button,
+ window.find_child("RoughnessChannelB") as Button,
+ window.find_child("RoughnessChannelA") as Button
+ ]
+
+ height_channel[0].pressed.connect(func() -> void: height_channel_selected = 0)
+ height_channel[1].pressed.connect(func() -> void: height_channel_selected = 1)
+ height_channel[2].pressed.connect(func() -> void: height_channel_selected = 2)
+ height_channel[3].pressed.connect(func() -> void: height_channel_selected = 3)
+
+ roughness_channel[0].pressed.connect(func() -> void: roughness_channel_selected = 0)
+ roughness_channel[1].pressed.connect(func() -> void: roughness_channel_selected = 1)
+ roughness_channel[2].pressed.connect(func() -> void: roughness_channel_selected = 2)
+ roughness_channel[3].pressed.connect(func() -> void: roughness_channel_selected = 3)
+
+ plugin.add_child(window)
+ _init_file_dialogs()
+
+ # the dialog disables the parent window "on top" so, restore it after 1 frame to alow the dialog to clear.
+ var set_on_top_fn: Callable = func(_file: String = "") -> void:
+ await RenderingServer.frame_post_draw
+ window.always_on_top = true
+ save_file_dialog.file_selected.connect(set_on_top_fn)
+ save_file_dialog.canceled.connect(set_on_top_fn)
+ open_file_dialog.file_selected.connect(set_on_top_fn)
+ open_file_dialog.canceled.connect(set_on_top_fn)
+
+ _init_texture_picker(window.find_child("AlbedoVBox"), IMAGE_ALBEDO)
+ _init_texture_picker(window.find_child("HeightVBox"), IMAGE_HEIGHT)
+ _init_texture_picker(window.find_child("NormalVBox"), IMAGE_NORMAL)
+ _init_texture_picker(window.find_child("RoughnessVBox"), IMAGE_ROUGHNESS)
+
+ (window.find_child("PackButton") as Button).pressed.connect(_on_pack_button_pressed)
+
+
+func _on_close_requested() -> void:
+ last_file_selected_fn = no_op
+ images = [null, null, null, null]
+ window.queue_free()
+ window = null
+
+
+func _init_file_dialogs() -> void:
+ save_file_dialog = EditorFileDialog.new()
+ save_file_dialog.set_filters(PackedStringArray(["*.png"]))
+ save_file_dialog.set_file_mode(EditorFileDialog.FILE_MODE_SAVE_FILE)
+ save_file_dialog.access = EditorFileDialog.ACCESS_FILESYSTEM
+ save_file_dialog.file_selected.connect(_on_save_file_selected)
+ save_file_dialog.ok_button_text = "Save"
+ save_file_dialog.size = Vector2i(550, 550)
+ #save_file_dialog.transient = false
+ #save_file_dialog.exclusive = false
+ #save_file_dialog.popup_window = true
+
+ open_file_dialog = EditorFileDialog.new()
+ open_file_dialog.set_filters(PackedStringArray(
+ ["*.png", "*.bmp", "*.exr", "*.hdr", "*.jpg", "*.jpeg", "*.tga", "*.svg", "*.webp", "*.ktx", "*.dds"]))
+ open_file_dialog.set_file_mode(EditorFileDialog.FILE_MODE_OPEN_FILE)
+ open_file_dialog.access = EditorFileDialog.ACCESS_FILESYSTEM
+ open_file_dialog.ok_button_text = "Open"
+ open_file_dialog.size = Vector2i(550, 550)
+ #open_file_dialog.transient = false
+ #open_file_dialog.exclusive = false
+ #open_file_dialog.popup_window = true
+
+ window.add_child(save_file_dialog)
+ window.add_child(open_file_dialog)
+
+
+func _init_texture_picker(p_parent: Node, p_image_index: int) -> void:
+ var line_edit: LineEdit = p_parent.find_child("LineEdit") as LineEdit
+ var file_pick_button: Button = p_parent.find_child("PickButton") as Button
+ var clear_button: Button = p_parent.find_child("ClearButton") as Button
+ var texture_rect: TextureRect = p_parent.find_child("TextureRect") as TextureRect
+ var texture_button: Button = p_parent.find_child("TextureButton") as Button
+ texture_button.set_script(load(DRAG_DROP_SCRIPT) as GDScript)
+
+ var set_channel_fn: Callable = func(used_channels: int) -> void:
+ var channel_count: int = 4
+ # enum Image.UsedChannels
+ match used_channels:
+ Image.USED_CHANNELS_L, Image.USED_CHANNELS_R: channel_count = 1
+ Image.USED_CHANNELS_LA, Image.USED_CHANNELS_RG: channel_count = 2
+ Image.USED_CHANNELS_RGB: channel_count = 3
+ Image.USED_CHANNELS_RGBA: channel_count = 4
+ if p_image_index == IMAGE_HEIGHT:
+ for i in 4:
+ height_channel[i].visible = i < channel_count
+ height_channel[0].button_pressed = true
+ height_channel[0].pressed.emit()
+ elif p_image_index == IMAGE_ROUGHNESS:
+ for i in 4:
+ roughness_channel[i].visible = i < channel_count
+ roughness_channel[0].button_pressed = true
+ roughness_channel[0].pressed.emit()
+
+ var load_image_fn: Callable = func(path: String):
+ var image: Image = Image.new()
+ var error: int = OK
+ # Special case for dds files
+ if path.get_extension() == "dds":
+ image = ResourceLoader.load(path).get_image()
+ if not image.is_empty():
+ # if the dds file is loaded, we must clear any mipmaps and
+ # decompress if needed in order to do per pixel operations.
+ image.clear_mipmaps()
+ image.decompress()
+ else:
+ error = FAILED
+ else:
+ error = image.load(path)
+ if error != OK:
+ _show_message(ERROR, "Failed to load texture '" + path + "'")
+ texture_rect.texture = null
+ images[p_image_index] = null
+ else:
+ _show_message(INFO, "Loaded texture '" + path + "'")
+ texture_rect.texture = ImageTexture.create_from_image(image)
+ images[p_image_index] = image
+ _set_wh_labels(p_image_index, image.get_width(), image.get_height())
+ if p_image_index == IMAGE_NORMAL:
+ _set_normal_vector(image)
+ if p_image_index == IMAGE_HEIGHT or p_image_index == IMAGE_ROUGHNESS:
+ set_channel_fn.call(image.detect_used_channels())
+
+ var os_drop_fn: Callable = func(files: PackedStringArray) -> void:
+ # OS drag drop holds mouse focus until released,
+ # Get mouse pos and check directly if inside texture_rect
+ var rect = texture_button.get_global_rect()
+ var mouse_position = texture_button.get_global_mouse_position()
+ if rect.has_point(mouse_position):
+ if files.size() != 1:
+ _show_message(ERROR, "Cannot load multiple files")
+ else:
+ line_edit.text = files[0]
+ load_image_fn.call(files[0])
+
+ var godot_drop_fn: Callable = func(path: String) -> void:
+ path = ProjectSettings.globalize_path(path)
+ line_edit.text = path
+ load_image_fn.call(path)
+
+ var open_fn: Callable = func() -> void:
+ open_file_dialog.current_path = last_opened_directory
+ if last_file_selected_fn != no_op:
+ open_file_dialog.file_selected.disconnect(last_file_selected_fn)
+ last_file_selected_fn = func(path: String) -> void:
+ line_edit.text = path
+ load_image_fn.call(path)
+ open_file_dialog.file_selected.connect(last_file_selected_fn)
+ open_file_dialog.popup_centered_ratio()
+
+ var line_edit_submit_fn: Callable = func(path: String) -> void:
+ line_edit.text = path
+ load_image_fn.call(path)
+
+ var clear_fn: Callable = func() -> void:
+ line_edit.text = ""
+ texture_rect.texture = null
+ images[p_image_index] = null
+ _set_wh_labels(p_image_index, -1, -1)
+
+ line_edit.text_submitted.connect(line_edit_submit_fn)
+ file_pick_button.pressed.connect(open_fn)
+ texture_button.pressed.connect(open_fn)
+ clear_button.pressed.connect(clear_fn)
+ texture_button.dropped.connect(godot_drop_fn)
+ window.files_dropped.connect(os_drop_fn)
+
+ if p_image_index == IMAGE_HEIGHT:
+ var lumin_fn: Callable = func() -> void:
+ if !images[IMAGE_ALBEDO]:
+ _show_message(ERROR, "Albedo Image Required for Operation")
+ else:
+ line_edit.text = "Generated Height"
+ var height_texture: Image = Terrain3DUtil.luminance_to_height(images[IMAGE_ALBEDO])
+ if height_texture.is_empty():
+ _show_message(ERROR, "Height Texture Generation error")
+ # blur the image by resizing down and back..
+ var w: int = height_texture.get_width()
+ var h: int = height_texture.get_height()
+ height_texture.resize(w / 4, h / 4)
+ height_texture.resize(w, h, Image.INTERPOLATE_CUBIC)
+ # "Load" the height texture
+ images[IMAGE_HEIGHT] = height_texture
+ texture_rect.texture = ImageTexture.create_from_image(images[IMAGE_HEIGHT])
+ _set_wh_labels(IMAGE_HEIGHT, height_texture.get_width(), height_texture.get_height())
+ set_channel_fn.call(Image.USED_CHANNELS_R)
+ _show_message(INFO, "Height Texture generated sucsessfully")
+ lumin_height_button.pressed.connect(lumin_fn)
+ plugin.ui.set_button_editor_icon(file_pick_button, "Folder")
+ plugin.ui.set_button_editor_icon(clear_button, "Remove")
+
+
+func _set_wh_labels(p_image_index: int, width: int, height: int) -> void:
+ var w: String = ""
+ var h: String = ""
+ if width > 0 and height > 0:
+ w = "w: " + str(width)
+ h = "h: " + str(height)
+ match p_image_index:
+ 0:
+ window.find_child("AlbedoW").text = w
+ window.find_child("AlbedoH").text = h
+ 1:
+ window.find_child("HeightW").text = w
+ window.find_child("HeightH").text = h
+ 2:
+ window.find_child("NormalW").text = w
+ window.find_child("NormalH").text = h
+ 3:
+ window.find_child("RoughnessW").text = w
+ window.find_child("RoughnessH").text = h
+
+
+func _show_message(p_level: int, p_text: String) -> void:
+ status_label.text = p_text
+ match p_level:
+ INFO:
+ print("Terrain3DChannelPacker: " + p_text)
+ status_label.add_theme_color_override("font_color", Color(0, 0.82, 0.14))
+ WARN:
+ push_warning("Terrain3DChannelPacker: " + p_text)
+ status_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0))
+ ERROR,_:
+ push_error("Terrain3DChannelPacker: " + p_text)
+ status_label.add_theme_color_override("font_color", Color(0.9, 0, 0))
+
+
+func _create_import_file(png_path: String) -> void:
+ var dst_import_path: String = png_path + ".import"
+ var file: FileAccess = FileAccess.open(TEMPLATE_PATH, FileAccess.READ)
+ var template_content: String = file.get_as_text()
+ file.close()
+ template_content = template_content.replace(
+ "$SOURCE_FILE", png_path).replace(
+ "$HIGH_QUALITY", str(high_quality_checkbox.button_pressed)).replace(
+ "$GENERATE_MIPMAPS", str(generate_mipmaps_checkbox.button_pressed)
+ )
+ var import_content: String = template_content
+ file = FileAccess.open(dst_import_path, FileAccess.WRITE)
+ file.store_string(import_content)
+ file.close()
+
+
+func _on_pack_button_pressed() -> void:
+ packing_albedo = images[IMAGE_ALBEDO] != null and images[IMAGE_HEIGHT] != null
+ var packing_normal_roughness: bool = images[IMAGE_NORMAL] != null and images[IMAGE_ROUGHNESS] != null
+
+ if not packing_albedo and not packing_normal_roughness:
+ _show_message(WARN, "Please select an albedo and height texture or a normal and roughness texture")
+ return
+ if packing_albedo:
+ save_file_dialog.current_path = last_saved_directory + "packed_albedo_height"
+ save_file_dialog.title = "Save Packed Albedo/Height Texture"
+ save_file_dialog.popup_centered_ratio()
+ if packing_normal_roughness:
+ queue_pack_normal_roughness = true
+ return
+ if packing_normal_roughness:
+ save_file_dialog.current_path = last_saved_directory + "packed_normal_roughness"
+ save_file_dialog.title = "Save Packed Normal/Roughness Texture"
+ save_file_dialog.popup_centered_ratio()
+
+
+func _on_save_file_selected(p_dst_path) -> void:
+ last_saved_directory = p_dst_path.get_base_dir() + "/"
+ var error: int
+ if packing_albedo:
+ error = _pack_textures(images[IMAGE_ALBEDO], images[IMAGE_HEIGHT], p_dst_path, false,
+ invert_height_checkbox.button_pressed, false, height_channel_selected)
+ else:
+ error = _pack_textures(images[IMAGE_NORMAL], images[IMAGE_ROUGHNESS], p_dst_path,
+ invert_green_checkbox.button_pressed, invert_smooth_checkbox.button_pressed,
+ align_normals_checkbox.button_pressed, roughness_channel_selected)
+
+ if error == OK:
+ EditorInterface.get_resource_filesystem().scan()
+ if window.visible:
+ window.hide()
+ await EditorInterface.get_resource_filesystem().resources_reimported
+ # wait 1 extra frame, to ensure the UI is responsive.
+ await RenderingServer.frame_post_draw
+ window.show()
+
+ if queue_pack_normal_roughness:
+ queue_pack_normal_roughness = false
+ packing_albedo = false
+ save_file_dialog.current_path = last_saved_directory + "packed_normal_roughness"
+ save_file_dialog.title = "Save Packed Normal/Roughness Texture"
+
+ save_file_dialog.call_deferred("popup_centered_ratio")
+ save_file_dialog.call_deferred("grab_focus")
+
+
+func _alignment_basis(normal: Vector3) -> Basis:
+ var up: Vector3 = Vector3(0, 0, 1)
+ var v: Vector3 = normal.cross(up)
+ var c: float = normal.dot(up)
+ var k: float = 1.0 / (1.0 + c)
+
+ var vxy: float = v.x * v.y * k
+ var vxz: float = v.x * v.z * k
+ var vyz: float = v.y * v.z * k
+
+ return Basis(Vector3(v.x * v.x * k + c, vxy - v.z, vxz + v.y),
+ Vector3(vxy + v.z, v.y * v.y * k + c, vyz - v.x),
+ Vector3(vxz - v.y, vyz + v.x, v.z * v.z * k + c)
+ )
+
+
+func _set_normal_vector(source: Image, quiet: bool = false) -> void:
+ # Calculate texture normal sum direction
+ var normal: Image = source
+ var sum: Color = Color(0.0, 0.0, 0.0, 0.0)
+ for x in normal.get_width():
+ for y in normal.get_height():
+ sum += normal.get_pixel(x, y)
+ var div: float = normal.get_height() * normal.get_width()
+ sum /= Color(div, div, div)
+ sum *= 2.0
+ sum -= Color(1.0, 1.0, 1.0)
+ normal_vector = Vector3(sum.r, sum.g, sum.b).normalized()
+ if normal_vector.dot(Vector3(0.0, 0.0, 1.0)) < 0.999 && !quiet:
+ _show_message(WARN, "Normal Texture Not Orthoganol to UV plane.\nFor Compatability with Detiling and Rotation, Select Orthoganolize Normals")
+
+
+func _align_normals(source: Image, iteration: int = 0) -> void:
+ # generate matrix to re-align the normalmap
+ var mat3: Basis = _alignment_basis(normal_vector)
+ # re-align the normal map pixels
+ for x in source.get_width():
+ for y in source.get_height():
+ var old_pixel: Color = source.get_pixel(x, y)
+ var vector_pixel: Vector3 = Vector3(old_pixel.r, old_pixel.g, old_pixel.b)
+ vector_pixel *= 2.0
+ vector_pixel -= Vector3.ONE
+ vector_pixel = vector_pixel.normalized()
+ vector_pixel = vector_pixel * mat3
+ vector_pixel += Vector3.ONE
+ vector_pixel *= 0.5
+ var new_pixel: Color = Color(vector_pixel.x, vector_pixel.y, vector_pixel.z, old_pixel.a)
+ source.set_pixel(x, y, new_pixel)
+ _set_normal_vector(source, true)
+ if normal_vector.dot(Vector3(0.0, 0.0, 1.0)) < 0.999 && iteration < 3:
+ ++iteration
+ _align_normals(source, iteration)
+
+
+func _pack_textures(p_rgb_image: Image, p_a_image: Image, p_dst_path: String, p_invert_green: bool,
+ p_invert_smooth: bool, p_align_normals : bool, p_alpha_channel: int) -> Error:
+ if p_rgb_image and p_a_image:
+ if p_rgb_image.get_size() != p_a_image.get_size() and !resize_toggle_checkbox.button_pressed:
+ _show_message(ERROR, "Textures must be the same size.\nEnable resize to override image dimensions")
+ return FAILED
+
+ if resize_toggle_checkbox.button_pressed:
+ var size: int = max(128, resize_option_box.value)
+ p_rgb_image.resize(size, size, Image.INTERPOLATE_CUBIC)
+ p_a_image.resize(size, size, Image.INTERPOLATE_CUBIC)
+
+ if p_align_normals and normal_vector.dot(Vector3(0.0, 0.0, 1.0)) < 0.999:
+ _align_normals(p_rgb_image)
+ elif p_align_normals:
+ _show_message(INFO, "Alignment OK, skipping Normal Orthogonalization")
+
+ var output_image: Image = Terrain3DUtil.pack_image(p_rgb_image, p_a_image,
+ p_invert_green, p_invert_smooth, p_alpha_channel)
+
+ if not output_image:
+ _show_message(ERROR, "Failed to pack textures")
+ return FAILED
+ if output_image.detect_alpha() != Image.ALPHA_BLEND:
+ _show_message(WARN, "Warning, Alpha channel empty")
+
+ output_image.save_png(p_dst_path)
+ _create_import_file(p_dst_path)
+ _show_message(INFO, "Packed to " + p_dst_path + ".")
+ return OK
+ else:
+ _show_message(ERROR, "Failed to load one or more textures")
+ return FAILED
diff --git a/addons/terrain_3d/menu/channel_packer.gd.uid b/addons/terrain_3d/menu/channel_packer.gd.uid
new file mode 100644
index 0000000..564c78d
--- /dev/null
+++ b/addons/terrain_3d/menu/channel_packer.gd.uid
@@ -0,0 +1 @@
+uid://bwldx4itd58o7
diff --git a/addons/terrain_3d/menu/channel_packer.tscn b/addons/terrain_3d/menu/channel_packer.tscn
new file mode 100644
index 0000000..11de99a
--- /dev/null
+++ b/addons/terrain_3d/menu/channel_packer.tscn
@@ -0,0 +1,530 @@
+[gd_scene load_steps=7 format=3 uid="uid://nud6dwjcnj5v"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ysabf"]
+bg_color = Color(0.211765, 0.239216, 0.290196, 1)
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lcvna"]
+bg_color = Color(0.168627, 0.211765, 0.266667, 1)
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(0.270588, 0.435294, 0.580392, 1)
+corner_radius_top_left = 5
+corner_radius_top_right = 5
+corner_radius_bottom_right = 5
+corner_radius_bottom_left = 5
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cb0xf"]
+bg_color = Color(0.137255, 0.137255, 0.137255, 1)
+draw_center = false
+border_width_left = 3
+border_width_top = 3
+border_width_right = 3
+border_width_bottom = 3
+border_color = Color(0.784314, 0.784314, 0.784314, 1)
+corner_radius_top_left = 5
+corner_radius_top_right = 5
+corner_radius_bottom_right = 5
+corner_radius_bottom_left = 5
+
+[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_7qdas"]
+
+[sub_resource type="ButtonGroup" id="ButtonGroup_wnxik"]
+
+[sub_resource type="ButtonGroup" id="ButtonGroup_bs6ki"]
+
+[node name="Window" type="Window"]
+title = "Terrain3D Channel Packer"
+initial_position = 1
+size = Vector2i(583, 856)
+wrap_controls = true
+always_on_top = true
+
+[node name="PanelContainer" type="PanelContainer" parent="."]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_ysabf")
+
+[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
+layout_mode = 2
+theme_override_constants/margin_left = 5
+theme_override_constants/margin_top = 5
+theme_override_constants/margin_right = 5
+theme_override_constants/margin_bottom = 5
+
+[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
+layout_mode = 2
+theme_override_constants/separation = 10
+
+[node name="AlbedoHeightPanel" type="PanelContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_lcvna")
+
+[node name="MarginContainer" type="MarginContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 10
+theme_override_constants/margin_top = 10
+theme_override_constants/margin_right = 10
+theme_override_constants/margin_bottom = 10
+
+[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer"]
+layout_mode = 2
+
+[node name="AlbedoVBox" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="AlbedoLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox"]
+layout_mode = 2
+text = "Albedo texture"
+
+[node name="AlbedoHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox"]
+layout_mode = 2
+
+[node name="LineEdit" type="LineEdit" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/AlbedoHBox"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="PickButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/AlbedoHBox"]
+layout_mode = 2
+
+[node name="ClearButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/AlbedoHBox"]
+layout_mode = 2
+
+[node name="MarginContainer" type="MarginContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox"]
+layout_mode = 2
+size_flags_vertical = 4
+theme_override_constants/margin_top = 10
+
+[node name="Panel" type="Panel" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/MarginContainer"]
+custom_minimum_size = Vector2(110, 110)
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme_override_styles/panel = SubResource("StyleBoxFlat_cb0xf")
+
+[node name="TextureRect" type="TextureRect" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -50.0
+offset_top = -50.0
+offset_right = 50.0
+offset_bottom = 50.0
+grow_horizontal = 2
+grow_vertical = 2
+expand_mode = 1
+
+[node name="TextureButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_styles/normal = SubResource("StyleBoxEmpty_7qdas")
+
+[node name="AlbedoWHHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="AlbedoW" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/AlbedoWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="AlbedoH" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/AlbedoWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="HBoxContainer2" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="LuminanceAsHeightButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/AlbedoVBox/HBoxContainer2"]
+layout_mode = 2
+text = " Generate Height from Luminance"
+icon_alignment = 2
+
+[node name="HeightVBox" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="HeightLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox"]
+layout_mode = 2
+text = "Height texture"
+
+[node name="HeightHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox"]
+layout_mode = 2
+
+[node name="LineEdit" type="LineEdit" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HeightHBox"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="PickButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HeightHBox"]
+layout_mode = 2
+
+[node name="ClearButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HeightHBox"]
+layout_mode = 2
+
+[node name="MarginContainer" type="MarginContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox"]
+layout_mode = 2
+size_flags_vertical = 4
+theme_override_constants/margin_top = 10
+
+[node name="Panel" type="Panel" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/MarginContainer"]
+custom_minimum_size = Vector2(110, 110)
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme_override_styles/panel = SubResource("StyleBoxFlat_cb0xf")
+
+[node name="TextureRect" type="TextureRect" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -50.0
+offset_top = -50.0
+offset_right = 50.0
+offset_bottom = 50.0
+grow_horizontal = 2
+grow_vertical = 2
+expand_mode = 1
+
+[node name="TextureButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_styles/normal = SubResource("StyleBoxEmpty_7qdas")
+
+[node name="HeightWHHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="HeightW" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HeightWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="HeightH" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HeightWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="HBoxContainer2" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="ConvertDepthToHeight" type="CheckBox" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HBoxContainer2"]
+layout_mode = 2
+text = " Convert Depth to Height"
+icon_alignment = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="HeightChannelLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HBoxContainer"]
+layout_mode = 2
+text = " Source Channel: "
+horizontal_alignment = 2
+
+[node name="HeightChannelR" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_pressed = true
+button_group = SubResource("ButtonGroup_wnxik")
+text = "R"
+
+[node name="HeightChannelB" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_group = SubResource("ButtonGroup_wnxik")
+text = "G"
+
+[node name="HeightChannelG" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_group = SubResource("ButtonGroup_wnxik")
+text = "B"
+
+[node name="HeightChannelA" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/AlbedoHeightPanel/MarginContainer/HBoxContainer/HeightVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_group = SubResource("ButtonGroup_wnxik")
+text = "A"
+
+[node name="NormalRoughnessPanel" type="PanelContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_lcvna")
+
+[node name="MarginContainer" type="MarginContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 10
+theme_override_constants/margin_top = 10
+theme_override_constants/margin_right = 10
+theme_override_constants/margin_bottom = 10
+
+[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer"]
+layout_mode = 2
+
+[node name="NormalVBox" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="NormalLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox"]
+layout_mode = 2
+text = "Normal texture"
+
+[node name="NormalHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox"]
+layout_mode = 2
+
+[node name="LineEdit" type="LineEdit" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/NormalHBox"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="PickButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/NormalHBox"]
+layout_mode = 2
+
+[node name="ClearButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/NormalHBox"]
+layout_mode = 2
+
+[node name="MarginContainer" type="MarginContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox"]
+layout_mode = 2
+size_flags_vertical = 4
+theme_override_constants/margin_top = 10
+
+[node name="Panel" type="Panel" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/MarginContainer"]
+custom_minimum_size = Vector2(110, 110)
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme_override_styles/panel = SubResource("StyleBoxFlat_cb0xf")
+
+[node name="TextureRect" type="TextureRect" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -50.0
+offset_top = -50.0
+offset_right = 50.0
+offset_bottom = 50.0
+grow_horizontal = 2
+grow_vertical = 2
+expand_mode = 1
+
+[node name="TextureButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_styles/normal = SubResource("StyleBoxEmpty_7qdas")
+
+[node name="NormalWHHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="NormalW" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/NormalWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="NormalH" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/NormalWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="InvertGreenChannelCheckBox" type="CheckBox" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/HBoxContainer"]
+layout_mode = 2
+text = " Convert DirectX to OpenGL"
+
+[node name="HBoxContainer2" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="AlignNormalsCheckBox" type="CheckBox" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/NormalVBox/HBoxContainer2"]
+layout_mode = 2
+text = " Orthoganolise Normals"
+
+[node name="RoughnessVBox" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="RoughnessLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox"]
+layout_mode = 2
+text = "Roughness texture"
+
+[node name="RoughnessHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox"]
+layout_mode = 2
+
+[node name="LineEdit" type="LineEdit" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/RoughnessHBox"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="PickButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/RoughnessHBox"]
+layout_mode = 2
+
+[node name="ClearButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/RoughnessHBox"]
+layout_mode = 2
+
+[node name="MarginContainer" type="MarginContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox"]
+layout_mode = 2
+size_flags_vertical = 4
+theme_override_constants/margin_top = 10
+
+[node name="Panel" type="Panel" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/MarginContainer"]
+custom_minimum_size = Vector2(110, 110)
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme_override_styles/panel = SubResource("StyleBoxFlat_cb0xf")
+
+[node name="TextureRect" type="TextureRect" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -50.0
+offset_top = -50.0
+offset_right = 50.0
+offset_bottom = 50.0
+grow_horizontal = 2
+grow_vertical = 2
+expand_mode = 1
+
+[node name="TextureButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/MarginContainer/Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_styles/normal = SubResource("StyleBoxEmpty_7qdas")
+
+[node name="RoughnessWHHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="RoughnessW" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/RoughnessWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="RoughnessH" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/RoughnessWHHBox"]
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="HBoxContainer2" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="InvertSmoothCheckBox" type="CheckBox" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/HBoxContainer2"]
+layout_mode = 2
+text = " Convert Smoothness to Roughness"
+
+[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox"]
+layout_mode = 2
+alignment = 1
+
+[node name="RoughnessChannelLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/HBoxContainer"]
+layout_mode = 2
+text = " Source Channel: "
+horizontal_alignment = 2
+
+[node name="RoughnessChannelR" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_pressed = true
+button_group = SubResource("ButtonGroup_bs6ki")
+text = "R"
+
+[node name="RoughnessChannelG" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_group = SubResource("ButtonGroup_bs6ki")
+text = "G"
+
+[node name="RoughnessChannelB" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_group = SubResource("ButtonGroup_bs6ki")
+text = "B"
+
+[node name="RoughnessChannelA" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/NormalRoughnessPanel/MarginContainer/HBoxContainer/RoughnessVBox/HBoxContainer"]
+layout_mode = 2
+toggle_mode = true
+button_group = SubResource("ButtonGroup_bs6ki")
+text = "A"
+
+[node name="GeneralOptionsLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
+layout_mode = 2
+text = "General Options"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="GeneralOptionsHBox" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="ResizeToggle" type="CheckBox" parent="PanelContainer/MarginContainer/VBoxContainer/GeneralOptionsHBox"]
+layout_mode = 2
+text = " Resize Packed Image"
+
+[node name="ResizeOptionButton" type="SpinBox" parent="PanelContainer/MarginContainer/VBoxContainer/GeneralOptionsHBox"]
+visible = false
+layout_mode = 2
+tooltip_text = "A value of 0 disables resizing."
+min_value = 128.0
+max_value = 4096.0
+step = 128.0
+value = 1024.0
+
+[node name="VSeparator" type="VSeparator" parent="PanelContainer/MarginContainer/VBoxContainer/GeneralOptionsHBox"]
+layout_mode = 2
+
+[node name="GenerateMipmapsCheckBox" type="CheckBox" parent="PanelContainer/MarginContainer/VBoxContainer/GeneralOptionsHBox"]
+layout_mode = 2
+button_pressed = true
+text = "Generate Mipmaps"
+
+[node name="HighQualityCheckBox" type="CheckBox" parent="PanelContainer/MarginContainer/VBoxContainer/GeneralOptionsHBox"]
+layout_mode = 2
+text = "Import High Quality"
+
+[node name="PackButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer"]
+layout_mode = 2
+text = "Pack textures as..."
+
+[node name="StatusLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"]
+custom_minimum_size = Vector2(0, 60)
+layout_mode = 2
+horizontal_alignment = 1
+autowrap_mode = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
+layout_mode = 2
+alignment = 1
+
+[node name="CloseButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "Close"
+
+[connection signal="toggled" from="PanelContainer/MarginContainer/VBoxContainer/GeneralOptionsHBox/ResizeToggle" to="PanelContainer/MarginContainer/VBoxContainer/GeneralOptionsHBox/ResizeOptionButton" method="set_visible"]
diff --git a/addons/terrain_3d/menu/channel_packer_dragdrop.gd b/addons/terrain_3d/menu/channel_packer_dragdrop.gd
new file mode 100644
index 0000000..154c8ad
--- /dev/null
+++ b/addons/terrain_3d/menu/channel_packer_dragdrop.gd
@@ -0,0 +1,17 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Channel Packer Dragdropper for Terrain3D
+@tool
+extends Button
+
+signal dropped
+
+func _can_drop_data(p_position, p_data) -> bool:
+ if typeof(p_data) == TYPE_DICTIONARY:
+ if p_data.files.size() == 1:
+ match p_data.files[0].get_extension():
+ "png", "bmp", "exr", "hdr", "jpg", "jpeg", "tga", "svg", "webp", "ktx", "dds":
+ return true
+ return false
+
+func _drop_data(p_position, p_data) -> void:
+ dropped.emit(p_data.files[0])
diff --git a/addons/terrain_3d/menu/channel_packer_dragdrop.gd.uid b/addons/terrain_3d/menu/channel_packer_dragdrop.gd.uid
new file mode 100644
index 0000000..d9a59e3
--- /dev/null
+++ b/addons/terrain_3d/menu/channel_packer_dragdrop.gd.uid
@@ -0,0 +1 @@
+uid://br45krrqbw8bg
diff --git a/addons/terrain_3d/menu/channel_packer_import_template.txt b/addons/terrain_3d/menu/channel_packer_import_template.txt
new file mode 100644
index 0000000..55003e3
--- /dev/null
+++ b/addons/terrain_3d/menu/channel_packer_import_template.txt
@@ -0,0 +1,32 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="$SOURCE_FILE"
+
+[params]
+
+compress/mode=2
+compress/high_quality=$HIGH_QUALITY
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=2
+compress/channel_pack=0
+mipmaps/generate=$GENERATE_MIPMAPS
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/terrain_3d/menu/directory_setup.gd b/addons/terrain_3d/menu/directory_setup.gd
new file mode 100644
index 0000000..b851747
--- /dev/null
+++ b/addons/terrain_3d/menu/directory_setup.gd
@@ -0,0 +1,86 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Directory Setup for Terrain3D
+extends Node
+
+const DIRECTORY_SETUP: String = "res://addons/terrain_3d/menu/directory_setup.tscn"
+
+var plugin: EditorPlugin
+var dialog: ConfirmationDialog
+var select_dir_btn: Button
+var selected_dir_le: LineEdit
+var editor_file_dialog: EditorFileDialog
+
+
+func _init() -> void:
+ editor_file_dialog = EditorFileDialog.new()
+ editor_file_dialog.set_filters(PackedStringArray(["*.res"]))
+ editor_file_dialog.set_file_mode(EditorFileDialog.FILE_MODE_SAVE_FILE)
+ editor_file_dialog.access = EditorFileDialog.ACCESS_RESOURCES
+ editor_file_dialog.ok_button_text = "Open"
+ editor_file_dialog.title = "Open a folder or file"
+ editor_file_dialog.dir_selected.connect(_on_dir_selected)
+ editor_file_dialog.size = Vector2i(850, 550)
+ editor_file_dialog.transient = false
+ editor_file_dialog.exclusive = false
+ editor_file_dialog.popup_window = true
+ add_child(editor_file_dialog)
+
+
+func directory_setup_popup() -> void:
+ dialog = load(DIRECTORY_SETUP).instantiate()
+ dialog.hide()
+
+ # Nodes
+ select_dir_btn = dialog.get_node("Margin/VBox/DirHBox/SelectDir")
+ selected_dir_le = dialog.get_node("Margin/VBox/DirHBox/LineEdit")
+
+ if plugin.terrain.data_directory:
+ selected_dir_le.text = plugin.terrain.data_directory
+
+ # Icons
+ plugin.ui.set_button_editor_icon(select_dir_btn, "Folder")
+
+ #Signals
+ select_dir_btn.pressed.connect(_on_select_file_pressed.bind(EditorFileDialog.FILE_MODE_OPEN_DIR))
+ dialog.confirmed.connect(_on_close_requested)
+ dialog.canceled.connect(_on_close_requested)
+ dialog.get_ok_button().pressed.connect(_on_ok_pressed)
+
+ # Popup
+ EditorInterface.popup_dialog_centered(dialog)
+
+
+func _on_close_requested() -> void:
+ dialog.queue_free()
+ dialog = null
+
+
+func _on_select_file_pressed(file_mode: EditorFileDialog.FileMode) -> void:
+ editor_file_dialog.file_mode = file_mode
+ editor_file_dialog.popup_centered()
+
+
+func _on_dir_selected(path: String) -> void:
+ selected_dir_le.text = path
+
+
+func _on_ok_pressed() -> void:
+ if not plugin.terrain:
+ push_error("Not connected terrain. Click the Terrain3D node first")
+ return
+ if selected_dir_le.text.is_empty():
+ push_error("No data directory specified")
+ return
+ if not DirAccess.dir_exists_absolute(selected_dir_le.text):
+ push_error("Directory doesn't exist: ", selected_dir_le.text)
+ return
+ # Check if directory empty of terrain files
+ var data_found: bool = false
+ var files: Array = DirAccess.get_files_at(selected_dir_le.text)
+ for file in files:
+ if file.begins_with("terrain3d") || file.ends_with(".res"):
+ data_found = true
+ break
+
+ print("Setting terrain directory: ", selected_dir_le.text)
+ plugin.terrain.data_directory = selected_dir_le.text
diff --git a/addons/terrain_3d/menu/directory_setup.gd.uid b/addons/terrain_3d/menu/directory_setup.gd.uid
new file mode 100644
index 0000000..de0b12f
--- /dev/null
+++ b/addons/terrain_3d/menu/directory_setup.gd.uid
@@ -0,0 +1 @@
+uid://0034ukv2mngn
diff --git a/addons/terrain_3d/menu/directory_setup.tscn b/addons/terrain_3d/menu/directory_setup.tscn
new file mode 100644
index 0000000..d5dabc5
--- /dev/null
+++ b/addons/terrain_3d/menu/directory_setup.tscn
@@ -0,0 +1,48 @@
+[gd_scene format=3 uid="uid://by3kr2nqbqr67"]
+
+[node name="DirectorySetup" type="ConfirmationDialog"]
+title = "Terrain3D Data Directory Setup"
+position = Vector2i(0, 36)
+size = Vector2i(750, 330)
+visible = true
+
+[node name="Margin" type="MarginContainer" parent="."]
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 742.0
+offset_bottom = 281.0
+theme_override_constants/margin_left = 20
+theme_override_constants/margin_top = 20
+theme_override_constants/margin_right = 20
+theme_override_constants/margin_bottom = 20
+
+[node name="VBox" type="VBoxContainer" parent="Margin"]
+layout_mode = 2
+
+[node name="Instructions" type="Label" parent="Margin/VBox"]
+custom_minimum_size = Vector2(400, 0)
+layout_mode = 2
+text = "Terrain3D now stores data in a directory instead of a single file. Each region is stored in a separate file named `terrain3d[-_]##[-_]##.res`. For instance, the region at location (-1, 1) would be named `terrain3d-01_01.res`. Enable Terrain3D / Debug / Show Region Labels for a visual display."
+autowrap_mode = 3
+
+[node name="DirectoryLabel" type="Label" parent="Margin/VBox"]
+custom_minimum_size = Vector2(400, 0)
+layout_mode = 2
+text = "
+Specify the directory to store your data. Any existing region files will be loaded."
+autowrap_mode = 3
+
+[node name="DirHBox" type="HBoxContainer" parent="Margin/VBox"]
+layout_mode = 2
+
+[node name="LineEdit" type="LineEdit" parent="Margin/VBox/DirHBox"]
+layout_mode = 2
+size_flags_horizontal = 3
+placeholder_text = "Data directory"
+
+[node name="SelectDir" type="Button" parent="Margin/VBox/DirHBox"]
+layout_mode = 2
+
+[node name="Spacer" type="Control" parent="Margin/VBox"]
+custom_minimum_size = Vector2(0, 40)
+layout_mode = 2
diff --git a/addons/terrain_3d/menu/terrain_menu.gd b/addons/terrain_3d/menu/terrain_menu.gd
new file mode 100644
index 0000000..c53656f
--- /dev/null
+++ b/addons/terrain_3d/menu/terrain_menu.gd
@@ -0,0 +1,83 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Menu for Terrain3D
+extends HBoxContainer
+
+
+const DirectoryWizard: Script = preload("res://addons/terrain_3d/menu/directory_setup.gd")
+const Packer: Script = preload("res://addons/terrain_3d/menu/channel_packer.gd")
+const Baker: Script = preload("res://addons/terrain_3d/menu/baker.gd")
+
+var plugin: EditorPlugin
+var menu_button: MenuButton = MenuButton.new()
+var directory_setup: DirectoryWizard = DirectoryWizard.new()
+var packer: Packer = Packer.new()
+var baker: Baker = Baker.new()
+
+# These are IDs and order must be consistent with add_item and set_disabled IDs
+enum {
+ MENU_DIRECTORY_SETUP,
+ MENU_PACK_TEXTURES,
+ MENU_SEPARATOR,
+ MENU_BAKE_ARRAY_MESH,
+ MENU_BAKE_OCCLUDER,
+ MENU_SEPARATOR2,
+ MENU_SET_UP_NAVIGATION,
+ MENU_BAKE_NAV_MESH,
+}
+
+
+func _enter_tree() -> void:
+ directory_setup.plugin = plugin
+ packer.plugin = plugin
+ baker.plugin = plugin
+ add_child(directory_setup)
+ add_child(baker)
+
+ menu_button.text = "Terrain3D"
+ menu_button.get_popup().add_item("Directory Setup...", MENU_DIRECTORY_SETUP)
+ menu_button.get_popup().add_item("Pack Textures...", MENU_PACK_TEXTURES)
+ menu_button.get_popup().add_separator("", MENU_SEPARATOR)
+ menu_button.get_popup().add_item("Bake ArrayMesh...", MENU_BAKE_ARRAY_MESH)
+ menu_button.get_popup().add_item("Bake Occluder3D...", MENU_BAKE_OCCLUDER)
+ menu_button.get_popup().add_separator("", MENU_SEPARATOR2)
+ menu_button.get_popup().add_item("Set up Navigation...", MENU_SET_UP_NAVIGATION)
+ menu_button.get_popup().add_item("Bake NavMesh...", MENU_BAKE_NAV_MESH)
+
+ menu_button.get_popup().id_pressed.connect(_on_menu_pressed)
+ menu_button.about_to_popup.connect(_on_menu_about_to_popup)
+ add_child(menu_button)
+
+
+func _on_menu_pressed(p_id: int) -> void:
+ match p_id:
+ MENU_DIRECTORY_SETUP:
+ directory_setup.directory_setup_popup()
+ MENU_PACK_TEXTURES:
+ packer.pack_textures_popup()
+ MENU_BAKE_ARRAY_MESH:
+ baker.bake_mesh_popup()
+ MENU_BAKE_OCCLUDER:
+ baker.bake_occluder_popup()
+ MENU_SET_UP_NAVIGATION:
+ baker.set_up_navigation_popup()
+ MENU_BAKE_NAV_MESH:
+ baker.bake_nav_mesh()
+
+
+func _on_menu_about_to_popup() -> void:
+ menu_button.get_popup().set_item_disabled(MENU_DIRECTORY_SETUP, not plugin.terrain)
+ menu_button.get_popup().set_item_disabled(MENU_PACK_TEXTURES, not plugin.terrain)
+ menu_button.get_popup().set_item_disabled(MENU_BAKE_ARRAY_MESH, not plugin.terrain)
+ menu_button.get_popup().set_item_disabled(MENU_BAKE_OCCLUDER, not plugin.terrain)
+
+ if plugin.terrain:
+ var nav_regions: Array[NavigationRegion3D] = baker.find_terrain_nav_regions(plugin.terrain)
+ menu_button.get_popup().set_item_disabled(MENU_BAKE_NAV_MESH, nav_regions.size() == 0)
+ menu_button.get_popup().set_item_disabled(MENU_SET_UP_NAVIGATION, nav_regions.size() != 0)
+ elif plugin.nav_region:
+ var terrains: Array[Terrain3D] = baker.find_nav_region_terrains(plugin.nav_region)
+ menu_button.get_popup().set_item_disabled(MENU_BAKE_NAV_MESH, terrains.size() == 0)
+ menu_button.get_popup().set_item_disabled(MENU_SET_UP_NAVIGATION, true)
+ else:
+ menu_button.get_popup().set_item_disabled(MENU_BAKE_NAV_MESH, true)
+ menu_button.get_popup().set_item_disabled(MENU_SET_UP_NAVIGATION, true)
diff --git a/addons/terrain_3d/menu/terrain_menu.gd.uid b/addons/terrain_3d/menu/terrain_menu.gd.uid
new file mode 100644
index 0000000..b1b1cea
--- /dev/null
+++ b/addons/terrain_3d/menu/terrain_menu.gd.uid
@@ -0,0 +1 @@
+uid://3gxvahogxa10
diff --git a/addons/terrain_3d/plugin.cfg b/addons/terrain_3d/plugin.cfg
new file mode 100644
index 0000000..8371f4e
--- /dev/null
+++ b/addons/terrain_3d/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Terrain3D"
+description="A high performance, editable terrain system for Godot 4."
+author="Cory Petkovsek & Roope Palmroos"
+version="1.0.0"
+script="src/editor_plugin.gd"
diff --git a/addons/terrain_3d/src/asset_dock.gd b/addons/terrain_3d/src/asset_dock.gd
new file mode 100644
index 0000000..cfa28fc
--- /dev/null
+++ b/addons/terrain_3d/src/asset_dock.gd
@@ -0,0 +1,883 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Asset Dock for Terrain3D
+@tool
+extends PanelContainer
+
+signal confirmation_closed
+signal confirmation_confirmed
+signal confirmation_canceled
+
+const ES_DOCK_SLOT: String = "terrain3d/dock/slot"
+const ES_DOCK_TILE_SIZE: String = "terrain3d/dock/tile_size"
+const ES_DOCK_FLOATING: String = "terrain3d/dock/floating"
+const ES_DOCK_PINNED: String = "terrain3d/dock/always_on_top"
+const ES_DOCK_WINDOW_POSITION: String = "terrain3d/dock/window_position"
+const ES_DOCK_WINDOW_SIZE: String = "terrain3d/dock/window_size"
+const ES_DOCK_TAB: String = "terrain3d/dock/tab"
+
+var texture_list: ListContainer
+var mesh_list: ListContainer
+var _current_list: ListContainer
+var _last_thumb_update_time: int = 0
+const MAX_UPDATE_TIME: int = 1000
+
+var placement_opt: OptionButton
+var floating_btn: Button
+var pinned_btn: Button
+var size_slider: HSlider
+var box: BoxContainer
+var buttons: BoxContainer
+var textures_btn: Button
+var meshes_btn: Button
+var asset_container: ScrollContainer
+var confirm_dialog: ConfirmationDialog
+var _confirmed: bool = false
+
+# Used only for editor, so change to single visible/hiddden
+enum {
+ HIDDEN = -1,
+ SIDEBAR = 0,
+ BOTTOM = 1,
+ WINDOWED = 2,
+}
+var state: int = HIDDEN
+
+enum {
+ POS_LEFT_UL = 0,
+ POS_LEFT_BL = 1,
+ POS_LEFT_UR = 2,
+ POS_LEFT_BR = 3,
+ POS_RIGHT_UL = 4,
+ POS_RIGHT_BL = 5,
+ POS_RIGHT_UR = 6,
+ POS_RIGHT_BR = 7,
+ POS_BOTTOM = 8,
+ POS_MAX = 9,
+}
+var slot: int = POS_RIGHT_BR
+var _initialized: bool = false
+var plugin: EditorPlugin
+var window: Window
+var _godot_last_state: Window.Mode = Window.MODE_FULLSCREEN
+
+
+func initialize(p_plugin: EditorPlugin) -> void:
+ if p_plugin:
+ plugin = p_plugin
+
+ _godot_last_state = plugin.godot_editor_window.mode
+ placement_opt = $Box/Buttons/PlacementOpt
+ pinned_btn = $Box/Buttons/Pinned
+ floating_btn = $Box/Buttons/Floating
+ floating_btn.owner = null
+ size_slider = $Box/Buttons/SizeSlider
+ size_slider.owner = null
+ box = $Box
+ buttons = $Box/Buttons
+ textures_btn = $Box/Buttons/TexturesBtn
+ meshes_btn = $Box/Buttons/MeshesBtn
+ asset_container = $Box/ScrollContainer
+
+ texture_list = ListContainer.new()
+ texture_list.plugin = plugin
+ texture_list.type = Terrain3DAssets.TYPE_TEXTURE
+ asset_container.add_child(texture_list)
+ mesh_list = ListContainer.new()
+ mesh_list.plugin = plugin
+ mesh_list.type = Terrain3DAssets.TYPE_MESH
+ mesh_list.visible = false
+ asset_container.add_child(mesh_list)
+ _current_list = texture_list
+
+ load_editor_settings()
+
+ # Connect signals
+ resized.connect(update_layout)
+ textures_btn.pressed.connect(_on_textures_pressed)
+ meshes_btn.pressed.connect(_on_meshes_pressed)
+ placement_opt.item_selected.connect(set_slot)
+ floating_btn.pressed.connect(make_dock_float)
+ pinned_btn.toggled.connect(_on_pin_changed)
+ pinned_btn.visible = ( window != null )
+ size_slider.value_changed.connect(_on_slider_changed)
+ plugin.ui.toolbar.tool_changed.connect(_on_tool_changed)
+
+ meshes_btn.add_theme_font_size_override("font_size", 16 * EditorInterface.get_editor_scale())
+ textures_btn.add_theme_font_size_override("font_size", 16 * EditorInterface.get_editor_scale())
+
+ _initialized = true
+ update_dock()
+ update_layout()
+
+
+func _ready() -> void:
+ if not _initialized:
+ return
+
+ # Setup styles
+ set("theme_override_styles/panel", get_theme_stylebox("panel", "Panel"))
+ # Avoid saving icon resources in tscn when editing w/ a tool script
+ if EditorInterface.get_edited_scene_root() != self:
+ pinned_btn.icon = get_theme_icon("Pin", "EditorIcons")
+ pinned_btn.text = ""
+ floating_btn.icon = get_theme_icon("MakeFloating", "EditorIcons")
+ floating_btn.text = ""
+
+ update_thumbnails()
+ confirm_dialog = ConfirmationDialog.new()
+ add_child(confirm_dialog)
+ confirm_dialog.hide()
+ confirm_dialog.confirmed.connect(func(): _confirmed = true; \
+ emit_signal("confirmation_closed"); \
+ emit_signal("confirmation_confirmed") )
+ confirm_dialog.canceled.connect(func(): _confirmed = false; \
+ emit_signal("confirmation_closed"); \
+ emit_signal("confirmation_canceled") )
+
+
+func get_current_list() -> ListContainer:
+ return _current_list
+
+
+## Dock placement
+
+func set_slot(p_slot: int) -> void:
+ p_slot = clamp(p_slot, 0, POS_MAX-1)
+
+ if slot != p_slot:
+ slot = p_slot
+ placement_opt.selected = slot
+ save_editor_settings()
+ plugin.select_terrain()
+ update_dock()
+
+
+func remove_dock(p_force: bool = false) -> void:
+ if state == SIDEBAR:
+ plugin.remove_control_from_docks(self)
+ state = HIDDEN
+
+ elif state == BOTTOM:
+ plugin.remove_control_from_bottom_panel(self)
+ state = HIDDEN
+
+ # If windowed and destination is not window or final exit, otherwise leave
+ elif state == WINDOWED and p_force and window:
+ var parent: Node = get_parent()
+ if parent:
+ parent.remove_child(self)
+ plugin.godot_editor_window.mouse_entered.disconnect(_on_godot_window_entered)
+ plugin.godot_editor_window.focus_entered.disconnect(_on_godot_focus_entered)
+ plugin.godot_editor_window.focus_exited.disconnect(_on_godot_focus_exited)
+ window.hide()
+ window.queue_free()
+ window = null
+ floating_btn.button_pressed = false
+ floating_btn.visible = true
+ pinned_btn.visible = false
+ placement_opt.visible = true
+ state = HIDDEN
+ update_dock() # return window to side/bottom
+
+
+func update_dock() -> void:
+ if not _initialized or window:
+ return
+
+ update_assets()
+
+ # Move dock to new destination
+ remove_dock()
+ # Sidebar
+ if slot < POS_BOTTOM:
+ state = SIDEBAR
+ plugin.add_control_to_dock(slot, self)
+ # Bottom
+ elif slot == POS_BOTTOM:
+ state = BOTTOM
+ plugin.add_control_to_bottom_panel(self, "Terrain3D")
+ plugin.make_bottom_panel_item_visible(self)
+
+
+func update_layout() -> void:
+ if not _initialized:
+ return
+
+ # Detect if we have a new window from Make floating, grab it so we can free it properly
+ if not window and get_parent() and get_parent().get_parent() is Window:
+ window = get_parent().get_parent()
+ make_dock_float()
+ return # Will call this function again upon display
+
+ var size_parent: Control = size_slider.get_parent()
+ # Vertical layout in window / sidebar
+ if window or slot < POS_BOTTOM:
+ box.vertical = true
+ buttons.vertical = false
+
+ if size.x >= 500 and size_parent != buttons:
+ size_slider.reparent(buttons)
+ buttons.move_child(size_slider, 3)
+ elif size.x < 500 and size_parent != box:
+ size_slider.reparent(box)
+ box.move_child(size_slider, 1)
+ floating_btn.reparent(buttons)
+ buttons.move_child(floating_btn, 4)
+
+ # Wide layout on bottom bar
+ else:
+ size_slider.reparent(buttons)
+ buttons.move_child(size_slider, 3)
+ floating_btn.reparent(box)
+ box.vertical = false
+ buttons.vertical = true
+
+ save_editor_settings()
+
+
+func update_thumbnails() -> void:
+ if not is_instance_valid(plugin.terrain):
+ return
+ if _current_list.type == Terrain3DAssets.TYPE_MESH and \
+ Time.get_ticks_msec() - _last_thumb_update_time > MAX_UPDATE_TIME:
+ plugin.terrain.assets.create_mesh_thumbnails()
+ _last_thumb_update_time = Time.get_ticks_msec()
+ for mesh_asset in mesh_list.entries:
+ mesh_asset.queue_redraw()
+
+
+## Dock Button handlers
+
+
+func _on_pin_changed(toggled: bool) -> void:
+ if window:
+ window.always_on_top = pinned_btn.button_pressed
+ save_editor_settings()
+
+
+func _on_slider_changed(value: float) -> void:
+ if texture_list:
+ texture_list.set_entry_width(value)
+ if mesh_list:
+ mesh_list.set_entry_width(value)
+ save_editor_settings()
+
+
+func _on_textures_pressed() -> void:
+ _current_list = texture_list
+ texture_list.update_asset_list()
+ texture_list.visible = true
+ mesh_list.visible = false
+ textures_btn.button_pressed = true
+ meshes_btn.button_pressed = false
+ texture_list.set_selected_id(texture_list.selected_id)
+ if plugin.is_terrain_valid():
+ EditorInterface.edit_node(plugin.terrain)
+ save_editor_settings()
+
+
+func _on_meshes_pressed() -> void:
+ _current_list = mesh_list
+ mesh_list.update_asset_list()
+ mesh_list.visible = true
+ texture_list.visible = false
+ meshes_btn.button_pressed = true
+ textures_btn.button_pressed = false
+ mesh_list.set_selected_id(mesh_list.selected_id)
+ if plugin.is_terrain_valid():
+ EditorInterface.edit_node(plugin.terrain)
+ update_thumbnails()
+ save_editor_settings()
+
+
+func _on_tool_changed(p_tool: Terrain3DEditor.Tool, p_operation: Terrain3DEditor.Operation) -> void:
+ if p_tool == Terrain3DEditor.INSTANCER:
+ _on_meshes_pressed()
+ elif p_tool in [ Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS ]:
+ _on_textures_pressed()
+
+
+## Update Dock Contents
+
+
+func update_assets() -> void:
+ if not _initialized:
+ return
+
+ # Verify signals to individual lists
+ if plugin.is_terrain_valid() and plugin.terrain.assets:
+ if not plugin.terrain.assets.textures_changed.is_connected(texture_list.update_asset_list):
+ plugin.terrain.assets.textures_changed.connect(texture_list.update_asset_list)
+ if not plugin.terrain.assets.meshes_changed.is_connected(mesh_list.update_asset_list):
+ plugin.terrain.assets.meshes_changed.connect(mesh_list.update_asset_list)
+
+ _current_list.update_asset_list()
+
+
+## Window Management
+
+
+func make_dock_float() -> void:
+ # If not already created (eg from editor panel 'Make Floating' button)
+ if not window:
+ remove_dock()
+ create_window()
+
+ state = WINDOWED
+ visible = true # Asset dock contents are hidden when popping out of the bottom!
+ pinned_btn.visible = true
+ floating_btn.visible = false
+ placement_opt.visible = false
+ window.title = "Terrain3D Asset Dock"
+ window.always_on_top = pinned_btn.button_pressed
+ window.close_requested.connect(remove_dock.bind(true))
+ window.window_input.connect(_on_window_input)
+ window.focus_exited.connect(save_editor_settings)
+ window.mouse_exited.connect(save_editor_settings)
+ window.size_changed.connect(save_editor_settings)
+ plugin.godot_editor_window.mouse_entered.connect(_on_godot_window_entered)
+ plugin.godot_editor_window.focus_entered.connect(_on_godot_focus_entered)
+ plugin.godot_editor_window.focus_exited.connect(_on_godot_focus_exited)
+ plugin.godot_editor_window.grab_focus()
+ update_assets()
+ save_editor_settings()
+
+
+func create_window() -> void:
+ window = Window.new()
+ window.wrap_controls = true
+ var mc := MarginContainer.new()
+ mc.set_anchors_preset(PRESET_FULL_RECT, false)
+ mc.add_child(self)
+ window.add_child(mc)
+ window.set_transient(false)
+ window.set_size(plugin.get_setting(ES_DOCK_WINDOW_SIZE, Vector2i(512, 512)))
+ window.set_position(plugin.get_setting(ES_DOCK_WINDOW_POSITION, Vector2i(704, 284)))
+ plugin.add_child(window)
+ window.show()
+
+
+func clamp_window_position() -> void:
+ if window and window.visible:
+ var bounds: Vector2i
+ if EditorInterface.get_editor_settings().get_setting("interface/editor/single_window_mode"):
+ bounds = EditorInterface.get_base_control().size
+ else:
+ bounds = DisplayServer.screen_get_position(window.current_screen)
+ bounds += DisplayServer.screen_get_size(window.current_screen)
+ var margin: int = 40
+ window.position.x = clamp(window.position.x, -window.size.x + 2*margin, bounds.x - margin)
+ window.position.y = clamp(window.position.y, 25, bounds.y - margin)
+
+
+func _on_window_input(event: InputEvent) -> void:
+ # Capture CTRL+S when doc focused to save scene
+ if event is InputEventKey and event.keycode == KEY_S and event.pressed and event.is_command_or_control_pressed():
+ save_editor_settings()
+ EditorInterface.save_scene()
+
+
+func _on_godot_window_entered() -> void:
+ if is_instance_valid(window) and window.has_focus():
+ plugin.godot_editor_window.grab_focus()
+
+
+func _on_godot_focus_entered() -> void:
+ # If asset dock is windowed, and Godot was minimized, and now is not, restore asset dock window
+ if is_instance_valid(window):
+ if _godot_last_state == Window.MODE_MINIMIZED and plugin.godot_editor_window.mode != Window.MODE_MINIMIZED:
+ window.show()
+ _godot_last_state = plugin.godot_editor_window.mode
+ plugin.godot_editor_window.grab_focus()
+
+
+func _on_godot_focus_exited() -> void:
+ if is_instance_valid(window) and plugin.godot_editor_window.mode == Window.MODE_MINIMIZED:
+ window.hide()
+ _godot_last_state = plugin.godot_editor_window.mode
+
+
+## Manage Editor Settings
+
+func load_editor_settings() -> void:
+ floating_btn.button_pressed = plugin.get_setting(ES_DOCK_FLOATING, false)
+ pinned_btn.button_pressed = plugin.get_setting(ES_DOCK_PINNED, true)
+ size_slider.value = plugin.get_setting(ES_DOCK_TILE_SIZE, 83)
+ _on_slider_changed(size_slider.value)
+ set_slot(plugin.get_setting(ES_DOCK_SLOT, POS_BOTTOM))
+ if floating_btn.button_pressed:
+ make_dock_float()
+ # TODO Don't save tab until thumbnail generation more reliable
+ #if plugin.get_setting(ES_DOCK_TAB, 0) == 1:
+ # _on_meshes_pressed()
+
+
+func save_editor_settings() -> void:
+ if not _initialized:
+ return
+ clamp_window_position()
+ plugin.set_setting(ES_DOCK_SLOT, slot)
+ plugin.set_setting(ES_DOCK_TILE_SIZE, size_slider.value)
+ plugin.set_setting(ES_DOCK_FLOATING, floating_btn.button_pressed)
+ plugin.set_setting(ES_DOCK_PINNED, pinned_btn.button_pressed)
+ # TODO Don't save tab until thumbnail generation more reliable
+ # plugin.set_setting(ES_DOCK_TAB, 0 if _current_list == texture_list else 1)
+ if window:
+ plugin.set_setting(ES_DOCK_WINDOW_SIZE, window.size)
+ plugin.set_setting(ES_DOCK_WINDOW_POSITION, window.position)
+
+
+##############################################################
+## class ListContainer
+##############################################################
+
+
+class ListContainer extends Container:
+ var plugin: EditorPlugin
+ var type := Terrain3DAssets.TYPE_TEXTURE
+ var entries: Array[ListEntry]
+ var selected_id: int = 0
+ var height: float = 0
+ var width: float = 83
+ var focus_style: StyleBox
+
+
+ func _ready() -> void:
+ set_v_size_flags(SIZE_EXPAND_FILL)
+ set_h_size_flags(SIZE_EXPAND_FILL)
+
+
+ func clear() -> void:
+ for e in entries:
+ e.get_parent().remove_child(e)
+ e.queue_free()
+ entries.clear()
+
+
+ func update_asset_list() -> void:
+ clear()
+
+ # Grab terrain
+ var t: Terrain3D
+ if plugin.is_terrain_valid():
+ t = plugin.terrain
+ elif is_instance_valid(plugin._last_terrain) and plugin.is_terrain_valid(plugin._last_terrain):
+ t = plugin._last_terrain
+ else:
+ return
+
+ if not t.assets:
+ return
+
+ if type == Terrain3DAssets.TYPE_TEXTURE:
+ var texture_count: int = t.assets.get_texture_count()
+ for i in texture_count:
+ var texture: Terrain3DTextureAsset = t.assets.get_texture(i)
+ add_item(texture)
+ if texture_count < Terrain3DAssets.MAX_TEXTURES:
+ add_item()
+ else:
+ var mesh_count: int = t.assets.get_mesh_count()
+ for i in mesh_count:
+ var mesh: Terrain3DMeshAsset = t.assets.get_mesh_asset(i)
+ add_item(mesh, t.assets)
+ if mesh_count < Terrain3DAssets.MAX_MESHES:
+ add_item()
+ if selected_id >= mesh_count or selected_id < 0:
+ set_selected_id(0)
+
+
+ func add_item(p_resource: Resource = null, p_assets: Terrain3DAssets = null) -> void:
+ var entry: ListEntry = ListEntry.new()
+ entry.focus_style = focus_style
+ var id: int = entries.size()
+
+ entry.set_edited_resource(p_resource)
+ entry.hovered.connect(_on_resource_hovered.bind(id))
+ entry.selected.connect(set_selected_id.bind(id))
+ entry.inspected.connect(_on_resource_inspected)
+ entry.changed.connect(_on_resource_changed.bind(id))
+ entry.type = type
+ entry.asset_list = p_assets
+ add_child(entry)
+ entries.push_back(entry)
+
+ if p_resource:
+ entry.set_selected(id == selected_id)
+ if not p_resource.id_changed.is_connected(set_selected_after_swap):
+ p_resource.id_changed.connect(set_selected_after_swap)
+
+
+ func _on_resource_hovered(p_id: int):
+ if type == Terrain3DAssets.TYPE_MESH:
+ if plugin.terrain:
+ plugin.terrain.assets.create_mesh_thumbnails(p_id)
+
+
+ func set_selected_after_swap(p_type: Terrain3DAssets.AssetType, p_old_id: int, p_new_id: int) -> void:
+ set_selected_id(clamp(p_new_id, 0, entries.size() - 2))
+
+
+ func set_selected_id(p_id: int) -> void:
+ selected_id = p_id
+
+ for i in entries.size():
+ var entry: ListEntry = entries[i]
+ entry.set_selected(i == selected_id)
+
+ plugin.select_terrain()
+
+ # Select Paint tool if clicking a texture
+ if type == Terrain3DAssets.TYPE_TEXTURE and \
+ not plugin.editor.get_tool() in [ Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS ]:
+ var paint_btn: Button = plugin.ui.toolbar.get_node_or_null("PaintBaseTexture")
+ if paint_btn:
+ paint_btn.set_pressed(true)
+ plugin.ui._on_tool_changed(Terrain3DEditor.TEXTURE, Terrain3DEditor.REPLACE)
+
+ elif type == Terrain3DAssets.TYPE_MESH and plugin.editor.get_tool() != Terrain3DEditor.INSTANCER:
+ var instancer_btn: Button = plugin.ui.toolbar.get_node_or_null("InstanceMeshes")
+ if instancer_btn:
+ instancer_btn.set_pressed(true)
+ plugin.ui._on_tool_changed(Terrain3DEditor.INSTANCER, Terrain3DEditor.ADD)
+
+ # Update editor with selected brush
+ plugin.ui._on_setting_changed()
+
+
+ func _on_resource_inspected(p_resource: Resource) -> void:
+ await get_tree().create_timer(.01).timeout
+ EditorInterface.edit_resource(p_resource)
+
+
+ func _on_resource_changed(p_resource: Resource, p_id: int) -> void:
+ if not p_resource:
+ var asset_dock: Control = get_parent().get_parent().get_parent()
+ if type == Terrain3DAssets.TYPE_TEXTURE:
+ asset_dock.confirm_dialog.dialog_text = "Are you sure you want to clear this texture?"
+ else:
+ asset_dock.confirm_dialog.dialog_text = "Are you sure you want to clear this mesh and delete all instances?"
+ asset_dock.confirm_dialog.popup_centered()
+ await asset_dock.confirmation_closed
+ if not asset_dock._confirmed:
+ update_asset_list()
+ return
+
+ if not plugin.is_terrain_valid():
+ plugin.select_terrain()
+ await get_tree().create_timer(.01).timeout
+
+ if plugin.is_terrain_valid():
+ if type == Terrain3DAssets.TYPE_TEXTURE:
+ plugin.terrain.get_assets().set_texture(p_id, p_resource)
+ else:
+ plugin.terrain.get_assets().set_mesh_asset(p_id, p_resource)
+ await get_tree().create_timer(.01).timeout
+ plugin.terrain.assets.create_mesh_thumbnails(p_id)
+
+ # If removing an entry, clear inspector
+ if not p_resource:
+ EditorInterface.inspect_object(null)
+
+ # If null resource, remove last
+ if not p_resource:
+ var last_offset: int = 2
+ if p_id == entries.size()-2:
+ last_offset = 3
+ set_selected_id(clamp(selected_id, 0, entries.size() - last_offset))
+
+
+ func get_selected_id() -> int:
+ return selected_id
+
+
+ func set_entry_width(value: float) -> void:
+ width = clamp(value, 66, 230)
+ redraw()
+
+
+ func get_entry_width() -> float:
+ return width
+
+
+ func redraw() -> void:
+ height = 0
+ var id: int = 0
+ var separation: float = 4
+ var columns: int = 3
+ columns = clamp(size.x / width, 1, 100)
+
+ for c in get_children():
+ if is_instance_valid(c):
+ c.size = Vector2(width, width) - Vector2(separation, separation)
+ c.position = Vector2(id % columns, id / columns) * width + \
+ Vector2(separation / columns, separation / columns)
+ height = max(height, c.position.y + width)
+ id += 1
+
+
+ # Needed to enable ScrollContainer scroll bar
+ func _get_minimum_size() -> Vector2:
+ return Vector2(0, height)
+
+
+ func _notification(p_what) -> void:
+ if p_what == NOTIFICATION_SORT_CHILDREN:
+ redraw()
+
+
+##############################################################
+## class ListEntry
+##############################################################
+
+
+class ListEntry extends VBoxContainer:
+ signal hovered()
+ signal selected()
+ signal changed(resource: Resource)
+ signal inspected(resource: Resource)
+
+ var resource: Resource
+ var type := Terrain3DAssets.TYPE_TEXTURE
+ var _thumbnail: Texture2D
+ var drop_data: bool = false
+ var is_hovered: bool = false
+ var is_selected: bool = false
+ var asset_list: Terrain3DAssets
+
+ @onready var button_row := HBoxContainer.new()
+ @onready var button_clear := TextureButton.new()
+ @onready var button_edit := TextureButton.new()
+ @onready var spacer := Control.new()
+ @onready var button_enabled := TextureButton.new()
+ @onready var clear_icon: Texture2D = get_theme_icon("Close", "EditorIcons")
+ @onready var edit_icon: Texture2D = get_theme_icon("Edit", "EditorIcons")
+ @onready var enabled_icon: Texture2D = get_theme_icon("GuiVisibilityVisible", "EditorIcons")
+ @onready var disabled_icon: Texture2D = get_theme_icon("GuiVisibilityHidden", "EditorIcons")
+
+ var name_label: Label
+ @onready var add_icon: Texture2D = get_theme_icon("Add", "EditorIcons")
+ @onready var background: StyleBox = get_theme_stylebox("pressed", "Button")
+ @onready var focus_style: StyleBox = get_theme_stylebox("focus", "Button").duplicate()
+
+
+ func _ready() -> void:
+ setup_buttons()
+ setup_label()
+ focus_style.set_border_width_all(2)
+ focus_style.set_border_color(Color(1, 1, 1, .67))
+
+
+ func setup_buttons() -> void:
+ var icon_size: Vector2 = Vector2(12, 12)
+ var margin_container := MarginContainer.new()
+ margin_container.mouse_filter = Control.MOUSE_FILTER_PASS
+ margin_container.add_theme_constant_override("margin_top", 5)
+ margin_container.add_theme_constant_override("margin_left", 5)
+ margin_container.add_theme_constant_override("margin_right", 5)
+ add_child(margin_container)
+
+ button_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ button_row.alignment = BoxContainer.ALIGNMENT_CENTER
+ button_row.mouse_filter = Control.MOUSE_FILTER_PASS
+ margin_container.add_child(button_row)
+
+ if type == Terrain3DAssets.TYPE_MESH:
+ button_enabled.set_texture_normal(enabled_icon)
+ button_enabled.set_texture_pressed(disabled_icon)
+ button_enabled.set_custom_minimum_size(icon_size)
+ button_enabled.set_h_size_flags(Control.SIZE_SHRINK_END)
+ button_enabled.set_visible(resource != null)
+ button_enabled.toggle_mode = true
+ button_enabled.mouse_filter = Control.MOUSE_FILTER_PASS
+ button_enabled.pressed.connect(enable)
+ button_row.add_child(button_enabled)
+
+ button_edit.set_texture_normal(edit_icon)
+ button_edit.set_custom_minimum_size(icon_size)
+ button_edit.set_h_size_flags(Control.SIZE_SHRINK_END)
+ button_edit.set_visible(resource != null)
+ button_edit.mouse_filter = Control.MOUSE_FILTER_PASS
+ button_edit.pressed.connect(edit)
+ button_row.add_child(button_edit)
+
+ spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ spacer.mouse_filter = Control.MOUSE_FILTER_PASS
+ button_row.add_child(spacer)
+
+ button_clear.set_texture_normal(clear_icon)
+ button_clear.set_custom_minimum_size(icon_size)
+ button_clear.set_h_size_flags(Control.SIZE_SHRINK_END)
+ button_clear.set_visible(resource != null)
+ button_clear.mouse_filter = Control.MOUSE_FILTER_PASS
+ button_clear.pressed.connect(clear)
+ button_row.add_child(button_clear)
+
+
+ func setup_label() -> void:
+ name_label = Label.new()
+ add_child(name_label, true)
+ name_label.visible = false
+ name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ name_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
+ name_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ name_label.add_theme_color_override("font_color", Color.WHITE)
+ name_label.add_theme_color_override("font_shadow_color", Color.BLACK)
+ name_label.add_theme_constant_override("shadow_offset_x", 1.)
+ name_label.add_theme_constant_override("shadow_offset_y", 1.)
+ name_label.add_theme_font_size_override("font_size", 15)
+ name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
+ if type == Terrain3DAssets.TYPE_TEXTURE:
+ name_label.text = "Add Texture"
+ else:
+ name_label.text = "Add Mesh"
+
+
+ func _notification(p_what) -> void:
+ match p_what:
+ NOTIFICATION_DRAW:
+ # Hide spacer if icons are crowding small textures
+ spacer.visible = size.x > 70 or type == Terrain3DAssets.TYPE_TEXTURE
+
+ var rect: Rect2 = Rect2(Vector2.ZERO, get_size())
+ if !resource:
+ draw_style_box(background, rect)
+ draw_texture(add_icon, (get_size() / 2) - (add_icon.get_size() / 2))
+ else:
+ if type == Terrain3DAssets.TYPE_TEXTURE:
+ name_label.text = (resource as Terrain3DTextureAsset).get_name()
+ self_modulate = resource.get_albedo_color()
+ _thumbnail = resource.get_albedo_texture()
+ if _thumbnail:
+ draw_texture_rect(_thumbnail, rect, false)
+ texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST_WITH_MIPMAPS
+ else:
+ name_label.text = (resource as Terrain3DMeshAsset).get_name()
+ var id: int = (resource as Terrain3DMeshAsset).get_id()
+ _thumbnail = resource.get_thumbnail()
+ if _thumbnail:
+ draw_texture_rect(_thumbnail, rect, false)
+ texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
+ else:
+ draw_rect(rect, Color(.15, .15, .15, 1.))
+ button_enabled.set_pressed_no_signal(!resource.is_enabled())
+ name_label.add_theme_font_size_override("font_size", 4 + rect.size.x/10)
+ if drop_data:
+ draw_style_box(focus_style, rect)
+ if is_hovered:
+ draw_rect(rect, Color(1, 1, 1, 0.2))
+ if is_selected:
+ draw_style_box(focus_style, rect)
+ NOTIFICATION_MOUSE_ENTER:
+ is_hovered = true
+ name_label.visible = true
+ emit_signal("hovered")
+ queue_redraw()
+ NOTIFICATION_MOUSE_EXIT:
+ is_hovered = false
+ name_label.visible = false
+ drop_data = false
+ queue_redraw()
+
+
+ func _gui_input(p_event: InputEvent) -> void:
+ if p_event is InputEventMouseButton:
+ if p_event.is_pressed():
+ match p_event.get_button_index():
+ MOUSE_BUTTON_LEFT:
+ # If `Add new` is clicked
+ if !resource:
+ if type == Terrain3DAssets.TYPE_TEXTURE:
+ set_edited_resource(Terrain3DTextureAsset.new(), false)
+ else:
+ set_edited_resource(Terrain3DMeshAsset.new(), false)
+ edit()
+ else:
+ emit_signal("selected")
+ MOUSE_BUTTON_RIGHT:
+ if resource:
+ edit()
+ MOUSE_BUTTON_MIDDLE:
+ if resource:
+ clear()
+
+
+ func _can_drop_data(p_at_position: Vector2, p_data: Variant) -> bool:
+ drop_data = false
+ if typeof(p_data) == TYPE_DICTIONARY:
+ if p_data.files.size() == 1:
+ queue_redraw()
+ drop_data = true
+ return drop_data
+
+
+ func _drop_data(p_at_position: Vector2, p_data: Variant) -> void:
+ if typeof(p_data) == TYPE_DICTIONARY:
+ var res: Resource = load(p_data.files[0])
+ if res is Texture2D and type == Terrain3DAssets.TYPE_TEXTURE:
+ var ta := Terrain3DTextureAsset.new()
+ if resource is Terrain3DTextureAsset:
+ ta.id = resource.id
+ ta.set_albedo_texture(res)
+ set_edited_resource(ta, false)
+ resource = ta
+ elif res is Terrain3DTextureAsset and type == Terrain3DAssets.TYPE_TEXTURE:
+ if resource is Terrain3DTextureAsset:
+ res.id = resource.id
+ set_edited_resource(res, false)
+ elif res is PackedScene and type == Terrain3DAssets.TYPE_MESH:
+ var ma := Terrain3DMeshAsset.new()
+ if resource is Terrain3DMeshAsset:
+ ma.id = resource.id
+ set_edited_resource(ma, false)
+ ma.set_scene_file(res)
+ resource = ma
+ elif res is Terrain3DMeshAsset and type == Terrain3DAssets.TYPE_MESH:
+ if resource is Terrain3DMeshAsset:
+ res.id = resource.id
+ set_edited_resource(res, false)
+ emit_signal("selected")
+ emit_signal("inspected", resource)
+
+
+
+ func set_edited_resource(p_res: Resource, p_no_signal: bool = true) -> void:
+ resource = p_res
+ if resource:
+ resource.setting_changed.connect(_on_resource_changed)
+ resource.file_changed.connect(_on_resource_changed)
+ if resource is Terrain3DMeshAsset:
+ resource.instancer_setting_changed.connect(_on_resource_changed)
+
+ if button_clear:
+ button_clear.set_visible(resource != null)
+
+ queue_redraw()
+ if !p_no_signal:
+ emit_signal("changed", resource)
+
+
+ func _on_resource_changed() -> void:
+ queue_redraw()
+ emit_signal("changed", resource)
+
+
+ func set_selected(value: bool) -> void:
+ is_selected = value
+ queue_redraw()
+
+
+ func clear() -> void:
+ if resource:
+ set_edited_resource(null, false)
+
+
+ func edit() -> void:
+ emit_signal("selected")
+ emit_signal("inspected", resource)
+
+
+ func enable() -> void:
+ if resource is Terrain3DMeshAsset:
+ resource.set_enabled(!resource.is_enabled())
diff --git a/addons/terrain_3d/src/asset_dock.gd.uid b/addons/terrain_3d/src/asset_dock.gd.uid
new file mode 100644
index 0000000..8c39f29
--- /dev/null
+++ b/addons/terrain_3d/src/asset_dock.gd.uid
@@ -0,0 +1 @@
+uid://bgoifepft1hjw
diff --git a/addons/terrain_3d/src/asset_dock.tscn b/addons/terrain_3d/src/asset_dock.tscn
new file mode 100644
index 0000000..f88c8c0
--- /dev/null
+++ b/addons/terrain_3d/src/asset_dock.tscn
@@ -0,0 +1,92 @@
+[gd_scene load_steps=2 format=3 uid="uid://dkb6hii5e48m2"]
+
+[ext_resource type="Script" path="res://addons/terrain_3d/src/asset_dock.gd" id="1_e23pg"]
+
+[node name="Terrain3D" type="PanelContainer"]
+custom_minimum_size = Vector2(256, 95)
+offset_right = 766.0
+offset_bottom = 100.0
+script = ExtResource("1_e23pg")
+
+[node name="Box" type="BoxContainer" parent="."]
+layout_mode = 2
+size_flags_vertical = 3
+vertical = true
+
+[node name="Buttons" type="BoxContainer" parent="Box"]
+layout_mode = 2
+
+[node name="TexturesBtn" type="Button" parent="Box/Buttons"]
+custom_minimum_size = Vector2(80, 30)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 0
+theme_override_font_sizes/font_size = 16
+toggle_mode = true
+button_pressed = true
+text = "Textures"
+
+[node name="MeshesBtn" type="Button" parent="Box/Buttons"]
+custom_minimum_size = Vector2(80, 30)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 0
+theme_override_font_sizes/font_size = 16
+toggle_mode = true
+text = "Meshes"
+
+[node name="PlacementOpt" type="OptionButton" parent="Box/Buttons"]
+custom_minimum_size = Vector2(80, 30)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 0
+selected = 7
+item_count = 9
+popup/item_0/text = "Left_UL"
+popup/item_1/text = "Left_BL"
+popup/item_1/id = 1
+popup/item_2/text = "Left_UR"
+popup/item_2/id = 2
+popup/item_3/text = "Left_BR"
+popup/item_3/id = 3
+popup/item_4/text = "Right_UL"
+popup/item_4/id = 4
+popup/item_5/text = "Right_BL "
+popup/item_5/id = 5
+popup/item_6/text = "Right_UR"
+popup/item_6/id = 6
+popup/item_7/text = "Right_BR"
+popup/item_7/id = 7
+popup/item_8/text = "Bottom"
+popup/item_8/id = 8
+
+[node name="SizeSlider" type="HSlider" parent="Box/Buttons"]
+custom_minimum_size = Vector2(80, 10)
+layout_mode = 2
+size_flags_horizontal = 3
+min_value = 66.0
+max_value = 230.0
+value = 83.0
+
+[node name="Floating" type="Button" parent="Box/Buttons"]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 0
+tooltip_text = "Pop this dock out to a floating window."
+toggle_mode = true
+text = "F"
+flat = true
+
+[node name="Pinned" type="Button" parent="Box/Buttons"]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 0
+tooltip_text = "Make this window \"Always on top\"."
+toggle_mode = true
+text = "P"
+flat = true
+
+[node name="ScrollContainer" type="ScrollContainer" parent="Box"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
diff --git a/addons/terrain_3d/src/double_slider.gd b/addons/terrain_3d/src/double_slider.gd
new file mode 100644
index 0000000..8119456
--- /dev/null
+++ b/addons/terrain_3d/src/double_slider.gd
@@ -0,0 +1,164 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# DoubleSlider for Terrain3D
+# Should work for other UIs
+@tool
+class_name DoubleSlider
+extends Control
+
+signal value_changed(Vector2)
+var label: Label
+var suffix: String
+var grabbed_handle: int = 0 # -1 left, 0 none, 1 right
+var min_value: float = 0.0
+var max_value: float = 100.0
+var step: float = 1.0
+var range := Vector2(0, 100)
+var display_scale: float = 1.
+var position_x: float = 0.
+var minimum_x: float = 60.
+
+
+func _ready() -> void:
+ # Setup Display Scale
+ # 0 auto, 1 75%, 2 100%, 3 125%, 4 150%, 5 175%, 6 200%, 7 custom
+ var es: EditorSettings = EditorInterface.get_editor_settings()
+ var ds: int = es.get_setting("interface/editor/display_scale")
+ if ds == 0:
+ ds = 2
+ elif ds == 7:
+ display_scale = es.get_setting("interface/editor/custom_display_scale")
+ else:
+ display_scale = float(ds + 2) * .25
+
+ update_label()
+
+
+func set_min(p_value: float) -> void:
+ min_value = p_value
+ if range.x <= min_value:
+ range.x = min_value
+ set_value(range)
+ update_label()
+
+
+func get_min() -> float:
+ return min_value
+
+
+func set_max(p_value: float) -> void:
+ max_value = p_value
+ if range.y == 0 or range.y >= max_value:
+ range.y = max_value
+ set_value(range)
+ update_label()
+
+
+func get_max() -> float:
+ return max_value
+
+
+func set_step(p_step: float) -> void:
+ step = p_step
+
+
+func get_step() -> float:
+ return step
+
+
+func set_value(p_range: Vector2) -> void:
+ range.x = clamp(p_range.x, min_value, max_value)
+ range.y = clamp(p_range.y, min_value, max_value)
+ if range.y < range.x:
+ var tmp: float = range.x
+ range.x = range.y
+ range.y = tmp
+
+ update_label()
+ emit_signal("value_changed", Vector2(range.x, range.y))
+ queue_redraw()
+
+
+func get_value() -> Vector2:
+ return range
+
+
+func update_label() -> void:
+ if label:
+ label.set_text(str(range.x) + suffix + "/" + str(range.y) + suffix)
+ if position_x == 0:
+ position_x = label.position.x
+ else:
+ label.position.x = position_x + 5 * display_scale
+ label.custom_minimum_size.x = minimum_x + 5 * display_scale
+
+
+func _get_handle() -> int:
+ return 1
+
+
+func _gui_input(p_event: InputEvent) -> void:
+ if p_event is InputEventMouseButton:
+ var button: int = p_event.get_button_index()
+ if button in [ MOUSE_BUTTON_LEFT, MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN ]:
+ if p_event.is_pressed():
+ var mid_point = (range.x + range.y) / 2.0
+ var xpos: float = p_event.get_position().x * 2.0
+ if xpos >= mid_point:
+ grabbed_handle = 1
+ else:
+ grabbed_handle = -1
+ match button:
+ MOUSE_BUTTON_LEFT:
+ set_slider(p_event.get_position().x)
+ MOUSE_BUTTON_WHEEL_DOWN:
+ set_slider(-1., true)
+ MOUSE_BUTTON_WHEEL_UP:
+ set_slider(1., true)
+ else:
+ grabbed_handle = 0
+
+ if p_event is InputEventMouseMotion:
+ if grabbed_handle != 0:
+ set_slider(p_event.get_position().x)
+
+
+func set_slider(p_xpos: float, p_relative: bool = false) -> void:
+ if grabbed_handle == 0:
+ return
+ var xpos_step: float = clamp(snappedf((p_xpos / size.x) * max_value, step), min_value, max_value)
+ if(grabbed_handle < 0):
+ if p_relative:
+ range.x += p_xpos
+ else:
+ range.x = xpos_step
+ else:
+ if p_relative:
+ range.y += p_xpos
+ else:
+ range.y = xpos_step
+ set_value(range)
+
+
+func _notification(p_what: int) -> void:
+ if p_what == NOTIFICATION_DRAW:
+ # Draw background bar
+ var bg: StyleBox = get_theme_stylebox("slider", "HSlider")
+ var bg_height: float = bg.get_minimum_size().y
+ var mid_y: float = (size.y - bg_height) / 2.0
+ draw_style_box(bg, Rect2(Vector2(0, mid_y), Vector2(size.x, bg_height)))
+
+ # Draw foreground bar
+ var handle: Texture2D = get_theme_icon("grabber", "HSlider")
+ var area: StyleBox = get_theme_stylebox("grabber_area", "HSlider")
+ var startx: float = (range.x / max_value) * size.x
+ var endx: float = (range.y / max_value) * size.x
+ draw_style_box(area, Rect2(Vector2(startx, mid_y), Vector2(endx - startx, bg_height)))
+
+ # Draw handles, slightly in so they don't get on the outside edges
+ var handle_pos: Vector2
+ handle_pos.x = clamp(startx - handle.get_size().x/2, -10, size.x)
+ handle_pos.y = clamp(endx - handle.get_size().x/2, 0, size.x - 10)
+ draw_texture(handle, Vector2(handle_pos.x, -mid_y - 10 * (display_scale - 1.)))
+ draw_texture(handle, Vector2(handle_pos.y, -mid_y - 10 * (display_scale - 1.)))
+
+ update_label()
diff --git a/addons/terrain_3d/src/double_slider.gd.uid b/addons/terrain_3d/src/double_slider.gd.uid
new file mode 100644
index 0000000..1e73752
--- /dev/null
+++ b/addons/terrain_3d/src/double_slider.gd.uid
@@ -0,0 +1 @@
+uid://stro0p1oawfb
diff --git a/addons/terrain_3d/src/editor_plugin.gd b/addons/terrain_3d/src/editor_plugin.gd
new file mode 100644
index 0000000..e1934cc
--- /dev/null
+++ b/addons/terrain_3d/src/editor_plugin.gd
@@ -0,0 +1,397 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Editor Plugin for Terrain3D
+@tool
+extends EditorPlugin
+
+
+# Includes
+const UI: Script = preload("res://addons/terrain_3d/src/ui.gd")
+const RegionGizmo: Script = preload("res://addons/terrain_3d/src/region_gizmo.gd")
+const ASSET_DOCK: String = "res://addons/terrain_3d/src/asset_dock.tscn"
+
+var modifier_ctrl: bool
+var modifier_alt: bool
+var modifier_shift: bool
+var _last_modifiers: int = 0
+var _input_mode: int = 0 # -1: camera move, 0: none, 1: operating
+var _use_meta: bool = false
+
+var terrain: Terrain3D
+var _last_terrain: Terrain3D
+var nav_region: NavigationRegion3D
+
+var editor: Terrain3DEditor
+var editor_settings: EditorSettings
+var ui: Node # Terrain3DUI see Godot #75388
+var asset_dock: PanelContainer
+var region_gizmo: RegionGizmo
+var current_region_position: Vector2
+var mouse_global_position: Vector3 = Vector3.ZERO
+var godot_editor_window: Window # The Godot Editor window
+
+
+func _init() -> void:
+ if OS.get_name() == "macOS":
+ _use_meta = true
+
+ # Get the Godot Editor window. Structure is root:Window/EditorNode/Base Control
+ godot_editor_window = EditorInterface.get_base_control().get_parent().get_parent()
+ godot_editor_window.focus_entered.connect(_on_godot_focus_entered)
+
+
+func _enter_tree() -> void:
+ editor = Terrain3DEditor.new()
+ setup_editor_settings()
+ ui = UI.new()
+ ui.plugin = self
+ add_child(ui)
+
+ region_gizmo = RegionGizmo.new()
+
+ scene_changed.connect(_on_scene_changed)
+
+ asset_dock = load(ASSET_DOCK).instantiate()
+ asset_dock.initialize(self)
+
+
+func _exit_tree() -> void:
+ asset_dock.remove_dock(true)
+ asset_dock.queue_free()
+ ui.queue_free()
+ editor.free()
+
+ scene_changed.disconnect(_on_scene_changed)
+ godot_editor_window.focus_entered.disconnect(_on_godot_focus_entered)
+
+
+func _on_godot_focus_entered() -> void:
+ _read_input()
+ ui.update_decal()
+
+
+## EditorPlugin selection function call chain isn't consistent. Here's the map of calls:
+## Assume we handle Terrain3D and NavigationRegion3D
+# Click Terrain3D: _handles(Terrain3D), _make_visible(true), _edit(Terrain3D)
+# Deselect: _make_visible(false), _edit(null)
+# Click other node: _handles(OtherNode)
+# Click NavRegion3D: _handles(NavReg3D), _make_visible(true), _edit(NavReg3D)
+# Click NavRegion3D, Terrain3D: _handles(Terrain3D), _edit(Terrain3D)
+# Click Terrain3D, NavRegion3D: _handles(NavReg3D), _edit(NavReg3D)
+func _handles(p_object: Object) -> bool:
+ if p_object is Terrain3D:
+ return true
+ elif p_object is NavigationRegion3D and is_instance_valid(_last_terrain):
+ return true
+
+ # Terrain3DObjects requires access to EditorUndoRedoManager. The only way to make sure it
+ # always has it, is to pass it in here. _edit is NOT called if the node is cut and pasted.
+ elif p_object is Terrain3DObjects:
+ p_object.editor_setup(self)
+ elif p_object is Node3D and p_object.get_parent() is Terrain3DObjects:
+ p_object.get_parent().editor_setup(self)
+
+ return false
+
+
+func _make_visible(p_visible: bool, p_redraw: bool = false) -> void:
+ if p_visible and is_selected():
+ ui.set_visible(true)
+ asset_dock.update_dock()
+ else:
+ ui.set_visible(false)
+
+
+func _edit(p_object: Object) -> void:
+ if !p_object:
+ _clear()
+
+ if p_object is Terrain3D:
+ if p_object == terrain:
+ return
+ terrain = p_object
+ _last_terrain = terrain
+ terrain.set_plugin(self)
+ terrain.set_editor(editor)
+ editor.set_terrain(terrain)
+ region_gizmo.set_node_3d(terrain)
+ terrain.add_gizmo(region_gizmo)
+ ui.set_visible(true)
+ terrain.set_meta("_edit_lock_", true)
+
+ # Get alerted when a new asset list is loaded
+ if not terrain.assets_changed.is_connected(asset_dock.update_assets):
+ terrain.assets_changed.connect(asset_dock.update_assets)
+ asset_dock.update_assets()
+ # Get alerted when the region map changes
+ if not terrain.data.region_map_changed.is_connected(update_region_grid):
+ terrain.data.region_map_changed.connect(update_region_grid)
+ update_region_grid()
+ else:
+ _clear()
+
+ if is_terrain_valid(_last_terrain):
+ if p_object is NavigationRegion3D:
+ ui.set_visible(true, true)
+ nav_region = p_object
+ else:
+ nav_region = null
+
+
+func _clear() -> void:
+ if is_terrain_valid():
+ if terrain.data.region_map_changed.is_connected(update_region_grid):
+ terrain.data.region_map_changed.disconnect(update_region_grid)
+
+ terrain.clear_gizmos()
+ terrain = null
+ editor.set_terrain(null)
+
+ ui.clear_picking()
+
+ region_gizmo.clear()
+
+
+func _forward_3d_gui_input(p_viewport_camera: Camera3D, p_event: InputEvent) -> int:
+ if not is_terrain_valid():
+ return AFTER_GUI_INPUT_PASS
+
+ _read_input(p_event)
+ ui.update_decal()
+
+ ## Setup active camera & viewport
+ # Always update this for all inputs, as the mouse position can move without
+ # necessarily being a InputEventMouseMotion object. get_intersection() also
+ # returns the last frame position, and should be updated more frequently.
+
+ # Snap terrain to current camera
+ terrain.set_camera(p_viewport_camera)
+
+ # Detect if viewport is set to half_resolution
+ # Structure is: Node3DEditorViewportContainer/Node3DEditorViewport(4)/SubViewportContainer/SubViewport/Camera3D
+ var editor_vpc: SubViewportContainer = p_viewport_camera.get_parent().get_parent()
+ var full_resolution: bool = false if editor_vpc.stretch_shrink == 2 else true
+
+ ## Get mouse location on terrain
+ # Project 2D mouse position to 3D position and direction
+ var vp_mouse_pos: Vector2 = editor_vpc.get_local_mouse_position()
+ var mouse_pos: Vector2 = vp_mouse_pos if full_resolution else vp_mouse_pos / 2
+ var camera_pos: Vector3 = p_viewport_camera.project_ray_origin(mouse_pos)
+ var camera_dir: Vector3 = p_viewport_camera.project_ray_normal(mouse_pos)
+
+ # If region tool, grab mouse position without considering height
+ if editor.get_tool() == Terrain3DEditor.REGION:
+ var t = -Vector3(0, 1, 0).dot(camera_pos) / Vector3(0, 1, 0).dot(camera_dir)
+ mouse_global_position = (camera_pos + t * camera_dir)
+ else:
+ #Else look for intersection with terrain
+ var intersection_point: Vector3 = terrain.get_intersection(camera_pos, camera_dir, true)
+ if intersection_point.z > 3.4e38 or is_nan(intersection_point.y): # max double or nan
+ return AFTER_GUI_INPUT_PASS
+ mouse_global_position = intersection_point
+
+ ## Handle mouse movement
+ if p_event is InputEventMouseMotion:
+
+ if _input_mode != -1: # Not cam rotation
+ ## Update region highlight
+ var region_position: Vector2 = ( Vector2(mouse_global_position.x, mouse_global_position.z) \
+ / (terrain.get_region_size() * terrain.get_vertex_spacing()) ).floor()
+ if current_region_position != region_position:
+ current_region_position = region_position
+ update_region_grid()
+
+ if _input_mode > 0 and editor.is_operating():
+ # Inject pressure - Relies on C++ set_brush_data() using same dictionary instance
+ ui.brush_data["mouse_pressure"] = p_event.pressure
+
+ editor.operate(mouse_global_position, p_viewport_camera.rotation.y)
+ return AFTER_GUI_INPUT_STOP
+
+ return AFTER_GUI_INPUT_PASS
+
+ if p_event is InputEventMouseButton and _input_mode > 0:
+ if p_event.is_pressed():
+ # If picking
+ if ui.is_picking():
+ ui.pick(mouse_global_position)
+ if not ui.operation_builder or not ui.operation_builder.is_ready():
+ return AFTER_GUI_INPUT_STOP
+
+ if modifier_ctrl and editor.get_tool() == Terrain3DEditor.HEIGHT:
+ var height: float = terrain.data.get_height(mouse_global_position)
+ ui.brush_data["height"] = height
+ ui.tool_settings.set_setting("height", height)
+
+ # If adjusting regions
+ if editor.get_tool() == Terrain3DEditor.REGION:
+ # Skip regions that already exist or don't
+ var has_region: bool = terrain.data.has_regionp(mouse_global_position)
+ var op: int = editor.get_operation()
+ if ( has_region and op == Terrain3DEditor.ADD) or \
+ ( not has_region and op == Terrain3DEditor.SUBTRACT ):
+ return AFTER_GUI_INPUT_STOP
+
+ # If an automatic operation is ready to go (e.g. gradient)
+ if ui.operation_builder and ui.operation_builder.is_ready():
+ ui.operation_builder.apply_operation(editor, mouse_global_position, p_viewport_camera.rotation.y)
+ return AFTER_GUI_INPUT_STOP
+
+ # Mouse clicked, start editing
+ editor.start_operation(mouse_global_position)
+ editor.operate(mouse_global_position, p_viewport_camera.rotation.y)
+ return AFTER_GUI_INPUT_STOP
+
+ # _input_apply released, save undo data
+ elif editor.is_operating():
+ editor.stop_operation()
+ return AFTER_GUI_INPUT_STOP
+
+ return AFTER_GUI_INPUT_PASS
+
+
+func _read_input(p_event: InputEvent = null) -> void:
+ ## Determine if user is moving camera or applying
+ if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) or \
+ p_event is InputEventMouseButton and p_event.is_released() and \
+ p_event.get_button_index() == MOUSE_BUTTON_LEFT:
+ _input_mode = 1
+ else:
+ _input_mode = 0
+
+ match get_setting("editors/3d/navigation/navigation_scheme", 0):
+ 2, 1: # Modo, Maya
+ if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) or \
+ ( Input.is_key_pressed(KEY_ALT) and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) ):
+ _input_mode = -1
+ if p_event is InputEventMouseButton and p_event.is_released() and \
+ ( p_event.get_button_index() == MOUSE_BUTTON_RIGHT or \
+ ( Input.is_key_pressed(KEY_ALT) and p_event.get_button_index() == MOUSE_BUTTON_LEFT )):
+ ui.last_rmb_time = Time.get_ticks_msec()
+ 0, _: # Godot
+ if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) or \
+ Input.is_mouse_button_pressed(MOUSE_BUTTON_MIDDLE):
+ _input_mode = -1
+ if p_event is InputEventMouseButton and p_event.is_released() and \
+ ( p_event.get_button_index() == MOUSE_BUTTON_RIGHT or \
+ p_event.get_button_index() == MOUSE_BUTTON_MIDDLE ):
+ ui.last_rmb_time = Time.get_ticks_msec()
+ if _input_mode < 0:
+ return
+
+ ## Determine modifiers pressed
+ modifier_shift = Input.is_key_pressed(KEY_SHIFT)
+ modifier_ctrl = Input.is_key_pressed(KEY_META) if _use_meta else Input.is_key_pressed(KEY_CTRL)
+ # Keybind enum: Alt,Space,Meta,Capslock
+ var alt_key: int
+ match get_setting("terrain3d/config/alt_key_bind", 0):
+ 3: alt_key = KEY_CAPSLOCK
+ 2: alt_key = KEY_META
+ 1: alt_key = KEY_SPACE
+ 0, _: alt_key = KEY_ALT
+ modifier_alt = Input.is_key_pressed(alt_key)
+
+ # Return if modifiers haven't changed AND brush_data has them;
+ # modifiers disappear from brush_data when clicking asset_dock (Why?)
+ var current_mods: int = int(modifier_shift) | int(modifier_ctrl) << 1 | int(modifier_alt) << 2
+ if _last_modifiers == current_mods and ui.brush_data.has("modifier_shift"):
+ return
+
+ _last_modifiers = current_mods
+ ui.brush_data["modifier_shift"] = modifier_shift
+ ui.brush_data["modifier_ctrl"] = modifier_ctrl
+ ui.brush_data["modifier_alt"] = modifier_alt
+ ui.update_modifiers()
+
+
+func update_region_grid() -> void:
+ if not region_gizmo:
+ return
+ region_gizmo.set_hidden(not ui.visible)
+
+ if is_terrain_valid():
+ region_gizmo.show_rect = editor.get_tool() == Terrain3DEditor.REGION
+ region_gizmo.use_secondary_color = editor.get_operation() == Terrain3DEditor.SUBTRACT
+ region_gizmo.region_position = current_region_position
+ region_gizmo.region_size = terrain.get_region_size() * terrain.get_vertex_spacing()
+ region_gizmo.grid = terrain.get_data().get_region_locations()
+
+ terrain.update_gizmos()
+ return
+
+ region_gizmo.show_rect = false
+ region_gizmo.region_size = 1024
+ region_gizmo.grid = [Vector2i.ZERO]
+
+
+func _on_scene_changed(scene_root: Node) -> void:
+ if not scene_root:
+ return
+
+ for node in scene_root.find_children("", "Terrain3DObjects"):
+ node.editor_setup(self)
+
+ asset_dock.update_assets()
+ await get_tree().create_timer(2).timeout
+ asset_dock.update_thumbnails()
+
+
+func is_terrain_valid(p_terrain: Terrain3D = null) -> bool:
+ var t: Terrain3D
+ if p_terrain:
+ t = p_terrain
+ else:
+ t = terrain
+ if is_instance_valid(t) and t.is_inside_tree() and t.data:
+ return true
+ return false
+
+
+func is_selected() -> bool:
+ var selected: Array[Node] = EditorInterface.get_selection().get_selected_nodes()
+ for node in selected:
+ if ( is_instance_valid(_last_terrain) and node.get_instance_id() == _last_terrain.get_instance_id() ) or \
+ node is Terrain3D:
+ return true
+ return false
+
+
+func select_terrain() -> void:
+ if is_instance_valid(_last_terrain) and is_terrain_valid(_last_terrain) and not is_selected():
+ var es: EditorSelection = EditorInterface.get_selection()
+ es.clear()
+ es.add_node(_last_terrain)
+
+
+## Editor Settings
+
+
+func setup_editor_settings() -> void:
+ editor_settings = EditorInterface.get_editor_settings()
+ if not editor_settings.has_setting("terrain3d/config/alt_key_bind"):
+ editor_settings.set("terrain3d/config/alt_key_bind", 0)
+ var property_info = {
+ "name": "terrain3d/config/alt_key_bind",
+ "type": TYPE_INT,
+ "hint": PROPERTY_HINT_ENUM,
+ "hint_string": "Alt,Space,Meta,Capslock"
+ }
+ editor_settings.add_property_info(property_info)
+
+
+func set_setting(p_str: String, p_value: Variant) -> void:
+ editor_settings.set_setting(p_str, p_value)
+
+
+func get_setting(p_str: String, p_default: Variant) -> Variant:
+ if editor_settings.has_setting(p_str):
+ return editor_settings.get_setting(p_str)
+ else:
+ return p_default
+
+
+func has_setting(p_str: String) -> bool:
+ return editor_settings.has_setting(p_str)
+
+
+func erase_setting(p_str: String) -> void:
+ editor_settings.erase(p_str)
diff --git a/addons/terrain_3d/src/editor_plugin.gd.uid b/addons/terrain_3d/src/editor_plugin.gd.uid
new file mode 100644
index 0000000..fb3e1ea
--- /dev/null
+++ b/addons/terrain_3d/src/editor_plugin.gd.uid
@@ -0,0 +1 @@
+uid://bsgxo1qywjdf3
diff --git a/addons/terrain_3d/src/gradient_operation_builder.gd b/addons/terrain_3d/src/gradient_operation_builder.gd
new file mode 100644
index 0000000..8bbafd4
--- /dev/null
+++ b/addons/terrain_3d/src/gradient_operation_builder.gd
@@ -0,0 +1,56 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Gradient Operation Builder for Terrain3D
+extends "res://addons/terrain_3d/src/operation_builder.gd"
+
+
+const MultiPicker: Script = preload("res://addons/terrain_3d/src/multi_picker.gd")
+
+
+func _get_point_picker() -> MultiPicker:
+ return tool_settings.settings["gradient_points"]
+
+
+func _get_brush_size() -> float:
+ return tool_settings.get_setting("size")
+
+
+func _is_drawable() -> bool:
+ return tool_settings.get_setting("drawable")
+
+
+func is_picking() -> bool:
+ return not _get_point_picker().all_points_selected()
+
+
+func pick(p_global_position: Vector3, p_terrain: Terrain3D) -> void:
+ if not _get_point_picker().all_points_selected():
+ _get_point_picker().add_point(p_global_position)
+
+
+func is_ready() -> bool:
+ return _get_point_picker().all_points_selected() and not _is_drawable()
+
+
+func apply_operation(p_editor: Terrain3DEditor, p_global_position: Vector3, p_camera_direction: float) -> void:
+ var points: PackedVector3Array = _get_point_picker().get_points()
+ assert(points.size() == 2)
+ assert(not _is_drawable())
+
+ var brush_size: float = _get_brush_size()
+ assert(brush_size > 0.0)
+
+ var start: Vector3 = points[0]
+ var end: Vector3 = points[1]
+
+ p_editor.start_operation(start)
+
+ var dir: Vector3 = (end - start).normalized()
+
+ var pos: Vector3 = start
+ while dir.dot(end - pos) > 0.0:
+ p_editor.operate(pos, p_camera_direction)
+ pos += dir * brush_size * 0.2
+
+ p_editor.stop_operation()
+
+ _get_point_picker().clear()
diff --git a/addons/terrain_3d/src/gradient_operation_builder.gd.uid b/addons/terrain_3d/src/gradient_operation_builder.gd.uid
new file mode 100644
index 0000000..e747d25
--- /dev/null
+++ b/addons/terrain_3d/src/gradient_operation_builder.gd.uid
@@ -0,0 +1 @@
+uid://def7sych6dp8b
diff --git a/addons/terrain_3d/src/multi_picker.gd b/addons/terrain_3d/src/multi_picker.gd
new file mode 100644
index 0000000..717ca7b
--- /dev/null
+++ b/addons/terrain_3d/src/multi_picker.gd
@@ -0,0 +1,88 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Multipicker for Terrain3D
+extends HBoxContainer
+
+
+signal pressed
+signal value_changed
+
+
+const ICON_PICKER_CHECKED: String = "res://addons/terrain_3d/icons/picker_checked.svg"
+const MAX_POINTS: int = 2
+
+
+var icon_picker: Texture2D
+var icon_picker_checked: Texture2D
+var points: PackedVector3Array
+var picking_index: int = -1
+
+
+func _enter_tree() -> void:
+ icon_picker = get_theme_icon("ColorPick", "EditorIcons")
+ icon_picker_checked = load(ICON_PICKER_CHECKED)
+
+ points.resize(MAX_POINTS)
+
+ for i in range(MAX_POINTS):
+ var button := Button.new()
+ button.icon = icon_picker
+ button.tooltip_text = "Pick point on the Terrain"
+ button.set_meta(&"point_index", i)
+ button.pressed.connect(_on_button_pressed.bind(i))
+ add_child(button)
+
+ _update_buttons()
+
+
+func _on_button_pressed(button_index: int) -> void:
+ points[button_index] = Vector3.ZERO
+ picking_index = button_index
+ _update_buttons()
+ pressed.emit()
+
+
+func _update_buttons() -> void:
+ for child in get_children():
+ if child is Button:
+ _update_button(child)
+
+
+func _update_button(button: Button) -> void:
+ var index: int = button.get_meta(&"point_index")
+
+ if points[index] != Vector3.ZERO:
+ button.icon = icon_picker_checked
+ else:
+ button.icon = icon_picker
+
+
+func clear() -> void:
+ points.fill(Vector3.ZERO)
+ _update_buttons()
+ value_changed.emit()
+
+
+func all_points_selected() -> bool:
+ return points.count(Vector3.ZERO) == 0
+
+
+func add_point(p_value: Vector3) -> void:
+ if points.has(p_value):
+ return
+
+ # If manually selecting a point individually
+ if picking_index != -1:
+ points[picking_index] = p_value
+ picking_index = -1
+ else:
+ # Else picking a sequence of points (non-drawable)
+ for i in range(MAX_POINTS):
+ if points[i] == Vector3.ZERO:
+ points[i] = p_value
+ break
+ _update_buttons()
+ value_changed.emit()
+
+
+func get_points() -> PackedVector3Array:
+ return points
diff --git a/addons/terrain_3d/src/multi_picker.gd.uid b/addons/terrain_3d/src/multi_picker.gd.uid
new file mode 100644
index 0000000..6db5cb7
--- /dev/null
+++ b/addons/terrain_3d/src/multi_picker.gd.uid
@@ -0,0 +1 @@
+uid://dvdtoa32h6xdn
diff --git a/addons/terrain_3d/src/operation_builder.gd b/addons/terrain_3d/src/operation_builder.gd
new file mode 100644
index 0000000..2a558be
--- /dev/null
+++ b/addons/terrain_3d/src/operation_builder.gd
@@ -0,0 +1,25 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Operation Builder for Terrain3D
+extends RefCounted
+
+
+const ToolSettings: Script = preload("res://addons/terrain_3d/src/tool_settings.gd")
+
+
+var tool_settings: ToolSettings
+
+
+func is_picking() -> bool:
+ return false
+
+
+func pick(p_global_position: Vector3, p_terrain: Terrain3D) -> void:
+ pass
+
+
+func is_ready() -> bool:
+ return false
+
+
+func apply_operation(editor: Terrain3DEditor, p_global_position: Vector3, p_camera_direction: float) -> void:
+ pass
diff --git a/addons/terrain_3d/src/operation_builder.gd.uid b/addons/terrain_3d/src/operation_builder.gd.uid
new file mode 100644
index 0000000..f14e1fe
--- /dev/null
+++ b/addons/terrain_3d/src/operation_builder.gd.uid
@@ -0,0 +1 @@
+uid://bu5cm0eh052rm
diff --git a/addons/terrain_3d/src/region_gizmo.gd b/addons/terrain_3d/src/region_gizmo.gd
new file mode 100644
index 0000000..c74c8f5
--- /dev/null
+++ b/addons/terrain_3d/src/region_gizmo.gd
@@ -0,0 +1,68 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Editor Region Gizmos for Terrain3D
+extends EditorNode3DGizmo
+
+var material: StandardMaterial3D
+var selection_material: StandardMaterial3D
+var region_position: Vector2
+var region_size: float
+var grid: Array[Vector2i]
+var use_secondary_color: bool = false
+var show_rect: bool = true
+
+var main_color: Color = Color.GREEN_YELLOW
+var secondary_color: Color = Color.RED
+var grid_color: Color = Color.WHITE
+var border_color: Color = Color.BLUE
+
+
+func _init() -> void:
+ material = StandardMaterial3D.new()
+ material.set_flag(BaseMaterial3D.FLAG_DISABLE_DEPTH_TEST, true)
+ material.set_flag(BaseMaterial3D.FLAG_ALBEDO_FROM_VERTEX_COLOR, true)
+ material.set_shading_mode(BaseMaterial3D.SHADING_MODE_UNSHADED)
+ material.set_albedo(Color.WHITE)
+
+ selection_material = material.duplicate()
+ selection_material.set_render_priority(0)
+
+
+func _redraw() -> void:
+ clear()
+
+ var rect_position = region_position * region_size
+
+ if show_rect:
+ var modulate: Color = main_color if !use_secondary_color else secondary_color
+ if abs(region_position.x) > Terrain3DData.REGION_MAP_SIZE*.5 or abs(region_position.y) > Terrain3DData.REGION_MAP_SIZE*.5:
+ modulate = Color.GRAY
+ draw_rect(Vector2(region_size,region_size)*.5 + rect_position, region_size, selection_material, modulate)
+
+ for pos in grid:
+ var grid_tile_position = Vector2(pos) * region_size
+ if show_rect and grid_tile_position == rect_position:
+ # Skip this one, otherwise focused region borders are not always visible due to draw order
+ continue
+
+ draw_rect(Vector2(region_size,region_size)*.5 + grid_tile_position, region_size, material, grid_color)
+
+ draw_rect(Vector2.ZERO, region_size * Terrain3DData.REGION_MAP_SIZE, material, border_color)
+
+
+func draw_rect(p_pos: Vector2, p_size: float, p_material: StandardMaterial3D, p_modulate: Color) -> void:
+ var lines: PackedVector3Array = [
+ Vector3(-1, 0, -1),
+ Vector3(-1, 0, 1),
+ Vector3(1, 0, 1),
+ Vector3(1, 0, -1),
+ Vector3(-1, 0, 1),
+ Vector3(1, 0, 1),
+ Vector3(1, 0, -1),
+ Vector3(-1, 0, -1),
+ ]
+
+ for i in lines.size():
+ lines[i] = ((lines[i] / 2.0) * p_size) + Vector3(p_pos.x, 0, p_pos.y)
+
+ add_lines(lines, p_material, false, p_modulate)
+
diff --git a/addons/terrain_3d/src/region_gizmo.gd.uid b/addons/terrain_3d/src/region_gizmo.gd.uid
new file mode 100644
index 0000000..346a404
--- /dev/null
+++ b/addons/terrain_3d/src/region_gizmo.gd.uid
@@ -0,0 +1 @@
+uid://bh6qwe1ok4cx3
diff --git a/addons/terrain_3d/src/tool_settings.gd b/addons/terrain_3d/src/tool_settings.gd
new file mode 100644
index 0000000..6145c9b
--- /dev/null
+++ b/addons/terrain_3d/src/tool_settings.gd
@@ -0,0 +1,692 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Tool settings bar for Terrain3D
+extends PanelContainer
+
+signal picking(type, callback)
+signal setting_changed
+
+enum Layout {
+ HORIZONTAL,
+ VERTICAL,
+ GRID,
+}
+
+enum SettingType {
+ CHECKBOX,
+ COLOR_SELECT,
+ DOUBLE_SLIDER,
+ OPTION,
+ PICKER,
+ MULTI_PICKER,
+ SLIDER,
+ LABEL,
+ TYPE_MAX,
+}
+
+const MultiPicker: Script = preload("res://addons/terrain_3d/src/multi_picker.gd")
+const DEFAULT_BRUSH: String = "circle0.exr"
+const BRUSH_PATH: String = "res://addons/terrain_3d/brushes"
+const ES_TOOL_SETTINGS: String = "terrain3d/tool_settings/"
+
+# Add settings flags
+const NONE: int = 0x0
+const ALLOW_LARGER: int = 0x1
+const ALLOW_SMALLER: int = 0x2
+const ALLOW_OUT_OF_BOUNDS: int = 0x3 # LARGER|SMALLER
+const NO_LABEL: int = 0x4
+const ADD_SEPARATOR: int = 0x8 # Add a vertical line before this entry
+const ADD_SPACER: int = 0x10 # Add a space before this entry
+const NO_SAVE: int = 0x20 # Don't save this in EditorSettings
+
+var plugin: EditorPlugin # Actually Terrain3DEditorPlugin, but Godot still has CRC errors
+var brush_preview_material: ShaderMaterial
+var select_brush_button: Button
+var selected_brush_imgs: Array
+var main_list: HFlowContainer
+var advanced_list: VBoxContainer
+var height_list: VBoxContainer
+var scale_list: VBoxContainer
+var rotation_list: VBoxContainer
+var color_list: VBoxContainer
+var settings: Dictionary = {}
+
+
+func _ready() -> void:
+ # Remove old editor settings
+ for setting in ["lift_floor", "flatten_peaks", "lift_flatten", "automatic_regions"]:
+ plugin.erase_setting(ES_TOOL_SETTINGS + setting)
+
+ # Setup buttons
+ main_list = HFlowContainer.new()
+ add_child(main_list, true)
+
+ add_brushes(main_list)
+
+ add_setting({ "name":"instructions", "label":"Click the terrain to add a region. CTRL+Click to remove. Or select another tool on the left.",
+ "type":SettingType.LABEL, "list":main_list, "flags":NO_LABEL|NO_SAVE })
+
+ add_setting({ "name":"size", "type":SettingType.SLIDER, "list":main_list, "default":20, "unit":"m",
+ "range":Vector3(0.1, 200, 1), "flags":ALLOW_LARGER|ADD_SPACER })
+
+ add_setting({ "name":"strength", "type":SettingType.SLIDER, "list":main_list, "default":33,
+ "unit":"%", "range":Vector3(1, 100, 1), "flags":ALLOW_LARGER })
+
+ add_setting({ "name":"height", "type":SettingType.SLIDER, "list":main_list, "default":20,
+ "unit":"m", "range":Vector3(-500, 500, 0.1), "flags":ALLOW_OUT_OF_BOUNDS })
+ add_setting({ "name":"height_picker", "type":SettingType.PICKER, "list":main_list,
+ "default":Terrain3DEditor.HEIGHT, "flags":NO_LABEL })
+
+ add_setting({ "name":"color", "type":SettingType.COLOR_SELECT, "list":main_list,
+ "default":Color.WHITE, "flags":ADD_SEPARATOR })
+ add_setting({ "name":"color_picker", "type":SettingType.PICKER, "list":main_list,
+ "default":Terrain3DEditor.COLOR, "flags":NO_LABEL })
+
+ add_setting({ "name":"roughness", "type":SettingType.SLIDER, "list":main_list, "default":-65,
+ "unit":"%", "range":Vector3(-100, 100, 1), "flags":ADD_SEPARATOR })
+ add_setting({ "name":"roughness_picker", "type":SettingType.PICKER, "list":main_list,
+ "default":Terrain3DEditor.ROUGHNESS, "flags":NO_LABEL })
+
+ add_setting({ "name":"enable_texture", "label":"Texture", "type":SettingType.CHECKBOX,
+ "list":main_list, "default":true, "flags":ADD_SEPARATOR })
+
+ add_setting({ "name":"texture_filter", "label":"Texture Filter", "type":SettingType.CHECKBOX,
+ "list":main_list, "default":false, "flags":ADD_SEPARATOR })
+
+ add_setting({ "name":"margin", "type":SettingType.SLIDER, "list":main_list, "default":0,
+ "unit":"", "range":Vector3(-50, 50, 1), "flags":ALLOW_OUT_OF_BOUNDS })
+
+ # Slope painting filter
+ add_setting({ "name":"slope", "type":SettingType.DOUBLE_SLIDER, "list":main_list, "default":Vector2(0, 90),
+ "unit":"°", "range":Vector3(0, 90, 1), "flags":ADD_SEPARATOR })
+
+ add_setting({ "name":"enable_angle", "label":"Angle", "type":SettingType.CHECKBOX,
+ "list":main_list, "default":true, "flags":ADD_SEPARATOR })
+ add_setting({ "name":"angle", "type":SettingType.SLIDER, "list":main_list, "default":0,
+ "unit":"%", "range":Vector3(0, 337.5, 22.5), "flags":NO_LABEL })
+ add_setting({ "name":"angle_picker", "type":SettingType.PICKER, "list":main_list,
+ "default":Terrain3DEditor.ANGLE, "flags":NO_LABEL })
+ add_setting({ "name":"dynamic_angle", "label":"Dynamic", "type":SettingType.CHECKBOX,
+ "list":main_list, "default":false, "flags":ADD_SPACER })
+
+ add_setting({ "name":"enable_scale", "label":"Scale", "type":SettingType.CHECKBOX,
+ "list":main_list, "default":true, "flags":ADD_SEPARATOR })
+ add_setting({ "name":"scale", "label":"±", "type":SettingType.SLIDER, "list":main_list, "default":0,
+ "unit":"%", "range":Vector3(-60, 80, 20), "flags":NO_LABEL })
+ add_setting({ "name":"scale_picker", "type":SettingType.PICKER, "list":main_list,
+ "default":Terrain3DEditor.SCALE, "flags":NO_LABEL })
+
+ ## Slope sculpting brush
+ add_setting({ "name":"gradient_points", "type":SettingType.MULTI_PICKER, "label":"Points",
+ "list":main_list, "default":Terrain3DEditor.SCULPT, "flags":ADD_SEPARATOR })
+ add_setting({ "name":"drawable", "type":SettingType.CHECKBOX, "list":main_list, "default":false,
+ "flags":ADD_SEPARATOR })
+ settings["drawable"].toggled.connect(_on_drawable_toggled)
+
+ ## Instancer
+ height_list = create_submenu(main_list, "Height", Layout.VERTICAL)
+ add_setting({ "name":"height_offset", "type":SettingType.SLIDER, "list":height_list, "default":0,
+ "unit":"m", "range":Vector3(-10, 10, 0.05), "flags":ALLOW_OUT_OF_BOUNDS })
+ add_setting({ "name":"random_height", "label":"Random Height ±", "type":SettingType.SLIDER,
+ "list":height_list, "default":0, "unit":"m", "range":Vector3(0, 10, 0.05),
+ "flags":ALLOW_OUT_OF_BOUNDS })
+
+ scale_list = create_submenu(main_list, "Scale", Layout.VERTICAL)
+ add_setting({ "name":"fixed_scale", "type":SettingType.SLIDER, "list":scale_list, "default":100,
+ "unit":"%", "range":Vector3(1, 1000, 1), "flags":ALLOW_OUT_OF_BOUNDS })
+ add_setting({ "name":"random_scale", "label":"Random Scale ±", "type":SettingType.SLIDER, "list":scale_list,
+ "default":20, "unit":"%", "range":Vector3(0, 99, 1), "flags":ALLOW_OUT_OF_BOUNDS })
+
+ rotation_list = create_submenu(main_list, "Rotation", Layout.VERTICAL)
+ add_setting({ "name":"fixed_spin", "label":"Fixed Spin (Around Y)", "type":SettingType.SLIDER, "list":rotation_list,
+ "default":0, "unit":"°", "range":Vector3(0, 360, 1) })
+ add_setting({ "name":"random_spin", "type":SettingType.SLIDER, "list":rotation_list, "default":360,
+ "unit":"°", "range":Vector3(0, 360, 1) })
+ add_setting({ "name":"fixed_tilt", "label":"Fixed Tilt", "type":SettingType.SLIDER, "list":rotation_list,
+ "default":0, "unit":"°", "range":Vector3(-85, 85, 1), "flags":ALLOW_OUT_OF_BOUNDS })
+ add_setting({ "name":"random_tilt", "label":"Random Tilt ±", "type":SettingType.SLIDER, "list":rotation_list,
+ "default":10, "unit":"°", "range":Vector3(0, 85, 1), "flags":ALLOW_OUT_OF_BOUNDS })
+ add_setting({ "name":"align_to_normal", "type":SettingType.CHECKBOX, "list":rotation_list, "default":false })
+
+ color_list = create_submenu(main_list, "Color", Layout.VERTICAL)
+ add_setting({ "name":"vertex_color", "type":SettingType.COLOR_SELECT, "list":color_list,
+ "default":Color.WHITE })
+ add_setting({ "name":"random_hue", "label":"Random Hue Shift ±", "type":SettingType.SLIDER,
+ "list":color_list, "default":0, "unit":"°", "range":Vector3(0, 360, 1) })
+ add_setting({ "name":"random_darken", "type":SettingType.SLIDER, "list":color_list, "default":50,
+ "unit":"%", "range":Vector3(0, 100, 1) })
+ #add_setting({ "name":"blend_mode", "type":SettingType.OPTION, "list":color_list, "default":0,
+ #"range":Vector3(0, 3, 1) })
+
+ if DisplayServer.is_touchscreen_available():
+ add_setting({ "name":"remove", "label":"Invert", "type":SettingType.CHECKBOX, "list":main_list, "default":false, "flags":ADD_SEPARATOR })
+
+ var spacer: Control = Control.new()
+ spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ main_list.add_child(spacer, true)
+
+ ## Advanced Settings Menu
+ advanced_list = create_submenu(main_list, "", Layout.VERTICAL, false)
+ add_setting({ "name":"auto_regions", "label":"Add regions while sculpting", "type":SettingType.CHECKBOX,
+ "list":advanced_list, "default":true })
+ add_setting({ "name":"align_to_view", "type":SettingType.CHECKBOX, "list":advanced_list,
+ "default":true })
+ add_setting({ "name":"show_cursor_while_painting", "type":SettingType.CHECKBOX, "list":advanced_list,
+ "default":true })
+ advanced_list.add_child(HSeparator.new(), true)
+ add_setting({ "name":"gamma", "type":SettingType.SLIDER, "list":advanced_list, "default":1.0,
+ "unit":"γ", "range":Vector3(0.1, 2.0, 0.01) })
+ add_setting({ "name":"jitter", "type":SettingType.SLIDER, "list":advanced_list, "default":50,
+ "unit":"%", "range":Vector3(0, 100, 1) })
+ add_setting({ "name":"crosshair_threshold", "type":SettingType.SLIDER, "list":advanced_list, "default":16.,
+ "unit":"m", "range":Vector3(0, 200, 1) })
+
+
+func create_submenu(p_parent: Control, p_button_name: String, p_layout: Layout, p_hover_pop: bool = true) -> Container:
+ var menu_button: Button = Button.new()
+ if p_button_name.is_empty():
+ menu_button.icon = get_theme_icon("GuiTabMenuHl", "EditorIcons")
+ else:
+ menu_button.set_text(p_button_name)
+ menu_button.set_toggle_mode(true)
+ menu_button.set_v_size_flags(SIZE_SHRINK_CENTER)
+ menu_button.toggled.connect(_on_show_submenu.bind(menu_button))
+
+ var submenu: PopupPanel = PopupPanel.new()
+ submenu.popup_hide.connect(menu_button.set_pressed.bind(false))
+ var panel_style: StyleBox = get_theme_stylebox("panel", "PopupMenu").duplicate()
+ panel_style.set_content_margin_all(10)
+ submenu.set("theme_override_styles/panel", panel_style)
+ submenu.add_to_group("terrain3d_submenus")
+
+ # Pop up menu on hover, hide on exit
+ if p_hover_pop:
+ menu_button.mouse_entered.connect(_on_show_submenu.bind(true, menu_button))
+
+ submenu.mouse_entered.connect(func(): submenu.set_meta("mouse_entered", true))
+
+ submenu.mouse_exited.connect(func():
+ # On mouse_exit, hide popup unless LineEdit focused
+ var focused_element: Control = submenu.gui_get_focus_owner()
+ if not focused_element is LineEdit:
+ _on_show_submenu(false, menu_button)
+ submenu.set_meta("mouse_entered", false)
+ return
+
+ focused_element.focus_exited.connect(func():
+ # Close submenu once lineedit loses focus
+ if not submenu.get_meta("mouse_entered"):
+ _on_show_submenu(false, menu_button)
+ submenu.set_meta("mouse_entered", false)
+ )
+ )
+
+ var sublist: Container
+ match(p_layout):
+ Layout.GRID:
+ sublist = GridContainer.new()
+ Layout.VERTICAL:
+ sublist = VBoxContainer.new()
+ Layout.HORIZONTAL, _:
+ sublist = HBoxContainer.new()
+
+ p_parent.add_child(menu_button, true)
+ menu_button.add_child(submenu, true)
+ submenu.add_child(sublist, true)
+
+ return sublist
+
+
+func _on_show_submenu(p_toggled: bool, p_button: Button) -> void:
+ # Don't show if mouse already down (from painting)
+ if p_toggled and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
+ return
+
+ # Hide menu if mouse is not in button or panel
+ var button_rect: Rect2 = Rect2(p_button.get_screen_transform().origin, p_button.get_global_rect().size)
+ var in_button: bool = button_rect.has_point(DisplayServer.mouse_get_position())
+ var popup: PopupPanel = p_button.get_child(0)
+ var popup_rect: Rect2 = Rect2(popup.position, popup.size)
+ var in_popup: bool = popup_rect.has_point(DisplayServer.mouse_get_position())
+ if not p_toggled and ( in_button or in_popup ):
+ return
+
+ # Hide all submenus before possibly enabling the current one
+ get_tree().call_group("terrain3d_submenus", "set_visible", false)
+ popup.set_visible(p_toggled)
+ var popup_pos: Vector2 = p_button.get_screen_transform().origin
+ popup_pos.y -= popup.size.y
+ if popup.get_child_count()>0 and popup.get_child(0) == advanced_list:
+ popup_pos.x -= popup.size.x - p_button.size.x
+ popup.set_position(popup_pos)
+
+
+func add_brushes(p_parent: Control) -> void:
+ var brush_list: GridContainer = create_submenu(p_parent, "Brush", Layout.GRID)
+ brush_list.name = "BrushList"
+
+ var brush_button_group: ButtonGroup = ButtonGroup.new()
+ brush_button_group.pressed.connect(_on_setting_changed)
+ var default_brush_btn: Button
+
+ var dir: DirAccess = DirAccess.open(BRUSH_PATH)
+ if dir:
+ dir.list_dir_begin()
+ var file_name = dir.get_next()
+ while file_name != "":
+ if !dir.current_is_dir() and file_name.ends_with(".exr"):
+ var img: Image = Image.load_from_file(BRUSH_PATH + "/" + file_name)
+ var thumbimg: Image = img.duplicate()
+ img.convert(Image.FORMAT_RF)
+
+ if thumbimg.get_width() != 100 and thumbimg.get_height() != 100:
+ thumbimg.resize(100, 100, Image.INTERPOLATE_CUBIC)
+ thumbimg = Terrain3DUtil.black_to_alpha(thumbimg)
+ thumbimg.convert(Image.FORMAT_LA8)
+ var thumbtex: ImageTexture = ImageTexture.create_from_image(thumbimg)
+
+ var brush_btn: Button = Button.new()
+ brush_btn.set_custom_minimum_size(Vector2.ONE * 100)
+ brush_btn.set_button_icon(thumbtex)
+ brush_btn.set_meta("image", img)
+ brush_btn.set_expand_icon(true)
+ brush_btn.set_material(_get_brush_preview_material())
+ brush_btn.set_toggle_mode(true)
+ brush_btn.set_button_group(brush_button_group)
+ brush_btn.mouse_entered.connect(_on_brush_hover.bind(true, brush_btn))
+ brush_btn.mouse_exited.connect(_on_brush_hover.bind(false, brush_btn))
+ brush_list.add_child(brush_btn, true)
+ if file_name == DEFAULT_BRUSH:
+ default_brush_btn = brush_btn
+
+ var lbl: Label = Label.new()
+ brush_btn.name = file_name.get_basename().to_pascal_case()
+ brush_btn.add_child(lbl, true)
+ lbl.text = brush_btn.name
+ lbl.visible = false
+ lbl.position.y = 70
+ lbl.add_theme_color_override("font_shadow_color", Color.BLACK)
+ lbl.add_theme_constant_override("shadow_offset_x", 1)
+ lbl.add_theme_constant_override("shadow_offset_y", 1)
+ lbl.add_theme_font_size_override("font_size", 16)
+
+ file_name = dir.get_next()
+
+ brush_list.columns = sqrt(brush_list.get_child_count()) + 2
+
+ if not default_brush_btn:
+ default_brush_btn = brush_button_group.get_buttons()[0]
+ default_brush_btn.set_pressed(true)
+ _generate_brush_texture(default_brush_btn)
+
+ settings["brush"] = brush_button_group
+
+ select_brush_button = brush_list.get_parent().get_parent()
+ # Optionally erase the main brush button text and replace it with the texture
+ select_brush_button.set_text("")
+ select_brush_button.set_button_icon(default_brush_btn.get_button_icon())
+ select_brush_button.set_custom_minimum_size(Vector2.ONE * 36)
+ select_brush_button.set_icon_alignment(HORIZONTAL_ALIGNMENT_CENTER)
+ select_brush_button.set_expand_icon(true)
+
+
+func _on_brush_hover(p_hovering: bool, p_button: Button) -> void:
+ if p_button.get_child_count() > 0:
+ var child = p_button.get_child(0)
+ if child is Label:
+ if p_hovering:
+ child.visible = true
+ else:
+ child.visible = false
+
+
+func _on_pick(p_type: Terrain3DEditor.Tool) -> void:
+ emit_signal("picking", p_type, _on_picked)
+
+
+func _on_picked(p_type: Terrain3DEditor.Tool, p_color: Color, p_global_position: Vector3) -> void:
+ match p_type:
+ Terrain3DEditor.HEIGHT:
+ settings["height"].value = p_color.r if not is_nan(p_color.r) else 0
+ Terrain3DEditor.COLOR:
+ settings["color"].color = p_color if not is_nan(p_color.r) else Color.WHITE
+ Terrain3DEditor.ROUGHNESS:
+ # 200... -.5 converts 0,1 to -100,100
+ settings["roughness"].value = round(200 * (p_color.a - 0.5)) if not is_nan(p_color.r) else 0.499
+ Terrain3DEditor.ANGLE:
+ settings["angle"].value = p_color.r
+ Terrain3DEditor.SCALE:
+ settings["scale"].value = p_color.r
+ _on_setting_changed()
+
+
+func _on_point_pick(p_type: Terrain3DEditor.Tool, p_name: String) -> void:
+ assert(p_type == Terrain3DEditor.SCULPT)
+ emit_signal("picking", p_type, _on_point_picked.bind(p_name))
+
+
+func _on_point_picked(p_type: Terrain3DEditor.Tool, p_color: Color, p_global_position: Vector3, p_name: String) -> void:
+ assert(p_type == Terrain3DEditor.SCULPT)
+ var point: Vector3 = p_global_position
+ point.y = p_color.r
+ settings[p_name].add_point(point)
+ _on_setting_changed()
+
+
+func add_setting(p_args: Dictionary) -> void:
+ var p_name: StringName = p_args.get("name", "")
+ var p_label: String = p_args.get("label", "") # Optional replacement for name
+ var p_type: SettingType = p_args.get("type", SettingType.TYPE_MAX)
+ var p_list: Control = p_args.get("list")
+ var p_default: Variant = p_args.get("default")
+ var p_suffix: String = p_args.get("unit", "")
+ var p_range: Vector3 = p_args.get("range", Vector3(0, 0, 1))
+ var p_minimum: float = p_range.x
+ var p_maximum: float = p_range.y
+ var p_step: float = p_range.z
+ var p_flags: int = p_args.get("flags", NONE)
+
+ if p_name.is_empty() or p_type == SettingType.TYPE_MAX:
+ return
+
+ var container: HBoxContainer = HBoxContainer.new()
+ container.set_v_size_flags(SIZE_EXPAND_FILL)
+ var control: Control # Houses the setting to be saved
+ var pending_children: Array[Control]
+
+ match p_type:
+ SettingType.LABEL:
+ var label := Label.new()
+ label.set_text(p_label)
+ pending_children.push_back(label)
+ control = label
+
+ SettingType.CHECKBOX:
+ var checkbox := CheckBox.new()
+ if !(p_flags & NO_SAVE):
+ checkbox.set_pressed_no_signal(plugin.get_setting(ES_TOOL_SETTINGS + p_name, p_default))
+ checkbox.toggled.connect( (
+ func(value, path):
+ plugin.set_setting(path, value)
+ ).bind(ES_TOOL_SETTINGS + p_name) )
+ else:
+ checkbox.set_pressed_no_signal(p_default)
+ checkbox.pressed.connect(_on_setting_changed)
+ pending_children.push_back(checkbox)
+ control = checkbox
+
+ SettingType.COLOR_SELECT:
+ var picker := ColorPickerButton.new()
+ picker.set_custom_minimum_size(Vector2(100, 25))
+ picker.edit_alpha = false
+ picker.get_picker().set_color_mode(ColorPicker.MODE_HSV)
+ if !(p_flags & NO_SAVE):
+ picker.set_pick_color(plugin.get_setting(ES_TOOL_SETTINGS + p_name, p_default))
+ picker.color_changed.connect( (
+ func(value, path):
+ plugin.set_setting(path, value)
+ ).bind(ES_TOOL_SETTINGS + p_name) )
+ else:
+ picker.set_pick_color(p_default)
+ picker.color_changed.connect(_on_setting_changed)
+ pending_children.push_back(picker)
+ control = picker
+
+ SettingType.PICKER:
+ var button := Button.new()
+ button.set_v_size_flags(SIZE_SHRINK_CENTER)
+ button.icon = get_theme_icon("ColorPick", "EditorIcons")
+ button.tooltip_text = "Pick value from the Terrain"
+ button.pressed.connect(_on_pick.bind(p_default))
+ pending_children.push_back(button)
+ control = button
+
+ SettingType.MULTI_PICKER:
+ var multi_picker: HBoxContainer = MultiPicker.new()
+ multi_picker.pressed.connect(_on_point_pick.bind(p_default, p_name))
+ multi_picker.value_changed.connect(_on_setting_changed)
+ pending_children.push_back(multi_picker)
+ control = multi_picker
+
+ SettingType.OPTION:
+ var option := OptionButton.new()
+ for i in int(p_maximum):
+ option.add_item("a", i)
+ option.selected = p_minimum
+ option.item_selected.connect(_on_setting_changed)
+ pending_children.push_back(option)
+ control = option
+
+ SettingType.SLIDER, SettingType.DOUBLE_SLIDER:
+ var slider: Control
+ if p_type == SettingType.SLIDER:
+ # Create an editable value box
+ var spin_slider := EditorSpinSlider.new()
+ spin_slider.set_flat(false)
+ spin_slider.set_hide_slider(true)
+ spin_slider.value_changed.connect(_on_setting_changed)
+ spin_slider.set_max(p_maximum)
+ spin_slider.set_min(p_minimum)
+ spin_slider.set_step(p_step)
+ spin_slider.set_suffix(p_suffix)
+ spin_slider.set_v_size_flags(SIZE_SHRINK_CENTER)
+ spin_slider.set_custom_minimum_size(Vector2(65, 0))
+
+ # Create horizontal slider linked to the above box
+ slider = HSlider.new()
+ slider.share(spin_slider)
+ if p_flags & ALLOW_LARGER:
+ slider.set_allow_greater(true)
+ if p_flags & ALLOW_SMALLER:
+ slider.set_allow_lesser(true)
+
+ pending_children.push_back(slider)
+ pending_children.push_back(spin_slider)
+ control = spin_slider
+
+ else: # DOUBLE_SLIDER
+ var label := Label.new()
+ label.set_custom_minimum_size(Vector2(60, 0))
+ label.set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER)
+ slider = DoubleSlider.new()
+ slider.label = label
+ slider.suffix = p_suffix
+ slider.value_changed.connect(_on_setting_changed)
+ pending_children.push_back(slider)
+ pending_children.push_back(label)
+ control = slider
+
+ slider.set_min(p_minimum)
+ slider.set_max(p_maximum)
+ slider.set_step(p_step)
+ slider.set_value(p_default)
+ slider.set_v_size_flags(SIZE_SHRINK_CENTER)
+ slider.set_custom_minimum_size(Vector2(50, 10))
+
+ if !(p_flags & NO_SAVE):
+ slider.set_value(plugin.get_setting(ES_TOOL_SETTINGS + p_name, p_default))
+ slider.value_changed.connect( (
+ func(value, path):
+ plugin.set_setting(path, value)
+ ).bind(ES_TOOL_SETTINGS + p_name) )
+ else:
+ slider.set_value(p_default)
+
+ control.name = p_name.to_pascal_case()
+ settings[p_name] = control
+
+ # Setup button labels
+ if not (p_flags & NO_LABEL):
+ # Labels are actually buttons styled to look like labels
+ var label := Button.new()
+ label.set("theme_override_styles/normal", get_theme_stylebox("normal", "Label"))
+ label.set("theme_override_styles/hover", get_theme_stylebox("normal", "Label"))
+ label.set("theme_override_styles/pressed", get_theme_stylebox("normal", "Label"))
+ label.set("theme_override_styles/focus", get_theme_stylebox("normal", "Label"))
+ label.pressed.connect(_on_label_pressed.bind(p_name, p_default))
+ if p_label.is_empty():
+ label.set_text(p_name.capitalize() + ": ")
+ else:
+ label.set_text(p_label.capitalize() + ": ")
+ pending_children.push_front(label)
+
+ # Add separators to front
+ if p_flags & ADD_SEPARATOR:
+ pending_children.push_front(VSeparator.new())
+ if p_flags & ADD_SPACER:
+ var spacer := Control.new()
+ spacer.set_custom_minimum_size(Vector2(5, 0))
+ pending_children.push_front(spacer)
+
+ # Add all children to container and list
+ for child in pending_children:
+ container.add_child(child, true)
+ p_list.add_child(container, true)
+
+
+# If label button is pressed, reset value to default or toggle checkbox
+func _on_label_pressed(p_name: String, p_default: Variant) -> void:
+ var control: Control = settings.get(p_name)
+ if not control:
+ return
+ if control is CheckBox:
+ set_setting(p_name, !control.button_pressed)
+ elif p_default != null:
+ set_setting(p_name, p_default)
+
+
+func get_settings() -> Dictionary:
+ var dict: Dictionary
+ for key in settings.keys():
+ dict[key] = get_setting(key)
+ return dict
+
+
+func get_setting(p_setting: String) -> Variant:
+ var object: Object = settings.get(p_setting)
+ var value: Variant
+ if object is Range:
+ value = object.get_value()
+ # Adjust widths of all sliders on update of values
+ var digits: float = count_digits(value)
+ var width: float = clamp( (1 + count_digits(value)) * 19., 50, 80) * clamp(EditorInterface.get_editor_scale(), .9, 2)
+ object.set_custom_minimum_size(Vector2(width, 0))
+ elif object is DoubleSlider:
+ value = object.get_value()
+ elif object is ButtonGroup: # "brush"
+ value = selected_brush_imgs
+ elif object is CheckBox:
+ value = object.is_pressed()
+ elif object is ColorPickerButton:
+ value = object.color
+ elif object is MultiPicker:
+ value = object.get_points()
+ if value == null:
+ value = 0
+ return value
+
+
+func set_setting(p_setting: String, p_value: Variant) -> void:
+ var object: Object = settings.get(p_setting)
+ if object is DoubleSlider: # Expects p_value is Vector2
+ object.set_value(p_value)
+ elif object is Range:
+ object.set_value(p_value)
+ elif object is ButtonGroup: # Expects p_value is Array [ "button name", boolean ]
+ if p_value is Array and p_value.size() == 2:
+ for button in object.get_buttons():
+ if button.name == p_value[0]:
+ button.button_pressed = p_value[1]
+ elif object is CheckBox:
+ object.button_pressed = p_value
+ elif object is ColorPickerButton:
+ object.color = p_value
+ plugin.set_setting(ES_TOOL_SETTINGS + p_setting, p_value) # Signal doesn't fire on CPB
+ elif object is MultiPicker: # Expects p_value is PackedVector3Array
+ object.points = p_value
+ _on_setting_changed(object)
+
+
+func show_settings(p_settings: PackedStringArray) -> void:
+ for setting in settings.keys():
+ var object: Object = settings[setting]
+ if object is Control:
+ if setting in p_settings:
+ object.get_parent().show()
+ else:
+ object.get_parent().hide()
+ if select_brush_button:
+ if not "brush" in p_settings:
+ select_brush_button.hide()
+ else:
+ select_brush_button.show()
+
+
+func _on_setting_changed(p_object: Variant = null) -> void:
+ # If a brush was selected
+ if p_object is Button and p_object.get_parent().name == "BrushList":
+ _generate_brush_texture(p_object)
+ # Optionally Set selected brush texture in main brush button
+ if select_brush_button:
+ select_brush_button.set_button_icon(p_object.get_button_icon())
+ # Hide popup
+ p_object.get_parent().get_parent().set_visible(false)
+ # Hide label
+ if p_object.get_child_count() > 0:
+ p_object.get_child(0).visible = false
+ emit_signal("setting_changed")
+
+
+func _generate_brush_texture(p_btn: Button) -> void:
+ if p_btn is Button:
+ var img: Image = p_btn.get_meta("image")
+ if img.get_width() < 1024 and img.get_height() < 1024:
+ img = img.duplicate()
+ img.resize(1024, 1024, Image.INTERPOLATE_CUBIC)
+ var tex: ImageTexture = ImageTexture.create_from_image(img)
+ selected_brush_imgs = [ img, tex ]
+
+
+func _on_drawable_toggled(p_button_pressed: bool) -> void:
+ if not p_button_pressed:
+ settings["gradient_points"].clear()
+
+
+func _get_brush_preview_material() -> ShaderMaterial:
+ if !brush_preview_material:
+ brush_preview_material = ShaderMaterial.new()
+ var shader: Shader = Shader.new()
+ var code: String = "shader_type canvas_item;\n"
+ code += "varying vec4 v_vertex_color;\n"
+ code += "void vertex() {\n"
+ code += " v_vertex_color = COLOR;\n"
+ code += "}\n"
+ code += "void fragment(){\n"
+ code += " vec4 tex = texture(TEXTURE, UV);\n"
+ code += " COLOR.a *= pow(tex.r, 0.666);\n"
+ code += " COLOR.rgb = v_vertex_color.rgb;\n"
+ code += "}\n"
+ shader.set_code(code)
+ brush_preview_material.set_shader(shader)
+ return brush_preview_material
+
+
+# Counts digits of a number including negative sign, decimal points, and up to 3 decimals
+func count_digits(p_value: float) -> int:
+ var count: int = 1
+ for i in range(5, 0, -1):
+ if abs(p_value) >= pow(10, i):
+ count = i+1
+ break
+ if p_value - floor(p_value) >= .1:
+ count += 1 # For the decimal
+ if p_value*10 - floor(p_value*10.) >= .1:
+ count += 1
+ if p_value*100 - floor(p_value*100.) >= .1:
+ count += 1
+ if p_value*1000 - floor(p_value*1000.) >= .1:
+ count += 1
+ # Negative sign
+ if p_value < 0:
+ count += 1
+ return count
+
diff --git a/addons/terrain_3d/src/tool_settings.gd.uid b/addons/terrain_3d/src/tool_settings.gd.uid
new file mode 100644
index 0000000..b6c65fb
--- /dev/null
+++ b/addons/terrain_3d/src/tool_settings.gd.uid
@@ -0,0 +1 @@
+uid://ciskaaennrffu
diff --git a/addons/terrain_3d/src/toolbar.gd b/addons/terrain_3d/src/toolbar.gd
new file mode 100644
index 0000000..0cc80fd
--- /dev/null
+++ b/addons/terrain_3d/src/toolbar.gd
@@ -0,0 +1,145 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Toolbar for Terrain3D
+extends VFlowContainer
+
+signal tool_changed(p_tool: Terrain3DEditor.Tool, p_operation: Terrain3DEditor.Operation)
+
+const ICON_REGION_ADD: String = "res://addons/terrain_3d/icons/region_add.svg"
+const ICON_REGION_REMOVE: String = "res://addons/terrain_3d/icons/region_remove.svg"
+const ICON_HEIGHT_ADD: String = "res://addons/terrain_3d/icons/height_add.svg"
+const ICON_HEIGHT_SUB: String = "res://addons/terrain_3d/icons/height_sub.svg"
+const ICON_HEIGHT_FLAT: String = "res://addons/terrain_3d/icons/height_flat.svg"
+const ICON_HEIGHT_SLOPE: String = "res://addons/terrain_3d/icons/height_slope.svg"
+const ICON_HEIGHT_SMOOTH: String = "res://addons/terrain_3d/icons/height_smooth.svg"
+const ICON_PAINT_TEXTURE: String = "res://addons/terrain_3d/icons/texture_paint.svg"
+const ICON_SPRAY_TEXTURE: String = "res://addons/terrain_3d/icons/texture_spray.svg"
+const ICON_COLOR: String = "res://addons/terrain_3d/icons/color_paint.svg"
+const ICON_WETNESS: String = "res://addons/terrain_3d/icons/wetness.svg"
+const ICON_AUTOSHADER: String = "res://addons/terrain_3d/icons/autoshader.svg"
+const ICON_HOLES: String = "res://addons/terrain_3d/icons/holes.svg"
+const ICON_NAVIGATION: String = "res://addons/terrain_3d/icons/navigation.svg"
+const ICON_INSTANCER: String = "res://addons/terrain_3d/icons/multimesh.svg"
+
+var add_tool_group: ButtonGroup = ButtonGroup.new()
+var sub_tool_group: ButtonGroup = ButtonGroup.new()
+
+
+func _init() -> void:
+ set_custom_minimum_size(Vector2(20, 0))
+
+func _ready() -> void:
+ add_tool_group.pressed.connect(_on_tool_selected)
+ sub_tool_group.pressed.connect(_on_tool_selected)
+
+ add_tool_button({ "tool":Terrain3DEditor.REGION,
+ "add_text":"Add Region", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_REGION_ADD,
+ "sub_text":"Remove Region", "sub_op":Terrain3DEditor.SUBTRACT, "sub_icon":ICON_REGION_REMOVE })
+
+ add_child(HSeparator.new())
+
+ add_tool_button({ "tool":Terrain3DEditor.SCULPT,
+ "add_text":"Raise", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_HEIGHT_ADD,
+ "sub_text":"Lower", "sub_op":Terrain3DEditor.SUBTRACT, "sub_icon":ICON_HEIGHT_SUB })
+
+ add_tool_button({ "tool":Terrain3DEditor.SCULPT,
+ "add_text":"Smooth", "add_op":Terrain3DEditor.AVERAGE, "add_icon":ICON_HEIGHT_SMOOTH })
+
+ add_tool_button({ "tool":Terrain3DEditor.HEIGHT,
+ "add_text":"Height", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_HEIGHT_FLAT,
+ "sub_text":"Zero", "sub_op":Terrain3DEditor.SUBTRACT, "sub_icon":ICON_HEIGHT_FLAT })
+
+ add_tool_button({ "tool":Terrain3DEditor.SCULPT,
+ "add_text":"Slope", "add_op":Terrain3DEditor.GRADIENT, "add_icon":ICON_HEIGHT_SLOPE })
+
+ add_child(HSeparator.new())
+
+ add_tool_button({ "tool":Terrain3DEditor.TEXTURE,
+ "add_text":"Paint Base Texture", "add_op":Terrain3DEditor.REPLACE, "add_icon":ICON_PAINT_TEXTURE })
+
+ add_tool_button({ "tool":Terrain3DEditor.TEXTURE,
+ "add_text":"Spray Overlay Texture", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_SPRAY_TEXTURE })
+
+ add_tool_button({ "tool":Terrain3DEditor.AUTOSHADER,
+ "add_text":"Enable Autoshader", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_AUTOSHADER,
+ "sub_text":"Disable Autoshader", "sub_op":Terrain3DEditor.SUBTRACT })
+
+ add_child(HSeparator.new())
+
+ add_tool_button({ "tool":Terrain3DEditor.COLOR,
+ "add_text":"Paint Color", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_COLOR,
+ "sub_text":"Remove Color", "sub_op":Terrain3DEditor.SUBTRACT })
+
+ add_tool_button({ "tool":Terrain3DEditor.ROUGHNESS,
+ "add_text":"Paint Wetness", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_WETNESS,
+ "sub_text":"Remove Wetness", "sub_op":Terrain3DEditor.SUBTRACT })
+
+ add_child(HSeparator.new())
+
+ add_tool_button({ "tool":Terrain3DEditor.HOLES,
+ "add_text":"Add Holes", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_HOLES,
+ "sub_text":"Remove Holes", "sub_op":Terrain3DEditor.SUBTRACT })
+
+ add_tool_button({ "tool":Terrain3DEditor.NAVIGATION,
+ "add_text":"Paint Navigable Area", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_NAVIGATION,
+ "sub_text":"Remove Navigable Area", "sub_op":Terrain3DEditor.SUBTRACT })
+
+ add_tool_button({ "tool":Terrain3DEditor.INSTANCER,
+ "add_text":"Instance Meshes", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_INSTANCER,
+ "sub_text":"Remove Meshes", "sub_op":Terrain3DEditor.SUBTRACT })
+
+ # Select first button
+ var buttons: Array[BaseButton] = add_tool_group.get_buttons()
+ buttons[0].set_pressed(true)
+ show_add_buttons(true)
+
+
+func add_tool_button(p_params: Dictionary) -> void:
+ # Additive button
+ var button := Button.new()
+ button.set_name(p_params.get("add_text", "blank").to_pascal_case())
+ button.set_meta("Tool", p_params.get("tool", 0))
+ button.set_meta("Operation", p_params.get("add_op", 0))
+ button.set_meta("ID", add_tool_group.get_buttons().size() + 1)
+ button.set_tooltip_text(p_params.get("add_text", "blank"))
+ button.set_button_icon(load(p_params.get("add_icon")))
+ button.set_flat(true)
+ button.set_toggle_mode(true)
+ button.set_h_size_flags(SIZE_SHRINK_END)
+ button.set_button_group(p_params.get("group", add_tool_group))
+ add_child(button, true)
+
+ # Subtractive button
+ var button2: Button
+ if p_params.has("sub_text"):
+ button2 = Button.new()
+ button2.set_name(p_params.get("sub_text", "blank").to_pascal_case())
+ button2.set_meta("Tool", p_params.get("tool", 0))
+ button2.set_meta("Operation", p_params.get("sub_op", 0))
+ button2.set_meta("ID", button.get_meta("ID"))
+ button2.set_tooltip_text(p_params.get("sub_text", "blank"))
+ button2.set_button_icon(load(p_params.get("sub_icon", p_params.get("add_icon"))))
+ button2.set_flat(true)
+ button2.set_toggle_mode(true)
+ button2.set_h_size_flags(SIZE_SHRINK_END)
+ else:
+ button2 = button.duplicate()
+ button2.set_button_group(p_params.get("group", sub_tool_group))
+ add_child(button2, true)
+
+
+func show_add_buttons(p_enable: bool) -> void:
+ for button in add_tool_group.get_buttons():
+ button.visible = p_enable
+ for button in sub_tool_group.get_buttons():
+ button.visible = !p_enable
+
+
+func _on_tool_selected(p_button: BaseButton) -> void:
+ # Select same tool on negative bar
+ var group: ButtonGroup = p_button.get_button_group()
+ var change_group: ButtonGroup = add_tool_group if group == sub_tool_group else sub_tool_group
+ var id: int = p_button.get_meta("ID", -2)
+ for button in change_group.get_buttons():
+ button.set_pressed_no_signal(button.get_meta("ID", -1) == id)
+
+ emit_signal("tool_changed", p_button.get_meta("Tool", Terrain3DEditor.TOOL_MAX), p_button.get_meta("Operation", Terrain3DEditor.OP_MAX))
diff --git a/addons/terrain_3d/src/toolbar.gd.uid b/addons/terrain_3d/src/toolbar.gd.uid
new file mode 100644
index 0000000..6ba79d5
--- /dev/null
+++ b/addons/terrain_3d/src/toolbar.gd.uid
@@ -0,0 +1 @@
+uid://b1j37u6utjbom
diff --git a/addons/terrain_3d/src/ui.gd b/addons/terrain_3d/src/ui.gd
new file mode 100644
index 0000000..1c16fff
--- /dev/null
+++ b/addons/terrain_3d/src/ui.gd
@@ -0,0 +1,574 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# UI for Terrain3D
+extends Node
+
+
+# Includes
+const TerrainMenu: Script = preload("res://addons/terrain_3d/menu/terrain_menu.gd")
+const Toolbar: Script = preload("res://addons/terrain_3d/src/toolbar.gd")
+const ToolSettings: Script = preload("res://addons/terrain_3d/src/tool_settings.gd")
+const OperationBuilder: Script = preload("res://addons/terrain_3d/src/operation_builder.gd")
+const GradientOperationBuilder: Script = preload("res://addons/terrain_3d/src/gradient_operation_builder.gd")
+const COLOR_RAISE := Color.WHITE
+const COLOR_LOWER := Color.BLACK
+const COLOR_SMOOTH := Color(0.5, 0, .2)
+const COLOR_LIFT := Color.ORANGE
+const COLOR_FLATTEN := Color.BLUE_VIOLET
+const COLOR_HEIGHT := Color(0., 0.32, .4)
+const COLOR_SLOPE := Color.YELLOW
+const COLOR_PAINT := Color.DARK_GREEN
+const COLOR_SPRAY := Color.PALE_GREEN
+const COLOR_ROUGHNESS := Color.ROYAL_BLUE
+const COLOR_AUTOSHADER := Color.DODGER_BLUE
+const COLOR_HOLES := Color.BLACK
+const COLOR_NAVIGATION := Color(.28, .0, .25)
+const COLOR_INSTANCER := Color.CRIMSON
+const COLOR_PICK_COLOR := Color.WHITE
+const COLOR_PICK_HEIGHT := Color.DARK_RED
+const COLOR_PICK_ROUGH := Color.ROYAL_BLUE
+
+const OP_NONE: int = 0x0
+const OP_POSITIVE_ONLY: int = 0x01
+const OP_NEGATIVE_ONLY: int = 0x02
+
+const RING1: String = "res://addons/terrain_3d/brushes/ring1.exr"
+var ring_texture : ImageTexture
+@onready var region_texture := ImageTexture.new() :
+ set(value):
+ var image: Image = Image.create_empty(1, 1, false, Image.FORMAT_R8)
+ image.fill(Color.WHITE)
+ value.create_from_image(image)
+ region_texture = value
+var plugin: EditorPlugin # Actually Terrain3DEditorPlugin, but Godot still has CRC errors
+var toolbar: Toolbar
+var tool_settings: ToolSettings
+var terrain_menu: TerrainMenu
+var setting_has_changed: bool = false
+var visible: bool = false
+var picking: int = Terrain3DEditor.TOOL_MAX
+var picking_callback: Callable
+var brush_data: Dictionary
+var operation_builder: OperationBuilder
+var last_tool: Terrain3DEditor.Tool
+var last_operation: Terrain3DEditor.Operation
+var last_rmb_time: int = 0 # Set in editor.gd
+
+# Editor decals, indices; 0 = main brush, 1 = slope point A, 2 = slope point B
+var mat_rid: RID
+var editor_decal_position: Array[Vector2] = [Vector2(), Vector2(), Vector2()]
+var editor_decal_rotation: Array[float] = [float(), float(), float()]
+var editor_decal_size: Array[float] = [float(), float(), float()]
+var editor_decal_color: Array[Color] = [Color(), Color(), Color()]
+var editor_decal_visible: Array[bool] = [bool(), bool(), bool()]
+var editor_brush_texture_rid: RID = RID()
+var editor_decal_timer: Timer
+var editor_decal_fade: float :
+ set(value):
+ editor_decal_fade = value
+ if editor_decal_color.size() > 0:
+ editor_decal_color[0].a = value
+ if is_shader_valid():
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_color", editor_decal_color)
+ if value < 0.001:
+ var r_map: PackedInt32Array = plugin.terrain.data.get_region_map()
+ RenderingServer.material_set_param(mat_rid, "_region_map", r_map)
+var editor_ring_texture_rid: RID
+
+
+func _enter_tree() -> void:
+ toolbar = Toolbar.new()
+ toolbar.hide()
+ toolbar.tool_changed.connect(_on_tool_changed)
+
+ tool_settings = ToolSettings.new()
+ tool_settings.setting_changed.connect(_on_setting_changed)
+ tool_settings.picking.connect(_on_picking)
+ tool_settings.plugin = plugin
+ tool_settings.hide()
+
+ terrain_menu = TerrainMenu.new()
+ terrain_menu.plugin = plugin
+ terrain_menu.hide()
+
+ plugin.add_control_to_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_SIDE_LEFT, toolbar)
+ plugin.add_control_to_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_BOTTOM, tool_settings)
+ plugin.add_control_to_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_MENU, terrain_menu)
+
+ _on_tool_changed(Terrain3DEditor.REGION, Terrain3DEditor.ADD)
+
+ editor_decal_timer = Timer.new()
+ editor_decal_timer.wait_time = .5
+ editor_decal_timer.one_shot = true
+ editor_decal_timer.timeout.connect(func():
+ get_tree().create_tween().tween_property(self, "editor_decal_fade", 0.0, 0.15))
+ add_child(editor_decal_timer)
+
+
+func _ready() -> void:
+ var img: Image = Image.load_from_file(RING1)
+ img.convert(Image.FORMAT_R8)
+ ring_texture = ImageTexture.create_from_image(img)
+ editor_ring_texture_rid = ring_texture.get_rid()
+
+
+func _exit_tree() -> void:
+ plugin.remove_control_from_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_SIDE_LEFT, toolbar)
+ plugin.remove_control_from_container(EditorPlugin.CONTAINER_SPATIAL_EDITOR_BOTTOM, tool_settings)
+ toolbar.queue_free()
+ tool_settings.queue_free()
+ terrain_menu.queue_free()
+ editor_decal_timer.queue_free()
+
+
+func set_visible(p_visible: bool, p_menu_only: bool = false) -> void:
+ terrain_menu.set_visible(p_visible)
+
+ if p_menu_only:
+ toolbar.set_visible(false)
+ tool_settings.set_visible(false)
+ else:
+ visible = p_visible
+ toolbar.set_visible(p_visible)
+ tool_settings.set_visible(p_visible)
+ update_decal()
+
+ if(plugin.editor):
+ if(p_visible):
+ await get_tree().create_timer(.01).timeout # Won't work, otherwise
+ _on_tool_changed(last_tool, last_operation)
+ else:
+ plugin.editor.set_tool(Terrain3DEditor.TOOL_MAX)
+ plugin.editor.set_operation(Terrain3DEditor.OP_MAX)
+
+
+func set_menu_visibility(p_list: Control, p_visible: bool) -> void:
+ if p_list:
+ p_list.get_parent().get_parent().visible = p_visible
+
+
+func _on_tool_changed(p_tool: Terrain3DEditor.Tool, p_operation: Terrain3DEditor.Operation) -> void:
+ clear_picking()
+ set_menu_visibility(tool_settings.advanced_list, true)
+ set_menu_visibility(tool_settings.scale_list, false)
+ set_menu_visibility(tool_settings.rotation_list, false)
+ set_menu_visibility(tool_settings.height_list, false)
+ set_menu_visibility(tool_settings.color_list, false)
+
+ # Select which settings to show. Options in tool_settings.gd:_ready
+ var to_show: PackedStringArray = []
+
+ match p_tool:
+ Terrain3DEditor.REGION:
+ to_show.push_back("instructions")
+ to_show.push_back("remove")
+ set_menu_visibility(tool_settings.advanced_list, false)
+
+ Terrain3DEditor.SCULPT:
+ to_show.push_back("brush")
+ to_show.push_back("size")
+ to_show.push_back("strength")
+ if p_operation in [Terrain3DEditor.ADD, Terrain3DEditor.SUBTRACT]:
+ to_show.push_back("remove")
+ elif p_operation == Terrain3DEditor.GRADIENT:
+ to_show.push_back("gradient_points")
+ to_show.push_back("drawable")
+
+ Terrain3DEditor.HEIGHT:
+ to_show.push_back("brush")
+ to_show.push_back("size")
+ to_show.push_back("strength")
+ to_show.push_back("height")
+ to_show.push_back("height_picker")
+
+ Terrain3DEditor.TEXTURE:
+ to_show.push_back("brush")
+ to_show.push_back("size")
+ to_show.push_back("enable_texture")
+ if p_operation == Terrain3DEditor.ADD:
+ to_show.push_back("strength")
+ to_show.push_back("slope")
+ to_show.push_back("enable_angle")
+ to_show.push_back("angle")
+ to_show.push_back("angle_picker")
+ to_show.push_back("dynamic_angle")
+ to_show.push_back("enable_scale")
+ to_show.push_back("scale")
+ to_show.push_back("scale_picker")
+
+ Terrain3DEditor.COLOR:
+ to_show.push_back("brush")
+ to_show.push_back("size")
+ to_show.push_back("strength")
+ to_show.push_back("color")
+ to_show.push_back("color_picker")
+ to_show.push_back("slope")
+ to_show.push_back("texture_filter")
+ to_show.push_back("margin")
+ to_show.push_back("remove")
+
+ Terrain3DEditor.ROUGHNESS:
+ to_show.push_back("brush")
+ to_show.push_back("size")
+ to_show.push_back("strength")
+ to_show.push_back("roughness")
+ to_show.push_back("roughness_picker")
+ to_show.push_back("slope")
+ to_show.push_back("texture_filter")
+ to_show.push_back("margin")
+ to_show.push_back("remove")
+
+ Terrain3DEditor.AUTOSHADER, Terrain3DEditor.HOLES, Terrain3DEditor.NAVIGATION:
+ to_show.push_back("brush")
+ to_show.push_back("size")
+ to_show.push_back("remove")
+
+ Terrain3DEditor.INSTANCER:
+ to_show.push_back("size")
+ to_show.push_back("strength")
+ to_show.push_back("slope")
+ set_menu_visibility(tool_settings.height_list, true)
+ to_show.push_back("height_offset")
+ to_show.push_back("random_height")
+ set_menu_visibility(tool_settings.scale_list, true)
+ to_show.push_back("fixed_scale")
+ to_show.push_back("random_scale")
+ set_menu_visibility(tool_settings.rotation_list, true)
+ to_show.push_back("fixed_spin")
+ to_show.push_back("random_spin")
+ to_show.push_back("fixed_tilt")
+ to_show.push_back("random_tilt")
+ to_show.push_back("align_to_normal")
+ set_menu_visibility(tool_settings.color_list, true)
+ to_show.push_back("vertex_color")
+ to_show.push_back("random_darken")
+ to_show.push_back("random_hue")
+ to_show.push_back("remove")
+
+ _:
+ pass
+
+ # Advanced menu settings
+ to_show.push_back("auto_regions")
+ to_show.push_back("align_to_view")
+ to_show.push_back("show_cursor_while_painting")
+ to_show.push_back("gamma")
+ to_show.push_back("jitter")
+ to_show.push_back("crosshair_threshold")
+ tool_settings.show_settings(to_show)
+
+ operation_builder = null
+ if p_operation == Terrain3DEditor.GRADIENT:
+ operation_builder = GradientOperationBuilder.new()
+ operation_builder.tool_settings = tool_settings
+
+ if plugin.editor:
+ plugin.editor.set_tool(p_tool)
+ plugin.editor.set_operation(_modify_operation(p_operation))
+ last_tool = p_tool
+ last_operation = p_operation
+
+ _on_setting_changed()
+ plugin.update_region_grid()
+
+
+func _on_setting_changed() -> void:
+ if not plugin.asset_dock:
+ return
+ brush_data = tool_settings.get_settings()
+ brush_data["asset_id"] = plugin.asset_dock.get_current_list().get_selected_id()
+ plugin.editor.set_brush_data(brush_data)
+ plugin.editor.set_operation(_modify_operation(plugin.editor.get_operation()))
+ update_decal()
+
+
+func update_modifiers() -> void:
+ toolbar.show_add_buttons(not plugin.modifier_ctrl)
+
+ if plugin.modifier_shift and not plugin.modifier_ctrl:
+ plugin.editor.set_tool(Terrain3DEditor.SCULPT)
+ plugin.editor.set_operation(Terrain3DEditor.AVERAGE)
+ else:
+ plugin.editor.set_tool(last_tool)
+ if plugin.modifier_ctrl:
+ plugin.editor.set_operation(_modify_operation(last_operation))
+ else:
+ plugin.editor.set_operation(last_operation)
+
+
+func _modify_operation(p_operation: Terrain3DEditor.Operation) -> Terrain3DEditor.Operation:
+ var remove_checked: bool = false
+ if DisplayServer.is_touchscreen_available():
+ var removable_tools := [Terrain3DEditor.REGION, Terrain3DEditor.SCULPT, Terrain3DEditor.HEIGHT, Terrain3DEditor.AUTOSHADER,
+ Terrain3DEditor.HOLES, Terrain3DEditor.INSTANCER, Terrain3DEditor.NAVIGATION,
+ Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS]
+ remove_checked = brush_data.get("remove", false) && plugin.editor.get_tool() in removable_tools
+
+ if plugin.modifier_ctrl or remove_checked:
+ return _invert_operation(p_operation, OP_NEGATIVE_ONLY)
+ return _invert_operation(p_operation, OP_POSITIVE_ONLY)
+
+
+func _invert_operation(p_operation: Terrain3DEditor.Operation, flags: int = OP_NONE) -> Terrain3DEditor.Operation:
+ if p_operation == Terrain3DEditor.ADD and ! (flags & OP_POSITIVE_ONLY):
+ return Terrain3DEditor.SUBTRACT
+ elif p_operation == Terrain3DEditor.SUBTRACT and ! (flags & OP_NEGATIVE_ONLY):
+ return Terrain3DEditor.ADD
+ return p_operation
+
+
+func update_decal() -> void:
+ if not plugin.terrain or brush_data.size() <= 3:
+ return
+ mat_rid = plugin.terrain.material.get_material_rid()
+ editor_decal_timer.start()
+
+ # If not a state that should show the decal, hide everything and return
+ if not visible or \
+ plugin._input_mode < 0 or \
+ # Wait for cursor to recenter after moving camera before revealing
+ # See https://github.com/godotengine/godot/issues/70098
+ Time.get_ticks_msec() - last_rmb_time <= 30 or \
+ (plugin._input_mode > 0 and not brush_data["show_cursor_while_painting"]):
+ hide_decal()
+ return
+
+ reset_decal_arrays()
+ editor_decal_position[0] = Vector2(plugin.mouse_global_position.x, plugin.mouse_global_position.z)
+ editor_decal_visible[0] = true
+ # Set region size, and modify region map for none background mode.
+ var r_map: PackedInt32Array = plugin.terrain.data.get_region_map()
+ if plugin.editor.get_tool() == Terrain3DEditor.REGION:
+ var r_size: float = float(plugin.terrain.get_region_size()) * plugin.terrain.get_vertex_spacing()
+ var map_size: int = plugin.terrain.data.REGION_MAP_SIZE
+ var half_r_size: float = r_size * 0.5
+ var pos: Vector2 = (Vector2(plugin.mouse_global_position.x, plugin.mouse_global_position.z) +
+ Vector2(half_r_size, half_r_size)).snappedf(r_size) - Vector2(half_r_size, half_r_size)
+ editor_brush_texture_rid = region_texture.get_rid()
+ editor_decal_position[0] = pos
+ editor_decal_size[0] = r_size
+ editor_decal_rotation[0] = 0.0
+
+ var loc: Vector2i = plugin.terrain.data.get_region_location(plugin.mouse_global_position)
+ loc += Vector2i(map_size / 2, map_size / 2)
+ if !(loc.x < 0 or loc.x > map_size - 1 or loc.y < 0 or loc.y > map_size - 1):
+ var index: int = clampi(loc.y * map_size + loc.x, 0, map_size * map_size - 1)
+ if plugin.terrain.material.get_world_background() == Terrain3DMaterial.WorldBackground.NONE:
+ if r_map[index] == 0 and plugin.editor.get_operation() == Terrain3DEditor.ADD:
+ r_map[index] = -index - 1
+ else:
+ r_map[index] = r_map[index]
+
+ match plugin.editor.get_operation():
+ Terrain3DEditor.ADD:
+ if r_map[index] <= 0:
+ editor_decal_color[0] = Color.WHITE
+ editor_decal_color[0].a = 0.25
+ else:
+ hide_decal()
+
+ Terrain3DEditor.SUBTRACT:
+ if r_map[index] > 0:
+ editor_decal_color[0] = Color.WHITE * .15
+ editor_decal_color[0].a = 0.75
+ else:
+ hide_decal()
+ else:
+ hide_decal()
+ # Set texture and color
+ elif picking != Terrain3DEditor.TOOL_MAX:
+ editor_brush_texture_rid = ring_texture.get_rid()
+ editor_decal_size[0] = 10. * plugin.terrain.get_vertex_spacing()
+ match picking:
+ Terrain3DEditor.HEIGHT:
+ editor_decal_color[0] = COLOR_PICK_HEIGHT
+ Terrain3DEditor.COLOR:
+ editor_decal_color[0] = COLOR_PICK_COLOR
+ Terrain3DEditor.ROUGHNESS:
+ editor_decal_color[0] = COLOR_PICK_ROUGH
+ editor_decal_color[0].a = 1.0
+ else:
+ editor_brush_texture_rid = brush_data["brush"][1].get_rid()
+ editor_decal_size[0] = maxf(brush_data["size"], .5)
+ if brush_data["align_to_view"]:
+ var cam: Camera3D = plugin.terrain.get_camera();
+ if (cam):
+ editor_decal_rotation[0] = cam.rotation.y
+ else:
+ editor_decal_rotation[0] = 0.
+ match plugin.editor.get_tool():
+ Terrain3DEditor.SCULPT:
+ match plugin.editor.get_operation():
+ Terrain3DEditor.ADD:
+ if plugin.modifier_alt:
+ editor_decal_color[0] = COLOR_LIFT
+ editor_decal_color[0].a = clamp(brush_data["strength"], .2, .5)
+ else:
+ editor_decal_color[0] = COLOR_RAISE
+ editor_decal_color[0].a = clamp(brush_data["strength"], .25, .5)
+ Terrain3DEditor.SUBTRACT:
+ if plugin.modifier_alt:
+ editor_decal_color[0] = COLOR_FLATTEN
+ editor_decal_color[0].a = clamp(brush_data["strength"], .25, .5) + .1
+ else:
+ editor_decal_color[0] = COLOR_LOWER
+ editor_decal_color[0].a = clamp(brush_data["strength"], .2, .5) + .25
+ Terrain3DEditor.AVERAGE:
+ editor_decal_color[0] = COLOR_SMOOTH
+ editor_decal_color[0].a = clamp(brush_data["strength"], .2, .5) + .25
+ Terrain3DEditor.GRADIENT:
+ editor_decal_color[0] = COLOR_SLOPE
+ editor_decal_color[0].a = clamp(brush_data["strength"], .2, .4)
+ Terrain3DEditor.HEIGHT:
+ editor_decal_color[0] = COLOR_HEIGHT
+ editor_decal_color[0].a = clamp(brush_data["strength"], .2, .5) + .25
+ Terrain3DEditor.TEXTURE:
+ match plugin.editor.get_operation():
+ Terrain3DEditor.REPLACE:
+ editor_decal_color[0] = COLOR_PAINT
+ editor_decal_color[0].a = .6
+ Terrain3DEditor.SUBTRACT:
+ editor_decal_color[0] = COLOR_PAINT
+ editor_decal_color[0].a = clamp(brush_data["strength"], .2, .5) + .1
+ Terrain3DEditor.ADD:
+ editor_decal_color[0] = COLOR_SPRAY
+ editor_decal_color[0].a = clamp(brush_data["strength"], .15, .4)
+ Terrain3DEditor.COLOR:
+ editor_decal_color[0] = brush_data["color"].srgb_to_linear()
+ editor_decal_color[0].a *= clamp(brush_data["strength"], .2, .5)
+ Terrain3DEditor.ROUGHNESS:
+ editor_decal_color[0] = COLOR_ROUGHNESS
+ editor_decal_color[0].a = clamp(brush_data["strength"], .2, .5) + .1
+ Terrain3DEditor.AUTOSHADER:
+ editor_decal_color[0] = COLOR_AUTOSHADER
+ editor_decal_color[0].a = .6
+ Terrain3DEditor.HOLES:
+ editor_decal_color[0] = COLOR_HOLES
+ editor_decal_color[0].a = .75
+ Terrain3DEditor.NAVIGATION:
+ editor_decal_color[0] = COLOR_NAVIGATION
+ editor_decal_color[0].a = .80
+ Terrain3DEditor.INSTANCER:
+ editor_brush_texture_rid = ring_texture.get_rid()
+ editor_decal_color[0] = COLOR_INSTANCER
+ editor_decal_color[0].a = .75
+
+ editor_decal_visible[1] = false
+ editor_decal_visible[2] = false
+
+ if plugin.editor.get_operation() == Terrain3DEditor.GRADIENT:
+ var point1: Vector3 = brush_data["gradient_points"][0]
+ if point1 != Vector3.ZERO:
+ editor_decal_color[1] = COLOR_SLOPE
+ editor_decal_size[1] = 10. * plugin.terrain.get_vertex_spacing()
+ editor_decal_visible[1] = true
+ editor_decal_position[1] = Vector2(point1.x, point1.z)
+ var point2: Vector3 = brush_data["gradient_points"][1]
+ if point2 != Vector3.ZERO:
+ editor_decal_color[2] = COLOR_SLOPE
+ editor_decal_size[2] = 10. * plugin.terrain.get_vertex_spacing()
+ editor_decal_visible[2] = true
+ editor_decal_position[2] = Vector2(point2.x, point2.z)
+
+ if RenderingServer.get_current_rendering_method().contains("gl_compatibility"):
+ for i in editor_decal_color.size():
+ editor_decal_color[i].a = maxf(0.1, editor_decal_color[i].a - .25)
+
+ editor_decal_fade = editor_decal_color[0].a
+ # Update Shader params
+ if is_shader_valid():
+ RenderingServer.material_set_param(mat_rid, "_editor_brush_texture", editor_brush_texture_rid)
+ RenderingServer.material_set_param(mat_rid, "_editor_ring_texture", editor_ring_texture_rid)
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_position", editor_decal_position)
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_rotation", editor_decal_rotation)
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_size", editor_decal_size)
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_color", editor_decal_color)
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_visible", editor_decal_visible)
+ RenderingServer.material_set_param(mat_rid, "_editor_crosshair_threshold", brush_data["crosshair_threshold"] + 0.1)
+ RenderingServer.material_set_param(mat_rid, "_region_map", r_map)
+
+
+func is_shader_valid() -> bool:
+ # As long as the compiled shader contains at least 1 uniform, we can use it to check
+ # if the shader compilation has failed, as this will then return an empty dictionary.
+ if not plugin.terrain:
+ return false
+ var params = RenderingServer.get_shader_parameter_list(plugin.terrain.material.get_shader_rid())
+ if params.is_empty():
+ return false
+ else:
+ return true
+
+
+func hide_decal() -> void:
+ editor_decal_visible = [false, false, false]
+ if is_shader_valid():
+ var r_map: PackedInt32Array = plugin.terrain.data.get_region_map()
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_visible", editor_decal_visible)
+ RenderingServer.material_set_param(mat_rid, "_region_map", r_map)
+
+
+# These array sizes are reset to 0 when closing scenes for some unknown reason, so check and reset
+func reset_decal_arrays() -> void:
+ if editor_decal_color.size() < 3:
+ editor_decal_position = [Vector2(), Vector2(), Vector2()]
+ editor_decal_rotation = [float(), float(), float()]
+ editor_decal_size = [float(), float(), float()]
+ editor_decal_color = [Color(), Color(), Color()]
+ editor_decal_visible = [false, false, false]
+ editor_brush_texture_rid = RID()
+
+
+func set_decal_rotation(p_rot: float) -> void:
+ editor_decal_rotation[0] = p_rot
+ if is_shader_valid():
+ RenderingServer.material_set_param(mat_rid, "_editor_decal_rotation", editor_decal_rotation)
+
+
+func _on_picking(p_type: int, p_callback: Callable) -> void:
+ picking = p_type
+ picking_callback = p_callback
+ update_decal()
+
+
+func clear_picking() -> void:
+ picking = Terrain3DEditor.TOOL_MAX
+
+
+func is_picking() -> bool:
+ if picking != Terrain3DEditor.TOOL_MAX:
+ return true
+
+ if operation_builder and operation_builder.is_picking():
+ return true
+
+ return false
+
+
+func pick(p_global_position: Vector3) -> void:
+ if picking != Terrain3DEditor.TOOL_MAX:
+ var color: Color
+ match picking:
+ Terrain3DEditor.HEIGHT, Terrain3DEditor.SCULPT:
+ color = Color(plugin.terrain.data.get_height(p_global_position), 0., 0., 1.)
+ Terrain3DEditor.ROUGHNESS:
+ color = plugin.terrain.data.get_pixel(Terrain3DRegion.TYPE_COLOR, p_global_position)
+ Terrain3DEditor.COLOR:
+ color = plugin.terrain.data.get_color(p_global_position)
+ Terrain3DEditor.ANGLE:
+ color = Color(plugin.terrain.data.get_control_angle(p_global_position), 0., 0., 1.)
+ Terrain3DEditor.SCALE:
+ color = Color(plugin.terrain.data.get_control_scale(p_global_position), 0., 0., 1.)
+ _:
+ push_error("Unsupported picking type: ", picking)
+ return
+ if picking_callback.is_valid():
+ picking_callback.call(picking, color, p_global_position)
+ picking_callback = Callable()
+ picking = Terrain3DEditor.TOOL_MAX
+
+ elif operation_builder and operation_builder.is_picking():
+ operation_builder.pick(p_global_position, plugin.terrain)
+
+
+func set_button_editor_icon(p_button: Button, p_icon_name: String) -> void:
+ p_button.icon = EditorInterface.get_base_control().get_theme_icon(p_icon_name, "EditorIcons")
diff --git a/addons/terrain_3d/src/ui.gd.uid b/addons/terrain_3d/src/ui.gd.uid
new file mode 100644
index 0000000..bc20eee
--- /dev/null
+++ b/addons/terrain_3d/src/ui.gd.uid
@@ -0,0 +1 @@
+uid://bpad72s36mwkx
diff --git a/addons/terrain_3d/terrain.gdextension b/addons/terrain_3d/terrain.gdextension
new file mode 100644
index 0000000..000d085
--- /dev/null
+++ b/addons/terrain_3d/terrain.gdextension
@@ -0,0 +1,32 @@
+[configuration]
+
+entry_symbol = "terrain_3d_init"
+compatibility_minimum = 4.4
+
+[icons]
+
+Terrain3D = "res://addons/terrain_3d/icons/terrain3d.svg"
+
+[libraries]
+
+windows.debug.x86_64 = "res://addons/terrain_3d/bin/libterrain.windows.debug.x86_64.dll"
+windows.release.x86_64 = "res://addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll"
+
+linux.debug.x86_64 = "res://addons/terrain_3d/bin/libterrain.linux.debug.x86_64.so"
+linux.release.x86_64 = "res://addons/terrain_3d/bin/libterrain.linux.release.x86_64.so"
+linux.debug.arm64 = "res://addons/terrain_3d/bin/libterrain.linux.debug.arm64.so"
+linux.release.arm64 = "res://addons/terrain_3d/bin/libterrain.linux.release.arm64.so"
+linux.debug.rv64 = "res://addons/terrain_3d/bin/libterrain.linux.debug.rv64.so"
+linux.release.rv64 = "res://addons/terrain_3d/bin/libterrain.linux.release.rv64.so"
+
+macos.debug = "res://addons/terrain_3d/bin/libterrain.macos.debug.framework"
+macos.release = "res://addons/terrain_3d/bin/libterrain.macos.release.framework"
+
+android.debug.arm64 = "res://addons/terrain_3d/bin/libterrain.android.debug.arm64.so"
+android.release.arm64 = "res://addons/terrain_3d/bin/libterrain.android.release.arm64.so"
+
+ios.debug = "res://addons/terrain_3d/bin/libterrain.ios.debug.universal.dylib"
+ios.release = "res://addons/terrain_3d/bin/libterrain.ios.release.universal.dylib"
+
+web.debug = "res://addons/terrain_3d/bin/libterrain.web.debug.wasm32.wasm"
+web.release = "res://addons/terrain_3d/bin/libterrain.web.release.wasm32.wasm"
diff --git a/addons/terrain_3d/terrain.gdextension.uid b/addons/terrain_3d/terrain.gdextension.uid
new file mode 100644
index 0000000..ef58a55
--- /dev/null
+++ b/addons/terrain_3d/terrain.gdextension.uid
@@ -0,0 +1 @@
+uid://bdn2ygldx56a8
diff --git a/addons/terrain_3d/tools/importer.gd b/addons/terrain_3d/tools/importer.gd
new file mode 100644
index 0000000..3dc0c10
--- /dev/null
+++ b/addons/terrain_3d/tools/importer.gd
@@ -0,0 +1,107 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Importer for Terrain3D
+@tool
+extends Terrain3D
+
+
+@export var clear_all: bool = false : set = reset_settings
+@export var clear_terrain: bool = false : set = reset_terrain
+@export var update_height_range: bool = false : set = update_heights
+
+
+func reset_settings(p_value) -> void:
+ if p_value:
+ height_file_name = ""
+ control_file_name = ""
+ color_file_name = ""
+ destination_directory = ""
+ import_position = Vector2i.ZERO
+ height_offset = 0.0
+ import_scale = 1.0
+ r16_range = Vector2(0, 1)
+ r16_size = Vector2i(1024, 1024)
+ material = null
+ assets = null
+ reset_terrain(true)
+
+
+func reset_terrain(p_value) -> void:
+ data_directory = ""
+ for region:Terrain3DRegion in data.get_regions_active():
+ data.remove_region(region, false)
+ data.update_maps(Terrain3DRegion.TYPE_MAX, true, false)
+
+
+func update_heights(p_value) -> void:
+ if p_value and data:
+ data.update_height_range()
+
+
+@export_group("Import File")
+@export_global_file var height_file_name: String = ""
+@export_global_file var control_file_name: String = ""
+@export_global_file var color_file_name: String = ""
+@export var import_position: Vector2i = Vector2i(0, 0) : set = set_import_position
+@export var import_scale: float = 1.0
+@export var height_offset: float = 0.0
+@export var r16_range: Vector2 = Vector2(0, 1)
+@export var r16_size: Vector2i = Vector2i(1024, 1024) : set = set_r16_size
+@export var run_import: bool = false : set = start_import
+
+@export_dir var destination_directory: String = ""
+@export var save_to_disk: bool = false : set = save_data
+
+
+func set_import_position(p_value: Vector2i) -> void:
+ import_position.x = clamp(p_value.x, -8192, 8192)
+ import_position.y = clamp(p_value.y, -8192, 8192)
+
+
+func set_r16_size(p_value: Vector2i) -> void:
+ r16_size.x = clamp(p_value.x, 0, 16384)
+ r16_size.y = clamp(p_value.y, 0, 16384)
+
+
+func start_import(p_value: bool) -> void:
+ if p_value:
+ print("Terrain3DImporter: Importing files:\n\t%s\n\t%s\n\t%s" % [ height_file_name, control_file_name, color_file_name])
+
+ var imported_images: Array[Image]
+ imported_images.resize(Terrain3DRegion.TYPE_MAX)
+ var min_max := Vector2(0, 1)
+ var img: Image
+ if height_file_name:
+ img = Terrain3DUtil.load_image(height_file_name, ResourceLoader.CACHE_MODE_IGNORE, r16_range, r16_size)
+ min_max = Terrain3DUtil.get_min_max(img)
+ imported_images[Terrain3DRegion.TYPE_HEIGHT] = img
+ if control_file_name:
+ img = Terrain3DUtil.load_image(control_file_name, ResourceLoader.CACHE_MODE_IGNORE)
+ imported_images[Terrain3DRegion.TYPE_CONTROL] = img
+ if color_file_name:
+ img = Terrain3DUtil.load_image(color_file_name, ResourceLoader.CACHE_MODE_IGNORE)
+ imported_images[Terrain3DRegion.TYPE_COLOR] = img
+ if assets.get_texture_count() == 0:
+ material.show_checkered = false
+ material.show_colormap = true
+ var pos := Vector3(import_position.x, 0, import_position.y)
+ data.import_images(imported_images, pos, height_offset, import_scale)
+ print("Terrain3DImporter: Import finished")
+
+
+func save_data(p_value: bool) -> void:
+ if destination_directory.is_empty():
+ push_error("Set destination directory first")
+ return
+ data.save_directory(destination_directory)
+
+
+@export_group("Export File")
+enum { TYPE_HEIGHT, TYPE_CONTROL, TYPE_COLOR }
+@export_enum("Height:0", "Control:1", "Color:2") var map_type: int = TYPE_HEIGHT
+@export var file_name_out: String = ""
+@export var run_export: bool = false : set = start_export
+
+func start_export(p_value: bool) -> void:
+ var err: int = data.export_image(file_name_out, map_type)
+ print("Terrain3DImporter: Export error status: ", err, " ", error_string(err))
+
diff --git a/addons/terrain_3d/tools/importer.gd.uid b/addons/terrain_3d/tools/importer.gd.uid
new file mode 100644
index 0000000..7a59538
--- /dev/null
+++ b/addons/terrain_3d/tools/importer.gd.uid
@@ -0,0 +1 @@
+uid://cib2rig7vup10
diff --git a/addons/terrain_3d/tools/importer.tscn b/addons/terrain_3d/tools/importer.tscn
new file mode 100644
index 0000000..4bc43c3
--- /dev/null
+++ b/addons/terrain_3d/tools/importer.tscn
@@ -0,0 +1,63 @@
+[gd_scene load_steps=9 format=3 uid="uid://blaieaqp413k7"]
+
+[ext_resource type="Script" path="res://addons/terrain_3d/tools/importer.gd" id="1_60b8f"]
+
+[sub_resource type="Gradient" id="Gradient_88f3t"]
+offsets = PackedFloat32Array(0.2, 1)
+colors = PackedColorArray(1, 1, 1, 1, 0, 0, 0, 1)
+
+[sub_resource type="FastNoiseLite" id="FastNoiseLite_muvel"]
+noise_type = 2
+frequency = 0.03
+cellular_jitter = 3.0
+cellular_return_type = 0
+domain_warp_enabled = true
+domain_warp_type = 1
+domain_warp_amplitude = 50.0
+domain_warp_fractal_type = 2
+domain_warp_fractal_lacunarity = 1.5
+domain_warp_fractal_gain = 1.0
+
+[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_ve0yk"]
+seamless = true
+color_ramp = SubResource("Gradient_88f3t")
+noise = SubResource("FastNoiseLite_muvel")
+
+[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_p55u0"]
+_shader_parameters = {
+"blend_sharpness": 0.87,
+"height_blending": true,
+"macro_variation1": Color(1, 1, 1, 1),
+"macro_variation2": Color(1, 1, 1, 1),
+"noise1_angle": 0.0,
+"noise1_offset": Vector2(0.5, 0.5),
+"noise1_scale": 0.04,
+"noise2_scale": 0.076,
+"noise3_scale": 0.225,
+"noise_texture": SubResource("NoiseTexture2D_ve0yk"),
+"vertex_normals_distance": 128.0
+}
+show_checkered = true
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_8rvqy"]
+cull_mode = 2
+vertex_color_use_as_albedo = true
+backlight_enabled = true
+backlight = Color(0.5, 0.5, 0.5, 1)
+
+[sub_resource type="Terrain3DMeshAsset" id="Terrain3DMeshAsset_7je72"]
+height_offset = 0.5
+density = 10.0
+material_override = SubResource("StandardMaterial3D_8rvqy")
+generated_type = 1
+
+[sub_resource type="Terrain3DAssets" id="Terrain3DAssets_op32e"]
+mesh_list = Array[Terrain3DMeshAsset]([SubResource("Terrain3DMeshAsset_7je72")])
+
+[node name="Importer" type="Terrain3D"]
+material = SubResource("Terrain3DMaterial_p55u0")
+assets = SubResource("Terrain3DAssets_op32e")
+mesh_lods = 8
+top_level = true
+script = ExtResource("1_60b8f")
+metadata/_edit_lock_ = true
diff --git a/addons/terrain_3d/utils/terrain_3d_objects.gd b/addons/terrain_3d/utils/terrain_3d_objects.gd
new file mode 100644
index 0000000..3a19f6d
--- /dev/null
+++ b/addons/terrain_3d/utils/terrain_3d_objects.gd
@@ -0,0 +1,191 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Objects parent for Terrain3D
+# Children nodes get transform updates on sculpting
+@tool
+extends Node3D
+class_name Terrain3DObjects
+
+const TransformChangedNotifier: Script = preload("res://addons/terrain_3d/utils/transform_changed_notifier.gd")
+
+const CHILD_HELPER_NAME: StringName = &"TransformChangedSignaller"
+const CHILD_HELPER_PATH: NodePath = ^"TransformChangedSignaller"
+
+var _undo_redo = null
+var _terrain_id: int
+var _offsets: Dictionary # Object ID -> Vector3(X, Y offset relative to terrain height, Z)
+var _ignore_transform_change: bool = false
+
+
+func _enter_tree() -> void:
+ if not Engine.is_editor_hint():
+ return
+
+ for child in get_children():
+ _on_child_entered_tree(child)
+
+ child_entered_tree.connect(_on_child_entered_tree)
+ child_exiting_tree.connect(_on_child_exiting_tree)
+
+
+func _exit_tree() -> void:
+ if not Engine.is_editor_hint():
+ return
+
+ child_entered_tree.disconnect(_on_child_entered_tree)
+ child_exiting_tree.disconnect(_on_child_exiting_tree)
+
+ for child in get_children():
+ _on_child_exiting_tree(child)
+
+
+func editor_setup(p_plugin) -> void:
+ _undo_redo = p_plugin.get_undo_redo()
+
+
+func get_terrain() -> Terrain3D:
+ var terrain := instance_from_id(_terrain_id) as Terrain3D
+ if not terrain or terrain.is_queued_for_deletion() or not terrain.is_inside_tree():
+ var terrains: Array[Node] = Engine.get_singleton(&"EditorInterface").get_edited_scene_root().find_children("", "Terrain3D")
+ if terrains.size() > 0:
+ terrain = terrains[0]
+ _terrain_id = terrain.get_instance_id() if terrain else 0
+
+ if terrain and terrain.data and not terrain.data.maps_edited.is_connected(_on_maps_edited):
+ terrain.data.maps_edited.connect(_on_maps_edited)
+
+ return terrain
+
+
+func _get_terrain_height(p_global_position: Vector3) -> float:
+ var terrain: Terrain3D = get_terrain()
+ if not terrain or not terrain.data:
+ return 0.0
+ var height: float = terrain.data.get_height(p_global_position)
+ if is_nan(height):
+ return 0.0
+ return height
+
+
+func _on_child_entered_tree(p_node: Node) -> void:
+ if not (p_node is Node3D):
+ return
+
+ assert(p_node.get_parent() == self)
+
+ var helper: TransformChangedNotifier = p_node.get_node_or_null(CHILD_HELPER_PATH)
+ if not helper:
+ helper = TransformChangedNotifier.new()
+ helper.name = CHILD_HELPER_NAME
+ p_node.add_child(helper, true, INTERNAL_MODE_BACK)
+ assert(p_node.has_node(CHILD_HELPER_PATH))
+
+ # When reparenting a Node3D, Godot changes its transform _after_ reparenting it. So here,
+ # we must use call_deferred, to avoid receiving transform_changed as a result of reparenting.
+ _setup_child_signal.call_deferred(p_node, helper)
+
+
+func _setup_child_signal(p_node: Node, helper: TransformChangedNotifier) -> void:
+ if not p_node.is_inside_tree():
+ return
+ if helper.transform_changed.is_connected(_on_child_transform_changed):
+ return
+
+ helper.transform_changed.connect(_on_child_transform_changed.bind(p_node))
+ _update_child_offset(p_node)
+
+
+func _on_child_exiting_tree(p_node: Node) -> void:
+ if not (p_node is Node3D) or not p_node.has_node(CHILD_HELPER_PATH):
+ return
+
+ var helper: TransformChangedNotifier = p_node.get_node_or_null(CHILD_HELPER_PATH)
+ if helper:
+ if helper.transform_changed.is_connected(_on_child_transform_changed):
+ helper.transform_changed.disconnect(_on_child_transform_changed)
+ p_node.remove_child(helper)
+ helper.queue_free()
+
+ _offsets.erase(p_node.get_instance_id())
+
+
+func _is_node_selected(p_node: Node) -> bool:
+ var editor_sel = Engine.get_singleton(&"EditorInterface").get_selection()
+ return editor_sel.get_transformable_selected_nodes().has(p_node)
+
+
+func _on_child_transform_changed(p_node: Node3D) -> void:
+ if _ignore_transform_change:
+ return
+
+ var lmb_down := Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)
+ if lmb_down and (_is_node_selected(p_node) or _is_node_selected(self)):
+ # The user may be moving the node using gizmos.
+ # We should wait until they're done before updating otherwise gizmos + this node conflict.
+ return
+
+ if not _offsets.has(p_node.get_instance_id()):
+ return
+
+ var old_offset: Vector3 = _offsets[p_node.get_instance_id()]
+ var old_h: float = _get_terrain_height(old_offset)
+ var old_position: Vector3 = old_offset + Vector3(0, old_h, 0)
+ var new_position: Vector3 = p_node.global_position
+ if old_position.is_equal_approx(new_position):
+ return
+ var new_h: float = _get_terrain_height(new_position)
+ var new_offset: Vector3 = new_position - Vector3(0, new_h, 0)
+
+ var translate_without_reposition: bool = Input.is_key_pressed(KEY_SHIFT)
+ var y_changed: bool = not is_equal_approx(old_position.y, p_node.global_position.y)
+ if not y_changed and not translate_without_reposition:
+ new_offset.y = old_offset.y
+ new_position = new_offset + Vector3(0, new_h, 0)
+
+ # Make sure that when the user undo's the translation, the offset change gets undone too!
+ _undo_redo.create_action("Translate", UndoRedo.MERGE_ALL)
+ _undo_redo.add_do_method(self, &"_set_offset_and_position", p_node.get_instance_id(), new_offset, new_position)
+ _undo_redo.add_undo_method(self, &"_set_offset_and_position", p_node.get_instance_id(), old_offset, old_position)
+ _undo_redo.commit_action()
+
+
+func _set_offset_and_position(p_id: int, p_offset: Vector3, p_position: Vector3) -> void:
+ var node := instance_from_id(p_id) as Node
+ if not is_instance_valid(node):
+ return
+
+ _ignore_transform_change = true
+ node.global_position = p_position
+ _offsets[p_id] = p_offset
+ _ignore_transform_change = false
+
+
+# Overwrite current offset stored for node with its current Y position relative to the terrain
+func _update_child_offset(p_node: Node3D) -> void:
+ var position: Vector3 = global_transform * p_node.position
+ var h: float = _get_terrain_height(position)
+ var offset: Vector3 = position - Vector3(0, h, 0)
+ _offsets[p_node.get_instance_id()] = offset
+
+
+# Overwrite node's current position with terrain height + stored offset for this node
+func _update_child_position(p_node: Node3D) -> void:
+ if not _offsets.has(p_node.get_instance_id()):
+ return
+
+ var position: Vector3 = global_transform * p_node.position
+ var h: float = _get_terrain_height(position)
+ var offset: Vector3 = _offsets[p_node.get_instance_id()]
+ var new_position: Vector3 = global_transform.inverse() * (offset + Vector3(0, h, 0))
+ if not p_node.position.is_equal_approx(new_position):
+ p_node.position = new_position
+
+
+func _on_maps_edited(p_edited_aabb: AABB) -> void:
+ var edited_area: AABB = p_edited_aabb.grow(1)
+ edited_area.position.y = -INF
+ edited_area.end.y = INF
+
+ for child in get_children():
+ var node := child as Node3D
+ if node && edited_area.has_point(node.global_position):
+ _update_child_position(node)
diff --git a/addons/terrain_3d/utils/terrain_3d_objects.gd.uid b/addons/terrain_3d/utils/terrain_3d_objects.gd.uid
new file mode 100644
index 0000000..3a1e873
--- /dev/null
+++ b/addons/terrain_3d/utils/terrain_3d_objects.gd.uid
@@ -0,0 +1 @@
+uid://dbndw8p05yam7
diff --git a/addons/terrain_3d/utils/transform_changed_notifier.gd b/addons/terrain_3d/utils/transform_changed_notifier.gd
new file mode 100644
index 0000000..5feaae8
--- /dev/null
+++ b/addons/terrain_3d/utils/transform_changed_notifier.gd
@@ -0,0 +1,16 @@
+# Copyright © 2025 Cory Petkovsek, Roope Palmroos, and Contributors.
+# Transform Changed Notifier for Terrain3D
+@tool
+extends Node3D
+
+signal transform_changed
+
+
+func _ready() -> void:
+ assert(Engine.is_editor_hint())
+ set_notify_transform(true)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_TRANSFORM_CHANGED:
+ transform_changed.emit()
diff --git a/addons/terrain_3d/utils/transform_changed_notifier.gd.uid b/addons/terrain_3d/utils/transform_changed_notifier.gd.uid
new file mode 100644
index 0000000..fc43825
--- /dev/null
+++ b/addons/terrain_3d/utils/transform_changed_notifier.gd.uid
@@ -0,0 +1 @@
+uid://claxtgppe8keq
diff --git a/assets/models/environment/fire_pit_env.glb.import b/assets/models/environment/fire_pit_env.glb.import
index c8bff7f..63a6ea9 100644
--- a/assets/models/environment/fire_pit_env.glb.import
+++ b/assets/models/environment/fire_pit_env.glb.import
@@ -32,6 +32,12 @@ animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
-_subresources={}
+_subresources={
+"nodes": {
+"PATH:Fire_Pit_Env": {
+"generate/physics": true
+}
+}
+}
gltf/naming_version=1
gltf/embedded_image_handling=1
diff --git a/assets/textures/smoke_texture.png b/assets/textures/smoke_texture.png
new file mode 100644
index 0000000..c71421e
--- /dev/null
+++ b/assets/textures/smoke_texture.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:18fa7bc7d5bbc4700978b4ebaac277e32262bcbb038953924c1b2895549978e6
+size 188346
diff --git a/assets/textures/smoke_texture.png.import b/assets/textures/smoke_texture.png.import
new file mode 100644
index 0000000..422c81d
--- /dev/null
+++ b/assets/textures/smoke_texture.png.import
@@ -0,0 +1,35 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://0acyl8dfoi6i"
+path.s3tc="res://.godot/imported/smoke_texture.png-9fda8d109e95054a6c1cc37a1bdbbd90.s3tc.ctex"
+metadata={
+"imported_formats": ["s3tc_bptc"],
+"vram_texture": true
+}
+
+[deps]
+
+source_file="res://assets/textures/smoke_texture.png"
+dest_files=["res://.godot/imported/smoke_texture.png-9fda8d109e95054a6c1cc37a1bdbbd90.s3tc.ctex"]
+
+[params]
+
+compress/mode=2
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=true
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=0
diff --git a/project.godot b/project.godot
index 958ee3f..731f163 100644
--- a/project.godot
+++ b/project.godot
@@ -15,6 +15,10 @@ run/main_scene="uid://dw0lbkh31rofd"
config/features=PackedStringArray("4.4", "Forward Plus")
config/icon="res://icon.svg"
+[editor_plugins]
+
+enabled=PackedStringArray("res://addons/sky_3d/plugin.cfg", "res://addons/terrain_3d/plugin.cfg")
+
[global_group]
Player=""
diff --git a/src/scenes/characters/enemy.tscn b/src/scenes/characters/enemy.tscn
index e5ed951..c446cae 100644
--- a/src/scenes/characters/enemy.tscn
+++ b/src/scenes/characters/enemy.tscn
@@ -73,10 +73,7 @@ bones/26/position = Vector3(2.04767e-08, 0.127245, 1.84988e-08)
bones/26/rotation = Quaternion(-0.00454016, 0.003573, -0.0595397, 0.998209)
bones/28/rotation = Quaternion(0.702583, -0.000939752, -0.00133551, 0.7116)
bones/29/rotation = Quaternion(0.741121, 0.00218287, 0.00265386, 0.671363)
-bones/30/rotation = Quaternion(0.32527, 0.890055, 0.295977, -0.119997)
-bones/31/rotation = Quaternion(0.514882, -0.114149, 0.114482, 0.841879)
bones/32/rotation = Quaternion(0.592003, -0.0381348, -0.0906989, 0.799907)
-bones/34/position = Vector3(-0.00316938, 0.0987206, 0.0147918)
bones/34/rotation = Quaternion(-0.06747, 0.718446, 0.0663966, 0.689112)
bones/35/rotation = Quaternion(0.580803, -0.000892082, -0.00149751, 0.814042)
bones/36/rotation = Quaternion(0.682777, 0.00174512, 0.002566, 0.73062)
@@ -87,10 +84,7 @@ bones/39/rotation = Quaternion(0.630039, 0.00151958, 0.00237936, 0.776559)
bones/40/rotation = Quaternion(0.652643, -0.00308136, -0.004211, 0.757647)
bones/41/rotation = Quaternion(-0.0684174, 0.72411, 0.0332338, 0.685478)
bones/42/position = Vector3(-0.00715364, 0.0679857, -0.0481983)
-bones/42/rotation = Quaternion(-0.120913, 0.799109, 0.0358921, 0.587807)
bones/43/rotation = Quaternion(0.498693, -0.0202705, -0.0256952, 0.866161)
-bones/44/rotation = Quaternion(0.715385, 0.035306, 0.0573145, 0.695481)
-bones/45/rotation = Quaternion(-0.129059, 0.728936, -0.0250259, 0.67184)
bones/46/position = Vector3(-0.00763592, 1.41808, 0.0529702)
bones/46/rotation = Quaternion(-0.509575, 0.218287, 0.374832, 0.743092)
bones/47/position = Vector3(-0.163133, 1.45507, -0.0294819)
@@ -106,24 +100,12 @@ bones/51/rotation = Quaternion(-0.00835387, -0.00217926, 0.0268349, 0.999603)
bones/52/rotation = Quaternion(-0.2563, 0.855859, 0.0299185, -0.448241)
bones/53/rotation = Quaternion(0.694912, 0.107145, -0.103548, 0.703488)
bones/54/rotation = Quaternion(0.74112, -0.00218243, -0.00265405, 0.671363)
-bones/55/rotation = Quaternion(-0.325269, 0.890055, 0.295977, 0.119998)
-bones/56/rotation = Quaternion(0.514882, 0.11415, -0.114483, 0.841879)
-bones/57/rotation = Quaternion(0.592004, 0.0381346, 0.0906992, 0.799907)
bones/59/position = Vector3(0.00316941, 0.0987207, 0.0147918)
-bones/59/rotation = Quaternion(0.138771, -0.704916, 0.15571, 0.677932)
bones/60/rotation = Quaternion(0.579984, 0.0462614, -0.0308755, 0.812727)
bones/61/rotation = Quaternion(0.682777, -0.00174717, -0.0025645, 0.73062)
-bones/62/rotation = Quaternion(-0.00426532, -0.736714, -0.0394788, 0.675038)
bones/63/position = Vector3(0.00622955, 0.0926416, -0.0236344)
-bones/63/rotation = Quaternion(0.104095, -0.719607, 0.186262, 0.660784)
-bones/64/rotation = Quaternion(0.670693, 0.0163391, -0.0185663, 0.741323)
bones/65/rotation = Quaternion(0.692499, 0.00330364, 0.00403915, 0.7214)
-bones/66/rotation = Quaternion(-0.0684178, -0.72411, -0.0332339, 0.685477)
bones/67/position = Vector3(0.0071537, 0.0679858, -0.0481984)
-bones/67/rotation = Quaternion(0.108364, -0.768224, 0.166291, 0.608635)
-bones/68/rotation = Quaternion(0.629954, 0.0295149, 0.0177509, 0.775869)
-bones/69/rotation = Quaternion(0.816678, -0.0439534, -0.0509861, 0.573155)
-bones/70/rotation = Quaternion(-0.12906, -0.728937, 0.0250258, 0.67184)
bones/71/position = Vector3(0.131284, 1.33873, -0.0170255)
bones/71/rotation = Quaternion(-0.00161625, 0.60429, 0.796762, -0.00107789)
bones/72/position = Vector3(-0.109309, 1.33794, -0.0175146)
diff --git a/src/scenes/levels/level.tscn b/src/scenes/levels/level.tscn
index 2ce2abf..6ac0657 100644
--- a/src/scenes/levels/level.tscn
+++ b/src/scenes/levels/level.tscn
@@ -1,37 +1,167 @@
-[gd_scene load_steps=11 format=3 uid="uid://dw0lbkh31rofd"]
+[gd_scene load_steps=17 format=3 uid="uid://dw0lbkh31rofd"]
+[ext_resource type="Script" uid="uid://bmywk4wvcp0lr" path="res://addons/sky_3d/src/Sky3D.gd" id="1_1jhfv"]
[ext_resource type="PackedScene" uid="uid://nsv4lbw7j8mi" path="res://src/scenes/characters/player.tscn" id="1_crbv0"]
[ext_resource type="PackedScene" uid="uid://b1fe4n68iivfm" path="res://assets/models/environment/rock_cliff_env_03.glb" id="2_d2tjv"]
+[ext_resource type="Script" uid="uid://27fj74ofndim" path="res://addons/sky_3d/src/Skydome.gd" id="2_juj6f"]
+[ext_resource type="Script" uid="uid://bm0hx4mklpml" path="res://addons/sky_3d/src/TimeOfDay.gd" id="3_0eo66"]
[ext_resource type="PackedScene" uid="uid://duotcsmd2fwkk" path="res://assets/models/environment/bush_vege_2.glb" id="3_fqsoq"]
[ext_resource type="PackedScene" uid="uid://bdg8366pt8iu7" path="res://assets/models/environment/topiary_tree_vege_01.glb" id="4_uvsco"]
[ext_resource type="PackedScene" uid="uid://ddh3p0sg6i080" path="res://assets/models/environment/tree_group_vege.glb" id="5_eyekk"]
[ext_resource type="PackedScene" uid="uid://cwvrti8hmxj0k" path="res://src/scenes/characters/enemy.tscn" id="6_uvsco"]
+[ext_resource type="PackedScene" uid="uid://6dmbuecqolod" path="res://src/scenes/structures/fire_pit.tscn" id="10_juj6f"]
+[ext_resource type="PackedScene" uid="uid://rkhiaud0rlq6" path="res://src/scenes/structures/lamp.tscn" id="11_0eo66"]
-[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_amufq"]
-sky_horizon_color = Color(0.662243, 0.671743, 0.686743, 1)
-ground_horizon_color = Color(0.662243, 0.671743, 0.686743, 1)
+[sub_resource type="PhysicalSkyMaterial" id="PhysicalSkyMaterial_dn2un"]
+use_debanding = false
-[sub_resource type="Sky" id="Sky_crbv0"]
-sky_material = SubResource("ProceduralSkyMaterial_amufq")
+[sub_resource type="Sky" id="Sky_7iny7"]
+sky_material = SubResource("PhysicalSkyMaterial_dn2un")
-[sub_resource type="Environment" id="Environment_d2tjv"]
+[sub_resource type="Environment" id="Environment_cxs0p"]
background_mode = 2
-sky = SubResource("Sky_crbv0")
-tonemap_mode = 2
-glow_enabled = true
+sky = SubResource("Sky_7iny7")
+ambient_light_source = 3
+ambient_light_color = Color(0.235156, 0.278907, 0.35, 1)
+ambient_light_sky_contribution = 0.7
+reflected_light_source = 2
+tonemap_mode = 3
+tonemap_white = 6.0
+
+[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_0slur"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_d2tjv"]
albedo_color = Color(0.321569, 0.317647, 0.117647, 1)
[node name="Level" type="Node3D"]
-[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
-environment = SubResource("Environment_d2tjv")
+[node name="Sky3D" type="WorldEnvironment" parent="."]
+environment = SubResource("Environment_cxs0p")
+camera_attributes = SubResource("CameraAttributesPractical_0slur")
+script = ExtResource("1_1jhfv")
+current_time = 2.13
+metadata/_custom_type_script = "uid://bmywk4wvcp0lr"
-[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
-transform = Transform3D(-0.866025, -0.433013, 0.25, 0, 0.5, 0.866025, -0.5, 0.75, -0.433013, 0, 0, 0)
+[node name="SunLight" type="DirectionalLight3D" parent="Sky3D"]
+transform = Transform3D(0, -0.423059, 0.906102, 0.999297, -0.0339567, -0.0158544, 0.0374756, 0.905466, 0.422762, 0.906102, -0.0158543, 0.422762)
+light_color = Color(0.98, 0.523, 0.294, 1)
+light_energy = 0.0
shadow_enabled = true
+[node name="MoonLight" type="DirectionalLight3D" parent="Sky3D"]
+transform = Transform3D(0, -0.956847, -0.290593, -0.200031, -0.28472, 0.937509, -0.97979, 0.0581276, -0.191399, -0.290592, 0.937509, -0.191399)
+light_color = Color(0.572549, 0.776471, 0.956863, 1)
+light_energy = 0.281253
+shadow_enabled = true
+
+[node name="Skydome" type="Node" parent="Sky3D"]
+script = ExtResource("2_juj6f")
+sky_visible = true
+dome_radius = 10.0
+tonemap_level = 0.0
+exposure = 1.3
+ground_color = Color(0.3, 0.3, 0.3, 1)
+sky_layers = 4
+sky_render_priority = -128
+horizon_level = 0.0
+sun_altitude = -147.012
+sun_azimuth = -107.961
+sun_disk_color = Color(0.996094, 0.541334, 0.140076, 1)
+sun_disk_intensity = 2.0
+sun_disk_size = 0.015
+sun_light_path = NodePath("../SunLight")
+sun_light_color = Color(1, 1, 1, 1)
+sun_horizon_light_color = Color(0.98, 0.523, 0.294, 1)
+sun_light_energy = 1.0
+moon_altitude = -20.3627
+moon_azimuth = -303.371
+moon_color = Color(1, 1, 1, 1)
+moon_size = 0.07
+enable_set_moon_texture = false
+moon_resolution = 2
+moon_light_path = NodePath("../MoonLight")
+moon_light_color = Color(0.572549, 0.776471, 0.956863, 1)
+moon_light_energy = 0.3
+deep_space_euler = Vector3(1.29154, 3.14159, -2.30693)
+background_color = Color(0.709804, 0.709804, 0.709804, 0.854902)
+set_background_texture = false
+stars_field_color = Color(1, 1, 1, 1)
+set_stars_field_texture = false
+stars_scintillation = 0.75
+stars_scintillation_speed = 0.01
+atm_quality = 1
+atm_wavelenghts = Vector3(680, 550, 440)
+atm_darkness = 0.5
+atm_sun_intensity = 18.0
+atm_day_tint = Color(0.807843, 0.909804, 1, 1)
+atm_horizon_light_tint = Color(0.980392, 0.635294, 0.462745, 1)
+atm_enable_moon_scatter_mode = false
+atm_night_tint = Color(0.168627, 0.2, 0.25098, 1)
+atm_level_params = Vector3(1, 0, 0)
+atm_thickness = 0.7
+atm_mie = 0.07
+atm_turbidity = 0.001
+atm_sun_mie_tint = Color(1, 1, 1, 1)
+atm_sun_mie_intensity = 1.0
+atm_sun_mie_anisotropy = 0.8
+atm_moon_mie_tint = Color(0.137255, 0.184314, 0.292196, 1)
+atm_moon_mie_intensity = 0.7
+atm_moon_mie_anisotropy = 0.8
+fog_visible = true
+fog_atm_level_params_offset = Vector3(0, 0, -1)
+fog_density = 0.00015
+fog_rayleigh_depth = 0.116
+fog_mie_depth = 0.0001
+fog_falloff = 3.0
+fog_start = 0.0
+fog_end = 1000.0
+fog_layers = 524288
+fog_render_priority = 123
+clouds_thickness = 1.7
+clouds_coverage = 0.5
+clouds_absorption = 2.0
+clouds_sky_tint_fade = 0.5
+clouds_intensity = 10.0
+clouds_size = 2.0
+clouds_uv = Vector2(0.16, 0.11)
+clouds_direction = Vector2(0.25, 0.25)
+clouds_speed = 0.07
+set_clouds_texture = false
+clouds_cumulus_visible = true
+clouds_cumulus_day_color = Color(0.823529, 0.87451, 1, 1)
+clouds_cumulus_horizon_light_color = Color(0.98, 0.43, 0.15, 1)
+clouds_cumulus_night_color = Color(0.090196, 0.094118, 0.129412, 1)
+clouds_cumulus_thickness = 0.0243
+clouds_cumulus_coverage = 0.55
+clouds_cumulus_absorption = 2.0
+clouds_cumulus_noise_freq = 2.7
+clouds_cumulus_intensity = 1.0
+clouds_cumulus_mie_intensity = 1.0
+clouds_cumulus_mie_anisotropy = 0.206
+clouds_cumulus_size = 0.5
+clouds_cumulus_direction = Vector3(0.25, 0.1, 0.25)
+clouds_cumulus_speed = 0.05
+set_clouds_cumulus_texture = false
+environment = SubResource("Environment_cxs0p")
+
+[node name="TimeOfDay" type="Node" parent="Sky3D"]
+script = ExtResource("3_0eo66")
+update_in_game = false
+update_in_editor = false
+dome_path = NodePath("../Skydome")
+system_sync = false
+total_cycle_in_minutes = 15.0
+total_hours = 2.13
+day = 1
+month = 1
+year = 2025
+celestials_calculations = 1
+compute_moon_coords = true
+compute_deep_space_coords = true
+latitude = 16.0
+longitude = 108.0
+utc = 7.0
+
[node name="Floor" type="CSGBox3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
use_collision = true
@@ -109,3 +239,9 @@ transform = Transform3D(0.910684, 0, 0.413104, 0, 1, 0, -0.413104, 0, 0.910684,
[node name="Enemy4" parent="Enemies" instance=ExtResource("6_uvsco")]
transform = Transform3D(-0.503623, 0, 0.863923, 0, 1, 0, -0.863923, 0, -0.503623, -5.81376, 1, 15.3224)
+
+[node name="FirePit" parent="." instance=ExtResource("10_juj6f")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9.96342, 0, 3.24593)
+
+[node name="Lamp" parent="." instance=ExtResource("11_0eo66")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16.5327, -3.57628e-07, -11.138)
diff --git a/src/scenes/structures/fire_pit.tscn b/src/scenes/structures/fire_pit.tscn
new file mode 100644
index 0000000..a95cfba
--- /dev/null
+++ b/src/scenes/structures/fire_pit.tscn
@@ -0,0 +1,48 @@
+[gd_scene load_steps=8 format=3 uid="uid://6dmbuecqolod"]
+
+[ext_resource type="PackedScene" uid="uid://sh4wfmyjjve" path="res://assets/models/environment/fire_pit_env.glb" id="1_klngy"]
+[ext_resource type="Texture2D" uid="uid://0acyl8dfoi6i" path="res://assets/textures/smoke_texture.png" id="2_kfkh6"]
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_klngy"]
+transparency = 1
+vertex_color_use_as_albedo = true
+albedo_color = Color(0.936458, 0.662528, 0.102757, 1)
+albedo_texture = ExtResource("2_kfkh6")
+emission_enabled = true
+emission = Color(0.647612, 0.431395, 0.0657308, 1)
+emission_energy_multiplier = 5.0
+billboard_mode = 1
+
+[sub_resource type="Gradient" id="Gradient_b2ijf"]
+offsets = PackedFloat32Array(0, 0.527197, 1)
+colors = PackedColorArray(0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0)
+
+[sub_resource type="GradientTexture1D" id="GradientTexture1D_magng"]
+gradient = SubResource("Gradient_b2ijf")
+
+[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_kfkh6"]
+emission_shape_scale = Vector3(0.5, 0.5, 0.5)
+emission_shape = 1
+emission_sphere_radius = 1.0
+gravity = Vector3(0, 3, 0)
+color_ramp = SubResource("GradientTexture1D_magng")
+
+[sub_resource type="QuadMesh" id="QuadMesh_b2ijf"]
+
+[node name="FirePit" type="Node3D"]
+
+[node name="fire_pit_env" parent="." instance=ExtResource("1_klngy")]
+
+[node name="Fire" type="GPUParticles3D" parent="fire_pit_env"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.615283, 0)
+material_override = SubResource("StandardMaterial3D_klngy")
+cast_shadow = 0
+lifetime = 1.25
+process_material = SubResource("ParticleProcessMaterial_kfkh6")
+draw_pass_1 = SubResource("QuadMesh_b2ijf")
+
+[node name="OmniLight3D" type="OmniLight3D" parent="fire_pit_env"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.645023, 0)
+light_color = Color(0.900353, 0.606455, 0.0980959, 1)
+light_energy = 10.0
+omni_range = 6.56384
diff --git a/src/scenes/structures/lamp.tscn b/src/scenes/structures/lamp.tscn
new file mode 100644
index 0000000..882be07
--- /dev/null
+++ b/src/scenes/structures/lamp.tscn
@@ -0,0 +1,13 @@
+[gd_scene load_steps=2 format=3 uid="uid://rkhiaud0rlq6"]
+
+[ext_resource type="PackedScene" uid="uid://dolbrwcjgq3yb" path="res://assets/models/environment/lamppost_furn.glb" id="1_mmulg"]
+
+[node name="Lamp" type="Node3D"]
+
+[node name="lamppost_furn" parent="." instance=ExtResource("1_mmulg")]
+
+[node name="OmniLight3D" type="OmniLight3D" parent="lamppost_furn"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.471, 2.92497, 0)
+light_color = Color(0.900353, 0.606455, 0.0980959, 1)
+light_energy = 10.0
+omni_range = 10.6869
diff --git a/src/scripts/characters/area_attack.gd b/src/scripts/characters/area_attack.gd
index 806b6ba..8bc3898 100644
--- a/src/scripts/characters/area_attack.gd
+++ b/src/scripts/characters/area_attack.gd
@@ -1,7 +1,9 @@
extends ShapeCast3D
-func deal_damage(damage: float) -> void:
+func deal_damage(damage: float, crit_chance: float) -> void:
for collision_id in get_collision_count():
+ var is_critical = randf() <= crit_chance
var collider = get_collider(collision_id)
+
if collider is Player or collider is Enemy:
- collider.health_component.take_damage(damage)
+ collider.health_component.take_damage(damage, is_critical)
diff --git a/src/scripts/characters/attack_cast.gd b/src/scripts/characters/attack_cast.gd
index 0ad1f0e..f53921a 100644
--- a/src/scripts/characters/attack_cast.gd
+++ b/src/scripts/characters/attack_cast.gd
@@ -1,10 +1,12 @@
extends RayCast3D
-func deal_damage(damage: float) -> void:
+func deal_damage(damage: float, crit_chance: float) -> void:
if !is_colliding():
return
+ var is_critical = randf() <= crit_chance
var collider = get_collider()
+
if collider is Enemy:
- collider.health_component.take_damage(damage)
+ collider.health_component.take_damage(damage, is_critical)
add_exception(collider)
diff --git a/src/scripts/characters/enemy.gd b/src/scripts/characters/enemy.gd
index 630490d..aff81fb 100644
--- a/src/scripts/characters/enemy.gd
+++ b/src/scripts/characters/enemy.gd
@@ -1,8 +1,9 @@
-extends CharacterBody3D
-class_name Enemy
+class_name Enemy extends CharacterBody3D
@export var max_health: float = 20.0
@export var xp_value: int = 30
+@export var damage: float = 20.0
+@export var crit_chance: float = 0.05
@onready var rig: Node3D = $Rig
@onready var health_component: HealthComponent = $HealthComponent
@@ -16,7 +17,7 @@ func _ready() -> void:
rig.set_active_mesh(mesh)
health_component.update_max_health(max_health)
-func _physics_process(delta: float) -> void:
+func _physics_process(_delta: float) -> void:
if rig.is_idle():
check_for_attacks()
@@ -33,4 +34,4 @@ func _on_health_component_defeat() -> void:
set_physics_process(false)
func _on_rig_heavy_attack() -> void:
- area_attack.deal_damage(20.0)
+ area_attack.deal_damage(damage, crit_chance)
diff --git a/src/scripts/characters/health_component.gd b/src/scripts/characters/health_component.gd
index 875be17..6fe9648 100644
--- a/src/scripts/characters/health_component.gd
+++ b/src/scripts/characters/health_component.gd
@@ -16,5 +16,9 @@ func update_max_health(max_hp_in: float) -> void:
max_health = max_hp_in
current_health = max_health
-func take_damage(damage_in: float) -> void:
- current_health -= damage_in
+func take_damage(damage_in: float, is_critical: bool) -> void:
+ var damage = damage_in
+ if is_critical:
+ damage *= 2.0
+ print("Is critical hit")
+ current_health -= damage
diff --git a/src/scripts/characters/player.gd b/src/scripts/characters/player.gd
index 52a6bd5..49e04bb 100644
--- a/src/scripts/characters/player.gd
+++ b/src/scripts/characters/player.gd
@@ -121,7 +121,8 @@ func handle_slashing_physics_frame(delta: float) -> void:
velocity.x = _attack_direction.x * attack_move_speed
velocity.z = _attack_direction.z * attack_move_speed
look_toward_direction(_attack_direction, delta)
- attack_cast.deal_damage(10.0 + stats.strength.get_modifier())
+ var damage = 10.0 + stats.strength.get_modifier()
+ attack_cast.deal_damage(damage, stats.agility.get_modifier())
func handle_overhead_physics_frame() -> void:
if !rig.is_overhead():
@@ -135,4 +136,5 @@ func _on_health_component_defeat() -> void:
set_physics_process(false)
func _on_rig_heavy_attack() -> void:
- area_attack.deal_damage(10.0 + stats.strength.get_modifier())
+ var damage = 10.0 + stats.strength.get_modifier()
+ area_attack.deal_damage(damage, stats.agility.get_modifier())
diff --git a/src/shaders/fire.tres b/src/shaders/fire.tres
new file mode 100644
index 0000000..086426b
--- /dev/null
+++ b/src/shaders/fire.tres
@@ -0,0 +1,9 @@
+[gd_resource type="VisualShader" format=3 uid="uid://ck7nn7v3pytdr"]
+
+[resource]
+code = "shader_type spatial;
+render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_schlick_ggx;
+
+
+
+"