100k Particles
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A hundred thousand specks of light, all obeying the same two lines of arithmetic: fall toward the pointer under a softened inverse-square pull, and ride a swirling flow field that keeps drifting through the frame. It is raw WebGL1 — no library, one buffer of points, a nine-line fragment shader — and the FPS counter beside it is measured, not decorative. The drift is a true curl field rather than wobbly noise: a smooth potential is built on a 96×96×24 grid whose x, y and time axes all wrap, and the velocity is its 2D curl, so the divergence cancels on the stencil down to float32 round-off and the swarm folds into filaments instead of quietly draining into a few sinks. Brightness is solved for rather than guessed — every speck deposits light additively on dark and subtracts ink on light while the trail quad decays what is already there, so the gain is set to exposure × fade × canvasArea / (n × size²) and 50k particles in a phone-sized square land on the same exposure as 100k across a 4K display.
Drag Swirl to zero first: the flow switches off and you get a clean radial free-fall, a hundred thousand streaks converging on one burning core, which is the clearest look at the pull term alone. Then push Pull to ×2.00 and click repeatedly to punch expanding rings through the collapse. With no WebGL it drops to a 2,000-particle canvas2d version running the identical physics, and under prefers-reduced-motion it opens as a still constellation with a play button.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>100k Particles — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (scrims need alpha, tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.90); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.90); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.90); --shadow: 0 1px 2px rgba(0,0,0,.35); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap {
max-width: 1180px; margin: 0 auto;
padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 16px;
}
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede code {
font-family: var(--font-mono); font-size: .87em; color: var(--accent);
background: var(--accent-soft); border-radius: 4px; padding: 1px 5px;
}
.stage { display: grid; grid-template-columns: minmax(0,1fr) 272px; gap: 16px; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); } }
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface);
aspect-ratio: 16 / 10; box-shadow: var(--shadow);
}
/* wide layout: the field fills the row the control panel defines, so the
showpiece is as tall as the instrument column instead of leaving dead space
under it (an aspect-ratio box does not stretch, hence the explicit height) */
@media (min-width: 901px) { .field { aspect-ratio: auto; height: 100%; min-height: 360px; } }
@media (max-width: 900px) { .field { aspect-ratio: 3 / 2; } }
@media (max-width: 560px) { .field { aspect-ratio: 1 / 1; } }
canvas {
display: block; width: 100%; height: 100%;
touch-action: manipulation; cursor: crosshair;
border-radius: calc(var(--radius) - 1px);
}
canvas[hidden] { display: none; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
.hint {
position: absolute; left: 10px; bottom: 10px; pointer-events: none;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
transition: opacity .5s ease; max-width: calc(100% - 20px);
}
.hint[hidden] { display: none; }
.hint.gone { opacity: 0; }
.overlay {
position: absolute; inset: 0; display: grid; place-items: center;
padding: 18px; text-align: center; pointer-events: none;
}
.overlay[hidden] { display: none; }
.overlay .box {
pointer-events: auto; display: flex; flex-direction: column; align-items: center; gap: 10px;
max-width: 36ch; background: var(--scrim); border: 1px solid var(--line);
border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow);
}
.overlay p { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); }
.panel { display: flex; flex-direction: column; gap: 12px; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
}
.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
font-family: var(--font-mono); font-size: 20px; line-height: 1.05;
font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted);
}
.pill {
display: inline-flex; align-items: center; gap: 6px; margin-top: 11px;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
padding: 3px 9px 3px 7px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.on { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.on .dot { background: var(--run); }
.meta {
display: flex; justify-content: space-between; gap: 8px; margin-top: 10px;
padding-top: 9px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em; color: var(--muted);
}
.meta b { color: var(--ink); font-weight: 500; }
.note {
display: none; align-items: flex-start; gap: 7px; margin-top: 10px;
font-size: 11.5px; line-height: 1.4; color: var(--muted);
background: var(--accent-soft); border: 1px solid var(--line);
border-radius: 8px; padding: 7px 9px;
}
.note.show { display: flex; }
.note .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; margin-top: 4px; }
.ctl { display: block; margin-bottom: 13px; }
.ctl:last-child { margin-bottom: 2px; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.ctl .desc { display: block; font-size: 11.5px; color: var(--muted); line-height: 1.35; margin-top: 2px; }
input[type=range] {
-webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
button {
font: inherit; font-size: 13px; font-family: var(--font-sans);
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; cursor: pointer; line-height: 1.2;
transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.big { font-size: 14px; padding: 9px 18px; }
.row { display: flex; flex-wrap: wrap; gap: 7px; }
.row > button { flex: 1 1 auto; }
.seg { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 7px; }
.seg button {
font-family: var(--font-mono); font-size: 12.5px; letter-spacing: .02em;
font-variant-numeric: tabular-nums; padding: 7px 4px;
}
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.cap { margin: 9px 0 0; font-size: 11.5px; line-height: 1.4; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.legend { display: flex; align-items: center; gap: 8px; margin-top: 11px; }
.legend .bar { flex: 1 1 auto; height: 6px; border-radius: 3px; background: var(--line); }
.legend .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
footer {
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .05em; padding-top: 2px;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>100k Particles</h1>
<p class="lede">
A hundred thousand specks of light, each one following the same two rules:
<b>fall toward the pointer</b>, and <b>ride a swirling flow</b> that keeps drifting
through the field. It runs on your graphics card through <code>WebGL</code>, so the
frame counter in the readout is real. <b>Move the pointer</b> to drag the swarm around;
<b>click</b> to set off a shockwave.
</p>
</header>
<main class="stage">
<div class="field" id="field">
<canvas id="glc" tabindex="0" aria-label="Particle field. Move the pointer to attract the particles, or use the arrow keys. Press Enter to set off a shockwave.">
An animated field of particles. Your browser does not support canvas.
</canvas>
<canvas id="c2d" hidden tabindex="0" aria-label="Particle field. Move the pointer to attract the particles, or use the arrow keys. Press Enter to set off a shockwave."></canvas>
<div class="hint" id="hint">move the pointer to pull · click to burst</div>
<div class="overlay" id="overlay" hidden>
<div class="box">
<button class="primary big" id="overlayPlay" type="button">▶ Play the simulation</button>
<p id="overlayNote">Paused.</p>
</div>
</div>
</div>
<div class="panel">
<section class="card" aria-label="Live readout">
<h2>Readout</h2>
<div class="stats">
<div class="stat"><span class="v" id="sFps">—</span><span class="k">fps</span></div>
<div class="stat"><span class="v" id="sMs">—</span><span class="k">sim ms</span></div>
<div class="stat"><span class="v" id="sN">100k</span><span class="k">particles</span></div>
</div>
<span class="pill" id="pill"><span class="dot"></span><span id="pillText">starting</span></span>
<div class="meta">
<span>renderer</span><span><b id="sRend">webgl</b></span>
</div>
<div class="meta">
<span>sim ms = js time to move every particle</span>
</div>
<div class="note" id="note"><span class="dot" aria-hidden="true"></span><span id="noteText"></span></div>
</section>
<section class="card" aria-label="Particle count">
<h2>Count</h2>
<div class="seg" id="seg" role="group" aria-label="Number of particles">
<button type="button" data-n="10000" aria-pressed="false" aria-label="10 thousand particles">10k</button>
<button type="button" data-n="50000" aria-pressed="false" aria-label="50 thousand particles">50k</button>
<button type="button" data-n="100000" aria-pressed="true" aria-label="100 thousand particles">100k</button>
</div>
<div class="legend">
<span class="lbl">slow</span>
<span class="bar" id="legendBar" aria-hidden="true"></span>
<span class="lbl">fast</span>
</div>
<p class="cap">Colour is speed: dim specks are drifting, <b>bright</b> ones are falling fast.</p>
</section>
<section class="card" aria-label="Forces">
<h2>Forces</h2>
<div class="ctl">
<div class="ctl-top"><label for="pull">Pull</label><span class="val" id="pullV" aria-hidden="true">×1.00</span></div>
<input type="range" id="pull" min="0" max="2" step="0.05" value="1">
<span class="desc">How hard the pointer drags everything toward it.</span>
</div>
<div class="ctl">
<div class="ctl-top"><label for="swirl">Swirl</label><span class="val" id="swirlV" aria-hidden="true">×1.00</span></div>
<input type="range" id="swirl" min="0" max="2" step="0.05" value="1">
<span class="desc">Strength of the drifting flow field. Set it to zero for a clean free-fall.</span>
</div>
</section>
<section class="card" aria-label="Controls">
<h2>Controls</h2>
<div class="row" style="margin-bottom:7px">
<button type="button" class="primary" id="playBtn">Pause</button>
<button type="button" id="burstBtn">Burst</button>
</div>
<div class="row">
<button type="button" id="resetBtn">Reset swarm</button>
</div>
</section>
</div>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
/* ── theme plumbing ─────────────────────────────────────────────────────── */
var root = document.documentElement;
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = !!(mqRM && mqRM.matches);
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
onThemeChange();
}
});
if (mqDark) {
var onScheme = function () { onThemeChange(); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* ── dom ────────────────────────────────────────────────────────────────── */
var field = document.getElementById("field");
var glc = document.getElementById("glc");
var c2d = document.getElementById("c2d");
var hint = document.getElementById("hint");
var overlay = document.getElementById("overlay");
var overlayPlay = document.getElementById("overlayPlay");
var overlayNote = document.getElementById("overlayNote");
var noteBox = document.getElementById("note");
var noteText= document.getElementById("noteText");
var sFps = document.getElementById("sFps");
var sMs = document.getElementById("sMs");
var sN = document.getElementById("sN");
var sRend = document.getElementById("sRend");
var pill = document.getElementById("pill");
var pillText = document.getElementById("pillText");
var legendBar= document.getElementById("legendBar");
var seg = document.getElementById("seg");
var pullEl = document.getElementById("pull");
var swirlEl= document.getElementById("swirl");
var pullV = document.getElementById("pullV");
var swirlV = document.getElementById("swirlV");
var playBtn = document.getElementById("playBtn");
var burstBtn= document.getElementById("burstBtn");
var resetBtn= document.getElementById("resetBtn");
var live = document.getElementById("live");
/* ── colour reading ─────────────────────────────────────────────────────── */
var COL = {
surface:[255,255,255], ink:[26,29,33], muted:[90,96,104],
accent:[192,86,0], line:[227,226,220]
};
var isLight = true;
var uLow = new Float32Array(3), uHigh = new Float32Array(3);
var pal2d = ["#000","#000","#000","#000","#000"];
var lineCol = "rgba(0,0,0,.3)";
function hexToRgb(h) {
h = String(h).trim();
if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
if (h.length !== 6) return null;
var n = parseInt(h, 16);
if (isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function tok(name, fb) {
var v = getComputedStyle(root).getPropertyValue(name);
return hexToRgb(v) || fb;
}
function mix(a, b, t) { return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t]; }
function rgba(c, a) { return "rgba(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + "," + a + ")"; }
function lum(c) { return (0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2]) / 255; }
function readTheme() {
COL.surface = tok("--surface", COL.surface);
COL.ink = tok("--ink", COL.ink);
COL.muted = tok("--muted", COL.muted);
COL.accent = tok("--accent", COL.accent);
COL.line = tok("--line", COL.line);
isLight = lum(COL.surface) > 0.5;
var lo, hi, i;
if (isLight) {
/* light: particles SUBTRACT from the page, so ink piles up amber then deep */
lo = mix(COL.surface, COL.accent, 0.92);
hi = mix(COL.accent, COL.ink, 0.72);
for (i = 0; i < 3; i++) {
uLow[i] = Math.max(0, (COL.surface[i] - lo[i]) / 255);
uHigh[i] = Math.max(0, (COL.surface[i] - hi[i]) / 255);
}
} else {
/* dark: additive glow, cores burn toward the warm paper colour */
lo = mix(COL.muted, COL.accent, 0.72);
hi = mix(COL.accent, COL.ink, 0.30);
for (i = 0; i < 3; i++) {
uLow[i] = (lo[i] / 255) * 0.45;
uHigh[i] = (hi[i] / 255) * 1.0;
}
}
/* canvas2d palette + constellation lines */
for (i = 0; i < 5; i++) {
var t = i / 4;
if (isLight) pal2d[i] = rgba(mix(mix(COL.surface, COL.accent, 0.85), mix(COL.accent, COL.ink, 0.6), t), (0.42 + 0.50 * t).toFixed(3));
else pal2d[i] = rgba(mix(mix(COL.muted, COL.accent, 0.6), mix(COL.accent, COL.ink, 0.6), t), (0.30 + 0.55 * t).toFixed(3));
}
lineCol = rgba(COL.accent, isLight ? 0.28 : 0.34);
legendBar.style.background = "linear-gradient(90deg," +
rgba(isLight ? mix(COL.surface, COL.accent, 0.55) : mix(COL.muted, COL.accent, 0.5), 1) + "," +
rgba(isLight ? mix(COL.accent, COL.ink, 0.6) : mix(COL.accent, COL.ink, 0.35), 1) + ")";
}
/* ── curl-noise flow field ──────────────────────────────────────────────── */
/* A divergence-free drift field: build a smooth periodic scalar potential psi
on a 96x96x24 grid (x, y, and time all wrap), then take its 2D curl,
v = (dpsi/dy, -dpsi/dx). Curl of a scalar field has zero divergence, so the
flow swirls without ever piling up or draining anywhere. */
var NG = 96, NZ = 24, NGG = NG * NG, NTOT = NGG * NZ;
var Fx = new Float32Array(NTOT), Fy = new Float32Array(NTOT);
var Wx = new Float32Array(NGG), Wy = new Float32Array(NGG);
function blurLine(src, dst, base, stride, len, r) {
var d = 2 * r + 1, inv = 1 / d, sum = 0, k, i;
for (k = -r; k <= r; k++) sum += src[base + (((k % len) + len) % len) * stride];
for (i = 0; i < len; i++) {
dst[base + i * stride] = sum * inv;
var ia = i + r + 1; if (ia >= len) ia -= len; /* r < len, so one wrap is enough */
var ib = i - r; if (ib < 0) ib += len;
sum += src[base + ia * stride] - src[base + ib * stride];
}
}
function blur3(A, T, r) {
var z, y, x;
for (z = 0; z < NZ; z++) for (y = 0; y < NG; y++) blurLine(A, T, (z * NG + y) * NG, 1, NG, r);
for (z = 0; z < NZ; z++) for (x = 0; x < NG; x++) blurLine(T, A, z * NGG + x, NG, NG, r);
for (y = 0; y < NG; y++) for (x = 0; x < NG; x++) blurLine(A, T, y * NG + x, NGG, NZ, Math.min(r, 8));
A.set(T);
}
function rms(A) {
var s = 0, i;
for (i = 0; i < A.length; i++) s += A[i] * A[i];
return Math.sqrt(s / A.length) || 1;
}
function buildField() {
var A = new Float32Array(NTOT), B = new Float32Array(NTOT), T = new Float32Array(NTOT), i;
for (i = 0; i < NTOT; i++) { A[i] = Math.random() * 2 - 1; B[i] = Math.random() * 2 - 1; }
blur3(A, T, 7); blur3(A, T, 5); /* broad swirls */
blur3(B, T, 3); blur3(B, T, 2); /* finer eddies */
var ra = 1 / rms(A), rb = 1 / rms(B);
for (i = 0; i < NTOT; i++) A[i] = A[i] * ra + B[i] * rb * 0.34;
/* curl by central differences, wrapping on x and y */
var z, y, x, o, xp, xm, yp, ym;
for (z = 0; z < NZ; z++) {
o = z * NGG;
for (y = 0; y < NG; y++) {
yp = ((y + 1) % NG) * NG; ym = ((y - 1 + NG) % NG) * NG;
for (x = 0; x < NG; x++) {
xp = (x + 1) % NG; xm = (x - 1 + NG) % NG;
Fx[o + y * NG + x] = (A[o + yp + x] - A[o + ym + x]) * 0.5;
Fy[o + y * NG + x] = -(A[o + y * NG + xp] - A[o + y * NG + xm]) * 0.5;
}
}
}
var s = 1 / Math.max(rms(Fx), rms(Fy));
for (i = 0; i < NTOT; i++) { Fx[i] *= s; Fy[i] *= s; }
Wx.set(Fx.subarray(0, NGG)); Wy.set(Fy.subarray(0, NGG));
}
/* ── simulation state ───────────────────────────────────────────────────── */
var MAXN = 100000;
var P = new Float32Array(MAXN * 2); /* position, world units */
var V = new Float32Array(MAXN * 2); /* velocity */
var R = new Float32Array(MAXN * 3); /* render feed: x, y, heat */
var N = 100000;
var MARGIN = 0.07; /* world overscan so wrapping is off-screen */
var PULL_K = 0.090, SOFT = 0.0050, SWIRL_K = 1.90, DAMP_K = 1.60, HREF2 = 0.90;
var ZRATE = 0.85; /* flow-field layers per second */
var PSPD = 0.95, PW = 0.075, PWINV = 1 / 0.075, PAMP = 3.1, PLIFE = 1.15, PMAX = 3;
var aspect = 1.6; /* world is aspect x 1 */
var pull = 1, swirl = 1;
var simT = 0;
var atX = 0.8, atY = 0.5, tgX = 0.8, tgY = 0.5;
var pointerMode = false, pointerTimer = 0;
var pulses = [];
var pX = new Float64Array(PMAX), pY = new Float64Array(PMAX), pR = new Float64Array(PMAX),
pA = new Float64Array(PMAX), pIn = new Float64Array(PMAX), pOut = new Float64Array(PMAX);
function seedRing(i0, i1) {
var cx = aspect * 0.5, cy = 0.5;
for (var i = i0; i < i1; i++) {
var ang = Math.random() * Math.PI * 2;
var rad = 0.09 + 0.34 * Math.sqrt(Math.random());
var x = cx + Math.cos(ang) * rad * 1.25, y = cy + Math.sin(ang) * rad;
var sp = 0.20 + Math.random() * 0.22;
P[i * 2] = x; P[i * 2 + 1] = y;
V[i * 2] = -Math.sin(ang) * sp * 1.1; V[i * 2 + 1] = Math.cos(ang) * sp;
R[i * 3] = x; R[i * 3 + 1] = y; R[i * 3 + 2] = 0.45;
}
}
function seedScatter(i0, i1) {
for (var i = i0; i < i1; i++) {
var x = -MARGIN + Math.random() * (aspect + 2 * MARGIN);
var y = -MARGIN + Math.random() * (1 + 2 * MARGIN);
var a = Math.random() * Math.PI * 2, sp = 0.25 + Math.random() * 0.25;
P[i * 2] = x; P[i * 2 + 1] = y;
V[i * 2] = Math.cos(a) * sp; V[i * 2 + 1] = Math.sin(a) * sp;
R[i * 3] = x; R[i * 3 + 1] = y; R[i * 3 + 2] = 0.3;
}
}
function addPulse(x, y) {
if (pulses.length >= PMAX) pulses.shift();
pulses.push({ x: x, y: y, t: 0 });
}
/* A burst fired while paused still gets to show itself: play the ring out
over real frames (never a blocking loop) and then settle back to paused. */
var burstRaf = 0, burstLeft = 0;
function burstPlay() {
burstRaf = 0;
if (running) return;
simT += 1 / 60; step(1 / 60); draw();
if (--burstLeft > 0) burstRaf = requestAnimationFrame(burstPlay);
}
function burstAt(x, y) {
if (!started) return false;
addPulse(x, y);
if (!running) {
burstLeft = 48; /* ~0.8s: long enough for the ring to cross */
if (!burstRaf) burstRaf = requestAnimationFrame(burstPlay);
}
return true;
}
function step(dt) {
var i, gg = NGG, G = NG;
/* blend the two nearest flow-field layers once per frame, not per particle */
var zf = (simT * ZRATE) % NZ; if (zf < 0) zf += NZ;
var z0 = zf | 0, z1 = (z0 + 1) % NZ, tz = zf - z0;
tz = tz * tz * (3 - 2 * tz);
var o0 = z0 * gg, o1 = z1 * gg;
for (i = 0; i < gg; i++) {
var ax0 = Fx[o0 + i]; Wx[i] = ax0 + (Fx[o1 + i] - ax0) * tz;
var ay0 = Fy[o0 + i]; Wy[i] = ay0 + (Fy[o1 + i] - ay0) * tz;
}
/* pulse rings: age them and pre-compute their annulus bounds */
var pn = 0;
for (i = 0; i < pulses.length; i++) {
var pu = pulses[i]; pu.t += dt;
if (pu.t >= PLIFE) { pulses.splice(i, 1); i--; continue; }
var r = PSPD * pu.t, rin = r - PW; if (rin < 0) rin = 0;
pX[pn] = pu.x; pY[pn] = pu.y; pR[pn] = r;
pA[pn] = PAMP * (1 - pu.t / PLIFE);
pIn[pn] = rin * rin; pOut[pn] = (r + PW) * (r + PW);
pn++;
}
var sw = SWIRL_K * swirl, pl = PULL_K * pull;
var damp = Math.exp(-DAMP_K * dt);
var tax = atX, tay = atY;
var xmin = -MARGIN, xspan = aspect + 2 * MARGIN;
var ymin = -MARGIN, yspan = 1 + 2 * MARGIN;
var n = N, k;
for (i = 0; i < n; i++) {
var i2 = i << 1;
var x = P[i2], y = P[i2 + 1], vx = V[i2], vy = V[i2 + 1];
/* bilinear sample of the curl field (the field tiles every 1 world unit) */
var u = x - Math.floor(x), v = y - Math.floor(y);
var fx = u * G, fy = v * G;
var x0 = fx | 0; if (x0 >= G) x0 = G - 1;
var y0 = fy | 0; if (y0 >= G) y0 = G - 1;
var sx = fx - x0, sy = fy - y0;
var x1 = x0 + 1; if (x1 === G) x1 = 0;
var y1 = y0 + 1; if (y1 === G) y1 = 0;
var r0 = y0 * G, r1 = y1 * G;
var q00 = Wx[r0 + x0], q10 = Wx[r0 + x1], q01 = Wx[r1 + x0], q11 = Wx[r1 + x1];
var e0 = q00 + (q10 - q00) * sx, e1 = q01 + (q11 - q01) * sx;
var cvx = e0 + (e1 - e0) * sy;
q00 = Wy[r0 + x0]; q10 = Wy[r0 + x1]; q01 = Wy[r1 + x0]; q11 = Wy[r1 + x1];
e0 = q00 + (q10 - q00) * sx; e1 = q01 + (q11 - q01) * sx;
var cvy = e0 + (e1 - e0) * sy;
vx += cvx * sw * dt; vy += cvy * sw * dt;
/* softened inverse-square pull toward the attractor */
var dx = tax - x, dy = tay - y;
var d2 = dx * dx + dy * dy + SOFT;
var g = pl / (d2 * Math.sqrt(d2)) * dt;
vx += dx * g; vy += dy * g;
/* expanding shockwave rings */
for (k = 0; k < pn; k++) {
var bx = x - pX[k], by = y - pY[k], b2 = bx * bx + by * by;
if (b2 > pOut[k] || b2 < pIn[k]) continue;
var bd = Math.sqrt(b2) + 1e-5;
var q = (bd - pR[k]) * PWINV; q = 1 - q * q;
if (q <= 0) continue;
var imp = pA[k] * q * q / (0.10 + bd) * dt;
vx += bx / bd * imp; vy += by / bd * imp;
}
vx *= damp; vy *= damp;
x += vx * dt; y += vy * dt;
if (x < xmin) x += xspan; else if (x >= xmin + xspan) x -= xspan;
if (y < ymin) y += yspan; else if (y >= ymin + yspan) y -= yspan;
P[i2] = x; P[i2 + 1] = y; V[i2] = vx; V[i2 + 1] = vy;
var sp2 = vx * vx + vy * vy;
var i3 = i * 3;
R[i3] = x; R[i3 + 1] = y; R[i3 + 2] = sp2 / (sp2 + HREF2);
}
}
/* ── renderers ──────────────────────────────────────────────────────────── */
var mode = "gl", gl = null, ctx2 = null, cv = glc;
var cssW = 800, cssH = 500, dpr = 1;
var ptProg = null, flProg = null, ptU = {}, flU = {};
var ptBuf = null, quadBuf = null, conPtBuf = null, conLnBuf = null;
var conPts = null, conLns = null;
var psMax = 64;
var VS_PT =
"attribute vec3 aPos;\n" +
"uniform vec2 uScale;\nuniform vec2 uOff;\nuniform float uSize;\n" +
"varying float vHeat;\n" +
"void main(){\n" +
" vHeat = aPos.z;\n" +
" gl_Position = vec4(aPos.xy * uScale + uOff, 0.0, 1.0);\n" +
" gl_PointSize = uSize * (0.72 + 0.85 * vHeat);\n" +
"}\n";
var FS_PT =
"precision mediump float;\n" +
"uniform vec3 uLow;\nuniform vec3 uHigh;\nuniform float uGain;\nuniform float uSharp;\n" +
"varying float vHeat;\n" +
"void main(){\n" +
" vec2 d = gl_PointCoord - 0.5;\n" +
" float r2 = dot(d, d);\n" +
" if (r2 > 0.25) discard;\n" +
" float f = pow(1.0 - r2 * 4.0, uSharp);\n" +
" vec3 c = mix(uLow, uHigh, vHeat) * (f * uGain);\n" +
" gl_FragColor = vec4(c, 1.0);\n" +
"}\n";
var VS_FL =
"attribute vec2 aXY;\nuniform vec2 uScale;\nuniform vec2 uOff;\n" +
"void main(){ gl_Position = vec4(aXY * uScale + uOff, 0.0, 1.0); }\n";
var FS_FL =
"precision mediump float;\nuniform vec4 uColor;\n" +
"void main(){ gl_FragColor = uColor; }\n";
function makeProg(g, vs, fs, attrName) {
var v = g.createShader(g.VERTEX_SHADER), f = g.createShader(g.FRAGMENT_SHADER);
g.shaderSource(v, vs); g.compileShader(v);
if (!g.getShaderParameter(v, g.COMPILE_STATUS)) return null;
g.shaderSource(f, fs); g.compileShader(f);
if (!g.getShaderParameter(f, g.COMPILE_STATUS)) return null;
var p = g.createProgram();
g.attachShader(p, v); g.attachShader(p, f);
g.bindAttribLocation(p, 0, attrName);
g.linkProgram(p);
if (!g.getProgramParameter(p, g.LINK_STATUS)) return null;
g.deleteShader(v); g.deleteShader(f);
return p;
}
function initGL() {
if (location.search.indexOf("renderer=2d") >= 0) return false;
var opts = { alpha: false, antialias: false, depth: false, stencil: false,
preserveDrawingBuffer: true, powerPreference: "high-performance" };
var g = null;
try { g = glc.getContext("webgl", opts) || glc.getContext("experimental-webgl", opts); } catch (e) { g = null; }
if (!g) return false;
ptProg = makeProg(g, VS_PT, FS_PT, "aPos");
flProg = makeProg(g, VS_FL, FS_FL, "aXY");
if (!ptProg || !flProg) return false;
ptU.scale = g.getUniformLocation(ptProg, "uScale");
ptU.off = g.getUniformLocation(ptProg, "uOff");
ptU.size = g.getUniformLocation(ptProg, "uSize");
ptU.low = g.getUniformLocation(ptProg, "uLow");
ptU.high = g.getUniformLocation(ptProg, "uHigh");
ptU.gain = g.getUniformLocation(ptProg, "uGain");
ptU.sharp = g.getUniformLocation(ptProg, "uSharp");
flU.scale = g.getUniformLocation(flProg, "uScale");
flU.off = g.getUniformLocation(flProg, "uOff");
flU.color = g.getUniformLocation(flProg, "uColor");
ptBuf = g.createBuffer();
g.bindBuffer(g.ARRAY_BUFFER, ptBuf);
g.bufferData(g.ARRAY_BUFFER, MAXN * 3 * 4, g.DYNAMIC_DRAW);
quadBuf = g.createBuffer();
g.bindBuffer(g.ARRAY_BUFFER, quadBuf);
g.bufferData(g.ARRAY_BUFFER, new Float32Array([0,0, 1,0, 0,1, 1,1]), g.STATIC_DRAW);
conPtBuf = g.createBuffer();
conLnBuf = g.createBuffer();
g.enableVertexAttribArray(0);
g.disable(g.DEPTH_TEST);
g.enable(g.BLEND);
var pr = g.getParameter(g.ALIASED_POINT_SIZE_RANGE);
if (pr && pr.length === 2 && pr[1] > 1) psMax = pr[1];
glc.addEventListener("webglcontextlost", function (ev) {
ev.preventDefault();
if (mode !== "gl") return;
stop();
switchTo2D("The graphics context dropped out, so this switched to the 2,000-particle canvas fallback.");
if (wantRun) start(); else drawStill();
}, false);
gl = g;
return true;
}
function switchTo2D(msg) {
mode = "2d"; gl = null;
glc.hidden = true; c2d.hidden = false; cv = c2d;
ctx2 = c2d.getContext("2d");
N = 2000;
var bs = seg.getElementsByTagName("button"), i;
for (i = 0; i < bs.length; i++) { bs[i].disabled = true; bs[i].setAttribute("aria-pressed", "false"); }
seg.setAttribute("aria-label", "Number of particles \u2014 locked to 2,000 in the canvas fallback");
sRend.textContent = "canvas 2d";
sN.textContent = "2k";
noteText.textContent = msg;
noteBox.className = "note show";
seedRing(0, N);
resize();
}
/* Exposure. Each frame every particle deposits light (dark: additive) or ink
(light: subtractive), and the fade quad decays what is already there, so the
steady-state brightness of a pixel is deposit/fade. Deposit scales with
n * pointArea / canvasArea, which means a fixed gain would blow out on a
small canvas and vanish on a big one. Solve for gain instead:
gain = EXPO * fade * canvasArea / (n * size^2)
Device pixel ratio cancels (both areas scale with dpr^2), so the picture
looks the same on a phone and on a 4K monitor. */
var EXPO_DARK = 3.60, EXPO_LIGHT = 1.95;
function sizeFor(n) { return n >= 100000 ? 2.0 : (n >= 50000 ? 2.5 : 3.6); }
/* Fade only sets how long the trail lives: because gain carries a factor of
fade, the steady-state exposure is fade-invariant. Light needs the longer
trail - loose specks of ink on white paper read as dust, a streak reads as
a stroke - and a slightly softer nib for the same reason. */
function fadeFor() { return isLight ? 0.17 : 0.11; }
function sharpFor() { return isLight ? 2.8 : 2.0; }
function gainFor(n) {
var s = sizeFor(n);
var e = (cssW * cssH) / (n * s * s);
var g = (isLight ? EXPO_LIGHT : EXPO_DARK) * fadeFor() * e;
return Math.max(0.03, Math.min(2.4, g));
}
function resize() {
/* the canvas fills the field's CONTENT box - the 1px border is not ours */
var w0 = field.clientWidth, h0 = field.clientHeight;
if (w0 < 2 || h0 < 2) return;
cssW = w0;
cssH = h0;
aspect = cssW / cssH;
var want = Math.min(window.devicePixelRatio || 1, mode === "2d" ? 2 : (N >= 100000 ? 1.5 : 2));
dpr = Math.max(1, want);
var w = Math.round(cssW * dpr), h = Math.round(cssH * dpr);
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
if (mode === "gl" && gl) { gl.viewport(0, 0, w, h); clearGL(); }
buildConstellation();
}
function clearGL() {
if (!gl) return;
gl.clearColor(COL.surface[0] / 255, COL.surface[1] / 255, COL.surface[2] / 255, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
}
function drawGL() {
var w = glc.width, h = glc.height;
gl.viewport(0, 0, w, h);
/* fade the previous frame toward the panel colour: that is the trail */
gl.blendEquation(gl.FUNC_ADD);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(flProg);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(flU.scale, 2, -2);
gl.uniform2f(flU.off, -1, 1);
gl.uniform4f(flU.color, COL.surface[0]/255, COL.surface[1]/255, COL.surface[2]/255, fadeFor());
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
/* particles: additive glow on dark, subtractive ink on light */
gl.blendEquation(isLight ? gl.FUNC_REVERSE_SUBTRACT : gl.FUNC_ADD);
gl.blendFunc(gl.ONE, gl.ONE);
gl.useProgram(ptProg);
gl.bindBuffer(gl.ARRAY_BUFFER, ptBuf);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, R.subarray(0, N * 3));
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 12, 0);
gl.uniform2f(ptU.scale, 2 / aspect, -2);
gl.uniform2f(ptU.off, -1, 1);
gl.uniform1f(ptU.size, Math.min(psMax, sizeFor(N) * dpr));
gl.uniform1f(ptU.gain, gainFor(N));
gl.uniform1f(ptU.sharp, sharpFor());
gl.uniform3fv(ptU.low, uLow);
gl.uniform3fv(ptU.high, uHigh);
gl.drawArrays(gl.POINTS, 0, N);
}
function draw2D() {
var w = c2d.width, h = c2d.height, s = h;
ctx2.globalCompositeOperation = "source-over";
ctx2.fillStyle = rgba(COL.surface, fadeFor() + 0.06);
ctx2.fillRect(0, 0, w, h);
ctx2.globalCompositeOperation = isLight ? "multiply" : "lighter";
var q = Math.max(2, Math.round((isLight ? 2.9 : 2.2) * dpr));
for (var b = 0; b < 5; b++) {
ctx2.fillStyle = pal2d[b];
for (var i = 0; i < N; i++) {
var i3 = i * 3, hb = R[i3 + 2] * 5 | 0; if (hb > 4) hb = 4;
if (hb !== b) continue;
ctx2.fillRect(R[i3] * s, R[i3 + 1] * s, q, q);
}
}
ctx2.globalCompositeOperation = "source-over";
}
function draw() { if (mode === "gl") drawGL(); else draw2D(); }
/* ── still constellation (reduced motion / paused first paint) ───────────── */
function buildConstellation() {
var n = Math.max(220, Math.min(560, Math.round(300 * aspect + 120)));
var xs = new Float64Array(n), ys = new Float64Array(n), deg = new Int32Array(n);
var i, j;
for (i = 0; i < n; i++) { xs[i] = Math.random() * aspect; ys[i] = Math.random(); }
var segs = [], rr = 0.10 * 0.10;
for (i = 0; i < n; i++) {
for (j = i + 1; j < n; j++) {
if (deg[i] >= 3) break;
if (deg[j] >= 3) continue;
var dx = xs[i] - xs[j], dy = ys[i] - ys[j];
if (dx * dx + dy * dy < rr) { segs.push(i, j); deg[i]++; deg[j]++; }
}
}
conPts = new Float32Array(n * 3);
for (i = 0; i < n; i++) { conPts[i*3] = xs[i]; conPts[i*3+1] = ys[i]; conPts[i*3+2] = 0.25 + Math.random() * 0.5; }
conLns = new Float32Array(segs.length * 2);
for (i = 0; i < segs.length; i++) { conLns[i*2] = xs[segs[i]]; conLns[i*2+1] = ys[segs[i]]; }
if (mode === "gl" && gl) {
gl.bindBuffer(gl.ARRAY_BUFFER, conPtBuf); gl.bufferData(gl.ARRAY_BUFFER, conPts, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, conLnBuf); gl.bufferData(gl.ARRAY_BUFFER, conLns, gl.STATIC_DRAW);
}
}
function drawStill() {
if (!conPts || !conLns) buildConstellation(); /* resize() bails on a 0-size field */
if (mode === "gl" && gl) {
clearGL();
gl.viewport(0, 0, glc.width, glc.height);
gl.blendEquation(gl.FUNC_ADD);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(flProg);
gl.bindBuffer(gl.ARRAY_BUFFER, conLnBuf);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(flU.scale, 2 / aspect, -2);
gl.uniform2f(flU.off, -1, 1);
gl.uniform4f(flU.color, COL.accent[0]/255, COL.accent[1]/255, COL.accent[2]/255, isLight ? 0.30 : 0.36);
gl.drawArrays(gl.LINES, 0, conLns.length / 2);
gl.blendEquation(isLight ? gl.FUNC_REVERSE_SUBTRACT : gl.FUNC_ADD);
gl.blendFunc(gl.ONE, gl.ONE);
gl.useProgram(ptProg);
gl.bindBuffer(gl.ARRAY_BUFFER, conPtBuf);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 12, 0);
gl.uniform2f(ptU.scale, 2 / aspect, -2);
gl.uniform2f(ptU.off, -1, 1);
gl.uniform1f(ptU.size, Math.min(psMax, 5.5 * dpr));
gl.uniform1f(ptU.gain, isLight ? 1.3 : 1.6);
gl.uniform1f(ptU.sharp, 2.0);
gl.uniform3fv(ptU.low, uLow);
gl.uniform3fv(ptU.high, uHigh);
gl.drawArrays(gl.POINTS, 0, conPts.length / 3);
} else if (ctx2) {
var w = c2d.width, h = c2d.height, s = h, i;
ctx2.globalCompositeOperation = "source-over";
ctx2.fillStyle = rgba(COL.surface, 1);
ctx2.fillRect(0, 0, w, h);
ctx2.strokeStyle = lineCol;
ctx2.lineWidth = Math.max(1, dpr * 0.9);
ctx2.beginPath();
for (i = 0; i < conLns.length; i += 4) {
ctx2.moveTo(conLns[i] * s, conLns[i+1] * s);
ctx2.lineTo(conLns[i+2] * s, conLns[i+3] * s);
}
ctx2.stroke();
ctx2.fillStyle = pal2d[4];
var q = Math.max(3, Math.round(3.2 * dpr));
for (i = 0; i < conPts.length; i += 3) {
ctx2.fillRect(conPts[i] * s - q / 2, conPts[i+1] * s - q / 2, q, q);
}
}
}
/* ── loop ───────────────────────────────────────────────────────────────── */
var raf = 0, running = false, wantRun = false, started = false;
var lastT = 0, fpsFrames = 0, fpsT0 = 0, msAvg = 0;
function frame(ts) {
raf = requestAnimationFrame(frame);
var dt = (ts - lastT) / 1000;
lastT = ts;
if (!(dt > 0)) dt = 1 / 60;
if (dt > 0.05) dt = 0.05;
simT += dt;
if (!pointerMode) {
tgX = aspect * 0.5 + Math.cos(simT * 0.33) * aspect * 0.29;
tgY = 0.5 + Math.sin(simT * 0.51) * 0.30;
}
var s = 1 - Math.exp(-9 * dt);
atX += (tgX - atX) * s; atY += (tgY - atY) * s;
var t0 = performance.now();
step(dt);
var stepMs = performance.now() - t0; /* sim only - draw() is the GPU's problem */
draw();
msAvg = msAvg ? msAvg + (stepMs - msAvg) * 0.12 : stepMs;
fpsFrames++;
if (ts - fpsT0 >= 500) {
var f = fpsFrames * 1000 / (ts - fpsT0);
sFps.textContent = String(Math.round(f));
sMs.textContent = msAvg.toFixed(1);
fpsFrames = 0; fpsT0 = ts;
}
}
function start() {
if (running) return;
if (!started) {
started = true;
seedRing(0, N);
if (mode === "gl") clearGL();
pulses.length = 0;
}
running = true;
if (burstRaf) cancelAnimationFrame(burstRaf);
burstRaf = 0; burstLeft = 0;
lastT = performance.now(); fpsT0 = lastT; fpsFrames = 0;
raf = requestAnimationFrame(frame);
overlay.hidden = true;
armHint();
playBtn.textContent = "Pause";
setPill(true, "running");
}
function stop() {
running = false;
if (raf) cancelAnimationFrame(raf);
raf = 0;
if (burstRaf) cancelAnimationFrame(burstRaf);
burstRaf = 0; burstLeft = 0;
}
function pauseByUser() {
wantRun = false;
stop();
playBtn.textContent = "Play";
overlayNote.textContent = "Paused. The swarm is holding exactly where you left it.";
overlay.hidden = false;
setPill(false, "paused");
sFps.textContent = "0";
}
function setPill(on, txt) {
pill.className = on ? "pill on" : "pill";
pillText.textContent = txt;
}
var sayTimer = 0;
function say(msg) {
clearTimeout(sayTimer);
sayTimer = setTimeout(function () { live.textContent = msg; }, 140);
}
/* ── pointer + keyboard ─────────────────────────────────────────────────── */
function toWorld(clientX, clientY) {
var r = field.getBoundingClientRect();
var h = Math.max(1, field.clientHeight || r.height);
return [(clientX - r.left - field.clientLeft) / h, (clientY - r.top - field.clientTop) / h];
}
function markPointer() {
pointerMode = true;
clearTimeout(pointerTimer);
pointerTimer = setTimeout(function () { pointerMode = false; }, 2600);
}
var hintArmed = false;
function armHint() { /* first play reveals it, wherever we started */
if (hintArmed) return;
hintArmed = true;
hint.hidden = false; hint.className = "hint";
setTimeout(dismissHint, 8000);
}
function dismissHint() {
if (hint.hidden || hint.className === "hint gone") return;
hint.className = "hint gone";
setTimeout(function () { hint.hidden = true; }, 600);
}
field.addEventListener("pointermove", function (e) {
var w = toWorld(e.clientX, e.clientY);
tgX = w[0]; tgY = w[1];
markPointer(); dismissHint();
});
field.addEventListener("pointerdown", function (e) {
var w = toWorld(e.clientX, e.clientY);
tgX = w[0]; tgY = w[1];
markPointer(); dismissHint();
burstAt(w[0], w[1]);
});
field.addEventListener("pointerleave", function () {
clearTimeout(pointerTimer);
pointerTimer = setTimeout(function () { pointerMode = false; }, 600);
});
function onKey(e) {
var k = e.key, d = 0.055, hit = true;
if (k === "ArrowLeft") tgX -= d;
else if (k === "ArrowRight") tgX += d;
else if (k === "ArrowUp") tgY -= d;
else if (k === "ArrowDown") tgY += d;
else if (k === "Enter" || k === " " || k === "Spacebar") {
if (burstAt(atX, atY)) say("Shockwave released.");
} else hit = false;
if (!hit) return;
e.preventDefault();
tgX = Math.max(0, Math.min(aspect, tgX));
tgY = Math.max(0, Math.min(1, tgY));
markPointer(); dismissHint();
}
glc.addEventListener("keydown", onKey);
c2d.addEventListener("keydown", onKey);
/* ── controls ───────────────────────────────────────────────────────────── */
function setCount(n) {
if (mode !== "gl" || n === N) return;
if (n > N) seedScatter(N, n);
N = n;
sN.textContent = Math.round(n / 1000) + "k";
var bs = seg.getElementsByTagName("button"), i;
for (i = 0; i < bs.length; i++) bs[i].setAttribute("aria-pressed", (+bs[i].getAttribute("data-n") === n) ? "true" : "false");
resize();
if (!running) { if (started) draw(); else drawStill(); }
say(n.toLocaleString() + " particles.");
}
seg.addEventListener("click", function (e) {
var b = e.target;
while (b && b !== seg && b.tagName !== "BUTTON") b = b.parentNode;
if (!b || b === seg || b.disabled) return;
setCount(+b.getAttribute("data-n"));
});
function syncPull() { pull = +pullEl.value; pullV.textContent = "×" + pull.toFixed(2); }
function syncSwirl() { swirl = +swirlEl.value; swirlV.textContent = "×" + swirl.toFixed(2); }
pullEl.addEventListener("input", function () { syncPull(); if (!running && started) { step(0.0001); draw(); } });
swirlEl.addEventListener("input", function () { syncSwirl(); if (!running && started) { step(0.0001); draw(); } });
playBtn.addEventListener("click", function () {
if (running) { pauseByUser(); say("Paused."); }
else { wantRun = true; start(); say("Running."); }
});
overlayPlay.addEventListener("click", function () {
wantRun = true; start(); say("Running.");
(glc.hidden ? c2d : glc).focus();
});
burstBtn.addEventListener("click", function () {
if (burstAt(atX, atY)) say("Shockwave released.");
else say("Press play first, then burst.");
});
resetBtn.addEventListener("click", function () {
if (!started) { /* still showing the constellation */
buildConstellation();
drawStill();
say("New constellation.");
return;
}
pulses.length = 0; burstLeft = 0;
seedRing(0, N);
atX = tgX = aspect * 0.5; atY = tgY = 0.5;
if (mode === "gl") clearGL();
if (!running) draw();
say("Swarm reset.");
});
/* ── theme + lifecycle ──────────────────────────────────────────────────── */
function onThemeChange() {
readTheme();
if (mode === "gl" && gl) clearGL();
if (!running) { if (started) draw(); else drawStill(); }
}
document.addEventListener("visibilitychange", function () {
if (document.hidden) stop();
else if (wantRun && !running) { lastT = performance.now(); fpsT0 = lastT; fpsFrames = 0; running = true; raf = requestAnimationFrame(frame); }
});
var rzTimer = 0;
function onResize() {
clearTimeout(rzTimer);
rzTimer = setTimeout(function () {
resize();
if (!running) { if (started) draw(); else drawStill(); }
}, 90);
}
if (window.ResizeObserver) new ResizeObserver(onResize).observe(field);
window.addEventListener("resize", onResize);
/* ── boot ───────────────────────────────────────────────────────────────── */
readTheme();
buildField();
if (!initGL()) {
switchTo2D("WebGL could not start here, so this is the 2,000-particle canvas fallback — same physics, fewer specks.");
} else {
sRend.textContent = "webgl";
resize();
var startN = (cssW < 430 || (window.devicePixelRatio || 1) > 2.4) ? 50000 : 100000;
N = startN;
sN.textContent = Math.round(N / 1000) + "k";
var bs0 = seg.getElementsByTagName("button");
for (var bi = 0; bi < bs0.length; bi++) bs0[bi].setAttribute("aria-pressed", (+bs0[bi].getAttribute("data-n") === N) ? "true" : "false");
resize();
seedRing(0, N);
}
syncPull(); syncSwirl();
atX = tgX = aspect * 0.5; atY = tgY = 0.5;
if (reduced) {
wantRun = false;
started = false;
hint.hidden = true;
overlayNote.textContent = "Your system asks for reduced motion, so this opens as a still constellation. Press play for the live " + N.toLocaleString() + "-particle field.";
overlay.hidden = false;
playBtn.textContent = "Play";
setPill(false, "still");
sFps.textContent = "0";
sMs.textContent = "0.0";
drawStill();
} else {
wantRun = true;
start();
}
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.