This post discusses about the tricky problem of colour interpolation, and explores possible solutions. Many software and engines offer read-to-use functions to interpolate colours. In Unity, for instance, Color.Lerp is available and does its job pretty nicely. Use the interactive swatch below to see how Color.Lerp works.
There’s nothing wrong in using these functions, as long as you know what the deal with colour interpolation is.
Interpolation is a technique that allows you to “fill a gap” between two numbers. Most APIs expose linear interpolation based on three parameters: the starting point , the ending point and a value between 0 and 1 which moves along the segment that connected them:
When , is returned. When , is returned instead. The beauty of this formula is that is easy to understand, efficient to implement, and it works in any dimension. Lerping in two dimension only requires to independently lerp the X and Y components. Lerping always returns points on the line that connects and , regardless of the number of dimensions. A standard RGB lerp can be done as such:
public static Color LerpRGB (Color a, Color b, float t)
return new Color
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.a + (b.a - a.a) * t
If it’s true that linear interpolation works as expected in three dimensions, the same cannot say for colours. There’s a fundamental difference between the XYZ and RGB spaces: the way the human eye perceive colours. While it make sense to connect two points in a 3D space with a line, the same doesn’t always apply for points in the RGB space. Interpolating the R, G and B components independently offers no guarantee on the hue of the intermediate colours. As Stuart Denman highlights in his Improve Color Blending, the RGB space of cyan and red meet halway in grey. A new hue appears because the RGB space does not capture how Humans perceive colours very well.
The next pages of this post will explore better ways to interpolate colours.
A first attempt to compensate for this is to switch to different colour space, such as HSV (also known as HSB). It has been designed to be “artist-friendly”, grouping colours by hue and ignoring how they are created on screen.
The result, as it can be seen above, is rather disappointing. The reason is that interpolating the H component cycles through different hues. In this case we don’t have to go through green, but looping over the H space in the opposite direction.
To implement an HSV lerping function we need to understand how these components are handled. For this example, we’ll assume all the HSV components range from 0 to 1. The following code is inspired from Improved Color Blending and relies on the ColorHSV Unity extension by C.J. Kimberlin:
public static Color LerpHSV (ColorHSV a, ColorHSV b, float t)
// Hue interpolation
float d = b.h - a.h;
if (a.h > b.h)
// Swap (a.h, b.h)
var h3 = b.h2;
b.h = a.h;
a.h = h3;
d = -d;
t = 1 - t;
if (d > 0.5) // 180deg
a.h = a.h + 1; // 360deg
h = ( a.h + t * (b.h - a.h) ) % 1; // 360deg
if (d <= 0.5) // 180deg
h = a.h + t * d
// Interpolates the rest
return new ColorHSV
h, // H
a.s + t * (b.s-a.s), // S
a.v + t * (b.v-a.v), // V
a.v + t * (b.v-a.v) // A
For comparison, the linear lerping through HSV space is also shown together with the corrected lerping (HSV*).
Despite all the effort, the transition still doesn’t look good. The reason is that even if we have correctly learped through the Hue component, different colours have different luminosities. As explained by Gregor Aisch in How To Avoid Equidistant HSV Colors, equidistant colours in the HSV space are not perceived as really equidistant. Even HSV colours with the same brightness (V) can differ in their perceived brightness and luminosity. Many aspects are responsible for this. The R, G and B components of a colour contributes in different ways the perceived luminosity, due to the way their respective photoreceptors work. Several attempts have been made to capture the non-linear relationships between R, G and B in a colour model. One of the most successful is the LCH (also known as HCL for Hue, Chroma and Lightness). Equidistant colours in the LCH space are also perceived as equidistant. The swatches below clearly shows how the LCH space provides a more uniform distribution of the colours.
The conversion from RGB to LCH is very expensive. This is because colours have to be converted into to intermediate spaces, the XYZ and LAB. A very good library which supports all of these conversions is chroma.js.
Using colours with equidistant perceived luminosity is essential for all these applications in which colours have a precise meaning, such as diagrams and heatmaps. Providing uniform luminosity is also important for colour blind people, as discussed in Accessibility Design: Color Blindness. A starting point to design a safe colour palette is ColorBrewer.
Interpolating colours by lerping their RGB components is the most common and
lazy easy approach to tackle a very complex problem. If the interpolated colours need to be visible at the same time (for instance in a chart or a diagram) chances are you might need a more advance technique. Conversion from RGB to HSV are supported by most frameoworks, but if you want to go the extra mile you should adopt the LCH colour space.
This post was strongly inspired by the many works of Gregor Aisch.
- Part 1. How to Find the Main Colours in an Image
- Part 2. The Incredibly Challenging Task of Sorting Colours
- Part 3. Accessibility Design: Color Blindness
- Part 4. GameBarcode: A Study of Colours in Games
- Improved Color Blending
- How To Avoid Equidistant HSV Colors
- Mastering Multi-hued Color Scales
- I Want Hue
- The Muller Formula (or predictable color preferences)