26

fBm: Octave Stacking

drag Y for gain · click to cycle octaves

Fractional Brownian Motion is just Perlin noise summed across geometrically-spaced frequencies:

where is the lacunarity (frequency multiplier between octaves) and is the gain (amplitude multiplier). With and you get the classic 'pink' fBm whose power spectrum falls as , mimicking the statistics of clouds, terrain, and turbulent fluids. The main panel renders the composite 2D field as a heightmap on a slate-to-amber palette; the strip on the right shows the contribution of each individual octave with label , dimmed toward neutral grey when its amplitude is small — so you can read off exactly how much each layer is adding. Drag the cursor vertically to scrub the gain : at low gain only the coarse base layer survives and the field is smooth and blobby; at high gain the fine octaves contribute heavily and the surface turns wrinkled and craggy. Click to cycle the octave count through — going from to at fixed gain visibly carves detail into the same large-scale shapes without changing them, which is the whole reason fBm is the workhorse of procedural texture synthesis.

idle
244 lines · vanilla
view source
// Fractional Brownian Motion (fBm) builder.
//
//     fbm(x) = sum_{k=0..N-1}  g^k * noise(L^k * x)
//
// where L is lacunarity (=2) and g is the gain. We render the composite
// field as a heightmap on the left/main area, and stack the contributing
// octaves vertically on a strip down the right side so the user can see
// what each layer adds.
//
// Interaction:
//   * mouseY scrubs gain g in [0.2, 0.9].
//   * Click cycles octave count through [1, 2, 4, 6, 8].

// ---- Classic Perlin 2D (Ken Perlin, with Ken's permutation table) ----
const PERM = new Uint8Array(512);
{
  const base = [151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,
    8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,
    177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,77,146,
    158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,102,143,54,65,25,63,
    161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,159,86,164,100,109,
    198,173,186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,
    47,16,58,17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,
    43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97,228,251,34,
    242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239,107,49,192,214,31,181,199,
    106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,205,93,222,114,67,29,24,72,243,
    141,128,195,78,66,215,61,156,180];
  for (let i = 0; i < 512; i++) PERM[i] = base[i & 255];
}

function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function grad2(hash, x, y) {
  // 8 gradient directions from the low 3 bits of the hash.
  switch (hash & 7) {
    case 0: return  x + y;
    case 1: return -x + y;
    case 2: return  x - y;
    case 3: return -x - y;
    case 4: return  x;
    case 5: return -x;
    case 6: return  y;
    default: return -y;
  }
}
function perlin2(x, y) {
  const xi = Math.floor(x) & 255;
  const yi = Math.floor(y) & 255;
  const xf = x - Math.floor(x);
  const yf = y - Math.floor(y);
  const u = fade(xf);
  const v = fade(yf);
  const aa = PERM[PERM[xi]     + yi];
  const ab = PERM[PERM[xi]     + yi + 1];
  const ba = PERM[PERM[xi + 1] + yi];
  const bb = PERM[PERM[xi + 1] + yi + 1];
  const x1 = lerp(grad2(aa, xf,     yf),     grad2(ba, xf - 1, yf),     u);
  const x2 = lerp(grad2(ab, xf,     yf - 1), grad2(bb, xf - 1, yf - 1), u);
  return lerp(x1, x2, v); // ~ [-1, 1]
}

// ---- Config ----
const OCTAVE_CHOICES = [1, 2, 4, 6, 8];
const LACUNARITY = 2.0;
const MAIN_SCALE = 4;        // main field rendered at width/MAIN_SCALE then upscaled
const PANEL_SCALE = 4;       // each octave panel rendered at panelW/PANEL_SCALE
const BASE_FREQ = 0.012;     // base frequency over canvas-px coords
const STRIP_FRAC = 0.30;     // fraction of width reserved for the octave strip (desktop)
const STRIP_FRAC_MOBILE = 0.34;

