deep reef

[github]

light shafts, swaying kelp and a turning school of fish over a reef

scenes 200×100 15 fps 29 colours, on a canvas 25.8 kB

use it

html

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

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

<Ascii piece={deepReef} />

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

astro

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

<Ascii piece="deep-reef" />

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

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

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.

deep-reef.ts

[js] [github]
/*
 * deep reef: looking along a coral reef from a few metres down. The sun is a
 * bright blaze in the rippled surface, shafts of light fan down from it,
 * kelp sways in the swell, a school of fish wheels through the dark water,
 * bubbles rise and caustics crawl over the sand.
 *
 * The water, the sand and the reef are shaded once; each frame adds the light
 * that moves (shafts, ripples, caustics) and draws what swims or sways on top.
 * Every cell is then 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: "deep reef",
  category: "scenes",
  note: "light shafts, swaying kelp and a turning school of fish over a reef",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#03101a",
  // A dot is never drawn darker than about half brightness (dot size carries
  // the darkness), so the palette starts at mid tones.
  palette: [
    "#114a66", "#165c78", "#1d708a", "#27869b", "#3a9fae", "#58b9c0", "#80d2d2", "#b0e8e2", "#e2fbf5",
    "#5e6b62", "#87927f", "#aab39a", "#cfd2b4",
    "#38461a", "#5a6420", "#857f2a", "#b0a03c", "#d6c25a", "#efe08e",
    "#8e3355", "#c4506a", "#e0786e", "#f0a070",
    "#6a3a78", "#9a5aa8", "#c88ad0",
    "#6f8a98", "#9fb8c6", "#cfe3ea",
  ],
} satisfies Meta;

const W = 200, H = 100;
const SURF = 15; // the surface band
const HZ = 61; // where the sea floor would meet the haze
const SUNX = 136; // where the sun shows through the surface
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 NONE = 0, SAND = 1, REEF = 2, FAR = 3, FAN = 4;

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): 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);
  const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: 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);
    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 seeded generator for laying things out.
function prng(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (s + 0x6d2b79f5) >>> 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export default function deepReef(): 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);
  };

  // --- caustics: a tiling web of bright lines (cell edges of a Voronoi) -----
  const CT = 64, CC = 6, CS = CT / CC;
  const caus = new Float32Array(CT * CT);
  {
    const rnd = prng(7);
    const pts: [number, number][] = [];
    for (let j = 0; j < CC; j++) for (let i = 0; i < CC; i++) pts.push([(i + 0.15 + 0.7 * rnd()) * CS, (j + 0.15 + 0.7 * rnd()) * CS]);
    for (let y = 0; y < CT; y++) {
      for (let x = 0; x < CT; x++) {
        let f1 = 1e9, f2 = 1e9;
        for (const [px, py] of pts) {
          for (let oy = -CT; oy <= CT; oy += CT) {
            for (let ox = -CT; ox <= CT; ox += CT) {
              const dx = x + 0.5 - px - ox, dy = y + 0.5 - py - oy;
              const d = Math.sqrt(dx * dx + dy * dy);
              if (d < f1) (f2 = f1), (f1 = d);
              else if (d < f2) f2 = d;
            }
          }
        }
        caus[y * CT + x] = Math.pow(1 - smooth(0, 0.42 * CS, f2 - f1), 2.2);
      }
    }
  }
  const causAt = (u: number, v: number): number => {
    u = ((u % CT) + CT) % CT;
    v = ((v % CT) + CT) % CT;
    const x0 = u | 0, y0 = v | 0, ax = u - x0, ay = v - y0;
    const x1 = (x0 + 1) % CT, y1 = (y0 + 1) % CT;
    const a = caus[y0 * CT + x0], b = caus[y0 * CT + x1], c = caus[y1 * CT + x0], d = caus[y1 * CT + x1];
    return a + (b - a) * ax + (c - a) * ay + (a - b - c + d) * ax * ay;
  };

  // --- the water: deep navy, lit from the surface and the sun ----------------
  const wr = new Float32Array(N), wg = new Float32Array(N), wb = new Float32Array(N);
  // which shaft each cell sits in: shafts fan out from the sun, above the frame
  const rayAt = new Uint16Array(N);
  const SUNY = -12, RAYS = 320;
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x, v = (r + 0.5) / H;
      const up = Math.pow(1 - v, 2.2);
      const sun = Math.exp(-(((x - SUNX) / 52) ** 2) - (((r - 4) / 34) ** 2));
      // looking level, the distance is a lit blue haze the reefs stand against
      const haze = Math.exp(-(((r - 54) / 15) ** 2)) * (1 - 0.6 * smooth(30, 100, Math.abs(x - 112)));
      // darker toward the sides and the floor, but the upper water stays lit
      // so the kelp stands dark against it
      const edge = 1 - 0.4 * smooth(50, 104, Math.abs(x - 112)) * smooth(8, 70, r) - 0.3 * smooth(66, 100, r);
      // soft clouds of plankton haze, so open water is never one flat tone
      const veil = 0.78 + 0.44 * fbm(x * 0.022 + 5, r * 0.04, 4);
      wr[k] = (0.012 + 0.09 * up + 0.05 * sun + 0.04 * haze) * edge * veil;
      wg[k] = (0.1 + 0.32 * up + 0.14 * sun + 0.15 * haze) * edge * veil;
      wb[k] = (0.15 + 0.27 * up + 0.12 * sun + 0.17 * haze) * edge * veil;
      const a = Math.atan2(x + 0.5 - SUNX, r + 0.5 - SUNY);
      rayAt[k] = Math.max(0, Math.min(RAYS - 1, Math.round((a + 1.6) * 100)));
    }
  }
  const RAY0 = 160; // the shaft straight down from the sun

  // --- the static scene: sand, the reef, a far reef in the haze, a sea fan --
  const mat = new Uint8Array(N);
  const ar = new Float32Array(N), ag = new Float32Array(N), ab = new Float32Array(N); // lit colour
  const fog = new Float32Array(N); // how much water stands between us and it
  const cu = new Float32Array(N), cv = new Float32Array(N), cw = new Float32Array(N); // caustic coords, weight
  // both reefs are rounded masses, shouldering down toward the open sand
  const dome = (u: number): number => 1 - Math.sqrt(Math.max(0, 1 - u * u));
  const leftTop = (x: number): number => 47 + 56 * dome(Math.min(1, x / 86)) - 8 * fbm(x * 0.06, 3.1, 4) + 3 * Math.max(0, (10 - x) / 10);
  const rightTop = (x: number): number => 69 + 34 * dome(Math.min(1, (196 - x) / 48)) - 5 * fbm(x * 0.08, 8.3, 3) - 3 * Math.exp(-(((x - 191) / 6) ** 2));
  const farTop = (x: number): number => HZ - 1 - 6 * fbm(x * 0.035 + 2, 1.7, 3) - 3 * Math.exp(-(((x - 120) / 18) ** 2));
  // brain corals: domes on the crests
  // [x, depth of the centre below the crest, radius, kind]
  const domes = [[16, 3, 7, 0], [41, 2.5, 6.5, 1], [53, 2, 4.5, 3], [65, 2, 5, 2], [158, 2, 4, 2], [186, 3, 5, 1], [196, 3, 4.5, 3]]
    .map(([x, d, r, kind]) => [x, (x < 100 ? leftTop(x) : rightTop(x)) + d, r, kind]);
  const coral = [
    [0.86, 0.36, 0.44], [0.93, 0.55, 0.3], [0.6, 0.36, 0.72], [0.78, 0.7, 0.35],
  ];
  const rock = [0.016, 0.03, 0.042];
  // [x, crest row, half width, fog, coral kind]
  const BOMMIES = [[114, 69, 9, 0.18, 0], [94, 63.6, 4, 0.5, 2], [139, 64.4, 4, 0.42, 1]];
  const FAN_C = [167, rightTop(167) + 1.5], FAN_R = 27;
  for (let r = 0; r < H; r++) {
    for (let x = 0; x < W; x++) {
      const k = r * W + x, y = r + 0.5;
      // the sea floor, a plane running off into the haze
      if (r >= HZ + 2) {
        const d = y - HZ;
        const z = 300 / d; // distance
        // the floor is seen at a low angle, so its pattern squeezes toward the
        // haze; spacing grows as it comes nearer, and fades out before it
        // gets too fine to draw
        const near = 0.4 + 0.6 * (d / 38);
        const sx = (x + 0.5 - 100) / near;
        const sy = 30 * Math.log(d);
        const aa = smooth(2.2, 1.4, 30 / d);
        // ripples in the sand run across our view, bending a little: thin
        // bright crests over darker troughs
        const ph = sy + 7 * fbm(sx * 0.07, sy * 0.06, 2) + sx * 0.05;
        const crest = Math.pow(0.5 + 0.5 * Math.sin(ph), 3) * smooth(0.3, 0.6, fbm(sx * 0.09 + 7, sy * 0.12, 2));
        const rip = 0.74 + aa * (0.5 * crest - 0.12) + 0.12 * (fbm(sx * 0.08, sy * 0.15, 3) - 0.5);
        mat[k] = SAND;
        const lit = 0.6 + 0.15 * smooth(100, 130, x) - 0.3 * smooth(84, 100, r);
        ar[k] = 0.38 * rip * lit, ag[k] = 0.37 * rip * lit, ab[k] = 0.3 * rip * lit;
        fog[k] = 1 - Math.exp(-z / 40);
        cu[k] = sx * 0.9, cv[k] = sy * 2.3, cw[k] = 0.5 * smooth(14, 26, d);
      }
      if (y >= farTop(x) && r < HZ + 6) {
        mat[k] = FAR;
        // lit along its crest, dim below, half lost in the blue
        const s = (0.45 + 0.55 * fbm(x * 0.15, y * 0.15, 2)) * (0.5 + 0.8 * Math.exp(-(y - farTop(x)) / 2));
        ar[k] = 0.06 * s, ag[k] = 0.11 * s, ab[k] = 0.14 * s;
        fog[k] = 0.68;
        cw[k] = 0;
      }
      // the sea fan: a thin lattice of veins spreading up from its root
      {
        const dx = x + 0.5 - FAN_C[0], dy = FAN_C[1] - y;
        const d = Math.hypot(dx, dy), a = Math.atan2(dx, dy);
        const reach = FAN_R * (0.68 + 0.36 * fbm(a * 2.6 + 5, 1, 3));
        if (dy > -1 && Math.abs(a) < 1.2 && d < reach) {
          const aw = a + 0.08 * (fbm(d * 0.2, a * 2, 2) - 0.5) * 4;
          const ray = Math.abs(((aw * 7.5) / Math.PI + 100) % 1 - 0.5);
          const ring = Math.abs(((d / 3.2) + 100) % 1 - 0.5);
          const mesh = hash(x * 3, r * 5) < 0.08;
          const vein = ray > 0.42 || (ring > 0.45 && d > 5) || mesh || (d < 4 && Math.abs(dx) < 1.2) || d > reach - 1.2;
          mat[k] = FAN;
          // dark at the root, catching the light toward its rim; between the
          // veins a thin dim web the water shows through
          const o = smooth(2, reach, d);
          const s = (0.35 + 0.75 * o) * (vein ? 1 : 0.22);
          ar[k] = mix(0.32, 0.72, o) * s, ag[k] = mix(0.12, 0.38, o) * s, ab[k] = mix(0.38, 0.8, o) * s;
          fog[k] = vein ? 0.2 : 0.4;
          cw[k] = 0;
        }
      }
      // small coral heads out on the sand, half lost in the blue
      for (const [bx, crest, hw, f0, kind] of BOMMIES) {
        const u = (x + 0.5 - bx) / hw;
        const top = crest + u * u * u * u * hw * 0.5 - 1.6 * fbm(x * 0.35, crest, 2);
        if (Math.abs(u) < 1.1 && y >= top && y < crest + hw * 0.5 + 1.5 * fbm(x * 0.3, crest + 5, 2) - 0.6 * u * u) {
          mat[k] = REEF;
          const below = y - top;
          const living = smooth(2.2, 0.6, below) * smooth(0.45, 0.6, fbm(x * 0.25 + bx, y * 0.3, 2));
          const lit = 0.3 + 0.6 * Math.exp(-below / 1.5);
          const [cr0, cg0, cb0] = coral[kind];
          ar[k] = mix(rock[0], cr0, living) * lit;
          ag[k] = mix(rock[1], cg0, living) * lit;
          ab[k] = mix(rock[2], cb0, living) * lit;
          fog[k] = f0;
          cu[k] = x * 0.5, cv[k] = y * 0.9, cw[k] = 0.5 * Math.exp(-below / 1.5);
        }
      }
      // reef masses, left and right: dark rock, rimmed with lit coral
      const tl = leftTop(x), tr = rightTop(x);
      const top = x < 100 ? tl : tr;
      if (y >= top && (x < 87 || x > 147)) {
        mat[k] = REEF;
        const below = y - top;
        const n = fbm(x * 0.18, y * 0.22, 4);
        // lumps of rock and coral heads, each lit on its upper side
        const lump = fbm(x * 0.07, y * 0.1, 3), lumpUp = fbm(x * 0.07, (y - 1.5) * 0.1, 3);
        const face = clamp(0.5 + (lumpUp - lump) * 14);
        const lit = (0.25 + 0.5 * face + 0.6 * Math.exp(-below / 5) + 0.14 * (n - 0.5)) * (0.55 + 0.45 * smooth(100, 40, r));
        // patches of living coral, thick along the crest, a few sponges below
        const kind = Math.floor(fbm(x * 0.06 + 11, y * 0.09, 3) * 7) % 4;
        const patch = fbm(x * 0.12 + 4, y * 0.12, 3);
        const living = Math.max(smooth(0.44, 0.56, patch) * smooth(7, 1.5, below),smooth(0.66, 0.72, patch) * 0.35 * smooth(24, 6, below));
        const [cr0, cg0, cb0] = coral[kind];
        // a cool rim of light along the bare rock of the crest
        const rim = smooth(2.4, 0.3, below) * 0.6;
        // and the upper lips of ledges down the face catch a little of it
        const ledge = smooth(0.66, 0.9, face) * smooth(3, 8, below) * 0.55;
        const rr = mix(mix(rock[0], 0.05, ledge), 0.12, rim), rg = mix(mix(rock[1], 0.11, ledge), 0.24, rim), rb = mix(mix(rock[2], 0.13, ledge), 0.25, rim);
        ar[k] = mix(rr, cr0, living) * lit;
        ag[k] = mix(rg, cg0, living) * lit;
        ab[k] = mix(rb, cb0, living) * lit;
        fog[k] = x < 100 ? 0.04 + 0.04 * smooth(0, 80, x) : 0.08;
        cu[k] = x * 0.5, cv[k] = y * 0.9, cw[k] = 0.8 * Math.exp(-below / 1.5);
      }
      for (const [dx0, dy0, dr, kind] of domes) {
        const dx = x + 0.5 - dx0, dy = y - dy0;
        const d = Math.hypot(dx, dy * 1.25);
        if (d < dr && dy < dr * 0.3) {
          mat[k] = REEF;
          const nz = Math.sqrt(Math.max(0, 1 - (d / dr) ** 2));
          const lamb = clamp(0.15 + 0.85 * (nz * 0.6 - (dy / dr) * 0.55 + (dx / dr) * 0.25));
          const groove = 0.75 + 0.25 * Math.sin(d * 2.4 + 2 * fbm(x * 0.3, y * 0.3, 2) * 3);
          const [cr0, cg0, cb0] = coral[(kind + 1) % 4];
          const s = (0.15 + 0.9 * lamb) * groove;
          ar[k] = cr0 * s, ag[k] = cg0 * s, ab[k] = cb0 * s;
          fog[k] = dx0 < 100 ? 0.04 + 0.04 * smooth(0, 80, dx0) : 0.08;
          cu[k] = x * 0.5, cv[k] = y * 0.9, cw[k] = 0.5 * clamp(-dy / dr + 0.6);
        }
      }
    }
  }
  // branching coral standing up off both crests
  for (const [seed, count, x0, span, topAt] of [[31, 10, 3, 60, leftTop], [53, 3, 178, 20, rightTop]] satisfies [seed: number, count: number, x0: number, span: number, topAt: (x: number) => number][]) {
    const rnd = prng(seed);
    for (let i = 0; i < count; i++) {
      const bx = x0 + rnd() * span;
      if (Math.abs(bx - FAN_C[0]) < 3) continue; // leave the fan's root clear
      const base = topAt(bx);
      const h = 2 + rnd() * 3.5;
      const lean = (rnd() - 0.5) * 0.5;
      const kind = rnd() < 0.5 ? 1 : 3;
      for (let s = 0; s < h; s += 0.5) {
        const x = Math.round(bx + lean * s + (s > h * 0.55 ? (i % 2 ? 1 : -1) * (s - h * 0.55) * 0.6 : 0));
        const r = Math.round(base - s);
        if (x < 0 || x >= W || r < 0) continue;
        const k = r * W + x;
        mat[k] = REEF;
        const tip = s / h;
        const [cr0, cg0, cb0] = coral[kind];
        const sh = 0.4 + 0.6 * tip;
        ar[k] = cr0 * sh, ag[k] = cg0 * sh, ab[k] = cb0 * sh;
        fog[k] = 0.06;
        cu[k] = x * 0.5, cv[k] = r * 0.9, cw[k] = 0.4;
      }
    }
  }
  // the water's colour filters what is behind it: reds go first
  const br = new Float32Array(N), bg = new Float32Array(N), bb = new Float32Array(N);
  for (let k = 0; k < N; k++) {
    if (mat[k] === NONE) {
      br[k] = wr[k], bg[k] = wg[k], bb[k] = wb[k];
      continue;
    }
    const f = fog[k];
    const tint = 1 - f;
    const ex = 1.9; // shallow water: the reef takes plenty of light
    br[k] = mix(ar[k] * ex * (0.55 + 0.45 * tint), wr[k], f);
    bg[k] = mix(ag[k] * ex * (0.85 + 0.15 * tint), wg[k], f);
    bb[k] = mix(ab[k] * ex, wb[k], f);
  }

  // --- the moving things, laid out once ------------------------------------
  const rnd = prng(1234);
  const gauss = (): number => {
    let s = 0;
    for (let i = 0; i < 4; i++) s += rnd();
    return (s - 2) * 1.7;
  };
  const fish: { a: number; b: number; ph: number; sp: number }[] = [];
  for (let i = 0; i < 150; i++) fish.push({ a: gauss() * 10, b: gauss() * 3.8, ph: rnd() * 6.28, sp: 0.8 + rnd() * 0.6 });
  // kelp: x, base row, length, width, phase, and how much water hides it
  const kelp: { bx: number; base: number; len: number; sz: number; ph: number; haze: number }[] = [];
  for (const [bx, base, len, sz, ph, haze] of [
    [14, 100, 96, 3.5, 0.0, 0], [189, 100, 96, 3.5, 5.2, 0],
  ])
    kelp.push({ bx, base, len, sz, ph, haze });
  const streams = [[44, 52, 8], [168, 66, 7], [116, 96, 6]];
  const bubbles: { x0: number; y0: number; ph: number; sp: number; w: number }[] = [];
  for (const [x0, y0, n] of streams) for (let i = 0; i < n; i++) bubbles.push({ x0, y0, ph: rnd(), sp: 4 + rnd() * 3, w: rnd() * 6.28 });
  const snow: { x: number; y: number; sp: number; ph: number; b: number }[] = [];
  for (let i = 0; i < 90; i++) snow.push({ x: rnd() * W, y: rnd() * H, sp: 0.3 + rnd() * 0.7, ph: rnd() * 6.28, b: 0.12 + rnd() * 0.22 });

  // --- each frame ------------------------------------------------------------
  const cr = new Float32Array(N), cg = new Float32Array(N), cb = new Float32Array(N), floor = new Float32Array(N);
  const rays = new Float32Array(RAYS);
  const out: string[] = new Array(N);
  const add = (x: number, r: number, R: number, G: number, B: number) => {
    if (x < 0 || x >= W || r < 0 || r >= H) return;
    const k = r * W + x;
    cr[k] += R, cg[k] += G, cb[k] += B;
  };
  const put = (x: number, r: number, R: number, G: number, B: number, a: number) => {
    if (x < 0 || x >= W || r < 0 || r >= H) return;
    const k = r * W + x;
    cr[k] = mix(cr[k], R, a), cg[k] = mix(cg[k], G, a), cb[k] = mix(cb[k], B, a);
  };

  return (t, { color } = {}) => {
    // shafts: a slow pattern across the surface, shimmering as the waves pass,
    // strongest straight under the sun
    for (let u = 0; u < RAYS; u++) {
      const s = Math.pow(smooth(0.5, 0.62, fbm(u * 0.06 + t * 0.025, 3.3, 3)), 1.2);
      const shimmer = 0.6 + 0.4 * noise(u * 0.18 - t * 0.7, t * 0.3);
      rays[u] = s * shimmer * (0.35 + 0.65 * Math.exp(-(((u - RAY0) / 60) ** 2)));
    }
    const cx0 = t * 0.9, cy0 = t * 0.35, cx1 = -t * 0.6 + 21, cy1 = t * 0.5 + 9;
    // the sun's blaze wobbles as the swell passes over it
    const hx = SUNX + 1.6 * Math.sin(t * 0.6) + 0.8 * Math.sin(t * 1.7 + 1);

    for (let r = 0; r < H; r++) {
      const y = r + 0.5;
      const depthFade = Math.exp(-r / 30) * smooth(0, 14, r + 6);
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        let R = br[k], G = bg[k], B = bb[k];
        const m = mat[k];
        const ray = rays[rayAt[k]] * depthFade;
        if (m === NONE) {
          if (r < SURF + 6) {
            // the underside of the surface: a rippling ceiling, pressed flat toward the haze
            const dist = SURF + 7 - y;
            const q = 30 / dist;
            const a = causAt(((x - 100) / dist) * 1.6 + t * 1.6, q * 6 + t * 0.8);
            const b2 = causAt(((x - 100) / dist) * 1.2 - t * 1.1 + 31, q * 4.5 - t * 0.6 + 17);
            const lit = Math.pow(smooth(SURF + 6, 0, y), 1.3);
            const c = Math.pow(Math.max(a, b2), 1.6);
            const near = Math.exp(-(((x - hx) / 34) ** 2));
            // troughs darken the water under them, crests focus light into lines
            const dim = 1 - lit * 0.7 * (1 - c);
            R *= dim, G *= dim, B *= dim;
            const v = lit * c * (0.1 + 0.42 * near);
            // the sun: a compact blaze through the surface, broken by the ripples
            const dx = x + 0.5 - hx;
            const hot = Math.exp(-((dx / 8) ** 2) - (((y - 6) / 3.6) ** 2)) * (1 + 0.3 * c);
            const halo = Math.exp(-((dx / 15) ** 2) - (((y - 6) / 7) ** 2)) * 0.3 * (0.6 + 0.6 * c);
            R += 0.55 * v + 0.9 * hot + 0.35 * halo;
            G += 0.85 * v + 1.0 * hot + 0.6 * halo;
            B += 0.8 * v + 0.97 * hot + 0.58 * halo;
          }
          R += 0.42 * ray, G += 0.86 * ray, B += 0.78 * ray;
          floor[k] = 0;
        } else {
          const f = fog[k];
          if (cw[k] > 0) {
            const c = Math.min(causAt(cu[k] + cx0, cv[k] + cy0), 1) * 0.6 + causAt(cu[k] * 0.8 + cx1, cv[k] * 0.8 + cy1) * 0.6;
            const s = cw[k] * c * c * (1 - f) * (0.6 + 0.6 * ray + 0.3 * depthFade);
            // on the sand the light comes back warm, on the reef cool
            if (m === SAND) R += 0.62 * s, G += 0.66 * s, B += 0.5 * s;
            else R += 0.55 * s, G += 0.72 * s, B += 0.62 * s;
          }
          R += 0.16 * ray * f, G += 0.3 * ray * f, B += 0.28 * ray * f;
          floor[k] = 0.02;
        }
        cr[k] = R, cg[k] = G, cb[k] = B;
      }
    }

    // the school: one body turning along a slow loop, each fish a beat behind
    const pathX = (s: number): number => 95 + 24 * Math.sin(0.11 * s + 0.4);
    const pathY = (s: number): number => 44 + 8 * Math.sin(0.17 * s + 2.2);
    for (const f of fish) {
      const s = t - f.a * 0.08;
      const vx = 24 * 0.11 * Math.cos(0.11 * s + 0.4), vy = 8 * 0.17 * Math.cos(0.17 * s + 2.2);
      const th = Math.atan2(vy, vx);
      const ct = Math.cos(th), st = Math.sin(th);
      const wob = Math.sin(t * 1.3 * f.sp + f.ph);
      const px = pathX(s) + f.a * ct - f.b * st + wob * 0.6;
      const py = pathY(s) + f.a * st + f.b * ct + Math.cos(t * f.sp + f.ph) * 0.4;
      const h = th + 0.15 * wob;
      const dx = Math.cos(h), dy = Math.sin(h);
      // dark shapes against the light, until a fish turns its silver flank to it
      const flash = smooth(0.72, 0.97, Math.abs(Math.sin(th * 1.6 + f.a * 0.35 + f.b * 0.4 - t * 0.5 + 0.6)));
      const R0 = mix(0.008, 0.85, flash), G0 = mix(0.018, 0.97, flash), B0 = mix(0.026, 1.0, flash);
      for (let j = 0; j < 2; j++) {
        const gx = Math.round(px - dx * j * 0.9), gy = Math.round(py - dy * j * 0.9);
        put(gx, gy, R0, G0, B0, j === 0 ? 1 : 0.85);
      }
    }

    // kelp: dark fronds framing the view, a gold edge only where a shaft hits
    for (const kp of kelp) {
      const { bx, base, len, sz, ph, haze } = kp;
      const side = bx < 100 ? 1 : -1; // which way the sun lies
      for (let r = base - 1; r >= base - len; r--) {
        if (r < 0) break;
        const s01 = (base - r) / len;
        const lean = side * 2.5 * sz * s01 * s01;
        // the swell runs up the frond, so it bends in an S rather than tipping like a stick
        const bend = Math.sin(t * 0.55 + ph - s01 * 4.2) * 2.2 * sz * Math.pow(s01, 1.2) + Math.sin(t * 0.21 + ph) * 1.5 * s01 + lean;
        const x = bx + bend;
        // the frond: a stipe with blades off alternate sides, each blade a lobe
        // that swells and tapers, trailing a little behind the stipe's sway
        const stem = 0.8 + 0.4 * sz * (1 - s01);
        const beat = s01 * len * 0.32 + ph;
        const lobeL = Math.pow(Math.max(0, Math.sin(beat)), 1.2) * sz * 2.3 * (1 - 0.35 * s01);
        const lobeR = Math.pow(Math.max(0, -Math.sin(beat)), 1.2) * sz * 2.3 * (1 - 0.35 * s01);
        const drag = Math.cos(t * 0.55 + ph - s01 * 4.2) * 0.8 * sz;
        const x0 = x - stem - lobeL + Math.min(0, drag), x1 = x + stem + lobeR + Math.max(0, drag);
        const ray = rays[rayAt[r * W + Math.max(0, Math.min(W - 1, Math.round(x)))]] * Math.exp(-r / 30);
        const lit = smooth(0.3, 0.6, ray);
        const xa = Math.round(x0), xb = Math.round(x1);
        for (let xx = xa; xx <= xb; xx++) {
          if (xx < 0 || xx >= W) continue;
          const k = r * W + xx;
          // backlit: dark through the middle, the edges glowing, the sun-facing
          // edge most of all and gold where a shaft catches it
          const sun = side > 0 ? xx === xb : xx === xa;
          const rim = xx === xa || xx === xb;
          const blade = Math.abs(xx - x) > stem ? 1 : 0;
          // the stipe is black, the blades let a little olive light through
          const through = blade * 0.12 * smooth(0, 1, Math.abs(xx - x) - stem) * (0.5 + 0.5 * hash(xx * 7, r * 3));
          const g = 0.02 + through + (rim ? 0.16 : 0) + (sun ? 0.34 + 0.66 * lit * (0.4 + 0.6 * blade) : 0);
          put(xx, r, mix(0.62 * g, wr[k], haze), mix(0.58 * g, wg[k], haze), mix(0.18 * g, wb[k], haze), 1);
          floor[k] = 0.02;
        }
      }
    }

    // bubbles: wobbling up to the surface, growing as they rise
    for (const b of bubbles) {
      const span = b.y0 - SURF;
      const p = ((t * b.sp) / span + b.ph) % 1;
      const y = b.y0 - p * span;
      const x = b.x0 + Math.sin(y * 0.35 + b.w) * 1.2 + p * 3;
      const v = 0.55 + 0.45 * p;
      add(Math.round(x), Math.round(y), 0.7 * v, 0.95 * v, 1.0 * v);
    }
    // specks drifting in the water
    for (const s of snow) {
      const x = (((s.x + t * s.sp + 2 * Math.sin(t * 0.3 + s.ph)) % W) + W) % W;
      const y = (((s.y + t * s.sp * 0.4) % H) + H) % H;
      add(Math.floor(x), Math.floor(y), s.b * 0.7, s.b * 0.9, s.b);
    }

    for (let r = 0; r < H; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const R = cr[k], G = cg[k], B = cb[k];
        const peak = Math.max(R, G, B, 1e-4);
        const level = clamp(floor[k] + (1 - floor[k]) * 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)])));
        out[k] = DOTS[step];
        if (color) {
          const want = step ? Math.min(1, (level + 0.06) / COVER[step]) : 0;
          const s = (0.3 + 0.7 * want) / peak;
          color[k] = nearest(clamp(R * s), clamp(G * s), clamp(B * 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");
  };
}