# Making three.js bloom 9x faster with the Call of Duty blur — Tobi Moccagatta

> three.js renders a fixed 5-mip chain of separable Gaussians every frame and ignores your radius. The Advanced Warfare downsample/upsample took bloom from 8.2ms to 0.9ms.

- Site: https://tobis.vision — Tobi Moccagatta, Creative Developer at basement.studio
- Canonical: https://tobis.vision/notes/three-js-bloom-jimenez-dual-filter
- Contact: contact@tobis.vision

_Published 2026-08-19 · 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:

| mip | radius | taps |
| --- | --- | --- |
| 0 | 6 | 22 |
| 1 | 10 | 38 |
| 2 | 14 | 54 |
| 3 | 18 | 70 |
| 4 | 22 | 86 |

**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.

---

## Other pages

- [Creative Developer](https://tobis.vision) — hey, I'm tobi. creative developer at basement.studio, working on shaders and 3D on the web. peek into my vision. Markdown: https://tobis.vision/index.md
- [Experiments](https://tobis.vision/experiments) — A growing list of experiments across shaders, sound, scenes, and interaction. Markdown: https://tobis.vision/experiments.md
- [Works](https://tobis.vision/works) — Shaders and real-time 3D for Coinbase, Modal, Baseten, E2B and Rox at basement.studio, plus Shader Lab and Annex. Markdown: https://tobis.vision/works.md
- [About](https://tobis.vision/about) — Creative developer at basement.studio working on shaders, WebGL and WebGPU. Self-taught, in Buenos Aires, open to freelance. Markdown: https://tobis.vision/about.md
- [Contact](https://tobis.vision/contact) — How to reach Tobi Moccagatta: one email address, what to put in it, what he takes on, and current availability from Buenos Aires (UTC-3). Markdown: https://tobis.vision/contact.md
- [Privacy](https://tobis.vision/privacy) — What this site collects and what it does not: cookieless analytics, no forms, no trackers, and an in-memory rate-limit counter that expires with its window. Markdown: https://tobis.vision/privacy.md
- [Developer resources](https://tobis.vision/developers) — Machine-readable tobis.vision: llms.txt, the OpenAPI spec, the read-only content API, and the markdown representation of every page. Markdown: https://tobis.vision/developers.md
- [Notes](https://tobis.vision/notes) — Write-ups from building shaders and real-time 3D on the web: WebGPU, TSL and three.js, mostly the parts that went wrong. Markdown: https://tobis.vision/notes.md
- [Grass (2026)](https://tobis.vision/experiments/grass) — A walkable Val d'Orcia field: 790,000 instanced grass cards on a procedurally baked atlas, backlit through a translucency map, bent by a travelling wind field and parted by a displacement trail you leave behind you. Markdown: https://tobis.vision/experiments/grass.md
- [Critters](https://tobis.vision/experiments/critters) — Soft vinyl creatures raymarched from signed distance fields, morphing between forms as one continuous surface and reacting to your cursor. Markdown: https://tobis.vision/experiments/critters.md
- [LPV](https://tobis.vision/experiments/lpv) — Real-time global illumination via light propagation volumes, hand-written in TSL on WebGPU compute. Markdown: https://tobis.vision/experiments/lpv.md
- [BIP](https://tobis.vision/experiments/bip) — A holoprojector experiment with BIP, the robot influencer. Markdown: https://tobis.vision/experiments/bip.md
- [Fluid](https://tobis.vision/experiments/fluid) — A WebGPU fluid simulation ported from Pavel Dobryakov's classic to three.js TSL. Markdown: https://tobis.vision/experiments/fluid.md
- [Alpha-to-coverage, and the black edges it doesn't fix](https://tobis.vision/notes/alpha-to-coverage-foliage-atlases) — 790,000 grass cards you can walk inside cannot be depth-sorted. Alpha-to-coverage solves the ordering, a contrast curve solves mip alpha collapse, and neither touches the dark outlines. Markdown: https://tobis.vision/notes/alpha-to-coverage-foliage-atlases.md
- [Real-time GI that just brightened the walls](https://tobis.vision/notes/real-time-gi-that-just-brightened-the-walls) — A light propagation volume can solve live and still read as a brightness slider: the missing sun-patch bounce, the dynamic range propagation destroys, and the read-side falloff that gives it back. Markdown: https://tobis.vision/notes/real-time-gi-that-just-brightened-the-walls.md
- [An MCP server that lets an agent write WebGPU shaders](https://tobis.vision/notes/mcp-server-that-writes-webgpu-shaders) — You cannot render WebGPU in a Node process, so the server owns nothing and relays into a live editor tab. The compile feedback loop is what makes an agent able to write shaders at all. Markdown: https://tobis.vision/notes/mcp-server-that-writes-webgpu-shaders.md
- [MSAA on a RenderTarget breaks GTAO on WebGPU](https://tobis.vision/notes/msaa-breaks-gtao-on-webgpu) — Why three.js GTAO fails with `Invalid ShaderModule "fragment_GTAO"` the moment a RenderTarget asks for samples, and what to do instead. Markdown: https://tobis.vision/notes/msaa-breaks-gtao-on-webgpu.md

## Machine-readable

- [llms.txt](https://tobis.vision/llms.txt)
- [sitemap.xml](https://tobis.vision/sitemap.xml)
- [robots.txt](https://tobis.vision/robots.txt)
- [openapi.json](https://tobis.vision/openapi.json)
- [developer resources](https://tobis.vision/developers)
