The problem: density fights clarity
Every particle system built on sprites or GL points hits the same wall. To get density you stack thousands of additive billboards, and the moment they overlap they blend. Overdraw turns the image into fuzz, bright edges bloom into fireflies, and "empty" space is never actually black because dozens of near-transparent quads are still accumulating there. You can tune it for hours and barely move the ceiling, because the ceiling is the rendering model, not the parameters.
I wanted photographic density: hundreds of thousands of points, each a sharp, individually-resolvable speck, with true black where there is nothing. On WebGL that is not a tuning problem. It is impossible by API.
The idea: stop blending, start compositing
Instead of drawing particles as geometry and letting the blender sort it out, treat the frame as a buffer in memory and composite into it by hand. One GPU compute thread per particle:
- Project the particle to a pixel.
- Pack it into one 32-bit word: a 16-bit depth in the high half, an
rgb565color in the low 16. atomicMaxthat word into a storage-buffer framebuffer at the pixel's flat index.
Depth is stored inverted (1 - ndc.z), so the nearest particle produces the largest word. That means a single atomicMax performs the depth test and the composite in one operation: nearest-wins per pixel, deterministically, with no blending and no draw-order dependence. Fireflies are eliminated structurally, exactly one particle writes each pixel, so there is nothing to stack. A second atomic buffer counts hits per pixel, which becomes a controlled glow term in the resolve instead of uncontrolled additive bloom.
Then a fullscreen pass reads the framebuffer back. One quad, whose fragment takes the integer screenCoordinate, turns it into the same flat index, reads the packed word straight out of the storage buffer, and unpacks depth and color. Zero hardware overdraw. Exactly one particle decides each pixel. Crisp specks, true black voids, roughly 10x more particles than the sprite approach can hold before clarity collapses.
This is software rasterization, the technique that predates hardware rasterization, moved onto the GPU because compute shaders finally allow the scattered atomic writes to memory that the fixed-function pipeline will not give you.
Why WebGL cannot do this
A hard API boundary, not a performance gap. WebGL2 has no compute stage, no writable storage buffers, and no atomics. There is no way to express "every particle scatter-writes into a shared framebuffer with a depth-aware atomic." WebGPU added all three. This isn't WebGL tuned harder, it's a different class of renderer that cannot exist without compute and storage atomics. The whole thing is TSL (Three.js Shading Language, r184) compiling to WGSL on three/webgpu.
The parts that actually hurt
The headline is simple. The reasons it took real time are the WebGPU-specific traps:
- The resolve must read the buffer, not a texture. My first instinct was to composite into a
StorageTextureand sample it in the resolve. A WebGPU StorageTexture is bound as storage, not as a sampled texture, sotexture(storageTex, uv)reads garbage. That cost two iterations and a triangular wedge artifact before the fix: drop the texture entirely and read the framebuffer buffer by integerscreenCoordinatein the fullscreen fragment. - WebGPU storage atomics are integer-only. There are no float atomics. The engine also runs a coarse 64×36×28 velocity grid for particle cohesion: particles scatter velocity and mass into the grid (particle-to-grid), the grid normalizes, particles sample it back (grid-to-particle). Doing that with int-only atomics means fixed point: 12.12, scale 4096, with cell sums kept under 2^31 so they never overflow the signed int. A GPU velocity solver built on a quirk of the atomics spec.
- You run out of storage-buffer bindings. WebGPU's default is 8 storage buffers per shader stage; a 9th fails pipeline creation. The raster kernel was over the line, so I consolidated the two morph targets (the start and end of each particle's position lerp) into a single buffer addressed by halves, dropping the bound count from 9 to 8 and getting the pipeline to build. Systems work, not shader art.
- One buffer, two binding layouts. The compute stage needs the framebuffer as
read_writeatomic; the fragment stage needs it as read-only storage. SameGPUBuffer, two incompatible access qualifiers. The fix is two TSL node views over the identical buffer, so compute writes and the resolve reads the exact same memory with no copy and no blit between them. - Depth of field on a fixed budget. Bokeh deposits each particle into a small, fixed, unrolled set of disk taps sized by its circle of confusion: 2 rings, 16 taps max, a 6px CoC cap, with the energy split across live taps so brightness is conserved. Bounded work per particle keeps it in budget instead of a variable-cost blur. The hard rule there is energy conservation, breaking it is how you get a black field, which I learned the direct way.
Where it stands
It ships at 420,000 particles at a steady 60fps at the device's real framebuffer resolution, and it is the hero of fifteenthmeridian.com. That is the conservative setting for broad-device performance; the engine scales past a million particles at 60fps on a strong GPU. Honestly it is the small end of what I have been building, there is a lot more that isn't public yet.
To be straight about lineage: this is atomic-min/max framebuffer compositing, the compute point-cloud rasterizer family. The idea is not a secret, it has prior art and other studios have shipped their own. The work here is the execution: the inverted-depth packed-word composite, the int-atomic velocity grid, the binding-layout and buffer-budget engineering, and holding a clean 60fps through all of it. Once it runs, the shader is recoverable by anyone who wants to read it. That is fine. The interesting part was never the bytes, it was building the thing.