OverviewFilesCommitsIssuesDocsReleasesPeers

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

556 lines · gd ref:
extends RefCounted
class_name UnderseaGeissGraph
## OUR GEISS FIELD, as a proc-nodes density graph (demo sprint §4; rebuilt 2026-10-06).
##
## TARGET: the map_geiss we had before 2026-10-04 — DCTerrainGeneratorGeiss running the
## GPU Gems 3 ch.1 density (hyper-tps a77f2a8~1 + dc-terrain 8da1f1d~1). The first
## replacement (P1-P4) kept the technique's NAMES but not its STRUCTURE: an unwarped
## heightfield seabed, three noise bands, and a discontinuous floor sawtooth that meshed
## as floating razor sheets. The user: "it looks nothing like the geiss map". So this
## follows the old density stage for stage, with OUR noise (Blender Perlin through
## proc-nodes) in place of NVIDIA's 16^3 value-noise volumes.
##
## Positive = solid, metres, 1 unit = 1 m (the old map's scale: surface near y = 0,
## ~±40 m of relief, ~50 m features). Every stage reads p_rel = p - (0, ground_y, 0).
##
##   0 Coordinates  p, p_rel
##   1 Masks        six very-low-frequency noises (~87 m lattice) that switch features on
##                  per REGION: warp strength, shelf, terraces, terrace warp share, hard
##                  floor, ridges. Without them every feature is everywhere at once.
##   2 Pre-warp     wp = p + warp(p) * prewarp * warp_mask — 3 channels x 2 octaves (~15
##                  and ~33 m lattices). The source of the overhangs.
##   3 Ground       -wp.y (the WARPED height — this is what makes the ground itself fold)
##                  + a regional hard floor: hard_floor m of solid below ~-14 m, which
##                  meshes as the flat floors in the hollows.
##   4 Shelf        density -> shelf_strength (a mix, not an add) in a shelf_thickness band
##                  around shelf_y, regional: a crust ledge with an eroded underside.
##   5 Terraces     CONTINUOUS steps: fy = -mix(y, wp.y, share) * terrace_freq, then
##                  floor(fy) + smooth_snap(fract(fy), 16) — flat treads joined by steep
##                  risers, never a jump; plus the overhang term (snap - fract). Regional.
##   6 Ridges       noise on wp * (2, 27, 2): thin repeating horizontal ridges. Regional.
##   7 Octaves      eight, lattices 0.38 m .. 54 m, amplitude x frequency ~constant; the
##                  two coarsest on ROTATED copies of wp. Scaled by octave_gain.
##   8 Output       full:   d = everything above              (map_geiss)
##                  detail: G = d + p_rel.y  (d without its flat ground plane: the erosion /
##                          diffusion generator adds G to its own heightfield, so there the
##                          warp moves the ground vertically only)
##
## NOISE CALIBRATION (measured 2026-10-06). The old field sampled value noise whose
## unsigned value has mean 0.4354 and std 0.1455 (linear lookups), 0.1712 (smoothed),
## 0.1741 (manual smooth trilinear) — measured from the volumes through the GPU-validated
## numpy twin. proc-nodes Perlin Factor (3D, Detail 0) has mean 0.5000, std 0.1325. Each
## lookup maps F -> 0.4354 + k (F - 0.5), k = old std / 0.1325, so every octave carries
## the old one's spread AND its bias (signed mean -0.129 x amplitude, part of where the
## old surface sits). An old lookup at frequency f on a 16^3 volume has a 1/(16 f) m
## cell — but cell for cell is NOT the match: per cell, Perlin's gradient RMS is 2.702 x
## its spread, the old value noise's 1.694 (L) / 1.769 (M) / 1.775 (H). At the same cell
## our octaves were ~1.55x steeper and folded the surface twice as often (overhang
## columns 18.1 % vs 8.4 %, 2 km window; the octaves alone make the old field's
## overhangs: 0.0 % without them). So the Perlin cell is widened until the GRADIENT
## matches too: s = 16 f x old_ratio / 2.702. Spread from the amplitude, gradient from
## the frequency — two moments, both measured.
##
## THE KNOBS ARE NODES (§4 P4): Value / Vector nodes labelled "knob:<name>", edited in
## Godot or in Blender through blender-bridge. THE GRAPH LIVES IN A BRIDGED SCENE,
## blender/undersea_geiss.tscn; this builder SEEDS it (tools/build_undersea_geiss_graph.gd)
## and is the CPU reference the gates sample. KNOBS = the graph's knobs; SCENE_KNOBS =
## values the scene's consumers read that are not part of the field.
##
## build() returns node ids: "p", "out" (density), "G" (detail), and probes "wp",
## "warp_mask", "ground", "terrace", "ridge", "oct".

