Merge eureka/aetheria-fauna-curl-probe (docs only)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 13:37:13 +02:00
commit eb5083dd44
23 changed files with 759 additions and 0 deletions

View file

@ -0,0 +1,168 @@
# Curl flow probe (2026-10-14)
Operator: "can we use something prettier for the flow noise?" This probe renders candidates for
`FlowField.Global` (today a Tri3D triangle noise, `Assets/Scripts/ServerShared/FlowField.cs` on master, lowered
in `Assets/Shaders/Volumetric.cginc` `globalFlow`) on the habitat probe's zone and scale. It is evidence for a
question, not a design.
`curl_probe.py` imports `../2026-10-14-fauna-habitat-probe/habitat_probe.py`: the zone (4000 u, the same six
bodies), `Zone.GetNormal`, and the FlowField transliteration (scale 512 u, amplitude 15, scroll speed .025). The
probe's numpy Tri3D matches `habitat_probe.flow_global` to 0.0 over 50 points. The new code is CultMath
`math.snoise_grad` (`packages/cultmath/src/CultMath/math.cs:714`), transliterated term for term to numpy
float64 with the same `step` tie order, and `math.fbm_grad`. The analytic gradient matches central differences
to 2.2e-8. The noise is float64, not the float32 law.
## What the curl candidates are
The flow is 2D: a stream function `psi(x/S, z/S, tau)`, velocity `k * (dpsi/dz, -dpsi/dx)`, no y component.
Today's flow has a halved y component; dropping it is part of the change. The curl of a scalar stream function
is divergence-free by construction (Bridson, Hourihan and Nordenstam, "Curl-noise for procedural fluid flow",
SIGGRAPH 2007, cited from memory). Each `k` is set so the RMS speed over the zone equals Tri3D's (6.60 u/s), so
the speeds below compare shape at equal energy.
| # | Name | Stream function |
|---|---|---|
| 0 | `0-tri3d` | Control: today's `FlowField.Global`, at y = 0. |
| 1 | `1-curl-simplex` | `snoise_grad(x/S, z/S, tau)`, one octave. k = 1.87. |
| 2 | `2-curl-fbm3` | `fbm_grad(..., 3 octaves, lacunarity 2, gain .4)`. The gradient weights octave i by `(2 * .4)^i`, so velocity carries 1, .8 and .64 per octave. k = 1.13. |
| 3 | `3-curl-warped` | `fbm_grad` with 2 octaves at a warped position `q = p + .55 * (n1, n2)`. n1 and n2 are two offset `snoise_grad` at frequency .6. The chain rule through the warp's Jacobian is exact, so the flow stays divergence-free. k = .76. |
| 4 | `4-curl-fbm3-slope` | #2 plus `FlowField.Slope` with swirl 6 and slide 1.5 (B25g), as the flow-slope-terms ruling turns it on. |
| 4b | `4b-curl-warped-slope` | #3 plus the same slope terms. |
| 5 | `5-lab-stretch-fixed` | The Nebula Span Lab's construction as a stream function (see below and `labs-noise-facts.md`): `fbm_grad` (3 octaves, lacunarity 2, gain .5) of the stretched argument `Mop(v) = v + (1/5 - 1) dot(v, d) d`, with d fixed at azimuth 25 (the lab's "Streaming filaments" preset). The flow is exact analytically, since Mop is symmetric. k = 1.09. |
| 5b | `5b-lab-stretch-contour` | #5 with d set to the local contour tangent (the swirl direction around wells, bilinear on the gravity grid). The analytic chain rule would need dd/dp, which is the height Hessian, so psi is differenced instead (4 evaluations). k = 1.25. |
| 5c | `5c-lab-stretch-contour-slope` | #5b plus the slope terms. |
Time is the noise's third axis: `tau = fmod(.025 * zoneTime, 867)`, folded in double as `FlowField.Scroll`
folds `frac`. This works because `snoise(float3)` has a period of exactly 867 along each axis: a shift of 867
along any axis moves the skewed lattice by `289 * (1, 1, 4)`, and every hash is taken mod 289. Measured in
float64 over 4,000 points, the shift changes the value by at most 1.2e-11 on each axis, and 2.9e-11 for the
3-octave fbm. The fold therefore adds no seam. It holds for fbm only with an integer lacunarity.
## Numbers
Each statistic is taken over 20,000 random points in 95 % of the zone, at t = 0. Divergence and vorticity are
central differences with a 1 u step. "Still" is the fraction of points with speed below .1 RMS.
| # | div RMS | vort RMS | div / vort | speed p10 / p50 / p90 / p99 / max (u/s) | still | snoise_grad per eval |
|---|---|---|---|---|---|---|
| 0 | .207 | .153 | **1.35** | 1.7 / 5.9 / 9.6 / 11.6 / 12.2 | 3.2 % | 0 (2 `normalize(tri3)` + `cross`) |
| 1 | 1.4e-6 | .100 | 1.4e-5 | 1.5 / 3.9 / 8.4 / 23.3 / 45.7 | 2.3 % | 1 |
| 2 | 4.5e-5 | .267 | 1.7e-4 | 1.5 / 4.1 / 10.5 / 19.3 / 32.3 | 1.9 % | 3 |
| 3 | 9.1e-4 | .585 | 1.5e-3 | 0.8 / 2.7 / 8.9 / 25.3 / **125.7** | 8.2 % | 4 |
| 4 | .175 | .334 | .52 | 3.7 / 7.3 / 12.9 / 21.4 / 34.7 | 0.4 % | 3 + slope term |
| 4b | .152 | .634 | .24 | 4.2 / 6.7 / 11.2 / 26.5 / 107.7 | 0.4 % | 4 + slope term |
| 5 | 5.6e-5 | .273 | 2.0e-4 | 0.9 / 3.2 / 10.7 / 22.9 / 36.1 | 6.4 % | 3 |
| 5b | 1.98 | 3.33 | .59 | 0.6 / 1.9 / 6.1 / 22.8 / **174.8** | 10.4 % | 12 as probed; 3 plus a height Hessian analytically |
| 5c | 2.02 | 3.35 | .60 | 4.3 / 6.4 / 9.6 / 24.0 / 212.0 | 0.2 % | as 5b plus the slope term |
The curl candidates' divergence is finite-difference error. Tri3D's is real: today's flow is more compressive
than rotational (1.35). That compression is why its long-exposure drift collapses into kinked convergence lines.
Cost is counted in calls; the probe measured no GPU timings. One `snoise_grad` is a few times the ALU of Tri3D.
The `+slope` rows add one gravity-texture gradient in the shader, which `FLOW_SLOPE` already pays today.
### Scroll
The table gives the correlation between the velocity field at t = 0 and at t, on a 64 x 64 grid.
| # | 10 s | 40 s | 120 s |
|---|---|---|---|
| 0 | .32 | .45 | .80 |
| 1 | .57 | -.09 | .09 |
| 2 | .29 | -.04 | .12 |
| 3 | .10 | -.01 | .01 |
| 5 | .22 | -.03 | .15 |
| 5b | .31 | -.01 | -.03 |
Tri3D is quasi-periodic: octave periods of 40 s and 24.7 s make the field recur, with a correlation of .8 at
120 s. The curl fields evolve without recurring for `867 / .025 = 34,680 s` (9.6 h). The warped field turns over
fastest, within 10 s. Its warp also rides tau, so it boils; giving the warp a slower tau rate is the knob.
Float32 precision of tau, as a noise coordinate: folded, the ULP is at most 6.1e-5 noise units (0.03 u at
S = 512) at any zone age. Unfolded, it is 2.4e-4 at 1 day, 2.0e-3 at 10 days and 1.6e-2 at 100 days (8 u
steps). The fold is required, as it is today.
## Images (option order)
- `lic_contact_sheet.png`: every LIC side by side, in the order 0, 1, 2, 3, 4, 4b, 5, 5b, 5c.
- `lic_<name>.png`: a line-integral convolution of the flow over the whole zone (512 px, t = 0). Brightness and
colour rise with speed relative to Tri3D's RMS. Red rings mark the wells.
- `drift_<name>.png`, left panel: the Stardust law for one cycle (Period 2 s from `ARPG.unity`;
`position -= flow(spawn) * lifetime * Period`, `Stardust.compute:142`), each particle's whole-cycle streak, in a
1200 u window. Right panel: the same seeds advected along the field for 40 s with RK2. This long exposure shows
the field's texture; Stardust does not draw it.
Reading the images. Tri3D shows the triangular lattice measured in the habitat probe (B25h): repeated chevrons,
and in the long exposure, crisp convergence streaks. `1-curl-simplex` is clean but bubbly: isolated round eddies
with fast rims (its p99 is 3.7x RMS), which reads as a pattern of its own. `2-curl-fbm3` is busier and more even,
with the same eddies plus small-scale turbulence. `3-curl-warped` is the most fluid: shearing filaments, folded
eddies and long streaming sheets in the exposure. It carries thin fast jets (max 19x RMS) and 8 % near-still
water. In `4` and `4b` the sun's swirl dominates: the sun's well covers the whole zone, so a 6 u/s swirl
underlies everything, and the slide term restores convergence (div/vort .24 to .52).
In the one-cycle Stardust panels, every candidate looks like short straight dashes, because Stardust
back-projects linearly at the spawn point over 2 s (up to 30 u). The noise's prettiness shows on screen through
the dashes' direction coherence and through the nebula's flow-advected noise, not through particle paths.
## The Nebula Span Lab candidate (5, 5b, 5c)
Self's input: the operator said "The last nebula playground I saw had some pretty sophisticated noise".
`labs-noise-facts.md` holds the Eyes extraction of that page, copied here unchanged. Its noise is a density warp,
not a flow. The page builds a two-level warp chain of fbm, ridged `1 - 2|n|` or curl fields over CultMath
simplex, with up to 4 octaves and a stretch along one fixed direction. It moves only by adding a constant offset
to the noise argument. Candidates 5 to 5c turn the lab's stretched fbm into a stream function and take its curl,
so the result is a velocity field. It scrolls on the same folded tau as the other curl candidates.
- **5, fixed axis**: long parallel jets, all leaning along azimuth 25. It reads as a prevailing wind across the
whole zone. It is exactly divergence-free and costs the same as #2 (3 `snoise_grad`). A fixed axis is a global
heading the zone has no physical reason to own.
- **5b and 5c, axis following the slope: the probe found this does not work as built.** The lab's stretch is a
global linear map of the position (`dot(v, d) d`). With a varying d, the argument changes by about
`|p| * delta(d)`, so it shears fastest far from the origin and where d turns quickly. At the well floors and
saddles the contour tangent is undefined or flips sign, and the bilinear axis passes through zero there. The
LIC shows dipole fans on every well and seam lines through the saddles. The numbers show it: a max speed 28x
RMS, and a measured div/vort of .59. The flow is a discrete curl, but its structure is finer than the
measurement step. Following the slope coherently needs the stretch in a local frame rather than in world
position. One route is to sample the noise in coordinates of the gravity potential, such as height and arc
along the contour. The other is to build the anisotropy from the height Hessian. Neither is probed here.
- **Following the Skein instead**: this is the same construction with d set to the nearest strand's tangent. It
hits the same fault (the stretch is global in position, and the axis is undefined at strand junctions and
between strands), so it needs the same local-frame fix. It is not rendered.
## CultMath gaps, by name
- **No curl node.** A 2D curl is a swizzle of `snoise_grad`/`fbm_grad` (`(g.y, -g.x)` of the xz-plane
gradient), but nothing names it, and Aetheria would carry the stream-function law. Candidates are
`curl2_snoise(float3)` and `curl2_fbm(float3, octaves, lacunarity, gain)`, in C# and HLSL, under the
mirror tests.
- **`fbm_grad` exists** in C#, HLSL and GLSL. Candidates 1 and 2 need nothing else from CultMath.
- **No warp-with-Jacobian.** Candidate 3 composes the chain rule by hand. CultMath has no
`warp_grad`/composition node that carries derivatives through a domain warp. The tape work
(CultLib `docs/cultmath-tape-target.md`) is where derivative composition would live.
- **No 4D simplex** (`snoise(float4)`/`snoise_grad(float4)`). A true 3D curl (Bridson's vector potential: three
potentials, so 3 `snoise_grad` per octave) that also evolves in time needs a fourth axis. Without one, a 3D
flow must scroll by translating instead of evolving. This matters only if the flow keeps a y component.
- **No Hessian of simplex.** The lab generates `cultmath_snoise_gh` locally by splicing CultMath's gradient
kernel (`makeGH`). CultMath has only the bound constant `CULTMATH_SNOISE_HESSIAN`. A slope-following stretch
done analytically also needs the height field's Hessian, which is a separate gap on the Aetheria side.
- **Lab-local pieces that CultMath lacks** (from `labs-noise-facts.md` section 2): the curl mode (`dispVec`
mode 2, `curlK`, a 3-component vector-potential curl); the ridged form `1 - 2|n|` with per-octave offset
(CultMath's `ridged_grad` is `1 - |n|`); the stretch operator `Mop` with per-component offsets
(`fieldComp`); and the warp chain (`fieldS`, `dispVec`, `dispY`, `brushPoint`, `ampG`). The facts also record
a constant mismatch: the lab pins `SN_L = 10.099261` and calls it CultMath's, but CultMath's
`SNOISE_LIPSCHITZ` is now 7.9640074.
- **No period constant.** The 867 period is a fact of `snoise(float3)` that nothing in CultMath states or pins.
A named `snoise_period` and a test pinning it would let FlowField fold tau in double without relying on this
probe.
## Consequences outside the noise
- **Convergence.** Tri3D's divergence is what makes drift pile into lines. A curl flow has none, so any habitat
or fauna term that reads flow convergence, such as the B25 convergence candidate, goes to zero except through
the slide term into wells.
- **Parity.** `aetheria-fauna:follow_up:flow-parity-under-cultmath` asks for Tri3D to move under CultMath. A curl
flow built from `snoise_grad`/`fbm_grad` has its primitives in CultMath already, so FlowFieldParityVerify
shrinks to the composition check and Tri3D never needs a CultMath twin.
To rerun: `py -3 -u curl_probe.py 512` from this folder, about 45 s. It needs numpy and Pillow, unlike the
sibling probes, and the habitat probe at its relative path. `run.log` is the committed run.

View file

@ -0,0 +1,338 @@
"""Curl-flow probe (Imagination, aetheria-fauna, 2026-10-14). Python 3 + numpy + Pillow.
Renders candidate replacements for FlowField.Global (today's Tri3D) on the habitat probe's zone and scale.
Reuses ../2026-10-14-fauna-habitat-probe/habitat_probe.py for the zone, the wells, Zone.GetNormal and the
FlowField law transliteration (flow_global, scroll, FLOW settings: scale 512, amplitude 15, speed .025).
New here, and only here:
- snoise_grad: CultMath math.snoise_grad (packages/cultmath/src/CultMath/math.cs:714) transliterated term for
term to numpy float64, including the tie order of the step() lines. Float64, not the float32 law.
- fbm_grad: CultMath math.fbm_grad, same octave compounding.
- curl candidates: 2D curl of a scalar stream function psi(x/S, z/S, tau), v = k (dpsi/dz, -dpsi/dx),
with tau = speed * t folded modulo 867 (snoise(float3) is exactly periodic by 867 along each axis; see
period_check). Velocity has no y component.
Usage: py -3 -u curl_probe.py [N] (N = LIC grid, default 512). Writes PNGs and prints numbers.
"""
import math, os, sys, time
import numpy as np
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "2026-10-14-fauna-habitat-probe"))
_argv = sys.argv; sys.argv = sys.argv[:1]
import habitat_probe as H # zone, wells, normal(), flow_global(), scroll(), FLOW
sys.argv = _argv
N = int(sys.argv[1]) if len(sys.argv) > 1 else 512
R, S = H.R, H.FLOW["scale"] # zone half-width 2000 u, flow scale 512 u
SPEED = H.FLOW["speed"] # .025 field units per second of zone time
PERIOD = 867.0 # snoise(float3) period along each axis, in noise units
STARDUST_PERIOD = 2.0 # Stardust Period in ARPG.unity / Main Menu.unity
SWIRL, SLIDE = 6.0, 1.5 # B25g slope terms (flow-slope-terms ruling)
rng = np.random.default_rng(7)
# ---------------- CultMath snoise_grad, float64 transliteration ----------------
def _mod289(v): return v - np.floor(v * (1.0 / 289.0)) * 289.0
def _permute(v): return _mod289(((v * 34.0) + 10.0) * v)
def _step(edge, value): return np.where(value < edge, 0.0, 1.0)
def snoise_grad(P):
"""P: (...,3). Returns (...,4): gradient xyz, value w."""
cx, cy = 1.0 / 6.0, 1.0 / 3.0
i = np.floor(P + (P.sum(-1, keepdims=True) * cy))
x0 = P - i + (i.sum(-1, keepdims=True) * cx)
g = np.stack([_step(x0[..., 1], x0[..., 0]), _step(x0[..., 2], x0[..., 1]), 1.0 - _step(x0[..., 2], x0[..., 0])], -1)
l = 1.0 - g
lzxy = l[..., [2, 0, 1]]
i1 = np.minimum(g, lzxy); i2 = np.maximum(g, lzxy)
x1 = x0 - i1 + cx; x2 = x0 - i2 + cy; x3 = x0 - 0.5
i = _mod289(i)
z4 = lambda k: np.stack([np.zeros_like(i[..., k]), i1[..., k], i2[..., k], np.ones_like(i[..., k])], -1)
p = _permute(_permute(_permute(i[..., 2:3] + z4(2)) + i[..., 1:2] + z4(1)) + i[..., 0:1] + z4(0))
n = 0.142857142857
nsx, nsy, nsz = 2.0 * n, 0.5 * n - 1.0, n
j = p - 49.0 * np.floor(p * nsz * nsz)
xs = np.floor(j * nsz); ys = np.floor(j - 7.0 * xs)
x = xs * nsx + nsy; y = ys * nsx + nsy
h = 1.0 - np.abs(x) - np.abs(y)
b0 = np.stack([x[..., 0], x[..., 1], y[..., 0], y[..., 1]], -1)
b1 = np.stack([x[..., 2], x[..., 3], y[..., 2], y[..., 3]], -1)
s0 = np.floor(b0) * 2.0 + 1.0; s1 = np.floor(b1) * 2.0 + 1.0
sh = -_step(h, 0.0)
a0 = b0[..., [0, 2, 1, 3]] + s0[..., [0, 2, 1, 3]] * sh[..., [0, 0, 1, 1]]
a1 = b1[..., [0, 2, 1, 3]] + s1[..., [0, 2, 1, 3]] * sh[..., [2, 2, 3, 3]]
ps = [np.stack([a0[..., 0], a0[..., 1], h[..., 0]], -1), np.stack([a0[..., 2], a0[..., 3], h[..., 1]], -1),
np.stack([a1[..., 0], a1[..., 1], h[..., 2]], -1), np.stack([a1[..., 2], a1[..., 3], h[..., 3]], -1)]
xs_ = [x0, x1, x2, x3]
value = 0.0; grad = 0.0
for pk, xk in zip(ps, xs_):
pk = pk * (1.79284291400159 - 0.85373472095314 * (pk * pk).sum(-1, keepdims=True))
m0 = np.maximum(0.5 - (xk * xk).sum(-1, keepdims=True), 0.0)
m3 = m0 ** 3; m4 = m0 ** 4
px = (pk * xk).sum(-1, keepdims=True)
value = value + m4 * px
grad = grad + (-8.0 * m3 * xk * px + m4 * pk)
return np.concatenate([grad * 105.0, value * 105.0], -1)
def fbm_grad(P, octaves, lacunarity, gain):
a, f, out = 1.0, 1.0, 0.0
for _ in range(octaves):
nn = snoise_grad(P * f)
out = out + np.concatenate([a * f * nn[..., :3], a * nn[..., 3:]], -1)
f *= lacunarity; a *= gain
return out
# ---------------- time fold ----------------
def tau_of(t):
"""speed * t folded modulo the noise period in double, as FlowField.Scroll folds frac in double."""
return math.fmod(SPEED * t, PERIOD)
# ---------------- candidates: world xz (arrays) -> velocity xz ----------------
def tri3d(x, z, t):
sc = H.scroll(t); G = H.GOLD
fx, fz = x / S, z / S; fy = np.zeros_like(fx)
def tri(v): return np.abs(v - np.floor(v) - .5)
def tri3(a, b, c): return tri(c + tri(b)), tri(c + tri(a)), tri(b + tri(a))
def nrm(a, b, c): l = np.sqrt(a * a + b * b + c * c); return a / l, b / l, c / l
a = nrm(*tri3(fx, fy - sc[0], fz)); b = nrm(*tri3(fx * G, fy * G - sc[1], fz * G))
cx = a[1] * b[2] - a[2] * b[1]; cz = a[0] * b[1] - a[1] * b[0]
A = H.FLOW["amp"]; return cx * A, cz * A
def stream_grad(kind, x, z, t):
"""Gradient of the stream function psi wrt field coords (x/S, z/S). Returns (gx, gz)."""
tau = tau_of(t)
P = np.stack([x / S, z / S, np.full_like(x, tau)], -1)
if kind == "curl1":
g = snoise_grad(P); return g[..., 0], g[..., 1]
if kind == "curlfbm":
g = fbm_grad(P, 3, 2.0, 0.4); return g[..., 0], g[..., 1]
if kind == "warp":
W, fw = 0.55, 0.6 # warp amplitude (field units), warp frequency
o1 = np.array([17.3, 41.9, 0.0]); o2 = np.array([-63.1, 5.7, 0.0])
Pw = np.stack([x / S * fw, z / S * fw, np.full_like(x, tau * fw)], -1)
n1 = snoise_grad(Pw + o1); n2 = snoise_grad(Pw + o2)
qx = x / S + W * n1[..., 3]; qz = z / S + W * n2[..., 3]
g = fbm_grad(np.stack([qx, qz, np.full_like(x, tau)], -1), 2, 2.0, 0.4)
# exact chain rule: grad_p psi = J^T grad_q psi, J = I + W fw [[dn1/dx, dn1/dz], [dn2/dx, dn2/dz]]
J11 = 1 + W * fw * n1[..., 0]; J12 = W * fw * n1[..., 1]
J21 = W * fw * n2[..., 0]; J22 = 1 + W * fw * n2[..., 1]
return g[..., 0] * J11 + g[..., 1] * J21, g[..., 0] * J12 + g[..., 1] * J22
if kind in ("lab", "labslope"):
# Nebula Span Lab construction as a stream function (labs-noise-facts.md 1a): fbm of the stretched argument
# Mop(v) = v + (1/st - 1) dot(v, d) d (SPAN 396), st 5, 3 octaves, gain .5 (lab defaults; lacunarity 2, not
# the preset's 2.1, so the 867 fold holds).
st = 5.0; a = 1.0 / st - 1.0
def psi(xx, zz, dx_, dz_):
px, pz = xx / S, zz / S; dp = px * dx_ + pz * dz_
return fbm_grad(np.stack([px + a * dp * dx_, pz + a * dp * dz_, np.full_like(xx, tau)], -1), 3, 2.0, 0.5)
if kind == "lab": # d fixed at azimuth 25 (the preset's fAz); analytic: Mop is symmetric, grad_p = Mop grad_q
dx_ = np.full_like(x, LAB_DIR[0]); dz_ = np.full_like(x, LAB_DIR[1]); g = psi(x, z, dx_, dz_)
gd = g[..., 0] * dx_ + g[..., 1] * dz_
return g[..., 0] + a * gd * dx_, g[..., 1] + a * gd * dz_
# "labslope": d = the local contour tangent (bilinear on the gravity grid). The analytic chain rule would need
# dd/dp, i.e. the height Hessian; instead psi is differenced (4 evaluations, 12 snoise_grad), which keeps the
# flow an exact discrete curl.
e = 2.0
def ps(xx, zz): d = DIRFN(xx, zz); return psi(xx, zz, d[0], d[1])[..., 3]
return ((ps(x + e, z) - ps(x - e, z)) / (2 * e) * S, (ps(x, z + e) - ps(x, z - e)) / (2 * e) * S)
raise KeyError(kind)
GAIN = {} # per-candidate k, set so RMS speed matches tri3d's RMS over the zone at t = 0
def slope_terms(x, z, t):
"""FlowField.Slope with swirl 6, slide 1.5, downhill from H.normal (pointwise, slow)."""
ux = np.zeros_like(x); uz = np.zeros_like(x)
fx, fz = x.ravel(), z.ravel(); ox, oz = ux.ravel(), uz.ravel()
for k in range(fx.size):
n = H.normal(float(fx[k]), float(fz[k]), t); dx, dz = n[0], n[2]; l = math.hypot(dx, dz)
if l > 0:
dx, dz = dx / l, dz / l
ox[k] = dz * SWIRL + dx * SLIDE; oz[k] = -dx * SWIRL + dz * SLIDE
return ox.reshape(x.shape), oz.reshape(x.shape)
DIRFN = None # set in main: (x, z) -> unit contour tangent (the swirl direction), nearest cell of the grid
LAB_DIR = (math.cos(math.radians(25)), math.sin(math.radians(25))) # Span Lab "Streaming filaments" fAz 25
def velocity(kind, x, z, t, slope=None):
if kind == "tri3d": return tri3d(x, z, t)
base = kind.replace("+slope", "")
gx, gz = stream_grad(base, x, z, t)
k = GAIN[base]
ux, uz = k * gz, -k * gx # curl of psi (k carries 1/S and the amplitude)
if kind.endswith("+slope"):
sx, sz = slope if slope is not None else slope_terms(x, z, t)
ux, uz = ux + sx, uz + sz
return ux, uz
CANDS = ["tri3d", "curl1", "curlfbm", "warp", "curlfbm+slope", "warp+slope", "lab", "labslope", "labslope+slope"]
NAMES = {"tri3d": "0-tri3d", "curl1": "1-curl-simplex", "curlfbm": "2-curl-fbm3",
"warp": "3-curl-warped", "curlfbm+slope": "4-curl-fbm3-slope", "warp+slope": "4b-curl-warped-slope",
"lab": "5-lab-stretch-fixed", "labslope": "5b-lab-stretch-contour", "labslope+slope": "5c-lab-stretch-contour-slope"}
COST = {"tri3d": "0 snoise_grad (2 normalize(tri3) + cross)", "curl1": "1 snoise_grad",
"curlfbm": "3 snoise_grad (fbm_grad, 3 octaves)", "warp": "4 snoise_grad (2 warp + fbm_grad 2 octaves)",
"curlfbm+slope": "3 snoise_grad + the slope term (gravity gradient: 1 texel gradient in the shader)",
"warp+slope": "4 snoise_grad + the slope term", "lab": "3 snoise_grad (fbm_grad of a stretched argument)",
"labslope": "12 snoise_grad as probed (psi differenced); 3 + a height Hessian analytically",
"labslope+slope": "as 5b + the slope term (axis and slope term share the gravity gradient)"}
# ---------------- images ----------------
STOPS = np.array([[0, 10, 14, 30], [.45, 20, 120, 130], [.8, 200, 210, 120], [1, 255, 245, 210]], float)
def ramp(v):
v = np.clip(v, 0, 1); out = np.zeros(v.shape + (3,))
for c in range(3): out[..., c] = np.interp(v, STOPS[:, 0], STOPS[:, c + 1])
return out
def wells_px(w, x0, z0, span):
for wl in H.WELLS:
i = (wl["pos"][0] - x0) / span * w; j = (wl["pos"][1] - z0) / span * w
if 0 <= i < w and 0 <= j < w: yield i, w - 1 - j
def lic(ux, uz, L=28):
n = ux.shape[0]; noise = rng.random((n, n))
sp = np.hypot(ux, uz) + 1e-12; dx, dz = ux / sp, uz / sp
acc = noise.copy(); cnt = np.ones_like(noise)
jj, ii = np.mgrid[0:n, 0:n].astype(float)
for sgn in (1, -1):
px, pz = ii.copy(), jj.copy()
for s in range(L):
a = np.clip(np.rint(px).astype(int), 0, n - 1); b = np.clip(np.rint(pz).astype(int), 0, n - 1)
px = px + sgn * dx[b, a] * .7; pz = pz + sgn * dz[b, a] * .7
a = np.clip(np.rint(px).astype(int), 0, n - 1); b = np.clip(np.rint(pz).astype(int), 0, n - 1)
wgt = math.cos(math.pi * .5 * (s + 1) / (L + 1)) ** 2
acc += noise[b, a] * wgt; cnt += wgt
return acc / cnt
def save_lic(name, ux, uz, vref):
t = lic(ux, uz); t = (t - t.mean()) / (t.std() + 1e-9) * .22 + .5
sp = np.hypot(ux, uz) / vref
rgb = ramp(.25 + .6 * np.clip(sp, 0, 1.6) / 1.6) * np.clip(t, 0, 1.2)[..., None] * 1.15
img = Image.fromarray(np.clip(rgb[::-1], 0, 255).astype(np.uint8)); d = ImageDraw.Draw(img)
for i, j in wells_px(N, -R, -R, 2 * R): d.ellipse([i - 4, j - 4, i + 4, j + 4], outline=(255, 70, 70), width=2)
path = os.path.join(HERE, f"lic_{name}.png"); img.save(path); return path
def save_drift(kind, name, t0, slope_fn):
"""Left: the Stardust law for one cycle (position -= flow(spawn) * lifetime * Period, Stardust.compute:142),
each particle's whole-cycle streak. Right: the same seeds advected along the field (RK2, 40 s), a long
exposure to read the field's texture; not what Stardust draws. Window 1200 u, centred off the sun."""
W = 640; span = 1200.0; x0, z0 = -1700.0, -600.0
m = 2600; sx = x0 + rng.random(m) * span; sz = z0 + rng.random(m) * span
panels = []
for mode in ("cycle", "exposure"):
img = Image.new("RGB", (W, W), (10, 14, 30)); d = ImageDraw.Draw(img)
if mode == "cycle":
ux, uz = velocity(kind, sx, sz, t0, slope=slope_fn(sx, sz) if kind.endswith("+slope") else None)
ex, ez = sx - ux * STARDUST_PERIOD, sz - uz * STARDUST_PERIOD
paths = [np.stack([sx, sz], -1), np.stack([ex, ez], -1)]
else:
px, pz = sx.copy(), sz.copy(); paths = [np.stack([px, pz], -1)]; dt = 1.0
for s in range(40):
sl = slope_fn(px, pz) if kind.endswith("+slope") else None
ax, az = velocity(kind, px, pz, t0 + s * dt, slope=sl)
mx, mz = px + ax * dt * .5, pz + az * dt * .5
sl = slope_fn(mx, mz) if kind.endswith("+slope") else None
bx, bz = velocity(kind, mx, mz, t0 + (s + .5) * dt, slope=sl)
px, pz = px + bx * dt, pz + bz * dt; paths.append(np.stack([px, pz], -1))
P = np.stack(paths, 0); P[..., 0] = (P[..., 0] - x0) / span * W; P[..., 1] = W - 1 - (P[..., 1] - z0) / span * W
for k in range(m):
pts = [tuple(p) for p in P[:, k]]
if mode == "cycle":
d.line(pts, fill=(120, 190, 190), width=1); d.point(pts[-1], fill=(255, 245, 210))
else:
d.line(pts, fill=(70, 150, 160), width=1); d.point(pts[-1], fill=(255, 245, 210))
for i, j in wells_px(W, x0, z0, span): d.ellipse([i - 5, j - 5, i + 5, j + 5], outline=(255, 70, 70), width=2)
panels.append(img)
out = Image.new("RGB", (2 * W + 8, W), (40, 40, 40)); out.paste(panels[0], (0, 0)); out.paste(panels[1], (W + 8, 0))
path = os.path.join(HERE, f"drift_{name}.png"); out.save(path); return path
# ---------------- numbers ----------------
def numbers(kind, t, slope_fn):
m = 20000; x = (rng.random(m) * 2 - 1) * R * .95; z = (rng.random(m) * 2 - 1) * R * .95; e = 1.0
sl = (lambda a, b: slope_fn(a, b)) if kind.endswith("+slope") else (lambda a, b: None)
ux, uz = velocity(kind, x, z, t, slope=sl(x, z))
axp, _ = velocity(kind, x + e, z, t, slope=sl(x + e, z)); axm, _ = velocity(kind, x - e, z, t, slope=sl(x - e, z))
_, azp = velocity(kind, x, z + e, t, slope=sl(x, z + e)); _, azm = velocity(kind, x, z - e, t, slope=sl(x, z - e))
bzp, _ = velocity(kind, x, z + e, t, slope=sl(x, z + e)); bzm, _ = velocity(kind, x, z - e, t, slope=sl(x, z - e))
_, bxp = velocity(kind, x + e, z, t, slope=sl(x + e, z)); _, bxm = velocity(kind, x - e, z, t, slope=sl(x - e, z))
div = (axp - axm) / (2 * e) + (azp - azm) / (2 * e)
vort = (bxp - bxm) / (2 * e) - (bzp - bzm) / (2 * e)
sp = np.hypot(ux, uz)
return dict(rms_div=float(np.sqrt((div ** 2).mean())), rms_vort=float(np.sqrt((vort ** 2).mean())),
div_over_vort=float(np.sqrt((div ** 2).mean()) / np.sqrt((vort ** 2).mean())),
speed=[float(np.percentile(sp, q)) for q in (10, 50, 90, 99)] + [float(sp.max())],
rms=float(np.sqrt((sp ** 2).mean())), still_frac=float((sp < .1 * np.sqrt((sp ** 2).mean())).mean()))
def period_check():
m = 4000; P = rng.random((m, 3)) * 50
out = {}
for ax in range(3):
Q = P.copy(); Q[:, ax] += PERIOD
out["axis%d" % ax] = float(np.abs(snoise_grad(P) - snoise_grad(Q)).max())
Q = P.copy(); Q[:, 2] += PERIOD
out["fbm3_z"] = float(np.abs(fbm_grad(P, 3, 2.0, .4) - fbm_grad(Q, 3, 2.0, .4)).max())
# float32 precision of an unfolded tau (as a field coordinate) at long zone times
for days in (1, 10, 100):
tau = SPEED * days * 86400.0
out[f"ulp32_unfolded_{days}d"] = float(np.spacing(np.float32(tau)))
out[f"ulp32_folded_{days}d"] = float(np.spacing(np.float32(math.fmod(tau, PERIOD))))
return out
def grad_check():
m = 2000; P = rng.random((m, 3)) * 20; e = 1e-5; g = snoise_grad(P)
err = 0.0
for ax in range(3):
d = np.zeros(3); d[ax] = e
fd = (snoise_grad(P + d)[:, 3] - snoise_grad(P - d)[:, 3]) / (2 * e)
err = max(err, float(np.abs(fd - g[:, ax]).max()))
return err
def main():
t0 = 0.0; T = time.time()
print("snoise_grad analytic vs FD max err", grad_check())
print("period / precision", period_check())
# tri3d numpy vs habitat_probe pointwise
xs = (rng.random(50) * 2 - 1) * R; zs = (rng.random(50) * 2 - 1) * R
a = tri3d(xs, zs, 123.4); sc = H.scroll(123.4)
b = np.array([[H.flow_global(float(x), 0.0, float(z), sc)[k] for k in (0, 2)] for x, z in zip(xs, zs)])
print("tri3d numpy vs habitat_probe max err", float(np.abs(np.stack(a, -1) - b).max()))
c = (np.arange(N) + .5) / N * 2 * R - R; X, Z = np.meshgrid(c, c)
tx, tz = tri3d(X, Z, t0); vref = float(np.sqrt((tx ** 2 + tz ** 2).mean()))
def dir_fn(x, z): # bilinear, renormalised: a continuous axis field
fi = np.clip((x + R) / (2 * R) * N - .5, 0, N - 1.001); fj = np.clip((z + R) / (2 * R) * N - .5, 0, N - 1.001)
i = fi.astype(int); j = fj.astype(int); u = fi - i; v = fj - j
bl = lambda A: (A[j, i] * (1 - u) + A[j, i + 1] * u) * (1 - v) + (A[j + 1, i] * (1 - u) + A[j + 1, i + 1] * u) * v
tx, tz = bl(TX), bl(TZ); l = np.hypot(tx, tz) + 1e-12
return tx / l, tz / l
global DIRFN; DIRFN = dir_fn
print("slope grid ...", flush=True)
SX, SZ = slope_terms(X, Z, t0)
# contour tangent from the slope terms: S = SWIRL t + SLIDE d with t = (d.z, -d.x), so t = (SWIRL S + SLIDE rot) / |..|
TX = (SWIRL * SX - SLIDE * (-SZ)) ; TZ = (SWIRL * SZ - SLIDE * SX)
TL = np.hypot(TX, TZ) + 1e-12; TX, TZ = TX / TL, TZ / TL
for kind in ("curl1", "curlfbm", "warp", "lab", "labslope"):
gx, gz = stream_grad(kind, X, Z, t0); GAIN[kind] = vref / float(np.sqrt((gx ** 2 + gz ** 2).mean()))
print("tri3d RMS speed", vref, "gains k", GAIN)
# slope sampler for scattered points: nearest cell of the grid (the shader samples a gravity texture too)
def slope_fn(x, z):
i = np.clip(((x + R) / (2 * R) * N).astype(int), 0, N - 1); j = np.clip(((z + R) / (2 * R) * N).astype(int), 0, N - 1)
return SX[j, i], SZ[j, i]
for kind in CANDS:
name = NAMES[kind]
ux, uz = velocity(kind, X, Z, t0, slope=(SX, SZ) if kind.endswith("+slope") else None)
p1 = save_lic(name, ux, uz, vref); p2 = save_drift(kind, name, t0, slope_fn)
nums = numbers(kind, t0, slope_fn)
print(f"== {name}: cost {COST[kind]}\n {nums}\n {p1}\n {p2}", flush=True)
# scroll: how far the field decorrelates over one minute of zone time, per candidate
for kind in CANDS[:4] + ["lab", "labslope"]:
u0 = np.stack(velocity(kind, X[::8, ::8], Z[::8, ::8], 0.0), -1).reshape(-1, 2)
for dt in (10.0, 40.0, 120.0):
u1 = np.stack(velocity(kind, X[::8, ::8], Z[::8, ::8], dt), -1).reshape(-1, 2)
cc = float((u0 * u1).sum() / np.sqrt((u0 ** 2).sum() * (u1 ** 2).sum()))
print(f" scroll {NAMES[kind]} corr(t=0, t={dt:.0f}s) = {cc:.3f}")
# contact sheet
ims = [Image.open(os.path.join(HERE, f"lic_{NAMES[k]}.png")).resize((384, 384)) for k in CANDS]
sheet = Image.new("RGB", (392 * len(CANDS) - 8, 384), (40, 40, 40))
for k, im in enumerate(ims): sheet.paste(im, (k * 392, 0))
sheet.save(os.path.join(HERE, "lic_contact_sheet.png"))
print("done", round(time.time() - T, 1), "s")
if __name__ == "__main__": main()

