Pular para o conteúdo
Fundamento · Matemática de corIntermediário

HSV e OKLab

HSV para ajustes que as pessoas sabem nomear, como girar o matiz ou tirar saturação, e OKLab para misturas e rampas que parecem uniformes.

Como funciona

RGB é como a tela produz cor: três quantidades de luz. Não é como as pessoas descrevem cor, e os degraus dele não parecem uniformes ao olho. “Deixa um pouco mais laranja”, “menos saturado”, “o mesmo brilho, só que mais azul” são difíceis de escrever como mudanças em vermelho, verde e azul. Um espaço de cor é outro trio de números para as mesmas cores, escolhido para que alguns desses ajustes fiquem simples. Dois cobrem quase tudo o que um shader precisa: HSV para ajustes intuitivos, OKLab para misturas perceptualmente uniformes.

HSV: matiz, saturação, valor

O HSV reorganiza o cubo RGB em volta da diagonal dos cinzas. O matiz é o ângulo no círculo cromático (aqui de 0 a 1 em vez de 0 a 360°, vermelho em 0, verde em 1/3, azul em 2/3), a saturação é o quanto a cor se afasta do cinza, e o valor é o maior dos três canais.

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;                                    // cinza não tem matiz; fica 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); // o lugar de cada canal no círculo
    vec3 ramp = clamp(min(k, 4.0 - k), 0.0, 1.0);          // 1 onde o canal está apagado
    return hsv.z * (1.0 - hsv.y * ramp);
}

Com essas duas, os ajustes comuns ficam com uma linha cada, e cada um dá a volta ou trava do jeito que o nome sugere:

vec3 hsv = rgbToHsv(color);
hsv.x = fract(hsv.x + 0.1);           // gira o matiz um décimo da volta, voltando ao início depois de 1
hsv.y *= 0.5;                         // metade da saturação
vec3 edited = hsvToRgb(hsv);

O HSV é uma reorganização, não um modelo da visão, e isso aparece. O amarelo e o azul com saturação total têm o mesmo valor, mas não se parecem nada em brilho. O matiz é indefinido para os cinzas, então um pixel cinza não pode ser “girado”. E interpolar o matiz pede cuidado: o meio do caminho entre 0.9 e 0.1 é 0.0 pelo lado curto, não 0.5. O HSV foi pensado para valores codificados, do jeito que os programas de pintura o usam; aplicado a valores lineares, o mesmo ajuste parece diferente, então escolha um e use sempre o mesmo.

OKLab: degraus uniformes

O OKLab, publicado por Björn Ottosson em 2020, foi construído para que distâncias iguais pareçam diferenças iguais. L é a luminosidade percebida, de 0 (preto) a 1 (branco), a vai do verde ao vermelho e b do azul ao amarelo. Ele parte do RGB linear, então decodifique antes:

// Coeficientes da definição do OKLab por Ottosson (entrada em sRGB linear).
// Cada linha de três números abaixo é uma linha da matriz dele; o GLSL preenche a mat3 coluna por coluna, então o vetor vai à esquerda.
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 sozinho falha abaixo de 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;
}

O uso que mais compensa é a mistura. Uma rampa entre duas cores misturada em RGB pode afundar no brilho ou passar por um meio apagado e acinzentado. A mesma rampa misturada em OKLab muda a luminosidade por igual de uma ponta à outra:

vec3 mixOklab(vec3 a, vec3 b, float t) { // a, b e o resultado são RGB linear
    return oklabToLinear(mix(linearToOklab(a), linearToOklab(b), t));
}

A forma polar, OKLCH, transforma a e b em croma length(lab.yz) e matiz atan(lab.z, lab.y). Funciona como o HSV, mas com uma luminosidade que quer dizer o que diz: gire o matiz e o brilho fica onde está, e um conjunto de cores com o mesmo L parece igualmente claro.

Alguns valores de OKLab não têm equivalente em RGB: aumente o croma o bastante e a conversão de volta devolve canais abaixo de 0 ou acima de 1. Trave o resultado com clamp ou baixe o croma até caber. Uma conversão custa dois produtos de matriz e uma raiz cúbica (ou um cubo) por canal em cada sentido: barato o bastante para todo pixel, mas quando as cores são constantes vale converter uma vez só.

Leitura complementar