├── .gitignore
├── .gitattributes
├── FPSController
├── models
│ ├── desert droid.glb
│ └── desert droid.glb.import
├── InteractableComponent.gd
└── FPSController.gd
├── addons
└── SimpleTerrain
│ ├── assets
│ ├── models
│ │ ├── grass.mesh
│ │ └── grass.gdshader
│ └── textures
│ │ ├── CC0 Tileable Grass Textures Set By Cethiel
│ │ ├── Grass_01.png
│ │ ├── Grass_02.png
│ │ ├── Grass_03.png
│ │ ├── Grass_04.png
│ │ ├── Grass_01_Nrm.png
│ │ ├── Grass_02_Nrm.png
│ │ ├── Grass_03_Nrm.png
│ │ ├── Grass_04_Nrm.png
│ │ ├── Grass_04_Nrm.png.import
│ │ ├── Grass_01.png.import
│ │ ├── Grass_02.png.import
│ │ ├── Grass_03.png.import
│ │ ├── Grass_04.png.import
│ │ ├── Grass_01_Nrm.png.import
│ │ ├── Grass_02_Nrm.png.import
│ │ └── Grass_03_Nrm.png.import
│ │ ├── foliage_icon.svg.import
│ │ ├── terrain_icon.svg.import
│ │ ├── terrain_icon.svg
│ │ └── foliage_icon.svg
│ ├── plugin.cfg
│ ├── NormalMapBaker.gd
│ ├── NormalMapBaker.tscn
│ ├── utils
│ └── tres_to_exr.py
│ ├── Shaders
│ ├── NormalMapShader.gdshader
│ ├── TerrainShaderUtils.gdshaderinc
│ ├── SimpleTerrain.gdshader
│ └── DebugShader.gdshader
│ ├── FoliageBrushToolbar.gd
│ ├── SimpleTerrainUtils.gd
│ ├── plugin.gd
│ ├── FoliageBrushToolbar.tscn
│ ├── BrushToolbar.gd
│ ├── TerrainBrushDecal.gd
│ ├── SimpleTerrainFoliage.gd
│ ├── BrushToolbar.tscn
│ └── FoliageBrushDecal.gd
├── utils
├── WaterMaker3D
│ ├── FogVolumeFadeScript.gd
│ ├── FogFade.gdshader
│ ├── CameraWaterOverlay.gdshader
│ ├── WaterMaker3D.gd
│ └── WaterMaker3D.tscn
└── CSGStairMaker3D.tscn
├── README.md
├── project.godot
├── LICENSE
└── rocky.gdshader
/.gitignore:
--------------------------------------------------------------------------------
1 | # Godot 4+ specific ignores
2 | .godot/
3 | *.uid
4 | .vscode/
5 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Normalize EOL for all files that Git considers text files.
2 | * text=auto eol=lf
3 |
--------------------------------------------------------------------------------
/FPSController/models/desert droid.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/FPSController/models/desert droid.glb
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/models/grass.mesh:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/models/grass.mesh
--------------------------------------------------------------------------------
/addons/SimpleTerrain/plugin.cfg:
--------------------------------------------------------------------------------
1 | [plugin]
2 |
3 | name="SimpleTerrain"
4 | description=""
5 | author="MajikayoGames"
6 | version=""
7 | script="plugin.gd"
8 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01_Nrm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01_Nrm.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02_Nrm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02_Nrm.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03_Nrm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03_Nrm.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04_Nrm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/majikayogames/SimpleTerrain/HEAD/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04_Nrm.png
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/models/grass.gdshader:
--------------------------------------------------------------------------------
1 | shader_type spatial;
2 | render_mode cull_disabled;
3 |
4 | uniform vec3 color : source_color;
5 | uniform vec3 color2 : source_color;
6 |
7 | void fragment() {
8 | ALBEDO = mix(color, color2, 1.0 - UV.y);
9 | if (!FRONT_FACING) {
10 | //NORMAL = -NORMAL;
11 | }
12 | }
--------------------------------------------------------------------------------
/utils/WaterMaker3D/FogVolumeFadeScript.gd:
--------------------------------------------------------------------------------
1 | extends FogVolume
2 |
3 | @export var fade_distance := 5
4 |
5 | # Called every frame. 'delta' is the elapsed time since the previous frame.
6 | func _process(_delta):
7 | var cam = get_viewport().get_camera_3d() if get_viewport() else null
8 | if cam:
9 | var fade_plane_normal = cam.global_transform.basis.z * -1
10 | var fade_plane_pos = cam.global_transform.origin + cam.global_transform.basis.z * -fade_distance
11 | var fade_plane_distance = fade_plane_pos.dot(fade_plane_normal)
12 | var fade_plane = Vector4(fade_plane_normal.x, fade_plane_normal.y, fade_plane_normal.z, fade_plane_distance)
13 | self.material.set_shader_parameter("fade_plane", fade_plane)
14 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/NormalMapBaker.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | extends Node
3 |
4 | func render_normalmap(heightmap : Texture2D, height_scale : float, quad_size : float) -> ViewportTexture:
5 | $SubViewport/TextureRect.material.set_shader_parameter("heightmap", heightmap)
6 | $SubViewport/TextureRect.material.set_shader_parameter("height_scale", height_scale)
7 | $SubViewport/TextureRect.material.set_shader_parameter("quad_size", quad_size)
8 | #$SubViewport/TextureRect.custom_minimum_size = heightmap.get_size() if heightmap else Vector2i(1,1)
9 | # It's one less pixel as we are calulcating per quad normals not per vertex normals
10 | $SubViewport.size = Vector2i(heightmap.get_size() - Vector2(1,1)) if heightmap else Vector2i(1,1)
11 | $SubViewport.render_target_update_mode = SubViewport.UPDATE_ONCE
12 | return $SubViewport.get_texture()
13 |
--------------------------------------------------------------------------------
/FPSController/InteractableComponent.gd:
--------------------------------------------------------------------------------
1 | class_name InteractableComponent
2 | extends Node
3 |
4 | var characters_hovering = {}
5 |
6 | signal interacted()
7 | signal interacted_by_character(character : CharacterBody3D)
8 |
9 | func interact_with(character : CharacterBody3D):
10 | interacted.emit()
11 | interacted_by_character.emit(character)
12 |
13 | func hover_cursor(character : CharacterBody3D):
14 | characters_hovering[character] = Engine.get_process_frames()
15 |
16 | func get_character_hovered_by_cur_camera() -> CharacterBody3D:
17 | for character in characters_hovering.keys():
18 | var cur_cam = get_viewport().get_camera_3d() if get_viewport() else null
19 | if cur_cam != null and character.is_ancestor_of(cur_cam):
20 | return character
21 | return null
22 |
23 | func _process(_delta):
24 | for character in characters_hovering.keys():
25 | if Engine.get_process_frames() - characters_hovering[character] > 1:
26 | characters_hovering.erase(character)
27 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04_Nrm.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://lq5abx640535"
6 | path="res://.godot/imported/Grass_04_Nrm.png-4a32e81e0fc7741676b6aa79c081b1c9.ctex"
7 | metadata={
8 | "vram_texture": false
9 | }
10 |
11 | [deps]
12 |
13 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04_Nrm.png"
14 | dest_files=["res://.godot/imported/Grass_04_Nrm.png-4a32e81e0fc7741676b6aa79c081b1c9.ctex"]
15 |
16 | [params]
17 |
18 | compress/mode=0
19 | compress/high_quality=false
20 | compress/lossy_quality=0.7
21 | compress/hdr_compression=1
22 | compress/normal_map=0
23 | compress/channel_pack=0
24 | mipmaps/generate=false
25 | mipmaps/limit=-1
26 | roughness/mode=0
27 | roughness/src_normal=""
28 | process/fix_alpha_border=true
29 | process/premult_alpha=false
30 | process/normal_map_invert_y=false
31 | process/hdr_as_srgb=false
32 | process/hdr_clamp_exposure=false
33 | process/size_limit=0
34 | detect_3d/compress_to=1
35 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/foliage_icon.svg.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://detap6fgfpjec"
6 | path="res://.godot/imported/foliage_icon.svg-323bed01fee784963537e38474720306.ctex"
7 | metadata={
8 | "vram_texture": false
9 | }
10 |
11 | [deps]
12 |
13 | source_file="res://addons/SimpleTerrain/assets/textures/foliage_icon.svg"
14 | dest_files=["res://.godot/imported/foliage_icon.svg-323bed01fee784963537e38474720306.ctex"]
15 |
16 | [params]
17 |
18 | compress/mode=0
19 | compress/high_quality=false
20 | compress/lossy_quality=0.7
21 | compress/hdr_compression=1
22 | compress/normal_map=0
23 | compress/channel_pack=0
24 | mipmaps/generate=false
25 | mipmaps/limit=-1
26 | roughness/mode=0
27 | roughness/src_normal=""
28 | process/fix_alpha_border=true
29 | process/premult_alpha=false
30 | process/normal_map_invert_y=false
31 | process/hdr_as_srgb=false
32 | process/hdr_clamp_exposure=false
33 | process/size_limit=0
34 | detect_3d/compress_to=1
35 | svg/scale=1.0
36 | editor/scale_with_editor_scale=false
37 | editor/convert_colors_with_editor_theme=false
38 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/terrain_icon.svg.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://dri5wcef3up8o"
6 | path="res://.godot/imported/terrain_icon.svg-787fa5ef142342f8d79924b6218096c6.ctex"
7 | metadata={
8 | "vram_texture": false
9 | }
10 |
11 | [deps]
12 |
13 | source_file="res://addons/SimpleTerrain/assets/textures/terrain_icon.svg"
14 | dest_files=["res://.godot/imported/terrain_icon.svg-787fa5ef142342f8d79924b6218096c6.ctex"]
15 |
16 | [params]
17 |
18 | compress/mode=0
19 | compress/high_quality=false
20 | compress/lossy_quality=0.7
21 | compress/hdr_compression=1
22 | compress/normal_map=0
23 | compress/channel_pack=0
24 | mipmaps/generate=false
25 | mipmaps/limit=-1
26 | roughness/mode=0
27 | roughness/src_normal=""
28 | process/fix_alpha_border=true
29 | process/premult_alpha=false
30 | process/normal_map_invert_y=false
31 | process/hdr_as_srgb=false
32 | process/hdr_clamp_exposure=false
33 | process/size_limit=0
34 | detect_3d/compress_to=1
35 | svg/scale=1.0
36 | editor/scale_with_editor_scale=false
37 | editor/convert_colors_with_editor_theme=false
38 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://boud3s677ciat"
6 | path.s3tc="res://.godot/imported/Grass_01.png-6d05351a99005295d0f2dd036ba42a16.s3tc.ctex"
7 | metadata={
8 | "imported_formats": ["s3tc_bptc"],
9 | "vram_texture": true
10 | }
11 |
12 | [deps]
13 |
14 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01.png"
15 | dest_files=["res://.godot/imported/Grass_01.png-6d05351a99005295d0f2dd036ba42a16.s3tc.ctex"]
16 |
17 | [params]
18 |
19 | compress/mode=2
20 | compress/high_quality=false
21 | compress/lossy_quality=0.7
22 | compress/hdr_compression=1
23 | compress/normal_map=0
24 | compress/channel_pack=0
25 | mipmaps/generate=true
26 | mipmaps/limit=-1
27 | roughness/mode=0
28 | roughness/src_normal=""
29 | process/fix_alpha_border=true
30 | process/premult_alpha=false
31 | process/normal_map_invert_y=false
32 | process/hdr_as_srgb=false
33 | process/hdr_clamp_exposure=false
34 | process/size_limit=0
35 | detect_3d/compress_to=0
36 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://xtm515jxtelv"
6 | path.s3tc="res://.godot/imported/Grass_02.png-49a04bb6d519658191b528041ee1e8aa.s3tc.ctex"
7 | metadata={
8 | "imported_formats": ["s3tc_bptc"],
9 | "vram_texture": true
10 | }
11 |
12 | [deps]
13 |
14 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02.png"
15 | dest_files=["res://.godot/imported/Grass_02.png-49a04bb6d519658191b528041ee1e8aa.s3tc.ctex"]
16 |
17 | [params]
18 |
19 | compress/mode=2
20 | compress/high_quality=false
21 | compress/lossy_quality=0.7
22 | compress/hdr_compression=1
23 | compress/normal_map=0
24 | compress/channel_pack=0
25 | mipmaps/generate=true
26 | mipmaps/limit=-1
27 | roughness/mode=0
28 | roughness/src_normal=""
29 | process/fix_alpha_border=true
30 | process/premult_alpha=false
31 | process/normal_map_invert_y=false
32 | process/hdr_as_srgb=false
33 | process/hdr_clamp_exposure=false
34 | process/size_limit=0
35 | detect_3d/compress_to=0
36 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://i07uc2wmk05j"
6 | path.s3tc="res://.godot/imported/Grass_03.png-b48b5670f94177c6ac0230f5768d7309.s3tc.ctex"
7 | metadata={
8 | "imported_formats": ["s3tc_bptc"],
9 | "vram_texture": true
10 | }
11 |
12 | [deps]
13 |
14 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03.png"
15 | dest_files=["res://.godot/imported/Grass_03.png-b48b5670f94177c6ac0230f5768d7309.s3tc.ctex"]
16 |
17 | [params]
18 |
19 | compress/mode=2
20 | compress/high_quality=false
21 | compress/lossy_quality=0.7
22 | compress/hdr_compression=1
23 | compress/normal_map=0
24 | compress/channel_pack=0
25 | mipmaps/generate=true
26 | mipmaps/limit=-1
27 | roughness/mode=0
28 | roughness/src_normal=""
29 | process/fix_alpha_border=true
30 | process/premult_alpha=false
31 | process/normal_map_invert_y=false
32 | process/hdr_as_srgb=false
33 | process/hdr_clamp_exposure=false
34 | process/size_limit=0
35 | detect_3d/compress_to=0
36 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://cgb0umtia0mu8"
6 | path.s3tc="res://.godot/imported/Grass_04.png-2bb94313b8e29e6d04a3fce235a8955f.s3tc.ctex"
7 | metadata={
8 | "imported_formats": ["s3tc_bptc"],
9 | "vram_texture": true
10 | }
11 |
12 | [deps]
13 |
14 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_04.png"
15 | dest_files=["res://.godot/imported/Grass_04.png-2bb94313b8e29e6d04a3fce235a8955f.s3tc.ctex"]
16 |
17 | [params]
18 |
19 | compress/mode=2
20 | compress/high_quality=false
21 | compress/lossy_quality=0.7
22 | compress/hdr_compression=1
23 | compress/normal_map=0
24 | compress/channel_pack=0
25 | mipmaps/generate=true
26 | mipmaps/limit=-1
27 | roughness/mode=0
28 | roughness/src_normal=""
29 | process/fix_alpha_border=true
30 | process/premult_alpha=false
31 | process/normal_map_invert_y=false
32 | process/hdr_as_srgb=false
33 | process/hdr_clamp_exposure=false
34 | process/size_limit=0
35 | detect_3d/compress_to=0
36 |
--------------------------------------------------------------------------------
/FPSController/models/desert droid.glb.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="scene"
4 | importer_version=1
5 | type="PackedScene"
6 | uid="uid://b3hsf5heasat2"
7 | path="res://.godot/imported/desert droid.glb-cfe9b372a55631c9649531555393f2c9.scn"
8 |
9 | [deps]
10 |
11 | source_file="res://FPSController/models/desert droid.glb"
12 | dest_files=["res://.godot/imported/desert droid.glb-cfe9b372a55631c9649531555393f2c9.scn"]
13 |
14 | [params]
15 |
16 | nodes/root_type=""
17 | nodes/root_name=""
18 | nodes/apply_root_scale=true
19 | nodes/root_scale=1.0
20 | nodes/import_as_skeleton_bones=false
21 | nodes/use_node_type_suffixes=true
22 | meshes/ensure_tangents=true
23 | meshes/generate_lods=true
24 | meshes/create_shadow_meshes=true
25 | meshes/light_baking=1
26 | meshes/lightmap_texel_size=0.2
27 | meshes/force_disable_compression=false
28 | skins/use_named_skins=true
29 | animation/import=true
30 | animation/fps=30
31 | animation/trimming=false
32 | animation/remove_immutable_tracks=true
33 | animation/import_rest_as_RESET=false
34 | import_script/path=""
35 | _subresources={}
36 | gltf/naming_version=1
37 | gltf/embedded_image_handling=1
38 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01_Nrm.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://cojfd5ubrntk2"
6 | path.bptc="res://.godot/imported/Grass_01_Nrm.png-10abfcb98ea0ebc566755326bddc2006.bptc.ctex"
7 | metadata={
8 | "imported_formats": ["s3tc_bptc"],
9 | "vram_texture": true
10 | }
11 |
12 | [deps]
13 |
14 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_01_Nrm.png"
15 | dest_files=["res://.godot/imported/Grass_01_Nrm.png-10abfcb98ea0ebc566755326bddc2006.bptc.ctex"]
16 |
17 | [params]
18 |
19 | compress/mode=2
20 | compress/high_quality=true
21 | compress/lossy_quality=0.7
22 | compress/hdr_compression=1
23 | compress/normal_map=1
24 | compress/channel_pack=0
25 | mipmaps/generate=true
26 | mipmaps/limit=-1
27 | roughness/mode=0
28 | roughness/src_normal=""
29 | process/fix_alpha_border=true
30 | process/premult_alpha=false
31 | process/normal_map_invert_y=false
32 | process/hdr_as_srgb=false
33 | process/hdr_clamp_exposure=false
34 | process/size_limit=0
35 | detect_3d/compress_to=0
36 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02_Nrm.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://c1itksfbi7bpf"
6 | path.s3tc="res://.godot/imported/Grass_02_Nrm.png-4794ce8c665cd0e581775ef7263d1506.s3tc.ctex"
7 | metadata={
8 | "imported_formats": ["s3tc_bptc"],
9 | "vram_texture": true
10 | }
11 |
12 | [deps]
13 |
14 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_02_Nrm.png"
15 | dest_files=["res://.godot/imported/Grass_02_Nrm.png-4794ce8c665cd0e581775ef7263d1506.s3tc.ctex"]
16 |
17 | [params]
18 |
19 | compress/mode=2
20 | compress/high_quality=false
21 | compress/lossy_quality=0.7
22 | compress/hdr_compression=1
23 | compress/normal_map=0
24 | compress/channel_pack=0
25 | mipmaps/generate=true
26 | mipmaps/limit=-1
27 | roughness/mode=0
28 | roughness/src_normal=""
29 | process/fix_alpha_border=true
30 | process/premult_alpha=false
31 | process/normal_map_invert_y=false
32 | process/hdr_as_srgb=false
33 | process/hdr_clamp_exposure=false
34 | process/size_limit=0
35 | detect_3d/compress_to=0
36 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03_Nrm.png.import:
--------------------------------------------------------------------------------
1 | [remap]
2 |
3 | importer="texture"
4 | type="CompressedTexture2D"
5 | uid="uid://c5wtsigops2lr"
6 | path.s3tc="res://.godot/imported/Grass_03_Nrm.png-a900b5ec1a2751eb98bb685d40d04b77.s3tc.ctex"
7 | metadata={
8 | "imported_formats": ["s3tc_bptc"],
9 | "vram_texture": true
10 | }
11 |
12 | [deps]
13 |
14 | source_file="res://addons/SimpleTerrain/assets/textures/CC0 Tileable Grass Textures Set By Cethiel/Grass_03_Nrm.png"
15 | dest_files=["res://.godot/imported/Grass_03_Nrm.png-a900b5ec1a2751eb98bb685d40d04b77.s3tc.ctex"]
16 |
17 | [params]
18 |
19 | compress/mode=2
20 | compress/high_quality=false
21 | compress/lossy_quality=0.7
22 | compress/hdr_compression=1
23 | compress/normal_map=0
24 | compress/channel_pack=0
25 | mipmaps/generate=true
26 | mipmaps/limit=-1
27 | roughness/mode=0
28 | roughness/src_normal=""
29 | process/fix_alpha_border=true
30 | process/premult_alpha=false
31 | process/normal_map_invert_y=false
32 | process/hdr_as_srgb=false
33 | process/hdr_clamp_exposure=false
34 | process/size_limit=0
35 | detect_3d/compress_to=0
36 |
--------------------------------------------------------------------------------
/utils/WaterMaker3D/FogFade.gdshader:
--------------------------------------------------------------------------------
1 | shader_type fog;
2 |
3 | uniform float density : hint_range(0, 1, 0.0001) = 1.0;
4 | uniform vec4 albedo : source_color = vec4(1.0);
5 | uniform vec4 emission : source_color = vec4(0, 0, 0, 1);
6 | uniform float height_falloff = 0.0;
7 | uniform float edge_fade = 0.1;
8 | uniform sampler3D density_texture: hint_default_white;
9 | uniform vec4 fade_plane = vec4(0., 1., 0., -999.);
10 |
11 | void fog() {
12 | DENSITY = density * clamp(exp2(-height_falloff * (WORLD_POSITION.y - OBJECT_POSITION.y)), 0.0, 1.0);
13 | DENSITY *= texture(density_texture, UVW).r;
14 | DENSITY *= pow(clamp(-2.0 * SDF / min(min(SIZE.x, SIZE.y), SIZE.z), 0.0, 1.0), edge_fade);
15 | ALBEDO = albedo.rgb;
16 | EMISSION = emission.rgb;
17 |
18 | // Anything above the fade plane will be drawn. Anything below has its density set to 0.
19 | float fade_sharpness = 0.35; // Higher is faster fade
20 | vec3 fade_plane_origin = fade_plane.xyz * fade_plane.w;
21 | vec3 pos_rel_to_plane = WORLD_POSITION - fade_plane_origin;
22 | float fade_mult = max(0., min(1., dot(pos_rel_to_plane, fade_plane.xyz) * fade_sharpness));
23 | DENSITY *= fade_mult * albedo.a;
24 | }
25 |
--------------------------------------------------------------------------------
/utils/WaterMaker3D/CameraWaterOverlay.gdshader:
--------------------------------------------------------------------------------
1 | shader_type canvas_item;
2 |
3 | uniform sampler2D screen_texture : hint_screen_texture;
4 |
5 | void fragment() {
6 | vec2 uv = SCREEN_UV;
7 |
8 | // Ripple effect
9 | float rippleSpeed = 1.5;
10 | float rippleDensity = 10.0;
11 | float rippleStrength = 0.02;
12 |
13 | // Calculate angle and distance from the center of the screen
14 | vec2 center = vec2(0.5, 0.5);
15 | vec2 delta = uv - center;
16 | float distance = length(delta);
17 | float angle = atan(delta.y, delta.x);
18 |
19 | // Apply the ripple effect
20 | uv.x += cos(angle * rippleDensity + TIME * rippleSpeed) * rippleStrength * distance;
21 | uv.y += sin(angle * rippleDensity + TIME * rippleSpeed) * rippleStrength * distance;
22 |
23 | // Simple blur effect
24 | vec4 color = vec4(0.0);
25 | float total = 0.0;
26 | for (float x = -2.0; x <= 2.0; x++) {
27 | for (float y = -2.0; y <= 2.0; y++) {
28 | vec2 samplePos = uv + vec2(x, y) * 0.001; // Blur radius
29 | color += texture(screen_texture, samplePos);
30 | total += 1.0;
31 | }
32 | }
33 | color /= total;
34 |
35 | // Output the final color
36 | COLOR = color;
37 | }
38 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/NormalMapBaker.tscn:
--------------------------------------------------------------------------------
1 | [gd_scene load_steps=6 format=3 uid="uid://dcr561ad4u2fe"]
2 |
3 | [ext_resource type="Script" uid="uid://mfu7ybr81ncf" path="res://addons/SimpleTerrain/NormalMapBaker.gd" id="1_hc6wb"]
4 | [ext_resource type="Shader" uid="uid://7shbfq8cfs31" path="res://addons/SimpleTerrain/Shaders/NormalMapShader.gdshader" id="1_prgwr"]
5 |
6 | [sub_resource type="World3D" id="World3D_d3tlc"]
7 | resource_local_to_scene = true
8 |
9 | [sub_resource type="ShaderMaterial" id="ShaderMaterial_6skty"]
10 | resource_local_to_scene = true
11 | shader = ExtResource("1_prgwr")
12 | shader_parameter/height_scale = 0.0
13 | shader_parameter/quad_size = 0.0
14 |
15 | [sub_resource type="CanvasTexture" id="CanvasTexture_vmti5"]
16 | resource_local_to_scene = true
17 |
18 | [node name="NormalMapBaker" type="Node"]
19 | script = ExtResource("1_hc6wb")
20 |
21 | [node name="SubViewport" type="SubViewport" parent="."]
22 | own_world_3d = true
23 | world_3d = SubResource("World3D_d3tlc")
24 | render_target_update_mode = 0
25 |
26 | [node name="TextureRect" type="TextureRect" parent="SubViewport"]
27 | material = SubResource("ShaderMaterial_6skty")
28 | anchors_preset = 15
29 | anchor_right = 1.0
30 | anchor_bottom = 1.0
31 | grow_horizontal = 2
32 | grow_vertical = 2
33 | texture = SubResource("CanvasTexture_vmti5")
34 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SimpleTerrain for Godot 4
2 |
3 | SimpleTerrain is a Godot 4 addon which provides a SimpleTerrain node as well as editor features to modify the terrain.
4 |
5 | I did a YouTube video explaining the functionality and structure of SimpleTerrain here:
6 | https://www.youtube.com/watch?v=AK951MB9kXM
7 |
8 | ## Features
9 |
10 | - Editor plugin for easy integration
11 | - Splatmap support for up to 4 textures
12 | - Normal mapped texture support
13 | - Optional triplanar texturing
14 | - Ability to paint holes in terrain
15 | - Collision map generation
16 | - Chunked LOD system for performant rendering
17 | - Foliage painting system with MultiMeshInstance3D
18 |
19 | ## Installation
20 |
21 | 1. Clone this repo or download as a zip by clicking the `Code` button above.
22 | 2. Copy the contents of the `addons` folder to your project's `addons` directory.
23 | 3. Enable in Godot under `Project Settings > Plugins`.
24 |
25 | ## Usage
26 |
27 | After installation, add a SimpleTerrain node to your scene. You can set the heightmap texture and edit via the terrain brush toolbar.
28 |
29 | ## License
30 |
31 | This addon is available under the CC0 license.
32 |
33 | ## Support
34 |
35 | If you find this addon helpful and would like to support its development, consider becoming a patron or buying me a coffee!
36 |
37 | [](https://www.patreon.com/MajikayoGames)
38 | [](https://ko-fi.com/majikayogames)
39 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/utils/tres_to_exr.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import re
3 | import sys
4 | import numpy as np
5 | import OpenEXR, Imath
6 |
7 | # Can be used to convert a RFLOAT .tres heightmap file to a .exr file to edit the heightmap in an image editor like GIMP.
8 | # Needs: pip install numpy OpenEXR Imath
9 |
10 | def extract_rfloat_from_tres(path):
11 | with open(path, "r") as f:
12 | text = f.read()
13 |
14 | # find width & height
15 | w_m = re.search(r'"width":\s*(\d+)', text)
16 | h_m = re.search(r'"height":\s*(\d+)', text)
17 | if not w_m or not h_m:
18 | raise ValueError("Width/height not found in .tres")
19 | width = int(w_m.group(1))
20 | height = int(h_m.group(1))
21 |
22 | # capture everything inside the PackedByteArray(...)
23 | m = re.search(r'PackedByteArray\((.*?)\)', text, re.DOTALL)
24 | if not m:
25 | raise ValueError("PackedByteArray data not found")
26 | data_str = m.group(1)
27 |
28 | # parse bytes
29 | byte_values = [int(v) for v in data_str.split(",") if v.strip().isdigit()]
30 | buf = bytes(byte_values)
31 |
32 | # to float32 array
33 | arr = np.frombuffer(buf, dtype=np.float32)
34 | if arr.size != width * height:
35 | raise ValueError(f"Data size mismatch: expected {width*height}, got {arr.size}")
36 | return arr.reshape((height, width))
37 |
38 | def save_exr(img, path):
39 | # single‐channel float EXR
40 | h, w = img.shape
41 | header = OpenEXR.Header(w, h)
42 | header['channels'] = {'R': Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT))}
43 | exr = OpenEXR.OutputFile(path, header)
44 | exr.writePixels({'R': img.astype(np.float32).tobytes()})
45 | exr.close()
46 |
47 | if __name__ == "__main__":
48 | if len(sys.argv) != 3:
49 | print("Usage: python tres_to_exr.py input.tres output.exr")
50 | sys.exit(1)
51 |
52 | input_tres, output_exr = sys.argv[1], sys.argv[2]
53 | img = extract_rfloat_from_tres(input_tres)
54 | save_exr(img, output_exr)
55 | print(f"Saved EXR to {output_exr}")
56 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/terrain_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
55 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/assets/textures/foliage_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
49 |
--------------------------------------------------------------------------------
/utils/CSGStairMaker3D.tscn:
--------------------------------------------------------------------------------
1 | [gd_scene load_steps=2 format=3 uid="uid://7b554rxf05fu"]
2 |
3 | [sub_resource type="GDScript" id="GDScript_0eugj"]
4 | script/source = "@tool
5 | extends CSGBox3D
6 |
7 | @export var num_stairs : int = 10
8 |
9 | var _cur_num_stairs = -1
10 | var _cur_size : Vector3
11 |
12 | func make_stairs():
13 | #if not Engine.is_editor_hint():
14 | #return
15 | #
16 | num_stairs = clampi(num_stairs, 0, 999)
17 |
18 | var stairs_poly = $StairsSubtractCSG#add_fresh_stairs_csg_poly()
19 |
20 | var point_arr : PackedVector2Array = PackedVector2Array()
21 | var step_height = self.size.y / num_stairs
22 | var step_width = self.size.x / num_stairs
23 |
24 | if num_stairs == 0:
25 | # For 0 stairs make a ramp
26 | point_arr.append(Vector2(self.size.x, self.size.y))
27 | point_arr.append(Vector2(0, self.size.y))
28 | point_arr.append(Vector2(0, 0))
29 | else:
30 | # Creating the points for the stairs polygon
31 | for i in range(num_stairs - 1):
32 | point_arr.append(Vector2(i * step_width, (i + 1) * step_height))
33 | if i < num_stairs:
34 | point_arr.append(Vector2((i + 1) * step_width, (i + 1) * step_height))
35 |
36 | # Closing the polygon by adding the last two points
37 | point_arr.append(Vector2(self.size.x - step_width, self.size.y))
38 | point_arr.append(Vector2(0, self.size.y))
39 |
40 | stairs_poly.polygon = point_arr
41 |
42 | stairs_poly.depth = self.size.z
43 |
44 | stairs_poly.position.z = self.size.z / 2.0
45 | stairs_poly.position.y = -self.size.y / 2.0
46 | stairs_poly.position.x = -self.size.x / 2.0
47 |
48 | _cur_num_stairs = num_stairs
49 | _cur_size = self.size
50 |
51 | # Called every frame. 'delta' is the elapsed time since the previous frame.
52 | func _process(_delta):
53 | if _cur_num_stairs != num_stairs or _cur_size != self.size:
54 | make_stairs()
55 | "
56 |
57 | [node name="CSGStairMaker3D" type="CSGBox3D"]
58 | script = SubResource("GDScript_0eugj")
59 | num_stairs = 4
60 |
61 | [node name="StairsSubtractCSG" type="CSGPolygon3D" parent="."]
62 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.5, -0.5, 0.5)
63 | operation = 2
64 | polygon = PackedVector2Array(0, 0.25, 0.25, 0.25, 0.25, 0.5, 0.5, 0.5, 0.5, 0.75, 0.75, 0.75, 0.75, 1, 0, 1)
65 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/Shaders/NormalMapShader.gdshader:
--------------------------------------------------------------------------------
1 | shader_type canvas_item;
2 | render_mode unshaded;
3 | uniform sampler2D heightmap;
4 | uniform float height_scale;
5 | uniform float quad_size;
6 |
7 | // Normal map will be one pixel smaller than the heightmap width and height
8 | // It stores the face normals of the heightmap's equivalent faces
9 | // This method is better for triplanar and preserving accurate face normals so no distortion on cliffs etc.
10 | // Smooth shading/per vert normals can be done by taking 4 samples in vertex shader of adjacent faces
11 | // You can just average the 4 normals and it gives you smooth shading which is how 3D modelling programs do it.
12 | // In practice I didn't do this since it looks fine without. Could eventually add that and per tri normals.
13 |
14 | #include "TerrainShaderUtils.gdshaderinc"
15 |
16 | float get_height(vec2 uv) {
17 | return sample_middle_of_pixel(heightmap,uv).r * height_scale;
18 | }
19 |
20 | void fragment() {
21 | // Note: Per quad normals but it could be the case the heightmap is erratic and it's not actually a quad, could be 2 stretched triangles
22 | // But this is a good approximation unless it has bad topology. We could store per tri normals here if we really wanted,
23 | // making the normal map 2x as wide.
24 | // From what I can tell average the 2 triangle's normals gets rid of all virtually all distortions on triplanar.
25 | // Even when it doesn't, seems like you can just crank up the texture size a bit and it works.
26 | float top_left = get_height(SCREEN_UV);
27 | float top_right = get_height(SCREEN_UV + vec2(SCREEN_PIXEL_SIZE.x, 0.));
28 | float bottom_left = get_height(SCREEN_UV + vec2(0., SCREEN_PIXEL_SIZE.y));
29 | vec3 x_basis_toptri = vec3(quad_size, top_right - top_left, 0.);
30 | vec3 z_basis_toptri = vec3(0., bottom_left - top_left, -quad_size);
31 | vec3 y_basis_toptri = cross(x_basis_toptri, z_basis_toptri);
32 | float bottom_right = get_height(SCREEN_UV + SCREEN_PIXEL_SIZE);
33 | vec3 x_basis_bottri = vec3(quad_size, bottom_right - bottom_left, 0.);
34 | vec3 z_basis_bottri = vec3(0., bottom_right - top_right, -quad_size);
35 | vec3 y_basis_bottri = cross(x_basis_bottri, z_basis_bottri);
36 |
37 | vec3 n = normalize((y_basis_toptri + y_basis_bottri) / 2.);
38 | vec3 packed = pack_normal(n).xyz;
39 | COLOR = vec4(packed, 1.0);
40 | }
41 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/Shaders/TerrainShaderUtils.gdshaderinc:
--------------------------------------------------------------------------------
1 | // Unpack from texture -> XYZ. Texture is between 0 and 1, normals are -1 to 1. Negate Z because OpenGL uses Y-up normal maps.
2 | vec3 unpack_normal(vec4 rgba) {
3 | vec3 n = rgba.xzy * 2.0 - vec3(1.0);
4 | // Had to negate Z because it comes from Y in the normal map,
5 | // and OpenGL-style normal maps are Y-up.
6 | n.z *= -1.0;
7 | return n;
8 | }
9 |
10 | vec4 pack_normal(vec3 n) {
11 | return vec4((0.5 * (n + 1.0)).xzy, 1.0);
12 | }
13 |
14 | // Change of basis https://www.youtube.com/watch?v=P2LTAUO1TdA
15 | vec3 apply_normal_basis(vec3 n1, vec3 n2)
16 | {
17 | vec3 up = n1;
18 | vec3 right = vec3(n1.y, -n1.z, n1.x);
19 | vec3 forward = vec3(n1.x, -n1.z, n1.y);
20 | vec3 change_of_basis = n2.x*right + n2.y*up + n2.z*forward;
21 |
22 | // Prevent n2 rotated from pointing away from n1
23 | //change_of_basis.y = max(change_of_basis.y, 0.0);
24 |
25 | return normalize(change_of_basis);
26 | }
27 |
28 | // Function for triplanar texture blending
29 | vec4 texture_triplanar(sampler2D tex, vec3 world_pos, vec3 normal, vec2 uv_scale) {
30 | // Blend factors based on the normal, abs to ensure positive values
31 | vec3 blend = abs(normalize(normal));
32 |
33 | // Normalize blend factors to ensure they sum to 1
34 | blend /= (blend.x + blend.y + blend.z);
35 |
36 | // Smooth the blend factors to reduce artifacts
37 | float sharpness = 2.0; // Adjust sharpness as needed
38 | blend = pow(blend, vec3(sharpness));
39 |
40 | // Normalized again after smoothing
41 | blend /= dot(blend, vec3(1.0));
42 |
43 | // Texturing for each plane
44 | vec4 texX = texture(tex, world_pos.yz / uv_scale) * blend.x;
45 | vec4 texY = texture(tex, world_pos.xz / uv_scale) * blend.y;
46 | vec4 texZ = texture(tex, world_pos.xy / uv_scale) * blend.z;
47 |
48 | // Final color blend
49 | return texX + texY + texZ;
50 | }
51 |
52 | vec4 sample_middle_of_pixel(sampler2D tex, vec2 uv) {
53 | // Sample from exact middle of each pixel to ensure same result as in collision shape calculation.
54 | vec2 texture_coords_in_px = floor(uv * (vec2(textureSize(tex, 0)) - 1.0));
55 | texture_coords_in_px += 0.5;
56 |
57 | // Put coordinates back into UV space and sample texture.
58 | vec2 texture_position = clamp(texture_coords_in_px / vec2(textureSize(tex,0)), 0.0, 1.0);
59 | return texture(tex, texture_position);
60 | }
--------------------------------------------------------------------------------
/utils/WaterMaker3D/WaterMaker3D.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | extends CSGBox3D
3 |
4 | @export var water_texture_move_speed := Vector3(0.0025, 0.0025, 0.0025)
5 | @export var water_texture_uv_scale := 0.04
6 | @export var water_color := Color(0.3098039329052, 0.54117649793625, 0.86666667461395, 0.38823530077934)
7 | @export var fog_color := Color(0, 0.04313725605607, 0.15686275064945)
8 | @export_range(0.0, 250.0) var fog_fade_dist := 5.0
9 |
10 | static var last_frame_drew_underwater_effect : int = -999
11 |
12 | func _ready():
13 | self.process_priority = 999 # Call _process last to update move after any camera movement
14 |
15 | # Track the current camera with an area so we can check if it is inside the water
16 | func should_draw_camera_underwater_effect():
17 | var camera := get_viewport().get_camera_3d() if get_viewport() else null
18 | if not camera: return false
19 | var aabb = self.global_transform * self.get_aabb().grow(0.025)
20 | if not aabb.has_point(camera.global_position): return false
21 | # Don't draw multiple overlays at once, incase 2 water bodies overlap
22 | if last_frame_drew_underwater_effect == Engine.get_process_frames(): return false
23 |
24 | %CameraPosShapeCast3D.global_position = camera.global_position
25 | %CameraPosShapeCast3D.force_shapecast_update()
26 | for i in %CameraPosShapeCast3D.get_collision_count():
27 | if %CameraPosShapeCast3D.get_collider(i) == %SwimmableArea3D:
28 | return true
29 | return false
30 |
31 | func _update_mesh():
32 | if get_node_or_null("%CollisionShape3D"):
33 | %CollisionShape3D.shape.size = self.size
34 |
35 | func _process(delta):
36 | _update_mesh()
37 | if self.material is StandardMaterial3D:
38 | if not Engine.is_editor_hint():
39 | self.material.uv1_offset += water_texture_move_speed * delta
40 | self.material.uv1_scale = Vector3(water_texture_uv_scale,water_texture_uv_scale,water_texture_uv_scale)
41 | self.material.albedo_color = water_color
42 | %FogVolume.material.set_shader_parameter("albedo", fog_color)
43 | %FogVolume.material.set_shader_parameter("emission", fog_color)
44 | %FogVolume.size = self.size
45 | %FogVolume.fade_distance = self.fog_fade_dist
46 | if not Engine.is_editor_hint():
47 | if should_draw_camera_underwater_effect():
48 | %WaterRippleOverlay.visible = true
49 | %FogVolume.material.set_shader_parameter("edge_fade", 0.1)
50 | last_frame_drew_underwater_effect = Engine.get_process_frames()
51 | else:
52 | %WaterRippleOverlay.visible = false
53 | %FogVolume.material.set_shader_parameter("edge_fade", 1.1)
54 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/FoliageBrushToolbar.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | class_name FoliageBrushToolbar
3 | extends Control
4 |
5 | const UTILS = preload("res://addons/SimpleTerrain/SimpleTerrainUtils.gd")
6 |
7 | var undo_redo : EditorUndoRedoManager
8 |
9 | var brush_randomness : float = 1.0
10 | func set_brush_randomness_from_ui(val):
11 | brush_randomness = float(val) / 100.0
12 | var brush_size : int = 64
13 | func set_brush_size_from_ui(val):
14 | brush_size = val
15 | var brush_density : float = 0.75
16 | func set_brush_density_from_ui(val):
17 | brush_density = int(val)
18 |
19 | func get_brush_mode():
20 | if %Add.button_pressed:
21 | return UTILS.FoliageBrushMode.ADD
22 | if %AddStacked.button_pressed:
23 | return UTILS.FoliageBrushMode.ADD_STACKED
24 | if %Remove.button_pressed:
25 | return UTILS.FoliageBrushMode.REMOVE
26 | return null
27 |
28 | func _ready():
29 | if not Engine.is_editor_hint():
30 | queue_free()
31 | return
32 |
33 | # Set up selection changed callback
34 | EditorInterface.get_selection().selection_changed.connect(_on_selection_changed)
35 | # Initial visibility check
36 | _on_selection_changed()
37 |
38 | func _exit_tree():
39 | if Engine.is_editor_hint():
40 | if EditorInterface.get_selection().selection_changed.is_connected(_on_selection_changed):
41 | EditorInterface.get_selection().selection_changed.disconnect(_on_selection_changed)
42 |
43 | func _on_selection_changed():
44 | var selected = EditorInterface.get_selection().get_selected_nodes()
45 | var should_show = false
46 |
47 | for node in selected:
48 | if node is SimpleTerrainFoliage:
49 | should_show = true
50 | break
51 |
52 | visible = should_show
53 |
54 | func get_simple_terrain_foliage_selected() -> SimpleTerrainFoliage:
55 | if not Engine.is_editor_hint():
56 | return null
57 | var selected_nodes = EditorInterface.get_selection().get_selected_nodes()
58 | for node in selected_nodes:
59 | if node is SimpleTerrainFoliage:
60 | return node as SimpleTerrainFoliage
61 | return null
62 |
63 | func _on_recalculate_y_pressed() -> void:
64 | var foliage_node = get_simple_terrain_foliage_selected()
65 | if not foliage_node:
66 | print("No SimpleTerrainFoliage node selected to recalculate Y positions.")
67 | return
68 |
69 | if not foliage_node.multimesh:
70 | print("Selected Foliage node has no MultiMesh.")
71 | return
72 |
73 | if not undo_redo:
74 | print("UndoRedo manager not available.")
75 | return
76 |
77 | var changes = foliage_node.update_instance_heights()
78 |
79 | if changes.is_empty():
80 | #print("No height changes needed for ", foliage_node.name)
81 | return
82 |
83 | #print("Applying %d height changes with UndoRedo for: %s" % [changes.size(), foliage_node.name])
84 |
85 | undo_redo.create_action("Update Foliage Heights")
86 |
87 | var multimesh : MultiMesh = foliage_node.multimesh # For type hinting and clarity
88 |
89 | for change in changes:
90 | var index : int = change[0]
91 | var old_transform : Transform3D = change[1]
92 | var new_transform : Transform3D = change[2]
93 |
94 | # Register the 'do' action: set the new transform
95 | undo_redo.add_do_method(multimesh, "set_instance_transform", index, new_transform)
96 | # Register the 'undo' action: restore the old transform
97 | undo_redo.add_undo_method(multimesh, "set_instance_transform", index, old_transform)
98 |
99 | undo_redo.commit_action()
100 |
--------------------------------------------------------------------------------
/utils/WaterMaker3D/WaterMaker3D.tscn:
--------------------------------------------------------------------------------
1 | [gd_scene load_steps=14 format=3 uid="uid://ca0wevg1lb4rx"]
2 |
3 | [ext_resource type="Script" uid="uid://w5j0ammn5vgr" path="res://utils/WaterMaker3D/WaterMaker3D.gd" id="1_7s6ww"]
4 | [ext_resource type="Shader" uid="uid://deenbjd7uq6uh" path="res://utils/WaterMaker3D/FogFade.gdshader" id="2_5f01f"]
5 | [ext_resource type="Shader" uid="uid://cbxrjq6roswbt" path="res://utils/WaterMaker3D/CameraWaterOverlay.gdshader" id="2_12cox"]
6 | [ext_resource type="Script" uid="uid://ch08n51eeissu" path="res://utils/WaterMaker3D/FogVolumeFadeScript.gd" id="3_b5ytn"]
7 |
8 | [sub_resource type="FastNoiseLite" id="FastNoiseLite_1bimf"]
9 | resource_local_to_scene = true
10 |
11 | [sub_resource type="NoiseTexture2D" id="NoiseTexture2D_mdx3j"]
12 | resource_local_to_scene = true
13 | seamless = true
14 | as_normal_map = true
15 | noise = SubResource("FastNoiseLite_1bimf")
16 |
17 | [sub_resource type="NoiseTexture2D" id="NoiseTexture2D_xl6ub"]
18 | resource_local_to_scene = true
19 | seamless = true
20 | as_normal_map = true
21 | noise = SubResource("FastNoiseLite_1bimf")
22 |
23 | [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_gdho2"]
24 | resource_local_to_scene = true
25 | transparency = 1
26 | cull_mode = 2
27 | depth_draw_mode = 1
28 | albedo_color = Color(0.309804, 0.541176, 0.866667, 0.388235)
29 | roughness = 0.0
30 | normal_enabled = true
31 | normal_texture = SubResource("NoiseTexture2D_mdx3j")
32 | refraction_enabled = true
33 | refraction_texture = SubResource("NoiseTexture2D_xl6ub")
34 | uv1_scale = Vector3(0.04, 0.04, 0.04)
35 | uv1_triplanar = true
36 | uv1_world_triplanar = true
37 |
38 | [sub_resource type="BoxShape3D" id="BoxShape3D_om5f8"]
39 | resource_local_to_scene = true
40 |
41 | [sub_resource type="BoxShape3D" id="BoxShape3D_cfplo"]
42 | size = Vector3(0.1, 0.1, 0.1)
43 |
44 | [sub_resource type="ShaderMaterial" id="ShaderMaterial_wrt5a"]
45 | resource_local_to_scene = true
46 | shader = ExtResource("2_5f01f")
47 | shader_parameter/density = 1.0
48 | shader_parameter/albedo = Color(0, 0.0431373, 0.156863, 1)
49 | shader_parameter/emission = Color(0, 0.0431373, 0.156863, 1)
50 | shader_parameter/height_falloff = 0.0
51 | shader_parameter/edge_fade = 0.1
52 | shader_parameter/fade_plane = Vector4(0, 1, 0, -999)
53 |
54 | [sub_resource type="ShaderMaterial" id="ShaderMaterial_ccyp1"]
55 | shader = ExtResource("2_12cox")
56 |
57 | [sub_resource type="PlaceholderTexture2D" id="PlaceholderTexture2D_dcd7l"]
58 |
59 | [node name="WaterMaker3D" type="CSGBox3D"]
60 | process_priority = 999
61 | material = SubResource("StandardMaterial3D_gdho2")
62 | script = ExtResource("1_7s6ww")
63 |
64 | [node name="SwimmableArea3D" type="Area3D" parent="." groups=["water_area"]]
65 | unique_name_in_owner = true
66 | collision_mask = 3
67 |
68 | [node name="CollisionShape3D" type="CollisionShape3D" parent="SwimmableArea3D"]
69 | unique_name_in_owner = true
70 | shape = SubResource("BoxShape3D_om5f8")
71 |
72 | [node name="CameraPosShapeCast3D" type="ShapeCast3D" parent="."]
73 | unique_name_in_owner = true
74 | shape = SubResource("BoxShape3D_cfplo")
75 | target_position = Vector3(0, 0, 0)
76 | collide_with_areas = true
77 | collide_with_bodies = false
78 |
79 | [node name="FogVolume" type="FogVolume" parent="."]
80 | unique_name_in_owner = true
81 | size = Vector3(1, 1, 1)
82 | material = SubResource("ShaderMaterial_wrt5a")
83 | script = ExtResource("3_b5ytn")
84 |
85 | [node name="WaterRippleOverlay" type="TextureRect" parent="."]
86 | unique_name_in_owner = true
87 | visible = false
88 | z_index = -10
89 | z_as_relative = false
90 | material = SubResource("ShaderMaterial_ccyp1")
91 | anchors_preset = 15
92 | anchor_right = 1.0
93 | anchor_bottom = 1.0
94 | grow_horizontal = 2
95 | grow_vertical = 2
96 | mouse_filter = 2
97 | texture = SubResource("PlaceholderTexture2D_dcd7l")
98 |
--------------------------------------------------------------------------------
/project.godot:
--------------------------------------------------------------------------------
1 | ; Engine configuration file.
2 | ; It's best edited using the editor UI and not directly,
3 | ; since the parameters that go here are not all obvious.
4 | ;
5 | ; Format:
6 | ; [section] ; section goes between []
7 | ; param=value ; assign values to parameters
8 |
9 | config_version=5
10 |
11 | [application]
12 |
13 | config/name="SimpleTerrain"
14 | run/main_scene="res://TestScene.tscn"
15 | config/features=PackedStringArray("4.5", "Forward Plus")
16 | config/icon="res://icon.svg"
17 |
18 | [editor_plugins]
19 |
20 | enabled=PackedStringArray("res://addons/SimpleTerrain/plugin.cfg")
21 |
22 | [input]
23 |
24 | up={
25 | "deadzone": 0.2,
26 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
27 | ]
28 | }
29 | down={
30 | "deadzone": 0.2,
31 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
32 | ]
33 | }
34 | left={
35 | "deadzone": 0.2,
36 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
37 | ]
38 | }
39 | right={
40 | "deadzone": 0.2,
41 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
42 | ]
43 | }
44 | jump={
45 | "deadzone": 0.2,
46 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
47 | ]
48 | }
49 | crouch={
50 | "deadzone": 0.2,
51 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
52 | ]
53 | }
54 | sprint={
55 | "deadzone": 0.2,
56 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
57 | ]
58 | }
59 | _noclip={
60 | "deadzone": 0.2,
61 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":86,"key_label":0,"unicode":118,"location":0,"echo":false,"script":null)
62 | ]
63 | }
64 | look_left={
65 | "deadzone": 0.2,
66 | "events": []
67 | }
68 | look_right={
69 | "deadzone": 0.2,
70 | "events": []
71 | }
72 | look_up={
73 | "deadzone": 0.2,
74 | "events": []
75 | }
76 | look_down={
77 | "deadzone": 0.2,
78 | "events": []
79 | }
80 | shoot={
81 | "deadzone": 0.2,
82 | "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(322, 17),"global_position":Vector2(331, 65),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null)
83 | ]
84 | }
85 | reload={
86 | "deadzone": 0.2,
87 | "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
88 | ]
89 | }
90 |
91 | [rendering]
92 |
93 | anti_aliasing/quality/msaa_3d=3
94 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/SimpleTerrainUtils.gd:
--------------------------------------------------------------------------------
1 | const HEIGHTMAP_FORMAT = Image.FORMAT_RF
2 | const SPLATMAP_FORMAT = Image.FORMAT_RGBA8
3 |
4 | enum BrushMode { RAISE, LOWER, FLATTEN, SPLAT_0, SPLAT_1, SPLAT_2, SPLAT_3, SPLAT_TRANSPARENT }
5 | enum FoliageBrushMode { ADD, ADD_STACKED, REMOVE }
6 |
7 | # Cache for fallback textures to avoid memory leaks
8 | static var _fallback_texture_cache = {}
9 |
10 | # Note: The web build was throwing an error for some reason if I didn't place EditorInterface
11 | # references in a separate file.
12 | static func get_editor_camera():
13 | # Running this directly crashes at runtime on web export so here's a workaround
14 | var script := GDScript.new()
15 | script.set_source_code("func eval(): return EditorInterface.get_editor_viewport_3d().get_camera_3d()" )
16 | script.reload()
17 | return script.new().eval()
18 |
19 | static func bresenham_line_connect(a : Vector2i, b : Vector2i) -> Array[Vector2i]:
20 | var d := Vector2i(abs(b.x - a.x), -abs(b.y - a.y))
21 | var s := Vector2i(1 if a.x < b.x else -1, 1 if a.y < b.y else -1)
22 | var err : int = d.x + d.y
23 | var result := [] as Array[Vector2i]
24 | while true:
25 | result.push_back(a)
26 | if a == b: break
27 | if err*2 >= d.y:
28 | if a.x == b.x: break
29 | err += d.y;
30 | a.x += s.x
31 | if err*2 <= d.x:
32 | if a.y == b.y: break
33 | err += d.x
34 | a.y += s.y
35 | return result
36 |
37 | # Convenience function to fallback to a 1x1 texture for flat terrain or default splat map.
38 | static func get_image_texture_with_fallback(texture : Texture2D, fallback_color : Color) -> Texture:
39 | if texture != null and not texture is ImageTexture:
40 | # For noise textures and maybe others, they return null at _ready
41 | # So disabling fallback if it's not null
42 | return texture
43 | var img = texture.get_image() if texture else null
44 | if not img:
45 | # Use cached fallback texture to avoid creating new ones repeatedly
46 | var color_key = str(fallback_color)
47 | # Ensure cache is initialized before use to avoid calling methods on null
48 | if _fallback_texture_cache == null:
49 | _fallback_texture_cache = {}
50 | if not _fallback_texture_cache.has(color_key):
51 | var black_1x1 := Image.create(1,1,false,Image.FORMAT_RGBA8)
52 | black_1x1.fill(fallback_color)
53 | _fallback_texture_cache[color_key] = ImageTexture.create_from_image(black_1x1)
54 | return _fallback_texture_cache[color_key]
55 | else:
56 | return texture
57 |
58 | static func is_texture_ready_for_edit(texture : Texture2D) -> bool:
59 | if texture == null or not texture is ImageTexture:
60 | return false
61 |
62 | # Check if texture is saved as an external resource file
63 | var resource_path = texture.resource_path
64 | if resource_path.ends_with(".tres") and not resource_path.ends_with(".res"):
65 | # This is an external .tres file, force conversion to avoid editing external files
66 | return false
67 |
68 | # If it has no resource path or is embedded in the scene, it's safe to edit
69 | return true
70 |
71 | static func get_default_texture_size_for_terain(terrain : SimpleTerrain) -> Vector2i:
72 | return terrain._get_num_verts_along_edge_total()
73 |
74 | static func get_texture_for_brush_mode(terrain : SimpleTerrain, brush_mode):
75 | if terrain == null:
76 | return null
77 | if brush_mode == BrushMode.RAISE or brush_mode == BrushMode.LOWER or brush_mode == BrushMode.FLATTEN:
78 | return terrain.heightmap_texture
79 | else:
80 | return terrain.splatmap_texture
81 |
82 | # Note, leaving type off undo_redo for web bug where it throws error even if not used.
83 | static func create_texture(terrain : SimpleTerrain, heightmap : bool, undo_redo, size := Vector2i()) -> void:
84 | if terrain == null: return
85 | if size.x == 0 or size.y == 0:
86 | size = get_default_texture_size_for_terain(terrain)
87 | var new_tex := ImageTexture.new()
88 | undo_redo.create_action("Create new terrain image")
89 | if heightmap:
90 | var img = Image.create(size.x, size.y, false, HEIGHTMAP_FORMAT)
91 | img.fill(Color.BLACK)
92 | new_tex.set_image(img)
93 | undo_redo.add_undo_property(terrain, "heightmap_texture", terrain.heightmap_texture)
94 | undo_redo.add_do_property(terrain, "heightmap_texture", new_tex)
95 | else:
96 | var img = Image.create(size.x, size.y, false, SPLATMAP_FORMAT)
97 | img.fill(Color.BLACK)
98 | print("Filled black")
99 | new_tex.set_image(img)
100 | undo_redo.add_undo_property(terrain, "splatmap_texture", terrain.splatmap_texture)
101 | undo_redo.add_do_property(terrain, "splatmap_texture", new_tex)
102 | undo_redo.commit_action()
103 |
104 | # Clear the fallback texture cache (should be called on plugin exit)
105 | static func clear_fallback_texture_cache():
106 | _fallback_texture_cache.clear()
107 |
108 | static func create_texture_if_necessary_before_paint(terrain : SimpleTerrain, undo_redo, brush_mode : BrushMode):
109 | if terrain.splatmap_texture == null and (
110 | brush_mode == BrushMode.SPLAT_0
111 | or brush_mode == BrushMode.SPLAT_1
112 | or brush_mode == BrushMode.SPLAT_2
113 | or brush_mode == BrushMode.SPLAT_3
114 | or brush_mode == BrushMode.SPLAT_TRANSPARENT):
115 | create_texture(terrain, false, undo_redo)
116 | if terrain.heightmap_texture == null and (
117 | brush_mode == BrushMode.RAISE
118 | or brush_mode == BrushMode.LOWER
119 | or brush_mode == BrushMode.FLATTEN):
120 | create_texture(terrain, true, undo_redo)
121 |
122 | static func get_point_y_on_plane(pt_on_plane : Vector2, a : Vector3, b : Vector3, c : Vector3) -> float:
123 | # Get the normal of the plane using cross product of two vectors on the plane
124 | var normal := (b - a).cross(c - a).normalized()
125 | if abs(normal.y) <= 0.00001:
126 | return a.y
127 | var d := -normal.dot(a)
128 | var y := -(normal.x * pt_on_plane.x + normal.z * pt_on_plane.y + d) / normal.y
129 | return y
130 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Creative Commons Legal Code
2 |
3 | CC0 1.0 Universal
4 |
5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
12 | HEREUNDER.
13 |
14 | Statement of Purpose
15 |
16 | The laws of most jurisdictions throughout the world automatically confer
17 | exclusive Copyright and Related Rights (defined below) upon the creator
18 | and subsequent owner(s) (each and all, an "owner") of an original work of
19 | authorship and/or a database (each, a "Work").
20 |
21 | Certain owners wish to permanently relinquish those rights to a Work for
22 | the purpose of contributing to a commons of creative, cultural and
23 | scientific works ("Commons") that the public can reliably and without fear
24 | of later claims of infringement build upon, modify, incorporate in other
25 | works, reuse and redistribute as freely as possible in any form whatsoever
26 | and for any purposes, including without limitation commercial purposes.
27 | These owners may contribute to the Commons to promote the ideal of a free
28 | culture and the further production of creative, cultural and scientific
29 | works, or to gain reputation or greater distribution for their Work in
30 | part through the use and efforts of others.
31 |
32 | For these and/or other purposes and motivations, and without any
33 | expectation of additional consideration or compensation, the person
34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she
35 | is an owner of Copyright and Related Rights in the Work, voluntarily
36 | elects to apply CC0 to the Work and publicly distribute the Work under its
37 | terms, with knowledge of his or her Copyright and Related Rights in the
38 | Work and the meaning and intended legal effect of CC0 on those rights.
39 |
40 | 1. Copyright and Related Rights. A Work made available under CC0 may be
41 | protected by copyright and related or neighboring rights ("Copyright and
42 | Related Rights"). Copyright and Related Rights include, but are not
43 | limited to, the following:
44 |
45 | i. the right to reproduce, adapt, distribute, perform, display,
46 | communicate, and translate a Work;
47 | ii. moral rights retained by the original author(s) and/or performer(s);
48 | iii. publicity and privacy rights pertaining to a person's image or
49 | likeness depicted in a Work;
50 | iv. rights protecting against unfair competition in regards to a Work,
51 | subject to the limitations in paragraph 4(a), below;
52 | v. rights protecting the extraction, dissemination, use and reuse of data
53 | in a Work;
54 | vi. database rights (such as those arising under Directive 96/9/EC of the
55 | European Parliament and of the Council of 11 March 1996 on the legal
56 | protection of databases, and under any national implementation
57 | thereof, including any amended or successor version of such
58 | directive); and
59 | vii. other similar, equivalent or corresponding rights throughout the
60 | world based on applicable law or treaty, and any national
61 | implementations thereof.
62 |
63 | 2. Waiver. To the greatest extent permitted by, but not in contravention
64 | of, applicable law, Affirmer hereby overtly, fully, permanently,
65 | irrevocably and unconditionally waives, abandons, and surrenders all of
66 | Affirmer's Copyright and Related Rights and associated claims and causes
67 | of action, whether now known or unknown (including existing as well as
68 | future claims and causes of action), in the Work (i) in all territories
69 | worldwide, (ii) for the maximum duration provided by applicable law or
70 | treaty (including future time extensions), (iii) in any current or future
71 | medium and for any number of copies, and (iv) for any purpose whatsoever,
72 | including without limitation commercial, advertising or promotional
73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
74 | member of the public at large and to the detriment of Affirmer's heirs and
75 | successors, fully intending that such Waiver shall not be subject to
76 | revocation, rescission, cancellation, termination, or any other legal or
77 | equitable action to disrupt the quiet enjoyment of the Work by the public
78 | as contemplated by Affirmer's express Statement of Purpose.
79 |
80 | 3. Public License Fallback. Should any part of the Waiver for any reason
81 | be judged legally invalid or ineffective under applicable law, then the
82 | Waiver shall be preserved to the maximum extent permitted taking into
83 | account Affirmer's express Statement of Purpose. In addition, to the
84 | extent the Waiver is so judged Affirmer hereby grants to each affected
85 | person a royalty-free, non transferable, non sublicensable, non exclusive,
86 | irrevocable and unconditional license to exercise Affirmer's Copyright and
87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the
88 | maximum duration provided by applicable law or treaty (including future
89 | time extensions), (iii) in any current or future medium and for any number
90 | of copies, and (iv) for any purpose whatsoever, including without
91 | limitation commercial, advertising or promotional purposes (the
92 | "License"). The License shall be deemed effective as of the date CC0 was
93 | applied by Affirmer to the Work. Should any part of the License for any
94 | reason be judged legally invalid or ineffective under applicable law, such
95 | partial invalidity or ineffectiveness shall not invalidate the remainder
96 | of the License, and in such case Affirmer hereby affirms that he or she
97 | will not (i) exercise any of his or her remaining Copyright and Related
98 | Rights in the Work or (ii) assert any associated claims and causes of
99 | action with respect to the Work, in either case contrary to Affirmer's
100 | express Statement of Purpose.
101 |
102 | 4. Limitations and Disclaimers.
103 |
104 | a. No trademark or patent rights held by Affirmer are waived, abandoned,
105 | surrendered, licensed or otherwise affected by this document.
106 | b. Affirmer offers the Work as-is and makes no representations or
107 | warranties of any kind concerning the Work, express, implied,
108 | statutory or otherwise, including without limitation warranties of
109 | title, merchantability, fitness for a particular purpose, non
110 | infringement, or the absence of latent or other defects, accuracy, or
111 | the present or absence of errors, whether or not discoverable, all to
112 | the greatest extent permissible under applicable law.
113 | c. Affirmer disclaims responsibility for clearing rights of other persons
114 | that may apply to the Work or any use thereof, including without
115 | limitation any person's Copyright and Related Rights in the Work.
116 | Further, Affirmer disclaims responsibility for obtaining any necessary
117 | consents, permissions or other rights required for any use of the
118 | Work.
119 | d. Affirmer understands and acknowledges that Creative Commons is not a
120 | party to this document and has no duty or obligation with respect to
121 | this CC0 or use of the Work.
--------------------------------------------------------------------------------
/addons/SimpleTerrain/Shaders/SimpleTerrain.gdshader:
--------------------------------------------------------------------------------
1 | shader_type spatial;
2 | render_mode depth_draw_always; // Needed because we set alpha for holes
3 | //render_mode unshaded,wireframe,cull_disabled;
4 |
5 | #include "TerrainShaderUtils.gdshaderinc"
6 |
7 | uniform sampler2D heightmap;
8 | uniform sampler2D splatmap;
9 | uniform sampler2D normalmap;
10 |
11 | uniform bool triplanar_on_texture_0 = true;
12 | uniform bool triplanar_on_texture_1 = false;
13 | uniform bool triplanar_on_texture_2 = false;
14 | uniform bool triplanar_on_texture_3 = false;
15 |
16 | uniform sampler2D texture_0_albedo : source_color;
17 | uniform sampler2D texture_1_albedo : source_color;
18 | uniform sampler2D texture_2_albedo : source_color;
19 | uniform sampler2D texture_3_albedo : source_color;
20 |
21 | uniform sampler2D texture_0_normal;
22 | uniform sampler2D texture_1_normal;
23 | uniform sampler2D texture_2_normal;
24 | uniform sampler2D texture_3_normal;
25 |
26 | uniform vec2 texture_0_uv_scale;
27 | uniform vec2 texture_1_uv_scale;
28 | uniform vec2 texture_2_uv_scale;
29 | uniform vec2 texture_3_uv_scale;
30 |
31 | uniform ivec3 cam_chunk_loc;
32 | uniform vec3 cam_rel_pos;
33 | uniform mat3 inv_normal_basis;
34 | uniform mat4 inv_global_transform;
35 | uniform ivec2 chunk_count;
36 | uniform float terrain_xz_scale;
37 | uniform float terrain_height_scale;
38 | uniform int highest_lod_res;
39 | uniform float lod_dropoff_rate;
40 |
41 | varying vec3 v_vert_pos_local;
42 | varying vec3 v_normal;
43 | varying vec4 v_splat_color;
44 | varying vec3 v_vert_pos_normalized;
45 |
46 | const float NUDGE = 0.00001;
47 |
48 | vec4 texture_triplanar_if_enabled(int texture_num, sampler2D tex, vec3 local_pos, vec3 normal, vec2 uv_scale) {
49 | if((triplanar_on_texture_0 && texture_num == 0) || (triplanar_on_texture_1 && texture_num == 1)
50 | || (triplanar_on_texture_2 && texture_num == 2) || (triplanar_on_texture_3 && texture_num == 3)) {
51 | return texture_triplanar(tex, local_pos, normal, uv_scale);
52 | }
53 | else {
54 | return texture(tex, local_pos.xz / uv_scale);
55 | }
56 | }
57 |
58 | int get_lod(vec3 vert_pos_local) {
59 | // Find LOD. When at an edge, nudge vert away from camera so it rounds down to the proper chunk.
60 | vec2 total_terrain_xz_size = terrain_xz_scale * vec2(chunk_count);
61 | vec3 vert_pos_normalized = vert_pos_local / vec3(total_terrain_xz_size.x, 1., total_terrain_xz_size.y);
62 | vec3 nudge_edge_verts = sign(vert_pos_local - cam_rel_pos) * NUDGE;
63 |
64 | ivec3 my_chunk_loc = ivec3(v_vert_pos_normalized * vec3(float(chunk_count.x), 1.0, float(chunk_count.y)) + nudge_edge_verts);
65 | int diff = max(
66 | max(
67 | abs(my_chunk_loc.x - cam_chunk_loc.x),
68 | abs(my_chunk_loc.z - cam_chunk_loc.z)
69 | ),
70 | abs(my_chunk_loc.y - cam_chunk_loc.y)
71 | );
72 |
73 | return max(
74 | 0, highest_lod_res - int(lod_dropoff_rate * float(diff))
75 | );
76 | }
77 |
78 | void vertex() {
79 | // Put vert in world space, then make it relative to the terrain's world start pos.
80 | vec3 vert_relative_to_terrain = (inv_global_transform * (MODEL_MATRIX * vec4(VERTEX, 1.0))).xyz;
81 | // Normalize vert between 0 (start of terrain) and 1 (end of terrain).
82 | vec2 total_terrain_xz_size = terrain_xz_scale * vec2(chunk_count);
83 | v_vert_pos_normalized = vert_relative_to_terrain / vec3(total_terrain_xz_size.x, 1., total_terrain_xz_size.y);
84 |
85 | int my_lod = get_lod(vert_relative_to_terrain);
86 |
87 | // When next to a lower LOD chunk, snap vert to nearest 2 subdivisions on lower LOD and interpolate.
88 | // Will have no effect if not on a chunk edge. (When chunk LOD == nudged LOD).
89 | float num_segments_per_chunk = pow(2.0, float(my_lod));
90 | vec2 num_segments_total = vec2(chunk_count) * num_segments_per_chunk;
91 | vec2 subdiv_size_normal = 1.0 / num_segments_total;
92 | vec2 snap_down = floor(v_vert_pos_normalized.xz * num_segments_total + NUDGE) / num_segments_total;
93 | vec2 snap_up = ceil(v_vert_pos_normalized.xz * num_segments_total - NUDGE) / num_segments_total;
94 | vec2 snap_diff = (v_vert_pos_normalized.xz - snap_down) / subdiv_size_normal;
95 | float interp_factor = max(snap_diff.x, snap_diff.y);
96 |
97 | // Get height at the 2 closest vertices on subdiv and interpolate.
98 | // snap_up and snap_down will be equal if not stepping down an LOD.
99 | float heightmap_val = mix(
100 | sample_middle_of_pixel(heightmap, snap_down).r,
101 | sample_middle_of_pixel(heightmap, snap_up).r,
102 | interp_factor
103 | );
104 | VERTEX.y = heightmap_val * terrain_height_scale;
105 | v_vert_pos_local.y = heightmap_val * terrain_height_scale;
106 | v_vert_pos_local.xz = (v_vert_pos_normalized.xz * vec2(chunk_count) * terrain_xz_scale);
107 |
108 | v_splat_color = sample_middle_of_pixel(splatmap, v_vert_pos_normalized.xz).rgba;
109 | }
110 |
111 | void fragment() {
112 | // Here, each channel of the splatmap directly represents the weight of each texture. Black is texture 0
113 | float tex_0_weight = clamp(1.0 - (v_splat_color.r + v_splat_color.g + v_splat_color.b), 0., 1.);
114 | float tex_1_weight = v_splat_color.r; // Weight for red (tex 1)
115 | float tex_2_weight = v_splat_color.g; // Weight for blue (tex 2)
116 | float tex_3_weight = v_splat_color.b; // Weight for green (tex 3)
117 |
118 | // Ensure the weights sum up to 1.0
119 | float total_weight = tex_0_weight + tex_1_weight + tex_2_weight + tex_3_weight;
120 | tex_0_weight /= total_weight;
121 | tex_1_weight /= total_weight;
122 | tex_2_weight /= total_weight;
123 | tex_3_weight /= total_weight;
124 |
125 | vec3 normal0 = unpack_normal(texture(texture_0_normal, v_vert_pos_local.xz / texture_0_uv_scale));
126 | vec3 normal1 = unpack_normal(texture(texture_1_normal, v_vert_pos_local.xz / texture_1_uv_scale));
127 | vec3 normal2 = unpack_normal(texture(texture_2_normal, v_vert_pos_local.xz / texture_2_uv_scale));
128 | vec3 normal3 = unpack_normal(texture(texture_3_normal, v_vert_pos_local.xz / texture_3_uv_scale));
129 |
130 | // Blend the normal maps in the same way as the textures
131 | vec3 ground_normal = normal0 * tex_0_weight +
132 | normal1 * tex_1_weight +
133 | normal2 * tex_2_weight +
134 | normal3 * tex_3_weight;
135 |
136 | vec3 terrain_normal = unpack_normal(texture(normalmap, v_vert_pos_normalized.xz));
137 | vec3 terrain_and_ground_normal = apply_normal_basis(ground_normal, terrain_normal);
138 |
139 | //if (terrain_normal.y < 0.0) {
140 | //ALBEDO = vec3(1.0,0.0,0.0);
141 | //}
142 |
143 | // Put in world space
144 | vec3 terrain_normal_world = normalize(inv_normal_basis * terrain_and_ground_normal);
145 |
146 | // Sample each of the four textures. Only doing triplanar on the first one.
147 | vec3 tex_0_color = texture_triplanar_if_enabled(0, texture_0_albedo, v_vert_pos_local, terrain_normal, texture_0_uv_scale).rgb;
148 | vec3 tex_1_color = texture_triplanar_if_enabled(1, texture_1_albedo, v_vert_pos_local, terrain_normal, texture_1_uv_scale).rgb;
149 | vec3 tex_2_color = texture_triplanar_if_enabled(2, texture_2_albedo, v_vert_pos_local, terrain_normal, texture_2_uv_scale).rgb;
150 | vec3 tex_3_color = texture_triplanar_if_enabled(3, texture_3_albedo, v_vert_pos_local, terrain_normal, texture_3_uv_scale).rgb;
151 |
152 | // Blend the textures based on the weights
153 | vec3 final_color = tex_0_color * tex_0_weight +
154 | tex_1_color * tex_1_weight +
155 | tex_2_color * tex_2_weight +
156 | tex_3_color * tex_3_weight;
157 |
158 | // Assign the final color to the fragment's albedo
159 | ALBEDO = final_color;
160 | //ALBEDO = vec3(tex_0_weight);
161 | //ALBEDO = terrain_normal;
162 | // Only make it a hole if the tri is fully transparent. This is so we have clearly defined holes.
163 | if (v_splat_color.a < 0.0001) {
164 | discard;
165 | }
166 |
167 | NORMAL = (VIEW_MATRIX * (vec4(terrain_normal_world, 0.0))).xyz;
168 | }
169 |
--------------------------------------------------------------------------------
/rocky.gdshader:
--------------------------------------------------------------------------------
1 | shader_type spatial;
2 |
3 | // ---------- USER-TWEAKABLE UNIFORMS ----------
4 | uniform float noise_scale : hint_range(0.1, 10.0) = 1.2;
5 | uniform float voronoi_scale : hint_range(0.1, 5.0) = 1.5;
6 | uniform float rock_detail : hint_range(0.0, 2.0) = 0.8;
7 | uniform float bump_strength : hint_range(0.0, 2.0) = 0.5;
8 | uniform vec3 base_color : source_color = vec3(0.6, 0.45, 0.35); // Darker tan
9 | uniform vec3 dark_color : source_color = vec3(0.3, 0.2, 0.15); // Darker brown
10 | uniform vec3 highlight_color : source_color = vec3(0.8, 0.65, 0.5); // Muted highlight
11 | uniform float color_variation : hint_range(0.0, 1.0) = 0.4;
12 | uniform float cartoon_contrast : hint_range(1.0, 8.0) = 4.0;
13 | uniform float style_sharpness : hint_range(0.1, 1.0) = 0.3;
14 |
15 | // ---------- VARYINGS ----------
16 | varying vec3 v_model_pos;
17 | varying vec3 v_model_norm;
18 |
19 | // ---------- UTILITY / HASH / NOISE ----------
20 | vec2 hash22(vec2 p) {
21 | p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
22 | return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
23 | }
24 |
25 | vec3 hash33(vec3 p) {
26 | p = vec3(dot(p, vec3(127.1, 311.7, 74.7)),
27 | dot(p, vec3(269.5, 183.3, 246.1)),
28 | dot(p, vec3(113.5, 271.9, 124.6)));
29 | return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
30 | }
31 |
32 | // Improved 3D noise function
33 | float noise3d(vec3 p) {
34 | vec3 i = floor(p);
35 | vec3 f = fract(p);
36 | vec3 u = f * f * (3.0 - 2.0 * f);
37 |
38 | return mix(
39 | mix(
40 | mix(dot(hash33(i + vec3(0.0, 0.0, 0.0)), f - vec3(0.0, 0.0, 0.0)),
41 | dot(hash33(i + vec3(1.0, 0.0, 0.0)), f - vec3(1.0, 0.0, 0.0)), u.x),
42 | mix(dot(hash33(i + vec3(0.0, 1.0, 0.0)), f - vec3(0.0, 1.0, 0.0)),
43 | dot(hash33(i + vec3(1.0, 1.0, 0.0)), f - vec3(1.0, 1.0, 0.0)), u.x), u.y),
44 | mix(
45 | mix(dot(hash33(i + vec3(0.0, 0.0, 1.0)), f - vec3(0.0, 0.0, 1.0)),
46 | dot(hash33(i + vec3(1.0, 0.0, 1.0)), f - vec3(1.0, 0.0, 1.0)), u.x),
47 | mix(dot(hash33(i + vec3(0.0, 1.0, 1.0)), f - vec3(0.0, 1.0, 1.0)),
48 | dot(hash33(i + vec3(1.0, 1.0, 1.0)), f - vec3(1.0, 1.0, 1.0)), u.x), u.y), u.z);
49 | }
50 |
51 | // 2D Voronoi function
52 | float voronoi2d(vec2 p) {
53 | vec2 i = floor(p);
54 | vec2 f = fract(p);
55 |
56 | float min_dist = 1.0;
57 |
58 | for (int y = -1; y <= 1; y++) {
59 | for (int x = -1; x <= 1; x++) {
60 | vec2 neighbor = vec2(float(x), float(y));
61 | vec2 point = hash22(i + neighbor);
62 | point = 0.5 + 0.5 * sin(6.2831 * point);
63 | vec2 diff = neighbor + point - f;
64 | float dist = length(diff);
65 | min_dist = min(min_dist, dist);
66 | }
67 | }
68 |
69 | return min_dist;
70 | }
71 |
72 | // Fractal Brownian Motion
73 | float fbm(vec3 p, int octaves) {
74 | float value = 0.0;
75 | float amplitude = 0.5;
76 | float frequency = 1.0;
77 |
78 | for (int i = 0; i < octaves; i++) {
79 | value += amplitude * noise3d(p * frequency);
80 | amplitude *= 0.5;
81 | frequency *= 2.0;
82 | }
83 |
84 | return value;
85 | }
86 |
87 | // ---------- TRIPLANAR SAMPLING ----------
88 | float sample_triplanar_noise(vec3 p, vec3 n) {
89 | vec3 w = abs(n);
90 | w = pow(w, vec3(3.0));
91 | w /= (w.x + w.y + w.z);
92 |
93 | float nx = fbm(vec3(p.y, p.z, 0.0), 4);
94 | float ny = fbm(vec3(p.x, p.z, 0.0), 4);
95 | float nz = fbm(vec3(p.x, p.y, 0.0), 4);
96 |
97 | return nx * w.x + ny * w.y + nz * w.z;
98 | }
99 |
100 | float sample_triplanar_voronoi(vec3 p, vec3 n) {
101 | vec3 w = abs(n);
102 | w = pow(w, vec3(3.0));
103 | w /= (w.x + w.y + w.z);
104 |
105 | float vx = voronoi2d(p.yz);
106 | float vy = voronoi2d(p.xz);
107 | float vz = voronoi2d(p.xy);
108 |
109 | return vx * w.x + vy * w.y + vz * w.z;
110 | }
111 |
112 | // ---------- NORMAL CALCULATION ----------
113 | vec3 calculate_normal(vec3 p, vec3 n) {
114 | const float eps = 0.0001;
115 |
116 | // Sample noise and voronoi separately for better control
117 | float noise_h = sample_triplanar_noise(p, n);
118 | float voronoi_h = sample_triplanar_voronoi(p, n);
119 |
120 | // Smooth the voronoi to reduce harsh edges
121 | voronoi_h = smoothstep(0.1, 0.9, voronoi_h);
122 |
123 | // Combine with reduced voronoi influence for normals
124 | float h = noise_h + voronoi_h * 0.3;
125 |
126 | // Calculate gradients with the same pattern
127 | float noise_hx = sample_triplanar_noise(p + vec3(eps, 0.0, 0.0), n);
128 | float voronoi_hx = smoothstep(0.1, 0.9, sample_triplanar_voronoi(p + vec3(eps, 0.0, 0.0), n));
129 | float hx = noise_hx + voronoi_hx * 0.3;
130 |
131 | float noise_hy = sample_triplanar_noise(p + vec3(0.0, eps, 0.0), n);
132 | float voronoi_hy = smoothstep(0.1, 0.9, sample_triplanar_voronoi(p + vec3(0.0, eps, 0.0), n));
133 | float hy = noise_hy + voronoi_hy * 0.3;
134 |
135 | float noise_hz = sample_triplanar_noise(p + vec3(0.0, 0.0, eps), n);
136 | float voronoi_hz = smoothstep(0.1, 0.9, sample_triplanar_voronoi(p + vec3(0.0, 0.0, eps), n));
137 | float hz = noise_hz + voronoi_hz * 0.3;
138 |
139 | vec3 gradient = vec3(hx - h, hy - h, hz - h) / eps;
140 |
141 | // Clamp gradient to prevent extreme values
142 | gradient = clamp(gradient, vec3(-2.0), vec3(2.0));
143 |
144 | return normalize(n - gradient * bump_strength);
145 | }
146 |
147 | // ---------- VERTEX ----------
148 | void vertex() {
149 | v_model_pos = VERTEX;
150 | v_model_norm = normalize(NORMAL);
151 | }
152 |
153 | // ---------- FRAGMENT ----------
154 | void fragment() {
155 | vec3 p_noise = v_model_pos * noise_scale;
156 | vec3 p_voronoi = v_model_pos * voronoi_scale;
157 | vec3 n = normalize(v_model_norm);
158 |
159 | // Sample noise and voronoi patterns
160 | float noise_val = sample_triplanar_noise(p_noise, n);
161 | float voronoi_val = sample_triplanar_voronoi(p_voronoi, n);
162 |
163 | // Combine patterns for rock detail
164 | float rock_pattern = mix(noise_val, voronoi_val, 0.7);
165 | rock_pattern = rock_pattern * 0.5 + 0.5; // Normalize to 0-1
166 |
167 | // Add fine detail with higher frequency for crispness
168 | float detail = fbm(p_noise * 3.0, 3) * rock_detail;
169 | rock_pattern += detail * 0.25;
170 |
171 | // Apply aggressive cartoon-style contrast enhancement
172 | rock_pattern = pow(rock_pattern, 1.0 / cartoon_contrast);
173 | rock_pattern = smoothstep(0.1, 0.9, rock_pattern);
174 |
175 | // Create sharp, distinct color zones with minimal blending
176 | float shadow_threshold = 0.3;
177 | float highlight_threshold = 0.7;
178 |
179 | float shadow_mask = smoothstep(shadow_threshold - style_sharpness, shadow_threshold + style_sharpness, rock_pattern);
180 | float highlight_mask = smoothstep(highlight_threshold - style_sharpness, highlight_threshold + style_sharpness, rock_pattern);
181 |
182 | // Quantize the pattern for more distinct zones but with higher resolution
183 | float quantized_pattern = floor(rock_pattern * 8.0) / 8.0;
184 |
185 | // Build color in sharp layers for strong cartoon effect
186 | vec3 rock_color = dark_color;
187 | rock_color = mix(rock_color, base_color, shadow_mask);
188 | rock_color = mix(rock_color, highlight_color, highlight_mask);
189 |
190 | // Add high-contrast stylized banding
191 | float color_bands = sin(quantized_pattern * 6.28 * 3.0) * 0.5 + 0.5;
192 | color_bands = step(0.5, color_bands); // Hard step for sharp bands
193 | float color_accent = color_bands * color_variation;
194 |
195 | // Apply color accent more dramatically
196 | rock_color += vec3(color_accent * 0.15, color_accent * 0.08, color_accent * 0.05);
197 |
198 | // Enhance contrast further by pushing colors to extremes
199 | rock_color = pow(rock_color, vec3(0.8)); // Gamma adjustment for more pop
200 |
201 | // Simplified roughness for cartoon style
202 | float roughness = mix(1.0, 0.7, highlight_mask);
203 | roughness = clamp(roughness, 0.0, 1.0);
204 |
205 | ALBEDO = clamp(rock_color, vec3(0.0), vec3(1.0));
206 | ROUGHNESS = roughness;
207 | NORMAL = normalize(VIEW_MATRIX * vec4(calculate_normal(p_noise, n), 0.0)).xyz;
208 | }
209 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/plugin.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | extends EditorPlugin
3 |
4 | const UTILS = preload("res://addons/SimpleTerrain/SimpleTerrainUtils.gd")
5 |
6 | var undo_redo : EditorUndoRedoManager
7 |
8 | var terrain_brush_container : Node
9 | var terrain_brush : TerrainBrushDecal
10 |
11 | var foliage_brush_container : Node
12 | var foliage_brush : Node3D
13 |
14 | var brush_toolbar : BrushToolbar
15 | var foliage_brush_toolbar : FoliageBrushToolbar
16 | var _left_mouse_pressed := false
17 |
18 | func get_simple_terrain_selected() -> SimpleTerrain:
19 | if not Engine.is_editor_hint():
20 | return null
21 | var selected_nodes = EditorInterface.get_selection().get_selected_nodes()
22 | for node in selected_nodes:
23 | if node is SimpleTerrain:
24 | return node as SimpleTerrain
25 | return null
26 |
27 | func get_simple_terrain_foliage_selected() -> SimpleTerrainFoliage:
28 | if not Engine.is_editor_hint():
29 | return null
30 | var selected_nodes = EditorInterface.get_selection().get_selected_nodes()
31 | for node in selected_nodes:
32 | if node is SimpleTerrainFoliage:
33 | return node as SimpleTerrainFoliage
34 | return null
35 |
36 | func remove_brush_node_from_tree():
37 | update_brush_node(true)
38 | update_foliage_brush_node(true)
39 |
40 | func update_brush_node(remove := false):
41 | var terrain := get_simple_terrain_selected()
42 | if brush_toolbar.get_brush_mode() == null or remove:
43 | terrain = null
44 | # Was freed when parent Terrain3D was freed
45 | if not is_instance_valid(terrain_brush_container) or terrain_brush_container == null:
46 | terrain_brush_container = Node.new()
47 | terrain_brush = TerrainBrushDecal.new()
48 | terrain_brush_container.add_child(terrain_brush)
49 | if terrain_brush_container.get_parent() != terrain:
50 | if terrain_brush_container.get_parent() != null:
51 | terrain_brush_container.get_parent().set_meta("_edit_lock_", null)
52 | terrain_brush_container.get_parent().remove_child(terrain_brush_container)
53 | if terrain != null:
54 | terrain.add_child(terrain_brush_container)
55 | terrain_brush.opacity = brush_toolbar.brush_opacity
56 | terrain_brush.brush_size = brush_toolbar.brush_size
57 | terrain_brush.hardness = brush_toolbar.brush_hardness
58 | terrain_brush.follow_mouse = true
59 | if not terrain_brush.is_inside_tree():
60 | terrain_brush.painting = false
61 |
62 | func update_foliage_brush_node(remove := false):
63 | var foliage := get_simple_terrain_foliage_selected()
64 | if foliage_brush_toolbar.get_brush_mode() == null or remove:
65 | foliage = null
66 |
67 | # If we're removing it or no foliage is selected, clean up any existing brush
68 | if foliage == null and is_instance_valid(foliage_brush_container):
69 | if foliage_brush_container.get_parent() != null:
70 | foliage_brush_container.get_parent().remove_child(foliage_brush_container)
71 | return
72 |
73 | # Was freed when parent was freed or doesn't exist yet
74 | if not is_instance_valid(foliage_brush_container) or foliage_brush_container == null:
75 | foliage_brush_container = Node.new()
76 | foliage_brush = FoliageBrushDecal.new()
77 | foliage_brush_container.add_child(foliage_brush)
78 |
79 | # Only proceed if we have a valid foliage node and brush
80 | if foliage != null and is_instance_valid(foliage_brush):
81 | # Update parent if needed
82 | if foliage_brush_container.get_parent() != foliage:
83 | if foliage_brush_container.get_parent() != null:
84 | foliage_brush_container.get_parent().set_meta("_edit_lock_", null)
85 | foliage_brush_container.get_parent().remove_child(foliage_brush_container)
86 | foliage.add_child(foliage_brush_container)
87 |
88 | # Update brush properties
89 | foliage_brush.follow_mouse = true
90 | foliage_brush.undo_redo = undo_redo
91 |
92 | if foliage_brush_toolbar:
93 | foliage_brush.density = foliage_brush_toolbar.brush_density
94 | foliage_brush.brush_size = foliage_brush_toolbar.brush_size
95 | foliage_brush.randomness = foliage_brush_toolbar.brush_randomness
96 |
97 | if not foliage_brush.is_inside_tree():
98 | foliage_brush.painting = false
99 |
100 | func _process(delta):
101 | update_brush_node()
102 | update_foliage_brush_node()
103 |
104 | func _enter_tree():
105 | add_custom_type("SimpleTerrain", "Node3D", preload("SimpleTerrain.gd"), preload("res://addons/SimpleTerrain/assets/textures/terrain_icon.svg"))
106 | add_custom_type("SimpleTerrainFoliage", "Node3D", preload("SimpleTerrainFoliage.gd"), preload("res://addons/SimpleTerrain/assets/textures/foliage_icon.svg"))
107 |
108 | brush_toolbar = preload("res://addons/SimpleTerrain/BrushToolbar.tscn").instantiate()
109 | foliage_brush_toolbar = preload("res://addons/SimpleTerrain/FoliageBrushToolbar.tscn").instantiate()
110 |
111 | undo_redo = get_undo_redo()
112 | brush_toolbar.undo_redo = undo_redo
113 | foliage_brush_toolbar.undo_redo = undo_redo
114 |
115 | add_control_to_container(CONTAINER_SPATIAL_EDITOR_BOTTOM, brush_toolbar)
116 | add_control_to_container(CONTAINER_SPATIAL_EDITOR_BOTTOM, foliage_brush_toolbar)
117 |
118 | func _exit_tree():
119 | # Clean-up of the plugin goes here.
120 | remove_custom_type("SimpleTerrain")
121 | remove_custom_type("SimpleTerrainFoliage")
122 | # Remove the dock.
123 | remove_control_from_container(CONTAINER_SPATIAL_EDITOR_BOTTOM, brush_toolbar)
124 | remove_control_from_container(CONTAINER_SPATIAL_EDITOR_BOTTOM, foliage_brush_toolbar)
125 | # Erase the control from the memory.
126 | remove_brush_node_from_tree()
127 | terrain_brush_container.queue_free()
128 | foliage_brush_container.queue_free()
129 | brush_toolbar.free()
130 | foliage_brush_toolbar.free()
131 |
132 | func _handles(object):
133 | return object is SimpleTerrain or object is SimpleTerrainFoliage
134 |
135 | func _forward_3d_gui_input(camera, event):
136 | var left_mouse_pressed = false
137 | var shift_pressed = false
138 |
139 | if event is InputEventMouse:
140 | if event is InputEventMouseButton:
141 | if event.button_index == MOUSE_BUTTON_LEFT:
142 | _left_mouse_pressed = event.pressed
143 | # Check for shift key state
144 | shift_pressed = event.shift_pressed
145 |
146 | var terrain = get_simple_terrain_selected()
147 | var foliage = get_simple_terrain_foliage_selected()
148 |
149 | var terrain_brush_mode_selected = terrain and brush_toolbar.get_brush_mode() != null
150 | var foliage_brush_mode_selected = foliage and foliage_brush_toolbar.get_brush_mode() != null
151 |
152 | if terrain_brush_mode_selected:
153 | update_brush_node()
154 | if event is InputEventMouseMotion:
155 | terrain_brush._raycast_and_snap_to_terrain(camera, Vector2(event.position))
156 | # Pass shift state to terrain brush
157 | terrain_brush.shift_pressed = shift_pressed
158 | else:
159 | remove_brush_node_from_tree()
160 |
161 | if foliage and foliage_brush_mode_selected:
162 | update_foliage_brush_node()
163 | if event is InputEventMouseMotion:
164 | foliage_brush._raycast_and_snap_to_terrain(camera, Vector2(event.position))
165 | else:
166 | update_foliage_brush_node(true)
167 |
168 | if _left_mouse_pressed and terrain_brush_mode_selected:
169 | var brush_mode = brush_toolbar.get_brush_mode()
170 | # Must create or convert the texture to an ImageTexture resource before painting on it
171 | UTILS.create_texture_if_necessary_before_paint(terrain, undo_redo, brush_mode)
172 | if not UTILS.is_texture_ready_for_edit(UTILS.get_texture_for_brush_mode(terrain, brush_mode)):
173 | _left_mouse_pressed = false
174 | brush_toolbar.show_convert_texture_popup(brush_mode)
175 | return EditorPlugin.AFTER_GUI_INPUT_STOP
176 |
177 | terrain_brush.undo_redo = undo_redo
178 | terrain_brush.painting = true
179 | terrain_brush.brush_mode = brush_toolbar.get_brush_mode()
180 | else:
181 | if terrain_brush:
182 | terrain_brush.painting = false
183 |
184 | if _left_mouse_pressed and foliage and foliage_brush_mode_selected:
185 | foliage_brush.painting = true
186 | foliage_brush.brush_mode = foliage_brush_toolbar.get_brush_mode()
187 | else:
188 | if foliage_brush:
189 | foliage_brush.painting = false
190 |
191 | # Only stop input propagation if actively using a brush
192 | if _left_mouse_pressed and (terrain_brush_mode_selected or foliage_brush_mode_selected):
193 | return EditorPlugin.AFTER_GUI_INPUT_STOP
194 | else:
195 | return EditorPlugin.AFTER_GUI_INPUT_PASS
196 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/FoliageBrushToolbar.tscn:
--------------------------------------------------------------------------------
1 | [gd_scene load_steps=6 format=3 uid="uid://8kj5d3wyim5t"]
2 |
3 | [ext_resource type="Script" uid="uid://ctuhbdxj16e1b" path="res://addons/SimpleTerrain/FoliageBrushToolbar.gd" id="1_omlq5"]
4 | [ext_resource type="Texture2D" uid="uid://detap6fgfpjec" path="res://addons/SimpleTerrain/assets/textures/foliage_icon.svg" id="2_0nnfe"]
5 |
6 | [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nxm1g"]
7 | border_color = Color(0, 0, 0, 1)
8 |
9 | [sub_resource type="Theme" id="Theme_r08lb"]
10 | TextureRect/styles/asd = SubResource("StyleBoxFlat_nxm1g")
11 |
12 | [sub_resource type="ButtonGroup" id="ButtonGroup_praku"]
13 | allow_unpress = true
14 |
15 | [node name="FoliageBrushToolbar" type="MarginContainer"]
16 | visible = false
17 | anchors_preset = 12
18 | anchor_top = 1.0
19 | anchor_right = 1.0
20 | anchor_bottom = 1.0
21 | offset_top = -41.0
22 | grow_horizontal = 2
23 | grow_vertical = 0
24 | theme = SubResource("Theme_r08lb")
25 | theme_override_constants/margin_left = 10
26 | theme_override_constants/margin_top = 5
27 | theme_override_constants/margin_right = 10
28 | theme_override_constants/margin_bottom = 5
29 | script = ExtResource("1_omlq5")
30 |
31 | [node name="HBoxContainer" type="HBoxContainer" parent="."]
32 | layout_mode = 2
33 |
34 | [node name="HBoxContainer" type="HBoxContainer" parent="HBoxContainer"]
35 | layout_mode = 2
36 |
37 | [node name="BrushButtons" type="MarginContainer" parent="HBoxContainer/HBoxContainer"]
38 | custom_minimum_size = Vector2(25, 0)
39 | layout_mode = 2
40 | theme_override_constants/margin_right = 10
41 |
42 | [node name="BrushButtons" type="HBoxContainer" parent="HBoxContainer/HBoxContainer/BrushButtons"]
43 | layout_mode = 2
44 |
45 | [node name="TextureRect" type="TextureRect" parent="HBoxContainer/HBoxContainer/BrushButtons/BrushButtons"]
46 | layout_mode = 2
47 | texture = ExtResource("2_0nnfe")
48 | stretch_mode = 5
49 |
50 | [node name="Label" type="Label" parent="HBoxContainer/HBoxContainer/BrushButtons/BrushButtons"]
51 | layout_mode = 2
52 | text = "Foliage:"
53 |
54 | [node name="Add" type="Button" parent="HBoxContainer/HBoxContainer/BrushButtons/BrushButtons"]
55 | unique_name_in_owner = true
56 | layout_mode = 2
57 | tooltip_text = "Add foliage, painting over same spot multiple times does not stack foliage. Add a collision shape used for raycasts for more precise brush placement."
58 | toggle_mode = true
59 | button_group = SubResource("ButtonGroup_praku")
60 | text = "Add"
61 |
62 | [node name="AddStacked" type="Button" parent="HBoxContainer/HBoxContainer/BrushButtons/BrushButtons"]
63 | unique_name_in_owner = true
64 | layout_mode = 2
65 | tooltip_text = "Add foliage, painting over same spot multiple times stacks foliage. Add a collision shape used for raycasts for more precise brush placement."
66 | toggle_mode = true
67 | button_group = SubResource("ButtonGroup_praku")
68 | text = "Add Stacked"
69 |
70 | [node name="Remove" type="Button" parent="HBoxContainer/HBoxContainer/BrushButtons/BrushButtons"]
71 | unique_name_in_owner = true
72 | layout_mode = 2
73 | tooltip_text = "Remove foliage within brush radius. Add a collision shape used for raycasts for more precise brush placement."
74 | toggle_mode = true
75 | button_group = SubResource("ButtonGroup_praku")
76 | text = "Remove"
77 |
78 | [node name="HBoxContainer2" type="HBoxContainer" parent="HBoxContainer"]
79 | layout_mode = 2
80 | size_flags_horizontal = 3
81 |
82 | [node name="BrushSize" type="MarginContainer" parent="HBoxContainer/HBoxContainer2"]
83 | layout_mode = 2
84 | size_flags_horizontal = 3
85 | theme_override_constants/margin_right = 10
86 |
87 | [node name="HBoxContainer" type="HBoxContainer" parent="HBoxContainer/HBoxContainer2/BrushSize"]
88 | layout_mode = 2
89 |
90 | [node name="Label" type="Label" parent="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer"]
91 | layout_mode = 2
92 | text = "Size:"
93 |
94 | [node name="SpinBox" type="SpinBox" parent="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer"]
95 | layout_mode = 2
96 | max_value = 512.0
97 | value = 64.0
98 | allow_greater = true
99 | suffix = "m"
100 |
101 | [node name="HSlider" type="HSlider" parent="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer"]
102 | custom_minimum_size = Vector2(50, 0)
103 | layout_mode = 2
104 | size_flags_horizontal = 3
105 | size_flags_vertical = 4
106 | min_value = 1.0
107 | max_value = 512.0
108 | value = 64.0
109 | allow_greater = true
110 |
111 | [node name="Density" type="MarginContainer" parent="HBoxContainer/HBoxContainer2"]
112 | layout_mode = 2
113 | size_flags_horizontal = 3
114 | tooltip_text = "Hardness of the brush edge"
115 | theme_override_constants/margin_right = 10
116 |
117 | [node name="HBoxContainer" type="HBoxContainer" parent="HBoxContainer/HBoxContainer2/Density"]
118 | layout_mode = 2
119 |
120 | [node name="Label" type="Label" parent="HBoxContainer/HBoxContainer2/Density/HBoxContainer"]
121 | layout_mode = 2
122 | text = "Density:"
123 |
124 | [node name="SpinBox" type="SpinBox" parent="HBoxContainer/HBoxContainer2/Density/HBoxContainer"]
125 | layout_mode = 2
126 | min_value = 1.0
127 | max_value = 10000.0
128 | value = 100.0
129 | rounded = true
130 | allow_greater = true
131 |
132 | [node name="HSlider" type="HSlider" parent="HBoxContainer/HBoxContainer2/Density/HBoxContainer"]
133 | custom_minimum_size = Vector2(50, 0)
134 | layout_mode = 2
135 | size_flags_horizontal = 3
136 | size_flags_vertical = 4
137 | max_value = 10000.0
138 | value = 100.0
139 |
140 | [node name="Randomness" type="MarginContainer" parent="HBoxContainer/HBoxContainer2"]
141 | layout_mode = 2
142 | size_flags_horizontal = 3
143 | tooltip_text = "Opacity or stength of the brush"
144 | theme_override_constants/margin_right = 10
145 |
146 | [node name="HBoxContainer" type="HBoxContainer" parent="HBoxContainer/HBoxContainer2/Randomness"]
147 | layout_mode = 2
148 |
149 | [node name="Label" type="Label" parent="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer"]
150 | layout_mode = 2
151 | text = "Randomness:"
152 |
153 | [node name="SpinBox" type="SpinBox" parent="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer"]
154 | layout_mode = 2
155 | step = 0.05
156 | value = 100.0
157 | suffix = "%"
158 |
159 | [node name="HSlider" type="HSlider" parent="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer"]
160 | custom_minimum_size = Vector2(50, 0)
161 | layout_mode = 2
162 | size_flags_horizontal = 3
163 | size_flags_vertical = 4
164 | value = 100.0
165 |
166 | [node name="RecalculateY" type="Button" parent="HBoxContainer/HBoxContainer2"]
167 | layout_mode = 2
168 | tooltip_text = "Recalculate the Y positions of the foliage for if the terrain heightmap has changed"
169 | text = "Recalculate Y"
170 |
171 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer/SpinBox" to="." method="set_brush_size_from_ui"]
172 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer/SpinBox" to="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer/HSlider" method="set_value_no_signal"]
173 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer/HSlider" to="HBoxContainer/HBoxContainer2/BrushSize/HBoxContainer/SpinBox" method="set_value"]
174 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/Density/HBoxContainer/SpinBox" to="." method="set_brush_density_from_ui"]
175 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/Density/HBoxContainer/SpinBox" to="HBoxContainer/HBoxContainer2/Density/HBoxContainer/HSlider" method="set_value_no_signal"]
176 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/Density/HBoxContainer/HSlider" to="HBoxContainer/HBoxContainer2/Density/HBoxContainer/SpinBox" method="set_value"]
177 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer/SpinBox" to="." method="set_brush_randomness_from_ui"]
178 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer/SpinBox" to="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer/HSlider" method="set_value_no_signal"]
179 | [connection signal="value_changed" from="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer/HSlider" to="HBoxContainer/HBoxContainer2/Randomness/HBoxContainer/SpinBox" method="set_value"]
180 | [connection signal="pressed" from="HBoxContainer/HBoxContainer2/RecalculateY" to="." method="_on_recalculate_y_pressed"]
181 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/Shaders/DebugShader.gdshader:
--------------------------------------------------------------------------------
1 | shader_type spatial;
2 | //render_mode depth_draw_always; // Needed because we set alpha for holes
3 | render_mode unshaded,wireframe,cull_disabled;
4 |
5 | #include "TerrainShaderUtils.gdshaderinc"
6 |
7 | uniform sampler2D heightmap;
8 | uniform sampler2D splatmap;
9 | uniform sampler2D normalmap;
10 |
11 | uniform bool triplanar_on_texture_0 = true;
12 | uniform bool triplanar_on_texture_1 = false;
13 | uniform bool triplanar_on_texture_2 = false;
14 | uniform bool triplanar_on_texture_3 = false;
15 |
16 | uniform sampler2D texture_0_albedo : source_color;
17 | uniform sampler2D texture_1_albedo : source_color;
18 | uniform sampler2D texture_2_albedo : source_color;
19 | uniform sampler2D texture_3_albedo : source_color;
20 |
21 | uniform sampler2D texture_0_normal;
22 | uniform sampler2D texture_1_normal;
23 | uniform sampler2D texture_2_normal;
24 | uniform sampler2D texture_3_normal;
25 |
26 | uniform vec2 texture_0_uv_scale;
27 | uniform vec2 texture_1_uv_scale;
28 | uniform vec2 texture_2_uv_scale;
29 | uniform vec2 texture_3_uv_scale;
30 |
31 | uniform ivec3 cam_chunk_loc;
32 | uniform vec3 cam_rel_pos;
33 | uniform mat3 inv_normal_basis;
34 | uniform mat4 inv_global_transform;
35 | uniform ivec2 chunk_count;
36 | uniform float terrain_xz_scale;
37 | uniform float terrain_height_scale;
38 | uniform int highest_lod_res;
39 | uniform float lod_dropoff_rate;
40 |
41 | varying vec3 v_vert_pos_local;
42 | varying vec3 v_normal;
43 | varying vec4 v_splat_color;
44 | varying vec3 v_vert_pos_normalized;
45 |
46 | const float NUDGE = 0.00001;
47 |
48 | vec4 texture_triplanar_if_enabled(int texture_num, sampler2D tex, vec3 local_pos, vec3 normal, vec2 uv_scale) {
49 | if((triplanar_on_texture_0 && texture_num == 0) || (triplanar_on_texture_1 && texture_num == 1)
50 | || (triplanar_on_texture_2 && texture_num == 2) || (triplanar_on_texture_3 && texture_num == 3)) {
51 | return texture_triplanar(tex, local_pos, normal, uv_scale);
52 | }
53 | else {
54 | return texture(tex, local_pos.xz / uv_scale);
55 | }
56 | }
57 |
58 | int get_lod(vec3 vert_pos_local) {
59 | // Find LOD. When at an edge, nudge vert away from camera so it rounds down to the proper chunk.
60 | vec2 total_terrain_xz_size = terrain_xz_scale * vec2(chunk_count);
61 | vec3 vert_pos_normalized = vert_pos_local / vec3(total_terrain_xz_size.x, 1., total_terrain_xz_size.y);
62 | vec3 nudge_edge_verts = sign(vert_pos_local - cam_rel_pos) * NUDGE;
63 |
64 | ivec3 my_chunk_loc = ivec3(v_vert_pos_normalized * vec3(float(chunk_count.x), 1.0, float(chunk_count.y)) + nudge_edge_verts);
65 | int diff = max(
66 | max(
67 | abs(my_chunk_loc.x - cam_chunk_loc.x),
68 | abs(my_chunk_loc.z - cam_chunk_loc.z)
69 | ),
70 | abs(my_chunk_loc.y - cam_chunk_loc.y)
71 | );
72 |
73 | return max(
74 | 0, highest_lod_res - int(lod_dropoff_rate * float(diff))
75 | );
76 | }
77 |
78 | void vertex() {
79 | // Put vert in world space, then make it relative to the terrain's world start pos.
80 | vec3 vert_relative_to_terrain = (inv_global_transform * (MODEL_MATRIX * vec4(VERTEX, 1.0))).xyz;
81 | // Normalize vert between 0 (start of terrain) and 1 (end of terrain).
82 | vec2 total_terrain_xz_size = terrain_xz_scale * vec2(chunk_count);
83 | v_vert_pos_normalized = vert_relative_to_terrain / vec3(total_terrain_xz_size.x, 1., total_terrain_xz_size.y);
84 |
85 | int my_lod = get_lod(vert_relative_to_terrain);
86 |
87 | // When next to a lower LOD chunk, snap vert to nearest 2 subdivisions on lower LOD and interpolate.
88 | // Will have no effect if not on a chunk edge. (When chunk LOD == nudged LOD).
89 | float num_segments_per_chunk = pow(2.0, float(my_lod));
90 | vec2 num_segments_total = vec2(chunk_count) * num_segments_per_chunk;
91 | vec2 subdiv_size_normal = 1.0 / num_segments_total;
92 | vec2 snap_down = floor(v_vert_pos_normalized.xz * num_segments_total + NUDGE) / num_segments_total;
93 | vec2 snap_up = ceil(v_vert_pos_normalized.xz * num_segments_total - NUDGE) / num_segments_total;
94 | vec2 snap_diff = (v_vert_pos_normalized.xz - snap_down) / subdiv_size_normal;
95 | float interp_factor = max(snap_diff.x, snap_diff.y);
96 |
97 | // Get height at the 2 closest vertices on subdiv and interpolate.
98 | // snap_up and snap_down will be equal if not stepping down an LOD.
99 | float heightmap_val = mix(
100 | sample_middle_of_pixel(heightmap, snap_down).r,
101 | sample_middle_of_pixel(heightmap, snap_up).r,
102 | interp_factor
103 | );
104 | VERTEX.y = heightmap_val * terrain_height_scale;
105 | v_vert_pos_local.y = heightmap_val * terrain_height_scale;
106 | v_vert_pos_local.xz = (v_vert_pos_normalized.xz * vec2(chunk_count) * terrain_xz_scale);
107 |
108 | v_splat_color = sample_middle_of_pixel(splatmap, v_vert_pos_normalized.xz).rgba;
109 | }
110 |
111 | void fragment() {
112 | // Here, each channel of the splatmap directly represents the weight of each texture. Black is texture 0
113 | float tex_0_weight = clamp(1.0 - (v_splat_color.r + v_splat_color.g + v_splat_color.b), 0., 1.);
114 | float tex_1_weight = v_splat_color.r; // Weight for red (tex 1)
115 | float tex_2_weight = v_splat_color.g; // Weight for blue (tex 2)
116 | float tex_3_weight = v_splat_color.b; // Weight for green (tex 3)
117 |
118 | // Ensure the weights sum up to 1.0
119 | float total_weight = tex_0_weight + tex_1_weight + tex_2_weight + tex_3_weight;
120 | tex_0_weight /= total_weight;
121 | tex_1_weight /= total_weight;
122 | tex_2_weight /= total_weight;
123 | tex_3_weight /= total_weight;
124 |
125 | vec3 normal0 = unpack_normal(texture(texture_0_normal, v_vert_pos_local.xz / texture_0_uv_scale));
126 | vec3 normal1 = unpack_normal(texture(texture_1_normal, v_vert_pos_local.xz / texture_1_uv_scale));
127 | vec3 normal2 = unpack_normal(texture(texture_2_normal, v_vert_pos_local.xz / texture_2_uv_scale));
128 | vec3 normal3 = unpack_normal(texture(texture_3_normal, v_vert_pos_local.xz / texture_3_uv_scale));
129 |
130 | // Blend the normal maps in the same way as the textures
131 | vec3 ground_normal = normal0 * tex_0_weight +
132 | normal1 * tex_1_weight +
133 | normal2 * tex_2_weight +
134 | normal3 * tex_3_weight;
135 |
136 | vec3 terrain_normal = unpack_normal(texture(normalmap, v_vert_pos_normalized.xz));
137 | vec3 terrain_and_ground_normal = apply_normal_basis(ground_normal, terrain_normal);
138 |
139 | //if (terrain_normal.y < 0.0) {
140 | //ALBEDO = vec3(1.0,0.0,0.0);
141 | //}
142 |
143 | // Put in world space
144 | vec3 terrain_normal_world = normalize(inv_normal_basis * terrain_and_ground_normal);
145 |
146 | // Sample each of the four textures. Only doing triplanar on the first one.
147 | vec3 tex_0_color = texture_triplanar_if_enabled(0, texture_0_albedo, v_vert_pos_local, terrain_normal, texture_0_uv_scale).rgb;
148 | vec3 tex_1_color = texture_triplanar_if_enabled(1, texture_1_albedo, v_vert_pos_local, terrain_normal, texture_1_uv_scale).rgb;
149 | vec3 tex_2_color = texture_triplanar_if_enabled(2, texture_2_albedo, v_vert_pos_local, terrain_normal, texture_2_uv_scale).rgb;
150 | vec3 tex_3_color = texture_triplanar_if_enabled(3, texture_3_albedo, v_vert_pos_local, terrain_normal, texture_3_uv_scale).rgb;
151 |
152 | // Blend the textures based on the weights
153 | vec3 final_color = tex_0_color * tex_0_weight +
154 | tex_1_color * tex_1_weight +
155 | tex_2_color * tex_2_weight +
156 | tex_3_color * tex_3_weight;
157 |
158 | // Assign the final color to the fragment's albedo
159 | ALBEDO = final_color;
160 | //ALBEDO = vec3(tex_0_weight);
161 | //ALBEDO = terrain_normal;
162 | // Only make it a hole if the tri is fully transparent. This is so we have clearly defined holes.
163 | if (v_splat_color.a < 0.0001) {
164 | discard;
165 | }
166 |
167 | NORMAL = (VIEW_MATRIX * (vec4(terrain_normal_world, 0.0))).xyz;
168 |
169 | vec3 chunk_loc = v_vert_pos_normalized * vec3(float(chunk_count.x), 1.0, float(chunk_count.y));
170 | //if((ivec2(chunk_loc.xz).x + ivec2(chunk_loc.xz).y) % 2 == 0) {
171 | //ALBEDO = mix(ALBEDO, vec3(1.), 0.25);
172 | //}
173 | int chunk_lod = get_lod(v_vert_pos_local);
174 | float lod_pct = 1.0 - float(chunk_lod) / float(highest_lod_res);
175 | if((floor(chunk_loc.xz + vec2(NUDGE)) != floor(chunk_loc.xz)) || (floor(chunk_loc.xz - vec2(NUDGE)) != floor(chunk_loc.xz))) {
176 | int lod_color = highest_lod_res - chunk_lod - 1;
177 | if(lod_color == -1) {
178 | ALBEDO = vec3(1.);
179 | }
180 | else if(lod_color % 3 == 0) {
181 | ALBEDO = mix(ALBEDO, vec3(0., 1., 0.), 1.1);
182 | }
183 | else if(lod_color % 2 == 0) {
184 | ALBEDO = mix(ALBEDO, vec3(1., 0., 0.), 1.1);
185 | }
186 | else {
187 | ALBEDO = mix(ALBEDO, vec3(0., 0., 1.), 1.1);
188 | }
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/BrushToolbar.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | class_name BrushToolbar
3 | extends MarginContainer
4 |
5 | const UTILS = preload("res://addons/SimpleTerrain/SimpleTerrainUtils.gd")
6 |
7 | var undo_redo : EditorUndoRedoManager
8 |
9 | var brush_opacity : float = 1.0
10 | func set_brush_opacity_from_ui(val):
11 | brush_opacity = float(val) / 100.0
12 | update_brush_preview()
13 | var brush_size : int = 64
14 | func set_brush_size_from_ui(val):
15 | brush_size = val
16 | update_brush_preview()
17 | var brush_hardness : float = 0.75
18 | func set_brush_hardness_from_ui(val):
19 | brush_hardness = float(val) / 100.0
20 | update_brush_preview()
21 |
22 | func get_brush_mode():
23 | if %Raise.button_pressed:
24 | return UTILS.BrushMode.RAISE
25 | if %Lower.button_pressed:
26 | return UTILS.BrushMode.LOWER
27 | if %Flatten.button_pressed:
28 | return UTILS.BrushMode.FLATTEN
29 | if %Splat_0.button_pressed:
30 | return UTILS.BrushMode.SPLAT_0
31 | if %Splat_1.button_pressed:
32 | return UTILS.BrushMode.SPLAT_1
33 | if %Splat_2.button_pressed:
34 | return UTILS.BrushMode.SPLAT_2
35 | if %Splat_3.button_pressed:
36 | return UTILS.BrushMode.SPLAT_3
37 | if %Splat_Transparent.button_pressed:
38 | return UTILS.BrushMode.SPLAT_TRANSPARENT
39 | return null
40 |
41 | func show_convert_texture_popup(brush_mode):
42 | var dialog : AcceptDialog = %ConvertTextureConfirmationDialog
43 | var texture_name := "Splatmap"
44 | if (brush_mode == UTILS.BrushMode.RAISE
45 | or brush_mode == UTILS.BrushMode.LOWER
46 | or brush_mode == UTILS.BrushMode.FLATTEN):
47 | texture_name = "Heightmap"
48 | %ConvertTextureConfirmationDialog.dialog_text = texture_name + " must be duplicated and converted to an ImageTexture resource embedded in the scene before editing."
49 | %ConvertTextureConfirmationDialog.show()
50 |
51 | func get_simple_terrain_selected() -> SimpleTerrain:
52 | if not Engine.is_editor_hint():
53 | return null
54 | var selected_nodes = EditorInterface.get_selection().get_selected_nodes()
55 | for node in selected_nodes:
56 | if node is SimpleTerrain:
57 | return node as SimpleTerrain
58 | return null
59 |
60 | # Called every frame. 'delta' is the elapsed time since the previous frame.
61 | func _process(delta):
62 | var terrain_selected := get_simple_terrain_selected()
63 |
64 | # Only show the toolbar and terrain settings popup when a SimpleTerrain node is selected.
65 | var selected_nodes = EditorInterface.get_selection().get_selected_nodes()
66 | if terrain_selected != null:
67 | self.visible = true
68 | else:
69 | self.visible = false
70 | #%SettingsPopup.hide()
71 |
72 | if terrain_selected != null:
73 | %HeightmapTextureRect.texture = terrain_selected.heightmap_texture
74 | %SplatmapTextureRect.texture = terrain_selected.splatmap_texture
75 | %HeightmapNoImageLabel.visible = %HeightmapTextureRect.texture == null
76 | %SplatmapNoImageLabel.visible = %SplatmapTextureRect.texture == null
77 |
78 | if terrain_selected != null:
79 | %ConvertToImageTextureHeightmap.disabled = (
80 | terrain_selected.heightmap_texture == null or terrain_selected.heightmap_texture is ImageTexture
81 | )
82 | %ConvertToImageTextureSplatmap.disabled = (
83 | terrain_selected.splatmap_texture == null or terrain_selected.splatmap_texture is ImageTexture
84 | )
85 |
86 | func _on_settings_popup_visibility_changed():
87 | var terrain_selected := get_simple_terrain_selected()
88 | if terrain_selected == null: return
89 |
90 | if terrain_selected != null:
91 | var heightmap = terrain_selected.heightmap_texture
92 | var size := heightmap.get_size() if heightmap else UTILS.get_default_texture_size_for_terain(terrain_selected)
93 | %HeightmapWidth.value = size.x
94 | %HeightmapHeight.value = size.y
95 | if terrain_selected != null:
96 | var splatmap = terrain_selected.splatmap_texture
97 | var size := splatmap.get_size() if splatmap else UTILS.get_default_texture_size_for_terain(terrain_selected)
98 | %SplatmapWidth.value = size.x
99 | %SplatmapHeight.value = size.y
100 |
101 | func _on_convert_to_image_texture_pressed(heightmap : bool):
102 | var terrain := get_simple_terrain_selected()
103 | if terrain == null: return
104 | var texture := terrain.heightmap_texture if heightmap else terrain.splatmap_texture
105 |
106 | undo_redo.create_action("Convert terrain image")
107 |
108 | var img := Image.new()
109 | img.copy_from(texture.get_image())
110 | img.clear_mipmaps()
111 | img.decompress()
112 | var new_image_texture := ImageTexture.new()
113 | if heightmap:
114 | img.convert(UTILS.HEIGHTMAP_FORMAT)
115 | new_image_texture.set_image(img)
116 | undo_redo.add_undo_property(terrain, "heightmap_texture", terrain.heightmap_texture)
117 | undo_redo.add_do_property(terrain, "heightmap_texture", new_image_texture)
118 | else:
119 | img.convert(UTILS.SPLATMAP_FORMAT)
120 | new_image_texture.set_image(img)
121 | undo_redo.add_undo_property(terrain, "splatmap_texture", terrain.splatmap_texture)
122 | undo_redo.add_do_property(terrain, "splatmap_texture", new_image_texture)
123 |
124 | undo_redo.commit_action()
125 |
126 | func _on_new_image_pressed(heightmap : bool):
127 | var terrain := get_simple_terrain_selected()
128 | if terrain == null: return
129 | var new_tex := ImageTexture.new()
130 | undo_redo.create_action("Create new terrain image")
131 | if heightmap:
132 | var img = Image.create(%HeightmapWidth.value, %HeightmapHeight.value, false, UTILS.HEIGHTMAP_FORMAT)
133 | img.fill(Color.BLACK)
134 | new_tex.set_image(img)
135 | undo_redo.add_undo_property(terrain, "heightmap_texture", terrain.heightmap_texture)
136 | undo_redo.add_do_property(terrain, "heightmap_texture", new_tex)
137 | else:
138 | var img = Image.create(%SplatmapWidth.value, %SplatmapHeight.value, false, UTILS.SPLATMAP_FORMAT)
139 | img.fill(Color.BLACK)
140 | print("Filled black")
141 | new_tex.set_image(img)
142 | undo_redo.add_undo_property(terrain, "splatmap_texture", terrain.splatmap_texture)
143 | undo_redo.add_do_property(terrain, "splatmap_texture", new_tex)
144 | undo_redo.commit_action()
145 |
146 | func _on_resize_image_pressed(heightmap : bool):
147 | var terrain := get_simple_terrain_selected()
148 | if terrain == null: return
149 | var texture = terrain.heightmap_texture if heightmap else terrain.splatmap_texture
150 |
151 | var img := Image.new()
152 | img.copy_from(texture.get_image())
153 | var new_tex := ImageTexture.new()
154 | undo_redo.create_action("Resize terrain image")
155 | if heightmap:
156 | img.resize(%HeightmapWidth.value, %HeightmapHeight.value)
157 | new_tex.set_image(img)
158 | undo_redo.add_undo_property(terrain, "heightmap_texture", terrain.heightmap_texture)
159 | undo_redo.add_do_property(terrain, "heightmap_texture", new_tex)
160 | else:
161 | img.resize(%SplatmapWidth.value, %SplatmapHeight.value)
162 | new_tex.set_image(img)
163 | undo_redo.add_undo_property(terrain, "splatmap_texture", terrain.splatmap_texture)
164 | undo_redo.add_do_property(terrain, "splatmap_texture", new_tex)
165 | undo_redo.commit_action()
166 |
167 | func update_brush_preview():
168 | TerrainBrushDecal.update_gradient_texture(%BrushPreviewTextureRect.texture, 64, brush_opacity, brush_hardness, false)
169 |
170 | func _on_create_collision_body_button_pressed():
171 | var terrain := get_simple_terrain_selected()
172 | if terrain == null: return
173 | if not terrain.has_collision_shape():
174 | undo_redo.create_action("Create collision shape")
175 | undo_redo.add_do_method(terrain, "create_collision_shape")
176 | undo_redo.add_undo_method(terrain, "remove_collision_shape")
177 | undo_redo.commit_action()
178 | else:
179 | terrain.create_collision_shape()
180 |
181 | func _on_bake_normal_map_button_pressed():
182 | var terrain := get_simple_terrain_selected()
183 | if terrain == null: return
184 | var texture := terrain.update_normalmap_and_set_shader_parameter()
185 | # Ensure the viewport texture the baker gives us is updated.
186 | RenderingServer.frame_post_draw.connect((
187 | func():
188 | var new_normalmap_texture = ImageTexture.create_from_image(texture.get_image())
189 | undo_redo.create_action("Bake normal map")
190 | undo_redo.add_do_property(terrain, "normalmap_texture", new_normalmap_texture)
191 | undo_redo.add_undo_property(terrain, "normalmap_texture", terrain.normalmap_texture)
192 | undo_redo.commit_action()
193 | ), CONNECT_ONE_SHOT)
194 |
195 | func _on_convert_texture_confirmation_dialog_confirmed():
196 | _on_convert_to_image_texture_pressed(%ConvertTextureConfirmationDialog.dialog_text.begins_with("Heightmap"))
197 |
198 | func _on_save_as_mesh_file_dialog_file_selected(path):
199 | var t = get_simple_terrain_selected()
200 |
201 | var num_verts_width = int(t._get_num_verts_along_edge_total(t.highest_lod_resolution).x)
202 | var num_verts_height = int(t._get_num_verts_along_edge_total(t.highest_lod_resolution).y)
203 | var scale_xz = t.terrain_xz_scale / (t._get_num_verts_along_chunk_edge(t.highest_lod_resolution) - 1)
204 | # Why don't I have to pass scale_xz??
205 | # Ohh it's because in create_collision_shape I do:
206 | # collision_shape.scale = Vector3(scale_xz, scale_xz, scale_xz)
207 | # Basically a workaround for collision shapes not being able to scale non uniform.
208 | # So I was scaling and unscaling to get the verts right. If I just pass 1 it scales it by the normal height
209 | # This is fine since meshes can be scaled manually. HeightmapShape can't even control spacing of tiles.
210 | var vertex_y_positions: PackedFloat32Array = t.get_collision_shape_data(Vector2(num_verts_width, num_verts_height), 1)#scale_xz)
211 |
212 | # Initialize SurfaceTool
213 | var st = SurfaceTool.new()
214 | st.begin(Mesh.PRIMITIVE_TRIANGLES)
215 |
216 | # Create vertices and add them to SurfaceTool
217 | var idx = 0
218 | var hole_indices = []
219 | var hole_at = func(i): return hole_indices.find(i) != -1
220 | for z in range(0, num_verts_height):
221 | for x in range(0, num_verts_width):
222 | var y = vertex_y_positions[x + z * num_verts_width] # Get the height from the heightmap
223 | if is_nan(y): hole_indices.push_back(idx)
224 | st.set_uv(Vector2(float(x)/float(num_verts_width-1),float(z)/float(num_verts_height-1)))
225 | st.add_vertex(Vector3(x * scale_xz, y if not is_nan(y) else 0.0, z * scale_xz))
226 | idx += 1
227 |
228 | # Create the indices for the mesh
229 | for z in range(0, num_verts_height - 1):
230 | for x in range(0, num_verts_width - 1):
231 | var start = x + z * num_verts_width
232 | # Match heightmap collision shape which skips triangles where any verts are NAN. Triangle 1:
233 | if not (hole_at.call(start) or hole_at.call(start + 1) or hole_at.call(start + num_verts_width)):
234 | st.add_index(start + 1)
235 | st.add_index(start + num_verts_width)
236 | st.add_index(start)
237 | # Triangle 2:
238 | if not (hole_at.call(start + 1) or hole_at.call(start + num_verts_width) or hole_at.call(start + num_verts_width + 1)):
239 | st.add_index(start + num_verts_width + 1)
240 | st.add_index(start + num_verts_width)
241 | st.add_index(start + 1)
242 |
243 | # Generate normals and tangents
244 | st.generate_normals()
245 | st.generate_tangents()
246 |
247 | # Commit the surface tool to an ArrayMesh
248 | var arr_mesh = st.commit()
249 | # Save the mesh
250 | ResourceSaver.save(arr_mesh, path)
251 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/TerrainBrushDecal.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | class_name TerrainBrushDecal
3 | extends Decal
4 |
5 | const UTILS = preload("res://addons/SimpleTerrain/SimpleTerrainUtils.gd")
6 | const BrushMode = UTILS.BrushMode
7 |
8 | const PAINT_RATE_MS = 1000/60
9 | var _last_paint_time = Time.get_ticks_msec()
10 | @export var terrain : SimpleTerrain
11 |
12 | var undo_redo : EditorUndoRedoManager
13 |
14 | var colors := {
15 | BrushMode.RAISE: Color.WHITE,
16 | BrushMode.LOWER: Color.BLACK,
17 | BrushMode.SPLAT_0: Color.BLACK,
18 | BrushMode.SPLAT_1: Color.RED,
19 | BrushMode.SPLAT_2: Color.GREEN,
20 | BrushMode.SPLAT_3: Color.BLUE,
21 | BrushMode.SPLAT_TRANSPARENT: Color(0.0, 0.0, 0.0, 0.0),
22 | }
23 |
24 | @export var brush_mode := BrushMode.RAISE
25 | var paint_texture := GradientTexture2D.new()
26 |
27 | @export var follow_mouse := false
28 | @export var painting := false
29 | @export var shift_pressed := false
30 |
31 | @export_range(0,1) var opacity := 1.0 :
32 | set(value):
33 | if opacity != value:
34 | opacity = value
35 | _update_textures()
36 | @export_range(1,4096) var brush_size : int = 64 :
37 | set(value):
38 | if brush_size != value:
39 | brush_size = value
40 | _update_textures()
41 | @export_range(0, 1) var hardness := 0.75 :
42 | set(value):
43 | if hardness != value:
44 | hardness = value
45 | _update_textures()
46 |
47 | static func update_gradient_texture(gradient_tex : GradientTexture2D, size : int, opacity : float, hardness : float, marker : bool):
48 | gradient_tex.width = size
49 | gradient_tex.height = size
50 | gradient_tex.fill_from = Vector2(0.5, 0.5)
51 | gradient_tex.fill_to = Vector2(1.0, 0.5)
52 | gradient_tex.fill = GradientTexture2D.FILL_RADIAL
53 | if gradient_tex.gradient == null:
54 | gradient_tex.gradient = Gradient.new()
55 | gradient_tex.gradient.interpolation_color_space = Gradient.GRADIENT_COLOR_SPACE_LINEAR_SRGB
56 | gradient_tex.gradient.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_CUBIC
57 | while gradient_tex.gradient.get_point_count() > 1:
58 | gradient_tex.gradient.remove_point(0)
59 | if marker:
60 | # Visual effect on editor marker to show hardness
61 | # Sharp circle for hard, blurred large border for softened
62 | var diff = lerp(0.1, 0.4, 1.0 - hardness)
63 | gradient_tex.gradient.set_color(0, Color(1, 1, 1, opacity/7))
64 | gradient_tex.gradient.set_offset(0, 0.95 - diff)
65 | gradient_tex.gradient.add_point(0.95 - diff/2, Color(1, 1, 1, clampf(opacity, 0.25, 1.0)))
66 | gradient_tex.gradient.add_point(1.0, Color.TRANSPARENT)
67 | else:
68 | gradient_tex.gradient.set_color(0, Color(1, 1, 1, opacity))
69 | gradient_tex.gradient.set_offset(0, 0.0)
70 | gradient_tex.gradient.add_point(hardness * 0.9999, Color(1, 1, 1, opacity))
71 | gradient_tex.gradient.add_point(1.0, Color.TRANSPARENT)
72 |
73 | func _update_textures():
74 | if texture_albedo == null:
75 | texture_albedo = GradientTexture2D.new()
76 | update_gradient_texture(texture_albedo, 256, opacity, hardness, true)
77 | update_gradient_texture(paint_texture, brush_size, opacity, hardness, false)
78 | _scale_decal_to_texture_size()
79 |
80 | func _get_draw_color() -> Color:
81 | var color := Color.WHITE
82 | if brush_mode == BrushMode.FLATTEN:
83 | if shift_pressed:
84 | # Flatten to absolute 0 when shift is pressed
85 | color = Color.BLACK
86 | elif terrain.heightmap_texture:
87 | var pos := _get_pos_on_texture(terrain.heightmap_texture)
88 | terrain.heightmap_texture.get_image().decompress()
89 | color = terrain.get_terrain_pixel(terrain.heightmap_texture, pos.x, pos.y)
90 | else:
91 | color = colors[brush_mode]
92 | return color
93 |
94 | func _get_cur_terrain_map_texture() -> Texture2D:
95 | if not terrain:
96 | return null
97 | if brush_mode == BrushMode.RAISE or brush_mode == BrushMode.LOWER or brush_mode == BrushMode.FLATTEN:
98 | return terrain.heightmap_texture
99 | else:
100 | return terrain.splatmap_texture
101 |
102 | func _get_pos_on_texture(texture : Texture2D) -> Vector2i:
103 | if not is_inside_tree() or not texture: return Vector2i(0,0)
104 | var norm_pos := terrain.get_global_pos_normalized_to_terrain(global_position)
105 | var to_px := Vector2(norm_pos.x, norm_pos.z) * Vector2(texture.get_image().get_size())
106 | return Vector2i(round(to_px.x),round(to_px.y))
107 |
108 | func _scale_decal_to_texture_size():
109 | if not terrain: return
110 | var active_canvas = _get_cur_terrain_map_texture()
111 | var active_canvas_size := active_canvas.get_image().get_size() if active_canvas else Vector2i(1024,1024)
112 | var rel_size := Vector2(paint_texture.get_image().get_size()) / Vector2(active_canvas_size)
113 | var size_in_world_units = rel_size * terrain.get_total_size_without_height()
114 | self.size = Vector3(size_in_world_units.x, 50, size_in_world_units.y)
115 |
116 | func blit_with_alpha_blending(from : Image, to : Image, pos : Vector2i, clamp : Color) -> void:
117 | var diff := Image.create(from.get_width(), from.get_height(), false, to.get_format())
118 | diff.fill(Color.BLACK)
119 | var destination_rect := Rect2i(pos, from.get_size())
120 | diff.blit_rect(to, destination_rect, Vector2i.ZERO)
121 | for x in diff.get_width():
122 | for y in diff.get_height():
123 | var brush_px := from.get_pixel(x, y)
124 | var clamped := brush_px.clamp(brush_px, clamp)
125 | if diff.get_format() == Image.FORMAT_RF:
126 | if brush_mode == BrushMode.FLATTEN:
127 | var lerped = lerp(diff.get_pixel(x, y).r, clamp.r, min(brush_px.a, clamp.a))
128 | diff.set_pixel(x, y, Color(lerped, 0, 0, 1))
129 | else:
130 | var cur_px = diff.get_pixel(x, y)
131 | if brush_mode == BrushMode.LOWER:
132 | var subtracted = cur_px.r - clamped.a / terrain.terrain_height_scale
133 | diff.set_pixel(x, y, Color(subtracted,0,0,1))
134 | else:
135 | var added = cur_px.r + clamped.r * clamped.a / terrain.terrain_height_scale
136 | diff.set_pixel(x, y, Color(added,0,0,1))
137 | else:
138 | var blended := diff.get_pixel(x, y).blend(clamped)
139 | blended.a = 1.0 if blended.a > 0.0001 else 0.0 # Incase painting back over hole
140 | diff.set_pixel(x, y, blended)
141 | to.blit_rect(diff, diff.get_used_rect(), pos)
142 |
143 | # Limitation of Godot HeightmapShape3D/heightmaps in general probably: Corners are greedily filled by triangles.
144 | # All bottom left and top right corners must be filled with a bottom or top triangle respectively.
145 | # If we let the user punch out these corner triangles, Godot's collision map won't match.
146 | # To do this, we must loop through all affected vertices positions, and check if they are a corner,
147 | # Where corner = any empty square next to filled left/bottom or top/right squares.
148 | # If we find a corner, we have must set the corner px to non transparent so it draws.
149 | func fix_splatmap_jaggies_to_match_collision_shape(pos : Vector2i, diff : Image):
150 | var total_verts = terrain._get_num_verts_along_edge_total()
151 | var set_px_alpha_to_one : Callable = (func(xy : Vector2i, img : Image): img.set_pixelv(xy, img.get_pixelv(xy).clamp(Color(0,0,0,1),Color(1,1,1,1))))
152 | var splatmap_image := terrain.splatmap_texture.get_image()
153 | if splatmap_image.is_compressed(): splatmap_image.decompress()
154 | var one_px_in_quads := Vector2(splatmap_image.get_size() - Vector2i(1,1)) / Vector2(total_verts - 1, total_verts - 1)
155 | var one_quad_in_px := Vector2(total_verts - 1, total_verts - 1) / Vector2(splatmap_image.get_size() - Vector2i(1,1))
156 | var tl_tri_filled : Callable = func(x, z): return (
157 | splatmap_image.get_pixelv(Vector2i(Vector2(x,z) * one_px_in_quads)).a > 0.0001
158 | or splatmap_image.get_pixelv(Vector2i(Vector2(x + 1,z) * one_px_in_quads)).a > 0.0001
159 | or splatmap_image.get_pixelv(Vector2i(Vector2(x,z + 1) * one_px_in_quads)).a > 0.0001
160 | )
161 | var br_tri_filled : Callable = func(x, z): return (
162 | splatmap_image.get_pixelv(Vector2i(Vector2(x,z + 1) * one_px_in_quads)).a > 0.0001 # bl
163 | or splatmap_image.get_pixelv(Vector2i(Vector2(x + 1,z) * one_px_in_quads)).a > 0.0001 # tr
164 | or splatmap_image.get_pixelv(Vector2i(Vector2(x + 1,z + 1) * one_px_in_quads)).a > 0.0001 # br
165 | )
166 | var diff_rect := Rect2i(pos, diff.get_size())
167 | #diff_rect = diff_rect.grow(int(max(ceil(one_quad_in_px.x * 2), ceil(one_quad_in_px.y * 2))))
168 | # Loop thru all quads, - 1 is there b/c quads not verts
169 | for x in range(0, total_verts - 1):
170 | for z in range(0, total_verts - 1):
171 | var tl_px = Vector2i(Vector2(x, z) * one_px_in_quads)
172 | var br_px = Vector2i(Vector2(x + 1, z + 1) * one_px_in_quads)
173 | if not diff_rect.has_point(tl_px) and not diff_rect.has_point(br_px):
174 | continue
175 | if tl_tri_filled.call(x,z) and br_tri_filled.call(x,z):
176 | continue
177 | var tl := terrain._collision_shape_has_vert_at(x,z)
178 | var tr := terrain._collision_shape_has_vert_at(x+1,z)
179 | var br := terrain._collision_shape_has_vert_at(x+1,z+1)
180 | var bl := terrain._collision_shape_has_vert_at(x,z+1)
181 | # Any time the collision map will have a triangle, correct the render to match
182 | if tl and tr and bl:
183 | set_px_alpha_to_one.call(tl_px, splatmap_image)
184 | if br and tr and bl:
185 | set_px_alpha_to_one.call(br_px, splatmap_image)
186 |
187 | func blit_hole_alpha_only(from : Image, to : Image, pos : Vector2i) -> void:
188 | var diff := Image.create(from.get_width(), from.get_height(), false, to.get_format())
189 | diff.fill(Color.BLACK)
190 | var destination_rect := Rect2i(pos, from.get_size())
191 | diff.blit_rect(to, destination_rect, Vector2i.ZERO)
192 | for x in diff.get_width():
193 | for y in diff.get_height():
194 | var original_px := diff.get_pixel(x, y)
195 | # Blit alpha as either one or zero since it's being used for painting holes
196 | if not is_equal_approx(from.get_pixel(x, y).a, 0.0):
197 | diff.set_pixel(x, y, Color(original_px.r, original_px.g, original_px.b, 0.0))
198 | to.blit_rect(diff, Rect2i(Vector2i(), diff.get_size()), pos)
199 | fix_splatmap_jaggies_to_match_collision_shape(pos, diff)
200 |
201 | var _initial_paint_state : Image
202 | var _paint_diff_rect := Rect2i(0,0,0,0)
203 | var _was_painting_last_frame := false
204 |
205 | func _before_paint_terrain():
206 | if not Engine.is_editor_hint() or not is_inside_tree(): return
207 | var tex := _get_cur_terrain_map_texture()
208 | if not tex: return
209 | var img := tex.get_image()
210 | img.decompress()
211 | # Save state of texture we're painting to
212 | _initial_paint_state = Image.create(img.get_width(), img.get_height(), false, img.get_format())
213 | _initial_paint_state.copy_from(img)
214 | # Reset the _paint_diff_rect
215 | _paint_diff_rect.size = Vector2i(0,0)
216 |
217 | func _after_paint_terrain():
218 | var tex := _get_cur_terrain_map_texture()
219 | if not tex: return
220 | var img := tex.get_image()
221 | # Clip the initial state and paint diff by the _paint_diff_rect, save them both to images
222 | # Commit an item to undoredo with the area before and after the paint diff
223 | undo_redo.create_action("Paint terrain")
224 | undo_redo.add_do_method(terrain, "blit_to_texture", terrain, tex, img.get_region(_paint_diff_rect), _paint_diff_rect.position)
225 | undo_redo.add_undo_method(terrain, "blit_to_texture", terrain, tex, _initial_paint_state.get_region(_paint_diff_rect), _paint_diff_rect.position)
226 | _initial_paint_state = null
227 | # Commit but don't execute because we already painted
228 | undo_redo.commit_action(false)
229 |
230 | func _paint_terrain():
231 | if not Engine.is_editor_hint() or not is_inside_tree(): return
232 | var tex := _get_cur_terrain_map_texture()
233 | if not tex: return
234 |
235 | var paint_pos = _get_pos_on_texture(tex) - Vector2i(paint_texture.get_size() / 2)
236 | tex.get_image().decompress()
237 | if brush_mode == BrushMode.SPLAT_TRANSPARENT:
238 | blit_hole_alpha_only(paint_texture.get_image(), tex.get_image(), paint_pos)
239 | else:
240 | blit_with_alpha_blending(paint_texture.get_image(), tex.get_image(), paint_pos, _get_draw_color())
241 |
242 | if not _paint_diff_rect.has_area():
243 | _paint_diff_rect = Rect2i(paint_pos, paint_texture.get_size())
244 | else:
245 | _paint_diff_rect = _paint_diff_rect.merge(Rect2i(paint_pos, paint_texture.get_size()))
246 | _paint_diff_rect = _paint_diff_rect.intersection(Rect2i(0,0,tex.get_width(), tex.get_height()))
247 | # Force update in editor
248 | tex.update(tex.get_image())
249 | terrain.update_normalmap_and_set_shader_parameter()
250 |
251 | func is_on_terrain() -> bool:
252 | return self.is_inside_tree() and self.visible
253 |
254 | func _raycast_and_snap_to_terrain(viewport_camera : Camera3D, mouse_position : Vector2):
255 | if not follow_mouse:
256 | self.visible = false
257 | return
258 | # Need to do an extra conversion in case the editor viewport is in half-resolution mode
259 | var viewport := viewport_camera.get_viewport()
260 | var viewport_container : Control = viewport.get_parent()
261 | var screen_pos = mouse_position * Vector2(viewport.size) / viewport_container.size
262 |
263 | var origin = viewport_camera.project_ray_origin(screen_pos)
264 | var dir = viewport_camera.project_ray_normal(screen_pos)
265 | var result = terrain.raycast_terrain_by_px(origin, dir * viewport_camera.far * 1.2)
266 | var hit_pos = result[0]
267 | if hit_pos != null:
268 | self.global_position = hit_pos
269 | self.visible = true
270 | else:
271 | self.visible = false
272 |
273 | func _enter_tree():
274 | if not Engine.is_editor_hint(): return
275 | if $'..' is SimpleTerrain:
276 | terrain = $'..'
277 | elif $'../..' is SimpleTerrain:
278 | terrain = $'../..'
279 | _update_textures()
280 |
281 | func _exit_tree():
282 | if _was_painting_last_frame:
283 | _after_paint_terrain()
284 | _was_painting_last_frame = false
285 |
286 | # Called every frame. 'delta' is the elapsed time since the previous frame.
287 | func _process(delta):
288 | if not Engine.is_editor_hint():
289 | return
290 | if painting and Time.get_ticks_msec() - _last_paint_time > PAINT_RATE_MS:
291 | if not _was_painting_last_frame:
292 | _before_paint_terrain()
293 | _was_painting_last_frame = true
294 | _paint_terrain()
295 | _last_paint_time = Time.get_ticks_msec()
296 | if not painting:
297 | if _was_painting_last_frame:
298 | _after_paint_terrain()
299 | _was_painting_last_frame = false
300 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/SimpleTerrainFoliage.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | class_name SimpleTerrainFoliage
3 | extends MultiMeshInstance3D
4 |
5 | @export var foliage_mesh: Mesh:
6 | set(value):
7 | foliage_mesh = value
8 | _update_multimesh()
9 | update_configuration_warning()
10 |
11 | @export_range(0.1, 10.0) var instance_scale: float = 1.0:
12 | set(value):
13 | instance_scale = value
14 | _update_transforms()
15 |
16 | ## How much the foliage should be offset from the terrain along the Y axis.
17 | @export var height_offset: float = 0.0:
18 | set(value):
19 | height_offset = value
20 |
21 | # Keep track of instance count for debugging
22 | var _instance_count_debug := 0
23 |
24 | func _ready():
25 | if Engine.is_editor_hint():
26 | # Initialize editor-specific functionality here
27 | _setup_multimesh()
28 | else:
29 | # Initialize game-specific functionality here
30 | pass
31 |
32 | func _setup_multimesh():
33 | if not multimesh:
34 | _update_multimesh()
35 |
36 | func _update_multimesh():
37 | # Only run in the editor
38 | if not Engine.is_editor_hint():
39 | return
40 |
41 | # If multimesh doesn't exist, do nothing. Creation handled by decal.
42 | if not multimesh:
43 | return
44 |
45 | var mesh_changed = false
46 |
47 | # Check and assign foliage_mesh if needed
48 | if foliage_mesh:
49 | # Only update if the mesh is different
50 | if multimesh.mesh != foliage_mesh:
51 | multimesh.mesh = foliage_mesh
52 | mesh_changed = true
53 | print("Assigned foliage_mesh to MultiMesh")
54 | # Handle case where foliage_mesh was removed
55 | elif multimesh.mesh != null:
56 | multimesh.mesh = null
57 | mesh_changed = true
58 | print("Cleared mesh from MultiMesh as foliage_mesh is null")
59 |
60 | # Update warning only if something actually changed
61 | if mesh_changed:
62 | update_configuration_warning()
63 |
64 | # Force update of editor warnings
65 | func update_configuration_warning():
66 | if Engine.is_editor_hint() and is_inside_tree():
67 | # This tells the editor to refresh the node's warnings
68 | notify_property_list_changed()
69 |
70 | func _update_transforms():
71 | if not is_inside_tree() or not Engine.is_editor_hint() or not multimesh:
72 | return
73 |
74 | var instance_count = multimesh.visible_instance_count
75 | #print("Updating scale for %d instances to %f" % [instance_count, instance_scale])
76 |
77 | # Update scale of all instances locally
78 | for i in range(instance_count):
79 | var current_transform : Transform3D = multimesh.get_instance_transform(i)
80 | var origin : Vector3 = current_transform.origin
81 | # Get the rotation part of the basis, ignoring existing scale
82 | var rotation_quat : Quaternion = current_transform.basis.get_rotation_quaternion()
83 |
84 | # Create a new basis scaled correctly
85 | # Start with the rotation, then apply the desired uniform scale
86 | var new_basis : Basis = Basis(rotation_quat).scaled(Vector3(instance_scale, instance_scale, instance_scale))
87 |
88 | # Construct the new transform using the new basis and original origin
89 | var new_transform : Transform3D = Transform3D(new_basis, origin)
90 |
91 | multimesh.set_instance_transform(i, new_transform)
92 |
93 | func add_instance_at_position(position: Vector3, random_rotation: bool = true) -> Transform3D:
94 | # Safety check: Caller (Decal) is responsible for ensuring multimesh exists
95 | if not multimesh or not foliage_mesh:
96 | print("ERROR: add_instance_at_position called but multimesh or foliage_mesh is null!")
97 | return Transform3D.IDENTITY
98 |
99 | var current_visible_count = multimesh.visible_instance_count
100 | _instance_count_debug = current_visible_count
101 |
102 | # Ensure we have capacity - grow multimesh as needed
103 | if current_visible_count >= multimesh.instance_count:
104 | var new_count = max(100, multimesh.instance_count * 2)
105 | #print("Growing multimesh: ", multimesh.instance_count, " -> ", new_count)
106 |
107 | # 1. Store existing transforms
108 | var existing_transforms: Array[Transform3D] = []
109 | for i in range(current_visible_count):
110 | existing_transforms.append(multimesh.get_instance_transform(i))
111 |
112 | # 2. Resize the multimesh instance_count (this clears the buffer)
113 | multimesh.instance_count = new_count
114 |
115 | # 3. Restore the saved transforms
116 | for i in range(current_visible_count):
117 | multimesh.set_instance_transform(i, existing_transforms[i])
118 |
119 | # 4. Restore the visible count (setting instance_count might reset it)
120 | multimesh.visible_instance_count = current_visible_count
121 |
122 | # Create transform for the new instance
123 | var transform = Transform3D.IDENTITY
124 |
125 | if random_rotation:
126 | # Random rotation around Y axis
127 | transform = transform.rotated(Vector3.UP, randf() * TAU)
128 |
129 | # Apply scale (Apply scale *after* rotation)
130 | transform = transform.scaled(Vector3(instance_scale, instance_scale, instance_scale))
131 |
132 | # Apply translation
133 | transform.origin = position
134 |
135 | # Set the transform for the new instance at the next available index
136 | multimesh.set_instance_transform(current_visible_count, transform)
137 |
138 | # Increase visible count AFTER setting transform
139 | multimesh.visible_instance_count = current_visible_count + 1
140 |
141 | print("Added instance with transform: ", transform,
142 | ", new visible_instance_count: ", multimesh.visible_instance_count)
143 |
144 | return transform
145 |
146 | # Add multiple instances at once using pre-defined transforms
147 | func add_instances_bulk(transforms_to_add: Array[Transform3D]):
148 | if not multimesh or not foliage_mesh:
149 | print("ERROR: add_instances_bulk called but multimesh or foliage_mesh is null!")
150 | return
151 |
152 | if transforms_to_add.is_empty():
153 | return # Nothing to add
154 |
155 | var current_visible_count = multimesh.visible_instance_count
156 | var num_to_add = transforms_to_add.size()
157 | var required_instance_count = current_visible_count + num_to_add
158 |
159 | # --- Resize if needed (only once) ---
160 | if required_instance_count > multimesh.instance_count:
161 | # Calculate new size, ensuring it fits needed instances, grows reasonably
162 | var new_count = max(required_instance_count, multimesh.instance_count * 2)
163 | new_count = max(new_count, 100) # Ensure a minimum size
164 | #print("Bulk Add: Growing multimesh: ", multimesh.instance_count, " -> ", new_count)
165 |
166 | # Store existing transforms before resize clears buffer
167 | var existing_transforms: Array[Transform3D] = []
168 | for i in range(current_visible_count):
169 | existing_transforms.append(multimesh.get_instance_transform(i))
170 |
171 | # Resize (clears buffer)
172 | multimesh.instance_count = new_count
173 |
174 | # Restore existing transforms
175 | for i in range(current_visible_count):
176 | multimesh.set_instance_transform(i, existing_transforms[i])
177 |
178 | # IMPORTANT: Restore visible count to state before adding new ones
179 | multimesh.visible_instance_count = current_visible_count
180 | # --- End Resize ---
181 |
182 | # --- Add new transforms ---
183 | for i in range(num_to_add):
184 | var target_index = current_visible_count + i
185 | # Basic safety check, though resizing should prevent this
186 | if target_index < multimesh.instance_count:
187 | multimesh.set_instance_transform(target_index, transforms_to_add[i])
188 | else:
189 | printerr("Bulk Add Error: Target index out of bounds after resize! Index: ", target_index, ", Instance Count: ", multimesh.instance_count)
190 | # --- End Add new transforms ---
191 |
192 | # --- Update visible count once ---
193 | multimesh.visible_instance_count = required_instance_count
194 | #print("Bulk Add: Added ", num_to_add, " instances. New visible count: ", multimesh.visible_instance_count)
195 |
196 | # Add an instance using a pre-defined transform (used for redo)
197 | func add_instance_with_transform(transform: Transform3D):
198 | # Safety check: Caller (Decal) is responsible for ensuring multimesh exists
199 | if not multimesh or not foliage_mesh:
200 | print("ERROR: add_instance_with_transform called but multimesh or foliage_mesh is null!")
201 | return
202 |
203 | var current_visible_count = multimesh.visible_instance_count
204 |
205 | # Ensure capacity (same logic as add_instance_at_position)
206 | if current_visible_count >= multimesh.instance_count:
207 | var new_count = max(100, multimesh.instance_count * 2)
208 | #print("Growing multimesh for redo: ", multimesh.instance_count, " -> ", new_count)
209 | var existing_transforms: Array[Transform3D] = []
210 | for i in range(current_visible_count):
211 | existing_transforms.append(multimesh.get_instance_transform(i))
212 | multimesh.instance_count = new_count
213 | for i in range(current_visible_count):
214 | multimesh.set_instance_transform(i, existing_transforms[i])
215 | multimesh.visible_instance_count = current_visible_count
216 |
217 | # Set the provided transform at the next available index
218 | multimesh.set_instance_transform(current_visible_count, transform)
219 |
220 | # Increase visible count
221 | multimesh.visible_instance_count = current_visible_count + 1
222 |
223 | #print("Redo: Added instance with transform: ", transform,
224 | # ", new visible_instance_count: ", multimesh.visible_instance_count)
225 |
226 | func remove_instance(index: int) -> bool:
227 | if not multimesh:
228 | return false
229 |
230 | var current_count = multimesh.visible_instance_count
231 |
232 | if index < 0 or index >= current_count:
233 | return false
234 |
235 | # Move the last instance to the removed spot (unless it's the last one)
236 | if index < current_count - 1:
237 | var last_transform = multimesh.get_instance_transform(current_count - 1)
238 | multimesh.set_instance_transform(index, last_transform)
239 |
240 | # Reduce the visible count
241 | multimesh.visible_instance_count = current_count - 1
242 |
243 | return true
244 |
245 | func clear_instances():
246 | if not multimesh:
247 | return
248 |
249 | multimesh.visible_instance_count = 0
250 | #print("Cleared all instances")
251 |
252 | # Remove all instances after a specific index (used for undo)
253 | func clear_instances_from(start_index: int):
254 | if not multimesh:
255 | return
256 |
257 | if multimesh.visible_instance_count > start_index:
258 | #print("Clearing instances from index ", start_index,
259 | # ", before: ", multimesh.visible_instance_count)
260 | multimesh.visible_instance_count = start_index
261 | #print("After clearing: visible_instance_count=", multimesh.visible_instance_count)
262 |
263 | # Set the visible instance count directly (used for undo/redo)
264 | func set_visible_instance_count(count: int):
265 | if not multimesh:
266 | return
267 | # Ensure count is valid
268 | if count >= 0 and count <= multimesh.instance_count:
269 | multimesh.visible_instance_count = count
270 | #print("Set visible_instance_count to: ", count)
271 | else:
272 | # This might happen if instance_count was reduced unexpectedly
273 | print("Warning: Invalid count for set_visible_instance_count: ", count,
274 | ", current instance_count: ", multimesh.instance_count,
275 | ", current visible_instance_count: ", multimesh.visible_instance_count)
276 | # As a fallback, set to the nearest valid boundary
277 | multimesh.visible_instance_count = clamp(count, 0, multimesh.instance_count)
278 |
279 | # Calculate the necessary height updates for all instances based on the parent terrain height.
280 | # Returns an array of changes: [ [index, old_transform, new_transform], ... ]
281 | func update_instance_heights() -> Array:
282 | var changes : Array = []
283 |
284 | if not is_inside_tree() or not Engine.is_editor_hint():
285 | print("Cannot calculate heights: Not in tree or not in editor.")
286 | return changes
287 |
288 | var parent = get_parent()
289 | if not parent is SimpleTerrain:
290 | print("Cannot calculate heights: Parent is not SimpleTerrain.")
291 | return changes
292 |
293 | if not multimesh:
294 | print("Cannot calculate heights: MultiMesh is not initialized.")
295 | return changes
296 |
297 | var instance_count = multimesh.visible_instance_count
298 | if instance_count == 0:
299 | # print("No instances to calculate height updates for.")
300 | return changes
301 |
302 | # print("Calculating height updates for %d instances..." % instance_count)
303 | # Cache global transform for efficiency
304 | var foliage_global_transform = global_transform
305 | # Define a small tolerance for floating point comparisons
306 | const Y_TOLERANCE = 0.001
307 |
308 | for i in range(instance_count):
309 | var old_transform = multimesh.get_instance_transform(i)
310 |
311 | # Calculate the global position of the instance
312 | var instance_global_pos : Vector3 = foliage_global_transform * old_transform.origin
313 |
314 | # Get the terrain height at the instance's global XZ position
315 | var terrain_local_y : float = parent.get_real_local_height_at_pos(instance_global_pos, parent.highest_lod_resolution)
316 |
317 | # Check if the Y position actually needs changing (within tolerance)
318 | # Apply the height offset to the target Y
319 | var target_local_y = terrain_local_y + height_offset
320 |
321 | if abs(old_transform.origin.y - target_local_y) > Y_TOLERANCE:
322 | # Create the new local origin for the instance
323 | var new_local_origin = Vector3(old_transform.origin.x, target_local_y, old_transform.origin.z)
324 |
325 | # Create the new transform, preserving rotation and scale
326 | var new_transform = Transform3D(old_transform.basis, new_local_origin)
327 |
328 | # Add the change to our list
329 | changes.append([i, old_transform, new_transform])
330 |
331 | # print("Finished calculating instance height updates. %d changes needed." % changes.size())
332 | return changes
333 |
334 | func _get_configuration_warnings() -> PackedStringArray:
335 | var warnings: PackedStringArray = []
336 |
337 | if not get_parent() is SimpleTerrain:
338 | warnings.append("SimpleTerrainFoliage must be a child of a SimpleTerrain node to function properly.")
339 |
340 | if not foliage_mesh:
341 | warnings.append("No foliage mesh assigned. Assign a mesh to display foliage.")
342 |
343 | return warnings
344 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/BrushToolbar.tscn:
--------------------------------------------------------------------------------
1 | [gd_scene load_steps=14 format=3 uid="uid://dhg07358xefq0"]
2 |
3 | [ext_resource type="Script" uid="uid://chbol681no7qu" path="res://addons/SimpleTerrain/BrushToolbar.gd" id="1_sf5jv"]
4 | [ext_resource type="Texture2D" uid="uid://dri5wcef3up8o" path="res://addons/SimpleTerrain/assets/textures/terrain_icon.svg" id="2_8iqfe"]
5 |
6 | [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nxm1g"]
7 | border_color = Color(0, 0, 0, 1)
8 |
9 | [sub_resource type="Theme" id="Theme_r08lb"]
10 | TextureRect/styles/asd = SubResource("StyleBoxFlat_nxm1g")
11 |
12 | [sub_resource type="ButtonGroup" id="ButtonGroup_praku"]
13 | allow_unpress = true
14 |
15 | [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_di5xf"]
16 | bg_color = Color(0.6, 0.6, 0.6, 0)
17 | border_width_left = 1
18 | border_width_top = 1
19 | border_width_right = 1
20 | border_width_bottom = 1
21 | border_color = Color(0.176471, 0.176471, 0.176471, 1)
22 |
23 | [sub_resource type="Theme" id="Theme_vs762"]
24 | PanelContainer/styles/panel = SubResource("StyleBoxFlat_di5xf")
25 |
26 | [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_c8tmb"]
27 | bg_color = Color(0.6, 0.6, 0.6, 0)
28 | border_width_left = 1
29 | border_width_top = 1
30 | border_width_right = 1
31 | border_width_bottom = 1
32 | border_color = Color(0, 0, 0, 1)
33 |
34 | [sub_resource type="Theme" id="Theme_rvyhp"]
35 | /styles/Border = SubResource("StyleBoxFlat_c8tmb")
36 |
37 | [sub_resource type="Gradient" id="Gradient_vw1bq"]
38 | interpolation_mode = 2
39 | interpolation_color_space = 1
40 | offsets = PackedFloat32Array(0, 0, 1)
41 | colors = PackedColorArray(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0)
42 |
43 | [sub_resource type="GradientTexture2D" id="GradientTexture2D_csyql"]
44 | gradient = SubResource("Gradient_vw1bq")
45 | fill = 1
46 | fill_from = Vector2(0.5, 0.5)
47 | fill_to = Vector2(1, 0.5)
48 |
49 | [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_qhlar"]
50 | bg_color = Color(0.6, 0.6, 0.6, 0)
51 | border_width_left = 1
52 | border_width_top = 1
53 | border_width_right = 1
54 | border_width_bottom = 1
55 | border_color = Color(0, 0, 0, 1)
56 |
57 | [sub_resource type="Theme" id="Theme_utdcu"]
58 | PanelContainer/styles/panel = SubResource("StyleBoxFlat_qhlar")
59 |
60 | [node name="BrushToolbar" type="MarginContainer"]
61 | visible = false
62 | anchors_preset = 12
63 | anchor_top = 1.0
64 | anchor_right = 1.0
65 | anchor_bottom = 1.0
66 | offset_top = -41.0
67 | grow_horizontal = 2
68 | grow_vertical = 0
69 | theme = SubResource("Theme_r08lb")
70 | theme_override_constants/margin_left = 10
71 | theme_override_constants/margin_top = 5
72 | theme_override_constants/margin_right = 10
73 | theme_override_constants/margin_bottom = 5
74 | script = ExtResource("1_sf5jv")
75 |
76 | [node name="HFlowContainer" type="HFlowContainer" parent="."]
77 | layout_mode = 2
78 |
79 | [node name="HBoxContainer" type="HBoxContainer" parent="HFlowContainer"]
80 | layout_mode = 2
81 |
82 | [node name="BrushButtons" type="MarginContainer" parent="HFlowContainer/HBoxContainer"]
83 | custom_minimum_size = Vector2(25, 0)
84 | layout_mode = 2
85 | theme_override_constants/margin_right = 10
86 |
87 | [node name="BrushButtons" type="HBoxContainer" parent="HFlowContainer/HBoxContainer/BrushButtons"]
88 | layout_mode = 2
89 |
90 | [node name="TextureRect" type="TextureRect" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
91 | layout_mode = 2
92 | texture = ExtResource("2_8iqfe")
93 | stretch_mode = 5
94 |
95 | [node name="Label" type="Label" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
96 | layout_mode = 2
97 | text = "Terrain brush:"
98 |
99 | [node name="Raise" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
100 | unique_name_in_owner = true
101 | layout_mode = 2
102 | tooltip_text = "Raise terrain"
103 | toggle_mode = true
104 | button_group = SubResource("ButtonGroup_praku")
105 | text = "Raise"
106 |
107 | [node name="Lower" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
108 | unique_name_in_owner = true
109 | layout_mode = 2
110 | tooltip_text = "Lower terrain"
111 | toggle_mode = true
112 | button_group = SubResource("ButtonGroup_praku")
113 | text = "Lower"
114 |
115 | [node name="Flatten" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
116 | unique_name_in_owner = true
117 | layout_mode = 2
118 | tooltip_text = "Flatten terrain using elevation under the brush as the base. Hold Shift to flatten to absolute 0."
119 | toggle_mode = true
120 | button_group = SubResource("ButtonGroup_praku")
121 | text = "Flatten"
122 |
123 | [node name="Splat_0" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
124 | unique_name_in_owner = true
125 | custom_minimum_size = Vector2(80, 0)
126 | layout_mode = 2
127 | tooltip_text = "Paint texture 0"
128 | toggle_mode = true
129 | button_group = SubResource("ButtonGroup_praku")
130 | text = "0"
131 |
132 | [node name="Splat_1" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
133 | unique_name_in_owner = true
134 | custom_minimum_size = Vector2(80, 0)
135 | layout_mode = 2
136 | tooltip_text = "Paint texture 1"
137 | toggle_mode = true
138 | button_group = SubResource("ButtonGroup_praku")
139 | text = "1"
140 |
141 | [node name="Splat_2" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
142 | unique_name_in_owner = true
143 | custom_minimum_size = Vector2(80, 0)
144 | layout_mode = 2
145 | tooltip_text = "Paint texture 2"
146 | toggle_mode = true
147 | button_group = SubResource("ButtonGroup_praku")
148 | text = "2"
149 |
150 | [node name="Splat_3" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
151 | unique_name_in_owner = true
152 | custom_minimum_size = Vector2(80, 0)
153 | layout_mode = 2
154 | tooltip_text = "Paint texture 3"
155 | toggle_mode = true
156 | button_group = SubResource("ButtonGroup_praku")
157 | text = "3"
158 |
159 | [node name="Splat_Transparent" type="Button" parent="HFlowContainer/HBoxContainer/BrushButtons/BrushButtons"]
160 | unique_name_in_owner = true
161 | custom_minimum_size = Vector2(20, 0)
162 | layout_mode = 2
163 | tooltip_text = "Paint holes in terrain"
164 | toggle_mode = true
165 | button_group = SubResource("ButtonGroup_praku")
166 | text = "Hole"
167 |
168 | [node name="BrushPreview" type="MarginContainer" parent="HFlowContainer/HBoxContainer"]
169 | layout_mode = 2
170 | theme_override_constants/margin_right = 7
171 |
172 | [node name="PanelContainer" type="PanelContainer" parent="HFlowContainer/HBoxContainer/BrushPreview"]
173 | layout_mode = 2
174 | tooltip_text = "Preview hardness and opacity"
175 | theme = SubResource("Theme_vs762")
176 |
177 | [node name="BrushPreviewTextureRect" type="TextureRect" parent="HFlowContainer/HBoxContainer/BrushPreview/PanelContainer"]
178 | unique_name_in_owner = true
179 | layout_mode = 2
180 | theme = SubResource("Theme_rvyhp")
181 | texture = SubResource("GradientTexture2D_csyql")
182 | expand_mode = 3
183 |
184 | [node name="HBoxContainer2" type="HBoxContainer" parent="HFlowContainer"]
185 | layout_mode = 2
186 | size_flags_horizontal = 3
187 |
188 | [node name="BrushSize" type="MarginContainer" parent="HFlowContainer/HBoxContainer2"]
189 | layout_mode = 2
190 | size_flags_horizontal = 3
191 | theme_override_constants/margin_right = 10
192 |
193 | [node name="HBoxContainer" type="HBoxContainer" parent="HFlowContainer/HBoxContainer2/BrushSize"]
194 | layout_mode = 2
195 |
196 | [node name="Label" type="Label" parent="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer"]
197 | layout_mode = 2
198 | text = "Size:"
199 |
200 | [node name="SpinBox" type="SpinBox" parent="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer"]
201 | layout_mode = 2
202 | max_value = 512.0
203 | value = 64.0
204 | allow_greater = true
205 | suffix = "px"
206 |
207 | [node name="HSlider" type="HSlider" parent="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer"]
208 | custom_minimum_size = Vector2(100, 0)
209 | layout_mode = 2
210 | size_flags_horizontal = 3
211 | size_flags_vertical = 4
212 | min_value = 1.0
213 | max_value = 512.0
214 | value = 64.0
215 | allow_greater = true
216 |
217 | [node name="Hardness" type="MarginContainer" parent="HFlowContainer/HBoxContainer2"]
218 | layout_mode = 2
219 | size_flags_horizontal = 3
220 | tooltip_text = "Hardness of the brush edge"
221 | theme_override_constants/margin_right = 10
222 |
223 | [node name="HBoxContainer" type="HBoxContainer" parent="HFlowContainer/HBoxContainer2/Hardness"]
224 | layout_mode = 2
225 |
226 | [node name="Label" type="Label" parent="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer"]
227 | layout_mode = 2
228 | text = "Hardness:"
229 |
230 | [node name="SpinBox" type="SpinBox" parent="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer"]
231 | layout_mode = 2
232 | step = 0.05
233 | value = 75.0
234 | suffix = "%"
235 |
236 | [node name="HSlider" type="HSlider" parent="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer"]
237 | custom_minimum_size = Vector2(100, 0)
238 | layout_mode = 2
239 | size_flags_horizontal = 3
240 | size_flags_vertical = 4
241 | value = 75.0
242 |
243 | [node name="Opacity" type="MarginContainer" parent="HFlowContainer/HBoxContainer2"]
244 | layout_mode = 2
245 | size_flags_horizontal = 3
246 | tooltip_text = "Opacity or stength of the brush"
247 | theme_override_constants/margin_right = 10
248 |
249 | [node name="HBoxContainer" type="HBoxContainer" parent="HFlowContainer/HBoxContainer2/Opacity"]
250 | layout_mode = 2
251 |
252 | [node name="Label" type="Label" parent="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer"]
253 | layout_mode = 2
254 | text = "Opacity:"
255 |
256 | [node name="SpinBox" type="SpinBox" parent="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer"]
257 | layout_mode = 2
258 | step = 0.05
259 | value = 100.0
260 | suffix = "%"
261 |
262 | [node name="HSlider" type="HSlider" parent="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer"]
263 | custom_minimum_size = Vector2(100, 0)
264 | layout_mode = 2
265 | size_flags_horizontal = 3
266 | size_flags_vertical = 4
267 | min_value = 1.0
268 | value = 100.0
269 |
270 | [node name="TerrainSettings" type="MarginContainer" parent="HFlowContainer/HBoxContainer2"]
271 | custom_minimum_size = Vector2(25, 0)
272 | layout_mode = 2
273 |
274 | [node name="Button" type="Button" parent="HFlowContainer/HBoxContainer2/TerrainSettings"]
275 | layout_mode = 2
276 | text = "Terrain settings"
277 |
278 | [node name="SettingsPopup" type="PopupPanel" parent="."]
279 | unique_name_in_owner = true
280 | title = "Terrain texture settings"
281 | initial_position = 2
282 | size = Vector2i(640, 627)
283 | unresizable = false
284 | borderless = false
285 | keep_title_visible = true
286 |
287 | [node name="PopupMargin" type="MarginContainer" parent="SettingsPopup"]
288 | offset_left = 4.0
289 | offset_top = 4.0
290 | offset_right = 636.0
291 | offset_bottom = 623.0
292 | theme_override_constants/margin_left = 30
293 | theme_override_constants/margin_top = 30
294 | theme_override_constants/margin_right = 30
295 | theme_override_constants/margin_bottom = 30
296 |
297 | [node name="PopupVBox" type="VBoxContainer" parent="SettingsPopup/PopupMargin"]
298 | layout_mode = 2
299 |
300 | [node name="HeightmapTitle" type="MarginContainer" parent="SettingsPopup/PopupMargin/PopupVBox"]
301 | layout_mode = 2
302 | theme_override_constants/margin_bottom = 5
303 |
304 | [node name="Label" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapTitle"]
305 | layout_mode = 2
306 | text = "Height map settings:"
307 |
308 | [node name="HeightmapOptions" type="GridContainer" parent="SettingsPopup/PopupMargin/PopupVBox"]
309 | layout_mode = 2
310 | columns = 3
311 |
312 | [node name="MarginContainer" type="MarginContainer" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions"]
313 | custom_minimum_size = Vector2(128, 128)
314 | layout_mode = 2
315 | size_flags_horizontal = 3
316 | size_flags_vertical = 4
317 |
318 | [node name="PanelContainer" type="PanelContainer" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/MarginContainer"]
319 | clip_contents = true
320 | custom_minimum_size = Vector2(128, 128)
321 | layout_mode = 2
322 | size_flags_horizontal = 0
323 | size_flags_vertical = 4
324 | theme = SubResource("Theme_utdcu")
325 |
326 | [node name="HeightmapTextureRect" type="TextureRect" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/MarginContainer/PanelContainer"]
327 | unique_name_in_owner = true
328 | layout_mode = 2
329 | expand_mode = 4
330 | stretch_mode = 5
331 |
332 | [node name="HeightmapNoImageLabel" type="CenterContainer" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/MarginContainer/PanelContainer"]
333 | unique_name_in_owner = true
334 | layout_mode = 2
335 |
336 | [node name="Label" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/MarginContainer/PanelContainer/HeightmapNoImageLabel"]
337 | layout_mode = 2
338 | text = "No image"
339 | horizontal_alignment = 1
340 |
341 | [node name="FlowContainer4" type="FlowContainer" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions"]
342 | layout_mode = 2
343 | size_flags_horizontal = 3
344 | size_flags_vertical = 4
345 |
346 | [node name="GridContainer" type="GridContainer" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer4"]
347 | layout_mode = 2
348 | columns = 2
349 |
350 | [node name="Label" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer4/GridContainer"]
351 | layout_mode = 2
352 | text = "Width:"
353 |
354 | [node name="HeightmapWidth" type="SpinBox" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer4/GridContainer"]
355 | unique_name_in_owner = true
356 | custom_minimum_size = Vector2(100, 0)
357 | layout_mode = 2
358 | min_value = 1.0
359 | max_value = 8192.0
360 | value = 1024.0
361 | rounded = true
362 | suffix = "px"
363 |
364 | [node name="Label2" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer4/GridContainer"]
365 | layout_mode = 2
366 | text = "Height:"
367 |
368 | [node name="HeightmapHeight" type="SpinBox" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer4/GridContainer"]
369 | unique_name_in_owner = true
370 | custom_minimum_size = Vector2(100, 0)
371 | layout_mode = 2
372 | min_value = 1.0
373 | max_value = 8192.0
374 | value = 1024.0
375 | rounded = true
376 | suffix = "px"
377 |
378 | [node name="FlowContainer6" type="FlowContainer" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions"]
379 | layout_mode = 2
380 | size_flags_horizontal = 3
381 | size_flags_vertical = 4
382 | alignment = 1
383 |
384 | [node name="ConvertToImageTextureHeightmap" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer6"]
385 | unique_name_in_owner = true
386 | layout_mode = 2
387 | text = "Convert to ImageTexture"
388 |
389 | [node name="NewImage" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer6"]
390 | layout_mode = 2
391 | text = "Create new"
392 |
393 | [node name="ResizeImage" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer6"]
394 | layout_mode = 2
395 | text = "Resize"
396 |
397 | [node name="SplatmapTitle" type="MarginContainer" parent="SettingsPopup/PopupMargin/PopupVBox"]
398 | layout_mode = 2
399 | theme_override_constants/margin_top = 25
400 | theme_override_constants/margin_bottom = 5
401 |
402 | [node name="Label" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapTitle"]
403 | layout_mode = 2
404 | text = "Splat map settings:"
405 |
406 | [node name="SplatmapOptions" type="GridContainer" parent="SettingsPopup/PopupMargin/PopupVBox"]
407 | layout_mode = 2
408 | columns = 3
409 |
410 | [node name="MarginContainer" type="MarginContainer" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions"]
411 | custom_minimum_size = Vector2(128, 128)
412 | layout_mode = 2
413 | size_flags_horizontal = 3
414 | size_flags_vertical = 4
415 |
416 | [node name="PanelContainer2" type="PanelContainer" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/MarginContainer"]
417 | custom_minimum_size = Vector2(128, 128)
418 | layout_mode = 2
419 | size_flags_horizontal = 0
420 | size_flags_vertical = 0
421 | theme = SubResource("Theme_utdcu")
422 |
423 | [node name="SplatmapTextureRect" type="TextureRect" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/MarginContainer/PanelContainer2"]
424 | unique_name_in_owner = true
425 | layout_mode = 2
426 | expand_mode = 2
427 | stretch_mode = 5
428 |
429 | [node name="SplatmapNoImageLabel" type="CenterContainer" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/MarginContainer/PanelContainer2"]
430 | unique_name_in_owner = true
431 | layout_mode = 2
432 |
433 | [node name="Label" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/MarginContainer/PanelContainer2/SplatmapNoImageLabel"]
434 | layout_mode = 2
435 | text = "No image"
436 | horizontal_alignment = 1
437 |
438 | [node name="FlowContainer4" type="FlowContainer" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions"]
439 | layout_mode = 2
440 | size_flags_horizontal = 3
441 | size_flags_vertical = 4
442 |
443 | [node name="GridContainer" type="GridContainer" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer4"]
444 | layout_mode = 2
445 | columns = 2
446 |
447 | [node name="Label" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer4/GridContainer"]
448 | layout_mode = 2
449 | text = "Width:"
450 |
451 | [node name="SplatmapWidth" type="SpinBox" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer4/GridContainer"]
452 | unique_name_in_owner = true
453 | custom_minimum_size = Vector2(100, 0)
454 | layout_mode = 2
455 | min_value = 1.0
456 | max_value = 8192.0
457 | value = 1024.0
458 | rounded = true
459 | suffix = "px"
460 |
461 | [node name="Label2" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer4/GridContainer"]
462 | layout_mode = 2
463 | text = "Height:"
464 |
465 | [node name="SplatmapHeight" type="SpinBox" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer4/GridContainer"]
466 | unique_name_in_owner = true
467 | custom_minimum_size = Vector2(100, 0)
468 | layout_mode = 2
469 | min_value = 1.0
470 | max_value = 8192.0
471 | value = 1024.0
472 | rounded = true
473 | suffix = "px"
474 |
475 | [node name="FlowContainer5" type="FlowContainer" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions"]
476 | layout_mode = 2
477 | size_flags_horizontal = 3
478 | size_flags_vertical = 4
479 | alignment = 1
480 |
481 | [node name="ConvertToImageTextureSplatmap" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer5"]
482 | unique_name_in_owner = true
483 | layout_mode = 2
484 | text = "Convert to ImageTexture"
485 |
486 | [node name="NewImage" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer5"]
487 | layout_mode = 2
488 | text = "Create new"
489 |
490 | [node name="ResizeImage" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer5"]
491 | layout_mode = 2
492 | text = "Resize"
493 |
494 | [node name="BakeButtons" type="VBoxContainer" parent="SettingsPopup/PopupMargin/PopupVBox"]
495 | layout_mode = 2
496 |
497 | [node name="MarginContainer2" type="MarginContainer" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons"]
498 | layout_mode = 2
499 | theme_override_constants/margin_top = 25
500 |
501 | [node name="FlowContainer6" type="FlowContainer" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons"]
502 | layout_mode = 2
503 | size_flags_horizontal = 3
504 | size_flags_vertical = 4
505 | alignment = 1
506 |
507 | [node name="SaveAsMesh" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6"]
508 | layout_mode = 2
509 | text = "Save as untextured mesh"
510 |
511 | [node name="SaveAsMeshFileDialog" type="FileDialog" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6/SaveAsMesh"]
512 | unique_name_in_owner = true
513 | initial_position = 2
514 | filters = PackedStringArray("*.mesh")
515 |
516 | [node name="BakeNormalMapButton" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6"]
517 | layout_mode = 2
518 | text = "Bake normal map"
519 |
520 | [node name="CreateCollisionBodyButton" type="Button" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6"]
521 | layout_mode = 2
522 | text = "Create collision body"
523 |
524 | [node name="MarginContainer" type="MarginContainer" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons"]
525 | layout_mode = 2
526 | theme_override_constants/margin_top = 15
527 |
528 | [node name="Label" type="Label" parent="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/MarginContainer"]
529 | custom_minimum_size = Vector2(300, 0)
530 | layout_mode = 2
531 | text = "Precomputing the normal map or collison body may improve load time or reduce initial stuttering but takes up more space on disk. The collision body is more expensive to compute."
532 | horizontal_alignment = 1
533 | autowrap_mode = 3
534 |
535 | [node name="ConvertTextureConfirmationDialog" type="ConfirmationDialog" parent="."]
536 | unique_name_in_owner = true
537 | initial_position = 2
538 | size = Vector2i(350, 135)
539 | ok_button_text = "Convert texture"
540 |
541 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer/SpinBox" to="." method="set_brush_size_from_ui"]
542 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer/SpinBox" to="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer/HSlider" method="set_value_no_signal"]
543 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer/HSlider" to="HFlowContainer/HBoxContainer2/BrushSize/HBoxContainer/SpinBox" method="set_value"]
544 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer/SpinBox" to="." method="set_brush_hardness_from_ui"]
545 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer/SpinBox" to="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer/HSlider" method="set_value_no_signal"]
546 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer/HSlider" to="HFlowContainer/HBoxContainer2/Hardness/HBoxContainer/SpinBox" method="set_value"]
547 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer/SpinBox" to="." method="set_brush_opacity_from_ui"]
548 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer/SpinBox" to="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer/HSlider" method="set_value_no_signal"]
549 | [connection signal="value_changed" from="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer/HSlider" to="HFlowContainer/HBoxContainer2/Opacity/HBoxContainer/SpinBox" method="set_value"]
550 | [connection signal="pressed" from="HFlowContainer/HBoxContainer2/TerrainSettings/Button" to="SettingsPopup" method="show"]
551 | [connection signal="visibility_changed" from="SettingsPopup" to="." method="_on_settings_popup_visibility_changed"]
552 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer6/ConvertToImageTextureHeightmap" to="." method="_on_convert_to_image_texture_pressed" binds= [true]]
553 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer6/NewImage" to="." method="_on_new_image_pressed" binds= [true]]
554 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/HeightmapOptions/FlowContainer6/ResizeImage" to="." method="_on_resize_image_pressed" binds= [true]]
555 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer5/ConvertToImageTextureSplatmap" to="." method="_on_convert_to_image_texture_pressed" binds= [false]]
556 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer5/NewImage" to="." method="_on_new_image_pressed" binds= [false]]
557 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/SplatmapOptions/FlowContainer5/ResizeImage" to="." method="_on_resize_image_pressed" binds= [false]]
558 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6/SaveAsMesh" to="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6/SaveAsMesh/SaveAsMeshFileDialog" method="show"]
559 | [connection signal="file_selected" from="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6/SaveAsMesh/SaveAsMeshFileDialog" to="." method="_on_save_as_mesh_file_dialog_file_selected"]
560 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6/BakeNormalMapButton" to="." method="_on_bake_normal_map_button_pressed"]
561 | [connection signal="pressed" from="SettingsPopup/PopupMargin/PopupVBox/BakeButtons/FlowContainer6/CreateCollisionBodyButton" to="." method="_on_create_collision_body_button_pressed"]
562 | [connection signal="confirmed" from="ConvertTextureConfirmationDialog" to="." method="_on_convert_texture_confirmation_dialog_confirmed"]
563 |
--------------------------------------------------------------------------------
/FPSController/FPSController.gd:
--------------------------------------------------------------------------------
1 | class_name FPSController
2 | extends CharacterBody3D
3 |
4 | @export var look_sensitivity : float = 0.006
5 | @export var controller_look_sensitivity := 0.05
6 |
7 | @export var jump_velocity := 6.0
8 | @export var auto_bhop := true
9 |
10 | const HEADBOB_MOVE_AMOUNT = 0.06
11 | const HEADBOB_FREQUENCY = 2.4
12 | var headbob_time := 0.0
13 |
14 | # Ground movement settings
15 | @export var walk_speed := 7.0
16 | @export var sprint_speed := 8.5
17 | @export var ground_accel := 11.0
18 | @export var ground_decel := 7.0
19 | @export var ground_friction := 3.5
20 |
21 | # Air movement settings. Need to tweak these to get the feeling dialed in.
22 | @export var air_cap := 0.85 # Can surf steeper ramps if this is higher, makes it easier to stick and bhop
23 | @export var air_accel := 800.0
24 | @export var air_move_speed := 500.0
25 |
26 | @export var swim_up_speed := 10.0
27 | @export var climb_speed := 7.0
28 |
29 | @export var health := 100.0
30 | @export var max_health := 100.0
31 |
32 | func take_damage(damage : float):
33 | health -= damage
34 |
35 | # Camera options
36 | enum CameraStyle {
37 | FIRST_PERSON, THIRD_PERSON_VERTICAL_LOOK, THIRD_PERSON_FREE_LOOK
38 | }
39 | @export var camera_style : CameraStyle = CameraStyle.FIRST_PERSON :
40 | set(v):
41 | camera_style = v
42 | _update_camera()
43 |
44 | var wish_dir := Vector3.ZERO
45 | var cam_aligned_wish_dir := Vector3.ZERO
46 |
47 | const CROUCH_TRANSLATE = 0.7
48 | const CROUCH_JUMP_ADD = CROUCH_TRANSLATE * 0.9 # * 0.9 for sourcelike camera jitter in air on crouch, makes for a nice notifier
49 | var is_crouched := false
50 |
51 | var noclip_speed_mult := 3.0
52 | var noclip := false
53 |
54 | const MAX_STEP_HEIGHT = 0.5 # Raycasts length should match this. StairsAhead one should be slightly longer.
55 | var _snapped_to_stairs_last_frame := false
56 | var _last_frame_was_on_floor = -INF
57 |
58 | const VIEW_MODEL_LAYER = 9
59 | const WORLD_MODEL_LAYER = 2
60 |
61 | func get_move_speed() -> float:
62 | if is_crouched:
63 | return walk_speed * 0.6
64 | return sprint_speed if Input.is_action_pressed("sprint") else walk_speed
65 |
66 | func _ready():
67 | update_view_and_world_model_masks()
68 | _update_camera()
69 |
70 | func update_view_and_world_model_masks():
71 | for child in %WorldModel.find_children("*", "VisualInstance3D", true, false):
72 | child.set_layer_mask_value(1, false)
73 | child.set_layer_mask_value(WORLD_MODEL_LAYER, true)
74 | for child in %ViewModel.find_children("*", "VisualInstance3D", true, false):
75 | child.set_layer_mask_value(1, false)
76 | child.set_layer_mask_value(VIEW_MODEL_LAYER, true)
77 | if child is GeometryInstance3D:
78 | child.cast_shadow = false
79 | %Camera3D.set_cull_mask_value(WORLD_MODEL_LAYER, false)
80 | %ThirdPersonCamera3D.set_cull_mask_value(VIEW_MODEL_LAYER, false)
81 |
82 | func _unhandled_input(event):
83 | if event is InputEventMouseButton:
84 | Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
85 | elif event.is_action_pressed("ui_cancel"):
86 | Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
87 |
88 | if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
89 | if event is InputEventMouseMotion:
90 | if camera_style == CameraStyle.THIRD_PERSON_FREE_LOOK:
91 | # Rotate the camera orbit rather than the player with mouse in free look mode
92 | %ThirdPersonOrbitCamYaw.rotate_y(-event.relative.x * look_sensitivity)
93 | else:
94 | %ThirdPersonOrbitCamYaw.rotation.y = 0
95 | rotate_y(-event.relative.x * look_sensitivity)
96 | # First person look up and down
97 | %Camera3D.rotate_x(-event.relative.y * look_sensitivity)
98 | %Camera3D.rotation.x = clamp(%Camera3D.rotation.x, deg_to_rad(-90), deg_to_rad(90))
99 | # Third person look up and down
100 | %ThirdPersonOrbitCamPitch.rotation.x = %Camera3D.rotation.x
101 |
102 | if event is InputEventMouseButton and event.is_pressed():
103 | if event.button_index == MOUSE_BUTTON_WHEEL_UP:
104 | noclip_speed_mult = min(100.0, noclip_speed_mult * 1.1)
105 | elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
106 | noclip_speed_mult = max(0.1, noclip_speed_mult * 0.9)
107 |
108 | func get_active_camera() -> Camera3D:
109 | if camera_style == CameraStyle.FIRST_PERSON:
110 | return %Camera3D
111 | else:
112 | return %ThirdPersonCamera3D
113 |
114 | func _update_camera():
115 | if not is_inside_tree():
116 | return
117 | var cameras = [%Camera3D, %ThirdPersonCamera3D]
118 | if not cameras.any(func(c : Camera3D): return c.current):
119 | return # Don't update camera if none are current, maybe on cutscene cam or something.
120 | get_active_camera().current = true
121 |
122 | var target_recoil := Vector2.ZERO
123 | var current_recoil := Vector2.ZERO
124 | const RECOIL_APPLY_SPEED : float = 10.0
125 | const RECOIL_RECOVER_SPEED : float = 7.0
126 |
127 | func add_recoil(pitch: float, yaw: float) -> void:
128 | target_recoil.x += pitch
129 | target_recoil.y += yaw
130 |
131 | func get_current_recoil() -> Vector2:
132 | return current_recoil
133 |
134 | func update_recoil(delta: float) -> void:
135 | # Slowly move target recoil back to 0,0
136 | target_recoil = target_recoil.lerp(Vector2.ZERO, RECOIL_RECOVER_SPEED * delta)
137 |
138 | # Slowly move current recoil to the target recoil
139 | var prev_recoil = current_recoil
140 | current_recoil = current_recoil.lerp(target_recoil, RECOIL_APPLY_SPEED * delta)
141 | var recoil_difference = current_recoil - prev_recoil
142 |
143 | # Rotate player/camera to current recoil
144 | rotate_y(recoil_difference.y)
145 | %Camera3D.rotate_x(recoil_difference.x)
146 | %Camera3D.rotation.x = clamp(%Camera3D.rotation.x, deg_to_rad(-90), deg_to_rad(90))
147 | %ThirdPersonOrbitCamPitch.rotation.x = %Camera3D.rotation.x
148 |
149 | func _headbob_effect(delta):
150 | headbob_time += delta * self.velocity.length()
151 | %Camera3D.transform.origin = Vector3(
152 | cos(headbob_time * HEADBOB_FREQUENCY * 0.5) * HEADBOB_MOVE_AMOUNT,
153 | sin(headbob_time * HEADBOB_FREQUENCY) * HEADBOB_MOVE_AMOUNT,
154 | 0
155 | )
156 |
157 | # Smoothly interpolated controller look with acceleration and deceleration
158 | var _cur_controller_look = Vector2()
159 | func _handle_controller_look_input(delta):
160 | var target_look = Input.get_vector("look_left", "look_right", "look_down", "look_up").normalized()
161 |
162 | if target_look.length() < _cur_controller_look.length():
163 | _cur_controller_look = target_look
164 | else:
165 | _cur_controller_look = _cur_controller_look.lerp(target_look, 5.0 * delta)
166 |
167 | if camera_style == CameraStyle.THIRD_PERSON_VERTICAL_LOOK or camera_style == CameraStyle.FIRST_PERSON:
168 | rotate_y(-_cur_controller_look.x * controller_look_sensitivity) # turn left and right
169 | else:
170 | %ThirdPersonOrbitCamYaw.rotate_y(-_cur_controller_look.x * controller_look_sensitivity) # turn left and right
171 | %Camera3D.rotate_x(_cur_controller_look.y * controller_look_sensitivity) # look up and down
172 | %Camera3D.rotation.x = clamp(%Camera3D.rotation.x, deg_to_rad(-90), deg_to_rad(90)) # clamp up and down range
173 | %ThirdPersonOrbitCamPitch.rotation.x = %Camera3D.rotation.x
174 |
175 | @onready var animation_tree : AnimationTree = $"WorldModel/desert droid container/AnimationTree"
176 | func update_animations():
177 | animation_tree.is_crouched = is_crouched
178 | animation_tree.is_in_air = noclip or (not is_on_floor() and not _snapped_to_stairs_last_frame)
179 | animation_tree.is_sprinting = Input.is_action_pressed("sprint")
180 |
181 | var rel_vel = self.global_basis.inverse() * ((self.velocity * Vector3(1,0,1)) / get_move_speed())
182 | var rel_vel_xz = Vector2(rel_vel.x, -rel_vel.z)
183 |
184 | if is_crouched:
185 | animation_tree.set("parameters/AnimationNodeStateMachine/Crouched/CrouchBlendSpace2D/blend_position", rel_vel_xz)
186 | elif Input.is_action_pressed("sprint"):
187 | animation_tree.set("parameters/AnimationNodeStateMachine/Standing/RunBlendSpace2D/blend_position", rel_vel_xz)
188 | else:
189 | animation_tree.set("parameters/AnimationNodeStateMachine/Standing/WalkBlendSpace2D/blend_position", rel_vel_xz)
190 |
191 | func _process(delta):
192 | _handle_controller_look_input(delta)
193 | if get_interactable_component_at_shapecast():
194 | get_interactable_component_at_shapecast().hover_cursor(self)
195 | if Input.is_action_just_pressed("interact"):
196 | get_interactable_component_at_shapecast().interact_with(self)
197 | if camera_style == CameraStyle.THIRD_PERSON_FREE_LOOK and wish_dir.length():
198 | # In free look mode, the camera determines move dir, not char direction. So change char direction per velocity.
199 | var add_rotation_y = (-self.global_transform.basis.z).signed_angle_to(wish_dir.normalized(), Vector3.UP)
200 | var rot_towards = lerp_angle(self.global_rotation.y, self.global_rotation.y + add_rotation_y, max(0.1, abs(add_rotation_y/TAU))) - self.global_rotation.y
201 | self.rotation.y += rot_towards
202 | %ThirdPersonOrbitCamYaw.rotation.y -= rot_towards
203 |
204 | update_recoil(delta)
205 | update_animations()
206 |
207 | func get_interactable_component_at_shapecast() -> InteractableComponent:
208 | for i in %InteractShapeCast3D.get_collision_count():
209 | # Allow colliding with player
210 | if i > 0 and %InteractShapeCast3D.get_collider(0) != $".":
211 | return null
212 | var collider = %InteractShapeCast3D.get_collider(i)
213 | if collider and collider.get_node_or_null("InteractableComponent") is InteractableComponent:
214 | return collider.get_node_or_null("InteractableComponent")
215 | return null
216 |
217 | var _saved_camera_global_pos = null
218 | func _save_camera_pos_for_smoothing():
219 | if _saved_camera_global_pos == null:
220 | _saved_camera_global_pos = %CameraSmooth.global_position
221 |
222 | func _slide_camera_smooth_back_to_origin(delta):
223 | if _saved_camera_global_pos == null: return
224 | %CameraSmooth.global_position.y = _saved_camera_global_pos.y
225 | %CameraSmooth.position.y = clampf(%CameraSmooth.position.y, -CROUCH_TRANSLATE, CROUCH_TRANSLATE) # Clamp incase teleported
226 | var move_amount = max(self.velocity.length() * delta, walk_speed/2 * delta)
227 | %CameraSmooth.position.y = move_toward(%CameraSmooth.position.y, 0.0, move_amount)
228 | _saved_camera_global_pos = %CameraSmooth.global_position
229 | if %CameraSmooth.position.y == 0:
230 | _saved_camera_global_pos = null # Stop smoothing camera
231 |
232 | func _push_away_rigid_bodies():
233 | for i in get_slide_collision_count():
234 | var c := get_slide_collision(i)
235 | if c.get_collider() is RigidBody3D:
236 | var push_dir = -c.get_normal()
237 | # How much velocity the object needs to increase to match player velocity in the push direction
238 | var velocity_diff_in_push_dir = self.velocity.dot(push_dir) - c.get_collider().linear_velocity.dot(push_dir)
239 | # Only count velocity towards push dir, away from character
240 | velocity_diff_in_push_dir = max(0., velocity_diff_in_push_dir)
241 | # Objects with more mass than us should be harder to push. But doesn't really make sense to push faster than we are going
242 | const MY_APPROX_MASS_KG = 80.0
243 | var mass_ratio = min(1., MY_APPROX_MASS_KG / c.get_collider().mass)
244 | # Optional add: Don't push object at all if it's 4x heavier or more
245 | if mass_ratio < 0.25:
246 | continue
247 | # Don't push object from above/below
248 | push_dir.y = 0
249 | # 5.0 is a magic number, adjust to your needs
250 | var push_force = mass_ratio * 5.0
251 | c.get_collider().apply_impulse(push_dir * velocity_diff_in_push_dir * push_force, c.get_position() - c.get_collider().global_position)
252 |
253 | func _snap_down_to_stairs_check() -> void:
254 | var did_snap := false
255 | # Modified slightly from tutorial. I don't notice any visual difference but I think this is correct.
256 | # Since it is called after move_and_slide, _last_frame_was_on_floor should still be current frame number.
257 | # After move_and_slide off top of stairs, on floor should then be false. Update raycast incase it's not already.
258 | %StairsBelowRayCast3D.force_raycast_update()
259 | var floor_below : bool = %StairsBelowRayCast3D.is_colliding() and not is_surface_too_steep(%StairsBelowRayCast3D.get_collision_normal())
260 | var was_on_floor_last_frame = Engine.get_physics_frames() == _last_frame_was_on_floor
261 | if not is_on_floor() and velocity.y <= 0 and (was_on_floor_last_frame or _snapped_to_stairs_last_frame) and floor_below:
262 | var body_test_result = KinematicCollision3D.new()
263 | if self.test_move(self.global_transform, Vector3(0,-MAX_STEP_HEIGHT,0), body_test_result):
264 | _save_camera_pos_for_smoothing()
265 | var translate_y = body_test_result.get_travel().y
266 | self.position.y += translate_y
267 | apply_floor_snap()
268 | did_snap = true
269 | _snapped_to_stairs_last_frame = did_snap
270 |
271 | func _snap_up_stairs_check(delta) -> bool:
272 | if not is_on_floor() and not _snapped_to_stairs_last_frame: return false
273 | # Don't snap stairs if trying to jump, also no need to check for stairs ahead if not moving
274 | if self.velocity.y > 0 or (self.velocity * Vector3(1,0,1)).length() == 0: return false
275 | var expected_move_motion = self.velocity * Vector3(1,0,1) * delta
276 | var step_pos_with_clearance = self.global_transform.translated(expected_move_motion + Vector3(0, MAX_STEP_HEIGHT * 2, 0))
277 | # Run a body_test_motion slightly above the pos we expect to move to, towards the floor.
278 | # We give some clearance above to ensure there's ample room for the player.
279 | # If it hits a step <= MAX_STEP_HEIGHT, we can teleport the player on top of the step
280 | # along with their intended motion forward.
281 | var down_check_result = KinematicCollision3D.new()
282 | if (self.test_move(step_pos_with_clearance, Vector3(0,-MAX_STEP_HEIGHT*2,0), down_check_result)
283 | and (down_check_result.get_collider().is_class("StaticBody3D") or down_check_result.get_collider().is_class("CSGShape3D"))):
284 | var step_height = ((step_pos_with_clearance.origin + down_check_result.get_travel()) - self.global_position).y
285 | # Note I put the step_height <= 0.01 in just because I noticed it prevented some physics glitchiness
286 | # 0.02 was found with trial and error. Too much and sometimes get stuck on a stair. Too little and can jitter if running into a ceiling.
287 | # The normal character controller (both jolt & default) seems to be able to handled steps up of 0.1 anyway
288 | if step_height > MAX_STEP_HEIGHT or step_height <= 0.01 or (down_check_result.get_position() - self.global_position).y > MAX_STEP_HEIGHT: return false
289 | %StairsAheadRayCast3D.global_position = down_check_result.get_position() + Vector3(0,MAX_STEP_HEIGHT,0) + expected_move_motion.normalized() * 0.1
290 | %StairsAheadRayCast3D.force_raycast_update()
291 | if %StairsAheadRayCast3D.is_colliding() and not is_surface_too_steep(%StairsAheadRayCast3D.get_collision_normal()):
292 | _save_camera_pos_for_smoothing()
293 | self.global_position = step_pos_with_clearance.origin + down_check_result.get_travel()
294 | apply_floor_snap()
295 | _snapped_to_stairs_last_frame = true
296 | return true
297 | return false
298 |
299 | var _cur_ladder_climbing : Area3D = null
300 | func _handle_ladder_physics() -> bool:
301 | # Keep track of whether already on ladder. If not already, check if overlapping a ladder area3d.
302 | var was_climbing_ladder := _cur_ladder_climbing and _cur_ladder_climbing.overlaps_body(self)
303 | if not was_climbing_ladder:
304 | _cur_ladder_climbing = null
305 | for ladder in get_tree().get_nodes_in_group("ladder_area3d"):
306 | if ladder.overlaps_body(self):
307 | _cur_ladder_climbing = ladder
308 | break
309 | if _cur_ladder_climbing == null:
310 | return false
311 |
312 | # Set up variables. Most of this is going to be dependent on the player's relative position/velocity/input to the ladder.
313 | var ladder_gtransform : Transform3D = _cur_ladder_climbing.global_transform
314 | var pos_rel_to_ladder := ladder_gtransform.affine_inverse() * self.global_position
315 |
316 | var forward_move := Input.get_action_strength("up") - Input.get_action_strength("down")
317 | var side_move := Input.get_action_strength("right") - Input.get_action_strength("left")
318 | var ladder_forward_move = ladder_gtransform.affine_inverse().basis * get_active_camera().global_transform.basis * Vector3(0, 0, -forward_move)
319 | var ladder_side_move = ladder_gtransform.affine_inverse().basis * get_active_camera().global_transform.basis * Vector3(side_move, 0, 0)
320 |
321 | # Strafe velocity is simple. Just take x component rel to ladder of both
322 | var ladder_strafe_vel : float = climb_speed * (ladder_side_move.x + ladder_forward_move.x)
323 | # For climb velocity, there are a few things to take into account:
324 | # If strafing directly into the ladder, go up, if strafing away, go down
325 | var ladder_climb_vel : float = climb_speed * -ladder_side_move.z
326 | # When pressing forward & facing the ladder, the player likely wants to move up. Vice versa with down.
327 | # So we will bias the direction (up/down) towards where we are looking by 45 degrees to give a greater margin for up/down detect.
328 | var up_wish := Vector3.UP.rotated(Vector3(1,0,0), deg_to_rad(-45)).dot(ladder_forward_move)
329 | ladder_climb_vel += climb_speed * up_wish
330 |
331 | # Only begin climbing ladders when moving towards them & prevent sticking to top of ladder when dismounting
332 | # Trying to best match the player's intention when climbing on ladder
333 | var should_dismount = false
334 | if not was_climbing_ladder:
335 | var mounting_from_top = pos_rel_to_ladder.y > _cur_ladder_climbing.get_node("TopOfLadder").position.y
336 | if mounting_from_top:
337 | # They could be trying to get on from the top of the ladder, or trying to leave the ladder.
338 | if ladder_climb_vel > 0: should_dismount = true
339 | else:
340 | # If not mounting from top, they are either falling or on floor.
341 | # In which case, only stick to ladder if intentionally moving towards
342 | if (ladder_gtransform.affine_inverse().basis * wish_dir).z >= 0: should_dismount = true
343 | # Only stick to ladder if very close. Helps make it easier to get off top & prevents camera jitter
344 | if abs(pos_rel_to_ladder.z) > 0.1: should_dismount = true
345 |
346 | # Let player step off onto floor
347 | if is_on_floor() and ladder_climb_vel <= 0: should_dismount = true
348 |
349 | if should_dismount:
350 | _cur_ladder_climbing = null
351 | return false
352 |
353 | # Allow jump off ladder mid climb
354 | if was_climbing_ladder and Input.is_action_just_pressed("jump"):
355 | self.velocity = _cur_ladder_climbing.global_transform.basis.z * jump_velocity * 1.5
356 | _cur_ladder_climbing = null
357 | return false
358 |
359 | self.velocity = ladder_gtransform.basis * Vector3(ladder_strafe_vel, ladder_climb_vel, 0)
360 | #self.velocity = self.velocity.limit_length(climb_speed) # Uncomment to turn off ladder boosting
361 |
362 | # Snap player onto ladder
363 | pos_rel_to_ladder.z = 0
364 | self.global_position = ladder_gtransform * pos_rel_to_ladder
365 |
366 | move_and_slide()
367 | return true
368 |
369 | # Returns true if player is in water, don't run normal air/ground physics in that case.
370 | func _handle_water_physics(delta) -> bool:
371 | if get_tree().get_nodes_in_group("water_area").all(func(area): return !area.overlaps_body(self)):
372 | return false
373 |
374 | if not is_on_floor():
375 | velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * 0.1 * delta
376 |
377 | self.velocity += cam_aligned_wish_dir * get_move_speed() * delta
378 |
379 | if Input.is_action_pressed("jump"):
380 | self.velocity.y += swim_up_speed * delta
381 |
382 | # Dampen velocity when in water
383 | self.velocity = self.velocity.lerp(Vector3.ZERO, 2 * delta)
384 |
385 | return true
386 |
387 | @onready var _original_capsule_height = $CollisionShape3D.shape.height
388 | func _handle_crouch(delta) -> void:
389 | var was_crouched_last_frame = is_crouched
390 | if Input.is_action_pressed("crouch"):
391 | is_crouched = true
392 | elif is_crouched and not self.test_move(self.global_transform, Vector3(0,CROUCH_TRANSLATE,0)):
393 | is_crouched = false
394 |
395 | # Allow for crouch to heighten/extend a jump
396 | var translate_y_if_possible := 0.0
397 | if was_crouched_last_frame != is_crouched and not is_on_floor() and not _snapped_to_stairs_last_frame:
398 | translate_y_if_possible = CROUCH_JUMP_ADD if is_crouched else -CROUCH_JUMP_ADD
399 | # Make sure not to get player stuck in floor/ceiling during crouch jumps
400 | if translate_y_if_possible != 0.0:
401 | var result = KinematicCollision3D.new()
402 | self.test_move(self.global_transform, Vector3(0,translate_y_if_possible,0), result)
403 | self.position.y += result.get_travel().y
404 | %Head.position.y -= result.get_travel().y
405 | %Head.position.y = clampf(%Head.position.y, -CROUCH_TRANSLATE, 0)
406 |
407 | %Head.position.y = move_toward(%Head.position.y, -CROUCH_TRANSLATE if is_crouched else 0.0, 7.0 * delta)
408 | $CollisionShape3D.shape.height = _original_capsule_height - CROUCH_TRANSLATE if is_crouched else _original_capsule_height
409 | $CollisionShape3D.position.y = $CollisionShape3D.shape.height / 2
410 | # Visual for tutorial
411 | #$WorldModel/MeshInstance3D.mesh.height = $CollisionShape3D.shape.height
412 | #$WorldModel/MeshInstance3D.position.y = $CollisionShape3D.position.y
413 | #$WorldModel/WigglyHair.position.y = $CollisionShape3D.shape.height - 0.302
414 | #$"WorldModel/disguise-glasses".position.y = $CollisionShape3D.shape.height - 0.9
415 |
416 | func _handle_noclip(delta) -> bool:
417 | if Input.is_action_just_pressed("_noclip") and OS.has_feature("debug"):
418 | noclip = !noclip
419 | noclip_speed_mult = 3.0
420 |
421 | $CollisionShape3D.disabled = noclip
422 |
423 | if not noclip:
424 | return false
425 |
426 | var speed = get_move_speed() * noclip_speed_mult
427 | if Input.is_action_pressed("sprint"):
428 | speed *= 3.0
429 |
430 | self.velocity = cam_aligned_wish_dir * speed#Vector3.ZERO # GMod style where you can fly w/ noclip
431 | global_position += self.velocity * delta
432 |
433 | return true
434 |
435 | func clip_velocity(normal: Vector3, overbounce : float, _delta : float) -> void:
436 | # When strafing into wall, + gravity, velocity will be pointing much in the opposite direction of the normal
437 | # So with this code, we will back up and off of the wall, cancelling out our strafe + gravity, allowing surf.
438 | var backoff := self.velocity.dot(normal) * overbounce
439 | # Not in original recipe. Maybe because of the ordering of the loop, in original source it
440 | # shouldn't be the case that velocity can be away away from plane while also colliding.
441 | # Without this, it's possible to get stuck in ceilings
442 | if backoff >= 0: return
443 |
444 | var change := normal * backoff
445 | self.velocity -= change
446 |
447 | # Second iteration to make sure not still moving through plane
448 | # Not sure why this is necessary but it was in the original recipe so keeping it.
449 | var adjust := self.velocity.dot(normal)
450 | if adjust < 0.0:
451 | self.velocity -= normal * adjust
452 |
453 | # Note to followers of my previous tutorials: This function has been simplified but does the same thing.
454 | func is_surface_too_steep(normal : Vector3) -> bool:
455 | return normal.angle_to(Vector3.UP) > self.floor_max_angle
456 |
457 | func _handle_air_physics(delta) -> void:
458 | self.velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta
459 |
460 | # Classic battle tested & fan favorite source/quake air movement recipe.
461 | # CSS players gonna feel their gamer instincts kick in with this one
462 | var cur_speed_in_wish_dir = self.velocity.dot(wish_dir)
463 | # Wish speed (if wish_dir > 0 length) capped to air_cap
464 | var capped_speed = min((air_move_speed * wish_dir).length(), air_cap)
465 | # How much to get to the speed the player wishes (in the new dir)
466 | # Notice this allows for infinite speed. If wish_dir is perpendicular, we always need to add velocity
467 | # no matter how fast we're going. This is what allows for things like bhop in CSS & Quake.
468 | # Also happens to just give some very nice feeling movement & responsiveness when in the air.
469 | var add_speed_till_cap = capped_speed - cur_speed_in_wish_dir
470 | if add_speed_till_cap > 0:
471 | var accel_speed = air_accel * air_move_speed * delta # Usually is adding this one.
472 | accel_speed = min(accel_speed, add_speed_till_cap) # Works ok without this but sticking to the recipe
473 | self.velocity += accel_speed * wish_dir
474 |
475 | if is_on_wall():
476 | # The floating mode is much better and less jittery for surf
477 | # This bit of code is tricky. Will toggle floating mode in air
478 | # is_on_floor() never triggers in floating mode, and instead is_on_wall() does.
479 | var wall_normal = get_wall_normal()
480 |
481 | # Ensure it's not a flat wall
482 | var is_wall_vertical = abs(wall_normal.dot(Vector3.UP)) < 0.1
483 |
484 | # Check if the surface is steep and NOT a flat wall
485 | if is_surface_too_steep(wall_normal) and not is_wall_vertical:
486 | self.motion_mode = CharacterBody3D.MOTION_MODE_FLOATING
487 | else:
488 | self.motion_mode = CharacterBody3D.MOTION_MODE_GROUNDED
489 |
490 | clip_velocity(wall_normal, 1, delta) # Allows surf
491 |
492 | func _handle_ground_physics(delta) -> void:
493 | # Similar to the air movement. Acceleration and friction on ground.
494 | var cur_speed_in_wish_dir = self.velocity.dot(wish_dir)
495 | var add_speed_till_cap = get_move_speed() - cur_speed_in_wish_dir
496 | if add_speed_till_cap > 0:
497 | var accel_speed = ground_accel * delta * get_move_speed()
498 | accel_speed = min(accel_speed, add_speed_till_cap)
499 | self.velocity += accel_speed * wish_dir
500 |
501 | # Apply friction
502 | var control = max(self.velocity.length(), ground_decel)
503 | var drop = control * ground_friction * delta
504 | var new_speed = max(self.velocity.length() - drop, 0.0)
505 | if self.velocity.length() > 0:
506 | new_speed /= self.velocity.length()
507 | self.velocity *= new_speed
508 |
509 | _headbob_effect(delta)
510 |
511 | func _physics_process(delta):
512 | if is_on_floor(): _last_frame_was_on_floor = Engine.get_physics_frames()
513 |
514 | _update_camera()
515 |
516 | var input_dir = Input.get_vector("left", "right", "up", "down").normalized()
517 | # Depending on which way you have you character facing, you may have to negate the input directions
518 | wish_dir = self.global_transform.basis * Vector3(input_dir.x, 0., input_dir.y)
519 | cam_aligned_wish_dir = get_active_camera().global_transform.basis * Vector3(input_dir.x, 0., input_dir.y)
520 | if camera_style == CameraStyle.THIRD_PERSON_FREE_LOOK:
521 | wish_dir = %ThirdPersonOrbitCamYaw.global_transform.basis * Vector3(input_dir.x, 0., input_dir.y)
522 |
523 | _handle_crouch(delta)
524 |
525 | if not _handle_noclip(delta) and not _handle_ladder_physics():
526 | if not _handle_water_physics(delta):
527 | if is_on_floor() or _snapped_to_stairs_last_frame:
528 | if Input.is_action_just_pressed("jump") or (auto_bhop and Input.is_action_pressed("jump")):
529 | self.velocity.y = jump_velocity
530 | _handle_ground_physics(delta)
531 | else:
532 | _handle_air_physics(delta)
533 |
534 | if not _snap_up_stairs_check(delta):
535 | # Because _snap_up_stairs_check moves the body manually, don't call move_and_slide
536 | # This should be fine since we ensure with the body_test_motion that it doesn't
537 | # collide with anything except the stairs it's moving up to.
538 | _push_away_rigid_bodies() # Call before move_and_slide()
539 | move_and_slide()
540 | _snap_down_to_stairs_check()
541 |
542 | _slide_camera_smooth_back_to_origin(delta)
543 |
--------------------------------------------------------------------------------
/addons/SimpleTerrain/FoliageBrushDecal.gd:
--------------------------------------------------------------------------------
1 | @tool
2 | class_name FoliageBrushDecal
3 | extends Decal
4 |
5 | const UTILS = preload("res://addons/SimpleTerrain/SimpleTerrainUtils.gd")
6 |
7 | var follow_mouse := true
8 | var painting := false
9 | var undo_redo : EditorUndoRedoManager
10 | var last_paint_position : Vector3 = Vector3.ZERO
11 | var brush_mode : UTILS.FoliageBrushMode = UTILS.FoliageBrushMode.ADD
12 |
13 | # New variables for grid-based painting
14 | var current_seed: int = 0
15 | var stroke_start_pos_xz: Vector2 = Vector2.ZERO
16 | var already_painted_dict: Dictionary = {}
17 |
18 | # State tracking for undo/redo
19 | var _current_pre_removal_transforms: Array[Transform3D] = []
20 | var _current_post_removal_transforms: Array[Transform3D] = []
21 | var _removal_occurred := false
22 | var _stroke_started := false # Track if we've started a new stroke
23 |
24 | # Renamed from opacity
25 | @export_range(0.01, 1.0) var density := 1 :
26 | set(value):
27 | if density != value:
28 | density = value
29 | _update_textures()
30 | @export_range(1,4096) var brush_size : int = 64 :
31 | set(value):
32 | if brush_size != value:
33 | brush_size = value
34 | _update_textures()
35 | # Renamed from hardness
36 | @export_range(0.0, 1.0) var randomness := 0.25 :
37 | set(value):
38 | if randomness != value:
39 | randomness = value
40 | _update_textures()
41 |
42 | # Foliage painting specific settings
43 | @export var random_rotation := true
44 | @export var random_scale := true
45 | @export_range(0.5, 1.5) var random_scale_min := 0.8
46 | @export_range(0.5, 2.0) var random_scale_max := 1.2
47 |
48 | var paint_texture := GradientTexture2D.new()
49 | const PAINT_RATE_MS = 1000/20 # Slower rate than terrain painting
50 | var _last_paint_time = Time.get_ticks_msec()
51 | var _initial_instance_count := 0
52 | var _added_transforms: Array[Transform3D] = []
53 | var _was_painting_last_frame := false # Track painting state
54 | var _created_multimesh_this_stroke := false # Track if MM was created by this action
55 |
56 | # Get the terrain and foliage nodes
57 | func get_terrain_node() -> SimpleTerrain:
58 | var current = self
59 | while current:
60 | current = current.get_parent()
61 | if current is SimpleTerrain:
62 | return current
63 | if current and current.get_parent():
64 | for node in current.get_parent().get_children():
65 | if node is SimpleTerrain:
66 | return node
67 | return null
68 |
69 | func get_foliage_node() -> SimpleTerrainFoliage:
70 | var current = self
71 | while current:
72 | current = current.get_parent()
73 | if current is SimpleTerrainFoliage:
74 | return current
75 | return null
76 |
77 | static func update_gradient_texture(gradient_tex : GradientTexture2D, size : int, density : float, randomness : float, marker : bool):
78 | gradient_tex.width = size
79 | gradient_tex.height = size
80 | gradient_tex.fill_from = Vector2(0.5, 0.5)
81 | gradient_tex.fill_to = Vector2(1.0, 0.5)
82 | gradient_tex.fill = GradientTexture2D.FILL_RADIAL
83 | if gradient_tex.gradient == null:
84 | gradient_tex.gradient = Gradient.new()
85 | gradient_tex.gradient.interpolation_color_space = Gradient.GRADIENT_COLOR_SPACE_LINEAR_SRGB
86 | gradient_tex.gradient.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_CUBIC
87 | # Clear existing points except the last one before adding new ones
88 | while gradient_tex.gradient.get_point_count() > 1:
89 | gradient_tex.gradient.remove_point(0)
90 |
91 | if marker:
92 | # Visual effect: randomness controls blur/spread
93 | var diff = lerp(0.1, 0.4, randomness) # Higher randomness -> larger diff -> more blur
94 | # --- Use fixed alpha values for consistent marker appearance ---
95 | gradient_tex.gradient.set_color(0, Color(1, 1, 1, 0.05)) # Fixed low alpha for inner part
96 | gradient_tex.gradient.set_offset(0, 0.95 - diff)
97 | gradient_tex.gradient.add_point(0.95 - diff/2, Color(1, 1, 1, 0.5)) # Fixed higher alpha for edge
98 | gradient_tex.gradient.add_point(1.0, Color.TRANSPARENT)
99 | else:
100 | # For the actual paint texture (if needed), use density and randomness
101 | gradient_tex.gradient.set_color(0, Color(1, 1, 1, density))
102 | gradient_tex.gradient.set_offset(0, 0.0)
103 | gradient_tex.gradient.add_point((1.0 - randomness) * 0.9999, Color(1, 1, 1, density))
104 | gradient_tex.gradient.add_point(1.0, Color.TRANSPARENT)
105 |
106 | func _scale_decal_to_texture_size():
107 | var terrain = get_terrain_node()
108 | if not terrain: return
109 |
110 | # Scale decal visually based on brush_size relative to terrain
111 | # Assume terrain dimensions are available (replace with actual properties if needed)
112 | var terrain_size = terrain.get_total_size_without_height() # Example getter
113 | if terrain_size.x <= 0 or terrain_size.y <= 0:
114 | print("Warning: Invalid terrain size for decal scaling.")
115 | return
116 |
117 | # Calculate decal size based on brush_size proportion to terrain
118 | # This assumes brush_size corresponds to pixels on a default texture size (e.g., 1024)
119 | var default_texture_size = 1024.0
120 | var relative_size = float(brush_size) / default_texture_size
121 | var diameter = relative_size * terrain_size.x # Use x dimension for consistency
122 |
123 | self.size = Vector3(diameter, 50, diameter) # Use fixed height
124 | #print("Scaled decal based on brush_size: ", brush_size, " -> size: ", self.size)
125 |
126 | func _update_textures():
127 | if texture_albedo == null:
128 | texture_albedo = GradientTexture2D.new()
129 | update_gradient_texture(texture_albedo, 256, density, randomness, true)
130 | update_gradient_texture(paint_texture, brush_size, density, randomness, false)
131 | _scale_decal_to_texture_size() # Restore call: update decal when brush_size changes
132 |
133 | func _raycast_and_snap_to_terrain(camera: Camera3D, screen_pos: Vector2) -> void:
134 | if not follow_mouse:
135 | self.visible = false
136 | return
137 |
138 | # Need to do an extra conversion in case the editor viewport is in half-resolution mode
139 | var viewport := camera.get_viewport()
140 | var viewport_container : Control = viewport.get_parent()
141 | var screen_pos_adjusted = screen_pos * Vector2(viewport.size) / viewport_container.size
142 |
143 | var origin = camera.project_ray_origin(screen_pos_adjusted)
144 | var dir = camera.project_ray_normal(screen_pos_adjusted)
145 |
146 | # Get the terrain node
147 | var terrain = get_terrain_node()
148 | if terrain:
149 | var hit_pos = null
150 | var physics_hit = false
151 |
152 | # --- Try physics raycast first if collision body exists ---
153 | var collision_body = terrain.get_collision_body() # Assuming this function exists
154 | if collision_body and collision_body is CollisionObject3D:
155 | #print("Collision body found", collision_body) # Keep for debugging if needed
156 | var space_state = get_world_3d().direct_space_state
157 | var exclude : Array[RID] = []
158 | const MAX_ITERATIONS = 10
159 |
160 | for i in range(MAX_ITERATIONS):
161 | var query_params = PhysicsRayQueryParameters3D.create(origin, origin + dir * camera.far * 1.2, collision_body.collision_mask)
162 | query_params.collide_with_bodies = true
163 | query_params.collide_with_areas = false
164 | query_params.exclude = exclude # Apply exclusion list
165 |
166 | var physics_result = space_state.intersect_ray(query_params)
167 |
168 | if not physics_result:
169 | # Ray hit nothing further along the path
170 | #print("Physics raycast stopped: Hit nothing further.")
171 | break
172 |
173 | if physics_result.collider == collision_body:
174 | # Hit the terrain!
175 | #print("Physics hit terrain on iteration ", i, physics_result)
176 | hit_pos = physics_result.position
177 | physics_hit = true
178 | break # Found the terrain, exit loop
179 | else:
180 | # Hit something else, add it to exclude list and try again
181 | #print("Physics hit other object on iteration ", i, ": ", physics_result.collider, " Adding RID: ", physics_result.rid)
182 | exclude.append(physics_result.rid)
183 | # Continue to next iteration
184 |
185 | # --- End Physics Raycast ---
186 |
187 | # --- Fallback to pixel raycast if physics failed or no body ---
188 | if not physics_hit:
189 | #print("Physics raycast failed to hit terrain, falling back to pixel raycast.") # Keep for debugging
190 | var result = terrain.raycast_terrain_by_px(origin, dir * camera.far * 1.2, false)
191 | if result and result.size() > 0 and result[0] != null: # Ensure result exists and is valid
192 | hit_pos = result[0]
193 | # --- End Fallback Raycast ---
194 |
195 | if hit_pos != null:
196 | global_position = hit_pos
197 | self.visible = true
198 | else:
199 | self.visible = false
200 | else:
201 | self.visible = false
202 |
203 | func _get_pos_on_texture(texture : Texture2D, world_pos: Vector3) -> Vector2i:
204 | if not is_inside_tree() or not texture: return Vector2i(0,0)
205 | var terrain = get_terrain_node()
206 | if not terrain: return Vector2i(0,0)
207 | var norm_pos := terrain.get_global_pos_normalized_to_terrain(world_pos)
208 | var to_px := Vector2(norm_pos.x, norm_pos.z) * Vector2(texture.get_image().get_size())
209 | return Vector2i(round(to_px.x),round(to_px.y))
210 |
211 | func _before_paint_foliage():
212 | if not Engine.is_editor_hint() or not is_inside_tree():
213 | return
214 |
215 | var foliage = get_foliage_node()
216 | # Check if foliage node exists at all
217 | if not foliage:
218 | printerr("FoliageBrushDecal: Cannot find SimpleTerrainFoliage parent node.")
219 | return
220 |
221 | # Check if foliage mesh is assigned (needed for creation)
222 | if not foliage.foliage_mesh:
223 | printerr("FoliageBrushDecal: Foliage node has no foliage_mesh assigned. Cannot paint.")
224 | return # Cannot paint or create multimesh without a mesh
225 |
226 | # Reset stroke creation flag
227 | _created_multimesh_this_stroke = false
228 | var new_multimesh = null # Variable to hold newly created multimesh for undo/redo
229 |
230 | # --- Create MultiMesh if it doesn't exist ---
231 | if not foliage.multimesh:
232 | print("FoliageBrushDecal: Foliage node multimesh is null. Creating new one.")
233 | new_multimesh = MultiMesh.new()
234 | new_multimesh.transform_format = MultiMesh.TRANSFORM_3D
235 | new_multimesh.instance_count = 100 # Sensible default initial size
236 | new_multimesh.visible_instance_count = 0
237 | new_multimesh.mesh = foliage.foliage_mesh # Assign the mesh
238 |
239 | # IMPORTANT: Directly assign the resource to the node property
240 | foliage.multimesh = new_multimesh
241 | _created_multimesh_this_stroke = true
242 | _initial_instance_count = 0 # If we just created it, initial count is 0
243 | # --- End MultiMesh Creation ---
244 | else:
245 | # If multimesh already exists, get its current visible count
246 | _initial_instance_count = foliage.multimesh.visible_instance_count
247 |
248 | # Initialize stroke variables
249 | var rng = RandomNumberGenerator.new()
250 | rng.randomize()
251 | current_seed = rng.randi()
252 | stroke_start_pos_xz = Vector2(global_position.x, global_position.z)
253 | already_painted_dict = {}
254 | _added_transforms.clear()
255 |
256 | # For REMOVE mode, capture initial state only at stroke start
257 | if brush_mode == UTILS.FoliageBrushMode.REMOVE and not _stroke_started:
258 | _stroke_started = true
259 | _removal_occurred = false
260 | if foliage.multimesh:
261 | _current_pre_removal_transforms.clear()
262 | for i in range(foliage.multimesh.visible_instance_count):
263 | _current_pre_removal_transforms.append(foliage.multimesh.get_instance_transform(i))
264 |
265 | # --- Helper: Calculate Grid Step based on brush radius and desired density --- #
266 | func _get_grid_step(radius: float, desired_points: int) -> float:
267 | if radius <= 0.0 or density <= 0.0:
268 | return 10.0 # Return default step if inputs invalid
269 |
270 | var area = PI * radius * radius
271 | var grid_step_sq = area / desired_points
272 | return maxf(0.1, sqrt(grid_step_sq)) # Ensure step is not zero
273 |
274 | func _paint_foliage():
275 | if not Engine.is_editor_hint() or not is_inside_tree():
276 | return
277 |
278 | var foliage = get_foliage_node()
279 | if not foliage or not foliage.foliage_mesh:
280 | print("Paint Foliage: No foliage node or mesh")
281 | return
282 |
283 | var terrain = get_terrain_node()
284 | if not terrain or not terrain.heightmap_texture:
285 | print("Paint Foliage: No terrain node or heightmap")
286 | return
287 |
288 | # Define sampling parameters
289 | var current_pos_xz = Vector2(global_position.x, global_position.z)
290 | var radius = self.size.x * 0.5
291 | if radius <= 0: return # Avoid issues if decal size is invalid
292 |
293 | # --- Handle different brush modes ---
294 | if brush_mode == UTILS.FoliageBrushMode.ADD:
295 | _add_foliage_in_radius(foliage, terrain, current_pos_xz, radius)
296 | elif brush_mode == UTILS.FoliageBrushMode.REMOVE:
297 | _remove_foliage_in_radius(foliage, current_pos_xz, radius)
298 | elif brush_mode == UTILS.FoliageBrushMode.ADD_STACKED:
299 | _add_stacked_foliage_in_radius(foliage, terrain, current_pos_xz, radius)
300 | # else: # Handle other modes like Smooth, Flatten later if needed
301 | # pass
302 |
303 | # --- Renamed and refactored from the original _paint_foliage ADD logic ---
304 | func _add_foliage_in_radius(foliage: SimpleTerrainFoliage, terrain: SimpleTerrain, current_pos_xz: Vector2, radius: float):
305 | # --- Calculate effective radius from decal size ---
306 | # radius is already passed in
307 | # --- End radius calculation ---
308 | var area_start_pos = current_pos_xz - Vector2(radius, radius)
309 | var area_size = Vector2(radius, radius) * 2.0
310 |
311 | # Calculate grid step based on density and calculated radius
312 | var grid_step = _get_grid_step(radius, density) # Density likely needs adjustment for desired points *within* the circle
313 |
314 | # Sample points in the area around the brush
315 | var sampled_points = _sample_field_in_area(
316 | area_start_pos,
317 | area_size,
318 | randomness, # Use the exported randomness directly
319 | grid_step,
320 | current_seed,
321 | stroke_start_pos_xz # Use the stroke start for grid alignment
322 | )
323 |
324 | # --- Create list to store transforms for this frame's bulk add ---
325 | var new_transforms_this_frame: Array[Transform3D] = []
326 |
327 | # Process sampled points
328 | for point_xz in sampled_points:
329 | # Determine grid cell for uniqueness check
330 | var grid_key = Vector2i(floor(point_xz.x / grid_step), floor(point_xz.y / grid_step))
331 |
332 | # Check if this grid cell has already been painted in this stroke
333 | if already_painted_dict.has(grid_key):
334 | continue
335 |
336 | # Check if the point is within the circular brush radius
337 | if current_pos_xz.distance_to(point_xz) > radius: # Use calculated radius
338 | continue
339 |
340 | # Calculate world position and get height
341 | var instance_position = Vector3(point_xz.x, 0, point_xz.y)
342 | # Use terrain reference passed into the function
343 | #var px = _get_pos_on_texture(terrain.heightmap_texture, instance_position)
344 | #var height = terrain.get_terrain_pixel(terrain.heightmap_texture, px.x, px.y).r * terrain.terrain_height_scale
345 | # Get height and add the offset
346 | var terrain_local_y = terrain.get_real_local_height_at_pos(foliage.global_transform * instance_position, terrain.highest_lod_resolution)
347 | instance_position.y = terrain_local_y + foliage.height_offset
348 |
349 | # --- Create Transform (copied from add_instance_at_position logic) ---
350 | var transform = Transform3D.IDENTITY
351 | if random_rotation:
352 | transform = transform.rotated(Vector3.UP, randf() * TAU)
353 |
354 | # Apply scale (Using foliage node's instance_scale)
355 | var instance_scale = foliage.instance_scale
356 | if random_scale:
357 | instance_scale *= randf_range(random_scale_min, random_scale_max)
358 | transform = transform.scaled(Vector3(instance_scale, instance_scale, instance_scale))
359 |
360 | # Apply translation (in foliage node's local space)
361 | transform.origin = foliage.global_transform.affine_inverse() * instance_position
362 | # --- End Transform Creation ---
363 |
364 | # Add the calculated transform to the list for bulk addition
365 | new_transforms_this_frame.append(transform)
366 |
367 | # Mark this grid cell as painted for this stroke (still needed)
368 | already_painted_dict[grid_key] = true
369 |
370 | # --- Perform bulk addition AFTER processing all points ---
371 | if not new_transforms_this_frame.is_empty():
372 | foliage.add_instances_bulk(new_transforms_this_frame)
373 | # Append the newly added transforms to the total list for the stroke (for undo/redo)
374 | _added_transforms.append_array(new_transforms_this_frame)
375 |
376 | # --- New function for stacked foliage addition ---
377 | func _add_stacked_foliage_in_radius(foliage: SimpleTerrainFoliage, terrain: SimpleTerrain, current_pos_xz: Vector2, radius: float):
378 | # radius is already passed in
379 | var area_start_pos = current_pos_xz - Vector2(radius, radius)
380 | var area_size = Vector2(radius, radius) * 2.0
381 |
382 | # Calculate grid step based on density and calculated radius
383 | var grid_step = _get_grid_step(radius, density) # Density likely needs adjustment for desired points *within* the circle
384 |
385 | # Sample points in the area around the brush
386 | # Use current_pos_xz for grid_center_pos to allow stacking
387 | var sampled_points = _sample_field_in_area(
388 | area_start_pos,
389 | area_size,
390 | randomness,
391 | grid_step,
392 | current_seed,
393 | current_pos_xz # Use current position for grid alignment
394 | )
395 |
396 | var new_transforms_this_frame: Array[Transform3D] = []
397 |
398 | # Process sampled points
399 | for point_xz in sampled_points:
400 | # DO NOT check already_painted_dict - this allows stacking
401 |
402 | # Check if the point is within the circular brush radius
403 | if current_pos_xz.distance_to(point_xz) > radius:
404 | continue
405 |
406 | # Calculate world position and get height
407 | var instance_position = Vector3(point_xz.x, 0, point_xz.y)
408 | # Use get_real_local_height_at_pos for consistency and add offset
409 | var terrain_local_y = terrain.get_real_local_height_at_pos(foliage.global_transform * instance_position, terrain.highest_lod_resolution)
410 | instance_position.y = terrain_local_y + foliage.height_offset
411 |
412 | var transform = Transform3D.IDENTITY
413 | if random_rotation:
414 | transform = transform.rotated(Vector3.UP, randf() * TAU)
415 |
416 | var instance_scale = foliage.instance_scale
417 | if random_scale:
418 | instance_scale *= randf_range(random_scale_min, random_scale_max)
419 | transform = transform.scaled(Vector3(instance_scale, instance_scale, instance_scale))
420 |
421 | transform.origin = foliage.global_transform.affine_inverse() * instance_position
422 |
423 | new_transforms_this_frame.append(transform)
424 |
425 | # Perform bulk addition AFTER processing all points
426 | if not new_transforms_this_frame.is_empty():
427 | foliage.add_instances_bulk(new_transforms_this_frame)
428 | # Append the newly added transforms to the total list for the stroke (for undo/redo)
429 | _added_transforms.append_array(new_transforms_this_frame)
430 |
431 | # --- New function to handle foliage removal ---
432 | func _remove_foliage_in_radius(foliage: SimpleTerrainFoliage, center_xz: Vector2, radius: float):
433 | if not foliage or not foliage.multimesh:
434 | return # Nothing to remove from
435 |
436 | var multimesh = foliage.multimesh
437 | var current_visible_count = multimesh.visible_instance_count
438 | if current_visible_count == 0:
439 | return # Nothing to remove
440 |
441 | var kept_transforms: Array[Transform3D] = []
442 | var foliage_global_xform = foliage.global_transform
443 | var radius_sq = radius * radius # Use squared distance for efficiency
444 |
445 | for i in range(current_visible_count):
446 | var instance_transform: Transform3D = multimesh.get_instance_transform(i)
447 | # Instance transform origin is in foliage node's local space. Convert to world.
448 | var world_pos: Vector3 = foliage_global_xform * instance_transform.origin
449 | var instance_pos_xz = Vector2(world_pos.x, world_pos.z)
450 |
451 | # Check distance squared against radius squared
452 | if center_xz.distance_squared_to(instance_pos_xz) > radius_sq:
453 | # Keep this instance
454 | kept_transforms.append(instance_transform)
455 |
456 | # Update MultiMesh only if instances were actually removed
457 | if kept_transforms.size() < current_visible_count:
458 | var new_count = kept_transforms.size()
459 |
460 | # Make sure the buffer size is large enough
461 | if multimesh.instance_count < new_count:
462 | printerr("Warning: MultiMesh instance_count might be too small after removal.")
463 | new_count = min(new_count, multimesh.instance_count)
464 | kept_transforms.resize(new_count)
465 |
466 | # Store the current state for redo
467 | _current_post_removal_transforms = kept_transforms.duplicate()
468 |
469 | # Apply the changes
470 | multimesh.visible_instance_count = new_count
471 | for i in range(new_count):
472 | multimesh.set_instance_transform(i, kept_transforms[i])
473 |
474 | _removal_occurred = true
475 |
476 | # Helper function to apply transforms to the multimesh
477 | func _apply_transforms_to_multimesh(foliage: SimpleTerrainFoliage, transforms: Array[Transform3D]):
478 | if not foliage or not foliage.multimesh:
479 | return
480 |
481 | var multimesh = foliage.multimesh
482 | var new_count = transforms.size()
483 |
484 | # Ensure multimesh can hold all transforms
485 | if multimesh.instance_count < new_count:
486 | var expanded_count = max(new_count, multimesh.instance_count * 2)
487 | multimesh.instance_count = expanded_count
488 |
489 | # Apply all transforms
490 | multimesh.visible_instance_count = new_count
491 | for i in range(new_count):
492 | multimesh.set_instance_transform(i, transforms[i])
493 |
494 | # Helper function for the redo operation of ADD mode
495 | func _redo_paint_foliage(foliage_node: SimpleTerrainFoliage, transforms_to_add: Array[Transform3D]):
496 | if not foliage_node:
497 | print("Redo failed: Foliage node is null")
498 | return
499 |
500 | if transforms_to_add.is_empty():
501 | print("Redo: No transforms to add")
502 | return
503 |
504 | # Use the bulk add function for redo as well
505 | print("Redo: Bulk adding ", transforms_to_add.size(), " transforms")
506 | foliage_node.add_instances_bulk(transforms_to_add)
507 |
508 | # Helper functions for REMOVE mode undo/redo
509 | func _undo_remove_foliage(foliage: SimpleTerrainFoliage, pre_transforms: Array):
510 | _apply_transforms_to_multimesh(foliage, pre_transforms)
511 |
512 | func _redo_remove_foliage(foliage: SimpleTerrainFoliage, post_transforms: Array):
513 | _apply_transforms_to_multimesh(foliage, post_transforms)
514 |
515 | func _after_paint_foliage():
516 | if not Engine.is_editor_hint() or not is_inside_tree():
517 | return
518 |
519 | var foliage = get_foliage_node()
520 | if not foliage:
521 | _added_transforms.clear()
522 | _created_multimesh_this_stroke = false
523 | _removal_occurred = false
524 | _stroke_started = false
525 | return
526 |
527 | # Handle undo/redo registration based on brush mode
528 | if brush_mode == UTILS.FoliageBrushMode.ADD or brush_mode == UTILS.FoliageBrushMode.ADD_STACKED:
529 | # Skip if nothing was added AND we didn't create the multimesh
530 | if _added_transforms.size() == 0 and not _created_multimesh_this_stroke:
531 | return
532 |
533 | # Skip undo registration if undo_redo is not set
534 | if not undo_redo:
535 | print("Warning: undo_redo is null, cannot create undo action")
536 | _added_transforms.clear()
537 | _created_multimesh_this_stroke = false
538 | return
539 |
540 | var transforms_for_action = _added_transforms.duplicate()
541 | var was_created_for_action = _created_multimesh_this_stroke
542 | var multimesh_resource_for_action = foliage.multimesh
543 |
544 | undo_redo.create_action("Paint foliage")
545 |
546 | if was_created_for_action:
547 | undo_redo.add_do_property(foliage, "multimesh", multimesh_resource_for_action)
548 | undo_redo.add_undo_property(foliage, "multimesh", null)
549 |
550 | if transforms_for_action.size() > 0:
551 | undo_redo.add_do_method(self, "_redo_paint_foliage", foliage, transforms_for_action)
552 | undo_redo.add_undo_method(foliage, "set_visible_instance_count", _initial_instance_count)
553 |
554 | undo_redo.commit_action(false)
555 |
556 | # Reset tracking variables for ADD mode
557 | _added_transforms.clear()
558 | _created_multimesh_this_stroke = false
559 |
560 | elif brush_mode == UTILS.FoliageBrushMode.REMOVE and _removal_occurred:
561 | # Skip undo registration if undo_redo is not set
562 | if not undo_redo:
563 | print("Warning: undo_redo is null, cannot create undo action for removal")
564 | _removal_occurred = false
565 | _stroke_started = false
566 | return
567 |
568 | # Make copies of the current transforms for this undo action
569 | var pre_transforms = _current_pre_removal_transforms.duplicate()
570 | var post_transforms = _current_post_removal_transforms.duplicate()
571 |
572 | undo_redo.create_action("Remove foliage")
573 |
574 | # Register the undo/redo methods with the transform arrays
575 | undo_redo.add_do_method(self, "_redo_remove_foliage", foliage, post_transforms)
576 | undo_redo.add_undo_method(self, "_undo_remove_foliage", foliage, pre_transforms)
577 |
578 | undo_redo.commit_action(false)
579 |
580 | # Reset removal tracking
581 | _removal_occurred = false
582 | _stroke_started = false
583 | _current_pre_removal_transforms.clear()
584 | _current_post_removal_transforms.clear()
585 |
586 | func _process(delta):
587 | if not Engine.is_editor_hint():
588 | return
589 |
590 | # Handle painting logic based on state changes
591 | if painting:
592 | # If we just started painting this frame
593 | if not _was_painting_last_frame:
594 | _before_paint_foliage() # Setup for the new stroke
595 | _was_painting_last_frame = true
596 |
597 | # Continue painting/removing if enough time has passed
598 | if Time.get_ticks_msec() - _last_paint_time > PAINT_RATE_MS:
599 | _paint_foliage() # Perform add or remove based on mode
600 | _last_paint_time = Time.get_ticks_msec()
601 | else:
602 | # If we just stopped painting this frame
603 | if _was_painting_last_frame:
604 | # Finalize the stroke and create undo action
605 | _after_paint_foliage()
606 | _was_painting_last_frame = false
607 |
608 | func _enter_tree():
609 | if not Engine.is_editor_hint(): return
610 | _update_textures()
611 |
612 | func _ready():
613 | if not Engine.is_editor_hint():
614 | queue_free()
615 | return
616 | self.visible = false # Start hidden
617 |
618 | # Uses a grid with random offsets aligned relative to grid_center_pos
619 | func _sample_field_in_area(start_pos: Vector2, area_size: Vector2, randomness: float, grid_step: float, seed: int, grid_center_pos: Vector2) -> Array[Vector2]:
620 | var results: Array[Vector2] = []
621 | var end_pos = start_pos + area_size
622 |
623 | # Determine grid boundaries covering the specified area, considering max random offset
624 | # Note: JS used randomness * gridStep, which is half the range.
625 | # randf_range gives full range, so searchExpansion needs only half gridStep * randomness.
626 | var search_expansion = grid_step * randomness # Max potential offset distance in one direction
627 | var min_check_pos = start_pos - Vector2(search_expansion, search_expansion)
628 | var max_check_pos = end_pos + Vector2(search_expansion, search_expansion)
629 |
630 | # Calculate grid indices needed to cover the check area
631 | var min_grid_x_index = floori(min_check_pos.x / grid_step)
632 | var max_grid_x_index = ceili(max_check_pos.x / grid_step)
633 | var min_grid_y_index = floori(min_check_pos.y / grid_step)
634 | var max_grid_y_index = ceili(max_check_pos.y / grid_step)
635 |
636 | # Index of the cell containing grid_center_pos (for relative calculation)
637 | var start_grid_i = floori(grid_center_pos.x / grid_step)
638 | var start_grid_j = floori(grid_center_pos.y / grid_step)
639 | # Offset of grid_center_pos within its cell (for alignment)
640 | var align_offset_x = grid_center_pos.x - start_grid_i * grid_step
641 | var align_offset_y = grid_center_pos.y - start_grid_j * grid_step
642 |
643 | var rng = RandomNumberGenerator.new()
644 |
645 | for i in range(min_grid_x_index, max_grid_x_index + 1):
646 | for j in range(min_grid_y_index, max_grid_y_index + 1):
647 | var relative_i = i - start_grid_i
648 | var relative_j = j - start_grid_j
649 | # Calculate cell seed using XOR and prime multiplication (ensure 32-bit ops)
650 | var cell_seed = int(seed ^ (relative_i * 16777619) ^ (relative_j * 37))
651 | # Use the cell-specific seed for the RNG
652 | rng.seed = cell_seed
653 |
654 | var base_x = i * grid_step + align_offset_x
655 | var base_y = j * grid_step + align_offset_y
656 |
657 | # Generate random offset within [-grid_step * randomness, grid_step * randomness]
658 | var offset_x = rng.randf_range(-1.0, 1.0) * grid_step * randomness
659 | var offset_y = rng.randf_range(-1.0, 1.0) * grid_step * randomness
660 |
661 | var final_pos = Vector2(base_x + offset_x, base_y + offset_y)
662 |
663 | # Only add points actually within the specified rendering bounds [start_pos, end_pos)
664 | if final_pos.x >= start_pos.x and final_pos.x < end_pos.x and \
665 | final_pos.y >= start_pos.y and final_pos.y < end_pos.y:
666 | results.append(final_pos)
667 |
668 | return results
669 |
--------------------------------------------------------------------------------