{"slug":"fire","number":"19","title":"Palette fire","era":"Demoscene · 1993","summary":"Heat rises through a 256-color fire LUT. Pointer is a torch.","description":"The 90s 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 color into yellow and white. The bottom row is random sparks. Move the pointer to poke the fire.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["demoscene","palette"],"hasGlsl":false,"interaction":"Move the pointer over the preview to feed the fire.","url":"https://3d-retro.com/experiments/fire","example_url":"https://3d-retro.com/examples/fire.html","api_url":"https://3d-retro.com/api/v1/experiments/fire","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"1990s palette fire. Heat rises, cools, and is remapped through a LUT.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/fire\" />\n  <title>Palette fire · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0c0e; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; image-rendering: pixelated; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #0b0c0e; }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#ff6622\",\n    heat: 1,\n    wind: 0\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [255, 102, 34];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const W = 160, H = 100;\n  const buf = document.createElement(\"canvas\");\n  buf.width = W; buf.height = H;\n  const bctx = buf.getContext(\"2d\");\n  const img = bctx.createImageData(W, H);\n  const px = img.data;\n  const heat = new Uint8Array(W * (H + 2));\n  const pal = new Uint8Array(256 * 3);\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n  function buildPal() {\n    const rgb = hexToRgb(PARAMS.color);\n    for (let i = 0; i < 256; i++) {\n      const t = i / 255;\n      let r, g, b;\n      if (t < 0.18) {\n        const k = t / 0.18;\n        r = 8 * k; g = 4 * k; b = 2 * k;\n      } else if (t < 0.45) {\n        const k = (t - 0.18) / 0.27;\n        r = 8 + (rgb[0] - 8) * k;\n        g = 4 + (rgb[1] * 0.25 - 4) * k;\n        b = 2 + (rgb[2] * 0.12) * k;\n      } else if (t < 0.75) {\n        const k = (t - 0.45) / 0.3;\n        r = rgb[0] + (255 - rgb[0]) * k;\n        g = rgb[1] * 0.25 + (220 - rgb[1] * 0.25) * k;\n        b = rgb[2] * 0.12 + 40 * k;\n      } else {\n        const k = (t - 0.75) / 0.25;\n        r = 255; g = 220 + 35 * k; b = 40 + 200 * k;\n      }\n      pal[i * 3] = r; pal[i * 3 + 1] = g; pal[i * 3 + 2] = b;\n    }\n  }\n  buildPal();\n\n  let pokeX = -1, pokeY = -1;\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    pokeX = ((ev.clientX - r.left) / r.width) * W;\n    pokeY = ((ev.clientY - r.top) / r.height) * H;\n  });\n  canvas.addEventListener(\"pointerleave\", function () { pokeX = -1; });\n\n  function step() {\n    buildPal();\n    const cool = Math.max(1, 3.2 / PARAMS.heat);\n    const wind = PARAMS.wind | 0;\n    for (let y = 0; y < H; y++) {\n      for (let x = 0; x < W; x++) {\n        const x0 = (x - 1 + wind + W) % W;\n        const x1 = x;\n        const x2 = (x + 1 + wind + W) % W;\n        const a = heat[(y + 1) * W + x0];\n        const b = heat[(y + 1) * W + x1];\n        const c = heat[(y + 1) * W + x2];\n        const d = heat[(y + 2) * W + x1];\n        let v = (a + b + c + d) * 0.25 - cool;\n        if (v < 0) v = 0;\n        heat[y * W + x] = v;\n      }\n    }\n    for (let x = 0; x < W; x++) {\n      const spark = Math.random() < 0.46 * PARAMS.heat ? 180 + Math.random() * 75 : Math.random() * 50;\n      heat[H * W + x] = spark;\n      heat[(H + 1) * W + x] = spark;\n    }\n    if (pokeX >= 0) {\n      const cx = pokeX | 0, cy = Math.max(0, pokeY | 0);\n      for (let dy = -3; dy <= 3; dy++) {\n        for (let dx = -3; dx <= 3; dx++) {\n          const xx = (cx + dx + W) % W;\n          const yy = cy + dy;\n          if (yy >= 0 && yy < H + 2) heat[yy * W + xx] = 255;\n        }\n      }\n    }\n    for (let i = 0; i < W * H; i++) {\n      const h = heat[i];\n      px[i * 4] = pal[h * 3];\n      px[i * 4 + 1] = pal[h * 3 + 1];\n      px[i * 4 + 2] = pal[h * 3 + 2];\n      px[i * 4 + 3] = 255;\n    }\n    bctx.putImageData(img, 0, 0);\n    ctx.imageSmoothingEnabled = false;\n    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);\n  }\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n  }\n\n  function frame() {\n    resize();\n    step();\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) {\n    resize();\n    for (let i = 0; i < 50; i++) step();\n  } else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}