extends UnderseaGeissGraph
class_name TerraceGraph
## LARGE-SCALE TERRACED TERRAIN from a smooth function (2026-10-06), the landform the
## Geiss field (UnderseaGeissGraph) will later sit on as its detail octave.
##
## 0 Coordinates p
## 1 Smooth H(x,z) = ground_y + relief-scaled 2D Perlin fBm (Scale 1/wavelength,
## Detail `detail`): ~2 km landforms, ~300 m between its p5 and p95
## 2 Terraces at RANDOM heights: level k sits at (k + step_random * hash(k)) * step
## above ground_y (steps of step * (1 ± step_random), ~5..67 m by
## default). With h = H - ground_y in [lo, hi] between two levels,
## T = ground_y + lo + smooth_snap((h - lo) / (hi - lo), sharpness) *
## (hi - lo) — flat treads joined by steep risers, CONTINUOUS (smooth_snap
## is 0 at 0 and 1 at 1, so both sides of a level meet: never a jump, which
## is what meshed as razor sheets in the first undersea graph).
## sharpness = the snap's power: 16 (Geiss's own) gives near-vertical risers.
## 3 Output d = T - y (a heightfield: positive = solid below the surface)
##
## Knobs are labelled nodes as in UnderseaGeissGraph. The graph lives in the bridged
## scene SCENE_T (seeded by tools/build_terrace_graph.gd), edited in Godot or Blender.
##
## build() ids: "p", "H" (smooth), "T" (terraced height), "out".
const TKNOBS := {
"ground_y": 0.0,
"wavelength": 2000.0, # m — Perlin Scale = 1 / wavelength
"detail": 2.0, # Blender Detail: 2 = three octaves
"roughness": 0.5,
"relief": 300.0, # m between the smooth field's p5 and p95
# 25 -> 36 and 0.6 -> 0.85 (user, 2026-10-06: "about 30% fewer terraces with larger
# height variance"): levels scale as 1/step, 25/36 = 0.69.
"step": 36.0, # m — mean terrace height
"step_random": 0.85, # each step is step * (1 ± step_random): ~5..67 m (<= 1)
"sharpness": 16.0, # smooth_snap power: higher = flatter treads, steeper risers
}
## p95 - p5 of the normalized Perlin Factor at Detail 2 (MEASURED by gate_terrace_graph
## T4, which fails if the relief stops matching the knob).
const F_SPAN := 0.30
## emit_output (inherited from UnderseaGeissGraph) false: build() stops after T (ids
## "T", "sep", "p") and adds no output — for TerraceGeissGraph.
const SCENE_T := "res://blender/geiss_terraces.tscn"
const HOLDER_T := ^"Terraces/ProcGeoNodes"
const M_DIV := 3
static func make_terraces(sample_at: Variant = null, knobs: Dictionary = {}) -> ProcNodeGraph:
var b := TerraceGraph.new()
b.build(ProcNodeGraph.new(), sample_at, knobs)
return b.g
## TKNOBS, overridden by whatever `graph` carries.
static func tknobs_for(graph: ProcNodeGraph) -> Dictionary:
var kn := TKNOBS.duplicate()
kn.merge(UnderseaGeissGraph.knobs_from(graph), true)
return kn
## The terraced surface height T at (x, z), on the CPU, with `knobs` (e.g. the scene's,
## from tknobs_for). For placing cameras and spawns FROM THE FIELD: a hard-coded start
## height put map_geiss_world's camera 30 m inside the rock (T(0, 300) = 150 m).
static func surface_at(x: float, z: float, knobs: Dictionary = {}) -> float:
var b := TerraceGraph.new()
var ids := b.build(ProcNodeGraph.new(), Vector3(x, 0.0, z), knobs)
return float(b.g.evaluate_socket(ids["T"], 0))
## The graph out of SCENE_T, or null (and a warning saying why). As load_graph.
static func load_terraces(path: String = SCENE_T) -> ProcNodeGraph:
if not ResourceLoader.exists(path):
push_warning("[terraces] %s is missing — seed it with tools/build_terrace_graph.gd" % path)
return null
var inst := (load(path) as PackedScene).instantiate()
var holder := inst.get_node_or_null(HOLDER_T)
var graph := holder.get("graph") as ProcNodeGraph if holder != null else null
if graph == null:
push_warning("[terraces] %s has no graph at %s" % [path, HOLDER_T])
inst.free()
return graph
func build(graph: ProcNodeGraph, sample_at: Variant = null, knobs: Dictionary = {},
_mode: String = FULL) -> Dictionary:
g = graph
k = TKNOBS.duplicate()
k.merge(knobs, true)
if not keep_knobs:
_knobs.clear()
_knob_row = 0
_decor = 0
var ids := {}
_stage(0) # coordinates
var p: int
if p_node >= 0:
p = p_node
elif sample_at == null:
p = _node(PositionNode.new())
else:
p = _node(VectorNode.new())
g.get_node(p).set("value", sample_at as Vector3)
ids["p"] = p
var sep := _node(SeparateXYZNode.new())
_link(p, 0, sep, 0)
_stage(1) # the smooth field
var xz := _node(CombineXYZNode.new())
_link(sep, 0, xz, 0)
_link(sep, 2, xz, 1)
var inv := _node(MathNode.new()) # 1 / wavelength
g.get_node(inv).set_operation(M_DIV)
_def(inv, 0, 1.0)
_link(_knob("wavelength"), 0, inv, 1)
var n := _node(PerlinNoiseNode.new())
g.get_node(n).set_noise_dimensions(2)
_link(xz, 0, n, 0)
_link(inv, 0, n, 2)
_link(_knob("detail"), 0, n, 3)
_link(_knob("roughness"), 0, n, 4)
var amp := _mathc(M_MUL, _knob("relief"), 0, 1.0 / F_SPAN)
var h := _math(M_MUL, _mathc(M_SUB, n, 0, 0.5), 0, amp, 0) # height above ground_y
ids["H"] = _math(M_ADD, h, 0, _knob("ground_y"), 0)
_stage(2) # terraces at random heights
# One level per `step` m cell, at b_k = (k + step_random * hash(k)) * step; ordered for
# step_random <= 1, so spacings span step * (1 ± step_random). h lies in [b_k, b_k+1]
# when b_k <= h, else in [b_k-1, b_k]; the snap runs across that interval, so the two
# sides of every level meet exactly — continuous, like the uniform version.
var kc := _math(M_FLOOR, _math(M_DIV, h, 0, _knob("step"), 0), 0, -1, 0)
var b0 := _level(kc)
var bm := _level(_mathc(M_SUB, kc, 0, 1.0))
var bp := _level(_mathc(M_ADD, kc, 0, 1.0))
var below := _math(M_GREATER, b0, 0, h, 0) # 1 when level k is above h
var lo := _node(MixNode.new()) # mix(b0, bm, below)
_link(below, 0, lo, 0)
_link(b0, 0, lo, 2)
_link(bm, 0, lo, 3)
var hi := _node(MixNode.new()) # mix(bp, b0, below)
_link(below, 0, hi, 0)
_link(bp, 0, hi, 2)
_link(b0, 0, hi, 3)
var span := _math(M_SUB, hi, 0, lo, 0)
var t := _snap(_math(M_DIV, _math(M_SUB, h, 0, lo, 0), 0, span, 0), _knob("sharpness"))
ids["lo"] = lo
ids["snap"] = t
var tt := _math(M_ADD, lo, 0, _math(M_MUL, t, 0, span, 0), 0)
var T := _math(M_ADD, tt, 0, _knob("ground_y"), 0)
ids["T"] = T
ids["sep"] = sep
if not emit_output:
return ids # a composer adds its own output
_stage(3) # output
var out := _math(M_SUB, T, 0, sep, 1)
ids["out"] = out
var go := _node(GroupOutputNode.new())
g.get_node(go).set_interface([{"name": "Density", "type": 0}])
_link(out, 0, go, 0)
ids["group_output"] = go
return ids
## Level k's height above ground_y: (k + step_random * hash(k)) * step.
func _level(kc: int) -> int:
var hsh := _node(WhiteNoiseNode.new())
g.get_node(hsh).set_noise_dimensions(1)
_link(kc, 0, hsh, 1) # W = level index
var off := _node(MathNode.new()) # hash * r + k
g.get_node(off).set_operation(M_MULADD)
_link(hsh, 0, off, 0)
_link(_knob("step_random"), 0, off, 1)
_link(kc, 0, off, 2)
return _math(M_MUL, off, 0, _knob("step"), 0)
## smooth_snap(t, m) with the power from a node (UnderseaGeissGraph._smooth_snap takes a
## constant): c + s * ((c + s t) * 2)^m * 0.5, c = (t > 0.5), s = 1 - 2c.
func _snap(t: int, m: int) -> int:
var c := _mathc(M_GREATER, t, 0, 0.5)
var s := _mathc(M_ADD, _mathc(M_MUL, c, 0, -2.0), 0, 1.0)
var u := _node(MathNode.new()) # s * t + c
g.get_node(u).set_operation(M_MULADD)
_link(s, 0, u, 0)
_link(t, 0, u, 1)
_link(c, 0, u, 2)
var pw := _math(M_POW, _mathc(M_MUL, u, 0, 2.0), 0, m, 0)
var r := _node(MathNode.new()) # s * (pw * 0.5) + c
g.get_node(r).set_operation(M_MULADD)
_link(s, 0, r, 0)
_link(_mathc(M_MUL, pw, 0, 0.5), 0, r, 1)
_link(c, 0, r, 2)
return r