new terrain plugin

This commit is contained in:
Nikolai Fesenko
2025-08-13 21:08:35 +02:00
parent 43fb8410c3
commit 85d50d3bd4
221 changed files with 11867 additions and 1 deletions

29
demo/src/CameraManager.gd Normal file
View File

@@ -0,0 +1,29 @@
extends Node3D
const CAMERA_MAX_PITCH: float = deg_to_rad(70)
const CAMERA_MIN_PITCH: float = deg_to_rad(-89.9)
const CAMERA_RATIO: float = .625
@export var mouse_sensitivity: float = .002
@export var mouse_y_inversion: float = -1.0
@onready var _camera_yaw: Node3D = self
@onready var _camera_pitch: Node3D = %Arm
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _input(p_event: InputEvent) -> void:
if p_event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_camera(p_event.relative)
get_viewport().set_input_as_handled()
return
func rotate_camera(p_relative:Vector2) -> void:
_camera_yaw.rotation.y -= p_relative.x * mouse_sensitivity
_camera_yaw.orthonormalize()
_camera_pitch.rotation.x += p_relative.y * mouse_sensitivity * CAMERA_RATIO * mouse_y_inversion
_camera_pitch.rotation.x = clamp(_camera_pitch.rotation.x, CAMERA_MIN_PITCH, CAMERA_MAX_PITCH)

View File

@@ -0,0 +1 @@
uid://b62ppvc03a6b1

22
demo/src/CaveEntrance.gd Normal file
View File

@@ -0,0 +1,22 @@
extends Area3D
func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node3D) -> void:
if body.name == "Player":
var env: WorldEnvironment = get_node_or_null("../../Environment/WorldEnvironment")
if env:
var tween: Tween = get_tree().create_tween()
tween.tween_property(env.environment, "ambient_light_energy", .1, .33)
func _on_body_exited(body: Node3D) -> void:
if body.name == "Player":
var env: WorldEnvironment = get_node_or_null("../../Environment/WorldEnvironment")
if env:
var tween: Tween = get_tree().create_tween()
tween.tween_property(env.environment, "ambient_light_energy", 1., .33)

View File

@@ -0,0 +1 @@
uid://c444j1ucmv5ti

133
demo/src/CodeGenerated.gd Normal file
View File

