15

Plasma Bloom

move mouse to shift, keys 1-5 swap palette, space rerolls

The classic demoscene plasma effect, ca. 1990: every pixel is colored by the sum of a handful of sine waves of its coordinates, then mapped through a 256-entry palette LUT that rotates one entry per frame so the colors appear to flow. The field here is , summed and clamped to a byte. The trick that makes it cheap in software — originally on 386s with no FPU — is that each row, column, and radial ring depends on only one coordinate, so all the calls can be hoisted into precomputed lookup tables rebuilt once per frame. The inner loop is then three table reads and a palette lookup per pixel. Move the mouse to drag the sine offsets; press to switch between lava, ice, neon, faux-CGA, and the textbook three-phase RGB palette; press space to roll a fresh random palette.

idle
231 lines · vanilla
view source
// Plasma Bloom — classic demoscene 4-sine plasma.
//
// Conceptually: each pixel's intensity is the sum of a few sine waves of the
// (x,y,t) coordinate, mapped through a 256-entry color LUT that rotates one
// step per frame so the colors "flow" without recomputing the field.
//
// Why this is fast despite the visuals:
//   - Per-pixel work in the inner loop is 3 table reads + 3 adds + one
//     palette lookup.  Math.sin is hoisted out into row / col / radial
//     tables that are rebuilt once per frame.
//   - We render into a half-resolution Uint32 buffer and let drawImage()
//     upscale it.  The GPU does the bilinear stretch for free.

// ---------- runtime state ----------
let W = 0, H = 0;          // CSS pixels of the output canvas
let lw = 0, lh = 0;        // low-res buffer dimensions
let off = null;            // OffscreenCanvas at (lw, lh)
let octx = null;
let img = null;            // ImageData backing `buf`
let buf = null;            // Uint32 view over img.data — one pixel per entry

// Lookup tables. SIN_TABLE is 1024-entry; the field tables hold a precomputed
// scaled sin contribution per row / col / radial-index.  At pixel (x,y) the
// raw field value is `colTab[x] + rowTab[y] + radTab[radIdx[y*lw+x]] + tBase`.
// All values are integers in the 0..N range; we'll mod 1024 to index SIN_TABLE.
const SIN_N = 1024;
let SIN_TABLE = null;
let colTab = null;         // Int32, length lw  — per-column sin contribution
let rowTab = null;         // Int32, length lh  — per-row    sin contribution
let radTab = null;         // Int32, length SIN_N — radial sin LUT (indexed by radIdx)
let radIdx = null;         // Uint16, length lw*lh — precomputed sqrt(...) index
let tBase = 0;             // time term (integer SIN units)

// Palette: 256 RGBA entries packed as Uint32 in native (little-endian) order.
// `paletteOffset` rotates every frame for the color-cycling effect.
let palette = null;
let paletteOffset = 0;
let paletteId = 0;
const PALETTE_NAMES = ['lava', 'ice', 'neon', 'cga-faux', 'default'];

// Slow drift of the time sine.  Mouse drags this and the two field offsets.
let phase = 0;
let driftX = 0, driftY = 0;

// HUD fade — show palette name for a moment after switching.
let hudTimer = 0;
let hudLine = '';

// ---------- one-time setup ----------
function buildSinTable() {
  SIN_TABLE = new Int16Array(SIN_N);
  // We pack sin into a fixed-point integer to keep the inner loop in i32 land.
  // Range chosen so 4 summed entries fit in a single byte (max ~255).
  // Each sin contributes [0, 64], so 4 of them sit in [0, 256).
  for (let i = 0; i < SIN_N; i++) {
    SIN_TABLE[i] = ((Math.sin((i / SIN_N) * Math.PI * 2) + 1) * 0.5 * 64) | 0;
  }
}

// ---------- palettes ----------
function packRGBA(r, g, b) {
  // Little-endian Uint32 -> RGBA on the wire.
  return (0xff << 24) | ((b & 0xff) << 16) | ((g & 0xff) << 8) | (r & 0xff);
}

