earthrise

[github]

the earth rising over a cratered lunar horizon in long low sunlight

scenes 200×100 15 fps 38 colours, on a canvas 21.7 kB

use it

html

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

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

<Ascii piece={earthrise} />

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

astro

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

<Ascii piece="earthrise" />

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

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

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.

earthrise.ts

[js] [github]
/*
 * earthrise: the Earth coming up over the lunar horizon. The sun is low on the
 * right, so every crater rim and boulder throws a long black shadow across the
 * grey ground, and the same light makes a gibbous Earth with a clean line
 * between day and night. The Earth turns, its clouds drift, it climbs very
 * slowly, and a few bright stars breathe.
 *
 * The ground is a heightfield of craters, rendered once column by column from
 * a camera standing on it, with real shadows marched toward the sun. Each
 * frame only the Earth and the few stars that twinkle are shaded again. Every
 * cell is then drawn as a halftone dot sized by its brightness, ordered-
 * dithered, in the palette colour nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "earthrise",
  category: "scenes",
  note: "the earth rising over a cratered lunar horizon in long low sunlight",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#030408",
  palette: [
    "#18181b", "#232326", "#303033", "#414143", "#555556", "#6b6a69", "#83817d", "#9c9993",
    "#b6b2aa", "#cfcac1", "#e6e1d8", "#f7f4ee",
    "#0c1120", "#131a2e", "#1b2540", "#262f4a",
    "#dfe9ff",
    "#0a2259", "#0f2f72", "#15408c", "#1d53a6", "#2a69bf", "#4386d3",
    "#6eaeea", "#a8d3f6",
    "#e8f0fa", "#c2d0e3", "#8b9fbc",
    "#2f4a26", "#3b5a2c", "#5b7238", "#7a9150", "#77783f", "#8f8550", "#a8955e", "#6b5634",
    "#b9774a", "#8a4838",
  ],
} satisfies Meta;

const W = 200, H = 100;
const EYE = 37; // screen row of eye level; the horizon dips below it
const F = 112; // focal length in cells
const CAM_H = 20;
const RM = 1500; // the moon's radius, in ground units, for the falling horizon
const ZMAX = 520;
const EC = [141, 32], ER = 22; // the Earth on screen, its lower edge still behind the horizon
const LON0 = 3.5; // which face of the globe is turned to us at the start
const RISE = 5, RISE_T = 150; // it climbs RISE rows and settles back over RISE_T seconds
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);
// the bright stars, which twinkle: [col, row, brightness, period in seconds]
const BRIGHT = [[24, 9, 1, 4.6], [67, 27, 0.85, 3.4], [99, 12, 0.95, 5.8], [189, 7, 0.8, 4.1]];

// toward the sun: low, from the right and a little behind us (z is forward)
const SUN = (() => {
  const v = [0.94, 0.14, -0.3];
  const l = Math.hypot(...v);
  return v.map((c) => c / l);
})();

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): number => (v < 0 ? 0 : v > 1 ? 1 : v);
const smooth = (a: number, b: number, v: number): number => {
  const k = clamp((v - a) / (b - a));
  return k * k * (3 - 2 * k);
};
const mix = (a: number, b: number, k: number): number => a + (b - a) * k;
const hex = (s: string): number[] => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255);

// A bowl with a raised rim, r in ground units, q its distance in radii.
const bowl = (q: number, r: number): number => r * ((q < 1 ? -0.36 * (1 - q * q) : 0) + 0.14 * Math.exp(-(((q - 1) / 0.25) ** 2)));

// Craters scattered one to a cell.
function craters(X: number, Z: number, cell: number, salt: number, r0: number, r1: number, p: number): number {
  const ci = Math.floor(X / cell), cj = Math.floor(Z / cell);
  let h = 0;
  for (let j = cj - 1; j <= cj + 1; j++) {
    for (let i = ci - 1; i <= ci + 1; i++) {
      if (hash(i + salt, j - salt) > p) continue;
      const cx = (i + hash(i, j + salt * 3)) * cell, cz = (j + hash(i + salt * 5, j)) * cell;
      const e = hash(j + salt, i - salt * 7);
      const r = cell * (r0 + (r1 - r0) * e * e);
      const dx = X - cx, dz = Z - cz, d2 = dx * dx + dz * dz;
      if (d2 > 4 * r * r) continue;
      h += bowl(Math.sqrt(d2) / r, r) * 0.95;
    }
  }
  return h;
}

// a few big boulders in the near ground, [x, z, radius]
const ROCKS = [[30, 50, 2.4], [62, 63, 1.8], [12, 70, 1.3], [-4, 45, 1.2], [90, 55, 1.6], [46, 90, 1.4]];

function height(X: number, Z: number): number {
  let h = 6 * fbm(X * 0.006 + 50, Z * 0.006 + 50, 3, 0) + 0.6 * fbm(X * 0.04, Z * 0.04, 2, 0);
  // old, worn highlands at the edge of sight, rising to the left, and a lower
  // ridge in front of them
  if (Z > 150) {
    const lift = smooth(150, 300, Z);
    // rounded massifs: folded noise, worn smooth
    const m = 1 - Math.abs(2 * fbm(X * 0.006 + 7, Z * 0.006, 4, 0) - 1);
    h += lift * (28 * Math.exp(-(((X + 260) / 230) ** 2)) + 45 * (m * m - 0.35));
    const ridge = Math.exp(-(((Z - 205) / 20) ** 2)) * Math.exp(-(((X + 140) / 130) ** 2));
    h += ridge * 22 * (0.35 + fbm(X * 0.018 + 3, Z * 0.01, 3, 0));
  }
  // big basins only out in the middle distance, so we do not stand in one
  if (Z > 90) h += craters(X, Z, 110, 11, 0.14, 0.34, 0.55) * smooth(90, 150, Z);
  if (Z < 300) h += craters(X, Z, 34, 23, 0.12, 0.36, 0.85);
  if (Z < 170) h += craters(X, Z, 10, 37, 0.12, 0.34, 0.85) * smooth(170, 110, Z);
  // one big crater in the near ground, off to the left
  {
    const dx = X + 24, dz = Z - 58;
    const q = Math.sqrt(dx * dx + dz * dz) / 16;
    if (q < 2) h += bowl(q, 16);
  }
  // boulders strewn close by, and a few big ones
  if (Z < 90) {
    const c = 5, ci = Math.floor(X / c), cj = Math.floor(Z / c);
    if (hash(ci + 91, cj) < 0.14) {
      const bx = (ci + 0.2 + 0.6 * hash(ci, cj + 92)) * c, bz = (cj + 0.2 + 0.6 * hash(ci + 93, cj)) * c;
      const br = 0.3 + 0.5 * hash(ci + 94, cj + 95) ** 2;
      const d2 = ((X - bx) ** 2 + (Z - bz) ** 2) / (br * br);
      if (d2 < 4) h += br * 0.9 * Math.exp(-d2 * 1.4);
    }
    for (const [bx, bz, br] of ROCKS) {
      const d2 = ((X - bx) ** 2 + (Z - bz) ** 2) / (br * br);
      // a squat, lumpy dome
      if (d2 < 1) h += br * (0.85 + 0.3 * noise(X * 1.3, Z * 1.3, 0)) * Math.sqrt(1 - d2);
    }
  }
  return h;
}

export default function earthrise(): Frame {
  const P = meta.palette.map(hex);
  const N = W * H;

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

  // Halftone one cell: dot size from brightness, colour from hue, with the
  // colour making up what the dot size could not.
  const chars: string[] = new Array(N).fill(" ");
  const cols = new Uint8Array(N);
  const dot = (k: number, x: number, r: number, cr: number, cg: number, cb: number, floor: number, cap: number) => {
    const peak = Math.max(cr, cg, cb, 1e-4);
    const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.85) * 0.95);
    const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(r & 3) * 4 + (x & 3)])));
    chars[k] = DOTS[step];
    const want = step ? Math.min(1, (level + 0.06) / COVER[step]) : 0;
    const s = Math.min(cap, 0.3 + 0.7 * want) / peak;
    cols[k] = nearest(clamp(cr * s), clamp(cg * s), clamp(cb * s));
  };

  // --- the ground: march each column from near to far ---------------------
  const ground = new Uint8Array(N);
  const top = new Int16Array(W).fill(H);
  const gx = new Float32Array(N), gz = new Float32Array(N), gy = new Float32Array(N);
  const camY = height(0, 7) + CAM_H;
  for (let c = 0; c < W; c++) {
    const dir = (c + 0.5 - W / 2) / F;
    let topR = H, Z = 26, prevY = 0, prevZ = 0, prevF = 1e9;
    while (Z < ZMAX && topR > 0) {
      const X = dir * Z;
      const hy = height(X, Z);
      const Y = hy - (Z * Z) / (2 * RM);
      const yf = EYE - (F * (Y - camY)) / Z;
      let r0 = Math.max(0, Math.ceil(yf - 0.5));
      for (let r = r0; r < topR; r++) {
        // place the cell between this sample and the last, by where its row falls
        const a = prevF > yf + 1e-6 ? clamp((prevF - (r + 0.5)) / (prevF - yf)) : 1;
        const k = r * W + c;
        ground[k] = 1;
        gz[k] = prevZ ? mix(prevZ, Z, a) : Z;
        gx[k] = dir * gz[k];
        gy[k] = prevZ ? mix(prevY, hy, a) : hy;
      }
      if (r0 < topR) topR = r0;
      (prevF = yf), (prevY = hy), (prevZ = Z);
      Z += 0.03 + Z * 0.012;
    }
    top[c] = topR;
  }

  // Static light: the ground and the sky, everything but the Earth's disc.
  const sr = new Float32Array(N), sg = new Float32Array(N), sb = new Float32Array(N), sf = new Float32Array(N), scap = new Float32Array(N).fill(1);
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      if (!ground[k]) continue;
      const X = gx[k], Z = gz[k], Y = gy[k];
      const e = 0.1 + Z * 0.004;
      const hx = (height(X + e, Z) - height(X - e, Z)) / (2 * e);
      const hz = (height(X, Z + e) - height(X, Z - e)) / (2 * e);
      const nl = Math.hypot(hx, 1, hz);
      const lam = (-hx * SUN[0] + SUN[1] - hz * SUN[2]) / nl;
      // toward the eye, for the moon's own way of reflecting (Lommel-Seeliger)
      const vx = -X, vy = camY - Y, vz = -Z, vl = Math.hypot(vx, vy, vz);
      const mu = Math.max(0.02, (-hx * vx + vy - hz * vz) / (nl * vl));
      let lit = 0;
      if (lam > 0) {
        // march toward the sun; a soft edge for the sun's own width
        lit = 1;
        let s = 0.1 + Z * 0.003;
        while (s < 120) {
          const px = X + SUN[0] * s, pz = Z + SUN[2] * s, py = Y + SUN[1] * s;
          const d = py - height(px, pz);
          if (d < 0) {
            lit = 0;
            break;
          }
          lit = Math.min(lit, (d * 30) / s);
          s += 0.05 + s * 0.2;
        }
        lit = smooth(0, 1, lit);
      }
      // regolith: patchy, the maria darker
      const albedo = 0.5 + 0.9 * fbm(X * 0.025 + 3, Z * 0.025, 3, 0) + 0.3 * (fbm(X * 0.35, Z * 0.35, 2, 0) - 0.5) - 0.22 * smooth(0.46, 0.64, fbm(X * 0.0035, Z * 0.0035 + 20, 3, 0));
      const ls = lam > 0 ? ((0.2 * lam) / (lam + mu) + 2.6 * lam) * lit : 0;
      // the near corners fall off a little, to frame the view
      const vig = 1 - 0.3 * smooth(84, 102, r) * smooth(30, 100, Math.abs(x - 100));
      let b = Math.pow((1 - Math.exp(-ls * 1.5)) * albedo, 1.0) * vig;
      // the far crest catches the sun along its whole length
      const crest = r - top[x];
      if (crest < 2 && lit > 0.2) b = Math.max(b, (crest ? 0.55 : 0.85) * albedo);
      // shadow is black, but for a breath of earthshine on what faces us
      const fill = lit > 0.02 || b > 0.03 ? 0.008 + 0.007 * clamp(-hz / nl + 0.5) : 0;
      sr[k] = b * 1.0 + fill * 0.75;
      sg[k] = b * 0.95 + fill * 0.85;
      sb[k] = b * 0.86 + fill * 1.2;
      sf[k] = 0;
      scap[k] = 0.42 + 0.62 * b; // grey stays grey: dim ground draws in darker ink
    }
  }

  // the sky: a soft band of the galaxy, and stars that hold still
  const near = (x: number, r: number): boolean => Math.hypot(x + 0.5 - EC[0], r + 0.5 - (EC[1] - RISE / 2)) < 2 * ER;
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      if (ground[k]) continue;
      // a black sky. Faint stars, thicker along a diagonal where the galaxy
      // runs, none round the Earth
      const bandD = (r - (4 + x * 0.32)) / 1.05;
      const band = Math.exp(-((bandD / 10) ** 2)) * smooth(0.35, 0.65, fbm(x * 0.05, r * 0.08, 3, 0)) * smooth(120, 70, x);
      let cr = 0, cg = 0, cb = 0, cap = 1;
      const hs = hash(x * 3 + 1, r * 7 + 2);
      if (hs > 0.994 - 0.05 * band && !near(x, r)) {
        const m = Math.pow(hash(x + 17, r + 29), 3);
        const s = 0.2 + 0.5 * m;
        const tint = hash(x + 5, r + 77);
        const [tr, tg, tb] = tint < 0.3 ? [0.84, 0.9, 1] : tint > 0.88 ? [1, 0.93, 0.84] : [0.96, 0.96, 0.98];
        (cr = s * tr), (cg = s * tg), (cb = s * tb);
      }
      sr[k] = cr, sg[k] = cg, sb[k] = cb, sf[k] = 0, scap[k] = cap;
    }
  }
  for (let k = 0; k < N; k++) dot(k, k % W, (k / W) | 0, sr[k], sg[k], sb[k], sf[k], scap[k]);

  // --- the Earth -----------------------------------------------------------
  // Equirectangular maps, wrapping in longitude: surface colour and cloud.
  const TW = 192, TH = 96;
  const tr = new Float32Array(TW * TH), tg = new Float32Array(TW * TH), tb = new Float32Array(TW * TH), sea = new Float32Array(TW * TH);
  const cloud = new Float32Array(TW * TH);
  const storms = [[0.9, 0.8, 1], [3.1, -0.85, -1], [4.6, 0.62, 1], [1.9, 0.25, 1], [5.6, -0.55, -1]];
  const elev = new Float32Array(TW * TH);
  for (let j = 0; j < TH; j++) {
    const v = (j + 0.5) / TH, lat = (0.5 - v) * Math.PI, al = Math.abs(lat);
    for (let i = 0; i < TW; i++) {
      const u = i / TW, lon = u * Math.PI * 2, t = j * TW + i;
      const wx = fbm(u * 6, v * 3 + 9, 3, 6);
      elev[t] = fbm(u * 8 + 1.6 * wx, v * 4, 5, 8) - 0.04 * smooth(1.2, 1.5, al);
      // clouds: warped noise, wound into spirals around a few storms
      let cx = u * 14, cy = v * 7;
      for (const [slon, slat, spin] of storms) {
        let dl = lon - slon;
        dl -= Math.round(dl / (Math.PI * 2)) * Math.PI * 2;
        const lx = dl * Math.cos(lat), ly = lat - slat;
        const dd = Math.hypot(lx, ly);
        const a = spin * 5 * Math.exp(-dd / 0.2);
        if (a * spin > 0.02) {
          const ca = Math.cos(a), sa = Math.sin(a);
          cx += ((lx * ca - ly * sa - lx) / (Math.PI * 2)) * 14;
          cy -= ((lx * sa + ly * ca - ly) / Math.PI) * 7;
        }
      }
      // streaked along the winds, east to west
      const q = fbm(cx * 0.5 + 3, cy * 1.2, 3, 7);
      const n0 = fbm(cx + 1.6 * q, cy * 1.3 + 0.5 * q, 5, 14);
      // folded into filaments, the way weather fronts string out
      const n = 0.4 * n0 + 0.6 * (1 - Math.abs(2 * fbm(cx * 1.5 + 2.2 * q, cy * 1.6 + 9, 4, 21) - 1));
      // cloudy at the equator and in the storm belts, clearer in the subtropics
      const belt = 0.05 * Math.exp(-((lat / 0.12) ** 2)) - 0.07 * Math.exp(-(((al - 0.42) / 0.16) ** 2)) + 0.05 * Math.exp(-(((al - 0.95) / 0.25) ** 2));
      cloud[t] = n + belt;
    }
  }
  // thresholds by share of the globe: about three tenths land, a third cloud
  const quantile = (A: Float32Array, p: number): number => {
    const s = Float32Array.from(A).sort();
    return s[Math.floor(p * (s.length - 1))];
  };
  const shore = quantile(elev, 0.7), c0 = quantile(cloud, 0.6), c1 = quantile(cloud, 0.86);
  for (let j = 0; j < TH; j++) {
    const v = (j + 0.5) / TH, lat = (0.5 - v) * Math.PI, al = Math.abs(lat);
    for (let i = 0; i < TW; i++) {
      const u = i / TW, t = j * TW + i, e = elev[t];
      const land = smooth(shore - 0.004, shore + 0.006, e);
      const ice = smooth(1.22, 1.32, al + 0.12 * fbm(u * 12, v * 6, 2, 12));
      // desert in the subtropics and on high ground, forest and scrub elsewhere,
      // broken up finely so a continent is never one flat tone
      const grain = fbm(u * 36 + 2, v * 18, 3, 36) - 0.5;
      const arid = clamp(Math.exp(-(((al - 0.4) / 0.22) ** 2)) * (0.1 + 1.2 * fbm(u * 10 + 4, v * 5, 3, 10)) + (e - shore - 0.04) * 4 + 0.8 * grain);
      const relief = 1 + 1.2 * grain - 2.5 * Math.max(0, e - shore - 0.08);
      let r = mix(0.13, 0.4, arid) * relief, g = mix(0.25, 0.34, arid) * relief, b = mix(0.08, 0.19, arid) * relief;
      // ocean, lighter over the shelves near the coasts
      const shelf = smooth(shore - 0.06, shore, e);
      const or = mix(0.025, 0.07, shelf), og = mix(0.11, 0.3, shelf), ob = mix(0.38, 0.62, shelf);
      r = mix(or, r, land), g = mix(og, g, land), b = mix(ob, b, land);
      r = mix(r, 0.92, ice), g = mix(g, 0.95, ice), b = mix(b, 0.99, ice);
      tr[t] = r, tg[t] = g, tb[t] = b, sea[t] = (1 - land) * (1 - ice);
      cloud[t] = smooth(c0, c1, cloud[t]);
    }
  }
  const sample = (A: Float32Array, lon: number, vy: number): number => {
    const fx = (((lon / (Math.PI * 2)) % 1) + 1) % 1 * TW;
    const x0 = Math.floor(fx), ax = fx - x0, x1 = (x0 + 1) % TW;
    const y0 = Math.max(0, Math.min(TH - 2, Math.floor(vy))), ay = clamp(vy - y0);
    const a = A[y0 * TW + x0], b = A[y0 * TW + x1], c = A[y0 * TW + TW + x0], d = A[y0 * TW + TW + x1];
    return a + (b - a) * ax + (c - a) * ay + (a - b - c + d) * ax * ay;
  };

  const L = [SUN[0], SUN[1], -SUN[2]]; // into screen space, z toward us
  const HV = (() => {
    const v = [L[0], L[1], L[2] + 1];
    const l = Math.hypot(...v);
    return v.map((c) => c / l);
  })();
  const tilt = 0.4, nod = 0.22;
  const ct = Math.cos(tilt), st = Math.sin(tilt), cn = Math.cos(nod), sn = Math.sin(nod);
  // the sky cells the Earth and its air can reach, as it rises and settles
  const box: number[] = [];
  for (let r = Math.max(0, EC[1] - RISE - ER - 13); r <= Math.min(H - 1, EC[1] + ER + 2); r++) {
    for (let x = EC[0] - ER - 13; x <= EC[0] + ER + 13; x++) if (!ground[r * W + x]) box.push(r * W + x);
  }
  // the bright stars and the faint cross each one carries
  const twinkle: [k: number, x: number, r: number, s: number, p: number, ph: number, core: boolean][] = [];
  for (const [x, r, s, p] of BRIGHT) {
    for (const [ox, oy, w] of [[0, 0, 1], [-1, 0, 0.2], [1, 0, 0.2], [0, -1, 0.2], [0, 1, 0.2]]) {
      const k = (r + oy) * W + x + ox;
      if (!ground[k]) twinkle.push([k, x + ox, r + oy, s * w, p, hash(x, r) * 6.28, w === 1]);
    }
  }
  const dirty = new Uint8Array(H);
  for (const k of box) dirty[(k / W) | 0] = 1;
  for (const [k] of twinkle) dirty[(k / W) | 0] = 1;
  const lines: string[] = [];
  for (let r = 0; r < H; r++) lines.push(chars.slice(r * W, (r + 1) * W).join(""));

  return (t, { color } = {}) => {
    const spin = t * 0.045;
    const drift = t * 0.012; // clouds run a little ahead of the ground
    const ey = EC[1] - RISE * (0.5 - 0.5 * Math.cos((t / RISE_T) * Math.PI * 2));
    for (const k of box) {
      const x = k % W, r = (k / W) | 0;
      let cr = sr[k], cg = sg[k], cb = sb[k], cap = scap[k], floor = 0;
      const dx = x + 0.5 - EC[0], dy = r + 0.5 - ey;
      const d = Math.hypot(dx, dy);
      // the Earth's air, a thin blue rim on its sunlit side
      if (d >= ER - 1 && d < ER + 12) {
        const side = smooth(-0.3, 0.75, (dx * L[0] - dy * L[1]) / d);
        let g = Math.exp(-Math.max(0, d - ER) / 1.2) * 0.55 * side;
        if (g < 0.05) g = 0; // no stray haze dots out in space
        cr += 0.25 * g, cg += 0.52 * g, cb += 1.0 * g;
        if (g > 0) cap = 1;
      }
      if (d < ER) {
        const nx = dx / ER, ny = -dy / ER, q2 = nx * nx + ny * ny;
        const nz = Math.sqrt(1 - q2);
        // into the globe's own frame: tip the pole toward us, then lean it
        const ax = nx * ct + ny * st;
        const ay0 = -nx * st + ny * ct;
        const ay = ay0 * cn - nz * sn;
        const az = ay0 * sn + nz * cn;
        const lat = Math.asin(Math.max(-1, Math.min(1, ay)));
        const lon = Math.atan2(ax, az) + LON0 + spin;
        const vy = (0.5 - lat / Math.PI) * TH - 0.5;
        const ndl = nx * L[0] + ny * L[1] + nz * L[2];
        // full sun at the right limb, dimming toward the terminator, so the
        // disc reads as a ball
        const day = smooth(-0.005, 0.06, ndl) * (0.45 + 0.8 * Math.sqrt(clamp(ndl)));
        const dusk = Math.exp(-(((ndl - 0.02) / 0.035) ** 2));
        const cl = sample(cloud, lon + drift, vy);
        // the cloud's own shadow, offset away from the sun
        const sh = sample(cloud, lon + drift - 0.03, vy + 0.4);
        const sw = sample(sea, lon, vy);
        let er = sample(tr, lon, vy), eg = sample(tg, lon, vy), eb = sample(tb, lon, vy);
        const shade = 1 - 0.45 * sh * (1 - cl);
        er *= shade, eg *= shade, eb *= shade;
        er = mix(er, 0.95, cl), eg = mix(eg, 0.97, cl), eb = mix(eb, 1.0, cl);
        er *= day * (1 + 0.06 * dusk), eg *= day * (1 - 0.03 * dusk), eb *= day * (1 - 0.1 * dusk);
        const glint = Math.pow(Math.max(0, nx * HV[0] + ny * HV[1] + nz * HV[2]), 70) * 0.8 * sw * (1 - cl);
        const rim = Math.pow(1 - nz, 2.2) * smooth(0, 0.3, ndl);
        er += glint * 0.95 + rim * 0.2;
        eg += glint * 0.92 + rim * 0.42;
        eb += glint * 0.85 + rim * 0.85;
        // a crisp edge where the disc meets space; the night side is a void
        const edge = smooth(1, 0.95, Math.sqrt(q2));
        cr = mix(cr, er, edge), cg = mix(cg, eg, edge), cb = mix(cb, eb, edge);
        // land keeps its earth tones instead of washing out to cream
        cap = mix(1, 0.66, (1 - sw) * (1 - cl) * smooth(0.95, 0.85, Math.abs(ay)));
        // the day side is the brightest thing in the sky: full, round dots
        floor = 0.15 * Math.min(1, day) * edge;
      }
      dot(k, x, r, cr, cg, cb, floor, cap);
    }
    for (const [k, x, r, s, p, ph, core] of twinkle) {
      const v = s * (0.86 + 0.14 * Math.sin((t / p) * Math.PI * 2 + ph));
      if (core) dot(k, x, r, v * 0.97, v * 0.98, v, 0, 1);
      else dot(k, x, r, Math.max(sr[k], v * 0.95), Math.max(sg[k], v), Math.max(sb[k], v * 1.15), 0, 0.6);
    }
    for (let r = 0; r < H; r++) if (dirty[r]) lines[r] = chars.slice(r * W, (r + 1) * W).join("");
    if (color) color.set(cols);
    return lines.join("\n");
  };
}