const KNOBS := {
	"ground_y": 0.0,           # m — the field's ground plane (Geiss: y = 0)
	"prewarp": 25.0,           # m of domain warp at full warp_mask
	"hard_floor": 40.0,        # m of solid added below the hard-floor band
	"shelf_y": -1.0,           # m, relative to ground_y
	"shelf_thickness": 2.5,    # m half-band
	"shelf_strength": 9.5,     # the density the shelf blends toward
	"terrace_freq": 0.13,      # steps per m  (~7.7 m treads)
	"terrace_str": 3.0,
	"overhang_str": 1.0,
	"ridge_str": 2.0,
	"octave_gain": 1.0,        # scales all eight octaves together
	"rot6_euler": Vector3(0.61, 1.13, 0.37),   # rotations of the two coarsest octaves
	"rot7_euler": Vector3(1.27, 0.42, 2.05),
}
## Read by the scene's consumers, not by the field: the rock shader's water line and the
## erosion / diffusion generator's coast band (set_undersea_coast).
const SCENE_KNOBS := {
	"water_y": 0.0,
	"coast_start": 30.0,       # m below water_y where the term begins (erosion / diffusion)
	"coast_full": 50.0,        # m below water_y where it is full strength
}

const KNOB_PREFIX := "knob:"
const FULL := "full"
const DETAIL := "detail"
## The lumpy part alone (pre-warp + octaves; no ground, shelf, terraces or ridges) —
## TerraceGeissGraph's cave volume.
const LUMPS := "lumps"

## Where the graph lives (see the header) and the node holding it. The bridge's own
## shape (the mountain's is Mountain/ProcGeoNodes): a MeshInstance3D is the object that
## crosses to Blender, and the graph rides on the ProcGeometryNode under it — a bare
## ProcGeometryNode is bridge plumbing and is never exported (blender_bridge.cpp
## _is_bridge_owned_node). The MeshInstance3D has no mesh: a density has no cage.
const SCENE := "res://blender/undersea_geiss.tscn"
const HOLDER := ^"Undersea/ProcGeoNodes"
## The label on G in the full graph — what detail_of() cuts out.
const DETAIL_LABEL := "out:detail"

# Blender DNA enum values (proc-nodes src/shared/blender_math_ops.h).
const M_ADD := 0
const M_SUB := 1
const M_MUL := 2
const M_POW := 10
const M_GREATER := 16
const M_ABS := 18
const M_FLOOR := 20
const M_FRACT := 22
const M_MULADD := 36
const V_ADD := 0
const V_MUL := 2
const V_SCALE := 10
const MR_LINEAR := 0

## Old lookup flavours -> (mean, std) of the unsigned value, and Perlin's (see header).
const OLD_MEAN := 0.4354
const OLD_STD := {"L": 0.1455, "M": 0.1712, "H": 0.1741}
const PERLIN_MEAN := 0.5
const PERLIN_STD := 0.1325
## Gradient RMS / spread, per lattice cell (see header).
const OLD_GRAD_RATIO := {"L": 1.694, "M": 1.769, "H": 1.775}
const PERLIN_GRAD_RATIO := 2.702

