Linear RGB color interpolation is not as great as its popularity would make it out to be. In this post I introduce blending in the CIE-LCh color-space, which is dramatically better for certain applications.
NOTE: you may want to read my article on Twisted Hue Lighting before reading this post.
Background and Issues
The standard way to blend between two colors (C1 and C2) is just to linearly interpolate each red, green, and blue component according to the following formulas:
R' = R1 + f * (R2 - R1) G' = G1 + f * (G2 - G1) B' = B1 + f * (B2 - B1)
Where f is a fraction the range [0,1]. When f is 0, our result is 100% C1, and when f is 1, our result is 100% C2. Simple enough. If we sample 7 points along this range, it produces a gradient, such as this one:
If you visualize RGB color-space as a 3D cube (one axis for each component), then this blend is just taking uniform color samples along a line that connects the two color points within that space (hence the term “linear” interpolation).
This technique is used a lot in games, for example if you have a changing dynamic light source – like the light from a setting sun. Or perhaps you’ve got an particle effect that blends from one color to another. The code for this is fairly cheap to run. However, depending on your art style, a linear blend in RGB color-space may not always be the best choice. Suppose we do the same blend as above, but with blue as the starting color:
Observant readers will notice something a bit strange about this gradient. The middle color, where f=0.5, is gray. It has no color at all. We can measure the saturation of these colors (by converting them to the HSB color-space), with the following result:
[1.0] [0.8] [0.5] [0.0] [0.5] [0.8] [1.0]
The brightness also changes in a similar way – it is not linear across this gradient.
In some applications, you may want to maintain color saturation, especially if you’re using these colors as a diffuse material color. It changes the characteristics of a scene dramatically when the saturation (color) goes away and the brightness is cut in half, such as when the middle color became gray in the blue-to-yellow example above. In the real world, a dramatic sunset is created by the particles in the air refracting the sunlight into wavelengths that create colors of yellow, orange, red, purple, and blue. The colors are blended in the same way a rainbow sends various wavelengths of light into your eye.
Polar Color-Space Interpolation
If your goal is to maintain color saturation and brightness, you can perform the interpolation in a polar-coordinate color space. Instead of the cube-shaped space of RGB, polar color spaces are cylindrical or conical in shape. Examples of polar coordinate spaces include HSB (also called HSV), HSL and CIE-LCh. *
* Technically HSB is a hexagonal cone shape, but we can consider that it approximates a circular cone.
To blend in HSB space, for example, you need to first convert each RGB color to an HSB color. For code to convert colors, follow the link at the end of this article. The code isn’t free, so don’t use it on 10,000 particles, but later I’ll show ways to optimize this. Once you have two HSB colors, their saturation and brightness can be interpolated linearly, using the same formulas that I introduced at the beginning of this article. The hue component is an angle around a circle, so you need to handle the discontinuity from 360 to 0 degrees and interpolate the shortest route around the circumference. Here is the algorithm as pseudo-code:
d = h2 - h1 if (h1 > h2) then swap(h1, h2), d = -d, f = 1 - f if (d > 180) then h1 = h1 + 360, h = ( h1 + f * (h2 - h1) ) mod 360 if (d <= 180) then h = h1 + f * d
Here I’m assuming that the hue ranges from [0, 360), but some implementations use [0, 1), so adjust accordingly. Using the blue-to-yellow example, we now get:
If the two colors have the same saturation and brightness (they do in the above example), this interpolation will just rotate around the circumference, taking the shortest path (could be clockwise or counter-clockwise). Notice the color saturation is maintained through the entire gradient.
One issue with the HSB color-space is that it doesn’t do a great job of representing brightness as our eye sees it. The disadvantages of HSV and HSL are explained very well in this Wikipedia article. In the above gradient, the blue on the left is perceptually much darker than the yellow on the right, even though in HSB, the brightness component is 1.0 over the entire gradient. The brightness is also inconsistent across the gradient, and the greens are too close to one another in color.
To solve this, we can do the interpolation in a different polar color space. The CIE-LCh color-space is similar in geometry and function to HSB, but takes into account human perception of color differences. It is a polar-coordinate version of the CIE-Lab color-space. I’ve put together a simple page so you can see the difference in the two color-spaces side-by-side.
Here is a blend of the same blue and yellow used above, but interpolated in CIE-LCh color-space:
Great! This time perceived brightness appears to change uniformly across the gradient.
Converting from RGB to CIE-LCh is not cheap: it requires converting RGB to XYZ, then XYZ to CIE-Lab, and finally CIE-Lab to CIE-LCh. After you compute the interpolated colors, you need RGB values to render the colors onto the screen. This means you have to reverse this conversion for each color along the gradient.
If you are generating a gradient with a lot of steps, you don’t have to do this conversion at every step. You can approximate the CIE-LCh interpolation by converting only a subset of steps back to RGB and then interpolate in RGB-space using the old school technique. If you just do this with the halfway point (f=0.5), for example, your cost is only 2 conversions to CIE-LCh space and 1 conversion back to RGB space. The rough approximation looks pretty close to our CIE-LCh result above:
What About CIE-Lab Space?
Even though CIE-Lab is a perceptual-based color-space, linear interpolations within it do not maintain saturation, even though brightness is uniformly blended. Here’s an example, showing the same gray problem:
Compared to the interpolation in CIE-LCh where saturation and brightness are uniform:
For an interesting comparison, here’s the same interpolation in HSB space:
And in RGB space:
Here are all the gradients of blue-to-yellow for comparison purposes:
Row 1: RGB Interpolation
Row 2: HSB Interpolation
Row 3: CIE-LCh Interpolation
Here is a cyan-to-red interpolation that also exhibits the gray problem in RGB space:
It is a dramatic example of the improved quality that you can get by interpolating in the CIE-LCh color-space.
In a future post, I’ll talk about how to do a weighted-blend of an arbitrary number of colors in polar coordinate systems.
Open-Source Code Examples
Example code to convert RGB color to HSB and back (C/C++): Stack Overflow