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.
This commit is contained in:
Paul Bailey
2026-11-03 23:49:39 +00:00
parent 1ebfce08e4
commit 12f7fd677d
32 changed files with 1922 additions and 233 deletions

View File

@@ -1,94 +1,502 @@
extends Node3D
## M0 mood props — a few low-poly trees and rocks built from primitives in code,
## so the scene looks "alive" with no imported assets. These are placeholders;
## real low-poly models (free asset packs, see docs/ROADMAP.md M0) replace them.
## Scenery: forests, rocks, cliff outcrops, bushes, logs, grass and flowers.
##
## place_on(terrain) is called by world.gd once the terrain exists, so each prop
## sits on the real ground height instead of floating at Y=0.
## 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.
var _terrain: Node3D
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
func place_on(terrain: Node3D) -> void:
_terrain = terrain
var rng := RandomNumberGenerator.new()
rng.seed = 42
for i in range(18):
var x := rng.randf_range(-55.0, 55.0)
var z := rng.randf_range(-55.0, 55.0)
var y: float = _terrain.sample_height(x, z)
if rng.randf() < 0.65:
_make_tree(Vector3(x, y, z), rng)
else:
_make_rock(Vector3(x, y, z), rng)
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),
]
func _make_tree(pos: Vector3, rng: RandomNumberGenerator) -> void:
@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.position = pos
root.name = label
add_child(root)
for key: Vector3i in groups:
root.add_child(_mm_instance(meshes[key.z], groups[key], [], shadows))
var trunk := MeshInstance3D.new()
var tm := CylinderMesh.new()
tm.top_radius = 0.18
tm.bottom_radius = 0.28
tm.height = 3.0
trunk.mesh = tm
trunk.position = Vector3(0, 1.5, 0)
trunk.material_override = _mat(Color(0.33, 0.24, 0.16))
root.add_child(trunk)
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
# Collision: a capsule around the trunk so you can't walk through the tree.
var body := StaticBody3D.new()
root.add_child(body)
var col := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = 0.35
cap.height = 4.0
col.shape = cap
col.position = Vector3(0, 2.0, 0)
body.add_child(col)
## 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)
# Low-poly conifer canopy: stacked cones.
var h := 3.0
for j in range(3):
var cone := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.0
cm.bottom_radius = 1.6 - j * 0.4
cm.height = 1.6
cm.radial_segments = 6 # low-poly
cone.mesh = cm
cone.position = Vector3(0, h + j * 1.0, 0)
cone.material_override = _mat(Color(0.16, 0.34, 0.2))
root.add_child(cone)
func _add_shape(shape: Shape3D, xf: Transform3D) -> void:
var cs := CollisionShape3D.new()
cs.shape = shape
cs.transform = xf
_colliders.add_child(cs)
root.rotate_y(rng.randf_range(0, TAU))
var s := rng.randf_range(0.85, 1.35)
root.scale = Vector3(s, s, s)
# --- trees ------------------------------------------------------------------
func _make_rock(pos: Vector3, rng: RandomNumberGenerator) -> void:
var rock := MeshInstance3D.new()
var bm := BoxMesh.new()
var box_size := Vector3(rng.randf_range(0.8, 1.8), rng.randf_range(0.6, 1.2), rng.randf_range(0.8, 1.8))
bm.size = box_size
rock.mesh = bm
rock.position = pos + Vector3(0, 0.3, 0)
rock.rotation = Vector3(rng.randf_range(-0.2, 0.2), rng.randf_range(0, TAU), rng.randf_range(-0.2, 0.2))
rock.material_override = _mat(Color(0.42, 0.42, 0.45))
add_child(rock)
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)))
# Collision: a box matching the rock mesh.
var body := StaticBody3D.new()
body.position = rock.position
body.rotation = rock.rotation
add_child(body)
var col := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = box_size
col.shape = box
body.add_child(col)
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")
func _mat(c: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = c
m.roughness = 0.95
return m
## 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