## The eight octaves: [amplitude, old frequency, flavour, source]. Source "wp", "c6",
## "c7" (the two rotated copies). density.h's own constants, products folded.
const OCTAVES := [
	[0.32 * 1.16, 0.1600 * 1.021, "L", "wp"],
	[0.64 * 1.12, 0.0800 * 0.985, "L", "wp"],
	[1.28 * 1.08, 0.0400 * 1.051, "L", "wp"],
	[2.56 * 1.04, 0.0200 * 1.020, "L", "wp"],
	[5.0, 0.0100 * 0.968, "L", "wp"],
	[10.0, 0.0050 * 0.994, "M", "wp"],
	[20.0 * 0.9, 0.0025 * 1.045, "M", "c6"],
	[40.0 * 0.8, 0.0012 * 0.972, "H", "c7"],
]
## Pre-warp: per axis, two smooth lookups [frequency factor, weight] at base 0.0041.
const WARP_BASE := 0.0041
const WARP := [[[0.971, 0.64], [0.461, 0.32]], [[0.997, 0.64], [0.453, 0.32]], [[1.032, 0.64], [0.511, 0.32]]]

var g: ProcNodeGraph
var k: Dictionary
var _col := 0
var _row := 0
var _knobs: Dictionary = {}       # knob name -> node id (created on first use)
var _knob_row := 0
var _decor := 0                   # decorrelation offset counter (one per lookup)
## COMPOSITION (TerraceGeissGraph): set before build().
## p_node >= 0 — use that existing node as the input position instead of making one.
## knob_prefix — label knobs "knob:<prefix><name>", so two builders' knobs in one graph
##   (both have a ground_y) stay distinct; values still come from `knobs` by bare name.
## col_offset — shift this builder's stage columns right, for a readable combined layout.
var p_node := -1
var knob_prefix := ""
var col_offset := 0
## keep_knobs — reuse the knob nodes already in _knobs (a composer hands several
##   builders ONE dictionary, so e.g. five terrace evaluations share one "knob:step").
var keep_knobs := false
## emit_output — false: build() returns its ids WITHOUT adding a Group Output. A composed
##   graph must have exactly ONE: the GPU compiler takes the FIRST Group Output it finds,
##   and with a sub-builder's still in the graph, map_geiss_world rendered the pre-cave
##   surface and never the caves (measured 2026-10-06: GPU == "surface" at every probe).
var emit_output := true


static func make(sample_at: Variant = null, knobs: Dictionary = {}, mode: String = FULL) -> ProcNodeGraph:
	var b := UnderseaGeissGraph.new()
	b.build(ProcNodeGraph.new(), sample_at, knobs, mode)
	return b.g


## The knob values a graph carries, by its "knob:<name>" labels. {} for none.
static func knobs_from(graph: ProcNodeGraph) -> Dictionary:
	var out := {}
	if graph == null:
		return out
	for i in graph.get_node_count():
		var n: ProcNode = graph.get_node(i)
		if n == null:
			continue
		var lbl := String(n.get("label"))
		if lbl.begins_with(KNOB_PREFIX):
			out[lbl.trim_prefix(KNOB_PREFIX)] = n.get("value")
	return out


## KNOBS and SCENE_KNOBS, overridden by whatever `graph` carries.
static func knobs_for(graph: ProcNodeGraph) -> Dictionary:
	var kn := KNOBS.duplicate()
	kn.merge(SCENE_KNOBS, true)
	kn.merge(knobs_from(graph), true)
	return kn


## The graph out of the bridged scene, or null (and a warning saying why).
## Instantiated and freed, not dug out of SceneState — the same reasoning as
## map_mountain._load_mountain_graph: the bridge rewrites the scene, the node keeps
## its name. The node is never in the tree, so ProcGeometryNode never evaluates.
static func load_graph(path: String = SCENE) -> ProcNodeGraph:
	if not ResourceLoader.exists(path):
		push_warning("[undersea] %s is missing — seed it with tools/build_undersea_geiss_graph.gd" % path)
		return null
	var ps := load(path) as PackedScene
	if ps == null:
		return null
	var inst := ps.instantiate()
	var holder := inst.get_node_or_null(HOLDER)
	var graph := holder.get("graph") as ProcNodeGraph if holder != null else null
	if graph == null:
		push_warning("[undersea] %s has no graph at %s" % [path, HOLDER])
	inst.free()
	return graph


