19. Palette fire · demoscene, early 1990s · Retro techniques
Palette fire
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();
})();