Reference Updated July 2026

Color Spaces Reference

RGB, sRGB, linear light, HSL, HSV, CIE XYZ, CIELAB and ACEScg — conversion formulas, gamma correction, and the exact places WebGL and Three.js pipelines get color wrong.

🎨 RGB & sRGB

RGB is not one color space but a family: three numbers only mean something once you fix a primaries set (the red/green/blue chromaticities) and a transfer function (how numeric values map to light intensity). The web, PNG/JPEG files, and most textures use the sRGB primaries and transfer function defined by IEC 61966-2-1.

Space Primaries Typical use
sRGB (encoded) Rec. 709 gamut Textures, UI colors, PNG/JPEG, canvas 2D
Linear-sRGB Rec. 709 gamut Lighting math, framebuffer blending
Display-P3 DCI-P3 gamut Wide-gamut displays, HDR canvas
Rec. 2020 Ultra-wide gamut 4K/8K broadcast, some HDR pipelines

8-bit packing

A texel stored as #RRGGBB packs each channel into $[0,255]$; normalise to $[0,1]$ before any math:

Byte → normalized float $$c_{norm} = \frac{c_{byte}}{255}$$

💡 Gamma Correction & Linear Light

Light intensity adds linearly — two photons are twice the energy of one — but sRGB-encoded pixel values do not. sRGB applies an approximately 2.2-power curve so that 8-bit values allocate more precision to the dark tones the eye is most sensitive to. Any lighting, blending, or mip-mapping performed directly on encoded sRGB bytes is physically wrong and produces washed-out or muddy results.

sRGB → linear (decoding)

Piecewise EOTF, per channel c ∈ [0,1] $$c_{linear} = \begin{cases} \dfrac{c_{srgb}}{12.92} & c_{srgb} \le 0.04045 \\[6pt] \left(\dfrac{c_{srgb} + 0.055}{1.055}\right)^{2.4} & c_{srgb} > 0.04045 \end{cases}$$

Linear → sRGB (encoding)

Inverse OETF, per channel c ∈ [0,1] $$c_{srgb} = \begin{cases} 12.92\,c_{linear} & c_{linear} \le 0.0031308 \\[6pt] 1.055\,c_{linear}^{1/2.4} - 0.055 & c_{linear} > 0.0031308 \end{cases}$$

A cheap approximation used in many shaders — accurate to within about 1% — replaces the piecewise curve with a flat power of 2.2:

Simple gamma-2.2 approximation $$c_{linear} \approx c_{srgb}^{2.2} \qquad c_{srgb} \approx c_{linear}^{1/2.2}$$
Stage Should operate in
Lighting / shading math Linear
Alpha blending, MSAA resolve Linear
Mip-map generation / texture filtering Linear
Final framebuffer / swap-chain output sRGB-encoded
Diffuse / albedo texture storage on disk sRGB-encoded
Normal maps, roughness/metalness, data textures Linear (no sRGB flag!)
// GLSL: sRGB <-> linear conversion for a single channel
float srgbToLinear(float c) {
  return c <= 0.04045
    ? c / 12.92
    : pow((c + 0.055) / 1.055, 2.4);
}

float linearToSrgb(float c) {
  return c <= 0.0031308
    ? c * 12.92
    : 1.055 * pow(c, 1.0 / 2.4) - 0.055;
}

vec3 srgbToLinear(vec3 c) {
  return vec3(srgbToLinear(c.r), srgbToLinear(c.g), srgbToLinear(c.b));
}

🌈 HSL & HSV

HSL (hue, saturation, lightness) and HSV/HSB (hue, saturation, value/brightness) are cylindrical re-parameterisations of RGB designed for human-friendly color pickers — not for lighting math. Hue $H \in [0°,360°)$ is shared between both; saturation and the lightness/value axis differ.

RGB → HSV

c_max, c_min from R,G,B ∈ [0,1]; Δ = c_max − c_min $$H = \begin{cases} 0 & \Delta = 0 \\ 60°\left(\dfrac{G-B}{\Delta} \bmod 6\right) & c_{max}=R \\ 60°\left(\dfrac{B-R}{\Delta}+2\right) & c_{max}=G \\ 60°\left(\dfrac{R-G}{\Delta}+4\right) & c_{max}=B \end{cases}$$ $$S = \begin{cases} 0 & c_{max}=0 \\ \Delta / c_{max} & \text{otherwise} \end{cases} \qquad V = c_{max}$$

HSL lightness vs HSV value

Relationship for the same R,G,B $$L = \frac{c_{max}+c_{min}}{2} \qquad S_{HSL} = \frac{\Delta}{1 - |2L-1|}\;(\Delta \ne 0)$$

A pure hue at $V=1, S=1$ in HSV is fully saturated and bright — but the same hue in HSL only reaches maximum saturation at $L=0.5$; at $L=1$ HSL always converges to white regardless of hue. This is why HSL color pickers feel more "balanced" for UI work while HSV feels more natural for artists mixing paint-like tints and shades.

