Pins LodStep's footprint branch, which only bites at LOD-far. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3009 lines
165 KiB
C#
3009 lines
165 KiB
C#
using CultMath;
|
|
using Xunit;
|
|
using static CultMath.math;
|
|
|
|
namespace CultMath.Tests;
|
|
|
|
/// <summary>
|
|
/// The noise bounds (design.md, "Intervals" and "Affine forms"): iv_snoise_ball and iv_fbm_ball enclose
|
|
/// their functions over a ball, a domain warp bounded by D is enclosed by enlarging the radius by D, or
|
|
/// by the centred warp, SNOISE_LIPSCHITZ, SNOISE2_LIPSCHITZ and SNOISE_HESSIAN have measured provenance, iv_frustum_ball encloses a screen tile's rays over a depth segment, the bound
|
|
/// composed from an analytic envelope and the noise ball encloses the density, and a tile march over
|
|
/// fields with authored empty space is measured against a dense one for the same transmittance. Lines
|
|
/// starting IV-REPORT carry the numbers the cut report quotes.
|
|
///
|
|
/// Hand mutations of iv_frustum_ball that TileBallEnclosesEveryRaySegment must kill: z0 for z1 in the
|
|
/// lateral term (z0 * footprintPerDepth); |m_c| for |(m_c, 1)| in the depth term; the warp dropped; the
|
|
/// rounding widening dropped (which the degenerate family's enclosure check alone must catch). Hand
|
|
/// mutations of the test's own fields and marches: w(z0) for w(z1) in VoidField.WeightBound must fail
|
|
/// WeightBoundEnclosesOneSignedNoise; CarveRadius for HollowRadius in the lo test of VoidField.Compose
|
|
/// and BodyDepth without |dir| must fail AnalyticBodyMatchesFineMarch; the footprint march's pre-body
|
|
/// integration dropped, or LodStep doubled, must fail IntervalSkipHalvesEvaluations's depth agreement; LodStep's
|
|
/// footprint z theta scaled x2 (or x4) must fail it at LOD-far.
|
|
/// </summary>
|
|
public sealed class NoiseBoundTests
|
|
{
|
|
private readonly ITestOutputHelper output;
|
|
|
|
public NoiseBoundTests(ITestOutputHelper output) => this.output = output;
|
|
|
|
private static float Uniform(System.Random random, float lo, float hi) => lo + (hi - lo) * random.NextSingle();
|
|
|
|
private static float LogUniform(System.Random random, float lo, float hi) =>
|
|
MathF.Exp(Uniform(random, MathF.Log(lo), MathF.Log(hi)));
|
|
|
|
private static float3 UnitVector(System.Random random)
|
|
{
|
|
float3 v;
|
|
do
|
|
{
|
|
v = new float3(Uniform(random, -1.0f, 1.0f), Uniform(random, -1.0f, 1.0f), Uniform(random, -1.0f, 1.0f));
|
|
}
|
|
while (dot(v, v) > 1.0f || dot(v, v) < 1.0e-4f);
|
|
|
|
return normalize(v);
|
|
}
|
|
|
|
// The first eight points of a ball are on its surface, where the bound is closest to binding; the
|
|
// rest are uniform in its volume.
|
|
private static float3 PointInBall(System.Random random, float3 centre, float radius, int index) =>
|
|
centre + UnitVector(random) * (index < 8 ? radius : radius * MathF.Cbrt(random.NextSingle()));
|
|
|
|
private static float3 RandomCentre(System.Random random) =>
|
|
new(Uniform(random, -20.0f, 20.0f), Uniform(random, -20.0f, 20.0f), Uniform(random, -20.0f, 20.0f));
|
|
|
|
private static bool Inside(float value, float2 bound) => value >= bound.x && value <= bound.y;
|
|
|
|
[Fact]
|
|
public void SnoiseBallEnclosesPoints()
|
|
{
|
|
var random = new System.Random(0xBA11);
|
|
for (var b = 0; b < 2000; b++)
|
|
{
|
|
var centre = RandomCentre(random);
|
|
var radius = LogUniform(random, 1.0e-3f, 1.0e3f);
|
|
var bound = iv_snoise_ball(centre, radius);
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var x = PointInBall(random, centre, radius, i);
|
|
Assert.True(Inside(snoise(x), bound), $"iv_snoise_ball({centre}, {radius:R}) = {bound} misses snoise({x}) = {snoise(x):R}");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void FbmBallEnclosesPoints()
|
|
{
|
|
var random = new System.Random(0xFB11);
|
|
for (var b = 0; b < 2000; b++)
|
|
{
|
|
var centre = RandomCentre(random);
|
|
var radius = LogUniform(random, 1.0e-3f, 1.0e3f);
|
|
var octaves = random.Next(0, 7);
|
|
var lacunarity = Uniform(random, 1.5f, 2.5f);
|
|
var gain = Uniform(random, 0.3f, 0.7f);
|
|
var bound = iv_fbm_ball(centre, radius, octaves, lacunarity, gain);
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var x = PointInBall(random, centre, radius, i);
|
|
var value = fbm_grad(x, octaves, lacunarity, gain).w;
|
|
Assert.True(Inside(value, bound), $"iv_fbm_ball({centre}, {radius:R}, {octaves}, {lacunarity:R}, {gain:R}) = {bound} misses {value:R} at {x}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The site's march rests on this: a point x in the ball (c, r), displaced by a flow of length at most
|
|
/// D, lies in the ball (c, r + D), so snoise of the warped point lies in iv_snoise_ball(c, r + D). The
|
|
/// geometric check allows one part in 1e5 for the rounding of x + flow.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WarpedPointsStayEnclosed()
|
|
{
|
|
var random = new System.Random(0x3A4B);
|
|
for (var b = 0; b < 2000; b++)
|
|
{
|
|
var centre = RandomCentre(random);
|
|
var radius = LogUniform(random, 1.0e-3f, 2.0f);
|
|
var warp = Uniform(random, 0.0f, 1.0f);
|
|
var bound = iv_snoise_ball(centre, radius + warp);
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var x = PointInBall(random, centre, radius, i);
|
|
var flow = UnitVector(random) * (warp * MathF.Sqrt(random.NextSingle()));
|
|
var warped = x + flow;
|
|
Assert.True(length(warped - centre) <= (radius + warp) * (1.0f + 1.0e-5f), $"warped point {warped} leaves the ball ({centre}, {radius + warp:R})");
|
|
Assert.True(Inside(snoise(warped), bound), $"iv_snoise_ball({centre}, {radius + warp:R}) = {bound} misses snoise({warped}) = {snoise(warped):R}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- The tile ball and the composed bound ----
|
|
|
|
// One tile case of TileBallEnclosesEveryRaySegment: the r2 domain, or one of the extreme families
|
|
// where float32 rounding of the centre is largest against the radius (Soul's verdict s1).
|
|
internal static (int N, float Focal, float2 Slope, float Z0, float Z1, float Warp) DrawBallCase(System.Random random, string family)
|
|
{
|
|
int[] sizes = { 1, 4, 8, 16 };
|
|
float[] depths = { 12.0f, 1000.0f, 2400.0f };
|
|
var n = sizes[random.Next(sizes.Length)];
|
|
var focal = Uniform(random, 500.0f, 3000.0f);
|
|
var slope = new float2(Uniform(random, -0.77f, 0.77f), Uniform(random, -0.77f, 0.77f));
|
|
var depth = depths[random.Next(depths.Length)];
|
|
var z0 = random.Next(4) == 0 ? 0.0f : Uniform(random, 0.0f, depth * 0.9f);
|
|
var z1 = z0 + LogUniform(random, 1.0e-3f * depth, depth - z0);
|
|
var warp = random.Next(4) == 0 ? 0.0f : LogUniform(random, 1.0e-3f, 60.0f);
|
|
float Signed(float magnitude) => random.Next(2) == 0 ? -magnitude : magnitude;
|
|
switch (family)
|
|
{
|
|
case "degenerate":
|
|
z0 = z1 = LogUniform(random, 1.0e-3f * depth, depth);
|
|
break;
|
|
case "thin":
|
|
z0 = z1 - 1.0e-6f * z1;
|
|
break;
|
|
case "wide slope":
|
|
slope = new float2(Signed(LogUniform(random, 1.0e-3f, 707.0f)), Signed(LogUniform(random, 1.0e-3f, 707.0f)));
|
|
break;
|
|
case "far":
|
|
z1 = LogUniform(random, 1.0e4f, 1.0e7f);
|
|
z0 = z1 - z1 * LogUniform(random, 1.0e-6f, 1.0f);
|
|
break;
|
|
case "from the camera":
|
|
z0 = 0.0f;
|
|
z1 = LogUniform(random, 1.0e-3f, 1.0f);
|
|
break;
|
|
case "tiny focal":
|
|
focal = LogUniform(random, 1.0f, 500.0f);
|
|
break;
|
|
case "huge warp":
|
|
warp = LogUniform(random, 60.0f, 1.0e6f);
|
|
break;
|
|
}
|
|
|
|
return (n, focal, slope, z0, z1, warp);
|
|
}
|
|
|
|
/// <summary>
|
|
/// iv_frustum_ball encloses every exact point of every ray of an N x N tile over a depth segment,
|
|
/// sub-pixel jitter included, each point then moved by a flow of length at most warp, with no
|
|
/// tolerance: the distance is taken in double from the float32 ball to the point (m z, z) formed in
|
|
/// double from the float32 slope and depth. 2,000 seeded tiles of the r2 domain (N in {1, 4, 8, 16},
|
|
/// f in [500, 3000], |m_c| up to 1.1, segments over the slab's, the fog's and the void's depth ranges,
|
|
/// a quarter starting at the camera) x 64 points, and 2,000 tiles of each extreme family (z0 == z1;
|
|
/// z1 - z0 = 1e-6 z1; |m_c| up to 1000; z up to 1e7; z0 = 0, z1 down to 1e-3; f down to 1; warp up to
|
|
/// 1e6) x the 8 points at the footprint's corners at both depth ends. Every corner point's flow is
|
|
/// full-length and points outward from the ball's centre. It also pins the ball to the derivation in
|
|
/// double, both ways: the centre within 2^-22 |c|_1, and r <= ball.w <= r + 2^-19 (r + |c|_1), so a
|
|
/// ball that forgets its rounding widening fails and one that grows past it fails. Misses and pin
|
|
/// failures are counted per family and reported together. Each tile's camera is drawn on its own, as a
|
|
/// moving camera's frames are: no ball, mask or probe result is carried from one to the next.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TileBallEnclosesEveryRaySegment()
|
|
{
|
|
var random = new System.Random(0x711E);
|
|
string[] families = { "r2 domain", "degenerate", "thin", "wide slope", "far", "from the camera", "tiny focal", "huge warp" };
|
|
var misses = new Dictionary<string, int>();
|
|
var pins = new Dictionary<string, int>();
|
|
var witness = new List<string>();
|
|
foreach (var family in families)
|
|
{
|
|
misses[family] = 0;
|
|
pins[family] = 0;
|
|
for (var t = 0; t < 2000; t++)
|
|
{
|
|
var (n, focal, slope, z0, z1, warp) = DrawBallCase(random, family);
|
|
var half = n / (2.0f * focal);
|
|
var footprint = n / (MathF.Sqrt(2.0f) * focal);
|
|
var ball = iv_frustum_ball(slope, z0, z1, footprint, warp);
|
|
var (cx, cy, cz, w) = ((double)ball.x, (double)ball.y, (double)ball.z, (double)ball.w);
|
|
|
|
var zm = (z0 + (double)z1) / 2.0;
|
|
var (ex, ey) = (slope.x * zm, slope.y * zm);
|
|
var c1 = Math.Abs(ex) + Math.Abs(ey) + Math.Abs(zm);
|
|
var radius = (z1 - (double)z0) / 2.0 * Math.Sqrt((double)slope.x * slope.x + (double)slope.y * slope.y + 1.0) + z1 * (double)footprint + warp;
|
|
var centreOff = Math.Abs(cx - ex) + Math.Abs(cy - ey) + Math.Abs(cz - zm);
|
|
if (!(centreOff <= Math.ScaleB(c1, -22) && radius <= w && w <= radius + Math.ScaleB(radius + c1, -19)))
|
|
{
|
|
pins[family]++;
|
|
if (witness.Count < 8)
|
|
witness.Add($"{family} pin: iv_frustum_ball({slope}, {z0:R}, {z1:R}, {footprint:R}, {warp:R}) = {ball}; derivation centre ({ex:R}, {ey:R}, {zm:R}), radius {radius:R}");
|
|
}
|
|
|
|
for (var i = 0; i < (family == "r2 domain" ? 64 : 8); i++)
|
|
{
|
|
var offset = i < 8
|
|
? new float2((i & 1) == 0 ? -half : half, (i & 2) == 0 ? -half : half)
|
|
: new float2(Uniform(random, -half, half), Uniform(random, -half, half));
|
|
var z = i < 8 ? ((i & 4) == 0 ? z0 : z1) : Uniform(random, z0, z1);
|
|
var m = slope + offset;
|
|
var (px, py, pz) = (m.x * (double)z, m.y * (double)z, (double)z);
|
|
double fx, fy, fz;
|
|
if (i < 8)
|
|
{
|
|
// Outward: along the corner's direction from the centre, full length.
|
|
var (dx, dy, dz) = (px - cx, py - cy, pz - cz);
|
|
var away = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
|
(fx, fy, fz) = away > 0.0 ? (dx / away * warp, dy / away * warp, dz / away * warp) : (warp, 0.0, 0.0);
|
|
}
|
|
else
|
|
{
|
|
var flow = UnitVector(random) * (warp * MathF.Sqrt(random.NextSingle()));
|
|
(fx, fy, fz) = (flow.x, flow.y, flow.z);
|
|
}
|
|
|
|
var (qx, qy, qz) = (px + fx - cx, py + fy - cy, pz + fz - cz);
|
|
var distance = Math.Sqrt(qx * qx + qy * qy + qz * qz);
|
|
if (!(distance <= w))
|
|
{
|
|
misses[family]++;
|
|
if (witness.Count < 8)
|
|
witness.Add($"{family}: iv_frustum_ball({slope}, {z0:R}, {z1:R}, {footprint:R}, {warp:R}) = {ball} misses slope {m}, depth {z:R} by {(distance - w) / w:R} r");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.True(misses.Values.Sum() + pins.Values.Sum() == 0,
|
|
"misses " + string.Join(", ", families.Select(f => $"{f} {misses[f]}")) + "; pin failures " + string.Join(", ", families.Select(f => $"{f} {pins[f]}"))
|
|
+ Environment.NewLine + string.Join(Environment.NewLine, witness));
|
|
}
|
|
|
|
/// <summary>
|
|
/// intervals-enclose for the composition the site and Aetheria use: over 2,000 seeded tile slices
|
|
/// (the deep well, the shallow wells and inside the fog, each under the centred-warp interval bound
|
|
/// and the affine bound; the void at every grid point, the shipped void and LOD-far; N in
|
|
/// {1, 4, 8, 16}; a camera drawn per tile) the bound composed from the envelope over the slice's box,
|
|
/// its fade, and the noise enclosure encloses the density at 64 points of the slice, each sampled
|
|
/// through the field's own warp, with no tolerance. The void's footprint-truncated density (octave weights) is checked
|
|
/// against the same bound; a quarter of the slices are LOD-far's, where the weights are strictly
|
|
/// between 0 and 1 and at 0, and the test asserts it sampled both.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EnvelopeBoundEnclosesDensity()
|
|
{
|
|
var random = new System.Random(0xE7B0);
|
|
int[] sizes = { 1, 4, 8, 16 };
|
|
var fog = new List<Scenario>();
|
|
foreach (var mode in new[] { BoundMode.Interval, BoundMode.Affine })
|
|
{
|
|
fog.Add(FogField.DeepWell(FogField.AetheriaWarp, mode));
|
|
fog.Add(FogField.Wells(FogField.AetheriaWarp, mode));
|
|
fog.Add(FogField.InsideFog(FogField.AetheriaWarp, mode));
|
|
}
|
|
|
|
var fields = new List<Scenario>();
|
|
foreach (var rh in VoidField.HollowRadii)
|
|
foreach (var offset in VoidField.CameraOffsets)
|
|
foreach (var ramp in VoidField.Ramps)
|
|
fields.Add(new VoidField(rh, ramp, offset.Fraction, offset.Name));
|
|
fields.Add(VoidField.Shipped());
|
|
fields.Add(VoidField.LodFar());
|
|
var counts = new Counts();
|
|
var (partial, zero) = (0, 0);
|
|
for (var t = 0; t < 2000; t++)
|
|
{
|
|
var field = t % 2 == 0 ? fog[(t / 2) % fog.Count] : t % 4 == 1 ? fields[^1] : fields[random.Next(fields.Count)];
|
|
var tile = field.DrawTile(random, sizes[random.Next(sizes.Length)]);
|
|
var near = field.Grid[0];
|
|
var far = field.Grid[^1];
|
|
var z0 = Uniform(random, near, far * 0.9f);
|
|
var z1 = z0 + LogUniform(random, 1.0e-3f * far, far - z0);
|
|
var bound = field.Bound(tile, z0, z1, gated: false, counts);
|
|
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 density = field.Density(tile, m, z, lod: false, counts);
|
|
Assert.True(Inside(density, bound), $"{field.Name}: bound {bound} over [{z0:R}, {z1:R}] misses density {density:R} at slope {m}, depth {z:R}");
|
|
if (field is VoidField voidField)
|
|
{
|
|
var (wc, wd) = voidField.Weights(z);
|
|
partial += (wc > 0.0f && wc < 1.0f) || (wd > 0.0f && wd < 1.0f) ? 1 : 0;
|
|
zero += wd == 0.0f ? 1 : 0;
|
|
var truncated = field.Density(tile, m, z, lod: true, counts);
|
|
Assert.True(Inside(truncated, bound), $"{field.Name}: bound {bound} misses the truncated density {truncated:R} at slope {m}, depth {z:R}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// The truncated check is not vacuous: some samples carry an octave weight strictly inside (0, 1)
|
|
// and some a fine weight of 0 (LOD-far reaches both).
|
|
Assert.True(partial > 0 && zero > 0, $"octave weights in (0, 1) at {partial} samples and fine weight 0 at {zero}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// VoidField's octave weight bound over [z0, z1] encloses each octave's weight at every depth of the
|
|
/// segment, and, for a one-signed noise interval n = [0.25, 1], the weighted noise w(z) n_s lies in
|
|
/// iv_mul(bound, n). 2,000 LOD-far segments with z0 ~ U(1460, 0.95 grid end), z1 ~ U(z0, grid end),
|
|
/// 16 depths each including both ends. A bound whose lower end is w(z0) misses w(z1) 0.25 wherever the
|
|
/// weight falls across the segment.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WeightBoundEnclosesOneSignedNoise()
|
|
{
|
|
var field = VoidField.LodFar();
|
|
var random = new System.Random(0x3B0D);
|
|
var end = field.Grid[^1];
|
|
var n = new float2(0.25f, 1.0f);
|
|
for (var i = 0; i < 2000; i++)
|
|
{
|
|
var z0 = Uniform(random, 1460.0f, 0.95f * end);
|
|
var z1 = Uniform(random, z0, end);
|
|
var (coarseBound, fineBound) = field.WeightBounds(z0, z1);
|
|
for (var s = 0; s < 16; s++)
|
|
{
|
|
var z = s == 0 ? z0 : s == 15 ? z1 : Uniform(random, z0, z1);
|
|
var (coarse, fine) = field.Weights(z);
|
|
foreach (var (octave, w, bound) in new[] { ("coarse", coarse, coarseBound), ("fine", fine, fineBound) })
|
|
{
|
|
Assert.True(Inside(w, bound), $"{octave} weight {w:R} at depth {z:R} lies outside its bound {bound} over [{z0:R}, {z1:R}]");
|
|
var weighted = iv_mul(bound, n);
|
|
foreach (var ns in new[] { 0.25f, Uniform(random, 0.25f, 1.0f), 1.0f })
|
|
Assert.True(Inside(w * ns, weighted), $"{octave} weight {w:R} x noise {ns:R} at depth {z:R} lies outside iv_mul({bound}, {n}) = {weighted} over [{z0:R}, {z1:R}]");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- The Lipschitz constant ----
|
|
|
|
private static float GradientNorm(float3 p)
|
|
{
|
|
var g = snoise_grad(p);
|
|
return MathF.Sqrt(g.x * g.x + g.y * g.y + g.z * g.z);
|
|
}
|
|
|
|
// Hill-climbs |snoise_grad| from p: the ascent direction is the central difference of |grad|, the
|
|
// step grows on success and halves on failure. Returns the largest |grad| it reached.
|
|
private static float AscendGradientNorm(float3 p)
|
|
{
|
|
const float h = 1.0e-3f;
|
|
var step = 1.0e-2f;
|
|
var best = GradientNorm(p);
|
|
for (var k = 0; k < 200 && step > 1.0e-6f; k++)
|
|
{
|
|
var d = new float3(
|
|
GradientNorm(p + new float3(h, 0.0f, 0.0f)) - GradientNorm(p - new float3(h, 0.0f, 0.0f)),
|
|
GradientNorm(p + new float3(0.0f, h, 0.0f)) - GradientNorm(p - new float3(0.0f, h, 0.0f)),
|
|
GradientNorm(p + new float3(0.0f, 0.0f, h)) - GradientNorm(p - new float3(0.0f, 0.0f, h)));
|
|
if (dot(d, d) == 0.0f)
|
|
break;
|
|
var q = p + normalize(d) * step;
|
|
var value = GradientNorm(q);
|
|
if (value > best)
|
|
{
|
|
p = q;
|
|
best = value;
|
|
step *= 1.5f;
|
|
}
|
|
else
|
|
{
|
|
step *= 0.5f;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
// Same climb on |snoise|, along the value's own analytic gradient.
|
|
private static float AscendAbsValue(float3 p)
|
|
{
|
|
var step = 1.0e-2f;
|
|
var best = MathF.Abs(snoise(p));
|
|
for (var k = 0; k < 200 && step > 1.0e-6f; k++)
|
|
{
|
|
var g = snoise_grad(p);
|
|
var d = new float3(g.x, g.y, g.z) * (g.w < 0.0f ? -1.0f : 1.0f);
|
|
if (dot(d, d) == 0.0f)
|
|
break;
|
|
var q = p + normalize(d) * step;
|
|
var value = MathF.Abs(snoise(q));
|
|
if (value > best)
|
|
{
|
|
p = q;
|
|
best = value;
|
|
step *= 1.5f;
|
|
}
|
|
else
|
|
{
|
|
step *= 0.5f;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
// The largest |snoise_grad| over `samples` seeded points in [-256, 256]^3, the largest after refining
|
|
// the `refine` largest of them by ascent, and the start whose ascent reached it (the witness).
|
|
private static (float Sampled, float Refined, float3 Witness) MeasureGradient(int seed, int samples, int refine)
|
|
{
|
|
var random = new System.Random(seed);
|
|
var points = new float3[samples];
|
|
var norms = new float[samples];
|
|
for (var i = 0; i < samples; i++)
|
|
{
|
|
points[i] = new float3(Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f));
|
|
norms[i] = GradientNorm(points[i]);
|
|
}
|
|
|
|
var order = Enumerable.Range(0, samples).OrderByDescending(i => norms[i]).Take(Math.Max(refine, 1)).ToArray();
|
|
var refined = norms[order[0]];
|
|
var witness = points[order[0]];
|
|
foreach (var i in order.Take(refine))
|
|
{
|
|
var value = AscendGradientNorm(points[i]);
|
|
if (value > refined)
|
|
{
|
|
refined = value;
|
|
witness = points[i];
|
|
}
|
|
}
|
|
|
|
return (norms[order[0]], refined, witness);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The provenance of SNOISE_LIPSCHITZ: the largest |snoise_grad| over 1e6 seeded points, the 1e4
|
|
/// largest refined by gradient ascent, times 1.10. Prints the start whose ascent reached the maximum,
|
|
/// which LipschitzConstantPinsSampledGradients climbs from again. Also prints the largest |snoise|
|
|
/// reached the same way, which is what lets iv_snoise_ball intersect with [-1, 1]. Slow; run
|
|
/// explicitly after any change to the snoise kernel and re-pin the constant and the witness.
|
|
/// </summary>
|
|
[Fact(Explicit = true)]
|
|
[Trait("Category", "Slow")]
|
|
public void MeasureLipschitz()
|
|
{
|
|
var (sampled, refined, witness) = MeasureGradient(0x11B5, 1_000_000, 10_000);
|
|
var random = new System.Random(0xAB5);
|
|
var valueStarts = Enumerable.Range(0, 10_000)
|
|
.Select(_ => new float3(Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f)));
|
|
var maxValue = valueStarts.Max(AscendAbsValue);
|
|
output.WriteLine($"IV-REPORT lipschitz: max |snoise_grad| sampled {sampled:R}, refined {refined:R}, x 1.10 = {refined * 1.10f:R}, witness ({witness.x:R}, {witness.y:R}, {witness.z:R}); max |snoise| refined {maxValue:R}");
|
|
Assert.True(refined <= SNOISE_LIPSCHITZ, $"measured {refined:R} exceeds SNOISE_LIPSCHITZ {SNOISE_LIPSCHITZ:R}");
|
|
Assert.True(maxValue < 1.0f, $"|snoise| reaches {maxValue:R}; iv_snoise_ball may not intersect with [-1, 1]");
|
|
}
|
|
|
|
// The start MeasureLipschitz printed as its witness.
|
|
private static readonly float3 LipschitzWitness = new(229.92526f, 73.37927f, -66.14595f);
|
|
|
|
/// <summary>
|
|
/// Pins SNOISE_LIPSCHITZ: no |snoise_grad| over 1e5 seeded points exceeds it, and the ascent from
|
|
/// MeasureLipschitz's witness reaches a maximum that, times 1.10, equals the constant bit for bit
|
|
/// (the expression MeasureLipschitz prints), so any moved constant fails here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LipschitzConstantPinsSampledGradients()
|
|
{
|
|
var (sampled, _, _) = MeasureGradient(0x11B6, 100_000, 0);
|
|
Assert.True(sampled <= SNOISE_LIPSCHITZ, $"|snoise_grad| reaches {sampled:R}, above SNOISE_LIPSCHITZ {SNOISE_LIPSCHITZ:R}");
|
|
var refined = AscendGradientNorm(LipschitzWitness);
|
|
Assert.True(refined <= SNOISE_LIPSCHITZ, $"|snoise_grad| reaches {refined:R}, above SNOISE_LIPSCHITZ {SNOISE_LIPSCHITZ:R}");
|
|
var measured = 1.10f * refined;
|
|
Assert.True(SNOISE_LIPSCHITZ == measured, $"SNOISE_LIPSCHITZ is {SNOISE_LIPSCHITZ:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureLipschitz and re-pin");
|
|
}
|
|
|
|
// ---- The curvature and 2D Lipschitz constants (math.Affine.cs, math.Interval.cs) ----
|
|
|
|
// Central differences over exact float32 steps: the step actually taken is (p + h) - p in float32, so
|
|
// the quotient is not skewed by the rounding of p + h far from the origin.
|
|
private static double Step(float x, float h, out double back) { back = x - (double)(x - h); return (x + h) - (double)x; }
|
|
|
|
// The spectral norm (largest absolute eigenvalue) of snoise's Hessian at p: the library's own
|
|
// snoise_hessian, symmetric bit for bit, then the closed-form eigenvalues of a symmetric 3x3.
|
|
private static double HessianNorm(float3 p)
|
|
{
|
|
var m = snoise_hessian(p);
|
|
var (a00, a11, a22) = ((double)m._m00, (double)m._m11, (double)m._m22);
|
|
var (a01, a02, a12) = ((double)m._m01, (double)m._m02, (double)m._m12);
|
|
var p1 = a01 * a01 + a02 * a02 + a12 * a12;
|
|
if (p1 == 0.0)
|
|
return Math.Max(Math.Abs(a00), Math.Max(Math.Abs(a11), Math.Abs(a22)));
|
|
var q = (a00 + a11 + a22) / 3;
|
|
var p2 = (a00 - q) * (a00 - q) + (a11 - q) * (a11 - q) + (a22 - q) * (a22 - q) + 2 * p1;
|
|
var s = Math.Sqrt(p2 / 6);
|
|
var (b00, b11, b22, b01, b02, b12) = ((a00 - q) / s, (a11 - q) / s, (a22 - q) / s, a01 / s, a02 / s, a12 / s);
|
|
var r = Math.Clamp((b00 * (b11 * b22 - b12 * b12) - b01 * (b01 * b22 - b12 * b02) + b02 * (b01 * b12 - b11 * b02)) / 2, -1.0, 1.0);
|
|
var phi = Math.Acos(r) / 3;
|
|
var largest = q + 2 * s * Math.Cos(phi);
|
|
var smallest = q + 2 * s * Math.Cos(phi + 2 * Math.PI / 3);
|
|
return Math.Max(Math.Abs(largest), Math.Abs(smallest));
|
|
}
|
|
|
|
// The gradient length of snoise(float2) at p, from central differences at h = 1e-3.
|
|
private static double Gradient2Norm(float2 p)
|
|
{
|
|
const float h = 1.0e-3f;
|
|
var fx = Step(p.x, h, out var bx);
|
|
var fy = Step(p.y, h, out var by);
|
|
var gx = (snoise(p + new float2(h, 0.0f)) - (double)snoise(p - new float2(h, 0.0f))) / (fx + bx);
|
|
var gy = (snoise(p + new float2(0.0f, h)) - (double)snoise(p - new float2(0.0f, h))) / (fy + by);
|
|
return Math.Sqrt(gx * gx + gy * gy);
|
|
}
|
|
|
|
// Hill-climbs f from p as AscendGradientNorm does: the ascent direction is f's central difference,
|
|
// the step grows on success and halves on failure. Returns the largest value reached.
|
|
private static double Ascend(Func<float3, double> f, float3 p) => AscendPoint(f, p).Best;
|
|
|
|
private static (double Best, float3 At) AscendPoint(Func<float3, double> f, float3 p)
|
|
{
|
|
const float h = 1.0e-3f;
|
|
var step = 1.0e-2f;
|
|
var best = f(p);
|
|
for (var k = 0; k < 200 && step > 1.0e-6f; k++)
|
|
{
|
|
var d = new float3(
|
|
(float)(f(p + new float3(h, 0.0f, 0.0f)) - f(p - new float3(h, 0.0f, 0.0f))),
|
|
(float)(f(p + new float3(0.0f, h, 0.0f)) - f(p - new float3(0.0f, h, 0.0f))),
|
|
(float)(f(p + new float3(0.0f, 0.0f, h)) - f(p - new float3(0.0f, 0.0f, h))));
|
|
if (dot(d, d) == 0.0f)
|
|
break;
|
|
var q = p + normalize(d) * step;
|
|
var value = f(q);
|
|
if (value > best)
|
|
{
|
|
p = q;
|
|
best = value;
|
|
step *= 1.5f;
|
|
}
|
|
else
|
|
{
|
|
step *= 0.5f;
|
|
}
|
|
}
|
|
|
|
return (best, p);
|
|
}
|
|
|
|
private static double Ascend2(Func<float2, double> f, float2 p) => Ascend(q => f(new float2(q.x, q.y)), new float3(p.x, p.y, 0.0f));
|
|
|
|
// The largest f over `samples` seeded points of [-256, 256]^dims, the largest after refining the
|
|
// `refine` largest by ascent, and the start whose ascent reached it (the witness).
|
|
private static (double Sampled, double Refined, float3 Witness) MeasureMax(Func<float3, double> f, bool planar, int seed, int samples, int refine)
|
|
{
|
|
var random = new System.Random(seed);
|
|
var points = new float3[samples];
|
|
var values = new double[samples];
|
|
for (var i = 0; i < samples; i++)
|
|
{
|
|
points[i] = new float3(Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f), planar ? 0.0f : Uniform(random, -256.0f, 256.0f));
|
|
values[i] = f(points[i]);
|
|
}
|
|
|
|
var order = Enumerable.Range(0, samples).OrderByDescending(i => values[i]).Take(Math.Max(refine, 1)).ToArray();
|
|
var refined = values[order[0]];
|
|
var witness = points[order[0]];
|
|
foreach (var i in order.Take(refine))
|
|
{
|
|
var value = planar ? Ascend2(q => f(new float3(q.x, q.y, 0.0f)), new float2(points[i].x, points[i].y)) : Ascend(f, points[i]);
|
|
if (value > refined)
|
|
{
|
|
refined = value;
|
|
witness = points[i];
|
|
}
|
|
}
|
|
|
|
return (values[order[0]], refined, witness);
|
|
}
|
|
|
|
private static double Gradient2At(float3 p) => Gradient2Norm(new float2(p.x, p.y));
|
|
|
|
/// <summary>
|
|
/// The provenance of SNOISE_HESSIAN: the largest spectral norm of snoise's Hessian (HessianNorm) over
|
|
/// 1e6 seeded points of [-256, 256]^3, the 1e4 largest refined by ascent, times 1.10. Prints the
|
|
/// start whose ascent reached the maximum, which HessianConstantPinsSampledCurvature climbs from
|
|
/// again. Slow; run explicitly after any change to the snoise kernel and re-pin the constant and the
|
|
/// witness.
|
|
/// </summary>
|
|
[Fact(Explicit = true)]
|
|
[Trait("Category", "Slow")]
|
|
public void MeasureHessian()
|
|
{
|
|
var (sampled, refined, witness) = MeasureMax(HessianNorm, false, 0x4E55, 1_000_000, 10_000);
|
|
output.WriteLine($"IV-REPORT hessian: max |H| sampled {sampled:R}, refined {refined:R}, x 1.10 = {(float)(refined * 1.10):R}, witness ({witness.x:R}, {witness.y:R}, {witness.z:R})");
|
|
Assert.True(refined <= SNOISE_HESSIAN, $"measured {refined:R} exceeds SNOISE_HESSIAN {SNOISE_HESSIAN:R}");
|
|
}
|
|
|
|
// The start MeasureHessian printed as its witness.
|
|
private static readonly float3 HessianWitness = new(-13.7920685f, -9.672134f, 61.563934f);
|
|
|
|
/// <summary>
|
|
/// Pins SNOISE_HESSIAN: no Hessian norm over 1e5 seeded points exceeds it, and the ascent from
|
|
/// MeasureHessian's witness reaches a maximum that, times 1.10, equals the constant bit for bit
|
|
/// (the expression MeasureHessian prints), so any moved constant fails here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HessianConstantPinsSampledCurvature()
|
|
{
|
|
var (sampled, _, _) = MeasureMax(HessianNorm, false, 0x4E56, 100_000, 0);
|
|
Assert.True(sampled <= SNOISE_HESSIAN, $"the Hessian norm reaches {sampled:R}, above SNOISE_HESSIAN {SNOISE_HESSIAN:R}");
|
|
var refined = Ascend(HessianNorm, HessianWitness);
|
|
Assert.True(refined <= SNOISE_HESSIAN, $"the Hessian norm reaches {refined:R}, above SNOISE_HESSIAN {SNOISE_HESSIAN:R}");
|
|
var measured = (float)(refined * 1.10);
|
|
Assert.True(SNOISE_HESSIAN == measured, $"SNOISE_HESSIAN is {SNOISE_HESSIAN:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureHessian and re-pin");
|
|
}
|
|
|
|
// ---- The third-derivative constant (math.Span.cs) ----
|
|
|
|
private static float3 CultMod289(float3 v) => v - floor(v * (1.0f / 289.0f)) * 289.0f;
|
|
private static float4 CultMod289(float4 v) => v - floor(v * (1.0f / 289.0f)) * 289.0f;
|
|
private static float4 CultPermute(float4 v) => CultMod289(((v * 34.0f) + 10.0f) * v);
|
|
|
|
// The four simplex corners of snoise at (vx, vy, vz): each corner's offset x_c from the point, and its
|
|
// gradient direction p_c, in double, with snoise_hessian's lattice, step() tie order and (float) hash,
|
|
// so one corner table serves the Hessian and its derivative. Key names the lattice cell and traversal
|
|
// order: two points with the same key are differentiated through the same corners.
|
|
internal static (double[][] X, double[][] P, double[] Key) Corners(double vx, double vy, double vz)
|
|
{
|
|
const double cx = 1.0 / 6.0;
|
|
const double cy = 1.0 / 3.0;
|
|
var skew = (vx + vy + vz) * cy;
|
|
double ix = Math.Floor(vx + skew), iy = Math.Floor(vy + skew), iz = Math.Floor(vz + skew);
|
|
var unskew = (ix + iy + iz) * cx;
|
|
double x0 = vx - ix + unskew, y0 = vy - iy + unskew, z0 = vz - iz + unskew;
|
|
double gx = x0 >= y0 ? 1.0 : 0.0, gy = y0 >= z0 ? 1.0 : 0.0, gz = 1.0 - (x0 >= z0 ? 1.0 : 0.0);
|
|
double lx = 1.0 - gx, ly = 1.0 - gy, lz = 1.0 - gz;
|
|
double i1x = Math.Min(gx, lz), i1y = Math.Min(gy, lx), i1z = Math.Min(gz, ly);
|
|
double i2x = Math.Max(gx, lz), i2y = Math.Max(gy, lx), i2z = Math.Max(gz, ly);
|
|
var x = new[]
|
|
{
|
|
new[] { x0, y0, z0 },
|
|
new[] { x0 - i1x + cx, y0 - i1y + cx, z0 - i1z + cx },
|
|
new[] { x0 - i2x + cy, y0 - i2y + cy, z0 - i2z + cy },
|
|
new[] { x0 - 0.5, y0 - 0.5, z0 - 0.5 },
|
|
};
|
|
|
|
var i = CultMod289(new float3((float)ix, (float)iy, (float)iz));
|
|
var hash = CultPermute(CultPermute(CultPermute(
|
|
i.z + new float4(0.0f, (float)i1z, (float)i2z, 1.0f)) + i.y + new float4(0.0f, (float)i1y, (float)i2y, 1.0f)) + i.x + new float4(0.0f, (float)i1x, (float)i2x, 1.0f));
|
|
const float n = 0.142857142857f;
|
|
var ns = new float3(2.0f * n, 0.5f * n - 1.0f, n);
|
|
var j = hash - 49.0f * floor(hash * ns.z * ns.z);
|
|
var xs = floor(j * ns.z);
|
|
var ys = floor(j - 7.0f * xs);
|
|
var gxs = xs * ns.x + ns.y;
|
|
var gys = ys * ns.x + ns.y;
|
|
var h = 1.0f - abs(gxs) - abs(gys);
|
|
var b0 = new float4(gxs.x, gxs.y, gys.x, gys.y);
|
|
var b1 = new float4(gxs.z, gxs.w, gys.z, gys.w);
|
|
var s0 = floor(b0) * 2.0f + 1.0f;
|
|
var s1 = floor(b1) * 2.0f + 1.0f;
|
|
var sh = -step(h, 0.0f);
|
|
var a0 = new float4(b0.x, b0.z, b0.y, b0.w) + new float4(s0.x, s0.z, s0.y, s0.w) * new float4(sh.x, sh.x, sh.y, sh.y);
|
|
var a1 = new float4(b1.x, b1.z, b1.y, b1.w) + new float4(s1.x, s1.z, s1.y, s1.w) * new float4(sh.z, sh.z, sh.w, sh.w);
|
|
var p0 = new float3(a0.x, a0.y, h.x);
|
|
var p1 = new float3(a0.z, a0.w, h.y);
|
|
var p2 = new float3(a1.x, a1.y, h.z);
|
|
var p3 = new float3(a1.z, a1.w, h.w);
|
|
var norm = 1.79284291400159f - 0.85373472095314f * new float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
|
p0 *= norm.x;
|
|
p1 *= norm.y;
|
|
p2 *= norm.z;
|
|
p3 *= norm.w;
|
|
double[] Of(float3 v) => new double[] { v.x, v.y, v.z };
|
|
return (x, new[] { Of(p0), Of(p1), Of(p2), Of(p3) }, new[] { ix, iy, iz, i1x, i1y, i1z, i2x, i2y, i2z });
|
|
}
|
|
|
|
// The Hessian of snoise at v in double, entry i * 3 + j: snoise_hessian's per-corner derivation, summed
|
|
// over Corners. A corner with m = 0.5 - |x|^2 <= 0 contributes nothing.
|
|
internal static double[] HessianDouble(double vx, double vy, double vz)
|
|
{
|
|
var (x, p, _) = Corners(vx, vy, vz);
|
|
var hessian = new double[9];
|
|
for (var c = 0; c < 4; c++)
|
|
{
|
|
var m = 0.5 - (x[c][0] * x[c][0] + x[c][1] * x[c][1] + x[c][2] * x[c][2]);
|
|
if (m <= 0.0)
|
|
continue;
|
|
var s = p[c][0] * x[c][0] + p[c][1] * x[c][1] + p[c][2] * x[c][2];
|
|
for (var i = 0; i < 3; i++)
|
|
for (var k = 0; k < 3; k++)
|
|
hessian[i * 3 + k] += 105.0 * (48.0 * m * m * s * x[c][i] * x[c][k]
|
|
- 8.0 * m * m * m * (p[c][k] * x[c][i] + p[c][i] * x[c][k] + (i == k ? s : 0.0)));
|
|
}
|
|
|
|
return hessian;
|
|
}
|
|
|
|
// The value of snoise at v in double: 105 sum m^4 s over Corners, the kernel's exact value (the tied
|
|
// kernel, as HessianDouble is its exact Hessian).
|
|
internal static double ValueDouble(double vx, double vy, double vz)
|
|
{
|
|
var (x, p, _) = Corners(vx, vy, vz);
|
|
var total = 0.0;
|
|
for (var c = 0; c < 4; c++)
|
|
{
|
|
var m = 0.5 - (x[c][0] * x[c][0] + x[c][1] * x[c][1] + x[c][2] * x[c][2]);
|
|
if (m <= 0.0)
|
|
continue;
|
|
total += 105.0 * m * m * m * m * (p[c][0] * x[c][0] + p[c][1] * x[c][1] + p[c][2] * x[c][2]);
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
// The gradient of snoise at v in double: 105 sum (m^4 p - 8 m^3 s x) over Corners, the derivative of
|
|
// ValueDouble (every corner's x moves one for one with v, dm = -2 x, ds = p).
|
|
internal static double[] GradDouble(double vx, double vy, double vz)
|
|
{
|
|
var (x, p, _) = Corners(vx, vy, vz);
|
|
var gradient = new double[3];
|
|
for (var c = 0; c < 4; c++)
|
|
{
|
|
var m = 0.5 - (x[c][0] * x[c][0] + x[c][1] * x[c][1] + x[c][2] * x[c][2]);
|
|
if (m <= 0.0)
|
|
continue;
|
|
var s = p[c][0] * x[c][0] + p[c][1] * x[c][1] + p[c][2] * x[c][2];
|
|
for (var i = 0; i < 3; i++)
|
|
gradient[i] += 105.0 * (m * m * m * m * p[c][i] - 8.0 * m * m * m * s * x[c][i]);
|
|
}
|
|
|
|
return gradient;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GradDouble is the kernel's gradient: within float rounding of snoise_grad (L1 distance at most 2e-4)
|
|
/// at 300 seeded points of [-20, 20]^3, the way ExactReferenceIsTheKernel pins ValueDouble.
|
|
/// </summary>
|
|
[Fact]
|
|
public void GradDoubleIsTheKernelsGradient()
|
|
{
|
|
var random = new System.Random(0x6BAD);
|
|
double Coordinate() => random.NextDouble() * 40.0 - 20.0;
|
|
for (var k = 0; k < 300; k++)
|
|
{
|
|
var p = new float3((float)Coordinate(), (float)Coordinate(), (float)Coordinate());
|
|
var exact = GradDouble(p.x, p.y, p.z);
|
|
var kernel = snoise_grad(p);
|
|
var distance = Math.Abs(exact[0] - kernel.x) + Math.Abs(exact[1] - kernel.y) + Math.Abs(exact[2] - kernel.z);
|
|
Assert.True(distance <= 2.0e-4, $"GradDouble({p}) is {exact[0]:R}, {exact[1]:R}, {exact[2]:R}, snoise_grad {kernel.x:R}, {kernel.y:R}, {kernel.z:R}");
|
|
}
|
|
}
|
|
|
|
private static double Delta(int a, int b) => a == b ? 1.0 : 0.0;
|
|
|
|
// The symmetric third-derivative tensor of snoise at p, entry (i * 3 + j) * 3 + k, in closed form:
|
|
// snoise_hessian's per-corner entry 105 (48 m^2 s x_i x_j - 8 m^3 (p_j x_i + p_i x_j + s delta_ij))
|
|
// differentiated by x_k (dm = -2 x_k, ds = p_k, every corner's x moves one for one with p), which sums
|
|
// to 105 (-192 m s x_i x_j x_k + 48 m^2 (p_k x_i x_j + p_j x_i x_k + p_i x_j x_k)
|
|
// + 48 m^2 s (delta_ik x_j + delta_jk x_i + delta_ij x_k) - 8 m^3 (p_j delta_ik + p_i delta_jk + p_k delta_ij)).
|
|
// Symmetric in i, j and k by construction, so there is no symmetrising pass.
|
|
private static double[] ThirdTensor(float3 p)
|
|
{
|
|
var (x, g, _) = Corners(p.x, p.y, p.z);
|
|
var t = new double[27];
|
|
for (var c = 0; c < 4; c++)
|
|
{
|
|
var m = 0.5 - (x[c][0] * x[c][0] + x[c][1] * x[c][1] + x[c][2] * x[c][2]);
|
|
if (m <= 0.0)
|
|
continue;
|
|
var s = g[c][0] * x[c][0] + g[c][1] * x[c][1] + g[c][2] * x[c][2];
|
|
for (var i = 0; i < 3; i++)
|
|
for (var j = 0; j < 3; j++)
|
|
for (var k = 0; k < 3; k++)
|
|
t[(i * 3 + j) * 3 + k] += 105.0 * (-192.0 * m * s * x[c][i] * x[c][j] * x[c][k]
|
|
+ 48.0 * m * m * (g[c][k] * x[c][i] * x[c][j] + g[c][j] * x[c][i] * x[c][k] + g[c][i] * x[c][j] * x[c][k])
|
|
+ 48.0 * m * m * s * (Delta(i, k) * x[c][j] + Delta(j, k) * x[c][i] + Delta(i, j) * x[c][k])
|
|
- 8.0 * m * m * m * (g[c][j] * Delta(i, k) + g[c][i] * Delta(j, k) + g[c][k] * Delta(i, j)));
|
|
}
|
|
|
|
return t;
|
|
}
|
|
|
|
/// <summary>
|
|
/// ThirdTensor is the third derivative of snoise: HessianDouble is the kernel's Hessian (within float
|
|
/// rounding of snoise_hessian over 200 seeded points, which proves Corners is the kernel's lattice),
|
|
/// and the closed form equals central differences of HessianDouble, Richardson-extrapolated from steps
|
|
/// h = 1e-3 and 2e-3 (error O(h^4)), at every one of 27 entries of every point whose neighbours share
|
|
/// its lattice cell and clear every corner's support edge. Dropping or mis-signing any term of the closed form moves some entry by far more
|
|
/// than the tolerance.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ThirdTensorMatchesDifferencesOfTheHessian()
|
|
{
|
|
var random = new System.Random(0x7D1);
|
|
var compared = 0;
|
|
var largest = 0.0;
|
|
var worst = 0.0;
|
|
for (var n = 0; n < 400; n++)
|
|
{
|
|
var p = new float3(Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f));
|
|
var hd = HessianDouble(p.x, p.y, p.z);
|
|
var kernel = snoise_hessian(p);
|
|
if (n < 200)
|
|
for (var i = 0; i < 3; i++)
|
|
for (var j = 0; j < 3; j++)
|
|
Assert.True(Math.Abs(hd[i * 3 + j] - kernel[i][j]) <= 0.1, $"HessianDouble({p}) entry {i},{j} is {hd[i * 3 + j]:R}, snoise_hessian {kernel[i][j]:R}");
|
|
|
|
double[] At(int axis, double delta) =>
|
|
HessianDouble(p.x + (axis == 0 ? delta : 0.0), p.y + (axis == 1 ? delta : 0.0), p.z + (axis == 2 ? delta : 0.0));
|
|
var (corner, _, key) = Corners(p.x, p.y, p.z);
|
|
// A corner whose support edge (m = 0) lies within the differencing window has a kink in the
|
|
// Hessian's second derivative there, which the O(h^4) extrapolation does not cover.
|
|
if (corner.Any(c => Math.Abs(0.5 - (c[0] * c[0] + c[1] * c[1] + c[2] * c[2])) < 0.01))
|
|
continue;
|
|
var sameCell = true;
|
|
for (var axis = 0; axis < 3; axis++)
|
|
foreach (var delta in new[] { -2.0e-3, 2.0e-3 })
|
|
sameCell &= Corners(p.x + (axis == 0 ? delta : 0.0), p.y + (axis == 1 ? delta : 0.0), p.z + (axis == 2 ? delta : 0.0)).Key.SequenceEqual(key);
|
|
if (!sameCell)
|
|
continue;
|
|
|
|
var closed = ThirdTensor(p);
|
|
compared++;
|
|
for (var k = 0; k < 3; k++)
|
|
{
|
|
var (plus1, minus1, plus2, minus2) = (At(k, 1.0e-3), At(k, -1.0e-3), At(k, 2.0e-3), At(k, -2.0e-3));
|
|
for (var i = 0; i < 3; i++)
|
|
for (var j = 0; j < 3; j++)
|
|
{
|
|
var d1 = (plus1[i * 3 + j] - minus1[i * 3 + j]) / 2.0e-3;
|
|
var d2 = (plus2[i * 3 + j] - minus2[i * 3 + j]) / 4.0e-3;
|
|
var richardson = (4.0 * d1 - d2) / 3.0;
|
|
var entry = closed[(i * 3 + j) * 3 + k];
|
|
largest = Math.Max(largest, Math.Abs(entry));
|
|
worst = Math.Max(worst, Math.Abs(entry - richardson) / (1.0 + Math.Abs(entry)));
|
|
}
|
|
}
|
|
}
|
|
|
|
output.WriteLine($"IV-REPORT third oracle: {compared} points, largest entry {largest:R}, worst relative difference from the Richardson differences {worst:R}");
|
|
Assert.True(compared >= 250, $"only {compared} of 400 points kept their lattice cell and cleared the support edges");
|
|
Assert.True(largest >= 200.0, $"the largest third-derivative entry compared is {largest:R}: the check has no teeth");
|
|
Assert.True(worst <= 1.0e-6, $"the closed-form third tensor differs from the differenced Hessian by {worst:R} (relative)");
|
|
}
|
|
|
|
// T(u, u, u) for a symmetric 3-tensor.
|
|
private static double Cubic(double[] t, double[] u)
|
|
{
|
|
var sum = 0.0;
|
|
for (var i = 0; i < 3; i++)
|
|
for (var j = 0; j < 3; j++)
|
|
for (var k = 0; k < 3; k++)
|
|
sum += t[(i * 3 + j) * 3 + k] * u[i] * u[j] * u[k];
|
|
return sum;
|
|
}
|
|
|
|
// The 26 unit directions of the axes, the edge midpoints and the cube corners.
|
|
private static readonly double[][] TensorStarts = Enumerable.Range(0, 27).Select(c => new[] { c % 3 - 1.0, c / 3 % 3 - 1.0, c / 9 - 1.0 })
|
|
.Where(v => v[0] != 0.0 || v[1] != 0.0 || v[2] != 0.0)
|
|
.Select(v => { var length = Math.Sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); return new[] { v[0] / length, v[1] / length, v[2] / length }; }).ToArray();
|
|
|
|
// The operator norm of a symmetric 3-tensor, max over unit u of |T(u, u, u)| (for a symmetric tensor
|
|
// that is the injective norm). From the six of 26 starts (axes, edge midpoints, cube corners) with
|
|
// the largest |T(u, u, u)|, the shifted power ascent u <- normalise(T(u, u, .) +- alpha u), with alpha
|
|
// twice the largest absolute row sum, which makes the iteration monotone in the cubic form (Kolda and
|
|
// Mayo's SS-HOPM), the sign chosen as the start's. A local maximum, so a lower bound; MeasureThird
|
|
// checks it against a dense direction sweep at its witness.
|
|
private static double TensorNorm(double[] t)
|
|
{
|
|
var alpha = 0.0;
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var row = 0.0;
|
|
for (var jk = 0; jk < 9; jk++)
|
|
row += Math.Abs(t[i * 9 + jk]);
|
|
alpha = Math.Max(alpha, 2.0 * row);
|
|
}
|
|
|
|
var best = 0.0;
|
|
var g = new double[3];
|
|
foreach (var start in TensorStarts.OrderByDescending(v => Math.Abs(Cubic(t, v))).Take(6))
|
|
{
|
|
var u = (double[])start.Clone();
|
|
var sign = Cubic(t, u) < 0.0 ? -1.0 : 1.0;
|
|
for (var iteration = 0; iteration < 40; iteration++)
|
|
{
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var sum = 0.0;
|
|
for (var j = 0; j < 3; j++)
|
|
for (var k = 0; k < 3; k++)
|
|
sum += t[(i * 3 + j) * 3 + k] * u[j] * u[k];
|
|
g[i] = sign * sum + alpha * u[i];
|
|
}
|
|
|
|
var length = Math.Sqrt(g[0] * g[0] + g[1] * g[1] + g[2] * g[2]);
|
|
if (length == 0.0)
|
|
break;
|
|
var moved = 0.0;
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var next = g[i] / length;
|
|
moved += (next - u[i]) * (next - u[i]);
|
|
u[i] = next;
|
|
}
|
|
|
|
if (moved < 1.0e-20)
|
|
break;
|
|
}
|
|
|
|
best = Math.Max(best, Math.Abs(Cubic(t, u)));
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
// The operator norm of snoise's third-derivative tensor at p.
|
|
private static double ThirdNorm(float3 p) => TensorNorm(ThirdTensor(p));
|
|
|
|
[Fact]
|
|
public void TensorNormFindsKnownMaxima()
|
|
{
|
|
// T = e_x (x) e_x (x) e_x: the cubic form is u_x^3, norm 1.
|
|
var single = new double[27];
|
|
single[0] = 1.0;
|
|
Assert.InRange(TensorNorm(single), 1.0 - 1.0e-9, 1.0 + 1.0e-9);
|
|
|
|
// The six permutations of (0, 1, 2) at 1: the cubic form is 6 u_x u_y u_z, largest on the cube
|
|
// diagonal at 6 / (3 sqrt 3) = 2 / sqrt 3.
|
|
var permutations = new double[27];
|
|
foreach (var (i, j, k) in new[] { (0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0) })
|
|
permutations[(i * 3 + j) * 3 + k] = 1.0;
|
|
Assert.InRange(TensorNorm(permutations), 2.0 / Math.Sqrt(3.0) - 1.0e-9, 2.0 / Math.Sqrt(3.0) + 1.0e-9);
|
|
|
|
// A negative extremum counts by its absolute value.
|
|
var negative = new double[27];
|
|
negative[13] = -3.0;
|
|
Assert.InRange(TensorNorm(negative), 3.0 - 1.0e-9, 3.0 + 1.0e-9);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The provenance of SNOISE_THIRD: the largest operator norm of snoise's closed-form third-derivative
|
|
/// tensor (ThirdNorm, ThirdTensor) over 1e6 seeded points of [-256, 256]^3, the 1e4 largest refined by ascent over the point,
|
|
/// times 1.10, on the tied kernel. Prints the start whose ascent reached the maximum, which
|
|
/// ThirdConstantPinsSampled climbs from again, and a sweep of 20,000 directions at the point the
|
|
/// ascent ends on (the tensor norm there against the largest cubic form the sweep finds, so the
|
|
/// tightness of the inner maximisation is on the record). Slow; run explicitly after any change to the
|
|
/// snoise kernel and re-pin the constant and the witness.
|
|
/// </summary>
|
|
[Fact(Explicit = true)]
|
|
[Trait("Category", "Slow")]
|
|
public void MeasureThird()
|
|
{
|
|
var (sampled, refined, witness) = MeasureMax(ThirdNorm, false, 0x4E57, 1_000_000, 10_000);
|
|
output.WriteLine($"IV-REPORT third: max |D3| sampled {sampled:R}, refined {refined:R}, x 1.10 = {(float)(refined * 1.10):R}, witness ({witness.x:R}, {witness.y:R}, {witness.z:R})");
|
|
var (best, at) = AscendPoint(ThirdNorm, witness);
|
|
var tensor = ThirdTensor(at);
|
|
var sweep = 0.0;
|
|
const int directions = 20_000;
|
|
for (var k = 0; k < directions; k++)
|
|
{
|
|
var z = 1.0 - 2.0 * (k + 0.5) / directions;
|
|
var r = Math.Sqrt(1.0 - z * z);
|
|
var phi = k * Math.PI * (3.0 - Math.Sqrt(5.0));
|
|
sweep = Math.Max(sweep, Math.Abs(Cubic(tensor, new[] { r * Math.Cos(phi), r * Math.Sin(phi), z })));
|
|
}
|
|
|
|
output.WriteLine($"IV-REPORT third witness ascent ends at ({at.x:R}, {at.y:R}, {at.z:R}): tensor norm {best:R}, dense sweep of {directions} directions {sweep:R} ({sweep / best:R} of it)");
|
|
Assert.True(sweep <= best * (1.0 + 1.0e-6), $"a direction sweep finds {sweep:R} over the ascent's {best:R}: the inner maximisation misses a maximum");
|
|
Assert.True(refined <= SNOISE_THIRD, $"measured {refined:R} exceeds SNOISE_THIRD {SNOISE_THIRD:R}");
|
|
}
|
|
|
|
// The start MeasureThird printed as its witness.
|
|
private static readonly float3 ThirdWitness = new(-184.81363f, -34.250977f, 68.26865f);
|
|
|
|
/// <summary>
|
|
/// Pins SNOISE_THIRD: no third-derivative norm over 1e5 seeded points exceeds it, and the ascent from
|
|
/// MeasureThird's witness reaches a maximum that, times 1.10, equals the constant bit for bit
|
|
/// (the expression MeasureThird prints), so any moved constant fails here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ThirdConstantPinsSampled()
|
|
{
|
|
var (sampled, _, _) = MeasureMax(ThirdNorm, false, 0x4E58, 100_000, 0);
|
|
Assert.True(sampled <= SNOISE_THIRD, $"the third-derivative norm reaches {sampled:R}, above SNOISE_THIRD {SNOISE_THIRD:R}");
|
|
var refined = Ascend(ThirdNorm, ThirdWitness);
|
|
Assert.True(refined <= SNOISE_THIRD, $"the third-derivative norm reaches {refined:R}, above SNOISE_THIRD {SNOISE_THIRD:R}");
|
|
var measured = (float)(refined * 1.10);
|
|
Assert.True(SNOISE_THIRD == measured, $"SNOISE_THIRD is {SNOISE_THIRD:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureThird and re-pin");
|
|
}
|
|
|
|
/// <summary>
|
|
/// SNOISE_HESSIAN where the curvature is realised. At the point the ascent from MeasureHessian's witness
|
|
/// reaches, the absolute second difference of snoise along 4,000 seeded directions u at step 0.05 has a
|
|
/// largest value within 10% of SNOISE_HESSIAN / 1.10 (the realised curvature is there, so the check
|
|
/// has teeth), and af_snoise(witness, r u, 0) encloses snoise(witness + eps r u) at eps in
|
|
/// {-1, -1/2, 0, 1/2, 1} for r in {0.1, 0.3} and every one of those directions, no tolerance: a constant
|
|
/// under the realised curvature (40 against about 50.6) misses on the directions near the top
|
|
/// eigenvector. The enclosure tests on random balls cannot see a constant 20% low; this and
|
|
/// HessianConstantPinsSampledCurvature are its defence.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SnoiseFormEnclosesTheCurvatureWitness()
|
|
{
|
|
var (_, witness) = AscendPoint(HessianNorm, HessianWitness);
|
|
var random = new System.Random(0xCA7E);
|
|
var directions = Enumerable.Range(0, 4000).Select(_ => UnitVector(random)).ToArray();
|
|
const float step = 0.05f;
|
|
var centreValue = snoise(witness);
|
|
var realised = directions.Max(u => Math.Abs(snoise(witness + u * step) + (double)snoise(witness - u * step) - 2.0 * centreValue) / ((double)step * step));
|
|
output.WriteLine($"IV-REPORT curvature witness ({witness.x:R}, {witness.y:R}, {witness.z:R}): largest second difference {realised:R} against SNOISE_HESSIAN {SNOISE_HESSIAN:R}");
|
|
Assert.True(realised >= 0.9 * SNOISE_HESSIAN / 1.10, $"the largest second difference at the witness is {realised:R}, under 0.9 SNOISE_HESSIAN / 1.10 = {0.9 * SNOISE_HESSIAN / 1.10:R}: the check has no teeth, or the constant moved");
|
|
foreach (var u in directions)
|
|
foreach (var r in new[] { 0.1f, 0.3f })
|
|
{
|
|
var form = af_snoise(witness, u * r, 0.0f);
|
|
foreach (var eps in new[] { -1.0f, -0.5f, 0.0f, 0.5f, 1.0f })
|
|
{
|
|
var value = snoise(witness + u * (r * eps));
|
|
Assert.True(Math.Abs(value - (form.x + (double)form.y * eps)) <= form.z, $"af_snoise(witness, {u * r}, 0) = {form} misses snoise = {value:R} at eps {eps:R}, direction {u}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The provenance of SNOISE2_LIPSCHITZ: the largest gradient length of snoise(float2) (Gradient2Norm)
|
|
/// over 1e6 seeded points of [-256, 256]^2, the 1e4 largest refined by ascent, times 1.10; and the
|
|
/// largest |snoise(float2)| reached by ascent from 1e4 starts, which is what lets iv_snoise_ball(float2)
|
|
/// intersect with [-1, 1]. Slow; run explicitly after any change to the 2D kernel and re-pin.
|
|
/// </summary>
|
|
[Fact(Explicit = true)]
|
|
[Trait("Category", "Slow")]
|
|
public void MeasureLipschitz2()
|
|
{
|
|
var (sampled, refined, witness) = MeasureMax(Gradient2At, true, 0x2D15, 1_000_000, 10_000);
|
|
var random = new System.Random(0x2D16);
|
|
var maxValue = Enumerable.Range(0, 10_000)
|
|
.Select(_ => new float2(Uniform(random, -256.0f, 256.0f), Uniform(random, -256.0f, 256.0f)))
|
|
.Max(p => Ascend2(q => Math.Abs(snoise(q)), p));
|
|
output.WriteLine($"IV-REPORT lipschitz2: max |grad snoise2| sampled {sampled:R}, refined {refined:R}, x 1.10 = {(float)(refined * 1.10):R}, witness ({witness.x:R}, {witness.y:R}); max |snoise2| refined {maxValue:R}");
|
|
Assert.True(refined <= SNOISE2_LIPSCHITZ, $"measured {refined:R} exceeds SNOISE2_LIPSCHITZ {SNOISE2_LIPSCHITZ:R}");
|
|
Assert.True(maxValue < 1.0, $"|snoise2| reaches {maxValue:R}; iv_snoise_ball(float2) may not intersect with [-1, 1]");
|
|
}
|
|
|
|
// The start MeasureLipschitz2 printed as its witness.
|
|
private static readonly float2 Lipschitz2Witness = new(-67.12454f, -129.53296f);
|
|
|
|
/// <summary>
|
|
/// Pins SNOISE2_LIPSCHITZ as LipschitzConstantPinsSampledGradients pins SNOISE_LIPSCHITZ: 1e5 seeded
|
|
/// points under it, and the ascent from MeasureLipschitz2's witness, times 1.10, equals it bit for bit (the expression MeasureLipschitz2 prints).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Lipschitz2ConstantPins()
|
|
{
|
|
var (sampled, _, _) = MeasureMax(Gradient2At, true, 0x2D17, 100_000, 0);
|
|
Assert.True(sampled <= SNOISE2_LIPSCHITZ, $"|grad snoise2| reaches {sampled:R}, above SNOISE2_LIPSCHITZ {SNOISE2_LIPSCHITZ:R}");
|
|
var refined = Ascend2(Gradient2Norm, Lipschitz2Witness);
|
|
Assert.True(refined <= SNOISE2_LIPSCHITZ, $"|grad snoise2| reaches {refined:R}, above SNOISE2_LIPSCHITZ {SNOISE2_LIPSCHITZ:R}");
|
|
var measured = (float)(refined * 1.10);
|
|
Assert.True(SNOISE2_LIPSCHITZ == measured, $"SNOISE2_LIPSCHITZ is {SNOISE2_LIPSCHITZ:R}, the re-measured 1.10 x {refined:R} is {measured:R}; re-run MeasureLipschitz2 and re-pin");
|
|
}
|
|
|
|
/// <summary>
|
|
/// iv_snoise_ball(float2) encloses snoise(float2) over 2,000 seeded discs of radius log-uniform in
|
|
/// [1e-3, 1e3] x 64 points (the first eight on the rim), no tolerance.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Snoise2BallEnclosesPoints()
|
|
{
|
|
var random = new System.Random(0xBA12);
|
|
for (var b = 0; b < 2000; b++)
|
|
{
|
|
var centre = new float2(Uniform(random, -20.0f, 20.0f), Uniform(random, -20.0f, 20.0f));
|
|
var radius = LogUniform(random, 1.0e-3f, 1.0e3f);
|
|
var bound = iv_snoise_ball(centre, radius);
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var angle = Uniform(random, 0.0f, 2.0f * MathF.PI);
|
|
var x = centre + new float2(MathF.Cos(angle), MathF.Sin(angle)) * (i < 8 ? radius : radius * MathF.Sqrt(random.NextSingle()));
|
|
Assert.True(Inside(snoise(x), bound), $"iv_snoise_ball({centre}, {radius:R}) = {bound} misses snoise({x}) = {snoise(x):R}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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 [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), 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>
|
|
[Fact]
|
|
public void CentredWarpStaysEnclosed()
|
|
{
|
|
var noise = new WarpedNoise { F0 = 1.0f / FogField.NoiseScale, Warp = FogField.AetheriaWarp };
|
|
var random = new System.Random(0xCE17);
|
|
var beyond = 0;
|
|
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.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, reach, time);
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var p = PointInBall(random, centre, radius, i);
|
|
var flow = noise.Flow(p);
|
|
foreach (var (shift, moved, grow) in new[] { (shift0, c0, g0), (shift1, c1, g1) })
|
|
{
|
|
var warped = p + flow * shift;
|
|
var distance = length(warped - moved);
|
|
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, (reach + grow) * noise.F0);
|
|
var value = snoise(warped * noise.F0);
|
|
Assert.True(Inside(value, bound), $"iv_snoise_ball over the centred ball ({moved}, {reach + grow:R}) = {bound} misses snoise = {value:R} at {warped}");
|
|
}
|
|
}
|
|
}
|
|
|
|
output.WriteLine($"IV-REPORT centred warp: {beyond} of {2000 * 64 * 2} warped points lie beyond r from the moved centre");
|
|
Assert.True(beyond > 0, "no warped point needs rho: the check cannot tell a centred warp without it");
|
|
}
|
|
|
|
/// <summary>
|
|
/// FlowVariation's width, pinned both ways: over 2,000 seeded balls (centres in [-2000, 2000]^3, radius
|
|
/// log-uniform in [1e-3, 500]) it equals min(sqrt(u^2 + v^2), 2) A, A = FlowAmplitude, with each of u and v
|
|
/// the reach of iv_snoise_ball(float2) at the flow plane's argument (plus its offset) plus the float32
|
|
/// allowance 2^-14 + L2 2^-20 (|q|_1 + r FlowFrequency), to 0.3% (the float32 sum n + e rounds at 6e-8, against an allowance of 6e-5). The enclosure test sees the
|
|
/// allowance only where a float32 flow differs from the exact one by more than the bound's slack, which
|
|
/// on the CPU is rare (CentredWarpStaysEnclosed); this is the test that sees it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FlowVariationCarriesItsAllowance()
|
|
{
|
|
var noise = new WarpedNoise { F0 = 1.0f / FogField.NoiseScale, Warp = FogField.AetheriaWarp };
|
|
var amplitude = noise.Warp * 2.0 / noise.Period;
|
|
var random = new System.Random(0xF10E);
|
|
double Reach(float2 q, double r)
|
|
{
|
|
var n = snoise(q);
|
|
var e = SNOISE2_LIPSCHITZ * r;
|
|
return Math.Max(Math.Min(e, 1.0 - n), Math.Min(e, n + 1.0));
|
|
}
|
|
|
|
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.0e-3f, 500.0f);
|
|
var rq = (double)radius * noise.FlowFrequency;
|
|
double Component(float2 offset)
|
|
{
|
|
var q = new float2(centre.x, centre.z) * noise.FlowFrequency + offset;
|
|
return Reach(q, rq) + Math.ScaleB(1.0, -14) + SNOISE2_LIPSCHITZ * Math.ScaleB(Math.Abs((double)q.x) + Math.Abs((double)q.y) + rq, -20);
|
|
}
|
|
|
|
var (u, v) = (Component(new float2(17.0f, 3.0f)), Component(new float2(-5.0f, 41.0f)));
|
|
var expected = Math.Min(Math.Sqrt(u * u + v * v), 2.0) * amplitude;
|
|
var observed = noise.FlowVariation(centre, radius);
|
|
Assert.True(Math.Abs(observed - expected) <= 3.0e-3 * expected, $"FlowVariation({centre}, {radius:R}) = {observed:R}, the documented rule gives {expected:R}");
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
|
|
// The arguments are built from the centred warp of the slice's own centre and reach, the detail
|
|
// from the unwarped set: no phase takes another's growth, nor the detail a warp.
|
|
var (moved0, grow0, moved1, grow1) = field.Noise.CentredWarp(slice.Centre, slice.Reach, tile.Time);
|
|
var (coverageFrequency, detailFrequency) = (field.Noise.F0, 4.0f * field.Noise.F0);
|
|
var expected = new[]
|
|
{
|
|
(slice.Coverage0, moved0 * coverageFrequency, (slice.Ball.w + grow0) * coverageFrequency),
|
|
(slice.Coverage1, moved1 * coverageFrequency, (slice.Ball.w + grow1) * coverageFrequency),
|
|
(slice.Detail, slice.Centre * detailFrequency + 17.0f, slice.Ball.w * detailFrequency),
|
|
};
|
|
foreach (var (arg, centre, radius) in expected)
|
|
{
|
|
Assert.True(length(arg.Centre - centre) <= 1.0e-6f * (1.0f + length(centre)) && Math.Abs(arg.Radius - radius) <= 1.0e-6f * radius, $"{field.Name}: argument ({arg.Centre}, {arg.Radius:R}) is not the documented ({centre}, {radius:R})");
|
|
}
|
|
|
|
var reachRule = length(slice.Coverage0.Axis) / coverageFrequency + slice.Ball.w;
|
|
Assert.True(Math.Abs(slice.Reach - reachRule) <= 1.0e-5f * reachRule, $"{field.Name}: the reach {slice.Reach:R} is not |axis| + radius = {reachRule:R}");
|
|
var axisTolerance = 1.0e-6f * length(slice.Coverage0.Axis);
|
|
Assert.True(length(slice.Coverage1.Axis - slice.Coverage0.Axis) <= axisTolerance && length(slice.Detail.Axis - slice.Coverage0.Axis * (detailFrequency / coverageFrequency)) <= 4.0f * axisTolerance, $"{field.Name}: the arguments do not share one axis");
|
|
|
|
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.Coverage0, coverageFrequency), (shift1, slice.Coverage1, coverageFrequency) };
|
|
for (var k = 0; k < 2; k++)
|
|
{
|
|
var (shift, arg, frequency) = phases[k];
|
|
var point = (p + flow * shift) * frequency;
|
|
var distance = length(point - (arg.Centre + arg.Axis * eps));
|
|
Assert.True(distance <= arg.Radius * (1.0f + 1.0e-5f) + 1.0e-6f * length(point), $"{field.Name}: phase {k} warped ray point at depth {z:R} is {distance:R} from the argument's centre + axis eps, outside radius {arg.Radius:R} (reach {slice.Reach:R}, slice [{z0:R}, {z1:R}])");
|
|
needing[k] += distance > slice.Ball.w * frequency ? 1 : 0;
|
|
}
|
|
|
|
var detail = p * detailFrequency + 17.0f;
|
|
var detailDistance = length(detail - (slice.Detail.Centre + slice.Detail.Axis * eps));
|
|
Assert.True(detailDistance <= slice.Detail.Radius * (1.0f + 1.0e-5f) + 1.0e-6f * length(detail), $"{field.Name}: the detail ray point at depth {z:R} is {detailDistance:R} from the argument's centre + axis eps, outside radius {slice.Detail.Radius:R} (slice [{z0:R}, {z1:R}])");
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The affine composition earns its place: over seeded slices of the deep well and the shallow wells
|
|
/// (warp 0 and the Aetheria warp, N = 8, slices of length log-uniform in [0.5, 100] from a drawn
|
|
/// depth), AffineBound is exactly the intersection of its two sound pieces (the affine arithmetic's
|
|
/// own, and the interval composition over the forms' ranges), and where the interval piece does not
|
|
/// already prove the slice empty the affine piece is the narrower of the two in at least 35% of
|
|
/// them and the intersection is narrower than the interval piece in total width by at least 0.5%
|
|
/// (measured: 43% and 1.1%). The affine piece alone is wider in total (the shared symbol pays where the forms are
|
|
/// correlated and loses to the clipped ranges where they are not), so it is the intersection's gain
|
|
/// that measures it. The cost contract (IntervalSkipHalvesEvaluations) sees only the sum, in which
|
|
/// af_snoise's Hessian forms already beat iv_snoise_ball; this is the test that sees af_add, af_mul and
|
|
/// af_range, the shared symbol, break.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AffineCompositionTightensItsRanges()
|
|
{
|
|
var random = new System.Random(0xAC0F);
|
|
var (open, wins, intersectionWidth, intervalWidth) = (0, 0, 0.0, 0.0);
|
|
foreach (var warp in new[] { 0.0f, FogField.AetheriaWarp })
|
|
foreach (var field in new[] { FogField.DeepWell(warp, BoundMode.Affine), FogField.Wells(warp, BoundMode.Affine) })
|
|
for (var t = 0; t < 1500; t++)
|
|
{
|
|
var tile = field.DrawTile(random, 8);
|
|
var z0 = Uniform(random, field.Grid[0], field.Grid[^1] * 0.9f);
|
|
var z1 = MathF.Min(z0 + LogUniform(random, 0.5f, 100.0f), field.Grid[^1]);
|
|
var (affine, interval) = field.AffinePieces(tile, z0, z1, new Counts());
|
|
var bound = field.Bound(tile, z0, z1, false, new Counts());
|
|
Assert.True(bound.x == max(affine.x, interval.x) && bound.y == min(affine.y, interval.y), $"{field.Name}: Bound {bound} is not the intersection of the affine piece {affine} and the interval piece {interval}");
|
|
if (!(interval.y > 0.0f))
|
|
continue;
|
|
open++;
|
|
wins += affine.y - affine.x < interval.y - interval.x ? 1 : 0;
|
|
intersectionWidth += bound.y - bound.x;
|
|
intervalWidth += interval.y - interval.x;
|
|
}
|
|
|
|
output.WriteLine($"IV-REPORT affine composition: {open} open slices, affine narrower in {wins} ({(double)wins / open:F3}), intersection width {intersectionWidth:F1} against the interval piece's {intervalWidth:F1} ({intersectionWidth / intervalWidth:F3})");
|
|
Assert.True(open > 0, "no slice is open");
|
|
Assert.True(wins >= 0.35 * open, $"the affine piece is narrower than the interval piece in {wins} of {open} open slices, under 35%");
|
|
Assert.True(intersectionWidth <= 0.995 * intervalWidth, $"the intersection's width {intersectionWidth:F1} is not under 0.995 of the interval piece's {intervalWidth:F1}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The gate only ever gives up what the probes could not have delivered. Gated, a slice the best
|
|
/// conceivable probe cannot prove empty returns the envelope; so a slice the ungated bound proves
|
|
/// empty (its upper density 0) must be proved empty gated too. A gate that errs toward giving up
|
|
/// (the reach inflated, a gradient or Hessian term overstated) loosens the bound exactly there and
|
|
/// costs only culling, which no enclosure test sees; a gate that errs the other way only costs probes,
|
|
/// which the cost contract (IntervalSkipHalvesEvaluations) sees. Over seeded slices of both wells and the fog interior at
|
|
/// warp 0 and the Aetheria warp, N = 8, in both modes, no slice the ungated bound proves empty is
|
|
/// left open by the gated one, and at least 200 are proved empty.
|
|
/// </summary>
|
|
[Fact]
|
|
public void GateNeverGivesUpAProvableCull()
|
|
{
|
|
var random = new System.Random(0x6A7E);
|
|
var (proved, lost) = (0, new List<string>());
|
|
foreach (var mode in new[] { BoundMode.Interval, BoundMode.Affine })
|
|
foreach (var warp in new[] { 0.0f, FogField.AetheriaWarp })
|
|
foreach (var field in new[] { FogField.DeepWell(warp, mode), FogField.Wells(warp, mode), FogField.InsideFog(warp, mode) })
|
|
for (var t = 0; t < 1500; t++)
|
|
{
|
|
var tile = field.DrawTile(random, 8);
|
|
var z0 = Uniform(random, field.Grid[0], field.Grid[^1] * 0.9f);
|
|
var z1 = MathF.Min(z0 + LogUniform(random, 0.5f, 100.0f), field.Grid[^1]);
|
|
if (field.Bound(tile, z0, z1, false, new Counts()).y > 0.0f)
|
|
continue;
|
|
proved++;
|
|
var gated = field.Bound(tile, z0, z1, true, new Counts());
|
|
if (gated.y > 0.0f)
|
|
lost.Add($"{field.Name} {mode} warp {warp}: slice [{z0:R}, {z1:R}] is proved empty ungated but the gate leaves it at {gated}");
|
|
}
|
|
|
|
output.WriteLine($"IV-REPORT gate: {proved} slices proved empty ungated, {lost.Count} left open by the gate");
|
|
Assert.True(proved >= 200, $"only {proved} slices are proved empty ungated: the check cannot tell a gate that loses them");
|
|
Assert.True(lost.Count == 0, $"{lost.Count} provable culls given up by the gate, first: {lost.FirstOrDefault()}");
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
var band = FogField.Amplitude * (FogField.FreeNoise.y - FogField.FreeNoise.x);
|
|
var well = field.Bowls[^1];
|
|
var rim = well.Scale / 2.0f;
|
|
float At(float r) => field.HeightAt(well.Centre + new float2(r, 0.0f));
|
|
var depth = At(0.0f) - At(rim);
|
|
var half = (At(0.0f) + At(rim)) / 2.0f;
|
|
float lo = 0.0f, hi = rim;
|
|
for (var k = 0; k < 60; k++)
|
|
{
|
|
var mid = (lo + hi) / 2.0f;
|
|
if (At(mid) > half)
|
|
lo = mid;
|
|
else
|
|
hi = mid;
|
|
}
|
|
|
|
var problems = new List<string>();
|
|
if (depth < 10.0f * band)
|
|
problems.Add($"depth {depth:R} is under 10 G = {10.0f * band:R}");
|
|
if (lo < 10.0f * band)
|
|
problems.Add($"half-depth radius {lo:R} is under 10 G = {10.0f * band:R}");
|
|
var random = new System.Random(0xD33B);
|
|
for (var c = 0; c < cameras; c++)
|
|
{
|
|
var origin = field.DrawTile(random, 8).Origin;
|
|
var xz = new float2(origin.x, origin.z);
|
|
var s = origin.y + field.HeightAt(xz);
|
|
if (!(length(xz - well.Centre) < lo && origin.y < FogField.FloorOffset - At(rim) && s >= FogField.FloorOffset + FogField.Amplitude * FogField.FreeNoise.y))
|
|
{
|
|
problems.Add($"camera {origin} is not inside the well, below the rim's fog surface, in empty air");
|
|
break;
|
|
}
|
|
}
|
|
|
|
return problems.Count == 0 ? null : $"{field.Name}: G = {band:R}; " + string.Join("; ", problems);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ruling wells-scale-order-of-magnitude: the band G is computed from FogField's FloorOffset, Amplitude
|
|
/// and free noise range (60). The deep well's depth, h(centre) - h(rim), and its half-depth radius,
|
|
/// found by bisection on the well map, are each at least 10 G, and each of 1,000 drawn cameras sits
|
|
/// inside that radius, below the rim's fog surface and in empty air (s above the band). A well of depth
|
|
/// 150 fails the same check.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DeepWellIsTenBandsDeepAndWide()
|
|
{
|
|
var deep = FogField.DeepWell(FogField.AetheriaWarp, BoundMode.Interval);
|
|
Assert.Null(TenBandsShortfall(deep, 1000));
|
|
Assert.NotNull(TenBandsShortfall(FogField.DeepWell(FogField.AetheriaWarp, BoundMode.Interval, depth: 150.0f), 1000));
|
|
var band = FogField.Amplitude * (FogField.FreeNoise.y - FogField.FreeNoise.x);
|
|
output.WriteLine($"IV-REPORT deep well: G = {band:R}, depth {deep.HeightAt(new float2(0.0f, 0.0f)) - deep.HeightAt(new float2(FogField.DeepWellScale / 2.0f, 0.0f)):R}");
|
|
}
|
|
|
|
// ---- The cull map ----
|
|
|
|
// An approximation of viridis at nine anchors, interpolated linearly, for t in [0, 1].
|
|
private static readonly (byte R, byte G, byte B)[] Viridis =
|
|
{
|
|
(68, 1, 84), (72, 40, 120), (62, 74, 137), (49, 104, 142), (38, 130, 142), (31, 158, 137), (53, 183, 121), (110, 206, 88), (253, 231, 37),
|
|
};
|
|
|
|
private static (byte, byte, byte) Colour(double t)
|
|
{
|
|
t = Math.Clamp(t, 0.0, 1.0) * (Viridis.Length - 1);
|
|
var i = Math.Min((int)t, Viridis.Length - 2);
|
|
var f = t - i;
|
|
byte Mix(byte a, byte b) => (byte)Math.Round(a + (b - a) * f);
|
|
return (Mix(Viridis[i].R, Viridis[i + 1].R), Mix(Viridis[i].G, Viridis[i + 1].G), Mix(Viridis[i].B, Viridis[i + 1].B));
|
|
}
|
|
|
|
private static uint[] CrcTable() => Enumerable.Range(0, 256).Select(n =>
|
|
{
|
|
var c = (uint)n;
|
|
for (var k = 0; k < 8; k++)
|
|
c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
|
return c;
|
|
}).ToArray();
|
|
|
|
// A minimal 8-bit RGB PNG: one IDAT of zlib-compressed rows, filter 0.
|
|
private static void WritePng(string path, int width, int height, byte[] rgb)
|
|
{
|
|
var table = CrcTable();
|
|
using var file = File.Create(path);
|
|
void Chunk(string type, byte[] data)
|
|
{
|
|
var name = System.Text.Encoding.ASCII.GetBytes(type);
|
|
var length = new[] { (byte)(data.Length >> 24), (byte)(data.Length >> 16), (byte)(data.Length >> 8), (byte)data.Length };
|
|
file.Write(length);
|
|
file.Write(name);
|
|
file.Write(data);
|
|
var crc = 0xFFFFFFFFu;
|
|
foreach (var b in name.Concat(data))
|
|
crc = table[(crc ^ b) & 0xFF] ^ (crc >> 8);
|
|
crc ^= 0xFFFFFFFFu;
|
|
file.Write(new[] { (byte)(crc >> 24), (byte)(crc >> 16), (byte)(crc >> 8), (byte)crc });
|
|
}
|
|
|
|
file.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 });
|
|
Chunk("IHDR", new byte[] { (byte)(width >> 24), (byte)(width >> 16), (byte)(width >> 8), (byte)width, (byte)(height >> 24), (byte)(height >> 16), (byte)(height >> 8), (byte)height, 8, 2, 0, 0, 0 });
|
|
using var packed = new MemoryStream();
|
|
using (var z = new System.IO.Compression.ZLibStream(packed, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true))
|
|
{
|
|
for (var y = 0; y < height; y++)
|
|
{
|
|
z.WriteByte(0);
|
|
z.Write(rgb, y * width * 3, width * 3);
|
|
}
|
|
}
|
|
|
|
Chunk("IDAT", packed.ToArray());
|
|
Chunk("IEND", Array.Empty<byte>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// The cull map of one deep-well frame at warp D, for the operator: a camera inside the well at xz
|
|
/// (0, -200), y = F - h / 2, looking along +z across the well, 960 x 540 pixels at a 60 degree vertical
|
|
/// field of view, in 8 x 8 tiles (the last row of tiles half off the frame). Each tile runs the
|
|
/// pre-pass under the centred-warp interval bound and under the affine bound; each pixel's ray (through
|
|
/// the pixel's centre) is marched densely for its transmittance and over each tile mask and the oracle
|
|
/// mask (the cells some ray of the tile samples nonzero), with the early-out at 0.02. The PNG is
|
|
/// 1920 x 1080 in four panels: top left the cells per ray the interval march integrates, top right the
|
|
/// affine march's, bottom left the oracle march's (all three on one viridis scale, logarithmic,
|
|
/// log(1 + n) / log(1 + the largest count)), bottom right the transmittance (black opaque, white clear). It is written only when
|
|
/// CULTMATH_WRITE_CULLMAP names a directory; the mean cells per ray are printed either way.
|
|
/// </summary>
|
|
[Fact(Explicit = true)]
|
|
[Trait("Category", "Slow")]
|
|
public void DeepWellCullMap()
|
|
{
|
|
const int width = 960, height = 540, size = 8;
|
|
var interval = FogField.DeepWell(FogField.AetheriaWarp, BoundMode.Interval);
|
|
var affine = FogField.DeepWell(FogField.AetheriaWarp, BoundMode.Affine);
|
|
var focal = height / 2.0f / MathF.Tan(MathF.PI / 6.0f);
|
|
var xz = new float2(0.0f, -200.0f);
|
|
var origin = new float3(xz.x, FogField.FloorOffset - 0.5f * interval.HeightAt(xz), xz.y);
|
|
var grid = interval.Grid;
|
|
var (cellsInterval, cellsAffine, cellsOracle) = (new int[width * height], new int[width * height], new int[width * height]);
|
|
var transmittance = new float[width * height];
|
|
var (tilesX, tilesY) = (width / size, (height + size - 1) / size);
|
|
var probes = new Counts[tilesX * tilesY];
|
|
var clock = System.Diagnostics.Stopwatch.StartNew();
|
|
Parallel.For(0, tilesX * tilesY, index =>
|
|
{
|
|
var (tx, ty) = (index % tilesX, index / tilesX);
|
|
var slope = new float2((tx * size + size / 2.0f - width / 2.0f) / focal, (height / 2.0f - (ty * size + size / 2.0f)) / focal);
|
|
var tile = interval.TileAt(origin, 0.0f, slope, size, focal, 3.0f);
|
|
probes[index] = new Counts();
|
|
var (maskInterval, _, _) = MarchTile(interval, tile, probes[index]);
|
|
var (maskAffine, _, _) = MarchTile(affine, tile, new Counts());
|
|
var scratch = new Counts();
|
|
var rays = new List<(int Pixel, float2 Slope)>();
|
|
for (var j = 0; j < size; j++)
|
|
for (var i = 0; i < size; i++)
|
|
{
|
|
var (px, py) = (tx * size + i, ty * size + (size - 1 - j));
|
|
rays.Add((py < height ? py * width + px : -1, tile.PixelSlope(i, j, 0.5f, 0.5f)));
|
|
}
|
|
|
|
var oracle = Enumerable.Range(0, grid.Length - 1).Where(c => rays.Any(r => interval.Density(tile, r.Slope, (grid[c] + grid[c + 1]) * 0.5f, false, scratch) > 0.0f)).ToList();
|
|
foreach (var (pixel, ray) in rays)
|
|
{
|
|
if (pixel < 0)
|
|
continue;
|
|
int Steps(List<int> mask)
|
|
{
|
|
var t = 1.0f;
|
|
var k = 0;
|
|
for (; k < mask.Count && t >= 0.02f; k++)
|
|
t *= Integrate(interval, tile, ray, grid[mask[k]], grid[mask[k] + 1], lod: false, scratch);
|
|
return k;
|
|
}
|
|
|
|
var dense = 1.0f;
|
|
for (var c = 0; c < grid.Length - 1 && dense >= 0.02f; c++)
|
|
dense *= Integrate(interval, tile, ray, grid[c], grid[c + 1], lod: false, scratch);
|
|
transmittance[pixel] = dense;
|
|
cellsInterval[pixel] = Steps(maskInterval);
|
|
cellsAffine[pixel] = Steps(maskAffine);
|
|
cellsOracle[pixel] = Steps(oracle);
|
|
}
|
|
});
|
|
|
|
var most = Math.Max(cellsInterval.Max(), Math.Max(cellsAffine.Max(), cellsOracle.Max()));
|
|
output.WriteLine($"IV-REPORT cull map (deep well, warp {FogField.AetheriaWarp:R}, {width}x{height}, {size}x{size} tiles, {clock.Elapsed.TotalSeconds:F0} s): cells per ray interval {cellsInterval.Average():F2}, affine {cellsAffine.Average():F2}, oracle {cellsOracle.Average():F2}; "
|
|
+ $"largest {most}; interval probes per tile env {probes.Average(p => p.Envelope):F1} snoise {probes.Average(p => p.Snoise):F2}; mean transmittance {transmittance.Average():F3}");
|
|
var directory = Environment.GetEnvironmentVariable("CULTMATH_WRITE_CULLMAP");
|
|
if (string.IsNullOrEmpty(directory))
|
|
return;
|
|
var rgb = new byte[2 * width * 2 * height * 3];
|
|
void Put(int x, int y, (byte R, byte G, byte B) c)
|
|
{
|
|
var o = (y * 2 * width + x) * 3;
|
|
(rgb[o], rgb[o + 1], rgb[o + 2]) = c;
|
|
}
|
|
|
|
for (var y = 0; y < height; y++)
|
|
for (var x = 0; x < width; x++)
|
|
{
|
|
var p = y * width + x;
|
|
Put(x, y, Colour(Math.Log(1.0 + cellsInterval[p]) / Math.Log(1.0 + most)));
|
|
Put(width + x, y, Colour(Math.Log(1.0 + cellsAffine[p]) / Math.Log(1.0 + most)));
|
|
Put(x, height + y, Colour(Math.Log(1.0 + cellsOracle[p]) / Math.Log(1.0 + most)));
|
|
var grey = (byte)Math.Round(255.0 * transmittance[p]);
|
|
Put(width + x, height + y, (grey, grey, grey));
|
|
}
|
|
|
|
for (var k = 0; k < 2 * width; k++)
|
|
for (var d = -1; d <= 0; d++)
|
|
Put(k, height + d, (255, 255, 255));
|
|
for (var k = 0; k < 2 * height; k++)
|
|
for (var d = -1; d <= 0; d++)
|
|
Put(width + d, k, (255, 255, 255));
|
|
Directory.CreateDirectory(directory);
|
|
var path = Path.Combine(directory, "deep-well-cull-map.png");
|
|
WritePng(path, 2 * width, 2 * height, rgb);
|
|
output.WriteLine($"IV-REPORT cull map written to {path}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mean of interval width over the true range (max - min of the 64 sampled values) per ball: how
|
|
/// loose the Lipschitz bound is, for a later affine cut to beat. Asserts only finiteness.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TightnessReport()
|
|
{
|
|
double Ratio(Func<float3, float, float2> bound, Func<float3, float> f, float radius, int seed)
|
|
{
|
|
var random = new System.Random(seed);
|
|
var sum = 0.0;
|
|
for (var b = 0; b < 2000; b++)
|
|
{
|
|
var centre = RandomCentre(random);
|
|
var interval = bound(centre, radius);
|
|
float lo = float.MaxValue, hi = float.MinValue;
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var v = f(PointInBall(random, centre, radius, i));
|
|
lo = MathF.Min(lo, v);
|
|
hi = MathF.Max(hi, v);
|
|
}
|
|
|
|
sum += (interval.y - interval.x) / (double)(hi - lo);
|
|
}
|
|
|
|
return sum / 2000;
|
|
}
|
|
|
|
var snoiseRatios = new[] { 0.05f, 0.25f, 1.0f }.Select(r => (r, Ratio(iv_snoise_ball, snoise, r, 0x7167))).ToArray();
|
|
var fbmRatios = new[] { 0.05f, 0.25f }.Select(r => (r, Ratio((c, rr) => iv_fbm_ball(c, rr, 4, 2.0f, 0.5f), x => fbm_grad(x, 4, 2.0f, 0.5f).w, r, 0x7168))).ToArray();
|
|
output.WriteLine("IV-REPORT tightness (mean width / sampled range): " +
|
|
string.Join(", ", snoiseRatios.Select(t => $"snoise r={t.r:R} {t.Item2:F2}")) + "; " +
|
|
string.Join(", ", fbmRatios.Select(t => $"fbm4 r={t.r:R} {t.Item2:F2}")));
|
|
Assert.All(snoiseRatios.Concat(fbmRatios), t => Assert.True(double.IsFinite(t.Item2)));
|
|
}
|
|
|
|
|
|
// ---- The saving: envelope-then-noise fields, marched over screen tiles ----
|
|
|
|
// Combined cost in snoise units: one envelope evaluation, pointwise or as an interval probe, costs this
|
|
// much. An estimate (a handful of pulses or one height-map fetch against ~150 flops of snoise); both
|
|
// counts are printed, so any other weight can be applied to them.
|
|
internal const double EnvelopeCost = 0.2;
|
|
|
|
// One snoise_grad evaluation (af_snoise's one call) costs this much in snoise units: the value and
|
|
// three gradient components over the same corners. An estimate, printed beside the count as the
|
|
// envelope weight is, and not tuned toward any contract.
|
|
internal const double GradCost = 2.0;
|
|
|
|
internal sealed class Counts
|
|
{
|
|
public long Snoise;
|
|
public long Envelope;
|
|
public long Grad;
|
|
|
|
public double Cost => Snoise + EnvelopeCost * Envelope + GradCost * Grad;
|
|
}
|
|
|
|
/// <summary>
|
|
/// One screen tile of one frame: a pinhole camera (origin and orthonormal basis; camera space looks
|
|
/// down +z and the ray of slope m is the points (m z, z)), the tile's central slope, its size N, the
|
|
/// focal length in pixels and the frame time. Every tile draws its own camera, so a probe result is
|
|
/// never carried to another frame.
|
|
/// </summary>
|
|
internal class Tile
|
|
{
|
|
public float3 Origin;
|
|
public float3 Right = new(1.0f, 0.0f, 0.0f);
|
|
public float3 Up = new(0.0f, 1.0f, 0.0f);
|
|
public float3 Forward = new(0.0f, 0.0f, 1.0f);
|
|
public float2 Slope;
|
|
public int N;
|
|
public float Focal;
|
|
public float Time;
|
|
|
|
public float Half => N / (2.0f * Focal);
|
|
public float Footprint => N / (MathF.Sqrt(2.0f) * Focal);
|
|
public float2 SlopesX => new(Slope.x - Half, Slope.x + Half);
|
|
public float2 SlopesY => new(Slope.y - Half, Slope.y + Half);
|
|
|
|
// Pixel (i, j) of the tile at sub-pixel offset (u, v) in [0, 1): its slope lies in SlopesX x SlopesY
|
|
// in float32, because (i + u) / f - N / (2f) is at most N / f - N / (2f) = N / (2f) exactly.
|
|
public float2 PixelSlope(int i, int j, float u, float v) =>
|
|
Slope + new float2((i + u) / Focal - Half, (j + v) / Focal - Half);
|
|
|
|
public float3 World(float3 c) => Origin + Right * c.x + Up * c.y + Forward * c.z;
|
|
|
|
public float3 Camera(float3 w)
|
|
{
|
|
var d = w - Origin;
|
|
return new float3(dot(d, Right), dot(d, Up), dot(d, Forward));
|
|
}
|
|
|
|
public void Look(float3 forward)
|
|
{
|
|
Forward = normalize(forward);
|
|
Right = normalize(cross(new float3(0.0f, 1.0f, 0.0f), Forward));
|
|
Up = cross(Forward, Right);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A field with an analytic envelope and the dense grid it is marched on. Density is the pointwise
|
|
/// field, envelope-gated (noise only where the envelope leaves the density undecided), counted. Bound
|
|
/// is the one composition of the envelope's interval over a tile slice's box and the noise ball over the
|
|
/// slice's iv_frustum_ball: an interval enclosing Density at every point of every ray of the tile in the
|
|
/// slice. A slice is provably empty iff Bound(...).y <= 0. gated is the march's form: it returns the
|
|
/// envelope's verdict without snoise when the envelope alone proves the slice empty, and attempts the
|
|
/// noise ball only when the best outcome any centre value could give would prove it; ungated, the ball
|
|
/// is always evaluated.
|
|
/// </summary>
|
|
/// <summary>FogField's bound: the centred-warp interval composition, or the affine one (design.md, "Affine forms").</summary>
|
|
internal enum BoundMode
|
|
{
|
|
Interval,
|
|
Affine,
|
|
}
|
|
|
|
internal abstract class Scenario
|
|
{
|
|
public required string Name { get; init; }
|
|
public float[] Grid { get; protected init; } = Array.Empty<float>();
|
|
public float Focal { get; init; } = 935.0f; // 1080 rows at a 60 degree vertical field of view
|
|
public float Extinction { get; init; } = 1.0f;
|
|
|
|
public abstract Tile DrawTile(System.Random random, int n);
|
|
|
|
public abstract float Density(Tile tile, float2 slope, float z, bool lod, Counts counts);
|
|
|
|
public abstract float2 Bound(Tile tile, float z0, float z1, bool gated, Counts counts);
|
|
|
|
// Cell boundaries near + (j / J)^2 (far - near): Aetheria's quadratic sample spacing
|
|
// (Assets/Shaders/Raymarching/CloudShader.shader:116).
|
|
protected static float[] Quadratic(float near, float far, int cells) =>
|
|
Enumerable.Range(0, cells + 1).Select(j => near + (float)j / cells * ((float)j / cells) * (far - near)).ToArray();
|
|
|
|
protected static float[] Cells(float step, float end) =>
|
|
Enumerable.Range(0, (int)MathF.Ceiling(end / step) + 1).Select(j => j * step).ToArray();
|
|
}
|
|
|
|
// iv_snoise_ball, with the snoise skipped where L r >= 2: there the bound is [-1, 1] whatever snoise(c)
|
|
// is (|snoise| < 1), so the result is the same and costs nothing.
|
|
private static float2 NoiseBall(float3 centre, float radius, Counts counts)
|
|
{
|
|
if (SNOISE_LIPSCHITZ * radius >= 2.0f)
|
|
return new float2(-1.0f, 1.0f);
|
|
counts.Snoise++;
|
|
return iv_snoise_ball(centre, radius);
|
|
}
|
|
|
|
// The best interval any centre value could give a ball of this radius, toward low or high values; the
|
|
// gate on whether a probe can prove anything. snoise(c) > -1, so a real ball's hi is at least -1 + L r,
|
|
// and likewise its lo is at most 1 - L r.
|
|
private static float2 BestBall(float radius, bool low)
|
|
{
|
|
var e = SNOISE_LIPSCHITZ * radius;
|
|
if (e >= 2.0f)
|
|
return new float2(-1.0f, 1.0f);
|
|
return low ? new float2(-1.0f, min(-1.0f + e, 1.0f)) : new float2(max(1.0f - e, -1.0f), 1.0f);
|
|
}
|
|
|
|
// Least and greatest distance along one axis from a box to a point, exact in float32: a coordinate in
|
|
// [lo, hi] is at least `near` and at most `far` from c, and squares, sums and sqrt are monotone.
|
|
private static (float Near, float Far) AxisDistance(float2 range, float c) =>
|
|
(c < range.x ? range.x - c : c > range.y ? c - range.y : 0.0f, max(abs(range.x - c), abs(range.y - c)));
|
|
|
|
/// <summary>
|
|
/// The noise of the fog and the void (r1's field shape): coverage is two snoise of the warped point,
|
|
/// cross-faded over two half-period phases; n = coverage + detail / 2, the detail one snoise at four
|
|
/// times the frequency. The flow is a unit-bounded 2D field times FlowAmplitude, so a phase moves a point
|
|
/// by at most Warp = FlowAmplitude * Period / 2. The flow is not counted: the site reads it from a
|
|
/// texture and Aetheria from its flow map.
|
|
/// </summary>
|
|
internal sealed class WarpedNoise
|
|
{
|
|
public required float F0 { get; init; }
|
|
public required float Warp { get; init; }
|
|
public float Period { get; init; } = 8.0f;
|
|
public float FlowFrequency { get; init; } = 1.0f / 512.0f;
|
|
public bool FlowInXY { get; init; }
|
|
|
|
private float FlowAmplitude => Warp * 2.0f / Period;
|
|
|
|
public float3 Flow(float3 p)
|
|
{
|
|
var q = (FlowInXY ? new float2(p.x, p.y) : new float2(p.x, p.z)) * FlowFrequency;
|
|
var v = new float2(snoise(q + new float2(17.0f, 3.0f)), snoise(q + new float2(-5.0f, 41.0f)));
|
|
v = v / max(1.0f, length(v)) * FlowAmplitude;
|
|
return FlowInXY ? new float3(v.x, v.y, 0.0f) : new float3(v.x, 0.0f, v.y);
|
|
}
|
|
|
|
public (float W0, float Shift0, float Shift1) Phases(float time)
|
|
{
|
|
var phase0 = frac(time / Period);
|
|
var phase1 = frac(time / Period + 0.5f);
|
|
return (1.0f - abs(2.0f * phase0 - 1.0f), (phase0 - 0.5f) * Period, (phase1 - 0.5f) * Period);
|
|
}
|
|
|
|
public float Coverage(float3 p, float time, Counts counts)
|
|
{
|
|
var (w0, shift0, shift1) = Phases(time);
|
|
var flow = Flow(p);
|
|
counts.Snoise += 2;
|
|
return w0 * snoise((p + flow * shift0) * F0) + (1.0f - w0) * snoise((p + flow * shift1) * F0);
|
|
}
|
|
|
|
public float Detail(float3 p, Counts counts)
|
|
{
|
|
counts.Snoise++;
|
|
return snoise(p * (4.0f * F0) + 17.0f);
|
|
}
|
|
|
|
// The cross-fade of the two phases' noise enclosures, term by term with the frame's own weights.
|
|
// A field whose ball carries the whole warp passes the same enclosure for both phases.
|
|
public float2 CoverageBound(float2 phase0, float2 phase1, float time)
|
|
{
|
|
var w0 = Phases(time).W0;
|
|
return iv_add(iv_scale(phase0, w0), iv_scale(phase1, 1.0f - w0));
|
|
}
|
|
|
|
/// <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, 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)
|
|
{
|
|
var q = (FlowInXY ? new float2(centre.x, centre.y) : new float2(centre.x, centre.z)) * FlowFrequency;
|
|
var u = Deviation(q + new float2(17.0f, 3.0f), radius * FlowFrequency);
|
|
var v = Deviation(q + new float2(-5.0f, 41.0f), radius * FlowFrequency);
|
|
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);
|
|
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>
|
|
/// The centred warp (design.md, "Affine forms"): phase k moves a point p of a set within reach of c
|
|
/// to p + flow(p) shift_k, which lies within |shift_k| rho of (p - c) + c + flow(c) shift_k, rho =
|
|
/// FlowVariation(c, reach). So phase k's noise argument is the set moved by flow(c) shift_k and
|
|
/// grown by |shift_k| rho, instead of grown by the whole warp. At warp 0 both phases are the set.
|
|
/// </summary>
|
|
public (float3 Centre0, float Grow0, float3 Centre1, float Grow1) CentredWarp(float3 centre, float reach, float time)
|
|
{
|
|
var (_, shift0, shift1) = Phases(time);
|
|
var flow = Flow(centre);
|
|
var rho = FlowVariation(centre, reach);
|
|
return (centre + flow * shift0, abs(shift0) * rho, centre + flow * shift1, abs(shift1) * rho);
|
|
}
|
|
|
|
public float2 CoverageBall(float3 centre, float radius, Counts counts) => NoiseBall(centre * F0, radius * F0, counts);
|
|
|
|
public float2 DetailBall(float3 centre, float radius, Counts counts) => NoiseBall(centre * (4.0f * F0) + 17.0f, radius * (4.0f * F0), counts);
|
|
|
|
public float CoverageRadius(float radius) => radius * F0;
|
|
|
|
public float DetailRadius(float radius) => radius * (4.0f * F0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Aetheria's nebula (Assets/Shaders/Volumetric.cginc:165-221, cloudDensity :91-102) over its zone
|
|
/// height map (Assets/Scripts/ServerShared/Zone.cs:369-397 GetHeight, :426 PowerPulse), in its units
|
|
/// (Assets/Resources/Settings.asset: ZoneDepth :97-98, DefaultEnvironment :386-411). The pre-distortion
|
|
/// SDF is s(p) = p.y + h(p.xz), h >= 0 the well map: the zone bowl PowerPulse(|xz| / 2R, 2) * 64 plus,
|
|
/// per well, PowerPulse(|xz - c_b| / r_b, 16) * d_b, with r_b = 500 M^0.25, d_b = 30 M^0.175 and
|
|
/// PowerPulse(x, e) = (1 - 4x^2)^e on [0, 1/2], 0 beyond. Noise displaces only below the safety distance:
|
|
/// fade = 1 - smoothstep(0.75 S, S, s), s' = s + A fade n(warp(p)), density = max(0, (F - s') / B); the
|
|
/// patch band and the 1e-9 fill are left out. The warp moves only the noise argument; the height is read
|
|
/// at the unwarped xz, as Aetheria reads _NebulaSurfaceHeight at pos.xz. Envelope coordinates are the
|
|
/// tile's camera frame (the camera yaws about y, so horizontal distances are the world's). Extinction
|
|
/// 0.5 is the environment's (Settings.asset:397, applied at CloudShader.shader:91).
|
|
/// </summary>
|
|
internal sealed class FogField : Scenario
|
|
{
|
|
public const float Safety = 30.0f; // SafetyDistance, Settings.asset:399
|
|
public const float FloorOffset = -20.0f; // :393
|
|
public const float FloorBlend = 10.0f; // :394
|
|
public const float Amplitude = 20.0f;
|
|
public const float NoiseScale = 414.2167f; // :409
|
|
public const float AetheriaWarp = 60.0f; // Flow GlobalAmplitude 15 x Period 8 / 2, :402-405
|
|
public const float ZoneRadius = 2000.0f;
|
|
internal static readonly float2 FreeNoise = new(-1.5f, 1.5f);
|
|
|
|
public required WarpedNoise Noise { get; init; }
|
|
public required BoundMode Mode { get; init; }
|
|
public (float2 Centre, float Scale, float Exponent, float Depth)[] Bowls { get; init; } = Array.Empty<(float2, float, float, float)>();
|
|
public required Func<System.Random, FogField, float3> CameraAt { get; init; }
|
|
public required float2 SlopesY { get; init; }
|
|
|
|
public FogField()
|
|
{
|
|
// Near 0.3 and far 2048: the ARPG scene's Main Camera, which carries the cloud march
|
|
// (Aetheria d3075eb0 Assets/Scenes/ARPG.unity:31777 m_Name, :31830 far clip plane;
|
|
// Assets/Scripts/Zone Display/VolumeCloudRenderer.cs:36 RequireComponent(Camera)).
|
|
Grid = Quadratic(0.3f, 2048.0f, 256);
|
|
Extinction = 0.5f;
|
|
}
|
|
|
|
private static WarpedNoise AetheriaNoise(float warp) => new() { F0 = 1.0f / NoiseScale, Warp = warp };
|
|
|
|
private static string Named(string name, BoundMode mode) => $"{name} [{(mode == BoundMode.Affine ? "affine" : "interval")}]";
|
|
|
|
/// <summary>(a) Height fog: no wells, the camera above the safety band at y = S + 30, rays level and up.</summary>
|
|
public static FogField HeightFog(float warp, BoundMode mode) => new()
|
|
{
|
|
Name = Named("(a) height fog", mode), Mode = mode, Noise = AetheriaNoise(warp), SlopesY = new float2(0.0f, 0.5f),
|
|
CameraAt = (random, _) => new float3(Uniform(random, -1000.0f, 1000.0f), Safety + 30.0f, Uniform(random, -1000.0f, 1000.0f)),
|
|
};
|
|
|
|
/// <summary>(b) Inside the fog: no wells, the camera at y = F - 10, rays level.</summary>
|
|
public static FogField InsideFog(float warp, BoundMode mode) => new()
|
|
{
|
|
Name = Named("(b) inside the fog", mode), Mode = mode, Noise = AetheriaNoise(warp), SlopesY = new float2(-0.05f, 0.05f),
|
|
CameraAt = (random, _) => new float3(Uniform(random, -1000.0f, 1000.0f), FloorOffset - 10.0f, Uniform(random, -1000.0f, 1000.0f)),
|
|
};
|
|
|
|
// The deep well's geometry (ruling wells-scale-order-of-magnitude): one PowerPulse well of exponent
|
|
// 2, scale 2220 and depth 600 at the zone's centre, so its depth and half-depth radius are each
|
|
// about 600, ten times the band (DeepWellIsTenBandsDeepAndWide measures both).
|
|
public const float DeepWellScale = 2220.0f;
|
|
public const float DeepWellDepth = 600.0f;
|
|
public const float DeepWellCameraRadius = 300.0f;
|
|
|
|
/// <summary>
|
|
/// (c) The deep well, the case Aetheria plays in: there the camera is always in some gravity well,
|
|
/// gazing across a bowl a sun's gravity carves (ruling wells-scale-order-of-magnitude). The zone bowl
|
|
/// R = 2000 plus one well at its centre, PowerPulse exponent 2, scale 2220, depth 600, so the well's
|
|
/// depth and half-depth radius are each about ten times G = 3 A = 60, the band of s where only the
|
|
/// noise decides density (s in [F - 1.5 A, F + 1.5 A] with the fade at 1). The camera's xz is uniform
|
|
/// within 300 of the centre, at y = F - h(xz) / 2, halfway between the sunken fog surface and the
|
|
/// rim's, in empty air; slopes and yaw as the shallow wells, m_y in [-0.25, 0.1]. A level ray crosses
|
|
/// hundreds of units of empty well before the far wall's band.
|
|
/// </summary>
|
|
public static FogField DeepWell(float warp, BoundMode mode, float depth = DeepWellDepth) => new()
|
|
{
|
|
Name = Named("(c) deep well", mode), Mode = mode, Noise = AetheriaNoise(warp), SlopesY = new float2(-0.25f, 0.1f),
|
|
Bowls = new (float2, float, float, float)[] { (new float2(0.0f, 0.0f), 2.0f * ZoneRadius, 2.0f, 64.0f), (new float2(0.0f, 0.0f), DeepWellScale, 2.0f, depth) },
|
|
CameraAt = (r, field) =>
|
|
{
|
|
var angle = Uniform(r, 0.0f, 2.0f * MathF.PI);
|
|
var radius = DeepWellCameraRadius * MathF.Sqrt(r.NextSingle());
|
|
var xz = new float2(radius * MathF.Cos(angle), radius * MathF.Sin(angle));
|
|
return new float3(xz.x, FloorOffset - 0.5f * field.HeightAt(xz), xz.y);
|
|
},
|
|
};
|
|
|
|
/// <summary>
|
|
/// (c-band) Shallow wells, the band-dominated row, printed only: the zone bowl R = 2000 (64 deep) and
|
|
/// four wells of mass 100, 1000, 10000 and 1000 at seeded positions inside it, 30 M^0.175 deep (67 to
|
|
/// 150, 1.1 to 2.5 times G) and 500 M^0.25 wide at exponent 16; the camera at a seeded xz in the bowl,
|
|
/// just above the noise band at y = max(0, S + 10 - h), gazing across with m_y in [-0.25, 0.1]. Most
|
|
/// of its cost is the band itself, which is where affine noise gains most.
|
|
/// </summary>
|
|
public static FogField Wells(float warp, BoundMode mode)
|
|
{
|
|
var random = new System.Random(0xA37E);
|
|
var bowls = new List<(float2, float, float, float)> { (new float2(0.0f, 0.0f), 2.0f * ZoneRadius, 2.0f, 64.0f) };
|
|
foreach (var mass in new[] { 100.0f, 1000.0f, 10000.0f, 1000.0f })
|
|
{
|
|
var angle = Uniform(random, 0.0f, 2.0f * MathF.PI);
|
|
var radius = ZoneRadius * MathF.Sqrt(random.NextSingle());
|
|
bowls.Add((new float2(radius * MathF.Cos(angle), radius * MathF.Sin(angle)), 500.0f * MathF.Pow(mass, 0.25f), 16.0f, 30.0f * MathF.Pow(mass, 0.175f)));
|
|
}
|
|
|
|
return new FogField
|
|
{
|
|
Name = Named("(c-band) shallow wells", mode), Mode = mode, Noise = AetheriaNoise(warp), Bowls = bowls.ToArray(), SlopesY = new float2(-0.25f, 0.1f),
|
|
CameraAt = (r, field) =>
|
|
{
|
|
var angle = Uniform(r, 0.0f, 2.0f * MathF.PI);
|
|
var radius = ZoneRadius * MathF.Sqrt(r.NextSingle());
|
|
var xz = new float2(radius * MathF.Cos(angle), radius * MathF.Sin(angle));
|
|
return new float3(xz.x, max(0.0f, Safety + 10.0f - field.HeightAt(xz)), xz.y);
|
|
},
|
|
};
|
|
}
|
|
|
|
private sealed class FogTile : Tile
|
|
{
|
|
public float2[] Centres = Array.Empty<float2>(); // bowl centres in the camera's (x, z)
|
|
}
|
|
|
|
public override Tile DrawTile(System.Random random, int n)
|
|
{
|
|
var origin = CameraAt(random, this);
|
|
var yaw = Uniform(random, 0.0f, 2.0f * MathF.PI);
|
|
var time = Uniform(random, 0.0f, 100.0f);
|
|
var slope = new float2(Uniform(random, -1.0f, 1.0f), Uniform(random, SlopesY.x, SlopesY.y));
|
|
return TileAt(origin, yaw, slope, n, Focal, time);
|
|
}
|
|
|
|
/// <summary>The tile of a camera at origin, yawed about y (0 looks along +z), at the given slope, size, focal length and time.</summary>
|
|
public Tile TileAt(float3 origin, float yaw, float2 slope, int n, float focal, float time)
|
|
{
|
|
var tile = new FogTile { Origin = origin, N = n, Focal = focal, Time = time, Slope = slope };
|
|
tile.Look(new float3(MathF.Sin(yaw), 0.0f, MathF.Cos(yaw)));
|
|
tile.Centres = Bowls.Select(b =>
|
|
{
|
|
var c = tile.Camera(new float3(b.Centre.x, origin.y, b.Centre.y));
|
|
return new float2(c.x, c.z);
|
|
}).ToArray();
|
|
return tile;
|
|
}
|
|
|
|
private static float Pulse(float distance, float scale, float exponent)
|
|
{
|
|
var x = distance / scale;
|
|
var t = 1.0f - 4.0f * x * x;
|
|
return t <= 0.0f ? 0.0f : pow(t, exponent);
|
|
}
|
|
|
|
// h at the camera-frame point (x, z), bowl centres in the same frame.
|
|
private float Height(float2[] centres, float x, float z)
|
|
{
|
|
var h = 0.0f;
|
|
for (var b = 0; b < Bowls.Length; b++)
|
|
{
|
|
var dx = x - centres[b].x;
|
|
var dz = z - centres[b].y;
|
|
h += Pulse(sqrt(dx * dx + dz * dz), Bowls[b].Scale, Bowls[b].Exponent) * Bowls[b].Depth;
|
|
}
|
|
|
|
return h;
|
|
}
|
|
|
|
/// <summary>The well map h at the world point (x, z).</summary>
|
|
public float HeightAt(float2 xz) => Height(Bowls.Select(b => b.Centre).ToArray(), xz.x, xz.y);
|
|
|
|
// Each bowl's term over the box is [pulse(far), pulse(near)], pulse decreasing in distance; pow is
|
|
// not required to be correctly rounded, so both ends are widened by one ulp.
|
|
private float2 HeightBound(float2[] centres, float2 x, float2 z)
|
|
{
|
|
var h = iv_point(0.0f);
|
|
for (var b = 0; b < Bowls.Length; b++)
|
|
{
|
|
var (nx, fx) = AxisDistance(x, centres[b].x);
|
|
var (nz, fz) = AxisDistance(z, centres[b].y);
|
|
var near = Pulse(sqrt(nx * nx + nz * nz), Bowls[b].Scale, Bowls[b].Exponent);
|
|
var far = Pulse(sqrt(fx * fx + fz * fz), Bowls[b].Scale, Bowls[b].Exponent);
|
|
var pulse = new float2(far > 0.0f ? MathF.BitDecrement(far) : 0.0f, near > 0.0f ? MathF.BitIncrement(near) : 0.0f);
|
|
h = iv_add(h, iv_scale(pulse, Bowls[b].Depth));
|
|
}
|
|
|
|
return h;
|
|
}
|
|
|
|
private static float2 Compose(float2 s, float2 fade, float2 n)
|
|
{
|
|
var displaced = iv_add(s, iv_scale(iv_mul(fade, n), Amplitude));
|
|
return new float2(max(0.0f, (FloorOffset - displaced.y) / FloorBlend), max(0.0f, (FloorOffset - displaced.x) / FloorBlend));
|
|
}
|
|
|
|
public override float Density(Tile t, float2 slope, float z, bool lod, Counts counts)
|
|
{
|
|
var tile = (FogTile)t;
|
|
var x = slope.x * z;
|
|
var y = slope.y * z;
|
|
counts.Envelope++;
|
|
var s = tile.Origin.y + y + Height(tile.Centres, x, z);
|
|
var fade = 1.0f - smoothstep(0.75f * Safety, Safety, s);
|
|
if (Compose(iv_point(s), iv_point(fade), FreeNoise).y <= 0.0f)
|
|
return 0.0f;
|
|
var p = tile.World(new float3(x, y, z));
|
|
var coverage = Noise.Coverage(p, tile.Time, counts);
|
|
if (Compose(iv_point(s), iv_point(fade), iv_add(iv_point(coverage), iv_scale(new float2(-1.0f, 1.0f), 0.5f))).y <= 0.0f)
|
|
return 0.0f;
|
|
var n = coverage + Noise.Detail(p, counts) * 0.5f;
|
|
return max(0.0f, (FloorOffset - (s + fade * n * Amplitude)) / FloorBlend);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The envelope first: the height and slope intervals over the slice's box, the fade, and the noise
|
|
/// at its free range. Where that already proves the slice empty the envelope is the answer;
|
|
/// otherwise Mode's bound, which (gated) returns the envelope when the best any probe could give
|
|
/// would not prove the slice either.
|
|
/// </summary>
|
|
public override float2 Bound(Tile t, float z0, float z1, bool gated, Counts counts)
|
|
{
|
|
var tile = (FogTile)t;
|
|
var frame = FrameOf(tile, z0, z1, counts);
|
|
if (gated && frame.Envelope.y <= 0.0f)
|
|
return frame.Envelope;
|
|
return Mode == BoundMode.Affine
|
|
? AffineBound(tile, z0, z1, frame, gated, counts)
|
|
: IntervalBound(tile, z0, z1, frame.S, frame.Fade, frame.Envelope, gated, counts);
|
|
}
|
|
|
|
// The height interval, the density level s over the slice's box, its fade and the envelope.
|
|
private readonly record struct Frame(float2 Height, float2 S, float2 Fade, float2 Envelope);
|
|
|
|
private Frame FrameOf(FogTile tile, float z0, float z1, Counts counts)
|
|
{
|
|
var depth = new float2(z0, z1);
|
|
counts.Envelope++;
|
|
var height = HeightBound(tile.Centres, iv_mul(depth, tile.SlopesX), depth);
|
|
var s = iv_add(iv_add(iv_point(tile.Origin.y), iv_mul(depth, tile.SlopesY)), height);
|
|
var fade = iv_sub(iv_point(1.0f), iv_smoothstep(0.75f * Safety, Safety, s));
|
|
return new Frame(height, s, fade, Compose(s, fade, FreeNoise));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The two sound bounds AffineBound intersects, ungated: the affine arithmetic's own (the shared
|
|
/// symbol through af_add, af_mul and af_range), and the interval composition over the same forms'
|
|
/// ranges (NoiseBoundTests.AffineCompositionTightensItsRanges).
|
|
/// </summary>
|
|
internal (float2 Affine, float2 Interval) AffinePieces(Tile t, float z0, float z1, Counts counts)
|
|
{
|
|
var tile = (FogTile)t;
|
|
return AffinePieces(tile, z0, z1, FrameOf(tile, z0, z1, counts), counts);
|
|
}
|
|
|
|
private static bool Same(float3 a, float3 b, float ga, float gb) => all(a == b) && ga == gb;
|
|
|
|
/// <summary>
|
|
/// The centred-warp interval bound. The slice's ball is iv_frustum_ball with no warp; each phase's
|
|
/// coverage ball is that ball moved by the flow at its centre and grown by what the flow can vary
|
|
/// across it (WarpedNoise.CentredWarp), one ball when the phases coincide (warp 0). The detail octave
|
|
/// reads the unwarped point, so its ball is the unwarped one.
|
|
/// </summary>
|
|
private float2 IntervalBound(FogTile tile, float z0, float z1, float2 s, float2 fade, float2 envelope, bool gated, Counts counts)
|
|
{
|
|
var ball = iv_frustum_ball(tile.Slope, z0, z1, tile.Footprint, 0.0f);
|
|
var centre = tile.World(new float3(ball.x, ball.y, ball.z));
|
|
var (c0, g0, c1, g1) = Noise.CentredWarp(centre, ball.w, tile.Time);
|
|
if (gated)
|
|
{
|
|
var provable = false;
|
|
foreach (var low in new[] { true, false })
|
|
{
|
|
var coverage = Noise.CoverageBound(BestBall(Noise.CoverageRadius(ball.w + g0), low), BestBall(Noise.CoverageRadius(ball.w + g1), low), tile.Time);
|
|
provable |= Compose(s, fade, iv_add(coverage, iv_scale(BestBall(Noise.DetailRadius(ball.w), low), 0.5f))).y <= 0.0f;
|
|
}
|
|
|
|
if (!provable)
|
|
return envelope;
|
|
}
|
|
|
|
var n0 = Noise.CoverageBall(c0, ball.w + g0, counts);
|
|
var n1 = Same(c0, c1, g0, g1) ? n0 : Noise.CoverageBall(c1, ball.w + g1, counts);
|
|
var noise = iv_add(Noise.CoverageBound(n0, n1, tile.Time), iv_scale(Noise.DetailBall(centre, ball.w, counts), 0.5f));
|
|
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, and the reach R = |axis| + radius of the whole set from that centre (the
|
|
/// distance the flow is varied over). It returns the final af_snoise arguments, so a consumer has no
|
|
/// term left to wire: each phase's coverage argument is the set moved by the flow at its centre
|
|
/// (WarpedNoise.CentredWarp over R) and grown by what the flow can vary across it, and the detail
|
|
/// argument is the unwarped set. The rays at depth z lie within an argument's radius of its centre
|
|
/// + axis eps(z), eps(z) = clamp((z - z_m) / h, -1, 1) (NoiseBoundTests.AffineWarpStaysEnclosed).
|
|
/// </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);
|
|
var coverageFrequency = Noise.F0;
|
|
var detailFrequency = 4.0f * Noise.F0;
|
|
return new AffineSlice(
|
|
axisCamera, ball, centre, reach,
|
|
new NoiseArg(c0 * coverageFrequency, axis * coverageFrequency, (ball.w + g0) * coverageFrequency),
|
|
new NoiseArg(c1 * coverageFrequency, axis * coverageFrequency, (ball.w + g1) * coverageFrequency),
|
|
new NoiseArg(centre * detailFrequency + 17.0f, axis * detailFrequency, ball.w * detailFrequency));
|
|
}
|
|
|
|
/// <summary>One af_snoise argument in noise space: the centre, the axis along eps, and the radius.</summary>
|
|
internal readonly record struct NoiseArg(float3 Centre, float3 Axis, float Radius)
|
|
{
|
|
/// <summary>The distance of the whole set from its centre: the gate's reach.</summary>
|
|
public float Reach => length(Axis) + Radius;
|
|
|
|
public float3 Snoise() => af_snoise(Centre, Axis, Radius);
|
|
}
|
|
|
|
internal readonly record struct AffineSlice(float3 AxisCamera, float4 Ball, float3 Centre, float Reach, NoiseArg Coverage0, NoiseArg Coverage1, NoiseArg Detail);
|
|
|
|
// 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)
|
|
{
|
|
var e = min(SNOISE_LIPSCHITZ * reach, 0.5f * SNOISE_HESSIAN * reach * reach);
|
|
if (e >= 2.0f)
|
|
return new float2(-1.0f, 1.0f);
|
|
return low ? new float2(-1.0f, min(-1.0f + e, 1.0f)) : new float2(max(1.0f - e, -1.0f), 1.0f);
|
|
}
|
|
|
|
private static float2 Clip(float2 range) => new(max(range.x, -1.0f), min(range.y, 1.0f));
|
|
|
|
/// <summary>
|
|
/// The affine bound, one shared symbol: depth z = z_m + h eps over the slice (af_frustum_axis and
|
|
/// af_frustum_ball give the noise argument the same eps). s = O_y + m_c,y z + (m_y - m_c,y) z + h(xz)
|
|
/// is affine in eps, with the lateral slope-depth term (at most Half z1, plus the rounding of the
|
|
/// pointwise m_y z) and the height interval in e. Each phase's coverage and the unwarped detail are
|
|
/// af_snoise over the slice moved and grown by the centred warp, the fade enters as af_from_iv, and
|
|
/// s' = s + A fade n is ranged by af_range. The result is intersected with the interval composition
|
|
/// over the same forms' ranges, each clipped to [-1, 1] (both sound, no further evaluation). The gate
|
|
/// is BestForm's.
|
|
/// </summary>
|
|
private float2 AffineBound(FogTile tile, float z0, float z1, Frame frame, bool gated, Counts counts)
|
|
{
|
|
if (gated)
|
|
{
|
|
var slice = Slice(tile, z0, z1);
|
|
var (s, fade) = (frame.S, frame.Fade);
|
|
var provable = false;
|
|
foreach (var low in new[] { true, false })
|
|
{
|
|
var coverage = Noise.CoverageBound(BestForm(slice.Coverage0.Reach, low), BestForm(slice.Coverage1.Reach, low), tile.Time);
|
|
provable |= Compose(s, fade, iv_add(coverage, iv_scale(BestForm(slice.Detail.Reach, low), 0.5f))).y <= 0.0f;
|
|
}
|
|
|
|
if (!provable)
|
|
return frame.Envelope;
|
|
}
|
|
|
|
var (affine, interval) = AffinePieces(tile, z0, z1, frame, counts);
|
|
return new float2(max(affine.x, interval.x), min(affine.y, interval.y));
|
|
}
|
|
|
|
private (float2 Affine, float2 Interval) AffinePieces(FogTile tile, float z0, float z1, Frame frame, Counts counts)
|
|
{
|
|
var slice = Slice(tile, z0, z1);
|
|
var (ball, axisCamera) = (slice.Ball, slice.AxisCamera);
|
|
var (height, s, fade) = (frame.Height, frame.S, frame.Fade);
|
|
var shared = slice.Coverage0 == slice.Coverage1;
|
|
counts.Grad += shared ? 2 : 3;
|
|
var n0 = slice.Coverage0.Snoise();
|
|
var n1 = shared ? n0 : slice.Coverage1.Snoise();
|
|
var nd = slice.Detail.Snoise();
|
|
var w0 = Noise.Phases(tile.Time).W0;
|
|
var noise = af_add(af_add(af_scale(n0, w0), af_scale(n1, 1.0f - w0)), af_scale(nd, 0.5f));
|
|
|
|
// The clamp residual of z = z_m + h eps(z), 2^-23 (|z_m| + h) (af_frustum_ball).
|
|
var residual = (abs(ball.z) + axisCamera.z) * 1.1920928955078125e-7f;
|
|
var depth = af_add_iv(af_symbol(ball.z, axisCamera.z), new float2(-residual, residual));
|
|
var lateral = tile.Half * z1 + (abs(tile.Slope.y) + tile.Half) * z1 * 2.384185791015625e-7f;
|
|
var level = af_add_iv(af_add_iv(af_add(af_point(tile.Origin.y), af_scale(depth, tile.Slope.y)), new float2(-lateral, lateral)), height);
|
|
var displaced = af_range(af_add(level, af_scale(af_mul(af_from_iv(fade), noise), Amplitude)));
|
|
var affine = new float2(max(0.0f, (FloorOffset - displaced.y) / FloorBlend), max(0.0f, (FloorOffset - displaced.x) / FloorBlend));
|
|
var interval = Compose(s, fade, iv_add(Noise.CoverageBound(Clip(af_range(n0)), Clip(af_range(n1)), tile.Time), iv_scale(Clip(af_range(nd)), 0.5f)));
|
|
return (affine, interval);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// (d) r1's uniform slab, kept as the worst case: the map's warped coverage field (F0 0.35, cutoff 0.25,
|
|
/// depth 12, period 4, extinction 2, flow in xy at 0.15, warp 0.05) with no envelope, so every cell is a
|
|
/// noise question. The dense sample evaluates the coverage first and the detail only where the coverage
|
|
/// passes the cutoff; ungated it would cost three snoise, as r1 counted.
|
|
/// </summary>
|
|
internal sealed class SlabField : Scenario
|
|
{
|
|
public const float F0 = 0.35f;
|
|
public const float Cutoff = 0.25f;
|
|
public const float Depth = 12.0f;
|
|
public const float SlabExtinction = 2.0f;
|
|
public const float SlabWarp = 0.05f;
|
|
|
|
public required WarpedNoise Noise { get; init; }
|
|
|
|
public SlabField() => Grid = Cells(0.125f, Depth);
|
|
|
|
public static SlabField Create(float warp) => new()
|
|
{
|
|
Name = "(d) uniform slab", Noise = new WarpedNoise { F0 = F0, Warp = warp, Period = 4.0f, FlowFrequency = 0.15f, FlowInXY = true },
|
|
};
|
|
|
|
public override Tile DrawTile(System.Random random, int n) => new()
|
|
{
|
|
Origin = new float3(Uniform(random, -50.0f, 50.0f), Uniform(random, -50.0f, 50.0f), 0.0f),
|
|
Slope = new float2(Uniform(random, -0.5f, 0.5f), Uniform(random, -0.5f, 0.5f)),
|
|
N = n, Focal = Focal, Time = Uniform(random, 0.0f, 100.0f),
|
|
};
|
|
|
|
public override float Density(Tile tile, float2 slope, float z, bool lod, Counts counts)
|
|
{
|
|
var p = tile.World(new float3(slope.x * z, slope.y * z, z));
|
|
var coverage = Noise.Coverage(p, tile.Time, counts);
|
|
if (coverage <= Cutoff)
|
|
return 0.0f;
|
|
counts.Snoise++;
|
|
var detail = 1.0f - abs(snoise(p * (4.0f * F0) + 17.0f));
|
|
return max(0.0f, coverage - Cutoff) * SlabExtinction * lerp(0.6f, 1.0f, detail);
|
|
}
|
|
|
|
public override float2 Bound(Tile tile, float z0, float z1, bool gated, Counts counts)
|
|
{
|
|
var maybe = new float2(0.0f, float.MaxValue);
|
|
var ball = iv_frustum_ball(tile.Slope, z0, z1, tile.Footprint, Noise.Warp);
|
|
if (gated && Noise.CoverageBound(BestBall(Noise.CoverageRadius(ball.w), low: true), BestBall(Noise.CoverageRadius(ball.w), low: true), tile.Time).y > Cutoff)
|
|
return maybe;
|
|
var coverageBall = Noise.CoverageBall(tile.World(new float3(ball.x, ball.y, ball.z)), ball.w, counts);
|
|
var coverage = Noise.CoverageBound(coverageBall, coverageBall, tile.Time);
|
|
return coverage.y <= Cutoff ? new float2(0.0f, 0.0f) : maybe;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// (e) The void: a cloud volume everywhere, carved by one negative pseudo-Gaussian sphere. Authored
|
|
/// knobs: hollow radius Rh (density exactly 0 inside) and ramp width S (the density rises to full over
|
|
/// [Rh, Rh + S]); everything else is derived from them. The brush (1 - (d / Rc)^2)^e with CARVE = 1.5
|
|
/// reaches full at Rc, so Rc = Rh + S, and Rh = Rc sqrt(1 - CARVE^(-1/e)) gives
|
|
/// e = -ln CARVE / ln(1 - (Rh / Rc)^2): density = K max(0, 1 - CARVE (1 - (d' / Rc)^2)^e), exactly 0 for
|
|
/// d' <= Rh and K for d' >= Rc, K = 1/30. The noise displaces the wall: fade =
|
|
/// smoothstep(Rh - S, Rh, d), d' = d + A fade n(warp(p)), A = 20, F0 = 0.01 (wavelengths 100 and 25),
|
|
/// warp D = 10. The sun orbits the origin at radius 175 with period 72 s and rests on the hollow's
|
|
/// bottom (c_v = sun + (0, Rh - 12.5, 0)); the camera looks at sun + (0, 60, 0) and, shipped, sits 120
|
|
/// behind the sun along the orbit tangent and 45 above it.
|
|
///
|
|
/// The one shipped void (ruling operator-shipped-void) is Shipped(): (Rh, S) = (198, 50), so Rc = 248
|
|
/// and e = -ln 1.5 / ln(1 - (198 / 248)^2), which the constructor derives; no other void is shipped.
|
|
///
|
|
/// The footprint-truncated field (lod) weights octave i by w_i(z) = 1 - smoothstep(l_i / 2m, l_i / m,
|
|
/// z theta), m = 4, theta = 1 / f. Deviation from the spec's [w_i(z1), w_i(z0)]: the bound takes each
|
|
/// weight over [w_i(z1), 1], because one Bound serves the full field (w = 1) and the truncated one
|
|
/// (w in [w_i(z1), w_i(z0)], w decreasing in z); [w_i(z1), 1] contains both and iv_mul is monotone in
|
|
/// its interval argument, so the composed interval encloses both fields. The slack is only in far
|
|
/// tiles, whose step the footprint already sets. At f = 935 every weight is 1 inside every grid;
|
|
/// LodFar() (f = 467) reaches weights in (0, 1) and at 0.
|
|
///
|
|
/// The dense grid is the fixed-step reference's: cells of min(10, S / 4) out to
|
|
/// |camera - c_v| + Rc + 6 / K. The density is provably K only from d >= Rc + A 1.5 (the free noise
|
|
/// interval), so 6 / K leaves at least 4.9 of optical depth past it and every ray goes opaque before
|
|
/// the grid ends.
|
|
/// </summary>
|
|
internal sealed class VoidField : Scenario
|
|
{
|
|
public const float Carve = 1.5f;
|
|
public const float Amplitude = 20.0f;
|
|
public const float K = 1.0f / 30.0f;
|
|
public const float StepFootprints = 4.0f; // k
|
|
public const float OctaveFootprints = 4.0f; // m
|
|
private static readonly float2 FreeNoise = new(-1.5f, 1.5f);
|
|
|
|
public static readonly float[] HollowRadii = { 100.0f, 200.0f, 400.0f, 1000.0f };
|
|
public static readonly float[] Ramps = { 10.0f, 50.0f, 150.0f };
|
|
public static readonly (string Name, float Fraction)[] CameraOffsets = { ("centred", 0.0f), ("mid", 0.5f), ("low", 0.94f) };
|
|
|
|
public readonly float HollowRadius;
|
|
public readonly float Ramp;
|
|
public readonly float CarveRadius;
|
|
public readonly float Exponent;
|
|
public readonly float RampStart;
|
|
public readonly float FixedStep;
|
|
public readonly float CameraFraction; // negative: the shipped camera
|
|
public readonly WarpedNoise Noise = new() { F0 = 0.01f, Warp = 10.0f };
|
|
|
|
private float CoarseWavelength => 1.0f / Noise.F0;
|
|
private float FineWavelength => 1.0f / (4.0f * Noise.F0);
|
|
private float Theta => 1.0f / Focal;
|
|
|
|
[System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
|
|
public VoidField(float hollowRadius, float ramp, float cameraFraction, string camera, float focal = 935.0f)
|
|
{
|
|
Focal = focal;
|
|
HollowRadius = hollowRadius;
|
|
Ramp = ramp;
|
|
CarveRadius = hollowRadius + ramp;
|
|
Exponent = (float)(-Math.Log(Carve) / Math.Log(1.0 - (double)hollowRadius / CarveRadius * hollowRadius / CarveRadius));
|
|
RampStart = hollowRadius - ramp;
|
|
FixedStep = min(10.0f, ramp / 4.0f);
|
|
CameraFraction = cameraFraction;
|
|
Name = $"(e) void Rh={hollowRadius:R} camera={camera} S={ramp:R}" + (focal == 935.0f ? "" : $" f={focal:R}");
|
|
var reach = cameraFraction < 0.0f ? length(new float2(120.0f, 45.0f - (hollowRadius - 12.5f))) : cameraFraction * hollowRadius;
|
|
Grid = Cells(FixedStep, reach + CarveRadius + 6.0f / K);
|
|
}
|
|
|
|
public static VoidField Shipped() => new(198.0f, 50.0f, -1.0f, "shipped");
|
|
|
|
/// <summary>
|
|
/// The configuration that reaches the octave weights: Rh = 4000, S = 50, the low camera, f = 467
|
|
/// (1080 rows at a 98 degree vertical field of view). The fine weight is below 1 past z = 1460 and 0
|
|
/// past 2920, the coarse below 1 past 5840, and the grid reaches about 7990. Printed, not asserted.
|
|
/// </summary>
|
|
public static VoidField LodFar() => new(4000.0f, 50.0f, 0.94f, "low", 467.0f);
|
|
|
|
/// <summary>The coarse and fine octave weights at depth z (1 for the full field).</summary>
|
|
public (float Coarse, float Fine) Weights(float z) => (Weight(CoarseWavelength, z * Theta), Weight(FineWavelength, z * Theta));
|
|
|
|
/// <summary>The coarse and fine octave weight bounds Bound uses over [z0, z1].</summary>
|
|
public (float2 Coarse, float2 Fine) WeightBounds(float z0, float z1) => (WeightBound(CoarseWavelength, z0, z1), WeightBound(FineWavelength, z0, z1));
|
|
|
|
private sealed class VoidTile : Tile
|
|
{
|
|
public float3 Cavity; // c_v in the camera frame
|
|
}
|
|
|
|
public override Tile DrawTile(System.Random random, int n)
|
|
{
|
|
var time = Uniform(random, 0.0f, 72.0f);
|
|
var angle = 2.0f * MathF.PI * time / 72.0f;
|
|
var sun = new float3(175.0f * MathF.Cos(angle), 0.0f, 175.0f * MathF.Sin(angle));
|
|
var tangent = new float3(-MathF.Sin(angle), 0.0f, MathF.Cos(angle));
|
|
var cavity = sun + new float3(0.0f, HollowRadius - 12.5f, 0.0f);
|
|
var target = sun + new float3(0.0f, 60.0f, 0.0f);
|
|
float3 origin;
|
|
float3 forward;
|
|
if (CameraFraction < 0.0f)
|
|
{
|
|
origin = sun - tangent * 120.0f + new float3(0.0f, 45.0f, 0.0f);
|
|
forward = target - origin;
|
|
}
|
|
else
|
|
{
|
|
// The shipped camera's direction from the centre, at a fraction of this hollow's radius;
|
|
// centred, it looks along the orbit, slightly down.
|
|
origin = cavity + normalize(tangent * -120.0f + new float3(0.0f, 45.0f - (198.0f - 12.5f), 0.0f)) * (CameraFraction * HollowRadius);
|
|
forward = CameraFraction == 0.0f ? tangent * -1.0f + new float3(0.0f, -0.2f, 0.0f) : target - origin;
|
|
}
|
|
|
|
var tile = new VoidTile
|
|
{
|
|
Origin = origin, N = n, Focal = Focal, Time = time,
|
|
Slope = new float2((random.Next(1920 / n) * n + n * 0.5f - 960.0f) / Focal, (random.Next(1080 / n) * n + n * 0.5f - 540.0f) / Focal),
|
|
};
|
|
tile.Look(forward);
|
|
tile.Cavity = tile.Camera(cavity);
|
|
return tile;
|
|
}
|
|
|
|
private static float Weight(float wavelength, float footprint) =>
|
|
1.0f - smoothstep(wavelength / (2.0f * OctaveFootprints), wavelength / OctaveFootprints, footprint);
|
|
|
|
private float2 WeightBound(float wavelength, float z0, float z1) =>
|
|
new(iv_sub(iv_point(1.0f), iv_smoothstep(wavelength / (2.0f * OctaveFootprints), wavelength / OctaveFootprints, new float2(z0 * Theta, z1 * Theta))).x, 1.0f);
|
|
|
|
/// <summary>The footprint step: max(k z theta, min(S / 4, half the finest live wavelength)).</summary>
|
|
public float LodStep(float z)
|
|
{
|
|
var footprint = z * Theta;
|
|
var fine = Weight(FineWavelength, footprint) > 0.0f ? FineWavelength : Weight(CoarseWavelength, footprint) > 0.0f ? CoarseWavelength : float.PositiveInfinity;
|
|
return max(StepFootprints * footprint, min(Ramp / 4.0f, fine / 2.0f));
|
|
}
|
|
|
|
private float Profile(float displaced)
|
|
{
|
|
if (displaced <= HollowRadius)
|
|
return 0.0f;
|
|
var x = displaced / CarveRadius;
|
|
var g = displaced < CarveRadius ? pow(max(0.0f, 1.0f - x * x), Exponent) : 0.0f;
|
|
return K * max(0.0f, 1.0f - Carve * g);
|
|
}
|
|
|
|
private float2 Compose(float2 d, float2 fade, float2 n)
|
|
{
|
|
var displaced = iv_add(d, iv_scale(iv_mul(fade, n), Amplitude));
|
|
return new float2(displaced.x >= CarveRadius ? K : 0.0f, displaced.y <= HollowRadius ? 0.0f : K);
|
|
}
|
|
|
|
public override float Density(Tile t, float2 slope, float z, bool lod, Counts counts)
|
|
{
|
|
var tile = (VoidTile)t;
|
|
var x = slope.x * z;
|
|
var y = slope.y * z;
|
|
counts.Envelope++;
|
|
var dx = x - tile.Cavity.x;
|
|
var dy = y - tile.Cavity.y;
|
|
var dz = z - tile.Cavity.z;
|
|
var d = sqrt(dx * dx + dy * dy + dz * dz);
|
|
var fade = smoothstep(RampStart, HollowRadius, d);
|
|
var free = Compose(iv_point(d), iv_point(fade), FreeNoise);
|
|
if (free.x == free.y)
|
|
return free.x;
|
|
var p = tile.World(new float3(x, y, z));
|
|
var wc = lod ? Weight(CoarseWavelength, z * Theta) : 1.0f;
|
|
var wd = lod ? Weight(FineWavelength, z * Theta) : 1.0f;
|
|
var coverage = wc > 0.0f ? Noise.Coverage(p, tile.Time, counts) * wc : 0.0f;
|
|
var partial = Compose(iv_point(d), iv_point(fade), iv_add(iv_point(coverage), iv_scale(new float2(-wd, wd), 0.5f)));
|
|
if (partial.x == partial.y)
|
|
return partial.x;
|
|
var detail = wd > 0.0f ? Noise.Detail(p, counts) * 0.5f * wd : 0.0f;
|
|
return Profile(d + fade * (coverage + detail) * Amplitude);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The field with no envelope gate: both noise terms always evaluated, the profile applied to the
|
|
/// displaced distance. Density's shortcuts go through Compose; this does not, so it is the value a
|
|
/// wrong Compose cannot also corrupt. Not counted.
|
|
/// </summary>
|
|
public float Exact(Tile t, float2 slope, float z, bool lod)
|
|
{
|
|
var tile = (VoidTile)t;
|
|
var counts = new Counts();
|
|
var (x, y) = (slope.x * z, slope.y * z);
|
|
var (dx, dy, dz) = (x - tile.Cavity.x, y - tile.Cavity.y, z - tile.Cavity.z);
|
|
var d = sqrt(dx * dx + dy * dy + dz * dz);
|
|
var fade = smoothstep(RampStart, HollowRadius, d);
|
|
var p = tile.World(new float3(x, y, z));
|
|
var wc = lod ? Weight(CoarseWavelength, z * Theta) : 1.0f;
|
|
var wd = lod ? Weight(FineWavelength, z * Theta) : 1.0f;
|
|
var n = Noise.Coverage(p, tile.Time, counts) * wc + Noise.Detail(p, counts) * 0.5f * wd;
|
|
return Profile(d + fade * n * Amplitude);
|
|
}
|
|
|
|
public override float2 Bound(Tile t, float z0, float z1, bool gated, Counts counts)
|
|
{
|
|
var tile = (VoidTile)t;
|
|
var depth = new float2(z0, z1);
|
|
counts.Envelope++;
|
|
var (nx, fx) = AxisDistance(iv_mul(depth, tile.SlopesX), tile.Cavity.x);
|
|
var (ny, fy) = AxisDistance(iv_mul(depth, tile.SlopesY), tile.Cavity.y);
|
|
var (nz, fz) = AxisDistance(depth, tile.Cavity.z);
|
|
var d = new float2(sqrt(nx * nx + ny * ny + nz * nz), sqrt(fx * fx + fy * fy + fz * fz));
|
|
var fade = iv_smoothstep(RampStart, HollowRadius, d);
|
|
var envelope = Compose(d, fade, FreeNoise);
|
|
if (gated && envelope.y <= 0.0f)
|
|
return envelope;
|
|
var ball = iv_frustum_ball(tile.Slope, z0, z1, tile.Footprint, Noise.Warp);
|
|
var wc = WeightBound(CoarseWavelength, z0, z1);
|
|
var wd = WeightBound(FineWavelength, z0, z1);
|
|
if (gated)
|
|
{
|
|
var provable = false;
|
|
foreach (var low in new[] { true, false })
|
|
{
|
|
var best = iv_add(iv_mul(Noise.CoverageBound(BestBall(Noise.CoverageRadius(ball.w), low), BestBall(Noise.CoverageRadius(ball.w), low), tile.Time), wc), iv_mul(iv_scale(BestBall(Noise.DetailRadius(ball.w), low), 0.5f), wd));
|
|
provable |= Compose(d, fade, best).y <= 0.0f;
|
|
}
|
|
|
|
if (!provable)
|
|
return envelope;
|
|
}
|
|
|
|
var centre = tile.World(new float3(ball.x, ball.y, ball.z));
|
|
var coverage = Noise.CoverageBall(centre, ball.w, counts);
|
|
var noise = iv_add(iv_mul(Noise.CoverageBound(coverage, coverage, tile.Time), wc), iv_mul(iv_scale(Noise.DetailBall(centre, ball.w, counts), 0.5f), wd));
|
|
return Compose(d, fade, noise);
|
|
}
|
|
}
|
|
|
|
// One cell's transmittance: the density at the cell's mid-depth, over the ray's length through it.
|
|
private static float Integrate(Scenario field, Tile tile, float2 slope, float z0, float z1, bool lod, Counts counts) =>
|
|
exp(-(field.Density(tile, slope, (z0 + z1) * 0.5f, lod, counts) * field.Extinction) * ((z1 - z0) * sqrt(slope.x * slope.x + slope.y * slope.y + 1.0f)));
|
|
|
|
/// <summary>
|
|
/// The tile pre-pass on cell-index ranges: probe the slice of the range; provably empty skips it and
|
|
/// doubles the next range; otherwise a range of more than one cell halves, and a single cell is dense.
|
|
/// It starts with the whole grid, runs to the grid's end (a pre-pass knows no ray's transmittance) and
|
|
/// yields the dense cells in order. Its probes are counted once per tile.
|
|
///
|
|
/// The body (ruling operator-analytic-wall-body): when a single dense cell's own bound is a positive
|
|
/// point (the cell is provably constant), one more probe over [grid[j], grid end]; if that is a
|
|
/// positive point too, every point of every ray of the tile over the rest of the grid has exactly that
|
|
/// density, so BodyStart = j, BodyDensity = its value, and every remaining cell is appended dense
|
|
/// without probing (adding cells is always sound, so the mask keeps its meaning). BodyStart is -1 when
|
|
/// no body is proven.
|
|
/// </summary>
|
|
private static (List<int> Dense, int BodyStart, float BodyDensity) MarchTile(Scenario field, Tile tile, Counts probes)
|
|
{
|
|
var grid = field.Grid;
|
|
var cells = grid.Length - 1;
|
|
var dense = new List<int>();
|
|
var j = 0;
|
|
var span = cells;
|
|
while (j < cells)
|
|
{
|
|
var end = Math.Min(j + span, cells);
|
|
var bound = field.Bound(tile, grid[j], grid[end], gated: true, probes);
|
|
if (bound.y <= 0.0f)
|
|
{
|
|
j = end;
|
|
span = Math.Min(span * 2, cells);
|
|
}
|
|
else if (end - j > 1)
|
|
{
|
|
span = (end - j + 1) / 2;
|
|
}
|
|
else
|
|
{
|
|
if (bound.x == bound.y && bound.x > 0.0f)
|
|
{
|
|
var rest = field.Bound(tile, grid[j], grid[cells], gated: true, probes);
|
|
if (rest.x == rest.y && rest.x > 0.0f)
|
|
{
|
|
dense.AddRange(Enumerable.Range(j, cells - j));
|
|
return (dense, j, rest.x);
|
|
}
|
|
}
|
|
|
|
dense.Add(j);
|
|
j++;
|
|
}
|
|
}
|
|
|
|
return (dense, -1, 0.0f);
|
|
}
|
|
|
|
// The body's optical depth from z to the grid's end, at the density the field's pointwise envelope gives
|
|
// at z (one envelope evaluation; no probe in the main pass). MarchLod takes exp(-BodyDepth) as one step.
|
|
private static float BodyDepth(Scenario field, Tile tile, float2 slope, float z, bool lod, Counts counts) =>
|
|
field.Density(tile, slope, z, lod, counts) * field.Extinction * ((field.Grid[^1] - z) * sqrt(slope.x * slope.x + slope.y * slope.y + 1.0f));
|
|
|
|
// The footprint-aware march, the void's shipped one: steps of LodStep(z) inside the unmasked runs only,
|
|
// octave weights applied, early-out below cutoff; at the body's start, one analytic step to the grid's
|
|
// end, and stop. AtBody is the transmittance when the march reaches the body's start, before that step,
|
|
// or the final transmittance when there is no body.
|
|
private static (float Transmittance, float AtBody) MarchLod(VoidField field, Tile tile, float2 slope, List<int> cells, int bodyStart, float cutoff, Counts counts, ref long steps, ref long bodySteps)
|
|
{
|
|
var grid = field.Grid;
|
|
var body = bodyStart >= 0 ? grid[bodyStart] : float.PositiveInfinity;
|
|
var transmittance = 1.0f;
|
|
var k = 0;
|
|
while (k < cells.Count && transmittance >= cutoff)
|
|
{
|
|
var z = grid[cells[k]];
|
|
var end = cells[k] + 1;
|
|
for (k++; k < cells.Count && cells[k] == end; k++)
|
|
end++;
|
|
while (z < grid[end] && transmittance >= cutoff)
|
|
{
|
|
if (z >= body)
|
|
{
|
|
var atBody = transmittance;
|
|
transmittance *= exp(-BodyDepth(field, tile, slope, z, lod: true, counts));
|
|
steps++;
|
|
bodySteps++;
|
|
return (transmittance, atBody);
|
|
}
|
|
|
|
var next = min(min(z + field.LodStep(z), grid[end]), z < body ? body : grid[end]);
|
|
transmittance *= Integrate(field, tile, slope, z, next, lod: true, counts);
|
|
steps++;
|
|
z = next;
|
|
}
|
|
}
|
|
|
|
return (transmittance, transmittance);
|
|
}
|
|
|
|
// The accuracy oracle before the body: the optical depth over every grid cell below `stop` (never the
|
|
// mask's, so it trusts no pre-pass), by midpoint quadrature at step h, accumulated in double. Not counted.
|
|
private static double ConvergedDepth(Scenario field, Tile tile, float2 slope, int stop, float h, bool lod)
|
|
{
|
|
var grid = field.Grid;
|
|
var counts = new Counts();
|
|
var dir = Math.Sqrt(slope.x * (double)slope.x + slope.y * (double)slope.y + 1.0);
|
|
var tau = 0.0;
|
|
for (var c = 0; c < stop; c++)
|
|
{
|
|
var samples = Math.Max(1, (int)Math.Ceiling((grid[c + 1] - grid[c]) / h));
|
|
var dz = (grid[c + 1] - grid[c]) / (double)samples;
|
|
for (var s = 0; s < samples; s++)
|
|
tau += field.Density(tile, slope, (float)(grid[c] + (s + 0.5) * dz), lod, counts) * (double)field.Extinction * dz * dir;
|
|
}
|
|
|
|
return tau;
|
|
}
|
|
|
|
internal sealed class RunStats
|
|
{
|
|
public readonly Counts Dense = new();
|
|
public readonly Counts Masked = new();
|
|
public readonly Counts Probes = new();
|
|
public readonly Counts Lod = new();
|
|
public readonly Counts Oracle = new();
|
|
public readonly Counts Range = new();
|
|
public long Rays;
|
|
public long Tiles;
|
|
public long DenseSteps;
|
|
public long MaskedSteps;
|
|
public long LodSteps;
|
|
public long BodySteps;
|
|
public long BodyTiles;
|
|
public long Unfinished;
|
|
public float MaxDifference;
|
|
public float MaxLodDifference;
|
|
public float MaxOracleDifference;
|
|
public long OracleEmpty;
|
|
public long OracleEmptySkipped;
|
|
public long RangeEmpty;
|
|
public long RangeEmptySkipped;
|
|
public long RangeViolations;
|
|
public bool Ranged;
|
|
public double MaxLodDepthError;
|
|
public double MaxRefDepthError;
|
|
|
|
public double PerRay(double value) => value / Rays;
|
|
|
|
// Dense cost over the tile march's: each ray's own dense cells plus the tile's probes, shared.
|
|
public double Ratio => Dense.Cost / (Masked.Cost + Probes.Cost);
|
|
|
|
public double LodRatio => Dense.Cost / (Lod.Cost + Probes.Cost);
|
|
|
|
// The oracle mask (the cells some ray of the tile samples nonzero) is a ceiling no pre-pass can move.
|
|
public double OracleCeiling => Dense.Cost / Oracle.Cost;
|
|
|
|
public double Efficiency => Oracle.Cost / (Masked.Cost + Probes.Cost);
|
|
|
|
// The tile march over the tile march plus its probes: what the probes cost, not what they cull.
|
|
public double ProbeOverhead => Masked.Cost / (Masked.Cost + Probes.Cost);
|
|
|
|
// The oracle-empty cells the pre-pass proves empty; a pre-pass that skips nothing scores 0.
|
|
public double CullFraction => (double)OracleEmptySkipped / OracleEmpty;
|
|
|
|
// The range ceiling: the oracle march's cost over the range mask's, with probes free. The range
|
|
// mask is what an exact enclosure of each cell over the tile's whole slice could prove, so no sound
|
|
// bound can reach the oracle, only this; it is sampled, so it is an upper estimate.
|
|
public double RangeCeiling => Oracle.Cost / Range.Cost;
|
|
|
|
// The tile march's efficiency against the range ceiling: the range mask's cost over the tile
|
|
// march's with its probes.
|
|
public double RangeEfficiency => Range.Cost / (Masked.Cost + Probes.Cost);
|
|
|
|
public double RangeCull => (double)RangeEmptySkipped / RangeEmpty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws `tiles` tiles, runs the pre-pass once per tile, draws the tile's N^2 ray slopes (one per pixel,
|
|
/// jittered inside it), builds the oracle mask (the cells whose mid-depth density is nonzero for at least
|
|
/// one of those slopes), and marches each ray densely over every cell, masked over the tile's dense cells
|
|
/// and over the oracle cells, all with the early-out at 0.02 and the same Integrate. With lod, the
|
|
/// footprint-aware march as well, and the agreement at the body start (or the grid's end): the optical
|
|
/// depth of a cutoff-0 footprint march and of the fixed-step cells, each against ConvergedDepth at
|
|
/// h = 0.5. The oracle mask and the agreement marches count into throwaway Counts, the oracle march into
|
|
/// its own, so the cost rows do not move. With range, the range mask as well: the oracle mask plus
|
|
/// every cell where the density is positive at any of 5 x 5 slopes over the tile's slope square
|
|
/// (corners included) x 9 depths over the cell; each ray is marched over it into Range, and a cell of
|
|
/// it the pre-pass skipped is counted as a range violation (a sampled point a bound failed to enclose).
|
|
/// </summary>
|
|
private static RunStats Run(Scenario field, int n, int tiles, int seed, bool lod = false, bool range = false)
|
|
{
|
|
var random = new System.Random(seed);
|
|
var stats = new RunStats { Ranged = range };
|
|
var scratch = new Counts();
|
|
var grid = field.Grid;
|
|
for (var t = 0; t < tiles; t++)
|
|
{
|
|
var tile = field.DrawTile(random, n);
|
|
var (cells, bodyStart, _) = MarchTile(field, tile, stats.Probes);
|
|
Assert.True(field is VoidField || bodyStart < 0, $"{field.Name}: a body reported at cell {bodyStart}; only the void's density can be provably constant");
|
|
stats.Tiles++;
|
|
stats.BodyTiles += bodyStart >= 0 ? 1 : 0;
|
|
var slopes = new List<float2>();
|
|
for (var j = 0; j < n; j++)
|
|
for (var i = 0; i < n; i++)
|
|
slopes.Add(tile.PixelSlope(i, j, random.NextSingle(), random.NextSingle()));
|
|
var inMask = new HashSet<int>(cells);
|
|
var oracle = new List<int>();
|
|
for (var c = 0; c < grid.Length - 1; c++)
|
|
{
|
|
var mid = (grid[c] + grid[c + 1]) * 0.5f;
|
|
if (slopes.Any(s => field.Density(tile, s, mid, false, scratch) > 0.0f))
|
|
{
|
|
oracle.Add(c);
|
|
}
|
|
else
|
|
{
|
|
stats.OracleEmpty++;
|
|
stats.OracleEmptySkipped += inMask.Contains(c) ? 0 : 1;
|
|
}
|
|
}
|
|
|
|
var ranged = new List<int>();
|
|
if (range)
|
|
{
|
|
var inOracle = new HashSet<int>(oracle);
|
|
for (var c = 0; c < grid.Length - 1; c++)
|
|
{
|
|
var sampled = inOracle.Contains(c);
|
|
for (var a = 0; a < 5 && !sampled; a++)
|
|
for (var b = 0; b < 5 && !sampled; b++)
|
|
for (var k = 0; k < 9 && !sampled; k++)
|
|
{
|
|
var slope = new float2(tile.SlopesX.x + (tile.SlopesX.y - tile.SlopesX.x) * a / 4.0f, tile.SlopesY.x + (tile.SlopesY.y - tile.SlopesY.x) * b / 4.0f);
|
|
sampled = field.Density(tile, slope, grid[c] + (grid[c + 1] - grid[c]) * k / 8.0f, false, scratch) > 0.0f;
|
|
}
|
|
|
|
if (sampled)
|
|
{
|
|
ranged.Add(c);
|
|
stats.RangeViolations += inMask.Contains(c) ? 0 : 1;
|
|
}
|
|
else
|
|
{
|
|
stats.RangeEmpty++;
|
|
stats.RangeEmptySkipped += inMask.Contains(c) ? 0 : 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
var stop = bodyStart >= 0 ? bodyStart : grid.Length - 1;
|
|
foreach (var slope in slopes)
|
|
{
|
|
var dense = 1.0f;
|
|
for (var c = 0; c < grid.Length - 1 && dense >= 0.02f; c++, stats.DenseSteps++)
|
|
dense *= Integrate(field, tile, slope, grid[c], grid[c + 1], lod: false, stats.Dense);
|
|
var masked = 1.0f;
|
|
for (var c = 0; c < cells.Count && masked >= 0.02f; c++, stats.MaskedSteps++)
|
|
masked *= Integrate(field, tile, slope, grid[cells[c]], grid[cells[c] + 1], lod: false, stats.Masked);
|
|
var exact = 1.0f;
|
|
for (var c = 0; c < oracle.Count && exact >= 0.02f; c++)
|
|
exact *= Integrate(field, tile, slope, grid[oracle[c]], grid[oracle[c] + 1], lod: false, stats.Oracle);
|
|
var rangedT = 1.0f;
|
|
for (var c = 0; c < ranged.Count && rangedT >= 0.02f; c++)
|
|
rangedT *= Integrate(field, tile, slope, grid[ranged[c]], grid[ranged[c] + 1], lod: false, stats.Range);
|
|
stats.Rays++;
|
|
stats.MaxDifference = MathF.Max(stats.MaxDifference, MathF.Abs(dense - masked));
|
|
stats.MaxOracleDifference = MathF.Max(stats.MaxOracleDifference, MathF.Abs(dense - exact));
|
|
if (dense >= 0.02f)
|
|
stats.Unfinished++;
|
|
if (lod)
|
|
{
|
|
var (marched, _) = MarchLod((VoidField)field, tile, slope, cells, bodyStart, 0.02f, stats.Lod, ref stats.LodSteps, ref stats.BodySteps);
|
|
stats.MaxLodDifference = MathF.Max(stats.MaxLodDifference, MathF.Abs(dense - marched));
|
|
long ignoredSteps = 0, ignoredBody = 0;
|
|
var (_, atBody) = MarchLod((VoidField)field, tile, slope, cells, bodyStart, 0.0f, scratch, ref ignoredSteps, ref ignoredBody);
|
|
var reference = 1.0;
|
|
for (var c = 0; c < stop; c++)
|
|
reference *= Integrate(field, tile, slope, grid[c], grid[c + 1], lod: false, scratch);
|
|
var tauConv = ConvergedDepth(field, tile, slope, stop, 0.5f, lod: true);
|
|
var tauLod = -Math.Log(Math.Max(atBody, 1.0e-30f));
|
|
var tauRef = -Math.Log(Math.Max(reference, 1.0e-300));
|
|
var scale = Math.Max(tauConv, 1.0e-3);
|
|
stats.MaxLodDepthError = Math.Max(stats.MaxLodDepthError, Math.Abs(tauLod - tauConv) / scale);
|
|
stats.MaxRefDepthError = Math.Max(stats.MaxRefDepthError, Math.Abs(tauRef - tauConv) / scale);
|
|
}
|
|
}
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
private void Report(string label, RunStats s) => output.WriteLine(
|
|
$"IV-REPORT {label}: dense snoise/ray {s.PerRay(s.Dense.Snoise):F2} env/ray {s.PerRay(s.Dense.Envelope):F2} (samples/ray {s.PerRay(s.DenseSteps):F2}); "
|
|
+ $"tile snoise/ray {s.PerRay(s.Masked.Snoise + (double)s.Probes.Snoise):F2} env/ray {s.PerRay(s.Masked.Envelope + (double)s.Probes.Envelope):F2} grad/ray {s.PerRay(s.Probes.Grad):F2} "
|
|
+ $"(probes/tile env {(double)s.Probes.Envelope / s.Tiles:F1} snoise {(double)s.Probes.Snoise / s.Tiles:F2} grad {(double)s.Probes.Grad / s.Tiles:F2}; dense cells/ray {s.PerRay(s.MaskedSteps):F2}); "
|
|
+ $"cost ratio {s.Ratio:F2}x, oracle ceiling {s.OracleCeiling:F2}x, efficiency {s.Efficiency:F3}, cull fraction {s.CullFraction:F3} ({s.OracleEmptySkipped}/{s.OracleEmpty}), "
|
|
+ $"probe overhead {s.ProbeOverhead:F3}; max |dT| {s.MaxDifference:R}, oracle {s.MaxOracleDifference:R}; unfinished rays {s.Unfinished}"
|
|
+ (s.Ranged
|
|
? $"; range ceiling {s.RangeCeiling:F3} (oracle cost over the range mask's), efficiency against it {s.RangeEfficiency:F3}, range cull {s.RangeCull:F3} ({s.RangeEmptySkipped}/{s.RangeEmpty}), range violations {s.RangeViolations}"
|
|
: ""));
|
|
|
|
private void ReportVoid(string label, RunStats s) => output.WriteLine(
|
|
$"IV-REPORT {label}: steps/px ref {s.PerRay(s.DenseSteps):F2}, masked {s.PerRay(s.MaskedSteps):F2}, LOD {s.PerRay(s.LodSteps):F2} ({(double)s.DenseSteps / s.LodSteps:F2}x fewer); "
|
|
+ $"snoise/px ref {s.PerRay(s.Dense.Snoise):F2}, masked {s.PerRay(s.Masked.Snoise):F2}, LOD {s.PerRay(s.Lod.Snoise):F2}; "
|
|
+ $"cost/px ref {s.PerRay(s.Dense.Cost):F2}, masked {s.PerRay(s.Masked.Cost + s.Probes.Cost):F2}, LOD {s.PerRay(s.Lod.Cost + s.Probes.Cost):F2} incl. probes (ref/LOD {s.LodRatio:F2}x, ref/masked {s.Ratio:F2}x); "
|
|
+ $"probes/tile env {(double)s.Probes.Envelope / s.Tiles:F1} snoise {(double)s.Probes.Snoise / s.Tiles:F2}; body tiles {s.BodyTiles}/{s.Tiles}, analytic steps/px {s.PerRay(s.BodySteps):F2}; "
|
|
+ $"depth error at the body start vs converged: LOD {s.MaxLodDepthError:G4}, fixed-step {s.MaxRefDepthError:G4} (ratio {s.MaxLodDepthError / s.MaxRefDepthError:F2}); "
|
|
+ $"end-of-ray max |dT| masked {s.MaxDifference:R}, LOD {s.MaxLodDifference:R}; unfinished rays {s.Unfinished}");
|
|
|
|
/// <summary>
|
|
/// The analytic body's soundness pin (ruling operator-analytic-wall-body). Over 2,000 seeded tiles of
|
|
/// the (e) grid, the shipped void and LOD-far at N in {1, 4, 8, 16}, wherever the pre-pass reports a
|
|
/// body start b: (1) the gated bound over [grid[b], grid end] is a positive point equal to the reported
|
|
/// density, every cell from b to the end is in the mask, and at 64 random (pixel, sub-pixel, cell >= b,
|
|
/// depth in cell) points the full and the truncated Density and the ungated Exact field all equal it
|
|
/// exactly, no tolerance; (2) BodyDepth, the optical depth MarchLod's one closed-form step uses, equals
|
|
/// the summed fixed-step depth -ln Integrate of the body cells (no early-out, same cells) within 1e-4
|
|
/// relative, compared in the depth domain so LOD-far's deep bodies do not underflow. (3) No tile of (a)-(d), at either warp, reports a body: their bounds are never a positive
|
|
/// point.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AnalyticBodyMatchesFineMarch()
|
|
{
|
|
var random = new System.Random(0xB0D1);
|
|
int[] sizes = { 1, 4, 8, 16 };
|
|
var voids = new List<VoidField>();
|
|
foreach (var rh in VoidField.HollowRadii)
|
|
foreach (var offset in VoidField.CameraOffsets)
|
|
foreach (var ramp in VoidField.Ramps)
|
|
voids.Add(new VoidField(rh, ramp, offset.Fraction, offset.Name));
|
|
voids.Add(VoidField.Shipped());
|
|
voids.Add(VoidField.LodFar());
|
|
var counts = new Counts();
|
|
var bodies = new Dictionary<string, int>();
|
|
for (var t = 0; t < 2000; t++)
|
|
{
|
|
var field = t % 4 == 0 ? voids[^2] : t % 4 == 1 ? voids[^1] : voids[random.Next(voids.Count)];
|
|
var tile = field.DrawTile(random, sizes[random.Next(sizes.Length)]);
|
|
var (cells, b, density) = MarchTile(field, tile, counts);
|
|
if (b < 0)
|
|
continue;
|
|
bodies[field.Name] = bodies.GetValueOrDefault(field.Name) + 1;
|
|
var grid = field.Grid;
|
|
var last = grid.Length - 1;
|
|
var rest = field.Bound(tile, grid[b], grid[last], gated: true, counts);
|
|
Assert.True(rest.x == rest.y && rest.x > 0.0f && rest.x == density, $"{field.Name}: body at cell {b} with density {density:R}, but the bound over the rest is {rest}");
|
|
Assert.Equal(Enumerable.Range(b, last - b), cells.Skip(cells.Count - (last - b)));
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var slope = tile.PixelSlope(random.Next(tile.N), random.Next(tile.N), random.NextSingle(), random.NextSingle());
|
|
var c = random.Next(b, last);
|
|
var z = Uniform(random, grid[c], grid[c + 1]);
|
|
foreach (var lod in new[] { false, true })
|
|
{
|
|
var pointwise = field.Density(tile, slope, z, lod, counts);
|
|
var exact = field.Exact(tile, slope, z, lod);
|
|
Assert.True(pointwise == density && exact == density, $"{field.Name}: body from cell {b} claims {density:R}; at slope {slope}, depth {z:R} (cell {c}, lod {lod}) Density is {pointwise:R} and the ungated field {exact:R}");
|
|
}
|
|
}
|
|
|
|
var ray = tile.PixelSlope(random.Next(tile.N), random.Next(tile.N), random.NextSingle(), random.NextSingle());
|
|
var depth = BodyDepth(field, tile, ray, grid[b], lod: true, counts);
|
|
var sum = 0.0;
|
|
for (var c = b; c < last; c++)
|
|
sum += -Math.Log(Integrate(field, tile, ray, grid[c], grid[c + 1], lod: true, counts));
|
|
Assert.True(Math.Abs(depth - sum) <= 1.0e-4 * sum, $"{field.Name}: the body's depth from cell {b}, BodyDepth {depth:R}, differs from the fixed-step cells' summed depth {sum:R} over cells {b}..{last}");
|
|
}
|
|
|
|
output.WriteLine("IV-REPORT analytic body: tiles with a body " + string.Join(", ", bodies.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key} {kv.Value}")));
|
|
Assert.True(bodies.ContainsKey(VoidField.Shipped().Name) && bodies.ContainsKey(VoidField.LodFar().Name), "no body proven at the shipped void or at LOD-far");
|
|
|
|
var scenarios = new (float Warp, Func<float, Scenario> Make)[]
|
|
{
|
|
(FogField.AetheriaWarp, w => FogField.HeightFog(w, BoundMode.Interval)), (FogField.AetheriaWarp, w => FogField.InsideFog(w, BoundMode.Interval)),
|
|
(FogField.AetheriaWarp, w => FogField.DeepWell(w, BoundMode.Interval)), (FogField.AetheriaWarp, w => FogField.DeepWell(w, BoundMode.Affine)),
|
|
(FogField.AetheriaWarp, w => FogField.Wells(w, BoundMode.Interval)), (FogField.AetheriaWarp, w => FogField.Wells(w, BoundMode.Affine)),
|
|
(SlabField.SlabWarp, SlabField.Create),
|
|
};
|
|
foreach (var (warp, make) in scenarios)
|
|
foreach (var w in new[] { 0.0f, warp })
|
|
{
|
|
var field = make(w);
|
|
for (var t = 0; t < 250; t++)
|
|
{
|
|
var (_, b, _) = MarchTile(field, field.DrawTile(random, sizes[random.Next(sizes.Length)]), counts);
|
|
Assert.True(b < 0, $"{field.Name} warp {w:R}: a body reported at cell {b}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The proving ground (docs/cultmath-interval-ground-cut.md, "Pass 3", "Pass 4" and "Pass 5b").
|
|
/// Scenarios (a) height fog and (b) inside the fog under the centred-warp interval bound, (c) the deep
|
|
/// well and (c-band) the shallow wells each under the centred-warp interval bound and the affine bound
|
|
/// on the same seeds, and (d) r1's uniform slab, each over 64 tiles at N in {1, 4, 8, 16} and warp in
|
|
/// {0, D}; (c) and (c-band) also print the range ceiling at N = 8, and no range-mask cell is skipped: the tile march integrates exactly the dense march's
|
|
/// cells, so the transmittance agrees within 1e-3 (a difference would be a skipped cell that was not
|
|
/// empty), the oracle march (each ray over the oracle mask: the cells whose mid-depth density is nonzero
|
|
/// for at least one of the tile's rays) agrees with the dense march exactly, and no (a)-(d) tile reports
|
|
/// a body. (e) the void: the grid over hollow radius, camera offset and ramp width (32 tiles at N = 8),
|
|
/// LOD-far (32 tiles) and the one shipped void (200 tiles), three marches each, the footprint-aware one
|
|
/// taking the analytic body step. Contracts at N = 8: (a) at least 2x combined cost, the lower of warp
|
|
/// 0 and D; (c) (ruling affine-wells-contract-cheaper-than-interval) on the deep well the affine
|
|
/// bound's combined cost ratio exceeds the centred interval's at warp 0 and at warp D, and, hard
|
|
/// (ruling wells-overhead-plus-floor), the pre-pass proves at least half of the oracle-empty cells
|
|
/// empty under both bounds at both warps; probe overhead, the efficiency against the oracle and against
|
|
/// 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-aware march's optical depth at the body start is off a converged march (h = 0.5, every
|
|
/// cell) by at most twice the fixed-step march's error, and it takes at most half the reference's
|
|
/// steps per pixel. A broken (c) contract fails the test; a shortfall of the (a) or (e) saving skips
|
|
/// naming saving-2x-scenarios and every such shortfall. The fields, the grids and the envelope cost are
|
|
/// not tuned toward either.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IntervalSkipHalvesEvaluations()
|
|
{
|
|
var atEight = new Dictionary<(string Key, float Warp), RunStats>();
|
|
var scenarios = new (string Key, float Warp, Func<float, Scenario> Make)[]
|
|
{
|
|
("a", FogField.AetheriaWarp, w => FogField.HeightFog(w, BoundMode.Interval)),
|
|
("b", FogField.AetheriaWarp, w => FogField.InsideFog(w, BoundMode.Interval)),
|
|
("c interval", FogField.AetheriaWarp, w => FogField.DeepWell(w, BoundMode.Interval)),
|
|
("c affine", FogField.AetheriaWarp, w => FogField.DeepWell(w, BoundMode.Affine)),
|
|
("c-band interval", FogField.AetheriaWarp, w => FogField.Wells(w, BoundMode.Interval)),
|
|
("c-band affine", FogField.AetheriaWarp, w => FogField.Wells(w, BoundMode.Affine)),
|
|
("d", SlabField.SlabWarp, SlabField.Create),
|
|
};
|
|
foreach (var (key, warp, make) in scenarios)
|
|
foreach (var n in new[] { 1, 4, 8, 16 })
|
|
foreach (var w in new[] { 0.0f, warp })
|
|
{
|
|
var field = make(w);
|
|
var stats = Run(field, n, 64, 0x5A7E + n, range: n == 8 && key.StartsWith('c'));
|
|
Report($"{field.Name} N={n} warp={w:R}", stats);
|
|
Assert.True(stats.RangeViolations == 0, $"{field.Name} N={n} warp={w:R}: the pre-pass skipped {stats.RangeViolations} cells where a sampled density is positive");
|
|
Assert.True(stats.MaxDifference <= 1.0e-3f, $"{field.Name} N={n} warp={w:R}: transmittance differs by {stats.MaxDifference:R}");
|
|
Assert.True(stats.MaxOracleDifference == 0.0f, $"{field.Name} N={n} warp={w:R}: the oracle march differs from the dense march by {stats.MaxOracleDifference:R}");
|
|
if (key == "d")
|
|
output.WriteLine($"IV-REPORT (d) N={n} warp={w:R}: ungated dense snoise/ray {3.0 * stats.PerRay(stats.DenseSteps):F2} (three per dense sample, as r1 counted)");
|
|
if (n == 8)
|
|
atEight[(key, w)] = stats;
|
|
}
|
|
|
|
var warps = new[] { 0.0f, FogField.AetheriaWarp };
|
|
var costA = warps.Min(w => atEight[("a", w)].Ratio);
|
|
var cullFloor = double.MaxValue;
|
|
var gains = new List<string>();
|
|
var shortfalls = new List<string>();
|
|
var broken = new List<string>();
|
|
foreach (var w in warps)
|
|
{
|
|
var interval = atEight[("c interval", w)];
|
|
var affine = atEight[("c affine", w)];
|
|
foreach (var (mode, stats) in new[] { ("interval", interval), ("affine", affine) })
|
|
{
|
|
Assert.True(stats.CullFraction >= 0.5, $"(c) {mode} warp={w:R}: the pre-pass proves {stats.CullFraction:F3} of the oracle-empty cells empty, under the 0.5 floor (ruling wells-overhead-plus-floor)");
|
|
cullFloor = Math.Min(cullFloor, stats.CullFraction);
|
|
}
|
|
|
|
var band = (atEight[("c-band interval", w)], atEight[("c-band affine", w)]);
|
|
gains.Add($"warp {w:R}: (c) affine {affine.Ratio:F2}x against centred interval {interval.Ratio:F2}x ({affine.Ratio / interval.Ratio:F2}x), probe overhead {affine.ProbeOverhead:F3} and {interval.ProbeOverhead:F3}, "
|
|
+ $"efficiency {affine.Efficiency:F3} and {interval.Efficiency:F3} against the oracle, {affine.RangeEfficiency:F3} and {interval.RangeEfficiency:F3} against the range ceiling {affine.RangeCeiling:F3}; "
|
|
+ $"(c-band) {band.Item2.Ratio:F2}x against {band.Item1.Ratio:F2}x ({band.Item2.Ratio / band.Item1.Ratio:F2}x)");
|
|
if (!(affine.Ratio > interval.Ratio))
|
|
broken.Add($"(c) warp {w:R}: affine {affine.Ratio:F2}x is not cheaper than the centred interval's {interval.Ratio:F2}x");
|
|
}
|
|
|
|
foreach (var rh in VoidField.HollowRadii)
|
|
foreach (var offset in VoidField.CameraOffsets)
|
|
foreach (var ramp in VoidField.Ramps)
|
|
{
|
|
var field = new VoidField(rh, ramp, offset.Fraction, offset.Name);
|
|
ReportVoid($"{field.Name} N=8", Run(field, 8, 32, 0x701D, lod: true));
|
|
}
|
|
|
|
var lodFar = VoidField.LodFar();
|
|
var far = Run(lodFar, 8, 32, 0x701D, lod: true);
|
|
ReportVoid($"{lodFar.Name} N=8 (LOD far)", far);
|
|
Assert.True(far.MaxLodDepthError <= 2.0 * far.MaxRefDepthError, $"at LOD-far, the one field where LodStep's footprint branch bites, the footprint-aware march's optical depth is off the converged march's by {far.MaxLodDepthError:G6} relative, more than twice the fixed-step march's {far.MaxRefDepthError:G6}");
|
|
|
|
var shipped = VoidField.Shipped();
|
|
var headline = Run(shipped, 8, 200, 0x5417, lod: true);
|
|
ReportVoid($"{shipped.Name} N=8 (headline, 200 tiles)", headline);
|
|
Assert.True(headline.MaxDifference == 0.0f, $"the masked fixed-step march differs from the reference by {headline.MaxDifference:R}");
|
|
Assert.True(headline.MaxLodDepthError <= 2.0 * headline.MaxRefDepthError, $"at the body start the footprint-aware march's optical depth is off the converged march's by {headline.MaxLodDepthError:G6} relative, more than twice the fixed-step march's {headline.MaxRefDepthError:G6}");
|
|
var steps = (double)headline.DenseSteps / headline.LodSteps;
|
|
|
|
if (costA < 2.0)
|
|
shortfalls.Add($"(a) saves {costA:F2}x combined cost, under 2x");
|
|
if (steps < 2.0)
|
|
shortfalls.Add($"(e) takes {steps:F2}x fewer steps per pixel at the shipped void, under 2x");
|
|
output.WriteLine($"IV-REPORT contracts at N=8: (a) {costA:F2}x cost (lower of warp 0 and D, at least 2x); (c) affine cheaper than the centred interval at both warps, cull fraction {cullFloor:F3} (at least 0.5 under both bounds, hard); "
|
|
+ $"(e) {steps:F2}x fewer steps/px, depth error at the body start LOD {headline.MaxLodDepthError:G4} vs fixed-step {headline.MaxRefDepthError:G4} (at most 2x)");
|
|
foreach (var line in gains)
|
|
output.WriteLine($"IV-REPORT (c) at N=8, {line}");
|
|
Assert.True(broken.Count == 0, "ruling affine-wells-contract-cheaper-than-interval: " + string.Join("; ", broken));
|
|
if (shortfalls.Count > 0)
|
|
Assert.Skip("saving-2x-scenarios: " + string.Join("; ", shortfalls));
|
|
}
|
|
}
|