DEV Community

Nightdrive Labs
Nightdrive Labs

Posted on

Cheap bloom for a Canvas 2D game in 8 lines (no WebGL)

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:

  1. Bright pass: keep only the bright pixels.
  2. Blur them.
  3. 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();
}
Enter fullscreen mode Exit fullscreen mode

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; } })();
Enter fullscreen mode Exit fullscreen mode

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.