// Smooth gradient between an array of color stops, sampled at 256 positions.
function gradientPalette(stops) {
  const out = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    const u = i / 255 * (stops.length - 1);
    const a = Math.floor(u);
    const b = Math.min(stops.length - 1, a + 1);
    const f = u - a;
    const c0 = stops[a], c1 = stops[b];
    const r = (c0[0] + (c1[0] - c0[0]) * f) | 0;
    const g = (c0[1] + (c1[1] - c0[1]) * f) | 0;
    const bl = (c0[2] + (c1[2] - c0[2]) * f) | 0;
    out[i] = packRGBA(r, g, bl);
  }
  return out;
}

function lavaPalette() {
  // Black -> deep red -> orange -> yellow-white. Hot plasma.
  return gradientPalette([
    [10, 0, 0], [80, 0, 0], [200, 30, 0],
    [255, 110, 0], [255, 200, 60], [255, 255, 220],
  ]);
}
function icePalette() {
  // Indigo -> teal -> ice white.
  return gradientPalette([
    [5, 0, 30], [10, 30, 90], [20, 90, 180],
    [80, 200, 230], [200, 240, 255], [255, 255, 255],
  ]);
}
function neonPalette() {
  // Saturated magenta/cyan/yellow — the "demo" look.
  const out = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    const u = i / 256 * Math.PI * 2;
    const r = ((Math.sin(u) * 0.5 + 0.5) * 255) | 0;
    const g = ((Math.sin(u + 2.094) * 0.5 + 0.5) * 255) | 0;
    const b = ((Math.sin(u + 4.188) * 0.5 + 0.5) * 255) | 0;
    out[i] = packRGBA(r, g, b);
  }
  return out;
}
function cgaPalette() {
  // Faux CGA / 16-color: 256 entries that quantize to 8 hard buckets, so the
  // plasma takes on a chunky retro feel.
  const stops = [
    [0, 0, 0], [0, 0, 170], [170, 0, 170], [255, 85, 255],
    [85, 255, 255], [255, 255, 85], [255, 255, 255], [255, 85, 85],
  ];
  const out = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    const k = (i >> 5) & 7;
    out[i] = packRGBA(stops[k][0], stops[k][1], stops[k][2]);
  }
  return out;
}
function defaultPalette() {
  // Three-phase RGB sinusoid, the textbook plasma palette.
  const out = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    const u = i / 256;
    const r = ((Math.sin(u * Math.PI * 2 + 0) * 0.5 + 0.5) * 255) | 0;
    const g = ((Math.sin(u * Math.PI * 2 + 2.0) * 0.5 + 0.5) * 255) | 0;
    const b = ((Math.sin(u * Math.PI * 2 + 4.0) * 0.5 + 0.5) * 255) | 0;
    out[i] = packRGBA(r, g, b);
  }
  return out;
}

// A small LCG so the random palette is reproducible per-roll and we don't have
// to lean on Math.random's frequency characteristics.
function rollPalette(seed) {
  let s = (seed | 0) || 0x9e3779b9;
  function rnd() { s = (s * 1664525 + 1013904223) | 0; return ((s >>> 0) / 0xffffffff); }
  // Three sin phases with random frequencies and offsets — same shape as the
  // default palette but with surprises.
  const fr = 1 + Math.floor(rnd() * 3);
  const fg = 1 + Math.floor(rnd() * 3);
  const fb = 1 + Math.floor(rnd() * 3);
  const pr = rnd() * Math.PI * 2;
  const pg = rnd() * Math.PI * 2;
  const pb = rnd() * Math.PI * 2;
  const out = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    const u = i / 256 * Math.PI * 2;
    const r = ((Math.sin(u * fr + pr) * 0.5 + 0.5) * 255) | 0;
    const g = ((Math.sin(u * fg + pg) * 0.5 + 0.5) * 255) | 0;
    const b = ((Math.sin(u * fb + pb) * 0.5 + 0.5) * 255) | 0;
    out[i] = packRGBA(r, g, b);
  }
  return out;
}

function setPalette(id) {
  paletteId = ((id % PALETTE_NAMES.length) + PALETTE_NAMES.length) % PALETTE_NAMES.length;
  if (paletteId === 0) palette = lavaPalette();
  else if (paletteId === 1) palette = icePalette();
  else if (paletteId === 2) palette = neonPalette();
  else if (paletteId === 3) palette = cgaPalette();
  else palette = defaultPalette();
  hudLine = 'palette: ' + PALETTE_NAMES[paletteId];
  hudTimer = 1.6;
}

