94 lines
3.4 KiB
JavaScript
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;
|
|
}
|