CultLib/packages/cultmath/docs/design.md
Meta Vens 1330362235 CultPhasor rename: README, design note and both changelogs
cultmath-unity [Unreleased] and cultlib [Unreleased] carry the Breaking entries; no version bump, no tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:28:25 +02:00

1098 lines
75 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.

# Design
CultMath exists to make renderer math readable in every place it needs to live:
Rust hot kernels, C# host code, and HLSL shader code.
## Parity Target: HLSL
HLSL is the semantic authority for every type and intrinsic HLSL defines. The
goal is source compatibility: HLSL-shaped math compiles as C# under
`using static CultMath.math;`. C# forces float literal suffixes (`0.5f`); the
rest of the spelling should survive the move.
That makes these HLSL rules, not options:
- Every swizzle exists. `float2/3/4`, `int2/3/4`, and `bool2/3/4` expose every
read swizzle of length 2 to 4, repeats included (`v.wwxy`), in both `xyzw` and
`rgba` naming (never mixed), plus single-component `r/g/b/a`. `double2/3` get
lengths 2 and 3 only, because there is no `double4`. Components and swizzles are
writable: `v.xz = f2;`, `c.rgb *= 0.5f;`, and `v[1] = s;` work. Setters exist
only for non-repeating masks, because HLSL rejects `v.xx = ...`.
- Every HLSL mixed constructor exists, as a struct constructor and as a `math`
function: `float4(float2, float2)`, `float4(float, float2, float)`,
`float3(float, float2)`, the copy form `float3(float3)`, and the rest, for the
float, int, and bool families (double gets constructors only; a `math.double3`
function would hide the `double3` type name).
- Swizzles and mixed constructors are generated. `tools/generate-swizzles.ps1`
writes the checked-in `src/CultMath/Swizzles.g.cs` (partial structs plus a
partial `math`). Edit the generator, rerun it, and commit both; never edit the
output by hand.
- Comparison operators (`<`, `>`, `<=`, `>=`, `==`, `!=`) are component-wise and
return `bool2`/`bool3`/`bool4`. Reduce them with `any` and `all`.
`Equals`/`GetHashCode` stay value-based so vectors still work as dictionary
keys.
- `select(condition, whenTrue, whenFalse)` uses HLSL's argument order.
- Matrices are row-major: constructors take rows, `m[i]` returns a reference to
row i (so `m[1][2] = s;` writes one element), `_mRC` is row R column C, and
products go through `mul` (`mul(m, v)` treats `v` as a column, `mul(v, m)` as a
row). `*` on matrices would be component-wise in HLSL, so CultMath does not
define it.
- `m[r][c] = v` writes through locals, array elements, fields of classes, and
ref locals. Through a readonly field, an `in` parameter, a property
(auto-properties included), or a static readonly value such as
`float3x3.identity`, C# takes a defensive copy: the write compiles and is
silently lost. Copy the matrix to a local, write to it, and assign it back.
- Scalar/vector arithmetic and comparisons have explicit overloads on both
sides rather than leaning on implicit scalar splat.
- Intrinsics return HLSL's types and edge cases: `sign` returns `int`/`intN`
(NaN gives 0); `step(y, x)` is `x < y ? 0 : 1` (NaN gives 1), which is how dxc
lowers it, in C# and in the HLSL mirror; float and double `min` and `max`, and
float `clamp` and `saturate`, follow the DXIL `FMin`/`FMax`/`Saturate` operations dxc emits for
them: `min(a, b)` is `a < b ? a : b`, `max(a, b)` is `a >= b ? a : b`, a NaN
operand returns the other (so `saturate(NaN)` is 0), `clamp(x, a, b)` is
`min(max(x, a), b)`, and `saturate(x)` is `min(1, max(0, x))`, which maps -0
to +0; `any`/`all` accept numeric vectors (a component is true when it is
not 0); `min`, `max`, and `abs` on int vectors return int vectors; int
`clamp` is `IMin(IMax(x, a), b)`, so inverted bounds return `b`.
- Same-size vector conversions (`intN(floatN)`, `floatN(intN)`, `boolN(floatN)`,
`boolN(intN)`, `floatN(boolN)`, `intN(boolN)`) exist as constructors and
`math` functions and follow dxc: float to int is `fptosi`, truncating toward
zero; numeric to bool is `!= 0` (`fcmp une`, so NaN is true); bool to numeric
is 0 or 1. DXIL leaves float-to-int undefined for NaN and out-of-range values,
and so does C#; CultMath does not pin them.
- `normalize(x)` is `x * Rsqrt(Dot(x, x))`, which is how dxc lowers it, and
DXIL.rst defines `Rsqrt` as `1 / sqrt(src)`. A zero vector therefore
normalizes to NaN (`0 * inf`), and a NaN component makes every component NaN.
(The special-value table under `Rsqrt` in DXIL.rst is a copy of the `Round`
table and contradicts that definition, so CultMath follows the definition.)
`length` and `distance` are `Sqrt` of the summed squares, so a zero vector
gives 0. Callers that can meet zero-length vectors guard them before calling
`normalize`.
- dxc demotes double `frac`, `exp`, `lerp`, and `floor` to float. CultMath's
double overloads keep double precision instead: `frac(double)` is
`x - floor(x)` in double. That is a deliberate divergence for CPU simulation
time, which shaders do not carry.
dxc marks float arithmetic and compares `fast` (no NaNs) unless a value is
`precise`, so drivers may optimize NaN handling away and NaN results on real
GPUs are not guaranteed. CultMath defines the CPU result by dxc's lowering and
the DXIL operation spec (DirectXShaderCompiler `docs/DXIL.rst`).
NaN does not reliably propagate through CultMath, just as it does not on the
GPU. Because `min`, `max`, and `saturate` return the non-NaN operand, some
functions turn NaN or infinity into numbers: `smoothstep(a, a, a)` is 0;
infinite inputs to `smoothstep` and the intercept helpers give numbers; the
bezier, `smoothstep01`, and `smootherstep` functions return finite values for
NaN input. Callers that must detect bad data should check `isnan` before calling these
functions.
Deferred until a consumer needs them: `float4x4`, `transpose`, `determinant`,
and HLSL's one-based `_11`..`_33` element names.
### HLSL Target: Source Transformations
`HlslSourceCompatibilityTests` compiles the real `shaders/CultMath.hlsl` with
Roslyn against CultMath. These transformations are the complete list; if a body
needs anything else, the test fails:
1. Include guards (`#ifndef`/`#define`/`#endif`) are dropped, except the capacity macro (`#ifndef CULTMATH_MAX_SITES`,
`#define CULTMATH_MAX_SITES 128`, `#endif`), which becomes `const int CULTMATH_MAX_SITES = 128;`.
2. Functions taking `Texture2D`/`SamplerState` are dropped; GPU resources have
no CultMath analog.
3. File-scope `static const` becomes `const`.
4. Floating literals gain `f` (`0.5` becomes `0.5f`).
5. Struct fields gain `public`. HLSL struct members have no access-modifier
concept; a C# struct's fields default to `private`, which would hide them
from the mirror test's reflection-based field comparison.
6. An array parameter `T name[CULTMATH_MAX_SITES]` becomes `T[] name`.
7. The file body is wrapped in a class, since C# has no free functions; HLSL
functions become private instance methods.
HLSL constructs that stay outside the target and must not appear in the mirror:
`const` locals of vector type (C# `const` is primitives only; write a plain
local) and writes through a swizzle component (`v.xy.x = s`, which C# rejects as
modifying a temporary).
The HLSL side is checked too: `tools/get-dxc.ps1` fetches Microsoft's
DirectXShaderCompiler (x64), and `tools/compile-hlsl.ps1` compiles both copies of
`CultMath.hlsl` with it (`dxc -T lib_6_3 -HV 2021 -WX`, so a warning fails like an error and the
text must compile cleanly); the script throws when a shader file is missing, when dxc cannot
launch or when it exits non-zero, so a stale exit code never reads as a pass.
The mirror test compares bit patterns (any NaN equals any NaN; -0 differs from
0). It proves the text of `CultMath.hlsl` computes the same float32 results as
C# `math` on the CPU, not that a GPU agrees: driver `sin` precision alone makes
`cultmath_hash` and value noise differ bit for bit on hardware. The mirror is
compiled under `using static CultMath.math;`, so its intrinsics are C# `math`
itself: the test proves the composition in the text, not the intrinsic rules,
which only `HlslSemanticsTests` pins.
Every mirror function is compared, internals included: its counterpart is the `math`
function of the same name, public or private (`snoise_mod289` and `snoise_permute` are
private), and a function with no counterpart fails by name. A struct parameter maps to the
CultMath type of the same name, and only if both have the same instance fields by name, type
and order (`SpanPath`, `SpanStage`); the argument is built from the case's scalars field by
field and copied to its C# twin by field name.
The comparison also allows exactly one struct return shape: a mirror function
whose HLSL return type is a plain struct of `float`/`floatN`/`int`/`intN`
fields is compared to its C# counterpart field by field, recursing into each
field's own components, down to bit-for-bit scalars, matching each leaf's
declaration path (field names, not just position) and declared type as well
as its value. `CultCellular` (`math.cs`, `cellular`), `CultPhasor`
(`math.Phacelle.cs`, `phacelle`), `SpanNoise` (`math.Span.cs`, `span_fbm`) and `CultSiteCells`
(`math.Sites.cs`, `site_cells_insert`) are its consumers. `CultSiteCells` is also a struct parameter (`site_cells_insert`, `site_cells_border`). A struct return with a mismatched, reordered-by-name, or
retyped field does not get a second comparison path or a shape of its own;
it fails the same walk that already handles a bare vector return.
### GLSL Target: Source Transformations
`shaders/CultMath.glsl` is a GLSL ES 3.00 (WebGL2) library generated from
`shaders/CultMath.hlsl` by `GlslLowering` (in the test project), and
`shaders/CultMath.Phacelle.glsl` is generated beside it from
`shaders/CultMath.Phacelle.hlsl`. Both are derived files: never edit them by
hand. Regenerate them, and the golden fixture
`tests/CultMath.Tests/fixtures/glsl-parity.json`, with:
```powershell
$env:CULTMATH_WRITE_GLSL = "1"; dotnet test packages/cultmath/tests/CultMath.Tests --filter GlslMirrorTests
```
Run it on Yggdrasil's `mcr.microsoft.com/dotnet/sdk:10.0` image (Ubuntu 24.04) only: that image owns the fixture's platform
families and `device-bounds-lavapipe.txt`, and `CULTMATH_WRITE_GLSL=1` fails on any other OS. Re-record the bounds there in
the same revision (`WRITE_BOUNDS=1`, `tools/device/run-lavapipe.sh`).
`GlslMirrorTests.CommittedGlslEqualsLowering` fails on any difference between
either committed file and the lowering of the committed HLSL. These
transformations, in order, are the complete list:
1. Each `#include "name"` is replaced in place by the included file, by the
same resolver the C# mirror uses; GLSL has no `#include`. The exception is
an include under a file-level licence of its own, listed in one table,
`GlslLowering.SeparateFiles`: its line is dropped and the file is lowered on
its own, by the same steps, into its own output. The table has one entry,
`CultMath.Phacelle.hlsl` to `CultMath.Phacelle.glsl`, because Phacelle is
MPL-2.0 and MPL-2.0 is file-level: kept separate, it leaves
`CultMath.glsl` MIT. Nothing else in the lowering is keyed by name.
2. Include guards are dropped, and so are the functions taking
`Texture2D`/`SamplerState`; the host shader samples its own textures.
3. Type names: `floatN`, `intN`, `uintN` and `boolN` become `vecN`, `ivecN`,
`uvecN` and `bvecN`.
4. File-scope `static const` becomes `const`.
5. C-style casts `(float)x`, `(int)x` and `(uint)x` become `float(x)`, `int(x)`
and `uint(x)`. The operand is found by a balanced-bracket scanner:
`(int)((state >> 28) + 4u)` nests parentheses.
6. Intrinsics are renamed: `lerp` to `mix`, `frac` to `fract`, `rsqrt` to
`inversesqrt`, `asuint` to `floatBitsToUint`, `asfloat` to
`uintBitsToFloat`, and `saturate(x)` becomes `clamp(x, 0.0, 1.0)`.
7. Float literals pass through unchanged.
8. Each output is wrapped in a guard named for its file: `CULTMATH_GLSL`,
`CULTMATH_PHACELLE_GLSL`.
9. The first line names the file as generated, its sources and its licence,
and says how to regenerate it; `CultMath.glsl`'s also says which
functions live in the separate file.
GLSL ES 3.00 is stricter than HLSL: no implicit conversions between int, uint
and float, no C-style casts, and no `step(genType, float)` overload. Those gaps
are closed in the HLSL, never in the lowering: the HLSL is written in the subset
both languages share, with edits that are legal HLSL and compute the same bits
(`step(h, float4(0.0, 0.0, 0.0, 0.0))` rather than `step(h, 0.0)`, `+ 4u` where
a uint is added, `(float)((uint)hash.x >> 8)` before a float multiply). The C#
mirror and the FXC and dxc compiles keep those edits honest. A body that needs a
transformation outside the list fails `GlslMirrorTests.NoHlslOnlyTokensSurvive`
rather than gaining a special case.
The library declares no precision. The host shader does, and it must be
`precision highp float; precision highp int;`: the PCG hashes need 32-bit
integers. `tools/compile-glsl.ps1` writes such a wrapper (its own `#version
300 es` line, the precision, the library by string concatenation, and one call
of every public function) and compiles it with glslang, which
`tools/get-glslang.ps1` fetches pinned. It runs twice: over `CultMath.glsl`
alone, and with `-Phacelle` over `CultMath.glsl` followed by
`CultMath.Phacelle.glsl`, the order a consumer that calls `cultmath_phacelle`
concatenates them in. Two negative controls must fail: `-Body` with an HLSL
token (`float x = lerp(0.0, 1.0, 0.5);`), and `-Body` calling
`cultmath_phacelle` without `-Phacelle`, which proves the MIT file does not
carry it. The Unity package ships neither GLSL file.
Parity runs in two legs, each with one owner: HLSL against C# by the mirror
test, bit for bit; GLSL against C# by the golden fixture, which records 256
seeded cases per function family as float32 bit patterns, evaluated by the
consumer on WebGL2 and read back. The families `pcg3d` and `pcg4d` are marked
exact; the float families are ulp-bounded, the bound measured on the device.
A conforming leg runs every fixture family through both routes on a device: `tools/device/run-lavapipe.sh`
compiles the HLSL with dxc and the committed GLSL with glslang, dispatches each family on Mesa lavapipe, and
`GlslMirrorTests.DeviceResultsHoldFixture` judges the results. Exact and integer families must match bit for bit,
families with a check pass their `Verify`, the sampled-form families are checked on the device's own noise values,
and every other family is held to the worst error recorded for that device in
`tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt`.
`iv_frustum_ball` is ulp-bounded, not exact: it calls `sqrt`, which GLSL ES
3.00 does not require to be correctly rounded, and WebGL2 compilers may
reassociate. On a GTX 1070 under ANGLE's D3D11 backend its radius differs from
C# by up to 2 ulp in 43 of 256 cases, sometimes smaller. What the march needs
is enclosure, and what culling needs is a ball no wider than its rounding
widening, so the family's fixture entry carries a `check` the consumer applies
to every case: from the case's arguments `(mx, my, z0, z1, fp, warp)`, compute
in double `zm = (z0 + z1) / 2`, the centre `c = (mx zm, my zm, zm)`, its
`|c|_1 = |mx zm| + |my zm| + |zm|`, the radius
`r = (z1 - z0) / 2 sqrt(mx^2 + my^2 + 1) + z1 fp + warp` and
`d = |GPU centre - c|`, and require
`r + d <= GPU radius <= r + d + 2^-19 (r + |c|_1)`. The lower side is
enclosure; the upper side is the bound `NoiseBoundTests` pins in C#, so a
lowering that inflates the ball fails too. Only those two bounds fail the
check; the largest ulp distance is reported as for any ulp-bounded family.
`GoldenFixtureMatchesCSharp` applies the same check to C#'s own results.
Every `af_*` function is an ulp-bounded family, and `af_snoise`, `af_fbm` and `af_frustum_ball` also carry a two-sided `check`
of their own, applied the same way: the device's own `snoise` or fBm at 75 sampled points of the case's set must lie in the device's
form with no tolerance (a device whose gradient error exceeds the allowance fails), its width must not pass the Lipschitz form's
allowance, and the device's `af_frustum_ball` radius must enclose the sampled ray points and stay within its rounding widening.
The reference is the device's own noise, which is right for culling soundness but would pass a device whose noise is wrong
everywhere, so two appended families (`af_snoise` and `af_fbm`, 256 cases each) add the centre leg: the fixture's own `x0`
(C#'s value at the centre) must lie in the device form's `[x0 - e, x0 + e]`, for every `af_snoise` and `af_fbm` family. They
also draw reach `|axis| + radius` from 1e-6 to 1e-3, the short slices near a camera, where the float32 allowance is most of `e`:
the main families' reach is at least about 1e-3, where the second-order remainder is far larger and a device that drops the
allowance still passes. `GoldenFixtureMatchesCSharp` requires each of the two to hold cases that fail on C#'s own results once
the allowance is removed.
Four more appended families, the coordinate-band `snoise_grad` and `snoise_hessian` reads (256 cases each, the magnitude of the
point log-uniform from 1e-4 to 1e2 and from 1e2 to 4e6), carry an allowance `check`: with `S = |x|_1` and the allowances
`math.span_slack(math.span_rounding(1, x, zero stage, 0, 0, 0))` (the owners of the span stage's allowances in C#, HLSL and GLSL),
the result must lie within the allowance of the kernel's exact value, gradient or Hessian in double, no tolerance. The two small
families must also fail it in at least 16 cases once the absolute term is removed. `GoldenFixtureMatchesCSharp` applies the check
to C#'s own results, and a device capture is judged by the same `Verify`.
`GoldenFixtureMatchesCSharp` evaluates C# on the committed fixture's own
arguments. Arguments are drawn only when the fixture is regenerated
(`CULTMATH_WRITE_GLSL=1`), because the draw itself calls `MathF.Pow`, whose
bits differ by OS. `iv_exp` and `phacelle` take their C# results from the
platform's `exp`, `sin` and `cos`, which also differ by OS, so their entries
name the platform that generated them and the test compares them case by case
against the running platform, printing the largest distance. `iv_exp`
("ulp-bounded, platform exp") must agree within one ulp. `phacelle`
("ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20
max(|v|, 1); on a device |diff| <= 2^-18 max(|v|, 1)") must agree across
platforms within `2^-20 max(|v|, 1)` per component, `v` being
the fixture's value: its sums of `exp`, `sin` and `cos` cancel near zero, where
ulps mean nothing, and Windows differs from the Linux fixture by up to
`3.6e-7 max(|v|, 1)`. The device clause is `2^-18` because the devices measured
are farther: lavapipe is `2.2e-6 max(|v|, 1)` and the GTX 1070 `3.1e-6`; the conforming
run judges it. Every other family is compared as text. `phacelle` is in
`CultMath.Phacelle.glsl`, so the evaluator concatenates
`CultMath.Phacelle.glsl` after `CultMath.glsl` for that family, with
normalization 0.5. The site does not vendor the MPL file and skips the family.
Its evaluator is the lavapipe GLSL route of the conforming run above, which covers
every fixture family, `phacelle` included. The real-device leg (ANGLE D3D11 on the GTX 1070)
is the later cut `angle-capture`; the one GLSL evaluation of `phacelle` on that device is a single probe (GTX 1070, ANGLE D3D11: within `3.1e-6 max(|v|, 1)`
of C#). Asura's `gpu-snoise-parity` is a different check, an FXC and Unity
readback of `snoise`, and evaluates no GLSL.
## Invariant 8: Value-and-Gradient Primitives
A primitive tagged invariant 8 returns its value and analytic gradient
together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin
(a value-only form would be a second path with no consumer). `smin_grad` and
`cellular` are the first primitives in this family:
- `smin_grad(float4 a, float4 b, float k)` is Inigo Quilez's quadratic-polynomial
smooth minimum ("smooth minimum",
<https://iquilezles.org/articles/smin/>), carried to value-and-gradient
form. Its blend factor `h` is affine in `b.w - a.w`, so differentiating the
value with respect to position, the terms carrying `dh/dp` cancel exactly;
the surviving gradient is `lerp(∇b, ∇a, h)`, the same `h` the value uses.
This is the analytic gradient, not an approximation, and it is continuous
across the `|a.w - b.w| = k` seam where `h` saturates to 0 or 1.
- `cellular(float3 p)` is Worley's cellular texture basis function ("A
Cellular Texture Basis Function", SIGGRAPH 1996), searched over the
jittered 5×5×5 neighbourhood of feature points selected by `pcg3d` (never
the sin-based `hash`), with cells whose position-only lower bound already
exceeds the running F2 skipped before their hash is even computed. It
returns `CultCellular { nearest, edge, id }`: `nearest` is `(∇F1, F1)`,
`edge` is `(∇F2 - ∇F1, F2 - F1)`, and `id` is the nearest cell's `pcg4d`
hash (over the cell coordinate alone, never the `pcg3d` hash that produced
its jitter) mapped to `[0, 1)`. `∇F1` and `∇F2` are each undefined exactly
at their own feature point (`F1 = 0` or `F2 = 0`); `cellular` follows the
repo's existing degenerate-normal convention
(`GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle`, which
guards a zero-length normal and returns the zero vector instead of the NaN
a bare `normalize` gives there) and returns the zero vector rather than NaN
for either. `F1 = 0` is ordinary (`p` sits on a feature point); `F2 = 0`
needs two distinct cells' `pcg3d`-jittered feature points to land on the
same float32 value in every component, which is unreachable at ordinary
magnitudes but confirmed reachable (see "Precision domain" below). The
identity of the nearest and second-nearest feature point changes
discontinuously across the `F1 = F2` set (a genuine kink, not a numerical
artifact), so `∇F1` and `∇F2` are only continuous away from that set.
The 5×5×5 radius is exact, not merely safer than the classic 3×3×3 radius:
for a query at position `u` inside its own cell (`u` in `[0, 1)^3` per
component) and a candidate feature at integer cell offset `d` with jitter
`j` in `[0, 1)^3`, the per-axis displacement `(u_i - j_i) - d_i` is always
strictly greater in magnitude than `max(0, |d_i| - 1)`, because
`u_i - j_i` is always strictly inside `(-1, 1)`. So any cell with `|d_i| >=
3` on some axis is strictly farther than 2 from the query. Two real,
always-present candidates bound `F1` and `F2`: the query's own cell, whose
distance is always strictly less than `sqrt(3)` (every axis strictly under
1); and the near-side neighbour on whichever single axis has `u_i` closest
to a cell boundary. Let `m = min(u_i, 1 - u_i)` on that chosen axis, so
`m` is in `[0, 1/2]` by construction. Stepping one cell towards that
nearer boundary puts the query within `1 + m` of the far face on the
offset axis and within `1 - m` of the near face on each of the other two
axes, so that neighbour's squared distance is at most
`(1 + m)^2 + 2*(1 - m)^2 = 3 - 2m + 3m^2 <= 3` for every `m` in `[0, 1/2]`
(3 at `m = 0`, dipping to a minimum of `8/3 ~= 2.667` at `m = 1/3`, then
rising back to 2.75 at `m = 1/2`), and strictly below 3 because
the jitter never reaches exactly 0 or 1. `F2` is at most the larger of
these two real candidates (any two real candidates upper-bound the
2nd-smallest value over the full infinite candidate set), so `F1 <
sqrt(3)` and `F2 < sqrt(3)` always, comfortably under the radius-2
exclusion distance of 2, with margin `2 - sqrt(3) ~= 0.268` (Soul's
adversarial check, cut 2a-i: the worst realizable in-box `F2` is exactly
`sqrt(3)`, reached only in the unattainable limit at a cell corner). 3×3×3's
matching argument only reaches radius 1, whose excluded cells start at
distance 1, short of the `sqrt(3)` ceiling — the gap its measured failures
(9 of 400,000 points in `[-50, 50]^3`, worst error 0.088) live in.
An earlier version of this proof picked the near-side neighbour on a fixed
axis instead of whichever axis sits closest to its own boundary, and that
version is wrong (Soul, cut 2a-i): `u = (0.5, 0, 0)` with the x-neighbour's
jittered feature at `(-1, 1 - 2^-24, 1 - 2^-24)` gives a distance of about
2.06, outside the radius-2 exclusion the proof needs — `u.x = 0.5` is the
point on the cell farthest from any boundary, so `x` was the wrong axis to
fix there. Choosing the axis by proximity to its own boundary is what keeps
`m <= 1/2` and the bound at `sqrt(3)`.
`CellularAndSminGradTests` pins both the prune's bit-identical equivalence
to an unpruned 5×5×5 reference and the search's exact agreement with an
independent 7×7×7 brute-force oracle.
Precision domain: `cellular`'s exactness above is a statement about the
search radius, not about float32. Past `|p|` of roughly `2^23`, adjacent
integer cells stop being distinguishable from their neighbours at float32
precision in some directions, and distinct cells' jittered features can
round onto the same value, making `F1 = F2` ties (and, per-component,
`F2 = 0`) increasingly common from about `1e6` and confirmed at `2^24`
(measured: 4096 of 4096 sampled integer points at `2^24` hit this before
the guard above). `cellular` stays finite there; it does not stay accurate
in the sense of matching the idealized real-valued Worley field.
The lower-bound prune's bit-identity to the unpruned 5×5×5 search also has
a domain: it holds for `|p| < 2^25`. Above that, `cell + d` (an integer
cell coordinate plus a small integer offset) starts to round in float32,
so a pruned cell's own recomputed offset can land on a different neighbour
than the unpruned loop visits at that same nominal `(dx, dy, dz)`, and the
two search paths diverge (Soul, cut 2a-i: measured 180 of 1,100,000 points
differ at `|p| ~= 3.4e7`). This is a float32-precision limit on the prune's
equivalence proof, not a bug in the prune itself: below `2^25` the two
paths are proven bit-identical (`PrunedSearchIsBitIdenticalToTheUnprunedFiveCubedSearch`,
which samples `[-50, 50]^3`, is comfortably inside that domain).
- `snoise(float3)` follows webgl-noise `src/noise3D.glsl` as of
stegu/webgl-noise `22434e04d7`: kernel radius squared `0.5`, output scale
`105`, permutation `mod289((34x+10)x)`. Upstream's `21d9fe23d7` (2020-10-14,
"an age-old bug that caused slight discontinuities along simplex
boundaries") moved the radius from `0.6` to `0.5`; at `0.6` a lattice vertex
outside the four summed corners can lie inside the kernel, leaving a value
jump at the cell boundary (3.1e-3 over the committed probe's 400 lines). `ff3b5d34ea` set the scale
to `105` and `a3e6d57095` (2021-06-30) the permutation; the 2D `snoise` shares
that permutation helper, so its values changed too, while its own kernel
(`0.5`) and scale (`130`) already matched upstream.
`NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries`
pins the continuity. Ties in the offset `x0` resolve in Gustavson's
`simplexnoise1234` order (x over y, y over z, x over z), a total order, so the
four corners are always a simplex. Upstream's `step` compares are cyclic: where
the three components are equal, which is every input whose differences `y - x`
and `z - x` are whole numbers (every integer point, the origin and every
`(t, t, t)` among them), the corners repeat the origin vertex and the value
jumps by about 0.77 near the origin and up to 1.56 at magnitudes of 64 and
above. `SnoiseTieScopeTests` pins the set against the 0.3.0 values. `NoiseGradTests.SnoiseIsContinuousOnTheSimplexDiagonal` pins the order.
- `snoise_grad(float3 p)` carries `snoise(float3)` to value-and-gradient form,
following the differentiation in webgl-noise `src/noise3Dgrad.glsl` (Ashima
Arts / Ian McEwan, MIT), which uses the same radius, permutation and scale as
`snoise(float3)`: per corner, `m0 = max(0.5 - dot(x,x), 0)`, so
`d(m0^4 * dot(p,x))/dx = -8*m0^3*dot(p,x)*x + m0^4*p`, summed over the four
corners and scaled by the same constant as the value, so `.w` is exactly
`snoise(float3)`. `fbm_grad` and
`ridged_grad` are octave sums of `snoise_grad`: each octave samples at
`p * frequency`, so by the chain rule its gradient scales by that same
frequency, and both amplitude (`gain`) and frequency (`lacunarity`) compound
per octave rather than applying once to the whole sum. `ridged_grad` folds
each octave about zero (`Σ aᵢ(1 − |nᵢ|)`, gradient `−Σ aᵢfᵢ sign(nᵢ)∇nᵢ`,
Musgrave's ridged multifractal, *Texturing and Modeling* ch. 16); the crease
at `nᵢ = 0` is deliberate (the operator's dune term, 2026-09-25), not a bug.
Both clamp `octaves` to `[0, 16]`: `HlslSourceCompatibilityTests`'s bit-parity
oracle drives every mirrored `int` parameter across the full int32 range,
which is the right domain for `pcg3d`/`pcg4d`'s O(1) hash inputs but would
turn an unclamped octave count into a multi-billion-iteration loop for these
two mirrors.
- `phacelle(float3 p, float3 side, float offset, float normalization)` is Rune
Skovbo Johansen's Phacelle noise ("phase + cell": a stripe pattern built by
blending one cosine/sine wave per jittered cell), generalised to 3D cells. It
returns `CultPhasor { cosine, sine }`, each `(∇, value)`. `side` is the stripe
wave vector, whose length is 2π times the stripe frequency; in 3D the
perpendicular to the flow is not unique, so the caller chooses it (on a unit
sphere, `cross(p̂, flow)`). `offset` is the phase in cycles. It visits 4×4×4
cells with `pcg3d`-hashed jitter in `[-0.5, 0.5)`, weights each wave by
`max(0, exp(-2d²) - 0.01111)`, and normalizes the blended phasor as upstream
does (length 1 where the raw length is at least `1 - normalization`, scaled
by `1 / (1 - normalization)` below). The gradient is exact, weight
derivatives and normalization included, rather than upstream's
`∇cos ≈ -sin·side`, which ignores the weight gradient. It is discontinuous
where the raw length crosses the normalization threshold and where a cell's
weight leaves its support (d² = 2.24995, distance 1.49998); the value is
continuous at both. At the support edge `∇w` jumps from `-4·0.01111·v` to 0,
a per-cell jump of length `0.0444·|v|` (0.0667 at `|v| = 1.5`); measured over
random points, the output gradient jumps by a median 1.5% of its length and
at most about 27%. `normalization` is meant to lie in `[0, 1]`: 1 or more
saturates (every output is stretched to length 1), a negative value scales
every output down by `1 / (1 - normalization)`, and the normalized gradient
scales as `1 / |I|` for the blended phasor `I`, so it grows where the blend
nearly cancels. The total weight `W` is positive for every `pcg3d` input (the
smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs
adversarial jitter no hash yields.
Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least
2.25 lie outside the support and have weight exactly 0, so they are skipped,
which changes no output bit. Cells whose bound sits just under 2.25 can still
carry a nonzero weight, so the cut cannot be lowered; `PhacelleTests` pins
the cut with points next to such cells, against an internal overload with the
prune disabled, and the HLSL mirror test pins it on the shader side. The prune
keeps about 45 of the 64 cells on average (a saving of about 30%); about 14
have a nonzero weight. The port sits in its own MPL-2.0 files
(`math.Phacelle.cs`, `CultPhasor.cs`, `CultMath.Phacelle.hlsl`); see
`THIRD-PARTY-NOTICES.md`. No 2D variant exists; nothing consumes one.
Integer hashing uses the PCG hashes from Jarzynski and Olano, "Hash Functions
for GPU Rendering" (JCGT 9(3), 2020): `pcg(uint)` is O'Neill's RXS-M-XS 32/32
permutation over one LCG step, and `pcg3d`/`pcg4d` are the paper's (3 → 3) and
(4 → 4) hashes. They use only uint multiply, add, xor, and shifts. dxc wraps
uint multiply and add modulo 2^32 exactly as C# unchecked arithmetic does, so
they are integer-exact in HLSL and on the CPU, GPU included, unlike the
`sin`-based `hash`. CultMath has no uint vectors, so `pcg3d(int3)` and
`pcg4d(int4)` carry uint bit patterns in int components. The `float2`/`float3`/`float4`
overloads hash the IEEE-754 bits (`asuint`), so -0 and 0 hash differently; they
exist for callers that already hold a hash key as float bits, and `pcg3d(float2)`
holds z at 0, which is the paper's route for (2 → N) inputs. Seeds and other
scalar uses take one output component.
`cellular`'s jitter and id always hash the integer cell coordinate directly
(`pcg3d(int3(neighbor))`, `pcg4d(int4(int3(cellId), 0))`), never a cell
coordinate's float32 bit pattern. Hashing the bit pattern of an
integer-valued float is not the same permutation as hashing the integer: two
adjacent cells' bit patterns share more structure (both have the same
exponent and a mantissa that differs by one increment) than two adjacent
integers do going into `pcg3d`'s own mixing, and that residual structure
survived into the jitter. Hashing the integer cell coordinate directly is
exact and runtime-independent, and it is the HLSL mirror's own hash, so C#
and shader agree bit-for-bit. What pins this is the oracle and mirror tests,
not a uniformity measurement: reverting to hashing the float bits (or the
sin-based hash) is caught by `ProductionSearchMatchesTheBruteForceOracleExactly`,
`EveryMirrorFunctionMatchesCSharpMath`, `F1NeverExceedsF2`, and the other
tests that key off the integer-hash fixtures (cut 2a-i). `CellularAndSminGradTests`
(`JitterAndIdPassAChiSquareUniformityTest`) checks the uniformity of the hash
as `cellular` uses it: the jitter marginals and the id pass a chi-square test
at a stated significance against `pcg3d(int3(...))`.
Where HLSL is silent, CultMath keeps its own decisions and does not defer to
Unity.Mathematics: `hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`,
`snoise`) enter only when a consumer needs them, with their semantics stated in
code.
Known C# friction: under `using static CultMath.math;` the constructor
functions (`float3(...)`, `float3x3(...)`) hide the type names in member access,
so static members need a qualified type (`CultMath.float3x3.Euler(...)`).
## Sites
A site is `float4(position.xyz, weight)`; a 2D consumer uses z = 0. `math.Sites.cs` scores a query point
against a site two ways and keeps the two best:
- `power_score(p, site)`: value `|p - s|^2 - w^2`, gradient `2 (p - s)`. A power cell is the intersection of the
half-spaces bounded by the radical planes against every other site, so `power_border` is exact.
- `multiplicative_score(p, site)`: value `|p - s| / w`, gradient `(p - s) / (|p - s| w)`, zero at `p = s`
(cellular's degenerate convention). Its borders are Apollonius circles, and the border distance is first order.
A span function is a C# loop over a per-site step that has a shader twin, because the HLSL mirror cannot take a
span or a buffer. `site_cells_empty()` starts the fold, `site_cells_insert(cells, score, index)` folds one score
in (strict `<` against F1, the old F1 moving to second, else strict `<` against F2, so an exact tie keeps the
earlier index first), and `site_cells_border(cells)` reads `float4(unit normal, distance)`, with `n = grad F2 -
grad F1` pointing toward the nearest site and `(0, 0, 0, 1.0e30)` when there is no second site. That border is
the distance to the bisector of the two lowest scores, first order and for either metric; it is not the distance
to the nearest site's cell boundary (the F2 - F1 edge error: the second-lowest score is not the nearest plane in
general). The exact power cell border is a second pass: `power_border_step(p, owner, other, border)` takes the
running `float4(unit normal toward the owner, distance)` and the radical plane of `owner` against `other`, with
the distance built from the offset `owner - p` and the weight difference `(w1 - w2)(w1 + w2)` so it does not
cancel for close sites seen from far; the cell boundary distance is the least over every site except the owner
(the fold's nearest), and a site coincident with the owner has no plane. The step trusts the caller's owner: a
query outside the owner's cell reports distance 0 with that plane's normal, not an error. `power_border(p, sites)` is that loop;
the shader's `cultmath_power_border(p, sites, count)` is the same loop over a by-value array (see "Sites in a shader" below).
Cell spans refuse a non-finite query, site position or weight, and more than 2^24 sites (indices are floats).
`power_cells` and
`multiplicative_cells` are that loop over a `ReadOnlySpan<float4>` and nothing else; a shader caller writes the
same loop over its own array. Every site of an explicit span is visited, so no window and no weight-ratio range
apply. `CultSiteCells` holds the two score fields and the two site indices (floats, -1 for none).
`gabriel_margin(a, b, other)` is `(|other - (a + b) / 2| - r) / r` with `r = |a - b| / 2`: zero on the diameter
circle, negative inside it, -1 when `a` and `b` coincide. `soft_gabriel_gate(sites, first, second, softness)`
is `smoothstep(-softness, softness, least margin over every other site)` on positions alone, 1 when no other site
exists; it is C# only. `distance_to_segment(point, start, end)` has out-free 2D and 3D forms beside the `out`
form, all over one public `segment_closest` per dimension, each with its 2D and 3D shader overload.
## Weighted lattice cells
`weighted_lattice_cells(p, time, bodies, cellSize, drift, boost, falloff, smoothing, stretchNormal, stretch)` in
`math.Lattice.cs` returns `float4(F1, F2, smooth, id)` over a jittered, drifting lattice, in cell units times weight.
Four public steps compose it, and a shader or a tape composes the same four:
- `lattice_site(cell, time, drift)`: the site `cell + 0.5 + 0.5 sin(drift time + 2 pi u)` per axis (so it stays within
one cell of the corner) and the base weight `0.5 + 0.5 u`, in [0.5, 1), from its own hash lane.
- `lattice_weight(base, border, boost, falloff)`: `base (1 + boost exp(-(border / falloff)^2))`. `border` is
`power_border` at the cell centre over the body span (exact, so the weight is continuous as bodies move; the first
order `site_cells_border` jumps where the second-nearest body changes and is not used).
- `lattice_distance(q, site, weight, normal, stretch)`: `weight |(stretch a, c)|`, `a` and `c` the offset along and
across the unit normal; isotropic at stretch 1.
- `lattice_smooth_step(m, d, f1, smoothing)`: the smooth minimum fold, started at `F1 + smoothing` and clamped to it,
so a cell with `d >= F1 + 2 smoothing` changes nothing and the result does not depend on the box visited.
The search visits Chebyshev rings around the query's cell and stops before ring `k` once `0.5 (k - 1) >= max(F2,
F1 + 2 smoothing)`: every cell on ring `k` has `d >= 0.5 (k - 1)` (site within one cell of its corner, weight at
least 0.5, stretch at least 1). A second pass folds the visited box in absolute row-major order, so ties and the
smooth minimum do not depend on how far the search went. The proof is the comment above `weighted_lattice_cells`.
The accepted range is boost 0 to `LatticeMaxBoost` (4, a weight ratio of 10:1), stretch 1 to `LatticeMaxStretch` (2)
and smoothing up to `LatticeMaxSmoothing` (1); outside it the function throws `ArgumentException`. `LatticeMaxRing`
(37) is the proven greatest ring at the range edge. Measured over 24,000 points on orbiting bodies (six bodies,
sigma ratio 6:1, the ruled look and the range edge), the greatest ring visited has median 3, 99th percentile 7 and
maximum 8 (49, 225 and 289 cells); `RingsStayWellInsideTheCap` asserts these.
### Sites in a shader
A shader function cannot take a span, so `cultmath_power_border(p, sites, count)`, `cultmath_lattice_cell_distance(...)`
and `cultmath_weighted_lattice_cells(p, time, bodies, count, ...)` take `float4 sites[CULTMATH_MAX_SITES]` by value and the
count in use, with `count` in `[0, CULTMATH_MAX_SITES]`. `CULTMATH_MAX_SITES` is 128 unless the consumer defines it before
including the library (`#ifndef`): 128 covers every Aetheria zone within GLSL ES 3.00's 224 guaranteed fragment uniform
vectors, and loops run to `count`, so the capacity costs uniform space, not iterations. A caller whose sites live in a
buffer copies them into a local array first; the library takes no resource, accessor macro or uniform of its own. The preconditions the C#
functions throw for are the caller's in a shader (finite arguments, `cellSize > 0`, the stated ranges, `p` within 2^23
cells). The ring pass is bounded `k <= 37` and exits as C# does; the box pass recomputes each distance instead of caching
75 x 75 of them, which gives the same values. The C# mirror compiles the array as `T[]`, and
`HlslSourceCompatibilityTests.SiteArrayMirrorsMatchCSharp` compares the three functions through the span twins in
`SiteArrays` (reflection cannot pass a `ReadOnlySpan`); `CULTMATH_MAX_SITES` is a shader-only capacity with no C# twin.
## Intervals
`math.Interval.cs` and `shaders/CultMath.Interval.hlsl` (included from
`CultMath.hlsl` after the Phacelle include) carry interval arithmetic for
culling empty space with a proof. Their invariant, **intervals-enclose**: every
interval function returns a `[lo, hi]` that contains `f(x)` for every `x` in
its input region, where `f(x)` is the same function evaluated pointwise in
float32. A consumer that skips a region because `hi < cutoff` has skipped
nothing that was there.
Representation: an interval is `float2(lo, hi)` with `lo <= hi`, both finite.
There is no empty interval; a caller that needs "provably empty" tests
`hi < cutoff`. `iv_point(x)` is `float2(x, x)`.
Ops: `iv_point`, `iv_add`, `iv_sub`, `iv_neg`, `iv_mul` (the least and greatest
of the four corner products), `iv_scale(iv, float)`, `iv_abs` and `iv_sqr`
(an interval straddling zero starts at 0), `iv_min`, `iv_max`, `iv_sqrt`
(`lo` clamped at 0), `iv_exp`, `iv_clamp(iv, float, float)`, `iv_saturate`,
`iv_smoothstep(float, float, iv)` (its ordered endpoints) and
`iv_lerp(iv, iv, float)`. No others until a consumer names one. Each has an
HLSL mirror named `cultmath_iv_*`, compared bit for bit by
`HlslSourceCompatibilityTests`.
The ulp-widening rule. Each bound is computed with the pointwise function's
own float32 operations. IEEE round-to-nearest is monotone, so an op that is
monotone on its region encloses its float32 evaluation exactly, with no
widening; that covers every op above except two, which widen by exactly one
ulp, in the implementation and never in the test:
- `iv_exp`: `exp` is not required to be correctly rounded, so a libm need not
be monotone. .NET's `MathF.Exp` on Linux steps down 0 times over every float
in [-87, 88.7] (`IntervalTests.MeasureMonotonicity`), so on that CPU the
widening is a margin; it is there for other libms and GPUs.
- `iv_smoothstep`: `t * t * (3 - 2t)` in float32 steps down 337,095 times over
the floats in [0, 1], each time by at most 1 ulp (`MeasureMonotonicity`).
Since `t = saturate((x - minimum) / (maximum - minimum))` is itself monotone
in `x`, that covers every `smoothstep`.
`iv_lerp` is the natural interval extension of `x + (y - x) * amount`, which is
how `lerp` evaluates; every step is monotone, so it encloses with no widening
for any `amount`, and it is tight only when `a` is a point, because `x` appears
twice.
`IntervalTests` runs every op over 10,000 seeded intervals (centres in
[-20, 20], widths log-uniform in [1e-4, 10], every eighth a point) and 16
points inside each, endpoints and zero included, with no tolerance; and for
every op it raises `lo` and lowers `hi` by one ulp and requires the harness to
catch each. A new op is one entry in that table.
Noise bounds. `iv_snoise_ball(c, r)` is `[n - L r, n + L r]` intersected with
`[-1, 1]`, where `n = snoise(c)` and `L = SNOISE_LIPSCHITZ`: one `snoise`, value
only. No interval function reads `snoise_grad`, and none has a value-only or
gradient twin. `iv_fbm_ball(c, r, octaves, lacunarity, gain)` is the octave sum
of `iv_snoise_ball(c * f_i, r * |f_i|)` scaled by `a_i`, compounding frequency
and amplitude exactly as `fbm_grad` does, octaves clamped to [0, 16]. A domain
warp that moves a point by at most `D` is enclosed by the radius `r + D`
(`NoiseBoundTests.WarpedPointsStayEnclosed`); this is what the gamecult.org
ground's march rests on.
`iv_frustum_ball(m_c, z0, z1, footprintPerDepth, warp)` is the ball one probe
serves a whole screen tile with. In a pinhole camera frame looking down `+z`,
where the ray of slope `m` is the points `(m z, z)`, it encloses every point of
every ray whose slope lies within `footprintPerDepth` of `m_c` (`N / (sqrt(2) f)`
for an `N x N` tile at focal length `f` pixels: the full pixel footprint, so
sub-pixel jitter is covered), over depths `[z0, z1]`, each point moved by a warp
of length at most `warp`. The centre is `(m_c z_m, z_m)`, `z_m` the mid-depth;
the radius is `((z1 - z0) / 2) |(m_c, 1)| + z1 footprintPerDepth + warp`. A point
of slope `m_c + e` at depth `z` is the centre plus `(z - z_m)(m_c, 1)` plus
`z (e, 0)`. The returned ball is float32, and on a degenerate (`z0 == z1`),
thin or far segment the rounding of the centre alone exceeds the gap the
triangle inequality leaves, so the radius is widened by
`(|c|_1 + radius) 2^-20`. With `u = 2^-24`, each centre component carries at most
two roundings (`z_m`, then the product), so the float centre is within
`2u |c|_1` of the exact one; the radius carries at most seven (`z1 - z0`, the
axis length at `3u`, the product, `z1 footprintPerDepth`, the sum, `+ warp`), so
the exact radius is at most `(1 + 7u)` times the float one; the widening rounds
once more. `2^-20 = 16u` covers `8u (|c|_1 + radius)` twice over, and fma
contraction only removes roundings. The axis count assumes a correctly rounded
`sqrt`, which IEEE gives C#. GLSL ES 3.00 does not: it inherits `sqrt`'s
precision from `inversesqrt`, and D3D-backed compilers may reassociate. So the
bound is proven for C# and HLSL on IEEE hardware, and on GLSL it is measured:
on a GTX 1070 under ANGLE's D3D11 backend the fixture's 256 cases keep a
minimum relative slack of `1.98e-6` (about `33u`), the same as C#'s. The
fixture's enclosure check is what measures it on each device. The caller rotates and translates the centre into world space and scales
centre and radius by its noise frequency; the rounding of that transform is the
caller's. `NoiseBoundTests.TileBallEnclosesEveryRaySegment` checks 2,000 seeded
tiles x 64 points of the r2 domain and 2,000 tiles x 8 corner points of each of
seven extreme families (`z0 == z1`, `z1 - z0 = 1e-6 z1`, `|m_c|` to 1000, `z` to
`1e7`, `z0 = 0` with `z1` to `1e-3`, `f` down to 1, warp to `1e6`), corner flows
full-length and outward, distances in double, with no tolerance. It pins the ball
to the derivation both ways (`r <= ball.w <= r + 2^-19 (r + |c|_1)`), so a ball
that forgets the widening fails and one grown past it fails too. A moving camera needs nothing
more: each frame's probes use that frame's camera, and no ball, mask or probe
result is carried from one frame to the next.
The noise ball alone proves little: at `L` near 10 it bounds `snoise` to a width
of `2 L r`, which covers all of `[-1, 1]` once `L r` reaches 1. Empty space
becomes provable through an analytic envelope. The consumer's density is a
pre-distortion SDF (a height fog, a well map, a carved sphere) that noise
displaces only near its surface, and the consumer composes the bound from `iv_*`
ops. The envelope is taken over the slice's exact box (`iv_mul` of the depth
range by each slope range, then the SDF's own monotone pieces), together with its
fade. The noise is `iv_snoise_ball` over `iv_frustum_ball`, scaled by the noise
frequency. The warp moves only the noise argument, so it enlarges the ball and
the envelope's box takes none of it. Where the fade's upper end is 0 the noise
term is identically 0 and no `snoise` is evaluated. Where the envelope already
proves the slice empty with the noise at its full range, the ball is not
evaluated either. A probe is worth attempting only when the best result any
centre value could give would prove the slice, and a ball with `L r >= 2` is
`[-1, 1]` whatever `snoise(c)` is, so it costs nothing. CultMath owns none of the
envelope: the interval files have no envelope function, and each consumer composes
its own. `NoiseBoundTests.EnvelopeBoundEnclosesDensity` checks that composition,
the one the site and Aetheria use, over 2,000 seeded slices x 64 warped points
with no tolerance.
`L` has provenance, not a proof. `NoiseBoundTests.MeasureLipschitz` (slow,
explicit) takes the largest `|snoise_grad|` over 1e6 seeded points in
[-256, 256]^3 (7.1933103), refines the 1e4 largest by gradient ascent
(7.2400064), and multiplies by 1.10: `L = 7.9640074`. It also reaches
`|snoise| = 0.9718335` by the same ascent on the value, which is what lets the
bound intersect with `[-1, 1]`. `LipschitzConstantPinsSampledGradients`
checks 1e5 more seeded points against `L` and climbs again from the start
`MeasureLipschitz` prints as its witness, failing if `L` is not 1.10 times that
maximum to within half a percent, so a 1% move of `L` fails. The enclosure tests
over 2,000 balls x 64 points are the defence. A change to the `snoise` kernel
must re-run `MeasureLipschitz` and re-pin `L` and the witness.
What the ball bounds guarantee: enclosure of `snoise` and `fbm_grad(...).w` over
the ball, in float32. What they do not: anything about tapes, pruning or choice tracking. Correlation
between the operands of one expression is what "Affine forms" below adds.
How loose the ball bounds are is a number:
`NoiseBoundTests.TightnessReport` prints the mean interval width over the
sampled range: 5.08 for `iv_snoise_ball` at `r = 0.05`, 1.96 at 0.25 and 1.28
at 1.0; 6.64 and 2.82 for 4-octave `iv_fbm_ball` at 0.05 and 0.25.
The saving is measured where there is empty space to find.
`NoiseBoundTests.IntervalSkipHalvesEvaluations` draws screen tiles, each with
its own camera, and runs one pre-pass per tile over the dense grid's cell ranges:
it starts with the whole grid, skips and doubles a range it proves empty, halves
one it cannot, and runs to the grid's end. It then draws the tile's `N^2` ray
slopes, one per pixel, jittered inside it. Each ray is marched three times with
the same integrator and the same early-out at transmittance 0.02: over every
cell (dense), over the tile's unproven cells (the tile march), and over the
oracle mask, the cells whose mid-depth density is nonzero for at least one of
the tile's rays. The tile march and the oracle march integrate the dense march's
nonzero cells, so their transmittance agrees with it exactly in every
configuration. The oracle is a ceiling the pre-pass cannot move: dense cost over
oracle cost. Against it the test prints the efficiency (oracle cost over the
tile march's cost with its probes) and the cull fraction (the oracle-empty cells
the pre-pass proves empty). The probe overhead is the tile march's cost over that
cost plus the probes'. Cost is counted in `snoise` evaluations, with an envelope
evaluation or probe weighted 0.2. That weight is an estimate, and both counts are
printed. The flow is not counted. At `N = 8`, in Aetheria's units on its
256-cell quadratic grid out to the Main Camera's far plane, 2048:
- (a) Height fog, camera above the safety band, rays level and up: the whole
grid is one envelope probe per tile, against 256 envelope evaluations per ray,
16384x. Every cell is oracle-empty and the pre-pass proves them all.
- (b) Inside the fog: 1.16x, and 1.11x with the warp. Most cells are fog, and
the pre-pass probes each one.
- (c) The deep well (ruling `wells-scale-order-of-magnitude`): the zone bowl
plus one well at its centre, `PowerPulse` exponent 2, scale 2220, depth 600.
The band `G = 60` is where only the noise decides density (`s` within
`F -+ 1.5 A` with the fade at 1); depth and half-depth radius are each
`10 G`. The camera's `xz` is uniform within 300 of the centre, at
`y = F - h(xz) / 2`, so it looks across the bowl from inside it. Both rows run
on the same seeds. With no warp, the centred interval takes 3.20x (oracle
ceiling 5.24x, efficiency 0.611, cull fraction 0.988, probe overhead 0.940)
and the affine bound 3.57x (efficiency 0.682, cull 0.993, overhead 0.905). With
the warp `D = 60`: 3.12x (ceiling 5.31x, efficiency 0.588, cull 0.987, overhead
0.929) against 3.46x (efficiency 0.651, cull 0.993, overhead 0.880), so affine
is 1.12x and 1.11x cheaper. The range mask (the oracle cells plus every cell
where any of 5 x 5 slopes x 9 depths has density above zero) gives a range
ceiling of 0.859 (0.848 with the warp), the cost a bound that proves exactly
the range-empty cells would reach against the oracle's; the efficiencies
against it are 0.794 and 0.712 (warp 0), 0.768 and 0.694 (warp `D`).
- (c-band) The shallow wells, four mass-derived wells in the zone bowl with
the camera gazing across, whose geometry is band-dominated, printed only.
Centred interval 3.01x (efficiency 0.414, cull 0.960, overhead 0.958), affine
3.95x (0.544, 0.980, 0.901); with the warp 2.75x (0.383, 0.954, 0.944) against
3.54x (0.494, 0.976, 0.865).
- (d) r1's uniform slab, which has no envelope: 1.38x with the warp 0.05, and
1.67x with no warp.
- (e) The void at the shipped point (ruling `operator-shipped-void`): `Rh = 198`,
`S = 50`, `Rc = Rh + S = 248`, `e = -ln 1.5 / ln(1 - (Rh / Rc)^2)`, the
shipped camera, 200 tiles. The fixed-step reference takes 36.34 steps per
pixel. The same cells masked by the pre-pass take 20.08, with identical
transmittance. The footprint-aware march takes 13.50: 2.69x fewer. `snoise`
per pixel goes from 32.98 to 26.70, and combined cost from 40.25 to 29.54 with
the probes, 1.36x. Every one of the 200 tiles proves a body. Before the body
both marches are measured against a converged one (midpoint quadrature at
`h = 0.5` over every cell, trusting no pre-pass): the relative optical-depth
error at the body start is at most 0.0608 for the fixed-step march and 0.08639
for the footprint march, 1.42x. Over the grid of hollow radius, camera offset
and ramp width the steps ratio runs from 1.59x (`Rh = 100`, low, `S = 150`) to
27.45x (`Rh = 1000`, centred, `S = 10`), the cost ratio from 1.26x to 3.89x,
and the depth-error ratio from 0.85 to 4.72 (`Rh = 200`, centred, `S = 150`,
where the errors are 0.01504 and 0.003188). LOD-far takes 14.19x fewer steps;
its depth errors, 0.2849 and 0.205, are not comparable, because its
fixed-step reference keeps the octaves the footprint march truncates.
The analytic body (ruling `operator-analytic-wall-body`). When a single dense
cell's own bound is a positive point, the pre-pass spends one more probe over
the rest of the grid. If that bound is a positive point too, every point of
every ray of the tile from there on has exactly that density, so the footprint
march takes one closed-form step, `exp(-BodyDepth)`, from the body start to the
grid's end and stops. `BodyDepth` is density x extinction x length x `|dir|`.
`NoiseBoundTests.AnalyticBodyMatchesFineMarch` pins it: the bound, the mask and
the pointwise densities agree exactly at the body, and `BodyDepth` equals the
summed fixed-step depth of the body cells within 1e-4 relative. The comparison
is in the depth domain, so LOD-far's deep bodies do not underflow.
The footprint-truncated void weights its octaves by `w_i(z)`, decreasing in `z`.
Its bound takes each weight over `[w_i(z1), 1]` rather than
`[w_i(z1), w_i(z0)]`, because one bound serves both the full field (`w = 1`) and
the truncated one. `[w_i(z1), 1]` contains both, and `iv_mul` is monotone in its
interval argument, so the composed interval encloses both fields.
`NoiseBoundTests.WeightBoundEnclosesOneSignedNoise` checks it on LOD-far over
2,000 segments: each weight lies in its bound, and `w(z) n` lies in
`iv_mul(bound, n)` for a one-signed `n`. A lower end of `w(z0)` fails it.
The contracts, at `N = 8`, the lower of warp 0 and `D`:
- (a) at least 2x combined cost;
- (c) on the deep well (rulings `affine-wells-contract-cheaper-than-interval`
and `wells-overhead-plus-floor`): the affine bound's combined cost ratio is
above the centred interval's at warp 0 and at warp `D`; and, hard, the
pre-pass proves at least half of the oracle-empty cells empty under both
bounds, so a pre-pass that skips nothing fails. The probe overhead, the
efficiencies against the oracle and the range ceiling, the ceilings and the
cull fraction are printed, not asserted; (c-band) is printed only;
- (e) at the shipped void: the masked fixed-step march agrees with the reference
exactly; the footprint march's optical depth at the body start is off the
converged march's by at most twice the fixed-step march's error; and it takes
at most half the reference's steps per pixel.
This run meets every contract: (a) 16384x, (c) affine 3.57x and 3.46x against
the centred interval's 3.20x and 3.12x, with a cull fraction of 0.987 at the
lowest, (e) 2.69x fewer steps and a depth error 1.42x the fixed-step march's.
The fields, the grids, the 0.2 envelope weight and the 2.0 gradient weight are
not tuned toward the contracts. An affine probe is one `snoise_grad`, counted at
2.0 `snoise` evaluations (an estimate, printed beside the count).
Consumers: the gamecult.org ground shader (through the GLSL lowering) and
Aetheria's nebula raymarch at its CultMath pin bump.
## Affine forms
`math.Affine.cs` carries reduced affine forms with one shared symbol: a
`float3(x0, a, e)` is the set `x0 + a eps + e delta` with `eps` and `delta` in
`[-1, 1]` and `e >= 0` (Messine's AF1, as Gamito and Maddock use it for gradient
noise, in one register as an interval is a `float2`). `eps` is shared by every
form over one region (for a screen-tile slice, the depth parameter, `z = z_m +
h eps`), so `af_sub(x, x)` has `a = 0`; `delta` is private to the form, and
every term that cannot be tracked is condensed into `e`. A slice is a
`float4(centre, radius)` with a `float3` axis. No op takes a compound type, a
reference parameter or a swizzle write, so each mirrors into the HLSL common
subset.
The ops: `af_point`, `af_symbol`, `af_from_iv`, `af_range` (the bridge back to
`iv_*`), `af_add`, `af_sub`, `af_neg`, `af_scale`, `af_add_iv`, `af_mul` (the
product `|a_x a_y|` and the cross-error terms condensed into `e`, so there is
no `af_sqr`), `af_snoise` and `af_fbm`, and the slice pair `af_frustum_axis` and
`af_frustum_ball`.
Rounding. Every op returns a form whose set at each `eps` contains the float32
value of its function at every point of its operands' sets at that `eps`. Each
op folds its own float32 rounding into `e` as `S 2^-20`, `S` the sum of the
magnitudes its roundings are relative to (named in the op's comment): at most
`8u S` is needed with `u = 2^-24`, and the factor of two over that is the
allowance for a device whose `+` and `*` are faithful rather than correctly
rounded. `AffineTests.EveryOpEnclosesItsPoints` evaluates the form's set in
double and the function in float32 with no tolerance. Products and `af_from_iv` also
add `2^-126`, the most an underflow to a subnormal can lose (`TinyOperandsEncloseTheirPoints`).
`af_from_iv` halves its endpoints before it sums and differences them, and `af_range`
scales its terms before it sums them, so endpoints near the float maximum do not overflow
in the middle of the op; a form that overflows anyway gives `af_range` infinite endpoints,
never NaN (`FromIntervalAndRangeHoldAtTheExtremes`). `af_range` finds a NaN endpoint by its bit pattern, because
`lo == lo` is a compare dxc marks `fast` and deletes under its default flags. `AbsorptionsArePinned` and
`SnoiseFormCarriesItsDocumentedWidth` pin each absorption and `af_snoise`'s float32 allowance both
ways: the enclosure tests alone cannot see them on a CPU, where float32 is exact.
`af_snoise(centre, axis, radius)` encloses `snoise` over `centre + axis eps + w`,
`|w| <= radius`, from one `snoise_grad`. It returns the narrower of the
Lipschitz form `(n, 0, L R)` and the centred form `(n, g . axis, |g| radius +
M R^2 / 2)` (`R = |axis| + radius`, `L = SNOISE_LIPSCHITZ`, `M =
SNOISE_HESSIAN`), so its total width is never more than the Lipschitz form's.
The centred form is Taylor's theorem, which is why it beats the ball when the
radius is small and the axis carries the variation. `af_fbm` compounds octaves
as `iv_fbm_ball` does. The float32 evaluation allowance is derived in
`af_snoise`'s comment.
`SNOISE_HESSIAN = 55.642143` is 1.10 times the refined maximum of the spectral
norm of the Hessian of `snoise(float3)`, measured by
`NoiseBoundTests.MeasureHessian` over 1e6 seeded points (the analytic
`snoise_hessian`, below) and refined by ascent from the 1e4 largest: refined
maximum 50.5838. The central differences of `snoise_grad` that an earlier pin
used read 50.955 at h = 1e-3, their roundoff.
`HessianConstantPinsSampledCurvature` fails on a 1% move.
`snoise_hessian(float3)` (`math.Span.cs`) is the analytic Hessian of `snoise`: a
symmetric `float3x3` on the same lattice, hash and tie order as `snoise_grad`,
differentiating each corner's gradient `-8 m^3 s x + m^4 p` once more. Each
off-diagonal entry is computed once and placed twice, so the matrix equals its
transpose bit for bit, and a clamped corner (`m = 0`) contributes nothing. Built
from nine scalars, the matrix reads as columns in GLSL (`mat3`) and rows in HLSL,
which is the same matrix because it is symmetric. `SNOISE_THIRD = 535.8009`
(`math.Span.cs`) is the next order: 1.10 times the maximum, over 1e6 seeded points,
of the operator norm of the third-derivative tensor of `snoise`, refined from the
1e4 largest; measured 487.0917 on the tied kernel. The tensor is closed form, in
double: `snoise_hessian`'s per-corner entry differentiated once more, on the
kernel's lattice, tie order and hash, and the test
`ThirdTensorMatchesDifferencesOfTheHessian` holds it against Richardson-extrapolated
differences of the double Hessian. The operator norm is a local ascent over unit
vectors (a dense sweep of 20,000 directions at the witness finds 99.9987% of it) and
the outer maximum a sampled search refined by ascent, so the constant is empirical
with a 10% margin, not a proof; an earlier pin from central differences of the
float Hessian read 488.1047, its roundoff.
`MeasureThird` takes the measurement and `ThirdConstantPinsSampled` pins it.
`integral_clamp_linear(a, b, lo, hi, t0, t1)` is the closed-form integral over
`[t0, t1]` of `clamp(a + b t, lo, hi)`. All three have HLSL and GLSL lowerings and
`glsl-parity.json` fixture families (`snoise_hessian`, `integral_clamp_linear`; the
`af_snoise` and `af_fbm` families are regenerated for the re-pin).
`SNOISE2_LIPSCHITZ = 8.117002` is the same for the gradient of `snoise(float2)`
(`MeasureLipschitz2`: sampled 7.2575, refined 7.3791, witness (-67.12454,
-129.53296), `|snoise2|` at most 0.99964), and it backs `iv_snoise_ball(float2,
float)`. Both are empirical with a margin, not proofs. A random ball cannot see a
curvature constant 20% low, so `SNOISE_HESSIAN` is defended by
`HessianConstantPinsSampledCurvature` and `SnoiseFormEnclosesTheCurvatureWitness`
(`af_snoise` at the point where the curvature is realised, along 4,000 directions: a
constant of 40 misses there). The margin over the function itself: a scan of the whole
289^3 hash period found a realised maximum of 50.58, and bounding each corner's gradient
independently over every hash configuration gives 52.22, so 55.64 sits 7% above the
configuration bound. A change to the kernels must re-run the measurements.
### Span stages
`span_fbm` and `span_ridged` (`math.Span.cs`; `cultmath_span_fbm` and `cultmath_span_ridged` in HLSL over
the same struct types, `cultmath_span_stage` behind both, and GLSL derived) are the octave stages
of a warp chain, `span_fbm(path, stage, octaves, lacunarity, gain)`, each returning
one `SpanNoise { float4 field; float curvature; float fold; }`. The inputs are two
structs with named public fields and no positional constructor, so the three floats
of the span and the six fields of the transform cannot be swapped silently.
`SpanPath { midpoint, velocity, speed, acceleration, halfLength }` is the path
`q(t)`, `|t| <= halfLength`, through `midpoint` with `velocity` at `t = 0`,
`|q'| <= speed` and `|q''| <= acceleration` along it. `SpanStage { frequency,
stretch, axis, shift, offset, octaveStep }` makes octave `o` sample `snoise_grad`
at `x_o(q) = f_o (M q + shift) + offset + o octaveStep`, with `f_o =
lacunarity^o`, amplitude `gain^o`, and `M = frequency (I + (1/stretch - 1) a a^T)`,
`a = normalize(axis)`; a zero axis or `stretch` 1 means no stretch, and `stretch`
must be positive and finite (unguarded, like `lacunarity`). `default(SpanStage)`
has frequency 0; `SpanStage.Identity` is the stage whose field equals `fbm_grad`
and `ridged_grad` bit for bit. `octaves`, `lacunarity` and `gain` stay positional:
they are `fbm_grad`'s own trailing triple, and `octaves` is the one int among the
scalars. `field` is the octave sum and its gradient in `q` (`f_o M grad n_o`).
`span_ridged` sums `gain^o (1 - |n_o|)`, `ridged_grad`'s convention. The Span
Lab's stage is `1 - 2|n|` per octave: with `G` the sum of `gain^o` over the
octaves (signed, so the alternating sum for a negative gain), its value is `2 value
- G`, and its gradient, curvature and fold are twice `span_ridged`'s.
The shaders have no `SpanStage.Identity`: a shader caller builds every stage field itself (in GLSL,
`SpanStage(1.0, 1.0, vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0))` is the identity). A constant would
need a second transformation rule (C# has no const structs, GLSL wants a constructor) and a mirror
exemption, for one value; the mirror test proves instead that the shader text at a C#-built identity
returns `cultmath_fbm_grad` and `cultmath_ridged_grad`'s field bit for bit.
Bounds come from the three pins only. With `|M| = |frequency| max(1, 1/stretch)` and
`nu = f_o |M| speed` the noise-space speed, the Hessian of the noise on the span has
operator norm at most `Hl = min(SNOISE_HESSIAN, ||snoise_hessian(x_mid)||_F + e_h +
SNOISE_THIRD nu halfLength)`, so `curvature = sum gain^o f_o^2 |M|^2 Hl` bounds the
operator norm of the field's Hessian in `q` at every point of the span (the pin takes
over once the third-derivative term exceeds it, and at `halfLength` 0 the local tier
alone remains). For `span_ridged`, an octave leaves the branch it is on at the
midpoint (`1 - s n`, `s` the midpoint's sign of `n`) only where `n` changes sign, and
there `|n| <= V - |n_mid|`, with `V = (|grad n_mid . w| + e_g |w|) halfLength + (Hl nu |w|
+ Gl f_o |M| acceleration) halfLength^2 / 2 + 2 e_n`, `w = f_o M velocity` and `Gl =
min(SNOISE_LIPSCHITZ, |grad n_mid| + e_g + Hl nu halfLength)`; `fold = sum 2 gain^o
max(V - |n_mid|, 0)`, exactly 0 for `span_fbm` and for an octave that cannot reach zero.
The float32 `snoise`, `snoise_grad` and `snoise_hessian` are not the exact functions, so
the quantities read at the midpoint carry allowances, by the derivation of `af_snoise`'s
comment. `S_o = |f_o| (|M q|_1 + |shift|_1) + |offset|_1 + |o octaveStep|_1 + sqrt(3) nu
halfLength` is the sum of the magnitudes the octave coordinate's roundings are relative
to, and `e_n = 2^-14 + L 2^-20 S_o` (value), `e_g = 2^-12 + H 2^-20 S_o` (gradient
length) and `e_h = 2^-10 + T 2^-20 S_o` (Frobenius norm of the Hessian), with `L`, `H`,
`T` the three pins; they add no pin. Measured against a double kernel with the float
hash (6000 points per band, `|x|` from 2^-2 to 2^23), two samples observed the error's
maxima as fractions of `e_n`, `e_g` and `e_h`: 0.057, 0.046 and 0.075 in the cut's own
sample (seed not recorded; the 0.075 is against `T 2^-20 |x|_1` alone, at the smallest
band, where the absolute Hessian error is 2.3e-5 and the absolute term carries it), and
0.064, 0.0485 and 0.057 in an audit's (seed 0x501). They are samples, not bounds: a new
seed moves them. They say the allowances enclose the error with a margin of about 15.
Errors grow linearly in `|x|`: the value's is 2e-4 at 2^8, 1.5e-2 at 2^14 and 0.94 at 2^20.
Rounding in the Frobenius-to-operator-norm step needs no allowance: the Frobenius norm
exceeds the operator norm.
Domain. No coordinate range is checked, clamped or refused. The allowances grow with
`S_o`, so `curvature` saturates at `SNOISE_HESSIAN` (true everywhere) by `S_o` of about
1.1e5, and `fold` turns positive for every octave whose `V + 2 e_n` reaches `|n_mid|`:
the bounds stay sound at every magnitude and stop being informative where float32 noise
is (see "Precision domain" above). `SpanStageTests` encloses the exact (double) Hessian
norm and both the float and the exact branch departure at 64 points of seeded spans in
bands of `S_o` from under 1e2 to over 1e6, and in a fixed span where the local tier
with its third-derivative term halved fails.
The slice. `af_frustum_ball(centreSlope, z0, z1, footprintPerDepth,
warpVariation)` returns the centre `(m_c z_m, z_m)` and a radius `z1
footprintPerDepth + warpVariation`, with `af_frustum_axis` the axis `(m_c, 1) h`:
every ray point at depth `z` is `centre + axis eps(z) + w`. Its derivation
and the clamp residual are in the comment. The caller rotates, translates and
scales the slice into the noise's frame, and owns the rounding of that.
The centred warp. A flow-warped sample reads `snoise` at `p + flow(p) shift`. The
caller does not enlarge the ball by the whole displacement `|flow| shift`; it
moves the centre by the flow at the centre, `c + flow(c) shift`, and widens the
radius by the flow's variation across the ball, `r + |shift| rho` with `rho =
FlowAmplitude (SNOISE2_LIPSCHITZ FlowFrequency r + a) sqrt 2` (the clamp to the unit
disc is nonexpansive), where `a = 2^-14 + L2 2^-20 (|q|_1 + r FlowFrequency)` is the
float32 allowance of the flow's two `snoise(float2)` reads (the point `q` plus its offset rounds at the
magnitude of `q`, and a float32 flow at two points of a ball differs by more than the exact
function can). CultMath owns neither the flow nor the envelope: the warp's
centre and variation are the caller's, and a consumer adds them to the slice's
`warpVariation` or to the form's radius. With two phases that cross-fade, each
phase takes its own centre; one ball serves when the phases' centres coincide
(warp 0). `NoiseBoundTests.CentredWarpStaysEnclosed` checks 2,000 balls, radius 1e-3 to
500, and fails when `rho` is dropped or halved; `AffineWarpStaysEnclosed` checks
3,000 tile slices of `FogField.Slice`, which returns the final `af_snoise` arguments
(each phase's moved centre and grown radius, the detail's unwarped set, the shared axis),
so the affine bound wires nothing itself: the reach over which the flow is varied is
`|axis| + radius`, each phase carries its own growth, and the gate reads each argument's
reach. It fails when the reach is the ball's radius or the larger of the two, a phase's
growth is dropped or taken from the other phase, the axis or the detail radius is scaled,
or `rho` is halved. `AffineCompositionTightensItsRanges` sees the shared-symbol
composition itself: the bound is exactly the intersection of the affine arithmetic's piece
and the interval composition over the forms' ranges, and the affine piece is the narrower
of the two in at least 35% of the open slices (the piece alone is wider in total).
The deep well is the representative Aetheria case: the camera inside a sun's
bowl, looking across. `DeepWellIsTenBandsDeepAndWide` computes the band `G` from
`FogField`'s own floor offset, amplitude and noise range (60) and requires the
depth (633 at the centre against the rim) and the half-depth radius to be at
least `10 G`, every drawn camera sitting inside the well below the rim; a well of
depth 150 fails. The shallow wells stay as the printed shallow-well, band-dominated row (c-band).
The test field's affine bound takes depth as `af_symbol(z_m, h)`, builds `s`
affine in it with the height interval, the footprint and the slope-depth
product in `e` through `af_add_iv`, takes the noise through `af_frustum_*` and
`af_snoise` per phase and for the detail octave, fades by `af_from_iv` and
`af_mul`, reads the range with `af_range`, and intersects with the centred
interval's bound (both sound). `FogField.Bound` makes no direct `snoise` call.
The cull-map render (`DeepWellCullMap`, explicit) writes a PNG when
`CULTMATH_WRITE_CULLMAP` names a directory: one deep-well camera at warp `D`,
960 x 540 in 8 x 8 tiles, with panels for dense cells per ray under the centred
interval, under the affine bound and under the oracle mask (one logarithmic
scale) and the transmittance. Its run: 4.23, 3.60 and 2.02 cells per ray on
average, 44 the most, mean transmittance 0.278.
## Expressions
`CultMath.Expr` holds a scalar SSA expression graph as a persisted value, a builder
for it, and a C# evaluator.
- **Graph (`ExprGraph`)**: a sealed immutable class. It has no name of its own; the
consumer record that holds it names it. Nodes are in topological order, an operand
names a lower node, and an edit builds a new graph. A persisted graph carries a
format version (1); one naming an unknown op, a newer format or a forward operand is
refused whole at decode. `ExprGraph.Create` is the one validator, run by the builder
and by decode.
- **Node**: its index in the topological order. The builder hash-conses identical
(op, operands, constant bits) nodes and drops nodes no output reaches, so one
expression always builds the same bytes; any rebuild may renumber nodes.
- **Port**: graph inputs vary per point, params are uniform per batch and carry a
default, outputs name a node. Names are `[A-Za-z_][A-Za-z0-9_]*`, unique per kind.
An op's operands are positional; a multi-result op (`cellular3`: nearest.xyzw,
edge.xyzw, id) is read only through `Pick`.
- **Op vocabulary**: one table (`ExprOps`), a stable string per op, append-only.
Vector arguments are decomposed operands: `snoise2`, `snoise3`, `cellular3`. An op
calls its CultMath function; a function's value change is a Breaking release note.
- **Evaluation**: `ExprEvaluator.Evaluate` runs the nodes in order through the table,
one IEEE single per op, so a result equals the direct composition of the same math
calls bit for bit. Params bind by name (`ExprBindings`): a missing name takes its
default and an unknown name is refused.
- **Stencils** are substitution at build time (`ExprBuilder.Substitute`, Fidget's
`remap_xyz`): N substitutions of the position inputs combined by ops give an ordinary
flat graph. There is no loop construct.
The MessagePack shape belongs to `GameCult.Caching.MessagePack` (`CultMathExprResolver`).
Interval, affine and gradient evaluation over the graph, bytecode, the HLSL
interpreter and pruning are not built.
## Rules
- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`.
- Keep Rust as the owner of native hot-path kernels and parity fixtures.
- Keep C#, shader, and scripting surfaces as runtime wrappers, not competing
math authorities.
- Keep `shaders/CultMath.hlsl` as the canonical shader mirror for shared
CultMath functions HLSL does not already provide (HLSL intrinsics such as
`mul`, `any`, and `log` need no mirror). Project-local shader helpers should
move here once more than one organ needs them or once CPU/GPU parity matters.
- Implement public shader semantics directly; do not copy Unity.Mathematics.
- Prefer plain mutable value types with public component fields, as HLSL does.
- Prefer component-wise overloads over clever generic machinery until the API
earns that complexity.
- Keep the surface boring and exact. Fill what a consumer uses, guided by HLSL
semantics; noise primitives, matrices, quaternions, and packing helpers need an
owner before they enter.
- Keep the core assembly engine-free. Unity conversions live in the Unity
package's `CultMath.UnityBridge` assembly.
## Non-Goals
- Compiling C# to shaders.
- Generating HLSL from Rust or Rust from HLSL.
- Replacing `System.Numerics` for general .NET work.
- Recreating every Unity.Mathematics type because a spreadsheet somewhere says
“coverage.” That way lies decorative obesity.