Math & geometry

Color management

The encoded-sRGB vs linear contract, OKLab/Okhsl/Okhsv and gamut clipping, CIELab and YCxCz, PQ, color temperature, 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.

Encoded sRGB or linear: pick one deliberately

Half of this library speaks encoded sRGB - what a hex literal, a CSS string or a colour picker holds - and half speaks linear light, which is what any arithmetic on colour has to be done in. Nothing in the types distinguishes the two, so getting it wrong is quietly wrong rather than loudly wrong: you get a plausible colour that is lightened by the wrong amount.

Speaks encoded sRGBSpeaks linear
Color#parse, Color#toHex, Color#toCssRGBAStringeverything in operations/ - color_lighten, color_darken, color_lerp, color_saturate, …
parse_color_normalized, color_from_hex, color_from_temperatureColor#computeLuminance, rgb_to_luminance
kelvin_to_rgb, rgb_to_kelvinlinear_srgb_to_oklab and the whole oklab/ family, rgb_to_xyz

So color_lighten(Color.parse('#808080')) is wrong. Convert first with Color.from_sRGB_to_linear, or hand the whole thing to color_srgb_apply, which decodes on the way in and re-encodes on the way out:

import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import { color_srgb_apply } from
  "@woosh/meep-engine/src/core/color/operations/color_srgb_apply.js";
import { color_lighten } from
  "@woosh/meep-engine/src/core/color/operations/color_lighten.js";

// encoded in, encoded out - safe to hand straight back to CSS
const lighter = color_srgb_apply(Color.parse('#808080'), color_lighten, 0.5);
element.style.background = lighter.toHex();

color_srgb_apply(input, operation, amount) is the only wrapper of its kind; every other operations/ function takes linear values directly.

Naming: name_to_name, and one trap

Conversion helpers follow a uniform <from>_to_<to> spelling. Each has a deprecated alias exported from its original path; use the canonical name.

DeprecatedCanonicalPath
hex2rgbhex_to_rgbcolor/hex/hex_to_rgb.js
rgb2hexrgb_to_hexcolor/hex/rgb_to_hex.js
rgb2hsvrgb_to_hsvcolor/hsv/rgb_to_hsv.js
int2rgbint_to_rgbcolor/int_to_rgb.js
rgb2uint24rgb_to_uint24color/rgb_to_uint24.js
rgb2uint32rgb_to_uint32color/rgb_to_uint32.js
hsv2rgbhsv_to_rgb_uint8color/hsv/hsv_to_rgb_uint8.js
hsv2rgb_floathsv_to_rgbcolor/hsv/hsv_to_rgb.js

The last two rows are not a rename. hsv2rgb returns 0–255 and hsv2rgb_float returns 0–1, while the unsuffixed canonical name follows the package default: hsv_to_rgb(h, s, v) returns {r, g, b} in 0–1, and hsv_to_rgb_uint8 is the byte-scaled variant. Code that swaps hsv2rgb for hsv_to_rgb expecting bytes will be 255× too dark.

xyz_to_hpe and hpe_to_xyz are exported from both color/hunt/ and color/xyz/; color/xyz/ is the canonical path.

sRGB and linear RGB

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, output_offset, input, input_offset) and linear_to_sRGB(output, output_offset, input, input_offset) 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).

PQ (SMPTE ST 2084)

linear_to_PQ(linear) and PQ_to_linear(pq) are the HDR transfer pair, under color/PQ/. They are defined in the standard’s units, where 1.0 is 10 000 nits - not the engine’s, where 1.0 is SDR reference white:

import { linear_to_PQ } from
  "@woosh/meep-engine/src/core/color/PQ/linear_to_PQ.js";
import { PQ_to_linear } from
  "@woosh/meep-engine/src/core/color/PQ/PQ_to_linear.js";
import { PQ_SDR_WHITE_LINEAR } from
  "@woosh/meep-engine/src/core/color/PQ/PQ_constants.js";

const pq = linear_to_PQ(engine_linear * PQ_SDR_WHITE_LINEAR);
const back = PQ_to_linear(pq) / PQ_SDR_WHITE_LINEAR;

Keeping the white-point convention out of the transfer function is what makes the pair an exact inverse. PQ_constants.js carries PQ_M1, PQ_M2, PQ_C1, PQ_C2, PQ_C3, PQ_MAX_NITS (10 000), SDR_WHITE_NITS (203) and PQ_SDR_WHITE_LINEAR. Negative input to linear_to_PQ clamps to 0 rather than returning NaN.

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.

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). oklab_to_oklch(output, L, a, b) and oklch_to_oklab(output, L, C, h) swap between the rectangular and cylindrical forms.

