Files
Emberwild/scripts/world/props.gd
Paul Bailey 12f7fd677d M0.5: detailed scenery, mountain/sea-cliff boundary, Godot 4.7
Scenery (all procedural, seeded, no imported assets):
- Terrain shader: grass/dry grass/dirt/sand/rock strata/snow + detail normals
- 5 tree types, faceted mossy rocks, cliff outcrops, sea stacks, bushes,
  logs and stumps, batched as MultiMesh per 48 m cell
- Wind-animated grass and flowers streamed in tiles around the player
- Lake (depth-shaded water with shore foam), ocean to the horizon,
  cloud sky shader, volumetric fog, glow, softer shadows
- Swimming: float at the surface, swim-jump to climb out

World bounds:
- Steep mountain rim with an invisible wall partway up; one side opens to
  a sheer sea cliff with the wall 1 m inside the edge; whole chunk is an
  island so the mesh edge is never visible
- Respawn safety net if the player leaves the walkable area or the map
- Fix: the cliff edge now tracks the wall in the headlands (it used to
  drop away inside the wall, leaving a pit with lake water hanging over it)

Tests: tests/test_boundary.gd (24-direction escape attempt) and
tests/test_terrain_edges.gd (no ground below waterline near the wall).

Upgrades the project to Godot 4.7 (project.godot rewritten by the 4.7
importer, .uid files added) and documents it in the setup guide.
2026-11-03 23:49:39 +00:00

503 lines
19 KiB
GDScript

