alpine dawn

[github]

snow peaks catching first light above a still, misty mountain lake

scenes 200×100 15 fps 46 colours, on a canvas 22.7 kB

use it

html

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

<ascii-art piece="alpine-dawn"></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 { alpineDawn } from "ascii.rest/pieces";

<Ascii piece={alpineDawn} />

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

astro

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

<Ascii piece="alpine-dawn" />

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

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

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.

alpine-dawn.ts

[js] [github]
/*
 * alpine dawn: jagged snow peaks catch the first pink light on their east
 * faces while their flanks stay in blue shadow. Mist pools along the far shore
 * and a still lake mirrors it all. The light warms toward gold as the sun
 * clears the ridge, the mist drifts, and slow ripples cross the water.
 *
 * The range is a heightfield, raymarched once from a camera just above the
 * water: each cell keeps its depth, height, sunlight (with cast shadows) and
 * snow cover, so a frame only re-tints it. The lake looks up the picture above
 * it along each cell's reflected ray. Every cell is then drawn as a halftone
 * dot, ordered-dithered, in the palette colour nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "alpine dawn",
  category: "scenes",
  note: "snow peaks catching first light above a still, misty mountain lake",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#090c18",
  palette: [
    "#0e1430", "#151d40", "#1d2752", "#263365", "#314179", "#3e508c", "#4f62a0", "#6577b3", "#8090c4", "#9eaad3", "#bec6e2",
    "#4b3e6c", "#6a5482", "#8c6a92", "#b0829c", "#cf96a4",
    "#e8a9a8", "#f5bcaa", "#ffd0b0", "#ffe2c2", "#fff1e0", "#fdfaf6",
    "#ffc887", "#f7a965", "#f2a08f", "#e58a87", "#f8b59d", "#d97b7e",
    "#7a4c4a", "#a5654f", "#523a4a",
    "#1b2034", "#262c45", "#363c59",
    "#0a1418", "#0f1f24", "#162a2f", "#203a3c",
    "#a3a7c6", "#c6c3d8", "#e0d4dc",
    "#5a4f7e", "#7b6c9c", "#9a8cb6", "#b8a8c8", "#d8bccb",
  ],
} satisfies Meta;

const W = 200, H = 100;
const K = 0.62; // tangent of half the field of view, across the width
const HZ = 56.5; // eye level, in rows
const CAM = 1.5; // camera height above the water
const SHORE_Z = 46; // distance to the far shore
const SHORE = 62; // first row of open water
const SUN = [151, 50];
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);

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

// Sharp crests where plain noise crosses its middle: rock ribs and couloirs.
function ridged(x: number, y: number, octaves: number): number {
  let s = 0, n = 0, amp = 0.5, f = 1;
  for (let i = 0; i < octaves; i++) {
    const v = 1 - Math.abs(2 * noise(x * f + i * 17.3, y * f, 0) - 1);
    s += amp * v * v;
    n += amp;
    amp *= 0.5;
    f *= 2.1;
  }
  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);

// Peaks as pyramids, each turned a little, placed by where their summits
// should land on screen: [column, row, distance, spread, turn].
const PEAKS = [
  [66, 11, 140, 0.95, 0.3],
  [38, 26, 115, 1.1, -0.15],
  [116, 22, 175, 0.9, 0.2],
  [92, 33, 150, 1.0, 0.1],
  [96, 25, 340, 1.1, 0.4],
  [180, 38, 200, 1.5, -0.1],
  [8, 30, 160, 1.2, 0.25],
].map(([sx, row, z, f, a]) => {
  const h = CAM + ((HZ - row) / 100) * K * z - 1.5;
  return [((sx - 100) / 100) * K * z, z, h, h * f, Math.cos(a), Math.sin(a)];
});

function terrain(x: number, z: number): number {
  let h = 0;
  for (const [px, pz, ph, pr, c, s] of PEAKS) {
    const dx = x - px, dz = z - pz;
    const rx = dx * c - dz * s, rz = dx * s + dz * c;
    const v = ph * (1 - (Math.abs(rx) + Math.abs(rz)) / pr);
    if (v > h) h = v;
  }
  const hills = (1 + 3 * fbm(x * 0.04, z * 0.04, 3, 0)) * smooth(SHORE_Z, SHORE_Z + 15, z);
  if (hills > h) h = hills;
  // crags, deeper on the high ground
  h += ((ridged(x * 0.06, z * 0.06, 3) - 0.45) * 6 + (ridged(x * 0.2, z * 0.2, 2) - 0.45) * 1.6) * smooth(4, 22, h);
  return h;
}

// Distance along a ray from height `oy` with slope `v` and spread `u` to the
// terrain beyond the shore, or 0 when it reaches the sky.
function march(u: number, v: number, oy: number): number {
  let z = SHORE_Z, prev = z;
  for (let i = 0; i < 260 && z < 520; i++) {
    const gap = oy + v * z - terrain(u * z, z);
    if (gap < 0) {
      let a = prev, b = z;
      for (let j = 0; j < 7; j++) {
        const m = (a + b) / 2;
        if (oy + v * m - terrain(u * m, m) < 0) b = m;
        else a = m;
      }
      return b;
    }
    prev = z;
    z += Math.max(0.35, gap * 0.45) + z * 0.002;
  }
  return 0;
}

export default function alpineDawn(): Frame {
  const P = meta.palette.map(hex);
  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): 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 L = (() => {
    const v = [0.9, 0.3, 0.14];
    const n = Math.hypot(...v);
    return v.map((c) => c / n);
  })();

  // --- the range, raymarched once ------------------------------------------
  const SR = SHORE; // rows above the open water
  const depth = new Float32Array(SR * W);
  const alt = new Float32Array(SR * W);
  const sun = new Float32Array(SR * W);
  const snow = new Float32Array(SR * W);
  const up = new Float32Array(SR * W);
  for (let r = 0; r < SR; r++) {
    const v = ((HZ - (r + 0.5)) / 100) * K;
    for (let x = 0; x < W; x++) {
      const u = ((x + 0.5 - 100) / 100) * K;
      const z = march(u, v, CAM);
      const k = r * W + x;
      if (!z) continue;
      const px = u * z, py = CAM + v * z;
      const e = 0.35;
      const hx = (terrain(px + e, z) - terrain(px - e, z)) / (2 * e);
      const hz = (terrain(px, z + e) - terrain(px, z - e)) / (2 * e);
      const nl = Math.hypot(hx, 1, hz);
      const nx = -hx / nl, ny = 1 / nl, nz = -hz / nl;
      let lit = Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
      if (lit > 0) {
        // cast shadow: walk toward the sun
        for (let s = 0.8; s < 160; s += 0.6 + s * 0.04) {
          const qx = px + L[0] * s, qy = py + L[1] * s + 0.15, qz = z + L[2] * s;
          if (qz < SHORE_Z) break;
          if (qy < terrain(qx, qz)) {
            lit = 0;
            break;
          }
        }
      }
      depth[k] = z;
      alt[k] = py;
      sun[k] = lit;
      up[k] = ny;
      const grain = fbm(px * 0.4, py * 0.25, 2, 0);
      // rock shows through in couloirs running down the fall line
      const gully = ridged(px * 0.22 + z * 0.05, py * 0.045, 2);
      snow[k] = smooth(0.36, 0.52, ny + 0.3 * (grain - 0.5)) * smooth(5, 11, py + 5 * grain) * (1 - 0.75 * smooth(0.62, 0.85, gully));
    }
  }

  // sunlit terrain with open sky directly above: the crest line
  const rim = new Uint8Array(SR * W);
  for (let k = W; k < SR * W; k++) rim[k] = depth[k] && !depth[k - W] && sun[k] > 0 ? 1 : 0;

  // --- the far shore's treeline --------------------------------------------
  const treeTop = new Float32Array(W);
  for (let x = 0; x < W; x++) treeTop[x] = SHORE - 0.6 - 1.2 * fbm(x * 0.06, 2.3, 2, 0);
  for (let tx = -2; tx < W + 2; tx += 1.6 + hash(tx * 9, 7) * 2.2) {
    const tip = SHORE - 2.6 - hash(tx * 3, 8) * 4 - 1.5 * smooth(60, 0, tx);
    const slope = 1.1 + hash(tx * 5, 9) * 0.5;
    for (let x = Math.max(0, Math.floor(tx - 6)); x < Math.min(W, tx + 6); x++) {
      treeTop[x] = Math.min(treeTop[x], tip + Math.abs(x + 0.5 - tx) * slope);
    }
  }

  // --- the lake: where each cell's reflected ray lands in the picture above -
  const LR = H - SHORE;
  const src = new Float32Array(LR * W);
  for (let r = SHORE; r < H; r++) {
    const v = ((HZ - (r + 0.5)) / 100) * K;
    for (let x = 0; x < W; x++) {
      const u = ((x + 0.5 - 100) / 100) * K;
      const z = march(u, -v, -CAM);
      const vs = -v - (z ? (2 * CAM) / z : 0);
      let row = HZ - (vs * 100) / K - 0.5;
      const mirror = 2 * SHORE - 1 - r; // the treeline stands on the shore
      if (mirror >= treeTop[x]) row = mirror;
      src[(r - SHORE) * W + x] = row;
    }
  }

  // --- the near pines and the bank they stand on ---------------------------
  const fg = new Uint8Array(N);
  const fgShade = new Float32Array(N);
  const fgRim = new Float32Array(N);
  const PINES = [
    [9, 5, 1.15], [20, 38, 0.85], [32, 66, 0.5],
    [192, 10, 1.15], [181, 40, 0.75], [204, 26, 1],
  ];
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const y = r + 0.5;
      const bankL = 88 + 14 * smooth(0, 52, x) + 2 * fbm(x * 0.2, 1, 2, 0);
      const bankR = 90 + 12 * smooth(W, W - 40, x) + 2 * fbm(x * 0.2, 4, 2, 0);
      if (y > bankL || y > bankR) (fg[k] = 1), (fgShade[k] = 0.15 * hash(x, r));
      for (const [px, tip, s] of PINES) {
        const d = y - tip;
        if (d < 0) continue;
        const tier = 3.4 * s;
        const f = d / tier - Math.floor(d / tier);
        const hw = (0.4 + d * 0.2) * (0.5 + 0.5 * f) * (1 + 0.6 * (hash(r, Math.floor(px) * 7) - 0.5) * smooth(0, 8, d));
        const dx = x + 0.5 - px;
        if (Math.abs(dx) <= hw) {
          fg[k] = 2;
          // the outline catches the dawn sky: a warm rim on the side facing
          // the sun, a dim cool one on the other, the inside stays black
          const edge = hw - Math.abs(dx) < 1;
          const sunward = px < SUN[0] ? dx > 0 : dx < 0;
          fgShade[k] = edge ? (sunward ? 1 : 0.5) : 0.3 * hash(x * 3, r * 5);
          // the rim is light from the sky behind, so it fades out below the shore
          fgRim[k] = edge ? smooth(70, 44, y) * (0.75 + 0.25 * hash(x, r * 7)) : 0;
        }
      }
    }
  }

  // --- mist, a wrapping sheet that drifts along the valley -----------------
  const MW = 480, M0 = 36, MR = SHORE + 2 - M0;
  const mist = new Float32Array(MW * MR);
  for (let r = 0; r < MR; r++) {
    for (let x = 0; x < MW; x++) {
      const y = r + M0;
      const q = fbm(x * 0.0125, y * 0.1, 2, MW * 0.0125);
      mist[r * MW + x] = fbm(x * 0.025 + q * 1.4, y * 0.22 + q, 4, MW * 0.025);
    }
  }

  // --- thin high cloud, streaked and lit from below by the sun -------------
  const CW = 640, CR = 34;
  const cloud = new Float32Array(CW * CR);
  for (let r = 0; r < CR; r++) {
    for (let x = 0; x < CW; x++) {
      const y = r + 0.5;
      const q = fbm(x * 0.0125, y * 0.12, 2, 8);
      const c = fbm(x * 0.025 + q * 2, y * 0.2 + q * 0.8, 4, 16);
      cloud[r * CW + x] = smooth(0.52, 0.7, c - 0.06 * Math.abs(y - 18) / 10) * smooth(5, 13, y) * smooth(33, 24, y);
    }
  }

  // a faint large-scale unevenness, so the open sky and the deep water are
  // never one flat halftone screen
  const hz = new Float32Array(N);
  for (let k = 0; k < N; k++) hz[k] = fbm((k % W) * 0.03, Math.floor(k / W) * 0.06, 3, 0);

  // the sky's colour at a point, for the sky itself and as haze on the peaks
  const skyR = new Float32Array(SR * W), skyG = new Float32Array(SR * W), skyB = new Float32Array(SR * W);
  const glowA = new Float32Array(SR * W);
  for (let r = 0; r < SR; r++) {
    for (let x = 0; x < W; x++) {
      const y = r + 0.5;
      const v = clamp(y / 52);
      const east = smooth(20, 190, x);
      const dx = x + 0.5 - SUN[0], dy = (y - SUN[1]) * 2.2;
      const ds = Math.sqrt(dx * dx + dy * dy);
      const low = Math.pow(v, 1.9);
      // indigo overhead, a soft unevenness in it, then a pale lilac and rose
      // band behind the range, lighter than the mountains' shadowed flanks
      const veil = (hz[r * W + x] - 0.5) * 0.1 * (1 - v);
      let cr = 0.03 + veil + low * (0.56 + 0.2 * east);
      let cg = 0.04 + veil + low * (0.48 + 0.02 * east);
      let cb = 0.13 + veil * 1.6 + low * (0.62 - 0.12 * east);
      skyR[r * W + x] = cr;
      skyG[r * W + x] = cg;
      skyB[r * W + x] = cb;
      // the sun's glow, kept apart so it can breathe
      glowA[r * W + x] = Math.exp(-ds / 6) * 0.65 + Math.exp(-ds / 15) * 0.2 + Math.exp(-ds / 50) * 0.1;
    }
  }

  const AR = new Float32Array(SR * W), AG = new Float32Array(SR * W), AB = new Float32Array(SR * W);
  const FR = new Float32Array(N), FG = new Float32Array(N), FB = new Float32Array(N);
  const floor = new Float32Array(N);
  const fade = new Float32Array(N).fill(1);
  const wisp = new Float32Array(W);

  return (t, { color } = {}) => {
    const warm = 0.75 - 0.5 * Math.exp(-t / 60); // rose first light warming toward gold
    const line = 6 + 4 * Math.exp(-t / 70); // the sunlit line creeps down the slopes
    const drift = t * 1.1;
    const pulse = 1 + 0.06 * Math.sin((t / 8) * Math.PI * 2); // the sun's glow breathes
    for (let x = 0; x < W; x++) wisp[x] = noise((x + drift * 0.6) * 0.06, 3.7, 0);

    // the warm light, from rose toward gold
    const lr = 1, lg = mix(0.6, 0.8, warm), lb = mix(0.55, 0.4, warm);

    for (let r = 0; r < SR; r++) {
      const y = r + 0.5;
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const gl = glowA[k] * pulse;
        const gg = 0.62 + 0.28 * smooth(0.15, 0.7, gl); // gold at the core, rose further out
        let cr = skyR[k] + gl, cg = skyG[k] + gl * gg, cb = skyB[k] + gl * (gg - 0.22), fl = 0.21;
        const z = depth[k];
        if (z) {
          const sn = snow[k];
          const lit = sun[k] * smooth(line, line + 7, alt[k]);
          const amb = (0.55 + 0.45 * up[k]) * (0.55 + 0.5 * smooth(4, 34, alt[k]));
          // snow: deep blue in shadow, rose to gold in the sun, ending sharply
          const sl = smooth(0.08, 0.24, lit);
          // full on faces and summits glow gold, glancing light stays rose
          const gold = clamp(0.6 * smooth(0.25, 0.8, lit) + 0.5 * smooth(14, 36, alt[k]));
          const br = 0.78 + 0.3 * lit;
          const sr = mix(0.13 * amb, lr * br, sl);
          const sg = mix(0.17 * amb, mix(lg - 0.12, lg + 0.14, gold) * br, sl);
          const sb = mix(0.36 * amb, mix(lb + 0.02, lb + 0.12, gold) * br, sl);
          // rock: slate in shadow, warm umber in the sun
          const rr = mix(0.06, 0.4, sl), rg = mix(0.07, 0.2, sl), rb = mix(0.14, 0.2, sl);
          cr = mix(rr, sr, sn);
          cg = mix(rg, sg, sn);
          cb = mix(rb, sb, sn);
          // forested foothills
          const wood = smooth(9, 4, alt[k]) * smooth(110, 75, z);
          cr = mix(cr, 0.07, wood);
          cg = mix(cg, 0.09, wood);
          cb = mix(cb, 0.18, wood);
          // distance hazes toward the sky behind, and haze settles in the
          // far valleys so each ridge stands clear of the one behind it
          const fog = smooth(16, 3, alt[k]) * smooth(70, 150, z) * 0.6;
          const haze = Math.max(fog, clamp(1 - Math.exp(-(z - SHORE_Z) / 260)) * 0.45);
          cr = mix(cr, skyR[k] + gl, haze);
          cg = mix(cg, skyG[k] + gl * gg, haze);
          cb = mix(cb, skyB[k] + gl * (gg - 0.22), haze);
          // the first light catches the crest itself in a bright line
          if (rim[k] && sl > 0.3) {
            const a = rim[k] * sl;
            cr = mix(cr, 1, a);
            cg = mix(cg, mix(0.89, 0.95, warm), a);
            cb = mix(cb, mix(0.76, 0.88, warm), a);
          }
          // the shadowed range still carries a dim blue screen; the wooded
          // foothills in front of it drop away to near black
          fl = mix(mix(0.42, 0.3, wood), 0.04, sl);
        } else {
          // a few stars still out in the west
          if (y < 34 && hash(x, r * 3 + 11) > 0.985) {
            const tw = 0.6 + 0.4 * Math.sin(t * (1.5 + hash(x, r) * 3) + hash(r, x) * 6.28);
            const s = tw * smooth(150, 40, x) * smooth(34, 6, y) * 0.75;
            cr = Math.max(cr, s * 0.9);
            cg = Math.max(cg, s * 0.92);
            cb = Math.max(cb, s);
          }
          // high cloud, rose-gold toward the sun and mauve away from it
          if (r < CR) {
            const sx = x + t * 0.8, ix = Math.floor(sx), fx = sx - ix;
            const c0 = cloud[r * CW + (ix % CW)], c1 = cloud[r * CW + ((ix + 1) % CW)];
            const c = (c0 + (c1 - c0) * fx) * (0.35 + 0.65 * smooth(40, 150, x));
            if (c > 0.01) {
              const g = Math.exp(-Math.hypot(x + 0.5 - SUN[0], (y - SUN[1]) * 1.6) / 55);
              const b = clamp(0.25 + 0.9 * g);
              const kr = mix(0.32, 1, b), kg = mix(0.24, mix(0.62, 0.74, warm), b), kb = mix(0.4, 0.5, b);
              cr = mix(cr, kr, c * 0.75);
              cg = mix(cg, kg, c * 0.75);
              cb = mix(cb, kb, c * 0.75);
            }
          }
          // the sun, just clearing the ridge
          const dx = x + 0.5 - SUN[0], dy = y - SUN[1];
          const ds = Math.sqrt(dx * dx + dy * dy);
          if (ds < 4.5) {
            const a = smooth(4.5, 3.3, ds);
            cr = mix(cr, 1, a);
            cg = mix(cg, 0.96, a);
            cb = mix(cb, 0.86, a);
          }
        }
        // mist pooled in the valley behind the shore, lit rose toward the sun
        const e = smooth(20, 170, x) * (0.6 + 0.4 * warm);
        const near = Math.exp(-Math.abs(x + 0.5 - SUN[0]) / 22) * 0.3;
        const mr = mix(0.48, 0.9, e) + near, mg = mix(0.46, 0.7, e) + near * 0.75, mb = mix(0.7, 0.7, e) + near * 0.5;
        if (r >= M0) {
          const m = mist[(r - M0) * MW + (Math.floor(x + drift) % MW)];
          // a ragged top edge: the sheet heaves in long swells and small tufts
          const edge = 5 * (m - 0.5) + 4 * (wisp[x] - 0.5);
          const band = smooth(M0 + 14, SHORE - 3, y + edge);
          const a = (0.3 + 0.7 * smooth(0.32, 0.64, m)) * band * 0.6;
          cr = mix(cr, mr, a);
          cg = mix(cg, mg, a);
          cb = mix(cb, mb, a);
          if (a > 0.05) fl = Math.max(fl, 0.2);
        }
        if (y >= treeTop[x]) {
          // the far shore's pines, dark against the mist
          const s = 0.4 + 0.6 * hash(x * 7, r * 3);
          cr = 0.04 + 0.03 * s;
          cg = 0.06 + 0.04 * s;
          cb = 0.1 + 0.05 * s;
          fl = 0;
          // and low wisps drifting across their feet
          const m = mist[(r - M0) * MW + (Math.floor(x * 0.7 + drift * 1.9 + 211) % MW)];
          const a = smooth(0.45, 0.72, m) * smooth(treeTop[x] + 1, SHORE, y) * 0.6;
          cr = mix(cr, mr, a);
          cg = mix(cg, mg, a);
          cb = mix(cb, mb, a);
        }
        AR[k] = cr;
        AG[k] = cg;
        AB[k] = cb;
        FR[k] = cr;
        FG[k] = cg;
        FB[k] = cb;
        floor[k] = fl;
      }
    }

    // the lake: the picture above, shaken a little by slow ripples
    for (let r = SHORE; r < H; r++) {
      const y = r + 0.5;
      const d = (y - SHORE) / LR;
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const w1 = noise(x * 0.045 + t * 0.06, y * 0.5 - t * 0.35, 0);
        const w2 = noise(x * 0.12 - t * 0.1, y * 1.1 - t * 0.7, 0);
        const sway = (w1 - 0.5) * (0.4 + 1.4 * d) + (w2 - 0.5) * 0.5;
        const sx = Math.max(0, Math.min(W - 1, Math.round(x + sway)));
        const sr = Math.max(0, Math.min(SR - 1, Math.round(src[(r - SHORE) * W + x] + (w2 - 0.5) * 0.6 * d)));
        const sk = sr * W + sx;
        const refl = 0.68 - 0.32 * d;
        const w3 = noise(x * 0.03 + t * 0.04, y * 1.9 - t * 0.45, 0);
        const lift = 1 + (w3 - 0.5) * (0.4 + 0.5 * d); // long, faint ripple lines
        // the water gives back a little less colour than it was sent
        const ar = AR[sk], ag = AG[sk], ab = AB[sk];
        const grey = (ar + ag + ab) / 3;
        const deep = (hz[k] - 0.5) * 0.1 * d; // slow unevenness in the dark water
        let cr = 0.02 + deep + mix(grey, ar, 0.75) * refl * lift;
        let cg = 0.035 + deep + mix(grey, ag, 0.75) * refl * lift;
        let cb = 0.07 + deep * 1.6 + mix(grey, ab, 0.75) * refl * lift;
        // the sun's road
        const roadW = 1.5 + (y - SHORE) * 0.45;
        const road = Math.exp(-(((x + 0.5 - SUN[0]) / roadW) ** 2));
        const glint = smooth(0.55, 0.85, w2) * road * (0.5 + 0.5 * warm);
        cr += glint;
        cg += glint * 0.8;
        cb += glint * 0.6;
        FR[k] = cr;
        FG[k] = cg;
        FB[k] = cb;
        floor[k] = 0.26;
        fade[k] = smooth(H + 2, H - 22, y);
        if (r === SHORE) {
          // a dark seam where the shore meets the water
          FR[k] = 0.06;
          FG[k] = 0.12;
          FB[k] = 0.14;
          floor[k] = 0;
        }
      }
    }

    // the near pines and the bank, black against it all, rimmed on the sun side
    for (let k = 0; k < N; k++) {
      if (!fg[k]) continue;
      const s = fgShade[k];
      FR[k] = 0.02 + 0.05 * s;
      FG[k] = 0.04 + 0.06 * s;
      FB[k] = 0.05 + 0.06 * s;
      floor[k] = 0;
      const e = fgRim[k];
      if (e > 0 && s === 1) {
        // warm on the side facing the sun
        FR[k] = mix(FR[k], 0.62, e), FG[k] = mix(FG[k], 0.4, e), FB[k] = mix(FB[k], 0.38, e);
        floor[k] = 0.12 * e;
      } else if (e > 0 && s === 0.5) {
        // cool lilac from the sky on the other
        FR[k] = mix(FR[k], 0.2, e), FG[k] = mix(FG[k], 0.22, e), FB[k] = mix(FB[k], 0.36, e);
        floor[k] = 0.22 * e;
      }
      fade[k] = 1;
    }

    for (let r = 0; r < H; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const cr = FR[k], cg = FG[k], cbl = FB[k], fl = floor[k];
        const peak = Math.max(cr, cg, cbl, 1e-4);
        const level = clamp(fl + (1 - fl) * Math.pow(peak, 1.1)) * fade[k];
        const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(r & 3) * 4 + (x & 3)])));
        out[k] = DOTS[step];
        if (color) {
          const want = step ? Math.min(1, (level + 0.06) / COVER[step]) : 0;
          // dim cells keep some of their darkness in the colour too, so the
          // shadows sit back in deep blues rather than as a bright fine screen
          const s = ((0.3 + 0.7 * want) * mix(0.5, 1, smooth(0.08, 0.5, peak))) / 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");
  };
}