@@ -0,0 +1,133 @@
extends Node
var terrain: Terrain3D
func _ready() -> void:
$UI.player = $Player
if has_node("RunThisSceneLabel3D"):
$RunThisSceneLabel3D.queue_free()
terrain = await create_terrain()
# Enable runtime navigation baking using the terrain
# Enable `Debug/Visible Navigation` if you wish to see it
$RuntimeNavigationBaker.terrain = terrain
$RuntimeNavigationBaker.enabled = true
func create_terrain() -> Terrain3D:
# Create textures
var green_gr := Gradient.new()
green_gr.set_color(0, Color.from_hsv(100./360., .35, .3))
green_gr.set_color(1, Color.from_hsv(120./360., .4, .37))
var green_ta: Terrain3DTextureAsset = await create_texture_asset("Grass", green_gr, 1024)
green_ta.uv_scale = 0.1
green_ta.detiling_rotation = 0.1
var brown_gr := Gradient.new()
brown_gr.set_color(0, Color.from_hsv(30./360., .4, .3))
brown_gr.set_color(1, Color.from_hsv(30./360., .4, .4))
var brown_ta: Terrain3DTextureAsset = await create_texture_asset("Dirt", brown_gr, 1024)
brown_ta.uv_scale = 0.03
green_ta.detiling_rotation = 0.1
var grass_ma: Terrain3DMeshAsset = create_mesh_asset("Grass", Color.from_hsv(120./360., .4, .37))
# Create a terrain
var terrain := Terrain3D.new()
terrain.name = "Terrain3D"
add_child(terrain, true)
# Set material and assets
terrain.material.world_background = Terrain3DMaterial.NONE
terrain.material.auto_shader = true
terrain.material.set_shader_param("auto_slope", 10)
terrain.material.set_shader_param("blend_sharpness", .975)
terrain.assets = Terrain3DAssets.new()
terrain.assets.set_texture(0, green_ta)
terrain.assets.set_texture(1, brown_ta)
terrain.assets.set_mesh_asset(0, grass_ma)
# Generate height map w/ 32-bit noise and import it with scale
var noise := FastNoiseLite.new()
noise.frequency = 0.0005
var img: Image = Image.create_empty(2048, 2048, false, Image.FORMAT_RF)
for x in img.get_width():
for y in img.get_height():
img.set_pixel(x, y, Color(noise.get_noise_2d(x, y), 0., 0., 1.))
terrain.region_size = 1024
terrain.data.import_images([img, null, null], Vector3(-1024, 0, -1024), 0.0, 150.0)
# Instance foliage
var xforms: Array[Transform3D]
var width: int = 100
var step: int = 2
for x in range(0, width, step):
for z in range(0, width, step):
var pos := Vector3(x, 0, z) - Vector3(width, 0, width) * .5
pos.y = terrain.data.get_height(pos)
xforms.push_back(Transform3D(Basis(), pos))
terrain.instancer.add_transforms(0, xforms)
# Enable the next line and `Debug/Visible Collision Shapes` to see collision
#terrain.collision.mode = Terrain3DCollision.DYNAMIC_EDITOR
return terrain
func create_texture_asset(asset_name: String, gradient: Gradient, texture_size: int = 512) -> Terrain3DTextureAsset:
# Create noise map
var fnl := FastNoiseLite.new()
fnl.frequency = 0.004
# Create albedo noise texture
var alb_noise_tex := NoiseTexture2D.new()
alb_noise_tex.width = texture_size
alb_noise_tex.height = texture_size
alb_noise_tex.seamless = true
alb_noise_tex.noise = fnl
alb_noise_tex.color_ramp = gradient
await alb_noise_tex.changed
var alb_noise_img: Image = alb_noise_tex.get_image()
# Create albedo + height texture
for x in alb_noise_img.get_width():
for y in alb_noise_img.get_height():
var clr: Color = alb_noise_img.get_pixel(x, y)
clr.a = clr.v # Noise as height
alb_noise_img.set_pixel(x, y, clr)
alb_noise_img.generate_mipmaps()
var albedo := ImageTexture.create_from_image(alb_noise_img)
# Create normal + rough texture
var nrm_noise_tex := NoiseTexture2D.new()
nrm_noise_tex.width = texture_size
nrm_noise_tex.height = texture_size
nrm_noise_tex.as_normal_map = true
nrm_noise_tex.seamless = true
nrm_noise_tex.noise = fnl
await nrm_noise_tex.changed
var nrm_noise_img = nrm_noise_tex.get_image()
for x in nrm_noise_img.get_width():
for y in nrm_noise_img.get_height():
var normal_rgh: Color = nrm_noise_img.get_pixel(x, y)
normal_rgh.a = 0.8 # Roughness
nrm_noise_img.set_pixel(x, y, normal_rgh)
nrm_noise_img.generate_mipmaps()
var normal := ImageTexture.create_from_image(nrm_noise_img)
var ta := Terrain3DTextureAsset.new()
ta.name = asset_name
ta.albedo_texture = albedo
ta.normal_texture = normal
return ta
func create_mesh_asset(asset_name: String, color: Color) -> Terrain3DMeshAsset:
var ma := Terrain3DMeshAsset.new()
ma.name = asset_name
ma.generated_type = Terrain3DMeshAsset.TYPE_TEXTURE_CARD
ma.material_override.albedo_color = color
return ma

View File

@@ -0,0 +1 @@
uid://dakis6gu8b7nm

23
demo/src/DemoScene.gd Normal file
View File

@@ -0,0 +1,23 @@
@tool
extends Node
@onready var terrain: Terrain3D = find_child("Terrain3D")
func _ready():
if not Engine.is_editor_hint() and has_node("UI"):
$UI.player = $Player
# Load Sky3D into the demo environment if enabled
if Engine.is_editor_hint() and has_node("Environment") and \
Engine.get_singleton(&"EditorInterface").is_plugin_enabled("sky_3d"):
$Environment.queue_free()
var sky3d = load("res://addons/sky_3d/src/Sky3D.gd").new()
sky3d.name = "Sky3D"
add_child(sky3d, true)
move_child(sky3d, 1)
sky3d.owner = self
sky3d.current_time = 10
sky3d.enable_editor_time = false

