Search Unity

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

Discussion in 'Shaders' started by cjddmut, Jan 6, 2017.

  1. cjddmut

    cjddmut

    Joined:
    Nov 19, 2012
    Posts:
    179
    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.

    Code (CSharp):
    1. fixed4 frag(v2f IN) : SV_Target
    2. {
    3.     fixed3 startColor = fixed3(1, 1, 1) * 0.1;
    4.     fixed3 multiplyColor;
    5.  
    6. #if UNITY_COLORSPACE_GAMMA
    7.     multiplyColor = LinearToGammaSpace(startColor);
    8.  
    9.     // None of these work either
    10.     // multiplyColor = fixed3(1, 1, 1) * LinearToGammaSpaceExact(0.1);
    11.     // multiplyColor = pow(startColor, 1 / 2.2);
    12. #else
    13.     multiplyColor = startColor;
    14. #endif
    15.  
    16.     multiplyColor *= multiplyColor;
    17.  
    18.     return fixed4(multiplyColor.rgb, 1);
    19. }
    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
    <NONE>

    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!
     
    Last edited: Jan 6, 2017
  2. PhobicGunner

    PhobicGunner

    Joined:
    Jun 28, 2011
    Posts:
    1,813
    OK, so after some testing it turns out your problem is here:

    Code (csharp):
    1.  
    2. multiplyColor *= multiplyColor;
    3.  
    That is, you square the color *after* gamma correction. Interestingly, check this out:

    Code (csharp):
    1.  
    2. if (i.uv.x < 0.5)
    3. {
    4.     multiplyColor = LinearToGammaSpace(startColor);
    5.     multiplyColor *= multiplyColor;
    6. }
    7. else
    8. {
    9.     multiplyColor = LinearToGammaSpace(startColor*startColor);
    10. }
    11.  
    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:

    Code (csharp):
    1.  
    2. if (i.uv.x < 0.5)
    3. {
    4.     multiplyColor = pow(startColor, 1 / 2.2);
    5.     multiplyColor *= multiplyColor;
    6. }
    7. else
    8. {
    9.     multiplyColor = pow(startColor*startColor, 1 / 2.2);
    10. }
    11.  
    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:

    Code (csharp):
    1.  
    2. startColor *= startColor;
    3.  
    It now looks the same between linear and gamma space.

    EDIT: OK, I found what the LinearToGammaSpace function is actually doing.
    Code (csharp):
    1.  
    2. inline half3 LinearToGammaSpace (half3 linRGB)
    3. {
    4.     linRGB = max(linRGB, half3(0.h, 0.h, 0.h));
    5.     // An almost-perfect approximation from http://chilliant.blogspot.com.au/2012/08/srgb-approximations-for-hlsl.html?m=1
    6.     return max(1.055h * pow(linRGB, 0.416666667h) - 0.055h, 0.h);
    7. }
    8.  
     
    cjddmut likes this.
  3. cjddmut

    cjddmut

    Joined:
    Nov 19, 2012
    Posts:
    179
    Power math rules be damned, you're right. Now to wrap my head around this and confirm my understanding. Thanks!
     
  4. bgolus

    bgolus

    Joined:
    Dec 7, 2012
    Posts:
    12,352
    Because the "gamma" rendering path isn't ^ (1/2.2), it's sRGB which is a more complex equation.
     
  5. PhobicGunner

    PhobicGunner

    Joined:
    Jun 28, 2011
    Posts:
    1,813
    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.
     
  6. bgolus

    bgolus

    Joined:
    Dec 7, 2012
    Posts:
    12,352
    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.