lantern lake

[github]

sky lanterns rising over a still mountain lake, one let go from a boat

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

use it

html

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

<ascii-art piece="lantern-lake"></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 { lanternLake } from "ascii.rest/pieces";

<Ascii piece={lanternLake} />

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

astro

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

<Ascii piece="lantern-lake" />

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

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

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 lantern-lake

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("lantern-lake", { seconds: 2 }) from ascii.rest/terminal shows it as a splash screen.

lantern-lake.ts

[js] [github]
/*
 * lantern lake: sky lanterns rising over a still mountain lake at night. A
 * festival on the far shore lets them go by the hundred: they climb from
 * behind the trees, lean on a slow breeze and burn out high over the water.
 * Nearer ones sail up past us, and a figure standing in a boat, a lamp on its
 * bow, holds one up, lets it go, and lights the next.
 *
 * Shaded in colour per cell on a square grid, then drawn as a halftone: dot
 * size is brightness, ordered-dithered, in the palette colour nearest its hue.
 * A lantern's size, speed and drift all come from its distance, so the far
 * ones crawl up as sparks and the near ones sail past large. Each one starts
 * a new flight, somewhere new, only where it can't be seen: behind the trees
 * or below the frame.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "lantern lake",
  category: "scenes",
  note: "sky lanterns rising over a still mountain lake, one let go from a boat",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#04060d",
  palette: [
    "#141b30", "#1c2a52", "#22346a", "#2a4080", "#324c96", "#3c58a8",
    "#33306e", "#433c88", "#54449a",
    "#62407e", "#7a4a88", "#934e86",
    "#a4546e", "#bc5e66", "#d06a62",
    "#dc7650", "#e88444", "#f09238",
    "#d4581a", "#ea6c1e", "#f78226", "#ff9530", "#ffa83c", "#ffbb4e", "#ffcc66", "#ffdc86", "#ffe8a8",
    "#fff3cf", "#fffbec",
    "#b8341a", "#d84420",
    "#2f3748", "#4f586c", "#5e5a7e", "#7a6a8c",
    "#5a3220", "#7c4628",
    // the far range's slate, then the stars: written straight in, never
    // matched, so the sky's violets can't turn slate and a greyed mix of warm
    // and blue can't come out as a white star
    "#4a5578", "#5d6a92", "#7381ab",
    "#c4cdec", "#e4e9fb", "#ffffff",
  ],
} satisfies Meta;

const W = 200, H = 100;
const WL = 64; // the waterline
const SRC = 100; // the middle of the festival on the far shore
// the boat's middle and its waterline, far enough out that the lantern held
// over its head sits against the dark trees' reflection
const BX = 108.5, BW = 90;
const SC = 1.4; // the boat's scale
const FX = BX - 3 * SC; // where the figure stands in it
// The figure lets a lantern go this far into every cycle and lights the
// next one at LIT, so it holds one up most of the time.
const CYCLE = 20, LET_GO = 3, LIT = 6;
const HELD = 5 * SC; // the boat's lanterns, in cells tall
const DOTS = " ·•●";
const COVER = [0, 0.3, 0.6, 1];
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 AIR = 0, FAR = 1, BACK = 2, PEAK = 3, TREE = 4;

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;
}

function noise(x: number, y: number): 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);
  const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number): 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);
    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);

// distance from a point to a segment
function seg(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
  const dx = bx - ax, dy = by - ay;
  const k = clamp(((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy));
  const ex = ax + dx * k - px, ey = ay + dy * k - py;
  return Math.sqrt(ex * ex + ey * ey);
}

// Ranges as tent peaks [x, height, slope], roughened: the high peaks either
// side, the nearer shoulders in front of them, and a distant range between.
const PEAKS: [number, number, number][] = [[22, 42, 1.2], [-4, 33, 0.9], [172, 33, 1.05], [215, 22, 0.6]];
const SHOULDERS: [number, number, number][] = [[50, 25, 1], [64, 12, 0.75], [150, 17, 0.95], [196, 21, 0.75]];
const DISTANT: [number, number, number][] = [[98, 17, 0.42], [122, 14, 0.5], [82, 12, 0.45], [140, 11, 0.5]];

function range(x: number, peaks: [number, number, number][], seed: number, rough: number): [number, number] {
  let m = -99, px = 0;
  for (const [cx, h, s] of peaks) {
    const v = h - Math.abs(x - cx) * s;
    if (v > m) (m = v), (px = cx);
  }
  const j = rough * (fbm(x * 0.09, seed, 3) - 0.5) + rough * 0.45 * (noise(x * 0.45, seed + 5) - 0.5);
  return [m + j * Math.min(1, Math.max(0, m) / 6), px];
}

// A sky lantern's outline, in units of its height from its top: wider at the
// shoulders than at the open bottom, the shoulders rounded.
function inside(u: number, v: number): boolean {
  if (v < 0 || v > 1) return false;
  let hw = 0.34 - 0.08 * v;
  if (v < 0.14) hw *= Math.sqrt(1 - 0.6 * ((0.14 - v) / 0.14) ** 2);
  return u <= hw && u >= -hw;
}

interface Flight {
  i: number;
  s: number; // size, in cells tall
  v: number; // rise, cells a second
  life: number; // seconds from one start to the next
  off: number;
  wd: number; // lean on the breeze, cells across for each row climbed
  sw: number;
  sf: number;
  ph: number;
  f1: number;
  f2: number;
  out: boolean; // sails off the top rather than burning out
  wide: boolean; // starts anywhere along the shore, not just at the festival
}

export default function lanternLake(): Frame {
  const P = meta.palette.map(hex);
  const STAR = P.length - 3; // the first of the stars' colours
  const SL = STAR - 3; // and of the far range's slate
  const N = W * H, S = WL * W;
  const out: string[] = new Array(N);

  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 < SL; 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 sky, built once ----------------------------------------
  const mat = new Uint8Array(S);
  const sR = new Float32Array(S), sG = new Float32Array(S), sB = new Float32Array(S);
  const take = new Float32Array(S); // how much of the festival's warm light each cell takes
  const backTop = new Float32Array(W), backX = new Float32Array(W);
  const frontTop = new Float32Array(W), frontX = new Float32Array(W), farTop = new Float32Array(W);
  for (let x = 0; x < W; x++) {
    const [hb, pb] = range(x + 0.5, PEAKS, 3.1, 4);
    const [hf, pf] = range(x + 0.5, SHOULDERS, 7.7, 3);
    const [hd] = range(x + 0.5, DISTANT, 11.3, 2.2);
    backTop[x] = WL - Math.max(hb, 0), backX[x] = pb;
    frontTop[x] = WL - Math.max(hf, 0), frontX[x] = pf;
    farTop[x] = WL - Math.max(hd, 0);
  }
  // The festival's light: a warm dome in the air over the middle of the far
  // shore, leaning the way the breeze carries the lanterns.
  const dome = (x: number, y: number) => {
    const up = Math.max(0, WL - 6 - y); // from the tops of the trees
    const dx = (x - SRC - 0.3 * up) / (44 + up);
    return Math.exp(-dx * dx) * (0.6 * Math.exp(-up / 14) + 0.4 * Math.exp(-up / 40));
  };
  // The sky's hue by how warm it is: night blue, through violet, mauve and
  // rose, to amber right over the trees. Mixing the glow into the blue would
  // grey it, and a grey dot is drawn pale, so the hue walks round instead.
  const HUES = [[0.22, 0.36, 1], [0.46, 0.36, 1], [0.76, 0.42, 0.92], [1, 0.48, 0.58], [1, 0.54, 0.34], [1, 0.58, 0.2]];
  const hue = (w: number, j: number) => {
    const f = clamp(w) * 5, i = Math.min(4, Math.floor(f));
    return mix(HUES[i][j], HUES[i + 1][j], f - i);
  };
  // A mountainside: the faces turned toward the festival take a little of its
  // light, the ridge between them wandering as it comes down from the peak,
  // and the ridgeline catches the sky behind it, blue away from the festival
  // and warm near it. `haze` lifts a range, `relief` is how far its faces
  // stand out from its gullies, `edge` is how bright its ridgeline is and
  // `warm` is how much the festival's light tints it. Returns that light.
  const slope = (k: number, x: number, y: number, top: number, px: number, haze: number, relief: number, edge: number, warm: number) => {
    const xc = x + 0.5, depth = y - top;
    const inward = px < SRC ? 1 : -1;
    const ridge = px + (depth + 1) * 0.6 * (noise(y * 0.1, px) - 0.5);
    const face = smooth(-4, 4, (xc - ridge) * inward);
    const u = x + depth * 0.45 * Math.sign(xc - px || 1);
    const rib = (q: number) => fbm(q * 0.13, px * 0.37, 3);
    const grad = (rib(u + 1) - rib(u - 1)) * 7 * inward;
    const lit = clamp(0.3 + 0.5 * face + grad * 0.5);
    const tex = 0.8 + 0.4 * fbm(x * 0.35, y * 0.35, 2);
    const w = lit * Math.exp(-(((xc - SRC) / 70) ** 2)) * (0.4 + 0.6 * smooth(top + 2, WL, y)) * 0.9;
    const b = (haze + relief * lit * lit) * tex + 0.16 * w;
    const rim = smooth(1.8, 0.3, depth) * edge, sky = dome(xc, top - 1);
    const c = (j: number) => b * hue(w * warm, j) + rim * hue(sky, j);
    sR[k] = c(0), sG[k] = c(1), sB[k] = c(2);
    return w;
  };
  // The far range's cells, drawn in slate, a greyed blue no other part of the
  // picture uses: the air in front of it greys it, so it stands apart from the
  // saturated sky above and the dark shoulders below. Written straight in, as
  // the stars are, since matching would pull the sky's violets to it as well.
  const far = new Uint8Array(S);
  for (let r = 0; r < WL; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const y = r + 0.5, xc = x + 0.5;
      if (y >= frontTop[x]) {
        mat[k] = PEAK;
        slope(k, x, y, frontTop[x], frontX[x], 0.05, 0.06, 0.3, 1);
      } else if (y >= backTop[x]) {
        mat[k] = BACK;
        // slate, but not where the festival lights it or on the ridgeline,
        // which keeps the sky's own blue
        if (slope(k, x, y, backTop[x], backX[x], 0.24, 0.3, 0.35, 0.5) < 0.3 && y - backTop[x] > 1.2) far[k] = 1;
      } else if (y >= farTop[x]) {
        mat[k] = FAR;
        // a distant range behind the festival, veiled in the warm haze its
        // light makes in the air in front, dark up on the ridge
        const tex = fbm(x * 0.2, y * 0.3, 3);
        const w = dome(xc, y);
        const b = 0.06 + 0.04 * tex + 0.3 * w + 0.7 * w ** 3;
        sR[k] = b * hue(w, 0), sG[k] = b * hue(w, 1), sB[k] = b * hue(w, 2);
      } else {
        // night blue overhead, lighter on the horizon, warmed over the festival
        const v = Math.pow(y / WL, 1.5);
        const hz = 0.85 + 0.3 * fbm(x * 0.04, r * 0.07, 3);
        const w = dome(xc, y);
        const b = (0.18 + 0.32 * v) * hz + 0.5 * w;
        sR[k] = b * hue(w, 0), sG[k] = b * hue(w, 1), sB[k] = b * hue(w, 2);
        take[k] = w;
      }
    }
  }

  // The far shore's trees: a low band of scrub along the water with spruce
  // standing out of it, shorter in the clearing where the festival is.
  const scrub = (x: number) => WL - 2.4 - 1.4 * fbm(x * 0.15, 5.5, 3);
  for (let r = 0; r < WL; r++) for (let x = 0; x < W; x++) if (r + 0.5 >= scrub(x + 0.5)) mat[r * W + x] = TREE;
  const spruce = (tx: number, th: number, tw: number, tb: number) => {
    for (let r = Math.max(0, Math.floor(tb - th)); r < WL; r++) {
      const y = r + 0.5, dy = y - (tb - th);
      if (dy < 0 || y >= tb + 0.5) continue;
      const tier = (dy + th * 0.3) / 1.6;
      const w = (dy / th) * tw * (0.7 + 0.45 * (tier - Math.floor(tier))) + 0.3;
      for (let x = Math.max(0, Math.floor(tx - tw - 1)); x <= Math.min(W - 1, tx + tw + 1); x++) {
        if (Math.abs(x + 0.5 - tx) <= w) mat[r * W + x] = TREE;
      }
    }
  };
  for (let x = -2; x < W + 2; ) {
    const h = hash(Math.floor(x * 7), 21);
    const clearing = smooth(22, 0, Math.abs(x - SRC));
    const th = (2.6 + 4.4 * hash(Math.floor(x * 3), 5) ** 1.2) * (1 - 0.3 * clearing);
    spruce(x + hash(Math.floor(x), 2), th, 1 + 0.8 * hash(Math.floor(x), 4), scrub(x) + 1);
    x += 1.2 + 1.8 * h;
  }
  for (let k = 0; k < S; k++) {
    if (mat[k] !== TREE) continue;
    const x = k % W, r = (k / W) | 0;
    const h = hash(x * 13 + r, 5);
    sR[k] = 0.004 + 0.004 * h, sG[k] = 0.006 + 0.006 * h, sB[k] = 0.012 + 0.008 * h;
    // the tops of the trees, backlit by the festival behind them
    const edge = r === 0 || mat[k - W] !== TREE ? 1 : r > 1 && mat[k - 2 * W] !== TREE ? 0.35 : 0;
    const g = edge * dome(x + 0.5, r + 0.5) * 0.45;
    sR[k] += g, sG[k] += g * 0.5, sB[k] += g * 0.18;
    take[k] = edge * 0.5;
  }
  const floorS = new Float32Array(S);
  for (let k = 0; k < S; k++) floorS[k] = mat[k] === AIR ? 0.04 + 0.08 * ((k / W) | 0) / WL : mat[k] === FAR || mat[k] === BACK ? 0.03 : 0;

  // people on the shore with lanterns still in their hands
  const shoreLights: [number, number, number][] = [];
  for (let i = 0, x = SRC - 24; i < 8; i++) {
    shoreLights.push([Math.round(x), WL - 1 - (hash(i, 62) > 0.6 ? 1 : 0), hash(i, 63) * 6.28]);
    x += 2 + 9 * hash(i, 61) ** 2;
  }

  // stars, thinning out where the festival lights the sky
  const stars: [number, number, number, number][] = [];
  for (let k = 0; k < S; k++) {
    if (mat[k] !== AIR) continue;
    const h = hash(k % W, Math.floor(k / W) + 101);
    if (h > 0.985 && take[k] < 0.25) stars.push([k, hash(k, 3) * 6.28, 0.8 + hash(k, 4) * 2.5, (0.45 + (h - 0.985) * 40) * (1 - take[k] * 2.5)]);
  }
  // rows of the water that break the reflection into strips
  const gap = new Uint8Array(H);
  for (let r = WL; r < H; r++) gap[r] = hash(r, 404) < 0.3 ? 1 : 0;

  // --- the lanterns -----------------------------------------------------------
  // Let go on the far shore. Each climbs from below the scrub, so its start is
  // hidden, and either burns out or sails off the top before it starts again.
  const shore: Flight[] = [];
  for (let i = 0; i < 240; i++) {
    const near = i >= 196;
    const s = near ? 1.5 + 1.7 * hash(i, 11) ** 1.5 : 0.45 + 0.9 * hash(i, 11) ** 1.4;
    const v = 0.3 + 0.62 * s;
    const out = near && hash(i, 12) < 0.7;
    const climb = out ? WL + 3 * s + 4 : 12 + 46 * hash(i, 13) ** 0.8;
    shore.push({
      i, s, v, life: (climb + s + 1) / v, off: hash(i, 14), wd: 0.12 + 0.3 * hash(i, 15),
      sw: 0.25 * s + 0.2, sf: 0.3 + 0.5 * hash(i, 16), ph: hash(i, 17) * 6.28, f1: 2 + 3 * hash(i, 18), f2: 5 + 4 * hash(i, 19),
      out, wide: near || hash(i, 20) < 0.08,
    });
  }
  shore.sort((a, b) => a.s - b.s);
  const startX = (f: Flight, lap: number) => {
    const n = f.i * 7 + lap * 131;
    if (f.wide) return 12 + 176 * hash(n, 23);
    return SRC - 6 + 30 * (hash(n, 23) + hash(n, 24) + hash(n, 25) - 1.5);
  };
  // The breeze leans the plume over to the right, more the higher it gets.
  const lean = (f: Flight, climb: number) => climb * f.wd + climb * climb * 0.004;

  // Let go near us, below the frame: they rise past large and fast.
  // [size, where it is across and down at the start]; the last few are still
  // below the frame, on their way up
  const NEAR: [number, number, number][] = [
    [12.5, 34, 30], [8.5, 178, 14], [6.5, 66, 74], [5.2, 154, 46], [4.4, 18, 86], [4.6, 136, 4],
    [9.5, 190, 116], [10.5, 150, 160], [5.8, 80, 128], [7, 28, 190], [4.2, 172, 150],
  ];
  const close = NEAR.map(([s, x, y], i) => {
    const v = 0.35 + 0.42 * s;
    const path = H + 5 * s; // from below the frame to above it, glow and all
    const life = (path / v) * (1.08 + 0.3 * hash(i, 41));
    return { i, s, v, x, path, life, off: (H + 2.5 * s - y) / v / life, wd: s * 0.05, sw: 0.35 * s, sf: 0.2 + 0.2 * hash(i, 42), ph: hash(i, 43) * 6.28 };
  });

  // --- the boat and the figure in it --------------------------------------------
  // Drawn in boat units, SC cells each, measured across from the figure (or
  // the boat's middle) and up from the waterline.
  // [shoulder, elbow, hand] for each pose, one arm; the other mirrors it
  const ARMS = {
    up: [[1.3, 9.4], [3, 11.5], [2, 14.1]],
    down: [[1.3, 9.4], [2, 6.6], [1.9, 4.4]],
    chest: [[1.3, 9.4], [2.4, 7.2], [1.2, 6]],
  };
  // a long low hull, its ends sweeping up, the bow (on the right) higher
  const gunwale = (dx: number) => 2.4 + (dx > 0 ? 3 : 1.8) * smooth(4, 12.5, Math.abs(dx)) ** 2;
  const hull = (dx: number, up: number) => up <= gunwale(dx) && up > -0.6 && Math.abs(dx) <= 12.5 - Math.max(0, 1.8 - up) * 2.4;
  const body = (dx: number, up: number, arms: number[][]) => {
    if ((dx * dx) / 1.3 + (up - 11.2) ** 2 / 1.5 < 1) return true; // the head
    if (up <= 9.8 && up > 1 && Math.abs(dx) <= 1.35 + 0.5 * smooth(9.8, 2, up)) return true; // a long coat
    const ax = Math.abs(dx);
    return seg(ax, up, arms[0][0], arms[0][1], arms[1][0], arms[1][1]) < 0.66 || seg(ax, up, arms[1][0], arms[1][1], arms[2][0], arms[2][1]) < 0.58;
  };
  // The boat and the figure, drawn into a box of cells round the boat each
  // frame (1 boat, 2 figure) with the arms partway between two poses, so they
  // move rather than snap from one to the next.
  const BOX = [Math.floor(BX - 13 * SC), BW - Math.ceil(16 * SC), Math.ceil(27 * SC), Math.ceil(16 * SC) + 2]; // x, row, cols, rows
  const hulls = new Uint8Array(BOX[2] * BOX[3]), shape = new Uint8Array(BOX[2] * BOX[3]);
  for (let j = 0; j < BOX[3]; j++) {
    for (let i = 0; i < BOX[2]; i++) if (hull((BOX[0] + i + 0.5 - BX) / SC, (BW - (BOX[1] + j + 0.5)) / SC)) hulls[j * BOX[2] + i] = 1;
  }
  const arms = ARMS.up.map((p) => [...p]);
  const pose = (a: number[][], b: number[][], k: number) => {
    for (let i = 0; i < 3; i++) (arms[i][0] = mix(a[i][0], b[i][0], k)), (arms[i][1] = mix(a[i][1], b[i][1], k));
    for (let j = 0; j < BOX[3]; j++) {
      for (let i = 0; i < BOX[2]; i++) {
        const q = j * BOX[2] + i;
        shape[q] = hulls[q] || (body((BOX[0] + i + 0.5 - FX) / SC, (BW - (BOX[1] + j + 0.5)) / SC, arms) ? 2 : 0);
      }
    }
  };
  // a small lamp on the bow, always lit, so the boat never goes dark between
  // one lantern and the next
  const BOWX = BX + 11.2 * SC, BOWY = BW - (gunwale(11.2) + 1.2) * SC;

  // --- per frame ----------------------------------------------------------------
  const R = new Float32Array(N), G = new Float32Array(N), B = new Float32Array(N);
  const FL = new Float32Array(N);

  const paint = (k: number, pr: number, pg: number, pb: number, a: number) => {
    R[k] += (pr - R[k]) * a, G[k] += (pg - G[k]) * a, B[k] += (pb - B[k]) * a;
    if (FL[k] < 0.12 * a) FL[k] = 0.12 * a;
  };
  // Draws a lantern h cells tall centred on (cx, cy) at brightness I and
  // opacity a, above row `limit`, leaving out the cells `hide` marks.
  const lantern = (cx: number, cy: number, h: number, I: number, a: number, limit: number, hide: Uint8Array | null) => {
    if (h < 1.4) {
      // a spark: one cell
      const x = Math.floor(cx), r = Math.floor(cy);
      if (x < 0 || x >= W || r < 0 || r >= limit) return;
      const k = r * W + x;
      if (hide && hide[k]) return;
      const q = I * (0.85 + 0.15 * h);
      paint(k, q, q * (0.42 + 0.3 * q), q * (0.1 + 0.18 * q * q), a);
      return;
    }
    if (h < 2.6) {
      // two cells tall, the paper above and the burner's glow below, spread
      // over the rows it straddles so it slides up rather than jumping
      const x = Math.floor(cx), top = cy - 1, r0 = Math.floor(top);
      if (x < 0 || x >= W) return;
      for (let r = r0; r <= r0 + 2; r++) {
        if (r < 0 || r >= limit) continue;
        const k = r * W + x;
        if (hide && hide[k]) continue;
        const up = Math.max(0, Math.min(r + 1, top + 1) - Math.max(r, top)); // overlap with the paper
        const dn = Math.max(0, Math.min(r + 1, top + 2) - Math.max(r, top + 1)); // and with the glow
        if (up + dn < 0.02) continue;
        const q = I * (0.8 * up + 1.1 * dn) / (up + dn);
        paint(k, q, q * (0.34 + 0.36 * q), q * (0.06 + 0.2 * q * q), a * (up + dn));
      }
      return;
    }
    const top = cy - h / 2;
    const x0 = Math.max(0, Math.floor(cx - 0.36 * h)), x1 = Math.min(W - 1, Math.floor(cx + 0.36 * h));
    const r0 = Math.max(0, Math.floor(top)), r1 = Math.min(limit - 1, Math.floor(top + h));
    for (let r = r0; r <= r1; r++) {
      for (let x = x0; x <= x1; x++) {
        const k = r * W + x;
        if (hide && hide[k]) continue;
        let cov = 0;
        for (let sy = 0; sy < 3; sy++) for (let sx = 0; sx < 3; sx++) if (inside((x + (sx + 0.5) / 3 - cx) / h, (r + (sy + 0.5) / 3 - top) / h)) cov++;
        if (!cov) continue;
        const u = (x + 0.5 - cx) / h, v = clamp((r + 0.5 - top) / h);
        const xn = Math.min(1, Math.abs(u) / (0.34 - 0.08 * v));
        // Lit through: past full brightness, so the body is solid, its colour
        // deep orange up under the crown and pale gold over the burner.
        const q = I * (1 - 0.3 * xn * xn) * (0.78 + 0.42 * v);
        const fl = Math.exp(-((u / 0.13) ** 2) - ((v - 0.9) / 0.1) ** 2) * I;
        paint(k, q + fl * 0.3, q * (0.3 + 0.42 * q) + fl * 0.6, q * (0.04 + 0.2 * q * q) + fl * 0.5, (cov / 9) * a);
      }
    }
  };
  // the warm air round a lantern
  const halo = (cx: number, cy: number, h: number, g: number, limit: number) => {
    const e = 0.4 + 0.32 * h;
    const reach = Math.ceil(e * 4);
    const r0 = Math.max(0, Math.floor(cy - reach)), r1 = Math.min(limit - 1, Math.floor(cy + reach));
    const x0 = Math.max(0, Math.floor(cx - reach)), x1 = Math.min(W - 1, Math.floor(cx + reach));
    for (let r = r0; r <= r1; r++) {
      const dy = r + 0.5 - cy;
      for (let x = x0; x <= x1; x++) {
        const dx = x + 0.5 - cx;
        const d = Math.max(0, Math.sqrt(dx * dx + dy * dy) - 0.3 * h);
        const q = g * (Math.exp(-d / e) + 0.25 * Math.exp(-d / (e * 2.5)));
        const k = r * W + x;
        R[k] += q, G[k] += q * 0.5, B[k] += q * 0.17;
      }
    }
  };
  const hide = new Uint8Array(S);
  for (let k = 0; k < S; k++) hide[k] = mat[k] === TREE ? 1 : 0;
  // the festival's light laid on the water as a broad road, widening toward us
  const roadX = new Float32Array(H), roadW = new Float32Array(H), roadA = new Float32Array(H);
  for (let r = WL; r < H; r++) {
    const dw = r + 0.5 - WL;
    roadX[r] = SRC + 4 + dw * 0.12;
    roadW[r] = 7 + dw * 0.9;
    // near the shore the mirror carries the festival's light; the road takes
    // over further out
    roadA[r] = 0.9 * smooth(3, 16, dw) * Math.exp(-dw / 60);
  }

  return (t, { color } = {}) => {
    const breathe = 0.06 * Math.sin(t * 0.5) + 0.04 * Math.sin(t * 1.7 + 1);

    // --- the sky and the land -------------------------------------------------------
    for (let k = 0; k < S; k++) {
      const g = 1 + take[k] * breathe;
      R[k] = sR[k] * g, G[k] = sG[k] * g, B[k] = sB[k] * g;
      FL[k] = floorS[k];
    }

    // lanterns from the far shore, the furthest first
    for (const f of shore) {
      const u = t / f.life + f.off;
      const lap = Math.floor(u);
      const a = (u - lap) * f.life;
      const cx = startX(f, lap) + lean(f, f.v * a) + f.sw * (Math.sin(a * f.sf + f.ph) - Math.sin(f.ph));
      const cy = WL + f.s * 0.5 + 0.6 - f.v * a;
      if (cy < -2 * f.s - 2) continue;
      let I = (0.7 + 0.45 * Math.min(1, f.s)) * (0.88 + 0.08 * Math.sin(t * f.f1 + f.ph) + 0.04 * Math.sin(t * f.f2));
      let alpha = 1;
      if (!f.out) {
        // burning out: it gutters, reddens and goes
        const end = smooth(f.life - 4, f.life, a);
        I *= 1 - 0.55 * end * (0.6 + 0.4 * Math.sin(a * 11 + f.ph));
        alpha = 1 - end;
      }
      if (alpha <= 0.01) continue;
      if (f.s >= 1.4) halo(cx, cy, f.s, 0.07 * I * alpha * smooth(0, 2, a), WL);
      lantern(cx, cy, f.s, I, alpha, WL, hide);
    }
    for (const [x, r, ph] of shoreLights) {
      const k = r * W + x;
      const q = 0.9 + 0.15 * Math.sin(t * 3.1 + ph) + 0.1 * Math.sin(t * 7.3 + ph * 2);
      R[k] = q, G[k] = q * 0.62, B[k] = q * 0.24, FL[k] = 0.2;
    }

    // --- the lake: the sky upside down, stretched and broken by slow ripples ----
    for (let r = WL; r < H; r++) {
      const y = r + 0.5;
      const dw = y - WL;
      const deep = mix(1, 0.65, dw / (H - WL));
      const ry = WL - 1 - (r - WL);
      const r0 = Math.max(0, ry) * W, r1 = Math.max(0, ry - 1) * W, r2 = Math.max(0, ry - 2) * W;
      const fade = smooth(H + 4, H - 7, y);
      const rx = roadX[r], rw = roadW[r], ra = roadA[r];
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const wave = noise(x * 0.04 + t * 0.05, r * 0.55 - t * 0.35);
        const sx = x + (0.45 + dw * 0.075) * Math.sin(r * 1.3 + t * 1.6 + wave * 4);
        let ix = Math.floor(sx);
        const fx = sx - ix;
        ix = Math.max(0, Math.min(W - 2, ix));
        const a0 = r0 + ix, a1 = r1 + ix, a2 = r2 + ix;
        const sky = mat[a0] !== TREE;
        // long level streaks where the ripples face us, darker troughs between
        const streak = smooth(0.25, 0.75, noise(x * 0.035 + t * 0.04, r * 0.75 - t * 0.3));
        let kr = 0.9 * (0.5 + 0.5 * streak) * (0.85 + 0.25 * wave) * deep;
        if (gap[r] && wave < 0.45) kr *= 0.2;
        const gx = 1 - fx;
        let cr = ((R[a0] * gx + R[a0 + 1] * fx) * 0.5 + (R[a1] * gx + R[a1 + 1] * fx) * 0.3 + (R[a2] * gx + R[a2 + 1] * fx) * 0.2) * kr + 0.006;
        let cg = ((G[a0] * gx + G[a0 + 1] * fx) * 0.5 + (G[a1] * gx + G[a1 + 1] * fx) * 0.3 + (G[a2] * gx + G[a2 + 1] * fx) * 0.2) * kr + 0.012;
        let cb = ((B[a0] * gx + B[a0 + 1] * fx) * 0.5 + (B[a1] * gx + B[a1 + 1] * fx) * 0.3 + (B[a2] * gx + B[a2 + 1] * fx) * 0.2) * kr + 0.028;
        // the festival's road: a glow on the water, broken into short level
        // dashes where the ripples catch it
        let g = 0;
        const qx = (x + 0.5 - rx) / rw;
        if (qx > -2.2 && qx < 2.2) {
          const dash = smooth(0.42, 0.72, noise(x * 0.11 + t * 0.1, r * 0.9 - t * 0.55));
          g = ra * Math.exp(-qx * qx) * (0.3 + 0.7 * dash) * (gap[r] ? 0.55 : 1);
        }
        // the shore lights laid on the water
        if (dw < 10) {
          for (const [lx, , ph] of shoreLights) {
            const d = x - lx;
            if (d > 3 || d < -3) continue;
            g += Math.exp(-d * d * 1.5) * Math.exp(-dw / 4) * smooth(0.35, 0.7, noise(x * 0.6 + ph, y * 1.3 - t * 1.4)) * 0.8;
          }
        }
        // where the warm light lies on the water it replaces the blue, so it
        // stays gold instead of greying
        if (g > 0) {
          const keep = 1 - Math.min(0.9, g * 4);
          cr = cr * keep + g, cg = cg * keep + g * 0.56, cb = cb * keep + g * 0.2;
        }
        // elsewhere the crests of the ripples catch the blue of the sky
        if (g < 0.2) {
          const sheen = smooth(0.6, 0.85, noise(x * 0.07 + t * 0.03, r * 1.1 - t * 0.25)) * 0.12 * (1 - g * 5) * deep;
          cr += sheen * 0.3, cg += sheen * 0.48, cb += sheen;
        }
        R[k] = cr * fade, G[k] = cg * fade, B[k] = cb * fade;
        FL[k] = (sky ? 0.07 * deep : 0.03) * fade;
      }
    }

    // --- the boat ------------------------------------------------------------------
    const tc = ((t % CYCLE) + CYCLE) % CYCLE;
    const bob = (at: number) => 0.3 * Math.sin(at * 1.3);
    const holdY = BW - 14.1 * SC - HELD / 2;
    // each of the boat's lanterns flickers in its own time, the same in the
    // hands and after it is let go: m is the cycle it is let go in
    const flick = (m: number) => 1.1 + 0.05 * Math.sin(t * 4.7 + m * 2.1) + 0.03 * Math.sin(t * 11.3 + m * 5.3);
    const n = Math.floor(t / CYCLE);
    const mine = flick(tc < LET_GO ? n : n + 1);
    // the lantern in the figure's hands, if there is one, and how lit it is
    let hy = 0, hs = HELD, ha = 0;
    if (tc < LET_GO || tc >= LIT + 5) (hy = holdY + bob(t)), (ha = 1);
    else if (tc >= LIT) {
      // lit at the chest, filling with light, then lifted over the head
      ha = smooth(LIT, LIT + 2.5, tc);
      hs = HELD * (0.75 + 0.25 * smooth(LIT, LIT + 3, tc));
      hy = mix(BW - 8.6 * SC, holdY + bob(t), smooth(LIT + 3.5, LIT + 5, tc));
    }
    // the arms: held up a moment after the let-go, lowered, brought up to the
    // chest to light the next, and lifted with it over the head
    if (tc < LET_GO + 2) pose(ARMS.up, ARMS.down, smooth(LET_GO + 1, LET_GO + 2, tc));
    else if (tc < LIT + 1) pose(ARMS.down, ARMS.chest, smooth(LIT - 0.6, LIT, tc));
    else pose(ARMS.chest, ARMS.up, smooth(LIT + 3.5, LIT + 5, tc));
    // the lanterns already let go: this cycle's and the two before
    const flights: [number, number, number][] = [];
    for (let m = n; m >= n - 2; m--) {
      const tr = m * CYCLE + LET_GO, age = t - tr;
      if (age < 0) continue;
      const y = holdY + bob(tr) - 2.8 * (age - 1.5 * (1 - Math.exp(-age / 1.5)));
      if (y < -3 * HELD) continue;
      flights.push([FX + 0.3 * age + 1.2 * Math.sin(age * 0.35), y, flick(m)]);
    }

    // their light: a glow in the air round each, and on the water a pool
    // round the boat and a broken road toward us, fading as a lantern rises
    // [x, the bottom of the lantern, how much of it reaches the water and the
    // boat, how big its pool is]
    const lights: [number, number, number, number][] = [];
    const lamp = 1.2 + 0.04 * Math.sin(t * 5.3) + 0.03 * Math.sin(t * 12.7);
    lights.push([BOWX, BOWY + 1, 0.4 * lamp, 0.4]);
    halo(BOWX, BOWY, 2, 0.07 * lamp, H);
    if (ha > 0) lights.push([FX, hy + hs / 2, ha * mine, 1]);
    for (const [x, y, l] of flights) {
      halo(x, y, HELD, 0.2 * l, H);
      lights.push([x, y + HELD / 2, l * Math.exp(-Math.max(0, holdY - y) / 8), 1]);
    }
    if (ha > 0) halo(FX, hy, HELD, 0.2 * ha * mine, H);
    for (const [lx, , q, sz] of lights) {
      if (q < 0.003) continue;
      for (let r = Math.floor(BW - 9 * SC); r < H; r++) {
        const y = r + 0.5, dw = y - BW;
        for (let x = Math.max(0, Math.floor(lx - 34 * sz)); x < Math.min(W, lx + 34 * sz); x++) {
          const k = r * W + x;
          const dx = x + 0.5 - lx;
          // the water behind the boat is further off, so its pool is squashed
          let g = Math.exp(-Math.sqrt((dx / (16 * SC * 0.8 * sz)) ** 2 + (dw / ((dw < 0 ? 2.6 : 3.6) * SC * sz)) ** 2)) * 0.5;
          if (dw > -1) {
            const w = (1.4 + 0.2 * Math.max(0, dw)) * (0.5 + 0.5 * sz);
            g += Math.exp(-((dx / w) ** 2)) * Math.exp(-Math.max(0, dw) / (16 * sz)) * smooth(0.4, 0.72, noise(x * 0.45 + 3, y * 1.2 - t * 1.3)) * 1.1;
          }
          g *= q;
          const keep = 1 - Math.min(0.9, g * 4);
          R[k] = R[k] * keep + g, G[k] = G[k] * keep + g * 0.62, B[k] = B[k] * keep + g * 0.26;
        }
      }
    }
    // the boat and the figure, dark against it, their edges lit from above
    for (let j = 0; j < BOX[3]; j++) {
      for (let i = 0; i < BOX[2]; i++) {
        const m = shape[j * BOX[2] + i];
        if (!m) continue;
        const x = BOX[0] + i, r = BOX[1] + j;
        if (x < 0 || x >= W || r >= H) continue;
        const k = r * W + x;
        // the sides of the figure, a thin rim against the road behind
        let g = m === 2 && (i === 0 || i === BOX[2] - 1 || !shape[j * BOX[2] + i - 1] || !shape[j * BOX[2] + i + 1]) ? 0.16 : 0;
        const top = j === 0 || !shape[(j - 1) * BOX[2] + i];
        for (const [lx, ly, lq] of lights) {
          if (lq < 0.003) continue;
          const dx = lx - (x + 0.5), dy = ly - (r + 0.5);
          const d = Math.sqrt(dx * dx + dy * dy) || 1;
          if (m === 2) {
            // the neighbour toward the light: if it is open, this cell is an edge facing it
            const ni = i + Math.round(dx / d), nj = j + Math.round(dy / d);
            const open = ni < 0 || nj < 0 || ni >= BOX[2] || nj >= BOX[3] || !shape[nj * BOX[2] + ni];
            g += lq * (open ? 0.95 : 0.05) * Math.exp(-d / (6 * SC));
          } else {
            // the gunwale catches the light along its length
            g += lq * (top ? 0.6 * Math.exp(-Math.abs(dx) / (11 * SC)) : 0.02);
          }
        }
        R[k] = 0.01 + g, G[k] = 0.008 + g * 0.55, B[k] = 0.014 + g * 0.22;
        FL[k] = 0;
      }
    }
    lantern(BOWX, BOWY, 2, lamp, 1, H, null);
    for (const [x, y, q] of flights) lantern(x, y, HELD, q, 1, H, null);
    if (ha > 0) lantern(FX, hy, hs, ha * mine, Math.min(1, ha * 1.4), H, null);

    // --- lanterns rising past us ------------------------------------------------------
    for (const c of close) {
      const u = t / c.life + c.off;
      const lap = Math.floor(u);
      const a = (u - lap) * c.life;
      // before its first flight it is still waiting below the frame
      if (lap < 0 || a * c.v > c.path) continue;
      let x0 = c.x;
      if (lap) {
        x0 = 6 + 188 * hash(c.i * 17 + lap, 44);
        if (x0 > 84 && x0 < 132) x0 = x0 < 108 ? x0 - 32 : x0 + 32; // clear of the boat
      }
      const cx = x0 + c.wd * a + c.sw * (Math.sin(a * c.sf + c.ph) - Math.sin(c.ph));
      const cy = H + 2.5 * c.s - c.v * a;
      const I = 1.15 + 0.05 * Math.sin(t * 3.3 + c.ph) + 0.03 * Math.sin(t * 8.1 + c.i);
      halo(cx, cy, c.s, 0.1 * I, H);
      lantern(cx, cy, c.s, I, 1, H, null);
    }

    // --- dots ---------------------------------------------------------------------------
    for (let r = 0; r < H; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const cr = R[k], cg = G[k], cb = B[k];
        const floor = FL[k];
        const peak = Math.max(cr, cg, cb, 1e-4);
        const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.85) * 0.95);
        const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(r & 3) * 4 + (x & 3)])));
        out[k] = DOTS[step];
        if (color) {
          // the far range in slate, unless a lantern or its glow is over it
          if (k < S && far[k] && FL[k] < 0.05 && cb >= cr) {
            color[k] = SL + (level > 0.42 ? 2 : level > 0.3 ? 1 : 0);
            continue;
          }
          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(cb * s));
        }
      }
    }
    // stars twinkle, wherever nothing brighter is in front of them
    // (a star needs a bigger dot than the sky's own to be seen against it)
    for (const [k, ph, rate, b] of stars) {
      const s = b * (0.7 + 0.3 * Math.sin(t * rate + ph));
      if (s < 0.3 || Math.max(R[k], G[k], B[k]) > 0.4) continue;
      out[k] = s > 0.95 ? "●" : s > 0.45 ? "•" : "·";
      if (color) color[k] = STAR + (s > 0.8 ? 2 : s > 0.55 ? 1 : 0);
    }
    const lines: string[] = [];
    for (let r = 0; r < H; r++) lines.push(out.slice(r * W, (r + 1) * W).join(""));
    return lines.join("\n");
  };
}