Home

19. Palette fire · demoscene, early 1990s · Retro techniques

Palette fire

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

The 1990s fire: a low-res buffer, each pixel is the average of the three below it minus a cooling term, then a palette from black through the tweak colour into yellow and white. The bottom row is random sparks. The algorithm is the widely shared one from early-1990s PC demos and tutorials; its first author is not documented, so no single original is claimed. Move the pointer to poke the fire.

Move the pointer over the preview to feed the fire.

Reference

Interpretation: this evokes a period look and does not reproduce one specific original.

Browser APIs

  • Canvas 2D
  • ImageData
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#ff6622",
    heat: 1,
    wind: 0
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [255, 102, 34];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const W = 160, H = 100;
  const buf = document.createElement("canvas");
  buf.width = W; buf.height = H;
  const bctx = buf.getContext("2d");
  const img = bctx.createImageData(W, H);
  const px = img.data;
  const heat = new Uint8Array(W * (H + 2));
  const pal = new Uint8Array(256 * 3);
  let raf = 0;
  let primed = false;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;

  function buildPal() {
    const rgb = hexToRgb(PARAMS.color);
    for (let i = 0; i < 256; i++) {
      const t = i / 255;
      let r, g, b;
      if (t < 0.18) {
        const k = t / 0.18;
        r = 8 * k; g = 4 * k; b = 2 * k;
      } else if (t < 0.45) {
        const k = (t - 0.18) / 0.27;
        r = 8 + (rgb[0] - 8) * k;
        g = 4 + (rgb[1] * 0.25 - 4) * k;
        b = 2 + (rgb[2] * 0.12) * k;
      } else if (t < 0.75) {
        const k = (t - 0.45) / 0.3;
        r = rgb[0] + (255 - rgb[0]) * k;
        g = rgb[1] * 0.25 + (220 - rgb[1] * 0.25) * k;
        b = rgb[2] * 0.12 + 40 * k;
      } else {
        const k = (t - 0.75) / 0.25;
        r = 255; g = 220 + 35 * k; b = 40 + 200 * k;
      }
      pal[i * 3] = r; pal[i * 3 + 1] = g; pal[i * 3 + 2] = b;
    }
  }
  buildPal();

  let pokeX = -1, pokeY = -1;
  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    pokeX = ((ev.clientX - r.left) / r.width) * W;
    pokeY = ((ev.clientY - r.top) / r.height) * H;
    wake();
  });
  canvas.addEventListener("pointerleave", function () { pokeX = -1; wake(); });

  function step() {
    buildPal();
    const cool = Math.max(1, 3.2 / PARAMS.heat);
    const wind = PARAMS.wind | 0;
    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        const x0 = (x - 1 + wind + W) % W;
        const x1 = x;
        const x2 = (x + 1 + wind + W) % W;
        const a = heat[(y + 1) * W + x0];
        const b = heat[(y + 1) * W + x1];
        const c = heat[(y + 1) * W + x2];
        const d = heat[(y + 2) * W + x1];
        let v = (a + b + c + d) * 0.25 - cool;
        if (v < 0) v = 0;
        heat[y * W + x] = v;
      }
    }
    for (let x = 0; x < W; x++) {
      const spark = Math.random() < 0.46 * PARAMS.heat ? 180 + Math.random() * 75 : Math.random() * 50;
      heat[H * W + x] = spark;
      heat[(H + 1) * W + x] = spark;
    }
    if (pokeX >= 0) {
      const cx = pokeX | 0, cy = Math.max(0, pokeY | 0);
      for (let dy = -3; dy <= 3; dy++) {
        for (let dx = -3; dx <= 3; dx++) {
          const xx = (cx + dx + W) % W;
          const yy = cy + dy;
          if (yy >= 0 && yy < H + 2) heat[yy * W + xx] = 255;
        }
      }
    }
    for (let i = 0; i < W * H; i++) {
      const h = heat[i];
      px[i * 4] = pal[h * 3];
      px[i * 4 + 1] = pal[h * 3 + 1];
      px[i * 4 + 2] = pal[h * 3 + 2];
      px[i * 4 + 3] = 255;
    }
    bctx.putImageData(img, 0, 0);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function frame() {
    resize();
    if (reduced && !primed) for (let i = 0; i < 50; i++) step();
    else step();
    primed = true;
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();