storm plains

[github]

an anvil thunderhead at dusk flickering over a wheat field

scenes 200×100 15 fps 42 colours, on a canvas 33.8 kB

use it

html

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

<ascii-art piece="storm-plains"></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 { stormPlains } from "ascii.rest/pieces";

<Ascii piece={stormPlains} />

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

astro

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

<Ascii piece="storm-plains" />

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

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

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.

storm-plains.ts

[js] [github]
/*
 * storm plains: an anvil thunderhead at dusk over open wheat country. The
 * sun has just set behind the farmhouse, so the storm's top and western flank
 * still catch its light while the base sinks into slate shadow. Lightning
 * flickers inside the cloud, now and then a bolt reaches the ground, rain
 * curtains drift under the base and the wheat moves in gusts.
 *
 * The cloud is built as a height field (heaped domes, a flat anvil, pouches
 * hanging under it) and lit from its surface normals. Every cell is shaded in
 * colour, then drawn as a halftone: dot size from brightness with an ordered
 * dither, colour from the palette entry nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "storm plains",
  category: "scenes",
  note: "an anvil thunderhead at dusk flickering over a wheat field",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#0b0912",
  palette: [
    // night violets, zenith to dusk
    "#0f0c20", "#141029", "#1c1638", "#261c4a", "#32235a", "#40306c", "#53407e", "#6a5590", "#8670a6",
    // dusty mauve, the shaded flank
    "#7a6288", "#9a7890", "#b98e94",
    // peach and gold, the sunlit tops
    "#e6a890", "#f2bca8", "#f7d6a8", "#fbe8c6",
    // amber afterglow
    "#f8b070", "#e88a4c", "#c8643a", "#9a4630", "#6a3226",
    // wheat, gold to umber
    "#f0c868", "#d8a447", "#b7843a", "#8c6230", "#5e4226", "#3a2a1c", "#261c14",
    // rain and slate
    "#181a2c", "#2a3048", "#3a4260", "#4f5878", "#6a7392", "#8890ae", "#a2aabd", "#5d5878", "#7c7598",
    // lightning
    "#a6a2c8", "#d6d2fa", "#f4f2ff",
    // lamplight
    "#ffe08a", "#ffb84a",
  ],
} satisfies Meta;

const W = 200, H = 100;
const HZ = 64; // the horizon
const SUN = [30, 70]; // just below the horizon, behind the farmhouse
const BASE = 45; // the storm's flat base
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, PLAIN = 1, FIELD = 2, ROAD = 3, HOUSE = 4, ROOF = 5, PANE = 6, TREE = 7, PUMP = 8, BELT = 9;

function hash(x: number, y: 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) {
  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) {
  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);
const dome = (dx: number, dy: number, r: number) => {
  const q = 1 - dx * dx - dy * dy;
  return q > 0 ? r * Math.sqrt(q) : 0;
};

// Distance from (px, py) to the segment a-b.
function segDist(px: number, py: number, ax: number, ay: number, bx: number, by: number) {
  const dx = bx - ax, dy = by - ay;
  const k = clamp(((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1));
  const ex = px - ax - dx * k, ey = py - ay - dy * k;
  return Math.sqrt(ex * ex + ey * ey);
}

export default function stormPlains(): Frame {
  const P = meta.palette.map(hex);
  const STALK = meta.palette.indexOf("#8c6230");
  const N = W * H;
  const out: string[] = new Array(N);

  const lut = new Uint8Array(32768).fill(255);
  const nearest = (r: number, g: number, b: 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 thunderhead, as a height field ----------------------------------
  const towerC = (y: number) => 138 + (BASE - y) * 0.14;
  const towerHW = (y: number) => 23 - (BASE - y) * 0.08;
  const anvilTop = (x: number) => 7.5 + 2.5 * smooth(150, 210, x) + 3 * smooth(112, 58, x);
  const anvilBot = (x: number) => 21 - 9 * smooth(124, 62, x) - 7 * smooth(152, 210, x);
  // The towers are heaps of puffs: [x, y, radius, bulge toward us]. Each puff
  // is a dome in the height field, so it gets its own lit side and shadow.
  const puffs: [number, number, number, number, number][] = [];
  let seed = 1;
  const rnd = () => hash(seed++, 911);
  for (let y = BASE - 4.5; y > 14; y -= 4.4) {
    const cx = towerC(y), hw = towerHW(y);
    const n = Math.max(2, Math.round((hw * 2) / 11));
    for (let i = 0; i < n; i++) {
      const u = ((i + 0.5) / n) * 2 - 1;
      const rr = 7 + rnd() * 4;
      puffs.push([cx + u * (hw - rr * 0.55) + (rnd() - 0.5) * 3, y + (rnd() - 0.5) * 2, rr, 4 * Math.sqrt(1 - u * u * 0.85), 1]);
    }
  }
  // the overshooting top, bulging out of the anvil
  puffs.push([141, 8.5, 9, 2, 0], [133, 9.5, 6, 1, 0], [149, 10, 6, 1, 0]);
  // the flanking line: younger towers stepping down to the west
  for (const [cx, top, w] of [
    [104, 27, 9],
    [89, 34, 6.5],
    [77, 40.5, 3.8],
  ]) {
    for (let y = BASE - w * 0.4; y > top + w * 0.6; y -= w * 0.75) puffs.push([cx + (rnd() - 0.5) * w * 0.6, y, w * (0.75 + rnd() * 0.2), 1, 1]);
    puffs.push([cx - w * 0.35, top + w * 0.75, w * 0.62, 1, 1], [cx + w * 0.3, top + w * 0.6, w * 0.7, 1.5, 1], [cx, top + w * 0.45, w * 0.55, 2, 1]);
  }
  // one continuous low base under the line, so the towers stand on something
  for (let x = 66; x < 126; x += 3.2 + rnd() * 1.6) puffs.push([x, BASE - 1.8 - rnd() * 1.2, 3 + rnd() * 1.6 + 1.6 * smooth(70, 110, x), 0.4, 1]);

  const SX = W + 4, SY = BASE + 8; // the grid, two cells of margin either side
  const sh = new Float32Array(SX * SY);
  const anv = new Float32Array(SX * SY); // how much of the height is anvil
  const lip = new Float32Array(SX * SY); // the anvil's sunlit top edge
  for (let r = 0; r < SY; r++) {
    for (let i = 0; i < SX; i++) {
      const x = i - 2 + 0.5, y = r + 0.5;
      // the towers, cut flat along the base
      let tower = 0;
      const cut = smooth(BASE + 1, BASE - 1.5, y + 1.2 * (noise(x * 0.15, 3.3, 0) - 0.5));
      for (const [px, py, pr, pz, flat] of puffs) {
        const dx = (x - px) / pr, dy = (y - py) / pr;
        if (dx * dx + dy * dy >= 1) continue;
        const v = (pr * 0.6 + pz) * Math.sqrt(1 - dx * dx - dy * dy) * (flat ? cut : 1);
        if (v > tower) tower = v;
      }
      // the anvil: flat on top, a lens in section, combed by the wind
      const fib = fbm(x * 0.035, y * 0.2, 3, 0) - 0.5;
      const top = anvilTop(x) + 2.2 * fib, bot = anvilBot(x) - 1.5 * fib - 3 * (fbm(x * 0.2, 7, 2, 0) - 0.5);
      const mid = (top + bot) / 2, half = Math.max(0.5, (bot - top) / 2);
      let anvil = dome(0, (y - mid) / half, Math.min(5, half * 0.9)) * smooth(58, 72, x + 6 * fib);
      const onTop = anvil > 0 ? smooth(top + 3.2, top + 0.6, y) : 0;
      // pouches of mammatus hanging under its western half
      if (x > 66 && x < 128 && y > bot - 2 && y < bot + 5) {
        const row = Math.floor((x - 66) / 5.2);
        const off = row * 5.2 + 66 + 2.6 + (hash(row, 3) - 0.5) * 1.2;
        const py = bot + 0.6 + hash(row, 4) * 1.2;
        anvil = Math.max(anvil, dome((x - off) / 2.7, (y - py) / 2.3, 2.2) * smooth(64, 76, x) * smooth(130, 116, x));
      }
      const solid = tower;
      let h = Math.max(solid, anvil);
      const a = anvil > solid ? smooth(0, 2, anvil - solid) : 0;
      // small billows on the towers; long streaks on the anvil, combed at a
      // slight slant so they do not line up with the rows
      const billow = fbm(x * 0.22, y * 0.24, 3, 0) - 0.5;
      const sy = y * 0.18 + 0.9 * (noise(x * 0.04, 5.5, 0) - 0.5) + x * 0.012;
      const streaky = fbm(x * 0.03 + 0.6 * noise(x * 0.08, y * 0.1, 0), sy, 3, 0) - 0.5;
      h += Math.min(1, h / 2.5) * mix(3 * billow, 2.2 * streaky, a);
      sh[r * SX + i] = Math.max(0, h);
      anv[r * SX + i] = a;
      lip[r * SX + i] = onTop * a;
    }
  }

  // --- static colour of every cell: sky, storm, land -----------------------
  const sr = new Float32Array(N), sg = new Float32Array(N), sb = new Float32Array(N);
  const mat = new Uint8Array(N);
  const cloud = new Float32Array(N); // storm cover, for the lightning
  const dark = new Float32Array(N); // base and shelf, lit from behind by a flash
  const darkSky = new Float32Array(N); // the slot under the storm
  const floorOf = new Float32Array(N);
  const jit = new Float32Array(N); // per-cell dither jitter, against contour lines
  for (let k = 0; k < N; k++) jit[k] = (hash(k % W, ((k / W) | 0) + 517) - 0.5) * (((k / W) | 0) < HZ ? 0.16 : 0.06);
  const glowSun = (x: number, y: number) => {
    const dx = x - SUN[0], dy = y - SUN[1];
    return Math.exp(-((dx / 48) ** 2) - ((dy / 10) ** 2)) * 0.9 + Math.exp(-((dx / 95) ** 2) - ((dy / 20) ** 2)) * 0.26;
  };
  const LX = -0.72, LY = -0.3, LZ = 0.62; // toward the light: west, a touch high, out of the page

  // the farmhouse, its tree and a windpump, standing on the horizon
  const ground = (x: number) => HZ + 3 - 1.2 * smooth(20, 70, x) * smooth(110, 70, x);
  const HX0 = 40, HX1 = 54, HW0 = 58; // walls from x 40 to 54, eaves at row 58
  const hb = ground(47);
  const PUMP_X = 66, PUMP_Y = hb - 15;

  // the shelf: a flat dark lip of cloud along the storm's leading edge
  const shelfTop = (x: number) => BASE - 2.4 + 1.2 * (noise(x * 0.2, 8.1, 0) - 0.5);
  const shelfBot = (x: number) => BASE + 2.4 + 1.8 * (fbm(x * 0.12, 8.7, 2, 0) - 0.5) - 2 * smooth(184, 199, x) - 2 * smooth(114, 104, x);
  const shelfOn = (x: number) => smooth(103, 112, x) * smooth(199, 190, x);

  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 < ground(x)) {
        // sky: indigo overhead, dusky rose lower down, amber where the sun went
        const v = y / HZ;
        const g = glowSun(x, y);
        const east = smooth(60, 190, x); // the storm side is cooler and darker
        const v2 = Math.pow(v, 2.2);
        const veil = 0.9 + 0.2 * fbm(x * 0.05, y * 0.09, 3, 0);
        let cr = (0.045 + 0.2 * v2 * (1 - 0.6 * east)) * veil + 1.0 * g;
        let cg = (0.045 + 0.09 * v2 * (1 - 0.5 * east)) * veil + 0.56 * g;
        let cb = (0.16 + 0.13 * v2 * (1 - 0.35 * east)) * veil + 0.22 * g;
        // the last light along the horizon, gone where the storm stands
        const be = smooth(45, 118, x);
        const band = Math.exp(-(HZ + 2 - y) / 11) * (1 - 0.85 * be) * (1 - 0.6 * g);
        cr += band * mix(0.5, 0.34, be), cg += band * mix(0.26, 0.21, be), cb += band * mix(0.26, 0.3, be);
        // under the storm the air itself goes dark slate, with only a thread
        // of light left along the ground behind the rain
        const under = smooth(98, 128, x + 8 * (noise(y * 0.15, 4.4, 0) - 0.5)) * smooth(BASE - 14, BASE - 1, y);
        // black under the base, opening to a dim violet glow at the ground:
        // the clear sky far beyond the storm, for the rain to hang against
        const slot = Math.pow(smooth(BASE + 1.5, HZ - 6, y), 0.5) *(0.85 + 0.3 * noise(x * 0.06, 6.6, 0));
        cr = mix(cr, mix(0.035, 0.38, slot) * veil, under * 0.92);
        cg = mix(cg, mix(0.032, 0.3, slot) * veil, under * 0.92);
        cb = mix(cb, mix(0.075, 0.56, slot) * veil, under * 0.92);
        darkSky[k] = under;
        // the storm
        if (r < SY - 1) {
          const i = x + 2, j = r * SX + i;
          const h = sh[j];
          const c = smooth(0.15, 1.6, h);
          if (c > 0) {
            const up = r > 0 ? sh[j - SX] : 0, dn = sh[j + SX];
            const gx = (sh[j + 1] - sh[j - 1]) / 2, gy = (dn - up) / 2;
            const nl = Math.sqrt(gx * gx + gy * gy + 1);
            const nx = -gx / nl, ny = -gy / nl, nz = 1 / nl;
            const an = anv[j];
            const lam = Math.pow(clamp(nx * LX + ny * LY + nz * LZ), 1.5);
            // earth's shadow climbing the tower: the tops still lit, the
            // lower bulk already in dusk
            const vis = 1 - 0.4 * smooth(22, BASE - 4, y + 5 * (noise(x * 0.12, 2.2, 0) - 0.5));
            const a = smooth(8, BASE, y);
            const near = Math.exp(-(((x - SUN[0]) / 110) ** 2));
            const flank = smooth(122, 108, x) * (1 - an); // the younger towers to the west
            // sunlight: cream-gold up high, peach lower down
            const sR = 1.0, sG = mix(0.84, 0.58, a), sB = mix(0.58, 0.45, a);
            // the tower's own bulk shades its eastern side, along a ragged edge
            const ej = 8 * (fbm(y * 0.14 + x * 0.02, 21.3, 3, 0) - 0.5);
            const lee = 1 - 0.65 * smooth(towerC(y) - 14 + ej, towerC(y) + towerHW(y) + 16 + ej, x) * (1 - an) * smooth(anvilBot(x) - 3, anvilBot(x) + 9, y + 5 * (noise(x * 0.15, 21, 0) - 0.5)) * (y < BASE ? 1 : 0);
            const sun = lam * vis * lee * (0.95 + 0.4 * near) * (1 - 0.3 * flank) * (1 - 0.2 * an);
            // skylight from above, violet; afterglow bounced up from the west
            const sky = 0.55 + 0.45 * Math.max(0, -ny);
            const bounce = Math.max(0, ny) * (0.2 + 0.45 * near) * (1 - 0.6 * flank) * (1 - 0.5 * an);
            const occ = 1 - 0.35 * smooth(4, 22, h) * (1 - lam);
            let kr = (0.27 * sky + sR * sun + 0.36 * bounce) * occ;
            let kg = (0.2 * sky + sG * sun + 0.16 * bounce) * occ;
            let kb = (0.45 * sky + sB * sun + 0.16 * bounce) * occ;
            // the anvil's underside sinks into mauve shadow; its top edge
            // catches the last direct light, cream toward the west
            const below = clamp(ny * 2.2) * an;
            kr *= 1 - 0.42 * below, kg *= 1 - 0.46 * below, kb *= 1 - 0.3 * below;
            const rim = lip[j] * smooth(196, 120, x) * (0.6 + 0.4 * lam);
            kr = mix(kr, 0.98, rim * 0.75), kg = mix(kg, 0.86, rim * 0.75), kb = mix(kb, 0.64, rim * 0.75);
            // the flankers' lower halves turn violet-grey
            const low = clamp(ny * 1.6 + 0.2) * flank;
            kr = mix(kr, 0.14, low * 0.65), kg = mix(kg, 0.11, low * 0.65), kb = mix(kb, 0.24, low * 0.65);
            // the base: dark slate where the rain hangs
            const base = smooth(BASE - 4.5, BASE - 0.5, y) * (1 - an) * (1 - 0.3 * near);
            kr = mix(kr, 0.03, base * 0.9), kg = mix(kg, 0.026, base * 0.9), kb = mix(kb, 0.06, base * 0.9);
            cr = mix(cr, kr, c), cg = mix(cg, kg, c), cb = mix(cb, kb, c);
            cloud[k] = c;
            dark[k] = base * c;
          }
        }
        // the shelf, hung along the base, a touch lighter on its leading lip
        const on = shelfOn(x);
        if (on > 0 && y > BASE - 5 && y < BASE + 5) {
          const st = shelfTop(x), sbt = shelfBot(x);
          const s = smooth(st - 1, st + 0.6, y) * smooth(sbt + 0.6, sbt - 0.8, y) * on;
          if (s > 0) {
            const n = fbm(x * 0.15, y * 0.5, 2, 0);
            // striations along its face, and a faint lip of light underneath
            const lit = 0.55 + 0.9 * n * (0.7 + 0.6 * noise(x * 0.05, y * 1.3, 0)) + 0.9 * smooth(sbt - 1.6, sbt - 0.2, y);
            cr = mix(cr, 0.06 * lit, s), cg = mix(cg, 0.056 * lit, s), cb = mix(cb, 0.12 * lit, s);
            cloud[k] = Math.max(cloud[k], s);
            dark[k] = Math.max(dark[k], s);
          }
        }
        sr[k] = cr, sg[k] = cg, sb[k] = cb;
        mat[k] = SKY;
        floorOf[k] = 0.06 - 0.06 * Math.max(darkSky[k], dark[k]);
      } else {
        // land: the afterglow caught only in the far rows near the sun
        const g = glowSun(x, HZ + 1) * Math.exp(-(y - HZ) / 3.2);
        mat[k] = y < HZ + 4 ? PLAIN : FIELD;
        sr[k] = 0.06 + 0.55 * g, sg[k] = 0.045 + 0.3 * g, sb[k] = 0.08 + 0.1 * g;
        floorOf[k] = 0.06;
      }

      // a shelterbelt of trees on the far horizon, half lost in the rain
      const belt = HZ + 2 - (1.5 + 2.2 * fbm(x * 0.3, 2, 2, 0)) * smooth(140, 150, x) * smooth(196, 186, x);
      const belt2 = HZ + 2 - (1 + 1.8 * fbm(x * 0.35, 9, 2, 0)) * smooth(0, 4, x) * smooth(18, 10, x);
      if (y >= Math.min(belt, belt2) && y < HZ + 3) mat[k] = BELT;

      // the cottonwood by the house
      const tx = (x + 0.5 - 31) / 8.5, ty = (y - (hb - 10)) / 7;
      const crown = tx * tx + ty * ty < 1 + 0.35 * (fbm(x * 0.4, y * 0.4, 2, 0) - 0.5);
      if (crown || (Math.abs(x + 0.5 - 31.5) < 1 && y > hb - 6 && y < hb + 1)) mat[k] = TREE;

      // the farmhouse: two storeys, a gable roof, a chimney, a porch
      if (x >= HX0 && x <= HX1 && y >= HW0 && y < hb + 1) mat[k] = HOUSE;
      if (y >= HW0 - 7 && y < HW0 && Math.abs(x + 0.5 - 47.5) <= 8.6 - (HW0 - y) * 1.15) mat[k] = ROOF;
      if (x >= 50 && x <= 51 && y >= HW0 - 8 && y < HW0 - 3) mat[k] = ROOF;
      if (x >= 36 && x < HX0 && y >= hb - 4 && y < hb - 3) mat[k] = ROOF;
      if (x === 36 && y >= hb - 3 && y < hb) mat[k] = HOUSE;
      if (x >= 49 && x <= 51 && y >= HW0 + 2 && y < HW0 + 5) mat[k] = PANE;
      if (x >= 43 && x <= 44 && y >= HW0 + 2 && y < HW0 + 4) mat[k] = PANE;

      // the windpump: a tapering lattice tower
      const py = y - PUMP_Y;
      if (py > 2 && y < hb + 0.5) {
        const half = 0.4 + py * 0.11;
        const dx = x + 0.5 - PUMP_X;
        if (Math.abs(Math.abs(dx) - half) < 0.55) mat[k] = PUMP;
        if (Math.abs(dx) < half && (py | 0) % 4 === 0) mat[k] = PUMP;
      }
    }
  }

  // the road: from the yard to the bottom edge, a leading line
  const roadC = (p: number) => 57 + 62 * Math.pow(p, 1.25);
  const roadW = (p: number) => 0.6 + 12 * p;
  for (let r = HZ + 3; r < H; r++) {
    const p = (r + 0.5 - HZ) / (H - HZ);
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      if (mat[k] !== FIELD) continue;
      if (Math.abs(x + 0.5 - roadC(p)) < roadW(p) + 0.6 * (noise(x * 0.3, r * 0.3, 0) - 0.5)) mat[k] = ROAD;
    }
  }

  // wheat: stalks in perspective, their columns converging on the farmhouse,
  // tall strokes up close and a fine grain toward the horizon
  const VX = 57;
  const TW = 512;
  const wheat = new Float32Array(H * TW);
  const persp = new Float32Array(N); // each cell's column in the wheat texture
  for (let r = HZ; r < H; r++) {
    const p = (r + 0.5 - HZ) / (H - HZ);
    for (let u = 0; u < TW; u++) wheat[r * TW + u] = fbm(u * 0.55, r * (0.5 - 0.42 * p), 3, TW * 0.55);
    for (let x = 0; x < W; x++) persp[r * W + x] = ((x + 0.5 - VX) * 24) / (r + 2 - HZ) + 256;
  }
  // how much light the field holds: the sun side, the far rows, not the east
  // or the foreground, which sink into the storm's shadow
  const fieldLight = new Float32Array(N);
  for (let r = HZ; r < H; r++) {
    const y = r + 0.5;
    const p = (y - HZ) / (H - HZ);
    for (let x = 0; x < W; x++) {
      const sunW = Math.exp(-(((x - SUN[0]) / 100) ** 2));
      const east = smooth(80, 130, x + 10 * p);
      const fore = 1 - 0.6 * smooth(H - 22, H - 2, y);
      fieldLight[r * W + x] = (0.72 + 0.45 * sunW) * (1 - 0.35 * east) * fore;
    }
  }
  // gusts: soft patches of bent, brighter wheat rolling across the field
  const GW = 512;
  const gust = new Float32Array(GW * (H - HZ));
  for (let r = HZ; r < H; r++)
    for (let u = 0; u < GW; u++) gust[(r - HZ) * GW + u] = smooth(0.4, 0.7, fbm(u * 0.025, r * 0.16, 3, GW * 0.025));

  // thin glowing streaks of altostratus in the clear western sky
  const CW = 400;
  const streak = new Float32Array(CW * HZ);
  for (let r = 0; r < HZ; r++)
    for (let u = 0; u < CW; u++) {
      const n = fbm(u * 0.02, r * 0.2, 4, CW * 0.02);
      streak[r * CW + u] = smooth(0.64, 0.8, n) * smooth(40, 46, r) * smooth(HZ - 3, 52, r);
    }

  // stars in the darkest part of the sky
  const stars: [number, number, number, number][] = [];
  for (let r = 0; r < 28; r++)
    for (let x = 0; x < 100; x++)
      if (hash(x, r * 5 + 3) > 0.986 && cloud[r * W + x] < 0.05) stars.push([r * W + x, hash(x, r) * 6.28, 1 + hash(r, x) * 2.5, 0.85 * (1 - r / 28) * (1 - x / 130)]);
  const star = new Float32Array(N);

  // wheat ears right in front of us, bowing with the wind:
  // [x, stalk height, phase, foot below the frame, head length]
  const ears: [number, number, number, number, number][] = [];
  for (let x = 2; x < W; x += 5 + hash(x | 0, 60) * 4) ears.push([x, 8 + hash(x * 3, 61) * 12, hash(x * 7, 62) * 6.28, hash(x * 5, 63) * 6, 5 + (hash(x, 64) > 0.5 ? 1 : 0)]);
  const ear = new Float32Array(N); // > 0: a lit grain head; < 0: a dark stalk
  const touched: number[] = [];

  // rain: one envelope of shafts that drifts, and falling streaks inside it
  const RW = 256;
  const shafts = new Float32Array(RW);
  for (let u = 0; u < RW; u++) shafts[u] = smooth(0.47, 0.57, fbm(u * 0.09375, 0.5, 2, 24));
  const colPhase = new Float32Array(W), colSpeed = new Float32Array(W);
  for (let x = 0; x < W; x++) (colPhase[x] = hash(x, 77) * 40), (colSpeed[x] = 22 + hash(x, 78) * 14);
  const rainTop = new Float32Array(W), rainOn = new Float32Array(W);
  for (let x = 0; x < W; x++) (rainTop[x] = shelfBot(x) - 1.5), (rainOn[x] = smooth(106, 118, x) * smooth(198, 186, x));

  // lightning: a fixed schedule of flashes, some carrying a bolt
  const PERIOD = 3.1;
  const bolts: { x: number; field: Float32Array }[] = [];
  for (let i = 0; i < 4; i++) {
    const segs: [number, number, number, number, number][] = [];
    const walk = (x: number, y: number, len: number, lean: number, depth: number) => {
      for (let s = 0; s < len && y < HZ + 2; s++) {
        const nx = x + (hash(i * 97 + s, depth * 13 + 41) - 0.5) * 3.2 + lean;
        const ny = y + 0.9 + hash(i * 31 + s, depth + 42) * 1.3;
        segs.push([x, y, nx, ny, depth]);
        if (depth === 0 && hash(i * 7 + s, 43) > 0.84) walk(nx, ny, 3 + hash(s, i) * 5, (hash(s, i + 9) - 0.5) * 2.4, 1);
        (x = nx), (y = ny);
      }
    };
    walk(122 + hash(i, 40) * 34, BASE - 4, 40, (hash(i, 44) - 0.5) * 0.8, 0);
    const field = new Float32Array(N).fill(99);
    for (let r = 25; r < HZ + 4; r++)
      for (let x2 = 80; x2 < W; x2++) {
        let m = 99;
        for (const [ax, ay, bx, by, dep] of segs) {
          if (Math.abs(x2 - ax) > 14 && Math.abs(x2 - bx) > 14) continue;
          const d = segDist(x2 + 0.5, r + 0.5, ax, ay, bx, by) + dep * 0.5;
          if (d < m) m = d;
        }
        field[r * W + x2] = m;
      }
    bolts.push({ x: segs[0][0], field });
  }
  const flashAt = (t: number) => {
    const n = Math.floor(t / PERIOD);
    if (n > 0 && hash(n, 50) < 0.25) return null; // some periods stay dark
    const start = n === 0 ? -0.08 : n * PERIOD + 0.2 + hash(n, 51) * 1.6;
    const l = t - start;
    if (l < 0 || l > 0.9) return null;
    // a stroke and its restrikes, each a sharp rise and a fast decay
    let I = 0;
    const strikes = [0, 0.09 + hash(n, 52) * 0.06, 0.32 + hash(n, 53) * 0.2];
    for (let j = 0; j < 3; j++) if (l >= strikes[j]) I += Math.exp(-(l - strikes[j]) / (j === 0 ? 0.09 : 0.06)) * (j === 0 ? 1 : 0.7 - j * 0.15);
    const bolt = n === 0 || hash(n, 54) > 0.55 ? bolts[n % 4] : null;
    const cx = bolt ? bolt.x : 115 + hash(n, 55) * 45;
    const cy = bolt ? BASE - 8 : 14 + hash(n, 56) * 24;
    return { I: Math.min(1.3, I), bolt, cx, cy, boltOn: bolt && l < 0.5 ? Math.min(1, I * 1.4) : 0, inside: 0 };
  };
  // between the big flashes, the storm keeps flickering inside itself
  const FL = 1.6;
  const flickerAt = (t: number) => {
    const n0 = Math.floor(t / FL);
    for (let n = n0; n >= n0 - 1 && n >= 0; n--) {
      const l = t - (n * FL + 0.35 + hash(n, 70) * 0.75);
      if (l < 0 || l > 0.6) continue;
      let I = 0;
      const pulses = [0, 0.08 + hash(n, 71) * 0.1, 0.26 + hash(n, 72) * 0.18];
      for (let j = 0; j < 3; j++) if (l >= pulses[j]) I += Math.exp(-(l - pulses[j]) / 0.07) * (j === 1 ? 0.7 : j === 2 ? 0.45 : 1);
      return { I: Math.min(1, I) * (0.25 + 0.2 * hash(n, 73)), bolt: null, cx: 120 + hash(n, 74) * 38, cy: 15 + hash(n, 75) * 25, boltOn: 0, inside: 1 };
    }
    return null;
  };

  return (t, { color } = {}) => {
    let f = flashAt(t);
    const fl = flickerAt(t);
    if (fl && (!f || f.I < fl.I)) f = fl;
    const I = f ? f.I : 0;
    const inside = f ? f.inside : 0;
    const drift = t * 1.2;
    const shaftOff = t * 0.9;
    const gOff = t * 9;

    for (const [k, ph, sp, a] of stars) star[k] = a * (0.55 + 0.45 * Math.sin(t * sp + ph));

    // the near ears: each stalk bends from its foot, more at the tip, and the
    // gusts that roll across the field push them further
    for (const k of touched) ear[k] = 0;
    touched.length = 0;
    for (const [ex, eh, ph, foot, hl] of ears) {
      const gu = gust[(H - 1 - HZ) * GW + ((((ex | 0) - Math.floor(gOff * 1.5)) % GW) + GW) % GW];
      // the inflow blows toward the storm, so every ear bows a little east
      const lean = 0.7 + 0.7 * Math.sin(t * 1.7 + ph) + 1.8 * gu;
      const tall = eh + hl;
      for (let i = 0; i < tall; i++) {
        const y = Math.round(H + foot - i);
        if (y >= H) continue;
        const q = i / tall;
        const x = Math.round(ex + lean * q * q);
        if (x < 0 || x + 1 >= W) continue;
        const k = y * W + x;
        if (i < eh) {
          // the stalk: a thin dark line against the field
          if (ear[k] === 0) (ear[k] = -1), touched.push(k);
        } else {
          // the head: plump, two grains wide in the middle, tapering at both
          // ends, the grains alternating side to side
          const j = i - eh;
          const tip = j === hl - 1;
          const v = tip ? 0.55 : j === 0 ? 0.7 : 1 - 0.18 * (j & 1);
          if (v > ear[k]) (ear[k] = v), touched.push(k);
          if (!tip && j > 0 && v * 0.82 > ear[k + 1]) (ear[k + 1] = v * 0.82), touched.push(k + 1);
          // a whisker of awns past the tip
          if (tip && y > 0) {
            const a = k - W + (lean > 0.5 ? 1 : 0);
            if (ear[a] < 0.3) (ear[a] = 0.3), touched.push(a);
          }
        }
      }
    }

    for (let r = 0; r < H; r++) {
      const y = r + 0.5;
      const p = (y - HZ) / (H - HZ);
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const m = mat[k];
        let cr = sr[k], cg = sg[k], cb = sb[k];
        let floor = floorOf[k], fade = 1, rain = 0;

        if (m === SKY) {
          const c = cloud[k];
          if (c < 0.98 && r < HZ) {
            // the altostratus, lit gold toward the sun
            const sx = x + drift, ix = Math.floor(sx), fx = sx - ix;
            const s0 = streak[r * CW + (ix % CW)], s1 = streak[r * CW + ((ix + 1) % CW)];
            const s = (s0 + (s1 - s0) * fx) * (1 - c) * smooth(105, 55, x) * 0.75;
            if (s > 0.005) {
              const sun = Math.exp(-(((x - SUN[0]) / 80) ** 2));
              cr = mix(cr, 0.5 + 0.5 * sun, s);
              cg = mix(cg, 0.24 + 0.36 * sun, s);
              cb = mix(cb, 0.34 + 0.06 * sun, s);
            }
            if (star[k]) {
              const v = star[k] * (1 - c);
              cr = Math.max(cr, v * 0.9), cg = Math.max(cg, v * 0.88), cb = Math.max(cb, v);
            }
          }
          // rain curtains hanging from the shelf to the ground
          const on = rainOn[x];
          if (on > 0 && y > rainTop[x]) {
            const u = x + (y - BASE) * 0.42 - shaftOff;
            const ui = ((Math.floor(u) % RW) + RW) % RW;
            const env = shafts[ui] * on * smooth(rainTop[x], rainTop[x] + 3, y) * (1 - c * 0.7);
            if (env > 0.01) {
              // falling streaks, slanted with the shafts
              const col = Math.floor(x + (y - BASE) * 0.42) % W;
              const fall = (y * 0.4 - t * colSpeed[col] * 0.1 + colPhase[col] + 1000) % 4;
              const lit = fall < 1.4 ? 1 : 0;
              rain = env;
              cr = mix(cr, 0.06 + 0.07 * lit, env * 0.88);
              cg = mix(cg, 0.065 + 0.075 * lit, env * 0.88);
              cb = mix(cb, 0.12 + 0.1 * lit, env * 0.88);
            }
          }
        } else if (m === FIELD || m === PLAIN) {
          const gi = (r - HZ) * GW + ((((x - Math.floor(gOff * (0.5 + p))) % GW) + GW) % GW);
          const gu = gust[gi];
          const g = sr[k] - 0.06; // the gold glint just under the horizon
          if (m === FIELD) {
            // stalks lean with the gust and spring back
            const sway = 0.5 * Math.sin(t * 2.1 + x * 0.05 + r * 0.17) + 2.2 * gu;
            const u = persp[k] + sway;
            const ui = Math.floor(u), uf = u - ui;
            const a0 = wheat[r * TW + (ui & 511)], a1 = wheat[r * TW + ((ui + 1) & 511)];
            const tex = a0 + (a1 - a0) * uf;
            const v = clamp(0.55 + (tex - 0.5) * (0.9 + 1.4 * p));
            const L = (v * 0.7 + 0.4 * gu) * fieldLight[k];
            cr = 0.04 + 0.7 * L + 0.9 * g;
            cg = 0.03 + 0.48 * L + 0.48 * g;
            cb = 0.035 + 0.18 * L + 0.12 * g;
            fade = smooth(H + 10, H - 8, y);
          } else {
            const v = 0.75 + 0.45 * gu;
            cr *= v, cg *= v, cb *= v;
          }
          floor = 0.05;
        } else if (m === ROAD) {
          const sun = 0.5 + 0.5 * Math.exp(-(((x - SUN[0]) / 110) ** 2));
          const rut = Math.abs(Math.abs(x + 0.5 - roadC(p)) - roadW(p) * 0.45) < 0.4 + p * 0.8 ? 0.6 : 1;
          const L = (0.6 + 0.3 * noise(x * 0.5, r * 0.5, 0)) * sun * rut * (1 - 0.65 * p);
          cr = 0.08 + 0.42 * L, cg = 0.06 + 0.3 * L, cb = 0.07 + 0.26 * L;
          fade = smooth(H + 10, H - 8, y);
          floor = 0.05;
        } else if (m === BELT) {
          // far trees: dark, hazed toward the rain
          const h = smooth(130, 190, x);
          cr = mix(0.07, 0.1, h), cg = mix(0.05, 0.1, h), cb = mix(0.1, 0.17, h);
          floor = 0.06;
        } else if (m === TREE) {
          const rim = noise(x * 0.6, y * 0.6, 0);
          cr = 0.06 + 0.06 * rim, cg = 0.04 + 0.03 * rim, cb = 0.07 + 0.04 * rim;
          floor = 0.03;
        } else if (m === HOUSE || m === PUMP) {
          // back-lit by the afterglow: a dark silhouette with a warm western rim
          const rim = m === HOUSE && x === HX0 ? 0.18 : 0;
          cr = 0.07 + rim, cg = 0.05 + rim * 0.55, cb = 0.09 + rim * 0.3;
          floor = 0.03;
        } else if (m === ROOF) {
          cr = 0.1, cg = 0.06, cb = 0.1;
          floor = 0.03;
        } else if (m === PANE) {
          const g = 0.88 + 0.12 * Math.sin(t * 2.3 + x) * Math.sin(t * 3.7);
          const small = x < 46 ? 0.6 : 1;
          cr = g * small, cg = 0.72 * g * small, cb = 0.3 * g * small;
          floor = 0.3;
        }

        const e = ear[k];
        if (e > 0) {
          // a grain head, warm and brightest on its sunward edge
          const sunW = Math.exp(-(((x - SUN[0]) / 120) ** 2));
          const L = e * (0.5 + 0.5 * sunW);
          (cr = 0.95 * L), (cg = 0.68 * L), (cb = 0.3 * L), (fade = 1), (floor = 0.1);
        }

        // the windpump's wheel, turning slowly, and its tail vane
        const wx = x + 0.5 - PUMP_X, wy = y - PUMP_Y;
        if (wx > -4 && wx < 7 && wy > -4 && wy < 4) {
          const wd = Math.sqrt(wx * wx + wy * wy);
          if (wd < 3.6 && wd > 0.4 && (Math.cos((Math.atan2(wy, wx) - t * 1.6) * 8) > 0.2 || wd < 1)) (cr = 0.07), (cg = 0.05), (cb = 0.09), (floor = 0.03);
          if (wy > -1 && wy < 0.6 && wx > 2) (cr = 0.07), (cg = 0.05), (cb = 0.09), (floor = 0.03);
        }

        // the lit pane's glow on the yard and the wall around it
        const lx = x + 0.5 - 50.5, ly = y - (HW0 + 3.5);
        if (m !== PANE && lx * lx + ly * ly < 200) {
          const lg = Math.exp(-Math.sqrt(lx * lx + ly * ly * 2) / 3) * 0.5;
          cr += lg, cg += lg * 0.66, cb += lg * 0.25;
        }

        // lightning: the cloud glows from inside; the base and the rain are
        // lit from behind; a full flash reaches the land as well
        if (I > 0.01) {
          const dx = x - f!.cx, dy = (y - f!.cy) * 1.3;
          const dist = Math.sqrt(dx * dx + dy * dy);
          const c = m === SKY ? cloud[k] : 0;
          const inner = inside ? Math.exp(-dist / 14) * c * I * 2 : Math.exp(-dist / 16) * (0.3 + 0.7 * c) * I * 1.3;
          const back = m === SKY ? (dark[k] * 0.5 + rain * 0.8) * Math.exp(-Math.abs(dx) / 34) * I * (inside ? 0.5 : 1) : 0;
          const amb = inside ? 0 : I * (m === SKY ? 0.08 : 0.05);
          const lw = inner + back;
          cr += 0.75 * lw + amb * 0.8, cg += 0.72 * lw + amb * 0.8, cb += 1.0 * lw + amb;
          if (f!.boltOn) {
            const d = f!.bolt!.field[k];
            if (d < 12 && (c < 0.6 || y > BASE - 1)) {
              const core = smooth(1.1, 0.35, d) * f!.boltOn;
              const glow = (Math.exp(-d / 2) * 0.55 + Math.exp(-d / 7) * 0.2) * f!.boltOn;
              cr += core + glow * 0.75, cg += core + glow * 0.72, cb += core + glow;
            }
          }
        }

        if (e < 0) {
          // a stalk: one unbroken line of the smallest dots in dark umber
          out[k] = DOTS[1];
          if (color) color[k] = STALK;
          continue;
        }

        const peak = Math.max(cr, cg, cb, 1e-4);
        const level = clamp(floor + (1 - floor) * peak * 0.97 + jit[k]) * 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) {
          // a small dot is drawn brighter, a large one dimmer, so a gradient
          // stays smooth across the steps; the darkest stay dark
          const want = step ? Math.min(1, (level + 0.04) / COVER[step]) : 0;
          const s = Math.min(0.14 + 0.86 * want, 0.45 + 0.8 * level) / peak;
          color[k] = nearest(clamp(cr * s), clamp(cg * s), clamp(cb * 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");
  };
}