## Give an erosion / diffusion generator (DCTerrainGeneratorProcNodes) the scene's
## undersea term: its coast band from the scene's coast_start / coast_full knobs, then
## detail_of(the scene's graph). Returns "" or why the term is not in use — the
## generator then keeps its plain submarine fBm, so the seabed never goes flat.
static func install_detail(gen: Object) -> String:
	var full := load_graph()
	var detail := detail_of(full)
	if detail == null:
		return "no detail term in %s" % SCENE
	var kn := knobs_for(full)
	gen.call("set_undersea_coast", float(kn["coast_start"]), float(kn["coast_full"]))
	gen.call("set_undersea_graph", detail)
	return String(gen.call("get_undersea_error"))


## The term G alone, for the erosion / diffusion generator: a COPY of `full` whose Group
## Output takes the node labelled DETAIL_LABEL. Copied through the bridge JSON
## (GraphExporter -> GraphImporter, the exact round trip test_density_roundtrip proves),
## so the scene's graph is never touched. Only the density's cone is emitted. null if
## `full` has no DETAIL_LABEL node or no Group Output.
static func detail_of(full: ProcNodeGraph) -> ProcNodeGraph:
	if full == null:
		return null
	var g: ProcNodeGraph = GraphImporter.import_from_json(GraphExporter.export_to_json(full))
	if g == null:
		return null
	var term := -1
	var go := -1
	for i in g.get_node_count():
		var n: ProcNode = g.get_node(i)
		if n == null:
			continue
		if String(n.get("label")) == DETAIL_LABEL:
			term = i
		elif n is GroupOutputNode:
			go = i
	if term < 0 or go < 0:
		push_warning("[undersea] the graph has no '%s' node or no Group Output — no detail term" % DETAIL_LABEL)
		return null
	var c := g.get_connections_flat()
	for i in range(0, c.size(), 4):
		if c[i + 2] == go and c[i + 3] == 0:
			g.disconnect_sockets(c[i], c[i + 1], go, 0)
	g.connect_sockets(term, 0, go, 0)
	return g