let mainBuf, mainCtx, mainW = 0, mainH = 0, mainImg = null;
let panelBuf, panelCtx, pBufW = 0, pBufH = 0, panelImg = null; // one tall panel, reused per octave

let octaveIdx = 2;           // index into OCTAVE_CHOICES; start at 4
let gain = 0.55;
let gainSmooth = 0.55;       // smoothed mouse-driven gain for nice scrubbing
let t = 0;
let driftZ = 0;              // 3rd-coordinate proxy: animate by offsetting sample coords

// Palette: grayscale -> amber (calm). 256 entries.
const palette = new Uint8Array(256 * 3);
{
  for (let i = 0; i < 256; i++) {
    const v = i / 255;
    // dark slate -> warm grey -> amber -> pale cream
    // stops:
    //   0.00  -> ( 18,  20,  26)
    //   0.45  -> ( 70,  66,  60)
    //   0.75  -> (200, 138,  58)
    //   1.00  -> (250, 230, 190)
    let r, g, b;
    if (v < 0.45) {
      const u = v / 0.45;
      r = 18  + (70  - 18 ) * u;
      g = 20  + (66  - 20 ) * u;
      b = 26  + (60  - 26 ) * u;
    } else if (v < 0.75) {
      const u = (v - 0.45) / 0.30;
      r = 70  + (200 - 70 ) * u;
      g = 66  + (138 - 66 ) * u;
      b = 60  + (58  - 60 ) * u;
    } else {
      const u = (v - 0.75) / 0.25;
      r = 200 + (250 - 200) * u;
      g = 138 + (230 - 138) * u;
      b = 58  + (190 - 58 ) * u;
    }
    palette[i * 3]     = r | 0;
    palette[i * 3 + 1] = g | 0;
    palette[i * 3 + 2] = b | 0;
  }
}

function ensureMainBuf(w, h) {
  if (w === mainW && h === mainH && mainBuf) return;
  mainW = w; mainH = h;
  mainBuf = new OffscreenCanvas(w, h);
  mainCtx = mainBuf.getContext('2d');
  mainImg = mainCtx.createImageData(w, h);
}
function ensurePanelBuf(w, h) {
  if (w === pBufW && h === pBufH && panelBuf) return;
  pBufW = w; pBufH = h;
  panelBuf = new OffscreenCanvas(w, h);
  panelCtx = panelBuf.getContext('2d');
  panelImg = panelCtx.createImageData(w, h);
}

function init({ canvas, ctx, width, height, input }) {
  // Nothing heavy at init — buffers grow on first tick.
}

