Making three.js bloom 9x faster with the Call of Duty blur

webgpu, three-js, tsl, performance, post-processing

Shader Lab was dropping frames on projects that had no business dropping frames. The profiler kept pointing at the same thing, and it was not the shaders people write in the editor. It was the bloom — the one three.js ships.

Replacing it with the progressive downsample/upsample from Call of Duty: Advanced Warfare took bloom's marginal frame cost from 8.2ms to 0.9ms. The output looks the same.

What three.js ships

BloomNode builds a fixed five-mip chain and blurs each mip with a separable Gaussian — one horizontal pass, one vertical pass:

_nMips = 5
kernelSizeArray = [6, 10, 14, 18, 22]

Those are kernel radii. The blur takes one centre tap and then loops to the radius, sampling both sides each iteration, so a radius of k costs 2k − 1 taps per direction. Two directions per mip:

mipradiustaps
0622
11038
21454
31870
42286

270 texture fetches across 10 render passes, every frame.

The part that actually hurt: none of that depends on your bloom radius. The mip count is a constant and the kernel sizes are a literal array. Turning the radius down changes how the result looks, not what it costs.

Then multiply. Six layer types in Shader Lab use bloom — crt, bloom, pattern, ink, halftone and particle-grid — and each one built its own BloomNode. A project with a few of those was running that entire fixed chain several times per frame, at full cost, to produce glows nobody asked to be that expensive.

What Advanced Warfare does instead

Jorge Jimenez's SIGGRAPH 2014 talk describes bloom as a mip pyramid you walk down and then back up, with small filters at every step instead of one big filter at each level. Downsample with 13 taps, upsample with a 9-tap tent, add the result into the finer level on the way back up.

The downsample is five overlapping 2×2 boxes — centre, four diagonals, four cardinals at distance 2, four corners at distance 2:

center   0.125
inner    0.125 each   (±1, ±1)
cardinal 0.0625 each  (±2, 0) (0, ±2)
corners  0.03125 each (±2, ±2)

Those sum to exactly 1, so the chain is energy-preserving without a normalisation step. The upsample is a 3×3 tent — 0.25 centre, 0.125 edges, 0.0625 corners — added onto the finer mip.

Wide blur comes from the pyramid, not from wide kernels. That is the whole trick, and it is why the result is visually indistinguishable: a Gaussian of radius 22 at mip 4 and a tent filter walked up five levels are approximating the same falloff. One costs 86 taps at that level, the other costs 9.

Radius that means something

Because reach now comes from depth rather than kernel width, the radius dial can control how many levels actually render:

activeLevels = MIN_ACTIVE_LEVELS + round(radius * span)

A small radius renders two levels — a prefilter, one downsample, one upsample. That is 23 texture fetches in 3 passes, against a fixed 270 in 10. A large radius walks the full pyramid down to a 4-pixel mip. The dial is now a performance control as well as a look control, which is the honest thing for it to be.

The six layers share one instance now, so the chain runs once per frame no matter how many bloom-using layers are stacked.

Two things that bit

Additive chains need normalising. Each upsample adds into the level below it, so total energy scales with how many levels ran. Turning the radius up made the image brighter as well as softer. The chain is divided by the active level count, so changing radius changes reach and not exposure.

And they need clamping. The CRT layer feeds its output back through a temporal persistence buffer. An unbounded additive chain inside a feedback loop does exactly what you would expect: every frame is a little brighter than the last until the whole thing is white. The compositor clamps the bloom result, and bloomIntensity is capped at 2 — enforced in the compositor rather than the slider, because the layer store writes parameters straight through without clamping them.

The lesson I would take

The cost was never in the technique, it was in a fixed chain that ignored the one parameter users actually touch. Before optimising a post-processing pass, check what its quality dial is wired to. If turning it down does not make anything faster, it is not a quality dial — it is a look dial with a fixed price, and that price is being paid on every frame whether the effect needs it or not.