jal mahal

[docs] [github]

the jaipur water palace floating on a misty lake at sunrise

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

html

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

<ascii-art piece="jal-mahal"></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 { jalMahal } from "ascii.rest/pieces";

<Ascii piece={jalMahal} />

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

astro

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

<Ascii piece="jal-mahal" />

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

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

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 jal-mahal

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

jal-mahal.ts

[js] [github]
/*
 * jal mahal: the water palace in Jaipur at a misty sunrise. Its top floors sit
 * out in the middle of Man Sagar Lake, four domed chhatris on the corners and
 * rows of arches along the front, mirrored in glassy water. The Aravalli hills
 * behind fade in layers of haze, the sun comes up pale gold through the mist,
 * birds cross the sky and egrets skim the lake past a lone wooden boat.
 *
 * 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: "jal mahal",
  category: "scenes",
  note: "the jaipur water palace floating on a misty lake at sunrise",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#16141f",
  palette: [
    "#3e4a6b", "#56628a", "#7180a6", "#8e9cc0", "#aab6d4", "#c6cfe3", "#e2e7f1",
    "#6b4a66", "#8a5f7c", "#a87891", "#c895a6", "#e2b4bd", "#f2d0d0",
    "#fff3d6", "#ffe3a6", "#f6c87a", "#e8a865", "#c98552",
    "#5a3e3a", "#7d5848", "#a47a5e", "#c99d78", "#e4c29c",
    "#2a2f45", "#3a3550", "#4d4660",
    "#4a3226", "#6e4a32",
    "#fbfaf7",
  ],
} satisfies Meta;

const W = 200, H = 100;
const WATER = 62; // the waterline
const SUN = [148, 30];
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);

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 `period` lattice cells in x when period > 0.
function noise(x: number, y: number, period: 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);
  let x0 = xi, x1 = xi + 1;
  if (period) {
    x0 = ((xi % period) + period) % period;
    x1 = (x0 + 1) % period;
  }
  const a = hash(x0, yi), b = hash(x1, yi), c = hash(x0, yi + 1), d = hash(x1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number, period: 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, period * 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 sky at a row, before the sun: misty blue overhead, rose in the middle,
// pale gold low down where the light comes through.
function skyAt(y: number): [number, number, number] {
  const v = clamp(y / WATER);
  if (v < 0.45) {
    const k = smooth(0, 0.45, v);
    return [mix(0.4, 0.86, k), mix(0.47, 0.62, k), mix(0.68, 0.68, k)];
  }
  const k = smooth(0.45, 0.95, v);
  return [mix(0.86, 1.0, k), mix(0.62, 0.84, k), mix(0.68, 0.6, k)];
}

export default function jalMahal(): 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);
  };

  // --- everything above the water that never moves, shaded once ----------
  // Sky, sun, hills and the palace go into one picture; the lake mirrors it.
  const SR = new Float32Array(WATER * W), SG = new Float32Array(WATER * W), SB = new Float32Array(WATER * W);
  const solid = new Uint8Array(WATER * W); // 1 where the palace stands, for the mist to know

  // three ridges of the Aravallis, the far ones palest, lower toward the sun
  // each ridge a few broad humps with a rough edge; Nahargarh the tall one
  // on the left
  const hump = (x: number, c: number, w: number) => Math.exp(-(((x - c) / w) ** 2));
  const ridges = [
    { top: (x: number) => 45 - 15 * hump(x, 32, 30) - 9 * hump(x, 98, 22) - 6 * hump(x, 188, 18) - 4 * hump(x, 138, 12) - 3 * fbm(x * 0.09, 1.3, 3, 0), tone: [0.5, 0.5, 0.68] },
    { top: (x: number) => 52 - 9 * hump(x, 8, 20) - 7 * hump(x, 62, 16) - 6 * hump(x, 172, 15) - 2.5 * fbm(x * 0.11, 4.1, 3, 0), tone: [0.36, 0.34, 0.52] },
    { top: (x: number) => 58.5 - 3 * hump(x, 36, 28) - 3 * hump(x, 152, 24) - 1.6 * fbm(x * 0.14, 7.7, 3, 0), tone: [0.24, 0.2, 0.3] },
  ];
  // the fort walls along Nahargarh's crest, bastions every so often
  const fortTop = (x: number) => {
    if (x < 6 || x > 60) return 1e9;
    const base = ridges[0].top(x);
    const bastion = Math.abs(((x - 6) % 13) - 6.5) > 5 ? 3 : 1.6;
    return base - bastion;
  };

  for (let r = 0; r < WATER; r++) {
    const y = r + 0.5;
    const [sr, sg, sb] = skyAt(y);
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const dx = x + 0.5 - SUN[0], dy = y - SUN[1];
      const ds = Math.sqrt(dx * dx + dy * dy);
      const glow = Math.exp(-ds / 34) * 0.3 + Math.exp(-ds / 11) * 0.45;
      const streak = 0.08 * (fbm(x * 0.025, y * 0.3, 3, 0) - 0.5);
      let cr = sr + glow * 0.6 + streak, cg = sg + glow * 0.48 + streak * 0.9, cb = sb + glow * 0.2 + streak;
      // long thin streaks of cloud, their undersides lit rose and gold
      const cl = smooth(0.56, 0.74, fbm(x * 0.012, y * 0.32, 4, 0)) * smooth(8, 16, y) * smooth(38, 28, y);
      if (cl > 0) {
        const warm = Math.exp(-ds / 40);
        cr = mix(cr, 1.0, cl * 0.55), cg = mix(cg, 0.76 + 0.12 * warm, cl * 0.55), cb = mix(cb, 0.74 - 0.1 * warm, cl * 0.55);
      }
      if (ds < 7.5) {
        // the sun, softened by the mist, a little paler at its middle
        const a = smooth(7.5, 6.2, ds);
        cr = mix(cr, 1.0, a), cg = mix(cg, 0.9 + 0.08 * smooth(7, 0, ds), a), cb = mix(cb, 0.6 + 0.3 * smooth(7, 0, ds), a);
      }
      // hills, front to back; each fades toward the sky colour at its foot,
      // where the mist pools in the valleys
      for (let i = ridges.length - 1; i >= 0; i--) {
        const R = ridges[i];
        const t0 = R.top(x);
        if (y < t0) continue;
        const lit = 0.06 * (fbm(x * 0.12, y * 0.2, 3, 0) - 0.5) + 0.05 * smooth(t0 + 2.5, t0, y);
        const pool = smooth(t0 + 2, t0 + 12, y) * 0.5;
        const h = pool;
        cr = mix(R.tone[0] + lit, sr + glow * 0.4, h);
        cg = mix(R.tone[1] + lit, sg + glow * 0.32, h);
        cb = mix(R.tone[2] + lit, sb + glow * 0.15, h);
        break;
      }
      const ft = fortTop(x);
      if (y >= ft && y < ridges[0].top(x) + 0.5 && y < ridges[1].top(x)) {
        // the wall, catching the low sun on its face, merlons along the top
        if (!(y < ft + 0.8 && x % 3 === 0)) (cr = 0.72), (cg = 0.56), (cb = 0.62);
      }
      SR[k] = cr, SG[k] = cg, SB[k] = cb;
    }
  }

  // The palace: a long two-storey front in rose sandstone, its lower storey
  // half under water. Light comes from the sun on the right.
  const STONE = [1.0, 0.76, 0.58], SHADE = [0.6, 0.42, 0.42], HOLE = [0.2, 0.13, 0.19], DOME = [1.0, 0.9, 0.74];
  const paint = (x: number, r: number, c: number[], s: number) => {
    if (x < 0 || x >= W || r < 0 || r >= WATER) return;
    const k = r * W + x;
    SR[k] = c[0] * s, SG[k] = c[1] * s, SB[k] = c[2] * s;
    solid[k] = 1;
  };
  const L = 64, Rt = 136, ROOF = 47;
  for (let r = 40; r < WATER; r++) {
    const y = r + 0.5;
    for (let x = L - 2; x <= Rt + 2; x++) {
      const fx = x + 0.5;
      const grain = 0.94 + 0.08 * hash(x * 3, r * 7);
      // parapet with small merlons, and the cornice under it
      if (y >= ROOF - 2 && y < ROOF - 0.5 && fx >= L && fx <= Rt && Math.floor(fx) % 3 !== 0) paint(x, r, STONE, 0.95 * grain);
      if (y >= ROOF - 0.5 && y < ROOF + 1 && fx >= L - 1.5 && fx <= Rt + 1.5) paint(x, r, DOME, 0.98);
      if (y < ROOF + 1 || fx < L || fx > Rt) continue;
      // the shadowed return of the left wing gives the block some depth
      let c = STONE, s = grain * (0.9 + 0.1 * smooth(L, Rt, fx));
      if (fx < L + 4) (c = SHADE), (s = grain);
      // a string course between the storeys
      if (y >= 53 && y < 54.2) (c = DOME), (s = 0.95);
      // upper arcade: small cusped arches
      const up = ((fx - L - 6) % 4.5 + 4.5) % 4.5 - 2.25;
      if (y >= 48.6 && y < 52.6 && fx > L + 5 && fx < Rt - 4 && Math.abs(up) < 1.05 && y > 49.4 + up * up * 0.5) (c = HOLE), (s = 1);
      // lower arcade: wider arches, opening onto the water
      const lo = ((fx - L - 7) % 6 + 6) % 6 - 3;
      if (y >= 55.2 && fx > L + 5 && fx < Rt - 4 && Math.abs(lo) < 1.5 && y > 56.2 + lo * lo * 0.45) (c = HOLE), (s = 0.9);
      // pilasters catch the light
      if (y >= 54.2 && Math.abs(lo) > 2.6 && fx > L + 5) s *= 1.06;
      // the stone darkened where the lake has stood against it for centuries
      if (y > WATER - 2) s *= 0.62;
      paint(x, r, c, s);
    }
  }

  // An octagonal chhatri: an arched drum, a broad eave, a dome and a finial.
  // `far` pushes the rear ones back into the haze.
  const chhatri = (cx: number, base: number, sc: number, far: number) => {
    const drumH = 7 * sc, hw = 3.6 * sc, eave = 4.9 * sc, domeH = 6.6 * sc, domeW = 4.3 * sc;
    const eaveY = base - drumH, domeBase = eaveY - 1.2 * sc;
    for (let r = Math.floor(domeBase - domeH - 4 * sc); r < Math.ceil(base); r++) {
      const y = r + 0.5;
      for (let x = Math.floor(cx - eave - 1); x <= Math.ceil(cx + eave + 1); x++) {
        const dx = x + 0.5 - cx;
        let c: number[] | null = null, s = 1;
        if (y >= eaveY && y < base && Math.abs(dx) <= hw) {
          // three faces of the octagon: the near one lit, left one in shade
          const face = dx < -hw * 0.45 ? 0.72 : dx > hw * 0.45 ? 1.02 : 0.92;
          c = STONE, s = face;
          const open = Math.abs(dx) < 0.9 * sc + 0.15 && y > eaveY + 2 * sc + dx * dx * 0.4;
          if (open && y < base - 0.8) (c = HOLE), (s = 1);
          if (Math.abs(Math.abs(dx) - hw * 0.45) < 0.3) s *= 0.88; // the corner pillars
        } else if (y >= eaveY - 1.2 * sc && y < eaveY && Math.abs(dx) <= eave - (eaveY - y) * 0.6) {
          (c = DOME), (s = 0.9 + 0.08 * Math.sign(dx));
        } else if (y < domeBase && y >= domeBase - domeH) {
          // onion profile: swells a little above the neck, then closes to a point
          const v = (domeBase - y) / domeH;
          const w = domeW * Math.sqrt(Math.max(0, 1 - Math.pow(v, 1.6))) * (0.88 + 0.22 * Math.sin(Math.min(1, v * 2.2) * Math.PI * 0.5));
          if (Math.abs(dx) <= w) (c = DOME), (s = 0.78 + 0.26 * (dx / Math.max(w, 0.5)) * 0.5 + 0.2 * v);
        } else if (y < domeBase - domeH && y >= domeBase - domeH - 3.4 * sc && Math.abs(dx) < 0.55) {
          (c = [0.98, 0.82, 0.5]), (s = 1);
        }
        if (!c) continue;
        const sky = skyAt(y);
        paint(x, r, [mix(c[0] * s, sky[0], far), mix(c[1] * s, sky[1], far), mix(c[2] * s, sky[2], far)], 1);
      }
    }
  };
  // the rear pair first, smaller and paler, then the central pavilion, then
  // the front corners
  chhatri(83, ROOF - 1.5, 0.85, 0.22);
  chhatri(117, ROOF - 1.5, 0.85, 0.22);
  // the central pavilion with a curved bangla roof
  for (let r = 34; r < ROOF; r++) {
    const y = r + 0.5;
    for (let x = 88; x <= 112; x++) {
      const dx = x + 0.5 - 100;
      const roofTop = 37 + 2.4 * (dx / 11) ** 2;
      if (y >= 41 && y < ROOF - 1.5 && Math.abs(dx) <= 9) {
        const bay = ((dx + 9) % 6 + 6) % 6 - 3;
        const open = Math.abs(bay) < 1.3 && y > 42.4 + bay * bay * 0.45;
        paint(x, r, open ? HOLE : STONE, open ? 1 : dx < -6 ? 0.78 : 0.96);
      } else if (y >= roofTop && y < 41 && Math.abs(dx) <= 11) {
        paint(x, r, DOME, 0.86 + 0.08 * (dx / 11) + 0.08 * (41 - y) / 4);
      }
    }
  }
  chhatri(67.5, ROOF - 1.5, 1, 0);
  chhatri(132.5, ROOF - 1.5, 1, 0);

  // --- mist: wrapping heaps of noise that drift slowly to the right -------
  const MW = 480;
  const mist = new Float32Array(MW * H);
  for (let r = 0; r < H; r++)
    for (let x = 0; x < MW; x++) mist[r * MW + x] = smooth(0.44, 0.72, fbm(x * 0.02, r * 0.14, 4, MW * 0.02));
  // where the mist lies: low on the water, a thin band through the hills
  const band = new Float32Array(H);
  for (let r = 0; r < H; r++) {
    const y = r + 0.5;
    band[r] = 0.95 * Math.exp(-(((y - WATER) / 4.5) ** 2)) + 0.6 * Math.exp(-(((y - 51) / 3) ** 2)) + 0.3 * Math.exp(-(((y - 40) / 3.5) ** 2));
  }

  // --- the flocks ---------------------------------------------------------
  // ducks and egrets high up as dark marks, egrets low over the water in white
  const birds: { x0: number; y0: number; v: number; ph: number; span: number; low: boolean }[] = [];
  for (let i = 0; i < 9; i++) {
    // a loose skein crossing the sun's side of the sky
    birds.push({ x0: 40 + i * 6 + hash(i, 1) * 4, y0: 14 + Math.abs(i - 4) * 2.2 + hash(i, 2) * 1.5, v: 4.2, ph: hash(i, 3) * 6.28, span: 2, low: false });
  }
  for (let i = 0; i < 4; i++) birds.push({ x0: 160 + i * 9, y0: 22 + hash(i, 4) * 6, v: 3.1, ph: hash(i, 5) * 6.28, span: 2, low: false });
  for (let i = 0; i < 5; i++) birds.push({ x0: i * 14 + hash(i, 6) * 6, y0: 67 + hash(i, 7) * 5, v: 6.5, ph: hash(i, 8) * 6.28, span: 3, low: true });

  const FR = new Float32Array(N), FG = new Float32Array(N), FB = new Float32Array(N);
  const set = (x: number, r: number, c: number[], a = 1) => {
    x = Math.round(x), r = Math.round(r);
    if (x < 0 || x >= W || r < 0 || r >= H) return;
    const k = r * W + x;
    FR[k] = mix(FR[k], c[0], a), FG[k] = mix(FG[k], c[1], a), FB[k] = mix(FB[k], c[2], a);
  };

  return (t, { color } = {}) => {
    const drift = t * 1.6;

    for (let r = 0; r < H; r++) {
      const y = r + 0.5;
      const mo = Math.floor(drift * (0.6 + 0.4 * band[r]));
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        let cr: number, cg: number, cb: number;
        if (r < WATER) {
          cr = SR[k], cg = SG[k], cb = SB[k];
        } else {
          // The lake: the picture above, mirrored and pulled sideways by the
          // ripples, more so toward us, a shade darker and bluer.
          const d = y - WATER;
          const w = noise(x * 0.05 + t * 0.15, d * 0.7 - t * 0.9, 0) - 0.5 + 0.5 * (noise(x * 0.16 - t * 0.3, d * 1.6 - t * 1.6, 0) - 0.5);
          const amp = 0.15 + d * 0.07;
          const sx = Math.max(0, Math.min(W - 1, Math.round(x + w * amp * 3)));
          const sr = Math.max(0, Math.min(WATER - 1, Math.round(WATER - 1 - d + w * (0.3 + d * 0.05))));
          const j = sr * W + sx;
          // the lake also holds the dawn's warmth, a rose wash over the mirror
          // the palace mirrored a step darker, so the waterline reads; a pale
          // line of sky lies right along it
          const dim = (0.86 - 0.12 * smooth(0, 38, d)) * (solid[j] ? 0.8 : 1);
          const edge = d < 1 ? 0.14 : 0;
          cr = SR[j] * dim + 0.07 + edge, cg = SG[j] * dim + 0.02 + edge, cb = SB[j] * dim + 0.02 + edge * 1.1;
          // ripple lines: thin lighter and darker streaks along the surface
          const line = noise(x * 0.03 - t * 0.05, d * 2.4 - t * 0.6, 0);
          const ln = (smooth(0.6, 0.85, line) - smooth(0.4, 0.15, line) * 0.7) * (0.04 + 0.08 * smooth(0, 30, d));
          cr += ln, cg += ln, cb += ln * 1.1;
          // the sun's path: a column of gold glints under the sun
          const pathW = 4 + d * 0.45;
          const path = Math.exp(-(((x + 0.5 - SUN[0] - w * 2) / pathW) ** 2));
          const glint = smooth(0.55, 0.82, noise(x * 0.3 + t * 0.4, d * 1.4 - t * 1.2, 0)) * path;
          cr += 0.55 * glint + 0.2 * path, cg += 0.45 * glint + 0.15 * path, cb += 0.22 * glint + 0.04 * path;
        }
        // drifting mist, in front of everything; thinner over the palace body
        const mv = mist[r * MW + ((x - mo) % MW + MW) % MW] * band[r];
        if (mv > 0.01) {
          const a = mv * (r < WATER && solid[k] ? 0.4 : 0.75);
          cr = mix(cr, 0.98, a), cg = mix(cg, 0.86, a), cb = mix(cb, 0.86, a);
        }
        FR[k] = cr, FG[k] = cg, FB[k] = cb;
      }
    }

    // the boat, dark against the sun's path, rocking a little as it drifts,
    // with its boatman and pole
    const bx = 158 + 4 * Math.sin(t * 0.08), by = 79 + 0.4 * Math.sin(t * 1.3);
    const WOOD = [0.24, 0.15, 0.12], WOOD2 = [0.4, 0.26, 0.18], MAN = [0.2, 0.15, 0.19];
    // rings spreading out from the pole, behind the hull
    for (let ring = 0; ring < 2; ring++) {
      const rad = ((t * 1.4 + ring * 4.5) % 9) + 1;
      for (let a = 0; a < 60; a++) {
        const ang = (a / 60) * Math.PI * 2;
        set(bx - 9 + Math.cos(ang) * rad * 2, by + 5 + Math.sin(ang) * rad * 0.4, [0.98, 0.9, 0.86], 0.6 * (1 - rad / 10));
      }
    }
    for (let i = -12; i <= 12; i++) {
      // a long low hull, prow and stern curling up
      const rise = Math.pow(Math.abs(i) / 12, 3) * 3.2;
      set(bx + i, by - rise, WOOD2);
      if (Math.abs(i) < 11.5) set(bx + i, by + 1 - rise * 0.3, WOOD);
      if (Math.abs(i) < 9.5) set(bx + i, by + 2, WOOD);
      // its broken reflection
      if (Math.abs(i) < 10 && hash(i + Math.floor(t * 4), 9) > 0.3) set(bx + i + Math.sin(t * 2 + i) * 0.6, by + 4, [0.32, 0.26, 0.32], 0.7);
    }
    // the boatman, leaning on his pole
    for (let j = 0; j < 9; j++) set(bx + 3, by - 1 - j, MAN), j > 1 && j < 7 && set(bx + 4, by - 1 - j, MAN);
    set(bx + 5, by - 6, MAN), set(bx + 6, by - 7, MAN);
    set(bx + 3, by - 11, MAN), set(bx + 4, by - 11, MAN);
    set(bx + 3, by - 12, [0.9, 0.3, 0.28]), set(bx + 4, by - 12, [0.9, 0.3, 0.28]); // a red turban
    for (let j = 0; j < 24; j++) set(bx + 8 - j * 0.7, by - 13 + j * 0.85, [0.3, 0.22, 0.18]);

    // birds: a V that flaps; dark against the sky, white over the water
    for (const b of birds) {
      const x = ((b.x0 + b.v * t) % 260 + 260) % 260 - 30;
      const f = Math.sin(t * (b.low ? 7 : 5) + b.ph);
      const y = b.y0 + 0.8 * Math.sin(t * 0.7 + b.ph);
      const ink = b.low ? [1, 0.99, 0.96] : [0.3, 0.27, 0.36];
      set(x, y, ink);
      for (let i = 1; i <= b.span; i++) {
        const lift = -f * i * 0.7;
        set(x - i, y + lift + (i === b.span ? 0.3 : 0), ink);
        set(x + i, y + lift + (i === b.span ? 0.3 : 0), ink);
      }
      if (b.low) {
        set(x + 1, y + 0.6, ink), set(x + 2, y + 0.4, [0.95, 0.75, 0.3]); // the neck and the bill
        // its reflection just under it
        if (y + 6 < H) set(x, y + 5, [0.9, 0.88, 0.88], 0.45);
      }
    }

    for (let r = 0; r < H; r++) {
      const fade = smooth(H, H - 22, r + 0.5); // the bottom rows thin out into the ground
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const cr = FR[k], cg = FG[k], cbl = FB[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 = Math.pow(clamp((peak - 0.18) / 0.78), 1.15) * fade;
        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");
  };
}