// ---------- buffer (re)allocation ----------
function ensureBuffers(width, height) {
  // 1/2 resolution — sweet spot between detail and a comfortable margin even
  // on phones.  Drop to 1/3 if either dimension exceeds 900 (e.g. desktop)
  // since the upscale hides the difference and 3x area savings matter.
  const scale = (width >= 900 || height >= 900) ? 3 : 2;
  const sw = Math.max(8, Math.floor(width / scale));
  const sh = Math.max(8, Math.floor(height / scale));
  if (sw === lw && sh === lh && off) return;
  lw = sw; lh = sh;
  off = new OffscreenCanvas(lw, lh);
  octx = off.getContext('2d');
  img = octx.createImageData(lw, lh);
  buf = new Uint32Array(img.data.buffer);
  colTab = new Int32Array(lw);
  rowTab = new Int32Array(lh);
  radTab = new Int32Array(SIN_N);

  // Precompute the per-pixel radial index `floor(sqrt(dx^2+dy^2) * RAD_FREQ)`
  // ONCE per resize.  Center is (lw/2, lh/2). The radial sin term in the
  // inner loop becomes `radTab[radIdx[i]]` — a 1D fetch into the rotated
  // sin LUT, with the sqrt amortized over many frames.
  radIdx = new Uint16Array(lw * lh);
  const ccx = lw * 0.5;
  const ccy = lh * 0.5;
  const RAD_FREQ = 0.18;  // controls how tightly the radial waves spiral
  for (let y = 0; y < lh; y++) {
    const dy = y - ccy;
    for (let x = 0; x < lw; x++) {
      const dx = x - ccx;
      const r = Math.sqrt(dx * dx + dy * dy);
      // Wrap into [0, SIN_N) — high-freq pixels would otherwise overflow
      // the radTab lookup.
      radIdx[y * lw + x] = ((r * RAD_FREQ * SIN_N / (Math.PI * 2)) | 0) % SIN_N;
    }
  }
}

// ---------- per-frame field tables ----------
// These four tables together form the plasma:
//   field(x,y) = colTab[x] + rowTab[y] + radTab[radIdx[y*lw+x]] + tBase
// Each contributes 0..64, summed they're 0..256, which we clamp to the
// 256-entry palette LUT.
function rebuildTables(tx, ty, tr) {
  // Frequencies in radians per pixel.  Mixed so the pattern doesn't tile
  // visibly.  Multiplied by SIN_N/(2π) so we can do integer mod indexing
  // into SIN_TABLE.
  const FX = 0.16, FY = 0.13;
  const KX = (FX * SIN_N / (Math.PI * 2));
  const KY = (FY * SIN_N / (Math.PI * 2));
  const itx = (tx * SIN_N / (Math.PI * 2)) | 0;
  const ity = (ty * SIN_N / (Math.PI * 2)) | 0;
  for (let x = 0; x < lw; x++) {
    let idx = ((x * KX) | 0) + itx;
    idx = ((idx % SIN_N) + SIN_N) % SIN_N;
    colTab[x] = SIN_TABLE[idx];
  }
  for (let y = 0; y < lh; y++) {
    let idx = ((y * KY) | 0) + ity;
    idx = ((idx % SIN_N) + SIN_N) % SIN_N;
    rowTab[y] = SIN_TABLE[idx];
  }
  // Radial table — shift the whole SIN_TABLE by a time-varying offset so the
  // concentric rings appear to breathe outward.
  const itr = (tr * SIN_N / (Math.PI * 2)) | 0;
  for (let i = 0; i < SIN_N; i++) {
    let j = (i + itr) % SIN_N;
    if (j < 0) j += SIN_N;
    radTab[i] = SIN_TABLE[j];
  }
}

