CultLib/docs/cultmath-interval-ground-cut.md
2026-10-04 13:00:00 +02:00

1624 lines
97 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CultMath intervals and the gamecult.org ground: cut map
Status, 2026-10-04: interval-ops, glsl-lowering, snoise-tie (48aadda0) and affine-forms with
affine-shaders (156782da) are on main; the as-built record is the last section. Figures quoted in
Passes 1-5 at SNOISE_LIPSCHITZ 10.099261 predate the tie fix (now 7.9640074) and are conservative;
design.md carries the current measurements. Map history: Imagination pass 4, 2026-10-03. Pass 1's Q1–Q3 are answered (below);
the Self opened the `cultmath-tapes` campaign from pass 2; pass 3 revises the
two CultLib cuts after their first Hands reports and the operator's challenge
to the march, and changes the site field to envelope-then-noise; pass 4
(section "Pass 4") revises `interval-ops` to r3 after its second Hands report
and Soul's verdict, and names the one shipped void (Q9). Pass 5 (section "Pass 5") maps
affine forms against CultLib `main` at `a7966142`. Where a later pass and
an earlier section disagree, the later pass wins. Where this map and the Body
disagree, the Body wins and this map is stale.
Pinned HEADs (every `file:line` below is against these):
| Repo | Ref | SHA |
| --- | --- | --- |
| CultLib | `main` | `c49c76ee` (CultMath 0.3.0 merged at `614fd445`) |
| gamecult-site | `main` | `535d170` (before `wash-home-only` lands) |
| Aetheria | local checkout | read only; `Volumetric.cginc`, `Raymarching/CloudShader.shader`, `Compute/Stardust/Stardust.{compute,shader,cs}`, `CustomDoF/DepthOfField.hlsl`, `Zone Display/VolumeCloudRenderer.cs` |
## The operator's words
1. "Can we replace the site's background with a fancy yet unobtrusive and
inexpensive shader thing? It's odd that my main site wouldn't have that,
considering my specialization. Would it be too much to have some optimized
subset of Aetheria's volumetric clouds shader in there?"
2. After the Self proposed a quarter-resolution, 8–16 step, static-on-phones
design: "map it as a cut that includes the GPU interval arithmetic we're
borrowing from Fidget for a planned Asura campaign. I'd like this shader to
be the proving ground for that - with the right math we can absolutely run
this fullscreen on mobile, per pixel as long as the sample count is low and
we're accumulating samples with TAA (Aetheria's TAA is honestly
embarrassing and it still handles smooth 1080p clouds on my tired old
1070)"
3. On pass 1's Q3 (ground tokens as the shader's input): "I like that, but the
background should be nice and dynamic and we should sample the wash from
the result instead. Is there any point sending you video? I don't think
your vision model has anything in it to encode motion. Anyway, this video
really blew people away, with its combination of the clouds, the flowing
stardust and my hacked DoF shader where I replaced the circle of confusion
calculation with an output from the raymarching shader, I think it was
final transmittance? Doesn't matter, looks great to have the clouds blur
what's behind them. This does remind me we're totally gonna need that
stardust too, it really sells the motion of the nebulae"
Rulings from pass 1: **Q1** new campaign `cultmath-tapes`. **Q2** the slow
ladder is samples, then resolution, then the CSS wash. **Q3** answered outside
the options: the shader owns the ground and it moves; the CSS wash is sampled
from the shader's result; the ground tokens are seed and fallback only, never
the shader's input; stardust and the raymarch-driven DoF are in scope.
## Ownership
### Where the interval math lives
**CultLib, `packages/cultmath`.** This is the parked campaign's own first step
(`docs/cultmath-tape-target.md`, sketch step 1: "CultMath `interval` in C# and
HLSL with parity tests ... It stands alone, and is the first cut to take even
if the rest stays parked"). The "planned Asura campaign" in the operator's
words is that document; its first named consumer is Aetheria's volumetric
raymarching, and Asura itself does not need intervals
(`cultmath-tape-target.md:103`).
The site speaks WebGL2 GLSL ES 3.00. Three routes were weighed:
| Route | What | Why not |
| --- | --- | --- |
| A | Hand-port the needed functions to GLSL inside gamecult-site | A second noise authority with no parity test; the exact thing Aetheria's `GPU Noise` copies are (follow-up `aetheria-snoise-pin-bump`). |
| B | `dxc -spirv` then SPIRV-Cross to GLSL ES, using the existing `tools/compile-hlsl-spirv.ps1` chain | SPIRV-Cross emits one whole shader with mangled names; there is no library a site shader can call `cultmath_snoise` from. |
| C | **A textual lowering HLSL → GLSL in CultLib with a complete, enumerated transformation list, the discipline of the HLSL → C# mirror (`packages/cultmath/docs/design.md:92-134`)** | Chosen. |
Under C, `shaders/CultMath.glsl` is a committed generated artifact, like
`Swizzles.g.cs`. One implementation of the lowering lives in the test project;
a test pins the committed file equal to the lowering of `CultMath.hlsl` and
regenerates it when asked. GLSL's stricter typing is not patched in the
lowering: the HLSL is written in the common subset (legal, bit-identical) and
glslang compiles the lowered text in CultLib's tooling. Parity runs in two
legs, each with one owner: HLSL ⇔ C# by the existing bit-for-bit mirror;
GLSL ⇔ C# by a committed golden fixture evaluated on WebGL2 in the browser
pane and read back. CultLib has no GPU harness (follow-up
`gpu-snoise-parity`); the browser pane is one. The site vendors
`CultMath.glsl` with the CultLib commit in its header.
### Campaign
`cultmath-tapes` (Q1), repos `GameCult/CultLib` and `GameCult/gamecult-site`,
target doc `docs/cultmath-tape-target.md` unparked for step 1 only. The site
cuts cite `site-masthead:ruling:operator-chromeless-column` and depend on
`wash-home-only` landing first, because they rewrite the same lines.
### Owner, consumers, invariant
- Owner: CultMath owns interval arithmetic and the noise bounds in C# and HLSL,
GLSL derived. The site owns its field, flow, march, TAA, stardust, DoF and
budget controller. `custom.scss` owns the brand palette the shader reads
(four nebula tokens) and the seed values of the ground tokens. `ground.js`
owns the sampled ground tokens while it runs.
- Consumers: the site ground now; Aetheria's nebula raymarch at its CultMath
pin bump; the tapes campaign's HLSL interpreter later.
- Protected invariant, **`intervals-enclose`**: every interval function returns
a `[lo, hi]` containing `f(x)` for every `x` in its input region, in C#, HLSL
and GLSL alike. A consumer that skips a region because `hi < cutoff` has
skipped nothing that was there. The domain warp does not weaken it: a warped
point lies in the ball enlarged by the warp bound, and a test says so.
## The authority map (all four cuts)
- Owner: `packages/cultmath` for `iv_*` and the ball bounds; `GlslLowering`
(test project) for `shaders/CultMath.glsl`; `custom.scss` for the four
nebula input tokens, `--gamecult-wash`, and the seed values of the fifteen
ground tokens; `ground.js` for the flow texture, scheduling, budget, TAA,
the stardust and DoF passes, and the sampled ground tokens; the site GLSL
files for the field, the particles and the resolve.
- Inputs: CultMath reads nothing new. The lowering reads the two HLSL files.
`ground.js` reads the nebula tokens, `--gamecult-wash`,
`prefers-reduced-motion`, `visibilityState`, canvas size, device pixel
ratio, the measured GPU frame time and its own 8×8 readback. The field
reads uniforms and the flow texture only.
- Outputs: `CultMath.glsl`; the interval functions; one canvas under the
page; the fifteen ground tokens written on `:root` from the readback; a
debug log behind a flag.
- Derived state: the ground tokens are derived from the shader's result while
it runs and are seed values otherwise; the CSS wash renders them; nothing
reads them back into the shader. The flow texture is the one owner of
motion: the warp, the TAA motion vectors and the stardust all read it.
`CultMath.glsl` is derived from `CultMath.hlsl`. The TAA history is a cache.
`S`, `dpr` and the particle count are derived from measurement within the
session.
- Forbidden writers: no colour literal in `ground.js` or any GLSL; the shader
never reads a ground token; `ground.js` never reads the ground tokens except
to seed before its first readback; no user-agent, platform or touch-point
sniffing; no class on `body` or `html` by script; no second wash token; no
second flow function (particles and clouds read one texture); no GLSL edited
by hand; no `iv_*` without a C# twin; no noise defined in the site.
- Shared paths: every page's ground, the ritual essays included, is one
canvas and one token set, in CSS and in the shader. Every reduced budget
(slow device, hidden tab, reduced motion, missing WebGL2 or half-float) goes
through the one controller. The 8×8 readback is the one path from the
shader to CSS.
- Deletion line: in the site, the six literal radial colours and three
literal stops become token references and the ritual block's own ground
goes, in the same commit the tokens are declared. In CultLib nothing is
deleted. The subtraction this buys is upstream: Aetheria's
`Assets/Plugins/GPU Noise/*.cginc` at its pin bump, and the tapes campaign's
step 1 already landed.
## The shader design
### What Aetheria contributes, and what the web version keeps
| Aetheria | Mechanism | Web subset |
| --- | --- | --- |
| `Volumetric.cginc:165-221` density | fill + coverage, domain-warped by `flow()` in two half-period phases cross-faded with `tri2` (a flow map; the advection never accumulates) | kept, one coverage octave and one detail octave, CultMath noise |
| `Volumetric.cginc:130-135` `globalFlow` | `Tri3D(p/scale − scroll) · amplitude` | replaced by two `snoise` at low frequency, rendered once per frame into a quarter-res flow texture |
| `CloudShader.shader:83-104` | transmittance integration | kept |
| `CloudShader.shader:261-314` TAA | reprojection by `_PrevVP`, γ=0.5 variance clip, fixed 5% blend, packed depth, the `density2` bug at `:311` | not kept; see TAA |
| `Stardust.compute:118-147` | stateless particles: grid cell hashed to a spawn point, `position −= flow · lifetime · period`, size `parabola(lifetime, 2)`, colour from a hue ramp × nebula tint | kept as an instanced vertex shader reading the flow texture; `gl_InstanceID` is the cell |
| `DepthOfField.hlsl:29-32` `FragCoC` | the CoC is read straight from a global `_DoFBlurTex` (its writer is not in this checkout; the operator recalls final transmittance), then the bokeh gather `FragBlur` `:150-201` | a gather on the stardust layer with radius ∝ cloud opacity at that pixel |
### Density field (site-owned, `cloud.frag.glsl`)
Pass 3 wraps this field in an envelope (a nebula floor with authored wells and
clear sky above it); `coverage` below becomes the noise that displaces the
floor, as in Aetheria. The flow, the phases and the hue stand.
```text
camera at the origin, looking +z, static; the slab z in [Z0, Z1] fills the view
flow(uv) = A · (snoise(q·Ff + o1), snoise(q·Ff + o2), 0) q = slab mid-plane point; quarter-res texture, once per frame
phase_k(t) = fract(t / PERIOD + k/2), w_k = tri2(phase_k) k = 0,1
warp_k(p) = p + flow · (phase_k − 0.5) · PERIOD
coverage(p) = Σ_k w_k · snoise(warp_k(p) · F0) two snoise
occupancy = max(0, coverage − CUTOFF) · K
detail(p) = 1 − |snoise(p · 4F0 + 17)| one snoise; the dune envelope
density = occupancy · mix(0.6, 1.0, detail)
hue(uv) = mix of the three nebula colours by flow.xy / A free: the flow texture's own channels
colour = hue · intensity
```
Three `snoise` per dense sample, one per empty probe. The pattern moves at
velocity `−flow` everywhere, because both phases advance at the same rate;
the cross-fade only changes content. That is what makes the motion vectors
below exact.
### Interval culling along the ray, with the warp
Pass 3 supersedes this section's march: the probe is per screen tile in a
pre-pass, the field has an analytic envelope the probe tests first, and the
loop below becomes the pre-pass's loop over cell ranges. The ball and the warp
enlargement stand.
`cultmath_iv_snoise_ball(c, r)` returns `[n(c) − L r, n(c) + L r] ∩ [−1, 1]`
with `L` the committed Lipschitz constant. Over a segment `[t, t+Δ]` the
unwarped ball is centred at `t + Δ/2` with radius `Δ/2`; the warp displaces
any point by at most `D = |flow|max · PERIOD / 2`, so the ball `(c, Δ/2 + D)`
contains both phases' warped points, and one evaluation bounds the whole
coverage term. The march is adaptive subdivision, Fidget's region test on a
1-D region:
```text
Δ = Δ0; t = Z0/dir.z + jitter · Δ0
while t < t_exit and evals < S:
iv = iv_snoise_ball(p(t + Δ/2) · F0, F0 · (Δ/2 + D)) 1 eval
if iv.hi < CUTOFF: t += Δ; Δ = min(2Δ, ΔMAX) provably empty: skip, grow
elif Δ > ΔDENSE: Δ = Δ / 2 maybe dense: refine
else: integrate density(p(t + Δ/2)) over Δ; t += Δ 3 evals
if transmittance < 0.02: break
if t < t_exit: integrate one coarse sample over the rest the budget ran out; measured, must be rare
```
`iv.hi < CUTOFF` is the interval proof that the `0` branch of
`max(0, coverage − CUTOFF)` wins on the whole segment: Fidget's `min`/`max`
pruning at its smallest, applied by hand to a two-clause expression. Tape and
automatic pruning stay parked; this proves the arithmetic they rest on.
### Step budget
`S` counts `snoise` evaluations per pixel per frame. `S = 16` desktop, `8`
phone, `4` floor; the controller sets it from measurement. A debug mode writes
`evals / S` and an unfinished flag into the colour. Contract: under 1% of rays
unfinished at the chosen `S` on the shipped field.
### TAA, motion-aware
The camera is static but the field moves, so history is reprojected by the
known motion, which the flow texture owns:
- Motion vector: `mv = project(−flow(uv) · dt)` in pixels; history is fetched
at `uv − mv`. Exact for the pattern between cross-fades (see above); the
cross-fade and the detail term are handled by rejection.
- Jitter: Halton(2,3) frame offset on the ray start plus interleaved gradient
noise per pixel, rotated per frame.
- Accumulation: `α = max(1/frames, 1/32)` on colour and transmittance.
- Rejection: a 3×3 min/max box of the current frame clamps the reprojected
history (AABB clamp, not variance clipping); out-of-bounds fetches take
`α = 1`.
- Reset on resize, on the first frame and on a budget step.
- Buffers: two RGBA16F history targets at canvas size (8-bit stalls
convergence at `α = 1/32` on a ground this dark); missing half-float falls
back to the CSS wash through the controller.
### Stardust (cut `site-stardust`)
Stateless, as Aetheria's: instance `i` is a cell of an `N×N` grid over the
slab's mid-plane, hashed by `cultmath_pcg3d` to a spawn point and a lifetime
offset; `lifetime = fract(t/PERIOD_P + offset)`; position = spawn − flow(uv
of spawn) · lifetime · PERIOD_P, read from the flow texture in the vertex
shader; size = `parabola(lifetime, 2)` × a hashed size; colour = the nebula
hue at its screen position × a hashed brightness. Rendered as instanced
billboards (one 6-vertex quad, `gl_InstanceID`) with additive blending into a
half-resolution RGBA16F stardust layer, with the `powerPulse` falloff of
`Stardust.shader:74-78`. Count: 16,384 desktop, 4,096 phone, controller-scaled
with `S`. The particles and the clouds move as one because they read the same
texture; there is no second flow.
### Depth of field driven by the raymarch (cut `site-stardust`)
Aetheria's hack: the CoC is a raymarch output, so clouds blur what is behind
them. Web version: in the resolve pass, the stardust layer is gathered with an
8-tap Poisson disk whose radius is `RMAX · opacity(uv)`, where
`opacity = 1 − T` is the TAA-accumulated cloud transmittance at that pixel
(the stable, converged value, which is why it is read from history and not
from the current frame). Composite: `ground + clouds + stardust_blurred · T`.
All stardust is treated as behind the whole cloud layer; a per-depth
transmittance would cost a second march and is not in scope.
### Sampled wash: the CSS reads the shader
After the resolve, a tiny pass downsamples the final frame to 8×8 (RGBA8).
`ground.js` reads it back asynchronously: `readPixels` into a
`PIXEL_PACK_BUFFER`, a `fenceSync`, then `getBufferSubData` on a later frame
once the fence has signalled. Never a synchronous readback. At most twice a
second, and only written when a value moved by more than 1/255:
- `--gamecult-ground-0/1/2`: the mean of rows 0–1, 3–4 and 6–7.
- `--gamecult-light-N`, `-a`: the colour at the seed light's centre texel,
split into the hue (normalised) and the excess over the ground stop there.
- `--gamecult-light-N-at`, `-r`: the seed values; positions are not fitted.
- `--gamecult-ground-mean`: the 8×8 mean, for any tinted UI.
These are written to `:root`'s inline style, so they beat the stylesheet's
seed declarations. The seeds live on `:root` in `custom.scss` (today's
literal wash), with the ritual essays' seeds under
`:root:has(body[data-slug="Blog/..."])`. The body background and the ritual
ground read the tokens. When WebGL2 is absent or the controller removes the
canvas, the seeds are what paints. Across page loads the last sample may be
carried as a cache (Q5).
### Palette input
The shader reads four input tokens from `custom.scss`, which is where the
brand says colour is defined: `--gamecult-nebula-0/1/2` (today orange
`255 138 42`, violet `109 96 255`, sky `89 183 255`) and
`--gamecult-nebula-base` (`#07111a`), plus `--gamecult-wash` as intensity.
Inputs and outputs are different tokens, so there is no cycle (Q4).
### Resolution and the slow ladder (Q2)
Full device pixels. The controller's ladder: `S` 16 → 8 → 4, then `dpr → 1`
(the stardust layer and DoF follow the canvas), then the CSS wash. Never a
step up within a session.
### Pausing and fallbacks
- `visibilityState !== "visible"`: no frames; on return, history is kept and
the motion vector uses the real elapsed `dt`, clamped to one frame.
- `prefers-reduced-motion: reduce`: time is frozen (phase, flow and particle
lifetimes fixed), render until 32 accumulated frames, stop; resize
re-renders. The clouds and stardust are there, still.
- No WebGL2, context lost, no half-float: canvas removed; the seeds paint.
- Too slow: measured; see below.
- The canvas is `position: fixed; inset: 0; z-index: -1; pointer-events:
none; aria-hidden`. The root background paints first, so the canvas covers
the CSS wash only once drawn.
### Text contrast
The resolve clamps relative luminance at 0.035 (about today's orange hotspot,
`#07111a` + `rgba(255,138,42,.18)` ≈ `(53, 42, 34)`, luminance 0.025), after
stardust and DoF are composited. Body text `#b7c7d9` (0.55) keeps ≥ 7:1 on any
ground pixel. A readback test asserts the max after 32 frames on `/`.
## Budget and how it is measured
| Device | Pixels | GPU per frame at 30 fps | Proxy |
| --- | --- | --- | --- |
| Desktop, GTX 1070 class, 2560×1440 | 3.7 M | ≤ 2.0 ms | 6% GPU duty |
| Laptop iGPU, Iris Xe, 1920×1080 at dpr 1.25 | 3.2 M | ≤ 3.5 ms | 10% duty |
| Mid-range phone, Pixel 6a / Galaxy A54 class, 1080×2400 | 2.6 M | ≤ 4.0 ms | ≤ 12% duty; no upward drift of the median over 5 minutes |
Cost model for the full stack on the phone (one `snoise` ≈ 150 flops, ~1
TFLOP available; estimates, replaced by measurement):
| Pass | Work | ms |
| --- | --- | --- |
| Flow texture, quarter res | 160 k texels × 2 snoise | 0.05 |
| Tile pre-pass (pass 3), 1/64 res, `N = 8` | 40 k tiles × ≈ 30 probes in the cavity (interior retired in a few, the wall's ≈ 15 cells at 1.5 probes each), envelope-only because the noise ball cannot prove anything at this scale; ≈ 0.1 snoise-equivalents per pixel; worst case ≤ 192 probes per tile | 0.1 (≤ 0.8) |
| March at `S = 8` | 2.6 M × 8 snoise per frame, every pixel marches a wall; LOD and the mask decide where the frame's samples land, not how many (the controller does) | 3.3 |
| TAA resolve + DoF gather | ~19 texel reads per pixel, plus the depth-history fetch and one matrix multiply for the camera reprojection | 1.1 |
| Sun self-shadowing sample (named, not taken) | one envelope-only transmittance sample per primary sample, ≈ +25% of the march | +0.8, not on the phone |
| Stardust layer, half res, 4,096 particles | fill-bound, small | 0.2 |
| 8×8 downsample + PBO readback at 2 Hz | tiny | 0.05 |
| **Total at `S = 8`, the cavity (pass 3)** | | **≈ 4.8** |
| Total at `S = 6` / `S = 4` | | ≈ 3.9 / 3.1 |
Said plainly: LOD does not change the per-frame cost, which the controller
fixes by `S`; it changes how much a frame's samples are worth. At `S = 8` the
cavity does not fit the 4 ms phone budget on the estimate (≈ 4.8); `S = 6`
sits at the line and `S = 4` is under it. What gives is `S`, and with the
mask and LOD that is nearly free: a converged wall needs about 12 snoise per
pixel in total, so `S = 4` converges in about three frames through the TAA
instead of thirty, and `S = 8` in two. Then `dpr`, as before. What gives, in order, is the
sample count, then the resolution; the stardust and the DoF are never dropped
before the clouds' samples; the self-shadowing sample is not taken on any
device until measured. On desktop at `S = 16` the stack is ≈ 1.9 ms.
Measurement, in this order, all in `ground.js` behind a debug flag and used by
the controller: `EXT_disjoint_timer_query_webgl2` where present (Chrome
desktop) per pass; else a `fenceSync` after the resolve, polled (universal on
WebGL2, phones and Safari included; the queue of a background page is
otherwise empty); else `requestAnimationFrame` cadence. Hands proves it in the
browser pane: desktop numbers with the timer query, the phone's pixel count
from the mobile preset, the phone's GPU time from the operator's phone.
## The proving-ground contract (what the tapes campaign inherits)
1. **Enclosure tests in C#** (and HLSL by the mirror): every `iv_*` op over
random intervals and points; `iv_snoise_ball`/`iv_fbm_ball` over 2,000
balls × 64 points. A mutant that shrinks any bound dies.
2. **The warp is enclosed**: for random flows bounded by `D`, every point
`warp_k(p)` over the segment lies in the enlarged ball and `snoise` of it
lies in `iv_snoise_ball(c, r + D)`. This is the test the site's march rests
on.
3. **The Lipschitz constant has provenance**: the max of `|snoise_grad|` over
10⁶ points refined by gradient ascent, × 1.10, measured by a committed
test and pinned by another. Empirical with a margin, not a proof; the
enclosure tests are the defence.
4. **Tightness is a number**: mean interval width over true range, printed
by a committed test, for a later affine cut to beat.
5. **The saving is a number** (amended in pass 3): a C# test marches tiles of
rays through four scenarios, an envelope-then-noise field with authored
empty space among them, with the dense march and with the tile-amortized
interval march, and asserts ≥ 2× lower combined cost at equal
transmittance on the authored scenarios; the uniform slab is printed as
the worst case. The shipped field's evals per pixel and unfinished
fraction come from the browser.
6. **Three runtimes agree**: the GLSL golden fixture passes on WebGL2, with
tolerance class and device named in the report.
Not proved here, said plainly: tape evaluation, automatic pruning, `min`/`max`
choice tracking, affine arithmetic. Those unpark with steps 2–4.
## Cut order
```text
glsl-lowering (CultLib) ─┐
interval-ops (CultLib) ─┴─> site-ground (clouds, flow, TAA, sampled wash) ─> site-stardust (particles, DoF)
^ after site-masthead wash-home-only
```
Four cuts. The two CultLib cuts are as in pass 1, except `interval-ops` gains
the warp enclosure test. `site-ground` grew (flow texture, reprojection, the
readback and token restructure); `site-stardust` is split out because its
Soul vocabulary is different (particle statistics, blur quality, the composite)
and because the ground must be measured on its own before the stardust is
charged against the same budget. No CultMath release is in the sequence; the
site consumes a commit.
Specs: `docs/cultmath-interval-ground-cut-glsl-lowering.spec.json`,
`docs/cultmath-interval-ground-cut-interval-ops.spec.json`,
`docs/cultmath-interval-ground-cut-site-ground.spec.json`,
`docs/cultmath-interval-ground-cut-site-stardust.spec.json`. Revision 2 of
the two CultLib cuts (pass 3):
`docs/cultmath-interval-ground-cut-interval-ops.r2.spec.json`,
`docs/cultmath-interval-ground-cut-glsl-lowering.r2.spec.json`. Revision 3 of
`interval-ops` (pass 4): `docs/cultmath-interval-ground-cut-interval-ops.r3.spec.json`;
`glsl-lowering` r2 and `site-ground` r3 now depend on it.
## Standing design decisions (means; Self may overrule)
- D1. Intervals are `float2(lo, hi)`. Affine arithmetic is not taken; the
tightness number says whether it is ever worth it.
- D2. No `iv_*` function has a value-only or gradient twin; the bounds take
the point value only.
- D3. The site vendors `CultMath.glsl` by CultLib commit, SHA in the header, a
drift check in the site cut.
- D4. The flow texture is the one owner of motion. Warp, motion vectors, hue
and particles read it; nothing evaluates flow a second way.
- D5. Routes are page loads (`enableSPA: false`, `quartz.config.ts:15`); if
SPA is ever enabled, `ground.js` listens for Quartz's `nav` event and
re-reads the input tokens.
- D6. The budget verdict is session-only; a stored verdict would outlive a
driver update.
- D7. Inputs and outputs are different tokens: four nebula inputs, fifteen
ground outputs with seeds. The shader never reads an output; CSS never reads
an input except through the shader's result.
- D8. The ground tokens are written on `:root` inline, so the seed in the
stylesheet loses to the sample without a class or a second rule.
- D9. The DoF blurs all stardust by the final opacity; per-depth transmittance
is out of scope.
- D10. Stardust and DoF land after the ground has been measured alone, so the
report can say what each costs.
## Pass 3: the envelope, the scenarios, the tile pre-pass, the split Phacelle
Pinned: CultLib `main` `d0ea37f8`; `hands/cultmath-interval-ops` at
`c9bd003b` (report `cut-interval-ops.h1`); `hands/cultmath-glsl-lowering` at
`5ae207e8` (report `cut-glsl-lowering.h1`, branched from interval-ops).
Rulings: `operator-tile-amortized-march`, `operator-split-phacelle`,
`operator-compiler-downloads`, `self-exp-measure-in-site`,
`operator-ground-void-brush` (answers Q6; the void scene below).
### The operator's words
On the march: "I'm skeptical about the interval march, both in implementation
and measurement. Are we testing it in scenarios where we're actually giving
it empty space to skip? Surely if the pre distortion SDF represents a clear
sky with the camera above the fog and aimed up, the interval math can quickly
tell us not to bother marching very far, no? In Aetheria the levels are
authored to place deep gravity wells for the player to gaze across, with
dynamic action spaced around the well, and finding the empty space there is
the whole point of using intervals, no?"
He is right. r1's saving test marched one `snoise` octave against a cutoff
through a slab with coverage everywhere: the worst case for intervals, and
0.93x said nothing about real scenes.
### What r1 measured, and what was wrong in the march
| Finding (`NoiseBoundTests.cs:344-376` at `c9bd003b`) | Effect | r2 |
| --- | --- | --- |
| Every probe charged one `snoise` per ray | a failed probe cost a third of a dense sample | one probe per tile (the ruling) |
| Coarse probes attempted where the ball could never prove emptiness: `hi = n(c) + L r` clamps to 1, so `L r F0 ≥ 1 + cutoff` is a guaranteed failure; at `StepMax = 2`, `L r F0 = 3.7` | every coarse probe wasted; the 0.74x row | a noise probe is attempted only when `L r F0` leaves room to prove `hi < cutoff`; otherwise the level is "maybe" for free |
| Growth capped at `StepMax = 8 Step0` | clear space cost `Depth / StepMax` probes | ranges double to the grid's end |
| The dense sample evaluated the detail octave even at zero occupancy | the baseline was dearer than a sane shader | coverage first, detail only when density can be nonzero (Aetheria's `if (dist < _SafetyDistance)`) |
| The finest-level probe before each dense cell | fine; amortized it is `1/N²` for a chance at 3 snoise | kept |
| No field structure | nothing to skip | envelope-then-noise, below |
### The field, from Aetheria
`Volumetric.cginc:165-221` with `:91-102`, `Zone.cs:369-397`
(`GetHeight`), `Zone.cs:426` (`PowerPulse`), `Settings.asset`
(`DefaultEnvironment`, `PlanetSettings`):
```text
s(p) = p.y + h(p.xz) the pre-distortion SDF; h ≥ 0 the well map
h(xz) = PowerPulse(|xz| / 2R, 2) · 64 + Σ_b PowerPulse(|xz − c_b| / r_b, 16) · d_b
PowerPulse(x, e) = (1 − 4x²)^e on [0, ½], 0 beyond; r_b = 500 M^0.25, d_b = 30 M^0.175
fade(s) = 1 − smoothstep(0.75 S, S, s) S = SafetyDistance 30: no noise above it
s'(p) = s + A · fade(s) · n(warp(p)) n = the two-phase warped coverage + ½ detail
density = max(0, (F − s') / B) F = FloorOffset −20, B = FloorBlend 10
```
Above the safety distance the density is exactly 0 and the noise is never
evaluated, by the dense march too. The warp applies to the noise argument
only; the height is read at the unwarped `xz` (Aetheria reads
`_NebulaSurfaceHeight` at `pos.xz` and warps inside `triNoise3d`).
### The composed bound (invariant `intervals-enclose`, kept)
Over a tile's frustum slice `[z0, z1]`, every step an existing `iv_*` op:
- Envelope over the slice's exact box, not the ball: `y = iv_mul([z0, z1],
m_y)`, `x = iv_mul([z0, z1], m_x)` with the tile's slope intervals;
`iv_h` = Σ over bowls of `[pulse(far), pulse(near)]`, `near`/`far` the
least and greatest distance from the box to the bowl centre (monotone, so
exact per bowl). `iv_s = iv_add(y, iv_h)`; `iv_fade` its ordered endpoints.
- Noise over the ball: `iv_n = iv_snoise_ball(ball.xyz · F0, ball.w · F0)`,
`ball = iv_frustum_ball(m_c, z0, z1, N / (√2 f), D)`: centre on the tile's
central ray at mid-depth, radius `(Δ/2)·|(m_c, 1)| + z1·N/(√2 f) + D`.
The warp enlarges only this radius; the envelope's box takes none.
- `iv_s' = iv_add(iv_s, iv_scale(iv_mul(iv_fade, iv_n), A))`; the slice is
provably empty iff `iv_s'.lo ≥ F`. When `iv_fade.hi = 0` the noise term is
identically zero and `iv_snoise_ball` is not called: the envelope proves
the slice empty at no `snoise` cost. This is why tiles compound: one
envelope probe per tile can retire a whole ray length.
Two tests pin it: `TileBallEnclosesEveryRaySegment` (every ray's points,
sub-pixel jitter and warp included, inside the ball) and
`EnvelopeBoundEnclosesDensity` (the composed bound encloses `density` at
warped points of 2,000 slices, no tolerance).
### Scenarios and predictions (combined cost, `e = 0.2` snoise per envelope evaluation, `N = 8`)
| Scenario | Setup | Prediction |
| --- | --- | --- |
| (a) height fog | `h = 0`, camera at `y = S + 30`, rays level and up | > 50x: dense pays 256 envelope evaluations per ray; the tile about ten probes shared by 64 rays; 0 snoise either way |
| (b) inside the fog | `h = 0`, camera at `y = F − 10`, rays level | 0.8–1.0x: every ray saturates in a few cells; the pre-pass still probes the whole grid |
| (c) Aetheria-like | zone bowl `R = 2000` + four wells (masses 100, 1000, 10000, 1000), camera above the fog in the bowl, rays gazing across (`m_y ∈ [−0.25, 0.1]`), Aetheria's units and 256-sample quadratic grid | 2–4x: the clear cells before the fog surface collapse to a few probes per tile; the fog cells cost the same on both sides. The noise ball proves nothing at Aetheria's scale (`L r F0 ≥ 1.6` at the finest cell with `D = 60`), so it is not evaluated; the saving is the envelope's |
| (d) uniform slab | r1's field, kept as the worst case | 1.3–1.5x: the ceiling at `L = 10.099261` with the cheaper baseline |
| (e) the void, the shipped scene | the true cavity, hollow radius `Rh = 198`, ramp `S = 50`, camera 186 from the centre, low; every ray crosses interior and ends in a wall; three marches (fixed-step reference, masked fixed-step, masked LOD) | **steps per pixel ≈ 35 → 6 (5–8x fewer), combined cost 1.7x, snoise alone 1.25x**, from the model below. The noise ball proves nothing (`L r F0 ≥ 1.5`); the interior is retired by the envelope, the ramp is sampled at the content limit |
The 2x contract on combined cost is asserted on (a) and (c) at `N = 8`; (b)
and (d) are printed. (e) is the headline: its contract is steps per pixel
(below), and it prints cost and snoise beside it.
### Footprint LOD, and the cavity cost as a function
The operator's words: "Surely this depends on the size of the cavity and the
camera placement, no? The slope of the wall's density gradient offset
determines the region we have to sample in. How much we actually sample that
region depends on how close it is to the camera. A 10-wide gradient 1,000
units away only needs one raymarching step worth of detail, right?"
Right. Pass 3b costed every wall at a fixed step. The march is now
footprint-aware, in the site and in the saving test:
- `θ_px = 2 tan(fov/2) / H`; the footprint at distance `z` is `z θ_px`.
- Step `Δ(z) = max(k z θ_px, min(S/4, λ_fine(z)/2))`, `k = 4`: never finer
than four footprints, otherwise the finer of the two content limits (four
samples across the ramp, two per wavelength of the finest live octave).
- Octave `i` of wavelength `λ_i` carries weight `w_i(z) = 1 −
smoothstep(λ_i/2m, λ_i/m, z θ_px)`, `m = 4`: one smooth blend per octave,
no popping; TAA hides the per-frame sparsity.
- The weights are part of the field, so the bound encloses the truncated
field exactly: the noise interval over a slice is `Σ_i iv_mul([w_i(z1),
w_i(z0)], iv_snoise_ball(c F_i, r F_i))`, `w_i` monotone in `z`.
`intervals-enclose` holds for what the shader evaluates.
- The pre-pass says where each ray's ramp begins; LOD says how finely to
sample it once there. They compound. The reference a march that does not
know where the wall is must pay is the fixed step `min(10, S/4)` everywhere.
The cavity is parametrised by the hollow radius `Rh` and the ramp width `S`;
the brush `(1 − (d/Rc)²)^e` with `CARVE` gives `Rh = Rc √(1 − CARVE^(−1/e))`
and `S` from the exponent (`e = 4` gives `S ≈ 1.3 Rh`, `e = 0.25` gives
`S ≈ 0.12 Rh`), so `(Rh, S)` are the knobs and `(Rc, e)` derived. Shipped:
`Rh = 198`, `S = 50`, so `Rc = Rh + S = 248` and `e = −ln CARVE / ln(1 −
(Rh/Rc)²) = 0.40`, `K = 1/30` (pass 4 corrects this line's `e ≈ 0.6, Rc ≈
220`, which did not satisfy the formula; Q9 holds the look question).
Imagination's model (scratchpad `cavity_model.py`; the test confirms or
refutes), 1080p, `N = 8`, per pixel for a converged frame; "ref" is the
fixed-step reference's steps:
| `Rh` | camera | `S = 10` steps / snoise / cost ratio | `S = 50` | `S = 150` |
| --- | --- | --- | --- | --- |
| 100 | low | 6 / 12 / 2.6x | 6 / 12 / 1.6x | 14 / 36 / 1.3x |
| 200 | low (**shipped**, ref ≈ 35 steps) | 6 / 12 / 3.2x | **6 / 12 / 1.7x** | 14 / 36 / 1.4x |
| 400 | low | 6 / 12 / 4.0x | 6 / 12 / 1.9x | 14 / 36 / 1.5x |
| 1000 | low | 5 / 9 / 8.2x | 6 / 12 / 2.5x | 14 / 36 / 1.7x |
| 1000 | centred | 4.3 / 7 / 12.9x | 6 / 12 / 2.9x | 14 / 36 / 1.8x |
Centred and mid offsets differ from low by under 10% at every point. What
the table says: within the frustum the footprint is under 4 units, so the
step is set by the content limits (`S/4`, and the detail octave's 12.5-unit
half-wavelength), not by distance; the "10-wide gradient 1,000 units away"
row is where the footprint finally bites (4.3 steps, 7 snoise). The sample
count is nearly flat in `Rh` and camera offset and rises with `S` only once
the detail wavelength, not the ramp, sets the step. What makes the shipped
scene cheaper without losing the mood, in order: a lower per-frame sample
budget, which LOD and the mask make nearly lossless (`S_budget = 4` converges
in about three frames at the walls); a thinner ramp (`S = 10` is 3.2x on cost
but reads as a surface; 25–50 keeps the fog); a longer detail wavelength or
no detail octave at the far wall; a larger hollow, which changes the
composition and costs nothing per pixel.
**Q8 retired.** At the shipped point the shipped march takes 5–8x fewer steps
than the fixed-step reference and 1.7x less combined cost; the contract for
(e) is the number that scales with GPU time, steps per pixel: ≥ 2x fewer than
the fixed-step reference at the shipped parameters, with snoise per pixel and
combined cost printed beside it and promised nothing. The test asserts that;
the browser measures it (`?ground=debug` against `?ground=dense`). A shortfall skips the assertion naming `saving-2x-scenarios`; the
fields, grids and `e` are not tuned. The honest summary: at a Lipschitz bound
near 10 the noise ball is weak everywhere; what intervals buy on these fields
is the envelope's empty space, found exactly, and the tiles make finding it
nearly free.
### The site ground: the void (ruling `operator-ground-void-brush`)
The operator's words: "There was an old main menu visual with a purely
authored field, first thing I did all the way back when the shader was new. I
believe it was a single negative pseudo gaussian spherical brush creating a
void in the clouds, with a sun gently nestled at the bottom lighting the
cavity and orbiting along a track with the camera following low, very moody,
gives us lots of options for authoring the flow field and stardust
distribution"
**The original, found.** Aetheria `e6181c11` ("Main Menu", 2021-03-13):
`Assets/Scenes/Main Menu.unity`, the `Sun` prefab instance under a `Center`
pivot, `Materials/Brushes/Gravity Well.mat` and
`Main Menu Sun Fog Tint.mat`, `Fog Marching Main Menu.mat` on
`VolumeRender.shader` of that commit. What survives, in its units:
| Element | Original value |
| --- | --- |
| The brush | `PowerBrush` `(1 − x²)^16`, depth 40, radius 256 (half depth at r = 52; a Gaussian of σ ≈ 45); the sun's own gravity-well child. A boundary bowl (depth 75, power 0.25, radius 1500) is flat across the scene: a constant 75 |
| The floor | offset −16.46, blend 39.6: the fog top is `y = −91` outside the void, `−131` at the sun |
| The sun | sphere radius 12.5 at `y = −130`: the bottom of the void |
| The orbit | `Center` rotates 5°/s about y (72 s period); the sun at radius 175 |
| The camera | Cinemachine vcam following the sun (framing transposer, distance 150, damping 10, handheld noise amplitude 1 at 0.25 Hz), looking at `Look Target` `(0, −130, 0)`, the orbit centre at the sun's depth; saved at `y = −27` |
| The light | authored, not marched: the sun's tint brush `(1 − x²)^3`, depth 2, radius 128, orange `(1, .4, .1)`, plus a scene-wide blue ambient tint `(0.18, 0.59, 1)` × 0.01 |
| Stardust | 256² particles, spacing 2, floor −60, ceiling 0, height exponent `800 / (ceiling + wellDepth)`: more headroom where the well is deeper, so the void fills with stardust |
| Noise | strength 150, frequency 0.01, safety 50 |
Not surviving: the 2021 `d()` displacement details (the 2021-05-30 "Volumetric
Redux" rewrote it), the tint map's runtime contents, the video. The 2021
brush was radial in `xz` and carved a bowl in a cloud floor; the operator
ruled (Q7, `operator-void-true-cavity`) that the site's void is a true 3D
hollow inside a cloud volume that surrounds it on every side, including
above. Everything else recovered carries over.
**The site scene** (`site-ground` r3; the brush parameters in this paragraph,
`Rc = 640`, `e = 4`, are superseded by pass 4's one shipped void, `Rh = 198`,
`S = 50`, `Rc = 248`, `e = 0.40`, pending Q9): the original's units. Outer volume: an
unbounded uniform fill (chosen over a shell: every ray then ends in a wall
and the march terminates by opacity, never by a far plane; `Z1 = 2 Rc` bounds
the pre-pass range only). The carve: `d = |p − c_v|`, `g(d) = (1 −
(d/Rc)²)^4` for `d < Rc`, `Rc = 640`, `CARVE = 1.5`; `density = K · max(0,
1 − CARVE · g(d'))`, exactly 0 inside the hollow radius `Rh = Rc √(1 −
CARVE^(−1/4)) = 198`, half at `0.49 Rc`, full at `Rc`; `K = 1/30`, so a wall
is opaque within ≈ 150 units of entry. Noise displaces the wall only:
`fade(d) = smoothstep(Rh − S, Rh, d)`, `S = 50`, `d' = d + A · fade · n`,
`A = 20`, `F0 = 0.01`. Its bound over a tile's slice: `d`'s interval is the
box's least and greatest distance to `c_v` (exact); `g`, `fade` and the carve
are monotone in it; the slice is provably empty iff `d_hi + A · fade(d_hi) ·
n_hi ≤ Rh`, and deep inside (`fade = 0`) no noise is evaluated. `Rc` grew
from the brush's 256 because a 3D hollow must hold the camera.
The sun orbits the origin at radius 175, period 72 s; the cavity centre is
`c_v = sun + (0, Rh − 12.5, 0)`, so the sun sphere rests on the hollow's
bottom and the hollow moves with it. The camera follows 120 behind the sun
along the tangent and 45 above it: 186 from `c_v`, 12 inside the hollow, low,
looking at `sun + (0, 60, 0)` so the lit lower wall and the dark upper wall
share the frame. The 2021 follow distance 150 and height 25 do not fit a
hollow a camera can stand in; 120 and 45 are the re-derivation. There is no
sky: behind a fully transmissive pixel the resolve shows the base token, and
only an unfinished ray (budget exhausted before the wall, measured, under 1%)
reaches it. `worldAt(t)` in `ground.js` is the one
owner of sun, cavity and camera; nothing integrates motion. The flow is authored
around the void: a swirl `SWIRL · tangent · (1 − (r/Rw)²)²` (largest
mid-slope, zero at rim and centre) plus the global noise flow, rendered into a
world-space window of side `4 Rw` around the sun, so warp, hue, motion vectors
and the stardust read one texture (D4). The stardust (next cut) spawns in the
bowl with the original's height rule and is lit by the same pulse.
**Lighting, cheapest moody version:** the original's mechanism made 3D. At a
sample, `light = SUN · (1 − (d/Rl)²)^3` for `d = |p − sun| < Rl = Rh = 198`
(the original's 128 scaled with the hollow), plus the blue ambient; `colour =
density · (light + ambient) · hue(flow)`. Zero extra `snoise`; the lower wall
within `Rl` of the sun glows and the upper wall stays dark because the pulse
is zero beyond `Rl`, which is the moody split; the sun inside the hollow is
occluded by nothing, so it is a small additive disc in the resolve, occluded
only by the accumulated transmittance along its ray. A self-shadowing step (one envelope-only
transmittance sample toward the sun per primary sample, no noise, ≈ +25% on
the march) is named, not taken: desktop-only if measurement ever allows.
**TAA with a moving camera:** the main pass writes the depth-weighted mean
march distance to an `R16F` history channel; the resolve reconstructs the
world point at that depth, moves it back by the field motion (`p + flow·dt`),
projects it with the previous frame's view-projection and fetches history
there. Same 3×3 clamp, same `α`, same resets. Colour, transmittance and depth
are reprojected by the one motion vector.
**Fit with the tokens:** `--gamecult-nebula-0` (orange) is the sun's colour,
`--gamecult-nebula-2` (sky) the ambient, `--gamecult-nebula-1` the cloud hue's
second pole, the base the sky. Outputs as pass 2, with one change:
`--gamecult-light-0-at` is written from the sun's projected position, so the
CSS wash's main light follows the sun; its seed is the sun's mean screen
position, so the fallback is a still of the same composition.
**Soundness with a moving camera:** each frame's pre-pass evaluates
`iv_frustum_ball` from that frame's camera; no ball, mask or probe result is
carried across frames. The only cross-frame state is the TAA history, which
is colour, not a soundness input. The saving test draws each tile's camera
independently and says so.
The march: a **low-resolution pre-pass** at `W/8 × H/8` runs the tile loop
over the slab's cell ranges (the envelope first, the noise ball only when the
envelope leaves `fade.hi > 0` and `L r F0` leaves room) and writes a per-tile
skip mask of the dense cells not proven empty into an `RGBA32UI` texel (128
bits; 96 cells at the slab's fine step). The main pass reads one texel
(`texelFetch`) and places its `S` samples in unmasked cells only, stratified
over the unmasked length; masked cells contribute exactly zero, so this is
exact importance sampling, not an approximation. The fragment cannot share a
probe across pixels (derivatives reach a 2×2 quad at best), so the pre-pass
is the cheaper and the only sound option in WebGL2. Cost at `N = 8` is in the
budget table: ≤ 144 probes per tile worst case (nothing skipped, hierarchy
descended everywhere), mostly envelope-only, ≈ 0.4–2 snoise-equivalents per
pixel; `N = 16` quarters it but makes the ball's lateral term equal the fine
half-length at the slab's far plane. **Predicted `N = 8`.** The site's own
ratio is measured in the browser against a dense reference with the same
envelope gate (the proving-ground contract's second half).
### The split Phacelle and the compile checks (glsl-lowering r2)
Pass 4: the glsl branch rebases onto interval-ops **r3**'s head, because r3
changes `cultmath_iv_frustum_ball`'s radius and the fixture family with it.
- Transformation step 1 gains one licence-keyed entry: an include under its
own licence is lowered into its own file. `CultMath.glsl` (MIT: `CultMath.hlsl`
+ `CultMath.Interval.hlsl`) and `CultMath.Phacelle.glsl` (MPL-2.0,
concatenated after it only by consumers that call `cultmath_phacelle`; the
site does not). Both pinned; `THIRD-PARTY-NOTICES.md` lists the MPL files
and no longer calls `CultMath.glsl` MPL.
- `iv_exp` joins the fixture with a platform tolerance (1 ulp, the generating
OS named), because `self-exp-measure-in-site` has the site measure WebGL2
`exp` against it. The fixture is regenerated for `cultmath_iv_frustum_ball`.
- glslang 16.6.0 and dxc are downloaded pinned and run: the MIT-only wrapper,
the two-file wrapper, a `lerp(` negative control, and a `cultmath_phacelle`
call against the MIT-only wrapper that must fail (proves the split).
- The glsl branch rebases onto interval-ops r2's head; its first commit is the
regeneration alone.
Carried, still out of scope: the Unity `CultMath.dll` rebuild (no release in
the sequence); the fixture's ulp bounds on WebGL2 (site cut); Stryker's
timeouts are reported as timeouts, not kills.
## Pass 4: Soul's five findings, the analytic body, the one shipped void
Pinned: CultLib `main` `780d8cfa` (docs only since `d0ea37f8`);
`hands/cultmath-interval-ops` at `43183276` (report `cut-interval-ops.h2`,
verdict `cut-interval-ops.s1`, worktree `F:\Projects\CultLib-iv`). Rulings new
in this pass: `operator-wells-contract-vs-ceiling` ((c) is held to at least 90%
of its probe-free ceiling, not 2x: the ceiling is what intervals control; the
fog band's cost belongs to the field) and `operator-analytic-wall-body` (where
the envelope interval proves the density constant, one closed-form
Beer-Lambert step instead of sampling at the content limits). Spec:
`docs/cultmath-interval-ground-cut-interval-ops.r3.spec.json`.
### What r2 measured
| Scenario | r2 result | Pass 3 prediction |
| --- | --- | --- |
| (a) height fog | 16384x | > 50x |
| (b) inside the fog | 0.99x | 0.8–1.0x |
| (c) Aetheria wells | 1.41x at warp D (3.32x at warp 0); probe-free ceiling 1.42x (3.46x) | 2–4x |
| (d) uniform slab | 1.23x | 1.3–1.5x |
| (e) void, shipped | steps per pixel 36.34 → 16.45 (2.21x fewer), cost 1.34x, snoise 26.6 | 35 → 6 (5–8x), cost 1.7x |
Two things the pass 3 model got wrong, both now owned by r3: the footprint
step kept its content limits (`S/4`, the detail half-wavelength 12.5) inside
the wall's constant-density body, which the model had costed at two steps; and
the noise band does not end at `Rc`: with `A = 20` and the free noise interval
`[−1.5, 1.5]`, the density is provably `K` only from `d ≥ Rc + 30`. The ruling
answers the first; the second is content and stays.
### Soul's findings and what r3 does with each
| Finding | Severity | r3 |
| --- | --- | --- |
| `frustum-ball-float-crossing` | Medium | the radius is widened by a derived float32 bound (below); seven extreme families join the enclosure test; the strictness sentence leaves the C#, the HLSL and `design.md` |
| `aetheria-far-plane-miscited` | Low | `far = 2048`, the Main Camera's (`ARPG.unity:31777`, `:31830`); the Tint camera's 1000 was the wrong camera |
| `shipped-void-two-scenes` | Medium | one shipped void, `(Rh, S) = (198, 50)`, `(Rc, e) = (248, 0.40)` derived; the map, the site-ground spec and the test agree; the look question is Q9 |
| `lod-weights-never-exercised` | Low | configuration "LOD far" (`Rh = 4000`, `S = 50`, low camera, `f = 467`) reaches weights in `(0, 1)` and at 0; the `[w(z1), 1]` bound is recorded as a deviation with its soundness argument |
| `stryker-timeout-survivor` | Low | 2525 named equivalent; ball radii to `1e3` so `L/r` dies to an enclosure test; every timeout hand-run; the report claims only demonstrated kills |
### The float widening rule (finding 1)
`iv_frustum_ball` returns a float32 ball; its promise is that every exact
point `(m z, z)` of every ray of the tile, `m` in the footprint, `z ∈ [z0,
z1]`, moved by at most the warp, lies inside it. Soul showed the r2 ball misses
such points by up to `5.7e-4 r` when `z0 == z1`, `3.3e-4 r` at `z ≈ 1e7` and
19% of `r` at `|m_c| ≈ 1000`: the triangle inequality is an equality on the
degenerate slice and the centre's own rounding crosses it. The rule, in C# and
both HLSL copies, after the r2 radius:
```text
radius += (|c.x| + |c.y| + |c.z| + radius) · 2^-20 (9.5367431640625e-7, exact)
```
Derivation with `u = 2^-24`: the centre `c = (m_c z_m, z_m)` carries at most
two roundings per component (`z_m = fl(z0 + z1) · 0.5`, then the product), so
`|c_float − c_exact| ≤ 2u |c|₁`; the radius carries at most seven first-order
roundings (`z1 − z0`; the axis `sqrt(mx² + my² + 1)` at `3u`; the product; `z1
· footprint`; the sum; `+ warp`), so `r_exact ≤ r_float (1 + 7u)`; the
widening's own addition rounds once more. `2^-20 = 16u` covers `8u (|c|₁ + r)`
twice over. fma contraction removes roundings and only tightens, so dxc's HLSL
and any driver's GLSL are covered by the same bound. Cost at the shipped
scales: `|c|₁ ≈ 600`, `r ≈ 5` → `6e-4` units, 0.01% of the radius. The
enclosure test computes distances in double from the float ball and the float
ray, no tolerance, over the r2 domain and the extreme families (`z0 == z1`;
`z1 − z0 = 1e-6 z1`; `|m_c| ≤ 1000`; `z ≤ 1e7`; `z0 = 0, z1 ≥ 1e-3`; `f ≥ 1`;
warp to `1e6`), corners at both depth ends with the flow pointed outward. The
derivation pin becomes two-sided (`r_exact ≤ ball.w ≤ r_exact + 2^-19 (r_exact
+ |c|₁)`), so a ball that forgets the widening fails and one that grows past
the rule fails. The caller's world transform and its own point rounding remain
the caller's; the function says so in its doc instead of a false proof.
### The one shipped void (finding 3) and Q9
The map held two voids: the Footprint-LOD section's `(Rh, S) = (198, 50)`
(with an `e ≈ 0.6, Rc ≈ 220` that did not satisfy its own formula) and the site
section's `Rc = 640, e = 4` (a 442-wide ramp with a 50-wide fade band). Hands
implemented the first with the arithmetic corrected (`Rc = Rh + S = 248`, `e =
0.40`) and measured 2.21x; Soul measured the second at 1.93x. Under the
analytic body the second gains nothing: across a 442-wide ramp the optical
depth exceeds 4 before the density is provably constant, so rays end inside the
ramp and the body is never proven. The map's own rule says `(Rh, S)` are the
knobs, so the honest choice is the first; it changes the look relative to the
site section, hence Q9 (below). For `CARVE = 1.5` the ramp width is tied to the
exponent (`S/Rh = 1/√(1 − 1.5^(−1/e)) − 1`: `e = 0.4 → 0.25`, `e = 0.7 →
0.5`, `e = 1 → 0.73`, `e = 4 → 2.2`), so "pseudo-Gaussian" and "narrow" cannot
both be had without a third knob, and a narrow wall at `e = 4` needs
`CARVE ≈ 58`, which is a hard wall again. The ramp width is the look-cost knob.
### The analytic body (ruling `operator-analytic-wall-body`)
Consumer-side, no new CultMath function; the saving test and the site mirror
each other.
- **When it applies.** The consumer's `Bound` over the whole remaining range
`[z, z_end]`, gated, is a point interval with positive value: for the void,
the envelope with the free noise interval `[−1.5, 1.5]` proves `fade = 1`
and `d_lo + A · (fade n)_lo ≥ Rc`, so every point of every ray of the tile
over the rest of the grid has density exactly `K` for any noise value. For
(a)–(d) no bound is a positive point (the fog's density varies with `s'`),
so the step never applies there, and the test asserts it.
- **Pre-pass.** `MarchTile` returns `(dense cells, bodyStart, bodyDensity)`:
when a single cell is not provably empty and its own bound is a positive
point, one more probe over `[z_j, z_end]`; if that is a positive point too,
`bodyStart = j` and every remaining cell is appended dense without probing
(always sound; it also drops the body's probes). The site writes the cell in
the mask texel's fourth word, `0xFFFFFFFF` when none.
- **Main pass.** Unmasked cells before `bodyStart` as before; at `bodyStart`,
`T *= exp(−K · (z_end − z) · |dir|)`, one step, stop. The grid end for every
void configuration becomes `|camera − c_v| + Rc + 6/K` (was `5/K`): the
body starts at `d ≥ Rc + 30` plus box slack, so `6/K` leaves at least 4.9 of
optical depth and `T ≤ 0.0075`. No probe in the main pass: the constant is
the field's pointwise envelope value at that cell.
- **Pin.** `AnalyticBodyMatchesFineMarch`: wherever a body is reported, the
bound over the rest of the grid is a positive point and the full and the
truncated density at 64 sampled points of the body equal it exactly; the
analytic factor equals the fixed-step product over the same cells within
`1e-4` relative; no (a)–(d) tile reports a body. The masked fixed-step march
stays the mask's soundness pin (exact agreement with the reference); only
the LOD march takes the step. Hand mutation that must fail: in
`VoidField.Compose`, `CarveRadius → HollowRadius` in the lo test.
### Footprint LOD, exercised (finding 4)
At `f = 935` every octave weight is 1 inside every r2 grid (the fine weight
drops below 1 past `z = 2922`; the largest grid ends at 2240). `VoidField`
takes a focal length, and one configuration reaches the weights: `Rh = 4000`,
`S = 50`, low camera, `f = 467` (a 98° vertical FOV at 1080 rows): fine weight
below 1 past 1460 and 0 past 2920, coarse below 1 past 5840, grid to ≈ 7990;
the footprint term `4 z θ` also exceeds 12.5 past 1460, so `LodStep` is
footprint-set there. It joins the enclosure test's field list and is printed by
the saving test, not asserted. Deviation recorded: the bound takes each weight
over `[w(z1), 1]`, not `[w(z1), w(z0)]`, because one `Bound` serves the full
field (`w = 1`) and the truncated one (`w ∈ [w(z1), w(z0)]`); `[w(z1), 1]`
contains both and `iv_mul` is monotone in its interval argument, so the
composed interval encloses both fields; the slack is only in far tiles whose
step the footprint already sets. Hand mutation that must fail the truncated
check there: `w(z0)` for `w(z1)`.
### Contracts after r3 (asserted at `N = 8`)
- (a): at least 2x combined cost. Measured 16384x.
- (c): at least 0.90 of its probe-free ceiling (`Ratio / Ceiling`, the lower
of warp 0 and D). Measured 0.99 at warp D, 0.96 at warp 0.
- (e): at least 2x fewer LOD steps per pixel than the fixed-step reference at
the one shipped void; the masked march exact; the LOD march within 0.02.
- (b), (d), the (e) grid and LOD-far: printed only.
### Predictions (Imagination's rule, not a measurement)
From r2's printed rows: the LOD steps that evaluated noise are about
`snoise / 2.8` plus two pre-band steps; the rest were constant-body steps,
which the analytic step makes one. Ref/LOD steps:
| `Rh`, camera | `S = 10` r2 → r3 | `S = 50` r2 → r3 | `S = 150` r2 → r3 |
| --- | --- | --- | --- |
| 100, low | 2.1 → 5.3x | 1.8 → 2.5x | 1.6 → 1.6x |
| 200, low | 2.7 → 5.8x | 2.2 → 2.8x | 1.8 → 1.8x |
| 400, low | 3.4 → 6.2x | 2.9 → 3.5x | 2.2 → 2.2x |
| 1000, low | 6.8 → 10.9x | 4.5 → 5.2x | 3.3 → 3.3x |
| 1000, centred | 12.4 → 30x | | |
| **shipped** (198, low, 50) | | **2.21 → ≈ 2.9x (12.5 steps), cost 1.34 → 1.38x, snoise 26.6 unchanged** | |
At `S = 150` rays end inside the ramp before the body is proven, so nothing
changes there. If the shipped point lands under 2.6x, the report says which
assumption failed: the pre-band's 0-snoise steps, or a body start later than
`Rc + 30` by box slack.
### Authority map delta (against pass 3's)
- `iv_frustum_ball` is now the owner of its own float32 rounding: the
widening is part of the function. Consumers add nothing for it and remain
owners of their world transform.
- The body proof is derived from the same `Bound` the mask is derived from, by
one more probe; the main pass reads it and never decides it. The Beer-Lambert
step is the consumer's integration, not a CultMath function.
- `(Rc, e)` are no longer authored; they are derived from `(Rh, S)`. The site
section's `Rc = 640, e = 4` is no longer an owner of the look (Q9 pending).
- (c)'s contract is derived from its own run (0.90 of the ceiling); no absolute
target is written for (c).
- Deletion line: the false strictness sentence, the Tint camera citation, the
(c) 2x assertion and the second shipped void leave before their replacements
are written. Net in `src`: one line and a doc comment.
### Dependent specs
`glsl-lowering` r2 now depends on `interval-ops` r3 and rebases onto its head
(the fixture family for `cultmath_iv_frustum_ball` changes with the widening).
`site-ground` r3 now depends on `interval-ops` r3; its field item carries the
one shipped void with `(Rc, e)` derived, its pre-pass writes the body-start
cell in the texel's fourth word, its main pass takes the one analytic step,
and `Z1 = |camera − c_v| + Rc + 6/K` (614 at the shipped point, 62 cells of
10; the mask has 96 bits). Both files are edited in place on `main`; the
mind's copies need re-admission by Self.
## Questions
**Q9. The one shipped void** (pass 4; finding `shipped-void-two-scenes`).
Authored as `(Rh, S)` with `(Rc, e)` derived; `CARVE = 1.5`, `A = 20`,
`F0 = 0.01`, `K = 1/30`, the camera as site-ground r3.
- (a) `Rh = 198`, `S = 50` → `Rc = 248`, `e = 0.40`. The scene r2 measured
(2.21x; predicted ≈ 2.9x with the analytic body). A 50-wide noisy wall whose
outer edge has a kink (`e < 1`), softened by the ±20 noise displacement and
the TAA. **Recommended**: the map's own rule, the measured scene and the
ruling's analytic body all point at it.
- (b) `Rc = 640`, `e = 4` (`Rh = 198`, a 442-wide pseudo-Gaussian ramp, fade
band 50): the site section's soft wall. Measured 1.93x; the body is never
proven, so the analytic step never fires; the (e) contract fails unless it
is dropped to printed or the detail octave is removed where the ramp is
wide, both of which are the operator's to decide.
- (c) `Rh = 198`, `S = 100` → `Rc = 298`, `e = 0.70`: the compromise, a
softer 100-wide wall; predicted ≈ 2.4x; not in the r2 grid, so unmeasured.
Hands codes (a); another ruling moves only the shipped constants here, in the
r3 spec and in the site-ground spec.
**Q6. The site ground's envelope look.** Answered outside the options by
`cultmath-tapes:ruling:operator-ground-void-brush`: the old main menu's void
(pass 3, "The site ground: the void").
**Q7. What "spherical" means for the brush.** Ruled
`cultmath-tapes:ruling:operator-void-true-cavity`: a true 3D hollow inside a
cloud volume that surrounds it on every side, including above; not the 2021
bowl.
**Q8. `cavity-contract`.** Retired in pass 3d (see "Footprint LOD"): the
contract for (e) is steps per pixel against the fixed-step reference at the
shipped parameters, ≥ 2x fewer, predicted 5–8x; cost and snoise are printed
beside it and promised nothing.
Specs for pass 3: `docs/cultmath-interval-ground-cut-site-ground.r3.spec.json`
beside the two CultLib r2 specs.
**Q4. Where the shader's palette input comes from.**
- (a) Four new input tokens in `custom.scss` (`--gamecult-nebula-0/1/2`,
`--gamecult-nebula-base`), seeded with today's wash colours.
**Recommended.** The nebula palette can be tuned without moving the UI
accent, and the brand doc's rule (colour is defined in `custom.scss`) holds.
- (b) Reuse `--secondary` and `--tertiary` from `quartz.config.ts` and promote
the violet to a config token. Fewer tokens; ties the nebula to the UI accent
for good.
**Q5. Carrying the last sampled wash across page loads.**
- (a) `ground.js` keeps the last written token set in `sessionStorage` and
applies it before the first paint on the next page; the seeds apply when
nothing is stored. A cache, never authority. **Recommended.** Without it,
every navigation shows the seed wash for the first frames, then the shader.
- (b) No storage; the seed paints first on every page. Simpler; a visible
shift on every navigation.
## Unsettled
- The Lipschitz constant is empirical with a margin; a per-cell polynomial
proof is possible and not in this cut.
- `_DoFBlurTex`'s writer is not in the Aetheria checkout; the web DoF takes the
operator's recollection (final transmittance) as the driver. If stills of
the video arrive, Hands can match the blur radius against them, but the
mechanism does not depend on it.
- The ritual ground's radii are `rem` lengths and its first gradient stop is
unpositioned (`custom.scss:124`); Hands normalises it when tokenising.
- Whether GameCult-Quartz's bundler permits a dynamic `import()` from a
component's `afterDOMLoaded` or needs a script tag; one line either way.
- `:root:has(body[data-slug])` for the ritual seeds: supported in every
current browser; a browser without `:has` gets the default seeds, which is
acceptable for a fallback of a fallback.
## Pass 5: affine forms (ruling `affine-forms-unparked`)
Imagination pass 5, 2026-10-03. It maps the affine-arithmetic piece of the
tape target, unparked on its own by ruling
`cultmath-tapes:ruling:affine-forms-unparked`. It answers follow-up
`cultmath-tapes:follow_up:wells-oracle-shortfall-affine-forms`. No tape,
pruning or interpreter is built; tape steps 2-5 stay parked under
`tape-target-unparks-when-asura-stable`. Pinned head: CultLib `main` at
`a7966142`. Every `file:line` in this section is against that commit.
### What was measured (body facts)
The probe is a renamed copy of `NoiseBoundTests.cs` with five more bounds for
`FogField`. It lives on the local branch `scratch/imagination-affine-probe`
(`5fa8e9ce`). That branch was never pushed to origin and is not for merging.
It was run on Yggdrasil through the ygg-verify stopgap (`dotnet/sdk:10.0`,
about 3 minutes). It re-runs `Run(FogField.Wells(w), 8, 64, 0x5A7E + 8)`, so
the tiles, rays and seeds are those of the landed (c) row at `N = 8`. V0
reproduces the landed figures exactly (0.378 and 0.194).
Constants the probe needed, each sampled over 2e5 seeded points with a 1.25
margin (probe grade, not provenance):
- the Frobenius norm of the Hessian of `snoise(float3)`, max 55.15, so
`M = 68.94`;
- the gradient length of `snoise(float2)`, max 7.35, so `L2 = 9.19`;
- for comparison, the mean `|snoise_grad|` is 2.59, against `L = 10.10`.
The bounds:
- **V0**: the landed bound.
- **V1**: V0 with the detail octave's ball no longer enlarged by the warp. The
detail reads the unwarped point (`NoiseBoundTests.cs:860`, `Noise.Detail(p, ...)` of the unwarped `p`), yet `Bound`
hands it `ball.w`, which carries `D` (`:875`, `:890`).
- **V2**: V1 with the warp centred. Each phase's coverage ball is centred at
`c + flow(c) shift_k`, with radius `r + |shift_k| rho`, where
`rho = FlowAmplitude L2 FlowFrequency r sqrt 2` bounds how far the flow moves
over the ball. This is the mean-value form of the warp: interval arithmetic
only, no affine noise.
- **V3**: V2 with the centred form of the noise,
`n(c) +- min(L R, |g(c)| R + M R^2 / 2)` from one `snoise_grad`.
- **V4**: V3 with a reduced affine form. Three shared symbols (depth `t`,
slopes `u` and `v`) carry the envelope's plane term and every noise term's
linear part, and one error symbol takes the rest. It applies where the fade
is the point 1, and is intersected with V3.
- **V5**: V4 with one shared symbol, depth `t`, plus one error symbol:
Gamito's `t0 + t1 e1 + t2 e2`.
A gradient evaluation is counted as 2 snoise, an estimate. V3 to V5 gate a
probe on the best result any centre value and gradient could give.
Range ceiling. A cell is range-nonempty if the density is positive at any of
5 x 5 slopes over the tile's slope square (corners included) times 9 depths
over the cell, or if it is in the oracle mask. Its efficiency is the oracle
march's cost over the cost of marching the range mask, with probes free. The
sampling can miss nonzero points, so this figure is an upper estimate. No
sound enclosure can beat it.
| (c), `N = 8` | efficiency (oracle) | cost ratio | dense cells/ray | probe overhead | cull |
| --- | --- | --- | --- | --- | --- |
| warp 0, V0 landed interval | 0.378 | 2.74x | 12.40 | 0.961 | 0.952 |
| warp 0, V2 centred interval | 0.368 | 2.67x | 12.40 | 0.937 | 0.952 |
| warp 0, V3 centred noise | 0.466 | 3.38x | 8.67 | 0.868 | 0.974 |
| warp 0, V5 affine, one symbol | **0.511** | **3.71x** | 7.81 | 0.859 | 0.979 |
| warp 0, V4 affine, three symbols | 0.534 | 3.88x | 7.37 | 0.853 | 0.981 |
| warp 0, range ceiling | 0.859 | | | | 0.995 |
| warp 60, V0 landed interval | 0.194 | 1.39x | 31.55 | 0.993 | 0.842 |
| warp 60, V1 detail unwarped | 0.205 | 1.47x | 27.74 | 0.963 | 0.864 |
| warp 60, V2 centred interval | 0.347 | 2.49x | 13.71 | 0.938 | 0.945 |
| warp 60, V3 centred noise | 0.441 | 3.16x | 9.52 | 0.870 | 0.969 |
| warp 60, V5 affine, one symbol | **0.477** | **3.42x** | 8.71 | 0.864 | 0.974 |
| warp 60, V4 affine, three symbols | 0.502 | 3.60x | 8.16 | 0.856 | 0.977 |
| warp 60, range ceiling | 0.854 | | | | 0.995 |
Every variant's transmittance equals the dense march's (max |dT| 0). No
variant's pre-pass proved a cell empty where any of the 225 samples per cell
was nonzero (0 enclosure violations).
What this establishes:
1. **The oracle cannot be reached by any sound bound.** The oracle mask
samples one depth per ray. A bound must cover the whole slice of the tile,
so the best any exact enclosure could do is about 0.86 of the oracle. That
matches the 0.57-0.85 that conservative visibility methods reach against
exact sets (`docs/research/culling-efficiency-oracle-prior-art.md`). An
oracle target of 0.90 is unreachable by construction.
2. **The warp diagnosis was half right.** At warp `D` most of the gap is the
warp enlarging the radius by its maximum (V0 to V2: 0.194 to 0.347). That
is fixed by evaluating the flow at the ball's centre and enlarging the
radius only by how far the flow can move across the ball. It takes no
affine noise, and it costs one more snoise per probe, for the second phase
ball. The detail octave's spurious warp (V1) is worth little on its own.
3. **Affine noise buys the rest:** the centred form, then the shared depth
symbol. Against the centred interval, affine with one symbol closes about a fifth
of the oracle gap at warp 0 and at warp `D` (efficiency 0.368 to 0.511 and
0.347 to 0.477). Its combined cost ratio is 1.39x and 1.37x the centred
interval's, and 1.35x and 2.46x the landed interval's. It reaches 0.59 and
0.56 of the range ceiling.
4. **Three symbols buy about 5% over one** (0.534 against 0.511, 0.502
against 0.477). One symbol fits a `float3`. Three need a struct or a
`float4` plus a `float`, and the HLSL mirror's harness compares struct
returns but has no struct parameters (`packages/cultmath/docs/design.md:126-135`).
5. **The probe overhead falls** from 0.96 to 0.86 under every centred or
affine bound. Each probe costs a gradient and proves more, so the total
cost still drops by 1.35x to 2.46x. Today's (c) assertion, overhead at
least 0.90 (`NoiseBoundTests.cs:1558`, `:1588`), would reject the cheaper
march. That is question `affine-wells-contract`.
### The model
An affine form is a transient value, not a persistent kind. The persistent
things this pass adds or changes:
| Thing | Identity | Lifecycle | Authority |
| --- | --- | --- | --- |
| `SNOISE_HESSIAN` (C#), `CULTMATH_SNOISE_HESSIAN` (HLSL, lowered to GLSL) | The constant's name; one value in `math.Affine.cs`, mirrored | Measured once by `NoiseBoundTests.MeasureHessian` (slow, explicit), as `SNOISE_LIPSCHITZ` is. It is re-measured and re-pinned whenever the `snoise` kernel changes. | `MeasureHessian` sets it; `HessianConstantPinsSampledCurvature` pins it; no consumer carries its own |
| `af_*` families in `fixtures/glsl-parity.json` | Family signature string, appended after the last existing family | Regenerated only by `CULTMATH_WRITE_GLSL=1`; earlier cases stay byte-identical | `GlslMirrorTests` generator |
| The (c) contracts | `IntervalSkipHalvesEvaluations` assertions | Changed only by a ruling | Operator (question `affine-wells-contract`) |
| `SNOISE2_LIPSCHITZ` (C#), mirrored in HLSL | The constant's name in `math.Interval.cs`, beside `SNOISE_LIPSCHITZ`, with an `iv_snoise_ball(float2, float)` overload | Measured by `MeasureLipschitz2` (slow, explicit) and pinned, as `L` is | CultMath. The (c) field's flow is `snoise(float2)`, and a bound on a CultMath function belongs to CultMath, not to a test-local helper. A consumer's own flow, such as the site's texture, keeps its own variation bound |
### Representation
**A reduced affine form with one shared symbol: `float3(x0, a, e)`, meaning
`x0 + a·ε + e·δ`.** Here `ε ∈ [-1, 1]` is shared by every form over the same
region, and `δ ∈ [-1, 1]` is a private error, with `e ≥ 0`.
- This is Messine's AF1 as Gamito and Maddock use it for gradient noise,
`t0 + t1 e1 + t2 e2`.
- Sharp and Jacobson's measurements show that this fixed-symbol form is the
fast one on GPUs: 1.0x against 8.4x for full affine.
- It fits one `float3` register, as an interval fits a `float2`. It needs no
struct parameter, so the mirror, lowering and fixture machinery take it
unchanged.
- V4 against V5 prices the two extra symbols at about 5% efficiency.
For a tile probe `ε` is the depth parameter: `z = z_m + h ε` with
`h = (z1 - z0) / 2`. The footprint goes into the error. For a single ray it is
Gamito's form exactly.
A point in space is not a new type. It is the three arguments
`(centre: float3, axis: float3, radius: float)`, the set
`{centre + axis ε + w : |w| ≤ radius}`. `af_snoise` and `af_fbm` take a point
in that form, and `af_frustum_axis` and `af_frustum_ball` produce one for a
tile slice.
Ops, named by the (c) consumer, plus `af_mul` because affine arithmetic is
defined by its product, as `iv_mul` is for intervals:
- `af_point`, `af_symbol(x0, a)`, `af_from_iv`, `af_range` (the bridge to
`iv_*`);
- `af_add`, `af_sub`, `af_neg`, `af_scale`, `af_add_iv`, `af_mul`;
- `af_snoise`, `af_fbm`;
- `af_frustum_axis`, `af_frustum_ball`.
No others until a consumer names one, which is the rule `design.md` already
states for intervals.
The interval side gains `SNOISE2_LIPSCHITZ` and the overload
`iv_snoise_ball(float2, float)`. The (c) field's flow is `snoise(float2)`, and
centring the warp needs a bound on it, which belongs to its owner.
`af_snoise(centre, axis, radius)` makes one `snoise_grad` call. With
`R = |axis| + radius`, it returns the narrower of two sound forms:
- the Lipschitz form `(n, 0, L R)`;
- the centred form `(n, g · axis, |g| radius + M R² / 2)`.
`e` absorbs the op's own float rounding and an allowance for a GPU's gradient
error. The allowance is derived in the doc comment and measured on WebGL2 by
the fixture's enclosure check, as `iv_frustum_ball`'s is.
The warp stays the consumer's, so CultMath owns no flow. The rule, stated in
`design.md` and tested for the (c) field: a warp whose value at the centre is
known, and whose variation over the ball is at most `ρ`, is enclosed by moving
the centre by `w(c)` and adding `ρ` to the radius. Interval balls benefit from
it too (V2).
### Authority map
- **Owner.** `src/CultMath/math.Affine.cs` owns affine-form semantics on the
CPU. `shaders/CultMath.Affine.hlsl` mirrors it bit for bit, and is the shader
semantic authority. `shaders/CultMath.glsl` is derived from the HLSL.
- **Inputs.**
- `snoise_grad`, whose `.w` is bit-equal to `snoise`;
- `SNOISE_LIPSCHITZ`;
- `SNOISE_HESSIAN`;
- the caller's forms and points.
- **Outputs.** `float3` forms, and `float2` intervals through `af_range`.
- **Derived state.**
- The GLSL text is generated by `GlslLowering`, unchanged.
- The fixture families are generated.
- The (c) efficiency rows are printed measurements, not state.
- **Forbidden writers.**
- A consumer linearising noise itself: the probe's V4 and V5 did this by
hand, and Hands must call `af_snoise`.
- A second curvature constant anywhere.
- Hand-edited `CultMath.glsl`.
- An affine or interval evaluator over `CultMath.Expr` (the parked tape's,
see collisions below).
- A flow or envelope function in CultMath. Consumers compose those, as for
intervals.
- **Shared paths.** The test field's `FogField`, the site ground and
Aetheria's nebula all reach affine noise through `af_snoise` or `af_fbm`,
and a tile slice through `af_frustum_*`.
- **Deletion line.**
- In `FogField.Bound` (`NoiseBoundTests.cs:875`), the warp-enlarged ball is
replaced by the centred warp, and the detail ball stops carrying the warp
(`:890`).
- The (c) probe-overhead assertion (`:1558`, `:1588`) is replaced as the
ruling on `affine-wells-contract` says.
- `design.md`'s "What they do not: ... affine forms" (`:584-587`) and "That
gap belongs to the tape target's affine forms" (`:623-624`) are rewritten.
- The tape target's open item on representation is closed.
### Cut order
1. **`affine-forms`, the one she can see.**
- It adds C# `math.Affine.cs` and `AffineTests`, plus `SNOISE_HESSIAN`'s
provenance.
- The (c) field gains the centred warp and the affine bound.
- (c) is re-measured with three bound rows on the same tiles: landed
interval, centred interval, affine. The range ceiling is printed beside
the oracle.
- An explicit, env-gated test writes a cull-map PNG of one (c) frame: dense
cells per ray for each bound, and the transmittance. Self publishes it.
- `design.md` is updated.
- It is blocked on `affine-wells-contract` only for the assertion's shape.
2. **`affine-shaders`.**
- It adds `CultMath.Affine.hlsl` and its Unity copy and `.meta`, the
include, and the regenerated `CultMath.glsl`.
- Fixture families are appended, with an enclosure check for `af_snoise`
and `af_frustum_ball`.
- The dxc and glslang compiles and the WebGL2 golden-fixture run happen on
Starfire.
- Both cuts share the branch `hands/cultmath-affine-forms` and merge
together after cut 2's Soul pass. Main never carries a C#-only `af_*`, so
three-runtimes-agree holds at every merged commit.
Writing the C# before the HLSL inverts the usual order, because HLSL is the
semantic authority. The cost is small: the ops are plain arithmetic in the
common subset. Cut 2 changes the C# if the HLSL dialect forces something,
never the reverse.
### Verification venue
Heavy work runs on Yggdrasil through the stopgap: `dotnet test`, Stryker scoped
to `math.Affine.cs`, the (c) re-measure and the cull-map render. Starfire runs
only the Windows and GPU legs, one job at a time:
- `dxc -T lib_6_3 -HV 2021` on `CultMath.hlsl`;
- `glslangValidator` 16.6.0 on the lowered GLSL (ruling
`operator-compiler-downloads`);
- the WebGL2 golden-fixture run on the GTX 1070, under ANGLE D3D11 in a
browser. It is seeded from the glsl-lowering Soul pass's `gpu.js`
(follow-up `glsl-browser-parity-run`).
### Collisions
- **`cycles-converters` r1** edits the same seams:
- the include list (`CultMath.hlsl:509-510`);
- `HlslMirrorTests.cs:64-67`;
- `build-unity-package.ps1:82-87`;
- the fixture's family list.
Whichever merges second rebases, regenerates `CultMath.glsl` and the fixture
(`CULTMATH_WRITE_GLSL=1`), and appends its families after the first's.
Nothing semantic collides.
- **`expr-ir` r1 and question `material-ir-in-cultmath`.** No file collides,
and neither cut waits on the other.
- Under `cultmath-ir-now`, the parked tape's step 2 will add interval and
affine evaluators over `ExprGraph`, and those will call `iv_*` and `af_*`
as op semantics. That is one more reason the affine ops are plain value
functions over `float3`, with no struct state.
- Under `aetheria-local`, nothing here changes.
- The `expr-ir` spec's negative check forbids `Interval(Evaluator)?` under
`Expr`. Self may add `Affine` to it at its next revision.
- **`site-ground` r3** (not dispatched): the void (e) and the site's flow
texture are not measured here. Follow-up `site-ground-affine-and-centred-warp`
carries both. Centring the warp in the site needs a bound on the flow
texture's variation, and the texture stays the one motion owner (it is
sampled at the centre, never re-derived).
### Pass 5b: the deep well (ruling `wells-scale-order-of-magnitude`)
The operator's words: "There should be a well pushing down with both a radius
and depth an order of magnitude larger than the span of the bit that's
expensive to sample, the fog gradient, otherwise there's no meaningful skipping
being measured".
**Her diagnosis holds.** The expensive band is the stretch of the
pre-distortion height `s` where only the noise decides whether there is
density.
- `FogField` has `F = -20` and `A = 20`, and the noise reaches ±1.5. With the
fade at 1, a slice is provably empty only where `s >= F + 1.5 A = 10`.
- It is dense whatever the noise does where `s < F - 1.5 A = -50`.
- So the band is `G = 60` units.
Today's (c) wells, from `NoiseBoundTests.cs:753-776`:
- **Depths** are `d = 30 M^0.175`: 67, 101, 150 and 101. That is 1.1 to 2.5
times `G`.
- **Half-depth radii** are about `0.103 x 500 M^0.25` for a `PowerPulse`
exponent of 16: 163, 290, 515 and 290. That is 2.7 to 8.6 times `G`.
- **The zone bowl** is 64 deep, 1.07 times `G`.
No well is ten times `G` deep. The camera sits just above the band
(`y = max(0, S + 10 - h)`) and grazes it, so most of the measured cost is the
band itself, and little of it is empty space.
**The probe.** The probe added a field, `(c') deep well`, on the scratch
branch at `bf0148b8`:
- the zone bowl plus one well at its centre, with depth 600 and half-depth
radius 600 (`PowerPulse` exponent 2, scale 2220), so each is `10 G`;
- the camera's xz within 300 of the centre, at `y = F - h / 2`, halfway
between the sunken fog surface and the rim;
- slopes as (c), `m_y` in [-0.25, 0.1].
A level ray crosses about 600 units of empty well before the wall, and the
band there is about 73 units along the ray. The run used the same run
parameters, seeds and constants as Pass 5, on Yggdrasil.
| `N = 8` | efficiency vs oracle | cost ratio | dense cells/ray | probe overhead | cull |
| --- | --- | --- | --- | --- | --- |
| deep well, warp 0, landed interval | 0.591 | 3.10x | 4.14 | 0.943 | 0.986 |
| deep well, warp 0, centred interval | 0.579 | 3.04x | 4.14 | 0.924 | 0.986 |
| deep well, warp 0, affine (one symbol) | **0.662** | **3.47x** | 3.27 | 0.879 | 0.993 |
| deep well, warp 0, range ceiling | 0.859 | (oracle ceiling 5.24x) | | | 0.997 |
| deep well, warp 60, landed interval | 0.462 | 2.45x | 6.58 | 0.970 | 0.967 |
| deep well, warp 60, centred interval | 0.555 | 2.95x | 4.34 | 0.926 | 0.984 |
| deep well, warp 60, affine (one symbol) | **0.644** | **3.42x** | 3.33 | 0.880 | 0.992 |
| deep well, warp 60, range ceiling | 0.848 | (oracle ceiling 5.31x) | | | 0.996 |
There were no enclosure violations, and transmittance equals the dense march
in every row.
What changes:
- Where the empty space is real, the interval march already skips most of it.
It reaches 0.59 at warp 0, and 0.46, or 0.56 with the warp centred, at warp
`D`.
- Affine's gain over the centred interval shrinks to 1.14x and 1.16x in
combined cost, against 1.39x and 1.37x on the band-dominated wells.
- Against the landed interval it is 1.12x and 1.40x.
- Affine reaches 0.77 and 0.76 of the range ceiling. What it wins is in the
wall's band, which is where the shallow wells spend everything.
- Probe overhead is 0.88 in both rows, still under the 0.90 rule.
Decision for the cut (`affine-forms` r2): **(c) becomes the deep well, which
carries the contract.** The four mass-derived wells are kept as a printed row,
`(c-band)`. They are not the skipping measurement the ruling asks for, but they
are Aetheria's own geometry and the band-dominated case, where affine gains
most. Dropping them would hide where affine pays. The cull-map image renders a
deep-well camera at warp `D`.
The contract question is re-asked against these numbers as
`cultmath-tapes:question:affine-wells-contract-r2`. The first question was
withdrawn in its favour, and `affine-forms` r2 supersedes r1.
## Pass 6: the snoise diagonal tie (finding `snoise-diagonal-tie-discontinuity`)
Soul's affine pass found that `snoise(float3)` and `snoise_grad` jump by up to
0.72 where the three components of `x0` compare equal
(`cultmath-tapes:finding:cut-affine-forms.s1.snoise-diagonal-tie-discontinuity`).
The defect predates every cut in this campaign. It came with the Ashima kernel
and survived the 0.3.0 kernel change. The cut is
`cultmath-tapes:cut_spec:cut-snoise-tie.r1`.
### Mechanism (probed)
`g = step(x0.yzx, x0.xyz)` makes three pairwise comparisons, all non-strict: x
wins its tie with y, y wins its tie with z, and z wins its tie with x. That is a
cycle, not an order. When all three components are equal, `g = (1,1,1)`, so
`i1 = (0,0,0)` and `i2 = (1,1,1)`, and the four corners are not a simplex.
- Corner 1 is then the origin vertex again, with the offset `x0 + 1/6`. Near a
lattice vertex that offset is short (`|x1|^2 = 1/12`), so the origin's
gradient is counted a second time.
- The tie set is the union of the lattice diagonals `v + s(1,1,1)`. It has
measure zero in space, but consumers sample it. Every point `(t, t, t)` lies
on it exactly in float32, for every `t`, because the three components are
computed identically. So does the origin: `snoise(0,0,0)` returns -0.4358730,
where the correct value is 0. `HlslSemanticsTests.cs:447` pins the wrong
value as golden. A consumer that samples `snoise(float3(t))` or
`cultmath_snoise(t.xxx)` reads a different function along the whole line.
- With the 0.5 kernel, a lattice vertex outside the four summed corners never
contributes on a face, because its squared distance from the face is at
least 0.5. So any total order of the three comparisons gives the same exact
value on every tie. The fix only has to make the comparisons an order; which
order it picks does not matter.
### Prior art
- Gustavson's reference C, `simplexnoise1234.c`, picks the 3D simplex with an
if-chain on `x0>=y0`, `y0>=z0` and `x0>=z0` and their complements. Ties go x
over y, y over z and x over z: a total order.
- Gustavson and McEwan's later `psrdnoise3.glsl` (JCGT 2022) replaces Ashima's
line with `step(f0.xyx, f0.yzz)` and a re-pairing. Its comment says ties
resolve "in priority order": also a total order, z over y over x.
- Upstream `webgl-noise` `noise3D.glsl` still has the cyclic `step`. Soul found
no fix in its issues or its git history (`log -S` on the line).
**The fix** makes one comparison strict, z against x:
`g = (step(x0.y, x0.x), step(x0.z, x0.y), 1 - step(x0.z, x0.x))`. Ties then go
x over y, y over z and x over z, which is exactly `simplexnoise1234`'s order.
The probe's double-precision reference uses that if-chain verbatim, and the
patched float kernel agrees with it to within float noise.
Other total orders are equivalent mutants: one strict compare placed on x, or
two strict compares. Only two spellings are degenerate, all-strict and today's
all-non-strict, and the cut's test kills both.
### What the probes measured
Both jobs ran on Yggdrasil, on main `a7966142` and on the affine head
`4ae986a5`. Each patched C# and both HLSL copies in the container, and
regenerated GLSL and the fixture.
| Probe | Old rule | Strict-z rule |
| --- | --- | --- |
| Random points, \|p\| <= 4, 256, 4096 (1e6 each): old and new bit-equal | | 1e6 of 1e6 at every scale |
| Random points, \|p\| <= 65536 (1e6) | | 23 differ, each on an exact z = x tie, at ulp level |
| Lattice-diagonal points and their +-2 ulp neighbourhoods, \|I\| <= 8, 64, 512, 4096 (500k each, about 10.5k exact triple ties each): max \|f32 - ref64\| | 0.772, 0.768, 0.775, 0.774 | 2.9e-6, 2.2e-5, 1.8e-4, 1.6e-3 (float32 at that magnitude) |
| Pairwise faces x0 = (a,a,b) and permutations (108k), \|I\| <= 8 / 512 | 2.5e-6 / 1.3e-4 | the same |
| Jump inside a 1e-4 ball at Soul's five witnesses | 0.21 to 0.72 | 0.0016 to 0.0018 (smooth bound 7.6 x 2 sqrt3 x 1e-4 = 0.0026) |
| Soul's vertex scan, worst jump, \|L\| <= 8, 32, 128 | 0.72, 0.65, 0.65 | 3.7e-4, 1.5e-3, 5.7e-3; 0 of 400 beyond the smooth bound at every scale |
| `af_snoise` around the old Lipschitz spike (2000 forms x 21 points) | 926 misses, worst 38 e | 0 misses, worst -0.72 e |
| `MeasureLipschitz` (seed 0x11B5) | refined 9.181147, L = 10.099261 | refined 7.2400064, x 1.10 = **7.9640074**, witness (229.92526, 73.37927, -66.14595) |
| `MeasureHessian` (affine head) | 50.954895, giving 56.050385 | unchanged, same witness |
| `snoise_grad(p).w == snoise(p)` in a 1e-4 ball about the old witness | | 0 mismatches |
The full suite passes once four things change together: the tie fix, `L`
re-pinned, the witness moved, and the origin golden set to 0. It passes
288 of 288 on main and 300 of 300 on the affine head.
With only the tie fix and nothing re-pinned, exactly two tests fail:
- `LipschitzConstantPinsSampledGradients`, the ratio pin, failing as
designed;
- the origin golden.
`HlslSourceCompatibilityTests` passes. Its special values include all-equal
triples, so it pins the HLSL tie rule bit for bit against C#.
Fixture bytes:
- The tie fix alone changes no fixture case. The 256 random points per family
never land on a tie.
- Re-pinning `L` changes 41 cases, all in `iv_snoise_ball(vec3)` and
`iv_fbm_ball`. These are the cases whose bound is not clamped to `[-1, 1]`.
- Every other family is byte-identical.
- `CultMath.glsl` changes 3 lines: the two tie lines and the constant.
### What follows
- **`SNOISE_LIPSCHITZ` narrows by 21%,** from 10.099261 to 7.9640074. Every
Lipschitz-form bound narrows with it: `iv_snoise_ball`, `iv_fbm_ball`, the
Lipschitz form inside `af_snoise`, and the march's skipping.
- The (c) rows in Passes 5 and 5b were measured at the old `L`. They are
conservative, not wrong. The affine branch re-measures them after it
merges main.
- `SNOISE_HESSIAN` does not move.
- **Release.** `math.snoise` returns different values on the tie set, which
includes the origin and every `(t, t, t)`, points ordinary consumers sample.
- The precedent is `asura:ruling:qn1-snoise-upstream`: a public snoise value
change ships as a minor bump while the package is below 1.0, filed under
Breaking. So the next `cultmath-unity` release after this cut is 0.4.0,
not 0.3.x.
- The intervals and `L` were never released (`cultmath-unity-v0.3.0`
predates `math.Interval.cs`), so re-pinning `L` is not a release change.
- The release is not part of this cut. Follow-up
`cultmath-tapes:follow_up:snoise-tie-release` carries it, together with
Aetheria's pin bump to that release.
- **Consumers.**
- gamecult-site vendors no CultMath GLSL yet, because `site-ground` has not
landed. It gets the fixed GLSL when it does.
- Aetheria pins 0.2.4, the old 0.6 kernel, which has the same cyclic `step`
line. Its pin bump (`asura:follow_up:aetheria-snoise-pin-bump`) should
target the release that carries this cut.
- No other runtime under `packages/` (Rust, TS, Python) carries snoise.
### Merge order
1. `snoise-tie` branches from main `a7966142` as
`hands/cultmath-snoise-tie`. It gets its own Soul gate and merges to main
first: it is small, independent, and fixes a defect in released code. Its
two fixture families go after `phacelle`.
2. `hands/cultmath-affine-forms` then merges main with a merge commit. It never
rebases, because the branch is shared. The job's edits to
`math.Interval.cs`, `NoiseBoundTests.cs` and `design.md` applied cleanly to
the affine head. The affine fix batch's (c) re-measure and the affine Soul
pass run on the merged tree, so they see the new `L`.
3. `affine-shaders` is revised to r2, which supersedes r1. It depends on
`snoise-tie` and starts from the merged tree, so its `af_*` families are
appended after the tie families, and its device enclosure checks run on the
fixed `cultmath_snoise_grad`. Nothing else in it changes.
## As built: the affine and tie cuts on main (Modeling, 2026-10-04)
Body map against CultLib `main` at `14568bf9`, after `48aadda0` (snoise tie)
and `156782da` (affine forms, merged as `hands/cultmath-affine-forms`). It
records what the Body is, not what Passes 5, 5b and 6 intended. Where it
disagrees with those passes, this section wins; where it disagrees with the
Body, the Body wins. Cut reports live in the eureka-state mind under
`cultmath-tapes`: `cut-snoise-tie.h2`, `cut-affine-forms.h1-h3`,
`cut-affine-shaders.h1-h3`.
### Pieces and owners
Paths are under `packages/cultmath/`.
| Piece | Path | Owner | Role |
| --- | --- | --- | --- |
| Snoise kernel | `src/CultMath/math.cs` (`snoise`, `snoise_grad`) | library, C# | Ties in `x0` break in Gustavson order (x over y, y over z, x over z), a total order. Mirrored in `shaders/CultMath.hlsl` and the Unity copy. |
| Lipschitz constant | `math.Interval.cs` `SNOISE_LIPSCHITZ = 7.9640074f`; `CultMath.Interval.hlsl`; generated GLSL | library | Derived from the kernel by `NoiseBoundTests.MeasureLipschitz` (seed 0x11B5): refined 7.2400064 x 1.10. A kernel change re-pins it. |
| Hessian constant | `math.Affine.cs` `SNOISE_HESSIAN = 56.050385f` | library | Unchanged by the tie cut. Provenance in `design.md` "Affine forms". |
| Affine ops | `math.Affine.cs`: `af_point`, `af_symbol`, `af_from_iv`, `af_range`, `af_add`, `af_sub`, `af_neg`, `af_scale`, `af_add_iv`, `af_mul`, `af_snoise`, `af_fbm`, `af_frustum_axis`, `af_frustum_ball`; plus 2D `iv_snoise_ball(float2, float)` in `math.Interval.cs` | library, C# | Plain value functions over `float3` (centre, coefficient of the one shared symbol, radius of the independent remainder). No struct state, no `Expr` coupling. |
| Affine HLSL | `shaders/CultMath.Affine.hlsl`, byte-identical `unity/org.gamecult.cultmath/Shaders/CultMath.Affine.hlsl` | library | `cultmath_`-prefixed bit-for-bit mirror of `math.Affine.cs`. |
| GLSL | `shaders/CultMath.glsl` (and `CultMath.Phacelle.glsl`) | derived | Generated by `GlslLowering` (test project) from the HLSL files. |
| Fixture | `tests/CultMath.Tests/fixtures/glsl-parity.json` | derived | Generated by `GlslMirrorTests.GoldenFixtureMatchesCSharp`. Families: the earlier ones, the tie families (after `phacelle`), `iv_snoise_ball` for vec3 and vec2, every `af_*`, and small-reach `af_snoise` and `af_fbm` families that exercise the centred branch. |
| Test harness | `tests/CultMath.Tests/AffineTests.cs`, `NoiseBoundTests.cs`, `GlslMirrorTests.cs`, `HlslMirrorTests.cs`, `NoiseGradTests.cs` | test project | `FogField` (with `DeepWell`, `Wells`, `InsideFog`, `AetheriaWarp`), `NoiseArg`, `AffineSlice`, `BoundMode` and the gates live here, not in the library. They stand in for the site ground and Aetheria. |
| Explicit measurements | `NoiseBoundTests` `[Fact(Explicit = true)]` facts: the (c) rows and `DeepWellCullMap` (PNG into the directory named by `CULTMATH_WRITE_CULLMAP`) | test project | Printed measurements, not state. Their numbers are copied into `design.md` by hand. |
### Inputs and outputs
- `af_snoise(centre, axis, radius)` takes the noise argument as a centre, the
direction of the one shared symbol and a ball radius. It reads `snoise_grad`
(`.w` equals `snoise` bitwise), `SNOISE_LIPSCHITZ` and `SNOISE_HESSIAN`, and
returns a `float3` form: the Lipschitz form or the centred Taylor form,
whichever is narrower (`design.md` "Affine forms"). `af_fbm` composes it per
octave. `af_range` turns a form into a `float2` interval and finds a NaN
endpoint by bit pattern, because dxc's fast-math compare folds `x != x`.
- `af_frustum_axis` and `af_frustum_ball` build the symbol and ball for a tile
slice of the view frustum. A consumer rotates and translates the centre into
world space and scales by its frequencies.
- Interval consumers keep `iv_*`; the two representations meet at `af_from_iv`
and `af_range`.
### Derived state and forbidden writers
- `CultMath.glsl`, `CultMath.Phacelle.glsl` and `glsl-parity.json` are never
hand-edited. The only writer is `CULTMATH_WRITE_GLSL=1 dotnet test --filter
GlslMirrorTests` from `packages/cultmath/tests/CultMath.Tests`. The tests
compare committed text with the lowering and committed families with C#'s
current output, so a hand edit fails them.
- `unity/.../Shaders/CultMath*.hlsl` is a copy.
`HlslMirrorTests.UnityPackageShaderIsIdenticalToTheCanonicalShader` pins byte
identity per file (`Interval` and `Affine` among them). Edit `shaders/` only.
- `SNOISE_LIPSCHITZ` is written in three places (C#, Interval HLSL, generated
GLSL) and re-pinned only by running `MeasureLipschitz`. `SNOISE_HESSIAN` has
one definition in C# and one in `CultMath.Affine.hlsl`.
- Forbidden: a consumer linearising noise itself, a second curvature constant,
an evaluator over `CultMath.Expr`, a flow or envelope function in CultMath.
- Shared paths a cut touches: the include list in `CultMath.hlsl`,
`HlslMirrorTests` InlineData, `scripts/build-unity-package.ps1`'s file list,
and the fixture's family list. A cut that adds families appends after the
previous cut's and regenerates.
### The mirror chain
C# (`math.cs`, `math.Interval.cs`, `math.Affine.cs`) -> HLSL (`shaders/`, Unity
copy byte-identical) -> GLSL ES 3.00 (generated by `GlslLowering`) -> fixture
(generated from C#). A change starts in C#; the HLSL follows bit for bit; the
GLSL and fixture are regenerated. `HlslMirrorTests` sweeps C# against the HLSL
semantics on the CPU, including `af_snoise`'s centred branch on both sides of
the choice. The tie fix landed in C#, `CultMath.hlsl`, the Unity copy and the
regenerated GLSL in one merge.
### Verification layer
- Yggdrasil (CPU, through the stopgap): `dotnet test` of `CultMath.Tests`
(mirror, GLSL golden-fixture comparison, `AffineTests`, the `NoiseBoundTests`
gates, `NoiseGradTests.SnoiseIsContinuousOnTheSimplexDiagonal`), Stryker
scoped to `math.Affine.cs`, and the explicit (c) and deep-well re-measures.
- Starfire (Windows and GPU, one job at a time): `tools/compile-glsl.ps1`
(glslangValidator), the dxc `lib_6_3 -HV 2021` compile of the HLSL, and the
WebGL2 golden-fixture run on the GTX 1070 under ANGLE D3D11. The device run
checks `af_snoise`, `af_fbm` and `af_frustum_ball` for enclosure against the
device's own noise (`design.md`, GLSL target). The device runner is not in the
repository. Follow-up `affine-device-runner-seed` carries it; until it lands
the device leg is a Starfire-local procedure, not a repeatable test, and
`glsl-browser-parity-run` stays open.
### Release state
- `unity/org.gamecult.cultmath/package.json` is 0.3.0 and `CHANGELOG.md` has no
unreleased section. The snoise value change on the tie set (the origin and
every `(t, t, t)`) is Breaking by the `qn1-snoise-upstream` precedent, so the
next release is 0.4.0. Follow-up `snoise-tie-release` carries the version
bump, the changelog entry (Breaking: snoise; Added: `iv_*`, `af_*`, the two
HLSL files and the GLSL library) and Aetheria's pin bump.
- Aetheria pins 0.2.4 (the 0.6 kernel with the cyclic `step`). gamecult-site
vendors no CultMath GLSL yet.
- `SNOISE_LIPSCHITZ` and the intervals were never released, so re-pinning `L`
is not itself a release event.
### Stale docs and state found (not edited)
- This file's header status line says "pass 4" and pins `c49c76ee`; Pass 5's
pin is `a7966142`. Neither is the as-built head.
- The (d) row near line 529 ("ceiling at `L = 10.099261`") and Pass 5's "`L =
10.10`" (near line 1037) are measurements at the old `L`, now 7.9640074. Pass 6
says they are conservative; the numbers are not re-printed.
- Pass 5 "Cut order" reads as future work and Pass 6 "Merge order" lists the
merge as pending. Both merged. Pass 5's deletion line cites
`NoiseBoundTests.cs` line numbers that have moved.
- `docs/cultmath-tape-target.md`: the header "Status: **parked**" (line 3) and
"The operator parked (c)" (line 54) say nothing of affine; only the "Open
before unparking" item (lines 109-112) records that affine forms were
unparked, and nothing records them as built. The interval-or-affine
representation question is closed: both exist. Tape steps 2-5 remain parked.
- `docs/cultmath-interval-ground-cut-interval-ops*.spec.json` (r1-r3) pin
`SNOISE_LIPSCHITZ` "untouched" and cite 10.099261 as provenance. They are spec
history, superseded by the constant in `math.Interval.cs`.
- `unity/org.gamecult.cultmath/CHANGELOG.md` has no entry for the tie change,
intervals or affine forms.
- The `0.77` in `packages/cultmath/docs/design.md:376` and `NoiseGradTests.cs:195`
is the upstream tie jump and is accurate; it is a different 0.77 from the
range-ceiling ratio at line 1347 of this file.