Positions and velocities live in textures, not in arrays. Each frame, a fragment shader reads the current state, applies the forces, and writes the next state into a second pair of textures, which are then swapped. The particle count becomes a texture dimension, so going from ten thousand points to two hundred thousand is a resolution change and not a rewrite, and the main thread cost stays flat either way.
Rendering the result uses a single instanced draw call. Every point reads its own position from the state texture in the vertex shader, so there is one geometry upload for the whole field and no per-particle work in JavaScript at any stage.
The forces are deliberately few: a gentle curl noise field for ambient drift, an attraction toward each point’s home position so the field recovers its shape, and a radial force at the pointer. Three forces was not the starting point — earlier versions had six, and the motion read as busy, not alive. Taking forces away improved it; adding more was the instinct I had to argue myself out of.
The additive glow is where the frame budget actually goes. Overlapping translucent points cost fill rate, not arithmetic, so the cost scales with screen area and not with particle count. That meant a resolution ceiling on high-density displays: the simulation renders below native and upscales, which is invisible on a soft glow and is the single change that made it viable on a phone.
Reduced motion is handled by rendering one settled frame of the field; the piece is never removed. The composition is intact and static. Someone who asked not to be moved gets the image, not an empty container.