Files
bubblechambersimart/src/render/noise.js
2026-05-20 16:53:23 -04:00

94 lines
3.4 KiB
JavaScript

/* ============================================================
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;
}