## Build into `graph`. `sample_at` = null -> Position (the shipped graph); a Vector3 ->
## a constant Vector node at that point, so a gate can evaluate_socket the density.
## `mode` FULL (the field) or DETAIL (G: the field without its ground plane).
func build(graph: ProcNodeGraph, sample_at: Variant = null, knobs: Dictionary = {},
		mode: String = FULL) -> Dictionary:
	g = graph
	k = KNOBS.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 gy := _node(CombineXYZNode.new())          # (0, ground_y, 0)
	_link(_knob("ground_y"), 0, gy, 1)
	var pr := _vmath(V_ADD, p, 0, _neg_vec(gy), 0)  # p_rel
	var sep := _node(SeparateXYZNode.new())
	_link(pr, 0, sep, 0)

	_stage(1)   # regional masks (density.h's uulf_rand / uulf_rand2 / uulf_rand3)
	# uulf_rand = clamp(NMQu * 2 - 0.5, 0, 1): pre-clamped, then each use re-ramps it.
	var r_y := _clamp01(_unsigned(pr, 0.000718, "M"), 2.0, -0.5)
	var r_z := _clamp01(_unsigned(pr, 0.000718, "M"), 2.0, -0.5)
	var r2_y := _unsigned(pr, 0.000632, "M")
	var r2_z := _unsigned(pr, 0.000632, "M")
	var r2_w := _unsigned(pr, 0.000632, "M")
	var r3_x := _unsigned(pr, 0.000695, "M")
	var warp_mask := _clamp01(r3_x, 1.4, -0.3)
	var shelf_mask := _clamp01(r_y, 1.5, -0.5)
	var terr_mask := _clamp01(r_z, 2.0, -1.0)
	var ridge_mask := _clamp01(r2_w, 2.0, -1.0)
	ids["warp_mask"] = warp_mask

	_stage(2)   # pre-warp
	var w := _node(CombineXYZNode.new())
	for a in 3:
		var s0 := _signed(pr, WARP_BASE * WARP[a][0][0], "H", WARP[a][0][1])
		var s1 := _signed(pr, WARP_BASE * WARP[a][1][0], "H", WARP[a][1][1])
		_link(_math(M_ADD, s0, 0, s1, 0), 0, w, a)
	var amt := _math(M_MUL, _knob("prewarp"), 0, warp_mask, 0)
	var ws := _node(VectorMathNode.new())
	g.get_node(ws).set_operation(V_SCALE)
	_link(w, 0, ws, 0)
	_link(amt, 0, ws, 3)
	var wp := _vmath(V_ADD, pr, 0, ws, 0)
	ids["wp"] = wp
	var wsep := _node(SeparateXYZNode.new())
	_link(wp, 0, wsep, 0)

	_stage(3)   # ground: -wp.y, plus the regional hard floor
	var ground := _mathc(M_MUL, wsep, 1, -1.0)
	# clamp((-4 - y*0.3) * 3, 0, 1) = 0 at y = -13.33, 1 at y = -14.44
	var hf_band := _map_l(sep, 1, -1, -40.0 / 3.0, -1, -130.0 / 9.0, -1, 0.0, -1, 1.0, true)
	var hf := _math(M_MUL, _math(M_MUL, hf_band, 0, _knob("hard_floor"), 0), 0, r2_z, 0)
	var d := _math(M_ADD, ground, 0, hf, 0)
	ids["ground"] = d

	_stage(4)   # shelf: blend toward shelf_strength in a band around shelf_y
	var off := _math(M_SUB, wsep, 1, _knob("shelf_y"), 0)
	var band := _math(M_SUB, _knob("shelf_thickness"), 0, _math(M_ABS, off, 0, -1, 0), 0)
	var band01 := _map_l(band, 0, -1, 0.0, -1, 1.0, -1, 0.0, -1, 1.0, true)
	var shelf_w := _mathc(M_MUL, _math(M_MUL, band01, 0, shelf_mask, 0), 0, 0.83)
	var shelf := _node(MixNode.new())               # float mix(d, shelf_strength, shelf_w)
	_link(shelf_w, 0, shelf, 0)
	_link(d, 0, shelf, 2)
	_link(_knob("shelf_strength"), 0, shelf, 3)
	d = shelf

	_stage(5)   # continuous terraces + their overhang term
	var share := _mathc(M_MUL, r2_y, 0, 0.5)
	var ymix := _node(MixNode.new())                # mix(y, wp.y, share)
	_link(share, 0, ymix, 0)
	_link(sep, 1, ymix, 2)
	_link(wsep, 1, ymix, 3)
	var fy := _math(M_MUL, ymix, 0, _neg(_knob("terrace_freq")), 0)
	var ft := _math(M_FRACT, fy, 0, -1, 0)
	var fl := _math(M_FLOOR, fy, 0, -1, 0)
	var t := _smooth_snap(ft, 16.0)
	var step := _math(M_ADD, fl, 0, t, 0)
	var ts := _math(M_MUL, _knob("terrace_str"), 0, terr_mask, 0)
	var os := _math(M_MUL, _knob("overhang_str"), 0, terr_mask, 0)
	var terr := _math(M_MUL, step, 0, ts, 0)
	var over := _math(M_MUL, _math(M_SUB, t, 0, ft, 0), 0, os, 0)
	var terrace := _math(M_ADD, terr, 0, over, 0)
	ids["terrace"] = terrace
	d = _math(M_ADD, d, 0, terrace, 0)

	_stage(6)   # ridges: anisotropic noise on the warped coordinate
	var q := _vmath(V_MUL, wp, 0, _vec(Vector3(2.0, 27.0, 2.0)), 0)
	var ridge := _math(M_MUL, _math(M_MUL, _signed(q, 0.0037, "L", 1.0), 0, _knob("ridge_str"), 0), 0, ridge_mask, 0)
	ids["ridge"] = ridge
	d = _math(M_ADD, d, 0, ridge, 0)

	_stage(7)   # octaves
	var src := {"wp": wp, "c6": _rotated(wp, "rot6_euler"), "c7": _rotated(wp, "rot7_euler")}
	var oct := -1
	for o in OCTAVES:
		var n := _signed(src[o[3]], float(o[1]), String(o[2]), float(o[0]))
		oct = n if oct < 0 else _math(M_ADD, oct, 0, n, 0)
	oct = _math(M_MUL, oct, 0, _knob("octave_gain"), 0)
	ids["oct"] = oct
	d = _math(M_ADD, d, 0, oct, 0)

	_stage(8)   # output
	var detail := _math(M_ADD, d, 0, sep, 1)        # d + p_rel.y: without the ground plane
	g.get_node(detail).set("label", DETAIL_LABEL)   # detail_of() cuts here
	ids["G"] = detail
	var out := oct if mode == LUMPS else (d if mode == FULL else detail)
	ids["out"] = out
	if not emit_output:
		return ids                                  # the composer adds the one output
	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