View File

@@ -0,0 +1 @@
uid://chstoagn42gbr

58
demo/src/Enemy.gd Normal file
View File

@@ -0,0 +1,58 @@
extends CharacterBody3D
const RETARGET_COOLDOWN: float = 1.0
@export var MOVE_SPEED: float = 50.0
@export var target: Node3D
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
var _retarget_timer: float = 1.0
func _ready() -> void:
nav_agent.velocity_computed.connect(_on_velocity_computed)
func _process(p_delta: float) -> void:
_retarget_timer += p_delta
if _retarget_timer > RETARGET_COOLDOWN and target:
# Don't reset the target position every frame. It triggers an A* search, which is expensive.
_retarget_timer = 0.0
nav_agent.set_target_position(target.global_position)
func is_on_nav_mesh() -> bool:
var closest_point := NavigationServer3D.map_get_closest_point(nav_agent.get_navigation_map(), global_position)
return global_position.distance_squared_to(closest_point) < nav_agent.path_max_distance ** 2
func _physics_process(p_delta: float) -> void:
if nav_agent.is_navigation_finished():
velocity.x = 0.0
velocity.z = 0.0
else:
var next_path_position: Vector3 = nav_agent.get_next_path_position()
var current_agent_position: Vector3 = global_position
var velocity_xz := (next_path_position - current_agent_position).normalized() * MOVE_SPEED
velocity.x = velocity_xz.x
velocity.z = velocity_xz.z
velocity.y -= 40 * p_delta
if nav_agent.avoidance_enabled:
nav_agent.set_velocity(velocity)
else:
_on_velocity_computed(velocity)
# Ensure enemy doesn't fall through terrain when collision absent
if get_parent().terrain:
var height: float = get_parent().terrain.data.get_height(global_position)
if not is_nan(height):
global_position.y = maxf(global_position.y, height)
func _on_velocity_computed(p_safe_velocity: Vector3) -> void:
velocity.x = p_safe_velocity.x
velocity.z = p_safe_velocity.z
move_and_slide()

1
demo/src/Enemy.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://6j2rrp5f1gjs

81
demo/src/Player.gd Normal file
View File

@@ -0,0 +1,81 @@
extends CharacterBody3D
@export var MOVE_SPEED: float = 50.0
@export var JUMP_SPEED: float = 2.0
@export var first_person: bool = false :
set(p_value):
first_person = p_value
if first_person:
var tween: Tween = create_tween()
tween.tween_property($CameraManager/Arm, "spring_length", 0.0, .33)
tween.tween_callback($Body.set_visible.bind(false))
else:
$Body.visible = true
create_tween().tween_property($CameraManager/Arm, "spring_length", 6.0, .33)
@export var gravity_enabled: bool = true :
set(p_value):
gravity_enabled = p_value
if not gravity_enabled:
velocity.y = 0
@export var collision_enabled: bool = true :
set(p_value):
collision_enabled = p_value
$CollisionShapeBody.disabled = ! collision_enabled
$CollisionShapeRay.disabled = ! collision_enabled
func _physics_process(p_delta) -> void:
var direction: Vector3 = get_camera_relative_input()
var h_veloc: Vector2 = Vector2(direction.x, direction.z).normalized() * MOVE_SPEED
if Input.is_key_pressed(KEY_SHIFT):
h_veloc *= 2
velocity.x = h_veloc.x
velocity.z = h_veloc.y
if gravity_enabled:
velocity.y -= 40 * p_delta
move_and_slide()
# Returns the input vector relative to the camera. Forward is always the direction the camera is facing
func get_camera_relative_input() -> Vector3:
var input_dir: Vector3 = Vector3.ZERO
if Input.is_key_pressed(KEY_A): # Left
input_dir -= %Camera3D.global_transform.basis.x
if Input.is_key_pressed(KEY_D): # Right
input_dir += %Camera3D.global_transform.basis.x
if Input.is_key_pressed(KEY_W): # Forward
input_dir -= %Camera3D.global_transform.basis.z
if Input.is_key_pressed(KEY_S): # Backward
input_dir += %Camera3D.global_transform.basis.z
if Input.is_key_pressed(KEY_E) or Input.is_key_pressed(KEY_SPACE): # Up
velocity.y += JUMP_SPEED + MOVE_SPEED*.016
if Input.is_key_pressed(KEY_Q): # Down
velocity.y -= JUMP_SPEED + MOVE_SPEED*.016
if Input.is_key_pressed(KEY_KP_ADD) or Input.is_key_pressed(KEY_EQUAL):
MOVE_SPEED = clamp(MOVE_SPEED + .5, 5, 9999)
if Input.is_key_pressed(KEY_KP_SUBTRACT) or Input.is_key_pressed(KEY_MINUS):
MOVE_SPEED = clamp(MOVE_SPEED - .5, 5, 9999)
return input_dir
func _input(p_event: InputEvent) -> void:
if p_event is InputEventMouseButton and p_event.pressed:
if p_event.button_index == MOUSE_BUTTON_WHEEL_UP:
MOVE_SPEED = clamp(MOVE_SPEED + 5, 5, 9999)
elif p_event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
MOVE_SPEED = clamp(MOVE_SPEED - 5, 5, 9999)
elif p_event is InputEventKey:
if p_event.pressed:
if p_event.keycode == KEY_V:
first_person = ! first_person
elif p_event.keycode == KEY_G:
gravity_enabled = ! gravity_enabled
elif p_event.keycode == KEY_C:
collision_enabled = ! collision_enabled
# Else if up/down released
elif p_event.keycode in [ KEY_Q, KEY_E, KEY_SPACE ]:
velocity.y = 0

