sierpinski

[github]

the chaos game filling a sierpinski triangle point by point

generative 59×26 20 fps 4.9 kB

use it

html

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

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

<Ascii piece={sierpinski} />

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

astro

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

<Ascii piece="sierpinski" />

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

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

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.

sierpinski.ts

[js] [github]
/*
 * sierpinski: the chaos game. A point jumps halfway to a corner picked at
 * random, over and over, and every spot it lands on stays lit.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "sierpinski",
  category: "generative",
  note: "the chaos game filling a sierpinski triangle point by point",
  cols: 59,
  rows: 26,
  fps: 20,
} satisfies Meta;

const QUAD = " ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█"; // a cell's quarters: 1 2 above, 4 8 below
const CYCLE = 9.6; // seconds from one empty frame to the next
const FILL = 6; // seconds of plotting, slow at first and then a flood
const HOLD = 7.3; // the finished triangle stays until here
const CLEAR = 9.3; // and has been erased along one unbroken path by here
const START = 6.1; // play opens on the finished triangle
const POINTS = 12000;
const RATE = 1; // how fast the plotting speeds up
const STEADY = 60; // throws a second underneath the flood
const DEPTH = 7; // levels of the figure a quarter is tested against

function mulberry32(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 sierpinski(): Frame {
  const { cols, rows } = meta;
  const W = cols * 2, H = rows * 2; // quarter cells: half a column wide, half a row tall
  // A quarter cell is twice as tall as it is wide, so an equilateral triangle
  // of height h quarters is h / sin 60 columns across.
  const top = 2, base = H - 2;
  const half = (base - top) / Math.sin(Math.PI / 3);
  const corners = [[cols, top], [cols - half, base], [cols + half, base]];

  // Where a point sits in the figure: -1 in a hole, else its place along the
  // arrowhead path that runs from the left corner over the apex to the right,
  // visiting every sub-triangle in turn. Corners are 0 apex, 1 left, 2 right.
  const place = (x: number, y: number): number => {
    const v = (y - top) / (base - top), s = (x - cols) / half;
    const w = [1 - v, (v - s) / 2, (v + s) / 2];
    if (w[0] < 0 || w[1] < 0 || w[2] < 0) return -1;
    let p = [1, 0, 2], at = 0, step = 1;
    for (let l = 0; l < DEPTH; l++) {
      const k = w.findIndex((x) => x >= 0.5);
      if (k < 0) return -1;
      for (let j = 0; j < 3; j++) w[j] = j === k ? 2 * w[j] - 1 : 2 * w[j];
      const i = p.indexOf(k);
      at += i * (step /= 3);
      p = i === 0 ? [p[0], p[2], p[1]] : i === 2 ? [p[1], p[0], p[2]] : p;
    }
    return at;
  };
  // A quarter belongs to the figure when two of its 16 sample points do; its
  // rank is the earliest of their places along the path.
  const rank = new Float32Array(W * H).fill(-1);
  for (let qy = 0; qy < H; qy++)
    for (let qx = 0; qx < W; qx++) {
      let n = 0, first = 2;
      for (let i = 0; i < 16; i++) {
        const at = place(qx + ((i & 3) + 0.5) / 4, qy + ((i >> 2) + 0.5) / 4);
        if (at >= 0) n++, (first = Math.min(first, at));
      }
      if (n >= 2) rank[qx + qy * W] = first;
    }

  const first = new Int32Array(W * H); // the throw that first lit each quarter
  let cycle = -1;
  const build = (k: number) => {
    const rand = mulberry32(k * 977 + 31);
    first.fill(POINTS - 1); // anything the game misses lights on the last throw
    let [x, y] = corners[(rand() * 3) | 0];
    for (let n = 0; n < POINTS; n++) {
      const [cx, cy] = corners[(rand() * 3) | 0];
      x = (x + cx) / 2;
      y = (y + cy) / 2;
      const at = Math.floor(x) + Math.floor(y) * W;
      if (first[at] > n) first[at] = n;
    }
  };

  const scale = (POINTS - STEADY * FILL) / (Math.exp(RATE * FILL) - 1);
  // The cell each corner's own quarter falls in, nudged inside the triangle.
  const marks = corners.map(([x, y], i) => Math.floor((x + [0, 1, -1][i]) / 2) + Math.floor((y + (i ? -1 : 1)) / 2) * cols);

  return (t) => {
    const u = t + START;
    const k = Math.floor(u / CYCLE);
    if (k !== cycle) build((cycle = k));
    const tau = u - k * CYCLE;
    const shown = tau >= FILL ? POINTS : Math.floor(STEADY * tau + scale * (Math.exp(RATE * tau) - 1));
    const gone = tau <= HOLD ? 0 : (tau - HOLD) / (CLEAR - HOLD);
    // 1 or 0, for the bit mask below.
    const on = (qx: number, qy: number): number => {
      const at = qx + qy * W;
      return Number(rank[at] >= gone && first[at] < shown);
    };
    // The corners are marked while the game is being played and once it is over.
    const marked = tau < FILL || tau >= CLEAR;
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) {
      let line = "";
      for (let c = 0; c < cols; c++) {
        const qx = c * 2, qy = r * 2;
        const m = on(qx, qy) | (on(qx + 1, qy) << 1) | (on(qx, qy + 1) << 2) | (on(qx + 1, qy + 1) << 3);
        line += m ? QUAD[m] : marked && marks.includes(c + r * cols) ? "·" : " ";
      }
      lines.push(line);
    }
    return lines.join("\n");
  };
}