Merge interval-ops r3 (3ac7250a) into the GLSL lowering

README conflict resolved by keeping r3's Intervals bullet, then the GLSL
bullet. design.md and HlslSourceCompatibilityTests.cs merged clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 13:32:26 +02:00
commit 8be32a2e27
7 changed files with 1300 additions and 116 deletions

View file

@ -38,9 +38,15 @@ shader semantics.
- interval arithmetic for culling empty space with a proof: `iv_*` functions
over `float2(lo, hi)` (`iv_add`, `iv_mul`, `iv_exp`, `iv_smoothstep`, ...)
and `iv_snoise_ball`/`iv_fbm_ball`, which bound `snoise` and `fbm_grad` over a
ball. Every one returns an interval containing the function's value at every
point of its input, so a raymarch can skip a segment whose `hi` is below its
cutoff; `cultmath_iv_*` in HLSL. See `docs/design.md`, "Intervals".
ball, and `iv_frustum_ball`, the ball around a screen tile's rays over a depth
segment, so one probe serves the whole tile (its radius carries its own
float32 rounding bound). Every one returns an interval
containing the function's value at every point of its input, so a raymarch
can skip a segment whose `hi` is below its cutoff; `cultmath_iv_*` in HLSL.
The bounds compose with a consumer's own analytic envelope (a height fog, a
carved sphere): its interval over the segment proves empty space in one probe,
and the noise ball is consulted only near the surface. See `docs/design.md`,
"Intervals".
- `shaders/CultMath.glsl`, the same `cultmath_*` library in GLSL ES 3.00 for
WebGL2, generated from `CultMath.hlsl` (never edited by hand). GLSL has no
`#include`: a WebGL consumer concatenates the file's text after its own

View file

