doom fire
[github]the psx fire spread, tongues climbing off a white-hot bed
effects 60×18 24 fps 5.7 kB
.:. .;::. :;;;. ::;: :!!.:.: :!!!!!: .:. :.+!!!!::: ..:;: .: .;;+;+++:;: .:;;;: ..;:: .!;++*+:!;: :;;!+!;. .:;;:!; :+****+:. ..!!+;!: . ..;;;;:..: !#**##*!;::;;+++++:...;.: ;++++;:.;;+*######*!!++***+!!::!;;: .!+**++!;++*##%####**+*+*##**+;!;+++! ;!+****+;+##%%%%%%%*+*##%###**!+**#+!;. .:!*#%#%#*+##%%%%%%%%#####%%%##*++####++:: .:+*#%%%%%###%%%@@@@@%%%%%%@@@%%###%%%%#*! :;*%@@@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%+: .:. .;::. :;;;. ::;: :!!.:.: :!!!!!: .:. :.+!!!!::: ..:;: .: .;;+;+++:;: .:;;;: ..;:: .!;++*+:!;: :;;!+!;. .:;;:!; :+****+:. ..!!+;!: . ..;;;;:..: !#**##*!;::;;+++++:...;.: ;++++;:.;;+*######*!!++***+!!::!;;: .!+**++!;++*##%####**+*+*##**+;!;+++! ;!+****+;+##%%%%%%%*+*##%###**!+**#+!;. .:!*#%#%#*+##%%%%%%%%#####%%%##*++####++:: .:+*#%%%%%###%%%@@@@@%%%%%%@@@%%###%%%%#*! :;*%@@@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@%%+:
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="doom-fire"></ascii-art>
No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.
react
// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { doomFire } from "ascii.rest/pieces";
<Ascii piece={doomFire} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="doom-fire", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="doom-fire" />
Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.
typescript
// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { doomFire } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, doomFire); mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.
/*
* doom fire: the PSX fire spread. Each step every cell climbs one row, drifts
* a column either way and cools a little, on a grid of square cells two
* across and four down to a character. Columns cool at different rates, so
* the fire stands up in separate tongues over a bed that never dims.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "doom fire",
category: "effects",
note: "the psx fire spread, tongues climbing off a white-hot bed",
cols: 60,
rows: 18,
fps: 24,
} satisfies Meta;
const SX = 2;
const SY = 4;
const MAX = 36;
const RAMP = " .:;!+*#%@"; // coolest to hottest, upright strokes in the middle
const RATE = 24; // spread steps a second
const BED = 0.72; // the share of the width that burns
// Tongues: [place across the bed from -1 to 1, tallest as a share of the
// frame, half width in cells]. Between them the fire cools fast.
const TONGUES: [number, number, number][] = [
[-0.66, 1.05, 11],
[-0.18, 2.1, 15],
[0.3, 1.6, 13],
[0.73, 0.95, 9],
];
const DRIFT = 34; // steps for a tongue's height to wander through one knot
const FLOOR = 0.16;
function mulberry32(seed: number) {
let s = seed | 0;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const hash = (x: number, y: number) => {
let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
const ease = (t: number) => t * t * (3 - 2 * t);
function noise(x: number, y: number): number {
const i = Math.floor(x), j = Math.floor(y), u = ease(x - i), v = ease(y - j);
const a = hash(i, j), b = hash(i + 1, j), c = hash(i, j + 1), d = hash(i + 1, j + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
export default function doomFire(): Frame {
const { cols, rows } = meta;
const W = cols * SX;
const H = rows * SY + 1;
const rand = mulberry32(9);
const heat = new Float32Array(W * H);
const cool = new Float32Array(W);
const tall = new Float32Array(W);
// The bed: white hot right across, dropping off over the last few columns.
const bed = Float32Array.from({ length: W }, (_, x) => {
const d = Math.abs((2 * x + 1) / W - 1) / BED;
return d < 0.88 ? 1 : d < 1 ? ease((1 - d) / 0.12) : 0;
});
let n = 0;
const step = () => {
n++;
// Each column cools at the rate that lets the nearest tongue reach its
// height, and each tongue's height wanders on its own, so they stand
// apart and rise and fall out of step.
tall.fill(0.28);
TONGUES.forEach(([at, top, half], i) => {
const lift = top * (0.55 + 0.45 * (noise(i * 7.3, n / DRIFT) * 0.7 + noise(i * 3.1 + 40, n / (DRIFT * 0.4)) * 0.3));
const mid = (W / 2) * (1 + at * BED) + 6 * (noise(i * 5.7 + 90, n / (DRIFT * 2)) - 0.5);
for (let x = Math.max(0, Math.floor(mid - half)); x < Math.min(W, mid + half); x++) {
const d = (x + 0.5 - mid) / half;
tall[x] = Math.max(tall[x], lift * (1 - Math.abs(d) ** 1.3));
}
});
for (let x = 0; x < W; x++) cool[x] = MAX / (rows * SY * tall[x]);
const base = (H - 1) * W;
for (let x = 0; x < W; x++) heat[base + x] = MAX * bed[x];
// The spread runs in place, so the last writer wins; alternating the scan
// keeps that from turning into a wind. Pockets of extra cooling rise with
// the flame, so near the top the tips tear off and fade on their own.
for (let y = 1; y < H; y++) {
const row = y * W;
const up = 1 - y / H;
const flip = (y + n) & 1;
for (let i = 0; i < W; i++) {
const x = flip ? W - 1 - i : i;
const r = rand();
const to = x + (r < 0.3 ? -1 : r > 0.7 ? 1 : 0);
if (to < 0 || to >= W) continue;
const tear = 1 + 2.2 * up * up * Math.max(0, noise(x / 5, (y + n) / 6) - 0.45);
const v = heat[row + x] - rand() * 2 * cool[x] * tear;
heat[row - W + to] = v > 0 ? v : 0;
}
}
};
for (let i = 0; i < 200; i++) step();
const lv = new Float32Array(cols * rows);
let last = 0;
let acc = 0;
return (t) => {
acc += Math.min(Math.max(t - last, 0), 0.25);
last = t;
while (acc >= 1 / RATE - 1e-6) {
step();
acc -= 1 / RATE;
}
for (let cy = 0; cy < rows; cy++)
for (let cx = 0; cx < cols; cx++) {
let sum = 0;
for (let j = 0; j < SY; j++) {
const at = (cy * SY + j) * W + cx * SX;
for (let i = 0; i < SX; i++) sum += heat[at + i];
}
const v = (sum / (SX * SY * MAX) - FLOOR) / (1 - FLOOR);
lv[cy * cols + cx] = v <= 0 ? 0 : Math.min(RAMP.length - 1, 1 + ((v * (RAMP.length - 1)) | 0));
}
// A faint glyph only stands with flame beside or below it, and a lone
// one not at all, so the tips keep clean edges instead of a haze of specks.
const lines: string[] = [];
for (let cy = 0; cy < rows; cy++) {
let line = "";
for (let cx = 0; cx < cols; cx++) {
let k = lv[cy * cols + cx];
if (k > 0 && k < 5) {
let any = 0, under = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++) {
const y = cy + dy, x = cx + dx;
if ((dy || dx) && y >= 0 && y < rows && x >= 0 && x < cols) {
const o = lv[y * cols + x];
if (o) any++;
if (dy >= 0 && o > under) under = o;
}
}
if (any < 2 || (k < 3 && under < 3)) k = 0;
}
line += RAMP[k];
}
lines.push(line);
}
return lines.join("\n");
};
}