flow field

[github]

particle trails gathering into winding, merging streams

generative 64×22 20 fps 5.8 kB

use it

html

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

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

<Ascii piece={flowField} />

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

astro

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

<Ascii piece="flow-field" />

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

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

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.

flow-field.ts

[js] [github]
/*
 * flow-field: particles carried left to right by a smooth noise field whose
 * currents pull them together, so their fading trails gather into a few
 * winding streams that merge and part as the field slowly changes.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "flow field",
  category: "generative",
  note: "particle trails gathering into winding, merging streams",
  cols: 64,
  rows: 22,
  fps: 20,
} satisfies Meta;

const STREAMS = 4; // currents across the field
const RATE = 16; // particles let go from the left edge a second
const SPEED = 9; // cell widths a second
const FADE = 1.6; // seconds for a trail to fade to about a third
const BEND = 12; // how far a current wanders up and down, in cell widths
const SCALE = 0.085; // noise cycles per cell width along a current
const PULL = 0.22; // how hard a current draws particles in, per cell width
const REACH = 7; // how far a current's pull is felt, in cell widths
const DRIFT = 0.04; // how fast the currents change
const TURN = 1.2; // the steepest heading off the horizontal, in radians
const WARM = 9; // seconds simulated before the first frame

function mulberry32(a: number): () => 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 fixed number in [0, 1) for each lattice point.
function hash(i: number, j: number, k: number): number {
  let h = Math.imul(i, 0x27d4eb2d) ^ Math.imul(j, 0x165667b1) ^ Math.imul(k, 0x9e3779b1);
  h = Math.imul(h ^ (h >>> 15), 0x85ebca77);
  return ((h ^ (h >>> 13)) >>> 0) / 4294967296;
}

// Smooth value noise along a line, changing with time z, in [0, 1).
function noise(x: number, z: number, k: number): number {
  const i = Math.floor(x), j = Math.floor(z);
  const s = (f: number) => f * f * f * (f * (f * 6 - 15) + 10);
  const u = s(x - i), v = s(z - j);
  const a = hash(i, j, k) + (hash(i + 1, j, k) - hash(i, j, k)) * u;
  const b = hash(i, j + 1, k) + (hash(i + 1, j + 1, k) - hash(i, j + 1, k)) * u;
  return a + (b - a) * v;
}

export default function flowField(): Frame {
  const { cols, rows } = meta;
  const W = cols, H = rows * 2; // the field in cell widths; a row is two of them tall
  const rand = mulberry32(0x5eed);
  const ink = new Float32Array(cols * rows);
  const dir = new Uint8Array(cols * rows);
  const ps: { x: number; y: number; o: number }[] = [];
  const ys = new Float32Array(STREAMS), ss = new Float32Array(STREAMS);

  // Where each current runs at column x, and its slope there.
  const currents = (x: number, z: number) => {
    for (let k = 0; k < STREAMS; k++) {
      // Wandering freely mid-frame, eased back in near the top and bottom.
      const at = (xx: number) => {
        const y = ((k + 0.5) / STREAMS - 0.5) * H + BEND * (2 * noise(xx * SCALE + k * 7.3, z + k * 3.1, k) - 1);
        return H / 2 + (H / 2 - 3) * Math.tanh(y / (H / 2 - 3));
      };
      ys[k] = at(x);
      ss[k] = at(x + 0.5) - at(x - 0.5);
    }
  };

  // The heading of a particle in lane `o` at (x, y): along the nearby
  // currents, and in towards them.
  const heading = (x: number, y: number, o: number, z: number): number => {
    currents(x, z);
    let num = 0, den = 1e-9, best = 0, near = 1e9;
    for (let k = 0; k < STREAMS; k++) {
      const d = ys[k] + o - y, w = Math.exp(-((d / REACH) ** 2));
      num += w * (ss[k] + PULL * d);
      den += w;
      if (Math.abs(d) < near) (near = Math.abs(d)), (best = ss[k] + PULL * d);
    }
    const slope = den > 1e-4 ? num / den : best;
    return Math.max(-TURN, Math.min(TURN, Math.atan(slope)));
  };

  // A glyph for each direction, from the true angle on screen: a cell is
  // twice as tall as it is wide, so / and \ stand at about 63 degrees.
  const STEEP = Math.atan(2);
  const glyph = (a: number): string => {
    const tilt = Math.abs(a);
    if (tilt < STEEP / 2) return "-";
    if (tilt > (Math.PI / 2 + STEEP) / 2) return "|";
    return a > 0 ? "\\" : "/"; // y grows downward
  };

  const mark = (x: number, y: number, a: number) => {
    const c = Math.floor(x), r = Math.floor(y / 2);
    if (c < 0 || c >= cols || r < 0 || r >= rows) return;
    const k = r * cols + c;
    ink[k] = 1;
    dir[k] = glyph(a).charCodeAt(0);
  };

  let clock = 0, due = 0;
  const step = (dt: number) => {
    clock += dt;
    const keep = Math.exp(-dt / FADE);
    for (let k = 0; k < ink.length; k++) ink[k] *= keep;
    const z = clock * DRIFT;
    // New particles come in at the left edge: most near a current, some
    // anywhere, to show the field drawing them in.
    for (due -= dt; due <= 0; due += 1 / RATE) {
      currents(0, z);
      const y = rand() < 0.75 ? ys[Math.floor(rand() * STREAMS)] + (rand() - 0.5) * 8 : 2 + rand() * (H - 4);
      ps.push({ x: 0, y, o: (rand() - 0.5) * 3.2 });
    }
    for (let i = ps.length - 1; i >= 0; i--) {
      const p = ps[i];
      const a = heading(p.x, p.y, p.o, z);
      const dx = Math.cos(a) * SPEED * dt, dy = Math.sin(a) * SPEED * dt;
      const n = Math.max(1, Math.ceil(Math.hypot(dx, dy * 0.5) * 1.5));
      for (let s = 1; s <= n; s++) mark(p.x + (dx * s) / n, p.y + (dy * s) / n, a);
      p.x += dx;
      p.y += dy;
      if (p.x >= W || p.y < 0 || p.y >= H) ps.splice(i, 1);
    }
  };
  for (let i = 0; i < WARM * meta.fps; i++) step(1 / meta.fps);

  let last = 0;
  return (t) => {
    const dt = Math.min(0.1, Math.max(0, t - last));
    last = t;
    if (dt > 0) step(dt);
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) {
      let s = "";
      for (let c = 0; c < cols; c++) {
        const k = r * cols + c, v = ink[k];
        s += v > 0.5 ? String.fromCharCode(dir[k]) : v > 0.27 ? "·" : v > 0.14 ? "." : " ";
      }
      lines.push(s);
    }
    return lines.join("\n");
  };
}