@ -458,6 +458,58 @@ 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, so HLSL and GLSL compilers are covered by
the same bound. At the shipped scales (`|c|_1` near 600, `r` near 5) it adds
`6e-4`. 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
@ -478,21 +530,43 @@ tracking or affine forms; those are later steps of
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 a number too. `NoiseBoundTests.IntervalSkipHalvesEvaluations`
marches 1,000 seeded rays through the map's warped coverage field (`F0 = 0.35`,
cutoff 0.25, a slab of depth 12) once at the dense step and once with the
adaptive interval march, and counts `snoise` evaluations (three per dense
sample, one per probe; the flow is not counted, because the site reads it from
a texture). Both marches integrate the same cells, so their transmittance
agrees exactly. At the test's parameters (first step 0.25, dense step 0.125,
warp bound 0.05) the dense march spends 287,940 evaluations and the interval
march 309,550: 0.93x, not the 2x saving the proving-ground contract asks for.
Over the grid the test also prints, the best is 1.83x (first step 0.0625, no
warp), and at a first step of 1 it is 0.74x. With `L` near 10 a probe proves a
segment empty only where `snoise` is far below the cutoff, and a probe that
fails costs one evaluation on top of the three the dense sample costs. The test
skips with that open fork named instead of passing; whether the march, the
field or the bound changes is the campaign's decision.
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. Each of the tile's `N^2` rays is then
marched twice with the same integrator and the same early-out at transmittance
0.02: once over every cell, once over the tile's unproven cells only. The two
integrate the same cells, so their transmittance agrees exactly in every
configuration. 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` with the warp on, in Aetheria's units
on its 256-cell quadratic grid:
- (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.
- (b) Inside the fog: 0.99x. Every cell is fog, and the pre-pass probes each one.
- (c) The zone bowl and four wells, the camera gazing across: 1.41x, and 3.32x
with no warp. The pre-pass cuts envelope evaluations from 212.93 to 37.12 per
ray. Almost all of the dense march's 78.67 `snoise` per ray fall in the noise
band, where the rays need them (ceiling 1.42x). The warp bound `D = 60` is what
leaves the noise ball unable to prove anything at this noise scale.
- (d) r1's uniform slab, which has no envelope: 1.23x, and 1.48x with no warp.
- (e) The void at the shipped point (`Rh = 198`, `S = 50`, the low camera,
200 tiles), with three marches. The fixed-step reference takes 36.34 steps per
pixel. The same cells masked by the pre-pass take 20.13, with identical
transmittance. The footprint-aware march takes 16.45 inside the unmasked
cells, within 0.008339431 of the reference's transmittance: 2.21x fewer steps.
`snoise` per pixel goes from 32.98 to 26.56, and combined cost improves 1.34x.
Over the grid of hollow radius, camera offset and ramp width the steps ratio
runs from 1.57x (`Rh = 100`, low, `S = 150`) to 12.40x (`Rh = 1000`, centred,
`S = 10`), and the cost ratio from 1.12x to 3.47x.
The contract is 2x on combined cost for (a) and (c) and 2x fewer steps per pixel
for (e). (a) and (e) meet it. (c) does not, so the test skips naming
`saving-2x-scenarios`. The fields, the grids and the 0.2 weight are not tuned
toward the contract.
Consumers: the gamecult.org ground shader (through the GLSL lowering) and
Aetheria's nebula raymarch at its CultMath pin bump.

View file

@ -104,4 +104,19 @@ float2 cultmath_iv_fbm_ball(float3 centre, float radius, int octaves, float lacu
return sum;
}
// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment
// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z).
// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by
// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs.
float4 cultmath_iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
{
float zm = (z0 + z1) * 0.5;
float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0);
float cx = centreSlope.x * zm;
float cy = centreSlope.y * zm;
float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp;
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7;
return float4(cx, cy, zm, radius);
}
#endif

View file

@ -164,4 +164,36 @@ public static partial class math
return sum;
}
/// <summary>
/// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth
/// segment [z0, z1], each point then moved by a warp of length at most warp: the ball to hand
/// iv_snoise_ball (scaled by the noise frequency) when one probe serves the whole tile. Camera frame:
/// a pinhole at the origin looking down +z, where the ray of slope m is the points (m z, z).
/// centreSlope is the tile's central slope m_c; footprintPerDepth is the largest distance of any
/// ray's slope from m_c, N / (sqrt(2) f) for an N x N pixel tile at focal length f pixels (the full
/// pixel footprint, so sub-pixel jitter is covered). The centre is (m_c z_m, z_m), z_m = (z0 + z1) / 2;
/// the radius is ((z1 - z0) / 2) |(m_c, 1)| + z1 footprintPerDepth + warp. Derivation: a point of slope
/// m_c + e (|e| &lt;= footprintPerDepth) at depth z is c + (z - z_m)(m_c, 1) + z (e, 0); the first term is
/// at most the first radius term, the second at most the second, and the warp adds at most warp.
/// The ball also carries its own float32 rounding: 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, 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. So every exact point of
/// every ray, at any depth and slope, lies in the returned float ball
/// (NoiseBoundTests.TileBallEnclosesEveryRaySegment, degenerate and far segments included). The caller
/// rotates and translates the centre into world space (the radius is unchanged) and scales both by its
/// noise frequency; the rounding of that transform is the caller's.
/// </summary>
public static float4 iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
{
var zm = (z0 + z1) * 0.5f;
var axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0f);
var cx = centreSlope.x * zm;
var cy = centreSlope.y * zm;
var radius = (z1 - z0) * 0.5f * axis + z1 * footprintPerDepth + warp;
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7f;
return new float4(cx, cy, zm, radius);
}
}

View file

@ -133,7 +133,7 @@ public sealed class HlslSourceCompatibilityTests
}
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
Assert.Equal(52, compared.Count);
Assert.Equal(53, compared.Count);
}
/// <summary>

File diff suppressed because it is too large Load diff

View file

@ -104,4 +104,19 @@ float2 cultmath_iv_fbm_ball(float3 centre, float radius, int octaves, float lacu
return sum;
}
// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment
// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z).
// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by
// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs.
float4 cultmath_iv_frustum_ball(float2 centreSlope, float z0, float z1, float footprintPerDepth, float warp)
{
float zm = (z0 + z1) * 0.5;
float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0);
float cx = centreSlope.x * zm;
float cy = centreSlope.y * zm;
float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp;
radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7;
return float4(cx, cy, zm, radius);
}
#endif