kyoto dusk

[github]

a pagoda at dusk behind a cherry tree lit by a stone lantern

scenes 200×100 15 fps 41 colours, on a canvas 27.5 kB

use it

html

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

<ascii-art piece="kyoto-dusk"></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 { kyotoDusk } from "ascii.rest/pieces";

<Ascii piece={kyotoDusk} />

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

astro

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

<Ascii piece="kyoto-dusk" />

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

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

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.

kyoto-dusk.ts

[js] [github]
/*
 * kyoto dusk: a five-storey pagoda dark against an indigo to rose sky with a
 * thin crescent moon, a temple pond holding its reflection, and in front a
 * cherry tree in full bloom lit from below by a stone lantern. Petals fall
 * and drift through the frame, the lantern flickers, the pond shivers.
 *
 * Shaded in colour cell by cell, then drawn as a halftone: each cell is a dot
 * sized by its brightness, ordered-dithered, in the palette colour nearest its
 * hue, with the colour brightened or dimmed to make up for the dot's size.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "kyoto dusk",
  category: "scenes",
  note: "a pagoda at dusk behind a cherry tree lit by a stone lantern",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#0b0a16",
  palette: [
    "#1c1d4a", "#262759", "#33316b", "#433d7c", "#574a8c", "#6e5a9a",
    "#8a5a92", "#a8668f", "#c4748f", "#db8a95", "#ec9f9c", "#f6b8a8", "#fbd0b8",
    "#fff1d8", "#fffaf0", "#7e5f9e",
    "#f9d3dc", "#f2b2c4", "#e28fab", "#c66e92", "#9c5279", "#74405f",
    "#ffd9a0", "#ffbb6a", "#f29a4a", "#d0763a", "#a35a35",
    "#2a2240", "#3a2c50", "#4a3860",
    "#5b4f86", "#7a6aa0",
    "#2c3a3a", "#3d4c46", "#56604f",
    "#9a6aa0", "#b47ea6",
    "#4a3038", "#6a4448",
    "#2b3a6a", "#3e4f86",
  ],
} satisfies Meta;

const W = 200, H = 100;
const SHORE = 80; // the far bank of the pond, where the pagoda stands
const PX = 141; // the pagoda's axis
const MOON = [176, 17];
const LX = 53, LB = 98, LS = 1.8; // the lantern's axis, foot and scale
const LAMP = [LX, LB - (LB - 83) * LS]; // its lit opening
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 SKY = 0, HILL = 1, TOWN = 2, PAGODA = 3, POND = 4, BANK = 5, TREE = 6, BLOOM = 7, STONE = 8, FLAME = 9;

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, 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);

// distance from a point to a segment, and how far along it the nearest point is
function seg(px: number, py: number, ax: number, ay: number, bx: number, by: number): [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), k];
}

/*
 * The pagoda, in cells about its axis: returns a shade code or 0 for air.
 * 1 body, 2 roof top, 3 roof underside, 4 roof rim, 5 spire, 6 lit doorway.
 */
const EAVE = [68.5, 58, 48.5, 40, 32.5]; // where each roof's eave sits
const ROOF = [14.5, 13.4, 12.3, 11.2, 10.1]; // each roof's half-width
const BODY = [6.4, 5.9, 5.4, 4.9, 4.4];
function pagoda(dx: number, y: number): number {
  const ax = Math.abs(dx);
  // stone base
  if (y >= 76 && y < SHORE + 0.5) return ax <= 9.5 - (y < 77 ? 1 : 0) ? 1 : 0;
  for (let i = 0; i < 5; i++) {
    const e = EAVE[i], R = ROOF[i];
    // a roof: thin at its upturned tips, rising in a shallow concave curve to the body
    const u = ax / R;
    if (u <= 1) {
      const lift = 3.5 * u * u * u * u;
      const bottom = e - lift + 0.6;
      const top = e - lift - 1.2 - 3.6 * Math.pow(1 - u, 1.7);
      if (y >= top && y < bottom) {
        if (y < top + 0.9) return 4;
        if (y > bottom - 1.0) return 3;
        return 2;
      }
    }
    // the storey beneath this roof, up from the roof below it (or the base)
    const floor = i === 0 ? 76 : EAVE[i - 1] - 3.6;
    if (y >= e + 0.6 && y < floor && ax <= BODY[i]) {
      if (i === 0 && ax <= 1 && y > 72 && y < 75.5) return 6;
      // a railed balcony under each roof
      if (y < e + 2 && ax <= BODY[i] + 1.2) return 3;
      return 1;
    }
    if (i > 0 && y >= e + 0.6 && y < e + 2 && ax <= BODY[i] + 1.2) return 3;
  }
  // the spire: a mast with nine rings and a flame-shaped finial
  const top = EAVE[4] - 1.2 - 3.6 - 0.2;
  if (y < top && y >= 9) {
    if (y >= top - 1.5) return ax <= 2.4 ? 3 : 0; // the roof box
    if (y >= 15 && y < top - 1.5) {
      const ring = Math.floor((y - 15) / 1.2) & 1;
      return ax <= (ring ? 1.4 : 0.55) ? 5 : 0;
    }
    if (y >= 12) return ax <= 1.3 - (y - 12) * 0.2 ? 5 : 0;
    return ax <= 0.6 ? 5 : 0;
  }
  return 0;
}

