22
Perlin · Simplex · Worley
drag Y for frequency · click to cycle Worley metric
idle
314 lines · vanilla
view source
// Perlin vs Simplex vs Worley — three procedural noise functions side-by-side.
//
// Same time axis t advances for all three so the visual comparison is fair.
// Perlin: bilinear-blended gradients on a regular square lattice (Ken Perlin, 1985).
// Simplex: gradient contributions from a triangular (simplex) lattice with a
// radial falloff (Ken Perlin's improved version, 2001) — fewer directional artifacts.
// Worley: cellular noise — for each pixel compute the distance to the nearest
// feature point on a jittered grid (Steven Worley, 1996).
//
// Interaction: drag the pointer vertically to scrub the spatial frequency
// (low Y = big blobs, high Y = fine detail). Click anywhere to cycle the
// Worley distance metric between euclidean, Chebyshev and Manhattan.
const W_GRID = 6; // worley feature points per side of the panel domain
// ---------- shared ----------
let pw, ph; // panel pixel size (each of three panels)
let stripH; // height of the 1D cross-section strip
let labelH; // top label band height
let buf; // OffscreenCanvas image buffer (one panel-wide column)
let bufCtx;
let imgData; // ImageData reused per panel
let t; // time
let freq; // current spatial frequency (cycles across panel)
let targetFreq;
let metricMode; // 0 euclidean, 1 chebyshev, 2 manhattan
let metricNames;
let clickHintT;
let frameSkip; // dynamic: render 1px columns or 2px on slow devices
let panelLayout; // 'row' on wide, 'col' on narrow (mobile portrait)
// ---------- perlin (classic 3D) ----------
let P; // permutation 0..511
function buildPerm(seed) {
const a = new Uint8Array(256);
for (let i = 0; i < 256; i++) a[i] = i;
// simple LCG shuffle
let s = seed | 0 || 1;
for (let i = 255; i > 0; i--) {
s = (s * 1664525 + 1013904223) | 0;
const j = ((s >>> 0) % (i + 1));
const tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}
P = new Uint8Array(512);
for (let i = 0; i < 512; i++) P[i] = a[i & 255];
}
function fade(x) { return x * x * x * (x * (x * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function gradP(hash, x, y, z) {
const h = hash & 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
function perlin3(x, y, z) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
const Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
const u = fade(x), v = fade(y), w = fade(z);
const A = P[X] + Y, AA = P[A] + Z, AB = P[A + 1] + Z;
const B = P[X + 1] + Y, BA = P[B] + Z, BB = P[B + 1] + Z;
return lerp(
lerp(
lerp(gradP(P[AA], x, y, z), gradP(P[BA], x - 1, y, z), u),
lerp(gradP(P[AB], x, y - 1, z), gradP(P[BB], x - 1, y - 1, z), u),
v),
lerp(
lerp(gradP(P[AA + 1], x, y, z - 1), gradP(P[BA + 1], x - 1, y, z - 1), u),
lerp(gradP(P[AB + 1], x, y - 1, z - 1), gradP(P[BB + 1], x - 1, y - 1, z - 1), u),
v),
w);
}
// ---------- simplex (3D) ----------
// Standard 3D simplex, after Stefan Gustavson's reference implementation.
const SIMPLEX_GRAD3 = new Int8Array([
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0,
1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1,
0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1
]);
const F3 = 1 / 3, G3 = 1 / 6;
function simplex3(x, y, z) {
const s = (x + y + z) * F3;
const i = Math.floor(x + s), j = Math.floor(y + s), k = Math.floor(z + s);
const tt = (i + j + k) * G3;
const X0 = i - tt, Y0 = j - tt, Z0 = k - tt;
const x0 = x - X0, y0 = y - Y0, z0 = z - Z0;
let i1, j1, k1, i2, j2, k2;
if (x0 >= y0) {
if (y0 >= z0) { i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 1; k2 = 0; }
else if (x0 >= z0) { i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 0; k2 = 1; }
else { i1 = 0; j1 = 0; k1 = 1; i2 = 1; j2 = 0; k2 = 1; }
} else {
if (y0 < z0) { i1 = 0; j1 = 0; k1 = 1; i2 = 0; j2 = 1; k2 = 1; }
else if (x0 < z0) { i1 = 0; j1 = 1; k1 = 0; i2 = 0; j2 = 1; k2 = 1; }
else { i1 = 0; j1 = 1; k1 = 0; i2 = 1; j2 = 1; k2 = 0; }
}
const x1 = x0 - i1 + G3, y1 = y0 - j1 + G3, z1 = z0 - k1 + G3;
const x2 = x0 - i2 + 2 * G3, y2 = y0 - j2 + 2 * G3, z2 = z0 - k2 + 2 * G3;
const x3 = x0 - 1 + 3 * G3, y3 = y0 - 1 + 3 * G3, z3 = z0 - 1 + 3 * G3;
const ii = i & 255, jj = j & 255, kk = k & 255;
function corner(xc, yc, zc, gi) {
let tval = 0.6 - xc * xc - yc * yc - zc * zc;
if (tval < 0) return 0;
const g0 = SIMPLEX_GRAD3[gi * 3], g1 = SIMPLEX_GRAD3[gi * 3 + 1], g2 = SIMPLEX_GRAD3[gi * 3 + 2];
tval *= tval;
return tval * tval * (g0 * xc + g1 * yc + g2 * zc);
}
const gi0 = P[ii + P[jj + P[kk]]] % 12;
const gi1 = P[ii + i1 + P[jj + j1 + P[kk + k1]]] % 12;
const gi2 = P[ii + i2 + P[jj + j2 + P[kk + k2]]] % 12;
const gi3 = P[ii + 1 + P[jj + 1 + P[kk + 1]]] % 12;
return 32 * (corner(x0, y0, z0, gi0) + corner(x1, y1, z1, gi1)
+ corner(x2, y2, z2, gi2) + corner(x3, y3, z3, gi3));
}
// ---------- worley ----------
// Jittered grid of feature points moving slowly in time. We sample in the unit
// [0,1] x [0,1] domain mapped to feature grid W_GRID.
function hashCell(ix, iy, salt) {
// deterministic-ish hash to two pseudo-random offsets in [0,1)
let h = (ix * 374761393 + iy * 668265263 + salt * 2147483647) | 0;
h = (h ^ (h >>> 13)) * 1274126177 | 0;
h = h ^ (h >>> 16);
const a = ((h >>> 0) & 0xffff) / 65536;
const b = ((h >>> 16) & 0xffff) / 65536;
return [a, b];
}
function worley(x, y, time, metric) {
// x,y in panel-normalized [0,1]. We snap to the feature grid.
const gx = x * W_GRID;
const gy = y * W_GRID;
const ix = Math.floor(gx), iy = Math.floor(gy);
const fx = gx - ix, fy = gy - iy;
let f1 = Infinity;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const cix = ix + dx, ciy = iy + dy;
const [a, b] = hashCell(cix, ciy, 1);
// animate the feature point inside the cell
const phaseA = a * 6.2831853 + time * 0.8;
const phaseB = b * 6.2831853 + time * 1.1;
const px = 0.5 + 0.4 * Math.cos(phaseA);
const py = 0.5 + 0.4 * Math.sin(phaseB);
const ddx = (dx + px) - fx;
const ddy = (dy + py) - fy;
let d;
if (metric === 0) d = Math.sqrt(ddx * ddx + ddy * ddy); // euclidean
else if (metric === 1) d = Math.max(Math.abs(ddx), Math.abs(ddy)); // chebyshev
else d = Math.abs(ddx) + Math.abs(ddy); // manhattan
if (d < f1) f1 = d;
}
}
// normalize to ~[0,1] given a grid of W_GRID cells
return Math.min(1, f1);
}
// ---------- layout ----------
function computeLayout(W, H) {
// wide → 3 panels in a row; narrow → 3 stacked. Each panel is followed
// by a cross-section strip beneath the panel triplet.
labelH = Math.max(18, Math.min(26, Math.round(H * 0.05)));
if (W >= H * 1.2) {
panelLayout = 'row';
stripH = Math.max(40, Math.round(H * 0.18));
const usableH = H - labelH - stripH - 6;
pw = Math.floor(W / 3);
ph = usableH;
} else {
panelLayout = 'col';
// three vertical panels + small strips between not enough room; use single
// mini-strip at very bottom and stack three big panels.
stripH = Math.max(36, Math.round(H * 0.12));
const usableH = H - labelH - stripH - 6;
ph = Math.floor(usableH / 3);
pw = W;
}
// for performance: on big panels, render every 2nd column then duplicate
frameSkip = (pw * ph > 220 * 220) ? 2 : 1;
buf = new OffscreenCanvas(pw, ph);
bufCtx = buf.getContext('2d');
imgData = bufCtx.createImageData(pw, ph);
}
// ---------- noise sampling driver ----------
function sampleNoise(which, nx, ny, time) {
// nx, ny in [0,1] panel-relative, freq = cycles across the panel.
const x = nx * freq;
const y = ny * freq;
if (which === 0) {
// perlin output ~[-0.7, 0.7] for fBm scale; remap to [0,1]
return 0.5 + 0.7 * perlin3(x, y, time);
} else if (which === 1) {
return 0.5 + 0.7 * simplex3(x, y, time);
} else {
// worley: pass freq via scaling the sampling coords (W_GRID scales it)
// Increase freq → smaller cells: scale nx,ny by (freq / base).
const scale = Math.max(0.4, freq / 3);
return worley(nx * scale, ny * scale, time, metricMode);
}
}
// Inlined into renderPanel to avoid per-pixel [r,g,b] array allocation.
function renderPanel(which, time) {
const data = imgData.data;
const step = frameSkip;
for (let py = 0; py < ph; py++) {
const ny = py / (ph - 1);
for (let px = 0; px < pw; px += step) {
const nx = px / (pw - 1);
let v = sampleNoise(which, nx, ny, time);
if (v < 0) v = 0; else if (v > 1) v = 1;
let r, g, b;
if (which === 0) {
r = (20 + 200 * v) | 0;
g = (40 + 210 * v) | 0;
b = (70 + 180 * v) | 0;
} else if (which === 1) {
r = (30 + 220 * v) | 0;
g = (35 + 175 * v) | 0;
b = (80 + 60 * v) | 0;
} else {
const iv = 1 - v;
r = (40 + 180 * iv) | 0;
g = (60 + 200 * iv) | 0;
b = (90 + 160 * v) | 0;
}
// fill step columns
for (let k = 0; k < step && px + k < pw; k++) {
const o = (py * pw + (px + k)) * 4;
data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = 255;
}
}
}
bufCtx.putImageData(imgData, 0, 0);
}
function init({ width, height }) {
buildPerm(1337);
t = 0;
freq = 4;
targetFreq = 4;
metricMode = 0;
metricNames = ['euclidean', 'Chebyshev', 'Manhattan'];
clickHintT = 180; // ~3s
computeLayout(width, height);
}
let lastW = 0, lastH = 0;
function tick({ dt, ctx, width, height, input, frame }) {
if (width !== lastW || height !== lastH) {
computeLayout(width, height);
lastW = width; lastH = height;
}
// ---- input ----
// mouseY scrubs frequency. Map y∈[0..height] → freq ∈ [1.5 .. 14] (log-ish).
const yClamped = Math.max(0, Math.min(height, input.mouseY));
const frac = 1 - (yClamped / height); // top = low freq, bottom = high freq
// user mental model from hint: "drag Y for frequency"; we want vertical drag
// to span a useful range. Use frac mapped through a slight curve.
const fNorm = frac;
targetFreq = 1.5 + 12.5 * fNorm * fNorm;
// smooth approach
freq += (targetFreq - freq) * 0.12;
// click cycles worley metric
const clicks = input.consumeClicks ? input.consumeClicks() : null;
if (clicks && clicks.length > 0) {
metricMode = (metricMode + 1) % 3;
clickHintT = 0;
}
if (clickHintT > 0) clickHintT--;
// time advances
t += dt * 0.35;
// ---- background ----
ctx.fillStyle = '#0b0d12';
ctx.fillRect(0, 0, width, height);
// ---- titles ----
ctx.font = `${Math.max(11, Math.round(labelH * 0.55))}px ui-sans-serif, system-ui, sans-serif`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
const titles = [
'Perlin',
'Simplex',
`Worley · ${metricNames[metricMode]}`
];
const titleColors = ['#7fb6c6', '#e6b35a', '#9bd3a8'];
// ---- render three panels ----
// Layout positions
let panelX = [0, 0, 0], panelY = [0, 0, 0];
if (panelLayout === 'row') {
panelX = [0, pw, 2 * pw];
panelY = [labelH, labelH, labelH];
} else {
panelX = [0, 0, 0];
panelY = [labelH, labelH + ph, labelH + 2 * ph];
}
for (let i = 0; i < 3; i++) {
renderPanel(i, t);
ctx.drawImage(buf, panelX[i], panelY[i]);
}
// title bar
for (let i = 0; i < 3; i++) {
let tx, ty;
if (panelLayout === 'row') {
tx = panelX[i] + pw / 2;
ty = labelH / 2;
} else {
tx = panelX[i] + pw / 2;
ty = panelY[i] + 12;
}
ctx.fillStyle = titleColors[i];
ctx.fillText(titles[i], tx, ty);
}
// dividers between panels
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
if (panelLayout === 'row') {
for (let i = 1; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(panelX[i] + 0.5, labelH);
ctx.lineTo(panelX[i] + 0.5, labelH + ph);
ctx.stroke();
}
}
// ---- cross-section strip ----
// Sample noise along the center horizontal line of each panel and plot as
// overlaid 1D curves. Same x-range so users see how each noise shapes up.
const stripY = panelLayout === 'row' ? (labelH + ph + 4) : (labelH + 3 * ph + 4);
const stripW = panelLayout === 'row' ? width : width;
ctx.fillStyle = '#11141a';
ctx.fillRect(0, stripY, stripW, stripH);
// grid line at 0.5
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.beginPath();
ctx.moveTo(0, stripY + stripH / 2);
ctx.lineTo(stripW, stripY + stripH / 2);
ctx.stroke();
// plot three curves
const samples = Math.min(stripW, 220);
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = titleColors[i];
ctx.globalAlpha = 0.95;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let s = 0; s <= samples; s++) {
const nx = s / samples;
const v = sampleNoise(i, nx, 0.5, t);
const px = nx * stripW;
const py = stripY + (1 - Math.max(0, Math.min(1, v))) * (stripH - 6) + 3;
if (s === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}
ctx.globalAlpha = 1;
// strip label
ctx.font = '11px ui-sans-serif, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('1D cross-section at panel center', 8, stripY + 4);
ctx.textAlign = 'right';
ctx.fillText(`freq ≈ ${freq.toFixed(1)}`, stripW - 8, stripY + 4);
// hint overlay first few seconds
if (clickHintT > 0) {
const a = Math.min(1, clickHintT / 60);
ctx.globalAlpha = a * 0.85;
ctx.fillStyle = '#000';
const hint = 'drag Y · click panel to cycle Worley metric';
ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const tw = ctx.measureText(hint).width + 16;
const hx = width / 2, hy = height - 8;
ctx.fillRect(hx - tw / 2, hy - 20, tw, 20);
ctx.fillStyle = '#fff';
ctx.fillText(hint, hx, hy - 4);
ctx.globalAlpha = 1;
}
}
Comments (0)
Log in to comment.