1
demo/src/Player.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dajlr3n5wjwmb

View File

@@ -0,0 +1,151 @@
extends Node
signal bake_finished
@export var enabled: bool = true : set = set_enabled
@export var enter_cost: float = 0.0 : set = set_enter_cost
@export var travel_cost: float = 1.0 : set = set_travel_cost
@export_flags_3d_navigation var navigation_layers: int = 1 : set = set_navigation_layers
@export var template: NavigationMesh : set = set_template
@export var terrain: Terrain3D
@export var player: Node3D
@export var mesh_size := Vector3(256, 512, 256)
@export var min_rebake_distance: float = 64.0
@export var bake_cooldown: float = 1.0
@export_group("Debug")
@export var log_timing: bool = false
var _scene_geometry: NavigationMeshSourceGeometryData3D
var _current_center := Vector3(INF,INF,INF)
var _bake_task_id: int = -1
var _bake_task_timer: float = 0.0
var _bake_cooldown_timer: float = 0.0
var _nav_region: NavigationRegion3D
func _ready():
_nav_region = NavigationRegion3D.new()
_nav_region.navigation_layers = navigation_layers
_nav_region.enabled = enabled
_nav_region.enter_cost = enter_cost
_nav_region.travel_cost = travel_cost
# Enabling edge connections comes with a performance penalty that causes hitches whenever
# the nav mesh is updated. The navigation server has to compare each edge, and it does this on
# the main thread.
_nav_region.use_edge_connections = false
add_child(_nav_region)
_update_map_cell_size()
# If you're using ProtonScatter, you will want to delay this next call until after all
# your scatter nodes have finished setting up. Here, we just defer one frame so that nodes
# after this one in the tree get set up first
parse_scene.call_deferred()
func set_enabled(p_value: bool) -> void:
enabled = p_value
if _nav_region:
_nav_region.enabled = enabled
set_process(enabled and template)
func set_enter_cost(p_value: bool) -> void:
enter_cost = p_value
if _nav_region:
_nav_region.enter_cost = enter_cost
func set_travel_cost(p_value: bool) -> void:
travel_cost = p_value
if _nav_region:
_nav_region.travel_cost = travel_cost
func set_navigation_layers(p_value: int) -> void:
navigation_layers = p_value
if _nav_region:
_nav_region.navigation_layers = navigation_layers
func set_template(p_value: NavigationMesh) -> void:
template = p_value
set_process(enabled and template)
_update_map_cell_size()
func parse_scene() -> void:
_scene_geometry = NavigationMeshSourceGeometryData3D.new()
NavigationServer3D.parse_source_geometry_data(template, _scene_geometry, self)
func _update_map_cell_size() -> void:
if get_viewport() and template:
var map := get_viewport().find_world_3d().navigation_map
NavigationServer3D.map_set_cell_size(map, template.cell_size)
NavigationServer3D.map_set_cell_height(map, template.cell_height)
func _process(p_delta: float) -> void:
if _bake_task_id != -1:
_bake_task_timer += p_delta
if not player or _bake_task_id != -1:
return
if _bake_cooldown_timer > 0.0:
_bake_cooldown_timer -= p_delta
return
var track_pos := player.global_position
if player is CharacterBody3D:
# Center on where the player is likely _going to be_:
track_pos += player.velocity * bake_cooldown
if track_pos.distance_squared_to(_current_center) >= min_rebake_distance * min_rebake_distance:
_current_center = track_pos
_rebake(_current_center)
func _rebake(p_center: Vector3) -> void:
assert(template != null)
_bake_task_id = WorkerThreadPool.add_task(_task_bake.bind(p_center), false, "RuntimeNavigationBaker")
_bake_task_timer = 0.0
_bake_cooldown_timer = bake_cooldown
func _task_bake(p_center: Vector3) -> void:
var nav_mesh: NavigationMesh = template.duplicate()
nav_mesh.filter_baking_aabb = AABB(-mesh_size * 0.5, mesh_size)
nav_mesh.filter_baking_aabb_offset = p_center
var source_geometry: NavigationMeshSourceGeometryData3D
source_geometry = _scene_geometry.duplicate()
if terrain:
var aabb: AABB = nav_mesh.filter_baking_aabb
aabb.position += nav_mesh.filter_baking_aabb_offset
var faces: PackedVector3Array = terrain.generate_nav_mesh_source_geometry(aabb, false)
source_geometry.add_faces(faces, Transform3D.IDENTITY)
if source_geometry.has_data():
NavigationServer3D.bake_from_source_geometry_data(nav_mesh, source_geometry)
_bake_finished.call_deferred(nav_mesh)
else:
_bake_finished.call_deferred(null)
func _bake_finished(p_nav_mesh: NavigationMesh) -> void:
if log_timing:
print("Navigation bake took ", _bake_task_timer, "s")
_bake_task_timer = 0.0
_bake_task_id = -1
if p_nav_mesh:
_nav_region.navigation_mesh = p_nav_mesh
bake_finished.emit()
assert(!NavigationServer3D.region_get_use_edge_connections(_nav_region.get_region_rid()))

