From 0a1b49a23163456b6c8c1becd0754b4eb72af9d4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 20:49:30 +0200 Subject: [PATCH] cultmath: span stages take SpanPath and SpanStage and widen curvature and fold by float rounding allowances span_fbm/span_ridged/span_stage lose the 14-argument signatures for SpanPath, SpanStage, octaves, lacunarity, gain. Per octave S_o and e_n, e_g, e_h (af_snoise's derivation plus the Hessian's 2^-10) widen the local Hessian tier, the gradient bound and the fold travel. Tests compare against the exact double kernel in coordinate bands, add the pinned rank-one span, widen the stage parameter ranges, and state the lab-form mapping. Not built or run yet. Co-Authored-By: Claude Opus 5.5 --- packages/cultmath/README.md | 5 +- packages/cultmath/docs/design.md | 68 ++- packages/cultmath/src/CultMath/SpanPath.cs | 26 ++ .../cultmath/src/CultMath/SpanPath.cs.meta | 2 + packages/cultmath/src/CultMath/SpanStage.cs | 33 ++ .../cultmath/src/CultMath/SpanStage.cs.meta | 2 + packages/cultmath/src/CultMath/math.Span.cs | 108 +++-- .../tests/CultMath.Tests/NoiseBoundTests.cs | 21 +- .../tests/CultMath.Tests/SpanStageTests.cs | 439 ++++++++++++------ 9 files changed, 490 insertions(+), 214 deletions(-) create mode 100644 packages/cultmath/src/CultMath/SpanPath.cs create mode 100644 packages/cultmath/src/CultMath/SpanPath.cs.meta create mode 100644 packages/cultmath/src/CultMath/SpanStage.cs create mode 100644 packages/cultmath/src/CultMath/SpanStage.cs.meta diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index 4f2018a3..61351da3 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -82,8 +82,9 @@ shader semantics. the analytic Hessian of `snoise(float3)` on the same lattice, hash and tie order as `snoise_grad` (a symmetric `float3x3`; GLSL `mat3`), and `integral_clamp_linear(a, b, lo, hi, t0, t1)`, the closed-form integral over `[t0, t1]` of `clamp(a + b t, - lo, hi)`; and, in C# for now (shaders follow), the octave stages `span_fbm` and - `span_ridged`, which return a `SpanNoise` (`field` as `float4(gradient, value)`, + lo, hi)`; and, in C# for now (shaders follow), the octave stages `span_fbm(path, stage, + octaves, lacunarity, gain)` and `span_ridged`, over a `SpanPath` and a `SpanStage` (`SpanStage.Identity` + is the plain fbm), which return a `SpanNoise` (`field` as `float4(gradient, value)`, `curvature`, `fold`) for a stretched, offset, shifted octave sum over a span. Three measured bounds describe `snoise`: `SNOISE_LIPSCHITZ` (gradient length, `NoiseBoundTests.MeasureLipschitz`), `SNOISE_HESSIAN` (Hessian spectral norm, `MeasureHessian`) and `SNOISE_THIRD` (third-derivative operator norm, diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md index 0d7cd738..b7edc78b 100644 --- a/packages/cultmath/docs/design.md +++ b/packages/cultmath/docs/design.md @@ -809,34 +809,62 @@ configuration bound. A change to the kernels must re-run the measurements. ### Span stages `span_fbm` and `span_ridged` (`math.Span.cs`, C# only so far) are the octave stages -of a warp chain, each returning one `SpanNoise { float4 field; float curvature; float -fold; }`. The span is the path `q(t)`, `|t| <= halfLength`, through `q` with the given -`velocity` at its midpoint, `|q'| <= speed` and `|q''| <= acceleration` along it. -Octave `o` samples `snoise_grad` at `x_o(q) = f_o (M q + shift) + offset + o -octaveStep`, with `f_o = lacunarity^o`, amplitude `gain^o`, and `M = frequency (I + -(1/stretch - 1) a a^T)`, `a = normalize(axis)`; a zero axis or `stretch` 1 means no -stretch, and `stretch` must be positive and finite (unguarded, like `lacunarity`). -`field` is the octave sum and its gradient in `q` (`f_o M grad n_o`); at frequency 1, -stretch 1 and zero shift, offset and octaveStep it equals `fbm_grad` and -`ridged_grad` bit for bit. `span_ridged` sums `gain^o (1 - |n_o|)`, `ridged_grad`'s -convention; the Span Lab's `1 - 2|n|` is twice this minus the gain sum, which the -consumer forms. +of a warp chain, `span_fbm(path, stage, octaves, lacunarity, gain)`, each returning +one `SpanNoise { float4 field; float curvature; float fold; }`. The inputs are two +structs with named public fields and no positional constructor, so the three floats +of the span and the six fields of the transform cannot be swapped silently. +`SpanPath { midpoint, velocity, speed, acceleration, halfLength }` is the path +`q(t)`, `|t| <= halfLength`, through `midpoint` with `velocity` at `t = 0`, +`|q'| <= speed` and `|q''| <= acceleration` along it. `SpanStage { frequency, +stretch, axis, shift, offset, octaveStep }` makes octave `o` sample `snoise_grad` +at `x_o(q) = f_o (M q + shift) + offset + o octaveStep`, with `f_o = +lacunarity^o`, amplitude `gain^o`, and `M = frequency (I + (1/stretch - 1) a a^T)`, +`a = normalize(axis)`; a zero axis or `stretch` 1 means no stretch, and `stretch` +must be positive and finite (unguarded, like `lacunarity`). `default(SpanStage)` +has frequency 0; `SpanStage.Identity` is the stage whose field equals `fbm_grad` +and `ridged_grad` bit for bit. `octaves`, `lacunarity` and `gain` stay positional: +they are `fbm_grad`'s own trailing triple, and `octaves` is the one int among the +scalars. `field` is the octave sum and its gradient in `q` (`f_o M grad n_o`). +`span_ridged` sums `gain^o (1 - |n_o|)`, `ridged_grad`'s convention. The Span +Lab's stage is `1 - 2|n|` per octave: with `G` the sum of `gain^o` over the +octaves (signed, so the alternating sum for a negative gain), its value is `2 value +- G`, and its gradient, curvature and fold are twice `span_ridged`'s. Bounds come from the three pins only. With `|M| = |frequency| max(1, 1/stretch)` and `nu = f_o |M| speed` the noise-space speed, the Hessian of the noise on the span has -operator norm at most `Hl = min(SNOISE_HESSIAN, ||snoise_hessian(x_mid)||_F + +operator norm at most `Hl = min(SNOISE_HESSIAN, ||snoise_hessian(x_mid)||_F + e_h + SNOISE_THIRD nu halfLength)`, so `curvature = sum gain^o f_o^2 |M|^2 Hl` bounds the operator norm of the field's Hessian in `q` at every point of the span (the pin takes over once the third-derivative term exceeds it, and at `halfLength` 0 the local tier alone remains). For `span_ridged`, an octave leaves the branch it is on at the midpoint (`1 - s n`, `s` the midpoint's sign of `n`) only where `n` changes sign, and -there `|n| <= V - |n_mid|`, with `V = |grad n_mid . w| halfLength + (Hl nu |w| + Gl f_o -|M| acceleration) halfLength^2 / 2`, `w = f_o M velocity` and `Gl = min(SNOISE_LIPSCHITZ, -|grad n_mid| + Hl nu halfLength)`; `fold = sum 2 gain^o max(V - |n_mid|, 0)`, exactly 0 -for `span_fbm` and for an octave that cannot reach zero. Rounding is not widened: the -Frobenius norm exceeds the operator norm except on a rank-one Hessian, and the pins -carry a 10% margin. `SpanStageTests` encloses the Hessian norm and the branch -departure at 64 points of seeded spans, in the local and the global regime. +there `|n| <= V - |n_mid|`, with `V = (|grad n_mid . w| + e_g |w|) halfLength + (Hl nu |w| ++ Gl f_o |M| acceleration) halfLength^2 / 2 + 2 e_n`, `w = f_o M velocity` and `Gl = +min(SNOISE_LIPSCHITZ, |grad n_mid| + e_g + Hl nu halfLength)`; `fold = sum 2 gain^o +max(V - |n_mid|, 0)`, exactly 0 for `span_fbm` and for an octave that cannot reach zero. + +The float32 `snoise`, `snoise_grad` and `snoise_hessian` are not the exact functions, so +the quantities read at the midpoint carry allowances, by the derivation of `af_snoise`'s +comment. `S_o = |f_o| (|M q|_1 + |shift|_1) + |offset|_1 + |o octaveStep|_1 + sqrt(3) nu +halfLength` is the sum of the magnitudes the octave coordinate's roundings are relative +to, and `e_n = 2^-14 + L 2^-20 S_o` (value), `e_g = 2^-12 + H 2^-20 S_o` (gradient +length) and `e_h = 2^-10 + T 2^-20 S_o` (Frobenius norm of the Hessian), with `L`, `H`, +`T` the three pins; they add no pin. Measured against a double kernel with the float +hash (6000 points per band, `|x|` from 2^-2 to 2^23), the error is at most 0.057 of +`e_n`, 0.046 of `e_g` and 0.075 of `e_h` (the last at the smallest band, where the +absolute Hessian error is 2.3e-5 and the absolute term carries it). Errors grow +linearly in `|x|`: the value's is 2e-4 at 2^8, 1.5e-2 at 2^14 and 0.94 at 2^20. +Rounding in the Frobenius-to-operator-norm step needs no allowance: the Frobenius norm +exceeds the operator norm. + +Domain. No coordinate range is checked, clamped or refused. The allowances grow with +`S_o`, so `curvature` saturates at `SNOISE_HESSIAN` (true everywhere) by `S_o` of about +1.1e5, and `fold` turns positive for every octave whose `V + 2 e_n` reaches `|n_mid|`: +the bounds stay sound at every magnitude and stop being informative where float32 noise +is (see "Precision domain" above). `SpanStageTests` encloses the exact (double) Hessian +norm and both the float and the exact branch departure at 64 points of seeded spans in +bands of `S_o` from under 1e2 to over 1e6, and in a fixed span where the local tier +with its third-derivative term halved fails. The slice. `af_frustum_ball(centreSlope, z0, z1, footprintPerDepth, warpVariation)` returns the centre `(m_c z_m, z_m)` and a radius `z1 diff --git a/packages/cultmath/src/CultMath/SpanPath.cs b/packages/cultmath/src/CultMath/SpanPath.cs new file mode 100644 index 00000000..cc6524d9 --- /dev/null +++ b/packages/cultmath/src/CultMath/SpanPath.cs @@ -0,0 +1,26 @@ +namespace CultMath; + +/// +/// The span and bound over: the path q(t), +/// |t| <= , through at t = 0 with there, +/// |q'| <= and |q''| <= along it. speed is at least +/// |velocity|; speed, acceleration and halfLength are non-negative. Name every field: the three floats +/// have no order a caller could mistake for a convention. +/// +public struct SpanPath +{ + /// q(0), the point the field and its gradient are evaluated at. + public float3 midpoint; + + /// q'(0). + public float3 velocity; + + /// An upper bound on |q'(t)| over the span; at least |velocity|. + public float speed; + + /// An upper bound on |q''(t)| over the span. + public float acceleration; + + /// The span covers |t| <= halfLength; 0 is the midpoint alone. + public float halfLength; +} diff --git a/packages/cultmath/src/CultMath/SpanPath.cs.meta b/packages/cultmath/src/CultMath/SpanPath.cs.meta new file mode 100644 index 00000000..3c58b0e9 --- /dev/null +++ b/packages/cultmath/src/CultMath/SpanPath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a8c1d52e7b94f06a1d5c2b9e04f7a31 diff --git a/packages/cultmath/src/CultMath/SpanStage.cs b/packages/cultmath/src/CultMath/SpanStage.cs new file mode 100644 index 00000000..faeccd3e --- /dev/null +++ b/packages/cultmath/src/CultMath/SpanStage.cs @@ -0,0 +1,33 @@ +namespace CultMath; + +/// +/// The transform of one octave stage of a warp chain, as and +/// read it. Octave o samples snoise at x_o = f_o (M q + shift) + offset + +/// o octaveStep, with f_o = lacunarity^o and M = (I + (1/ - 1) a a^T), +/// a = normalize(); a zero axis, or a stretch of 1, means no stretch. stretch must be +/// positive and finite, and nothing guards it. default(SpanStage) has frequency 0, which samples +/// one point: start from . +/// +public struct SpanStage +{ + /// The stage whose span_fbm/span_ridged field equals fbm_grad/ridged_grad: frequency 1, stretch 1, every vector zero. + public static readonly SpanStage Identity = new SpanStage { frequency = 1.0f, stretch = 1.0f }; + + /// The scale of M; its sign is kept. + public float frequency; + + /// The factor the axis direction is stretched by (M scales it by 1/stretch); 1 is none. + public float stretch; + + /// The direction stretch applies along; any length, zero for none. + public float3 axis; + + /// Added to M q before the per-octave scaling. + public float3 shift; + + /// Added after the per-octave scaling. + public float3 offset; + + /// Added to the octave coordinate once per octave index: o octaveStep. + public float3 octaveStep; +} diff --git a/packages/cultmath/src/CultMath/SpanStage.cs.meta b/packages/cultmath/src/CultMath/SpanStage.cs.meta new file mode 100644 index 00000000..7edaee93 --- /dev/null +++ b/packages/cultmath/src/CultMath/SpanStage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d46b0f27c1e4a85b3e8f1a60c2d5b97 diff --git a/packages/cultmath/src/CultMath/math.Span.cs b/packages/cultmath/src/CultMath/math.Span.cs index 4df9cd21..73bf199e 100644 --- a/packages/cultmath/src/CultMath/math.Span.cs +++ b/packages/cultmath/src/CultMath/math.Span.cs @@ -135,55 +135,78 @@ public static partial class math // Plain fbm of snoise over a span: the octave stage of a warp chain, its value and gradient at the // span's midpoint and the bounds that hold over the whole span. See span_stage for the span, the - // transform and the bound rules. At frequency 1, stretch 1 and zero shift, offset and octaveStep the - // field equals fbm_grad(q, octaves, lacunarity, gain) bit for bit. - public static SpanNoise span_fbm(float3 q, float3 velocity, float speed, float acceleration, float halfLength, - float frequency, float stretch, float3 axis, float3 shift, float3 offset, float3 octaveStep, int octaves, float lacunarity, float gain) => - span_stage(false, q, velocity, speed, acceleration, halfLength, frequency, stretch, axis, shift, offset, octaveStep, octaves, lacunarity, gain); + // transform and the bound rules. At SpanStage.Identity the field equals fbm_grad(path.midpoint, + // octaves, lacunarity, gain) bit for bit. + public static SpanNoise span_fbm(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) => + span_stage(false, path, stage, octaves, lacunarity, gain); - // Ridged fbm over a span: octave o contributes gain^o (1 - |n_o|), ridged_grad's convention (the Span - // Lab's 1 - 2|n| is twice this minus the gain sum, the consumer's to form). fold bounds how far the - // sum can leave the branch it is on at the midpoint. At the identity transform the field equals - // ridged_grad bit for bit. - public static SpanNoise span_ridged(float3 q, float3 velocity, float speed, float acceleration, float halfLength, - float frequency, float stretch, float3 axis, float3 shift, float3 offset, float3 octaveStep, int octaves, float lacunarity, float gain) => - span_stage(true, q, velocity, speed, acceleration, halfLength, frequency, stretch, axis, shift, offset, octaveStep, octaves, lacunarity, gain); + // Ridged fbm over a span: octave o contributes gain^o (1 - |n_o|), ridged_grad's convention. fold + // bounds how far the sum can leave the branch it is on at the midpoint. At SpanStage.Identity the field + // equals ridged_grad bit for bit. + // + // The Span Lab's stage is 1 - 2|n| per octave. With G the sum of gain^o over the octaves (signed: a + // negative gain gives its alternating sum), the lab's value is 2 value - G, and its gradient, + // curvature and fold are twice this one's. A consumer in the lab form applies that mapping and does + // not derive its own bounds. + public static SpanNoise span_ridged(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) => + span_stage(true, path, stage, octaves, lacunarity, gain); - // The span is the path q(t), |t| <= halfLength, through q at t = 0 with the given velocity there, - // |q'| <= speed and |q''| <= acceleration along it (speed at least |velocity|; all three and halfLength - // non-negative). Octave o samples snoise_grad at x_o(q) = f_o (M q + shift) + offset + o octaveStep with - // f_o = lacunarity^o, amplitude gain^o and M = frequency (I + (1/stretch - 1) a a^T), a = normalize(axis); - // a zero axis, or stretch 1, means no stretch. stretch must be positive and finite, and nothing guards - // it, as nothing guards lacunarity. octaves clamps to [0, 16] as fbm_grad's does. The field is the - // octave sum in q: gradient f_o M grad n_o (M is symmetric), the same octave loop as fbm_grad and - // ridged_grad. + private static float span_l1(float3 v) => abs(v.x) + abs(v.y) + abs(v.z); + + // The span is SpanPath: q(t), |t| <= halfLength, through midpoint with velocity at t = 0, |q'| <= speed + // and |q''| <= acceleration along it (speed at least |velocity|; all three and halfLength non-negative). + // The transform is SpanStage: octave o samples snoise_grad at x_o(q) = f_o (M q + shift) + offset + + // o octaveStep with f_o = lacunarity^o, amplitude gain^o and M = frequency (I + (1/stretch - 1) a a^T), + // a = normalize(axis); a zero axis, or stretch 1, means no stretch. stretch must be positive and + // finite, and nothing guards it, as nothing guards lacunarity. octaves clamps to [0, 16] as fbm_grad's + // does. The field is the octave sum in q: gradient f_o M grad n_o (M is symmetric), the same octave + // loop as fbm_grad and ridged_grad. // // Bounds, per octave, from SNOISE_LIPSCHITZ (L), SNOISE_HESSIAN (H) and SNOISE_THIRD (T) only. With // |M| = |frequency| max(1, 1/stretch) (1 without stretch) and nu = f_o |M| speed, the noise-space speed: // * |x(t) - x_mid| <= nu halfLength, so the Hessian of n at x(t) has operator norm at most - // Hl = min(H, ||hessian(x_mid)||_F + T nu halfLength) (the Hessian is T-Lipschitz in operator norm, - // and the Frobenius norm bounds the operator norm), and |grad n(x(t))| at most - // Gl = min(L, |grad n_mid| + Hl nu halfLength). + // Hl = min(H, ||hessian(x_mid)||_F + e_h + T nu halfLength) (the Hessian is T-Lipschitz in operator + // norm, and the Frobenius norm bounds the operator norm), and |grad n(x(t))| at most + // Gl = min(L, |grad n_mid| + e_g + Hl nu halfLength). // * curvature = sum gain^o f_o^2 |M|^2 Hl: the Hessian in q of n(x_o(q)) is f_o^2 M H M. // * fold, ridged only. Over the span the octave's value departs from its midpoint branch // gain^o (1 - s n) (s the sign of n at the midpoint) only where n has changed sign, and there // |n(t)| <= V - |n_mid|, V bounding |n(t) - n_mid|. Writing n(t) - n_mid as the integral of // grad n . x', x' = w + rho with w = f_o M velocity and |rho| <= f_o |M| acceleration |t|, and // grad n = grad n_mid + delta with |delta| <= Hl nu |t|: - // V = |grad n_mid . w| halfLength + (Hl nu |w| + Gl f_o |M| acceleration) halfLength^2 / 2, + // V = (|grad n_mid . w| + e_g |w|) halfLength + (Hl nu |w| + Gl f_o |M| acceleration) halfLength^2 / 2 + // + 2 e_n, // and the octave's fold is 2 gain^o max(V - |n_mid|, 0), zero when it cannot reach zero. - // Rounding is not widened: the Frobenius norm exceeds the operator norm except on a rank-one - // Hessian, and the pins carry a 10% margin. - private static SpanNoise span_stage(bool ridged, float3 q, float3 velocity, float speed, float acceleration, float halfLength, - float frequency, float stretch, float3 axis, float3 shift, float3 offset, float3 octaveStep, int octaves, float lacunarity, float gain) + // + // The float32 snoise, snoise_grad and snoise_hessian are not the exact functions, and the field a + // consumer reads is the float one, so what is read at the midpoint carries an allowance, by the + // derivation af_snoise's comment gives (math.Affine.cs): the skew and the corner offsets round the + // coordinate by about 2u of the magnitudes it is formed from, which moves a result by its derivative + // times that, and the caller's scaling of the point and a GPU's evaluation take 2u to 16u = 2^-20. + // S_o is the sum of those magnitudes, |f_o| (|M q|_1 + |shift|_1) + |offset|_1 + |o octaveStep|_1, plus + // the span's reach sqrt(3) nu halfLength (the coordinate of a path point). The allowances are the + // value's e_n = 2^-14 + L 2^-20 S_o, the gradient length's e_g = 2^-12 + H 2^-20 S_o (H is the + // gradient's own derivative's pin) and the Hessian's Frobenius norm's e_h = 2^-10 + T 2^-20 S_o (T is + // the Hessian's derivative's pin): af_snoise's absolute terms for the first two, and 2^-10 for the + // Hessian's, which the kernel's rounding at small coordinates (2.3e-5 absolute, measured against a + // double kernel) sits well under. The same probe (6000 points per band, |x| from 2^-2 to 2^23) puts + // the error at most 0.057, 0.046 and 0.075 of e_n, e_g and e_h. No new pin enters. + // + // Domain: no coordinate range is checked, clamped or refused. The allowances grow with S_o, so + // curvature saturates at H (true everywhere) by S_o of about 1.1e5, and fold turns positive for every + // octave whose V + 2 e_n reaches |n_mid|: the bounds stay sound at every magnitude and stop being + // informative where float32 noise is (design.md, Precision domain). + private static SpanNoise span_stage(bool ridged, SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) { + var q = path.midpoint; + var halfLength = path.halfLength; octaves = clamp(octaves, 0, 16); - var stretched = dot(axis, axis) > 0.0f && stretch != 1.0f; - var direction = stretched ? normalize(axis) : new float3(0.0f, 0.0f, 0.0f); - var squeeze = stretched ? 1.0f / stretch - 1.0f : 0.0f; - var norm = abs(frequency) * (stretched ? max(1.0f, 1.0f / stretch) : 1.0f); - var mq = stretched ? frequency * (q + squeeze * dot(direction, q) * direction) : frequency * q; - var mv = stretched ? frequency * (velocity + squeeze * dot(direction, velocity) * direction) : frequency * velocity; + var stretched = dot(stage.axis, stage.axis) > 0.0f && stage.stretch != 1.0f; + var direction = stretched ? normalize(stage.axis) : new float3(0.0f, 0.0f, 0.0f); + var squeeze = stretched ? 1.0f / stage.stretch - 1.0f : 0.0f; + var norm = abs(stage.frequency) * (stretched ? max(1.0f, 1.0f / stage.stretch) : 1.0f); + var mq = stretched ? stage.frequency * (q + squeeze * dot(direction, q) * direction) : stage.frequency * q; + var mv = stretched ? stage.frequency * (path.velocity + squeeze * dot(direction, path.velocity) * direction) : stage.frequency * path.velocity; var amplitude = 1.0f; var scale = 1.0f; var value = 0.0f; @@ -192,10 +215,11 @@ public static partial class math var fold = 0.0f; for (var i = 0; i < octaves; i++) { - var x = scale * (mq + shift) + (offset + (float)i * octaveStep); + var step = (float)i * stage.octaveStep; + var x = scale * (mq + stage.shift) + (stage.offset + step); var n = snoise_grad(x); var g = new float3(n.x, n.y, n.z); - var mg = stretched ? frequency * (g + squeeze * dot(direction, g) * direction) : frequency * g; + var mg = stretched ? stage.frequency * (g + squeeze * dot(direction, g) * direction) : stage.frequency * g; if (ridged) { var s = (float)sign(n.w); @@ -210,15 +234,19 @@ public static partial class math var weight = abs(amplitude); var factor = abs(scale) * norm; - var nu = factor * speed; - var local = min(SNOISE_HESSIAN, span_frobenius(snoise_hessian(x)) + SNOISE_THIRD * nu * halfLength); + var nu = factor * path.speed; + var rounding = 9.5367431640625e-7f * (abs(scale) * (span_l1(mq) + span_l1(stage.shift)) + span_l1(stage.offset) + span_l1(step) + 1.7320508f * nu * halfLength); + var valueSlack = 6.103515625e-5f + SNOISE_LIPSCHITZ * rounding; + var gradientSlack = 2.44140625e-4f + SNOISE_HESSIAN * rounding; + var hessianSlack = 9.765625e-4f + SNOISE_THIRD * rounding; + var local = min(SNOISE_HESSIAN, span_frobenius(snoise_hessian(x)) + hessianSlack + SNOISE_THIRD * nu * halfLength); curvature += weight * factor * factor * local; if (ridged) { var w = scale * mv; var reach = local * nu * halfLength; - var gl = min(SNOISE_LIPSCHITZ, length(g) + reach); - var travel = abs(dot(g, w)) * halfLength + (local * nu * length(w) + gl * factor * acceleration) * (0.5f * halfLength * halfLength); + var gl = min(SNOISE_LIPSCHITZ, length(g) + gradientSlack + reach); + var travel = (abs(dot(g, w)) + gradientSlack * length(w)) * halfLength + (local * nu * length(w) + gl * factor * path.acceleration) * (0.5f * halfLength * halfLength) + 2.0f * valueSlack; fold += 2.0f * weight * max(travel - abs(n.w), 0.0f); } diff --git a/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs b/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs index 8d61150a..0962a357 100644 --- a/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/NoiseBoundTests.cs @@ -633,7 +633,7 @@ public sealed class NoiseBoundTests // 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. - private static (double[][] X, double[][] P, double[] Key) Corners(double vx, double vy, double vz) + 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; @@ -686,7 +686,7 @@ public sealed class NoiseBoundTests // 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. - private static double[] HessianDouble(double vx, double vy, double vz) + internal static double[] HessianDouble(double vx, double vy, double vz) { var (x, p, _) = Corners(vx, vy, vz); var hessian = new double[9]; @@ -705,6 +705,23 @@ public sealed class NoiseBoundTests 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; + } + 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: diff --git a/packages/cultmath/tests/CultMath.Tests/SpanStageTests.cs b/packages/cultmath/tests/CultMath.Tests/SpanStageTests.cs index bc6c6eeb..8bd68193 100644 --- a/packages/cultmath/tests/CultMath.Tests/SpanStageTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/SpanStageTests.cs @@ -32,48 +32,76 @@ public sealed class SpanStageTests return normalize(v); } - // One draw of the stage parameters: every one of them off its identity value. - private readonly record struct Stage(float Frequency, float Stretch, float3 Axis, float3 Shift, float3 Offset, float3 Step, int Octaves, float Lacunarity, float Gain) + private static float LogUniform(System.Random random, float lo, float hi) => MathF.Exp(Uniform(random, MathF.Log(lo), MathF.Log(hi))); + + private static float Signed(System.Random random, float magnitude) => random.Next(2) == 0 ? magnitude : -magnitude; + + private static readonly float3 None = new(0.0f, 0.0f, 0.0f); + + private static double L1(double[] v) => Math.Abs(v[0]) + Math.Abs(v[1]) + Math.Abs(v[2]); + + // One draw of the stage parameters: every one of them off its identity value, over the ranges the stage + // takes in use (stage-parameter-range): |frequency| 1e-3..10, stretch 1e-2..1e2, |lacunarity| 0.5..3, + // |gain| 0.1..1.2, the last three and frequency with random signs where a sign is meaningful, + // octaveStep nonzero in every component. + private readonly record struct Stage(SpanStage S, int Octaves, float Lacunarity, float Gain) { - public static Stage Draw(System.Random random, int octaves) => new( - Uniform(random, 0.3f, 1.6f), - random.Next(2) == 0 ? Uniform(random, 0.2f, 0.8f) : Uniform(random, 1.25f, 5.0f), - UnitVector(random) * Uniform(random, 0.5f, 3.0f), - Vector(random, -8.0f, 8.0f), - Vector(random, -8.0f, 8.0f), - Vector(random, -3.0f, 3.0f), - octaves, - Uniform(random, 1.5f, 2.3f), - Uniform(random, 0.35f, 0.7f)); + public static Stage Draw(System.Random random, int maxOctaves = 8, int minOctaves = 1) => new( + new SpanStage + { + frequency = Signed(random, LogUniform(random, 1.0e-3f, 10.0f)), + stretch = LogUniform(random, 1.0e-2f, 1.0e2f), + axis = UnitVector(random) * Uniform(random, 0.5f, 3.0f), + shift = Vector(random, -8.0f, 8.0f), + offset = Vector(random, -8.0f, 8.0f), + octaveStep = new float3(Signed(random, Uniform(random, 0.1f, 3.0f)), Signed(random, Uniform(random, 0.1f, 3.0f)), Signed(random, Uniform(random, 0.1f, 3.0f))), + }, + random.Next(minOctaves, maxOctaves + 1), + Signed(random, Uniform(random, 0.5f, 3.0f)), + Signed(random, Uniform(random, 0.1f, 1.2f))); - public SpanNoise Fbm(float3 q, float3 velocity, float speed, float acceleration, float halfLength) => - span_fbm(q, velocity, speed, acceleration, halfLength, Frequency, Stretch, Axis, Shift, Offset, Step, Octaves, Lacunarity, Gain); + // A draw whose octave coordinates stay under `limit` for every q with |q|_1 <= qL1: the fastest + // octave's |f_o| |M| is capped by rejection. For the derivative checks, where float rounding of the + // coordinate must not swamp a difference quotient. + public static Stage DrawSmooth(System.Random random, float limit, float qL1, int maxOctaves = 8, int minOctaves = 1) + { + while (true) + { + var stage = Draw(random, maxOctaves, minOctaves); + var worst = 0.0; + for (var o = 0; o < stage.Octaves; o++) + worst = Math.Max(worst, Math.Abs(stage.Scale(o)) * (stage.Norm() * qL1 + L1(Components(stage.S.shift))) + L1(Components(stage.S.offset)) + o * L1(Components(stage.S.octaveStep))); + if (worst < limit) + return stage; + } + } - public SpanNoise Ridged(float3 q, float3 velocity, float speed, float acceleration, float halfLength) => - span_ridged(q, velocity, speed, acceleration, halfLength, Frequency, Stretch, Axis, Shift, Offset, Step, Octaves, Lacunarity, Gain); + public SpanNoise Fbm(SpanPath path) => span_fbm(path, S, Octaves, Lacunarity, Gain); + + public SpanNoise Ridged(SpanPath path) => span_ridged(path, S, Octaves, Lacunarity, Gain); // M = frequency (I + (1/stretch - 1) a a^T) in double, a the normalised axis (none for a zero axis). public double[,] Matrix() { var m = new double[3, 3]; - var a = new double[] { Axis.x, Axis.y, Axis.z }; + var a = Components(S.axis); var length = Math.Sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]); for (var i = 0; i < 3; i++) for (var j = 0; j < 3; j++) - m[i, j] = Frequency * ((i == j ? 1.0 : 0.0) + (length > 0.0 ? (1.0 / Stretch - 1.0) * a[i] * a[j] / (length * length) : 0.0)); + m[i, j] = S.frequency * ((i == j ? 1.0 : 0.0) + (length > 0.0 && S.stretch != 1.0f ? (1.0 / S.stretch - 1.0) * a[i] * a[j] / (length * length) : 0.0)); return m; } - // Octave o's noise-space point for the span point q. - public float3 Point(float3 q, int octave) + // Octave o's noise-space point for the span point q, in double from the same inputs. + public double[] PointD(float3 q, int octave) { var m = Matrix(); var scale = Math.Pow(Lacunarity, octave); var x = new double[3]; - var qv = new double[] { q.x, q.y, q.z }; - var shift = new double[] { Shift.x, Shift.y, Shift.z }; - var offset = new double[] { Offset.x, Offset.y, Offset.z }; - var step = new double[] { Step.x, Step.y, Step.z }; + var qv = Components(q); + var shift = Components(S.shift); + var offset = Components(S.offset); + var step = Components(S.octaveStep); for (var i = 0; i < 3; i++) { var mq = 0.0; @@ -82,28 +110,59 @@ public sealed class SpanStageTests x[i] = scale * (mq + shift[i]) + offset[i] + octave * step[i]; } + return x; + } + + public float3 Point(float3 q, int octave) + { + var x = PointD(q, octave); return new float3((float)x[0], (float)x[1], (float)x[2]); } + // S_o: the magnitudes octave o's coordinate roundings are relative to, plus the span's reach. + public double Magnitude(SpanPath path, int octave) + { + var m = Matrix(); + var qv = Components(path.midpoint); + var mq = new double[3]; + for (var i = 0; i < 3; i++) + for (var j = 0; j < 3; j++) + mq[i] += m[i, j] * qv[j]; + var scale = Math.Abs(Scale(octave)); + return scale * (L1(mq) + L1(Components(S.shift))) + L1(Components(S.offset)) + octave * L1(Components(S.octaveStep)) + + Math.Sqrt(3.0) * scale * Norm() * path.speed * path.halfLength; + } + + public double MaxMagnitude(SpanPath path) => Enumerable.Range(0, Octaves).Max(o => Magnitude(path, o)); + + // Octave o as a stage of its own: one octave at the scaled frequency, shift and offset. + public Stage OneOctave(int octave) => new( + new SpanStage { frequency = S.frequency * (float)Scale(octave), stretch = S.stretch, axis = S.axis, shift = S.shift * (float)Scale(octave), offset = S.offset + (float)octave * S.octaveStep, octaveStep = None }, + 1, 1.0f, 1.0f); + public double Amplitude(int octave) => Math.Pow(Gain, octave); public double Scale(int octave) => Math.Pow(Lacunarity, octave); // The spectral norm of M: frequency times the larger of 1 and 1/stretch. - public double Norm() => Math.Abs(Frequency) * Math.Max(1.0, 1.0 / Stretch); + public double Norm() => Math.Abs(S.frequency) * Math.Max(1.0, 1.0 / S.stretch); } + private static double[] Components(float3 v) => new double[] { v.x, v.y, v.z }; + + private static SpanPath PathAt(float3 q) => new() { midpoint = q, velocity = None, speed = 0.0f, acceleration = 0.0f, halfLength = 0.0f }; + // A span through q: velocity v, constant acceleration c of norm `acceleration`, so q(t) = q + v t + c t^2 / 2; // the speed bound is |v| plus what c can add over halfLength. - private readonly record struct Span(float3 Q, float3 Velocity, float3 Acceleration, float Speed, float AccelerationBound, float HalfLength) + private readonly record struct Span(SpanPath Path, float3 Acceleration) { public static Span Draw(System.Random random, float3 q, float halfLength, float acceleration) { var velocity = UnitVector(random) * Uniform(random, 0.2f, 3.0f); - return new Span(q, velocity, UnitVector(random) * acceleration, length(velocity) + acceleration * halfLength, acceleration, halfLength); + return new Span(new SpanPath { midpoint = q, velocity = velocity, speed = length(velocity) + acceleration * halfLength, acceleration = acceleration, halfLength = halfLength }, UnitVector(random) * acceleration); } - public float3 At(float t) => Q + Velocity * t + Acceleration * (0.5f * t * t); + public float3 At(float t) => Path.midpoint + Path.velocity * t + Acceleration * (0.5f * t * t); } // The operator norm of a symmetric 3x3 matrix (entries in row-major order): the larger |eigenvalue|. @@ -121,28 +180,25 @@ public sealed class SpanStageTests return Math.Max(Math.Abs(q + 2.0 * s * Math.Cos(phi)), Math.Abs(q + 2.0 * s * Math.Cos(phi + 2.0 * Math.PI / 3.0))); } - // The Hessian in q of the stage field at span point q (signed per octave for a ridged sum), by the chain - // rule from snoise_hessian: sum over octaves of weight_o f_o^2 M H_o M. + // The exact Hessian in q of the stage field at span point q (signed per octave for a ridged sum), by the + // chain rule from the double kernel's Hessian: sum over octaves of weight_o f_o^2 M H_o M, with every + // octave point computed in double from the same SpanStage. private static double[] FieldHessian(Stage stage, float3 q, bool ridged) { var m = stage.Matrix(); var total = new double[9]; for (var o = 0; o < stage.Octaves; o++) { - var x = stage.Point(q, o); - var h = snoise_hessian(x); - var weight = stage.Amplitude(o) * stage.Scale(o) * stage.Scale(o) * (ridged ? -Math.Sign(snoise(x)) : 1.0); - var hd = new double[3, 3]; - for (var i = 0; i < 3; i++) - for (var j = 0; j < 3; j++) - hd[i, j] = h[i][j]; + var x = stage.PointD(q, o); + var h = NoiseBoundTests.HessianDouble(x[0], x[1], x[2]); + var weight = stage.Amplitude(o) * stage.Scale(o) * stage.Scale(o) * (ridged ? -Math.Sign(NoiseBoundTests.ValueDouble(x[0], x[1], x[2])) : 1.0); for (var i = 0; i < 3; i++) for (var j = 0; j < 3; j++) { var sum = 0.0; for (var k = 0; k < 3; k++) for (var l = 0; l < 3; l++) - sum += m[i, k] * hd[k, l] * m[l, j]; + sum += m[i, k] * h[k * 3 + l] * m[l, j]; total[i * 3 + j] += weight * sum; } } @@ -168,24 +224,41 @@ public sealed class SpanStageTests } /// - /// At frequency 1, stretch 1 (with and without an axis) and zero shift, offset and octaveStep, the field of + /// The exact reference the enclosure tests lean on is the kernel: the double ValueDouble is snoise to + /// within float rounding at small coordinates, so a sign taken from it is the kernel's sign. + /// + [Fact] + public void ExactReferenceIsTheKernel() + { + var random = new System.Random(0xE4AC7); + for (var k = 0; k < 300; k++) + { + var p = Vector(random, -20.0f, 20.0f); + Assert.InRange(NoiseBoundTests.ValueDouble(p.x, p.y, p.z) - snoise(p), -2.0e-4, 2.0e-4); + } + } + + /// + /// At SpanStage.Identity (also with an axis, stretch 1) and zero shift, offset and octaveStep, the field of /// span_fbm and span_ridged is fbm_grad's and ridged_grad's, bit for bit, for 0 to 20 octaves (the clamp /// included), every lacunarity and gain drawn, over random and simplex-tie points: any change to the octave - /// loop's order, scaling or accumulation fails. + /// loop's order, scaling or accumulation, and any bit the rounding allowances put in the field, fails. /// [Fact] public void SpanStagesAtIdentityAreFbmAndRidged() { var random = new System.Random(0x57A6E1); - var none = new float3(0.0f, 0.0f, 0.0f); foreach (var p in Points(random)) { var octaves = random.Next(0, 21); var lacunarity = Uniform(random, 1.4f, 2.6f); var gain = Uniform(random, 0.3f, 0.8f); - var axis = random.Next(2) == 0 ? none : UnitVector(random) * 2.0f; - var fbm = span_fbm(p, UnitVector(random), 1.0f, 0.5f, 0.25f, 1.0f, 1.0f, axis, none, none, none, octaves, lacunarity, gain); - var ridged = span_ridged(p, UnitVector(random), 1.0f, 0.5f, 0.25f, 1.0f, 1.0f, axis, none, none, none, octaves, lacunarity, gain); + var stage = SpanStage.Identity; + if (random.Next(2) == 1) + stage.axis = UnitVector(random) * 2.0f; + var path = new SpanPath { midpoint = p, velocity = UnitVector(random), speed = 1.0f, acceleration = 0.5f, halfLength = 0.25f }; + var fbm = span_fbm(path, stage, octaves, lacunarity, gain); + var ridged = span_ridged(path, stage, octaves, lacunarity, gain); Assert.True(SameBits(fbm.field, fbm_grad(p, octaves, lacunarity, gain)), $"span_fbm at {p}, {octaves} octaves: {fbm.field} is not fbm_grad {fbm_grad(p, octaves, lacunarity, gain)}"); Assert.True(SameBits(ridged.field, ridged_grad(p, octaves, lacunarity, gain)), $"span_ridged at {p}, {octaves} octaves: {ridged.field} is not ridged_grad {ridged_grad(p, octaves, lacunarity, gain)}"); Assert.Equal(0.0f, fbm.fold); @@ -194,10 +267,12 @@ public sealed class SpanStageTests /// /// The gradient is the derivative of the value through the whole transform: with every parameter off its - /// identity (frequency, stretch both ways, axis, shift, offset, octaveStep, lacunarity, gain), field.xyz - /// equals the fourth-order central difference of field.w along each axis, within a tolerance built from the - /// third-derivative pin (truncation) and float32 rounding of the coordinates. A dropped M or lacunarity - /// factor, or M applied to the wrong side, moves it by the gradient itself. + /// identity (frequency of either sign, stretch both ways, axis, shift, offset, octaveStep, lacunarity and + /// gain of either sign, 4 to 8 octaves) field.xyz equals the fourth-order central difference of field.w + /// along each axis, within a tolerance built from the third-derivative pin (truncation) and float32 + /// rounding of the coordinates, and both agree with an independent octave sum. A dropped M or lacunarity + /// factor, M applied to the wrong side, a clamp on frequency, stretch or the octaveStep index, or a dropped + /// sign (abs of the scale or the amplitude) moves the field by the gradient itself. /// [Theory] [InlineData(false)] @@ -207,15 +282,23 @@ public sealed class SpanStageTests var random = new System.Random(ridged ? 0x6A1D : 0x6A1C); var worst = 0.0; var checkedCases = 0; - for (var k = 0; k < 400; k++) + var spans = 0; + for (var k = 0; k < 6000 && spans < 80; k++) { - var stage = Stage.Draw(random, ridged ? random.Next(1, 3) : random.Next(1, 4)); + var stage = Stage.DrawSmooth(random, 300.0f, 9.0f, 8, 4); var q = Vector(random, -3.0f, 3.0f); // The fold's crease at n = 0 is not differentiable: keep every octave's stencil clear of it. - if (ridged && Enumerable.Range(0, stage.Octaves).Any(o => abs(snoise(stage.Point(q, o))) < 0.3f)) + if (ridged && Enumerable.Range(0, stage.Octaves).Any(o => abs(snoise(stage.Point(q, o))) < 0.15f)) continue; + spans++; var span = Span.Draw(random, q, 0.1f, 0.1f); - SpanNoise At(float3 p) => ridged ? stage.Ridged(p, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength) : stage.Fbm(p, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); + SpanNoise At(float3 p) + { + var path = span.Path; + path.midpoint = p; + return ridged ? stage.Ridged(path) : stage.Fbm(path); + } + var centre = At(q); // The value and gradient from an independent assembly of the octaves: pins shift, offset, octaveStep, // M, lacunarity and gain themselves, which a value/gradient self-consistency check cannot see. @@ -233,18 +316,16 @@ public sealed class SpanStageTests referenceGradient[i] += sign * stage.Amplitude(o) * stage.Scale(o) * matrix[i, j] * g[j]; } - var weight = 0.0; - for (var o = 0; o < stage.Octaves; o++) - weight += stage.Amplitude(o) * stage.Scale(o) * stage.Norm(); - Assert.True(Math.Abs(referenceValue - centre.field.w) <= 5.0e-4 * (1.0 + weight), $"stage {stage}: the value is {centre.field.w:R}, an independent octave sum gives {referenceValue:R}"); - for (var i = 0; i < 3; i++) - Assert.True(Math.Abs(referenceGradient[i] - centre.field[i]) <= 5.0e-3 * (1.0 + weight), $"stage {stage}: gradient {"xyz"[i]} is {centre.field[i]:R}, an independent octave sum gives {referenceGradient[i]:R}"); - // A step of 0.02 in the noise space of the fastest octave. - var fastest = stage.Norm() * Math.Pow(stage.Lacunarity, stage.Octaves - 1); - var h = (float)(0.02 / fastest); var scale = 0.0; for (var o = 0; o < stage.Octaves; o++) - scale += stage.Amplitude(o) * stage.Scale(o) * stage.Norm(); + scale += Math.Abs(stage.Amplitude(o) * stage.Scale(o)) * stage.Norm(); + var amplitudes = Enumerable.Range(0, stage.Octaves).Sum(o => Math.Abs(stage.Amplitude(o))); + Assert.True(Math.Abs(referenceValue - centre.field.w) <= 2.0e-3 * (1.0 + amplitudes), $"stage {stage}: the value is {centre.field.w:R}, an independent octave sum gives {referenceValue:R}"); + for (var i = 0; i < 3; i++) + Assert.True(Math.Abs(referenceGradient[i] - centre.field[i]) <= 1.0e-2 * (1.0 + scale), $"stage {stage}: gradient {"xyz"[i]} is {centre.field[i]:R}, an independent octave sum gives {referenceGradient[i]:R}"); + // A step of 0.02 in the noise space of the fastest octave. + var fastest = stage.Norm() * Enumerable.Range(0, stage.Octaves).Max(o => Math.Abs(stage.Scale(o))); + var h = (float)(0.02 / fastest); for (var axis = 0; axis < 3; axis++) { var e = new float3(axis == 0 ? h : 0.0f, axis == 1 ? h : 0.0f, axis == 2 ? h : 0.0f); @@ -276,33 +357,46 @@ public sealed class SpanStageTests var random = new System.Random(0x57E7C4); for (var k = 0; k < 60; k++) { - var stage = Stage.Draw(random, 3); + var stage = Stage.DrawSmooth(random, 300.0f, 15.0f, 3, 3); var q = Vector(random, -5.0f, 5.0f); var span = Span.Draw(random, q, 0.2f, 0.3f); - var plain = stage with { Stretch = 1.0f }; - var noAxis = stage with { Axis = new float3(0.0f, 0.0f, 0.0f), Stretch = 3.0f }; - var reference = plain.Ridged(q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); - var isotropic = (plain with { Axis = new float3(0.0f, 0.0f, 0.0f) }).Ridged(q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); - var viaNoAxis = noAxis.Ridged(q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); + var plain = stage with { S = stage.S with { stretch = 1.0f } }; + var noAxis = stage with { S = stage.S with { axis = None, stretch = 3.0f } }; + var reference = plain.Ridged(span.Path); + var isotropic = (plain with { S = plain.S with { axis = None } }).Ridged(span.Path); + var viaNoAxis = noAxis.Ridged(span.Path); Assert.True(SameBits(reference.field, isotropic.field) && reference.curvature == isotropic.curvature && reference.fold == isotropic.fold, "stretch 1 with an axis differs from no axis"); Assert.True(SameBits(isotropic.field, viaNoAxis.field) && isotropic.curvature == viaNoAxis.curvature && isotropic.fold == viaNoAxis.fold, "a zero axis with stretch 3 differs from the isotropic stage"); - var unit = stage with { Axis = new float3(0.0f, 1.0f, 0.0f) }; - var scaled = stage with { Axis = new float3(0.0f, 2.0f, 0.0f) }; - var a = unit.Fbm(q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); - var b = scaled.Fbm(q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); + var unit = stage with { S = stage.S with { axis = new float3(0.0f, 1.0f, 0.0f) } }; + var scaled = stage with { S = stage.S with { axis = new float3(0.0f, 2.0f, 0.0f) } }; + var a = unit.Fbm(span.Path); + var b = scaled.Fbm(span.Path); Assert.True(SameBits(a.field, b.field) && a.curvature == b.curvature, "a non-unit axis differs from its normalisation"); - var stretched = unit.Fbm(q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); - Assert.False(SameBits(stretched.field, (unit with { Stretch = 1.0f }).Fbm(q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength).field), "a stretch of " + unit.Stretch + " did not change the field"); + var stretched = unit.Fbm(span.Path); + Assert.False(SameBits(stretched.field, (unit with { S = unit.S with { stretch = 1.0f } }).Fbm(span.Path).field), "a stretch of " + unit.S.stretch + " did not change the field"); } } - // The stage parameters and per-octave bookkeeping the curvature checks share. + // The coordinate bands the enclosure tests draw: a large offset (0 keeps the drawn one) lifts S_o through + // the decades; Bucket names the decade a span's largest S_o is in. + private static readonly float[] OffsetScales = { 0.0f, 40.0f, 400.0f, 4.0e3f, 4.0e4f, 4.0e5f, 4.0e6f }; + + private static int Bucket(double magnitude) => magnitude < 1.0e2 ? 0 : magnitude < 1.0e3 ? 1 : magnitude < 1.0e4 ? 2 : magnitude < 1.0e5 ? 3 : magnitude < 1.0e6 ? 4 : 5; + + private static Stage Banded(System.Random random, float offsetScale, int octaves) + { + var stage = Stage.Draw(random, octaves); + if (offsetScale > 0.0f) + stage = stage with { S = stage.S with { offset = Vector(random, -offsetScale, offsetScale) } }; + return stage; + } + private static void CheckCurvature(Stage stage, bool ridged, Span span, SpanNoise result, int samples, ref double tightest, ref int checkedPoints) { for (var s = 0; s < samples; s++) { - var t = span.HalfLength * (2.0f * s / (samples - 1) - 1.0f); + var t = span.Path.halfLength * (2.0f * s / (samples - 1) - 1.0f); var norm = SymmetricNorm(FieldHessian(stage, span.At(t), ridged)); Assert.True(norm <= result.curvature * (1.0 + 1.0e-3), $"stage {stage}, span {span}: the Hessian norm at t = {t:R} is {norm:R}, above curvature {result.curvature:R}"); tightest = Math.Min(tightest, result.curvature > 0.0 ? 1.0 - norm / result.curvature : 1.0); @@ -311,13 +405,15 @@ public sealed class SpanStageTests } /// - /// span-bounds-enclose, curvature: over seeded spans the operator norm of the field's q-Hessian (chain rule - /// from snoise_hessian, M and the octave scales) at 64 points per span never exceeds curvature, in the - /// local regime (short spans, where the third-derivative term is the bound) and the global one (long spans, - /// where SNOISE_HESSIAN is); at halfLength 0 curvature is the local tier, the Frobenius norm of the - /// midpoint Hessians scaled by amplitude, f_o^2 and |M|^2, to within float rounding; and a span through - /// MeasureHessian's witness stays enclosed in 200 directions, which the Frobenius norm at the midpoint alone - /// does not guarantee. + /// span-bounds-enclose, curvature: over seeded spans the operator norm of the exact field q-Hessian (chain + /// rule from the double kernel's Hessian, every octave point in double, M and the octave scales) at 64 + /// points per span never exceeds curvature, in the local regime (short spans, where the third-derivative + /// term is the bound) and the global one (long spans, where SNOISE_HESSIAN is), and in every coordinate + /// band from S_o under 1e2 to over 1e6, where the float32 kernel is no longer the exact one; at + /// halfLength 0 curvature is the widened local tier, the Frobenius norm of the midpoint Hessians plus + /// e_h, capped at the pin, scaled by amplitude, f_o^2 and |M|^2; and a span through MeasureHessian's + /// witness stays enclosed in 200 directions, which the Frobenius norm at the midpoint alone does not + /// guarantee. /// [Theory] [InlineData(false)] @@ -327,28 +423,34 @@ public sealed class SpanStageTests var random = new System.Random(ridged ? 0xC0DE2 : 0xC0DE1); var tightest = 1.0; var checkedPoints = 0; - foreach (var halfLength in new[] { 0.002f, 0.02f, 0.2f, 1.0f, 8.0f }) - for (var k = 0; k < 24; k++) - { - var stage = Stage.Draw(random, random.Next(1, 4)); - var span = Span.Draw(random, Vector(random, -30.0f, 30.0f), halfLength, Uniform(random, 0.0f, 2.0f)); - var result = ridged ? stage.Ridged(span.Q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength) : stage.Fbm(span.Q, span.Velocity, span.Speed, span.AccelerationBound, span.HalfLength); - CheckCurvature(stage, ridged, span, result, 64, ref tightest, ref checkedPoints); - } + var buckets = new int[6]; + foreach (var offsetScale in OffsetScales) + foreach (var halfLength in new[] { 0.02f, 0.5f, 8.0f }) + for (var k = 0; k < 8; k++) + { + var stage = Banded(random, offsetScale, 3); + var span = Span.Draw(random, Vector(random, -30.0f, 30.0f), halfLength, Uniform(random, 0.0f, 2.0f)); + var result = ridged ? stage.Ridged(span.Path) : stage.Fbm(span.Path); + buckets[Bucket(stage.MaxMagnitude(span.Path))]++; + CheckCurvature(stage, ridged, span, result, 64, ref tightest, ref checkedPoints); + } - // halfLength 0: the local tier exactly. + Assert.True(buckets.All(count => count >= 3), $"the coordinate bands drawn are {string.Join(", ", buckets)} spans: a band is not reached"); + + // halfLength 0: the widened local tier. for (var k = 0; k < 40; k++) { - var stage = Stage.Draw(random, random.Next(1, 4)); + var stage = Stage.DrawSmooth(random, 100.0f, 90.0f, 3); var q = Vector(random, -30.0f, 30.0f); var span = Span.Draw(random, q, 0.0f, 1.0f); - var result = ridged ? stage.Ridged(q, span.Velocity, span.Speed, span.AccelerationBound, 0.0f) : stage.Fbm(q, span.Velocity, span.Speed, span.AccelerationBound, 0.0f); + var result = ridged ? stage.Ridged(span.Path) : stage.Fbm(span.Path); var expected = 0.0; for (var o = 0; o < stage.Octaves; o++) { var h = snoise_hessian(stage.Point(q, o)); var frobenius = Math.Sqrt(Enumerable.Range(0, 9).Sum(i => (double)h[i / 3][i % 3] * h[i / 3][i % 3])); - expected += stage.Amplitude(o) * stage.Scale(o) * stage.Scale(o) * stage.Norm() * stage.Norm() * Math.Min(SNOISE_HESSIAN, frobenius); + var slack = Math.Pow(2.0, -10) + SNOISE_THIRD * Math.Pow(2.0, -20) * stage.Magnitude(span.Path, o); + expected += Math.Abs(stage.Amplitude(o)) * stage.Scale(o) * stage.Scale(o) * stage.Norm() * stage.Norm() * Math.Min(SNOISE_HESSIAN, frobenius + slack); } Assert.InRange(result.curvature, expected * (1.0 - 1.0e-3), expected * (1.0 + 1.0e-3)); @@ -357,27 +459,50 @@ public sealed class SpanStageTests // Through the Hessian witness: one octave at the identity transform, the span's midpoint 0.1 to 0.5 away, // moving toward the witness at speed 1, so the witness lies on the span. var witness = new float3(-13.7920685f, -9.672134f, 61.563934f); - var identity = new Stage(1.0f, 1.0f, new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), 1, 2.0f, 0.5f); + var identity = new Stage(SpanStage.Identity, 1, 2.0f, 0.5f); var through = 0.0; for (var k = 0; k < 200; k++) { var u = UnitVector(random); var distance = Uniform(random, 0.1f, 0.5f); - var span = new Span(witness + u * distance, -u, new float3(0.0f, 0.0f, 0.0f), 1.0f, 0.0f, 0.6f); - var result = ridged ? identity.Ridged(span.Q, span.Velocity, 1.0f, 0.0f, 0.6f) : identity.Fbm(span.Q, span.Velocity, 1.0f, 0.0f, 0.6f); + var span = new Span(new SpanPath { midpoint = witness + u * distance, velocity = -u, speed = 1.0f, acceleration = 0.0f, halfLength = 0.6f }, None); + var result = ridged ? identity.Ridged(span.Path) : identity.Fbm(span.Path); CheckCurvature(identity, ridged, span, result, 64, ref tightest, ref checkedPoints); through = Math.Max(through, SymmetricNorm(FieldHessian(identity, witness, ridged))); } Assert.True(through > 40.0, $"the witness Hessian norm is {through:R}: the through-the-witness spans do not reach the curvature"); - output.WriteLine($"IV-REPORT span curvature ({(ridged ? "ridged" : "fbm")}): {checkedPoints} points enclosed, least relative slack {tightest:R}, witness norm {through:R}"); + output.WriteLine($"IV-REPORT span curvature ({(ridged ? "ridged" : "fbm")}): {checkedPoints} points enclosed, bands {string.Join("/", buckets)}, least relative slack {tightest:R}, witness norm {through:R}"); + } + + /// + /// local-tier-coefficients-unpinned: a fixed span (found by an audit, kept) at SpanStage.Identity, one + /// octave, where the Hessian's operator norm at the end of the span (11.768) exceeds the local tier built + /// with half of SNOISE_THIRD's term (about 10.1) and the exact norm along the 64 sampled points stays + /// under curvature (12.526 before the allowance). + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SpanCurvatureEnclosesThePinnedRankOneSpan(bool ridged) + { + var stage = new Stage(SpanStage.Identity, 1, 2.0f, 0.5f); + var span = new Span(new SpanPath { midpoint = new float3(144.93297f, -286.08800f, -53.59360f), velocity = new float3(-0.02139f, 0.17122f, -0.98500f), speed = 1.0f, acceleration = 0.0f, halfLength = 0.01f }, None); + var result = ridged ? stage.Ridged(span.Path) : stage.Fbm(span.Path); + var tightest = 1.0; + var points = 0; + CheckCurvature(stage, ridged, span, result, 64, ref tightest, ref points); + var end = SymmetricNorm(FieldHessian(stage, span.At(span.Path.halfLength), ridged)); + Assert.True(end > 10.5 && result.curvature >= end, $"the end-of-span norm is {end:R} and curvature {result.curvature:R}: the span no longer pins the local tier"); } /// /// span-bounds-enclose, fold: for span_ridged, the value's departure from the midpoint branch (each /// octave's gain^o (s n - |n|), s the sign of n at the midpoint, summed) never exceeds fold at 64 points of - /// every span; fold is positive on spans that do cross zero (and the departure there is not trivially - /// zero), exactly 0 when no octave can reach zero and for span_fbm, and 0 at halfLength 0. + /// every span, measured twice: for the float field span_ridged itself evaluates at the path point, and for + /// the exact octave sum (double kernel); in every coordinate band from S_o under 1e2 to over 1e6. fold is + /// positive on spans that do cross zero (and the departure there is not trivially zero), exactly 0 for + /// span_fbm and when no octave can reach zero. /// [Fact] public void SpanFoldEnclosesTheBranchDeviation() @@ -385,62 +510,77 @@ public sealed class SpanStageTests var random = new System.Random(0xF01D); var crossing = 0; var largest = 0.0; - var tightest = 1.0; - for (var k = 0; k < 400; k++) - { - var stage = Stage.Draw(random, random.Next(1, 4)); - var halfLength = new[] { 0.02f, 0.1f, 0.5f, 2.0f }[random.Next(4)]; - // Draw midpoints with small noise values so spans cross zero, as well as arbitrary ones. - var q = Vector(random, -30.0f, 30.0f); - if (k % 2 == 0) + var tightestFloat = 1.0; + var tightestExact = 1.0; + var buckets = new int[6]; + foreach (var offsetScale in OffsetScales) + for (var k = 0; k < 60; k++) { - var best = q; - var smallest = float.MaxValue; - for (var trial = 0; trial < 40; trial++) + var stage = Banded(random, offsetScale, 3); + var halfLength = new[] { 0.02f, 0.1f, 0.5f, 2.0f }[random.Next(4)]; + // Draw midpoints with small noise values so spans cross zero, as well as arbitrary ones. + var q = Vector(random, -30.0f, 30.0f); + if (k % 2 == 0) { - var candidate = Vector(random, -30.0f, 30.0f); - var value = abs(snoise(stage.Point(candidate, 0))); - if (value < smallest) - (smallest, best) = (value, candidate); + var best = q; + var smallest = float.MaxValue; + for (var trial = 0; trial < 40; trial++) + { + var candidate = Vector(random, -30.0f, 30.0f); + var value = abs(snoise(stage.Point(candidate, 0))); + if (value < smallest) + (smallest, best) = (value, candidate); + } + + q = best; } - q = best; - } - - var span = Span.Draw(random, q, halfLength, Uniform(random, 0.0f, 2.0f)); - var fold = stage.Ridged(q, span.Velocity, span.Speed, span.AccelerationBound, halfLength).fold; - Assert.Equal(0.0f, stage.Fbm(q, span.Velocity, span.Speed, span.AccelerationBound, halfLength).fold); - Assert.Equal(0.0f, stage.Ridged(q, span.Velocity, span.Speed, span.AccelerationBound, 0.0f).fold); - var signs = Enumerable.Range(0, stage.Octaves).Select(o => Math.Sign(snoise(stage.Point(q, o)))).ToArray(); - var peak = 0.0; - for (var s = 0; s < 64; s++) - { - var t = halfLength * (2.0f * s / 63 - 1.0f); - var point = span.At(t); - var departure = 0.0; - for (var o = 0; o < stage.Octaves; o++) + var span = Span.Draw(random, q, halfLength, Uniform(random, 0.0f, 2.0f)); + buckets[Bucket(stage.MaxMagnitude(span.Path))]++; + var fold = stage.Ridged(span.Path).fold; + Assert.Equal(0.0f, stage.Fbm(span.Path).fold); + var signs = Enumerable.Range(0, stage.Octaves).Select(o => Math.Sign(stage.OneOctave(o).Fbm(PathAt(q)).field.w)).ToArray(); + var peakFloat = 0.0; + var peakExact = 0.0; + for (var s = 0; s < 64; s++) { - var n = (double)snoise(stage.Point(point, o)); - departure += stage.Amplitude(o) * (signs[o] * n - Math.Abs(n)); + var t = halfLength * (2.0f * s / 63 - 1.0f); + var point = span.At(t); + var departureFloat = 0.0; + var departureExact = 0.0; + for (var o = 0; o < stage.Octaves; o++) + { + var octave = stage.OneOctave(o); + var nFloat = (double)octave.Fbm(PathAt(point)).field.w; + var absFloat = 1.0 - octave.Ridged(PathAt(point)).field.w; + departureFloat += stage.Amplitude(o) * (signs[o] * nFloat - absFloat); + var x = stage.PointD(point, o); + var nExact = NoiseBoundTests.ValueDouble(x[0], x[1], x[2]); + departureExact += stage.Amplitude(o) * (signs[o] * nExact - Math.Abs(nExact)); + } + + peakFloat = Math.Max(peakFloat, Math.Abs(departureFloat)); + peakExact = Math.Max(peakExact, Math.Abs(departureExact)); + Assert.True(Math.Abs(departureFloat) <= fold * (1.0 + 1.0e-3) + 1.0e-6, $"stage {stage}, span {span}: the float branch departure at t = {t:R} is {departureFloat:R}, above fold {fold:R}"); + Assert.True(Math.Abs(departureExact) <= fold * (1.0 + 1.0e-3) + 1.0e-6, $"stage {stage}, span {span}: the exact branch departure at t = {t:R} is {departureExact:R}, above fold {fold:R}"); } - peak = Math.Max(peak, Math.Abs(departure)); - Assert.True(Math.Abs(departure) <= fold * (1.0 + 1.0e-3), $"stage {stage}, span {span}: the branch departure at t = {t:R} is {departure:R}, above fold {fold:R}"); + if (fold > 0.0) + crossing++; + largest = Math.Max(largest, Math.Max(peakFloat, peakExact)); + if (fold > 0.0 && peakFloat > 0.0) + tightestFloat = Math.Min(tightestFloat, peakFloat / fold); + if (fold > 0.0 && peakExact > 0.0) + tightestExact = Math.Min(tightestExact, peakExact / fold); } - if (fold > 0.0) - crossing++; - largest = Math.Max(largest, peak); - if (fold > 0.0 && peak > 0.0) - tightest = Math.Min(tightest, peak / fold); - } - - Assert.True(crossing >= 100, $"only {crossing} of 400 spans had a positive fold"); + Assert.True(buckets.All(count => count >= 3), $"the coordinate bands drawn are {string.Join(", ", buckets)} spans: a band is not reached"); + Assert.True(crossing >= 100, $"only {crossing} of {OffsetScales.Length * 60} spans had a positive fold"); Assert.True(largest > 0.1, $"the largest branch departure drawn is {largest:R}: the check has no teeth"); - output.WriteLine($"IV-REPORT span fold: {crossing} spans with fold > 0, largest departure {largest:R}, tightest departure / fold {tightest:R}"); + output.WriteLine($"IV-REPORT span fold: {crossing} spans with fold > 0, bands {string.Join("/", buckets)}, largest departure {largest:R}, tightest departure / fold {tightestFloat:R} (float) {tightestExact:R} (exact)"); // An octave that cannot reach zero: a midpoint where |n| is large and a short span give fold exactly 0. - var calm = new Stage(1.0f, 1.0f, new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), 1, 2.0f, 0.5f); + var calm = new Stage(SpanStage.Identity, 1, 2.0f, 0.5f); var found = 0; for (var k = 0; k < 400 && found < 20; k++) { @@ -448,7 +588,7 @@ public sealed class SpanStageTests if (abs(snoise(q)) < 0.6f) continue; found++; - Assert.Equal(0.0f, calm.Ridged(q, new float3(1.0f, 0.0f, 0.0f), 1.0f, 0.0f, 0.01f).fold); + Assert.Equal(0.0f, calm.Ridged(new SpanPath { midpoint = q, velocity = new float3(1.0f, 0.0f, 0.0f), speed = 1.0f, acceleration = 0.0f, halfLength = 0.01f }).fold); } Assert.True(found >= 5, $"only {found} calm midpoints found"); @@ -464,8 +604,7 @@ public sealed class SpanStageTests public void SpanFoldCoversAccelerationAlone() { var random = new System.Random(0xACCE1); - var identity = new Stage(1.0f, 1.0f, new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), new float3(0.0f, 0.0f, 0.0f), 1, 2.0f, 0.5f); - var still = new float3(0.0f, 0.0f, 0.0f); + var identity = new Stage(SpanStage.Identity, 1, 2.0f, 0.5f); var cases = 0; var crossings = 0; var tightest = double.MaxValue; @@ -480,8 +619,8 @@ public sealed class SpanStageTests if (length(gradient) < 1.5f || abs(n.w) > 0.4f * length(gradient) * acceleration * halfLength * halfLength * 0.5f) continue; var c = -(float)Math.Sign(n.w) * acceleration * normalize(gradient); - var span = new Span(q, still, c, acceleration * halfLength, acceleration, halfLength); - var fold = identity.Ridged(q, still, span.Speed, acceleration, halfLength).fold; + var span = new Span(new SpanPath { midpoint = q, velocity = None, speed = acceleration * halfLength, acceleration = acceleration, halfLength = halfLength }, c); + var fold = identity.Ridged(span.Path).fold; var departure = 0.0; for (var s = 0; s < 64; s++) {