OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / undersea / terrace_graph.gd

195 lines · gd ref:
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