Home Games Shader Sandbox

Game Dev Mechanics: Bloom (Post-Processing) — How It Works

Drag to orbit · Scroll to zoom

Every game developer has seen it: a sword crackling with energy, neon signs reflecting off rain-slicked streets, a bonfire's warm embrace in a dark cavern. That soft, luminous glow extending beyond bright objects is bloom — one of the most widely used post-processing effects in game rendering. Bloom makes bright things feel truly bright by simulating how light behaves in real cameras and human vision.

In this article, we'll tear apart the bloom pipeline step by step, from brightness extraction to Gaussian blur to final compositing, and show you the math and shader code behind it all.

The Physics of Glow

Bloom has roots in real optics. When intense light enters a camera lens, it scatters through lens imperfections, aperture edges, and internal reflections. This scattering creates a visible halo around the light source, called veiling glare. The human eye does something similar: bright lights appear to bleed beyond their actual boundaries because of scattering in the eye's lens and vitreous humor.

Without bloom, even the brightest pixel on screen is rendered at the exact same physical dimensions as any other. A white pixel representing a 10,000-lumen lamp looks no different in size from a white pixel on a piece of paper. Bloom restores that perceptual difference, giving players an intuitive sense of relative brightness.

The Bloom Pipeline

The standard bloom algorithm is a multi-pass post-processing pipeline. Instead of drawing the final image in a single step, bloom works on the finished scene image through a series of transformations:

  • Pass 1 — Scene Render: Render the entire scene to an off-screen texture (render target) instead of the screen
  • Pass 2 — Brightness Extraction: Isolate only the pixels that exceed a brightness threshold
  • Pass 3 — Gaussian Blur: Blur the extracted bright pixels to create the glow spread
  • Pass 4 — Composite: Additively blend the blurred glow back onto the original scene

Each pass runs a fragment shader across a full-screen quad, reading from one texture and writing to another. Let's break each step down.

Step 1: Render to an Off-Screen Buffer

The first requirement is rendering the scene to a render target (also called a framebuffer object, or FBO) rather than directly to the screen. This gives us a texture of the scene that we can feed into subsequent shader passes.

For bloom to work convincingly, the render target should use a High Dynamic Range (HDR) format, typically 16-bit or 32-bit floating-point per channel. In standard 8-bit rendering, all color values are clamped to the $[0, 1]$ range. A glowing fireball and a white piece of paper both max out at $(1.0, 1.0, 1.0)$. HDR rendering preserves values above $1.0$, so that fireball might be $(5.0, 3.0, 0.2)$ while the paper remains at $(0.9, 0.9, 0.9)$. This distinction is what bloom uses to determine what should glow.

// Create an HDR render target for the scene
const sceneTarget = new THREE.WebGLRenderTarget(width, height, {
    minFilter: THREE.LinearFilter,
    magFilter: THREE.LinearFilter,
    format: THREE.RGBAFormat,
    type: THREE.HalfFloatType  // 16-bit float per channel
});

// Render scene to the target instead of the screen
renderer.setRenderTarget(sceneTarget);
renderer.render(scene, camera);

Step 2: Extract Bright Pixels

With the scene rendered to an HDR texture, the brightness extraction pass reads each pixel and determines whether it's bright enough to contribute to bloom. The standard approach calculates relative luminance using perceptual weights derived from the CIE 1931 color space:

$$L = 0.2126R + 0.7152G + 0.0722B$$

These weights reflect human visual sensitivity; we perceive green as much brighter than blue, even at the same physical intensity. If $L$ exceeds a threshold $t$, we keep the pixel; otherwise, we output black.

Rather than a hard cutoff (which creates visible edges in the bloom), a soft threshold produces smoother results:

$$\text{contribution} = \frac{\max(0,\; L - t)}{\max(L,\; \epsilon)}$$

This scales each pixel's color by how far its luminance exceeds the threshold, preserving the original color ratios and creating a gradual falloff at the boundary. Here's the fragment shader:

uniform sampler2D sceneTexture;
uniform float threshold;
varying vec2 vUv;

void main() {
    vec4 color = texture2D(sceneTexture, vUv);
    float luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));

    // Soft threshold: scale by how much luminance exceeds threshold
    float contribution = max(0.0, luminance - threshold)
                       / max(luminance, 0.0001);

    gl_FragColor = vec4(color.rgb * contribution, 1.0);
}

Step 3: Blur the Bright Regions

This is the core of bloom. A Gaussian blur spreads each bright pixel outward, simulating the light scatter that creates glow. The two-dimensional Gaussian function is defined as:

