/* ============================================================ noise.js — value noise / fbm helpers for the analog layer. Used for film grain structure and low-frequency tonal mottle (uneven gas glow & development stains). ============================================================ */ import { mulberry32, cyrb53 } from '../rng.js'; /* Hash-based value noise on an integer lattice, smoothstep-interpolated. */ function lattice(seedInt) { const cache = new Map(); return (ix, iy) => { const key = ix * 73856093 ^ iy * 19349663; let v = cache.get(key); if (v === undefined) { let h = (seedInt ^ Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263)) >>> 0; h = Math.imul(h ^ (h >>> 13), 1274126177) >>> 0; v = (h >>> 0) / 4294967296; cache.set(key, v); } return v; }; } const smooth = (t) => t * t * (3 - 2 * t); function valueNoise2D(lat, x, y) { const x0 = Math.floor(x), y0 = Math.floor(y); const fx = smooth(x - x0), fy = smooth(y - y0); const v00 = lat(x0, y0), v10 = lat(x0 + 1, y0); const v01 = lat(x0, y0 + 1), v11 = lat(x0 + 1, y0 + 1); const a = v00 + (v10 - v00) * fx; const b = v01 + (v11 - v01) * fx; return a + (b - a) * fy; } /* Fractal (fbm) value noise, octaves of valueNoise2D. Returns [0,1]. */ export function fbm(lat, x, y, octaves = 4, lacunarity = 2, gain = 0.5) { let amp = 1, freq = 1, sum = 0, norm = 0; for (let o = 0; o < octaves; o++) { sum += amp * valueNoise2D(lat, x * freq, y * freq); norm += amp; amp *= gain; freq *= lacunarity; } return sum / norm; } /* Build a low-frequency tonal mottle canvas (uneven illumination + stains). `cells` ≈ how many noise cells across the image (low = broad blotches). */ export function mottleCanvas(seedStr, size, cells = 5, octaves = 4) { const c = document.createElement('canvas'); c.width = c.height = size; const cx = c.getContext('2d'); const img = cx.createImageData(size, size); const d = img.data; const lat = lattice(parseInt(cyrb53(seedStr + '::mottle').slice(0, 8), 16)); const s = cells / size; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { let n = fbm(lat, x * s, y * s, octaves, 2.1, 0.55); // bias toward mid, widen contrast a touch n = Math.min(1, Math.max(0, (n - 0.5) * 1.6 + 0.5)); const v = Math.round(n * 255); const i = (y * size + x) * 4; d[i] = d[i + 1] = d[i + 2] = v; d[i + 3] = 255; } } cx.putImageData(img, 0, 0); return c; } /* Build a film-grain tile. Grain is generated at `grainSize` then meant to be drawn upscaled, giving clumps larger than one device pixel (real emulsion grain is not per-pixel). Returns an offscreen canvas of grain in alpha. */ export function grainCanvas(seedStr, grainSize, contrast = 1) { const c = document.createElement('canvas'); c.width = c.height = grainSize; const cx = c.getContext('2d'); const img = cx.createImageData(grainSize, grainSize); const d = img.data; const rng = mulberry32(parseInt(cyrb53(seedStr + '::grain').slice(0, 8), 16)); for (let i = 0; i < d.length; i += 4) { // signed grain centred on 0.5, gaussian-ish via two uniforms let g = (rng() + rng() - 1) * 0.5 + 0.5; g = Math.min(1, Math.max(0, (g - 0.5) * contrast + 0.5)); const v = Math.round(g * 255); d[i] = d[i + 1] = d[i + 2] = v; d[i + 3] = 255; } cx.putImageData(img, 0, 0); return c; }