# ---- helpers -------------------------------------------------------------------

func _stage(col: int) -> void:
	_col = col + col_offset
	_row = 0


func _node(n: ProcNode) -> int:
	var i := g.add_node(n)
	n.set_graph_position(Vector2(_col * 260, _row * 140))
	_row += 1
	return i


func _link(a: int, sa: int, b: int, sb: int) -> void:
	g.connect_sockets(a, sa, b, sb)


func _def(n: int, s: int, v: Variant) -> void:
	g.get_node(n).set_input_default(s, v)


## The knob's node — a labelled Value (or Vector) node, made on first use and parked
## in a column of its own left of stage 0, so they read as the graph's settings.
func _knob(name: String) -> int:
	if _knobs.has(name):
		return _knobs[name]
	var v: Variant = k[name]
	var n: ProcNode = VectorNode.new() if v is Vector3 else ValueNode.new()
	n.set("value", v)
	n.set("label", KNOB_PREFIX + knob_prefix + name)
	var i := g.add_node(n)
	n.set_graph_position(Vector2(-300 + col_offset * 260, _knob_row * 90))
	_knob_row += 1
	_knobs[name] = i
	return i


func _vec(v: Vector3) -> int:
	var n := _node(VectorNode.new())
	g.get_node(n).set("value", v)
	return n


## a <op> b — b = -1 for a unary op.
func _math(op: int, a: int, sa: int, b: int, sb: int) -> int:
	var n := _node(MathNode.new())
	g.get_node(n).set_operation(op)
	_link(a, sa, n, 0)
	if b >= 0:
		_link(b, sb, n, 1)
	return n


## a <op> constant
func _mathc(op: int, a: int, sa: int, c: float) -> int:
	var n := _node(MathNode.new())
	g.get_node(n).set_operation(op)
	_link(a, sa, n, 0)
	_def(n, 1, c)
	return n


func _neg(a: int) -> int:
	return _mathc(M_MUL, a, 0, -1.0)


func _neg_vec(a: int) -> int:
	var n := _node(VectorMathNode.new())
	g.get_node(n).set_operation(V_SCALE)
	_link(a, 0, n, 0)
	_def(n, 3, -1.0)
	return n


func _vmath(op: int, a: int, sa: int, b: int, sb: int) -> int:
	var n := _node(VectorMathNode.new())
	g.get_node(n).set_operation(op)
	_link(a, sa, n, 0)
	_link(b, sb, n, 1)
	return n


