Teardown cavempt.com Working recreation JFA R&D · Aug 2026

It's not generative.
It's one draw call
and a blur.

The Cav Empt background is a field of expanding, segmented rings drawn as GPU instances, then run through a blur and an alpha threshold — the trick that makes separate shapes melt into each other. The per-product variation isn't randomness. It's a seed.

Live recreation · WebGL2 · no libraries — arcs · — fps

Seed → identity

What it actually is

Read off the live page, not guessed

The page carries a single <canvas> inside a container classed circle-field-container, fixed to the viewport at z-index: -1 — a background plate behind the entire site. It holds a WebGL context, driven by three.js inside a Nuxt app. Nothing about it is procedural noise or a generative art library. It's a particle system with an unusual primitive and one clever post-process.

The instanced attributes give the whole game away. Each instance carries a centre, a radius, an angle, a half-arc, a half-stroke and a colour — so the primitive being drawn is not a circle but a segment of a stroked circle. One quad per arc, thousands of arcs, one draw call. The fragment shader does the rest: distance from the instance centre compared against the radius gives the stroke band, the angle relative to the instance's own angle clips it to a sweep, and a smoothstep on the result antialiases the edge.

What's in the shaderTypeWhat it means
aCornervec2The quad corner. The only non-instanced attribute — four vertices, reused for every arc.
iCentervec2Per-arc centre. Because it's per-arc and not per-ring, segments of one ring can drift apart — that's the jitter.
iRadiusfloatCurrent radius. Grows over the ring's life.
iAnglefloatWhere on the ring this segment sits. Advances over time = spin.
iHalfArcfloatHalf the sweep. The parameter that turns a ring into dashes.
iHalfStrokefloatHalf the line weight, in pixels.
iColorvec3Per-arc colour, drawn from the palette.
uTex / uDir / uRadius / uThreshold—A second program: sample a texture along a direction, then threshold. This is the post-process.

The four passes

Blur plus threshold is the whole look

Pass 1

Draw arcs

Instanced quads, one per segment. Crisp, separate, a bit thin and mechanical on its own.

Pass 2

Blur

Separable — horizontal then vertical, repeated. Colours bleed into each other here, which is where the palette starts to mix.

Pass 3

Threshold alpha

Cut the blurred alpha at a hard edge. Anything that overlapped now reads as one continuous body.

Pass 4

Composite

Un-premultiply to recover the mixed colour, lay it on the background.

Pass 3 is the one that matters, and it's an old trick — the "gooey filter", usually done in CSS with an SVG feGaussianBlur into an feColorMatrix that steepens the alpha channel. Blur spreads each shape's alpha into a soft halo; where two halos overlap the alpha sums past the threshold; the threshold snaps it back to a hard edge. The result is that shapes near each other fuse with a real fillet between them, like surface tension. Shapes far apart stay separate. Nothing computes the merge — it falls out of the arithmetic.

// passes 3 and 4, in full
vec4  c   = texture(uTex, vUv);
vec3  rgb = c.a > 0.002 ? c.rgb / c.a : vec3(0.0);   // un-premultiply
float g   = min(c.a * uGain, 1.0);                  // put back what the blur took
float a   = smoothstep(uThreshold - uSoft, uThreshold + uSoft, g);
frag = vec4(mix(uBg, rgb, a), 1.0);

The uGain line is the one that isn't obvious, and it's the reason my first build rendered a completely black frame. Blurring a 9-pixel stroke over a 40-pixel kernel divides its peak alpha by roughly four, so nothing ever reaches a threshold of 0.34 and the whole field cuts to background. The SVG version of this filter has the same term hiding in plain sight — the classic feColorMatrix recipe multiplies the alpha channel by about 18 before subtracting 7. That multiply isn't stylistic. Without it there is no image.

Why WebGL and not SVG here. The gooey filter is famously cheap to write in SVG and famously expensive to run — an animated feGaussianBlur over a full-viewport region is per-frame raster work that mostly isn't GPU-accelerated, and it will melt a laptop fan at 1400×800. Moving it to a shader makes the blur nearly free and lets the arc count go into the thousands. This is exactly the escalation ladder from the animation review: stay in SVG until a specific effect makes it impossible, then move that one layer.

