misty forest

[github]

pine ridges fading into morning fog, sunbeams slanting through

scenes 200×100 15 fps 32 colours, on a canvas 21.0 kB

use it

html

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

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

<Ascii piece={mistyForest} />

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

astro

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

<Ascii piece="misty-forest" />

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

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

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.

misty-forest.ts

[js] [github]
/*
 * misty forest: morning in a pine forest. Ridge after ridge of pines recedes
 * into fog, each paler than the one in front, with mist lying in sheets in the
 * valleys between them. A low sun sits behind the farthest trees and sends
 * beams slanting down through the fog to a clearing on the forest floor. The
 * fog drifts, the beams shimmer, and motes of dust float in the light.
 *
 * Every cell is shaded in colour, layer by layer with the haze of its depth,
 * then drawn as a halftone: dot size from brightness with an ordered dither,
 * colour from the palette entry nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "misty forest",
  category: "scenes",
  note: "pine ridges fading into morning fog, sunbeams slanting through",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#090f0e",
  palette: [
    // sunlight and the warm fog around it
    "#fffbea", "#fff0c8", "#fbe2a6", "#f2c97e", "#e0a95e", "#b9834a",
    // cool fog, pale to sea-green
    "#e4ebe6", "#cbe0dc", "#a8d2d0", "#86c0c2", "#68a7ac", "#4f8c93", "#3b7078",
    // pines, from the haze to the dark in front of us
    "#557570", "#456761", "#365952", "#2a4a44", "#1f3c37", "#172f2b", "#11231f",
    // moss and bark where the light touches the floor
    "#a4a35a", "#8a8f4c", "#6d7440", "#4d5530", "#7a5a3a", "#5b4532",
    // the clear sky above the fog, deepening overhead
    "#a9c3cc", "#8eadb8", "#6f93a2", "#53798a", "#3d6172", "#2b4a5a",
  ],
} satisfies Meta;

const W = 200, H = 100;
const SUN: [number, number] = [146, 45.5];
const SUN_R = 3.4;
const DOTS = " ·•●";
const COVER = [0, 0.3, 0.6, 1];
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => v / 16 - 0.47);
const SKY = -1, FLOOR = 5, GIANT = 6;

function hash(x: number, y: number) {
  let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

function noise(x: number, y: number, period: number) {
  const xi = Math.floor(x), yi = Math.floor(y);
  const fx = x - xi, fy = y - yi;
  const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy);
  let x0 = xi, x1 = xi + 1;
  if (period) {
    x0 = ((xi % period) + period) % period;
    x1 = (x0 + 1) % period;
  }
  const a = hash(x0, yi), b = hash(x1, yi), c = hash(x0, yi + 1), d = hash(x1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number, period: number) {
  let s = 0, n = 0, amp = 0.5, f = 1;
  for (let i = 0; i < octaves; i++) {
    s += amp * noise(x * f, y * f, period * f);
    n += amp;
    amp *= 0.5;
    f *= 2;
  }
  return s / n;
}

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

// The ridges, far to near: [ridge row, its rise and fall, tree spacing, tree
// heights from and to, haze, how fast its fog drifts, mist lying in the
// valley below it, drifting fog in front of it, sunbeam in front of it]
const LAYERS = [
  [50, 6, 1.6, 1, 2.5, 0.48, 0.8, 1, 0.14, 0.6],
  [59, 6, 2, 3, 5.5, 0.3, 1.3, 0.8, 0.16, 0.8],
  [69, 7, 2.6, 4, 7, 0.13, 2, 0.66, 0.18, 0.9],
  [80, 6, 3.4, 5, 9, 0.03, 2.8, 0.44, 0.14, 1],
  [90, 1.5, 10, 9, 24, 0.03, 3.4, 0, 0.08, 0.6],
];
const NEAR = LAYERS.length - 1;

export default function mistyForest(): 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) => {
    const k = (Math.min(31, (r * 31.99) | 0) << 10) | (Math.min(31, (g * 31.99) | 0) << 5) | Math.min(31, (b * 31.99) | 0);
    if (lut[k] !== 255) return lut[k];
    let best = 0, bd = 1e9;
    for (let i = 0; i < P.length; i++) {
      const dr = P[i][0] - r, dg = P[i][1] - g, db = P[i][2] - b;
      const d = 0.3 * dr * dr + 0.5 * dg * dg + 0.2 * db * db;
      if (d < bd) (bd = d), (best = i);
    }
    return (lut[k] = best);
  };

  // the colour of the fog: cool and green-grey, warming to cream by the sun
  const fogR = (s: number) => mix(0.7, 1.05, s), fogG = (s: number) => mix(0.92, 0.92, s), fogB = (s: number) => mix(0.9, 0.6, s);

  // --- the layers, rasterised far to near so nearer ones cover farther -----
  const layer = new Int8Array(N).fill(SKY);
  const edge = new Float32Array(N); // how much a cell sits on a silhouette's sunward rim
  const below = new Float32Array(N); // rows below the layer's tree line
  const tex = new Float32Array(N); // the lower edge of a tier of boughs, near trees only
  const ground = new Float32Array(N); // the row a cell's ridge rises from, for its valley mist
  LAYERS.forEach(([Y, amp, gap, h0, h1], i) => {
    // nearer ridges dip toward the sun, a valley opening onto the light
    const ridge = (x: number) => Y + amp * (fbm(x * (0.018 + i * 0.003), i * 13 + 2, 3, 0) - 0.5) * (i < 3 ? 4 : 3) + (i > 0 && i < NEAR ? (2 + i * 2) * Math.exp(-(((x - SUN[0] - 4) / 38) ** 2)) : 0);
    const base = Float32Array.from({ length: W }, (_, x) => ridge(x));
    const fill = new Uint8Array(N);
    const spire = new Uint8Array(N);
    for (let x = 0; x < W; x++) for (let r = Math.max(0, Math.floor(ridge(x))); r < H; r++) fill[r * W + x] = 1;
    // pines: a spire of tiers, each tier flaring out and stepping back in
    for (let tx = -2 + hash(i, 1) * gap; tx < W + 2; tx += gap * (0.7 + hash(tx | 0, i + 3) * 0.7)) {
      // the nearest trees leave a clearing under the sun for the light to land in
      if (i === NEAR && tx > 92 && tx < 140) continue;
      const th = h0 + (h1 - h0) * hash(tx * 7 | 0, i + 5);
      const tip = ridge(tx) - th;
      const tier = 2 + th * 0.12;
      for (let r = Math.max(0, Math.floor(tip)); r < Math.min(H, ridge(tx) + 2); r++) {
        const d = r + 0.5 - tip;
        if (d < 0) continue;
        const saw = (d % tier) / tier;
        const half = d * 0.3 * (0.6 + 0.5 * saw) + 0.35;
        for (let x = Math.max(0, Math.floor(tx - half)); x <= Math.min(W - 1, Math.ceil(tx + half)); x++) {
          const dx = Math.abs(x + 0.5 - tx);
          if (dx > half) continue;
          const k = r * W + x;
          fill[k] = spire[k] = 1;
          if (i === NEAR) tex[k] = smooth(0.62, 0.95, saw) * smooth(0.3, 0.85, dx / half) * (0.55 + 0.45 * hash(x, r * 5));
        }
      }
    }
    // where the silhouette starts in each column, smoothed a little so the
    // mist line follows the forest rather than every single spire
    const top = new Float32Array(W).fill(H);
    for (let x = 0; x < W; x++)
      for (let r = 0; r < H; r++)
        if (fill[r * W + x]) {
          top[x] = r;
          break;
        }
    const line = top.map((_, x) => {
      let s = 0;
      for (let d = -3; d <= 3; d++) s += top[Math.min(W - 1, Math.max(0, x + d))];
      return Math.max(s / 7, top[x]);
    });
    const open = (xx: number, rr: number) => xx >= 0 && xx < W && (rr < 0 || !fill[rr * W + xx]);
    for (let r = 0; r < H; r++)
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        if (!fill[k]) continue;
        layer[k] = i === NEAR && !spire[k] ? FLOOR : i;
        if (i !== NEAR) tex[k] = 0;
        below[k] = r + 0.5 - line[x];
        ground[k] = base[x];
        // a rim where the sky (or a farther layer) shows beside or above
        const toward = x < SUN[0] ? 1 : -1;
        edge[k] = open(x + toward, r) || open(x, r - 1) ? 1 : open(x + 2 * toward, r) ? 0.5 : 0;
      }
  });

  // --- what stands in front: a giant pine cut by the left of the frame, and a
  // smaller one at the right edge, so the frame is not a symmetric curtain ---
  const giant = new Uint8Array(N);
  for (const [gx, tip, spread, tier] of [[9, -12, 15, 7], [192, 12, 7, 5]]) {
    for (let r = Math.max(0, Math.floor(tip)); r < H; r++) {
      const d = r + 0.5 - tip;
      const saw = (d % tier) / tier;
      // each tier of boughs sweeps out and droops, so the outline is a stack
      // of points rather than a straight edge where it meets the frame
      const half = Math.min(spread * (0.5 + 0.5 * saw), d * 0.34 * (0.45 + 0.7 * saw) + 0.5);
      for (let x = Math.max(0, Math.floor(gx - half)); x <= Math.min(W - 1, Math.ceil(gx + half)); x++) {
        const dx = Math.abs(x + 0.5 - gx);
        // the side toward the frame stays full, so no sliver of sky shows there
        const outer = (x + 0.5 - gx) * (gx - W / 2) > 0 ? 1.6 : 1;
        const ragged = half * outer * (0.82 + 0.3 * noise(x * 0.5, r * 0.4, 0));
        if (dx <= ragged || dx < 0.9) {
          const k = r * W + x;
          giant[k] = 1;
          tex[k] = smooth(0.66, 0.96, saw) * smooth(0.25, 0.8, dx / ragged) * (0.5 + 0.5 * hash(x * 3, r));
        }
      }
    }
  }
  for (let k = 0; k < N; k++) if (giant[k]) (layer[k] = GIANT), (below[k] = 0);
  // a rim two cells deep on the side that faces the sun
  {
    const open = (x: number, r: number) => x >= 0 && x < W && (r < 0 || !giant[r * W + x]);
    for (let r = 0; r < H; r++)
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        if (!giant[k]) continue;
        const tx = x < SUN[0] ? 1 : -1;
        edge[k] = open(x + tx, r) || open(x, r - 1) ? 1 : open(x + 2 * tx, r) ? 0.6 : 0;
      }
  }

  // --- static colour --------------------------------------------------------
  const sr = new Float32Array(N), sg = new Float32Array(N), sb = new Float32Array(N);
  const fogAmt = new Float32Array(N); // how much drifting fog shows in front of a cell
  const fogSpeed = new Float32Array(N);
  const rayAmt = new Float32Array(N); // how much of a sunbeam the air in front of it holds
  const sunS = new Float32Array(N); // nearness to the sun, for the fog's warmth
  const floorLit = new Float32Array(N); // where a beam that reaches the floor lights it
  const lift = new Float32Array(N); // the dot floor, so the darkest air still shows
  const cloudAmt = new Float32Array(N); // where high cloud can show, in the sky only
  const abin = new Float32Array(N), dist = new Float32Array(N);
  const RA = 720;
  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 L = layer[k];
      const dx = x + 0.5 - SUN[0], dy = y - SUN[1];
      const ang = Math.atan2(dy, dx);
      abin[k] = ((ang / (Math.PI * 2)) * RA + RA) % RA;
      const d = (dist[k] = Math.sqrt(dx * dx + dy * dy));
      const s = (sunS[k] = Math.exp(-Math.sqrt(dx * dx + dy * dy * 1.96) / 30));
      // a soft warm bloom over a wide radius, in the air and the fog
      const bloom = Math.exp(-d / 16) * 0.25 + Math.exp(-d / 6) * 0.1;
      let cr: number, cg: number, cb: number, ray: number;
      if (L === SKY) {
        // sky: deep overhead, paling to the fog band low down, warm by the sun
        const v = clamp(y / 52);
        const p = Math.pow(v, 1.6);
        const veil = 0.95 + 0.1 * fbm(x * 0.04, y * 0.08, 3, 0);
        cr = mix(0.065, 0.36, p) * veil, cg = mix(0.14, 0.58, p) * veil, cb = mix(0.17, 0.62, p) * veil;
        // the fog band the far ridge stands in
        const band = 0.75 * smooth(28, 50, y) * (0.55 + 0.75 * fbm(x * 0.025, y * 0.16, 3, 0));
        cr = mix(cr, fogR(0), band), cg = mix(cg, fogG(0), band), cb = mix(cb, fogB(0), band);
        // warm in hue round the sun, but falling off in brightness, so the
        // disc stands in a halo rather than a flat blaze
        const kw = s * 0.55, kb = 0.5 + 0.5 * Math.exp(-d / 9);
        cr = mix(cr, fogR(s) * kb, kw), cg = mix(cg, fogG(s) * kb, kw), cb = mix(cb, fogB(s) * kb, kw);
        const glow = Math.exp(-d / 3) * 0.3 + Math.exp(-d / 10) * 0.18;
        cr += glow + bloom, cg += glow * 0.9 + bloom * 0.78, cb += glow * 0.66 + bloom * 0.45;
        // the disc itself, a little brighter in the middle
        const disc = smooth(SUN_R + 0.6, SUN_R - 0.4, d);
        cr = mix(cr, 1.3, disc), cg = mix(cg, 1.24, disc), cb = mix(cb, 1.08, disc);
        fogAmt[k] = 0.3 * smooth(30, 50, y);
        fogSpeed[k] = 0.4;
        cloudAmt[k] = 0.75 * smooth(5, 15, y) * smooth(44, 28, y) * smooth(SUN_R + 2, SUN_R + 8, d);
        ray = 0.35 * smooth(26, 46, y);
        lift[k] = 0.04;
      } else if (L <= NEAR - 1) {
        const [, , , , , haze, speed, M, drift, beam] = LAYERS[L];
        // pine: dark teal, paling with distance; mist pooled just under the
        // tree line, and a sheet of it lying along the valley floor below
        const pool = smooth(2.5, 8 + L * 1.5, below[k]) * smooth(20, 11, below[k]);
        // the sheet follows the lie of the land, smoothly, so it never streaks
        const sheet = Math.exp(-(((y - (ground[k] + 5)) / 3) ** 2)) * (0.75 + 0.5 * fbm(x * 0.035, L * 7.3, 3, 0));
        const mist = clamp(Math.max(pool * 0.5, sheet) * M);
        const h = clamp(haze + (1 - haze) * mist);
        const veil = 0.9 + 0.2 * fbm(x * 0.06, y * 0.12, 3, 0);
        const pr = 0.02, pg = 0.075, pb = 0.07;
        cr = mix(pr, fogR(s) * veil, h), cg = mix(pg, fogG(s) * veil, h), cb = mix(pb, fogB(s) * veil, h);
        cr += bloom * h, cg += bloom * 0.78 * h, cb += bloom * 0.45 * h;
        const rim = edge[k] * s * (0.2 + 0.6 * (1 - haze));
        cr += rim * 0.95, cg += rim * 0.8, cb += rim * 0.45;
        fogAmt[k] = drift;
        fogSpeed[k] = speed;
        // the trees stop most of the light; the beams show in the mist between
        // (far off there is more air in front of them to hold the light)
        ray = beam * (L < 3 ? 0.45 + 0.55 * clamp(mist / M) : 0.25 + 0.75 * clamp(mist / M));
        lift[k] = 0.03;
      } else if (L === NEAR) {
        // the nearest pines: near-black, a faint teal on each tier's lower edge
        const g = tex[k] * 0.12;
        // (the body itself stays black, or the dither would screen it evenly)
        cr = 0.001 + g * 0.35, cg = 0.004 + g, cb = 0.004 + g * 0.9;
        const rim = edge[k] * (0.12 + 0.6 * s);
        cr += rim * 0.9, cg += rim * 0.7, cb += rim * 0.38;
        fogAmt[k] = LAYERS[L][8];
        fogSpeed[k] = LAYERS[L][6];
        ray = 0.12;
        lift[k] = 0;
      } else if (L === FLOOR) {
        // the forest floor: moss in clumps, sparse, fading out toward the frame
        const clump = smooth(0.42, 0.72, fbm(x * 0.09, y * 0.4, 3, 0));
        const speck = hash(x * 7, r * 11) > 0.55 ? 1 : 0.35;
        const m = (0.06 + 0.3 * clump) * speck * smooth(H + 2, H - 8, y);
        cr = 0.02 + m * 0.6, cg = 0.03 + m * 0.62, cb = 0.02 + m * 0.3;
        fogAmt[k] = 0.06;
        fogSpeed[k] = 3.4;
        ray = 0;
        // dappled patches the beams can land on
        floorLit[k] = smooth(0.32, 0.56, fbm(x * 0.06 + 3, y * 0.24, 3, 0)) * smooth(H + 3, H - 6, y);
        lift[k] = 0;
      } else {
        // the giants: near-black needles, the tiers drawn by a faint teal edge,
        // a warm rim two cells deep toward the light
        const g = tex[k] * 0.15;
        cr = 0.001 + g * 0.35, cg = 0.004 + g, cb = 0.004 + g * 0.9;
        // warm on the side near the sun, a cool fog-lit edge far from it
        const warm = Math.exp(-d / 50);
        const rim = edge[k] * (0.13 + 0.55 * warm);
        cr += rim * mix(0.4, 0.95, warm), cg += rim * mix(0.75, 0.66, warm), cb += rim * mix(0.72, 0.32, warm);
        fogAmt[k] = 0.04;
        fogSpeed[k] = 4;
        ray = 0.04;
        lift[k] = 0;
      }
      // the beams fan out mostly down and to the west, the way the gaps face
      ray *= 0.2 + 0.8 * smooth(1.25, 1.75, ang) * smooth(3.1, 2.6, ang);
      rayAmt[k] = ray;
      sr[k] = cr, sg[k] = cg, sb[k] = cb;
    }

  // drifting fog banks: wide soft noise that wraps so it can slide forever
  const FW = 400;
  const fog = new Float32Array(FW * H);
  for (let r = 0; r < H; r++)
    for (let u = 0; u < FW; u++) fog[r * FW + u] = smooth(0.42, 0.75, fbm(u * 0.022, r * 0.09, 4, FW * 0.022));

  // thin high cloud, long and flat, lit from below by the low sun
  const CH = 46;
  const cloud = new Float32Array(FW * CH);
  for (let r = 0; r < CH; r++)
    for (let u = 0; u < FW; u++) {
      const q = fbm(u * 0.01, r * 0.05, 2, FW * 0.01);
      cloud[r * FW + u] = smooth(0.44, 0.68, fbm(u * 0.016 + q * 1.5, r * 0.17, 4, FW * 0.016));
    }

  // sunbeams: a handful of wide shafts through the gaps, [angle, half width,
  // strength], with a faint grain along each, and a slower pattern sliding
  // across them so they brighten and fade
  const SHAFTS = [[1.42, 0.05, 0.7], [1.66, 0.07, 1], [1.93, 0.05, 0.8], [2.18, 0.08, 1], [2.45, 0.05, 0.75], [2.7, 0.06, 0.9], [2.95, 0.04, 0.6]];
  const rayA = new Float32Array(RA), rayB = new Float32Array(RA);
  for (let i = 0; i < RA; i++) {
    const a = (i / RA) * Math.PI * 2;
    let v = 0;
    for (const [c, w, st] of SHAFTS) v = Math.max(v, st * smooth(w, w * 0.35, Math.abs(a - c)));
    rayA[i] = v * (0.8 + 0.2 * fbm(i * 0.4, 3.1, 2, RA * 0.4));
    rayB[i] = smooth(0.4, 0.7, fbm(i * 0.03, 8.7, 2, RA * 0.03));
  }

  // dust in the air: [x, y, drift speed, bob phase, size]
  const motes: [number, number, number, number, number][] = [];
  for (let i = 0; i < 110; i++) motes.push([hash(i, 1) * W, 50 + hash(i, 2) * 44, 0.3 + hash(i, 3) * 0.8, hash(i, 4) * 6.28, hash(i, 5)]);
  const mote = new Float32Array(N);
  const moteCells: number[] = [];

  return (t, { color } = {}) => {
    for (const k of moteCells) mote[k] = 0;
    moteCells.length = 0;
    for (const [mx, my, sp, ph, sz] of motes) {
      const x = Math.floor((((mx + t * sp + 2.5 * Math.sin(t * 0.4 + ph)) % W) + W) % W);
      const y = Math.floor(my + 2 * Math.sin(t * 0.3 + ph * 1.7) - ((t * sp * 0.2) % 6));
      if (y < 0 || y >= H) continue;
      const k = y * W + x;
      mote[k] = 0.5 + 0.5 * sz;
      moteCells.push(k);
    }
    // the beams hold their places (the gaps in the trees do not move) and
    // only sway a hair; a second, slower pattern drifts across them
    const shiftA = 2 * Math.sin(t * 0.35), shiftB = -t * 1.6;
    const pulse = 0.88 + 0.12 * Math.sin(t * 0.7);

    for (let r = 0; r < H; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        let cr = sr[k], cg = sg[k], cb = sb[k];
        const s = sunS[k];

        // the fog banks drift, nearer ones faster
        const fa = fogAmt[k];
        if (fa > 0.01) {
          const u = x + t * fogSpeed[k], ui = Math.floor(u), uf = u - ui;
          const f0 = fog[r * FW + (ui % FW)], f1 = fog[r * FW + ((ui + 1) % FW)];
          const a = (f0 + (f1 - f0) * uf) * fa;
          cr = mix(cr, fogR(s), a), cg = mix(cg, fogG(s), a), cb = mix(cb, fogB(s), a);
        }

        const ca = cloudAmt[k];
        if (ca > 0.01) {
          const u = x + t * 0.6, ui = Math.floor(u), uf = u - ui;
          const c0 = cloud[r * FW + (ui % FW)], c1 = cloud[r * FW + ((ui + 1) % FW)];
          const a = (c0 + (c1 - c0) * uf) * ca;
          if (a > 0.005) {
            // cool grey-teal, warming to gold on the undersides near the sun
            const w = Math.exp(-dist[k] / 40);
            cr = mix(cr, mix(0.26, 0.95, w), a), cg = mix(cg, mix(0.38, 0.72, w), a), cb = mix(cb, mix(0.42, 0.45, w), a);
          }
        }

        // the beams: brightest near the sun, fading with distance
        const ra = rayAmt[k], fl = floorLit[k];
        let beam = 0;
        if (ra > 0.01 || fl > 0.01) {
          const ai = abin[k];
          const ia = Math.floor(ai + shiftA), ib = Math.floor(ai + shiftB);
          beam = rayA[((ia % RA) + RA) % RA] * (0.6 + 0.4 * rayB[((ib % RA) + RA) % RA]) * pulse;
        }
        if (ra > 0.01 && dist[k] > SUN_R) {
          // lit shafts brighten the air, the shadows between them dim it
          const fall = Math.exp(-dist[k] / 80) * smooth(SUN_R, 12, dist[k]);
          const b = (beam - 0.3) * fall * ra * 2.2;
          if (b > 0) cr += b * 1.05, cg += b * 0.8, cb += b * 0.42;
          else {
            const dim = 1 + b;
            cr *= dim, cg *= dim, cb *= dim;
          }
        }
        if (fl > 0.01) {
          // a patch of sun on the moss where a beam lands
          const b = beam * fl * 1.1;
          cr += b * 1.0, cg += b * 0.78, cb += b * 0.4;
        }

        let floor = lift[k];
        if (mote[k] && dist[k] > 6) {
          // a mote shows up where a beam catches it
          const ia = Math.floor(abin[k] + shiftA);
          const lit = rayA[((ia % RA) + RA) % RA] * Math.exp(-dist[k] / 90);
          const v = mote[k] * (0.1 + 1.6 * lit) * Math.min(1, rayAmt[k] * 2);
          if (v > 0.18) {
            cr = Math.max(cr, v * 1.05), cg = Math.max(cg, v * 0.97), cb = Math.max(cb, v * 0.7);
            floor = 0.3;
          }
        }

        const peak = Math.max(cr, cg, cb, 1e-4);
        const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.9) * 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 sc = (0.3 + 0.7 * want) / peak;
          color[k] = nearest(clamp(cr * sc), clamp(cg * sc), clamp(cb * sc));
        }
      }
    }
    const lines: string[] = [];
    for (let r = 0; r < H; r++) lines.push(out.slice(r * W, (r + 1) * W).join(""));
    return lines.join("\n");
  };
}