volcano night

[docs] [github]

a volcano erupting at night, lava rivers, an ash plume full of lightning

scenes 200×100 15 fps 31 colours, on a canvas 22.2 kB [report a bug]

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="volcano-night"></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 ascii.rest
import { Ascii } from "ascii.rest/react";
import { volcanoNight } from "ascii.rest/pieces";

<Ascii piece={volcanoNight} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="volcano-night", to fetch the piece only when it mounts.

astro

---
// npm install ascii.rest
import Ascii from "ascii.rest/astro";
---

<Ascii piece="volcano-night" />

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 ascii.rest
import { mount } from "ascii.rest";
import { volcanoNight } from "ascii.rest/pieces";

const el = document.querySelector<HTMLCanvasElement>("canvas")!;
const stop = mount(el, volcanoNight);

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.

terminal

npx ascii.rest volcano-night

Plays it in your terminal, in colour, until you press a key. Add --seconds 5 to stop it on its own; npx ascii.rest list shows every piece. In a CLI of your own, play("volcano-night", { seconds: 2 }) from ascii.rest/terminal shows it as a splash screen.

volcano-night.ts

[js] [github]
/*
 * volcano night: a stratovolcano erupting over a black lava field. Fountains
 * of lava burst from the crater and fall back as sparks, rivers of lava wind
 * down the slopes carrying dark crust plates, and an ash plume towers over it
 * all, lit red from below, with lightning flickering inside. The lake in front
 * holds the glow, broken up by ripples.
 *
 * Each frame is shaded in colour cell by cell on a square grid, then drawn as
 * a halftone: every cell is a dot whose size is its brightness, ordered-
 * dithered so gradients read as texture, in the palette colour nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "volcano night",
  category: "scenes",
  note: "a volcano erupting at night, lava rivers, an ash plume full of lightning",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#070405",
  palette: [
    "#5a1a10", "#7a2410", "#982e0e", "#b8380b", "#d4480b", "#ea5e10", "#f77a18", "#ff9628",
    "#ffb244", "#ffcc63", "#ffe28c", "#fff3c0", "#fffcec",
    "#4a3a37", "#63504b", "#7d6863", "#98827b", "#b39d94", "#cdb9af",
    "#8a4a3a", "#a85a40", "#c87048",
    "#3c4258", "#5d6280", "#8a90b4", "#b4b9dc", "#dfe2f7", "#b9a8ff", "#f4f0ff",
    "#1a1012", "#2c1a18",
  ],
} satisfies Meta;

const W = 200, H = 100;
const CX = 100; // the crater
const RIM = 40; // the summit's height, in rows
const BASE = 86; // where the cone's slopes would meet flat ground
const HIGH = BASE - RIM;
const SPAN = 32; // how broadly the slopes flare
const PLAIN = 76; // the far edge of the lava field
const SHORE = 81; // the lake's edge, roughly
const DOTS = " ·•●";
const COVER = [0, 0.3, 0.6, 1]; // how much each dot fills, against the largest
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => v / 16 - 0.47);

const SKY = 0, ROCK = 1, LAKE = 2;

function hash(x: number, y: number): number {
  let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

// Value noise, wrapping every `px` lattice cells in x and `py` in y when set.
function noise(x: number, y: number, px = 0, py = 0): number {
  const xi = Math.floor(x), yi = Math.floor(y);
  const fx = x - xi, fy = y - yi;
  const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy);
  let x0 = xi, x1 = xi + 1, y0 = yi, y1 = yi + 1;
  if (px) (x0 = ((xi % px) + px) % px), (x1 = (x0 + 1) % px);
  if (py) (y0 = ((yi % py) + py) % py), (y1 = (y0 + 1) % py);
  const a = hash(x0, y0), b = hash(x1, y0), c = hash(x0, y1), d = hash(x1, y1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number, py = 0): number {
  let s = 0, n = 0, amp = 0.5, f = 1;
  for (let i = 0; i < octaves; i++) {
    s += amp * noise(x * f, y * f, 0, py * f);
    n += amp;
    amp *= 0.5;
    f *= 2;
  }
  return s / n;
}

const clamp = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const smooth = (a: number, b: number, v: number) => {
  const k = clamp((v - a) / (b - a));
  return k * k * (3 - 2 * k);
};
const mix = (a: number, b: number, k: number) => a + (b - a) * k;
const hex = (s: string) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255);

// The cone's profile: flat at the summit, concave slopes flaring to the plain.
const coneY = (x: number) => {
  const d = Math.abs(x - CX);
  if (d < 6) return RIM + 0.9 * (1 - (d / 6) ** 2) - 0.6;
  return BASE - HIGH / (1 + ((d - 6) / SPAN) ** 1.6) + 1.4 * (fbm(x * 0.13, 1.3, 3) - 0.5) * smooth(6, 20, d);
};
// How far from the crater the slope reaches at a given row (the inverse of coneY).
const reach = (y: number) => {
  const f = (BASE - y) / HIGH;
  if (f >= 1) return 6;
  if (f <= 0.05) return 95;
  return Math.min(95, 6 + SPAN * Math.pow(1 / f - 1, 1 / 1.6));
};
// The plume's spine leans right as it rises; it widens and mushrooms at the top.
const plumeX = (y: number) => CX + 0.01 * (RIM - y) ** 2;
const plumeW = (y: number) => 8 + 27 * Math.pow(Math.max(0, RIM - y) / RIM, 0.55) + 16 * smooth(14, -3, y);

export default function volcanoNight(): Frame {
  const P = meta.palette.map(hex);
  const N = W * H;
  const out: string[] = new Array(N);

  // Nearest palette colour, cached on a 32-step cube.
  const lut = new Uint8Array(32768).fill(255);
  const nearest = (r: number, g: number, b: number): number => {
    const k = (Math.min(31, (r * 31.99) | 0) << 10) | (Math.min(31, (g * 31.99) | 0) << 5) | Math.min(31, (b * 31.99) | 0);
    if (lut[k] !== 255) return lut[k];
    let best = 0, bd = 1e9;
    for (let i = 0; i < P.length; i++) {
      const dr = P[i][0] - r, dg = P[i][1] - g, db = P[i][2] - b;
      const d = 0.3 * dr * dr + 0.5 * dg * dg + 0.2 * db * db;
      if (d < bd) (bd = d), (best = i);
    }
    return (lut[k] = best);
  };

  // --- the land and the lake, built once ----------------------------------
  const mat = new Uint8Array(N);
  const rock = new Float32Array(N); // gullies and rubble texture, 0..1
  const crack = new Float32Array(N); // glowing seams in the lava field
  const phase = new Float32Array(N);
  const shoreAt = new Float32Array(W);
  const topAt = new Float32Array(W);
  for (let x = 0; x < W; x++) {
    shoreAt[x] = SHORE + 2 * (fbm(x * 0.045, 9, 3) - 0.5) * 2 + 0.7 * noise(x * 0.3, 2);
    topAt[x] = Math.min(coneY(x + 0.5), PLAIN + 1.6 * (fbm(x * 0.06, 5, 3) - 0.5));
  }
  // Worley seams for the cooled field, squashed by distance into thin lines.
  const cellPt = (i: number, j: number) => [i + hash(i, j * 7 + 1), j + hash(i * 3 + 5, j)];
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const y = r + 0.5;
      if (y >= shoreAt[x]) mat[k] = LAKE;
      else if (y >= topAt[x]) {
        mat[k] = ROCK;
        // gullies run down the slope, rubble below
        rock[k] = 0.55 * fbm(x * 0.3 + y * 0.04 * (x < CX ? -1 : 1) * 3, y * 0.07, 3) + 0.45 * fbm(x * 0.5, y * 0.5, 2);
        if (y > PLAIN - 3) {
          const sx = x / 7, sy = (y - PLAIN) / 2.6 + 40;
          const ix = Math.floor(sx), iy = Math.floor(sy);
          let f1 = 9, f2 = 9;
          for (let j = iy - 1; j <= iy + 1; j++)
            for (let i = ix - 1; i <= ix + 1; i++) {
              const [px, py] = cellPt(i, j);
              const d = Math.hypot(px - sx, py - sy);
              if (d < f1) (f2 = f1), (f1 = d);
              else if (d < f2) f2 = d;
            }
          const near = smooth(PLAIN - 3, PLAIN + 3, y);
          crack[k] = smooth(0.16, 0.02, f2 - f1) * near * (0.4 + 0.6 * fbm(x * 0.08, y * 0.2, 2));
          phase[k] = fbm(x * 0.05, y * 0.1, 2) * 12;
        }
      }
    }
  }

  // --- the lava rivers: winding lines down the face of the cone -------------
  // Each row of the table: start row, end row, start and end fraction of the
  // slope's reach (negative is left), width, wiggle, phase.
  const RIVERS = [
    [41.5, 75, -0.55, -0.5, 1, 0.09, 0.3],
    [41.5, 77, 0.45, 0.32, 1, 0.08, 2.1],
    [42, 80, -0.08, -0.22, 1.3, 0.06, 4.0],
    [43, 70, 0.15, 0.7, 0.8, 0.07, 5.2],
    [45, 73, -0.3, -0.85, 0.75, 0.05, 1.4],
  ];
  const rivId = new Uint8Array(N).fill(255);
  const rivS = new Float32Array(N); // distance along the river
  const rivA = new Float32Array(N); // across it, -1 at one bank to 1 at the other
  const rivK = new Float32Array(N); // how far down, 0 at the crater to 1 at the toe
  const rivGlow = new Float32Array(N); // light the rivers throw on the rock around them
  RIVERS.forEach(([y0, y1, f0, f1, wm, amp, ph], id) => {
    const pts: number[][] = [];
    let s = 0, lx = 0, ly = 0;
    for (let y = y0; y <= y1; y += 0.25) {
      const k = (y - y0) / (y1 - y0);
      const f = f0 + (f1 - f0) * smooth(0, 1, k) + amp * Math.sin(k * Math.PI * 4 + ph) + 0.05 * (noise(k * 7, id * 7) - 0.5);
      const x = CX + f * reach(y);
      if (pts.length) s += Math.hypot(x - lx, y - ly);
      pts.push([x, y, s, k]);
      (lx = x), (ly = y);
    }
    for (let r = Math.floor(y0) - 3; r < Math.min(H, y1 + 5); r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        if (mat[k] !== ROCK) continue;
        const y = r + 0.5;
        let bd = 1e9, bi = 0;
        for (let i = 0; i < pts.length; i++) {
          const p = pts[i];
          if (Math.abs(p[1] - y) > 7) continue;
          const d = (p[0] - x - 0.5) ** 2 + (p[1] - y) ** 2;
          if (d < bd) (bd = d), (bi = i);
        }
        if (bd > 1e8) continue;
        const [px, , ps, pk] = pts[bi];
        const d = Math.sqrt(bd);
        // narrow at the vent, broader downhill, fanning out where it pools
        const hw = (0.55 + 1.3 * pk + 1.4 * smooth(0.85, 1, pk)) * wm;
        const heat = 1 - 0.55 * pk;
        rivGlow[k] = Math.max(rivGlow[k], Math.exp(-Math.max(0, d - hw) / 2.2) * heat);
        if (d < hw && (rivId[k] === 255 || d / hw < Math.abs(rivA[k]))) {
          rivId[k] = id;
          rivS[k] = ps;
          rivA[k] = (x + 0.5 < px ? -1 : 1) * (d / hw);
          rivK[k] = pk;
        }
      }
    }
  });

  // --- the plume: two tall noise textures that wrap, scrolled upward ---------
  const PH = 160;
  const texA = new Float32Array(W * PH);
  const texB = new Float32Array(W * PH);
  for (let r = 0; r < PH; r++)
    for (let x = 0; x < W; x++) {
      const q = fbm(x * 0.03, r * 0.03, 3, PH * 0.03);
      texA[r * W + x] = fbm(x * 0.07 + q * 1.5, r * 0.07, 4, PH * 0.07);
      texB[r * W + x] = fbm(x * 0.12 + 40, r * 0.12, 3, PH * 0.12);
    }
  const sample = (tex: Float32Array, x: number, y: number) => {
    const yi = Math.floor(y), fy = y - yi;
    const r0 = ((yi % PH) + PH) % PH, r1 = (r0 + 1) % PH;
    const xi = Math.max(0, Math.min(W - 1, x | 0));
    return tex[r0 * W + xi] + (tex[r1 * W + xi] - tex[r0 * W + xi]) * fy;
  };

  // a faint unevenness in the night sky
  const haze = new Float32Array(N);
  for (let k = 0; k < N; k++) haze[k] = fbm((k % W) * 0.04, Math.floor(k / W) * 0.07, 3);

  // --- the lava fountains: glowing arcs, and loose sparks, each its own clock --
  const ARCS = 34;
  const SPARKS = 110;
  const G = 50;
  const sp = Array.from({ length: ARCS + SPARKS }, (_, i) => ({
    vx: (hash(i, 1) - 0.5) * 2,
    vy: hash(i, 2),
    period: 2.6 + 1.4 * hash(i, 3),
    off: hash(i, 4) * 10,
    x0: (hash(i, 5) - 0.5) * 4,
  }));

  const R = new Float32Array(N), Gc = new Float32Array(N), B = new Float32Array(N);
  const floorAt = new Float32Array(N);
  const fadeAt = new Float32Array(N).fill(1);
  for (let k = 0; k < N; k++) {
    const r = Math.floor(k / W);
    // the dark should stay dark: no floor, so unlit cells are bare ground
    floorAt[k] = 0;
    if (mat[k] === LAKE) fadeAt[k] = smooth(H + 6, H - 6, r + 0.5);
  }
  const addR = new Float32Array(N), addG = new Float32Array(N), addB = new Float32Array(N);
  const splat = (x: number, y: number, a: number, cr: number, cg: number, cb: number) => {
    const xi = Math.round(x), yi = Math.round(y);
    if (xi < 0 || xi >= W || yi < 0 || yi >= H) return;
    const k = yi * W + xi;
    if (mat[k] === LAKE) return;
    // the brightest pass wins, so crossing arcs never melt into a blob
    if (cr * a > addR[k]) (addR[k] = cr * a), (addG[k] = cg * a), (addB[k] = cb * a);
  };

  return (t, { color } = {}) => {
    addR.fill(0), addG.fill(0), addB.fill(0);

    // Fountains, beating harder every few seconds.
    const surge = 0.65 + 0.35 * Math.sin(t * 1.3) * Math.sin(t * 0.47 + 1);
    for (let i = 0; i < ARCS + SPARKS; i++) {
      const s = sp[i];
      const tt = t + s.off;
      const n = Math.floor(tt / s.period);
      const tau = tt - n * s.period;
      // Each launch a little different in height and lean. An arc is a clot of
      // lava thrown wide that leaves a long glowing trail; a spark is a point.
      const arc = i < ARCS;
      const kick = (0.82 + 0.3 * hash(i, n + 99)) * (0.8 + 0.25 * surge);
      const vy = (arc ? 30 + 18 * s.vy : 18 + 24 * s.vy) * kick;
      const vx = (arc ? (0.35 + 0.65 * Math.abs(s.vx)) * Math.sign(s.vx) * 20 : s.vx * 11) * kick + 1;
      const life = (2 * vy) / G + 0.7;
      if (tau > life) continue;
      const tail = arc ? 0.34 : 0.04;
      const steps = arc ? 26 : 2;
      for (let j = 0; j <= steps; j++) {
        const u = tau - (j / steps) * tail;
        if (u < 0) break;
        const x = CX + s.x0 + vx * u;
        const y = RIM - 0.8 - vy * u + 0.5 * G * u * u;
        if (y > coneY(x) + 1.5) continue; // landed
        const age = u / life;
        // white-hot at the head, cooling to deep orange down the trail
        const f = j / steps;
        const a = (1 - 0.75 * f) * (1 - 0.5 * age) * (arc ? 1.1 : 0.75);
        splat(x, y, a, 1, mix(0.95, 0.35, Math.min(1, age + f * 0.6)), mix(0.65, 0.04, Math.min(1, age * 1.4 + f)));
      }
    }

    // Lightning: a bolt in some quarter-second slots, struck twice and fading.
    const slot = Math.floor(t / 0.3), sf = t / 0.3 - slot;
    let bolt = 0, bx = 0, by = 0;
    const boltCells: number[] = [];
    if (hash(slot, 77) > 0.8) {
      bolt = sf < 0.15 ? 1 : sf < 0.3 ? 0.25 : sf < 0.5 ? 0.85 : Math.max(0, 0.85 - (sf - 0.5) * 2);
      let y = 4 + hash(slot, 3) * 8;
      let x = plumeX(y) + (hash(slot, 4) - 0.5) * plumeW(y) * 1.1;
      const len = 10 + hash(slot, 5) * 12;
      (bx = x), (by = y + len / 2);
      const branchAt = 3 + Math.floor(hash(slot, 6) * 5);
      let brx = 0, bry = 0;
      for (let i = 0; i < len; i++) {
        const nx = x + (hash(slot * 31 + i, 8) - 0.5) * 3.2;
        const ny = y + 1;
        for (let q = 0; q <= 2; q++) boltCells.push(Math.round(mix(x, nx, q / 2)) + Math.round(mix(y, ny, q / 2)) * W);
        (x = nx), (y = ny);
        if (i === branchAt) (brx = x), (bry = y);
      }
      // a short fork
      for (let i = 0; i < 6 && brx; i++) {
        brx += hash(slot, 40 + i) < 0.5 ? 1.3 : -1.3;
        bry += 0.8;
        boltCells.push(Math.round(brx) + Math.round(bry) * W);
      }
    }

    const rise = t * 3.2;
    for (let r = 0; r < H; r++) {
      const y = r + 0.5;
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const m = mat[k];
        let cr = 0, cg = 0, cb = 0;
        const dcx = x + 0.5 - CX, dcy = (y - RIM + 1) * 1.4;
        const dc = Math.sqrt(dcx * dcx + dcy * dcy);

        if (m === SKY) {
          // The eruption's glow fills the sky behind the cone: hottest low
          // around the summit, deep red higher up, fading into night, so the
          // cone stands out black against it.
          const v = y / PLAIN;
          const veil = 0.85 + 0.3 * haze[k];
          const gx = dcx / 66, gy = (y - RIM - 12) / 38;
          const glow = 0.5 * Math.exp(-(gx * gx + gy * gy) * 2.2) + 0.16 * Math.exp(-dc / 14) + 0.2 * Math.pow(v, 6);
          const warm = smooth(0, RIM + 20, y); // orange low down, deep red higher
          cr = (0.005 + glow) * veil;
          cg = (0.005 + glow * (0.1 + 0.22 * warm)) * veil;
          cb = (0.012 + glow * 0.05) * veil;

          // the plume
          let c = 0;
          if (y < RIM + 1) {
            // swellings that climb the column, so its flanks billow as it rises
            const px = plumeX(y), pw = plumeW(y) * (1 + 0.13 * Math.sin((y + rise) * 0.42) + 0.07 * Math.sin((y + rise) * 0.9 + 2));
            const e = 1 - Math.abs(x + 0.5 - px) / pw;
            if (e > -0.5) {
              const a = sample(texA, x, y + rise);
              const b = sample(texB, x - t * 1.5, y + rise * 1.6);
              const d = 0.65 * a + 0.35 * b;
              c = smooth(0.0, 0.2, e + (a - 0.5) * 2.5 + (b - 0.5) * 0.7) * smooth(-1, 3, y);
              if (c > 0.01) {
                // billows lit from beneath: denser than the air just below
                const below = 0.65 * sample(texA, x, y + 3.5 + rise) + 0.35 * sample(texB, x - t * 1.5, y + 3.5 + rise * 1.6);
                const lb = clamp(0.45 + (d - below) * 13);
                const up = RIM - y;
                // charcoal ash, and the fire's light caught on the undersides
                const heat = Math.exp(-up / 10) * 0.6 + 0.16 * Math.exp(-up / 30);
                // just above the vent the column is darker, so the arcs show against it
                const young = 0.3 + 0.7 * smooth(RIM - 4, RIM - 20, y);
                const l2 = lb * lb;
                // heavy lumps: shadowed folds near black, lit bulges pale grey
                const body = (0.15 + 0.5 * l2) * (0.7 + 0.3 * clamp(e * 2)) * young;
                const fire = heat * (0.2 + 1.1 * l2) * (0.45 + 0.55 * smooth(RIM - 2, RIM - 12, y));
                let pr = body + fire * 0.95;
                let pg = body * 0.86 + fire * (0.18 + 0.25 * Math.exp(-up / 6));
                let pb = body * 0.8 + fire * 0.05;
                if (bolt) {
                  const dl = Math.hypot(x + 0.5 - bx, (y - by) * 0.8);
                  const L = bolt * (Math.exp(-dl / 5) * 0.6 + Math.exp(-dl / 16) * 0.22) * (0.3 + 0.9 * lb);
                  (pr += 0.7 * L), (pg += 0.66 * L), (pb += 0.95 * L);
                }
                const al = Math.min(1, c * 1.15);
                cr = mix(cr, pr, al);
                cg = mix(cg, pg, al);
                cb = mix(cb, pb, al);
              }
            }
          }
          if (c < 0.05 && hash(x, r * 3 + 11) > 0.982) {
            // stars, washed out near the glow
            const tw = 0.6 + 0.4 * Math.sin(t * (1.5 + hash(x, r) * 3) + hash(r, x) * 6.28);
            const s = (0.35 + 0.5 * tw) * smooth(0.25, 0.04, glow) * smooth(PLAIN - 4, 20, y);
            (cr = Math.max(cr, s * 0.85)), (cg = Math.max(cg, s * 0.87)), (cb = Math.max(cb, s));
          }
        } else if (m === ROCK) {
          // black basalt, rimmed red by the light falling on it
          const s = rock[k];
          cr = 0.012 + 0.04 * s;
          cg = 0.01 + 0.024 * s;
          cb = 0.012 + 0.024 * s;
          const rg = rivGlow[k];
          const up = Math.exp(-dc / 6) * 0.9 + Math.exp(-dc / 22) * 0.06;
          // a thin rim of light along the crest, and the lava's glow on the slopes
          const brow = smooth(topAt[x] + 1.4, topAt[x], y) * 0.55 * Math.exp(-dc / 90);
          const lit = (rg * 0.22 + rg * rg * 0.3 + up + brow) * (0.4 + 0.9 * s);
          cr += lit * 0.9;
          cg += lit * 0.22;
          cb += lit * 0.05;
          const id = rivId[k];
          if (id !== 255) {
            const a = rivA[k], kk = rivK[k];
            const sv = rivS[k];
            // a hot core, cooler at the banks and downhill
            const heat = clamp((1 - 0.75 * a * a) * (1 - 0.5 * kk) + 0.2 * (noise(sv * 0.9 - t * 4.5, a * 2 + id * 9) - 0.5));
            let lr = 0.75 + 0.3 * heat, lg = 0.12 + 0.75 * heat * heat, lb = 0.02 + 0.35 * heat ** 4;
            // crust plates riding the flow, more of them as it cools
            const pn = noise(sv * 0.32 - t * 0.95, a * 1.3 + id * 13) * 0.7 + noise(sv * 0.8 - t * 2.4, a * 2.6 + id * 5) * 0.3;
            const th = 0.6 - 0.22 * kk + 0.12 * a * a;
            const plate = smooth(th, th + 0.05, pn);
            const seam = smooth(th - 0.05, th, pn) * (1 - plate);
            const dark = 0.13 + 0.12 * noise(sv * 1.5 - t * 0.95 * 4.7, a * 4);
            lr = mix(lr, dark, plate) + seam * 0.3;
            lg = mix(lg, dark * 0.3, plate) + seam * 0.15;
            lb = mix(lb, dark * 0.2, plate);
            const edge = smooth(1, 0.75, Math.abs(a));
            cr = mix(cr, lr, edge);
            cg = mix(cg, lg, edge);
            cb = mix(cb, lb, edge);
          } else if (crack[k] > 0.01) {
            const pulse = 0.55 + 0.45 * Math.sin(t * 0.9 + phase[k]);
            const g = crack[k] * pulse * (0.5 + rg);
            cr += 0.75 * g;
            cg += 0.18 * g;
            cb += 0.03 * g;
          }
        } else {
          // the lake: the scene above, stretched down and torn into ripples
          const sh = shoreAt[x];
          const depth = y - sh;
          const w = noise(x * 0.05 + t * 0.15, y * 0.9 - t * 0.9) * 0.6 + noise(x * 0.15 - t * 0.3, y * 1.6 - t * 1.6) * 0.4;
          const sx = Math.max(1, Math.min(W - 2, Math.round(x + (w - 0.5) * 4)));
          const sy = Math.max(0, Math.min(Math.floor(shoreAt[sx]) - 1, Math.round(sh - depth * 3 - 1 - (w - 0.5) * 4)));
          const sk = sy * W + sx;
          const band = 0.3 + 1.1 * smooth(0.35, 0.7, w);
          const dim = 1.05 * (1 - depth / 45);
          // a little smeared sideways, as ripples do
          const rr = 0.5 * R[sk] + 0.25 * (R[sk - 1] + R[sk + 1]);
          const rg = 0.5 * Gc[sk] + 0.25 * (Gc[sk - 1] + Gc[sk + 1]);
          const rb = 0.5 * B[sk] + 0.25 * (B[sk - 1] + B[sk + 1]);
          cr = 0.01 + rr * band * dim;
          cg = 0.01 + rg * band * dim * 0.9;
          cb = 0.02 + rb * band * dim * 0.9;
          // a dark line where the water meets the shore
          const lip = smooth(1.5, 0, depth);
          (cr *= 1 - 0.7 * lip), (cg *= 1 - 0.7 * lip), (cb *= 1 - 0.7 * lip);
        }

        if (m !== LAKE) {
          // the vent's own light, and the fountains
          const vg = Math.exp(-dc / 1.4) * 1.3 + Math.exp(-dc / 5) * 0.2;
          cr += vg + addR[k];
          cg += vg * 0.55 + addG[k];
          cb += vg * 0.18 + addB[k];
        }
        (R[k] = cr), (Gc[k] = cg), (B[k] = cb);
      }
    }
    for (const c of boltCells) {
      if (c < 0 || c >= N || mat[c] !== SKY) continue;
      const v = Math.min(1, bolt * 1.4);
      (R[c] = mix(R[c], 0.92, v)), (Gc[c] = mix(Gc[c], 0.9, v)), (B[c] = mix(B[c], 1, v));
    }

    for (let r = 0; r < H; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        // a black point, so the faintest light leaves the ground bare
        const cr = Math.max(0, R[k] - 0.025), cg = Math.max(0, Gc[k] - 0.025), cbl = Math.max(0, B[k] - 0.025);
        const floor = floorAt[k];
        // Dot size from brightness, dithered. Then the colour makes up what the
        // dot size could not: a small dot is drawn brighter and a large one
        // dimmer, so a gradient stays smooth across the dither's steps.
        const peak = Math.max(cr, cg, cbl, 1e-4);
        const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.85) * 0.95) * fadeAt[k];
        const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(r & 3) * 4 + (x & 3)])));
        out[k] = DOTS[step];
        if (color) {
          const want = step ? Math.min(1, (level + 0.06) / COVER[step]) : 0;
          const s = (0.3 + 0.7 * want) / peak;
          color[k] = nearest(clamp(cr * s), clamp(cg * s), clamp(cbl * s));
        }
      }
    }
    const lines: string[] = [];
    for (let r = 0; r < H; r++) lines.push(out.slice(r * W, (r + 1) * W).join(""));
    return lines.join("\n");
  };
}