Where the per-item variation comes from

Seeded, not random

The component takes overrides — background colour, emitter positions, and a parameter block — so a product page can hand it a different configuration than the homepage. But the interesting version of this, and the one worth stealing, is to derive the configuration from the item's own identifier. Hash the product handle into a 32-bit integer, run a small deterministic PRNG off it, and pull the palette and parameters from that stream.

// deterministic: same handle, same field, forever, on every device
function hash(str){
  let h = 2166136261;
  for (const ch of str) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
  return h >>> 0;
}
const rng  = mulberry32(hash("washed-cn-shoulder-bag"));
const hue  = rng() * 360;
const segs = 3 + Math.floor(rng() * 7);

Type a different handle into the box above and the whole field re-derives: palette, segment count, spin direction, arc gaps. It never repeats for the same string and never differs across two visitors. That's the property that makes it usable as identity rather than decoration — a customer who sees the pink-and-acid version of a product and comes back tomorrow sees the same one.

How we'd use it

Four honest applications, one warning

1 · A generative mark per record

The strongest version for our work isn't a shop background — it's a distinct visual identity per item in a catalogue, generated from the item's key. A neighbourhood in a civic dataset, a dataset in a library, a speaker in a programme, a chapter in a report. Seeded from the name, so it's stable, needs no design time per item, and stays recognisably part of one system because every instance shares the same generator and parameter ranges. This is the cheapest way to give five hundred things individual character without drawing five hundred things.

2 · Parameters bound to data, not to a PRNG

Swap the random stream for real variables and the decoration becomes an encoding. Segment count from a category count; ring interval from event frequency; expansion speed from rate of change; palette from a sequential ramp rather than a random hue. The caution is the same one as the sphere shading test: the moment a viewer could plausibly read meaning into a channel, that channel has to actually carry meaning — or be visibly constant. A field where radius means nothing but looks like it means something is worse than a field that's obviously ornamental.

3 · The threshold as a density estimator

This is the genuinely interesting one. Blur-then-threshold is a kernel density estimate with a hard contour — the blur is the kernel, the threshold is the isoline. Feed it real point data instead of emitted rings and the merging behaviour stops being decorative and starts being a legitimate reading of spatial clustering: marks that fuse are marks within a bandwidth of each other. It's a live, GPU-speed alternative to a contour plot, and it inherits the same caveat — the bandwidth is a choice, and the choice changes the story.

4 · A poster engine

Because the renderer is resolution-independent and seeded, the same code that fills a browser background will render a 300 dpi still. Set a seed, freeze a frame, output at print size. For Data Vandals that's a run of unique posters — one per participant, per event, per response — from one script.

The warning. This treatment is strongly associated with one brand right now, and the arc-plus-goo combination is recognisable enough that using it straight will read as a Cav Empt homage rather than as our work. The parts worth taking are the architecture — instanced primitives, a post-process that creates relationships between marks, and a seed that ties output to identity. The primitive itself should be something else. Arcs are their answer. Ours might be line segments, glyph fragments, isolines, or the actual geometry of the data we're given.

What it costs

Budget, for once, is not the problem

One draw call for the whole field regardless of arc count. Two to six full-screen passes for the blur depending on radius. At the default settings above, roughly —. The instance buffer is rebuilt on the CPU each frame, which is the only part that scales linearly with arc count, and it's a tight loop over a Float32Array — a few thousand arcs is nothing. The practical limit is fill rate on the blur passes, not geometry, so the lever when it gets slow is blur at half resolution, not fewer arcs.

Recreated here in about 300 lines of raw WebGL2 with no dependencies, because the whole thing is two shader programs and a ring buffer. Cav Empt reach for three.js, which is a reasonable call inside a Nuxt app that may want more 3D later — but it means shipping ~130 KB gzip of renderer to draw some arcs. If a field like this were the only WebGL on a site, hand-rolling it is a fraction of the weight.

← Shader Playground