Instanced Quad Particles
Four vertices and six indices are shared by every particle; three instanced attributes carry position, size, spin, alpha, tint and shape. One `drawElementsInstanced` puts the whole field on screen. Quads instead of `gl_PointSize` because point sprites are capped at 255px on many drivers, cannot be rotated, and are culled whole the instant their centre leaves the viewport — a large sprite disappears while half of it is still visible.
Instanced Quad Particles
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/instanced-quad-particles.jsonAlso installs webgl, prng, damp, use-raf, use-in-view, use-visibility, use-resize-observer, use-reduced-motion from this registry.
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.
Source
10 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| density | number | = 900 | Particles per megapixel of container area. The count is derived from this, not fixed. |
| maxCount | number | = 1800 | Population ceiling, and the size the buffers are allocated at. |
| sizeRange | readonly [number, number] | = [5, 28] | Particle size in CSS pixels. The distribution is biased toward the small end. |
| softness | number | = 0.62 | Edge width, 0..1. 0 is a hard silhouette, 1 is a pure gradient. Floored at one device pixel. |
| spin | number | = 0.5 | Peak spin in radians per second; each particle takes a signed fraction of it. |
| speed | number | = 1 | Multiplier on the whole drift, including the upward march. |
| wander | number | = 0.06 | Sinusoidal wander amplitude in field units, where 1 is the container. |
| opacity | number | = 0.55 | Overall alpha multiplier applied in the fragment shader. |
| colorA | string | = '#9fb4cc' | First tint. Any value ogl's Color accepts. |
| colorB | string | = '#e8ddc9' | Second tint; each particle mixes between the two by a seeded amount. |
| background | string | = '#07090d' | Opaque clear colour the additive field is composited onto. |
| seed | number | string | = 'instanced-quad-particles' | Seeds the scatter. Same seed, same field on every reload and on the server. |
| className | string | — | Merged onto the host element via cn. |
How it works
The technique in order, not the code in order.
The geometry is one unit quad in
position(-0.5..0.5) plus an index buffer, and three attributes markedinstanced: 1:aOrigin(field-space rest position),aParams(size in CSS px, signed spin rate, base alpha, tint mix) andaSeed(two flow phases, spin phase, shape mix). Ten floats per particle, 72 KB at the 1800 ceiling.Buffers are allocated once at
maxCountand never reallocated. The visible population comes fromgeometry.setInstancedCount(count)wherecountis derived from container area —clamp(round(megapixels * density), 250, maxCount)— so the same density reads correctly in a 320px card and a 2000px hero. Because the scatter is random, any prefix of the buffer is still a spatially even field.The vertex shader rotates the quad corner in PIXEL space and only then divides by the drawing-buffer resolution to reach clip space. Rotating in clip space would shear the quad by the container aspect ratio, and a square particle would become a lozenge that changes shape as it spins.
Drift is a pure function of
elapsedand the per-particle seed: the field marches upward withfract(y + t * RISE), then two out-of-phase sinusoids sampled at the particle's own position add wander. No per-particle state is stored, so a resize, a tab switch or a reduced-motion still can all be drawn from one number.fractteleports a particle from the top edge to the bottom, which is a visible pop.uFadeBandsmoothsteps alpha to zero within 12% of each end, so the population always arrives and leaves at zero opacity.One rounded-box SDF in the fragment shader covers both shapes. At
shape0 the half-extents and the corner radius are both 0.5, which reduces exactly tolength(uv) - 0.5— a true circle. At 1 it is a narrow shard, and the shard is the only reason per-particle rotation is visible at all.Blending is additive (
ONE, ONE) onto an opaque clear colour, which is order independent — a thousand overlapping instances need no depth sort, which is what makes one draw call possible. The canvas is opaque rather than transparent because additive blending onto transparency would have to brighten the page background, and the GPU cannot see it.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| density | = 900 | Particles per megapixel. Tuned so a typical card holds 250-400 and a full-width hero approaches the ceiling. Doubling it does not double the perceived density — it doubles the fill rate and flattens the depth cue that comes from having gaps. |
| maxCount | = 1800 | Ceiling and allocation size. Not a performance limit on the vertex side — 1800 quads is 7200 vertices, nothing — but the fill cost of 1800 soft additive quads averaging 12px is roughly one full screen of blended pixels per frame. |
| MAX_DPR | = 1.5 | Additive fill is the entire cost of this component and it scales with the square of the pixel ratio. Dust has no fine detail that a third sample per axis resolves, so 3x phones render at 1.5x and the frame budget drops by a factor of four against an uncapped ratio. |
| SIZE_BIAS | = 2.2 | Exponent on the uniform random used for size. At 1 a quarter of the population lands in the largest quarter of the range, and a hundred 28px blobs is fog rather than dust. At 2.2 the large sizes are rare enough to read as individuals. |
| RISE | = 0.045 | Field heights per second at speed 1 — about 22 seconds to cross. Slow enough that the eye reads drift rather than travel; above ~0.15 the field looks like falling snow played in reverse. |
| FADE_BAND | = 0.12 | Fraction of the field height the wrap fade occupies at each end. Below ~0.06 the pop at the seam becomes visible for the largest particles, whose radius alone is a couple of percent of the height. Above 0.2 a quarter of the field is permanently dim. |
| FLOW_SCALE | = 1.4 | Cycles of wander across the field. Under 1 the whole population slides sideways as a sheet; well above 2 adjacent particles move in opposite directions and the motion reads as noise rather than air. |
| X_MIN / X_MAX | = -0.08 / 1.08 | Field-space x range. Seeding wider than the container means horizontal wander can never bare a strip at the left or right edge. The off-screen quads are clipped by the rasteriser and cost nothing — which is exactly what a point sprite could not do. |
| alpha per particle | = 1 - 0.78 * sizeT | Large particles are dimmed so total emitted light is roughly independent of size. Without it the few big instances dominate every additive overlap and the field looks like it has hot spots. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The rAF subscription is never made and the field is drawn once at elapsed 0. That is a complete, correct frame rather than a collapsed one: the scatter, sizes, tints, shapes and spin phases are all per-particle seeds that already vary at t=0, and the wrap fade applies exactly as it does in motion. Resizing redraws the same still.
geometry.remove() deletes every VAO and every buffer, program.remove() deletes the shaders and the program, then loseContext(gl) hands the context back through WEBGL_lose_context — ogl has no dispose() and nothing else releases a context. The canvas backing store is zeroed and the node removed, so the drawing buffer goes with it rather than waiting for GC.
The field drifts upward and sideways with no visible seam at the top or bottom edge, and no particle ever appears or vanishes abruptly. Individual particles spin, and the shards are visibly rotating rather than shimmering. Resize the container narrow and wide: particles stay square at every aspect ratio, and the population changes without a stutter. One draw call per frame in a frame capture, whatever the count. Mount and unmount a dozen instances and none of the earlier ones go black.
Pitfalls
- Reaching for
gl_PointSize. It is capped — 255px on a great many drivers, and the cap is silent — it cannot be rotated, and the sprite is culled by its centre point, so anything large blinks out while half of it is still on screen. - Leaving ogl's
cullFaceat its default.Programdefaults it togl.BACK; once the quad corners are rotated in the vertex shader the winding the driver sees is not the winding you authored, and culled particles are simply absent with no error anywhere. - Rotating the corner offset after converting to clip space. The container aspect ratio shears it, so a particle is square only when the container is, and it visibly changes shape through a spin.
- Rebuilding the buffers on resize to change the particle count.
setInstancedCountselects a prefix of what is already uploaded; reallocating 72 KB of GPU memory on every ResizeObserver notification is a stall for no visual difference. - Wrapping with
fractand no fade. The seam is a hard teleport, and at 1800 particles something is crossing it constantly, so the field flickers along one edge. - Compositing an additive field onto a transparent canvas. Additive needs to know what it is adding to; with
alpha: truethe destination is zero, the result is the particle colour flat, and the field looks pasted on rather than glowing. - Reusing one React-owned canvas across effect runs. Cleanup force-loses the context, and
getContexton that same element afterwards returns the dead one — under StrictMode's mount / unmount / mount that means every instance is permanently black. ogl creates its own canvas, which is appended and removed per run. - Assuming
renderer.setSizeleaves the CSS alone. ogl writes literal px into the canvas's inlinestyle, which beats any class; the percentages have to be re-asserted after every call or the element stops being fluid.
Notes
- ogl rather than three: the whole component is one instanced mesh with no lights, no camera and no scene graph, and ogl's
Geometryexposes instanced attributes directly withinstanced: 1instead of through anInstancedBufferGeometrywrapper. - No camera is passed to
renderer.render, which is deliberate. Without one ogl skips frustum culling and the matrix uniforms entirely — the shader writes clip space itself, so a projection matrix would be an identity multiply per vertex. depth: falseon the context. Nothing occludes anything under additive blending, so a depth buffer would be memory plus a clear per frame for no result.- Sizes are authored in CSS pixels and multiplied by the pixel ratio in the shader, so the field looks identical across a dpr change and nothing needs rebuilding when one happens.
- GLSL ES 1.00 with no derivative functions: the edge anti-aliasing floor is
1.0 / sizePx, which is exact for a quad of that pixel size, so the same source compiles under a WebGL1 fallback context wherefwidthneeds an extension.