$$G(x, y) = \frac{1}{2\pi\sigma^2}\, e^{-\frac{x^2 + y^2}{2\sigma^2}}$$

where $\sigma$ (sigma) controls the spread. A larger $\sigma$ produces a wider, softer glow.

The Separable Optimization

A direct 2D Gaussian blur with an $N \times N$ kernel requires $N^2$ texture samples per pixel. For a 9-tap kernel, that's 81 samples, which is expensive for real-time rendering at full resolution. Fortunately, the 2D Gaussian is separable:

$$G(x, y) = G(x) \cdot G(y) = \left(\frac{1}{\sqrt{2\pi}\sigma}\, e^{-\frac{x^2}{2\sigma^2}}\right) \cdot \left(\frac{1}{\sqrt{2\pi}\sigma}\, e^{-\frac{y^2}{2\sigma^2}}\right)$$

This mathematical property means we can split the 2D blur into two sequential 1D passes (one horizontal, one vertical) and achieve the exact same result. The cost drops from $O(N^2)$ to $O(2N)$. For a 9-tap kernel, that's 18 samples instead of 81.

The blur shader takes a direction uniform; set it to $(1, 0)$ for horizontal, $(0, 1)$ for vertical:

uniform sampler2D inputTexture;
uniform vec2 direction;   // (1,0) or (0,1)
uniform vec2 resolution;
varying vec2 vUv;

void main() {
    vec2 texelSize = direction / resolution;

    // 9-tap Gaussian kernel (sigma ~ 2.7)
    vec3 result = texture2D(inputTexture, vUv).rgb * 0.227027;

    result += texture2D(inputTexture, vUv + texelSize * 1.0).rgb * 0.194595;
    result += texture2D(inputTexture, vUv - texelSize * 1.0).rgb * 0.194595;

    result += texture2D(inputTexture, vUv + texelSize * 2.0).rgb * 0.121622;
    result += texture2D(inputTexture, vUv - texelSize * 2.0).rgb * 0.121622;

    result += texture2D(inputTexture, vUv + texelSize * 3.0).rgb * 0.054054;
    result += texture2D(inputTexture, vUv - texelSize * 3.0).rgb * 0.054054;

    result += texture2D(inputTexture, vUv + texelSize * 4.0).rgb * 0.016216;
    result += texture2D(inputTexture, vUv - texelSize * 4.0).rgb * 0.016216;

    gl_FragColor = vec4(result, 1.0);
}

The kernel weights are calculated from the Gaussian function $G(x) = e^{-x^2 / (2\sigma^2)}$ and then normalized so they sum to $1.0$, ensuring overall brightness is preserved.

Multi-Scale Blur for Wider Glow

A single blur pass at full resolution produces a relatively tight glow. For the wide, dreamy bloom seen in many games, you need either a very large kernel (expensive) or a multi-scale approach. The most common technique progressively downsamples the bright pixel texture:

  • Blur at $\frac{1}{2}$ resolution — produces a narrow glow
  • Blur at $\frac{1}{4}$ resolution — produces a medium glow
  • Blur at $\frac{1}{8}$ resolution — produces a wide glow

Combining these creates a multi-layered bloom with both tight bright cores and soft wide halos. Performing multiple blur iterations at a single reduced resolution achieves a similar widening effect; each iteration effectively doubles the kernel radius.

Step 4: Composite and Tone Map

The final pass blends the blurred bloom texture with the original scene using additive blending:

$$C_{\text{final}} = C_{\text{scene}} + I \cdot C_{\text{bloom}}$$

where $I$ is the bloom intensity, an artist-controlled parameter that governs how strong the glow appears. Since we've been working in HDR, the composited result may still contain values well above $1.0$. A tone mapping operator compresses these back to the displayable $[0, 1]$ range.

Reinhard tone mapping is a simple and effective choice:

$$C_{\text{mapped}} = \frac{C}{C + 1}$$

This smoothly compresses bright values while leaving dark values nearly unchanged. A value of $10.0$ maps to $\approx 0.91$, while $0.1$ maps to $\approx 0.09$. After tone mapping, gamma correction ($\gamma = 2.2$) converts from linear to sRGB for display:

uniform sampler2D sceneTexture;
uniform sampler2D bloomTexture;
uniform float bloomStrength;
varying vec2 vUv;

void main() {
    vec3 scene = texture2D(sceneTexture, vUv).rgb;
    vec3 bloom = texture2D(bloomTexture, vUv).rgb;
    vec3 result = scene + bloom * bloomStrength;

    // Reinhard tone mapping
    result = result / (result + vec3(1.0));

    // Gamma correction (linear to sRGB)
    result = pow(result, vec3(1.0 / 2.2));

    gl_FragColor = vec4(result, 1.0);
}