Okhsv and Okhsl

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.

linear_srgb_to_okhsl / okhsl_to_linear_srgb are the lightness-based twin, backed by okhsl_chroma_bounds (which reports the C_0 / C_mid / C_max chroma bounds for a hue at a given lightness) and okhsl_st_mid.

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(output, 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.

Three clipping strategies take a linear sRGB triple and write a clipped one:

FunctionProjects toward
gamut_clip_preserve_chroma(output, r, g, b)the colour’s own lightness - keeps chroma, shifts lightness least
gamut_clip_project_to_lcusp(output, r, g, b)the cusp lightness for that hue
gamut_clip_adaptive_l0(output, r, g, b, alpha)an L0 chosen per colour, between its own lightness and mid-grey

gamut_clip_adaptive_l0 is the one to reach for when clipping a whole image: a colour barely outside the gamut is left almost alone, one far outside is projected toward 0.5 where the gamut is wide enough to keep some chroma. alpha defaults to GAMUT_CLIP_DEFAULT_ALPHA (0.05). gamut_clip_at_l0(output, r, g, b, choose_l0) is the generic form the other two are built on.

CIELab and YCxCz

Two spaces with a clean division of labour:

  • CIELab (color/lab/) is the space to measure a colour difference in. xyz_to_lab(output, output_offset, input, input_offset) and lab_to_xyz(...) convert against the D65 white point, which maps to exactly (100, 0, 0). lab_distance_hyab(a, a_offset, b, b_offset) is the HyAB metric - absolute lightness difference plus euclidean chromatic distance - which behaves better than plain euclidean at large colour differences. lab_apply_hunt_adjustment scales chroma with lightness for the Hunt effect.
  • YCxCz (color/ycxcz/) is the space to filter in. It is CIELab’s opponent structure without the cube-root compression, so a weighted average of colours is still meaningful - which is what applying a contrast-sensitivity function needs, and what CIELab cannot give you. xyz_to_ycxcz / ycxcz_to_xyz.

One gotcha worth knowing about YCxCz: the achromatic channel is 100 for reference white and -16 for black, not 0. That offset is CIELab’s, and in CIELab the compression’s 4/29 intercept cancels it; drop the compression and nothing does. The channel is still linear in luminance and exactly invertible, but it is not L*.

Color temperature

Kelvin to sRGB

kelvin_to_rgb(result, result_offset, temperature) converts a black-body color temperature (in Kelvin, useful range roughly 1000–40 000 K) to encoded 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. color_from_temperature(kelvin, output) is the Color-shaped face of the same thing.

sRGB to Kelvin

rgb_to_kelvin(input, input_offset) 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, valid XYZ_CMF_MIN_NM 360 to XYZ_CMF_MAX_NM 830) and the sRGB CMFs (sRGB_cmf) are available when the analytic approximation is insufficient.

D65 spectral power distribution

D65_spd_analytical and D65_spd_tabulated provide the CIE D65 illuminant SPD; D65_TRISTIMULUS_XYZ is the white point itself. Use these when building a custom spectral-to-XYZ integrator that weights by the illuminant.

Luminance, YCbCr and YCoCg

rgb_to_luminance(r, g, b) returns relative luminance using Rec. 709 coefficients. Input must be linear - the same coefficients applied to encoded sRGB give luma (Y′), which is not a physical brightness and does not average correctly.

import { rgb_to_luminance } from
  "@woosh/meep-engine/src/core/color/rgb_to_luminance.js";

const Y = rgb_to_luminance(r, g, b);   // r, g, b linear

The coefficients themselves live in one place, REC709_PRIMARIES.js, as REC709_LUMINANCE_R / _G / _B. They are derived from the Rec. 709 chromaticities rather than the rounded 0.2126 / 0.7152 / 0.0722 the spec quotes, which is what makes linear white land on Y = 1 and on L* = 100, a* = 0, b* = 0 exactly. The difference is about 1e-4 - below an 8-bit quantum - and the WGSL twin the renderer uses carries the same digits.

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.

