OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / bccc60b

gate: the erosion filter joins the parity gate, and the f-suffix hazard is measured
hyper-games #65 NEXT item 3. erosion_filter.inc was the one dual-compiled source not among the gate's subjects -- the oldest of the six, the one dc-terrain and hyper-tps both evaluate, and the only one written before the f-suffix rule. It is the sixth subject now. 5365 -> 6445 cases, 6445/6445 agree 1074 EXACT (up from 1056), 5371 NEAR 3862/5371 bit-identical anyway, worst relative 3.04e-05 (ps_fbm_noise_3d, unchanged -- no erosion case is anywhere near it) Twelve opcodes, bottom-up in the layering the Blender port was built in and for the same reason: a divergence in erosion_hash is unreadable once it has been through eight octaves, a fade and a gully mask. erosion_hash is EXACT -- integer lattice arithmetic to a float conversion, the same class as the blender hash layer, and a near-miss there is a finding, not a tolerance. STRIDE 24 -> 56. erosion_node_eval_full takes nineteen parameters, thirty floats once the vec2s and vec4s are counted out. The alternative was reusing slots that already mean something else, which is how a gate comes to compare two things neither side meant. The block's slot indices are EMITTED into the shader from the C++ enum, like the opcodes -- Voronoi's are still spelled `gF(b, 15u)` by hand, which is the drift this file warns about everywhere else. --- THE HAZARD'S EFFECT, WHICH IS WHAT #65 ACTUALLY ASKED FOR. 119 bare float literals, none suffixed, so on the C++ side every expression containing one is evaluated in double and narrowed at the end. Both texts were compiled at once -- the shipped .inc and an f-suffixed twin, in two namespaces, the only moment both exist to be asked: erosion_hash 29282 cases 0 differ grad_noised_2d 43923 cases 0 differ fractal_noise_2d 43923 cases 0 differ phacelle_noise 58564 cases 4276 differ (7.3%) worst 9.2e-07 erosion_filter 8405 cases 1713 differ (20.4%) worst 3.0e-07 erosion_node_eval_full 3362 cases 581 differ (17.3%) worst 2.4e-07 ⚠ I PREDICTED THE WRONG SITE. I wrote, into the gate, that the quintic fade `vec2 u = f*f*f*(f*(f*6.0-15.0)+10.0)` was where the hazard was sharpest -- a chained expression full of bare literals at the bottom of every octave. It is IMMUNE, 0 of 43923. `f*6.0` resolves to the shim's `operator*(vec2, float)` and the literal narrows AT THE CALL, so every vector expression in the file is already float. Only genuinely scalar chains promote, which is why the three that differ are the three with scalar arithmetic in them. The sentence is corrected in the file. AND THE SUFFIXES ARE NOT APPLIED, on the measurement. With the twin in place the gate went 3862/5371 bit-identical -> 3861: one case WORSE. At 1e-07 the GPU's FMA contraction dominates and it is not systematically on either side of the double promotion. Rewriting 119 literals in a filter two other systems evaluate, to move numbers by 3e-07 in a direction the measurement does not endorse, is churn with a surface change attached. The class is not removed by construction here; it is covered by this gate instead. That reasoning is in the file so the next reader does not re-derive it -- and it is a decision, not a law: the measurement is there to be argued with. --- A DEFECT IN THE GATE'S OWN COMPARATOR, found by the new cases. smooth_start (-0.5, 0.0) divides by the smoothing and returns +inf on BOTH sides -- the same number -- and the gate printed `cpu=inf gpu=inf FAIL`. inf - inf is nan, and every comparison against nan is false, so the NEAR arm rejected a pair that agrees bit for bit. Identical bits are agreement and that clause now comes first; a non-finite relative error no longer competes to be the reported worst. --- MUTATION-TESTED, and the obvious way proves nothing here. Editing the .inc changes BOTH sides -- one text compiled twice -- so the gate stays green. The mutation that works is to edit the .inc and NOT regenerate the .gen.h: the CPU picks it up through the shim, the GPU keeps the shipped text. That is also the stale-header scenario the generator promises cannot happen silently. Nine mutations, every family caught by its own case (table in the file). ⛔ A TENTH PROVED NOTHING AND IS RECORDED AS SUCH. TAU 6.28318530717958647 -> 6.28318530717000000 left the gate green, and it is not a hole in the phacelle cases: both spellings are the same float32, 6.2831855, so the mutation never happened. Changing it to 6.2831 -- a real float32 change -- fails 92 cases. Unmoved: erosion_filter.inc and its generated header byte-identical (md5 dc0dd5bda83eb8d71cd62e8b9e3ca189), peak RSS 675 MiB against the 4 GiB cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bccc60b91e4b61abfc6cb77904485814bbc0f860
by plankatron <fleeky@gmail.com> · 2026-09-06T14:14:33.000Z

Changed files (1) +490 -8

