diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md
index f5f5d5a5..dde7a4d2 100644
--- a/packages/cultmath/docs/design.md
+++ b/packages/cultmath/docs/design.md
@@ -100,7 +100,10 @@ needs anything else, the test fails:
no CultMath analog.
3. File-scope `static const` becomes `const`.
4. Floating literals gain `f` (`0.5` becomes `0.5f`).
-5. The file body is wrapped in a class, since C# has no free functions; HLSL
+5. Struct fields gain `public`. HLSL struct members have no access-modifier
+ concept; a C# struct's fields default to `private`, which would hide them
+ from the mirror test's reflection-based field comparison.
+6. The file body is wrapped in a class, since C# has no free functions; HLSL
functions become private instance methods.
HLSL constructs that stay outside the target and must not appear in the mirror:
@@ -120,6 +123,44 @@ compiled under `using static CultMath.math;`, so its intrinsics are C# `math`
itself: the test proves the composition in the text, not the intrinsic rules,
which only `HlslSemanticsTests` pins.
+The comparison also allows exactly one struct return shape: a mirror function
+whose HLSL return type is a plain struct of `float`/`floatN`/`int`/`intN`
+fields is compared to its C# counterpart field by field, recursing into each
+field's own components, down to bit-for-bit scalars. `CultCellular` (`math.cs`,
+`cellular`) is the first and, for now, only consumer. A struct return with a
+mismatched field does not get a second comparison path or a shape of its own;
+it fails the same walk that already handles a bare vector return.
+
+## Invariant 8: Value-and-Gradient Primitives
+
+A primitive tagged invariant 8 returns its value and analytic gradient
+together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin
+(a value-only form would be a second path with no consumer). `smin_grad` and
+`cellular` are the first primitives in this family:
+
+- `smin_grad(float4 a, float4 b, float k)` is Inigo Quilez's cubic-polynomial
+ smooth minimum ("smooth minimum",
+ ), carried to value-and-gradient
+ form. Its blend factor `h` is affine in `b.w - a.w`, so differentiating the
+ value with respect to position, the terms carrying `dh/dp` cancel exactly;
+ the surviving gradient is `lerp(∇b, ∇a, h)`, the same `h` the value uses.
+ This is the analytic gradient, not an approximation, and it is continuous
+ across the `|a.w - b.w| = k` seam where `h` saturates to 0 or 1.
+- `cellular(float3 p)` is Worley's cellular texture basis function ("A
+ Cellular Texture Basis Function", SIGGRAPH 1996), searched over the
+ jittered 3×3×3 neighbourhood of feature points selected by `pcg3d` (never
+ the sin-based `hash`). It returns `CultCellular { nearest, edge, id }`:
+ `nearest` is `(∇F1, F1)`, `edge` is `(∇F2 - ∇F1, F2 - F1)`, and `id` is the
+ nearest cell's `pcg3d` hash mapped to `[0, 1)`. `∇F1`/`∇F2` are undefined
+ exactly at their own feature point (`F1 = 0` or `F2 = 0`); `cellular`
+ follows the repo's existing degenerate-normal convention
+ (`GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle`, which
+ guards a zero-length normal and returns the zero vector instead of the NaN
+ a bare `normalize` gives there) and returns the zero vector rather than NaN.
+ The identity of the nearest and second-nearest feature point changes
+ discontinuously across the `F1 = F2` set (a genuine kink, not a numerical
+ artifact), so `∇F1` and `∇F2` are only continuous away from that set.
+
Integer hashing uses the PCG hashes from Jarzynski and Olano, "Hash Functions
for GPU Rendering" (JCGT 9(3), 2020): `pcg(uint)` is O'Neill's RXS-M-XS 32/32
permutation over one LCG step, and `pcg3d`/`pcg4d` are the paper's (3 → 3) and
diff --git a/packages/cultmath/src/CultMath/CultCellular.cs b/packages/cultmath/src/CultMath/CultCellular.cs
new file mode 100644
index 00000000..a48cb95e
--- /dev/null
+++ b/packages/cultmath/src/CultMath/CultCellular.cs
@@ -0,0 +1,25 @@
+namespace CultMath;
+
+///
+/// The value-and-gradient result of (invariant 8's one struct
+/// return shape). Every field follows the same float4(gradient.xyz, value.w) layout as a
+/// bare value-and-gradient return.
+///
+public struct CultCellular
+{
+ /// (∇F1, F1): the gradient and distance to the nearest feature point.
+ public float4 nearest;
+
+ /// (∇F2 - ∇F1, F2 - F1): for lineae along cell borders.
+ public float4 edge;
+
+ /// The nearest cell's pcg3d hash, mapped to [0, 1).
+ public float id;
+
+ public CultCellular(float4 nearest, float4 edge, float id)
+ {
+ this.nearest = nearest;
+ this.edge = edge;
+ this.id = id;
+ }
+}
diff --git a/packages/cultmath/src/CultMath/CultCellular.cs.meta b/packages/cultmath/src/CultMath/CultCellular.cs.meta
new file mode 100644
index 00000000..8c9ed1ae
--- /dev/null
+++ b/packages/cultmath/src/CultMath/CultCellular.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 76be9498b63e410891715b3ec965caf8
diff --git a/packages/cultmath/src/CultMath/math.cs b/packages/cultmath/src/CultMath/math.cs
index b5d5339c..32d6afb1 100644
--- a/packages/cultmath/src/CultMath/math.cs
+++ b/packages/cultmath/src/CultMath/math.cs
@@ -688,6 +688,79 @@ public static partial class math
private static float3 snoise_permute(float3 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
private static float4 snoise_permute(float4 value) => snoise_mod289(((value * 34.0f) + 1.0f) * value);
+ // Inigo Quilez, "smooth minimum" (https://iquilezles.org/articles/smin/): the cubic-polynomial
+ // smin, carried to value-and-gradient form (invariant 8). h is affine in (b.w - a.w) inside the
+ // smoothing band, so differentiating value = lerp(b.w, a.w, h) - k*h*(1-h) with respect to
+ // position, the terms carrying dh/dp cancel exactly (dh/dp * [(a.w-b.w) - k + 2*k*h] and the
+ // bracket is identically zero given how h is built from a.w-b.w). What is left is exactly
+ // lerp(∇b, ∇a, h): the gradient blend is not an approximation, it is the analytic gradient.
+ // Outside the band (|a.w - b.w| >= k), h saturates to 0 or 1 and this is exactly min(a, b) with
+ // that input's own gradient, continuously (h and the correction term both reach the boundary at
+ // the same value from the smooth side, so there is no kink to exclude here the way cellular's
+ // F1 = F2 seam needs one).
+ public static float4 smin_grad(float4 a, float4 b, float k)
+ {
+ var h = saturate(0.5f + 0.5f * (b.w - a.w) / k);
+ var value = lerp(b.w, a.w, h) - k * h * (1.0f - h);
+ var gradient = lerp(new float3(b.x, b.y, b.z), new float3(a.x, a.y, a.z), h);
+ return new float4(gradient, value);
+ }
+
+ // Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996): F1/F2 and their analytic
+ // gradients, searched over the jittered 3x3x3 neighbourhood of feature points. Feature points
+ // are selected with pcg3d (design.md, "Integer hashing"), never the sin-based hash. The gradient
+ // of a distance field, del|p - c|, is the unit vector (p - c)/|p - c|; that is undefined exactly
+ // at a feature point (F1 = 0), so cellular follows the repo's existing degenerate-normal
+ // convention (GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle: guard the zero-length
+ // case and return the zero vector instead of the NaN a bare normalize would produce there).
+ public static CultCellular cellular(float3 p)
+ {
+ var cell = floor(p);
+ var f1 = 1.0e30f;
+ var f2 = 1.0e30f;
+ var c1 = float3.zero;
+ var c2 = float3.zero;
+ var hash1 = 0;
+
+ for (var dz = -1; dz <= 1; dz++)
+ {
+ for (var dy = -1; dy <= 1; dy++)
+ {
+ for (var dx = -1; dx <= 1; dx++)
+ {
+ var neighbor = cell + new float3(dx, dy, dz);
+ var hash = pcg3d(neighbor);
+ var jitter = new float3(cellular_unit(hash.x), cellular_unit(hash.y), cellular_unit(hash.z));
+ var feature = neighbor + jitter;
+ var d = distance(p, feature);
+
+ if (d < f1)
+ {
+ f2 = f1; c2 = c1;
+ f1 = d; c1 = feature; hash1 = hash.x;
+ }
+ else if (d < f2)
+ {
+ f2 = d; c2 = feature;
+ }
+ }
+ }
+ }
+
+ var grad1 = f1 > 0.0f ? (p - c1) / f1 : float3.zero;
+ var grad2 = f2 > 0.0f ? (p - c2) / f2 : float3.zero;
+
+ return new CultCellular(
+ new float4(grad1, f1),
+ new float4(grad2 - grad1, f2 - f1),
+ cellular_unit(hash1));
+ }
+
+ // Maps a pcg3d output component's uint bit pattern to [0, 1) by the standard uint-to-float
+ // divide-by-2^32; the division is by an exact power of two, and the uint-to-float conversion is
+ // the same IEEE-754 round-to-nearest both dxc and C# use, so this is bit-exact on both sides.
+ private static float cellular_unit(int bits) => (uint)bits * (1.0f / 4294967296.0f);
+
public static float value_noise(float2 position)
{
var cell = floor(position);