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
OKLabA color space where equal distances look like equal differences: L is lightness, a runs green to red and b blue to yellow. Ramps mixed in it stay even. OKLab A color space where equal distances look like equal differences: L is lightness, a runs green to red and b blue to yellow. Ramps mixed in it stay even.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.