Neon games look flat without bloom: the soft light that bleeds around bright things. The usual advice is "switch to WebGL and write a post-process shader". For my Canvas 2D boss-rush game I wanted the glow without porting the renderer, so I did it with the 2D context itself. The whole pass is 8 lines and costs almost nothing (numbers at the end).
The idea
Bloom is three steps:
- Bright pass: keep only the bright pixels.
- Blur them.
- Add the blurred image back on top of the frame.
Canvas 2D can do all three. ctx.filter takes CSS filter functions (brightness, contrast, blur), and globalCompositeOperation = "lighter" is additive blending. The trick that makes it cheap is doing steps 1-2 on a quarter-resolution canvas: blur is the expensive part, and a 480x270 buffer has 1/16 of the pixels. A blurred image doesn't need resolution anyway.
The code
let bloomC, bloomX;
function applyBloom(cv, cx) { // call after the world is drawn, before the HUD
const bw = Math.max(1, cv.width >> 2), bh = Math.max(1, cv.height >> 2);
if (!bloomC) { bloomC = document.createElement("canvas"); bloomX = bloomC.getContext("2d"); }
if (bloomC.width !== bw || bloomC.height !== bh) { bloomC.width = bw; bloomC.height = bh; }
bloomX.globalCompositeOperation = "copy"; // replace, don't accumulate
bloomX.filter = "brightness(0.75) contrast(2.6) blur(3px)"; // bright pass + blur in one go
bloomX.drawImage(cv, 0, 0, bw, bh); bloomX.filter = "none";
cx.save(); cx.setTransform(1, 0, 0, 1, 0, 0);
cx.globalCompositeOperation = "lighter"; cx.globalAlpha = 0.4; // additive, at 40%
cx.drawImage(bloomC, 0, 0, cv.width, cv.height); cx.restore();
}
brightness(0.75) contrast(2.6) is the bright pass: contrast pushes mid-greys down to black and bright colours up, so only the neon survives. blur(3px) at quarter resolution is roughly a 12 px blur at full size. Upscaling the small buffer with drawImage adds a bilinear blur for free.
Three things that bit me
Draw order. Apply bloom after the game world and before the HUD. Bloomed text is unreadable, and white UI panels turn into a fog.
Not every browser supports ctx.filter. Safari added it much later than Chrome and Firefox, and a browser without it ignores the property silently: no error, the filter just does nothing, and you add an unblurred copy of the whole frame on top. Everything goes washed out. So test that the feature works instead of checking that the property exists:
const FILTER_OK = (() => { try {
const a = document.createElement("canvas"), b = document.createElement("canvas");
a.width = a.height = b.width = b.height = 9;
const ag = a.getContext("2d"), bg = b.getContext("2d");
ag.fillStyle = "#fff"; ag.fillRect(4, 4, 1, 1); // one white pixel
bg.filter = "blur(2px)"; bg.drawImage(a, 0, 0);
return bg.getImageData(2, 4, 1, 1).data[3] > 0; // did it spread two pixels left?
} catch (e) { return false; } })();
Give players a switch. Bloom plus screen flashes is a lot for some people. I put "Glow (bloom)" next to "Reduce flashes" in the settings, and I turn it off automatically when the game's low-quality mode kicks in (frame time above 22 ms for ~2 seconds).
Was it worth it?
With 4x CPU throttling in DevTools the game still ran at 122-145 fps with bloom on, so the cost is negligible next to the game logic. Bullets, parry rings and boss cores went from "coloured dots" to "light". For a small game that's the best visual gain per line of code I've found.
If you've done post-processing in Canvas 2D some other way (for example getImageData loops or OffscreenCanvas in a worker), I'd like to hear how it compared.
You can see the bloom in action in the game it was written for: Void Parry, a free neon boss-rush in the browser (PC and phone).
Written with AI assistance; the code runs in my game.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.