saturn rise

[docs] [github]

saturn and its rings hanging over the cracked ice and geysers of a moon

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

html

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

<ascii-art piece="saturn-rise"></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install ascii.rest
import { Ascii } from "ascii.rest/react";
import { saturnRise } from "ascii.rest/pieces";

<Ascii piece={saturnRise} />

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

astro

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

<Ascii piece="saturn-rise" />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install ascii.rest
import { mount } from "ascii.rest";
import { saturnRise } from "ascii.rest/pieces";

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

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

terminal

npx ascii.rest saturn-rise

Plays it in your terminal, in colour, until you press a key. Add --seconds 5 to stop it on its own; npx ascii.rest list shows every piece. In a CLI of your own, play("saturn-rise", { seconds: 2 }) from ascii.rest/terminal shows it as a splash screen.

saturn-rise.ts

[js] [github]
/*
 * saturn rise: Saturn hanging huge over the cracked ice of one of its small
 * moons. The rings are tilted toward us and cut razor thin, the planet's
 * shadow falls across them behind the globe, and their own shadow lies in a
 * band on the clouds. Geysers vent ice from the fractures in slow glittering
 * fans, the ring particles shimmer as they orbit, a tiny moon crosses, and the
 * small far sun burns low over the ridges.
 *
 * Saturn and its rings are traced once, cell by cell, as a sphere and a plane
 * seen from far away, so every shadow is real and static; each frame only the
 * cloud bands turn and the rings' texture slides round at its own speed for
 * each radius. 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: "saturn rise",
  category: "scenes",
  note: "saturn and its rings hanging over the cracked ice and geysers of a moon",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#04050a",
  palette: [
    "#0b0d14", "#161922", "#252935", "#3a3f4c",
    "#3b2a1a", "#5c4027", "#7f5a35", "#a37645", "#c4925a", "#dcae74", "#ecc893", "#f6e0b8",
    "#6b6a6c", "#8a8070", "#b3a892", "#d8cfba", "#7f8a96",
    "#0e1a2e", "#15284a", "#1f3a66", "#2c5085", "#3f6aa3", "#5e8bbf", "#89afd6", "#b6d0ea", "#ddebf8",
    "#2a7fb0", "#56b3dc",
    "#ffffff", "#fff0c8",
  ],
} satisfies Meta;

const W = 200, H = 100;
const HZ = 70; // the horizon row
const F = 110; // focal length in cells, for the sky
const FG = 60; // and a wider one for the ice, so the near plates are not squashed flat
const CAM = 8; // eye height over the ice
const SC = [84, 33], SR = 25; // Saturn's centre and radius on screen
const INC = 0.34, ROLL = 0.14; // the rings open toward us and lean up to the right
const SUN = [173, 55]; // the sun on screen
const MOON_R = 2.85, MOON_T = 140; // the small moon's orbit, in Saturn radii, and its period
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 = [[176, 12, 1, 4.4], [12, 9, 0.85, 3.6], [194, 33, 0.8, 5.2], [150, 5, 0.75, 4.9], [7, 44, 0.7, 3.9]];
// the geysers: [base col, base row, height in rows, spread, brightness]
const PLUMES = [[146, 72, 40, 0.2, 0.9], [162, 75, 58, 0.22, 1.1], [124, 71, 26, 0.18, 0.8], [22, 77, 38, 0.2, 0.85]];
const STRIPES = [25, 46, 92]; // the tiger stripes, by distance

const norm = (v: number[]): number[] => {
  const l = Math.hypot(...v);
  return v.map((c) => c / l);
};
// the ring plane's normal, and two axes in it
const NR = [-Math.sin(ROLL) * Math.cos(INC), Math.cos(ROLL) * Math.cos(INC), Math.sin(INC)];
const E1 = norm([NR[1], -NR[0], 0]);
const E2 = [-NR[2] * E1[1], NR[2] * E1[0], NR[0] * E1[1] - NR[1] * E1[0]];
// toward the sun, for Saturn (z toward us) and for the ice (z away from us)
const LS = norm([0.6, 0.28, 0.72]);
const LG = norm([0.78, 0.42, 0.3]);
const LV = norm([(SUN[0] - 100) / F, (HZ - SUN[1]) / F, 1]); // the sun's real direction, for glare

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

// The rings by radius, in Saturn radii: how much light they stop, and their
// colour. C ring faint and grey, B ring dense and cream, the Cassini division
// nearly empty, A ring greyer with the Encke gap, then the thin F ring.
function ring(r: number): [a: number, cr: number, cg: number, cb: number] {
  const fine = 0.88 + 0.24 * fbm(r * 90, 3.3, 2, 0); // ringlets
  let a = 0, c = [0, 0, 0];
  if (r > 1.24 && r < 1.53) (a = 0.2 * fine), (c = [0.55, 0.52, 0.48]);
  else if (r >= 1.53 && r < 1.95) (a = Math.min(1, (0.7 + 0.4 * smooth(1.53, 1.7, r)) * fine)), (c = [1, 0.9, 0.72]);
  else if (r >= 1.95 && r < 2.03) (a = 0.02), (c = [0.5, 0.48, 0.45]);
  else if (r >= 2.03 && r < 2.27) (a = r > 2.2 && r < 2.23 ? 0.04 : 0.55 * fine), (c = [0.78, 0.74, 0.68]);
  else if (r > 2.315 && r < 2.335) (a = 0.4), (c = [0.85, 0.82, 0.76]);
  return [clamp(a), c[0], c[1], c[2]];
}

// The ice: low hummocks, long sharp pressure ridges wandering across the
// plain, and the tiger stripes, trenches with a low ridge either side.
function ice(X: number, Z: number): number {
  let h = 0.9 * fbm(X * 0.03 + 11, Z * 0.03, 3, 0);
  const w = fbm(X * 0.012, Z * 0.012 + 5, 2, 0);
  const crest = 1 - Math.abs(2 * noise(X * 0.016 + w * 2.5, Z * 0.045 + w * 3, 0) - 1);
  h += 4.5 * crest ** 8 * smooth(0.45, 0.65, fbm(X * 0.02 + 3, Z * 0.02, 2, 0));
  for (const zc of STRIPES) {
    const zs = zc + 0.08 * X + zc * 0.12 * (fbm(X * 0.01, zc, 2, 0) - 0.5);
    const wd = zc * 0.07, d = Math.abs(Z - zs) / wd;
    h += wd * (-0.45 * Math.exp(-d * d) + 0.25 * Math.exp(-(((d - 1.8) / 0.7) ** 2)));
  }
  return h;
}

// The plates the ice is broken into: cellular noise, giving the distance to
// the nearest crack and which plate this is.
function plates(X: number, Z: number): [edge: number, id: number] {
  const xi = Math.floor(X), zi = Math.floor(Z);
  let d1 = 9, d2 = 9, id = 0;
  for (let j = -1; j <= 1; j++) {
    for (let i = -1; i <= 1; i++) {
      const cx = xi + i, cz = zi + j;
      const dx = cx + hash(cx, cz + 41) - X, dz = cz + hash(cx + 43, cz) - Z;
      const d = Math.sqrt(dx * dx + dz * dz);
      if (d < d1) (d2 = d1), (d1 = d), (id = hash(cx + 7, cz + 9));
      else if (d < d2) d2 = d;
    }
  }
  return [d2 - d1, id];
}

export default function saturnRise(): 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, cr: number, cg: number, cb: number, floor: number, cap: number) => {
    const x = k % W, r = (k / W) | 0;
    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 static picture: sky, ridges and ice -----------------------------
  const sr = new Float32Array(N), sg = new Float32Array(N), sb = new Float32Array(N), sf = new Float32Array(N), scap = new Float32Array(N).fill(1);
  const solid = new Uint8Array(N); // ridge or ice, which hides anything in the sky
  // the far ridges, a jagged line along the horizon, highest at the left
  const crest = new Float32Array(W + 2);
  for (let x = -1; x <= W; x++) {
    const m = 1 - Math.abs(2 * fbm(x * 0.035 + 4, 1.5, 3, 0) - 1);
    crest[x + 1] = HZ - 0.5 - (1.2 + 4.5 * m * m + 6 * Math.exp(-(((x - 38) / 30) ** 2)) + 3 * Math.exp(-(((x - 190) / 14) ** 2)) + 0.8 * hash(x, 7));
  }
  // The ice, marched column by column from our feet to the skyline, so the
  // near ridges stand up against what lies behind them.
  const gnd = new Uint8Array(N);
  const gx = new Float32Array(N), gz = new Float32Array(N), gy = new Float32Array(N);
  for (let c = 0; c < W; c++) {
    const dir = (c + 0.5 - 100) / FG;
    let topR = H, Z = 10, prevF = 1e9, prevZ = 0, prevY = 0;
    while (Z < 3000 && topR > 0) {
      const hy = ice(dir * Z, Z);
      const yf = HZ + (FG * (CAM - hy)) / Z;
      const 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;
        gnd[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.05 + Z * 0.012;
    }
  }

  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const y = r + 0.5;
      let cr = 0, cg = 0, cb = 0, floor = 0, cap = 1;
      const ds = Math.hypot(x + 0.5 - SUN[0], y - SUN[1]);
      if (gnd[k]) {
        solid[k] = 1;
        const X = gx[k], Z = gz[k], Y = gy[k];
        const e = 0.1 + Z * 0.006;
        const hx = (ice(X + e, Z) - ice(X - e, Z)) / (2 * e), hz = (ice(X, Z + e) - ice(X, Z - e)) / (2 * e);
        const nl = Math.hypot(hx, 1, hz);
        const nx = -hx / nl, ny = 1 / nl, nz = -hz / nl;
        let lam = Math.max(0, nx * LG[0] + ny * LG[1] + nz * LG[2]);
        // the low sun throws long shadows off the ridges
        if (lam > 0 && Z < 400) {
          for (let s = 0.3 + Z * 0.004; s < 80; s += 0.25 + s * 0.15) {
            if (ice(X + LG[0] * s, Z + LG[2] * s) > Y + 0.02 + LG[1] * s) {
              lam = 0;
              break;
            }
          }
        }
        // hairline cracks between the plates the crust has broken into
        const [edge] = plates(X * 0.14, Z * 0.14);
        const near = smooth(130, 30, Z);
        const crack = smooth(0.05 + 0.6 / Z, 0.01, edge) * near;
        // fresh blue ice down in the stripes
        let deep = 0;
        for (const zc of STRIPES) {
          const zs = zc + 0.08 * X + zc * 0.12 * (fbm(X * 0.01, zc, 2, 0) - 0.5);
          deep = Math.max(deep, Math.exp(-((Math.abs(Z - zs) / (zc * 0.07)) ** 2) * 1.6));
        }
        const albedo = 0.85 + 0.25 * (fbm(X * 0.2, Z * 0.2, 2, 0) - 0.5);
        const sun = Math.max(0, 2.2 * lam - 0.25);
        cr = albedo * (sun * 1.0 + 0.06);
        cg = albedo * (sun * 0.98 + 0.12);
        cb = albedo * (sun * 0.95 + 0.28);
        cr = mix(cr, 0.08, deep * 0.9), cg = mix(cg, 0.36, deep * 0.9), cb = mix(cb, 0.62, deep * 0.9);
        // the cracks: black seams between the plates
        cr = mix(cr, 0, crack), cg = mix(cg, 0.02, crack), cb = mix(cb, 0.06, crack);
        // the sun's glare on the ice, a scatter of glints under it
        const vx = -X, vy = CAM - Y, vz = -Z, vl = Math.hypot(vx, vy, vz);
        const hvx = LV[0] + vx / vl, hvy = LV[1] + vy / vl, hvz = LV[2] + vz / vl, hl = Math.hypot(hvx, hvy, hvz);
        const glint = Math.pow(Math.max(0, (nx * hvx + ny * hvy + nz * hvz) / hl), 60) * smooth(0.6, 0.9, hash(x, r * 5)) * (1 - crack);
        cr += 1.2 * glint, cg += 1.15 * glint, cb += 1.05 * glint;
        // far ice is flatter in tone
        // far ice fades to a dim blue plain, lit along the skyline under the sun
        const far = smooth(35, 260, Z) * 0.8;
        const toward = Math.exp(-Math.abs(x - SUN[0]) / 28) * smooth(150, 600, Z);
        cr = mix(cr, 0.22 + 0.6 * toward, far), cg = mix(cg, 0.3 + 0.55 * toward, far), cb = mix(cb, 0.48 + 0.4 * toward, far);
        floor = 0.02;
        cap = 0.5 + 0.6 * clamp(Math.max(cr, cg, cb)); // dim ice draws in darker ink
      } else if (y >= crest[x + 1]) {
        solid[k] = 1;
        // the ridges, backlit: a dark blue wall, slopes that face the sun lit,
        // and the crest itself catching the light, brightest near the sun
        const slope = crest[x + 2] - crest[x];
        const face = clamp(slope * 0.5) * 0.4;
        const below = y - crest[x + 1];
        const rim = Math.exp(-below / 0.9) * (0.25 + 0.9 * Math.exp(-Math.abs(x - SUN[0]) / 30));
        const streak = 0.75 + 0.5 * hash(x, 3);
        const b = (0.12 + face) * streak;
        cr = 0.1 * b + rim * 1.0;
        cg = 0.18 * b + rim * 0.97;
        cb = 0.38 * b + rim * 0.9;
        cr += 0.3 * Math.exp(-ds / 7);
        cg += 0.26 * Math.exp(-ds / 7);
        cb += 0.2 * Math.exp(-ds / 7);
        floor = 0.03;
        cap = 0.22 + 0.85 * clamp(Math.max(cr, cg, cb));
      } else {
        // a black sky, the faint stars thicker in a band of the galaxy
        const band = Math.exp(-((((r - (90 - x * 0.45)) / 12) ** 2))) * smooth(0.4, 0.7, fbm(x * 0.05, r * 0.08, 3, 0));
        const hs = hash(x * 3 + 1, r * 7 + 2);
        if (hs > 0.992 - 0.04 * band) {
          const m = Math.pow(hash(x + 17, r + 29), 3);
          const s = 0.18 + 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);
        }
        cr += 0.015 * band, cg += 0.018 * band, cb += 0.03 * band;
      }
      sr[k] = cr, sg[k] = cg, sb[k] = cb, sf[k] = floor, scap[k] = cap;
    }
  }
  for (let k = 0; k < N; k++) dot(k, sr[k], sg[k], sb[k], sf[k], scap[k]);

  // --- Saturn's cloud bands, an equirectangular map wrapping in longitude --
  const TW = 256, TH = 128;
  const tr = new Float32Array(TW * TH), tg = new Float32Array(TW * TH), tb = new Float32Array(TW * TH);
  for (let j = 0; j < TH; j++) {
    for (let i = 0; i < TW; i++) {
      const u = i / TW, v = (j + 0.5) / TH;
      const lat = (0.5 - v) * Math.PI;
      // the bands wave a little along their length, more near the equator
      const warp = 0.035 * (fbm(u * 10, v * 18, 3, 10) - 0.5) * (1 + 1.5 * Math.exp(-((lat / 0.3) ** 2)));
      const s = Math.sin(lat + warp);
      const wave = Math.sin(s * 13 + 0.8 * Math.sin(s * 4)) + 0.45 * Math.sin(s * 33 + 1.3);
      let tone = 0.56 + 0.3 * wave + 0.25 * (fbm(s * 22 + 3, 0.5, 3, 0) - 0.5);
      tone += 0.05 * (fbm(u * 40, v * 20, 2, 40) - 0.5);
      tone = clamp(tone);
      let r = mix(0.5, 1.0, tone), g = mix(0.3, 0.86, tone), b = mix(0.13, 0.6, tone);
      // a bright cream zone at the equator, cold grey-blue toward the poles
      const eq = Math.exp(-((s / 0.1) ** 2));
      r = mix(r, 1.0, 0.55 * eq), g = mix(g, 0.9, 0.55 * eq), b = mix(b, 0.66, 0.55 * eq);
      const pole = smooth(0.72, 0.96, Math.abs(s));
      r = mix(r, 0.48, 0.65 * pole), g = mix(g, 0.53, 0.65 * pole), b = mix(b, 0.58, 0.65 * pole);
      const t = j * TW + i;
      tr[t] = r, tg[t] = g, tb[t] = b;
    }
  }
  const sample = (A: Float32Array, u: number, vy: number): number => {
    const fx = (((u % 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;
  };

  // The rings' texture: clumps and wakes by radius and angle, wrapping in
  // angle, each radius sliding round at its own orbital speed.
  const RB = 520, TB = 720, R0 = 1.1, R1 = 2.4;
  const rt = new Float32Array(RB * TB);
  const omega = new Float32Array(RB);
  for (let i = 0; i < RB; i++) {
    const rr = R0 + ((R1 - R0) * (i + 0.5)) / RB;
    omega[i] = 0.05 * Math.pow(rr, -1.5);
    for (let j = 0; j < TB; j++) rt[i * TB + j] = 0.8 + 0.4 * fbm(j / 16, i * 0.3, 3, 45);
  }

  // --- trace Saturn and its rings once -------------------------------------
  // per cell: the globe's coverage, light and map position; the ring's
  // premultiplied colour, its coverage, radius bin and angle; which is in front
  const sat: number[] = [];
  const pa = new Float32Array(N), pl = new Float32Array(N), pu = new Float32Array(N), pv = new Float32Array(N), pz = new Float32Array(N).fill(-9);
  const ra = new Float32Array(N), rcr = new Float32Array(N), rcg = new Float32Array(N), rcb = new Float32Array(N), rz = new Float32Array(N), rbin = new Int16Array(N), rth = new Float32Array(N);
  const SUB = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
  for (let r = 0; r < HZ; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      if (solid[k]) continue;
      let acov = 0, ar = 0, ag = 0, ab = 0, pcov = 0;
      for (const [ox, oy] of SUB) {
        const px = (x + ox - SC[0]) / SR, py = -(r + oy - SC[1]) / SR;
        const q2 = px * px + py * py;
        const zs = q2 < 1 ? Math.sqrt(1 - q2) : -9;
        if (q2 < 1) pcov += 0.25;
        const zr = -(NR[0] * px + NR[1] * py) / NR[2];
        const rr = Math.sqrt(q2 + zr * zr);
        if (rr < R0 || rr > R1 || zr < zs) continue; // no ring here, or it is behind the globe
        const [a, cr, cg, cb] = ring(rr);
        if (a <= 0) continue;
        // in the planet's shadow?
        const d = px * LS[0] + py * LS[1] + zr * LS[2];
        const perp = Math.sqrt(Math.max(0, rr * rr - d * d));
        const lit = d < 0 ? smooth(0.98, 1.03, perp) : 1;
        const b = 0.92 * (0.08 + 0.92 * lit);
        acov += a * 0.25, ar += a * cr * b * 0.25, ag += a * cg * b * 0.25, ab += a * cb * b * 0.25;
      }
      const px = (x + 0.5 - SC[0]) / SR, py = -(r + 0.5 - SC[1]) / SR;
      const q2 = px * px + py * py;
      if (pcov > 0) {
        const qc = Math.min(0.999, Math.sqrt(q2));
        const nz = Math.sqrt(1 - qc * qc), nx = q2 > 0.998 ? px / Math.sqrt(q2) * qc : px, ny = q2 > 0.998 ? py / Math.sqrt(q2) * qc : py;
        const ndl = nx * LS[0] + ny * LS[1] + nz * LS[2];
        // the rings' shadow: walk toward the sun to the ring plane
        let shade = 1;
        const dn = NR[0] * LS[0] + NR[1] * LS[1] + NR[2] * LS[2];
        const tt = -(NR[0] * nx + NR[1] * ny + NR[2] * nz) / dn;
        if (tt > 0) {
          const qx = nx + tt * LS[0], qy = ny + tt * LS[1], qz = nz + tt * LS[2];
          shade = 1 - 0.88 * ring(Math.hypot(qx, qy, qz))[0];
        }
        const day = smooth(-0.03, 0.14, ndl) * (0.3 + 0.85 * Math.sqrt(clamp(ndl)));
        const limb = 0.5 + 0.5 * Math.sqrt(nz);
        pl[k] = day * limb * shade + 0.05 * limb; // ringshine keeps the night side from pure black
        // latitude and longitude about the ring plane's pole
        const sy = nx * NR[0] + ny * NR[1] + nz * NR[2];
        const s1 = nx * E1[0] + ny * E1[1] + nz * E1[2], s2 = nx * E2[0] + ny * E2[1] + nz * E2[2];
        pu[k] = Math.atan2(s1, s2) / (Math.PI * 2);
        pv[k] = (0.5 - Math.asin(Math.max(-1, Math.min(1, sy))) / Math.PI) * TH - 0.5;
        pa[k] = pcov;
        pz[k] = nz;
      }
      const zr = -(NR[0] * px + NR[1] * py) / NR[2];
      const rr = Math.sqrt(q2 + zr * zr);
      if (acov > 0.002) {
        ra[k] = acov, rcr[k] = ar, rcg[k] = ag, rcb[k] = ab, rz[k] = zr;
        rbin[k] = Math.max(0, Math.min(RB - 1, Math.floor(((rr - R0) / (R1 - R0)) * RB)));
        const th = Math.atan2(px * E2[0] + py * E2[1] + zr * E2[2], px * E1[0] + py * E1[1] + zr * E1[2]);
        rth[k] = th;
      }
      if (pcov > 0 || acov > 0.002) sat.push(k);
    }
  }

  // --- the geysers' particles, launched in a steady stream ---------------
  type Grain = [phase: number, life: number, vx: number, vy: number, g: number, tw: number];
  const grains: Grain[][] = PLUMES.map(([, , ph, sp], pi) => {
    const list: Grain[] = [];
    for (let i = 0; i < 150; i++) {
      const life = 16 + 12 * hash(i, pi * 7 + 1);
      const vy = (ph * (0.75 + 0.5 * hash(i, pi * 7 + 2))) / (life * 0.8);
      const vx = (hash(i, pi * 7 + 3) - 0.5) * 3.2 * sp * vy;
      list.push([hash(i, pi * 7 + 4), life, vx, vy, vy / life * 0.35, hash(i, pi * 7 + 5) * 6.28]);
    }
    return list;
  });

  // every cell that can change: Saturn, the moon's whole orbit, the plumes,
  // the sun's flare and the bright stars
  const live = new Uint8Array(N);
  for (const k of sat) live[k] = 1;
  const orbit = (a: number): number[] => {
    const ca = Math.cos(a) * MOON_R, sa = Math.sin(a) * MOON_R;
    return [ca * E1[0] + sa * E2[0], ca * E1[1] + sa * E2[1], ca * E1[2] + sa * E2[2]];
  };
  for (let i = 0; i < 720; i++) {
    const [ox, oy] = orbit((i / 720) * Math.PI * 2);
    const cx = Math.round(SC[0] + ox * SR), cy = Math.round(SC[1] - oy * SR);
    for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
      const x = cx + dx, r = cy + dy;
      if (x >= 0 && x < W && r >= 0 && r < H) live[r * W + x] = 1;
    }
  }
  for (const [bx, by, ph, sp] of PLUMES) {
    for (let r = Math.max(0, by - ph - 8); r <= by; r++) {
      const hw = Math.ceil((0.7 + (by - r) * sp * 1.4) * 2.2) + 3;
      for (let x = Math.max(0, Math.floor(bx - hw)); x <= Math.min(W - 1, Math.ceil(bx + hw)); x++) live[r * W + x] = 1;
    }
  }
  for (let r = SUN[1] - 14; r <= SUN[1] + 14; r++) for (let x = SUN[0] - 26; x <= SUN[0] + 26; x++) if (x >= 0 && x < W) live[r * W + x] = 1;
  for (const [x, r] of BRIGHT) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) live[(r + dy) * W + x + dx] = 1;
  const cells: number[] = [];
  for (let k = 0; k < N; k++) if (live[k]) cells.push(k);
  const dirty = new Uint8Array(H);
  for (const k of cells) dirty[(k / W) | 0] = 1;

  const fr = new Float32Array(N), fg = new Float32Array(N), fb = new Float32Array(N), ff = new Float32Array(N), fc = new Float32Array(N);
  const lines: string[] = [];
  for (let r = 0; r < H; r++) lines.push(chars.slice(r * W, (r + 1) * W).join(""));

  return (t, { color } = {}) => {
    for (const k of cells) (fr[k] = sr[k]), (fg[k] = sg[k]), (fb[k] = sb[k]), (ff[k] = sf[k]), (fc[k] = scap[k]);

    // the small moon, where it is on its orbit
    const [mx, my, mz] = orbit(1.9 + (t / MOON_T) * Math.PI * 2);
    const msx = SC[0] + mx * SR, msy = SC[1] - my * SR;
    const moonAt = (k: number): number => {
      const x = k % W, r = (k / W) | 0;
      const d = Math.hypot(x + 0.5 - msx, r + 0.5 - msy);
      return smooth(1.9, 1.2, d);
    };
    const moonLit = (k: number): number => {
      const x = k % W, r = (k / W) | 0;
      return 0.35 + 0.65 * smooth(-0.4, 0.6, ((x + 0.5 - msx) * LS[0] - (r + 0.5 - msy) * LS[1]) / 1.4);
    };

    // Saturn: under the rings or over them, as they lie; the moon between
    const spin = t * 0.0025;
    const lay = (k: number, c: number, cr: number, cg: number, cb: number) => {
      fr[k] = mix(fr[k], cr, c), fg[k] = mix(fg[k], cg, c), fb[k] = mix(fb[k], cb, c);
    };
    for (const k of sat) {
      const m = moonAt(k), mzBehind = mz < pz[k] && pa[k] > 0.5;
      if (m > 0 && !mzBehind && (ra[k] === 0 || mz < rz[k])) lay(k, m, 0.82 * moonLit(k), 0.8 * moonLit(k), 0.76 * moonLit(k));
      const ringOver = ra[k] > 0 && (pa[k] === 0 || rz[k] > pz[k] - 1e-3);
      const drawRing = () => {
        const b = rbin[k];
        let th = (rth[k] - omega[b] * t) / (Math.PI * 2);
        th = (((th % 1) + 1) % 1) * TB;
        const j0 = th | 0, j1 = (j0 + 1) % TB, aj = th - j0;
        let s = rt[b * TB + j0] * (1 - aj) + rt[b * TB + j1] * aj;
        // two dark spokes riding round the B ring
        let sp = (rth[k] - 0.4 - t * 0.016) / (Math.PI * 2);
        sp = Math.abs(sp - Math.round(sp * 2) / 2);
        s *= 1 - 0.3 * Math.exp(-((sp / 0.012) ** 2)) * smooth(1.6, 1.7, R0 + ((R1 - R0) * b) / RB) * smooth(1.92, 1.82, R0 + ((R1 - R0) * b) / RB);
        const a = ra[k];
        fr[k] = fr[k] * (1 - a) + rcr[k] * s, fg[k] = fg[k] * (1 - a) + rcg[k] * s, fb[k] = fb[k] * (1 - a) + rcb[k] * s;
        fc[k] = 1;
      };
      if (ra[k] > 0 && !ringOver) drawRing();
      if (pa[k] > 0) {
        const u = pu[k] + spin, v = pv[k], l = pl[k];
        lay(k, pa[k], sample(tr, u, v) * l, sample(tg, u, v) * l, sample(tb, u, v) * l);
        ff[k] = 0.1 * Math.min(1, l) * pa[k];
        // belts draw in darker ink than zones, so the bands survive the halftone
        fc[k] = 0.35 + 0.7 * clamp(Math.max(fr[k], fg[k], fb[k]));
      }
      if (ringOver) drawRing();
      if (m > 0 && mz > Math.max(pa[k] > 0.5 ? pz[k] : -9, ra[k] > 0 ? rz[k] : -9)) lay(k, m, 0.82 * moonLit(k), 0.8 * moonLit(k), 0.76 * moonLit(k));
    }
    // the moon out past the rings, over the plain sky
    for (let r = Math.max(0, Math.floor(msy - 2)); r <= Math.min(HZ, Math.ceil(msy + 2)); r++) {
      for (let x = Math.max(0, Math.floor(msx - 2)); x <= Math.min(W - 1, Math.ceil(msx + 2)); x++) {
        const k = r * W + x;
        if (solid[k] || pa[k] > 0 || ra[k] > 0) continue;
        const m = moonAt(k);
        if (m > 0) lay(k, m, 0.82 * moonLit(k), 0.8 * moonLit(k), 0.76 * moonLit(k)), (fc[k] = 1);
      }
    }

    // the geysers: a soft fan of vapour rising, and the glittering grains in it
    for (let p = 0; p < PLUMES.length; p++) {
      const [bx, by, ph, sp, pb] = PLUMES[p];
      // lit from behind: the nearer the sun, the brighter they glow
      const fs = pb * (0.55 + 1.3 * Math.exp(-Math.abs(bx - SUN[0]) / 25));
      for (let r = Math.max(0, by - ph - 8); r < by; r++) {
        const h = by - (r + 0.5);
        const w = 0.7 + h * sp * 1.4;
        const hw = Math.ceil(w * 2.2);
        for (let x = Math.max(0, Math.floor(bx - hw)); x <= Math.min(W - 1, Math.ceil(bx + hw)); x++) {
          const k = r * W + x;
          const dx = x + 0.5 - bx;
          const fan = Math.exp(-((dx / w) ** 2));
          const rise = noise(dx * 0.22 + p * 9, (h - t * 1.3) * 0.11, 0);
          // the same vapour spread wider as it climbs, so thinner, then gone
          let d = fan * (3 / (w + 2)) * smooth(ph * 1.2, ph * 0.25, h) * (0.5 + 0.8 * rise) * smooth(0, 5, h) * fs * 0.55;
          d = Math.max(0, d - 0.03); // no stray haze dots out in space
          fr[k] += d * 0.72, fg[k] += d * 0.86, fb[k] += d;
          if (d > 0.05) (fc[k] = 1), (ff[k] = Math.max(ff[k], 0.12 * smooth(0.05, 0.3, d)));
        }
      }
      for (const [phase, life, vx, vy, g, tw] of grains[p]) {
        const age = (((t / life + phase) % 1) + 1) % 1 * life;
        const gx = bx + vx * age, gy = by - vy * age + g * age * age;
        if (gy >= by) continue;
        const x = Math.floor(gx), r = Math.floor(gy);
        if (x < 0 || x >= W || r < 0) continue;
        const k = r * W + x;
        if (!live[k]) continue;
        const v = (1 - age / life) * (0.55 + 0.45 * Math.sin(t * 3 + tw)) * fs * 0.7;
        fr[k] += v * 0.85, fg[k] += v * 0.93, fb[k] += v;
        fc[k] = 1;
      }
    }

    // the sun: a hard white point, a small glow, and a long flare along the horizon
    const breathe = 1 + 0.12 * Math.sin(t * 0.9);
    for (let r = SUN[1] - 14; r <= SUN[1] + 14; r++) {
      for (let x = Math.max(0, SUN[0] - 26); x <= Math.min(W - 1, SUN[0] + 26); x++) {
        const k = r * W + x;
        const dx = x + 0.5 - SUN[0], dy = r + 0.5 - SUN[1], d = Math.hypot(dx, dy);
        let g = Math.exp(-d / 2) * 1.4 + Math.exp(-d / 8) * 0.2;
        g += Math.exp(-dy * dy * 2) * Math.exp(-Math.abs(dx) / (8 * breathe)) * 1.1;
        g += Math.exp(-dx * dx * 2) * Math.exp(-Math.abs(dy) / (4 * breathe)) * 0.9;
        if (d < 1.6) g = 3;
        if (solid[k]) g *= 0.4;
        fr[k] += g, fg[k] += g * 0.96, fb[k] += g * 0.86;
        if (g > 0.08) fc[k] = 1;
      }
    }

    for (const [x, r, s, p] of BRIGHT) {
      const v = s * (0.8 + 0.2 * Math.sin((t / p) * Math.PI * 2 + hash(x, r) * 6.28));
      const k = r * W + x;
      if (pa[k] > 0 || ra[k] > 0) continue;
      fr[k] = Math.max(fr[k], v * 0.95), fg[k] = Math.max(fg[k], v * 0.97), fb[k] = Math.max(fb[k], v);
      for (const o of [-1, 1, -W, W]) (fr[k + o] += v * 0.16), (fg[k + o] += v * 0.17), (fb[k + o] += v * 0.2);
    }

    for (const k of cells) dot(k, fr[k], fg[k], fb[k], ff[k], fc[k]);
    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");
  };
}