Color management
OKLab and Okhsv perceptual color spaces, gamut mapping, color temperature, sRGB/linear conversions, YCbCr, and spectral color-matching functions.
The engine’s color library lives under @woosh/meep-engine/src/core/color/. All functions follow the same no-allocation convention as the rest of the core math: results are written into a caller-supplied array, channels are normalised floats in [0, 1] unless stated otherwise.
sRGB and linear RGB
Physically-based rendering requires linear light values. The two transfer-function converters handle the IEC 61966-2-1 sRGB piecewise formula exactly.
Channel converters
convert_channel_sRGB_to_linear(c) and convert_channel_linear_to_sRGB(c) operate on a single float:
import { convert_channel_sRGB_to_linear } from
"@woosh/meep-engine/src/core/color/sRGB/sRGB_to_linear.js";
import { convert_channel_linear_to_sRGB } from
"@woosh/meep-engine/src/core/color/sRGB/linear_to_sRGB.js";
const linear = convert_channel_sRGB_to_linear(0.5); // ≈ 0.214
const gamma = convert_channel_linear_to_sRGB(0.214); // ≈ 0.5
Array converters
sRGB_to_linear(output, outputOffset, input, inputOffset) and linear_to_sRGB(output, outputOffset, input, inputOffset) convert all three channels at once into an existing output buffer:
import { sRGB_to_linear } from
"@woosh/meep-engine/src/core/color/sRGB/sRGB_to_linear.js";
const linear = [0, 0, 0];
sRGB_to_linear(linear, 0, [0.5, 0.5, 0.5], 0);
All values are normalised (0–1), not byte-range (0–255).
OKLab
OKLab (Ottosson 2020) is a perceptually uniform Lab-like color space. L is lightness (0–1), a and b are opponent-color axes. Equal numerical distances in OKLab correspond to roughly equal perceived differences.
linear sRGB ↔ OKLab
import { linear_srgb_to_oklab } from
"@woosh/meep-engine/src/core/color/oklab/linear_srgb_to_oklab.js";
import { oklab_to_linear_srgb } from
"@woosh/meep-engine/src/core/color/oklab/oklab_to_linear_srgb.js";
const lab = [0, 0, 0];
linear_srgb_to_oklab(lab, r, g, b); // output: [L, a, b]
const rgb = [0, 0, 0];
oklab_to_linear_srgb(rgb, L, a, b); // output: [r, g, b] (may be out-of-gamut)
The forward path applies the LMS matrix, cube-root compression, and the final linear combination. The inverse is exact: cube the compressed channels, then apply the inverse LMS matrix.
linear sRGB ↔ OKLab via XYZ
xyz_to_oklab(out, X, Y, Z) and oklab_to_xyz(out, L, a, b) are available when you need the intermediate CIE XYZ step explicitly (e.g. when integrating spectral data).
Okhsv
Okhsv is a hue-saturation-value space that adapts OKLab to the sRGB gamut boundary. Unlike HSV in sRGB, equal saturation values look equally saturated across hues.
import { linear_srgb_to_okhsv } from
"@woosh/meep-engine/src/core/color/oklab/linear_srgb_to_okhsv.js";
import { okhsv_to_linear_srgb } from
"@woosh/meep-engine/src/core/color/oklab/okhsv_to_linear_srgb.js";
const hsv = [0, 0, 0];
linear_srgb_to_okhsv(hsv, r, g, b); // output: [h, s, v], h in [0,1)
const rgb = [0, 0, 0];
okhsv_to_linear_srgb(rgb, h, s, v); // output: [r, g, b] linear sRGB
The conversion finds the gamut cusp for each hue and applies a toe function to map the lightness axis smoothly.
Gamut mapping
find_gamut_intersection(a, b, L1, C1, L0) finds the parameter t at which the line from (L0, 0) to (L1, C1) (in the OKLab L–C plane) exits the sRGB gamut for the given hue direction (a, b). The upper half uses one step of Halley’s method for accuracy:
import { find_gamut_intersection } from
"@woosh/meep-engine/src/core/color/oklab/find_gamut_intersection.js";
// a, b must satisfy a² + b² = 1 (normalised hue direction)
const t = find_gamut_intersection(a, b, L1, C1, L0);
// L = L0*(1-t) + t*L1, C = t*C1 is the gamut boundary point
find_cusp(out, a, b) returns the [L, C] cusp point — the maximum chroma achievable for a given hue direction — which both find_gamut_intersection and the Okhsv conversions use internally.
Color temperature
Kelvin to sRGB
kelvin_to_rgb(result, resultOffset, temperature) converts a black-body color temperature (in Kelvin, range roughly 800–40 000 K) to sRGB gamma-corrected RGB. The output is in sRGB; convert with sRGB_to_linear before mixing.
import { kelvin_to_rgb } from
"@woosh/meep-engine/src/core/color/kelvin/kelvin_to_rgb.js";
const rgb = [0, 0, 0];
kelvin_to_rgb(rgb, 0, 6500); // approximate daylight
The function uses three piecewise logarithmic approximations (sub-1000 K black-body fade, red-dominant below 6600 K, blue-dominant above) with a blended transition at the crossover.
sRGB to Kelvin
rgb_to_kelvin(input, inputOffset) estimates the correlated color temperature from an RGB value using a binary search over kelvin_to_rgb, converging to within 0.4 K:
import { rgb_to_kelvin } from
"@woosh/meep-engine/src/core/color/kelvin/rgb_to_kelvin.js";
const T = rgb_to_kelvin([0.96, 0.95, 1.0], 0); // ≈ 6500 K
Planckian spectral radiance
planckian_radiance(lambda_m, T) evaluates the relative spectral power of a black-body radiator at wavelength lambda_m (in metres) and temperature T (in Kelvin). This is the physical foundation used when integrating a light source’s SPD against color-matching functions:
import { planckian_radiance } from
"@woosh/meep-engine/src/core/color/illuminant/planckian_radiance.js";
const power = planckian_radiance(550e-9, 6504); // relative power at 550 nm
Spectral color-matching functions
CIE 1931 XYZ (Wyman analytic fit)
xyz_cmf_wyman(out, wavelength_nm) returns the CIE 1931 2° x̄(λ), ȳ(λ), z̄(λ) using the multi-lobe Gaussian fits from Wyman, Sloan, and Shirley (2013). Valid over 380–780 nm; returns zeros outside that range. Absolute error is under 0.05 across the spectrum:
import { xyz_cmf_wyman } from
"@woosh/meep-engine/src/core/color/xyz/xyz_cmf_wyman.js";
const xyz = [0, 0, 0];
xyz_cmf_wyman(xyz, 550); // CIE XYZ tristimulus at 550 nm
A tabulated version (xyz_cmf_tabulated) and the sRGB CMFs (sRGB_cmf) are available in the same directory when the analytic approximation is insufficient.
D65 spectral power distribution
D65_spd_analytical and D65_spd_tabulated provide the CIE D65 illuminant SPD. Use these when building a custom spectral-to-XYZ integrator that weights by the illuminant.
Luminance and YCbCr
rgb_to_luminance(r, g, b) returns the perceptual luma using Rec. 709 coefficients (0.2126 R + 0.7152 G + 0.0722 B):
import { rgb_to_luminance } from
"@woosh/meep-engine/src/core/color/rgb_to_luminance.js";
const Y = rgb_to_luminance(r, g, b);
rgb_to_YCbCr_uint24(r, g, b) converts byte-range RGB (0–255) to a packed 24-bit YCbCr integer in Rec. 709 full-range encoding (0xYYCbCr). The inverse is YCbCr_to_rgb_uint24.
HDR packing
rgb_to_rgbe9995(r, g, b) packs three HDR floats into a single uint32 using the RGBE 9-9-9-5 shared-exponent format (the same encoding used in OpenEXR’s RGB9E5 type). The inverse is rgbe9995_to_rgb.
The Color class
For cases where a first-class RGBA object is more convenient than a raw array, Color provides a mutable (r, g, b, a) value with an onChanged signal, hex/HSV/uint conversion helpers, and linear↔sRGB convenience wrappers:
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
const c = new Color(1, 0.5, 0, 1);
All channels are 0–1 floats. The class is used primarily by the ECS component system where reactive change detection is needed.
Where to go next
- Math & geometry — the vector math these conversions are built on.
- Spatial acceleration — the BVH and intersection queries that sit alongside this library.