├── .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 | [![Patreon](https://img.shields.io/badge/Patreon-Support%20Me-orange)](https://www.patreon.com/MajikayoGames) 38 | [![Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20Me%20a%20Coffee-blue)](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 | 16 | 36 | 38 | 42 | 48 | 53 | 54 | 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 | --------------------------------------------------------------------------------