Spring Lattice Field
A grid that behaves like a stretched sheet. A coarse lattice of damped springs runs on the CPU, is packed into an RGBA8 texture once per frame, and the fragment shader reads that texture with LINEAR filtering to displace its own sample coordinate. The pointer pushes cells outward and drags them along with its velocity; diffusion between neighbours turns each push into a wave that spreads and settles.
Spring Lattice Field
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/spring-lattice-field.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Spring Lattice Field
A grid that behaves like a stretched sheet. A coarse lattice of damped springs runs on the CPU, is packed into an RGBA8 texture once per frame, and the fragment shader reads that texture with LINEAR filtering to displace its own sample coordinate. The pointer pushes cells outward and drags them along with its velocity; diffusion between neighbours turns each push into a wave that spreads and settles.
Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.
## What it does
A CPU damped-spring lattice uploaded as a texture each frame, displacing a procedural grid the cursor can push around.
- Category: webgl
- Runtime cost: webgl
- Dependencies: none beyond React
## How it works
1. The lattice is `round(width / cellSize)` by `round(height / cellSize)` cells, sized in CSS pixels rather than device pixels so the simulation is identical on a retina display instead of running four times as many cells.
2. Each step, cells within `radius` of the pointer receive two impulses: a radial one along `(cell - pointer) / |cell - pointer|` scaled by `radialStrength`, and a flow one along the pointer's own velocity scaled by `flowStrength`. Both are weighted by `t*t*(3-2t)` where `t = 1 - dist/radius` — a smoothstep falloff, C1 at both ends, so the disturbance has no visible rim.
3. Then `vel = (vel - disp * returnStiffness) * velocityDamping` and `nextDisp = disp + vel + (neighbourAverage - disp) * diffusion`. The diffusion term is what makes it a sheet rather than a field of independent springs: a local push propagates outward as a wave instead of staying put.
4. The integration writes into a scratch buffer, not in place. In place, the +x and +y neighbours would already hold the new value when a cell reads them, and the wave would visibly drift toward the lattice origin.
5. Displacement is packed into the texture as `d / DISP_MAX * 0.5 + 0.5` in RG, with cell speed in B. Speed, not displacement, drives the glow — so the field lights up while it is moving and goes dark once it settles, which is what reads as elastic rather than as a permanent dent.
6. The shader adds `disp / gridDimensions` to its uv before drawing an `fwidth`-normalised grid, so lines stay one pixel wide even where the field is stretching them, and `u_density` is set to the lattice row count so the drawn lines land on cell boundaries.
## Constants — use these values
These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".
- `STEP = 1 / 60`
Fixed simulation timestep. Every other physics constant is expressed per step — `velocityDamping = 0.93` only means "keep 93% per 1/60 s" if the step is always 1/60 s. Integrating per frame instead makes the field stiffer on a 60 Hz laptop than on a 120 Hz phone.
- `MAX_SUBSTEPS = 4`
Catch-up ceiling. Returning to a backgrounded tab otherwise tries to simulate the whole elapsed time in one frame and the page locks up for seconds. The leftover accumulator is discarded when the ceiling is hit, or the debt compounds and the simulation runs saturated forever.
- `returnStiffness = 0.018`
Spring constant per step. Above ~0.05 the lattice snaps back before the wave can spread and it reads as rubber sheeting; below ~0.006 disturbances take several seconds to clear and the grid looks permanently dented.
- `velocityDamping = 0.93`
Velocity retained per step. At 1.0 the lattice never loses energy and rings forever; below ~0.85 the push is absorbed within a few cells and nothing propagates.
- `diffusion = 0.22`
Fraction of the way each cell moves toward its neighbours' average per step. This is the wave speed. Past 0.25 the explicit scheme goes unstable and the lattice fills with checkerboard noise; near 0 the cells decouple and the grid dimples pointwise.
- `radius = 5.5`
Pointer influence, in cells. Expressed in cells rather than pixels so the physics is unchanged at any `cellSize`. Under ~3 the cursor pokes a hole; past ~10 it lifts the whole panel and the motion loses its centre.
- `radialStrength = 0.02`
Sustained outward push per step. Equilibrium displacement is roughly `radialStrength / returnStiffness` ≈ 1.1 cells — enough for the grid to visibly part around a resting cursor without approaching the encode range.
- `flowStrength = 0.08`
Drag along the pointer velocity. A transient impulse peaks at about `impulse / (1 - velocityDamping)` ≈ 14x, so this is roughly ten times smaller than the radial constant for a comparable displacement. Raise it and fast swipes clip the encode range.
- `MAX_POINTER_SPEED = 45`
Cells per second. A pointer that teleports across the element — tab switch, window drag, the first event after a long pause — otherwise injects one enormous impulse and the whole lattice snaps in a single step.
- `DISP_MAX = 2.5`
Encode range in cells. Displacement is clamped to it before packing into 8 bits; an unclamped value wraps the byte and the cell flips to the opposite sign, which reads as a hard pop exactly where the motion is strongest.
- `cellSize = 18`
CSS pixels per cell. Cost is quadratic: at 6 a 1440x900 panel is 36,000 cells and the step stops fitting in a frame. Above ~30 the lattice is coarse enough that LINEAR interpolation between cells becomes visible as flat facets.
## API
```ts
function SpringLatticeField(props: { cellSize?: number; radius?: number; radialStrength?: number; flowStrength?: number; ink?: readonly [number, number, number]; line?: readonly [number, number, number]; glow?: readonly [number, number, number]; maxDpr?: number; className?: string }): JSX.Element
```
- `cellSize: number` — default `18`
CSS pixels per lattice cell. Cost is quadratic in this.
- `radius: number` — default `5.5`
Pointer influence radius, in lattice cells.
- `radialStrength: number` — default `0.02`
Outward push per fixed step, in cells/step².
- `flowStrength: number` — default `0.08`
How strongly pointer velocity drags cells along with it.
- `ink: readonly [number, number, number]` — default `[0.035, 0.038, 0.048]`
Base field colour, linear 0..1 RGB.
- `line: readonly [number, number, number]` — default `[0.16, 0.17, 0.2]`
Grid line colour, added on top of ink.
- `glow: readonly [number, number, number]` — default `[0.35, 0.62, 0.95]`
Colour added in proportion to cell speed.
- `maxDpr: number` — default `2`
Upper bound on device pixel ratio.
- `className: string`
Merged onto the host element.
## Reference implementation of the core
This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.
```tsx
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const MAX_DPR = 2;
/* The simulation runs on a fixed timestep. Every constant below is tuned per
STEP — `velocityDamping = 0.93` means "keep 93% of the velocity after 1/60 of
a second", and it only means that if the step is always 1/60 of a second. A
per-frame integration would make the field stiffer on a 60 Hz laptop than on
a 120 Hz phone, which is the exact failure mode the frame-rate independence
rule exists to prevent. Everything here is in LATTICE CELLS, not pixels, so
the physics is identical at any cellSize. */
const STEP = 1 / 60;
/* Ceiling on catch-up steps per frame. Without it, returning to a backgrounded
tab tries to simulate the whole elapsed time in one frame and the page hangs;
with it, the field simply resumes from where it was. */
const MAX_SUBSTEPS = 4;
const RETURN_STIFFNESS = 0.018;
const VELOCITY_DAMPING = 0.93;
const DIFFUSION = 0.22;
/* Pointer speed cap, cells/second. A pointer teleporting across the element
(tab switch, window drag) otherwise injects one enormous impulse and the
lattice snaps past the encode range in a single step. */
const MAX_POINTER_SPEED = 45;
/* Encode range in cells. Displacement is packed into 8 bits as
`d / DISP_MAX * 0.5 + 0.5`, so anything beyond this must be clamped — an
unclamped value wraps the byte and the cell flips to the opposite sign,
which reads as a hard pop. */
const DISP_MAX = 2.5;
/* Maps cell speed (cells/step) into the 0..1 glow channel. */
const ENERGY_SCALE = 6;
const MIN_CELLS = 8;
const MAX_CELLS = 80;
interface Lattice {
cols: number;
rows: number;
/** Interleaved x,y displacement per cell, in cells. */
disp: Float32Array;
/** Interleaved x,y velocity per cell, in cells/step. */
vel: Float32Array;
/** Scratch buffer — diffusion must read the whole previous state. */
next: Float32Array;
/** RGBA8 upload staging. */
pixels: Uint8Array;
}
function createLattice(cols: number, rows: number): Lattice {
const cells = cols * rows;
return {
cols,
rows,
disp: new Float32Array(cells * 2),
vel: new Float32Array(cells * 2),
next: new Float32Array(cells * 2),
pixels: new Uint8Array(cells * 4),
};
}
/* One fixed-timestep step. Pointer position is in cell coordinates; pointer
velocity is in cells/second and is converted here, so the caller does not
have to know the step length. */
function stepLattice(
lattice: Lattice,
pointerX: number,
pointerY: number,
pointerVX: number,
pointerVY: number,
active: boolean,
radius: number,
radialStrength: number,
flowStrength: number,
): void {
const { cols, rows, disp, vel, next } = lattice;
if (active) {
// Only the cells inside the radius can be touched, so bound the loop
// instead of testing every cell. At radius 5.5 that is ~120 cells out of
// a couple of thousand.
const minX = Math.max(0, Math.floor(pointerX - radius));
const maxX = Math.min(cols - 1, Math.ceil(pointerX + radius));
const minY = Math.max(0, Math.floor(pointerY - radius));
const maxY = Math.min(rows - 1, Math.ceil(pointerY + radius));
const flowX = pointerVX * STEP * flowStrength;
const flowY = pointerVY * STEP * flowStrength;
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const dx = x - pointerX;
const dy = y - pointerY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > radius) continue;
/* Smoothstep falloff, not linear. A linear falloff has a corner at the
radius: cells cross the boundary with non-zero force and the edge of
the disturbance is a visible circle. `t*t*(3-2t)` is C1 at both ends,
so the influence dies out with zero slope and no rim appears. */
const t = 1 - dist / radius;
const shaped = t * t * (3 - 2 * t);
// At dist 0 the direction is undefined; pushing nothing is correct.
const inv = dist > 1e-4 ? 1 / dist : 0;
const i = (y * cols + x) * 2;
vel[i] = (vel[i] ?? 0) + dx * inv * shaped * radialStrength + flowX * shaped;
vel[i + 1] =
(vel[i + 1] ?? 0) + dy * inv * shaped * radialStrength + flowY * shaped;
}
}
}
// Spring toward rest, then bleed energy. Damping applied AFTER the spring
// term keeps the system unconditionally stable at these constants.
for (let i = 0; i < vel.length; i++) {
vel[i] = ((vel[i] ?? 0) - (disp[i] ?? 0) * RETURN_STIFFNESS) * VELOCITY_DAMPING;
}
/* Integrate and diffuse in one pass into `next`. Diffusion is what makes the
lattice behave like a sheet rather than a field of independent springs:
each cell is pulled `DIFFUSION` of the way toward its four neighbours'
average, so a local push propagates outward as a wave. Writing in place
would make the result depend on iteration order — the +x and +y neighbours
would already be updated, and the wave would drift toward the origin. */
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = (y * cols + x) * 2;
for (let c = 0; c < 2; c++) {
const self = disp[i + c] ?? 0;
// Edges mirror themselves: a free boundary. Clamping them to zero
// instead pins the border and every wave reflects off it.
const left = x > 0 ? disp[i - 2 + c] ?? self : self;
const right = x < cols - 1 ? disp[i + 2 + c] ?? self : self;
const up = y > 0 ? disp[i - cols * 2 + c] ?? self : self;
const down = y < rows - 1 ? disp[i + cols * 2 + c] ?? self : self;
const avg = (left + right + up + down) * 0.25;
next[i + c] = self + (vel[i + c] ?? 0) + (avg - self) * DIFFUSION;
}
}
}
disp.set(next);
}
/** Pack displacement into RG and speed into B. Returns total energy. */
function encodeLattice(lattice: Lattice): number {
const { disp, vel, pixels } = lattice;
const cells = lattice.cols * lattice.rows;
let total = 0;
for (let c = 0; c < cells; c++) {
const i = c * 2;
const dx = disp[i] ?? 0;
const dy = disp[i + 1] ?? 0;
const vx = vel[i] ?? 0;
const vy = vel[i + 1] ?? 0;
const speed = Math.sqrt(vx * vx + vy * vy);
total += speed;
const o = c * 4;
pixels[o] = encodeSigned(dx);
pixels[o + 1] = encodeSigned(dy);
pixels[o + 2] = Math.min(255, Math.round(speed * ENERGY_SCALE * 255));
pixels[o + 3] = 255;
}
return total;
}
function encodeSigned(value: number): number {
const n = value / DISP_MAX;
const clamped = n < -1 ? -1 : n > 1 ? 1 : n;
return Math.round((clamped * 0.5 + 0.5) * 255);
}
interface Pass {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
texture: WebGLTexture;
locations: Map<string, WebGLUniformLocation | null>;
/** Allocated texture dimensions, so resize can tell reallocation from upload. */
texCols: number;
texRows: number;
}
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('[spring-lattice-field]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createPass(gl: WebGL2RenderingContext): Pass | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, SPRING_LATTICE_VERT);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, SPRING_LATTICE_FRAG);
const program = gl.createProgram();
if (!vert || !frag || !program) {
if (vert) gl.deleteShader(vert);
if (frag) gl.deleteShader(frag);
if (program) gl.deleteProgram(program);
return null;
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[spring-lattice-field]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
const texture = gl.createTexture();
if (!vao || !buffer || !texture) {
gl.deleteProgram(program);
if (vao) gl.deleteVertexArray(vao);
if (buffer) gl.deleteBuffer(buffer);
if (texture) gl.deleteTexture(texture);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
/* LINEAR is load-bearing, not a default: it is what turns a 40x24 simulation
into a smooth field across two million fragments. CLAMP_TO_EDGE because
REPEAT would sample the opposite edge of the lattice at the border and
stamp a seam there. */
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return {
gl,
program,
vao,
buffer,
texture,
locations: new Map(),
texCols: 0,
texRows: 0,
};
}
function uniformLocation(pass: Pass, name: string): WebGLUniformLocation | null {
const cached = pass.locations.get(name);
if (cached !== undefined) return cached;
const found = pass.gl.getUniformLocation(pass.program, name);
pass.locations.set(name, found);
return found;
}
function setVec3(
gl: WebGL2RenderingContext,
location: WebGLUniformLocation | null,
v: readonly [number, number, number],
): void {
if (location === null) return;
gl.uniform3f(location, v[0], v[1], v[2]);
}
function disposePass(pass: Pass): void {
const { gl } = pass;
gl.deleteVertexArray(pass.vao);
gl.deleteBuffer(pass.buffer);
gl.deleteTexture(pass.texture);
gl.deleteProgram(pass.program);
loseContext(gl);
}
export function SpringLatticeField({
cellSize = 18,
radius = 5.5,
radialStrength = 0.02,
flowStrength = 0.08,
ink = [0.035, 0.038, 0.048],
line = [0.16, 0.17, 0.2],
glow = [0.35, 0.62, 0.95],
maxDpr = MAX_DPR,
className,
}: SpringLatticeFieldProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const passRef = useRef<Pass | null>(null);
const latticeRef = useRef<Lattice | null>(null);
const accumulator = useRef(0);
const prevPointer = useRef<{ x: number; y: number } | null>(null);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
// Light smoothing only. Heavy smoothing would blunt the velocity signal the
// flow push is built from, and the lattice would stop reacting to flicks.
const pointer = useSmoothPointer({ target: host, smoothing: 0.004 });
const [supported, setSupported] = useState(true);
const params = useRef({ radius, radialStrength, flowStrength, ink, line, glow });
params.current = { radius, radialStrength, flowStrength, ink, line, glow };
const drawRef = useRef<() => void>(() => {});
drawRef.current = () => {
const pass = passRef.current;
const lattice = latticeRef.current;
if (!pass || !lattice) return;
const { gl } = pass;
const p = params.current;
gl.useProgram(pass.program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, pass.texture);
if (pass.texCols !== lattice.cols || pass.texRows !== lattice.rows) {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
lattice.cols,
lattice.rows,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
lattice.pixels,
);
pass.texCols = lattice.cols;
pass.texRows = lattice.rows;
} else {
// texSubImage2D, not texImage2D: re-specifying the texture every frame
// makes the driver re-validate and often reallocate it, which shows up as
// a steadily climbing GPU memory graph.
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
lattice.cols,
lattice.rows,
gl.RGBA,
gl.UNSIGNED_BYTE,
lattice.pixels,
);
}
const uField = uniformLocation(pass, 'u_field');
if (uField !== null) gl.uniform1i(uField, 0);
const uRes = uniformLocation(pass, 'u_resolution');
if (uRes !== null) {
gl.uniform2f(uRes, gl.drawingBufferWidth, gl.drawingBufferHeight);
}
const uGrid = uniformLocation(pass, 'u_grid');
if (uGrid !== null) gl.uniform2f(uGrid, lattice.cols, lattice.rows);
const uDisp = uniformLocation(pass, 'u_dispMax');
if (uDisp !== null) gl.uniform1f(uDisp, DISP_MAX);
/* Grid density is tied to the lattice row count so the drawn lines land on
cell boundaries. Decoupling them makes the displacement look like it is
sliding underneath the grid rather than moving it. */
const uDensity = uniformLocation(pass, 'u_density');
if (uDensity !== null) gl.uniform1f(uDensity, lattice.rows);
setVec3(gl, uniformLocation(pass, 'u_ink'), p.ink);
setVec3(gl, uniformLocation(pass, 'u_line'), p.line);
setVec3(gl, uniformLocation(pass, 'u_glow'), p.glow);
gl.bindVertexArray(pass.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const pass = createPass(gl);
if (!pass) {
loseContext(gl);
setSupported(false);
return;
}
passRef.current = pass;
setSupported(true);
return () => {
passRef.current = null;
disposePass(pass);
};
}, []);
// Resize rebuilds the lattice and redraws. Under reduced motion this is the
// only draw that ever happens, and it shows the field at rest.
useEffect(() => {
const canvas = canvasRef.current;
const pass = passRef.current;
if (!canvas || !pass || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
pass.gl.viewport(0, 0, w, h);
// Lattice resolution is in CSS pixels, deliberately: the simulation should
// feel the same on a retina display, not run four times as many cells.
const cols = Math.min(MAX_CELLS, Math.max(MIN_CELLS, Math.round(width / cellSize)));
const rows = Math.min(MAX_CELLS, Math.max(MIN_CELLS, Math.round(height / cellSize)));
const current = latticeRef.current;
if (!current || current.cols !== cols || current.rows !== rows) {
latticeRef.current = createLattice(cols, rows);
encodeLattice(latticeRef.current);
prevPointer.current = null;
accumulator.current = 0;
}
drawRef.current();
}, [width, height, cellSize, maxDpr, supported]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((dt) => {
const lattice = latticeRef.current;
if (!lattice) return;
const p = params.current;
const state = pointer.current;
const px = state.nx * lattice.cols;
const py = state.ny * lattice.rows;
// Velocity from the change in position, in cells/second, then capped.
let vx = 0;
let vy = 0;
const prev = prevPointer.current;
if (prev && dt > 0) {
vx = (px - prev.x) / dt;
vy = (py - prev.y) / dt;
const speed = Math.sqrt(vx * vx + vy * vy);
if (speed > MAX_POINTER_SPEED) {
const k = MAX_POINTER_SPEED / speed;
vx *= k;
vy *= k;
}
}
prevPointer.current = { x: px, y: py };
accumulator.current += dt;
let steps = 0;
while (accumulator.current >= STEP && steps < MAX_SUBSTEPS) {
stepLattice(
lattice,
px,
py,
vx,
vy,
state.active,
p.radius,
p.radialStrength,
p.flowStrength,
);
accumulator.current -= STEP;
steps++;
}
// Hit the ceiling: throw the backlog away rather than carrying a debt that
// can never be paid off, which would leave the simulation permanently
// saturated and running MAX_SUBSTEPS every frame.
if (steps === MAX_SUBSTEPS) accumulator.current = 0;
if (steps > 0) {
encodeLattice(lattice);
drawRef.current();
}
}, running);
```
## Requirements
These are not optional. A version of this component that skips any of them is
not finished.
**Cleanup.** Deletes the VAO, vertex buffer, field texture and program, then `loseContext(gl)`. The typed arrays are dropped with the lattice ref. The rAF subscription, resize observer and pointer listener unsubscribe with their hooks.
**Reduced motion.** No rAF subscription, so the lattice never steps and the pointer never disturbs it. One frame is drawn on mount and on resize showing the field at rest: the full grid, the vignette, no displacement and no glow. The component is a static grid, not a blank panel.
**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.
**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.
## Known pitfalls — avoid these specifically
- Integrating per frame instead of on a fixed accumulator. The constants above are tuned per 1/60 s; run them per frame and the same component is noticeably stiffer at 60 fps than at 120, and unstable under a frame spike. This is the one place the tuned constants and the frame-rate independence rule collide, and the accumulator is the resolution.
- Carrying the accumulator forward after hitting `MAX_SUBSTEPS`. The debt can never be repaid, so the simulation runs four substeps every frame from then on and quietly costs four times what it should.
- Diffusing in place. Cells read their +x and +y neighbours after those neighbours have already been updated, so the wave propagates faster in one diagonal than the other and visibly drifts toward the lattice origin.
- Setting the field texture to NEAREST. The lattice itself becomes visible as hard blocky steps — at `cellSize` 18 that is a grid of 18 px squares sliding under the drawn grid.
- Calling `texImage2D` every frame instead of `texSubImage2D`. Re-specifying the texture makes the driver re-validate and frequently reallocate it; the symptom is a GPU memory graph that climbs for as long as the page is open.
- Failing to clamp before encoding. Displacement past `DISP_MAX` wraps the unsigned byte, so a cell being pushed hard right suddenly reads as pushed hard left, and the field pops.
- Sizing the lattice in device pixels. A retina display quietly runs four times the cells for identical visual output, and the component drops frames only on the machines its author is least likely to be testing on.
- Heavy pointer smoothing. The flow push is built from the derivative of the pointer position, and over-smoothing flattens that derivative — the lattice stops responding to flicks while still responding to slow drags, which reads as lag rather than as smoothing.
- Leaving the context alive on unmount. Raw GL has no `dispose()`; the VAO, buffer, texture and program are deleted explicitly and `loseContext(gl)` hands the context back, or the page starts blanking its oldest canvases after roughly sixteen mounts.
## Notes
- The simulation could run on the GPU with ping-pong framebuffers, and at this lattice size it would be slower overall: two extra render targets, a readback whenever anything else needs the field state, and float-texture support to feature detect. A few thousand cells on the CPU cost under 0.2 ms and stay inspectable in a debugger.
- Resizing rebuilds the lattice from rest. Interpolating the old state into the new dimensions is possible but the disturbance is gone within a second of a resize anyway.
## Definition of done
Rest the cursor on the field and the grid parts around it by roughly one cell and holds there. Move it and a wave spreads outward through the lines and settles within about a second. Swipe fast and the lines are dragged along the swipe direction, glowing while they move and going dark once they stop. Grid lines stay one pixel wide even where they are most stretched. Throttle the CPU to 6x and the wave still travels at the same speed, just at fewer frames.
When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
Source
9 files, exactly as installed.
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useSmoothPointer } from '@/hooks/use-smooth-pointer';
import { useVisibility } from '@/hooks/use-visibility';
import { hasWebGL, loseContext, pixelRatio } from '@/lib/webgl';
import {
SPRING_LATTICE_FRAG,
SPRING_LATTICE_VERT,
} from '@/components/motion/shaders/spring-lattice-field';
/* ==========================================================================
Spring Lattice Field
--------------------------------------------------------------------------
Raw WebGL2, no framework. The interesting part is the split: a coarse
damped-spring lattice runs on the CPU (a few thousand cells, trivial), gets
packed into an RGBA8 texture once per frame, and the GPU reads that texture
with LINEAR filtering to displace a procedural grid across every fragment.
The alternative — simulating on the GPU with ping-pong framebuffers — is
faster in the abstract and worse here: two extra render targets, a readback
any time you want to know the field state, and float-texture support to feature
detect. At this lattice size the CPU step is under 0.2 ms and the whole
simulation stays inspectable in a debugger.
========================================================================== */
export interface SpringLatticeFieldProps {
/** CSS pixels per lattice cell. Lower = finer simulation, quadratic cost. */
cellSize?: number;
/** Radius of the pointer's influence, in lattice cells. */
radius?: number;
/** Outward push per step from the pointer, in cells/step². */
radialStrength?: number;
/** How strongly pointer velocity drags the lattice along with it. */
flowStrength?: number;
/** Base colour of the field. Linear 0..1 RGB. */
ink?: readonly [number, number, number];
/** Grid line colour, added on top of `ink`. */
line?: readonly [number, number, number];
/** Colour added in proportion to cell speed. */
glow?: readonly [number, number, number];
/** Upper bound on device pixel ratio. */
maxDpr?: number;
className?: string;
}
// #region critical
const TRIANGLE = new Float32Array([-1, -1, 3, -1, -1, 3]);
const MAX_DPR = 2;
/* The simulation runs on a fixed timestep. Every constant below is tuned per
STEP — `velocityDamping = 0.93` means "keep 93% of the velocity after 1/60 of
a second", and it only means that if the step is always 1/60 of a second. A
per-frame integration would make the field stiffer on a 60 Hz laptop than on
a 120 Hz phone, which is the exact failure mode the frame-rate independence
rule exists to prevent. Everything here is in LATTICE CELLS, not pixels, so
the physics is identical at any cellSize. */
const STEP = 1 / 60;
/* Ceiling on catch-up steps per frame. Without it, returning to a backgrounded
tab tries to simulate the whole elapsed time in one frame and the page hangs;
with it, the field simply resumes from where it was. */
const MAX_SUBSTEPS = 4;
const RETURN_STIFFNESS = 0.018;
const VELOCITY_DAMPING = 0.93;
const DIFFUSION = 0.22;
/* Pointer speed cap, cells/second. A pointer teleporting across the element
(tab switch, window drag) otherwise injects one enormous impulse and the
lattice snaps past the encode range in a single step. */
const MAX_POINTER_SPEED = 45;
/* Encode range in cells. Displacement is packed into 8 bits as
`d / DISP_MAX * 0.5 + 0.5`, so anything beyond this must be clamped — an
unclamped value wraps the byte and the cell flips to the opposite sign,
which reads as a hard pop. */
const DISP_MAX = 2.5;
/* Maps cell speed (cells/step) into the 0..1 glow channel. */
const ENERGY_SCALE = 6;
const MIN_CELLS = 8;
const MAX_CELLS = 80;
interface Lattice {
cols: number;
rows: number;
/** Interleaved x,y displacement per cell, in cells. */
disp: Float32Array;
/** Interleaved x,y velocity per cell, in cells/step. */
vel: Float32Array;
/** Scratch buffer — diffusion must read the whole previous state. */
next: Float32Array;
/** RGBA8 upload staging. */
pixels: Uint8Array;
}
function createLattice(cols: number, rows: number): Lattice {
const cells = cols * rows;
return {
cols,
rows,
disp: new Float32Array(cells * 2),
vel: new Float32Array(cells * 2),
next: new Float32Array(cells * 2),
pixels: new Uint8Array(cells * 4),
};
}
/* One fixed-timestep step. Pointer position is in cell coordinates; pointer
velocity is in cells/second and is converted here, so the caller does not
have to know the step length. */
function stepLattice(
lattice: Lattice,
pointerX: number,
pointerY: number,
pointerVX: number,
pointerVY: number,
active: boolean,
radius: number,
radialStrength: number,
flowStrength: number,
): void {
const { cols, rows, disp, vel, next } = lattice;
if (active) {
// Only the cells inside the radius can be touched, so bound the loop
// instead of testing every cell. At radius 5.5 that is ~120 cells out of
// a couple of thousand.
const minX = Math.max(0, Math.floor(pointerX - radius));
const maxX = Math.min(cols - 1, Math.ceil(pointerX + radius));
const minY = Math.max(0, Math.floor(pointerY - radius));
const maxY = Math.min(rows - 1, Math.ceil(pointerY + radius));
const flowX = pointerVX * STEP * flowStrength;
const flowY = pointerVY * STEP * flowStrength;
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const dx = x - pointerX;
const dy = y - pointerY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > radius) continue;
/* Smoothstep falloff, not linear. A linear falloff has a corner at the
radius: cells cross the boundary with non-zero force and the edge of
the disturbance is a visible circle. `t*t*(3-2t)` is C1 at both ends,
so the influence dies out with zero slope and no rim appears. */
const t = 1 - dist / radius;
const shaped = t * t * (3 - 2 * t);
// At dist 0 the direction is undefined; pushing nothing is correct.
const inv = dist > 1e-4 ? 1 / dist : 0;
const i = (y * cols + x) * 2;
vel[i] = (vel[i] ?? 0) + dx * inv * shaped * radialStrength + flowX * shaped;
vel[i + 1] =
(vel[i + 1] ?? 0) + dy * inv * shaped * radialStrength + flowY * shaped;
}
}
}
// Spring toward rest, then bleed energy. Damping applied AFTER the spring
// term keeps the system unconditionally stable at these constants.
for (let i = 0; i < vel.length; i++) {
vel[i] = ((vel[i] ?? 0) - (disp[i] ?? 0) * RETURN_STIFFNESS) * VELOCITY_DAMPING;
}
/* Integrate and diffuse in one pass into `next`. Diffusion is what makes the
lattice behave like a sheet rather than a field of independent springs:
each cell is pulled `DIFFUSION` of the way toward its four neighbours'
average, so a local push propagates outward as a wave. Writing in place
would make the result depend on iteration order — the +x and +y neighbours
would already be updated, and the wave would drift toward the origin. */
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = (y * cols + x) * 2;
for (let c = 0; c < 2; c++) {
const self = disp[i + c] ?? 0;
// Edges mirror themselves: a free boundary. Clamping them to zero
// instead pins the border and every wave reflects off it.
const left = x > 0 ? disp[i - 2 + c] ?? self : self;
const right = x < cols - 1 ? disp[i + 2 + c] ?? self : self;
const up = y > 0 ? disp[i - cols * 2 + c] ?? self : self;
const down = y < rows - 1 ? disp[i + cols * 2 + c] ?? self : self;
const avg = (left + right + up + down) * 0.25;
next[i + c] = self + (vel[i + c] ?? 0) + (avg - self) * DIFFUSION;
}
}
}
disp.set(next);
}
/** Pack displacement into RG and speed into B. Returns total energy. */
function encodeLattice(lattice: Lattice): number {
const { disp, vel, pixels } = lattice;
const cells = lattice.cols * lattice.rows;
let total = 0;
for (let c = 0; c < cells; c++) {
const i = c * 2;
const dx = disp[i] ?? 0;
const dy = disp[i + 1] ?? 0;
const vx = vel[i] ?? 0;
const vy = vel[i + 1] ?? 0;
const speed = Math.sqrt(vx * vx + vy * vy);
total += speed;
const o = c * 4;
pixels[o] = encodeSigned(dx);
pixels[o + 1] = encodeSigned(dy);
pixels[o + 2] = Math.min(255, Math.round(speed * ENERGY_SCALE * 255));
pixels[o + 3] = 255;
}
return total;
}
function encodeSigned(value: number): number {
const n = value / DISP_MAX;
const clamped = n < -1 ? -1 : n > 1 ? 1 : n;
return Math.round((clamped * 0.5 + 0.5) * 255);
}
interface Pass {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
texture: WebGLTexture;
locations: Map<string, WebGLUniformLocation | null>;
/** Allocated texture dimensions, so resize can tell reallocation from upload. */
texCols: number;
texRows: number;
}
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('[spring-lattice-field]', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createPass(gl: WebGL2RenderingContext): Pass | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, SPRING_LATTICE_VERT);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, SPRING_LATTICE_FRAG);
const program = gl.createProgram();
if (!vert || !frag || !program) {
if (vert) gl.deleteShader(vert);
if (frag) gl.deleteShader(frag);
if (program) gl.deleteProgram(program);
return null;
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[spring-lattice-field]', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
const texture = gl.createTexture();
if (!vao || !buffer || !texture) {
gl.deleteProgram(program);
if (vao) gl.deleteVertexArray(vao);
if (buffer) gl.deleteBuffer(buffer);
if (texture) gl.deleteTexture(texture);
return null;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, TRIANGLE, gl.STATIC_DRAW);
const attrib = gl.getAttribLocation(program, 'a_pos');
if (attrib >= 0) {
gl.enableVertexAttribArray(attrib);
gl.vertexAttribPointer(attrib, 2, gl.FLOAT, false, 0, 0);
}
gl.bindVertexArray(null);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
/* LINEAR is load-bearing, not a default: it is what turns a 40x24 simulation
into a smooth field across two million fragments. CLAMP_TO_EDGE because
REPEAT would sample the opposite edge of the lattice at the border and
stamp a seam there. */
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return {
gl,
program,
vao,
buffer,
texture,
locations: new Map(),
texCols: 0,
texRows: 0,
};
}
function uniformLocation(pass: Pass, name: string): WebGLUniformLocation | null {
const cached = pass.locations.get(name);
if (cached !== undefined) return cached;
const found = pass.gl.getUniformLocation(pass.program, name);
pass.locations.set(name, found);
return found;
}
function setVec3(
gl: WebGL2RenderingContext,
location: WebGLUniformLocation | null,
v: readonly [number, number, number],
): void {
if (location === null) return;
gl.uniform3f(location, v[0], v[1], v[2]);
}
function disposePass(pass: Pass): void {
const { gl } = pass;
gl.deleteVertexArray(pass.vao);
gl.deleteBuffer(pass.buffer);
gl.deleteTexture(pass.texture);
gl.deleteProgram(pass.program);
loseContext(gl);
}
export function SpringLatticeField({
cellSize = 18,
radius = 5.5,
radialStrength = 0.02,
flowStrength = 0.08,
ink = [0.035, 0.038, 0.048],
line = [0.16, 0.17, 0.2],
glow = [0.35, 0.62, 0.95],
maxDpr = MAX_DPR,
className,
}: SpringLatticeFieldProps) {
const host = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const passRef = useRef<Pass | null>(null);
const latticeRef = useRef<Lattice | null>(null);
const accumulator = useRef(0);
const prevPointer = useRef<{ x: number; y: number } | null>(null);
const { width, height } = useResizeObserver(host);
const reduced = useReducedMotion();
const visible = useVisibility();
// Light smoothing only. Heavy smoothing would blunt the velocity signal the
// flow push is built from, and the lattice would stop reacting to flicks.
const pointer = useSmoothPointer({ target: host, smoothing: 0.004 });
const [supported, setSupported] = useState(true);
const params = useRef({ radius, radialStrength, flowStrength, ink, line, glow });
params.current = { radius, radialStrength, flowStrength, ink, line, glow };
const drawRef = useRef<() => void>(() => {});
drawRef.current = () => {
const pass = passRef.current;
const lattice = latticeRef.current;
if (!pass || !lattice) return;
const { gl } = pass;
const p = params.current;
gl.useProgram(pass.program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, pass.texture);
if (pass.texCols !== lattice.cols || pass.texRows !== lattice.rows) {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
lattice.cols,
lattice.rows,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
lattice.pixels,
);
pass.texCols = lattice.cols;
pass.texRows = lattice.rows;
} else {
// texSubImage2D, not texImage2D: re-specifying the texture every frame
// makes the driver re-validate and often reallocate it, which shows up as
// a steadily climbing GPU memory graph.
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
lattice.cols,
lattice.rows,
gl.RGBA,
gl.UNSIGNED_BYTE,
lattice.pixels,
);
}
const uField = uniformLocation(pass, 'u_field');
if (uField !== null) gl.uniform1i(uField, 0);
const uRes = uniformLocation(pass, 'u_resolution');
if (uRes !== null) {
gl.uniform2f(uRes, gl.drawingBufferWidth, gl.drawingBufferHeight);
}
const uGrid = uniformLocation(pass, 'u_grid');
if (uGrid !== null) gl.uniform2f(uGrid, lattice.cols, lattice.rows);
const uDisp = uniformLocation(pass, 'u_dispMax');
if (uDisp !== null) gl.uniform1f(uDisp, DISP_MAX);
/* Grid density is tied to the lattice row count so the drawn lines land on
cell boundaries. Decoupling them makes the displacement look like it is
sliding underneath the grid rather than moving it. */
const uDensity = uniformLocation(pass, 'u_density');
if (uDensity !== null) gl.uniform1f(uDensity, lattice.rows);
setVec3(gl, uniformLocation(pass, 'u_ink'), p.ink);
setVec3(gl, uniformLocation(pass, 'u_line'), p.line);
setVec3(gl, uniformLocation(pass, 'u_glow'), p.glow);
gl.bindVertexArray(pass.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!hasWebGL()) {
setSupported(false);
return;
}
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
depth: false,
stencil: false,
powerPreference: 'high-performance',
});
if (!gl) {
setSupported(false);
return;
}
const pass = createPass(gl);
if (!pass) {
loseContext(gl);
setSupported(false);
return;
}
passRef.current = pass;
setSupported(true);
return () => {
passRef.current = null;
disposePass(pass);
};
}, []);
// Resize rebuilds the lattice and redraws. Under reduced motion this is the
// only draw that ever happens, and it shows the field at rest.
useEffect(() => {
const canvas = canvasRef.current;
const pass = passRef.current;
if (!canvas || !pass || width === 0 || height === 0) return;
const dpr = pixelRatio(maxDpr);
const w = Math.max(1, Math.round(width * dpr));
const h = Math.max(1, Math.round(height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
pass.gl.viewport(0, 0, w, h);
// Lattice resolution is in CSS pixels, deliberately: the simulation should
// feel the same on a retina display, not run four times as many cells.
const cols = Math.min(MAX_CELLS, Math.max(MIN_CELLS, Math.round(width / cellSize)));
const rows = Math.min(MAX_CELLS, Math.max(MIN_CELLS, Math.round(height / cellSize)));
const current = latticeRef.current;
if (!current || current.cols !== cols || current.rows !== rows) {
latticeRef.current = createLattice(cols, rows);
encodeLattice(latticeRef.current);
prevPointer.current = null;
accumulator.current = 0;
}
drawRef.current();
}, [width, height, cellSize, maxDpr, supported]);
const running = supported && visible && !reduced && width > 0 && height > 0;
useRaf((dt) => {
const lattice = latticeRef.current;
if (!lattice) return;
const p = params.current;
const state = pointer.current;
const px = state.nx * lattice.cols;
const py = state.ny * lattice.rows;
// Velocity from the change in position, in cells/second, then capped.
let vx = 0;
let vy = 0;
const prev = prevPointer.current;
if (prev && dt > 0) {
vx = (px - prev.x) / dt;
vy = (py - prev.y) / dt;
const speed = Math.sqrt(vx * vx + vy * vy);
if (speed > MAX_POINTER_SPEED) {
const k = MAX_POINTER_SPEED / speed;
vx *= k;
vy *= k;
}
}
prevPointer.current = { x: px, y: py };
accumulator.current += dt;
let steps = 0;
while (accumulator.current >= STEP && steps < MAX_SUBSTEPS) {
stepLattice(
lattice,
px,
py,
vx,
vy,
state.active,
p.radius,
p.radialStrength,
p.flowStrength,
);
accumulator.current -= STEP;
steps++;
}
// Hit the ceiling: throw the backlog away rather than carrying a debt that
// can never be paid off, which would leave the simulation permanently
// saturated and running MAX_SUBSTEPS every frame.
if (steps === MAX_SUBSTEPS) accumulator.current = 0;
if (steps > 0) {
encodeLattice(lattice);
drawRef.current();
}
}, running);
// #endregion critical
return (
<div ref={host} className={cn('relative h-full w-full overflow-hidden', className)}>
{supported ? (
<canvas ref={canvasRef} className="block h-full w-full" />
) : (
<div
aria-hidden
className="h-full w-full bg-bg"
style={{
backgroundImage:
'repeating-linear-gradient(0deg, var(--k-line) 0 1px, transparent 1px 18px), repeating-linear-gradient(90deg, var(--k-line) 0 1px, transparent 1px 18px)',
}}
/>
)}
</div>
);
}
/* ==========================================================================
Spring Lattice Field — GLSL ES 3.00
--------------------------------------------------------------------------
The fragment shader knows nothing about springs. It reads a displacement
texture the CPU wrote this frame, offsets its own sample coordinate by it,
and draws a grid. All of the physics is on the other side of that texture.
========================================================================== */
export const SPRING_LATTICE_VERT = `#version 300 es
in vec2 a_pos;
out vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
export const SPRING_LATTICE_FRAG = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform sampler2D u_field;
uniform vec2 u_resolution;
uniform vec2 u_grid; /* lattice columns, rows */
uniform float u_dispMax; /* the encode range, in lattice cells */
uniform float u_density; /* grid lines across the short axis */
uniform vec3 u_ink;
uniform vec3 u_line;
uniform vec3 u_glow;
/* Analytic anti-aliased grid. Dividing distance-to-line by fwidth converts it
into pixels, so one smoothstep gives a hairline of constant apparent weight
whatever the grid is scaled to — and it stays one pixel while the field is
stretching it, which a texture-based grid cannot do. */
float gridMask(vec2 p, float thickness) {
vec2 w = fwidth(p);
vec2 d = abs(fract(p - 0.5) - 0.5) / max(w, vec2(1e-5));
return 1.0 - smoothstep(thickness - 0.5, thickness + 0.5, min(d.x, d.y));
}
void main() {
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
/* LINEAR filtering on the field texture is doing real work here: it
interpolates between lattice cells, so a 40x24 simulation drives a
continuous displacement across two million fragments. With NEAREST you
see the lattice itself as blocky steps. */
vec4 field = texture(u_field, v_uv);
vec2 disp = (field.rg * 2.0 - 1.0) * u_dispMax; /* cells */
float energy = field.b;
/* Cells -> uv. Dividing by the grid dimensions is what makes the same
displacement mean the same physical distance at any lattice resolution. */
vec2 duv = disp / u_grid;
vec2 p = v_uv + duv;
p.x *= aspect;
float fine = gridMask(p * u_density, 0.5);
float coarse = gridMask(p * (u_density * 0.25), 0.9);
vec3 col = u_ink;
col += u_line * (fine * 0.55 + coarse * 0.5);
/* Energy is speed, not displacement: the field lights up while it is moving
and goes dark once it settles, which is what makes it read as elastic
rather than as a permanent dent. */
col += u_glow * energy * (0.35 + 0.9 * (fine + coarse));
float vig = smoothstep(1.3, 0.45, distance(v_uv, vec2(0.5)));
col *= mix(0.7, 1.0, vig);
fragColor = vec4(col, 1.0);
}
`;
/* ==========================================================================
Frame-rate-independent interpolation.
--------------------------------------------------------------------------
The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice
as fast on a 120 Hz display as on a 60 Hz one, so the same code feels
different on different hardware and visibly speeds up when the browser
drops frames. `damp` fixes it by raising the retention factor to the power
of elapsed time, which is the exact continuous form of the same curve.
========================================================================== */
/**
* Fraction of the remaining distance to close this frame.
*
* @param smoothing Fraction of the gap that REMAINS after one second.
* Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft,
* 0.5 ≈ slow drift.
* @param dt Seconds elapsed since the previous frame.
*/
export function dampFactor(smoothing: number, dt: number): number {
return 1 - Math.pow(smoothing, dt);
}
/** Move `current` toward `target` at a rate independent of frame rate. */
export function damp(
current: number,
target: number,
smoothing: number,
dt: number,
): number {
return current + (target - current) * dampFactor(smoothing, dt);
}
/**
* The per-frame lerp constant an author measured at 60fps, converted into the
* `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame"
* be ported without re-tuning by eye:
*
* damp(x, t, perFrameToSmoothing(0.1), dt)
*/
export function perFrameToSmoothing(alpha: number, baseFps = 60): number {
return Math.pow(1 - alpha, baseFps);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
export function inverseLerp(a: number, b: number, v: number): number {
return a === b ? 0 : (v - a) / (b - a);
}
export function mapRange(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
doClamp = true,
): number {
const t = inverseLerp(inMin, inMax, v);
const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t);
return r;
}
/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp(inverseLerp(edge0, edge1, x), 0, 1);
return t * t * (3 - 2 * t);
}
/**
* Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at
* distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and
* no branches, which is why carousels that use it feel analogue rather than
* stepped.
*/
export function gaussian(distance: number, falloff = 1): number {
const d = distance * falloff;
return Math.exp(-(d * d));
}
'use client';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One shared requestAnimationFrame loop.
--------------------------------------------------------------------------
Forty components each calling requestAnimationFrame means forty callbacks
the browser schedules separately and forty chances to read layout after a
write. One loop, one ordered pass, one place to clamp delta time.
Delta is clamped to 100 ms. Without that, returning to a backgrounded tab
delivers a multi-second dt and every physics integration in the page
explodes on the first frame back.
========================================================================== */
export type RafCallback = (dt: number, elapsed: number) => void;
const callbacks = new Set<RafCallback>();
let frame = 0;
let last = 0;
let start = 0;
const MAX_DT = 0.1;
const DEFAULT_DT = 1 / 60;
function tick(now: number) {
frame = requestAnimationFrame(tick);
const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT);
last = now;
const elapsed = (now - start) / 1000;
// Copy: a callback may unsubscribe itself (or another) mid-iteration.
for (const cb of Array.from(callbacks)) {
if (callbacks.has(cb)) cb(dt, elapsed);
}
}
function subscribe(cb: RafCallback) {
callbacks.add(cb);
if (frame === 0) {
start = performance.now();
last = 0;
frame = requestAnimationFrame(tick);
}
return () => {
callbacks.delete(cb);
if (callbacks.size === 0 && frame !== 0) {
cancelAnimationFrame(frame);
frame = 0;
last = 0;
}
};
}
/**
* Run `cb` every frame while `enabled`.
*
* The callback is held in a ref, so it does not need to be memoised and the
* subscription is never torn down just because the closure changed identity.
*/
export function useRaf(cb: RafCallback, enabled = true): void {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
if (!enabled) return;
return subscribe((dt, elapsed) => ref.current(dt, elapsed));
}, [enabled]);
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
prefers-reduced-motion, SSR-safe.
--------------------------------------------------------------------------
useSyncExternalStore rather than useState + useEffect: the server snapshot
is an explicit `false`, so the markup React renders on the server and the
markup it hydrates with agree, and the real value lands in the same commit
as hydration instead of one frame later. A useEffect version flashes the
animated variant before removing it — the exact thing the user asked not to
see.
Reduced motion is not "duration: 0". For most components here it means a
different render: the final state, a static poster, or a paused frame.
========================================================================== */
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
function getSnapshot() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/** Generic media-query hook, same SSR guarantees. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
},
() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
},
() => false,
);
}
'use client';
import { useEffect, useState, type RefObject } from 'react';
/* ==========================================================================
Element size, from one shared ResizeObserver.
--------------------------------------------------------------------------
Sized from the element rather than the window because a preview in a grid
cell resizes when the grid reflows, not when the viewport does. Reading
`window.innerWidth` is the reason so many canvas components are correct on
load and wrong after a sidebar opens.
========================================================================== */
export interface Size {
width: number;
height: number;
}
type Handler = (size: Size) => void;
let observer: ResizeObserver | null = null;
const handlers = new Map<Element, Handler>();
function ensureObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
observer ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.contentBoxSize?.[0];
const size: Size = box
? { width: box.inlineSize, height: box.blockSize }
: { width: entry.contentRect.width, height: entry.contentRect.height };
handlers.get(entry.target)?.(size);
}
});
return observer;
}
export function useResizeObserver(ref: RefObject<Element | null>): Size {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
const ro = ensureObserver();
if (!el || !ro) return;
// Seed synchronously: waiting for the first observer callback leaves one
// frame where a canvas is sized 0x0, which some drivers log as an error.
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
handlers.set(el, (next) =>
setSize((prev) =>
prev.width === next.width && prev.height === next.height ? prev : next,
),
);
ro.observe(el);
return () => {
handlers.delete(el);
ro.unobserve(el);
};
}, [ref]);
return size;
}
'use client';
import { useEffect, useRef, type RefObject } from 'react';
import { useRaf } from '@/hooks/use-raf';
import { damp } from '@/lib/damp';
/* ==========================================================================
Pointer position, smoothed, in several coordinate spaces at once.
--------------------------------------------------------------------------
Almost every cursor effect wants the same four things and computes them
slightly differently each time: client pixels, element-local pixels, 0..1
normalised, and -1..1 NDC for shaders. Producing all four from one damped
source keeps a cursor dot and the shader it drives exactly in step.
Exposed via ref for the same reason as scroll velocity: per-frame values
must not re-render.
========================================================================== */
export interface PointerState {
/** Viewport pixels. */
clientX: number;
clientY: number;
/** Pixels relative to the target element's top-left. */
x: number;
y: number;
/** 0..1 across the element. */
nx: number;
ny: number;
/** -1..1, y up. Feed straight into a shader uniform. */
ndcX: number;
ndcY: number;
/** Whether the pointer has been seen. Nothing should render before. */
active: boolean;
}
export interface UseSmoothPointerOptions {
/** Fraction of the gap remaining after one second. Lower = tighter. */
smoothing?: number;
/** Listen on this element rather than the window. */
target?: RefObject<HTMLElement | null>;
/** Follow touch drags as well as mouse moves. */
touch?: boolean;
}
function makeInitialState(): PointerState {
return {
clientX: 0,
clientY: 0,
x: 0,
y: 0,
nx: 0.5,
ny: 0.5,
ndcX: 0,
ndcY: 0,
active: false,
};
}
export function useSmoothPointer(
options: UseSmoothPointerOptions = {},
): { current: PointerState } {
const { smoothing = 0.001, target, touch = true } = options;
const state = useRef<PointerState>(makeInitialState());
// `null` means "no real pointer yet", which is distinct from a legitimate
// pointer at (0, 0) in the top-left corner.
const targetPos = useRef<{ clientX: number; clientY: number } | null>(null);
const seeded = useRef(false);
// Options the listener needs but that must not re-subscribe it on change.
const touchRef = useRef(touch);
touchRef.current = touch;
useEffect(() => {
const el = target?.current ?? null;
const node: HTMLElement | Window = el ?? window;
const onMove = (event: Event) => {
const e = event as PointerEvent;
if (!touchRef.current && e.pointerType === 'touch') return;
targetPos.current = { clientX: e.clientX, clientY: e.clientY };
};
node.addEventListener('pointermove', onMove, { passive: true });
return () => node.removeEventListener('pointermove', onMove);
// `target` is a ref object; its identity is stable for the component's
// lifetime, so this subscribes once.
}, [target]);
useRaf((dt) => {
const el = target?.current ?? null;
const rect = el
? el.getBoundingClientRect()
: {
left: 0,
top: 0,
width: window.innerWidth,
height: window.innerHeight,
};
const pointer = targetPos.current;
if (pointer === null) {
// Park at the centre until a real pointer arrives, so the first move
// eases out from the middle rather than snapping in from (0, 0). Done
// once — re-centring every frame would fight a slow first movement.
if (!seeded.current && rect.width > 0 && rect.height > 0) {
seeded.current = true;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
state.current = {
...state.current,
clientX: cx,
clientY: cy,
x: cx - rect.left,
y: cy - rect.top,
};
}
return;
}
const clientX = damp(state.current.clientX, pointer.clientX, smoothing, dt);
const clientY = damp(state.current.clientY, pointer.clientY, smoothing, dt);
const x = clientX - rect.left;
const y = clientY - rect.top;
const nx = rect.width > 0 ? x / rect.width : 0.5;
const ny = rect.height > 0 ? y / rect.height : 0.5;
state.current = {
clientX,
clientY,
x,
y,
nx,
ny,
ndcX: nx * 2 - 1,
ndcY: -(ny * 2 - 1),
active: true,
};
});
return state;
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
Document visibility, shared.
--------------------------------------------------------------------------
A backgrounded tab still runs `setInterval`, still holds WebGL contexts,
and — on some platforms — still gets throttled rAF ticks with multi-second
deltas. Anything expensive should stop when the tab is hidden and resume
when it comes back, rather than trying to catch up.
`useSyncExternalStore` with an explicit server snapshot so SSR markup and
the first client render agree.
========================================================================== */
function subscribe(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
function getSnapshot(): boolean {
return document.visibilityState === 'visible';
}
/** Assume visible on the server: the alternative flashes a paused state. */
function getServerSnapshot(): boolean {
return true;
}
export function useVisibility(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
import type * as THREE from 'three';
/* ==========================================================================
WebGL context lifecycle.
--------------------------------------------------------------------------
Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on
low-end devices). Creating the 17th does not fail — it fires
`webglcontextlost` on the OLDEST one, so an unrelated component elsewhere
on the page goes black. Same-origin iframes share the renderer process, so
isolating previews in iframes does not raise the ceiling.
The only real fix is to release contexts on unmount, and `renderer.dispose()`
ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself
alive until GC decides otherwise, which may be never. `forceContextLoss()`
is the call that actually hands it back.
========================================================================== */
/**
* Fully release a three.js renderer. Call this in the `useEffect` cleanup of
* any component that constructs a `WebGLRenderer` — the preview host drives
* unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal
* path and nothing component-specific is required.
*/
export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void {
if (!renderer) return;
// Stop the loop first: a queued frame that renders into a lost context
// throws, and the throw escapes the cleanup function.
renderer.setAnimationLoop(null);
renderer.dispose();
renderer.forceContextLoss();
const canvas = renderer.domElement;
if (canvas) {
// Zeroing the backing store releases the drawing buffer immediately
// instead of holding width×height×4 bytes until the node is collected.
canvas.width = 0;
canvas.height = 0;
}
}
/**
* Release a raw WebGL context (ogl, or hand-written GL). The extension is the
* only portable way to do it; it is absent in some headless configurations,
* hence the optional call.
*/
export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void {
if (!gl) return;
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
/** Recursively dispose geometries, materials and textures under a root. */
export function disposeObject(root: THREE.Object3D | null | undefined): void {
if (!root) return;
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
if (!material) return;
const materials = Array.isArray(material) ? material : [material];
for (const m of materials) {
for (const value of Object.values(m as unknown as Record<string, unknown>)) {
if (value && typeof value === 'object' && 'isTexture' in value) {
(value as THREE.Texture).dispose();
}
}
m.dispose?.();
}
});
}
/**
* Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and
* the cost is quadratic; on a 3x phone an uncapped full-screen shader is the
* single most common cause of a janky first paint.
*/
export function pixelRatio(max = 2): number {
if (typeof window === 'undefined') return 1;
return Math.min(window.devicePixelRatio || 1, max);
}
/**
* Whether WebGL is actually available. Some browsers expose the constructor
* but fail to create a context (blocklisted GPU, no hardware acceleration);
* a component that assumes success renders a permanently blank box.
*/
export function hasWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl2') ??
canvas.getContext('webgl') ??
canvas.getContext('experimental-webgl');
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
return true;
} catch {
return false;
}
}
API
Every parameter, its type and what happens when you change it.
function SpringLatticeField(props: { cellSize?: number; radius?: number; radialStrength?: number; flowStrength?: number; ink?: readonly [number, number, number]; line?: readonly [number, number, number]; glow?: readonly [number, number, number]; maxDpr?: number; className?: string }): JSX.Element| Parameter | Type | Default | Description |
|---|---|---|---|
| cellSize | number | = 18 | CSS pixels per lattice cell. Cost is quadratic in this. |
| radius | number | = 5.5 | Pointer influence radius, in lattice cells. |
| radialStrength | number | = 0.02 | Outward push per fixed step, in cells/step². |
| flowStrength | number | = 0.08 | How strongly pointer velocity drags cells along with it. |
| ink | readonly [number, number, number] | = [0.035, 0.038, 0.048] | Base field colour, linear 0..1 RGB. |
| line | readonly [number, number, number] | = [0.16, 0.17, 0.2] | Grid line colour, added on top of ink. |
| glow | readonly [number, number, number] | = [0.35, 0.62, 0.95] | Colour added in proportion to cell speed. |
| maxDpr | number | = 2 | Upper bound on device pixel ratio. |
| className | string | — | Merged onto the host element. |
How it works
The technique in order, not the code in order.
- 01
The lattice is
round(width / cellSize)byround(height / cellSize)cells, sized in CSS pixels rather than device pixels so the simulation is identical on a retina display instead of running four times as many cells. - 02
Each step, cells within
radiusof the pointer receive two impulses: a radial one along(cell - pointer) / |cell - pointer|scaled byradialStrength, and a flow one along the pointer's own velocity scaled byflowStrength. Both are weighted byt*t*(3-2t)wheret = 1 - dist/radius— a smoothstep falloff, C1 at both ends, so the disturbance has no visible rim. - 03
Then
vel = (vel - disp * returnStiffness) * velocityDampingandnextDisp = disp + vel + (neighbourAverage - disp) * diffusion. The diffusion term is what makes it a sheet rather than a field of independent springs: a local push propagates outward as a wave instead of staying put. - 04
The integration writes into a scratch buffer, not in place. In place, the +x and +y neighbours would already hold the new value when a cell reads them, and the wave would visibly drift toward the lattice origin.
- 05
Displacement is packed into the texture as
d / DISP_MAX * 0.5 + 0.5in RG, with cell speed in B. Speed, not displacement, drives the glow — so the field lights up while it is moving and goes dark once it settles, which is what reads as elastic rather than as a permanent dent. - 06
The shader adds
disp / gridDimensionsto its uv before drawing anfwidth-normalised grid, so lines stay one pixel wide even where the field is stretching them, andu_densityis set to the lattice row count so the drawn lines land on cell boundaries.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| STEP | = 1 / 60 | Fixed simulation timestep. Every other physics constant is expressed per step — velocityDamping = 0.93 only means "keep 93% per 1/60 s" if the step is always 1/60 s. Integrating per frame instead makes the field stiffer on a 60 Hz laptop than on a 120 Hz phone. |
| MAX_SUBSTEPS | = 4 | Catch-up ceiling. Returning to a backgrounded tab otherwise tries to simulate the whole elapsed time in one frame and the page locks up for seconds. The leftover accumulator is discarded when the ceiling is hit, or the debt compounds and the simulation runs saturated forever. |
| returnStiffness | = 0.018 | Spring constant per step. Above ~0.05 the lattice snaps back before the wave can spread and it reads as rubber sheeting; below ~0.006 disturbances take several seconds to clear and the grid looks permanently dented. |
| velocityDamping | = 0.93 | Velocity retained per step. At 1.0 the lattice never loses energy and rings forever; below ~0.85 the push is absorbed within a few cells and nothing propagates. |
| diffusion | = 0.22 | Fraction of the way each cell moves toward its neighbours' average per step. This is the wave speed. Past 0.25 the explicit scheme goes unstable and the lattice fills with checkerboard noise; near 0 the cells decouple and the grid dimples pointwise. |
| radius | = 5.5 | Pointer influence, in cells. Expressed in cells rather than pixels so the physics is unchanged at any cellSize. Under ~3 the cursor pokes a hole; past ~10 it lifts the whole panel and the motion loses its centre. |
| radialStrength | = 0.02 | Sustained outward push per step. Equilibrium displacement is roughly radialStrength / returnStiffness ≈ 1.1 cells — enough for the grid to visibly part around a resting cursor without approaching the encode range. |
| flowStrength | = 0.08 | Drag along the pointer velocity. A transient impulse peaks at about impulse / (1 - velocityDamping) ≈ 14x, so this is roughly ten times smaller than the radial constant for a comparable displacement. Raise it and fast swipes clip the encode range. |
| MAX_POINTER_SPEED | = 45 | Cells per second. A pointer that teleports across the element — tab switch, window drag, the first event after a long pause — otherwise injects one enormous impulse and the whole lattice snaps in a single step. |
| DISP_MAX | = 2.5 | Encode range in cells. Displacement is clamped to it before packing into 8 bits; an unclamped value wraps the byte and the cell flips to the opposite sign, which reads as a hard pop exactly where the motion is strongest. |
| cellSize | = 18 | CSS pixels per cell. Cost is quadratic: at 6 a 1440x900 panel is 36,000 cells and the step stops fitting in a frame. Above ~30 the lattice is coarse enough that LINEAR interpolation between cells becomes visible as flat facets. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
No rAF subscription, so the lattice never steps and the pointer never disturbs it. One frame is drawn on mount and on resize showing the field at rest: the full grid, the vignette, no displacement and no glow. The component is a static grid, not a blank panel.
Cleanup
Deletes the VAO, vertex buffer, field texture and program, then loseContext(gl). The typed arrays are dropped with the lattice ref. The rAF subscription, resize observer and pointer listener unsubscribe with their hooks.
Definition of done
Rest the cursor on the field and the grid parts around it by roughly one cell and holds there. Move it and a wave spreads outward through the lines and settles within about a second. Swipe fast and the lines are dragged along the swipe direction, glowing while they move and going dark once they stop. Grid lines stay one pixel wide even where they are most stretched. Throttle the CPU to 6x and the wave still travels at the same speed, just at fewer frames.
Pitfalls
- Integrating per frame instead of on a fixed accumulator. The constants above are tuned per 1/60 s; run them per frame and the same component is noticeably stiffer at 60 fps than at 120, and unstable under a frame spike. This is the one place the tuned constants and the frame-rate independence rule collide, and the accumulator is the resolution.
- Carrying the accumulator forward after hitting
MAX_SUBSTEPS. The debt can never be repaid, so the simulation runs four substeps every frame from then on and quietly costs four times what it should. - Diffusing in place. Cells read their +x and +y neighbours after those neighbours have already been updated, so the wave propagates faster in one diagonal than the other and visibly drifts toward the lattice origin.
- Setting the field texture to NEAREST. The lattice itself becomes visible as hard blocky steps — at
cellSize18 that is a grid of 18 px squares sliding under the drawn grid. - Calling
texImage2Devery frame instead oftexSubImage2D. Re-specifying the texture makes the driver re-validate and frequently reallocate it; the symptom is a GPU memory graph that climbs for as long as the page is open. - Failing to clamp before encoding. Displacement past
DISP_MAXwraps the unsigned byte, so a cell being pushed hard right suddenly reads as pushed hard left, and the field pops. - Sizing the lattice in device pixels. A retina display quietly runs four times the cells for identical visual output, and the component drops frames only on the machines its author is least likely to be testing on.
- Heavy pointer smoothing. The flow push is built from the derivative of the pointer position, and over-smoothing flattens that derivative — the lattice stops responding to flicks while still responding to slow drags, which reads as lag rather than as smoothing.
- Leaving the context alive on unmount. Raw GL has no
dispose(); the VAO, buffer, texture and program are deleted explicitly andloseContext(gl)hands the context back, or the page starts blanking its oldest canvases after roughly sixteen mounts.
Notes
- The simulation could run on the GPU with ping-pong framebuffers, and at this lattice size it would be slower overall: two extra render targets, a readback whenever anything else needs the field state, and float-texture support to feature detect. A few thousand cells on the CPU cost under 0.2 ms and stay inspectable in a debugger.
- Resizing rebuilds the lattice from rest. Interpolating the old state into the new dimensions is possible but the disturbance is gone within a second of a resize anyway.