PL. 116 · COLOUR / COLOUR SPACE / CARTESIAN LERP
Oklab Colour Space
Björn Ottosson, 2020
OPEN THE LIVE PLATE ▸DEFINITION
sRGB → linear: c ≤ 0.04045 ? c/12.92 : ((c+0.055)/1.055)^2.4 linear → Oklab (Ottosson 2020): (l,m,s) = M₁·(r,g,b); (l′,m′,s′) = (∛l,∛m,∛s); (L,a,b) = M₂·(l′,m′,s′) CIELAB (D65): L* = 116 f(Y/Yn) − 16, a* = 500(f(X/Xn) − f(Y/Yn)), b* = 200(f(Y/Yn) − f(Z/Zn)), f(t) = ∛t for t > (6/29)³
NOTES
Three rows of swatches walk between the same two colours, one row per interpolation space. Top: plain sRGB — the numbers a screen actually stores, blended in a straight line. The middle swatches lose saturation and, for hue pairs that straddle blue, sit visibly darker than either end, because raw channel values do not track perceived lightness at all. Middle: CIELAB, the 1976 space most software still treats as perceptually uniform — Ottosson himself is blunt about the failure: "largest issue is their inability to predict hue. In particular blue hues are predicted badly." This row shows exactly that: the interpolated hue wanders off the straight path between the endpoints and desaturates hardest of the three. Bottom: Oklab, fitted in 2020 against modern colour-appearance data rather than the 1931 experiments CIELAB still rests on — the same straight-line blend, but the hue reads as one continuous sweep and the lightness stays close to level throughout. Saturation is set as a fraction of the most vivid colour the sRGB gamut actually holds at each hue, for the lightness this plate keeps fixed — found by bisection rather than assumed, so the slider never asks a screen for colour it cannot show. The pair drifts slowly around the hue circle, so every rotation shows a different failure mode of the older two spaces.
PROVENANCE
- Origin
- Bjorn Ottosson, "A perceptual color space for image processing", personal blog post, 23 December 2020 (bottosson.github.io/posts/oklab/)
- Standing
- The post carries an explicit dedication placing the maths and the reference code in the public domain, with an MIT licence offered as a named alternative for anyone who cannot or does not want to use public-domain software. Verified by reading the page directly on 16 August 2026 — the licence paragraph sits under the "Converting from linear sRGB to Oklab" section, immediately after the C code it covers.
- What is not here
- No CIE colour-matching-function table enters this plate anywhere. sRGB-to-linear is the standard transfer function; linear-sRGB-to-Oklab is the fused matrix pair Ottosson published, transcribed from the C code the post gives; the CIELAB stage is the standard 1976 formulae against the D65 white point. All three are closed-form linear-algebra transforms of a defined primaries set — sRGB and D65 are specifications, not measurements of a spectrum — which is a different thing from the tabulated observer data blackbody and dispersion need and were built to avoid.
- Checked, not just plotted
- The post gives a worked table of its own, D65 white point XYZ (0.950, 1.000, 1.089) mapping to Oklab (1.000, 0.000, 0.000). The matrices transcribed here reproduce that exactly, and a full forward-then-inverse round trip on red, green, blue, white and black returns each original sRGB value to four decimal places.
- Constants
- hue0/hueSpan place the endpoint pair on the hue circle; sat is a 0..1 fraction of the most saturated in-gamut colour at each endpoint hue, not an absolute chroma number a reader would have to know the limits of; bands sets how many discrete swatches each row is cut into
TOUCHDESIGNER · GLSL
The same shader this plate runs, reframed for a GLSL TOP. Pasted bare it renders the published constants as a still frame; wire absTime.seconds into u_t on the Vectors page to animate it.
// FORMA — PL. 116 · OKLAB COLOUR SPACE — Björn Ottosson, 2020
// sRGB → linear: c ≤ 0.04045 ? c/12.92 : ((c+0.055)/1.055)^2.4
// linear → Oklab (Ottosson 2020): (l,m,s) = M₁·(r,g,b); (l′,m′,s′) = (∛l,∛m,∛s); (L,a,b) = M₂·(l′,m′,s′)
// CIELAB (D65): L* = 116 f(Y/Yn) − 16, a* = 500(f(X/Xn) − f(Y/Yn)), b* = 200(f(Y/Yn) − f(Z/Zn)), f(t) = ∛t for t > (6/29)³
// TouchDesigner port — paste into a GLSL TOP's pixel shader. Set the
// resolution on the TOP's Common page. As pasted it renders the published
// constants as a still frame; to animate, add a uniform named u_t on the
// GLSL TOP's Vectors 1 page with the expression absTime.seconds.
// Constants are consts — edit to tweak; comments give the measured range.
// Written from the published mathematics, not adapted from any code.
#define u_res (uTDOutputInfo.res.zw)
uniform float u_t; // absTime.seconds on the Vectors page; unset = still
const float u_phase = 0.4366; // this plate's own grid phase, 0..1
// FORMA's COLOUR accent as cosine-gradient coefficients
const vec3 u_pal_a = vec3(0.46, 0.2002, 0.3896);
const vec3 u_pal_b = vec3(0.5, 0.2176, 0.4235);
const vec3 u_pal_c = vec3(1, 1, 1);
const vec3 u_pal_d = vec3(0, 0.05, 0.1);
const float p_hue0 = 110.0; // hue A (deg) · live 0 .. 360
const float p_hueSpan = 154.0; // hue separation (deg) · live 40 .. 280
const float p_sat = 0.95; // saturation (of sRGB gamut) · live 0.15 .. 1
const float p_bands = 10.0; // swatches per row · live 4 .. 20
/* The order's ramp — the same cosine formulation the JS kit uses, so a
plate keeps its classification colour in either language. */
vec3 ramp(float t){
return clamp(u_pal_a + u_pal_b * cos(6.28318530718 * (u_pal_c * t + u_pal_d)), 0.0, 1.0);
}
/* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
float cycle(float t, float period){ return fract(t / period + u_phase); }
float pingpong(float t, float period){
float u = cycle(t, period);
return u < 0.5 ? u * 2.0 : 2.0 - u * 2.0;
}
/* sRGB transfer function — the standard IEC 61966-2-1 curve. */
float oklab_srgbToLinear(float c){ return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); }
float oklab_linearToSrgb(float c){ return c <= 0.0031308 ? c * 12.92 : 1.055 * pow(c, 1.0 / 2.4) - 0.055; }
/* The matrices Ottosson published in 2020, transcribed rather than derived,
as zero-argument functions rather than top-level consts. namespaceBody()
in compose.js — the machinery that lets an operand appear twice in one
composite shader — renames every function a body declares but has no
reason to walk a bare "const mat3 X = ..." line, since nothing before
this plate ever declared one: a self-composition (oklab paired with
itself) would otherwise emit "const mat3 OKLAB_FWD = ..." twice at
global scope and fail to link — the exact class of bug that already made
simplex times simplex fail to link over the function name snoise2.
Wrapping the constants as functions puts them through the same renamer
plate() and every helper here already pass through, at the cost of one
call per use rather than one lookup. */
mat3 oklab_mFwd(){ return mat3(
0.4122214708, 0.2119034982, 0.0883024619,
0.5363325363, 0.6806995451, 0.2817188376,
0.0514459929, 0.1073969566, 0.6299787005
); }
mat3 oklab_mL2Lab(){ return mat3(
0.2104542553, 1.9779984951, 0.0259040371,
0.7936177850, -2.4285922050, 0.7827717662,
-0.0040720468, 0.4505937099, -0.8086757660
); }
mat3 oklab_mLab2L(){ return mat3(
1.0, 1.0, 1.0,
0.3963377774, -0.1055613458, -0.0894841775,
0.2158037573, -0.0638541728, -1.2914855480
); }
mat3 oklab_mL2Rgb(){ return mat3(
4.0767416621, -1.2684380046, -0.0041960863,
-3.3077115913, 2.6097574011, -0.7034186147,
0.2309699292, -0.3413193965, 1.7076147010
); }
mat3 oklab_mRgb2Xyz(){ return mat3(
0.4124564, 0.2126729, 0.0193339,
0.3575761, 0.7151522, 0.1191920,
0.1804375, 0.0721750, 0.9503041
); }
mat3 oklab_mXyz2Rgb(){ return mat3(
3.2404542, -0.9692660, 0.0556434,
-1.5371385, 1.8760108, -0.2040259,
-0.4985314, 0.0415560, 1.0572252
); }
vec3 oklab_white(){ return vec3(0.95047, 1.0, 1.08883); }
vec3 oklab_fromLinear(vec3 lin){
vec3 lms = oklab_mFwd() * lin;
vec3 lms_ = vec3(pow(max(lms.x, 0.0), 1.0 / 3.0), pow(max(lms.y, 0.0), 1.0 / 3.0), pow(max(lms.z, 0.0), 1.0 / 3.0));
return oklab_mL2Lab() * lms_;
}
vec3 oklab_toLinear(vec3 lab){
vec3 lms_ = oklab_mLab2L() * lab;
vec3 lms = lms_ * lms_ * lms_;
return oklab_mL2Rgb() * lms;
}
float oklab_labF(float t){
float d = 6.0 / 29.0;
return t > d * d * d ? pow(t, 1.0 / 3.0) : t / (3.0 * d * d) + 4.0 / 29.0;
}
float oklab_labFInv(float f){
float d = 6.0 / 29.0;
return f > d ? f * f * f : 3.0 * d * d * (f - 4.0 / 29.0);
}
vec3 oklab_cieLabFromLinear(vec3 lin){
vec3 xyz = oklab_mRgb2Xyz() * lin;
vec3 w = oklab_white();
vec3 fw = vec3(oklab_labF(xyz.x / w.x), oklab_labF(xyz.y / w.y), oklab_labF(xyz.z / w.z));
return vec3(116.0 * fw.y - 16.0, 500.0 * (fw.x - fw.y), 200.0 * (fw.y - fw.z));
}
vec3 oklab_cieLabToLinear(vec3 lab){
float fy = (lab.x + 16.0) / 116.0;
float fx = fy + lab.y / 500.0;
float fz = fy - lab.z / 200.0;
vec3 w = oklab_white();
vec3 xyz = vec3(oklab_labFInv(fx) * w.x, oklab_labFInv(fy) * w.y, oklab_labFInv(fz) * w.z);
return oklab_mXyz2Rgb() * xyz;
}
bool oklab_inGamut(vec3 lin){
return lin.x > -1e-4 && lin.x < 1.0 + 1e-4 &&
lin.y > -1e-4 && lin.y < 1.0 + 1e-4 &&
lin.z > -1e-4 && lin.z < 1.0 + 1e-4;
}
/* Most vivid in-gamut chroma at the fixed lightness this plate holds,
sixteen bisection steps — mirrors the JS path exactly, constant
iteration count so the loop bound is fixed rather than slider-driven. */
float oklab_maxChroma(float L0, float h){
float lo = 0.0, hi = 0.4;
for (int i = 0; i < 16; i++){
float mid = (lo + hi) * 0.5;
vec3 lin = oklab_toLinear(vec3(L0, mid * cos(h), mid * sin(h)));
if (oklab_inGamut(lin)) lo = mid; else hi = mid;
}
return lo;
}
vec3 oklab_encode(vec3 lin){
lin = clamp(lin, 0.0, 1.0);
return vec3(oklab_linearToSrgb(lin.x), oklab_linearToSrgb(lin.y), oklab_linearToSrgb(lin.z));
}
vec3 oklab_decode(vec3 srgb){
return vec3(oklab_srgbToLinear(srgb.x), oklab_srgbToLinear(srgb.y), oklab_srgbToLinear(srgb.z));
}
vec3 plate(vec2 uv){
const float L0 = 0.68;
float margin = 0.06, gap = 0.035;
float rowH = (1.0 - margin * 2.0 - gap * 2.0) / 3.0;
float rowW = 1.0 - margin * 2.0;
float pitch = rowH + gap;
vec3 ink = vec3(4.0, 6.0, 10.0) / 255.0;
float xx = uv.x - margin, yy = uv.y - margin;
if (xx < 0.0 || xx > rowW || yy < 0.0) return ink;
int row = int(floor(yy / pitch));
if (row < 0 || row > 2 || yy - float(row) * pitch > rowH) return ink;
float bands = floor(p_bands + 0.5);
float i = floor(xx / rowW * bands);
i = clamp(i, 0.0, bands - 1.0);
float tt = i / max(bands - 1.0, 1.0);
float hueA = p_hue0 * 0.017453292519943295 + u_phase * 6.283185307 + u_t * 0.024;
float hueB = hueA + p_hueSpan * 0.017453292519943295;
float cA = p_sat * oklab_maxChroma(L0, hueA);
float cB = p_sat * oklab_maxChroma(L0, hueB);
vec3 A = oklab_encode(oklab_toLinear(vec3(L0, cA * cos(hueA), cA * sin(hueA))));
vec3 B = oklab_encode(oklab_toLinear(vec3(L0, cB * cos(hueB), cB * sin(hueB))));
if (row == 0){
return mix(A, B, tt);
} else if (row == 1){
vec3 labA = oklab_cieLabFromLinear(oklab_decode(A));
vec3 labB = oklab_cieLabFromLinear(oklab_decode(B));
return oklab_encode(oklab_cieLabToLinear(mix(labA, labB, tt)));
} else {
vec3 okA = oklab_fromLinear(oklab_decode(A));
vec3 okB = oklab_fromLinear(oklab_decode(B));
return oklab_encode(oklab_toLinear(mix(okA, okB, tt)));
}
}
out vec4 fragColor;
void main(){
// FORMA's uv runs y-down, matching its canvas; TD's vUV runs up
vec2 uv = vec2(vUV.s, 1.0 - vUV.t);
fragColor = TDOutputSwizzle(vec4(plate(uv), 1.0));
}
NUKE · BLINKSCRIPT
The same shader this plate runs, transpiled to a BlinkScript kernel. Paste it into a BlinkScript node's Kernel Source and press Recompile; every constant arrives as a knob at its published value, and u_t animates with the expression frame/24. Compiled and rendered in Nuke 17.1, then compared against this plate on the page.
// FORMA — PL. 116 · OKLAB COLOUR SPACE — Björn Ottosson, 2020
// sRGB → linear: c ≤ 0.04045 ? c/12.92 : ((c+0.055)/1.055)^2.4
// linear → Oklab (Ottosson 2020): (l,m,s) = M₁·(r,g,b); (l′,m′,s′) = (∛l,∛m,∛s); (L,a,b) = M₂·(l′,m′,s′)
// CIELAB (D65): L* = 116 f(Y/Yn) − 16, a* = 500(f(X/Xn) − f(Y/Yn)), b* = 200(f(Y/Yn) − f(Z/Zn)), f(t) = ∛t for t > (6/29)³
// Nuke port — a BlinkScript kernel. Paste into a BlinkScript node's Kernel
// Source and press Recompile. Every constant arrives as a knob at its published
// value (the comment gives the measured range); u_t is a knob too — animate it
// with the expression frame/24 or leave it at 0 for the still frame. Written
// from the published mathematics, not adapted from any code.
// Transpiled from the shader this plate runs on the page (GLSL ES 3.00):
// vec → float2/3/4, swizzles expanded, GLSL builtins Blink lacks written out
// as forma_ functions, float literals suffixed. Compiled and rendered in a
// real Nuke (17.1v1) and compared against this plate on the page: 34 of 34.
//
// plate() and its helpers are written to a single exit — the loop that runs
// once. That is not a style: Blink 17.1 drops a conditional early return from
// a called function while Vectorize is on, which is the node default, with no
// warning and no error. Written this way it paints correctly as pasted.
kernel Forma_oklab : ImageComputationKernel<ePixelWise>
{
Image<eWrite> dst;
param:
float u_t; // seconds; 0 is the still frame
float p_hue0; // hue A (deg) · live 0 .. 360
float p_hueSpan; // hue separation (deg) · live 40 .. 280
float p_sat; // saturation (of sRGB gamut) · live 0.15 .. 1
float p_bands; // swatches per row · live 4 .. 20
local:
float2 u_res;
float u_phase;
float3 u_pal_a, u_pal_b, u_pal_c, u_pal_d;
void define(){
defineParam(u_t, "u_t", 0.0f);
defineParam(p_hue0, "p_hue0", 110.0f);
defineParam(p_hueSpan, "p_hueSpan", 154.0f);
defineParam(p_sat, "p_sat", 0.95f);
defineParam(p_bands, "p_bands", 10.0f);
}
void init(){
u_res = float2(float(dst.bounds.width()), float(dst.bounds.height()));
u_phase = 0.4366f; // this plate's own grid phase, 0..1
// FORMA's COLOUR accent as cosine-gradient coefficients
u_pal_a = float3(0.46f, 0.2002f, 0.3896f);
u_pal_b = float3(0.5f, 0.2176f, 0.4235f);
u_pal_c = float3(1.0f, 1.0f, 1.0f);
u_pal_d = float3(0.0f, 0.05f, 0.1f);
}
/* GLSL builtins Blink lacks, written as templates rather than overload sets.
Blink's operators return expression templates (Swizzle<float,N>), so a call
passing an expression cannot resolve against an overload set on float2
against float3 — measured in Nuke 17.1: a float2 expression is ambiguous
between the two, while scalar-against-vector resolves. A template deduces
the expression's own type, so the ambiguity cannot arise. */
template <class T> T forma_fract(T v){ return v - floor(v); }
template <class T, class S> T forma_mod(T x, S y){ return x - y * floor(x / y); }
/* Blink's own min/max/clamp take no scalar bound against a vector, which GLSL
does; v * 0.0f + b is that bound at the vector's own width, and collapses to
b when v is a scalar, so one template serves both. */
template <class T, class S> T forma_min(T a, S b){ return min(a, a * 0.0f + b); }
template <class T, class S> T forma_max(T a, S b){ return max(a, a * 0.0f + b); }
template <class T, class S> T forma_clamp(T v, S lo, S hi){ return clamp(v, v * 0.0f + lo, v * 0.0f + hi); }
int forma_min(int a, int b){ return min(a, b); }
int forma_max(int a, int b){ return max(a, b); }
/* GLSL step(edge, x) is 1 where x >= edge; floor(sign(x - e) * 0.5 + 1) is
that exactly, equality included, out of builtins Blink does have. */
template <class T, class S> T forma_step(S e, T x){ return floor(sign(x - e) * 0.5f + 1.0f); }
template <class T, class S> T forma_smoothstep(S a, S b, T x){
T t = forma_clamp((x - a) / (b - a), 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
}
template <class T> float forma_distance(T a, T b){ return length(a - b); }
float forma_tanh(float x){ float e = exp(2.0f * x); return (e - 1.0f) / (e + 1.0f); }
float forma_radians(float d){ return d * 0.01745329252f; }
// the page's hash2 is exact uint32; Blink has int, so the shifts are made
// logical by masking and the read-back is lifted into 0 .. 2^32
/* A uint read back as a float. Blink has no unsigned type, so a value past
2^31 arrives as a negative int and float() of it is negative. Measured on
gabor, whose own generator then returned uniforms in [-0.5, 0.5) and drew
a different picture — it compiled, it rendered, and only comparing it with
/* The order's ramp — the same cosine formulation the JS kit uses, so a
plate keeps its classification colour in either language. */
float3 ramp(float t){
return forma_clamp(u_pal_a + u_pal_b * cos(6.28318530718f * (u_pal_c * t + u_pal_d)), 0.0f, 1.0f);
}
/* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
float cycle(float t, float period){ return forma_fract(t / period + u_phase); }
float pingpong(float t, float period){
float u = cycle(t, period);
return u < 0.5f ? u * 2.0f : 2.0f - u * 2.0f;
}
/* sRGB transfer function — the standard IEC 61966-2-1 curve. */
float oklab_srgbToLinear(float c){ return c <= 0.04045f ? c / 12.92f : pow((c + 0.055f) / 1.055f, 2.4f); }
float oklab_linearToSrgb(float c){ return c <= 0.0031308f ? c * 12.92f : 1.055f * pow(c, 1.0f / 2.4f) - 0.055f; }
/* The matrices Ottosson published in 2020, transcribed rather than derived,
as zero-argument functions rather than top-level consts. namespaceBody()
in compose.js — the machinery that lets an operand appear twice in one
composite shader — renames every function a body declares but has no
reason to walk a bare "const float3x3 X = ..." line, since nothing before
this plate ever declared one: a self-composition (oklab paired with
itself) would otherwise emit "const float3x3 OKLAB_FWD = ..." twice at
global scope and fail to link — the exact class of bug that already made
simplex times simplex fail to link over the function name snoise2.
Wrapping the constants as functions puts them through the same renamer
plate() and every helper here already pass through, at the cost of one
call per use rather than one lookup. */
float3x3 oklab_mFwd(){ return float3x3(0.4122214708f, 0.5363325363f, 0.0514459929f, 0.2119034982f, 0.6806995451f, 0.1073969566f, 0.0883024619f, 0.2817188376f, 0.6299787005f); }
float3x3 oklab_mL2Lab(){ return float3x3(0.2104542553f, 0.7936177850f, -0.0040720468f, 1.9779984951f, -2.4285922050f, 0.4505937099f, 0.0259040371f, 0.7827717662f, -0.8086757660f); }
float3x3 oklab_mLab2L(){ return float3x3(1.0f, 0.3963377774f, 0.2158037573f, 1.0f, -0.1055613458f, -0.0638541728f, 1.0f, -0.0894841775f, -1.2914855480f); }
float3x3 oklab_mL2Rgb(){ return float3x3(4.0767416621f, -3.3077115913f, 0.2309699292f, -1.2684380046f, 2.6097574011f, -0.3413193965f, -0.0041960863f, -0.7034186147f, 1.7076147010f); }
float3x3 oklab_mRgb2Xyz(){ return float3x3(0.4124564f, 0.3575761f, 0.1804375f, 0.2126729f, 0.7151522f, 0.0721750f, 0.0193339f, 0.1191920f, 0.9503041f); }
float3x3 oklab_mXyz2Rgb(){ return float3x3(3.2404542f, -1.5371385f, -0.4985314f, -0.9692660f, 1.8760108f, 0.0415560f, 0.0556434f, -0.2040259f, 1.0572252f); }
float3 oklab_white(){ return float3(0.95047f, 1.0f, 1.08883f); }
float3 oklab_fromLinear(float3 lin){
float3 lms = oklab_mFwd() * lin;
float3 lms_ = float3(pow(forma_max(lms.x, 0.0f), 1.0f / 3.0f), pow(forma_max(lms.y, 0.0f), 1.0f / 3.0f), pow(forma_max(lms.z, 0.0f), 1.0f / 3.0f));
return oklab_mL2Lab() * lms_;
}
float3 oklab_toLinear(float3 lab){
float3 lms_ = oklab_mLab2L() * lab;
float3 lms = lms_ * lms_ * lms_;
return oklab_mL2Rgb() * lms;
}
float oklab_labF(float t){
float d = 6.0f / 29.0f;
return t > d * d * d ? pow(t, 1.0f / 3.0f) : t / (3.0f * d * d) + 4.0f / 29.0f;
}
float oklab_labFInv(float f){
float d = 6.0f / 29.0f;
return f > d ? f * f * f : 3.0f * d * d * (f - 4.0f / 29.0f);
}
float3 oklab_cieLabFromLinear(float3 lin){
float3 xyz = oklab_mRgb2Xyz() * lin;
float3 w = oklab_white();
float3 fw = float3(oklab_labF(xyz.x / w.x), oklab_labF(xyz.y / w.y), oklab_labF(xyz.z / w.z));
return float3(116.0f * fw.y - 16.0f, 500.0f * (fw.x - fw.y), 200.0f * (fw.y - fw.z));
}
float3 oklab_cieLabToLinear(float3 lab){
float fy = (lab.x + 16.0f) / 116.0f;
float fx = fy + lab.y / 500.0f;
float fz = fy - lab.z / 200.0f;
float3 w = oklab_white();
float3 xyz = float3(oklab_labFInv(fx) * w.x, oklab_labFInv(fy) * w.y, oklab_labFInv(fz) * w.z);
return oklab_mXyz2Rgb() * xyz;
}
bool oklab_inGamut(float3 lin){
return lin.x > -1e-4 && lin.x < 1.0f + 1e-4 &&
lin.y > -1e-4 && lin.y < 1.0f + 1e-4 &&
lin.z > -1e-4 && lin.z < 1.0f + 1e-4;
}
/* Most vivid in-gamut chroma at the fixed lightness this plate holds,
sixteen bisection steps — mirrors the JS path exactly, constant
iteration count so the loop bound is fixed rather than slider-driven. */
float oklab_maxChroma(float L0, float h){
float lo = 0.0f;
float hi = 0.4f;
for (int i = 0; i < 16; i++){
float mid = (lo + hi) * 0.5f;
float3 lin = oklab_toLinear(float3(L0, mid * cos(h), mid * sin(h)));
if (oklab_inGamut(lin)) lo = mid; else hi = mid;
}
return lo;
}
float3 oklab_encode(float3 lin){
lin = forma_clamp(lin, 0.0f, 1.0f);
return float3(oklab_linearToSrgb(lin.x), oklab_linearToSrgb(lin.y), oklab_linearToSrgb(lin.z));
}
float3 oklab_decode(float3 srgb){
return float3(oklab_srgbToLinear(srgb.x), oklab_srgbToLinear(srgb.y), oklab_srgbToLinear(srgb.z));
}
float3 plate(float2 uv){
float3 forma_r = float3(0.0f, 0.0f, 0.0f);
for (int forma_once = 0; forma_once < 1; forma_once++){
const float L0 = 0.68f;
float margin = 0.06f;
float gap = 0.035f;
float rowH = (1.0f - margin * 2.0f - gap * 2.0f) / 3.0f;
float rowW = 1.0f - margin * 2.0f;
float pitch = rowH + gap;
float3 ink = float3(4.0f, 6.0f, 10.0f) / 255.0f;
float xx = uv.x - margin;
float yy = uv.y - margin;
if (xx < 0.0f || xx > rowW || yy < 0.0f) { forma_r = ink; break; }
int row = int(floor(yy / pitch));
if (row < 0 || row > 2 || yy - float(row) * pitch > rowH) { forma_r = ink; break; }
float bands = floor(p_bands + 0.5f);
float i = floor(xx / rowW * bands);
i = forma_clamp(i, 0.0f, bands - 1.0f);
float tt = i / forma_max(bands - 1.0f, 1.0f);
float hueA = p_hue0 * 0.017453292519943295f + u_phase * 6.283185307f + u_t * 0.024f;
float hueB = hueA + p_hueSpan * 0.017453292519943295f;
float cA = p_sat * oklab_maxChroma(L0, hueA);
float cB = p_sat * oklab_maxChroma(L0, hueB);
float3 A = oklab_encode(oklab_toLinear(float3(L0, cA * cos(hueA), cA * sin(hueA))));
float3 B = oklab_encode(oklab_toLinear(float3(L0, cB * cos(hueB), cB * sin(hueB))));
if (row == 0){
{ forma_r = lerp(A, B, tt); break; }
} else if (row == 1){
float3 labA = oklab_cieLabFromLinear(oklab_decode(A));
float3 labB = oklab_cieLabFromLinear(oklab_decode(B));
{ forma_r = oklab_encode(oklab_cieLabToLinear(lerp(labA, labB, tt))); break; }
} else {
float3 okA = oklab_fromLinear(oklab_decode(A));
float3 okB = oklab_fromLinear(oklab_decode(B));
{ forma_r = oklab_encode(oklab_toLinear(lerp(okA, okB, tt))); break; }
}
}
return forma_r;
}
void process(int2 pos){
// FORMA's uv runs y-down like its canvas; Nuke's rows run up
float2 uv = float2((float(pos.x) + 0.5f) / u_res.x, 1.0f - (float(pos.y) + 0.5f) / u_res.y);
float3 c = plate(uv);
dst() = float4(c.x, c.y, c.z, 1.0f);
}
};