The Complete Render Loop

Putting it all together, a single frame of bloom rendering looks like this:

function renderWithBloom() {
    // 1. Render scene to HDR render target
    renderer.setRenderTarget(sceneTarget);
    renderer.render(scene, camera);

    // 2. Extract bright pixels (threshold pass)
    quad.material = thresholdMaterial;
    thresholdMaterial.uniforms.tDiffuse.value = sceneTarget.texture;
    renderer.setRenderTarget(brightTarget);
    renderer.render(postScene, postCamera);

    // 3. Horizontal Gaussian blur
    quad.material = blurMaterial;
    blurMaterial.uniforms.tDiffuse.value = brightTarget.texture;
    blurMaterial.uniforms.direction.value.set(1.0, 0.0);
    renderer.setRenderTarget(blurTargetA);
    renderer.render(postScene, postCamera);

    // 4. Vertical Gaussian blur
    blurMaterial.uniforms.tDiffuse.value = blurTargetA.texture;
    blurMaterial.uniforms.direction.value.set(0.0, 1.0);
    renderer.setRenderTarget(blurTargetB);
    renderer.render(postScene, postCamera);

    // 5. Composite bloom with original scene, output to screen
    quad.material = compositeMaterial;
    compositeMaterial.uniforms.tScene.value = sceneTarget.texture;
    compositeMaterial.uniforms.tBloom.value = blurTargetB.texture;
    renderer.setRenderTarget(null);
    renderer.render(postScene, postCamera);
}

Advanced Techniques

Kawase Blur

An alternative to Gaussian blur, the Kawase blur uses a series of progressively wider bilinear samples. Each pass samples at the corners of an expanding box pattern. It's cheaper per pass than a Gaussian kernel and produces visually similar results. Many mobile games use Kawase blur for bloom because of its favorable performance-to-quality ratio.

Threshold Knee

Instead of the soft threshold described above, some engines use a knee function that smoothly transitions between no bloom and full bloom over a configurable range. Unity's post-processing stack, for example, uses a quadratic knee curve that provides fine artistic control over where bloom begins.

Lens Dirt and Starburst

Many modern games enhance bloom with lens dirt textures, smudge and dust overlays that become visible when bright bloom is present. Some implementations also add starburst patterns caused by diffraction from the camera's aperture blades.

Games That Use Bloom

  • Unreal Engine uses a 6-stage progressive downsampling bloom with configurable tint per stage, giving artists per-octave color control
  • The Legend of Zelda: Breath of the Wild uses bloom extensively for shrine interiors, divine beast cores, and sunlit landscapes
  • Hollow Knight applies subtle bloom to the Knight's nail effects, dream sequences, and the Radiance boss fight
  • Dark Souls creates its iconic bonfire warmth with intense bloom, making safe havens feel genuinely inviting
  • Cyberpunk 2077 leans heavily on bloom for neon signs, holographic displays, and vehicle headlights in night scenes
  • Ori and the Blind Forest uses carefully tuned bloom to create its ethereal, painterly atmosphere

Performance Considerations

Bloom is one of the more performance-friendly post-processing effects, but a few strategies keep it fast:

  • Half-resolution blur: Performing the blur passes at half the scene resolution cuts the number of fragment shader invocations by 75% and naturally widens the blur without a larger kernel
  • Fewer taps: A 5-tap kernel with bilinear sampling between texels can approximate a 9-tap kernel at nearly half the cost
  • Limit iterations: Two to three blur iterations at half resolution produce a wide, smooth bloom. More iterations yield diminishing visual returns
  • Early out: If no pixels exceed the threshold (e.g., a very dark scene), skip the blur and composite passes entirely
  • Shared render targets: Reuse render targets across frames and across different post-processing effects to minimize GPU memory allocation

Bloom connects physically accurate lighting to how players perceive brightness. Once you understand the pipeline, from threshold extraction through blur and compositing, you have precise control over how light feels in your game, whether it's a bonfire's warmth or a neon sign's harsh glare.

Comments

Like this article? Consider supporting us

Your support helps us keep creating free game dev content, tutorials, and tools.

Free

$0 /month

Newsletter and public posts

  • Newsletter access
  • Public posts & updates
  • Community access

Studio Backer

$25 /month

Direct impact on development with your name in the credits

  • Everything in Supporter
  • Your name in game credits
  • Priority feature requests
  • Direct developer access
  • Monthly asset downloads