Ordered dithering is a very cheap solution to this.
Many implementations just sample some noise texture, possibly because that's cheaper - but hardware is so fast nowadays that even sampling some non-trivial noise function many times per pixel hardly registers.
A deferred 2.5D renderer I wrote some while ago just does this screen-wide on the entire framebuffer in a post process step and that pretty much hides all banding already:
vec2 fragCoord = gl_FragCoord.xy;
float noise = fract(52.9829189 * fract(dot(fragCoord, vec2(0.06711056, 0.00583715))));
resultColor += vec3((noise - 0.5) / 255.0);
You might call this random noise (though it's static). It's enough if you're operating in high precision framebuffers for most rendering steps, and will do a decent job at hiding banding when things are downsampled to whatever the screen supports.If you can't afford to just rgba16float everything you might have to be smarter about what you do on an individual light level. Probably using some fancier noise/making sure overlapping lights don't amplify the noise.
As screen resolution and density increases, dithering could even replace transparency as long as you don't look close enough.
If the full frame is maintained with more values then quite a lot of things like Floyd Steinberg optimize well enough to be integrated with a full frame update.