|
| 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 | +} |
0 commit comments