BIN
docs/research/2026-10-14-curl-flow-probe/drift_0-tri3d.png (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,136 @@
# Nebula labs: noise facts (eyes-nebula-labs, 2026-10-14)
Sources
- SPAN = "Nebula Span Lab" (artifact be6a9e31, v1791074878-d07e). Saved HTML: C:\Users\Meta\.claude\projects\F--Projects-CultLib\5ea0b87f-66d0-4b24-8d36-45015e4cedc1\tool-results\artifact-be6a9e31-1791074878-d07e.html (1166 lines). Line numbers below are that file's.
- KERN = "Kernel Nebula Lab" (artifact c09d2762, v1791066352-e6f2). Returned inline, no saved file; pointers are by function/constant name (page is ~560 lines).
- Caveat: the SPAN tool output was truncated near HTML line 830. I read lines 940-1000 of the saved file and grepped the rest. I did NOT read every line of SPAN (bench/audit/test JS harness at ~840-940 and ~1000-1166 only grepped).
## 1. Noise constructions
### Common finding
Neither lab uses fbm of value noise, Worley, Phacelle, or hash noise. The ONLY noise primitive in SPAN is CultMath's 3D simplex `cultmath_snoise` (Ashima/McEwan, SPAN 232-286) and its analytic gradient `cultmath_snoise_grad` (SPAN 293-352), embedded verbatim in a JS template string `CULTMATH` (SPAN 232). KERN uses NO noise function at all: its density is a procedurally generated cascade of anisotropic compact-support kernels (1b).
### 1a. SPAN: density = ramp pushed down by brushes, domain-warped twice
Density is not a noise sum; `fieldS` (SPAN 434-441) returns a height-like scalar s and density = ext * clamp((F - s)/B, 0, cap):
```glsl
float fieldS(vec3 p){
float H; vec2 g; brushes(brushPoint(p), H, g);
float gm = ampG(H);
vec3 q1 = p + vec3(0.0, H, 0.0);
vec3 q2 = q1 + (gm*uWA[0])*dispVec(0, q1);
return q2.y + (gm*uWA[1])*dispY(1, q2);
}
```
(reference march: `rho = uExt*clamp((uF - s)/uB, 0.0, uCap)` in traceRef.) Brush bowl H shifts q1.y; warp 1 displaces q1 by a 3-vector field (amplitude a1); warp 2 displaces q2 but only its Y component reaches s (amplitude a2). Two warp levels, hard-coded. Warp amplitude is modulated by the bowl: `ampG(H)` = ampRim + (ampDeep-ampRim)*H/Hsum (SPAN 391).
Base noise: `cultmath_snoise_grad(...)` returns vec4(grad, value) (SPAN 293). A Hessian variant `cultmath_snoise_gh` is page-generated by `makeGH(src)` (SPAN 838-845) by string-splicing the CultMath grad kernel.
Fractal sum per component, `fieldComp` (SPAN 398-409): plain or ridged, per-octave offset OCT, lacunarity uWLac[st], gain uWGain[st]:
```glsl
float fieldComp(int st, int c, vec3 q, bool ridge, out vec3 g){
vec3 base = Mop(st, q) + uWSh[st]; float lac = 1.0, gn = 1.0, sum = 0.0; g = vec3(0.0);
for (int o = 0; o < uWOct[st]; o++){
vec4 t = cultmath_snoise_grad(lac*base + CO[st*3 + c] + float(o)*OCT);
float fac = 1.0, val = t.w;
if (ridge){ fac = t.w >= 0.0 ? -2.0 : 2.0; val = 1.0 - 2.0*abs(t.w); }
sum += gn*val; g += (gn*fac*lac)*Mop(st, t.xyz);
lac *= uWLac[st]; gn *= uWGain[st];
}
return sum;
}
```
Ridged form here is `1 - 2|n|` (range -1..1), not CultMath's `1 - |n|`. Displacement components x/y/z use different offsets from `CO[6] = {O0,O1,O2,O4,O3,O5}` (SPAN 376-380; OCT = (17.3,-9.1,23.9)). Anisotropic stretch (SPAN 396):
`vec3 Mop(int st, vec3 v){ return uWF[st]*(v + (1.0/uWStr[st] - 1.0)*dot(v, uDir)*uDir); }` - frequency F times a stretch along the fixed flow axis uDir.
Three noise modes per warp stage (uWMode: 0 plain fbm, 1 ridged, 2 curl), `dispVec` (SPAN 411-417):
```glsl
vec3 dispVec(int st, vec3 q){
int md = uWMode[st], nc = uWOct[st] > 0 ? 3 : 0;
vec3 w = vec3(0.0), gs[3];
for (int c = 0; c < nc; c++){ vec3 g; float v = fieldComp(st, c, q, md == 1, g); w[c] = v; gs[c] = g; }
if (md == 2) w = curlK(st)*vec3(gs[2].y - gs[1].z, gs[0].z - gs[2].x, gs[1].x - gs[0].y);
return w;
}
```
Curl: curl of a 3-component vector potential (three fbm fields at three offsets) from analytic gradients, scaled `curlK(st) = 0.25/uWF[st]` (SPAN 410). Divergence-free. `dispY` (SPAN 418-427) is warp 2's Y-only version (curl: `curlK*(gx.z - gz.x)`).
Brush warp, `brushPoint` (SPAN 427-433): the xz position fed to the brushes is displaced by two snoise values: `pb[j] += uBwA*cultmath_snoise_grad(xb + (j == 0 ? O6 : O7)).w`, `xb = vec3(p.x,p.z,0)*uBwF + uBwSh` (ragged bowl edges).
Brushes (SPAN 383-390): H = sum D_i (1-r^2)^e_i, comment "Aetheria PowerBrush powerPulse"; three brushes from JS `brushList()` (not read line by line).
Time evolution (JS `setUniforms`, SPAN 976-978): pure domain scroll of the noise argument by constant vectors:
```js
const sp = P.speed*0.03;
gl.uniform3fv(U.uWSh, new Float32Array([simT*sp*0.9, simT*sp*0.35, -simT*sp*0.6, -simT*sp*0.5, simT*sp*0.8, simT*sp*0.4]));
u3("uBwSh", [simT*sp*0.4, -simT*sp*0.7, 0.3]);
```
`uWSh[st]` is added to the noise argument (`base = Mop(st,q) + uWSh[st]`). `anim` toggle default off (SPAN 118); `simT += dt` when on (SPAN 1037). No crossfade, no velocity field, no advection.
Composition: s = brush bowl (itself warped by 2 snoise in xz) -> + a1*gm*W1(q1) (vec3 plain/ridged/curl fbm, <=4 octaves, anisotropic) -> + a2*gm*W2y(q2) -> density = ext*clamp((F-s)/B,0,cap) -> single-scatter with HG phase and analytic sun optical depth. Most of the page is the span integrator (`compField` SPAN 448-483, `evalSpan` 484-531, curvature bounds); the noise definition itself is ~50 lines (394-441). Pinned constants `SN_H=56.050385, SN_L=10.099261, SN_T=540.0` (SPAN ~374).
### 1b. KERN: kernel cascade, no noise function
Density is a sum of compact-support kernels rho = peak * w * (1-u)^k, u = |A^-1 (x - mu)|^2 (`pointDens`, shader), each an anisotropic ellipsoid in a multiplicative cascade built in JS `build(p)`:
- roots on a jittered lattice (spacing H0), hashed per cell (`hash32`, `rngSeed`), kept by the fog envelope `Pm(s)=(F-s)/gradB` times radial edge fade `ef`;
- root axes: e1 = flow tangent rotated by random phi, e2 = n x e1, c2 = surface normal * flat; alpha = asp*(0.8+0.4r);
- children: up to `nc` per node, offsets from a 256-entry random table TXi, linear map table TL (rotation about random axis by rot*S(), diag scale cs*{alc,1,1/alc}), mass share q = u^qp * Pc/Pp (intermittency); parent keeps `keep`; `lvl` <= 6; budgets KBUDGET 420000 kernels, IDXCAP 4096*3072 bin entries;
- kernel orders `kr` (roots), `kc` (children) in (1-u)^k; closed-form line integral `segInt(k,R,al,s0,s1)`; mass normaliser `kMass(k)`;
- LOD: `uLod*foot` culls kernels below pixel footprint and swaps child peak (`t2.w` vs `t3.w`).
KERN flow (`flowAt`, in build): `fx = -z/r*1.0 + gx*30 + 0.5*sin(z*0.011+1.3)`, `fz = x/r*1.0 + gz*30 + 0.5*cos(x*0.013+0.7)`, normalised, scaled by `sp = 0.6+0.4*sin(x*0.011+z*0.007)`; gx,gz = bowl-height gradient (central diff e=3). Stored per root (cr[24], cr[25]), passed to the shader as t4.x, t4.y.
KERN time (`draw()` + shader): two-phase crossfade, PERIOD=8: `ph0=(simT/PERIOD)%1, ph1=(simT/PERIOD+0.5)%1, w0 = 1-|2*ph0-1|`; each kernel evaluated twice with weights uW=(w0,1-w0), centre `mu = t0.xyz + vec3(t4.x,0,t4.y)*(uWarp*sh)`, sh=(ph-0.5)*2; `uWarp` = "Flow warp distance". Same two-copy cycle structure as the game shader (section 4), with a per-root baked, time-constant 2D flow vector.
Tint: per-kernel hash `t4.w` plus depth `(uF-s)/uTintDepth`.
## 2. Which exist in CultMath (F:\Projects\CultLib\packages\cultmath)
| Lab construct | In CultMath? | Where |
|---|---|---|
| `cultmath_snoise(vec3)` | yes | shaders/CultMath.glsl:85 (3D), :134 (2D); C# `math.snoise(float3)` src/CultMath/math.cs:608, float2 :661; HLSL CultMath.hlsl |
| `cultmath_snoise_grad` -> vec4(grad,val) | yes | CultMath.glsl:171; C# math.cs:714 |
| fbm with grad | yes `cultmath_fbm_grad(p,octaves,lacunarity,gain)` | CultMath.glsl:233; C# math.cs:784 |
| ridged | yes `cultmath_ridged_grad`, sum amp*(1-abs(n)) | CultMath.glsl:254; C# math.cs:811. Differs from lab: lab uses 1-2|n|, per-octave offset OCT, per-component offsets, and does not call CultMath's fbm_grad/ridged_grad |
| interval / affine snoise, fbm | yes | cultmath_iv_snoise_ball :595, cultmath_iv_fbm_ball :611, cultmath_af_snoise :721, cultmath_af_fbm :738 (C# math.Interval.cs:146,176; math.Affine.cs:159,183) |
| value noise | yes (unused by labs) | cultmath_value_noise :778, _bicubic :792 |
| hash | yes (unused) | cultmath_hash :392-394 |
| Phacelle | yes (unused) | `CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization)` CultMath.Phacelle.glsl:25; math.Phacelle.cs |
| Cellular/Voronoi | src/CultMath/CultCellular.cs and Voronoi.cs exist (Voronoi.cs public API is tone sampling); no GLSL cellular function found by my grep (unused) | |
| Cycles port | CultMath.Cycles.glsl has math/mix/map-range/hsv node ports; my grep found no noise-texture/Musgrave/Voronoi node (unused) | |
| `cultmath_snoise_gh` (Hessian function) | NO, page-local, generated by `makeGH` (SPAN 838). CultMath has only the constant `CULTMATH_SNOISE_HESSIAN = 56.050385` (CultMath.glsl:656; C# math.Affine.cs:35) | |
| curl noise (`dispVec` mode 2, `curlK`) | NO (grep "curl" in CultMath.glsl and math*.cs: no hit); page-local | SPAN 410-417 |
| `fieldComp` (offsets, anisotropic `Mop`, ridged 1-2|n|) | NO, page-local | SPAN 394-409 |
| warp chain `fieldS`, `brushPoint`, `ampG`, `brushes` | NO, page-local | SPAN 383-391, 427-441 |
| `compField`/`evalSpan` bound evaluators | NO, page-local | SPAN 448-531 |
| KERN hash32/rngSeed/cascade/segInt | NO, page-local | KERN build(), shader |
Constant mismatch: SPAN pins `SN_L = 10.099261` and its page text says "SNOISE_LIPSCHITZ 10.10 from CultMath"; CultMath currently has `SNOISE_LIPSCHITZ = 7.9640074` (math.Interval.cs:21, CultMath.glsl:519, CultMath.Interval.hlsl:10; derivation at packages/cultmath/docs/design.md:593). `SNOISE_HESSIAN` 56.050385 matches.
## 3. Authoring parameters
SPAN `SPEC` (SPAN 106-168; id, label, min..max, default):
- Field: F -100..100 (-20); gradB 2..60 (12); ext 0.002..0.4 (0.035); cap 0.5..10 (4)
- Brush: bDepth 0..260 (120); bWidth 400..3000 (1600); bExp 2..8 (2)
- Chaos: a1 0..80 (24); f1 0.1..3 per 100u (0.8); a2 0..40 (6); f2 0.3..8 (3); anim check (off); speed 0.1..4 (1)
- Warp 1 shape: m1 select {Plain fbm, Ridged, Curl} (0); o1 1..4 (1); lac1 1.2..3.5 (2); gain1 0.2..0.8 (0.5); st1 stretch 0.2..8 (1)
- Warp 2 shape: m2, o2 1..4 (1), lac2 1.2..3.5 (2), gain2 0.2..0.8 (0.5), st2 0.2..8 (1)
- Flow direction: fAz 0..360 (30); fEl -90..90 (0)
- Brush warp and regions: bwA 0..150 (0); bwF 0.1..4 (0.6); ampRim 0..4 (1); ampDeep 0..4 (1)
- Integrator: tol 0.01..1.5 (0.15); shadeLim 0.1..2 (0.5); maxSpans 8..1000 (400); hScale 0.05..1 (1); refSteps 32..2048 (256); lightN 0..4 (0); lightStep 4..80 (20); v2 checks satOn(1) rangeOn(1) localOn(0) ctrlOn(1)
- Sun: az 0..360 (40); el 5..90 (80); sunI 0..20 (3); sunCol #cfeaff. Medium: g -0.9..0.95 (0.4); albedo 0..1 (0.85); emit 0..4 (0.8); amb 0..1.5 (0.15). Tint colors c0 #8ff3e8, c1 #20b4c8, c2 #0b5f86
- View: mode select (4 modes), split, exposure 0.05..6 (0.5), fov 30..110 (70), yaw, pitch, camx/camz +-900, camh -60..200, maxDist 300..2400, scale 0.2..1
Presets (SPAN 172-): teal (defaults), view1, ramp (a1=a2=bDepth=0), chaos (a1 60,f1 .6,a2 14,f2 4), "Streaming filaments" fil (o1 3, lac1 2.1, st1 5, a2 12, f2 2.2, o2 2, st2 4, fAz 25, fEl 8, ampRim 1.3, ampDeep 0.5), curl (m1=m2=2, a1 16, f1 .55, o1 2, a2 5, f2 2.4), ridge (m1=m2=1, a1 26, o1 3, a2 8, bwA 35, bwF .7, ampRim 1.4, ampDeep .6). Test buttons: Benchmark, Noise self-test, Span vs reference, Matched-quality bench, Bound audit.
KERN `SPEC` (min..max, probe/aeth default; Cascade items trigger rebuild; presets "Aetheria-ish" and "Probe defaults"):
- Cascade: lvl 1..6 (4/4); nc 1..8 (5/6); keep .05..0.9 (.25/.15); cs .2..0.7 (.40/.45); asp 1..3.5 (1.8/1.35); alc 1..3 (1.6/1.3); rot 0..1.57 (.6/.9); off 0..1.2 (.6/.75); qp .2..4 (2/1.2); flat .2..1 (.5/.6); spacing 30..120 (56); rootR .3..1 (.55/.6); radius 300..1200 (700); warp 0..80 (30); seed 1..99 (7)
- Kernel: kr 0..4 (2/2), kc 0..4 (1/2). Envelope: depth 0..300 (120); width 800..3000 (1600); gradB 4..40 (12)
- Medium: ext .005..0.6 (.10/.035); albedo 0..1 (1/.85); g -.9..0.95 (.6/.4); emit 0..4 (0/1.1); amb 0..1.5 (0/.15); lightN 0..4; lightStep 4..60 (20); lod 0..2 (.5)
- Sun (sunI 0..20, az, el, sunCol); Nebula tint (c0..c2, tintVar 0..1 (.3/.35), tintDepth 5..120 (40)); View (exposure, fov 30..110, yaw, pitch, camx +-650, camh 5..200, scale .2..1, speed .1..3, anim check off)
## 4. Is the labs' motion a velocity field Aetheria's global flow could adopt?
Aetheria flow facts (FlowField.cs is not on fauna-curl-probe HEAD; read from commit 5f5a1e4d on branch eureka/aetheria-fauna-flow-field via `git -C C:\aeth-wt\fauna-curl-probe show 5f5a1e4d:Assets/Scripts/ServerShared/FlowField.cs`; HLSL twin `git show HEAD:Assets/Shaders/Volumetric.cginc` lines 46-74, 130-161, 175-205):
- `FlowField.Global(settings, position, scroll)` = `Tri3D(field - (0,scroll.x,0), field*Golden - (0,scroll.y,0)) * GlobalAmplitude`, y *= .5, field = position/GlobalScale. `Tri3D` = cross(normalize(tri3(p)), normalize(tri3(p*1.618))), `tri(x)=|frac(x)-.5|` (piecewise linear). `Scroll(settings,t)` = phase mod 1 of GlobalScrollSpeed*t (second octave times Golden). It is a velocity field (position -> vec3), with a scrolled phase as the time term; `Slope` adds swirl+slide from the gravity gradient.
- The game's nebula `density()` (Volumetric.cginc 175-205) uses that flow to ADVECT `triNoise3d` samples: `noise = pow(triNoise3d((pos + fl*(lerpK-.5)*_FlowPeriod)/_NebulaNoiseScale), exponent) * tri2(lerpK)`, two copies at phases frac(t/P) and frac(t/P+.5), then a second pair at doubled cycle rate and 8x spatial frequency; sum displaces pos.y. Base noise is `triNoise3d` (Volumetric.cginc 51-67, 2 octaves of tri3 folds), not simplex.
Lab motion:
- SPAN: not a velocity field. Time is a constant-vector domain scroll added to the noise argument (`uWSh`, `uBwSh`; SPAN 977-978). The "flow" is `uDir`, a fixed azimuth/elevation axis along which the warp noise is stretched (SPAN 396); page text states it is not derived from the brush gradient. Curl mode (SPAN 410-417) is the curl of a displacement potential, a divergence-free displacement evaluated per point at one instant; it is not advected and defines no time derivative beyond the scroll shift. Density warp only.
- KERN: motion is a two-phase crossfade (period 8) displacing kernel centres by a per-root baked, time-constant 2D flow vector times `uWarp`*(phase-.5)*2. Same cycle/crossfade structure as the game's `density()`, but with its own hand-written 2D flow (`flowAt`: swirl about origin + 30*bowl-gradient + sinusoid wobble, speed-modulated), not FlowField.
Neither lab references FlowField, Tri3D, or any advection of noise by a time-varying velocity field.

BIN
docs/research/2026-10-14-curl-flow-probe/lic_0-tri3d.png (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
docs/research/2026-10-14-curl-flow-probe/lic_2-curl-fbm3.png (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,60 @@
snoise_grad analytic vs FD max err 2.1925998794358748e-08
period / precision {'axis0': 1.0206946399193839e-11, 'axis1': 1.2218337452907235e-11, 'axis2': 1.0844658504538529e-11, 'fbm3_z': 2.8636648607971438e-11, 'ulp32_unfolded_1d': 0.000244140625, 'ulp32_folded_1d': 3.0517578125e-05, 'ulp32_unfolded_10d': 0.001953125, 'ulp32_folded_10d': 6.103515625e-05, 'ulp32_unfolded_100d': 0.015625, 'ulp32_folded_100d': 7.62939453125e-06}
tri3d numpy vs habitat_probe max err 0.0
slope grid ...
tri3d RMS speed 6.603448949711046 gains k {'curl1': 1.8716047508573805, 'curlfbm': 1.126907845785225, 'warp': 0.7637324999861898, 'lab': 1.0915955246130895, 'labslope': 0.3915687026127663}
== 0-tri3d: cost 0 snoise_grad (2 normalize(tri3) + cross)
{'rms_div': 0.20651205778187315, 'rms_vort': 0.15264839371242175, 'div_over_vort': 1.3528609948619998, 'speed': [1.7371031863703295, 5.901654179436111, 9.62248760054106, 11.586184082696114, 12.223586819574475], 'rms': 6.503824936533222, 'still_frac': 0.0319}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_0-tri3d.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_0-tri3d.png
== 1-curl-simplex: cost 1 snoise_grad
{'rms_div': 1.425074821129316e-06, 'rms_vort': 0.10044531683043485, 'div_over_vort': 1.4187568580574375e-05, 'speed': [1.45451944015769, 3.8807304552976696, 8.391131663376978, 23.33639445000513, 45.734852437181104], 'rms': 6.304015614672217, 'still_frac': 0.02265}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_1-curl-simplex.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_1-curl-simplex.png
== 2-curl-fbm3: cost 3 snoise_grad (fbm_grad, 3 octaves)
{'rms_div': 4.4671182513764254e-05, 'rms_vort': 0.26681305173755265, 'div_over_vort': 0.00016742502745969304, 'speed': [1.528316643363086, 4.090096045508526, 10.483278795239606, 19.287985834601265, 32.30309968232166], 'rms': 6.50861489653746, 'still_frac': 0.01945}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_2-curl-fbm3.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_2-curl-fbm3.png
== 3-curl-warped: cost 4 snoise_grad (2 warp + fbm_grad 2 octaves)
{'rms_div': 0.000906042817989356, 'rms_vort': 0.5852965811516004, 'div_over_vort': 0.001548006339293271, 'speed': [0.7536706913694655, 2.6797695276184594, 8.86473165219103, 25.314462796750334, 125.72251267419585], 'rms': 6.680781927946263, 'still_frac': 0.08235}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_3-curl-warped.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_3-curl-warped.png
== 4-curl-fbm3-slope: cost 3 snoise_grad + the slope term (gravity gradient: 1 texel gradient in the shader)
{'rms_div': 0.17486274684757078, 'rms_vort': 0.3344055353668619, 'div_over_vort': 0.5229062570861346, 'speed': [3.7055528133164164, 7.341877386459386, 12.86410634452323, 21.39850147392345, 34.68972953904961], 'rms': 8.890401528869425, 'still_frac': 0.0041}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_4-curl-fbm3-slope.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_4-curl-fbm3-slope.png
== 4b-curl-warped-slope: cost 4 snoise_grad + the slope term
{'rms_div': 0.15167226920301866, 'rms_vort': 0.6337080265131629, 'div_over_vort': 0.23934093124488495, 'speed': [4.152942742162839, 6.721649628568566, 11.157884115284288, 26.547283825576866, 107.69405595071426], 'rms': 8.93130732391741, 'still_frac': 0.0035}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_4b-curl-warped-slope.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_4b-curl-warped-slope.png
== 5-lab-stretch-fixed: cost 3 snoise_grad (fbm_grad of a stretched argument)
{'rms_div': 5.558963705645817e-05, 'rms_vort': 0.2730793919618278, 'div_over_vort': 0.0002035658445593314, 'speed': [0.8667381407585574, 3.172091295533267, 10.728892348644765, 22.920368457618324, 36.08635765573939], 'rms': 6.658199538461918, 'still_frac': 0.06445}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_5-lab-stretch-fixed.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_5-lab-stretch-fixed.png
== 5b-lab-stretch-contour: cost 12 snoise_grad as probed (psi differenced); 3 + a height Hessian analytically
{'rms_div': 1.9801024466641561, 'rms_vort': 3.3310309410856718, 'div_over_vort': 0.5944413251288467, 'speed': [0.6113167724180104, 1.9093933242420025, 6.116526381276066, 22.779618958983708, 174.75200171199305], 'rms': 6.253181637281358, 'still_frac': 0.1038}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_5b-lab-stretch-contour.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_5b-lab-stretch-contour.png
== 5c-lab-stretch-contour-slope: cost as 5b + the slope term (axis and slope term share the gravity gradient)
{'rms_div': 2.021078137274867, 'rms_vort': 3.3530115055974576, 'div_over_vort': 0.6027650468544219, 'speed': [4.309379229574169, 6.448502702031059, 9.634888532119085, 23.996604067476206, 211.9540577466552], 'rms': 8.764542223350555, 'still_frac': 0.0019}
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\lic_5c-lab-stretch-contour-slope.png
C:\aeth-wt\fauna-curl-probe\docs\research\2026-10-14-curl-flow-probe\drift_5c-lab-stretch-contour-slope.png
scroll 0-tri3d corr(t=0, t=10s) = 0.316
scroll 0-tri3d corr(t=0, t=40s) = 0.452
scroll 0-tri3d corr(t=0, t=120s) = 0.799
scroll 1-curl-simplex corr(t=0, t=10s) = 0.565
scroll 1-curl-simplex corr(t=0, t=40s) = -0.088
scroll 1-curl-simplex corr(t=0, t=120s) = 0.087
scroll 2-curl-fbm3 corr(t=0, t=10s) = 0.291
scroll 2-curl-fbm3 corr(t=0, t=40s) = -0.044
scroll 2-curl-fbm3 corr(t=0, t=120s) = 0.117
scroll 3-curl-warped corr(t=0, t=10s) = 0.100
scroll 3-curl-warped corr(t=0, t=40s) = -0.007
scroll 3-curl-warped corr(t=0, t=120s) = 0.010
scroll 5-lab-stretch-fixed corr(t=0, t=10s) = 0.221
scroll 5-lab-stretch-fixed corr(t=0, t=40s) = -0.033
scroll 5-lab-stretch-fixed corr(t=0, t=120s) = 0.145
scroll 5b-lab-stretch-contour corr(t=0, t=10s) = 0.312
scroll 5b-lab-stretch-contour corr(t=0, t=40s) = -0.010
scroll 5b-lab-stretch-contour corr(t=0, t=120s) = -0.026
done 90.9 s