export default function kyotoDusk(): Frame {
  const P = meta.palette.map(hex);
  const N = W * H;
  const out = new Array<string>(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 < 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);
  };

  const mat = new Uint8Array(N);
  const R = new Float32Array(N), G = new Float32Array(N), B = new Float32Array(N);
  const floorA = new Float32Array(N).fill(0.12);
  const warmth = new Float32Array(N); // how much of the lantern's light each cell takes

  // --- sky ----------------------------------------------------------------
  const skyAt = (x: number, y: number): [number, number, number] => {
    const v = clamp(y / SHORE);
    // indigo overhead, through violet, to rose and peach low in the west (left)
    const west = Math.exp(-Math.abs(x - 80) / 120);
    let a = smooth(0, 0.5, v);
    let r = mix(0.07, 0.22, a), g = mix(0.07, 0.14, a), b = mix(0.25, 0.38, a);
    a = smooth(0.42, 0.86, v);
    r = mix(r, 0.52, a), g = mix(g, 0.32, a), b = mix(b, 0.58, a);
    a = smooth(0.78, 0.98, v) * (0.55 + 0.45 * west);
    r = mix(r, 1.0, a), g = mix(g, 0.62, a), b = mix(b, 0.48, a);
    const band = Math.exp(-Math.abs(y - 74) / 4) * west * 0.18;
    r += band, g += band * 0.66, b += band * 0.45;
    // a faint unevenness, so no stretch of sky is one flat tone
    const veil = 0.86 + 0.28 * fbm(x * 0.035, y * 0.09, 3, 0);
    r *= veil, g *= veil, b *= veil;
    // the moon's halo
    const dx = x - MOON[0], dy = y - MOON[1];
    const d = Math.sqrt(dx * dx + dy * dy);
    const halo = Math.exp(-d / 5) * 0.24 + Math.exp(-d / 16) * 0.07;
    r += halo * 0.75, g += halo * 0.72, b += halo;
    return [r, g, b];
  };

  // --- the far side: hills, town roofs, the pagoda -------------------------
  // low in the west where the glow is, rising behind the pagoda and the town
  const hillA = (x: number) => 77.5 - (3 + 13 * smooth(70, 175, x)) * (0.3 + 1.1 * fbm(x * 0.022 + 3, 1, 4, 0));
  const hillB = (x: number) => 77 - 2.5 * fbm(x * 0.04 + 9, 2, 4, 0);
  // low tiled roofs along the far bank, a few lit
  const town = (x: number) => {
    const i = Math.floor((x + 3) / 11);
    const f = (x + 3) / 11 - i;
    // a hipped roof: a short level ridge, sloping ends, a gap between houses
    const h = 2 + hash(i, 5) * 2.5;
    const e = Math.abs(f - 0.5) * 2;
    return e > 0.86 ? SHORE : SHORE - 1 - h + Math.max(0, e - 0.35) * 6;
  };

  // --- the cherry tree's skeleton ----------------------------------------
  const branches: number[][] = [
    // trunk, leaning up and to the right
    [16, 101, 20, 86, 5.2, 4.4], [20, 86, 27, 70, 4.4, 3.6], [27, 70, 31, 58, 3.6, 3],
    // limbs
    [31, 58, 50, 46, 3, 2.1], [50, 46, 74, 37, 2.1, 1.4], [74, 37, 98, 31, 1.4, 0.9], [98, 31, 116, 30, 0.9, 0.5],
    [31, 58, 24, 40, 2.6, 1.8], [24, 40, 12, 26, 1.8, 1.1], [12, 26, 0, 18, 1.1, 0.7],
    [29, 50, 42, 30, 2, 1.3], [42, 30, 52, 19, 1.3, 0.8], [52, 19, 70, 13, 0.8, 0.5],
    [50, 46, 60, 52, 1.2, 0.7], [74, 37, 86, 44, 1, 0.5], [42, 30, 66, 24, 1, 0.6], [66, 24, 90, 21, 0.6, 0.4],
    [24, 40, 34, 25, 1, 0.6], [12, 26, 6, 16, 0.7, 0.4], [98, 31, 104, 40, 0.6, 0.35],
    // sprays drooping low over the lantern
    [40, 51, 44, 58, 1.1, 0.4], [58, 43, 62, 55, 0.9, 0.35], [70, 38, 76, 47, 0.7, 0.3],
  ];
  // short twigs off every limb, reaching up and out
  const limbs = branches.length;
  for (let b = 3; b < limbs; b++) {
    const [ax, ay, bx, by, w0, w1] = branches[b];
    const ang = Math.atan2(by - ay, bx - ax);
    for (let j = 0; j < 3; j++) {
      const k = 0.25 + 0.65 * hash(b * 7 + j, 31);
      const sx = mix(ax, bx, k), sy = mix(ay, by, k);
      const turn = (j & 1 ? 1 : -1) * (0.5 + 0.6 * hash(b, j + 40));
      const len = 4 + 7 * hash(b + j, 41);
      const a2 = ang + turn - 0.25;
      branches.push([sx, sy, sx + Math.cos(a2) * len, sy + Math.sin(a2) * len, mix(w0, w1, k) * 0.55, 0.3]);
    }
  }
  // blossom clumps all along the limbs and twigs, thickest at their ends
  const clusters: [number, number, number][] = [];
  for (let b = 3; b < branches.length; b++) {
    const [ax, ay, bx, by, w0] = branches[b];
    const len = Math.hypot(bx - ax, by - ay);
    const n = 1 + Math.round(len / 1.7);
    for (let i = 0; i <= n; i++) {
      const k = i / n;
      if (k < 0.3 && w0 > 2) continue;
      const h1 = hash(b * 13 + i, 7), h2 = hash(b * 5 + i, 11), h3 = hash(b * 3 + i, 17);
      const cx = mix(ax, bx, k) + (h1 - 0.5) * 6, cy = mix(ay, by, k) + (h2 - 0.5) * 4.5 - 1;
      // only the drooping sprays (and their twigs) hang below the crown
      const spray = (b >= limbs - 3 && b < limbs) || b >= limbs + 3 * (limbs - 6);
      // the crown is domed: thinner toward its top, never cut by the frame
      const dome = 9 + 10 * Math.pow(clamp(Math.abs(cx - 52) / 70), 1.6);
      if (cx > 122 || cy < dome || cy > (spray ? 55 : 47)) continue;
      clusters.push([cx, cy, 1.4 + 1.6 * h3 + 0.8 * k]);
    }
  }

  const bankX = (y: number) => 38 + (y - SHORE) * 2.3 + 4 * fbm(y * 0.2, 4, 2, 0);

  // build the static picture
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const y = r + 0.5, xc = x + 0.5;
      let m = SKY, cr: number | undefined, cg: number | undefined, cb: number | undefined, fl = 0.12, warm = 0;
      if (y < SHORE) {
        [cr, cg, cb] = skyAt(xc, y);
        fl = 0.04;
        // distant hills in haze, then a nearer ridge
        if (y >= hillA(xc)) {
          // the Higashiyama hills, flat and violet in the haze
          m = HILL;
          const s = skyAt(xc, hillA(xc));
          const d = smooth(hillA(xc), hillA(xc) + 8, y);
          // wooded slopes: clumps of trees in the haze
          const tex = fbm(xc * 0.3, y * 0.45, 3, 0);
          const a = 0.6 + 0.2 * d + 0.25 * (tex - 0.5);
          cr = mix(s[0], 0.14, a), cg = mix(s[1], 0.08, a), cb = mix(s[2], 0.24, a);
          // the ridge line catches the afterglow
          const lip = smooth(hillA(xc) + 1.4, hillA(xc), y) * 0.3;
          cr = mix(cr, s[0] * 1.1, lip), cg = mix(cg, s[1] * 1.05, lip), cb = mix(cb, s[2], lip);
        }
        if (y >= hillB(xc)) {
          m = HILL;
          const tex = fbm(xc * 0.3, y * 0.3, 3, 0);
          cr = 0.1 + 0.05 * tex, cg = 0.07 + 0.03 * tex, cb = 0.17 + 0.05 * tex;
          // mist settling along the water
          const mist = smooth(hillB(xc) + 1, SHORE, y) * 0.5;
          cr = mix(cr, 0.7, mist), cg = mix(cg, 0.44, mist), cb = mix(cb, 0.58, mist);
        }
        if (y >= town(xc) && xc > 70) {
          m = TOWN;
          cr = 0.08, cg = 0.06, cb = 0.14;
          // the roof ridges catch the sky
          const tf = Math.abs(((xc + 3) / 11) % 1 - 0.5) * 2;
          if (y < town(xc) + 0.9 && tf < 0.45) (cr = 0.5), (cg = 0.31), (cb = 0.44), (fl = 0.06);
          // a few lit shoji under the eaves
          const wi = Math.floor(xc / 4);
          if (y > SHORE - 1.6 && y < SHORE - 0.5 && (xc % 4) < 1.2 && hash(wi, 7) > 0.74) {
            (cr = 1), (cg = 0.66), (cb = 0.32);
            fl = 0.3;
          }
        }
        const p = pagoda(xc - PX, y);
        if (p) {
          m = PAGODA;
          const dx = xc - PX;
          const rim = Math.exp(-Math.abs(dx + 4) / 30); // the sky to the west lights its left
          // dark timber; the tiled roofs pick up the sky, their ridges most of all
          if (p === 1) (cr = 0.07), (cg = 0.05), (cb = 0.12);
          else if (p === 2) (cr = 0.2 + 0.08 * rim), (cg = 0.13 + 0.03 * rim), (cb = 0.27 + 0.04 * rim);
          else if (p === 3) (cr = 0.05), (cg = 0.04), (cb = 0.1);
          else if (p === 4) (cr = 0.9 * rim + 0.25), (cg = 0.5 * rim + 0.15), (cb = 0.5 * rim + 0.3);
          else if (p === 5) (cr = 0.3 + 0.2 * (dx < 0 ? 1 : 0)), (cg = 0.22), (cb = 0.32);
          else if (p === 6) (cr = 0.7), (cg = 0.42), (cb = 0.24), (fl = 0.2);
        }
      } else {
        // the pond, and the near bank where the lantern stands
        if (xc < bankX(y)) {
          m = BANK;
          const tex = fbm(xc * 0.3, y * 0.5, 3, 0);
          cr = 0.08 + 0.05 * tex, cg = 0.08 + 0.05 * tex, cb = 0.1 + 0.05 * tex;
          // fallen petals on the moss
          if (hash(x * 7, r * 3) > 0.975 - 0.015 * smooth(SHORE + 4, H, y)) (cr = 0.42), (cg = 0.22), (cb = 0.3), (fl = 0.06);
          // the stone lip of the pond
          if (xc > bankX(y) - 2.6) {
            const s = 0.85 + 0.3 * hash(x * 3, r * 5);
            (cr = 0.45 * s), (cg = 0.38 * s), (cb = 0.46 * s), (fl = 0.1);
          }
          warm = 1;
        } else m = POND;
      }
      mat[k] = m;
      R[k] = cr || 0, G[k] = cg || 0, B[k] = cb || 0;
      floorA[k] = fl;
      warmth[k] = warm;
    }
  }
  // the reflection source: the far side before the tree covers it
  const RR = R.slice(), RG = G.slice(), RB = B.slice(), RM = mat.slice();

  // --- the stone lantern ----------------------------------------------------
  const lantern = (dx: number, y: number): number => {
    const ax = Math.abs(dx);
    if (y >= 93.5 && y < LB) return ax <= 4.2 - (y < 94.5 ? 0.8 : 0) ? 1 : 0; // foot
    if (y >= 87 && y < 93.5) return ax <= 1.4 ? 1 : 0; // post
    if (y >= 85.5 && y < 87) return ax <= 3.6 - (y < 86.2 ? 0.6 : 0) ? 1 : 0; // platform
    if (y >= 80 && y < 85.5) {
      if (ax <= 1.7 && y >= 80.8 && y < 84.8) return 2; // lit opening
      return ax <= 2.9 ? 1 : 0;
    }
    if (y >= 76.5 && y < 80) {
      // the roof, flaring out with upturned corners
      const u = (80 - y) / 3.5;
      const hw = 5.6 - 4.1 * Math.pow(u, 0.8) + (y > 79.2 ? 0.6 : 0);
      return ax <= hw ? 3 : 0;
    }
    if (y >= 73.5 && y < 76.5) return ax <= 1.3 - Math.abs(y - 75) * 0.25 ? 3 : ax <= 0.5 ? 3 : 0; // finial
    return 0;
  };
  // drawn a size up from its plan, standing on the bank
  const at = (xc: number, y: number) => lantern((xc - LX) / LS, LB - (LB - y) / LS);
  for (let r = 45; r < H; r++) {
    for (let x = LX - 14; x <= LX + 14; x++) {
      const y = r + 0.5, xc = x + 0.5;
      const l = at(xc, y);
      if (!l) continue;
      const k = r * W + x;
      const py = LB - (LB - y) / LS, pdx = (xc - LX) / LS;
      if (l === 2) {
        mat[k] = FLAME;
        // hottest at the heart of the firebox
        const c = Math.exp(-(pdx * pdx * 0.5 + (py - 82.8) * (py - 82.8) * 0.35));
        (R[k] = 0.85 + 0.15 * c), (G[k] = 0.5 + 0.38 * c), (B[k] = 0.2 + 0.4 * c * c), (floorA[k] = 0.35);
      } else {
        mat[k] = STONE;
        // dark granite, its top surfaces catching the last of the sky
        const g = 0.8 + 0.4 * hash(x * 5, r * 3);
        const edge = !at(xc, y - 1) ? 1 : 0;
        (R[k] = (0.05 + 0.22 * edge) * g), (G[k] = (0.04 + 0.14 * edge) * g), (B[k] = (0.08 + 0.2 * edge) * g);
        // the roof's underside and the platform take the flame's light
        const under = (l === 3 && py > 78.6) || (py >= 85.5 && py < 86.4);
        if (under) {
          const f = Math.exp(-Math.abs(py - 82.8) / 3) * 0.75;
          (R[k] += f), (G[k] += f * 0.5), (B[k] += f * 0.18);
        }
        floorA[k] = 0.02;
      }
      warmth[k] = l === 2 ? 0 : 0.75;
    }
  }

  // --- the cherry tree -------------------------------------------------------
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < 130; x++) {
      const k = r * W + x;
      const y = r + 0.5, xc = x + 0.5;
      // blossom density: soft clouds, broken up by noise into clumps
      let d = 0, best = 0, up = 0;
      for (const [cx, cy, rad] of clusters) {
        const dx = xc - cx, dy = (y - cy) * 1.15;
        const q = (dx * dx + dy * dy) / (rad * rad);
        if (q < 3) {
          const c = Math.exp(-q * 1.4);
          d += c;
          if (c > best) (best = c), (up = (dy + dx * 0.4) / rad);
        }
      }
      // big holes where the sky shows through, small ones between clumps
      const n = fbm(xc * 0.11, y * 0.15, 3, 0), n2 = noise(xc * 0.5, y * 0.6, 0);
      const bloom = (1 - Math.exp(-d * 1.5)) * (0.15 + 1.35 * n) * (0.75 + 0.5 * n2);
      let onBranch = false, bw = 0;
      for (const [ax, ay, bx, by, w0, w1] of branches) {
        const [dist, kk] = seg(xc, y, ax, ay, bx, by);
        const w = mix(w0, w1, kk) * 0.5 + 0.15;
        if (dist < w) {
          onBranch = true;
          bw = Math.max(bw, (dist / w) * (xc < ax + (bx - ax) * kk ? -1 : 1));
        }
      }
      if (onBranch && bloom < 0.85) {
        mat[k] = TREE;
        // dark bark threading through the blossom; the side toward the lantern catches it
        const lit = smooth(0.35, 1, bw);
        (R[k] = 0.05 + 0.03 * lit), (G[k] = 0.035 + 0.01 * lit), (B[k] = 0.07);
        floorA[k] = 0;
        warmth[k] = 0.8;
      } else if (bloom > 0.5) {
        mat[k] = BLOOM;
        // each spray pale pink on top where the sky lights it, deep rose beneath
        const inner = hash(x * 3, r * 7);
        let b = clamp(0.62 - 0.6 * up + 0.26 * (inner - 0.5) - 0.3 * smooth(0.75, 0.5, bloom));
        b = mix(b, 0.12, smooth(0.3, 1.1, up));
        // the lower crown sits in its own shade
        b *= 1 - 0.3 * smooth(28, 48, y);
        (R[k] = 0.34 + 0.64 * b), (G[k] = 0.08 + 0.62 * b * b), (B[k] = 0.2 + 0.5 * b);
        floorA[k] = 0.1;
        warmth[k] = 1;
      }
    }
  }

  // the lantern's light: how far each cell sits from the lit opening
  const glow = new Float32Array(N);
  for (let r = 0; r < H; r++)
    for (let x = 0; x < W; x++) {
      const dx = x + 0.5 - LAMP[0], dy = (r + 0.5 - LAMP[1]) * 1.1;
      const d = Math.sqrt(dx * dx + dy * dy);
      glow[r * W + x] = Math.exp(-d / 4) * 0.6 + Math.exp(-d / 11) * 0.32 + Math.exp(-d / 32) * 0.1;
      // the blossom overhead takes the lamp's light from below, from further off
      const m = mat[r * W + x];
      if (m === BLOOM || m === TREE) glow[r * W + x] += Math.exp(-d / 11) * 1.6;
      // and a pool of light on the moss round its foot
      if (m === BANK) {
        const px = (x + 0.5 - LX) / 20, py = (r + 0.5 - LB + 3) / 8;
        glow[r * W + x] += Math.exp(-(px * px + py * py)) * 0.6;
      }
    }

  // a few faint stars in the indigo
  const stars: [number, number, number][] = [];
  for (let i = 0; i < 400; i++) {
    const x = Math.floor(hash(i, 91) * W), r = Math.floor(hash(i, 92) * 40);
    const k = r * W + x;
    if (mat[k] === SKY && hash(i, 93) > 0.72) stars.push([k, hash(i, 94) * 6.28, 0.6 + hash(i, 95) * 1.6]);
  }

  // thin streaks of cloud low in the sky, dark violet, their undersides lit
  // by the set sun; they wrap so they can drift forever
  const CW = 500, C0 = 34, C1 = 68;
  const cdens = (x: number, y: number) =>
    fbm(x * 0.014, y * 0.13, 4, CW * 0.014) - 0.3 * (1 - smooth(C0, C0 + 10, y) * smooth(C1, C1 - 8, y));
  const ccov = new Float32Array(CW * (C1 - C0)), clit = new Float32Array(CW * (C1 - C0));
  for (let r = C0; r < C1; r++)
    for (let x = 0; x < CW; x++) {
      const y = r + 0.5, d = cdens(x, y);
      ccov[(r - C0) * CW + x] = smooth(0.56, 0.7, d);
      clit[(r - C0) * CW + x] = clamp(0.5 + (d - cdens(x - 1, y + 2.2)) * 7);
    }

  // petals: each with its own drift, fall, sway and tumble
  const petals: { x: number; y: number; vx: number; vy: number; sw: number; sf: number; ph: number; tum: number }[] = [];
  for (let i = 0; i < 50; i++)
    petals.push({
      // most of them near the tree, thinning out downwind
      x: Math.pow(hash(i, 101), 2.2) * 140, y: hash(i, 102) * (H + 20),
      vx: 3 + hash(i, 103) * 4, vy: 1.6 + hash(i, 104) * 2.2,
      sw: 1 + hash(i, 105) * 2, sf: 0.6 + hash(i, 106) * 1.2, ph: hash(i, 107) * 6.28, tum: 2 + hash(i, 108) * 4,
    });
  // petals afloat on the pond, drifting slowly
  const floaters: [number, number, number][] = [];
  for (let i = 0; i < 26; i++) floaters.push([hash(i, 111) * W, SHORE + 3 + hash(i, 112) * 20, 0.3 + hash(i, 113) * 0.5]);

  const dith = new Float32Array(N);
  for (let k = 0; k < N; k++) dith[k] = BAYER[((Math.floor(k / W)) & 3) * 4 + ((k % W) & 3)] * 0.4 + (hash(k, 77) - 0.5) * 0.5;
  const tone = new Float32Array(1025);
  for (let i = 0; i <= 1024; i++) {
    const v = i / 256;
    tone[i] = v < 0.75 ? v : 0.75 + 0.25 * (1 - Math.exp(-(v - 0.75) * 4));
  }

  const pr = new Float32Array(N), pg = new Float32Array(N), pb = new Float32Array(N);
  const pmask = new Uint8Array(N);

  return (t, { color } = {}) => {
    // the flame breathes, with now and then a gutter
    const flick = 0.82 + 0.1 * Math.sin(t * 7.3) * Math.sin(t * 3.1 + 1) + 0.12 * (noise(t * 4, 3.3, 0) - 0.5) * 2;
    const cdrift = (((t * 0.8) % CW) + CW) % CW;

    // petals in the air this frame
    pmask.fill(0);
    for (const p of petals) {
      const xx = ((((p.x + p.vx * t + p.sw * Math.sin(t * p.sf + p.ph)) % (W + 40)) + W + 40) % (W + 40)) - 20;
      const yy = ((((p.y + p.vy * t + 0.6 * Math.sin(t * p.sf * 1.7 + p.ph)) % (H + 20)) + H + 20) % (H + 20)) - 10;
      const x = Math.round(xx), r = Math.round(yy);
      if (x < 0 || x >= W || r < 0 || r >= H) continue;
      const k = r * W + x;
      // tumbling, catching light; dim against the dark bank and water
      const face = (0.55 + 0.45 * Math.abs(Math.sin(t * p.tum + p.ph))) * (mat[k] === POND || mat[k] === BANK ? 0.6 : 1);
      const lg = glow[k] * flick * 1.6;
      pr[k] = (1.0 * face + lg * 0.6), pg[k] = (0.76 * face + lg * 0.35), pb[k] = (0.84 * face + lg * 0.1);
      pmask[k] = 1;
    }

    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: number, cg: number, cb: number, fl = floorA[k], fade = 1;
        if (m === POND) {
          // the far side, upside down, shaken by small ripples
          const depth = (y - SHORE) / (H - SHORE);
          const wob = Math.sin(y * 1.7 - t * 2 + Math.sin(x * 0.13 + t * 0.6) * 1.4) * (0.05 + 0.8 * depth);
          let sx = Math.round(x + wob);
          sx = sx < 0 ? 0 : sx > W - 1 ? W - 1 : sx;
          const w = noise(x * 0.14 + t * 0.2, y * 1.1 - t * 0.8, 0);
          // ripples also tip the image up and down, so level lines break
          // (drawn a little stretched, so the pagoda's lower roofs land in the pond)
          let sr = Math.round(SHORE - 1 - (r - SHORE) * 1.35 + (w - 0.5) * 3 * depth);
          sr = sr < 0 ? 0 : sr > SHORE - 1 ? SHORE - 1 : sr;
          const j = sr * W + sx;
          // crisp and bright right under the bank, darker further out
          const lit = mix(0.95, 0.6, smooth(SHORE + 1, SHORE + 6, y)) * (0.8 + 0.4 * w);
          cr = RR[j] * lit * 0.9, cg = RG[j] * lit * 0.92, cb = RB[j] * lit + 0.03;
          const glint = smooth(0.74, 0.95, w) * 0.06;
          cr += glint, cg += glint * 0.8, cb += glint;
          // the lantern's light laid on the water as a broken warm streak
          const sl = Math.exp(-Math.abs(x + 0.5 - LX - 9 - (y - SHORE) * 0.25) / (3.5 + depth * 3)) * smooth(0.45, 0.8, w) * 1.0 * flick;
          cr += sl, cg += sl * 0.62, cb += sl * 0.25;
          fade = smooth(H + 2, H - 9, y);
        } else {
          cr = R[k], cg = G[k], cb = B[k];
          if (m === SKY && r >= C0 && r < C1) {
            const sx = x + cdrift, ix = Math.floor(sx), fx = sx - ix;
            const i0 = (r - C0) * CW + (ix % CW), i1 = (r - C0) * CW + ((ix + 1) % CW);
            const c = ccov[i0] + (ccov[i1] - ccov[i0]) * fx;
            if (c > 0.01) {
              const l = clit[i0] + (clit[i1] - clit[i0]) * fx;
              // brighter undersides toward the west and the horizon
              const sun = l * (0.45 + 0.55 * smooth(C0, C1, y)) * (0.6 + 0.4 * Math.exp(-Math.abs(x - 80) / 90));
              const a = c * 0.85;
              cr = mix(cr, mix(0.2, 1.0, sun), a), cg = mix(cg, mix(0.12, 0.58, sun), a), cb = mix(cb, mix(0.27, 0.5, sun), a);
            }
          }
        }
        // lamplight on everything near the lantern
        const wm = warmth[k];
        if (wm > 0) {
          const g = glow[k] * flick * wm;
          cr += g * 1.0, cg += g * 0.58, cb += g * 0.22;
        } else if (m === SKY || m === POND) {
          const g = glow[k] * flick * 0.6;
          cr += g, cg += g * 0.6, cb += g * 0.3;
        }
        if (m === FLAME) {
          const f = 0.85 + 0.25 * flick;
          (cr = f), (cg = 0.72 * f), (cb = 0.38 * f);
        }
        if (pmask[k] && m !== FLAME) {
          cr = pr[k], cg = pg[k], cb = pb[k], fl = 0.3;
        }

        cr = tone[Math.min(1024, (cr * 256) | 0)], cg = tone[Math.min(1024, (cg * 256) | 0)], cb = tone[Math.min(1024, (cb * 256) | 0)];
        const peak = Math.max(cr, cg, cb, 1e-4);
        const level = clamp(fl + (1 - fl) * Math.pow(peak, 1.1) * 1.02) * fade;
        const step = Math.max(0, Math.min(3, Math.round(level * 3 + dith[k])));
        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(cb * s));
        }
      }
    }
    // the moon: a thin crescent, lit on the side toward the set sun
    for (let r = MOON[1] - 6; r <= MOON[1] + 6; r++) {
      for (let x = MOON[0] - 6; x <= MOON[0] + 6; x++) {
        const dx = x + 0.5 - MOON[0], dy = r + 0.5 - MOON[1];
        const inside = dx * dx + dy * dy < 5.2 * 5.2;
        const ex = dx - 2.2, ey = dy + 1.6; // the shadowed disc, offset up and right
        if (inside && ex * ex + ey * ey > 4.9 * 4.9) {
          const k = r * W + x;
          out[k] = "●";
          if (color) color[k] = nearest(1, 0.96, 0.86);
        }
      }
    }
    // stars twinkle
    for (const [k, ph, sp] of stars) {
      const s = Math.sin(t * sp + ph);
      if (s > -0.3) {
        out[k] = s > 0.7 ? "•" : "·";
        if (color) color[k] = nearest(0.9, 0.88, 1);
      }
    }
    // petals resting on the pond
    for (const [fx, fy, sp] of floaters) {
      const x = Math.round((fx + t * sp) % W), r = Math.round(fy + 0.3 * Math.sin(t * 0.8 + fx));
      if (r >= H) continue;
      const k = r * W + x;
      if (mat[k] !== POND) continue;
      out[k] = "•";
      if (color) color[k] = nearest(0.95, 0.72, 0.8);
    }
    const lines: string[] = [];
    for (let r = 0; r < H; r++) lines.push(out.slice(r * W, (r + 1) * W).join(""));
    return lines.join("\n");
  };
}