Loading
The renderer is not decoration. It makes distance, motion, relationships, and failure legible without changing the physics underneath.
Launch the simulation
0404 / 09The physics engine does not know that a particle glows.
It knows five typed arrays: x, y, vx, vy, and type. It knows which same-type pairs lie within thirty pixels. It knows nothing about bloom, trails, grids, color halos, or the black field behind the world.
That separation is deliberate. The renderer is allowed to make the state easier to see. It is never allowed to make the state more interesting by changing it.
The distinction matters because visual polish can quietly become a scientific lie. A long trail can make random drift look purposeful. Heavy bloom can merge nearby points into a structure the physics has not produced. Connection lines can suggest bonds where the model defines only proximity.
The rendering problem was therefore not “make it beautiful.” It was: make relationships legible, preserve the raw state, and fail without taking the experiment with you.
Lines are amplified here so their rule is inspectable.
Connections are always derived: same type, direct screen distance ≤30 px, one line per pair, capped globally at 8,192. Trails are three prior frame buffers at 0.30, 0.10, and 0.03 brightness and disable automatically above 1.5×.
Particle Life defines one small renderer interface. Both WebGPU and WebGL2 implement it:
interface Renderer {
render(view: RenderView): void;
resize(width: number, height: number): void;
setPointSize(size: number): void;
setTrails(enabled: boolean): void;
onLost(callback: () => void): void;
destroy(): void;
}The simulation loop does not branch through WebGPU-specific physics. It hands either renderer the same positions, types, and optional connection segments. WebGPU is attempted first. If the browser has no adapter, device creation fails, or recovery requires a safer path, the system constructs WebGL2 instead.
This is graceful degradation in the literal sense: visual technique may degrade; behavior does not.
WebGL2 can render GL_POINTS and set point size in the vertex shader. WGSL's WebGPU pipeline has no equivalent programmable point-size primitive. Each particle is therefore an instance of a six-vertex quad—two triangles—expanded around its position by the vertex shader.
The GPU reads particle position and type from a storage buffer. Instance index selects the particle; vertex index selects one of six quad corners. The fragment shader calculates radial distance from the quad center, discards the corners, and shades a soft circular glow.
That sounds more expensive than drawing points, but instancing keeps the draw compact: one geometry pattern, repeated for every particle, with no CPU-built mesh per frame.
The final canvas is not drawn in one pass.
WebGL2 fallback stops before multi-pass bloom
Lines, optional trail ghosts, and live particles first build one HDR scene. Bloom is derived from that scene at quarter resolution. The final pass draws the grid, then adds the sharp scene and bloom. WebGL2 keeps lines, trails, glow, and grid but omits multi-pass bloom.
First, connection lines, trail ghosts, and live particles render into an HDR scene texture. Then the scene is downsampled to one quarter resolution for bloom. A separable Gaussian blur runs horizontally into one texture and vertically back into another. The final pass clears the screen, draws the procedural grid, composites the sharp scene, and adds the blurred bloom.
Quarter-resolution bloom is a useful trade. Glow is spatially broad, so it does not need full-resolution detail. Reducing each dimension by four makes the blur operate on one sixteenth of the pixels while producing the visual information it is meant to carry.
Render rule: Preserve precise position in the sharp scene; let bloom communicate density and energy around it.
Each backend keeps a four-slot ring of particle buffers: the live frame plus three previous frames. Older frames render at 0.3, 0.1, and 0.03 brightness before the current state.
This is not motion blur derived from velocity. It is short visual memory. A moving particle leaves three sampled positions, making direction visible without drawing an arrow on every point.
At speeds above 1.5×, the simulation loop disables trails. Faster simulated time increases the distance between stored frames until ghosts stop describing a path and begin producing unrelated duplicates. Removing the effect is more honest than keeping it because it looks energetic.
Same-type particles within thirty screen-space pixels can be joined by faint lines. The engine finds these pairs with a dedicated spatial hash because the line radius differs from the force radius.
Three constraints protect the meaning and frame budget:
i < j.Pairs that are close only across the toroidal seam are intentionally skipped. Drawing the mathematically short wrapped connection on a flat canvas would produce a line spanning the entire screen. The physics remains toroidal; the visual overlay chooses screen-space clarity.
This is a useful reminder that a visualization may make a different boundary decision from the model, provided the difference is explicit and does not feed back into physics.
The fallback uses GL_POINTS, buffer updates, additive blending, the same particle palette, trail ring, and connection segments. It does not reproduce the full multi-pass WebGPU bloom pipeline. Instead, each particle shader produces its own radial glow.
The result is less luminous at large density, but it retains the information hierarchy: precise colored positions, visible movement, local same-type proximity, and a stable world.
Fallback quality matters because WebGPU availability is not binary. A browser may expose the API while the adapter is unavailable. A software device may be too weak. A GPU can be lost after the simulation has already run for minutes.
A canvas configured for WebGPU cannot be reconfigured as WebGL2. When the GPU device dies, React mounts a fresh canvas element and the existing simulation loop binds the WebGL2 renderer to it.
Positions, velocities, matrix, elapsed simulated time, and seed stay alive. Only the view is replaced.
That architecture was possible because Part 3 gave the world one owner. If the canvas had owned the particles, renderer failure would have meant experiment failure.
World coordinates are CSS pixels. Each renderer applies devicePixelRatio when sizing its physical canvas and converting coordinates. A click at a location therefore maps to the same world point on standard and high-density displays.
GPU buffers grow when particle capacity grows, not every frame. Typed arrays are packed into reusable staging data. Destroy releases buffers, textures, pipelines, and contexts when navigating away.
These details do not appear in the final image. That is precisely why they need design: invisible resource behavior can determine whether a full-screen experiment remains stable after repeated navigation, resize, spawn, and device-loss events.
Bloom makes dense regions visible before individual points become unreadable. Trails reveal direction. Connection lines reveal local same-type proximity. The grid provides scale. The black field preserves contrast.
Each effect answers a question. None is evidence by itself.
Open the simulation, select Galaxy, and move from 1× through 2×. Watch for the moment trails disappear. The physics does not suddenly change at 1.5×; the renderer stops making a visual claim it can no longer support.
Part 5 asks the harder question: once a pattern looks alive, how can we measure whether anything beyond appearance has actually changed?
More to read
Fixed time, deterministic seeds, a toroidal world, and the spatial hash that lets two thousand particles remain an experiment.
A beautiful pattern is a discovery prompt, not a result. The project needed measurements that could disagree with my eyes.
The matrix is not a palette or a control panel. It is a directional language for attraction, repulsion, pursuit, and structure.