Fade to Black in Linear color space

So whenever I’ve needed to fade to black in Unity, I’ve always used the old black UI Image with a tweened alpha and its great.

However, I’m working on a project that is in Linear rather than Gamma and I noticed that the dark end of my fading curve was very short and the light part a much longer tale. Its an entirely different curve due to Linear space. I knew my eyes were seeing it, but to confirm it, I tried putting the black UI Image in front of a white one, adjusting the alpha and checking the white level with the eye dropper tool.

So when the alpha is at 50%, the color showing through is at 73 in the V of HSV (in Gamma color space, its always the inverse of the alpha of the black image). When the alpha is at 90%, its already 35 V!!!

I tried writing a custom sprite shader to see if converting from Linear to Gamma

return pow( IN.color, 1.0 / 2.2 );

This brings it a bit closer, but its still not really the right curve… the dark end of the fade is still too short and when fading to black, there is an extremely noticeable jump from full brightness to a darker color. Not subtle at all.

Wondering if anyone has any ideas about how to get a nice proper black fade in Linear space.

Isn’t it easier to just adjust your tweening curve?

that’s essentially what the code in my shader is doing… exponentially reshaping the curve…

but if you know a way to get a nice linear transition like you automatically get in Gamma color space, please share…

I couldn’t tell you an algorithm for exact translation of gamma to linear. What I would do is link the tweening to an animation curve with curve.Evaluate(t), and then simply tune the curve until I get the result I want.

Something like this: (warning - untested psuedocode)

public float tweenDuration = 5;
public AnimationCurve curve;
float tween = 0;
float tweenScale;
bool active = true;

void Start()
{
   tweenScale = 1/tweenDuration;
}

void Update()
{
    if(tween < 1 && active)
   {
        color.alpha = curve.Evaluate(tweenDuration) //pseudo
        tween += time.deltaTime * tweenScale; //scales the tween duration to between 0 and 1
   }
   if(tweenDuration>1 && active)active = false;
}

This way you can choose how long the tweening takes, and have exact, visual control over how the tweening interpolates.

1 Like

Yea that could work… I was just hoping there would be a magic bullet kind of solution… seems like something we’ll all need if the overall trend is a move towards Linear color space.


plotted a bunch of values taken by hand with the eye dropper tool. hopefully this will do the trick!

1 Like

No need to make your own curve, or otherwise hack something in. Unity provides the gamma (actually sRGB) to linear conversion functions.

However, one bit of warning. Doing this inverse modification to the alpha only solves the very specific case of a fully white background and a fade to black using alpha. It’ll still be wrong for all other color combinations (ie: grey background fading to black!). There simply is no way to do a fade from one color to another with alpha blend when using linear space color and have it match the apparently linear blend you get when using gamma space.

The correct solution is to use a post process to do the fade.

// properties
_ColorVec ("Color", Vector) = (0,0,0,0) // should _not_ be a Color, and do not set it using SetColor!

half4 frag (v2f_img i) : SV_Target
{
  // sample frame buffer image
  half4 col = tex2D(_MainTex, i.uv);

#ifndef UNITY_COLORSPACE_GAMMA
  // if in linear space, convert color to sRGB "gamma" space
  col = LinearToGammaSpace (col);
#endif

  // lerp between colors (always done in sRGB space)
  col = lerp(col, _ColorVec, _ColorVec.a);

#ifndef UNITY_COLORSPACE_GAMMA
  // convert back to linear space in needed
  col = GammaToLinearSpace (col);
#endif

  return col;
}

When setting the color value from script, for any color not white or black you’ll want to make sure it’s being passed to the shader in sRGB space. When you use SetColor on a Color property, Unity will automatically convert the color from the usual sRGB representation you’re used to setting colors in to the color space of the project. Since you want the sRGB version of the color, make sure you use:
mat.SetVector("_ColorVec", myColor);

Unity will implicitly cast colors to a Vector4 for you, without any color space conversions.

1 Like

I’m assuming Mathf.LinearToGammaSpace is roughly the same as what I was doing in the shader: pow(x, 1/2.2)
I tried swapping it but it still didn’t give good results. My hand-plotted curve is a big improvement though!

The post-process method makes a lot of sense though, but I’m on mobile so is it possible that adding a blit could incur a bigger cost?

It is not. It’s the real sRGB conversions, like shown here:
http://chilliant.blogspot.com/2012/08/srgb-approximations-for-hlsl.html
The approximations on that page are what the in-shader functions shown above use.

The fact it didn’t look significantly better confuses me a bit.

Here is the class I’m using with my AnimationCurve:

using UnityEngine;
using UnityEngine.UI;

[ExecuteInEditMode]
public class LinearFadeToBlack : MonoBehaviour
{
    [SerializeField]
    AnimationCurve _curve;

    [SerializeField][Range( 0, 1 )]
    float _alpha;

    [SerializeField]
    Image _image;

    void Update()
    {
        var col = _image.color;
        col.a = _curve.Evaluate( _alpha );
        _image.color = col;
    }
}

To try out the built-in conversion, I changed the line in the Update function to be

col.a = Mathf.GammaToLinearSpace( _alpha );

I suspect this might not be the right way to implement it…

I think that should be:
~~col.a = Mathf.LinearToGammaSpace(_alpha);~~

edit: Nope, try this:
col.a = 1f - Mathf.GammaToLinearSpace(1f - _alpha);

So, here’s an example of the above working.

There are 4 bars below, each is using the i.uv.y to interpolate from one color to another.

  • The first bar is what you get with just lerping the two colors values with i.uv.y. This is what you’re trying to avoid.
  • The second bar is converting the colors into gamma space, doing the lerp with i.uv.y, and converting back into linear space, which properly recreates the curve you see when using gamma color space rendering. i.e.: This is what you’re trying to reproduce.
  • The third bar is a white background with an alpha blended black alpha blended in as a second pass. The alpha is using 1 - GammaToLinear(1 - i.uv.y) like what I suggested in the above post. Note this matches the overall appearance of the second bar, but there is some slight banding. This was done using linear color space w/o HDR enabled. With HDR enabled, it’s a perfect match to the second bar.
  • The fourth bar is doing a lerp in the shader using the converted i.uv.y like in the third bar. It does not have any banding issues, and also perfectly matches the second bar.

So, we’re all good, right? Well, as an example, lets flip the colors so we have effectively a black background and a white foreground we’re blending to.

As you can see, the first and second bars look exactly like before, just upside down. But the third and fourth bars now look worse than even the first bar! This is why I said the hack of modifying the alpha curve only works in the specific case of a white background and black overlay. However, it might be good enough if you always have a black overlay you’re fading to. Here’s what it looks like with a middle grey background:

If you look, you’ll notice the second and third bars do not match the second, and again, there’s some additional banding in the third bar (the one using alpha blending). But, overall, it’s passable. For my own shaders I use a bit of screen space noise to hide that kind of banding when it shows up. On the default white background of this site it’s not super obvious, but it’s much more obvious when on a dark background or otherwise filling your screen.

7 Likes

This is an amazingly detailed response, bgolus. Great write-up!

Yes thank you! Super comprehensive analysis of the options!