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