Launch OpenGPEX

Color Pipeline

How v2 manages color: a single fixed working color space (Linear Display-P3), GPU-side gamut alignment with lossless out-of-gamut preservation, TRC-wrapped perceptual adjustments, and a mathematically derived matrix system that eliminated hand-copied constant drift.


The Working Color Space

A color space is fully defined by three elements: gamut (primaries) + white point + TRC. v2 fixes one global working space for the entire engine:

Element Value
Gamut Display-P3 primaries
White point D65
TRC Linear light (no gamma)
Container rgba16float (upgraded to rgba32float when the adapter grants both float32-blendable and float32-filterable)

Everything downstream — the 16 blend modes, 3D LUT interpolation, adjustment operators, Gaussian convolution, and the export readback buffer — executes in this one space without ever needing to know a layer's original encoding. The canvas swapchain is likewise fixed to display-p3, so layer add/remove/merge/reorder never triggers a working-space rebuild or a full re-transcode.


The Three Invariants

Invariant A — Out-of-Gamut Preservation (no clamp)

Display-P3 covers roughly 53.6% of CIE 1931. Adobe RGB greens and ProPhoto/Rec.2020 primaries fall outside it, so mapping them into P3 necessarily produces negative or >1.0 channel values. The fragment-shader gamut_to_working matrix transform is forbidden from clamping: out-of-bounds floats are stored verbatim in rgba16float, and the strictly invertible inverse matrix recovers them losslessly when exporting back to the source gamut.

Example — Adobe RGB pure green maps to a physically negative red channel; treating that as a float overflow and zeroing it (the historical bug) destroys the color:

AdobeRGB (0, 1, 0) → P3 linear ≈ (-0.1501, 0.9536, 0.0265)

Invariant B — Fixed Compute Format

All compositing math runs in a float linear-light format, independent of source depth: rgba16float by default, upgraded to rgba32float only when the adapter grants BOTH float32-blendable (render-target side) and float32-filterable (sampling side) — otherwise the ping-pong passes would bind a filtering sampler to an unfilterable float texture and pipeline creation fails. (32-bit float textures always carry 32-bit sources and the export readback regardless.)

Invariant C — GPU-Side Gamut Alignment

Source-gamut conversion happens per-sample inside the fragment shader, driven by a gamut_id packed into the layer uniform flags:

gamut_id Source gamut Behavior
0 Display-P3 Identity pass-through
1 sRGB MAT_SRGB_TO_P3
2 Adobe RGB MAT_ADOBE_RGB_TO_P3
3 ProPhoto RGB MAT_PROPHOTO_TO_P3
4 Rec.2020 MAT_REC2020_TO_P3

Blend asymmetry contract: gamut conversion applies only to the foreground sample point. The accumulator background is always already in the working space; converting it again is a bug.


Shader Sampling Sequence

Every layer-sampling fragment shader follows the same fixed stage order:

① textureSample                     raw straight-alpha source pixel
② TRC restore                       sRGB/gamma → linear light (skip if source linear)
③ gamut alignment                   source gamut → Display-P3 (3×3, no clamp),
    │                               then render-intent tone-map (SDR passthrough if 0)
④ adjustments (optional)            apply_adjustments() — TRC-wrapped
⑤ premultiply                       rgb *= a

Adjustments deserve a note: curves, levels, color balance, and 3D LUTs are perceptual-domain operators — users expect slider behavior in the encoded domain. So apply_adjustments() internally wraps its math: linear → encode → operator → decode → linear. It operates on straight (de-premultiplied) RGB and re-premultiplies afterward so alpha never leaks into color math.


Single Source of Truth Matrices

Conversion matrices are not hand-copied constants. core/color/gamut.ts stores each gamut's xy primaries and white point, and derives matrices chromatically:

M(A→B) = M_RGB→XYZ(B)⁻¹ × Bradford(W_A→W_B) × M_RGB→XYZ(A)

The same derivation feeds both the WGSL shader literals and CPU-side export constants, guarded by automated tests asserting the derivation against published anchors (canonical Rec.709 D65, the CSS Color 4 published sRGB→P3 matrix), that the WGSL literals match the TS derivation (tolerance ≤ 1e-6), and that every forward/inverse pair multiplies to the identity (≤ 1e-5). This eliminated the ~0.6% round-trip drift of the old hand-copied matrices, which produced 1–2 code-value shifts on import→export.


Ingest Bifurcation

Input material splits into two lanes at decode time:

sRGB / Display-P3 (standard gamut)
    └─► zero-copy bitmap lane
        createImageBitmap(colorSpaceConversion: 'none')
        → copyExternalImageToTexture (explicit native colorSpace tag)
        → rgba8unorm texture (gamut_id 1u / 0u)
        → shader: srgb_to_linear + matrix (or identity)

wider than P3 (Adobe RGB / ProPhoto / Rec.2020, 8-bit or 16-bit)
    └─► floating-point raw lane
        16-bit: vips/libraw naked pixels;  8-bit wide: wideGamut8ToF16
        → writeTexture → rgba16float resident texture (linear, gamut_id 2u–4u)
        → shader: skip TRC, gamut matrix, negatives preserved

Why 8-bit wide-gamut sources must be lifted to f16-linear rather than stored as rgba8unorm: (1) the shader only implements the sRGB piecewise EOTF — Adobe RGB (γ≈2.2) and ProPhoto (γ≈1.8) would be mis-decoded; (2) normalized integer formats cannot represent the negative values that gamut mapping produces, causing permanent clipping; (3) one double-precision CPU lift eliminates repeated 8-bit requantization banding.


Export Encoding

The GPU readback returns premultiplied linear Display-P3 f32 pixels. All terminal conversion is centralized in one function (unpremultiplyEncodeGamut):

f16/f32 linear P3 → un-premultiply → × M(P3→target) → target TRC encode
                 → quantize → Uint8 / Uint16

Encoders are bound by a hard contract: pixels arriving at any format encoder must already be in the target color space and TRC. Encoders never call color conversion; their only job is container packing plus writing the matching ICC profile or EXIF color tags. The 28 scattered private CPU conversion branches that existed in v1 encoders were removed entirely.


What Changed from v1

Aspect v1 v2
Working space Per-file srgb or display-p3 gamma; import-time one-shot matrix conversion Global constant Linear Display-P3; per-sample GPU conversion
Strategy routing Centralized ColorPipeline three-axis strategy classes Deleted — replaced by declarative ingest/egest decision engines and shader-side color math
Out-of-gamut colors Clamped at import (~30% of ProPhoto lost) Preserved as floats; lossless round trip
Where conversion runs CPU (import, export, per-op) GPU fragment shaders; one centralized export encode
Matrix maintenance Hand-copied constants with drift Derived from primaries, test-guarded to machine precision

Last updated: 2026-10-12