Bloom

Multi-level bloom post-processing

Quick Start

import { createBloom } from './webgpu-market/bloom/bloom';

const bloom = createBloom(device, { format: 'rgba8unorm' });

// Each frame — apply bloom from input texture to output texture
bloom.render(inputTexture, outputTexture, {
  threshold: 0.8,
  strength: 1.0,
  radius: 0.5,
});

bloom.destroy();
Source
// Multi-level bloom — threshold, downsample (Jimenez 13-tap), Gaussian blur, tent upsample, composite
// Based on the Call of Duty: Advanced Warfare bloom presentation (Jimenez 2014)

struct BloomUniforms {
  threshold: f32,
  soft_knee: f32,
  strength: f32,
  radius: f32,
  texel_size: vec2<f32>,
  direction: vec2<f32>,
}

@group(0) @binding(0) var<uniform> u: BloomUniforms;
@group(1) @binding(0) var src_texture: texture_2d<f32>;
@group(1) @binding(1) var src_sampler: sampler;
@group(1) @binding(2) var blend_texture: texture_2d<f32>;

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) uv: vec2<f32>,
}

@vertex
fn vs_quad(@builtin(vertex_index) vi: u32) -> VertexOutput {
  var pos = array<vec2<f32>, 6>(
    vec2(-1.0, -1.0), vec2( 1.0, -1.0), vec2(-1.0,  1.0),
    vec2(-1.0,  1.0), vec2( 1.0, -1.0), vec2( 1.0,  1.0),
  );
  var uvs = array<vec2<f32>, 6>(
    vec2(0.0, 1.0), vec2(1.0, 1.0), vec2(0.0, 0.0),
    vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(1.0, 0.0),
  );
  var out: VertexOutput;
  out.position = vec4(pos[vi], 0.0, 1.0);
  out.uv = uvs[vi];
  return out;
}

fn luminance(c: vec3<f32>) -> f32 {
  return dot(c, vec3(0.2126, 0.7152, 0.0722));
}

// 1. Extract bright regions with soft-knee threshold
@fragment
fn fs_threshold(in: VertexOutput) -> @location(0) vec4<f32> {
  let color = textureSample(src_texture, src_sampler, in.uv);
  let lum = luminance(color.rgb);
  let knee = u.threshold * u.soft_knee;
  let contribution = smoothstep(u.threshold - knee, u.threshold + knee, lum);
  return vec4(color.rgb * contribution, 1.0);
}

// 2. 13-tap Jimenez downsample (CoD:AW technique) — high quality, anti-flicker
@fragment
fn fs_downsample(in: VertexOutput) -> @location(0) vec4<f32> {
  let t = u.texel_size;
  let uv = in.uv;

  let a = textureSample(src_texture, src_sampler, uv);
  let b = textureSample(src_texture, src_sampler, uv + vec2(-0.5, -0.5) * t);
  let c = textureSample(src_texture, src_sampler, uv + vec2( 0.5, -0.5) * t);
  let d = textureSample(src_texture, src_sampler, uv + vec2(-0.5,  0.5) * t);
  let e = textureSample(src_texture, src_sampler, uv + vec2( 0.5,  0.5) * t);
  let f = textureSample(src_texture, src_sampler, uv + vec2(-1.0, -1.0) * t);
  let g = textureSample(src_texture, src_sampler, uv + vec2( 0.0, -1.0) * t);
  let h = textureSample(src_texture, src_sampler, uv + vec2( 1.0, -1.0) * t);
  let i = textureSample(src_texture, src_sampler, uv + vec2(-1.0,  0.0) * t);
  let j = textureSample(src_texture, src_sampler, uv + vec2( 1.0,  0.0) * t);
  let k = textureSample(src_texture, src_sampler, uv + vec2(-1.0,  1.0) * t);
  let l = textureSample(src_texture, src_sampler, uv + vec2( 0.0,  1.0) * t);
  let m = textureSample(src_texture, src_sampler, uv + vec2( 1.0,  1.0) * t);

  var result = a * 0.125;
  result += (b + c + d + e) * 0.125;
  result += (f + g + i) * (1.0 / 16.0);
  result += (g + h + j) * (1.0 / 16.0);
  result += (i + k + l) * (1.0 / 16.0);
  result += (j + l + m) * (1.0 / 16.0);

  return result;
}

