black hole

[github]

a black hole, its lensed disk turning, one side brighter

space 64×20 15 fps 6.6 kB

use it

html

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

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

<Ascii piece={blackHole} />

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

astro

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

<Ascii piece="black-hole" />

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

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, blackHole);

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.

black-hole.ts

[js] [github]
/*
 * black-hole: a Schwarzschild black hole and its disk, seen almost edge on.
 * Rays are traced once through the bent space; then only the gas turns,
 * faster near the hole, the side coming toward us brighter.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "black hole",
  category: "space",
  note: "a black hole, its lensed disk turning, one side brighter",
  cols: 64,
  rows: 20,
  fps: 15,
} satisfies Meta;

// The disk is light, not shade, so the ramp keeps its sense on paper too.
const RAMP = " .:-=+*#%@";
const SX = 3, SY = 4; // rays per cell across and down
const D = 30, IN = 3, OUT = 9; // camera distance, the disk's inner and outer edge, in Schwarzschild radii
const TILT = 0.15; // the camera's height above the disk plane, in radians
const SPAN = 0.34; // half the view's width, as a tangent
const GAIN = 1.6, FLOOR = 0.03; // exposure, and the faintest light that shows
const LOOP = 60; // seconds until the gas comes round to where it started
const BANDS = 12; // rings of gas, each turning a whole number of times a loop
const SPIN = 7; // turns a loop at the inner edge; Kepler sets the rest
const BMAX = 12, NB = 1500, H = 0.02; // the table of paths: impact parameters and step

function mulberry32(a: number) {
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// A ray stays in a plane through the hole, and its path there depends only on
// its impact parameter b. In that plane u = 1/r obeys u'' = 1.5u^2 - u in the
// angle a swept from the camera; each path is stepped with RK4 until it falls
// in or leaves, and kept as u at every step.
function paths(): Float32Array[] {
  const f = (u: number) => 1.5 * u * u - u;
  const table: Float32Array[] = [];
  for (let n = 0; n < NB; n++) {
    const b = ((n + 0.5) / NB) * BMAX;
    let u = 1 / D, w = Math.sqrt(1 - (b / D) ** 2) / b;
    const us = [u];
    while (u < 1 && (u > 1 / (OUT + 1) || w > 0)) {
      const k1 = w, l1 = f(u);
      const k2 = w + (H / 2) * l1, l2 = f(u + (H / 2) * k1);
      const k3 = w + (H / 2) * l2, l3 = f(u + (H / 2) * k2);
      const k4 = w + H * l3, l4 = f(u + H * k3);
      u += (H / 6) * (k1 + 2 * k2 + 2 * k3 + k4);
      w += (H / 6) * (l1 + 2 * l2 + 2 * l3 + l4);
      us.push(u);
    }
    table.push(Float32Array.from(us));
  }
  return table;
}

export default function blackHole(): Frame {
  const { cols, rows } = meta;
  const cw = (2 * SPAN) / cols; // one cell's width as a tangent; it stands twice as tall
  const s = Math.sin(TILT), c = Math.cos(TILT);
  const table = paths();

  // Each band's gas is a ring of noise: a few whole harmonics, so it closes.
  const rand = mulberry32(17);
  const TAB = 256;
  const band: { tab: Float32Array; turns: number }[] = [];
  for (let b = 0; b < BANDS; b++) {
    const r = IN + ((OUT - IN) * b) / (BANDS - 1);
    const turns = Math.max(1, Math.round(SPIN * (IN / r) ** 1.5));
    const waves = [2, 3, 5, 8, 13].map((k): [number, number, number] => [k, rand() * 6.283, (0.5 + rand()) / Math.sqrt(k)]);
    const tab = new Float32Array(TAB);
    let lo = Infinity, hi = -Infinity;
    for (let i = 0; i < TAB; i++) {
      for (const [k, p, a] of waves) tab[i] += a * Math.sin((k * 2 * Math.PI * i) / TAB + p);
      lo = Math.min(lo, tab[i]);
      hi = Math.max(hi, tab[i]);
    }
    for (let i = 0; i < TAB; i++) tab[i] = 0.45 + (0.55 * (tab[i] - lo)) / (hi - lo);
    band.push({ tab, turns });
  }

  // Each ray: its plane (e1 toward the camera, e2 along the ray), then the
  // angles where its height s cos a + e2y sin a is zero, a half turn apart.
  // A crossing inside the disk keeps what never changes: bands, angle, weight.
  const cells = Array.from({ length: cols * rows }, (): number[] => []);
  const empty = new Uint8Array(cols * rows).fill(1);
  for (let j = 0; j < rows * SY; j++) {
    const y = (rows / 2 + 0.5 - (j + 0.5) / SY) * 2 * cw; // half a row high: the arc over the hole is the taller one
    for (let i = 0; i < cols * SX; i++) {
      const x = ((i + 0.5) / SX - cols / 2) * cw;
      const k = Math.floor(j / SY) * cols + Math.floor(i / SX);
      const n = Math.hypot(x, y, 1);
      const d = [x / n, (y * c - s) / n, (y * s + c) / n];
      const out = d[1] * s - d[2] * c;
      const side = Math.sqrt(1 - out * out);
      const e2 = [d[0] / side, (d[1] - out * s) / side, (d[2] + out * c) / side];
      const us = table[Math.min(NB - 1, Math.floor((D * side * NB) / BMAX))];
      if (us[us.length - 1] >= 1) empty[k] = 0;
      // Images that wind more than once round the hole are thinner than a
      // cell and only speckle the shadow, so they are left out.
      const end = Math.min((us.length - 1) * H, 5.6);
      let light = 1;
      for (let a = (Math.atan2(-s, e2[1]) + 2 * Math.PI) % Math.PI; a < end; a += Math.PI) {
        const p = a / H, q = Math.floor(p);
        const r = 1 / (us[q] + (us[q + 1] - us[q]) * (p - q));
        if (r <= IN || r >= OUT) continue;
        const phi = Math.atan2(Math.sin(a) * e2[2] - Math.cos(a) * c, Math.sin(a) * e2[0]);
        const edge = Math.min(1, (r - IN) / 0.7) * (1 - ((r - IN) / (OUT - IN)) ** 2);
        const doppler = 1 - 0.5 * Math.cos(phi);
        const pos = ((r - IN) / (OUT - IN)) * (BANDS - 1);
        const b0 = Math.min(BANDS - 2, Math.floor(pos));
        empty[k] = 0;
        cells[k].push(b0, pos - b0, phi, (light * edge * doppler * (IN / r) ** 1.5) / (SX * SY));
        light *= 0.45; // a crossing seen through the disk is dimmer
      }
    }
  }

  // A few faint stars where the sky is clear.
  const stars = new Uint8Array(cols * rows);
  for (let k = 0; k < cols * rows; k++) stars[k] = empty[k] && rand() < 0.03 ? 1 : 0;

  const at = (b: number, a: number) => {
    const i = Math.floor((a / (2 * Math.PI)) * TAB) % TAB;
    return band[b].tab[i < 0 ? i + TAB : i];
  };

  return (t) => {
    const turn = (2 * Math.PI * (t % LOOP)) / LOOP;
    let out = "";
    for (let r = 0; r < rows; r++) {
      if (r) out += "\n";
      for (let q = 0; q < cols; q++) {
        const k = r * cols + q;
        const h = cells[k];
        let sum = 0;
        for (let i = 0; i < h.length; i += 4) {
          const b = h[i], fr = h[i + 1], phi = h[i + 2];
          const g0 = at(b, phi - band[b].turns * turn), g1 = at(b + 1, phi - band[b + 1].turns * turn);
          sum += h[i + 3] * (g0 + (g1 - g0) * fr);
        }
        const v = Math.min(1, Math.max(0, sum * GAIN - FLOOR) / (1 - FLOOR)) ** 0.75;
        const ch = RAMP[Math.round(v * (RAMP.length - 1))];
        out += ch === " " && stars[k] ? "." : ch;
      }
    }
    return out;
  };
}