diff --git a/.changeset/stable-hash-backend-parity.md b/.changeset/stable-hash-backend-parity.md new file mode 100644 index 00000000..2a3decaf --- /dev/null +++ b/.changeset/stable-hash-backend-parity.md @@ -0,0 +1,7 @@ +--- +'@lovo/matter': minor +--- + +Seeded randomness now renders the same pattern on the WebGPU and WebGL2 backends. three's TSL `hash()` writes its PCG constants as float literals, which GLSL rounds to a different hash than WGSL computes, so the same `seed` produced a different Voronoi layout in Safari than in Chrome. The new `stableHash` and `stableHashUint` exports run the same PCG with integer-typed constants and chain hash streams u32-to-u32, and `voronoiCells`, `grain`, `metaballs`, and `ditherPattern` now draw from them. + +One-time visual break: deriving seeds from the raw hash word re-rolls every seeded layout once, on both backends. Any `seed` value renders a new (stable) pattern after this release. diff --git a/AGENTS.md b/AGENTS.md index 37a9a618..faeab49c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,7 @@ These rules exist because Matter doubles as a shader-learning project for its au 22. **Subscribing to a container in the demo control store (docs demo panels) re-renders everything under it.** `writeAtPath` rebuilds every object and array along the written path, so a component that subscribes to the root params object, or to a list's array, re-renders on every write anywhere inside it. The control components are deliberately unmemoized, so that re-render cascades. The cascade bit three separate times on one branch before the pattern below stuck. Subscribe to a leaf, or to a stable primitive like a list's `length`, and read containers non-reactively at event time through `useControlStore()`. See `ControlPanel`'s copy buttons and `ListInput`'s add and remove. 23. **Never build a running minimum or argmin as an unrolled JS select() chain, because the tab hangs before the shader compiles.** A select-based accumulator references itself twice per step, once in the comparison and once in the else-branch, and three's `getNodeType` recursion has no cross-reference memoization, so type resolution goes exponential in chain depth. `voronoiCells`' 34-step chain froze headless Chromium indefinitely at first render, with a CDP-interrupted stack showing nothing but `getNodeType`. Adding `.toVar()` per step does NOT help, because VarNode delegates its type lookup inward. Additive chains such as fbm and wave-lines are safe, because they reference the accumulator once per step. The fix is TSL's imperative side: `Fn` plus `Loop` or `If` plus `.assign()`. That emits a real GPU `for` loop. See `voronoiCells`. Three's own MaterialX worley uses the same pattern, and it works on both WebGPU and the WebGL2 fallback with fixed integer bounds. The fbm caveat about "no clean loop primitive" is about dynamic counts like uniform-driven octaves, not about this. 24. **Never call a heavyweight noise primitive such as mx_noise or simplex inside GPU loops that run many iterations per pixel.** `voronoiCells` briefly sampled `simplexNoise` twice per neighbor: 68 calls per pixel inside its 3×3 and 5×5 loops. The WebGL2 backend's synchronous GLSL compile of that shader took **128 seconds** under software GL, which is what CI's headless Chromium and local Playwright run, so every visual test reads as a hung tab. The trap hides during development. Multiplying the noise by a _constant_ 0 lets the GLSL compiler dead-code it, which is fast, and only a _uniform_-driven amplitude forces the real compile. We measured it with a `PerformanceObserver('longtask')` timeline showing one 127.7s task at first render. If loop code needs randomness, build it from the integer `hash()`. See `cellRandom` and `seedInCell` in `voronoi-cells.ts`, which are nested-hash per-cell streams whose random phases drive a sine orbit. Same feel, and it compiles in milliseconds. +25. **Use `stableHash`/`stableHashUint` for seeded randomness, never three's `hash()`, and never round-trip a hash stream through float.** three's `hash()` writes its PCG constants as bare numbers, so codegen emits float literals: WGSL const-evaluates them at 64-bit and recovers the exact integers, while GLSL rounds them into f32's 24-bit mantissa (747796405 becomes 747796416), so the two backends run different hashes (MAT-92). The second trap survives exact constants: `hash(x).mul(0xffffff).toUint()` crosses u32 → f32 → u32, the compilers disagree by an ULP on the float leg often enough, and the truncation turns that ULP into a different integer, which reseeds everything downstream — measured as whole re-rolled rows on the WebGL2 backend. Chain seeds with `stableHashUint` (u32 to u32, nothing to round) and take `stableHash`'s float only as a final output, where an ULP is invisible. Verify any change here by diffing `/components/voronoi?visualTest=1` captures across both backends: expect vertex-speck differences only, never cell-shaped regions. ## Color system (shipped) diff --git a/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-darwin.png b/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-darwin.png index e8844758..a4188d55 100644 Binary files a/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-darwin.png and b/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-linux.png b/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-linux.png index 93c586e4..3a7e8b14 100644 Binary files a/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-linux.png and b/apps/docs-tests/visual/blobs.spec.ts-snapshots/blobs-default-chromium-linux.png differ diff --git a/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-darwin.png b/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-darwin.png index 5eb51a4f..d26b6b3e 100644 Binary files a/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-darwin.png and b/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-linux.png b/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-linux.png index 270b4c3e..d26b6b3e 100644 Binary files a/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-linux.png and b/apps/docs-tests/visual/grain.spec.ts-snapshots/grain-default-chromium-linux.png differ diff --git a/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-darwin.png b/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-darwin.png index cde6d9a7..0bd57839 100644 Binary files a/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-darwin.png and b/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-linux.png b/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-linux.png index b01a1cd2..20569e12 100644 Binary files a/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-linux.png and b/apps/docs-tests/visual/voronoi.spec.ts-snapshots/voronoi-default-chromium-linux.png differ diff --git a/apps/docs/public/posters/blobs.jpg b/apps/docs/public/posters/blobs.jpg index 957717be..8ff201fc 100644 Binary files a/apps/docs/public/posters/blobs.jpg and b/apps/docs/public/posters/blobs.jpg differ diff --git a/apps/docs/public/posters/grain.jpg b/apps/docs/public/posters/grain.jpg index 505e2f87..593f9210 100644 Binary files a/apps/docs/public/posters/grain.jpg and b/apps/docs/public/posters/grain.jpg differ diff --git a/apps/docs/public/posters/voronoi.jpg b/apps/docs/public/posters/voronoi.jpg index 0fbce594..df28a2ae 100644 Binary files a/apps/docs/public/posters/voronoi.jpg and b/apps/docs/public/posters/voronoi.jpg differ diff --git a/packages/matter/src/index.ts b/packages/matter/src/index.ts index beb7c0ce..05c1f4dd 100644 --- a/packages/matter/src/index.ts +++ b/packages/matter/src/index.ts @@ -65,6 +65,7 @@ export { elapsedTime } from './primitives/time/time.js'; export { resetRendererClock } from './runtime/clock/reset-clock.js'; export { grain } from './primitives/grain/grain.js'; +export { stableHash, stableHashUint } from './primitives/stable-hash/stable-hash.js'; export { dither } from './primitives/dither/dither.js'; export { ditherThreshold } from './primitives/dither-pattern/dither-pattern.js'; diff --git a/packages/matter/src/primitives/dither-pattern/dither-pattern.ts b/packages/matter/src/primitives/dither-pattern/dither-pattern.ts index 410a967e..d760dd3a 100644 --- a/packages/matter/src/primitives/dither-pattern/dither-pattern.ts +++ b/packages/matter/src/primitives/dither-pattern/dither-pattern.ts @@ -1,8 +1,9 @@ import { DataTexture, NearestFilter, RedFormat, RepeatWrapping, UnsignedByteType } from 'three'; import type { ShaderNodeObject } from 'three/tsl'; -import { cos, floor, fract, hash, texture } from 'three/tsl'; +import { cos, floor, fract, texture } from 'three/tsl'; import type { Node } from 'three/webgpu'; +import { stableHash, stableHashUint } from '../stable-hash/stable-hash.js'; import { BLUE_NOISE_SIZE, BLUE_NOISE_TILE } from './blue-noise-tile.js'; // Threshold maps for ordered dithering. Every pattern turns a dither-cell @@ -90,9 +91,9 @@ function whiteNoise(coord: ShaderNodeObject): ShaderNodeObject { // visible gradient axis). No time input: the pattern is frozen, so static // scenes stay static. const column = cell.x.toUint(); - const rowHash = hash(cell.y.toUint()).mul(0xffffff).toUint(); + const rowHash = stableHashUint(cell.y.toUint()); - return hash(column.add(rowHash)); + return stableHash(column.add(rowHash)); } function gradientNoise(coord: ShaderNodeObject): ShaderNodeObject { diff --git a/packages/matter/src/primitives/grain/grain.ts b/packages/matter/src/primitives/grain/grain.ts index e0754a1f..537e9c7e 100644 --- a/packages/matter/src/primitives/grain/grain.ts +++ b/packages/matter/src/primitives/grain/grain.ts @@ -1,8 +1,9 @@ -import { float, hash, screenCoordinate } from 'three/tsl'; +import { float, screenCoordinate } from 'three/tsl'; import type { ShaderNodeObject } from 'three/tsl'; import type { Node } from 'three/webgpu'; import type { TSLNode } from '../color-ramp/color-ramp.js'; +import { stableHash, stableHashUint } from '../stable-hash/stable-hash.js'; type TSLScalar = TSLNode | number; @@ -28,9 +29,9 @@ export function grain(intensity: TSLScalar, timeOffset: TSLScalar = 0): ShaderNo // both read as grain "drifting" in one direction. Nesting scrambles each axis // before they meet, so there is no shared gradient axis: every frame is an // independent, isotropic field that boils in place with no directional drift. - const frameHash = hash(float(timeOffset)).mul(0xffffff).toUint(); - const rowHash = hash(row.add(frameHash)).mul(0xffffff).toUint(); + const frameHash = stableHashUint(float(timeOffset)); + const rowHash = stableHashUint(row.add(frameHash)); const seed = column.add(rowHash); - return hash(seed).sub(0.5).mul(intensity); + return stableHash(seed).sub(0.5).mul(intensity); } diff --git a/packages/matter/src/primitives/metaballs/metaballs.ts b/packages/matter/src/primitives/metaballs/metaballs.ts index 5510f8ed..9faf25dc 100644 --- a/packages/matter/src/primitives/metaballs/metaballs.ts +++ b/packages/matter/src/primitives/metaballs/metaballs.ts @@ -15,7 +15,6 @@ import { float, Fn, fract, - hash, If, int, length, @@ -32,6 +31,7 @@ import { import type { Node } from 'three/webgpu'; import type { TSLNode } from '../color-ramp/color-ramp.js'; +import { stableHash, stableHashUint } from '../stable-hash/stable-hash.js'; type TSLScalar = TSLNode | number; @@ -92,7 +92,7 @@ export interface MetaballsResult { /** Hard cap on the GPU loop — `count` clamps to this. */ export const MAX_BLOBS = 20; -// Shifts the seed positive before hashing: three's hash() converts its +// Shifts the seed positive before hashing: stableHash() converts its // input to u32, and u32(negative float) is backend-defined (same guard as // voronoiCells). const HASH_DOMAIN_OFFSET = 512; @@ -129,7 +129,7 @@ const FAST_WEIGHT = 0.35; * them exactly once — so this stays clear of the exponential getNodeType * recursion that bans select-chain argmins (see AGENTS.md's running-minimum * gotcha; fbm's additive chain is the safe precedent). In-loop randomness - * comes only from the integer hash() — never a heavyweight noise primitive + * comes only from the integer stableHash() — never a heavyweight noise primitive * (the 128-second-compile gotcha). * * @param p — Vec2 TSL node in centered pattern space (blobs roam around the @@ -148,7 +148,7 @@ export function metaballs(p: TSLNode, options: MetaballsOptions = {}): Metaballs // Root of every per-blob stream: hashing the seed first (rather than // adding it to the blob index) means consecutive seeds re-roll every // stream to unrelated values instead of shifting blobs one index over. - const seedHash = hash(add(seed, HASH_DOMAIN_OFFSET)).mul(0xffffff).toUint(); + const seedHash = stableHashUint(add(seed, HASH_DOMAIN_OFFSET)); // Fn provides the statement context (a "stack") that Loop/If/assign // append to — TSL's imperative side. Both outputs pack into one vec2. @@ -167,12 +167,12 @@ export function metaballs(p: TSLNode, options: MetaballsOptions = {}): Metaballs // pattern) so no linear index axis leaks through as correlation // between neighboring blobs. One stream colors the blob, one sizes // it, two shape its path. - const blobSeed = hash(float(i).toUint().add(seedHash)).mul(0xffffff).toUint(); - const colorHash = hash(blobSeed); - const sizeHash = hash(blobSeed.add(1)); - const roamHash = vec2(hash(blobSeed.add(2)), hash(blobSeed.add(3))); - const slowPhase = vec2(hash(blobSeed.add(4)), hash(blobSeed.add(5))).mul(TWO_PI); - const fastPhase = vec2(hash(blobSeed.add(6)), hash(blobSeed.add(7))).mul(TWO_PI); + const blobSeed = stableHashUint(float(i).toUint().add(seedHash)); + const colorHash = stableHash(blobSeed); + const sizeHash = stableHash(blobSeed.add(1)); + const roamHash = vec2(stableHash(blobSeed.add(2)), stableHash(blobSeed.add(3))); + const slowPhase = vec2(stableHash(blobSeed.add(4)), stableHash(blobSeed.add(5))).mul(TWO_PI); + const fastPhase = vec2(stableHash(blobSeed.add(6)), stableHash(blobSeed.add(7))).mul(TWO_PI); // Where this blob is right now: per axis, a base-frequency sine plus // a double-frequency sine (an integer multiple, so the combined path diff --git a/packages/matter/src/primitives/stable-hash/stable-hash.test.ts b/packages/matter/src/primitives/stable-hash/stable-hash.test.ts new file mode 100644 index 00000000..47377c6a --- /dev/null +++ b/packages/matter/src/primitives/stable-hash/stable-hash.test.ts @@ -0,0 +1,109 @@ +// Guards the fix for MAT-92: the PCG constants must sit in the node graph +// as uint-typed constants. A bare JS number becomes a float-typed constant, +// which the GLSL backend emits as an f32 literal — and none of the three +// PCG constants survive f32's 24-bit mantissa (747796405 rounds to +// 747796416, 2891336453 to 2891336448, 277803737 to 277803744). WGSL +// const-evaluates the same literal at 64-bit precision, so the two backends +// run different hashes. Uint-typed constants emit as integer literals +// (747796405u), which are exact in both languages. +import { float } from 'three/tsl'; +import { describe, expect, it } from 'vitest'; + +import { stableHash, stableHashUint } from './stable-hash.js'; + +const PCG_CONSTANTS = [747796405, 2891336453, 277803737]; + +interface GraphNode { + isNode?: boolean; + nodeType?: string; + value?: unknown; + method?: string; + bNode?: GraphNode; + // TSL wraps nodes in a proxy; `self` is the proxy's escape hatch back to + // the raw node. Walking raw nodes matters: the proxy intercepts property + // access (swizzles, assign sugar), so generic traversal only behaves on + // the real object. + self?: GraphNode; + getSerializeChildren?: () => Iterable<{ childNode: GraphNode }>; +} + +// Walk every node reachable from the root, depth-first. +// getSerializeChildren is three's own traversal (it iterates a node's +// public properties and yields the ones that are nodes), so anything +// codegen would visit, this visits. +function collectNodes(root: GraphNode, visited = new Set()): GraphNode[] { + if (visited.has(root)) return []; + visited.add(root); + const nodes = [root]; + + if (root.getSerializeChildren) { + for (const { childNode } of root.getSerializeChildren()) { + nodes.push(...collectNodes(childNode, visited)); + } + } + + return nodes; +} + +describe('stableHash', () => { + it('returns a node', () => { + expect(stableHash(float(1))).toBeDefined(); + }); + + it('caps the float output below 1 through min()', () => { + // toFloat() rounds hash words at or above 0xFFFFFF80 up to 2^32, which + // would scale to an exact 1.0 and break the [0, 1) contract. No GPU runs + // in this suite, so assert the structure instead: a min() MathNode whose + // second operand is the cap. Matching the constant alone would pass with + // the cap attached to anything at all. + const proxied = stableHash(float(1)) as unknown as GraphNode; + const nodes = collectNodes(proxied.self ?? proxied); + const caps = nodes.filter( + (node) => node.method === 'min' && node.bNode?.value === 1 - 2 ** -24, + ); + + expect(caps.length).toBe(1); + }); + + it('stableHashUint carries every PCG constant as a uint-typed node', () => { + const proxied = stableHashUint(float(1)) as unknown as GraphNode; + const nodes = collectNodes(proxied.self ?? proxied); + const found = new Set(); + + for (const node of nodes) { + if (typeof node.value === 'number' && PCG_CONSTANTS.includes(node.value)) { + expect(node.nodeType, `constant ${node.value}`).toBe('uint'); + found.add(node.value); + } + } + + expect([...found].sort()).toEqual([...PCG_CONSTANTS].sort()); + }); + + it('carries every PCG constant as a uint-typed node, never float', () => { + const proxied = stableHash(float(1)) as unknown as GraphNode; + const nodes = collectNodes(proxied.self ?? proxied); + + const constantsFound = new Map(); + + for (const node of nodes) { + if (typeof node.value === 'number' && PCG_CONSTANTS.includes(node.value)) { + const types = constantsFound.get(node.value) ?? []; + + types.push(node.nodeType ?? 'unknown'); + constantsFound.set(node.value, types); + } + } + + // All three constants must be present... + expect([...constantsFound.keys()].sort()).toEqual([...PCG_CONSTANTS].sort()); + + // ...and every occurrence must be typed uint. A single float-typed copy + // reintroduces the divergence. + for (const [value, types] of constantsFound) { + for (const type of types) { + expect(type, `constant ${value} must be uint, got ${type}`).toBe('uint'); + } + } + }); +}); diff --git a/packages/matter/src/primitives/stable-hash/stable-hash.ts b/packages/matter/src/primitives/stable-hash/stable-hash.ts new file mode 100644 index 00000000..3fae5885 --- /dev/null +++ b/packages/matter/src/primitives/stable-hash/stable-hash.ts @@ -0,0 +1,99 @@ +// Backend-stable integer hash: the same PCG hash three's TSL hash() uses, +// rebuilt so both GPU backends run it with the same constants. Every +// primitive that needs per-cell or per-frame randomness (voronoi, grain, +// metaballs, dither-pattern) draws from this instead of three's hash(). +import type { ShaderNodeObject } from 'three/tsl'; +import { min, uint } from 'three/tsl'; +import type { Node } from 'three/webgpu'; + +// --------------------------------------------------------------- +// Why this exists (MAT-92) +// --------------------------------------------------------------- +// three's hash() writes its PCG constants as bare JS numbers. TSL types a +// bare number as float, so both code generators emit the constant as a +// float literal wrapped in a uint conversion — u32(747796405.0) in WGSL, +// uint(747796405.0) in GLSL. The same text means different numbers in the +// two languages: +// +// - WGSL evaluates unsuffixed literals at 64-bit precision during +// constant folding, so the conversion recovers the exact integer. +// - GLSL float literals ARE 32-bit floats. A float's 24-bit mantissa +// cannot hold these 30-32 bit constants, so GLSL rounds them first: +// 747796405 -> 747796416, 2891336453 -> 2891336448, +// 277803737 -> 277803744. +// +// The WebGL2 fallback therefore ran a structurally identical PCG with +// wrong constants — a perfectly valid hash, just not the same one — and +// every seeded layout diverged between backends (MAT-92). +// +// The fix is to declare each constant with uint(), which makes a +// uint-typed constant node. Both builders emit those as integer literals +// (747796405u), exact in both languages. Output is bit-identical to what +// the WebGPU backend always produced, so the canonical pattern is the one +// posters were already captured on; only WebGL2 output changes. + +/** + * Hash an integer-valued seed to a raw 32-bit word, identical on the WebGPU + * and WebGL2 backends. + * + * Use THIS, never a float round-trip, when the result seeds another hash. + * The old pattern — `hash(x).mul(0xffffff).toUint()` — crossed through + * float twice: u32 -> f32 rounds a 32-bit word into a 24-bit mantissa, and + * the two backends' compilers take different liberties with the conversion + * and the multiplies (measured on MAT-92: ANGLE's GLSL-to-Metal path and + * Tint's WGSL path disagree by an ULP often enough that the truncation + * back to u32 flipped an integer for roughly a quarter of inputs). One + * flipped integer re-rolls every value derived from it. Integer-to-integer + * chaining has no rounding step, so there is nothing to disagree about. + * + * The seed is converted to u32 first, so only the integer part + * participates, and negative seeds are backend-defined — shift them + * positive before hashing (see HASH_DOMAIN_OFFSET in voronoi-cells). + * MAT-106 tracks folding negatives safely inside this function. + */ +export function stableHashUint(seed: ShaderNodeObject): ShaderNodeObject { + // PCG (permuted congruential generator), from pcg-random.org via + // shadertoy XlGcRh — the exact algorithm three's hash() implements. + // + // Step 1, the LCG scramble: state = seed * 747796405 + 2891336453. Runs + // in u32, where multiplication wraps modulo 2^32 by definition on both + // backends — the wrap IS the mixing. + const state = seed.toUint().mul(uint(747796405)).add(uint(2891336453)); + + // Step 2, the permutation: xorshift by a data-dependent amount (the top + // bits of state pick how far to shift), then one more multiply. This is + // what breaks up the LCG's lattice structure. + const word = state + .shiftRight(state.shiftRight(uint(28)).add(uint(4))) + .bitXor(state) + .mul(uint(277803737)); + + // Step 3, fold: xor the halves together so every output bit depends on + // every input bit. + return word.shiftRight(uint(22)).bitXor(word); +} + +/** + * Hash an integer-valued seed to a pseudo-random float in [0, 1), identical + * on the WebGPU and WebGL2 backends up to one unit in the last place. + * + * The float is for CONSUMING randomness — a ramp position, a coordinate, a + * phase — where an ULP is invisible (~1e-8 of the range). To seed another + * hash, take stableHashUint instead; converting this float back to an + * integer re-amplifies that ULP into a completely different hash stream. + * Same conversion contract as stableHashUint. + */ +export function stableHash(seed: ShaderNodeObject): ShaderNodeObject { + // 2^-32 is a power of two, so the scale constant is exact everywhere. The + // cap keeps the documented [0, 1) contract at the top of the range: + // toFloat() rounds any word at or above 0xFFFFFF80 up to 2^32 (f32 spacing + // there is 256), and 2^32 * 2^-32 would leak an exact 1.0 roughly once per + // 33 million draws. 1 - 2^-24 is the largest float below 1, so the cap + // moves only those out-of-contract draws and no others. + return min( + stableHashUint(seed) + .toFloat() + .mul(1 / 2 ** 32), + 1 - 2 ** -24, + ); +} diff --git a/packages/matter/src/primitives/voronoi/voronoi-cells.ts b/packages/matter/src/primitives/voronoi/voronoi-cells.ts index a0ce67be..5297a053 100644 --- a/packages/matter/src/primitives/voronoi/voronoi-cells.ts +++ b/packages/matter/src/primitives/voronoi/voronoi-cells.ts @@ -11,7 +11,6 @@ import { floor, Fn, fract, - hash, If, int, Loop, @@ -26,6 +25,7 @@ import { import type { Node } from 'three/webgpu'; import type { TSLNode } from '../color-ramp/color-ramp.js'; +import { stableHash, stableHashUint } from '../stable-hash/stable-hash.js'; type TSLScalar = TSLNode | number; @@ -67,13 +67,13 @@ export interface VoronoiCellsResult { hash: ShaderNodeObject; } -// Shifts cell coordinates positive before hashing: three's hash() converts +// Shifts cell coordinates positive before hashing: stableHash() converts // its input to u32, and u32(negative float) is backend-defined. The shift // absorbs coordinates down to -512 — scales up to ~1000 cells at // non-negative sample-window offsets. Inputs below that (e.g. a large // negative seed offset upstream) land in backend-defined territory; a -// wrap-safe fold is deferred to the backend-stable hash work (MAT-92), -// since any change to hash inputs re-rolls every cell layout. +// wrap-safe fold inside stableHash is MAT-106, kept out of the backend +// fix (MAT-92) because changing hash inputs re-rolls every cell layout. const HASH_DOMAIN_OFFSET = 512; const TWO_PI = Math.PI * 2; @@ -114,14 +114,19 @@ export function voronoiCells(p: TSLNode, options: VoronoiCellsOptions = {}): Vor // Per-cell random streams, derived by NESTING hashes (grain's pattern) so // no linear seed axis leaks through as diagonal correlation across the // field. One stream colors the cell, two anchor its seed, two phase its - // orbit. + // orbit. The chain stays u32 end to end — stableHashUint into + // stableHashUint — because a float round-trip between hashes is what let + // the two backends diverge (MAT-92); the float view is taken only at the + // very end, where an ULP of difference is invisible instead of a reseed. const cellRandom = (cell: ShaderNodeObject) => { const shifted = add(cell, HASH_DOMAIN_OFFSET); - const rowHash = hash(shifted.y).mul(0xffffff).toUint(); - const cellHash = hash(shifted.x.toUint().add(rowHash)); - const streamSeed = cellHash.mul(0xffffff).toUint(); - const homeOffset = vec2(hash(streamSeed), hash(streamSeed.add(1))); - const orbitPhase = vec2(hash(streamSeed.add(2)), hash(streamSeed.add(3))); + const rowHash = stableHashUint(shifted.y); + // One PCG word per cell, cached in a GPU variable: its float view + // colors the cell, and the raw word seeds the four streams below. + const cellWord = stableHashUint(shifted.x.toUint().add(rowHash)).toVar(); + const cellHash = cellWord.toFloat().mul(1 / 2 ** 32); + const homeOffset = vec2(stableHash(cellWord), stableHash(cellWord.add(1))); + const orbitPhase = vec2(stableHash(cellWord.add(2)), stableHash(cellWord.add(3))); return { cellHash, homeOffset, orbitPhase }; };