Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/stable-hash-backend-parity.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/docs/public/posters/blobs.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/docs/public/posters/grain.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/docs/public/posters/voronoi.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/matter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -90,9 +91,9 @@ function whiteNoise(coord: ShaderNodeObject<Node>): ShaderNodeObject<Node> {
// 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<Node>): ShaderNodeObject<Node> {
Expand Down
9 changes: 5 additions & 4 deletions packages/matter/src/primitives/grain/grain.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
}
20 changes: 10 additions & 10 deletions packages/matter/src/primitives/metaballs/metaballs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
float,
Fn,
fract,
hash,
If,
int,
length,
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
109 changes: 109 additions & 0 deletions packages/matter/src/primitives/stable-hash/stable-hash.test.ts
Original file line number Diff line number Diff line change
@@ -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>()): 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<number>();

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<number, string[]>();

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');
}
}
});
});
99 changes: 99 additions & 0 deletions packages/matter/src/primitives/stable-hash/stable-hash.ts
Original file line number Diff line number Diff line change
@@ -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<Node>): ShaderNodeObject<Node> {
// 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<Node>): ShaderNodeObject<Node> {
// 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,
);
}
Loading
Loading