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 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 |
A texel stored as #RRGGBB packs each channel into
$[0,255]$; normalise to $[0,1]$ before any math:
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.
A cheap approximation used in many shaders — accurate to within about 1% — replaces the piecewise curve with a flat power of 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 (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.
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 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.
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.
| 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 |
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.
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.
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);
}
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
});
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.
SRGB8 internal texture
format so the GPU decodes before filtering.
pow(color, 1.0/2.2) in a shader
and leaving renderer.outputColorSpace =
SRGBColorSpace set produces washed-out, over-bright
output — pick exactly one encode step.
atan2.