View File

@@ -0,0 +1 @@
uid://brh8x1wnycrl5

64
demo/src/UI.gd Normal file
View File

@@ -0,0 +1,64 @@
extends Control
var player: Node
var visible_mode: int = 1
func _init() -> void:
RenderingServer.set_debug_generate_wireframes(true)
func _process(p_delta) -> void:
$Label.text = "FPS: %d\n" % Engine.get_frames_per_second()
if(visible_mode == 1):
$Label.text += "Move Speed: %.1f\n" % player.MOVE_SPEED if player else ""
$Label.text += "Position: %.1v\n" % player.global_position if player else ""
$Label.text += """
Player
Move: WASDEQ,Space,Mouse
Move speed: Wheel,+/-,Shift
Camera View: V
Gravity toggle: G
Collision toggle: C
Window
Quit: F8
UI toggle: F9
Render mode: F10
Full screen: F11
Mouse toggle: Escape / F12
"""
func _unhandled_key_input(p_event: InputEvent) -> void:
if p_event is InputEventKey and p_event.pressed:
match p_event.keycode:
KEY_F8:
get_tree().quit()
KEY_F9:
visible_mode = (visible_mode + 1 ) % 3
$Label/Panel.visible = (visible_mode == 1)
visible = visible_mode > 0
KEY_F10:
var vp = get_viewport()
vp.debug_draw = (vp.debug_draw + 1 ) % 6
get_viewport().set_input_as_handled()
KEY_F11:
toggle_fullscreen()
get_viewport().set_input_as_handled()
KEY_ESCAPE, KEY_F12:
if Input.get_mouse_mode() == Input.MOUSE_MODE_VISIBLE:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
get_viewport().set_input_as_handled()
func toggle_fullscreen() -> void:
if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN or \
DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
DisplayServer.window_set_size(Vector2(1280, 720))
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN)

1
demo/src/UI.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dne6na1m4xku8