Centred warp: FogField.Slice owns the affine warp terms and AffineWarpStaysEnclosed tests them; FlowVariation carries a float32 allowance; CentredWarpStaysEnclosed down to r = 1e-3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-04 02:55:49 +02:00
commit 3958dca2cd
2 changed files with 107 additions and 21 deletions

View file

@ -765,13 +765,20 @@ The centred warp. A flow-warped sample reads `snoise` at `p + flow(p) shift`. Th
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 sqrt 2` (the clamp to the unit
disc is nonexpansive). CultMath owns neither the flow nor the envelope: the warp's
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 and
fails when `rho` is dropped.
(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` (the owner the affine bound reads: the reach over
which the flow is varied is `|axis| + radius`, and each phase carries its own growth)
and fails when the reach is the ball's radius, a phase's growth is dropped, or `rho`
is halved.
The deep well is the representative Aetheria case: the camera inside a sun's
bowl, looking across. `DeepWellIsTenBandsDeepAndWide` computes the band `G` from

View file

@ -730,10 +730,11 @@ public sealed class NoiseBoundTests
/// <summary>
/// The centred warp (design.md, "Affine forms"): over 2,000 seeded balls of the deep well's flow (warp
/// D = 60, period 8, flow frequency 1/512; centres in [-2000, 2000]^3, radius log-uniform in [1, 500],
/// D = 60, period 8, flow frequency 1/512; centres in [-2000, 2000]^3, radius log-uniform in [1e-3, 500],
/// a time per ball) x 64 points, each phase's warped point p + flow(p) shift_k lies in the ball
/// (c + flow(c) shift_k, r + |shift_k| rho) (geometry allows one part in 1e5 for the rounding of the
/// warped point, as WarpedPointsStayEnclosed does), and snoise of it, at the coverage frequency, lies
/// (c + flow(c) shift_k, r + |shift_k| rho), r the radius widened by (|c|_1 + r) 2^-20 as iv_frustum_ball
/// widens its ball (the float32 point p is inside only up to that, which matters below a radius of 1),
/// geometry allowing one part in 1e5 for the rounding of the warped point, as WarpedPointsStayEnclosed does, and snoise of it, at the coverage frequency, lies
/// in iv_snoise_ball of that ball with no tolerance. Dropping rho fails: some warped points lie
/// farther than r from the moved centre.
/// </summary>
@ -746,10 +747,11 @@ public sealed class NoiseBoundTests
for (var b = 0; b < 2000; b++)
{
var centre = new float3(Uniform(random, -2000.0f, 2000.0f), Uniform(random, -2000.0f, 2000.0f), Uniform(random, -2000.0f, 2000.0f));
var radius = LogUniform(random, 1.0f, 500.0f);
var radius = LogUniform(random, 1.0e-3f, 500.0f);
var reach = radius + (abs(centre.x) + abs(centre.y) + abs(centre.z) + radius) * 9.5367431640625e-7f;
var time = Uniform(random, 0.0f, 100.0f);
var (_, shift0, shift1) = noise.Phases(time);
var (c0, g0, c1, g1) = noise.CentredWarp(centre, radius, time);
var (c0, g0, c1, g1) = noise.CentredWarp(centre, reach, time);
for (var i = 0; i < 64; i++)
{
var p = PointInBall(random, centre, radius, i);
@ -758,11 +760,11 @@ public sealed class NoiseBoundTests
{
var warped = p + flow * shift;
var distance = length(warped - moved);
Assert.True(distance <= (radius + grow) * (1.0f + 1.0e-5f), $"warped point {warped} leaves the centred ball ({moved}, {radius + grow:R})");
Assert.True(distance <= (reach + grow) * (1.0f + 1.0e-5f), $"warped point {warped} leaves the centred ball ({moved}, {reach + grow:R})");
beyond += distance > radius ? 1 : 0;
var bound = iv_snoise_ball(moved * noise.F0, (radius + grow) * noise.F0);
var bound = iv_snoise_ball(moved * noise.F0, (reach + grow) * noise.F0);
var value = snoise(warped * noise.F0);
Assert.True(Inside(value, bound), $"iv_snoise_ball over the centred ball ({moved}, {radius + grow:R}) = {bound} misses snoise = {value:R} at {warped}");
Assert.True(Inside(value, bound), $"iv_snoise_ball over the centred ball ({moved}, {reach + grow:R}) = {bound} misses snoise = {value:R} at {warped}");
}
}
}
@ -771,6 +773,58 @@ public sealed class NoiseBoundTests
Assert.True(beyond > 0, "no warped point needs rho: the check cannot tell a centred warp without it");
}
/// <summary>
/// The centred warp over an affine slice (FogField.Slice, the owner AffineBound reads): over 3,000
/// seeded tile slices (the deep well, the shallow wells and inside the fog; N in {1, 4, 8, 16}; a
/// slice of length log-uniform in [0.5, 1500] from a drawn depth, so the axis is from far under to far
/// over the ball's radius) x 64 rays each (the first two at the slice's ends), each phase's warped ray
/// point p + flow(p) shift_k lies within radius + growth_k of moved_k + axis eps(z), eps(z) =
/// clamp((z - z_m) / h, -1, 1), no tolerance but one part in 1e5 for the rounding of the warped point.
/// The check sees each term of the slice: a reach of the ball's radius instead of |axis| + radius, a
/// phase's growth dropped, or rho halved leaves rays outside; the report counts the rays that need
/// each phase's growth, and the test asserts both phases have some.
/// </summary>
[Fact]
public void AffineWarpStaysEnclosed()
{
var random = new System.Random(0xAF77);
int[] sizes = { 1, 4, 8, 16 };
var fields = new[]
{
FogField.DeepWell(FogField.AetheriaWarp, BoundMode.Affine), FogField.Wells(FogField.AetheriaWarp, BoundMode.Affine), FogField.InsideFog(FogField.AetheriaWarp, BoundMode.Affine),
};
var needing = new[] { 0, 0 };
for (var t = 0; t < 3000; t++)
{
var field = fields[t % fields.Length];
var tile = field.DrawTile(random, sizes[random.Next(sizes.Length)]);
var z0 = Uniform(random, field.Grid[0], field.Grid[^1] * 0.9f);
var z1 = MathF.Min(z0 + LogUniform(random, 0.5f, 1500.0f), field.Grid[^1]);
var slice = field.Slice(tile, z0, z1);
var (_, shift0, shift1) = field.Noise.Phases(tile.Time);
var (zm, h) = (slice.Ball.z, slice.AxisCamera.z);
for (var i = 0; i < 64; i++)
{
var m = tile.PixelSlope(random.Next(tile.N), random.Next(tile.N), random.NextSingle(), random.NextSingle());
var z = i < 2 ? (i == 0 ? z0 : z1) : Uniform(random, z0, z1);
var p = tile.World(new float3(m.x * z, m.y * z, z));
var flow = field.Noise.Flow(p);
var eps = h == 0.0f ? 0.0f : clamp((z - zm) / h, -1.0f, 1.0f);
var phases = new[] { (shift0, slice.Centre0, slice.Grow0), (shift1, slice.Centre1, slice.Grow1) };
for (var k = 0; k < 2; k++)
{
var (shift, moved, grow) = phases[k];
var distance = length(p + flow * shift - (moved + slice.Axis * eps));
Assert.True(distance <= (slice.Ball.w + grow) * (1.0f + 1.0e-5f), $"{field.Name}: phase {k} warped ray point at depth {z:R} is {distance:R} from moved + axis eps, outside radius {slice.Ball.w:R} + growth {grow:R} (reach {slice.Reach:R}, slice [{z0:R}, {z1:R}])");
needing[k] += distance > slice.Ball.w ? 1 : 0;
}
}
}
output.WriteLine($"IV-REPORT affine warp: rays beyond the unwarped radius, phase 0 {needing[0]}, phase 1 {needing[1]}");
Assert.True(needing[0] > 0 && needing[1] > 0, $"no ray needs phase growth ({needing[0]}, {needing[1]}): the check cannot tell a slice without it");
}
// Why a field is not ten bands deep and wide, or null. The band G is the span of s where only the
// noise decides density: s in [F + A lo, F + A hi] for the free noise range [lo, hi], with the fade at 1.
private static string? TenBandsShortfall(FogField field, int cameras)
@ -1218,8 +1272,9 @@ public sealed class NoiseBoundTests
/// <summary>
/// How far the flow can move across a ball of radius r about c: each flow component is snoise(float2)
/// of the point projected to the flow plane and scaled by FlowFrequency, which varies over the ball by
/// at most iv_snoise_ball(float2)'s reach from its centre value; the projection and the clamp to the
/// unit disc are nonexpansive, and two unit-disc vectors differ by at most 2.
/// at most iv_snoise_ball(float2)'s reach from its centre value, plus Deviation's float32 allowance; the
/// projection and the clamp to the unit disc are nonexpansive, and two unit-disc vectors differ by at
/// most 2.
/// </summary>
public float FlowVariation(float3 centre, float radius)
{
@ -1229,11 +1284,17 @@ public sealed class NoiseBoundTests
return min(sqrt(u * u + v * v), 2.0f) * FlowAmplitude;
}
// How far snoise(float2) can differ from its value at q over radius, in float32: the ball's reach plus
// the allowance of af_snoise's convention for the 2D value, 2^-14 + L2 2^-20 (|q|_1 + r). The point
// q + offset rounds at the magnitude of q + offset (a unit in the last place of 20 is 2^-19), so the
// flow at two points of a ball differs by more than the exact function can, and the iv_snoise_ball
// bound, which is exact for the point it is given, does not carry that.
private static float Deviation(float2 q, float radius)
{
var n = snoise(q);
var ball = iv_snoise_ball(q, radius);
return max(ball.y - n, n - ball.x);
var allowance = 6.103515625e-5f + SNOISE2_LIPSCHITZ * 9.5367431640625e-7f * (abs(q.x) + abs(q.y) + radius);
return max(ball.y - n, n - ball.x) + allowance;
}
/// <summary>
@ -1524,6 +1585,29 @@ public sealed class NoiseBoundTests
return Compose(s, fade, noise);
}
/// <summary>
/// The affine slice of a tile over [z0, z1] in the world frame, with the centred warp applied: the
/// camera-frame axis and ball (af_frustum_axis, af_frustum_ball with no warp), the ball's world
/// centre, the world axis, the reach R = |axis| + radius of the whole set from that centre (the
/// distance the flow is varied over), and each phase's moved centre and growth
/// (WarpedNoise.CentredWarp over R). The rays at depth z are centre + axis eps(z) + w, and a phase's
/// warped point lies within radius + growth of moved + axis eps(z)
/// (NoiseBoundTests.AffineWarpStaysEnclosed). AffineBound reads its noise arguments from here.
/// </summary>
internal AffineSlice Slice(Tile t, float z0, float z1)
{
var tile = (FogTile)t;
var axisCamera = af_frustum_axis(tile.Slope, z0, z1);
var ball = af_frustum_ball(tile.Slope, z0, z1, tile.Footprint, 0.0f);
var centre = tile.World(new float3(ball.x, ball.y, ball.z));
var axis = tile.Right * axisCamera.x + tile.Up * axisCamera.y + tile.Forward * axisCamera.z;
var reach = length(axis) + ball.w;
var (c0, g0, c1, g1) = Noise.CentredWarp(centre, reach, tile.Time);
return new AffineSlice(axisCamera, ball, centre, axis, reach, c0, g0, c1, g1);
}
internal readonly record struct AffineSlice(float3 AxisCamera, float4 Ball, float3 Centre, float3 Axis, float Reach, float3 Centre0, float Grow0, float3 Centre1, float Grow1);
// The best interval any centre value and gradient could give af_snoise over a set of reach R (the
// gradient 0, the value at the far end), toward low or high values: the affine gate.
private static float2 BestForm(float reach, bool low)
@ -1548,12 +1632,7 @@ public sealed class NoiseBoundTests
/// </summary>
private float2 AffineBound(FogTile tile, float z0, float z1, float2 height, float2 s, float2 fade, float2 envelope, bool gated, Counts counts)
{
var axisCamera = af_frustum_axis(tile.Slope, z0, z1);
var ball = af_frustum_ball(tile.Slope, z0, z1, tile.Footprint, 0.0f);
var centre = tile.World(new float3(ball.x, ball.y, ball.z));
var axis = tile.Right * axisCamera.x + tile.Up * axisCamera.y + tile.Forward * axisCamera.z;
var reach = length(axis) + ball.w;
var (c0, g0, c1, g1) = Noise.CentredWarp(centre, reach, tile.Time);
var (axisCamera, ball, centre, axis, reach, c0, g0, c1, g1) = Slice(tile, z0, z1);
var coverageFrequency = Noise.F0;
var detailFrequency = 4.0f * Noise.F0;
if (gated)