// ---------- init / tick ----------
function init({ canvas, ctx, width, height }) {
  W = width; H = height;
  buildSinTable();
  ensureBuffers(W, H);
  setPalette(0);
  paletteOffset = 0;
  phase = 0;
  driftX = 0; driftY = 0;
  // Paint once so the first frame isn't blank if init->tick has a hitch.
  ctx.fillStyle = '#000';
  ctx.fillRect(0, 0, W, H);
}

function tick({ ctx, dt, width, height, input, time }) {
  if (width !== W || height !== H) {
    W = width; H = height;
    ensureBuffers(W, H);
  }

  // ----- input -----
  // Keys 1-5 select a palette. Numeric keys come through `input.keys` as the
  // character itself.
  for (let k = 1; k <= 5; k++) {
    if (input.justPressed(String(k))) setPalette(k - 1);
  }
  if (input.justPressed(' ') || input.justPressed('Space')) {
    palette = rollPalette(((time * 1000) | 0) ^ 0xa53f);
    hudLine = 'palette: rerolled';
    hudTimer = 1.6;
  }

  // Mouse drags the sine offsets.  We map mouse pos relative to canvas
  // center, then ease toward it so motion is buttery, not jittery.
  const mx = (input.mouseX || W * 0.5) / W - 0.5;   // [-0.5, 0.5]
  const my = (input.mouseY || H * 0.5) / H - 0.5;
  driftX += (mx * Math.PI * 2 * 3 - driftX) * Math.min(1, dt * 4);
  driftY += (my * Math.PI * 2 * 3 - driftY) * Math.min(1, dt * 4);

  // Slow time drift on top of the mouse contribution.
  phase += dt * 0.6;

  // Rebuild the precomputed sin tables for this frame.
  rebuildTables(driftX + phase * 0.7, driftY - phase * 0.5, phase * 1.3);
  // The "time" sine contributes a constant offset added to every pixel.
  tBase = SIN_TABLE[(((phase * 0.5 * SIN_N / (Math.PI * 2)) | 0) % SIN_N + SIN_N) % SIN_N];

  // ----- render the plasma field -----
  // Inner loop: 1 add (col+row), 1 table lookup (rad), 1 add (tBase),
  // 1 byte clamp (mask), 1 palette LUT, 1 store.  Everything else is
  // hoisted.  This costs maybe 6-8 ns per pixel on V8 — at 1/2 res of a
  // 1080x1080 viewport that's ~290k pixels, ~2 ms per frame.
  const pal = palette;
  const off2 = paletteOffset & 0xff;
  const tb = tBase | 0;
  for (let y = 0; y < lh; y++) {
    const rowVal = rowTab[y] + tb;
    const rowOff = y * lw;
    for (let x = 0; x < lw; x++) {
      let v = colTab[x] + rowVal + radTab[radIdx[rowOff + x] | 0];
      // v is in [0, ~256). The rotation makes the palette flow.
      v = (v + off2) & 0xff;
      buf[rowOff + x] = pal[v];
    }
  }
  octx.putImageData(img, 0, 0);

  // Upscale to fill.  Bilinear smoothing gives a soft demoscene glow; turn it
  // off for the CGA palette to keep the chunky retro look honest.
  ctx.imageSmoothingEnabled = paletteId !== 3;
  ctx.drawImage(off, 0, 0, W, H);

  // Cycle one palette step per frame — the canonical color-cycling trick.
  paletteOffset = (paletteOffset + 1) & 0xff;

  // ----- HUD -----
  if (hudTimer > 0) {
    hudTimer -= dt;
    const a = Math.max(0, Math.min(1, hudTimer / 1.6));
    ctx.fillStyle = `rgba(0,0,0,${(0.55 * a).toFixed(3)})`;
    ctx.fillRect(10, 10, 200, 30);
    ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
    ctx.font = '13px ui-monospace, monospace';
    ctx.textAlign = 'left';
    ctx.fillText(hudLine, 20, 30);
  }

  // Persistent legend in the corner, low-contrast so it doesn't fight the art.
  ctx.fillStyle = 'rgba(255,255,255,0.45)';
  ctx.font = '11px ui-monospace, monospace';
  ctx.textAlign = 'right';
  ctx.fillText('mouse: warp  ·  1-5: palette  ·  space: reroll', W - 10, H - 10);
}

Comments (0)

Log in to comment.