docs / architecture
ARCHITECTURE
the shared runtime
Each effect is a small module with four functions. The runtime gives it a container, one shared pointer, and a stable frame loop, and it releases all resources when the effect or the page goes away.
# Effect contract
An effect is a plain object with four functions and one number. It does not know about React, about the HUD, or about the other effects.
export interface EffectInit {
container: HTMLElement;
pointer: Pointer;
width: number;
height: number;
dpr: number;
reduced: boolean;
}
// Contract that every module in ./effects implements.
export interface Effect {
init(opts: EffectInit): void;
update(dt: number, t: number): void;
resize(width: number, height: number, dpr: number): void;
dispose(): void;
count?: number;
}initgets a container element. The effect makes its own canvas (or canvases) and adds them to the container. It must not draw anywhere else.update(dt, t)runs one time for each frame.dtis the time step in seconds, andtis the simulation time. The effect does the physics and the drawing in this one call.resizegets the new viewport size in CSS px and the device pixel ratio.disposeremoves the canvases and releases GPU resources. The three WebGL effects also force a context loss here, because browsers limit the number of live WebGL contexts.countis the number of simulated elements, for the statistics.
Each effect module is a singleton: export default { … } with its state on this. The sequence init → dispose → init on the same object must work, and it does, because init makes all state again.
# Shared pointer
All effects read one pointer object. It gives them the same view of the mouse, so an effect has no event listeners of its own.
| field | meaning |
|---|---|
x, y | The effective pointer in CSS px. This is the real mouse, or the autopilot when the visitor is idle. |
sx, sy | A smoothed position (low-pass filter). Effects that must not jitter use this. |
vx, vy, speed | Smoothed velocity in px/s, and its length. |
down, downTime | Button state, and the time of the last press. |
idle | True while the autopilot moves the pointer. |
rx, ry | The last real mouse position. |
Autopilot
A background that freezes when the mouse stops looks broken. After 2.5 s with no pointermove, the pointer starts to follow a slow Lissajous path around a point a little above the screen centre. The change is eased over the next 2.5 s: k goes from 0 to 1, and the target is a blend of the path and the last real position. The first real movement sets x, y back to the mouse immediately.
if (since > IDLE_MS) {
// Autopilot: slow Lissajous drift around the centre of the screen.
const k = Math.min(1, (since - IDLE_MS) / 2500); // ease in over 2.5s
const cx = innerWidth * 0.5, cy = innerHeight * 0.45;
const ax = innerWidth * 0.32, ay = innerHeight * 0.28;
const tx = cx + Math.sin(t * 0.37) * ax * Math.cos(t * 0.11);
const ty = cy + Math.sin(t * 0.53 + 1.3) * ay;
p.x += ((tx - p.x) * k + (p.rx - p.x) * (1 - k)) * Math.min(1, dt * 3);
p.y += ((ty - p.y) * k + (p.ry - p.y) * (1 - k)) * Math.min(1, dt * 3);
p.idle = true;
} else {
p.x = p.rx; p.y = p.ry; p.idle = false;
}Velocity and smoothing
The raw velocity is the position difference divided by dt. A tab switch or a window focus change can move the pointer across the full screen in one frame, which is a velocity of 100,000 px/s or more. Effects that add pointer velocity to their physics (fluid, gravity-well, ascii) would explode, so the raw value is limited to 5000 px/s before the filter.
The two filters use 1 − e^(−dt · rate) as the blend factor. This gives the same response time at all frame rates, which a fixed factor such as 0.2 does not.
// velocity
if (dt > 0) {
let ivx = (p.x - lx) / dt, ivy = (p.y - ly) / dt;
const isp = Math.hypot(ivx, ivy); if (isp > 5000) { ivx *= 5000 / isp; ivy *= 5000 / isp; }
const a = 1 - Math.exp(-dt * 12);
p.vx += (ivx - p.vx) * a; p.vy += (ivy - p.vy) * a;
}
lx = p.x; ly = p.y;
p.speed = Math.hypot(p.vx, p.vy);
// smoothed position
const s = 1 - Math.exp(-dt * 10);
p.sx += (p.x - p.sx) * s; p.sy += (p.y - p.sy) * s;# Frame loop
One requestAnimationFrame loop drives the pointer and the active effect. It is outside React: no component renders for each frame. React state changes only when the effect changes, and two times for each second for the statistics.
// ---------- loop ----------
let last = performance.now(), t = 0, fpsN = 0, hudT = 0, running = true, raf = 0;
document.addEventListener('visibilitychange', () => {
running = document.visibilityState === 'visible';
cancelAnimationFrame(raf);
if (running) { last = performance.now(); raf = requestAnimationFrame(frame); }
}, { signal });
function frame(now: number) {
if (!running || destroyed) return;
raf = requestAnimationFrame(frame);
let dt = (now - last) / 1000; last = now;
if (dt > 0.05) dt = 0.05; // clamp big hitches so physics stays stable
t += dt;
pointer.update(dt, t);
if (current && !loading) {
const t0 = performance.now();
current.update(dt, t);
const ms = performance.now() - t0;
fpsN++;
hudT += dt;
if (hudT > 0.5) {
onStats?.({ fps: Math.round(fpsN / hudT), ms, count: current.count || null });
fpsN = 0; hudT = 0;
}
}
}
raf = requestAnimationFrame(frame);- Time step limit.
dtis limited to 0.05 s. After a long frame (garbage collection, a slow shader compile), an unlimiteddtwould put a large step into each physics integrator, and spring and gravity systems become unstable with large steps. Most effects limit their own step again to 1/30 s. - Pause while hidden. Browsers stop
requestAnimationFramein hidden tabs, but they give a very largedton the first frame after that. The loop thus stops onvisibilitychange, and it setslastto the current time before it starts again, so the first step is a normal one. - Statistics.
msis the CPU time ofupdate()only. For the WebGL effects this is near zero, because the GPU does the work after the call returns. The frame rate is the more useful number for those effects.
# Selection and on-demand loading
The catalogue (catalogue.ts) is plain data. The registry adds one literal import() for each effect, so the bundler makes one chunk for each effect, and one more for three.js. A visitor who looks only at matrix never downloads three.js.
async function select(id: string) {
const def = registry.find((d) => d.id === id) || registry[0];
if (destroyed || loading || (currentDef && currentDef.id === def.id)) return;
loading = true;
try {
const mod = await def.load();
// The host can unmount while the import is in flight (React Strict Mode does this).
if (destroyed) return;
if (current) { try { current.dispose(); } catch (e) { console.warn(e); } }
container.replaceChildren();
current = null;
currentDef = def;
mod.default.init({ container, pointer, width, height, dpr, reduced });
current = mod.default;
if (!fixed) location.hash = def.id;
onChange?.(def);
} catch (err) {
console.error(err);
onError?.(err instanceof Error ? err.message : String(err));
} finally {
loading = false;
}
}Three details in select prevent defects that are hard to find:
- The
loadingflag drops a second selection while an import is in flight. Without it, two fast key presses can start two effects. destroyedis examined again after theawait. React Strict Mode mounts, unmounts, and mounts again in development. The import of the first mount can resolve after its controller is destroyed, and it must not callinitthen.- The previous effect is disposed, and the container is emptied, before the new
init. Ifinitthrows, there is no current effect, the error text goes to the UI, and the next selection works.
# Resize and pixel ratio
Resize events arrive many times for each second while the visitor drags a window edge. A resize makes new canvases or render targets, which is expensive, so the controller waits until 60 ms pass with no new event.
The controller limits the device pixel ratio to 2. Each effect can apply a lower limit: the WebGL effects use 1.5, and nebula also limits its internal width, because its cost is for each pixel.
const IDLE_MS = 2500- controls
- Time with no pointer movement before the autopilot starts.
- why
- Shorter, and the autopilot starts while a visitor reads and holds the mouse still. Longer, and the background looks frozen.
if (isp > 5000)- controls
- Largest raw pointer velocity, in px/s.
- why
- A fast real movement is approximately 3000 px/s. Values above 5000 come from focus changes, not from a hand.
Math.exp(-dt * 12)- controls
- Rate of the velocity low-pass filter, in 1/s.
- why
- A time constant of approximately 80 ms: fast enough that a flick still reads as a flick, slow enough to remove the noise of single pointer events.
Math.exp(-dt * 10)- controls
- Rate of the smoothed position sx, sy.
- why
- A little slower than the velocity filter, so that objects that follow sx, sy trail the cursor visibly.
if (dt > 0.05) dt = 0.05;- controls
- Largest dt that an effect receives, in seconds.
- why
- 0.05 s is 20 fps. Below that frame rate the simulation runs slower than real time, which is better than an unstable simulation.
}, 60);- controls
- Quiet time in ms before resize() is called.
- why
- Longer than one frame, and short enough that the visitor does not see a stretched canvas after the drag ends.
Math.min(devicePixelRatio || 1, 2)- controls
- Largest device pixel ratio that effects receive.
- why
- A ratio of 3 has 2.25 times the pixels of a ratio of 2, and the difference is not visible in soft, glowing images.
if (hudT > 0.5)- controls
- Time between statistics updates, in seconds.
- why
- Numbers that change on each frame cannot be read, and each update is a React render.
# Reduced motion
The controller reads prefers-reduced-motion: reduce one time and gives the result to init as reduced. What an effect does with it is the decision of the effect:
| effect | what reduced changes |
|---|---|
gravity-well | time step × 0.5 |
matrix | time step × 0.6 |
orbits | time step × 0.6 |
ascii | time step × 0.5 |
warp | base star speed 2 and not 5 |
fluid, tendrils, nebula | nothing (the flag is not read in update) |
The docs pages go further than the showcase: with reduced motion set, the background of a docs page does not start until the visitor starts it.
# Teardown
The first version of this project was a static page that never unmounted, and it had no teardown. In a React app the runtime must be able to go away fully: on navigation, in Strict Mode, and on Fast Refresh.
function destroy() {
if (destroyed) return;
destroyed = true;
cancelAnimationFrame(raf);
clearTimeout(resizeT);
listeners.abort();
pointer.dispose();
if (current) { try { current.dispose(); } catch (e) { console.warn(e); } }
current = null;
container.replaceChildren();
if (window.mousefx === api) delete window.mousefx;
}All listeners of the controller share one AbortController signal, and the pointer has its own. One abort() removes them all, so a new listener cannot be forgotten in the cleanup.
The controller lifetime is bound to the container node, not to a component. The #fx element has a ref callback that makes the controller and returns destroy as its cleanup. An earlier version made the controller in a useEffect of a parent component. A Fast Refresh then replaced the #fx node but did not run that effect again, and the controller drew into a node that was no longer in the document. With the ref callback, this condition cannot occur.
# Showcase mode and fixed-effect mode
| showcase mode | fixed-effect mode | |
|---|---|---|
| used by | the showcase at / | the background of each effect docs page |
keys 1–8, Space, arrows, h | select effects, hide the UI | not captured (Space scrolls the page) |
| URL hash | read and written (#fluid) | not touched (it is a heading anchor) |
window.mousefx | set | not set |
| pointer, frame loop, resize, statistics, teardown | the same code | the same code |
The two modes are one createController function with a mode option. A second, smaller runtime for the docs would mean two frame loops and two teardown paths to keep correct.
# Embed one effect
The modules in src/lib/mousefx have no React dependency. To put one effect behind a different site, copy that directory (the three.js effects need three@0.160), make a fixed full-viewport container with pointer-events: none, and drive the effect with your own loop:
import { createPointer } from './mousefx/pointer';
import fx from './mousefx/effects/fluid.js';
const container = document.getElementById('fx')!;
const pointer = createPointer();
fx.init({ container, pointer, width: innerWidth, height: innerHeight, dpr: devicePixelRatio, reduced: false });
let last = performance.now(), t = 0, raf = 0;
function frame(now: number) {
raf = requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
pointer.update(dt, t);
fx.update(dt, t);
}
raf = requestAnimationFrame(frame);
const onResize = () => fx.resize(innerWidth, innerHeight, devicePixelRatio);
addEventListener('resize', onResize);
// when the page or the component goes away:
function stop() {
cancelAnimationFrame(raf);
removeEventListener('resize', onResize);
pointer.dispose();
fx.dispose();
}
Or use createController({ container, mode: 'fixed', effectId: 'fluid' }), which adds the pause while hidden, the resize debounce, and the statistics, and returns destroy().