Standard bloom keys on brightness, so white text blows out while your brand color stays flat. We rewrote the luminance pass to weight by saturation — and learned that our first fix rendered nothing at all.
Every 3D renderer ships a bloom effect, and every bloom effect has the same idea behind it: find the bright pixels, blur them, add them back. It is a good approximation of how a camera lens scatters light, and on a spaceship or a neon sign it looks fantastic.On a website it looks wrong, and it took us a while to articulate why.
Brightness is the wrong signal
A website is mostly white. Backgrounds are white, cards are white, and body text sits on white. The one thing that is not white is the part you care about: the brand color in the logo, the accent on the primary button, the gradient behind the hero.Standard bloom thresholds on luminance — perceived brightness. Point it at a light-mode page and it does exactly what you asked and nothing you wanted: the white headings and white cards bloom into a soft haze, while the blue button, being darker than the paper around it, does not glow at all.Turning the intensity down does not fix it. It just gives you less of the wrong effect.
What we actually wanted
We wanted glow to track color, not brightness. A saturated blue should bloom. A white card should not, no matter how bright it is.That is a different threshold function. In HSV terms, we want to weight a pixel's contribution by its saturation — how far it is from grey — and only then by how bright it is. A fully desaturated pixel should contribute almost nothing regardless of its value; a vivid one should contribute even when it is mid-brightness.We exposed that as a single control, colorBias:
At 0, you get classic luminance bloom — the standard behaviour.
At 1, only saturated color blooms, and whites are left alone entirely.
The default sits at 0.6, which glows brand colors clearly while still letting a genuinely blown-out highlight do its thing.
The implementation
There is no configuration flag for this. Bloom decides what is "bright" inside a small shader — a luminance pass that runs before the blur — so changing the rule means changing that shader.The approach: subclass the effect, then patch the fragment shader of its luminance material to compute saturation alongside value, and mix between the two using our uniform.
// Value: how bright the pixel is (classic bloom keys on this alone)
float v = max(c.r, max(c.g, c.b));
float minC = min(c.r, min(c.g, c.b));
// Saturation: how far the pixel is from grey
float s = v <= 0.0 ? 0.0 : (v - minC) / v;
// colorBias = 0 -> pure luminance bloom
// colorBias = 1 -> only saturated color blooms
float weight = mix(1.0, s, colorBias);
The weight then scales the pixel's contribution before the usual threshold and blur. Everything downstream is unchanged.
The part where it did nothing
Our first version was wrong in the most convincing way possible: it looked like correct code, it ran without errors, and it changed nothing at all.
We had wired the shader patch through a React ref and applied it in an effect. That is idiomatic, and it is also too late — by the time the effect ran, the material had already been compiled and the first frame had already been drawn with the original shader.
What saved us was refusing to trust our eyes. Instead of eyeballing two renders, we rendered the same scene at colorBias 0 and 1 and diffed the PNGs pixel by pixel. The average difference was 0.000. Not "subtle" — byte-identical. The feature was a no-op and we had been about to ship it.
Moving the patch into the effect's constructor — before anything compiles or renders — changed the average difference to 39.5 per pixel, and the visual check finally matched the intent: the white heading halos vanished, the blue accents kept glowing.
If a visual change looks subtle, measure it
A shader tweak that is doing nothing and a shader tweak that is doing a little look identical to a tired person. A pixel diff answers in seconds and does not care how convinced you are.
Why this matters beyond bloom
The general lesson is about defaults borrowed from a different domain. Bloom's luminance threshold is not wrong — it is right for the scenes it was designed for, where bright means emissive. A webpage inverts that assumption: bright means paper, and color means content.
Most of building Frametic has been finding those inversions. A rendering engine built for 3D worlds, pointed at a flat document, keeps making reasonable assumptions that turn out backwards.