rgb_to_ycocg(result, result_offset, r, g, b) and ycocg_to_rgb convert linear RGB to and from YCoCg. The transform is a plain linear map, so it is HDR-safe and - the property that matters - it commutes with interpolation: lerp(ycocg(a), ycocg(b)) equals ycocg(lerp(a, b)). That is what lets YCoCg data be hardware-filtered exactly as if it were RGB and reconstructed afterwards.

HDR packing and tonemapping

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(out, out_offset, rgbe).

tonemap_aces(output, output_offset, input, input_offset) applies the ACES filmic curve to a linear triple.

Colormaps

MAGMA_LUT is a 256-entry, 8-bit RGB lookup table sampled uniformly from the perceptually uniform “magma” map, with MAGMA_LUT_ENTRY_COUNT alongside it. Equal steps along it look like equal steps in magnitude and it stays readable in greyscale - which is why it is the right choice for an error or heat map, where a rainbow would invent boundaries the data does not have.

import { MAGMA_LUT } from
  "@woosh/meep-engine/src/core/color/colormap/MAGMA_LUT.js";

const i = Math.round(t * 255) * 3;
const [r, g, b] = [MAGMA_LUT[i], MAGMA_LUT[i + 1], MAGMA_LUT[i + 2]]; // 0..255

The Color class

Color is a mutable (r, g, b, a) value with a change signal, for cases where a first-class RGBA object is more convenient than a raw array.

import { Color } from "@woosh/meep-engine/src/core/color/Color.js";

const c = new Color(1, 0.5, 0, 1);
c.set(0.2, 0.4, 0.8, 1);            // fires onChanged

Three things about it are worth stating plainly:

  • It is space-agnostic. Four floats and a signal, with no space tag, enum or brand. It holds whatever you put in it; the contract lives in the functions that consume it, per the table at the top of this page.
  • The range is 0–1 for SDR but unenforced. set() accepts any finite number so HDR content and out-of-gamut intermediates work. The price is that a channel which has gone wrong is usually still a perfectly finite number, so no assertion catches it.
  • onChanged is a lazy getter. It allocates its Signal on first access rather than in the constructor, because a Signal per Color is 4× the construction cost and most instances are never subscribed to. Reading it always yields the same Signal for a given instance, so color.onChanged.add(...) behaves as a plain property would.

Only set() and the methods routed through it fire onChanged; the numeric index setters (c[0] = …) deliberately do not.

Color.from_sRGB_to_linear(input, output) and Color.from_linear_to_sRGB(input, output) are the statics that move a Color between the two conventions, allocating a new one when output is omitted.

Constructing one

The construct/ directory holds Color-returning factories, each taking an optional output to write into:

FunctionBuilds fromResult
color_from_hex(hex, output)a hex stringencoded sRGB
color_from_temperature(kelvin, output)a blackbody temperatureencoded sRGB
color_from_uint24(value, output)0xRRGGBBalpha 1; throws outside [0, 0xFFFFFF]
color_from_uint32(value, output)0xRRGGBBAAalpha from the low byte
color_gray(value, alpha, output)one scalarspace-agnostic - a linear 0.5 and an encoded 0.5 are different greys

parse_color_normalized(output, value) is the array-shaped parser behind Color.parse: it takes a CSS colour string or a 24-bit integer and writes r, g, b, a as encoded sRGB into a four-element output.

Operating on one

operations/ holds the arithmetic, all of it expecting linear input:

FunctionEffect
color_lighten(input, amount)Move the Okhsv value channel a fraction of the way toward 1
color_darken(input, amount)Scale the Okhsv value channel down, clamped to 0–1
color_saturate(input, amount)Move the Okhsv saturation channel a fraction of the way toward 1
color_desaturate(input, amount)Scale the Okhsv saturation channel down, clamped to 0–1
color_lerp(color_0, color_1, t)Perceptual interpolation in Okhsv, taking the shortest arc around the hue circle
color_add / color_sub / color_add_scaled / color_multiply_rgbChannel arithmetic into an optional output
color_clamp(input, output)Clamp every channel to 0–1
color_is_in_gamut(input, tolerance)Whether every channel is inside 0–1
color_get_hsl(output, input)Read HSL out without allocating a Color
color_mix_okhsv_channel / color_scale_okhsv_channelTarget one Okhsv channel, with OKHSV_CHANNEL_SATURATION / OKHSV_CHANNEL_VALUE

color_lighten, color_darken, color_saturate and color_desaturate return input unchanged when amount is 0, and a new Color otherwise.

Where to go next