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