// GLSL: hue [0,1] -> RGB (fully saturated, full value)
vec3 hue2rgb(float h) {
  vec3 p = abs(fract(vec3(h) + vec3(1.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0);
  return clamp(p - 1.0, 0.0, 1.0);
}

vec3 hsv2rgb(vec3 hsv) {
  vec3 rgb = hue2rgb(hsv.x);
  return mix(vec3(1.0), rgb, hsv.y) * hsv.z;
}

👁 CIE XYZ & CIELAB

CIE 1931 XYZ is a device-independent color space derived directly from human cone-cell response curves — every other color space is defined as a transform to or from XYZ. $Y$ carries luminance; $X$ and $Z$ carry chromatic information with no perceptual meaning on their own.

Linear-sRGB → XYZ (D65 white point)

Rec. 709 primaries matrix $$\begin{pmatrix}X\\Y\\Z\end{pmatrix} = \begin{pmatrix} 0.4124564 & 0.3575761 & 0.1804375 \\ 0.2126729 & 0.7151522 & 0.0721750 \\ 0.0193339 & 0.1191920 & 0.9503041 \end{pmatrix} \begin{pmatrix}R_{linear}\\G_{linear}\\B_{linear}\end{pmatrix}$$

XYZ is still not perceptually uniform — a numeric distance of 0.01 in one region can look identical while the same distance elsewhere looks strikingly different. CIELAB (L*a*b*) was built specifically to fix this: equal numeric distances correspond roughly to equal perceived color differences, which is why it's used for color difference metrics ($\Delta E$) and gamut mapping.

XYZ → LAB, relative to white point (Xn, Yn, Zn) $$L^* = 116\,f(Y/Y_n) - 16$$ $$a^* = 500\left(f(X/X_n) - f(Y/Y_n)\right)$$ $$b^* = 200\left(f(Y/Y_n) - f(Z/Z_n)\right)$$ $$f(t) = \begin{cases} t^{1/3} & t > (6/29)^3 \\ \dfrac{1}{3}(29/6)^2 t + \dfrac{4}{29} & \text{otherwise} \end{cases}$$
Space Perceptually uniform? Common use
CIE XYZ No Interchange format between color spaces
CIELAB Approximately ΔE color difference, print gamut mapping
OKLab / OKLCH Better than LAB Modern CSS gradients, perceptual hue rotation

🔥 Color Temperature & White Points

Correlated color temperature (CCT), measured in kelvin, describes the color of an ideal black-body radiator at that temperature. Lower kelvin values look warm/orange (candlelight ≈ 1900K), higher values look cool/blue (overcast sky ≈ 7000K+). A "white point" is simply the CCT a renderer treats as neutral white — D65 (~6504K) is the sRGB and Rec. 709 standard.

Source Approx. CCT
Candle flame 1,900 K
Incandescent bulb 2,700 K
Sunrise / sunset 3,000–4,000 K
D50 (print/graphic arts white point) 5,003 K
D65 (sRGB / Rec. 709 white point) 6,504 K
Overcast daylight 7,000–10,000 K

In a renderer, CCT is typically converted to a linear RGB multiplier (a Planckian-locus approximation) and applied as a white-balance tint on the final lit image, or fed into a directional light's color to simulate a specific light source.

🌅 HDR, ACEScg & Tone Mapping

Physically-based renderers accumulate unbounded linear radiance — a bright light source or specular highlight can exceed 1.0 many times over. Before that HDR image can be shown on an SDR (standard dynamic range) display, it must be tone mapped down into displayable $[0,1]$ range, then re-encoded to sRGB.

Reinhard tone mapping (simple, per-channel)

Compresses [0,∞) → [0,1) $$c_{mapped} = \frac{c_{hdr}}{1 + c_{hdr}}$$

ACES filmic approximation (Narkowicz 2015)

Fast fitted curve used widely in real-time engines $$a=2.51,\; b=0.03,\; c=2.43,\; d=0.59,\; e=0.14$$ $$c_{mapped} = \text{clamp}\!\left(\frac{c_{hdr}(a\,c_{hdr}+b)} {c_{hdr}(c\,c_{hdr}+d)+e},\; 0,\; 1\right)$$

ACEScg is the wide-gamut linear working space used inside the Academy Color Encoding System pipeline for film and high-end real-time rendering — it uses primaries wider than Rec. 2020 specifically so that intermediate lighting calculations never clip before the final tone-mapping step. Three.js's ACESFilmicToneMapping renderer setting implements the Narkowicz fit above.

// GLSL: ACES filmic fit
vec3 acesFilmic(vec3 x) {
  const float a = 2.51;
  const float b = 0.03;
  const float c = 2.43;
  const float d = 0.59;
  const float e = 0.14;
  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

⚙️ Three.js Color Management

Since r152, Three.js enables THREE.ColorManagement by default: colors you author in JavaScript (hex, CSS strings) are treated as sRGB and automatically converted to linear internally, while textures need an explicit color-space tag so the renderer knows whether to decode them.

import * as THREE from 'three';

// Albedo / diffuse textures carry human-authored color -> sRGB
const albedoMap = new THREE.TextureLoader().load('brick-albedo.jpg');
albedoMap.colorSpace = THREE.SRGBColorSpace;

// Normal / roughness / metalness / AO textures store raw data -> Linear (no decode)
const normalMap = new THREE.TextureLoader().load('brick-normal.jpg');
normalMap.colorSpace = THREE.NoColorSpace;

// Renderer output encoding + filmic tone mapping
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;

// Hex/CSS colors are authored in sRGB and auto-converted to linear
const mat = new THREE.MeshStandardMaterial({
  map: albedoMap,
  normalMap: normalMap,
  color: 0xff6b35, // interpreted as sRGB by ColorManagement
});
Getting it wrong

Tagging an albedo texture as NoColorSpace (or the legacy encoding = LinearEncoding) skips the sRGB decode, so lighting is computed on gamma-encoded values — surfaces render too dark and desaturated in shadow, too flat in mid-tones. Tagging a normal map as SRGBColorSpace corrupts the vector data with an unwanted gamma curve, producing visibly wrong shading and lighting artifacts.

⚠️ Common Pitfalls

sRGB #ff6b35
linear ≈ 0.897, 0.056, 0.010
sRGB #60a5fa
linear ≈ 0.121, 0.373, 0.925
sRGB #34d399
linear ≈ 0.023, 0.677, 0.336
sRGB #c084fc
linear ≈ 0.510, 0.216, 0.955