planet

[github]

a banded ringed planet turning, with shadows both ways

space 64×18 20 fps 5.5 kB

use it

html

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

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

<Ascii piece={planet} />

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

astro

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

<Ascii piece="planet" />

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

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

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.

planet.ts

[js] [github]
/*
 * planet: a ringed gas giant, banded and turning, lit from the upper left.
 * The rings throw a shadow across the globe and the globe one across the rings.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "planet",
  category: "space",
  note: "a banded ringed planet turning, with shadows both ways",
  cols: 64,
  rows: 18,
  fps: 20,
} satisfies Meta;

const RAMP = " .,:-=+*#%@";
const RP = 6.5; // the globe's radius, in rows
const TILT = 0.4; // how far the ring plane opens toward the eye
const ROLL = 0.2; // how far the axis leans in the picture
const DAY = 18; // seconds a turn
// The rings by radius, in globe radii: [from, to, opacity at from, at to,
// brightness]. The inner one is dark dust, a dusky band where it crosses the globe.
const RINGS: [number, number, number, number, number][] = [
  [1.22, 1.48, 0.3, 0.5, 0.1],
  [1.5, 1.9, 0.85, 1, 1],
  [1.98, 2.16, 0.8, 0.65, 0.85],
];

const unit = (v: number[]) => v.map((c) => c / Math.hypot(...v));
const SUN = unit([-0.42, 0.34, 0.84]);
const AXIS = [0, Math.cos(TILT), Math.sin(TILT)]; // the pole, in view space before the roll
const E2 = [0, -Math.sin(TILT), Math.cos(TILT)]; // the equator's direction toward the eye

const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const smooth = (e0: number, e1: number, x: number) => {
  const u = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
  return u * u * (3 - 2 * u);
};

// Ring opacity at radius r, in globe radii, and the brightness of what is there.
let albedo = 0;
function ring(r: number) {
  for (const [a, b, p, q, w] of RINGS) if (r >= a && r < b) return (albedo = w), p + ((q - p) * (r - a)) / (b - a);
  return 0;
}

// Cloud brightness at a latitude and longitude: pale zones and dark belts
// whose edges ripple with longitude, a dark line along each edge, an oval
// storm, and darker poles.
function cloud(lat: number, lon: number) {
  const w = lat + 0.05 * Math.sin(4 * lon + 6 * lat) + 0.03 * Math.sin(9 * lon - 4 * lat + 1);
  const s = Math.cos(w * 7.2); // a pale zone on the equator, belts either side
  let v = 0.5 + 0.42 * smooth(-0.35, 0.35, s) - 0.28 * Math.exp(-((s / 0.16) ** 2));
  v += 0.06 * Math.sin(lon * 7 + lat * 21);
  const dl = ((((lon + 0.6) % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI)) - Math.PI;
  const d = ((dl * Math.cos(lat)) / 0.42) ** 2 + ((lat - 0.42) / 0.12) ** 2;
  if (d < 1) v = d > 0.45 ? 0.3 : 1;
  return v * (1 - 0.35 * Math.abs(Math.sin(lat)) ** 4);
}

// A few fixed stars, from a seeded generator so every copy has the same sky.
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;
  };
}

export default function planet(): Frame {
  const { cols, rows } = meta;
  const cr = Math.cos(ROLL), sr = Math.sin(ROLL);
  const lines: string[] = new Array(rows);
  const rand = mulberry32(7);
  const stars = Array.from({ length: 14 }, (): [number, number, number] => [1 + Math.floor(rand() * (cols - 2)), Math.floor(rand() * rows), rand() * 6.283]);

  // Brightness, 0 to 1, at a point on the picture in rows, or -1 for space.
  // The globe tops out below the rings, so a ring crossing it reads as brighter.
  const sample = (px: number, py: number, spin: number) => {
    const x = cr * px - sr * py, y = sr * px + cr * py; // undo the roll
    const r2 = x * x + y * y;
    let globe = -1, gz = -Infinity;
    if (r2 < RP * RP) {
      const z = Math.sqrt(RP * RP - r2), n = [x / RP, y / RP, z / RP];
      gz = z;
      const lat = Math.asin(dot(n, AXIS)), lon = Math.atan2(n[0], dot(n, E2)) - spin;
      const lit = smooth(-0.08, 0.4, dot(n, SUN));
      // The rings' shadow: follow the light from here to the ring plane.
      const s = -(y * AXIS[1] + z * AXIS[2]) / dot(SUN, AXIS);
      const shade = s > 0 ? 1 - 0.75 * ring(Math.hypot(x + s * SUN[0], y + s * SUN[1], z + s * SUN[2]) / RP) : 1;
      globe = 0.86 * cloud(lat, lon) * lit * shade * (0.7 + 0.3 * n[2]);
    }
    // The ring plane where this line of sight crosses it.
    const z = (-y * AXIS[1]) / AXIS[2];
    const op = ring(Math.sqrt(r2 + z * z) / RP);
    if (op > 0 && z > gz) {
      // The globe's shadow on the rings, on the side away from the sun.
      const b = x * SUN[0] + y * SUN[1] + z * SUN[2], c = r2 + z * z - RP * RP;
      const v = op * albedo * (b < 0 && b * b > c ? 0.04 : 1);
      return globe < 0 ? v : globe * (1 - op) + v;
    }
    return globe;
  };

  return (t, { paper = false } = {}) => {
    const spin = (2 * Math.PI * t) / DAY;
    for (let r = 0; r < rows; r++) {
      let line = "";
      for (let c = 0; c < cols; c++) {
        let sum = 0, hits = 0;
        for (let k = 0; k < 4; k++) {
          const px = (c + 0.25 + 0.5 * (k & 1) - cols / 2) * 0.5;
          const py = rows / 2 - r - 0.25 - 0.5 * (k >> 1);
          const v = sample(px, py, spin);
          if (v >= 0) (sum += v), hits++;
        }
        if (hits < 2) {
          line += " ";
          continue;
        }
        let i = Math.min(RAMP.length - 1, Math.max(1, Math.round((sum / hits) * (RAMP.length - 1))));
        if (paper) i = RAMP.length - i;
        line += RAMP[i];
      }
      lines[r] = line;
    }
    // Stars go only where nothing else is, and one in three slowly dims.
    for (const [c, r, p] of stars) {
      if (lines[r][c] !== " ") continue;
      const ch = p < 2 && Math.sin(t * 0.9 + p * 3) < -0.3 ? " " : p < 4 ? "·" : ".";
      lines[r] = lines[r].slice(0, c) + ch + lines[r].slice(c + 1);
    }
    return lines.join("\n");
  };
}