Unconventional method of storing rendered lighting results in inaccurate blending of transparent objects

I’ll be starting this off with an example. Fair warning, this report has quite a lot of words. This affects both Studio and the client and is consistently reproducible and always present as it’s a fundamental issue, meaning I will also not be adding my system information as it is irrelevant to the issue.

A Simple Scene

Let’s take a simple scene of a hole in the wall facing a dummy.


Other than the gold bars illuminated by an environment map, you can’t really see anything else in the room as expected by it being in shadow. Now, let’s add a window at 75% transparency.

What happened there? Now you can suddenly see everything else in the room, albeit dimly, just by adding a transparent window! There’s clearly a chair on the left, while on the right there’s some text that says you shouldn’t be able to read it and a sword. Consulting light physics, this isn’t possible as you’re not actually adding any light to the room. Much on the contrary, rather, as the window should be lowering the amount of light both entering and leaving the room.

The Alpha Blending Equation

Before I can explain why the above occurs, I should first talk about blending and how transparent objects are rendered. The most common method of blending is Alpha Blending, which takes an alpha, a starting value, and an ending value. Here’s it in C++.

float blend(float alpha, float startValue, float endValue) {
        return startValue * (1.0 - alpha) + endValue * alpha;
}

When the alpha is 0, the output value is the starting value, whilst an alpha of 1 results in the output value being the ending value.
(The transparency property on parts and anything else with one acts inverse to alpha, meaning a transparency of 1 is an alpha of 0, while a transparency of 0 is an alpha of 1, etc.) Here’s a visual example:

Roblox, too, uses alpha blending for transparency. In their case, the starting value is everything previously rendered and the ending value is the value of the transparent material/particle/beam/UI. Unfortunately, as you’ve probably seen in the title, blending results in incorrect resulting values. This is a result of how Roblox does pseudo-HDR internally, something which I’ll explain in more detail in about… oh hey, it’s right there!

Why is HDR relevant here?

HDR, short for High Dynamic Range, refers to the ability to use and store, well, a high range of dynamic values. While the vast majority of users don’t own a HDR monitor themselves, it is extremely valuable in rendering as it allows values above 1 to be stored and used for the final lighting of rendered geometry. This means if, say, a glowing object ends up with a value of (0.6, 1.7, 1.3), it is stored as such instead of being clamped to (0.6, 1.0, 1.0). At the end of rendering a frame, the rendered image undergoes tonemapping, resulting in regular 0 - 1 values that can be displayed on an SDR (Standard Dynamic Range) monitor.

So, why is HDR still relevant here? Well, it’s because Roblox doesn’t actually use HDR internally! It uses SDR internally, instead opting to darken and square root the final value before it’s written. The amount it is darkened depends on the exposure value in lighting. At the default exposure of 0, it will be darkened to a quarter of it’s original value. The square root is there to prevent too much precision loss as a result of trying to fit higher values in the 0 - 1 value range. By itself, this is perfectly fine, though it does mean you can have a maximum theoretical brightness of 4. In anything using the final image, such as bloom or tonemapping, you simply do the inverse of those operations, squaring the value and multiplying it by 4 to regain the original value. In the case of blending though, the square root completely screws the end result up. Let’s take the example further back and apply the same operations.

Hey, that final blended value is wrong! It’s nearly 14% lower than the proper value, and that’s a pretty significant difference. Let’s use the example room above with a background value of (0.005, 0.005, 0.005, 1.0) taken from a dark area of the room and a foreground object of (0.185, 0.185, 0.185, 0.25) as the lit window at 75% transparency. These may seem like extremely low values, but stuff is a lot dimmer than you’d think and gamma correction during tonemapping brings the final value up significantly. Using the alpha blending formula, the final blended value SHOULD be (0.05, 0.05, 0.05).
Don’t worry if you can’t discern the colours against the background, all you need to know is that the final blended value is wrong.

Nearly 50% dimmer than the correct value! This means that the amount that it’s off varies depending on the starting and ending colours and can easily result in the outlines of objects being revealed. In games which use the dark to add a level of difficulty, such as horror games, this will almost always result in being able to see something you shouldn’t normally be able to see.

To fufil the requirement of reproduction steps, here they are:

  1. Place a part
  2. Make it partially transparent
  3. The resulting blended colour will be wrong from the correct value

Here’s a studio file to quickly view the issue. Switching the window between 0.75 and 1 transparency makes the issue more obvious.
Flawed Transparency.rbxl (290.4 KB)

Expected behavior

Here’s the example room again, except I’ve removed the square roots responsible for the weirdness.


The visible banding is due to the use of the aforementioned SDR internal image, meaning I’m still utilising the same division by a factor determined by exposure. This wouldn’t be present in a proper implementation so just imagine it without any banding.

Looks much better! Now, dark areas of the room aren’t brightened up in a weird way that reveals anything, and even in the lit areas it looks better. What’s better is that none of the opaque geometry is affected in colour or brightness by this, so it doesn’t drastically affect opaque parts of your game.

Officially, this could be resolved by switching the internal image to a true HDR format, avoiding the need for the division and square root entirely and allowing values above 4 to be stored as well. I partly understand the reason that a clamped image is used, as it allows some ancient devices that don’t have support for floating point images to get a pseudo-HDR effect, but realistically that’s a very, very small minority and most likely can’t even run Roblox at the quality levels that the method benefits.

If maintaining the look for older games is desired, it could be implemented as a toggle named “HDR” in the Lighting instance and in the shaders an if-else statement controls whether to use the current method or a true HDR internal image. (If-else statements are basically negligible if all pixels take the same branch.)

I’ve simplified lots of what I’ve said in regards to rendering, so it should not be taken as a definite to how a frame is rendered. The concept remains the same, though. Internal image refers to the colour attachment of the framebuffer that is geometry in the camera view is rendered to.

5 Likes

Is the window supposed to be something other than Glass? I’m not sure if this is important, but the Glass material, according to the Announcement of it, uses multiplicative blending instead of alpha blending:

Yes, the window is smooth plastic because glass doesn’t allow other transparently rendered objects such as surface GUIs through, and it actually demonstrates the issue I’m talking about.

Glass doesn’t suffer from the same issues because of its use of multiplicative blending rather than alpha blending, so it doesn’t really apply there. It undoes the dynamic range compression first then does the blending, so you end up with the correct result afterwards. Though, if a true HDR internal image was used then bright objects would look more realistic through glass.