26
fBm: Octave Stacking
drag Y for gain · click to cycle octaves
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.