function tick({ ctx, dt, time, width, height, input }) {
  t = time;
  driftZ += dt * 0.18; // slow temporal drift via offset

  // ---- input ----
  // Click cycles octave count. consumeClicks() is the canonical drain.
  for (const _c of input.consumeClicks()) {
    octaveIdx = (octaveIdx + 1) % OCTAVE_CHOICES.length;
  }
  // mouseY -> gain in [0.2, 0.9]. Only scrub when the pointer is over the canvas.
  if (input.mouseY >= 0 && input.mouseY <= height) {
    const f = Math.max(0, Math.min(1, input.mouseY / Math.max(1, height)));
    gain = 0.2 + (0.9 - 0.2) * f;
  }
  // smooth a bit for nicer feel
  gainSmooth += (gain - gainSmooth) * Math.min(1, dt * 6);

  const octaves = OCTAVE_CHOICES[octaveIdx];

  // ---- layout: split into main field + right-side octave strip ----
  const mobile = width < 540;
  const stripFrac = mobile ? STRIP_FRAC_MOBILE : STRIP_FRAC;
  const stripW = Math.max(72, Math.min(220, (width * stripFrac) | 0));
  const stripGap = 8;
  const fieldW = Math.max(64, width - stripW - stripGap);
  const fieldH = height;
  const fieldX = 0;
  const fieldY = 0;

  // ---- render the composite fBm field to mainBuf at reduced res ----
  const bw = Math.max(2, Math.ceil(fieldW / MAIN_SCALE));
  const bh = Math.max(2, Math.ceil(fieldH / MAIN_SCALE));
  ensureMainBuf(bw, bh);

  // amplitude normalization so values stay in roughly [-1,1] regardless of gain/octaves
  let ampSum = 0;
  {
    let a = 1;
    for (let k = 0; k < octaves; k++) { ampSum += a; a *= gainSmooth; }
  }
  const invAmp = 1 / Math.max(1e-6, ampSum);

  const img = mainImg;
  const data = img.data;

  // sample frequency in screen-px; multiply by MAIN_SCALE so the field
  // looks the same regardless of the reduced render resolution.
  const f0 = BASE_FREQ * MAIN_SCALE;
  const zx = driftZ * 11.0;   // independent offsets per axis so motion isn't diagonal
  const zy = driftZ * -7.3;

  let oi = 0;
  for (let y = 0; y < bh; y++) {
    for (let x = 0; x < bw; x++) {
      let amp = 1;
      let freq = f0;
      let sum = 0;
      for (let k = 0; k < octaves; k++) {
        // shift each octave by a unique offset so they don't all line up at 0,0
        sum += amp * perlin2(x * freq + zx + k * 31.7, y * freq + zy + k * 17.3);
        amp *= gainSmooth;
        freq *= LACUNARITY;
      }
      sum *= invAmp;                  // now in roughly [-1, 1]
      let v = sum * 0.5 + 0.5;         // [0, 1]
      if (v < 0) v = 0; else if (v > 1) v = 1;
      const pi = (v * 255) | 0;
      const o = oi * 4;
      data[o]     = palette[pi * 3];
      data[o + 1] = palette[pi * 3 + 1];
      data[o + 2] = palette[pi * 3 + 2];
      data[o + 3] = 255;
      oi++;
    }
  }
  mainCtx.putImageData(img, 0, 0);

  // Clear background.
  ctx.fillStyle = '#0c0d10';
  ctx.fillRect(0, 0, width, height);

  // Composite up.
  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(mainBuf, 0, 0, bw, bh, fieldX, fieldY, fieldW, fieldH);

  // ---- octave strip on the right ----
  const stripX = fieldX + fieldW + stripGap;
  const labelGap = 2;
  const interPanelGap = 4;
  const panelH = Math.max(28, Math.floor((fieldH - interPanelGap * (octaves - 1)) / octaves));
  const panelInnerW = stripW;

  const pbw = Math.max(2, Math.ceil(panelInnerW / PANEL_SCALE));
  const pbh = Math.max(2, Math.ceil(panelH / PANEL_SCALE));
  ensurePanelBuf(pbw, pbh);

  // Each panel: render just that octave, scaled to fit the panel cell.
  // Frequency for octave k inside the panel: we scale the world coords so
  // the panel shows ~one "tile" of meaningful structure regardless of k —
  // i.e. the coarser octaves show their big blobs, finer octaves show
  // their busy ripples. Use the same world frequency as the composite,
  // sampled across the panel area mapped onto the main field's coord box.
  for (let k = 0; k < octaves; k++) {
    const py = fieldY + k * (panelH + interPanelGap);
    const ampForK = Math.pow(gainSmooth, k); // its contribution to the composite
    const freqK = f0 * Math.pow(LACUNARITY, k);

    // Sample over the same world-coord rectangle as the main field so the
    // panel's pattern aligns with what you see on the left.
    const pimg = panelImg;
    const pdata = pimg.data;
    let pi = 0;
    for (let y = 0; y < pbh; y++) {
      // map panel y -> main-field y (in main-buf coords)
      const my = (y / Math.max(1, pbh - 1)) * (bh - 1);
      for (let x = 0; x < pbw; x++) {
        const mx = (x / Math.max(1, pbw - 1)) * (bw - 1);
        const n = perlin2(mx * freqK + zx + k * 31.7, my * freqK + zy + k * 17.3);
        // show the octave's actual contribution (post-gain), but boosted into
        // the panel's [0,1] range so finer octaves don't fade to grey.
        // We map n in [-1,1] to [0,1] using its own amplitude before
        // normalization, then dim slightly by amp so high octaves at low
        // gain go visibly darker — that's the whole pedagogical point.
        let v = n * 0.5 + 0.5;
        // dim toward neutral grey by amp (so amp=0 looks ~flat grey, amp=1 full)
        const dim = 0.25 + 0.75 * Math.max(0.05, Math.min(1, ampForK));
        v = 0.5 + (v - 0.5) * dim;
        if (v < 0) v = 0; else if (v > 1) v = 1;
        const idx = (v * 255) | 0;
        const o = pi * 4;
        pdata[o]     = palette[idx * 3];
        pdata[o + 1] = palette[idx * 3 + 1];
        pdata[o + 2] = palette[idx * 3 + 2];
        pdata[o + 3] = 255;
        pi++;
      }
    }
    panelCtx.putImageData(pimg, 0, 0);

    // Panel image
    ctx.imageSmoothingEnabled = true;
    ctx.drawImage(panelBuf, 0, 0, pbw, pbh, stripX, py, panelInnerW, panelH);

    // Panel border
    ctx.strokeStyle = 'rgba(255,255,255,0.18)';
    ctx.lineWidth = 1;
    ctx.strokeRect(stripX + 0.5, py + 0.5, panelInnerW - 1, panelH - 1);

    // Label: octave k, amplitude g^k
    const labelText = 'k=' + k + '  g^k=' + ampForK.toFixed(2);
    ctx.font = '11px monospace';
    const lw = ctx.measureText(labelText).width + 8;
    ctx.fillStyle = 'rgba(0,0,0,0.55)';
    ctx.fillRect(stripX + 4, py + 4, lw, 16);
    ctx.fillStyle = '#fff';
    ctx.textBaseline = 'top';
    ctx.textAlign = 'left';
    ctx.fillText(labelText, stripX + 8, py + 6);
  }

  // ---- HUD on the main field ----
  ctx.font = '13px monospace';
  ctx.textBaseline = 'top';
  ctx.textAlign = 'left';

  const hudLines = [
    'fBm builder',
    'octaves N = ' + octaves,
    'gain g    = ' + gainSmooth.toFixed(2),
    'lacunarity L = ' + LACUNARITY.toFixed(1),
  ];
  const hudW = 168;
  const hudH = 6 + hudLines.length * 16 + 4;
  ctx.fillStyle = 'rgba(0,0,0,0.55)';
  ctx.fillRect(8, 8, hudW, hudH);
  ctx.fillStyle = '#fff';
  for (let i = 0; i < hudLines.length; i++) {
    ctx.fillText(hudLines[i], 16, 12 + i * 16);
  }

  // Gain scrub indicator: a thin track along the left edge with a marker at mouseY.
  const trackX = 4;
  const trackY = hudH + 16;
  const trackH = Math.max(40, fieldH - trackY - 16);
  ctx.fillStyle = 'rgba(255,255,255,0.08)';
  ctx.fillRect(trackX, trackY, 2, trackH);
  // marker
  const gNorm = (gainSmooth - 0.2) / (0.9 - 0.2);
  const my = trackY + gNorm * trackH;
  ctx.fillStyle = 'rgba(250,200,120,0.95)';
  ctx.beginPath();
  ctx.arc(trackX + 1, my, 4, 0, Math.PI * 2);
  ctx.fill();

  // Bottom hint (mobile-friendly).
  ctx.font = '11px monospace';
  ctx.textBaseline = 'bottom';
  ctx.fillStyle = 'rgba(255,255,255,0.55)';
  ctx.fillText('drag Y for gain  ·  click to cycle octaves', 12, height - 8);
}

Comments (0)

Log in to comment.