Research: curl flow probe, Nebula Span Lab stretch as a flow (fixed axis; contour axis fails as built), labs noise facts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 06:17:42 +02:00
commit 196edced82
11 changed files with 260 additions and 12 deletions

View file

@ -28,6 +28,9 @@ the speeds below compare shape at equal energy.
| 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
@ -48,6 +51,9 @@ central differences with a 1 u step. "Still" is the fraction of points with spee
| 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.
@ -65,6 +71,8 @@ The table gives the correlation between the velocity field at t = 0 and at t, on
| 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
@ -76,7 +84,7 @@ 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.
- `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`;
@ -96,6 +104,31 @@ In the one-cycle Stardust panels, every candidate looks like short straight dash
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
@ -109,6 +142,15 @@ the dashes' direction coherence and through the nebula's flow-advected noise, no
- **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.

View file

@ -118,6 +118,24 @@ def stream_grad(kind, x, z, t):
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
@ -132,6 +150,9 @@ def slope_terms(x, z, t):
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", "")
@ -143,13 +164,16 @@ def velocity(kind, x, z, t, slope=None):
ux, uz = ux + sx, uz + sz
return ux, uz
CANDS = ["tri3d", "curl1", "curlfbm", "warp", "curlfbm+slope", "warp+slope"]
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"}
"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"}
"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)
@ -272,11 +296,21 @@ def main():
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()))
for kind in ("curl1", "curlfbm", "warp"):
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)
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)
@ -288,7 +322,7 @@ def main():
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]:
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)

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,8 +1,8 @@
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
tri3d RMS speed 6.603448949711046 gains k {'curl1': 1.8716047508573805, 'curlfbm': 1.126907845785225, 'warp': 0.7637324999861898}
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
@ -27,6 +27,18 @@ slope grid ...
{'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
@ -39,4 +51,10 @@ slope grid ...
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
done 45.1 s
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