## Map Range whose four bounds are each EITHER a linked node (id >= 0) or a constant.
func _map_l(src: int, ss: int, fmin_n: int, fmin: float, fmax_n: int, fmax: float,
		tmin_n: int, tmin: float, tmax_n: int, tmax: float,
		clamp_it: bool = false, interp: int = MR_LINEAR) -> int:
	var n := _node(MapRangeNode.new())
	g.get_node(n).set_clamp(clamp_it)
	g.get_node(n).set_interpolation_type(interp)
	_link(src, ss, n, 0)
	var bounds := [[fmin_n, fmin], [fmax_n, fmax], [tmin_n, tmin], [tmax_n, tmax]]
	for i in 4:
		if int(bounds[i][0]) >= 0:
			_link(int(bounds[i][0]), 0, n, i + 1)
		else:
			_def(n, i + 1, float(bounds[i][1]))
	return n


## clamp(x * a + b, 0, 1) as one clamped Map Range.
func _clamp01(x: int, a: float, b: float) -> int:
	return _map_l(x, 0, -1, -b / a, -1, (1.0 - b) / a, -1, 0.0, -1, 1.0, true)


## One Perlin lookup standing in for an old `flavour` lookup at frequency `f` (Scale
## 16 f, widened to match the gradient — see header), on a decorrelated copy of `vec`:
## each lookup gets its own large offset, as the old field used separate volumes and
## channels. Returns the Perlin node (socket 0 = Factor).
func _perlin_at(vec: int, f: float, flavour: String) -> int:
	_decor += 1
	var off := _vec(Vector3(131.7, 71.3, 197.9) * float(_decor))
	var sv := _vmath(V_ADD, vec, 0, off, 0)
	var n := _node(PerlinNoiseNode.new())
	g.get_node(n).set_noise_dimensions(3)
	_link(sv, 0, n, 0)
	_def(n, 2, 16.0 * f * float(OLD_GRAD_RATIO[flavour]) / PERLIN_GRAD_RATIO)
	_def(n, 3, 0.0)
	return n


## The old UNSIGNED lookup (mean OLD_MEAN, std OLD_STD[flavour]) from one Perlin.
func _unsigned(vec: int, f: float, flavour: String) -> int:
	var kf: float = OLD_STD[flavour] / PERLIN_STD
	var lo := OLD_MEAN - PERLIN_MEAN * kf
	return _map_l(_perlin_at(vec, f, flavour), 0, -1, 0.0, -1, 1.0, -1, lo, -1, lo + kf)


## The old SIGNED lookup times `amp` (2u - 1, scaled), as one Map Range.
func _signed(vec: int, f: float, flavour: String, amp: float) -> int:
	var kf: float = OLD_STD[flavour] / PERLIN_STD
	var lo := OLD_MEAN - PERLIN_MEAN * kf
	return _map_l(_perlin_at(vec, f, flavour), 0, -1, 0.0, -1, 1.0,
			-1, amp * (2.0 * lo - 1.0), -1, amp * (2.0 * (lo + kf) - 1.0))


## smooth_snap(t, m) = c + s * ((c + s t) * 2)^m * 0.5, c = (t > 0.5), s = 1 - 2c:
## 0 -> 0, 0.5 -> 0.5, 1 -> 1, flat at both ends — continuous, so floor(fy) + it never jumps.
func _smooth_snap(t: int, m: float) -> int:
	var c := _mathc(M_GREATER, t, 0, 0.5)
	var s := _mathc(M_MUL, c, 0, -2.0)
	s = _mathc(M_ADD, s, 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 := _mathc(M_POW, _mathc(M_MUL, u, 0, 2.0), 0, m)
	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


## `vec` rotated by the Euler knob `knob`.
func _rotated(vec: int, knob: String) -> int:
	var rot := _node(EulerToRotationNode.new())
	_link(_knob(knob), 0, rot, 0)
	var rv := _node(RotateVectorNode.new())
	_link(vec, 0, rv, 0)
	_link(rot, 0, rv, 1)
	return rv