2D lighting with HDR, tonemapping and bloom, step by step

The first time most of us add lights to a 2D game, we multiply the sprite colour by a light colour and call it done. It works until two torches overlap and the floor between them turns a flat, chalky white, or until a bright crystal looks exactly as bright as a slightly lighter wall. The fix is not a cleverer multiply. It is giving your lights room to be brighter than the screen can show, and then deciding, at the very end, how to squeeze that range back down. That is what HDR, tonemapping and bloom are for, and they fit a 2D renderer surprisingly well.
This piece walks through the whole chain as a series of passes. The WGSL is small and focused on the maths; the Rust side is described in terms of what each pass reads and writes, since the exact setup depends on the framework you use or on how you drive wgpu.
The pipeline at a glance
Here is the full frame, from sprites to the swapchain:
1. Scene pass: draw sprites (albedo) -> scene (RGBA16F)
2. Light pass: draw lights additively -> light (RGBA16F)
3. Composite: scene * light + emissive -> hdr (RGBA16F)
4. Bloom: threshold, downsample chain, upsample chain -> bloom
5. Final: tonemap(hdr + bloom * k), then encode to sRGB -> screen
Each step is a full-screen or near full-screen draw. In a 2D game the scene is cheap to draw, so even five or six passes at 1080p tend to be fine on modest hardware. Profile anyway; the bloom chain is where cost hides.
Why 16-bit float targets
A normal 8-bit texture per channel stores values from 0 to 1 in 256 steps. Anything above 1 is clipped. Lights in the real world, and in good-looking games, go far above "white". A lantern core might be 4 times brighter than the lit wall next to it, and you want that difference to survive until the end of the frame.
An RGBA16F render target (in wgpu terms, a 16-bit float format with four channels) stores half-precision floats. That gives you values well above 1, plenty of precision in the darks, and blending that behaves the way additive light should. It costs twice the memory of an 8-bit target: a 1920 by 1080 target is about 2.07 million pixels, times 8 bytes, so roughly 16.6 MB per target. With scene, light and composite targets plus a bloom chain you are in the 60 to 70 MB range, which is acceptable on desktop and something to watch on the web or older phones.
One colour rule matters more than any format choice: do your lighting maths in linear space. If your sprite textures are authored in sRGB (they almost always are), sample them through an sRGB texture format so the GPU converts them to linear on read. Then convert back to sRGB only at the very end. Mixing the two is the most common reason 2D lighting looks muddy.
The light map
The light pass draws every light into its own target, starting from an ambient colour and using additive blending. In wgpu that means a blend state where source and destination factors are both one, so each light just adds its contribution.
A point light in 2D is usually a quad with a falloff computed in the fragment shader:
struct Light {
color: vec3<f32>,
intensity: f32,
radius: f32,
};
@group(0) @binding(0) var<uniform> light: Light;
@fragment
fn fs_light(@location(0) local: vec2<f32>) -> @location(0) vec4<f32> {
// local runs from -1 to 1 across the light quad
let d = length(local);
let falloff = clamp(1.0 - d, 0.0, 1.0);
let soft = falloff * falloff;
return vec4<f32>(light.color * light.intensity * soft, 1.0);
}
The squared falloff is not physically exact (real point lights fall off with the inverse square of distance and never reach zero), but it has a finite edge, which makes culling trivial and looks right in a stylised 2D world. Intensity is the important part: set a torch to 3.0 and a crystal to 6.0, and the light map will happily store those values without clipping.
The ambient colour is what the light map is cleared to. A dark blue around (0.05, 0.06, 0.10) reads as night without crushing everything to black.
Composite: albedo times light
The composite pass multiplies the scene colour by the light map, then adds anything emissive. Emissive pixels are things that glow on their own, like lava, screens or eyes, and they should not be dimmed by darkness around them. A simple approach is a separate emissive texture or an emissive mask channel drawn during the scene pass.
@fragment
fn fs_composite(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let albedo = textureSample(scene_tex, samp, uv).rgb;
let light = textureSample(light_tex, samp, uv).rgb;
let glow = textureSample(emissive_tex, samp, uv).rgb;
return vec4<f32>(albedo * light + glow, 1.0);
}
The output goes into another RGBA16F target. At this point the image is "correct" but unusable: some pixels are 0.02, some are 9.0, and the screen can only show 0 to 1.
Tonemapping operators
Tonemapping is the function that maps that wide range into the displayable one. Two operators cover most needs.
Reinhard
The simplest is Reinhard: c / (1 + c). It maps 0 to 0, 1 to 0.5, and approaches 1 as values grow, so nothing ever clips.
fn reinhard(c: vec3<f32>) -> vec3<f32> {
return c / (vec3<f32>(1.0) + c);
}
It is gentle and safe, and it tends to look a bit flat and grey, because it compresses the midtones a lot. An exposure multiplier before the curve helps: reinhard(c * exposure) with exposure around 1.5 to 2.0 brings the midtones back.
ACES approximation
The other common choice is a fitted curve that approximates the ACES filmic look. A widely used rational approximation looks like this:
fn aces_approx(x: vec3<f32>) -> vec3<f32> {
let a = 2.51;
let b = 0.03;
let c = 2.43;
let d = 0.59;
let e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3<f32>(0.0), vec3<f32>(1.0));
}
It has a toe (darks get a little deeper) and a shoulder (highlights roll off smoothly), which gives more contrast and a more cinematic feel. It also shifts saturated colours slightly towards white in the highlights, which suits fire and magic.
A worked comparison
Here is what the two operators do to a few input brightness values, rounded to two decimals:
| Input | Reinhard | ACES approx |
|---|---|---|
| 0.1 | 0.09 | 0.13 |
| 0.5 | 0.33 | 0.62 |
| 1.0 | 0.50 | 0.80 |
| 2.0 | 0.67 | 0.91 |
| 4.0 | 0.80 | 0.97 |
Two things jump out. Reinhard puts a fully lit surface (1.0) at half brightness, which is why it needs exposure. The ACES fit keeps midtones bright and still leaves headroom, so a 4.0 lantern core and a 2.0 lit wall stay distinguishable. For pixel art I usually start with the ACES approximation and an exposure near 1.0, then tune.
Bloom with a downsample and upsample chain
Bloom spreads light from very bright pixels into their surroundings. A cheap Gaussian blur at full resolution looks thin and costs a lot. The better approach is a mip chain.
Threshold and downsample
First, extract the bright parts. A soft threshold keeps the edge from popping:
fn bright(c: vec3<f32>, threshold: f32, knee: f32) -> vec3<f32> {
let lum = max(c.r, max(c.g, c.b));
let soft = clamp(lum - threshold + knee, 0.0, 2.0 * knee);
let w = max(soft * soft / (4.0 * knee + 0.0001), lum - threshold);
return c * (max(w, 0.0) / max(lum, 0.0001));
}
Then downsample repeatedly, halving the size each time. Each downsample pass samples a small pattern of texels (a 4-tap or 13-tap filter are both common) and averages them. For 1080p, six levels take you from 960 by 540 down to roughly 30 by 16, which covers glows from a few pixels to a large part of the screen.
Upsample and combine
Then walk back up. Each upsample pass samples the smaller level with a small tent filter and adds it to the next larger level, using additive blending. By the time you are back at the top, you have a blur that is wide and smooth, built from cheap passes.
The cost works out nicely. Level one is a quarter of the screen's pixels, level two a sixteenth, and so on, so the whole downsample chain adds up to about a third of a full screen's pixels, and the upsample chain the same again. That is far cheaper than a single wide blur at full size.
Mixing it in
Finally, add bloom before tonemapping, not after:
let hdr = textureSample(hdr_tex, samp, uv).rgb;
let bloom = textureSample(bloom_tex, samp, uv).rgb;
let mapped = aces_approx((hdr + bloom * 0.04) * exposure);
Adding it in HDR space means the tonemapper treats bloom like any other light, so it never clips into ugly flat halos. A bloom strength around 0.03 to 0.08 is a good starting range. Above that, everything looks like it has been smeared with petroleum jelly.
Debugging the chain
When something looks wrong, look at each target on its own. A small debug key that cycles the final pass through scene, light map, composite and bloom saves hours. Most problems show up immediately: a light map that is pure white means intensities far too high or a blend state that is not additive; a composite that looks washed out usually means sRGB textures were sampled as linear; bloom that flickers as the camera moves often comes from a hard threshold or from downsampling with a single tap. Fix the earliest broken target first, because every later pass inherits its mistakes.
Pixel art specifics
Pixel art has its own needs. Render the scene at your native low resolution (for example 320 by 180), do lighting and composite there, and scale up with nearest filtering at the end, after tonemapping. If you light at the upscaled resolution, the light edges will be smoother than your pixels and the style breaks. Bloom is the exception: a blurry glow on top of crisp pixels reads well, so you can run the bloom chain at a higher resolution if you like the look. The pixel art guide has more on keeping palettes coherent, which matters once lights start tinting everything.
Colourful lights also interact with limited palettes. A warm torch over a cool stone palette produces in-between colours your artist never drew. Some games embrace that; others quantise the final image back to the palette after tonemapping. Try both before deciding.
The graphics section collects related articles on shaders and rendering, and if you are still choosing a framework, the macroquad vs Bevy comparison covers which one gives you easier access to custom render passes.
A small exercise
Build the light pass and composite only, skip bloom, and use Reinhard. Place two overlapping torches at intensity 2.0 and look at the overlap. Then switch to the ACES approximation and change nothing else. The difference in how the overlap reads is the whole argument for this chain, and once you see it, adding the bloom passes is just plumbing.
Filed in Graphics & Shaders

Written by Odalys Brisco
Odalys covers graphics programming: wgpu, shaders, lighting and the maths behind a good-looking 2D frame. She came to it from technical art and still sketches every effect on paper before writing a line of WGSL. Based in Valencia.
