Skip to content

Commit e030ffe

Browse files
committed
feat(@typegpu/noise): Add blue noise generation and benchmarks
1 parent e269bfe commit e030ffe

13 files changed

Lines changed: 838 additions & 1 deletion

File tree

‎packages/typegpu-noise/README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,26 @@ const mainFrag = tgpu
2424
});
2525
```
2626

27+
## Blue-noise dithering
28+
29+
`blueNoise2d.generate` creates tileable 2D blue noise for dithering. It returns a
30+
`Float32Array` of values in `(0, 1)`, stored in row-major order. Configure the tile
31+
size and seed, or use the defaults of 64 and 0.
32+
33+
```ts
34+
import { blueNoise2d } from '@typegpu/noise';
35+
import { d } from 'typegpu';
36+
37+
const size = 64;
38+
const values = blueNoise2d.generate({ size, seed: 42 });
39+
const noise = root.createReadonly(d.arrayOf(d.f32, values.length), values);
40+
```
41+
42+
Generation is synchronous and requires no GPU device. Generate a tile during
43+
setup, or precompute and save the values for reuse. The same size and seed
44+
reproduce a tile within the same implementation; exact patterns may change
45+
between releases. Subtract 0.5 from a value to get centered dither.
46+
2747
## TypeGPU is created by Software Mansion
2848

2949
[![swm](https://logo.swmansion.com/logo?color=white&variant=desktop&width=150&tag=typegpu-github 'Software Mansion')](https://swmansion.com)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Blue-noise CPU generation benchmarks
2+
3+
Run from the repository root using Vitest's experimental benchmark API:
4+
5+
```sh
6+
pnpm --filter @typegpu/noise benchmark:blue-noise
7+
pnpm --filter @typegpu/noise benchmark:blue-noise --outputJson /tmp/blue-noise-vitest.json
8+
```
9+
10+
To compare with a previous Vitest report, pass `--compare /tmp/blue-noise-vitest.json`.
11+
Benchmarks are separate from `vitest run`; correctness checks live in the package's
12+
regular tests.
13+
14+
## Variants
15+
16+
The baseline imports a frozen copy of the original CPU generator from `baseline.ts`.
17+
The `doubled-fused` entry now calls the optimized production generator directly.
18+
`variants.ts` retains the experimental implementations for comparison:
19+
20+
| Variant | Energy update | Extrema search |
21+
| --- | --- | --- |
22+
| baseline | Original modulo wrapping | Separate scan |
23+
| fused | Original modulo wrapping | Fused into the update |
24+
| split | Split each row at its horizontal wrap; one vertical wrap check per row | Separate scan |
25+
| split-fused | Split rows | Fused into the update |
26+
| doubled | Read a contiguous window in a 2×2 repeated kernel | Separate scan |
27+
| doubled-fused | Repeated kernel | Fused into the update |
28+
29+
Fused scans cache the maximum occupied-pixel energy and minimum empty-pixel
30+
energy. The caches are refreshed when restoring the prototype pattern. All
31+
variants retain the full Gaussian, Float64 energy accumulation, row-major visit
32+
order, strict comparisons for tie-breaking, and the original algorithm phases.
33+
The update loops are separate functions to avoid a variant-selection branch
34+
inside the per-pixel loop.
35+
36+
The doubled variants allocate an additional `4 * size²` Float64 values: 128 KiB
37+
at 64×64 and 512 KiB at 128×128. The split variants require no expanded kernel.
38+
39+
Fused scans and repeated lookup tables are inspired by
40+
[Martin Fiedler's C implementation](https://gist.github.com/kajott/d9f9bb93043040bfe2f48f4f499903d8).
41+
42+
## Method
43+
44+
- Use Vitest's `bench()` API, with a comparison group for each size: 32, 63, 64,
45+
and 128.
46+
- Each iteration generates three complete masks, with seeds 0, 42, and 4294967295.
47+
Reported times and throughput refer to this three-mask batch, not one mask.
48+
- Include allocations, seeding, kernel setup, relaxation, and ranking in timing.
49+
- Warm up with one batch, then measure six batches per variant. Time-based minimums
50+
are disabled so slow cases do not receive fewer samples or a different seed mix.
51+
- Let Vitest report timing statistics and relative performance. Variants run in
52+
Vitest's order; the old harness's per-round rotation is no longer used.
53+
- Check exact output parity separately in `tests/blue-noise-variants.test.ts` at
54+
sizes 4, 5, 8, 17, 32, 63, 64, and 128 for all three seeds. Assertions are never
55+
part of benchmark timing.
56+
57+
Results are exploratory measurements on one machine, not a cross-platform
58+
performance guarantee. Six samples are sufficient for an initial comparison,
59+
not a rigorous confidence interval. Increase the iteration count for more
60+
extensive measurements.
61+
62+
The winning doubled-fused approach has been promoted to the production generator.
63+
Production also saves/restores cached extrema with the prototype, avoiding the
64+
experimental variant's one-time scan after restoration.
65+
66+
The historical results below were collected with the previous custom harness,
67+
which measured individual masks and rotated variant order. Its JSON reports are
68+
preserved for reference and cannot be used with Vitest's `--compare` option.
69+
70+
## Initial results
71+
72+
Measured before promotion on Apple M3 Pro, Node 24.20.0 (V8), and Bun 1.3.10 (JavaScriptCore).
73+
Numbers below are medians in milliseconds for complete 64×64 generation.
74+
75+
| Variant | Node | Speedup | Bun | Speedup |
76+
| --- | ---: | ---: | ---: | ---: |
77+
| baseline | 69.03 | 1.00× | 50.16 | 1.00× |
78+
| fused | 46.86 | 1.47× | 35.46 | 1.41× |
79+
| split | 59.92 | 1.15× | 46.04 | 1.09× |
80+
| split-fused | 43.98 | 1.57× | 37.43 | 1.34× |
81+
| doubled | 58.14 | 1.19× | 45.21 | 1.11× |
82+
| doubled-fused | 33.29 | 2.07× | 30.77 | 1.63× |
83+
84+
At 128×128, doubled-fused improves Node from 1222.9 ms to 549.0 ms, and Bun from 857.9 ms to 504.3 ms.
85+
86+
Doubled-fused wins at every measured size in both runtimes. It is the strongest
87+
candidate, now implemented in production. Fused scans alone are a useful lower-memory
88+
alternative; split wrapping combined with fusion does not consistently improve on
89+
fusion alone across engines and sizes.
90+
91+
All parity checks passed in both runtimes. Because outputs match exactly, these
92+
variants preserve the baseline mask quality for the tested inputs. No Gaussian
93+
truncation or precision reduction was used.
94+
95+
Full samples, ranges, timestamps, and runtime metadata: [Node](results/node.json)
96+
and [Bun](results/bun.json).
97+
98+
## Production verification
99+
100+
The promoted package implementation was remeasured against the frozen baseline
101+
in Node 24.20.0. All exact-output checks passed, including size 128.
102+
103+
| Size | Original (ms) | Production (ms) | Speedup |
104+
| --- | ---: | ---: | ---: |
105+
| 32×32 | 4.40 | 2.46 | 1.79× |
106+
| 63×63 | 65.60 | 31.64 | 2.07× |
107+
| 64×64 | 70.38 | 34.17 | 2.06× |
108+
| 128×128 | 1129.21 | 501.41 | 2.25× |
109+
110+
Raw results: [Node production verification](results/node-production.json).
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Frozen pre-optimization CPU reference for benchmarks and exact-output regression tests.
2+
export interface BlueNoise2DOptions {
3+
/** Side length of the square tile, in pixels. Defaults to 64. */
4+
size?: number;
5+
/** Unsigned 32-bit seed. Defaults to 0. Does not affect randf's state. */
6+
seed?: number;
7+
}
8+
9+
/**
10+
* Generates a seamless, periodic blue-noise dither tile on the CPU.
11+
*
12+
* Returns `size * size` thresholds in row-major order (`y * size + x`).
13+
* Each threshold is a normalized rank midpoint, `(rank + 0.5) / (size * size)`,
14+
* in (0, 1). Subtract 0.5 for centered dither. Sample at integer pixel
15+
* coordinates with wrapping and without interpolation.
16+
*
17+
* Uses Ulichney's void-and-cluster algorithm with Gaussian sigma = 1.5 pixels:
18+
* https://cv.ulichney.com/papers/1993-void-cluster.pdf
19+
*
20+
* This is a synchronous setup/build-time operation, not a shader function.
21+
* Ranking takes O(size^4) time and O(size^2) memory. Prefer small tiles
22+
* (e.g. 32 or 64); generate once, precompute, or run in a worker.
23+
* Identical options produce identical output within the same implementation;
24+
* the exact pattern may change between releases.
25+
*
26+
* @example
27+
* ```ts
28+
* const values = blueNoise2d.generate({ size: 64, seed: 42 });
29+
* const buffer = root.createReadonly(d.arrayOf(d.f32, values.length), values);
30+
* // Alternatively, save Array.from(values) as JSON at build time.
31+
* ```
32+
*/
33+
export function generate(options: BlueNoise2DOptions = {}): Float32Array {
34+
const { size = 64, seed = 0 } = options;
35+
// Midpoints must remain strictly below 1 when stored as f32.
36+
if (!Number.isSafeInteger(size) || size < 4 || size * size > 2 ** 23) {
37+
throw new RangeError('Blue-noise size must be an integer >= 4 with size squared <= 2^23.');
38+
}
39+
if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) {
40+
throw new RangeError('Blue-noise seed must be an unsigned 32-bit integer.');
41+
}
42+
43+
const count = size * size;
44+
const initialCount = Math.floor(count * 0.1);
45+
const pattern = new Uint8Array(count);
46+
const energy = new Float64Array(count);
47+
const kernel = Float64Array.from({ length: count }, (_, i) => {
48+
const x = Math.min(i % size, size - (i % size));
49+
const y = Math.min(Math.floor(i / size), size - Math.floor(i / size));
50+
return Math.exp(-(x * x + y * y) / (2 * 1.5 ** 2));
51+
});
52+
53+
function setPixel(index: number, value: number) {
54+
const delta = value - (pattern[index] as number);
55+
pattern[index] = value;
56+
const x = index % size;
57+
const y = Math.floor(index / size);
58+
for (let row = 0; row < size; row++) {
59+
for (let col = 0; col < size; col++) {
60+
energy[row * size + col] =
61+
(energy[row * size + col] as number) +
62+
delta * (kernel[((row - y + size) % size) * size + ((col - x + size) % size)] as number);
63+
}
64+
}
65+
}
66+
67+
function findPixel(value: number, tightest: boolean) {
68+
let best = -1;
69+
let bestEnergy = tightest ? -Infinity : Infinity;
70+
for (let i = 0; i < count; i++) {
71+
if (pattern[i] !== value) continue;
72+
if (tightest ? (energy[i] as number) > bestEnergy : (energy[i] as number) < bestEnergy) {
73+
best = i;
74+
bestEnergy = energy[i] as number;
75+
}
76+
}
77+
return best;
78+
}
79+
80+
// Local PRNG state: generating a tile does not alter randf's random sequence.
81+
let state = seed;
82+
const shuffled = Array.from({ length: count }, (_, i) => i);
83+
for (let i = count - 1; i > 0; i--) {
84+
// Mulberry32 supports every u32 seed, including zero.
85+
state = (state + 0x6d2b79f5) | 0;
86+
let value = Math.imul(state ^ (state >>> 15), state | 1);
87+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
88+
const random = ((value ^ (value >>> 14)) >>> 0) / 0x100000000;
89+
const j = Math.floor(random * (i + 1));
90+
[shuffled[i], shuffled[j]] = [shuffled[j] as number, shuffled[i] as number];
91+
}
92+
for (const index of shuffled.slice(0, initialCount)) setPixel(index, 1);
93+
94+
// Relax the seed pattern until removing a cluster exposes the largest void.
95+
for (;;) {
96+
const cluster = findPixel(1, true);
97+
setPixel(cluster, 0);
98+
const voidPixel = findPixel(0, false);
99+
// Stop on ties too, so floating-point drift cannot cycle between equal patterns.
100+
if ((energy[voidPixel] as number) >= (energy[cluster] as number) - 1e-12) {
101+
setPixel(cluster, 1);
102+
break;
103+
}
104+
setPixel(voidPixel, 1);
105+
}
106+
107+
const prototype = pattern.slice();
108+
const prototypeEnergy = energy.slice();
109+
const thresholds = new Float32Array(count);
110+
111+
// Rank the sparse half by removing clusters, then filling voids.
112+
for (let rank = initialCount - 1; rank >= 0; rank--) {
113+
const cluster = findPixel(1, true);
114+
thresholds[cluster] = (rank + 0.5) / count;
115+
setPixel(cluster, 0);
116+
}
117+
pattern.set(prototype);
118+
energy.set(prototypeEnergy);
119+
for (let rank = initialCount; rank < Math.floor(count / 2); rank++) {
120+
const voidPixel = findPixel(0, false);
121+
thresholds[voidPixel] = (rank + 0.5) / count;
122+
setPixel(voidPixel, 1);
123+
}
124+
125+
// Above half coverage, zeros are the minority: remove their tightest clusters.
126+
// Minimum energy of ones means maximum energy of zeros on the periodic grid.
127+
for (let rank = Math.floor(count / 2); rank < count; rank++) {
128+
const cluster = findPixel(0, false);
129+
thresholds[cluster] = (rank + 0.5) / count;
130+
setPixel(cluster, 1);
131+
}
132+
133+
return thresholds;
134+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { bench, describe } from 'vitest';
2+
import { cases, seeds, sizes } from './cases.ts';
3+
4+
for (const size of sizes) {
5+
describe(`${size}×${size} (three-seed batch)`, () => {
6+
for (const { name, run } of cases) {
7+
bench(
8+
name,
9+
() => {
10+
for (const seed of seeds) run(size, seed);
11+
},
12+
{ time: 0, iterations: 6, warmupTime: 0, warmupIterations: 1 },
13+
);
14+
}
15+
});
16+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { generate } from '../../src/blue-noise-2d.ts';
2+
import { generate as baseline } from './baseline.ts';
3+
import { generateVariant, type Variant } from './variants.ts';
4+
5+
export const seeds = [0, 42, 0xffffffff];
6+
export const sizes = [32, 63, 64, 128];
7+
export const cases = [
8+
{ name: 'baseline', run: (size: number, seed: number) => baseline({ size, seed }) },
9+
...(['fused', 'split', 'split-fused', 'doubled'] satisfies Variant[]).map((variant) => ({
10+
name: variant,
11+
run: (size: number, seed: number) => generateVariant(size, seed, variant),
12+
})),
13+
{ name: 'doubled-fused', run: (size: number, seed: number) => generate({ size, seed }) },
14+
];

0 commit comments

Comments
 (0)