// 3. 9-tap separable Gaussian blur
@fragment
fn fs_blur(in: VertexOutput) -> @location(0) vec4<f32> {
  let dir = u.direction * u.texel_size * u.radius;

  var result = textureSample(src_texture, src_sampler, in.uv) * 0.2270270270;
  result += textureSample(src_texture, src_sampler, in.uv + dir * 1.0) * 0.1945945946;
  result += textureSample(src_texture, src_sampler, in.uv - dir * 1.0) * 0.1945945946;
  result += textureSample(src_texture, src_sampler, in.uv + dir * 2.0) * 0.1216216216;
  result += textureSample(src_texture, src_sampler, in.uv - dir * 2.0) * 0.1216216216;
  result += textureSample(src_texture, src_sampler, in.uv + dir * 3.0) * 0.0540540541;
  result += textureSample(src_texture, src_sampler, in.uv - dir * 3.0) * 0.0540540541;
  result += textureSample(src_texture, src_sampler, in.uv + dir * 4.0) * 0.0162162162;
  result += textureSample(src_texture, src_sampler, in.uv - dir * 4.0) * 0.0162162162;

  return result;
}

// 4. 9-tap tent upsample + additive combine with current level
@fragment
fn fs_upsample(in: VertexOutput) -> @location(0) vec4<f32> {
  let t = u.texel_size;
  let uv = in.uv;

  var bloom = textureSample(src_texture, src_sampler, uv + vec2(-1.0, -1.0) * t) * (1.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv + vec2( 0.0, -1.0) * t) * (2.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv + vec2( 1.0, -1.0) * t) * (1.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv + vec2(-1.0,  0.0) * t) * (2.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv                        ) * (4.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv + vec2( 1.0,  0.0) * t) * (2.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv + vec2(-1.0,  1.0) * t) * (1.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv + vec2( 0.0,  1.0) * t) * (2.0 / 16.0);
  bloom += textureSample(src_texture, src_sampler, uv + vec2( 1.0,  1.0) * t) * (1.0 / 16.0);

  let current = textureSample(blend_texture, src_sampler, uv);
  return current + bloom;
}

// 5. Final composite: original + bloom × strength
@fragment
fn fs_composite(in: VertexOutput) -> @location(0) vec4<f32> {
  let original = textureSample(src_texture, src_sampler, in.uv);
  let bloom = textureSample(blend_texture, src_sampler, in.uv);
  return vec4(original.rgb + bloom.rgb * u.strength, original.a);
}
Documentation

bloom

Multi-level bloom post-processing. Extracts bright regions, builds a mipmap pyramid with Jimenez 13-tap downsampling, applies separable Gaussian blur at each level, upsamples with tent filtering, and composites the result back onto the original image.

API

createBloom(device, options?)

  • deviceGPUDevice
  • options.format — Texture format. Default 'rgba8unorm'.
  • options.levels — Number of mip levels in the bloom pyramid. Default 5.

Returns { render, destroy }.

render(input, output, params?)

Applies bloom from input texture to output texture. Both must have TEXTURE_BINDING usage; output must also have RENDER_ATTACHMENT.

  • params.threshold — Brightness threshold for bloom extraction. Default 0.8.
  • params.strength — Bloom intensity multiplier. Default 1.0.
  • params.radius — Blur radius scale. Default 0.5.
  • params.softKnee — Soft threshold transition width. Default 0.5.
Further Reading

Further Reading

Rationale

Bloom simulates the way bright light bleeds into surrounding areas in cameras and the human eye. It's one of the most common post-processing effects in games and real-time graphics — any scene with HDR lighting, emissive materials, or bright light sources benefits from bloom. Without it, bright areas look flat and clipped.

This module implements a production-quality bloom pipeline used in shipped AAA games, in a single self-contained WebGPU file.

Original Research

  • Jorge Jimenez (2014) — Next Generation Post Processing in Call of Duty: Advanced Warfare — The presentation that introduced the 13-tap downsample filter used in this module. The key insight is using a weighted combination of 13 bilinear taps instead of a simple box filter, which prevents aliasing and fireflies in the bloom. https://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare/

  • Kawase (2003) — Frame Buffer Postprocessing Effects in DOUBLE-S.T.E.A.L — An earlier approach using iterative dual-blur that's cheaper but lower quality. Good context for understanding the design space.

Existing Implementations

Further Learning

  • LearnOpenGL: Bloom — Step-by-step tutorial explaining the bloom pipeline with diagrams. https://learnopengl.com/Advanced-Lighting/Bloom
  • Alexander Christensen: HDR Bloom — Practical guide to implementing bloom in a modern rendering pipeline, covers threshold, downsampling, and compositing.