Files
Emberwild/scripts/world/proc_mesh.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

373 lines
15 KiB
GDScript

extends RefCounted
## Procedural low-poly mesh builders: trees, rocks, bushes, logs, grass, flowers.
##
## Every builder takes a seed, so the same seed gives the same mesh on every
## machine (the determinism rule in docs/ARCHITECTURE.md §4). Meshes are
## flat-shaded and coloured per face through vertex colours (authored as sRGB).
## Callers assign materials per surface; tree dictionaries list which surface is
## which in "surfaces".
##
## Winding: Godot treats triangle (a, b, c) as front-facing when
## (b - a).cross(c - a) points AWAY from the viewer. `_tri()` orders the
## vertices from an outward hint, so builders never have to think about it.
const BARK := Color(0.31, 0.22, 0.15)
const BARK_GREY := Color(0.42, 0.38, 0.33)
const BIRCH_WHITE := Color(0.86, 0.85, 0.80)
const BIRCH_DARK := Color(0.16, 0.15, 0.14)
const WOOD := Color(0.70, 0.55, 0.36)
const SPRUCE := Color(0.13, 0.27, 0.17)
const PINE := Color(0.18, 0.31, 0.16)
const LEAF_GREEN := Color(0.27, 0.43, 0.16)
const LEAF_GOLD := Color(0.62, 0.50, 0.15)
const BIRCH_LEAF := Color(0.42, 0.54, 0.20)
const BUSH := Color(0.19, 0.34, 0.14)
const ROCK := Color(0.50, 0.49, 0.47)
const STEM := Color(0.25, 0.42, 0.15)
static var _ico_cache := {}
# --- primitives -------------------------------------------------------------
static func _begin() -> SurfaceTool:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st.set_smooth_group(-1) # flat shading
return st
static func _commit(st: SurfaceTool, mesh: ArrayMesh = null) -> ArrayMesh:
st.generate_normals()
return st.commit(mesh)
static func _mul(c: Color, k: float) -> Color:
return Color(c.r * k, c.g * k, c.b * k, 1.0)
static func _tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, outward: Vector3, col: Color) -> void:
if (b - a).cross(c - a).dot(outward) > 0.0:
var tmp := b
b = c
c = tmp
st.set_color(col)
st.add_vertex(a)
st.set_color(col)
st.add_vertex(b)
st.set_color(col)
st.add_vertex(c)
## Tapered cylinder from `base` to `top`. `off` fixes the ring rotation so
## stacked segments line up; cap_col.a > 0 adds end caps in that colour.
static func _cylinder(st: SurfaceTool, base: Vector3, top: Vector3, r0: float, r1: float, segs: int,
col: Color, rng: RandomNumberGenerator, off: float = -1.0, cap_col: Color = Color(0, 0, 0, 0)) -> void:
var ax := (top - base).normalized()
var ref := Vector3.UP if absf(ax.y) < 0.95 else Vector3.RIGHT
var u := ax.cross(ref).normalized()
var v := ax.cross(u).normalized()
if off < 0.0:
off = rng.randf() * TAU
var ring0: Array[Vector3] = []
var ring1: Array[Vector3] = []
for i in range(segs):
var a := off + TAU * i / segs
var dir := u * cos(a) + v * sin(a)
ring0.append(base + dir * r0)
ring1.append(top + dir * r1)
for i in range(segs):
var j := (i + 1) % segs
var mid := (ring0[i] + ring0[j] + ring1[i] + ring1[j]) * 0.25
var out := mid - (base + ax * (mid - base).dot(ax))
var c := _mul(col, rng.randf_range(0.85, 1.1))
_tri(st, ring0[i], ring0[j], ring1[j], out, c)
_tri(st, ring0[i], ring1[j], ring1[i], out, c)
if cap_col.a > 0.0:
for i in range(segs):
var j := (i + 1) % segs
_tri(st, top, ring1[i], ring1[j], ax, cap_col)
_tri(st, base, ring0[i], ring0[j], -ax, cap_col)
static func icosphere(subdiv: int) -> Array:
if _ico_cache.has(subdiv):
return _ico_cache[subdiv]
var t := (1.0 + sqrt(5.0)) / 2.0
var v := [
Vector3(-1, t, 0), Vector3(1, t, 0), Vector3(-1, -t, 0), Vector3(1, -t, 0),
Vector3(0, -1, t), Vector3(0, 1, t), Vector3(0, -1, -t), Vector3(0, 1, -t),
Vector3(t, 0, -1), Vector3(t, 0, 1), Vector3(-t, 0, -1), Vector3(-t, 0, 1),
]
var faces := [
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
]
var tris := []
for f in faces:
tris.append([(v[f[0]] as Vector3).normalized(), (v[f[1]] as Vector3).normalized(), (v[f[2]] as Vector3).normalized()])
for _s in range(subdiv):
var nt := []
for tr in tris:
var a: Vector3 = tr[0]
var b: Vector3 = tr[1]
var c: Vector3 = tr[2]
var ab := ((a + b) * 0.5).normalized()
var bc := ((b + c) * 0.5).normalized()
var ca := ((c + a) * 0.5).normalized()
nt.append([a, ab, ca])
nt.append([b, bc, ab])
nt.append([c, ca, bc])
nt.append([ab, bc, ca])
tris = nt
_ico_cache[subdiv] = tris
return tris
## Lumpy flat-shaded sphere. Displacement is a function of position, so shared
## corners move together and the surface stays watertight.
static func _blob(st: SurfaceTool, center: Vector3, radii: Vector3, noise: FastNoiseLite, amount: float,
col: Color, rng: RandomNumberGenerator, subdiv: int = 1) -> void:
for tr in icosphere(subdiv):
var p: Array[Vector3] = []
for k in range(3):
var q: Vector3 = tr[k]
var d := 1.0 + noise.get_noise_3dv(q * 1.7 + center) * amount
p.append(center + q * radii * d)
_tri(st, p[0], p[1], p[2], (p[0] + p[1] + p[2]) / 3.0 - center, _mul(col, rng.randf_range(0.88, 1.1)))
## One drooping tier of a conifer: a star-shaped ring (alternating branch tips
## and notches) rising to an apex, with a darker underside.
static func _conifer_tier(st: SurfaceTool, center: Vector3, r: float, h: float, segs: int,
col: Color, rng: RandomNumberGenerator) -> void:
var n := segs * 2
var ring: Array[Vector3] = []
var off := rng.randf() * TAU
for i in range(n):
var a := off + TAU * i / n + rng.randf_range(-0.12, 0.12)
var tip := i % 2 == 0
var rr := r * (rng.randf_range(0.9, 1.12) if tip else rng.randf_range(0.58, 0.7))
var yy := -(r * 0.32 if tip else r * 0.12) + rng.randf_range(-0.08, 0.08)
ring.append(center + Vector3(cos(a) * rr, yy, sin(a) * rr))
var apex := center + Vector3(rng.randf_range(-0.08, 0.08), h, rng.randf_range(-0.08, 0.08))
var under := center + Vector3(0.0, h * 0.18, 0.0)
for i in range(n):
var a := ring[i]
var b := ring[(i + 1) % n]
var mid := (a + b) * 0.5 - center
var out := Vector3(mid.x, 0.0, mid.z).normalized() + Vector3(0.0, 0.8, 0.0)
_tri(st, apex, a, b, out, _mul(col, rng.randf_range(0.85, 1.12)))
_tri(st, under, a, b, Vector3(mid.x * 0.3, -1.0, mid.z * 0.3), _mul(col, 0.55))
# --- trees ------------------------------------------------------------------
## Spruce (bushy, tiers from near the ground) or pine (tall, tiers only up top).
static func conifer(seed: int, tall: bool) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var bark := _begin()
var leaf := _begin()
var trunk_h := rng.randf_range(8.5, 10.5) if tall else rng.randf_range(5.5, 7.0)
var trunk_r := 0.26 if tall else 0.3
var lean := Vector3(rng.randf_range(-0.15, 0.15), 0.0, rng.randf_range(-0.15, 0.15))
_cylinder(bark, Vector3(0, -0.5, 0), Vector3(0, trunk_h, 0) + lean, trunk_r, 0.05, 7, BARK, rng)
var tiers := rng.randi_range(4, 5) if tall else rng.randi_range(5, 7)
var y0 := trunk_h * 0.55 if tall else 1.3
var y1 := trunk_h - 0.6
var r_bottom := 1.7 if tall else 2.4
var col := _mul(PINE if tall else SPRUCE, rng.randf_range(0.9, 1.1))
for i in range(tiers):
var f := float(i) / float(tiers - 1)
var y := lerpf(y0, y1, f)
var r := lerpf(r_bottom, 0.55, pow(f, 0.9)) * rng.randf_range(0.9, 1.1)
_conifer_tier(leaf, Vector3(0, y, 0) + lean * (y / trunk_h), r, lerpf(1.9, 1.3, f), 9, col, rng)
_conifer_tier(leaf, Vector3(0, y1 + 0.9, 0) + lean, 0.35, 1.1, 5, col, rng)
var mesh := _commit(bark)
_commit(leaf, mesh)
return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": trunk_r, "trunk_h": trunk_h}
## Broadleaf (oak-ish): short thick trunk, a few branches, a crown of blobs.
static func broadleaf(seed: int, leaf_col: Color) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var noise := FastNoiseLite.new()
noise.seed = seed
noise.frequency = 1.3
var bark := _begin()
var leaf := _begin()
var trunk_h := rng.randf_range(2.6, 3.6)
var top := Vector3(rng.randf_range(-0.3, 0.3), trunk_h, rng.randf_range(-0.3, 0.3))
_cylinder(bark, Vector3(0, -0.4, 0), top, 0.36, 0.2, 7, BARK, rng)
var k := rng.randi_range(3, 4)
for i in range(k):
var a := TAU * i / k + rng.randf_range(-0.4, 0.4)
var start := top * rng.randf_range(0.75, 1.0)
var end := top + Vector3(cos(a) * rng.randf_range(1.1, 1.8), rng.randf_range(0.6, 1.4), sin(a) * rng.randf_range(1.1, 1.8))
_cylinder(bark, start, end, 0.15, 0.06, 5, BARK, rng)
var crown := top + Vector3(0, 1.4, 0)
var col := _mul(leaf_col, rng.randf_range(0.92, 1.08))
var blobs := rng.randi_range(6, 9)
for i in range(blobs):
var c := crown + Vector3(rng.randf_range(-1.7, 1.7), rng.randf_range(-0.6, 1.2), rng.randf_range(-1.7, 1.7))
if i == 0:
c = crown + Vector3(0, 0.8, 0)
var r := rng.randf_range(1.0, 1.6)
_blob(leaf, c, Vector3(r, r * 0.82, r), noise, 0.28, _mul(col, rng.randf_range(0.85, 1.15)), rng)
var mesh := _commit(bark)
_commit(leaf, mesh)
return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": 0.36, "trunk_h": trunk_h}
## Birch: slim white trunk with dark bands, light airy crown.
static func birch(seed: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var noise := FastNoiseLite.new()
noise.seed = seed
noise.frequency = 1.5
var bark := _begin()
var leaf := _begin()
var trunk_h := rng.randf_range(6.0, 8.0)
var lean := Vector3(rng.randf_range(-0.4, 0.4), 0.0, rng.randf_range(-0.4, 0.4))
var off := rng.randf() * TAU
var y := -0.4
while y < trunk_h:
var dark := rng.randf() < 0.3
var seg_h := rng.randf_range(0.12, 0.22) if dark else rng.randf_range(0.5, 1.0)
var y2 := minf(y + seg_h, trunk_h)
var r0 := lerpf(0.18, 0.05, maxf(y, 0.0) / trunk_h)
var r1 := lerpf(0.18, 0.05, y2 / trunk_h)
var col := BIRCH_DARK if dark else _mul(BIRCH_WHITE, rng.randf_range(0.92, 1.03))
_cylinder(bark, Vector3(0, y, 0) + lean * (maxf(y, 0.0) / trunk_h), Vector3(0, y2, 0) + lean * (y2 / trunk_h),
r0, r1, 6, col, rng, off)
y = y2
var col_leaf := _mul(BIRCH_LEAF, rng.randf_range(0.92, 1.08))
for i in range(rng.randi_range(5, 7)):
var cy := rng.randf_range(trunk_h * 0.5, trunk_h + 0.6)
var c := Vector3(rng.randf_range(-0.9, 0.9), cy, rng.randf_range(-0.9, 0.9)) + lean * (cy / trunk_h)
var r := rng.randf_range(0.7, 1.1)
_blob(leaf, c, Vector3(r, r * 1.35, r), noise, 0.3, _mul(col_leaf, rng.randf_range(0.85, 1.15)), rng)
var mesh := _commit(bark)
_commit(leaf, mesh)
return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": 0.2, "trunk_h": trunk_h}
## Dead tree: bare grey trunk with forked branches.
static func dead_tree(seed: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var bark := _begin()
var trunk_h := rng.randf_range(4.0, 6.0)
var top := Vector3(rng.randf_range(-0.3, 0.3), trunk_h, rng.randf_range(-0.3, 0.3))
_cylinder(bark, Vector3(0, -0.4, 0), top, 0.28, 0.07, 6, BARK_GREY, rng)
for i in range(rng.randi_range(4, 6)):
var a := rng.randf() * TAU
var start := top * rng.randf_range(0.45, 0.95)
var end := start + Vector3(cos(a) * rng.randf_range(0.8, 1.6), rng.randf_range(0.5, 1.5), sin(a) * rng.randf_range(0.8, 1.6))
_cylinder(bark, start, end, 0.09, 0.03, 4, BARK_GREY, rng)
var a2 := a + rng.randf_range(-0.8, 0.8)
_cylinder(bark, end, end + Vector3(cos(a2) * 0.6, rng.randf_range(0.3, 0.8), sin(a2) * 0.6), 0.04, 0.015, 4, BARK_GREY, rng)
return {"mesh": _commit(bark), "surfaces": ["bark"], "trunk_r": 0.28, "trunk_h": trunk_h}
# --- small props ------------------------------------------------------------
static func bush(seed: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var noise := FastNoiseLite.new()
noise.seed = seed
noise.frequency = 2.0
var leaf := _begin()
var col := _mul(BUSH, rng.randf_range(0.9, 1.15))
for i in range(rng.randi_range(3, 5)):
var r := rng.randf_range(0.45, 0.8)
var c := Vector3(rng.randf_range(-0.55, 0.55), rng.randf_range(0.25, 0.6), rng.randf_range(-0.55, 0.55))
_blob(leaf, c, Vector3(r, r * 0.8, r), noise, 0.3, _mul(col, rng.randf_range(0.85, 1.15)), rng)
return {"mesh": _commit(leaf), "surfaces": ["leaf"]}
## Faceted rock. `tall` gives slab/pillar shapes for cliff outcrops.
static func rock(seed: int, tall: bool) -> ArrayMesh:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var noise := FastNoiseLite.new()
noise.seed = seed
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
noise.frequency = 0.9
var squash := Vector3(rng.randf_range(0.9, 1.4), rng.randf_range(0.55, 0.85), rng.randf_range(0.8, 1.2))
if tall:
squash = Vector3(rng.randf_range(0.7, 1.0), rng.randf_range(1.2, 1.6), rng.randf_range(0.6, 0.9))
var st := _begin()
var base_col := _mul(ROCK, rng.randf_range(0.9, 1.1))
for tr in icosphere(1):
var p: Array[Vector3] = []
for k in range(3):
var q: Vector3 = tr[k]
q = q * (1.0 + noise.get_noise_3dv(q) * 0.38) * squash
q.y = maxf(q.y, -0.35 * squash.y) # flat-ish base so it sits on the ground
p.append(q)
_tri(st, p[0], p[1], p[2], p[0] + p[1] + p[2], _mul(base_col, rng.randf_range(0.82, 1.12)))
return _commit(st)
static func log_mesh(seed: int, length: float, radius: float) -> ArrayMesh:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var st := _begin()
_cylinder(st, Vector3(-length * 0.5, 0, 0), Vector3(length * 0.5, 0, 0), radius, radius * 0.85, 8, BARK, rng, -1.0, WOOD)
return _commit(st)
static func stump_mesh(seed: int, height: float, radius: float) -> ArrayMesh:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var st := _begin()
_cylinder(st, Vector3(0, -0.3, 0), Vector3(0, height, 0), radius * 1.15, radius, 8, BARK, rng, -1.0, WOOD)
return _commit(st)
## A clump of tapered blades. UV.y = 0 at the root, 1 at the tip (drives wind).
static func grass_clump(seed: int, blades: int = 10) -> ArrayMesh:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
for i in range(blades):
var a := rng.randf() * TAU
var rr := sqrt(rng.randf()) * 0.3
var pos := Vector3(cos(a) * rr, -0.05, sin(a) * rr)
var face := rng.randf() * TAU
var side := Vector3(cos(face), 0.0, sin(face))
var fwd := Vector3(-side.z, 0.0, side.x)
var h := rng.randf_range(0.32, 0.72)
var w := rng.randf_range(0.035, 0.06)
var bend := rng.randf_range(0.08, 0.28)
var b0 := pos - side * w
var b1 := pos + side * w
var mc := pos + fwd * bend * 0.35 + Vector3(0, h * 0.55, 0)
var m0 := mc - side * w * 0.7
var m1 := mc + side * w * 0.7
var tip := pos + fwd * bend + Vector3(0, h, 0)
var u := rng.randf()
var data := [[b0, 0.0], [b1, 0.0], [m1, 0.55], [b0, 0.0], [m1, 0.55], [m0, 0.55], [m0, 0.55], [m1, 0.55], [tip, 1.0]]
for d in data:
st.set_normal(Vector3.UP)
st.set_uv(Vector2(u, d[1]))
st.add_vertex(d[0])
return st.commit()
## Small flower: a stem (surface 0) and a five-petal head (surface 1, white so
## the MultiMesh instance colour tints it).
static func flower(seed: int) -> ArrayMesh:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var stem := _begin()
var head := _begin()
var h := 0.32
var c := Vector3(0, h, 0)
_tri(stem, Vector3(-0.012, -0.05, 0), Vector3(0.012, -0.05, 0), c, Vector3.BACK, STEM)
_tri(stem, Vector3(0, -0.05, -0.012), Vector3(0, -0.05, 0.012), c, Vector3.RIGHT, STEM)
for k in range(5):
var a := TAU * k / 5.0
var tip := c + Vector3(cos(a) * 0.085, 0.025, sin(a) * 0.085)
var l := c + Vector3(cos(a - 0.45) * 0.045, 0.012, sin(a - 0.45) * 0.045)
var r := c + Vector3(cos(a + 0.45) * 0.045, 0.012, sin(a + 0.45) * 0.045)
_tri(head, c, l, tip, Vector3.UP, Color.WHITE)
_tri(head, c, tip, r, Vector3.UP, Color.WHITE)
var mesh := _commit(stem)
_commit(head, mesh)
return mesh