Not getting the result I expect in linear vs gamma color space

I’m in the process of wrapping my head around linear and gamma color spaces so to convince myself that I understand it I created super simple shader that, from my understanding, should result in the same color.

fixed4 frag(v2f IN) : SV_Target
{
    fixed3 startColor = fixed3(1, 1, 1) * 0.1;
    fixed3 multiplyColor;

#if UNITY_COLORSPACE_GAMMA
    multiplyColor = LinearToGammaSpace(startColor);

    // None of these work either
    // multiplyColor = fixed3(1, 1, 1) * LinearToGammaSpaceExact(0.1);
    // multiplyColor = pow(startColor, 1 / 2.2);
#else
    multiplyColor = startColor;
#endif

    multiplyColor *= multiplyColor;

    return fixed4(multiplyColor.rgb, 1);
}

But it doesn’t! The result of the shader (it’s on a sprite stretched to fill the screen) is below.

But shouldn’t this result in the same output? My understanding of how the gamma and linear workflows work is well represented by the image below.

Assuming that image is correct (which if it isn’t would be fantastic information!) then the flows and result should be the following:

Linear
Shader
result = 0.1 * 0.1 = 0.01

Step Before Display
final = 0.01 ^ (1 / 2.2) = 0.123284674

Final A = 0.123284674

Gamma
Shader
gammaCol = 0.1 ^ (1 / 2.2) = 0.351119173
result = gammaCol * gammaCol = 0.123284674

Step Before Display

Final B = 0.123284674

In the end Final A = Final B which is the value before we hand off to the display to apply its correction. But the end result is different? I’m not sure where I’m not following correctly or if something is going on that I do not understand. Any help would be appreciated!

OK, so after some testing it turns out your problem is here:

multiplyColor *= multiplyColor;

That is, you square the color after gamma correction. Interestingly, check this out:

if (i.uv.x < 0.5)
{
    multiplyColor = LinearToGammaSpace(startColor);
    multiplyColor *= multiplyColor;
}
else
{
    multiplyColor = LinearToGammaSpace(startColor*startColor);
}

You’ll see a difference between the two halves of the surface.
This threw me for a loop too, since I tested the numbers in a calculator and came up with the same result as you did. HOWEVER, now check this out:

if (i.uv.x < 0.5)
{
    multiplyColor = pow(startColor, 1 / 2.2);
    multiplyColor *= multiplyColor;
}
else
{
    multiplyColor = pow(startColor*startColor, 1 / 2.2);
}

I replaced LinearToGammaSpace with pow( input, 1/2.2 ), and now both halves of the surface look precisely the same just as the calculations did.
So, obviously, LinearToGammaSpace is NOT just doing input^(1/2.2), otherwise the results should be the same.
What it actually is doing, I don’t know.

Anyway, if you move the squaring to before the gamma correction, such as doing:

startColor *= startColor;

It now looks the same between linear and gamma space.

EDIT: OK, I found what the LinearToGammaSpace function is actually doing.

inline half3 LinearToGammaSpace (half3 linRGB)
{
    linRGB = max(linRGB, half3(0.h, 0.h, 0.h));
    // An almost-perfect approximation from http://chilliant.blogspot.com.au/2012/08/srgb-approximations-for-hlsl.html?m=1
    return max(1.055h * pow(linRGB, 0.416666667h) - 0.055h, 0.h);
}
1 Like

Power math rules be damned, you’re right. Now to wrap my head around this and confirm my understanding. Thanks!

Because the “gamma” rendering path isn’t ^ (1/2.2), it’s sRGB which is a more complex equation.

That was definitely new info to me. I had for years assumed that ^(1/2.2) was the standard. Only after reading the post referenced in Unity’s shader code did I find that apparently that’s not only just an approximation, it’s not a very good approximation.
So the short answer is that Unity’s more accurate sRGB approximation is not commutative, as far as I can tell because of the subtraction.

Yeah, the problem is Gamma is not the same thing as sRGB, but almost everyone refers to sRGB as “Gamma” even though it is not actually that.

^ (1/2.2) is a really common approximation, which I think came from the fact that in Windows the expected gamma for monitors is 2.2 or 2.1. Some applications even use ^0.5 (or rather sqrt()) as an approximation, especially on mobile, as it’s even cheaper and close enough that most people won’t notice.