Skip to content
Fundamental · Color mathIntermediate

HSV & OKLab

HSV for edits people can name, like shifting hue or draining saturation, and OKLab for blends and ramps that look even.

How it works

RGB is how a screen makes color: three amounts of light. It is not how people describe color, and its steps don’t look even to the eye. “Make it a bit more orange”, “less saturated”, “the same brightness but bluer” are hard to write as changes to red, green and blue. A color space is another set of three numbers for the same colors, chosen so that some of those edits become simple. Two cover nearly every shader need: HSV for intuitive edits, OKLab for perceptually even mixing.

HSV: hue, saturation, value

HSV rearranges the RGB cube around its gray diagonal. Hue is the angle around the color wheel (here 0–1 instead of 0–360°, red at 0, green at 1/3, blue at 2/3), saturation is how far from gray the color is, and value is the largest of the three channels.

vec3 rgbToHsv(vec3 c) {
    float hi = max(c.r, max(c.g, c.b));
    float lo = min(c.r, min(c.g, c.b));
    float range = hi - lo;
    float h = 0.0;                                    // gray has no hue; call it 0
    if (range > 0.0) {
        if (hi == c.r)      h = mod((c.g - c.b) / range, 6.0);
        else if (hi == c.g) h = (c.b - c.r) / range + 2.0;
        else                h = (c.r - c.g) / range + 4.0;
    }
    float s = hi > 0.0 ? range / hi : 0.0;
    return vec3(h / 6.0, s, hi);
}

vec3 hsvToRgb(vec3 hsv) {
    vec3 k = mod(vec3(5.0, 3.0, 1.0) + hsv.x * 6.0, 6.0); // each channel's place on the wheel
    vec3 ramp = clamp(min(k, 4.0 - k), 0.0, 1.0);          // 1 where the channel is off
    return hsv.z * (1.0 - hsv.y * ramp);
}

With those two, the common edits are one line each, and each one wraps or clamps the way its name suggests:

vec3 hsv = rgbToHsv(color);
hsv.x = fract(hsv.x + 0.1);           // rotate hue a tenth of the way round, wrapping at 1
hsv.y *= 0.5;                         // half the saturation
vec3 edited = hsvToRgb(hsv);

HSV is a rearrangement, not a model of vision, and it shows. Full-saturation yellow and full-saturation blue have the same value but look nothing alike in brightness. Hue is undefined for grays, so a gray pixel can’t be “rotated”. And interpolating hue needs care: halfway between 0.9 and 0.1 is 0.0 the short way round, not 0.5. HSV was designed for encoded values, the way paint programs use it; applied to linear values the same edit looks different, so choose one and use it consistently.

OKLab: even steps

OKLab, published by Björn Ottosson in 2020, is built so that equal distances look like equal differences. L is perceived lightness from 0 (black) to 1 (white), a runs from green to red and b from blue to yellow. It starts from linear RGB, so decode first:

// Coefficients from Ottosson's definition of OKLab (linear sRGB input).
// Each line of three numbers below is one row of his matrix; GLSL fills mat3 column by column, so the vector goes on the left.
const mat3 RGB_TO_LMS = mat3(
    0.4122214708, 0.5363325363, 0.0514459929,
    0.2119034982, 0.6806995451, 0.1073969566,
    0.0883024619, 0.2817188376, 0.6299787005);
const mat3 LMS_TO_LAB = mat3(
    0.2104542553,  0.7936177850, -0.0040720468,
    1.9779984951, -2.4285922050,  0.4505937099,
    0.0259040371,  0.7827717662, -0.8086757660);
const mat3 LAB_TO_LMS = mat3(
    1.0,  0.3963377774,  0.2158037573,
    1.0, -0.1055613458, -0.0638541728,
    1.0, -0.0894841775, -1.2914855480);
const mat3 LMS_TO_RGB = mat3(
     4.0767416621, -3.3077115913,  0.2309699292,
    -1.2684380046,  2.6097574011, -0.3413193965,
    -0.0041960863, -0.7034186147,  1.7076147010);

vec3 cbrt3(vec3 x) { return sign(x) * pow(abs(x), vec3(1.0 / 3.0)); } // pow alone fails below 0

vec3 linearToOklab(vec3 rgb) { return cbrt3(rgb * RGB_TO_LMS) * LMS_TO_LAB; }

vec3 oklabToLinear(vec3 lab) {
    vec3 lms = lab * LAB_TO_LMS;
    return (lms * lms * lms) * LMS_TO_RGB;
}

The use that pays off most is mixing. A ramp between two colors mixed in RGB can dip in brightness or swing through a dull, grayish middle. The same ramp mixed in OKLab changes lightness evenly from one end to the other:

vec3 mixOklab(vec3 a, vec3 b, float t) { // a, b and the result are linear RGB
    return oklabToLinear(mix(linearToOklab(a), linearToOklab(b), t));
}

The polar form, OKLCH, turns a and b into chroma length(lab.yz) and hue atan(lab.z, lab.y). It works like HSV with a lightness that means what it says: rotate the hue and the brightness stays put, and a set of colors with the same L reads as equally bright.

Some OKLab values have no RGB equivalent: push chroma high enough and the conversion back returns channels below 0 or above 1. Clamp the result, or lower the chroma until it fits. A conversion costs two matrix products and a cube root (or a cube) per channel each way: cheap enough for every pixel, though when the colors are constants it is worth converting them once.

Further reading