reaction diffusion
[github]gray-scott spots and stripes dying back and growing out
generative 60×24 20 fps 5.5 kB
=** =*= *##*= -*****= *%%##*= -- -- -#%%#* === *%@@@# #@@@# *########**= *@@@@@@# #@@@@* #@@@@@@%%###*** **= %@@@@@- =%@@@@* =%@@@@@%* =*#**- -*#%# == %@@@@# =%@@@%# =**=- - *%@@@%* =%@@@%= -@@@@@%- #@@@@%= -*** *@@@@* #@@@@%- *%@@@@%*-=#@@@@%%= @@@@@@= *%%* *%@@@@@@#= *@@@@@%%# *#%%%# %@@@@% *%%@@@@@@@%* *%@@%%%#= #%%@@@= == #@@@@@@@%#- *#%%#* *** *@@@@@@%* *##* -%@@@@%* =*#** =%@@@* #@@@@%- #@@@%#= =**** =@@@@@@= #@@@@%* -%@@@@@%#*==***= =##%%#- *@@@@@@ =%@@@@# #@@@@%#= === =##%%@% =%@@# *%@@@@# #%%#= - *#* #@@@@%# *##* -- #%%%#* *##* -= -=- ==- -- =** =*= *##*= -*****= *%%##*= -- -- -#%%#* === *%@@@# #@@@# *########**= *@@@@@@# #@@@@* #@@@@@@%%###*** **= %@@@@@- =%@@@@* =%@@@@@%* =*#**- -*#%# == %@@@@# =%@@@%# =**=- - *%@@@%* =%@@@%= -@@@@@%- #@@@@%= -*** *@@@@* #@@@@%- *%@@@@%*-=#@@@@%%= @@@@@@= *%%* *%@@@@@@#= *@@@@@%%# *#%%%# %@@@@% *%%@@@@@@@%* *%@@%%%#= #%%@@@= == #@@@@@@@%#- *#%%#* *** *@@@@@@%* *##* -%@@@@%* =*#** =%@@@* #@@@@%- #@@@%#= =**** =@@@@@@= #@@@@%* -%@@@@@%#*==***= =##%%#- *@@@@@@ =%@@@@# #@@@@%#= === =##%%@% =%@@# *%@@@@# #%%#= - *#* #@@@@%# *##* -- #%%%#* *##* -= -=- ==- --
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="reaction-diffusion"></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 { reactionDiffusion } from "ascii.rest/pieces";
<Ascii piece={reactionDiffusion} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="reaction-diffusion", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="reaction-diffusion" />
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 { reactionDiffusion } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, reactionDiffusion); 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.
/*
* reaction diffusion: a Gray-Scott reaction whose spots on the left give way
* to stripes on the right. Every few seconds the kill rate rises, the pattern
* dies back to a few survivors, and they grow out again.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "reaction diffusion",
category: "generative",
note: "gray-scott spots and stripes dying back and growing out",
cols: 60,
rows: 24,
fps: 20,
} satisfies Meta;
const W = 60, H = 48; // the grid, two cells to a character so they are square
const SPOTS = [0.0367, 0.0649], STRIPES = [0.03, 0.057]; // feed and kill rates
const DU = 0.45, DV = 0.225; // diffusion
const RATE = 1000; // reaction steps a second
const CYCLE = 8000; // steps from one die back to the next
const BACK = [0.1, 0.25, 0.35, 0.48], BUMP = 0.012; // the kill rate's rise over a cycle, and its height
const SEEDS = 48, WARM = 1200; // seeds scattered at the start, and steps run before the first frame
// Concentration of the second chemical. Below the floor is bare ground; above
// it a steep curve, so every feature has a crisp edge. The pattern is the ink,
// so it keeps its ramp on paper.
const RAMP = "-=*#%@";
const FLOOR = 0.12, TOP = 0.28;
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 reactionDiffusion(): Frame {
const { cols, rows } = meta;
const N = W * H;
let u = new Float32Array(N).fill(1), v = new Float32Array(N);
let u2 = new Float32Array(N), v2 = new Float32Array(N);
const smooth = (a: number, b: number, x: number) => {
const s = Math.max(0, Math.min(1, (x - a) / (b - a)));
return s * s * (3 - 2 * s);
};
const feed = new Float32Array(W), kill = new Float32Array(W);
for (let x = 0; x < W; x++) {
const s = smooth(0.1, 0.9, (1 + Math.sin((2 * Math.PI * (x + 0.5)) / W)) / 2);
feed[x] = STRIPES[0] + (SPOTS[0] - STRIPES[0]) * s;
kill[x] = STRIPES[1] + (SPOTS[1] - STRIPES[1]) * s;
}
// The frame fades toward its edges, so the pattern thins out there.
const fade = new Float32Array(cols * rows);
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++) {
const ax = Math.abs((c + 0.5) / cols - 0.5) * 2, ay = Math.abs((r + 0.5) / rows - 0.5) * 2;
fade[r * cols + c] = 1 - 0.6 * smooth(0.55, 1, Math.cbrt(ax ** 3 + ay ** 3));
}
const rand = mulberry32(5);
const seed = (cx: number, cy: number) => {
for (let dy = -2; dy <= 2; dy++)
for (let dx = -2; dx <= 2; dx++) {
const i = ((cy + dy + H) % H) * W + ((cx + dx + W) % W);
u[i] = 0.5;
v[i] = 0.25;
}
};
for (let i = 0; i < SEEDS; i++) seed(Math.floor(rand() * W), Math.floor(rand() * H));
// Each die back is uneven: a smooth random field, new every cycle, sets
// how hard it bites where, so the survivors fall differently each time.
const bite = new Float32Array(N);
const reshape = () => {
const waves = [0, 1, 2].map(() => {
let kx = 0, ky = 0;
while (!kx && !ky) (kx = Math.floor(rand() * 5) - 2), (ky = Math.floor(rand() * 5) - 2);
return [kx, ky, rand() * 6.283];
});
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
let s = 0;
for (const [kx, ky, p] of waves) s += Math.cos(2 * Math.PI * ((kx * x) / W + (ky * y) / H) + p);
bite[y * W + x] = 1 + s / 6;
}
};
const bump = (p: number) => BUMP * smooth(BACK[0], BACK[1], p) * (1 - smooth(BACK[2], BACK[3], p));
let n = 0, cycle = -1;
const step = () => {
// The cycle starts on the first frame.
const k = Math.floor((n - WARM) / CYCLE);
if (k !== cycle) (cycle = k), reshape();
const p = (n - WARM) / CYCLE - k;
const b = bump(p);
n++;
for (let y = 0; y < H; y++) {
const ym = ((y + H - 1) % H) * W, y0 = y * W, yp = ((y + 1) % H) * W;
for (let x = 0; x < W; x++) {
const xm = (x + W - 1) % W, xp = (x + 1) % W;
const i = y0 + x;
const lu = 0.2 * (u[ym + x] + u[yp + x] + u[y0 + xm] + u[y0 + xp]) + 0.05 * (u[ym + xm] + u[ym + xp] + u[yp + xm] + u[yp + xp]) - u[i];
const lv = 0.2 * (v[ym + x] + v[yp + x] + v[y0 + xm] + v[y0 + xp]) + 0.05 * (v[ym + xm] + v[ym + xp] + v[yp + xm] + v[yp + xp]) - v[i];
const uvv = u[i] * v[i] * v[i];
u2[i] = u[i] + DU * lu - uvv + feed[x] * (1 - u[i]);
v2[i] = v[i] + DV * lv + uvv - (feed[x] + kill[x] + b * bite[i]) * v[i];
}
}
[u, u2] = [u2, u];
[v, v2] = [v2, v];
// Should a die back ever take everything, start again from a few seeds.
if (p < BACK[3] && p + 1 / CYCLE >= BACK[3] && v.reduce((s, x) => s + x, 0) < 8)
for (let i = 0; i < 4; i++) seed(Math.floor(rand() * W), Math.floor(rand() * H));
};
while (n < WARM) step();
return (t) => {
const want = WARM + Math.floor(t * RATE);
if (want - n > RATE / 5) n = want - Math.ceil(RATE / 5); // a long pause skips ahead rather than stalls
while (n < want) step();
const lines: string[] = [];
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const val = ((v[2 * r * W + c] + v[(2 * r + 1) * W + c]) / 2) * fade[r * cols + c];
const q = Math.sqrt(Math.min(1, (val - FLOOR) / (TOP - FLOOR)));
line += val < FLOOR ? " " : RAMP[Math.min(RAMP.length - 1, Math.floor(q * RAMP.length))];
}
lines.push(line);
}
return lines.join("\n");
};
}