tests/test_shared_noise_parity.cpp +490 -8
……@@ -85,6 +85,11 @@
8585#include <vector>
8686
8787#include "blender_noise_cpp_shim.h"
88// NOT `using`d. dcerosion has its own vec2/vec3/vec4, floor, max, dot and
89// mix, and so does bnoisegl -- two shims for two .inc families, deliberately
90// separate. Every erosion call below is qualified, and the types are built
91// explicitly, so nothing is resolved by luck.
92#include "erosion_cpp_shim.h"
8893
8994using namespace godot::bnoisegl;
9095
……@@ -96,10 +101,52 @@ static const char *SUBJECT_FILES[] = {
96101 "src/gpu/blender_voronoi_glsl.gen.h",
97102 "src/gpu/blender_ramp_blend_glsl.gen.h",
98103 "src/gpu/proc_simplex_glsl.gen.h",
104 // THE SIXTH, added 2026-09-06. erosion_filter.inc was the one dual-compiled
105 // source not among the subjects -- the oldest of them, the one three other
106 // systems evaluate, and the only one written before the f-suffix rule. It
107 // carries 119 bare float literals and not one suffixed, so on the C++ side
108 // every expression containing one is evaluated in DOUBLE and narrowed at the
109 // end, where the shader computes the whole chain in float. `t*t*t*(t*(t*6.0
110 // - 15.0) + 10.0)` is that hazard in one line, and it is the quintic fade at
111 // the bottom of every octave of every erosion evaluation.
112 //
113 // It is self-contained: pn_max_octaves appears only in its comments, and the
114 // pn_ero_* globals belong to erosion_wrapper_glsl.h, not to this text.
115 //
116 // ⚠ THE HAZARD'S EFFECT WAS MEASURED, AND IT DOES NOT JUSTIFY SUFFIXING.
117 // hyper-games #65 asked for the effect, not the hazard. Both texts were
118 // compiled at once -- the shipped .inc and an f-suffixed twin, in two
119 // namespaces -- and asked the same questions over a dense sweep:
120 //
121 // erosion_hash 29282 cases 0 differ
122 // grad_noised_2d 43923 cases 0 differ
123 // fractal_noise_2d 43923 cases 0 differ
124 // phacelle_noise 58564 cases 4276 differ (7.3%) worst 9.2e-07
125 // erosion_filter 8405 cases 1713 differ (20.4%) worst 3.0e-07
126 // erosion_node_eval_full 3362 cases 581 differ (17.3%) worst 2.4e-07
127 //
128 // The three zeros are the finding. `vec2 u = f*f*f*(f*(f*6.0-15.0)+10.0)` --
129 // the quintic fade, which I predicted would be the sharpest site -- is
130 // IMMUNE, because `f*6.0` resolves to the shim's `operator*(vec2, float)`
131 // and the literal narrows AT THE CALL. Only genuinely scalar chains
132 // (`t - 0.5 * smoothing`) promote, which is why the three that differ are
133 // the three with scalar arithmetic in them.
134 //
135 // And suffixing does not move the CPU toward the GPU. With the twin in
136 // place the whole gate went from 3862/5371 bit-identical to 3861 -- one
137 // case WORSE. At this magnitude the GPU's FMA contraction dominates and it
138 // is not systematically on either side of the double promotion. So the
139 // literals are left alone: rewriting 119 of them in a filter that
140 // dc-terrain and hyper-tps both evaluate, to move numbers by 3e-07 in a
141 // direction the measurement does not endorse, is churn with a surface
142 // change attached. The class is not removed by construction here; it is
143 // covered by this gate instead.
144 "src/gpu/erosion_filter_glsl.gen.h",
99145};
100146static const char *SUBJECT_MARKS[] = { "BLENDER_HASH_GLSL", "BLENDER_PERLIN_GLSL",
101 "BLENDER_VORONOI_GLSL", "BLENDER_RAMP_BLEND_GLSL", "PROC_SIMPLEX_GLSL" };
102static const int SUBJECT_N = 5;
147 "BLENDER_VORONOI_GLSL", "BLENDER_RAMP_BLEND_GLSL", "PROC_SIMPLEX_GLSL",
148 "EROSION_FILTER_GLSL" };
149static const int SUBJECT_N = 6;
103150
104151static const double TOL_REL = 1e-5;
105152static const double TOL_ABS = 1e-6;
……@@ -289,8 +336,13 @@ static void init_vulkan(Ctx &c) {
289336// ============================================================================
290337
291338// 24 rather than 16 since Voronoi arrived: its parameter block is eleven
292// fields and every one of them changes the answer.
293static const uint32_t STRIDE = 24;
339// fields and every one of them changes the answer. 56 since the erosion filter
340// arrived: erosion_node_eval_full takes NINETEEN parameters, thirty floats once
341// the vec2s and vec4s are counted out, and every one of them changes the
342// surface. Widening the record costs 1300 x 32 more words -- 166 KB -- and the
343// alternative is reusing slots that already mean something else, which is how a
344// gate comes to compare two things neither side meant.
345static const uint32_t STRIDE = 56;
294346
295347enum Op {
296348 OP_HASH1 = 1, OP_HASH2, OP_HASH3, OP_HASH4,
……@@ -317,6 +369,21 @@ enum Op {
317369 OP_RAMP_BLEND, // type = blend mode, comp = channel
318370 OP_SIMPLEX, // the raw Ashima lattice
319371 OP_SIMPLEX_FBM, // type = octaves
372 // erosion_filter.inc, bottom-up. The layering is the one the Blender port
373 // was built in, and for the same reason: a divergence in erosion_hash is
374 // unreadable once it has been through eight octaves and a fade.
375 OP_ERO_HASH, // comp 0..1 -- integer lattice, EXACT
376 OP_ERO_GRAD_NOISED, // comp 0..2 -- height + ANALYTIC derivative
377 OP_ERO_FRACTAL, // comp 0..2 -- the octave loop
378 OP_ERO_CLAMP01,
379 OP_ERO_POW_INV,
380 OP_ERO_EASE_OUT,
381 OP_ERO_SMOOTH_START,
382 OP_ERO_NORMALIZE_SAFE, // comp 0..1
383 OP_ERO_PHACELLE, // comp 0..3 -- the fade-approach primitive
384 OP_ERO_FILTER, // comp 0..4 -- delta.xyz, magnitude, ridge
385 OP_ERO_EVAL_FULL, // comp 0..1 -- the wrapper ErosionNoiseNode calls
386 OP_ERO_EVAL_GRAD, // comp 0..3 -- same, keeping the slope
320387 OP__COUNT
321388};
322389
……@@ -341,6 +408,13 @@ static const OpName OP_NAMES[] = {
341408 { OP_VORONOI_XFX, "OP_VORONOI_XFX" }, { OP_VORONOI_EDGE, "OP_VORONOI_EDGE" },
342409 { OP_VORONOI_RADIUS, "OP_VORONOI_RADIUS" }, { OP_RAMP_BLEND, "OP_RAMP_BLEND" },
343410 { OP_SIMPLEX, "OP_SIMPLEX" }, { OP_SIMPLEX_FBM, "OP_SIMPLEX_FBM" },
411 { OP_ERO_HASH, "OP_ERO_HASH" }, { OP_ERO_GRAD_NOISED, "OP_ERO_GRAD_NOISED" },
412 { OP_ERO_FRACTAL, "OP_ERO_FRACTAL" }, { OP_ERO_CLAMP01, "OP_ERO_CLAMP01" },
413 { OP_ERO_POW_INV, "OP_ERO_POW_INV" }, { OP_ERO_EASE_OUT, "OP_ERO_EASE_OUT" },
414 { OP_ERO_SMOOTH_START, "OP_ERO_SMOOTH_START" },
415 { OP_ERO_NORMALIZE_SAFE, "OP_ERO_NORMALIZE_SAFE" },
416 { OP_ERO_PHACELLE, "OP_ERO_PHACELLE" }, { OP_ERO_FILTER, "OP_ERO_FILTER" },
417 { OP_ERO_EVAL_FULL, "OP_ERO_EVAL_FULL" }, { OP_ERO_EVAL_GRAD, "OP_ERO_EVAL_GRAD" },
344418};
345419
346420struct Rec {
……@@ -360,7 +434,27 @@ enum { W_OP = 0, W_DIM = 1, W_TYPE = 2, W_FLAGS = 3, W_COMP = 4,
360434 W_DETAIL = 9, W_ROUGH = 10, W_LAC = 11, W_OFF = 12, W_GAIN = 13, W_DIST = 14,
361435 // Voronoi's parameter block
362436 W_SCALE = 15, W_SMOOTH = 16, W_EXPONENT = 17, W_RANDOMNESS = 18, W_MAXDIST = 19,
363 W_METRIC = 20 };
437 W_METRIC = 20,
438 // The erosion block. Voronoi's slots are spelled `gF(b, 15u)` by hand in the
439 // GLSL dispatcher, which is the drift this file warns about everywhere else,
440 // so these are EMITTED into the shader from the enum (see SLOT_NAMES) and
441 // both sides name them.
442 W_E_PX = 24, W_E_PY = 25,
443 W_E_NX = 26, W_E_NY = 27, W_E_NZ = 28,
444 W_E_S0 = 29, // 16 scalars, W_E_S0 + k
445 W_E_R0 = 45, // rounding vec4
446 W_E_O0 = 49, // onset vec4
447 W_E_A0 = 53, // assumed slope vec2
448 W_E_OCT = 55 };
449
450// Emitted into the shader beside the opcodes, same reason.
451struct SlotName { int slot; const char *name; };
452static const SlotName SLOT_NAMES[] = {
453 { W_E_PX, "W_E_PX" }, { W_E_PY, "W_E_PY" },
454 { W_E_NX, "W_E_NX" }, { W_E_NY, "W_E_NY" }, { W_E_NZ, "W_E_NZ" },
455 { W_E_S0, "W_E_S0" }, { W_E_R0, "W_E_R0" }, { W_E_O0, "W_E_O0" },
456 { W_E_A0, "W_E_A0" }, { W_E_OCT, "W_E_OCT" },
457};
364458
365459// Filled from the record, all eleven fields -- a GLSL struct has no defaults
366460// and a field left unset is garbage, not a sensible value.
……@@ -380,6 +474,18 @@ static BvParams params_from(const uint32_t *w) {
380474 return p;
381475}
382476
477// The erosion block's accessors. es(w, k) is scalar k; e2/e4 build the vector
478// bundles at a named base. Spelled once so the arms below read like the .inc's
479// own signatures.
480static float es(const uint32_t *w, int k) { return float_of(w[W_E_S0 + k]); }
481static dcerosion::vec2 e2(const uint32_t *w, int base) {
482 return dcerosion::vec2(float_of(w[base]), float_of(w[base + 1]));
483}
484static dcerosion::vec4 e4(const uint32_t *w, int base) {
485 return dcerosion::vec4(float_of(w[base]), float_of(w[base + 1]),
486 float_of(w[base + 2]), float_of(w[base + 3]));
487}
488
383489// --- the CPU half of the dispatcher -----------------------------------------
384490static uint32_t eval_cpu(const uint32_t *w) {
385491 const uint32_t op = w[W_OP], dim = w[W_DIM], comp = w[W_COMP];
……@@ -393,6 +499,10 @@ static uint32_t eval_cpu(const uint32_t *w) {
393499 const float gain = float_of(w[W_GAIN]), dist = float_of(w[W_DIST]);
394500 const vec2 v2(x, y); const vec3 v3(x, y, z); const vec4 v4(x, y, z, wv);
395501 const ivec2 i2(ia, ib); const ivec3 i3(ia, ib, ic); const ivec4 i4(ia, ib, ic, id);
502 // The erosion block, read by slot name on both sides.
503 const float ex = float_of(w[W_E_PX]), ey = float_of(w[W_E_PY]);
504 const float enx = float_of(w[W_E_NX]), eny = float_of(w[W_E_NY]),
505 enz = float_of(w[W_E_NZ]);
396506
397507 switch (op) {
398508 case OP_HASH1: return bn_hash(ua);
……@@ -510,6 +620,62 @@ static uint32_t eval_cpu(const uint32_t *w) {
510620 : (dim == 3) ? bv_voronoi_n_sphere_radius(p, v3)
511621 : bv_voronoi_n_sphere_radius(p, v4));
512622 }
623 // --- erosion_filter.inc, through dcerosion ---------------------------
624 case OP_ERO_HASH: {
625 dcerosion::vec2 q = dcerosion::erosion_hash(dcerosion::vec2(ex, ey));
626 return bits_of(comp == 0 ? q.x : q.y);
627 }
628 case OP_ERO_GRAD_NOISED: {
629 dcerosion::vec3 q = dcerosion::grad_noised_2d(dcerosion::vec2(ex, ey));
630 return bits_of(comp == 0 ? q.x : (comp == 1 ? q.y : q.z));
631 }
632 case OP_ERO_FRACTAL: {
633 dcerosion::vec3 q = dcerosion::fractal_noise_2d(dcerosion::vec2(ex, ey),
634 es(w, 0), (int)w[W_E_OCT], es(w, 1), es(w, 2));
635 return bits_of(comp == 0 ? q.x : (comp == 1 ? q.y : q.z));
636 }
637 case OP_ERO_CLAMP01: return bits_of(dcerosion::clamp01(es(w, 0)));
638 case OP_ERO_POW_INV: return bits_of(dcerosion::pow_inv(es(w, 0), es(w, 1)));
639 case OP_ERO_EASE_OUT: return bits_of(dcerosion::ease_out(es(w, 0)));
640 case OP_ERO_SMOOTH_START:
641 return bits_of(dcerosion::smooth_start(es(w, 0), es(w, 1)));
642 case OP_ERO_NORMALIZE_SAFE: {
643 dcerosion::vec2 q = dcerosion::normalize_safe(dcerosion::vec2(ex, ey));
644 return bits_of(comp == 0 ? q.x : q.y);
645 }
646 case OP_ERO_PHACELLE: {
647 dcerosion::vec4 q = dcerosion::phacelle_noise(dcerosion::vec2(ex, ey),
648 dcerosion::vec2(enx, eny), es(w, 0), es(w, 1), es(w, 2));
649 return bits_of(comp == 0 ? q.x : (comp == 1 ? q.y : (comp == 2 ? q.z : q.w)));
650 }
651 case OP_ERO_FILTER: {
652 dcerosion::ErosionResult r = dcerosion::erosion_filter(
653 dcerosion::vec2(ex, ey), dcerosion::vec3(enx, eny, enz),
654 es(w, 0), es(w, 1), es(w, 2), es(w, 3),
655 e4(w, W_E_R0), e4(w, W_E_O0), e2(w, W_E_A0),
656 es(w, 4), (int)w[W_E_OCT], es(w, 5),
657 es(w, 6), es(w, 7), es(w, 8), es(w, 9));
658 return bits_of(comp == 0 ? r.delta.x : (comp == 1 ? r.delta.y
659 : (comp == 2 ? r.delta.z : (comp == 3 ? r.magnitude : r.ridge))));
660 }
661 case OP_ERO_EVAL_FULL: {
662 dcerosion::vec2 q = dcerosion::erosion_node_eval_full(
663 dcerosion::vec2(ex, ey), dcerosion::vec3(enx, eny, enz),
664 es(w, 0), es(w, 1), es(w, 2), (int)w[W_E_OCT], es(w, 3),
665 es(w, 4), es(w, 5), es(w, 6), es(w, 7), es(w, 8),
666 es(w, 9), es(w, 10), e2(w, W_E_S0 + 11),
667 e4(w, W_E_R0), e4(w, W_E_O0), e2(w, W_E_A0), es(w, 13));
668 return bits_of(comp == 0 ? q.x : q.y);
669 }
670 case OP_ERO_EVAL_GRAD: {
671 dcerosion::vec4 q = dcerosion::erosion_node_eval_grad(
672 dcerosion::vec2(ex, ey), dcerosion::vec3(enx, eny, enz),
673 es(w, 0), es(w, 1), es(w, 2), (int)w[W_E_OCT], es(w, 3),
674 es(w, 4), es(w, 5), es(w, 6), es(w, 7), es(w, 8),
675 es(w, 9), es(w, 10), e2(w, W_E_S0 + 11),
676 e4(w, W_E_R0), e4(w, W_E_O0), e2(w, W_E_A0), es(w, 13));
677 return bits_of(comp == 0 ? q.x : (comp == 1 ? q.y : (comp == 2 ? q.z : q.w)));
678 }
513679 default:
514680 fprintf(stderr, "FATAL: cpu dispatcher has no arm for op %u\n", op);
515681 exit(1);
……@@ -524,6 +690,8 @@ layout(std430, binding = 1) readonly buffer In { uint v[]; } in_v;
524690
525691uint gW(uint b, uint k) { return in_v.v[b + k]; }
526692float gF(uint b, uint k) { return uintBitsToFloat(in_v.v[b + k]); }
693// The erosion block's scalar k, so this side reads it the way es(w, k) does.
694float eS(uint b, uint k) { return gF(b, W_E_S0 + k); }
527695
528696void main() {
529697 uint idx = gl_GlobalInvocationID.x;
……@@ -545,6 +713,14 @@ void main() {
545713 vec2 v2 = vec2(x, y); vec3 v3 = vec3(x, y, z); vec4 v4 = vec4(x, y, z, w);
546714 ivec2 i2 = ivec2(ia, ib); ivec3 i3 = ivec3(ia, ib, ic); ivec4 i4 = ivec4(ia, ib, ic, id);
547715
716 // The erosion block. Slot names come from the C++ enum via SLOT_NAMES.
717 vec2 ep = vec2(gF(b, W_E_PX), gF(b, W_E_PY));
718 vec3 en = vec3(gF(b, W_E_NX), gF(b, W_E_NY), gF(b, W_E_NZ));
719 vec4 e_round = vec4(gF(b, W_E_R0), gF(b, W_E_R0 + 1u), gF(b, W_E_R0 + 2u), gF(b, W_E_R0 + 3u));
720 vec4 e_onset = vec4(gF(b, W_E_O0), gF(b, W_E_O0 + 1u), gF(b, W_E_O0 + 2u), gF(b, W_E_O0 + 3u));
721 vec2 e_slope = vec2(gF(b, W_E_A0), gF(b, W_E_A0 + 1u));
722 int e_oct = int(gW(b, W_E_OCT));
723
548724 uint r = 0u;
549725 if (op == OP_HASH1) { r = bn_hash(ua); }
550726 else if (op == OP_HASH2) { r = bn_hash(ua, ub); }
……@@ -704,6 +880,62 @@ void main() {
704880 r = floatBitsToUint(f);
705881 }
706882 }
883 else if (op == OP_ERO_HASH) {
884 vec2 q = erosion_hash(ep);
885 r = floatBitsToUint(comp == 0u ? q.x : q.y);
886 }
887 else if (op == OP_ERO_GRAD_NOISED) {
888 vec3 q = grad_noised_2d(ep);
889 r = floatBitsToUint(comp == 0u ? q.x : (comp == 1u ? q.y : q.z));
890 }
891 else if (op == OP_ERO_FRACTAL) {
892 vec3 q = fractal_noise_2d(ep, eS(b, 0u), e_oct, eS(b, 1u), eS(b, 2u));
893 r = floatBitsToUint(comp == 0u ? q.x : (comp == 1u ? q.y : q.z));
894 }
895 else if (op == OP_ERO_CLAMP01) { r = floatBitsToUint(clamp01(eS(b, 0u))); }
896 else if (op == OP_ERO_POW_INV) { r = floatBitsToUint(pow_inv(eS(b, 0u), eS(b, 1u))); }
897 else if (op == OP_ERO_EASE_OUT) { r = floatBitsToUint(ease_out(eS(b, 0u))); }
898 else if (op == OP_ERO_SMOOTH_START) {
899 r = floatBitsToUint(smooth_start(eS(b, 0u), eS(b, 1u)));
900 }
901 else if (op == OP_ERO_NORMALIZE_SAFE) {
902 vec2 q = normalize_safe(ep);
903 r = floatBitsToUint(comp == 0u ? q.x : q.y);
904 }
905 else if (op == OP_ERO_PHACELLE) {
906 vec4 q = phacelle_noise(ep, vec2(en.x, en.y), eS(b, 0u), eS(b, 1u), eS(b, 2u));
907 r = floatBitsToUint(comp == 0u ? q.x : (comp == 1u ? q.y : (comp == 2u ? q.z : q.w)));
908 }
909 else if (op == OP_ERO_FILTER) {
910 ErosionResult q = erosion_filter(ep, en,
911 eS(b, 0u), eS(b, 1u), eS(b, 2u), eS(b, 3u),
912 e_round, e_onset, e_slope,
913 eS(b, 4u), e_oct, eS(b, 5u),
914 eS(b, 6u), eS(b, 7u), eS(b, 8u), eS(b, 9u));
915 float f;
916 if (comp == 0u) { f = q.delta.x; }
917 else if (comp == 1u) { f = q.delta.y; }
918 else if (comp == 2u) { f = q.delta.z; }
919 else if (comp == 3u) { f = q.magnitude; }
920 else { f = q.ridge; }
921 r = floatBitsToUint(f);
922 }
923 else if (op == OP_ERO_EVAL_FULL) {
924 vec2 q = erosion_node_eval_full(ep, en,
925 eS(b, 0u), eS(b, 1u), eS(b, 2u), e_oct, eS(b, 3u),
926 eS(b, 4u), eS(b, 5u), eS(b, 6u), eS(b, 7u), eS(b, 8u),
927 eS(b, 9u), eS(b, 10u), vec2(eS(b, 11u), eS(b, 12u)),
928 e_round, e_onset, e_slope, eS(b, 13u));
929 r = floatBitsToUint(comp == 0u ? q.x : q.y);
930 }
931 else if (op == OP_ERO_EVAL_GRAD) {
932 vec4 q = erosion_node_eval_grad(ep, en,
933 eS(b, 0u), eS(b, 1u), eS(b, 2u), e_oct, eS(b, 3u),
934 eS(b, 4u), eS(b, 5u), eS(b, 6u), eS(b, 7u), eS(b, 8u),
935 eS(b, 9u), eS(b, 10u), vec2(eS(b, 11u), eS(b, 12u)),
936 e_round, e_onset, e_slope, eS(b, 13u));
937 r = floatBitsToUint(comp == 0u ? q.x : (comp == 1u ? q.y : (comp == 2u ? q.z : q.w)));
938 }
707939 out_v.v[idx] = r;
708940}
709941)DISPATCH";
……@@ -743,6 +975,28 @@ struct B {
743975 w[W_EXPONENT] = bits_of(expo); w[W_RANDOMNESS] = bits_of(rand01);
744976 w[W_MAXDIST] = bits_of(maxd); w[W_METRIC] = (uint32_t)metric; return *this;
745977 }
978 // --- the erosion block ---------------------------------------------------
979 B &ep(float px, float py) {
980 w[W_E_PX] = bits_of(px); w[W_E_PY] = bits_of(py); return *this;
981 }
982 B &en(float nx, float ny, float nz) {
983 w[W_E_NX] = bits_of(nx); w[W_E_NY] = bits_of(ny); w[W_E_NZ] = bits_of(nz);
984 return *this;
985 }
986 B &es(int k, float v) { w[W_E_S0 + k] = bits_of(v); return *this; }
987 B &eoct(int o) { w[W_E_OCT] = (uint32_t)o; return *this; }
988 B &eround(float a_, float b_, float c_, float d_) {
989 w[W_E_R0] = bits_of(a_); w[W_E_R0 + 1] = bits_of(b_);
990 w[W_E_R0 + 2] = bits_of(c_); w[W_E_R0 + 3] = bits_of(d_); return *this;
991 }
992 B &eonset(float a_, float b_, float c_, float d_) {
993 w[W_E_O0] = bits_of(a_); w[W_E_O0 + 1] = bits_of(b_);
994 w[W_E_O0 + 2] = bits_of(c_); w[W_E_O0 + 3] = bits_of(d_); return *this;
995 }
996 B &eslope(float a_, float b_) {
997 w[W_E_A0] = bits_of(a_); w[W_E_A0 + 1] = bits_of(b_); return *this;
998 }
999
7461000 B &frac(float detail, float rough, float lac, float off, float gain, float dist) {
7471001 w[W_DETAIL] = bits_of(detail); w[W_ROUGH] = bits_of(rough);
7481002 w[W_LAC] = bits_of(lac); w[W_OFF] = bits_of(off);
……@@ -1046,6 +1300,213 @@ static void build_simplex_cases() {
10461300 }
10471301}
10481302
1303// ============================================================================
1304// erosion_filter.inc, bottom-up.
1305//
1306// The layering is the one the Blender port was built in and for the same
1307// reason: a divergence in erosion_hash is unreadable once it has been through
1308// eight octaves, a fade and a gully mask, and each layer here is checked before
1309// the one that consumes it.
1310//
1311// The parameter bundles are the SHIPPED ones -- runevision's reference values,
1312// read off get_erosion_library_glsl()'s globals and ErosionNoiseNode's socket
1313// defaults, not invented for the test. A gate on parameters nothing uses proves
1314// the arithmetic and not the surface.
1315//
1316// ---------------------------------------------------------------------------
1317// ⚠ HOW TO MUTATION-TEST THIS FAMILY, because the obvious way proves nothing.
1318//
1319// Every other gate in this repo is mutated by editing the thing under test.
1320// Here the thing under test is ONE TEXT compiled twice, so editing the .inc
1321// changes BOTH sides and the gate stays green -- "a transcription used as the
1322// reference can only ever agree with itself", one level up. The mutation that
1323// works is to edit the .inc and NOT regenerate the .gen.h: the CPU picks the
1324// change up through the shim, the GPU keeps the shipped text, and they part.
1325// That is also exactly the stale-generated-header scenario the generator's own
1326// docstring promises cannot happen silently.
1327//
1328// Measured 2026-09-06, every family caught by its own case:
1329//
1330// erosion_hash 2^24 -> 2^24-1 (both lines) 9 red erosion_hash
1331// quintic fade +10.0 -> +11.0 105 red grad_noised_2d
1332// fade derivative 30.0 -> 29.0 62 red grad_noised, fractal
1333// clamp(t, 0.0, 1.0) -> (t, 0.0, 0.9) 521 red clamp01 + downstream
1334// ease_out 1 - v*v -> 1 - v*v*v 541 red ease_out + downstream
1335// pow_inv 1 - pow() -> 1 - 0.5*pow() 408 red pow_inv + wrappers
1336// smooth_start 0.5*s -> 0.4*s 357 red filter, _full, _grad
1337// normalize_safe 1e-20 -> 1e-1 2 red normalize_safe
1338// phacelle TAU 6.2831853 -> 6.2831 92 red phacelle_noise
1339//
1340// ⛔ AND ONE THAT PROVED NOTHING. TAU 6.28318530717958647 -> 6.28318530717000000
1341// left the gate GREEN, and it is not a coverage gap: both spellings are the
1342// same float32 (6.2831855), so the mutation never happened. It is recorded
1343// because a sweep that had stopped at "9 of 10 caught" would have gone looking
1344// for a hole in the phacelle cases that is not there.
1345// ---------------------------------------------------------------------------
1346// ============================================================================
1347static void build_erosion_cases() {
1348 // Points chosen where this filter actually breaks: the origin (a lattice
1349 // point, where erosion_hash's floor lands exactly), the negative quadrant
1350 // (GLSL fract is x - floor(x), which for negative x is neither fmod nor
1351 // truncation), a diagonal, and coordinates large enough that the hash's
1352 // 16777216.0 = 2^24 matters.
1353 const float P[][2] = {
1354 { 0.0f, 0.0f }, { 0.5f, 0.5f }, { 1.0f, 0.0f },
1355 { 0.37f, 0.81f }, { -0.7f, 1.3f }, { -3.3f, -4.1f },
1356 { 12.25f, -7.75f }, { 123.5f, 456.25f }, { 1e-4f, -1e-4f },
1357 };
1358 const int NP = (int)(sizeof(P) / sizeof(P[0]));
1359
1360 // EXACT for the hash: it is integer arithmetic to a float conversion, the
1361 // same class as the blender hash layer, and a near-miss is a finding.
1362 for (int i = 0; i < NP; i++) {
1363 for (uint32_t c = 0; c < 2; c++) {
1364 add("erosion_hash", true, B(OP_ERO_HASH).comp(c).ep(P[i][0], P[i][1]));
1365 }
1366 }
1367
1368 // grad_noised_2d is where the f-suffix hazard is sharpest: the quintic fade
1369 // `t*t*t*(t*(t*6.0 - 15.0) + 10.0)` and its derivative `30.0*t*t*(t*(t-2.0)
1370 // + 1.0)` are chained expressions containing bare literals, so on the C++
1371 // side the whole chain evaluates in DOUBLE and narrows once at the end,
1372 // where the shader rounds at every step in float.
1373 for (int i = 0; i < NP; i++) {
1374 for (uint32_t c = 0; c < 3; c++) {
1375 add("grad_noised_2d", false, B(OP_ERO_GRAD_NOISED).comp(c).ep(P[i][0], P[i][1]));
1376 }
1377 }
1378
1379 // The scalar helpers. clamp01 and ease_out are one expression each; pow_inv
1380 // carries a pow(), and smooth_start has the branch whose t >= smoothing side
1381 // is the guard against a division by zero.
1382 const float T[] = { -0.5f, 0.0f, 0.25f, 0.5f, 1.0f, 1.5f, 7.0f };
1383 const int NT = (int)(sizeof(T) / sizeof(T[0]));
1384 for (int i = 0; i < NT; i++) {
1385 add("clamp01", false, B(OP_ERO_CLAMP01).es(0, T[i]));
1386 add("ease_out", false, B(OP_ERO_EASE_OUT).es(0, T[i]));
1387 for (int k = 0; k < 3; k++) {
1388 const float pw[3] = { 0.5f, 1.0f, 2.5f };
1389 add("pow_inv", false, B(OP_ERO_POW_INV).es(0, T[i]).es(1, pw[k]));
1390 const float sm[3] = { 0.0f, 0.1f, 1.0f };
1391 add("smooth_start", false, B(OP_ERO_SMOOTH_START).es(0, T[i]).es(1, sm[k]));
1392 }
1393 }
1394 // normalize_safe's whole reason for existing is the l > 1e-20 branch, so
1395 // the zero vector and a vector just under the threshold are both here.
1396 const float NV[][2] = { { 0.0f, 0.0f }, { 3.0f, 4.0f }, { -1e-21f, 0.0f }, { 1e-8f, -1e-8f } };
1397 for (int i = 0; i < 4; i++) {
1398 for (uint32_t c = 0; c < 2; c++) {
1399 add("normalize_safe", false, B(OP_ERO_NORMALIZE_SAFE).comp(c).ep(NV[i][0], NV[i][1]));
1400 }
1401 }
1402
1403 // fractal_noise_2d: the octave loop over grad_noised_2d, which is where a
1404 // per-step rounding difference would accumulate if there were one.
1405 for (int i = 0; i < NP; i++) {
1406 for (int oct = 1; oct <= 7; oct += 2) {
1407 for (uint32_t c = 0; c < 3; c++) {
1408 add("fractal_noise_2d", false, B(OP_ERO_FRACTAL).comp(c)
1409 .ep(P[i][0], P[i][1]).eoct(oct)
1410 .es(0, 3.0f).es(1, 2.0f).es(2, 0.5f));
1411 }
1412 }
1413 }
1414
1415 // phacelle_noise: sixteen samples around a cell, TAU written to nineteen
1416 // digits -- more than float32 holds -- and 0.01111. The normalisation
1417 // parameter is swept because 0 and 1 take different branches.
1418 for (int i = 0; i < NP; i++) {
1419 for (int k = 0; k < 2; k++) {
1420 for (uint32_t c = 0; c < 4; c++) {
1421 add("phacelle_noise", false, B(OP_ERO_PHACELLE).comp(c)
1422 .ep(P[i][0], P[i][1]).en(0.6f, 0.8f, 0.0f)
1423 .es(0, 1.0f).es(1, k ? 0.37f : 0.0f).es(2, k ? 1.0f : 0.0f));
1424 }
1425 }
1426 }
1427
1428 // The filter and both wrappers, on the shipped bundles.
1429 //
1430 // rounding = (crease 0.1, ridge 0.0, input feature 0.1, per octave 2.0)
1431 // onset = (gully 1.25, octave 1.25, ridge 2.8, ridge octave 1.5)
1432 // assumed = (0.7, 1.0)
1433 //
1434 // max_octaves 999 is what every shipping caller passes and makes each
1435 // octave weight exactly 1.0; 4.5 exercises the fractional taper, which is
1436 // the branch that weights the last partial octave.
1437 const float GN[][3] = {
1438 { 0.0f, 0.0f, 0.0f }, // flat: the assumed-slope path, since the
1439 // real slope is zero and normalize_safe
1440 // returns the zero vector
1441 { 0.3f, 0.1f, -0.2f },
1442 { -0.8f, 0.45f, 0.6f },
1443 { 0.95f, -1.2f, 0.7f },
1444 };
1445 const float MO[] = { 999.0f, 4.5f };
1446 for (int i = 0; i < NP; i++) {
1447 for (int g = 0; g < 4; g++) {
1448 for (int m = 0; m < 2; m++) {
1449 for (uint32_t c = 0; c < 5; c++) {
1450 add("erosion_filter", false, B(OP_ERO_FILTER).comp(c)
1451 .ep(P[i][0], P[i][1]).en(GN[g][0], GN[g][1], GN[g][2])
1452 .es(0, 0.25f) // fade_target
1453 .es(1, 0.16f) // strength
1454 .es(2, 0.5f) // gully_weight
1455 .es(3, 1.5f) // detail
1456 .es(4, 5.0f) // scale
1457 .es(5, 2.0f) // lacunarity
1458 .es(6, 0.5f) // gain
1459 .es(7, 1.0f) // cell_scale
1460 .es(8, 0.5f) // normalization
1461 .es(9, MO[m]) // max_octaves
1462 .eoct(5)
1463 .eround(0.1f, 0.0f, 0.1f, 2.0f)
1464 .eonset(1.25f, 1.25f, 2.8f, 1.5f)
1465 .eslope(0.7f, 1.0f), 5e-5);
1466 }
1467 for (uint32_t c = 0; c < 2; c++) {
1468 add("erosion_node_eval_full", false, B(OP_ERO_EVAL_FULL).comp(c)
1469 .ep(P[i][0], P[i][1]).en(GN[g][0], GN[g][1], GN[g][2])
1470 .es(0, 5.0f) // height_scale
1471 .es(1, 0.16f) // erosion_strength
1472 .es(2, 1.0f) // cell_size
1473 .es(3, 0.5f) // gain
1474 .es(4, 0.6f) // peak_height_divisor (pn_fade_div)
1475 .es(5, 0.15f) // gully_band_scale (pn_erosion_scale)
1476 .es(6, 0.25f) // input_height_amplitude
1477 .es(7, 2.0f) // octave_lacunarity
1478 .es(8, 0.5f) // gully_depth
1479 .es(9, 1.5f) // fine_gully_slope_restriction
1480 .es(10, 0.5f) // gully_normalize_threshold
1481 .es(11, -0.65f) // height_offset
1482 .es(12, 0.0f) // height_offset_follows_altitude
1483 .es(13, MO[m]) // max_octaves
1484 .eoct(5)
1485 .eround(0.1f, 0.0f, 0.1f, 2.0f)
1486 .eonset(1.25f, 1.25f, 2.8f, 1.5f)
1487 .eslope(0.7f, 1.0f), 5e-5);
1488 }
1489 // _grad exists because reconstructing the gradient by central
1490 // differences cost six more full evaluations per edge -- 491 us
1491 // to 3795 us per 32^3 grid. It is a different function from
1492 // _full and has to be checked as one.
1493 for (uint32_t c = 0; c < 4; c++) {
1494 add("erosion_node_eval_grad", false, B(OP_ERO_EVAL_GRAD).comp(c)
1495 .ep(P[i][0], P[i][1]).en(GN[g][0], GN[g][1], GN[g][2])
1496 .es(0, 5.0f).es(1, 0.16f).es(2, 1.0f).es(3, 0.5f)
1497 .es(4, 0.6f).es(5, 0.15f).es(6, 0.25f).es(7, 2.0f)
1498 .es(8, 0.5f).es(9, 1.5f).es(10, 0.5f)
1499 .es(11, -0.65f).es(12, 0.0f).es(13, MO[m])
1500 .eoct(5)
1501 .eround(0.1f, 0.0f, 0.1f, 2.0f)
1502 .eonset(1.25f, 1.25f, 2.8f, 1.5f)
1503 .eslope(0.7f, 1.0f), 5e-5);
1504 }
1505 }
1506 }
1507 }
1508}
1509
10491510static long peak_rss_kb() {
10501511 std::ifstream f("/proc/self/status");
10511512 std::string line;
……@@ -1094,6 +1555,7 @@ int main(int argc, char **argv) {
10941555 build_voronoi_cases();
10951556 build_ramp_cases();
10961557 build_simplex_cases();
1558 build_erosion_cases();
10971559 if (g_cases.empty()) { fprintf(stderr, "FATAL: no cases\n"); return 1; }
10981560
10991561 int n_exact = 0;
……@@ -1113,6 +1575,12 @@ int main(int argc, char **argv) {
11131575 snprintf(b, sizeof(b), "#define %s %uu\n", OP_NAMES[i].name, (unsigned)OP_NAMES[i].op);
11141576 defs += b;
11151577 }
1578 // And the erosion block's slots, for the same reason.
1579 for (size_t i = 0; i < sizeof(SLOT_NAMES) / sizeof(SLOT_NAMES[0]); i++) {
1580 snprintf(b, sizeof(b), "#define %s %uu\n", SLOT_NAMES[i].name,
1581 (unsigned)SLOT_NAMES[i].slot);
1582 defs += b;
1583 }
11161584 }
11171585 const std::string shader = defs + lib + DISPATCH_GLSL;
11181586
……@@ -1248,13 +1716,27 @@ int main(int argc, char **argv) {
12481716 if (!ok) bad_exact++;
12491717 } else {
12501718 const double a = float_of(k.cpu), b = float_of(got[i]);
1719 const bool bitident = (got[i] == k.cpu);
1720 if (bitident) n_bitident++;
1721 // IDENTICAL BITS ARE AGREEMENT, and that clause has to come first.
1722 //
1723 // Without it two identical INFINITIES read as a disagreement:
1724 // inf - inf is nan, and every comparison against nan is false, so
1725 // the arm below rejects a pair that is the same number. Found
1726 // 2026-09-06 by smooth_start(-0.5, 0.0), which divides by the
1727 // smoothing and produces +inf on both sides -- the gate printed
1728 // `cpu=inf gpu=inf FAIL`, which is a sentence about the comparator
1729 // and not about the filter.
12511730 const double diff = fabs(a - b);
12521731 const double scale = fabs(a) > 1.0 ? fabs(a) : 1.0;
12531732 const double rel = diff / scale;
1254 if (got[i] == k.cpu) n_bitident++;
1255 if (rel > worst_rel) { worst_rel = rel; worst_name = k.name; }
1733 // A non-finite rel must not become the reported worst: it would
1734 // print as the headline number for a pair that agrees.
1735 if (std::isfinite(rel) && rel > worst_rel) {
1736 worst_rel = rel; worst_name = k.name;
1737 }
12561738 const double abs_tol = (k.tol_abs > 0.0) ? k.tol_abs : TOL_ABS;
1257 ok = (diff <= abs_tol) || (rel <= TOL_REL);
1739 ok = bitident || (diff <= abs_tol) || (rel <= TOL_REL);
12581740 }
12591741 if (!ok) {
12601742 bad++;