Tech 5 min read

Rendering the visible spectrum accurately: color science and Abney effect correction

IkesanContents

Brandon Li wrote a great article about the technical problems involved in displaying the visible spectrum accurately on a screen, so I dug further into the color science behind it.

It looks like it should be as simple as “show a rainbow gradient correctly,” but once you actually do it, you fall into a deep color-science hole.

the basic conversion pipeline

To convert each wavelength of visible light, roughly 380nm to 780nm, into RGB values for a display, you go through these steps.

1. convert wavelength to XYZ with CIE color matching functions

Color matching functions define how much red, green, and blue primary light you need to reproduce a given wavelength.

The notable thing in Brandon Li’s article is that it uses the CIE 2012 “physiologically valid” CMFs instead of the older 1931 ones. They are derived from cone-cell data and produce noticeably different results. The 1931 CMFs are known to be less accurate in the short-wavelength range because of the measurement methods available at the time.

2. XYZ color space

The wavelength is converted into CIE XYZ coordinates, where luminance and chromaticity are separated, making brightness and hue easier to reason about independently.

3. convert to sRGB

Then you apply the standard linear matrix transform from XYZ to sRGB.

[RGB]=[3.24061.53720.49860.96891.87580.04150.05570.20401.0570][XYZ]\begin{bmatrix} R \\ G \\ B \end{bmatrix} = \begin{bmatrix} 3.2406 & -1.5372 & -0.4986 \\ -0.9689 & 1.8758 & 0.0415 \\ 0.0557 & -0.2040 & 1.0570 \end{bmatrix} \begin{bmatrix} X \\ Y \\ Z \end{bmatrix}

Those values come from the sRGB primaries and the D65 white point. After that, gamma correction is applied to get final sRGB values.

the sRGB gamut problem

Here is the first wall: pure spectral colors sit outside the sRGB gamut. Because RGB values need to stay between 0 and 1, some wavelengths produce negative values after conversion.

In other words, some wavelengths cannot physically be reproduced on an sRGB display.

There are several ways to deal with that:

  • clipping: clamp negatives to 0
  • fixed gray mixing: mix spectral colors with gray to pull them into gamut
  • wavelength-dependent gray adjustment: change the gray mix by wavelength
  • projection in chromaticity space: project toward the gamut boundary

As the SDSU guide points out, projecting along lines of constant dominant wavelength is a common approach, but it still does not guarantee perceptual correctness.

Abney effect

The most interesting part is correcting for the Abney effect.

The Abney effect is the perceptual shift in hue that happens when you change saturation. For example, if you mix gray into blue light, it can look purple instead of simply looking like a lighter blue. In CIE chromaticity space the mix is linear, but the eye does not perceive it that way.

manual correction

Brandon Li took a pragmatic route: he compared spectra from real gas-discharge tubes, measured through a diffraction grating, against computer-generated colors and tuned the output by hand. For each wavelength he identified an effective wavelength and compensated for the hue shift that happens when a color is desaturated.

The data came from the NIST atomic spectra database, and ionization-state distribution was modeled with the Boltzmann factor.

full correction with CIECAM02

The proper way to do it is to use a color appearance model. CIECAM02 mathematically models human color perception and can correct for perceptual effects including the Abney effect.

The workflow is:

  1. convert XYZ into CIECAM02 coordinates
  2. manipulate colors inside CIECAM02 space
  3. convert back to XYZ and then to sRGB

Operations in CIECAM02 space are perceptually more uniform, so mixing with gray does not shift hue the same way. The tradeoff is computational cost and implementation complexity.

OkLCh and the CSS color debate

This issue is directly related to CSS color syntax too.

why HSL is not enough

hsl() is just a cylindrical transform of RGB, so it is not perceptually uniform. A blue and a yellow with the same lightness: 50% can look wildly different in brightness.

from CIELCh to OkLCh

CSS Color Level 4 originally considered CIELCh as the basis for gamut mapping. But CIELAB has a hue-shift problem in the blue region: as saturation drops, blue drifts toward purple.

CSSWG moved to OkLCh instead. OkLCh, proposed by Björn Ottosson in 2021, improves on that blue-region hue shift.

practical use of oklch()

oklch() lets you specify colors in terms of lightness, chroma, and hue.

.brand { color: oklch(0.6 0.15 250); }
.brand-light { color: oklch(0.8 0.10 250); }
.brand-muted { color: oklch(0.6 0.05 250); }

Unlike HSL, changing lightness or chroma does not shift hue much. That makes it much easier to build design tokens with stable hues and deliberate variations.

Used with color-mix(), it also makes hover states and derived accent colors easy to express.

.button:hover {
  background: color-mix(in oklch, var(--brand) 80%, white);
}

Because the mix happens inside OkLCh space, gray does not push blue toward purple the way it can in CIELAB-based mixing.

browser support

oklch() is supported in Chrome 111+, Firefox 113+, Safari 15.4+, and Edge 111+. By 2026 it is widely usable in production, and color-mix() is in roughly the same place.

ACES 2.0

The film and video world is dealing with the same family of problems through ACES.

ACES 1.x used 3x3 matrix transforms that could only model linear color-space conversions, which made hue shifts similar to the Abney effect harder to manage. ACES 2.0 refreshes the gamut-mapping algorithm to address that problem.

Wide-gamut spaces like ACEScg and BT.2020 can cover regions closer to the spectral boundary than sRGB can, but if your display is still sRGB, gamut mapping is still unavoidable at the end.

tools you can use

If you want to implement spectral rendering yourself, the Python colour-science library is a good starting point.

pip install colour-science

It includes CIE matching data, XYZ ↔ sRGB conversion, CIECAM02, and more.


You only want to render a rainbow correctly, and suddenly you are dealing with 1931 measurement errors, quirks of human perception, and CSS spec changes. That is the rabbit hole. What I found most interesting was Brandon Li’s manual correction work with gas discharge tubes and a diffraction grating.