extends Node3D
## Scenery: forests, rocks, cliff outcrops, bushes, logs, grass and flowers.
##
## All placement is seeded, so a server and its clients would agree on where
## every tree is (docs/ARCHITECTURE.md §4). Rendering uses MultiMesh batches
## bucketed into CELL x CELL world cells: a few hundred draw batches instead of
## thousands of nodes, and off-screen cells get frustum-culled.
##
## Trees, logs and larger rocks in or near the walkable valley get simple
## collision. Grass is streamed in tiles around the player because it's too
## dense to place over the whole map at once.
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
const CELL := 48.0
const GRASS_TILE := 12.0
const FLOWER_COLORS := [
Color(0.96, 0.95, 0.90), Color(0.98, 0.84, 0.25), Color(0.62, 0.42, 0.86),
Color(0.45, 0.60, 0.95), Color(0.93, 0.45, 0.55),
]
@export var world_seed: int = 1337
@export var tree_spacing: float = 3.6
@export var forest_density: float = 1.0
@export var grass_spacing: float = 0.42
@export var grass_view_distance: float = 55.0
@export var grass_tiles_per_update: int = 6
var terrain: Node3D
var _player: Node3D
var _spawn := Vector3.ZERO
var _forest_noise := FastNoiseLite.new()
var _meadow_noise := FastNoiseLite.new()
var _colliders: StaticBody3D
var _bark_mat: StandardMaterial3D
var _leaf_mat: ShaderMaterial
var _rock_mat: ShaderMaterial
var _grass_mat: ShaderMaterial
var _grass_root: Node3D
var _grass_tiles: Dictionary = {} # Vector2i -> Node3D (or null for empty tiles)
var _grass_mesh: ArrayMesh
var _flower_mesh: ArrayMesh
var _grass_timer := 0.0
func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
terrain = t
_player = player
_spawn = spawn
_forest_noise.seed = world_seed + 11
_forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_forest_noise.frequency = 0.011
_forest_noise.fractal_octaves = 3
_meadow_noise.seed = world_seed + 23
_meadow_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_meadow_noise.frequency = 0.03
_meadow_noise.fractal_octaves = 2
_colliders = StaticBody3D.new()
_colliders.name = "PropColliders"
add_child(_colliders)
_make_materials()
_scatter_trees()
_scatter_rocks()
_scatter_bushes()
_place_logs()
_grass_root = Node3D.new()
_grass_root.name = "Grass"
add_child(_grass_root)
_grass_mesh = ProcMesh.grass_clump(world_seed + 5)
_grass_mesh.surface_set_material(0, _grass_mat)
_flower_mesh = ProcMesh.flower(world_seed + 6)
var stem := StandardMaterial3D.new()
stem.vertex_color_use_as_albedo = true
stem.vertex_color_is_srgb = true
stem.cull_mode = BaseMaterial3D.CULL_DISABLED
var head := stem.duplicate() as StandardMaterial3D
head.roughness = 0.7
_flower_mesh.surface_set_material(0, stem)
_flower_mesh.surface_set_material(1, head)
_update_grass(100000) # everything in range, before the first frame
func _process(delta: float) -> void:
if _player == null:
return
_grass_timer -= delta
if _grass_timer > 0.0:
return
_grass_timer = 0.2
_update_grass(grass_tiles_per_update)
# --- materials --------------------------------------------------------------
func _make_materials() -> void:
var noise_tex: Texture2D = terrain.noise_tex
_bark_mat = StandardMaterial3D.new()
_bark_mat.vertex_color_use_as_albedo = true
_bark_mat.vertex_color_is_srgb = true
_bark_mat.roughness = 1.0
_leaf_mat = ShaderMaterial.new()
_leaf_mat.shader = preload("res://shaders/foliage.gdshader")
_leaf_mat.set_shader_parameter("noise_tex", noise_tex)
_rock_mat = ShaderMaterial.new()
_rock_mat.shader = preload("res://shaders/rock.gdshader")
_rock_mat.set_shader_parameter("noise_tex", noise_tex)
_rock_mat.set_shader_parameter("snow_height", terrain.snow_height)
_grass_mat = ShaderMaterial.new()
_grass_mat.shader = preload("res://shaders/grass.gdshader")
_grass_mat.set_shader_parameter("noise_tex", noise_tex)
_grass_mat.set_shader_parameter("grass_lush", terrain.grass_lush)
_grass_mat.set_shader_parameter("grass_dry", terrain.grass_dry)
func _apply_materials(v: Dictionary) -> Dictionary:
var mesh: ArrayMesh = v.mesh
var surfaces: Array = v.surfaces
for i in range(surfaces.size()):
mesh.surface_set_material(i, _bark_mat if surfaces[i] == "bark" else _leaf_mat)
return v
# --- helpers ----------------------------------------------------------------
## Jittered grid covering the whole chunk.
func _jittered(spacing: float, rng: RandomNumberGenerator) -> PackedVector2Array:
var pts := PackedVector2Array()
var half: float = terrain.size * 0.5 - 2.0
var z := -half
while z < half:
var x := -half
while x < half:
pts.append(Vector2(x + rng.randf() * spacing, z + rng.randf() * spacing))
x += spacing
z += spacing
return pts
func _forest(x: float, z: float) -> float:
return smoothstep(-0.15, 0.35, _forest_noise.get_noise_2d(x, z))
func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> void:
var key := Vector3i(floori(x / CELL), floori(z / CELL), vi)
if not groups.has(key):
groups[key] = []
groups[key].append(xf)
func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> void:
var root := Node3D.new()
root.name = label
add_child(root)
for key: Vector3i in groups:
root.add_child(_mm_instance(meshes[key.z], groups[key], [], shadows))
func _mm_instance(mesh: Mesh, xforms: Array, colors: Array, shadows: bool) -> MultiMeshInstance3D:
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_colors = not colors.is_empty() # must be set before instance_count
mm.mesh = mesh
mm.instance_count = xforms.size()
for i in range(xforms.size()):
mm.set_instance_transform(i, xforms[i])
if not colors.is_empty():
mm.set_instance_color(i, colors[i])
var mmi := MultiMeshInstance3D.new()
mmi.multimesh = mm
mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
return mmi
## Basis that tips `UP` part-way toward the ground normal, then yaws and scales.
func _ground_basis(normal: Vector3, follow: float, yaw: float, s: float) -> Basis:
var up := Vector3.UP.lerp(normal, follow).normalized()
var b := Basis(Vector3.UP, yaw)
var axis := Vector3.UP.cross(up)
if axis.length() > 0.0001:
b = Basis(axis.normalized(), Vector3.UP.angle_to(up)) * b
return b * Basis.from_scale(Vector3.ONE * s)
func _add_shape(shape: Shape3D, xf: Transform3D) -> void:
var cs := CollisionShape3D.new()
cs.shape = shape
cs.transform = xf
_colliders.add_child(cs)
# --- trees ------------------------------------------------------------------
func _scatter_trees() -> void:
var variants: Array = []
var conifers: Array[int] = []
var broad: Array[int] = []
var birches: Array[int] = []
for i in range(3):
conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 100 + i, false)))
for i in range(2):
conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 110 + i, true)))
for i in range(3):
var col: Color = ProcMesh.LEAF_GOLD if i == 2 else ProcMesh.LEAF_GREEN
broad.append(variants.size()); variants.append(_apply_materials(ProcMesh.broadleaf(world_seed + 120 + i, col)))
for i in range(2):
birches.append(variants.size()); variants.append(_apply_materials(ProcMesh.birch(world_seed + 130 + i)))
var dead := variants.size()
variants.append(_apply_materials(ProcMesh.dead_tree(world_seed + 140)))
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 1
var groups := {}
for p in _jittered(tree_spacing, rng):
# Draw every random number up front so the sequence never depends on
# which branch below is taken (stable layout when rules are tweaked).
var roll := rng.randf()
var pick := rng.randf()
var yaw := rng.randf() * TAU
var s := rng.randf_range(0.75, 1.3)
var h: float = terrain.sample_height(p.x, p.y)
if roll > _tree_chance(p.x, p.y, h) * forest_density:
continue
var vi: int
var conifer_p := lerpf(0.4, 0.97, smoothstep(2.0, 24.0, h))
if pick < 0.025:
vi = dead
elif pick < conifer_p:
vi = conifers[int(pick * 9973.0) % conifers.size()]
elif (pick - conifer_p) / (1.0 - conifer_p) < 0.6:
vi = broad[int(pick * 7919.0) % broad.size()]
else:
vi = birches[int(pick * 7919.0) % birches.size()]
var xf := Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(p.x, h - 0.1, p.y))
_group(groups, p.x, p.y, vi, xf)
if terrain.inside_boundary(p.x, p.y, -4.0):
var v: Dictionary = variants[vi]
var cap := CapsuleShape3D.new()
cap.radius = v.trunk_r * s
cap.height = maxf(v.trunk_h * s, cap.radius * 2.0 + 0.1)
_add_shape(cap, Transform3D(Basis(), Vector3(p.x, h + cap.height * 0.5, p.y)))
var meshes := variants.map(func(v): return v.mesh)
_flush(groups, meshes, true, "Trees")
## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so
## the view out to sea is clear and you can walk right up to the edge.
func _on_clifftop(x: float, z: float) -> bool:
return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \
and terrain.inside_boundary(x, z, -8.0)
func _tree_chance(x: float, z: float, h: float) -> float:
if h < terrain.water_level + 0.8:
return 0.0
if Vector2(x - _spawn.x, z - _spawn.z).length() < 9.0 or _on_clifftop(x, z):
return 0.0
var n: Vector3 = terrain.sample_normal(x, z)
var slope := 1.0 - n.y
if slope > 0.5:
return 0.0
var c := lerpf(0.035, 0.8, _forest(x, z))
c *= 1.0 - smoothstep(0.25, 0.5, slope)
c *= 1.0 - smoothstep(terrain.snow_height - 12.0, terrain.snow_height - 2.0, h)
return c
# --- rocks ------------------------------------------------------------------
func _scatter_rocks() -> void:
var meshes: Array = []
var hulls: Array = []
for i in range(7):
var m := ProcMesh.rock(world_seed + 300 + i, i >= 5)
m.surface_set_material(0, _rock_mat)
meshes.append(m)
hulls.append((m.create_convex_shape(true, true) as ConvexPolygonShape3D).points)
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 2
var groups := {}
# Small stones: everywhere, more on slopes and in the shallows.
for p in _jittered(6.0, rng):
var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU
var s := rng.randf_range(0.2, 0.75)
var h: float = terrain.sample_height(p.x, p.y)
var n: Vector3 = terrain.sample_normal(p.x, p.y)
var chance := 0.1 + (1.0 - n.y) * 0.8
if h < terrain.water_level - 2.0 or roll > chance:
continue
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.6, 0.25)
# Boulders: sparse, a little more common in forests.
for p in _jittered(14.0, rng):
var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU
var s := rng.randf_range(1.2, 3.0)
var h: float = terrain.sample_height(p.x, p.y)
var n: Vector3 = terrain.sample_normal(p.x, p.y)
if h < terrain.water_level - 0.5 or Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 8.0 or _on_clifftop(p.x, p.y):
continue
if roll > 0.07 + _forest(p.x, p.y) * 0.08:
continue
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3)
# Cliff outcrops: big slabs breaking up the steep mountain faces.
for p in _jittered(8.0, rng):
var roll := rng.randf(); var vi := rng.randi_range(0, 6); var yaw := rng.randf() * TAU
var s := rng.randf_range(3.0, 8.0)
var n: Vector3 = terrain.sample_normal(p.x, p.y)
if 1.0 - n.y < 0.32 or roll > 0.22:
continue
var h: float = terrain.sample_height(p.x, p.y)
_place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.9, 0.6)
# Sea stacks: tall pillars standing off the cliff (no collision, unreachable).
var placed := 0
var tries := 0
while placed < 9 and tries < 200:
tries += 1
var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0)
var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0))
var vi := rng.randi_range(5, 6)
var yaw := rng.randf() * TAU
var s := rng.randf_range(3.5, 7.0)
var h: float = terrain.sample_height(p.x, p.y)
if h > terrain.sea_level - 3.0:
continue
var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * rng.randf_range(1.8, 2.6), s))
_group(groups, p.x, p.y, vi, Transform3D(b, Vector3(p.x, h - 1.0, p.y)))
placed += 1
_flush(groups, meshes, true, "Rocks")
func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3,
vi: int, yaw: float, s: float, follow: float, sink: float) -> void:
var b := _ground_basis(n, follow, yaw, s)
var pos := Vector3(p.x, h - sink * s, p.y)
_group(groups, p.x, p.y, vi, Transform3D(b, pos))
if s >= 0.55 and terrain.inside_boundary(p.x, p.y, -6.0):
var pts := PackedVector3Array()
for q: Vector3 in hulls[vi]:
pts.append(q * s)
var shape := ConvexPolygonShape3D.new()
shape.points = pts
_add_shape(shape, Transform3D(b.orthonormalized(), pos))
# --- bushes, logs, stumps ---------------------------------------------------
func _scatter_bushes() -> void:
var meshes: Array = []
for i in range(3):
meshes.append(_apply_materials(ProcMesh.bush(world_seed + 200 + i)).mesh)
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 3
var groups := {}
for p in _jittered(4.5, rng):
var roll := rng.randf(); var vi := rng.randi_range(0, 2); var yaw := rng.randf() * TAU
var s := rng.randf_range(0.7, 1.5)
var h: float = terrain.sample_height(p.x, p.y)
var n: Vector3 = terrain.sample_normal(p.x, p.y)
if h < terrain.water_level + 0.5 or n.y < 0.75 or h > terrain.snow_height - 8.0:
continue
if Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 4.0 or _on_clifftop(p.x, p.y):
continue
var f := _forest(p.x, p.y)
var edge := 1.0 - absf(f - 0.5) * 2.0 # forest edges get the most undergrowth
if roll > 0.05 + 0.4 * edge:
continue
_group(groups, p.x, p.y, vi, Transform3D(_ground_basis(n, 0.5, yaw, s), Vector3(p.x, h - 0.1, p.y)))
_flush(groups, meshes, true, "Bushes")
func _place_logs() -> void:
var root := Node3D.new()
root.name = "Logs"
add_child(root)
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 4
var logs := 0
var stumps := 0
var tries := 0
while (logs < 22 or stumps < 30) and tries < 4000:
tries += 1
var x := rng.randf_range(-terrain.play_radius, terrain.play_radius)
var z := rng.randf_range(-terrain.play_radius, terrain.play_radius)
var yaw := rng.randf() * TAU
var make_log := rng.randf() < 0.45
if not terrain.inside_boundary(x, z, 6.0) or _forest(x, z) < 0.35:
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0:
continue
var mi := MeshInstance3D.new()
var shape: Shape3D
var xf: Transform3D
if make_log and logs < 22:
var length := rng.randf_range(3.0, 5.0)
var r := rng.randf_range(0.22, 0.34)
mi.mesh = ProcMesh.log_mesh(world_seed + 400 + logs, length, r)
xf = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h + r * 0.75, z))
var box := BoxShape3D.new()
box.size = Vector3(length, r * 1.8, r * 1.8)
shape = box
logs += 1
elif not make_log and stumps < 30:
var sh := rng.randf_range(0.35, 0.7)
var r := rng.randf_range(0.25, 0.4)
mi.mesh = ProcMesh.stump_mesh(world_seed + 500 + stumps, sh, r)
xf = Transform3D(Basis(Vector3.UP, yaw), Vector3(x, h, z))
var cyl := CylinderShape3D.new()
cyl.radius = r * 1.1
cyl.height = sh + 0.3
shape = cyl
stumps += 1
else:
mi.free()
continue
mi.material_override = _bark_mat
mi.transform = xf
root.add_child(mi)
var col_xf := xf
if shape is CylinderShape3D:
col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3
_add_shape(shape, col_xf)
# --- grass streaming --------------------------------------------------------
func _update_grass(budget: int) -> void:
var p := _player.global_position
var reach := grass_view_distance + GRASS_TILE
var c := Vector2i(floori(p.x / GRASS_TILE), floori(p.z / GRASS_TILE))
var tr := int(ceil(reach / GRASS_TILE))
var wanted := {}
var todo: Array = []
for dz in range(-tr, tr + 1):
for dx in range(-tr, tr + 1):
var k := c + Vector2i(dx, dz)
var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z))
if d > reach:
continue
wanted[k] = true
if not _grass_tiles.has(k):
todo.append([d, k])
todo.sort_custom(func(a, b): return a[0] < b[0])
for i in range(mini(budget, todo.size())):
var k: Vector2i = todo[i][1]
_grass_tiles[k] = _make_grass_tile(k)
for k: Vector2i in _grass_tiles.keys():
if wanted.has(k):
continue
var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z))
if d > reach + GRASS_TILE * 1.5:
var node = _grass_tiles[k]
if node != null and is_instance_valid(node):
node.queue_free()
_grass_tiles.erase(k)
func _make_grass_tile(k: Vector2i) -> Node3D:
var rng := RandomNumberGenerator.new()
rng.seed = hash(k) ^ world_seed
var ox := k.x * GRASS_TILE
var oz := k.y * GRASS_TILE
var centre := Vector3(ox + GRASS_TILE * 0.5, 0.0, oz + GRASS_TILE * 0.5)
var grass_xf: Array = []
var flower_xf: Array = []
var flower_col: Array = []
var steps := int(GRASS_TILE / grass_spacing)
for iz in range(steps):
for ix in range(steps):
var x := ox + (ix + rng.randf()) * grass_spacing
var z := oz + (iz + rng.randf()) * grass_spacing
var keep := rng.randf(); var yaw := rng.randf() * TAU
var s := rng.randf_range(0.75, 1.25); var fl := rng.randf()
if not terrain.in_bounds(x, z):
continue
var h: float = terrain.sample_height(x, z)
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0:
continue
var n: Vector3 = terrain.sample_normal(x, z)
if n.y < 0.72:
continue
var meadow := _meadow_noise.get_noise_2d(x, z)
var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7)
if keep > dens:
continue
var tall := lerpf(0.7, 1.35, smoothstep(-0.1, 0.4, meadow))
var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * tall, s))
grass_xf.append(Transform3D(b, Vector3(x - centre.x, h - 0.03, z - centre.z)))
if fl < 0.02 * smoothstep(0.0, 0.4, meadow):
var ci := int(absf(_forest_noise.get_noise_2d(x * 3.0, z * 3.0)) * 10.0) % FLOWER_COLORS.size()
flower_xf.append(Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(x - centre.x, h, z - centre.z)))
flower_col.append(FLOWER_COLORS[ci])
if grass_xf.is_empty():
return null
var node := Node3D.new()
node.position = centre
var g := _mm_instance(_grass_mesh, grass_xf, [], false)
_fade(g, grass_view_distance)
node.add_child(g)
if not flower_xf.is_empty():
var f := _mm_instance(_flower_mesh, flower_xf, flower_col, false)
_fade(f, grass_view_distance * 0.7)
node.add_child(f)
_grass_root.add_child(node)
return node
func _fade(gi: GeometryInstance3D, dist: float) -> void:
gi.visibility_range_end = dist
gi.visibility_range_end_margin = 10.0
gi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF