22

Perlin · Simplex · Worley

drag Y for frequency · click to cycle Worley metric

Three foundational procedural noises sampled over the same domain with a shared time axis. **Perlin** (1985) blends lattice-aligned gradients with a quintic fade , giving smooth but grid-biased blobs. **Simplex** (Perlin, 2001) trades the square lattice for a triangular one and uses radial gradient contributions , killing the axis-aligned artifacts and dropping the cost from to in dimensions. **Worley** (1996) is cellular: for each point compute , the distance to the nearest jittered feature point — the visual hallmarks are Voronoi-like cells with bright cores and dark seams. The 1D cross-section below plots through each panel so you can read off the actual function shapes: Perlin and Simplex look like smooth random curves, Worley like a sawtooth peaking near cell centers. Drag the pointer vertically to scrub the spatial frequency — low frequencies expose the underlying lattice in Perlin most clearly. Click to cycle the Worley distance metric between Euclidean (), Chebyshev (, giving square cells), and Manhattan (, giving diamond cells).

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.