Skip to content

Color: OKLCH/OKLab Color Space Support#33043

Open
RenaudRohlinger wants to merge 3 commits intomrdoob:devfrom
RenaudRohlinger:features/oklch
Open

Color: OKLCH/OKLab Color Space Support#33043
RenaudRohlinger wants to merge 3 commits intomrdoob:devfrom
RenaudRohlinger:features/oklch

Conversation

@RenaudRohlinger
Copy link
Collaborator

@RenaudRohlinger RenaudRohlinger commented Feb 22, 2026

Description
Adds perceptually uniform color manipulation to three.js, both CPU-side (Color) and GPU-side (TSL).

Why OKLCH over HSL?

  • Uniform perceived brightness across all hues, changing hue doesn't shift how light/dark a color looks
  • Smooth gradients without muddy desaturated midpoints (e.g. red→blue stays vibrant)
  • Better palette generation for accessible, consistent UI colors
  • LLMs loves it
image

This contribution is funded by Spawn

@RenaudRohlinger RenaudRohlinger changed the title Color: Add OKLCH Support Color: OKLCH/OKLab Color Space Support Feb 22, 2026
@github-actions
Copy link

github-actions bot commented Feb 22, 2026

📦 Bundle size

Full ESM build, minified and gzipped.

Before After Diff
WebGL 359.17
85.24
359.17
85.24
+0 B
+0 B
WebGPU 626.25
174.04
627.88
174.69
+1.63 kB
+647 B
WebGPU Nodes 624.83
173.8
626.46
174.44
+1.63 kB
+644 B

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Before After Diff
WebGL 490.91
119.65
492.36
120.39
+1.45 kB
+737 B
WebGPU 699.88
189.07
701.31
189.71
+1.43 kB
+644 B
WebGPU Nodes 649.09
176.43
650.51
177.13
+1.43 kB
+703 B

@thelazylamaGit
Copy link

thelazylamaGit commented Feb 22, 2026

As someone who has also been working with OKLCH in TSL I actually think I might have some info/code that could help out here. After implementing the basic oklchToLinearSRGB conversion which is essentially the same as what you have here, I still wasn't happy with how it turned out as the lightness didn't seem very uniform especially around the teal colour range. Turned out that I was missing a crucial step in getting it to display the colours correctly in srgb which is gamut mapping. You can see an example of how gamut mapping effects the final colour with this example here. Luckily TypeGPU already has a great Oklab gamut clipping example so in this case all I had to do was copy over the gamut mapping code from TypeGPU's repository and convert it to TSL. Immediately it made a huge difference and looked so much better and more uniform, especially when doing effects like random lightness. I'll paste the code I ended up with below, it's a bit messy and uses typescript but I hope it can be of some use.

Code
import {
  Fn,
  If,
  Switch,
  float,
  int,
  vec2,
  vec3,
  abs,
  clamp,
  length,
  max,
  min,
  mix,
  pow,
  sign,
  sqrt,
  step,
  cbrt,
  select,
  uniform,
} from 'three/tsl'
import type { FloatNode, Vec2Node, Vec3Node } from './tsl-utils'

/**
 * sRGB <-> linear (component-wise), branchless
 * Matches standard sRGB EOTF/OETF.
 */
export const linearToSrgb = Fn(([linear]: [Vec3Node]): Vec3Node => {
  const a = linear.mul(12.92)

  // clamp to >=0 per-channel (scalar max)
  const lin = vec3(max(linear.x, 0.0), max(linear.y, 0.0), max(linear.z, 0.0))

  // pow per-channel (scalar pow)
  const p = vec3(pow(lin.x, 1.0 / 2.4), pow(lin.y, 1.0 / 2.4), pow(lin.z, 1.0 / 2.4))

  const b = p.mul(1.055).sub(0.055)

  // step per-channel (scalar step) → {0,1} mask
  const t = vec3(step(0.0031308, linear.x), step(0.0031308, linear.y), step(0.0031308, linear.z))

  // mix(a,b,t) but without calling mix() (avoids any typing weirdness)
  return a.add(b.sub(a).mul(t))
})

export const srgbToLinear = Fn(([rgb]: [Vec3Node]): Vec3Node => {
  const a = rgb.mul(1.0 / 12.92)

  const base = rgb.add(0.055).mul(1.0 / 1.055)

  const b = vec3(pow(base.x, 2.4), pow(base.y, 2.4), pow(base.z, 2.4))

  const t = vec3(step(0.04045, rgb.x), step(0.04045, rgb.y), step(0.04045, rgb.z))

  return a.add(b.sub(a).mul(t))
})

export const normalizeAB = Fn(([lab]: [Vec3Node]) => {
  const eps = float(0.00001)
  const C = max(eps, length(lab.yz))
  const aN = lab.y.div(C)
  const bN = lab.z.div(C)
  return vec3(C, aN, bN)
})

/**
 * OKLab conversion (Björn Ottosson’s matrices)
 */
export const linearRgbToOklab = Fn(([rgb]: [Vec3Node]): Vec3Node => {
  const l: FloatNode = float(0.4122214708)
    .mul(rgb.x)
    .add(float(0.5363325363).mul(rgb.y))
    .add(float(0.0514459929).mul(rgb.z))

  const m: FloatNode = float(0.2119034982)
    .mul(rgb.x)
    .add(float(0.6806995451).mul(rgb.y))
    .add(float(0.1073969566).mul(rgb.z))

  const s: FloatNode = float(0.0883024619)
    .mul(rgb.x)
    .add(float(0.2817188376).mul(rgb.y))
    .add(float(0.6299787005).mul(rgb.z))

  const l_ = cbrt(l)
  const m_ = cbrt(m)
  const s_ = cbrt(s)

  return vec3(
    float(0.2104542553).mul(l_).add(float(0.793617785).mul(m_)).sub(float(0.0040720468).mul(s_)),
    float(1.9779984951).mul(l_).sub(float(2.428592205).mul(m_)).add(float(0.4505937099).mul(s_)),
    float(0.0259040371).mul(l_).add(float(0.7827717662).mul(m_)).sub(float(0.808675766).mul(s_)),
  )
})

export const oklabToLinearRgb = Fn(([lab]: [Vec3Node]) => {
  const l_ = lab.x.add(float(0.3963377774).mul(lab.y)).add(float(0.2158037573).mul(lab.z))
  const m_ = lab.x.sub(float(0.1055613458).mul(lab.y)).sub(float(0.0638541728).mul(lab.z))
  const s_ = lab.x.sub(float(0.0894841775).mul(lab.y)).sub(float(1.291485548).mul(lab.z))

  const l = l_.mul(l_).mul(l_)
  const m = m_.mul(m_).mul(m_)
  const s = s_.mul(s_).mul(s_)

  return vec3(
    float(4.0767416621).mul(l).sub(float(3.3077115913).mul(m)).add(float(0.2309699292).mul(s)),
    float(-1.2684380046).mul(l).add(float(2.6097574011).mul(m)).sub(float(0.3413193965).mul(s)),
    float(-0.0041960863).mul(l).sub(float(0.7034186147).mul(m)).add(float(1.707614701).mul(s)),
  )
})

/**
 * Internal helpers for correct sRGB gamut clipping in OKLab
 *
 * We represent LC struct as vec2(L, C).
 */

// a,b are normalized (a^2 + b^2 = 1)
const computeMaxSaturation = Fn(([a, b]: [FloatNode, FloatNode]): FloatNode => {
  const k0 = float(0).toVar()
  const k1 = float(0).toVar()
  const k2 = float(0).toVar()
  const k3 = float(0).toVar()
  const k4 = float(0).toVar()
  const wl = float(0).toVar()
  const wm = float(0).toVar()
  const ws = float(0).toVar()

  const condR = float(-1.88170328).mul(a).sub(float(0.80936493).mul(b)).greaterThan(1.0)
  const condG = float(1.81444104).mul(a).sub(float(1.19445276).mul(b)).greaterThan(1.0)

  If(condR, () => {
    k0.assign(1.19086277)
    k1.assign(1.76576728)
    k2.assign(0.59662641)
    k3.assign(0.75515197)
    k4.assign(0.56771245)
    wl.assign(4.0767416621)
    wm.assign(-3.3077115913)
    ws.assign(0.2309699292)
  })
    .ElseIf(condG, () => {
      k0.assign(0.73956515)
      k1.assign(-0.45954404)
      k2.assign(0.08285427)
      k3.assign(0.1254107)
      k4.assign(0.14503204)
      wl.assign(-1.2684380046)
      wm.assign(2.6097574011)
      ws.assign(-0.3413193965)
    })
    .Else(() => {
      k0.assign(1.35733652)
      k1.assign(-0.00915799)
      k2.assign(-1.1513021)
      k3.assign(-0.50559606)
      k4.assign(0.00692167)
      wl.assign(-0.0041960863)
      wm.assign(-0.7034186147)
      ws.assign(1.707614701)
    })

  const k_l = float(0.3963377774).mul(a).add(float(0.2158037573).mul(b))
  const k_m = float(-0.1055613458).mul(a).sub(float(0.0638541728).mul(b))
  const k_s = float(-0.0894841775).mul(a).sub(float(1.291485548).mul(b))

  const S = k0
    .add(k1.mul(a))
    .add(k2.mul(b))
    .add(k3.mul(a.mul(a)))
    .add(k4.mul(a.mul(b)))
    .toVar()

  const one = float(1.0)
  const l_ = one.add(S.mul(k_l))
  const m_ = one.add(S.mul(k_m))
  const s_ = one.add(S.mul(k_s))

  const l = l_.mul(l_).mul(l_)
  const m = m_.mul(m_).mul(m_)
  const s = s_.mul(s_).mul(s_)

  const l_dS = float(3.0).mul(k_l).mul(l_.mul(l_))
  const m_dS = float(3.0).mul(k_m).mul(m_.mul(m_))
  const s_dS = float(3.0).mul(k_s).mul(s_.mul(s_))

  const l_dS2 = float(6.0).mul(k_l.mul(k_l)).mul(l_)
  const m_dS2 = float(6.0).mul(k_m.mul(k_m)).mul(m_)
  const s_dS2 = float(6.0).mul(k_s.mul(k_s)).mul(s_)

  const f = wl.mul(l).add(wm.mul(m)).add(ws.mul(s))
  const f1 = wl.mul(l_dS).add(wm.mul(m_dS)).add(ws.mul(s_dS))
  const f2 = wl.mul(l_dS2).add(wm.mul(m_dS2)).add(ws.mul(s_dS2))

  S.assign(S.sub(f.mul(f1).div(f1.mul(f1).sub(float(0.5).mul(f).mul(f2)))))

  return S
})

const findCusp = Fn(([a, b]: [FloatNode, FloatNode]): Vec2Node => {
  const S_cusp = computeMaxSaturation(a, b)

  const rgb_at_max = oklabToLinearRgb(vec3(1.0, S_cusp.mul(a), S_cusp.mul(b)))
  const maxRGB = max(rgb_at_max.x, max(rgb_at_max.y, rgb_at_max.z))

  const L_cusp = cbrt(float(1.0).div(maxRGB))
  const C_cusp = L_cusp.mul(S_cusp)

  // return LC(L_cusp, C_cusp)
  return vec2(L_cusp, C_cusp)
})

const findGamutIntersection = Fn(
  ([a, b, L1, C1, L0, cusp]: [FloatNode, FloatNode, FloatNode, FloatNode, FloatNode, Vec2Node]): FloatNode => {
    const cuspL = cusp.x.toConst()
    const cuspC = cusp.y.toConst()

    const FLT_MAX = float(3.40282346e38)

    const t = float(0.0).toVar()

    // (L1 - L0) * cusp.C - (cusp.L - L0) * C1
    const lowerTest = L1.sub(L0).mul(cuspC).sub(cuspL.sub(L0).mul(C1))

    If(lowerTest.lessThanEqual(float(0.0)), () => {
      // Lower half
      t.assign(cuspC.mul(L0).div(C1.mul(cuspL).add(cuspC.mul(L0.sub(L1)))))
    }).Else(() => {
      // Upper half
      t.assign(cuspC.mul(L0.sub(1.0)).div(C1.mul(cuspL.sub(1.0)).add(cuspC.mul(L0.sub(L1)))))

      // One Halley refinement step
      const dL = L1.sub(L0)
      const dC = C1

      const k_l = float(0.3963377774).mul(a).add(float(0.2158037573).mul(b))
      const k_m = float(-0.1055613458).mul(a).sub(float(0.0638541728).mul(b))
      const k_s = float(-0.0894841775).mul(a).sub(float(1.291485548).mul(b))

      const l_dt = dL.add(dC.mul(k_l))
      const m_dt = dL.add(dC.mul(k_m))
      const s_dt = dL.add(dC.mul(k_s))

      const L = L0.mul(float(1.0).sub(t)).add(t.mul(L1))
      const C = t.mul(C1)

      const l_ = L.add(C.mul(k_l))
      const m_ = L.add(C.mul(k_m))
      const s_ = L.add(C.mul(k_s))

      const l = l_.mul(l_).mul(l_)
      const m = m_.mul(m_).mul(m_)
      const s = s_.mul(s_).mul(s_)

      const ldt = float(3.0).mul(l_dt).mul(l_.mul(l_))
      const mdt = float(3.0).mul(m_dt).mul(m_.mul(m_))
      const sdt = float(3.0).mul(s_dt).mul(s_.mul(s_))

      const ldt2 = float(6.0).mul(l_dt.mul(l_dt)).mul(l_)
      const mdt2 = float(6.0).mul(m_dt.mul(m_dt)).mul(m_)
      const sdt2 = float(6.0).mul(s_dt.mul(s_dt)).mul(s_)

      // r
      const r = float(4.0767416621).mul(l).sub(float(3.3077115913).mul(m)).add(float(0.2309699292).mul(s)).sub(1.0)
      const r1 = float(4.0767416621).mul(ldt).sub(float(3.3077115913).mul(mdt)).add(float(0.2309699292).mul(sdt))
      const r2 = float(4.0767416621).mul(ldt2).sub(float(3.3077115913).mul(mdt2)).add(float(0.2309699292).mul(sdt2))

      const u_r = r1.div(r1.mul(r1).sub(float(0.5).mul(r).mul(r2)))
      const t_r_raw = r.negate().mul(u_r)
      const t_r = select(u_r.greaterThanEqual(float(0.0)), t_r_raw, FLT_MAX)

      // g
      const g = float(-1.2684380046).mul(l).add(float(2.6097574011).mul(m)).sub(float(0.3413193965).mul(s)).sub(1.0)
      const g1 = float(-1.2684380046).mul(ldt).add(float(2.6097574011).mul(mdt)).sub(float(0.3413193965).mul(sdt))
      const g2 = float(-1.2684380046).mul(ldt2).add(float(2.6097574011).mul(mdt2)).sub(float(0.3413193965).mul(sdt2))

      const u_g = g1.div(g1.mul(g1).sub(float(0.5).mul(g).mul(g2)))
      const t_g_raw = g.negate().mul(u_g)
      const t_g = select(u_g.greaterThanEqual(float(0.0)), t_g_raw, FLT_MAX)

      // b (note: channel name "bch" to avoid shadowing parameter b)
      const bch = float(-0.0041960863).mul(l).sub(float(0.7034186147).mul(m)).add(float(1.707614701).mul(s)).sub(1.0)
      const b1 = float(-0.0041960863).mul(ldt).sub(float(0.7034186147).mul(mdt)).add(float(1.707614701).mul(sdt))
      const b2 = float(-0.0041960863).mul(ldt2).sub(float(0.7034186147).mul(mdt2)).add(float(1.707614701).mul(sdt2))

      const u_b = b1.div(b1.mul(b1).sub(float(0.5).mul(bch).mul(b2)))
      const t_b_raw = bch.negate().mul(u_b)
      const t_b = select(u_b.greaterThanEqual(float(0.0)), t_b_raw, FLT_MAX)

      t.assign(t.add(min(t_r, min(t_g, t_b))))
    })

    return t
  },
)

const gamutClipPreserveChroma = Fn(([lab]: [Vec3Node]) => {
  const L = lab.x

  const nab = normalizeAB(lab) // (C, aN, bN)

  const C = nab.x
  const a_ = nab.y
  const b_ = nab.z

  const L0 = clamp(L, float(0.0), float(1.0))
  const cusp = findCusp(a_, b_)

  const t = clamp(findGamutIntersection(a_, b_, L, C, L0, cusp), float(0.0), float(1.0))

  const L_clipped = mix(L0, L, t)
  const C_clipped = t.mul(C)

  return vec3(L_clipped, C_clipped.mul(a_), C_clipped.mul(b_))
})

// Controls “adaptive” behavior

const gamutClipAdaptiveL05 = Fn(([lab, alpha]: [Vec3Node, FloatNode]) => {
  const L = lab.x

  const nab = normalizeAB(lab) // (C, aN, bN)
  const C = nab.x
  const a_ = nab.y
  const b_ = nab.z

  const Ld = L.sub(0.5)
  const e1 = float(0.5).add(abs(Ld)).add(alpha.mul(C))
  const inside = max(float(0.0), e1.mul(e1).sub(float(2.0).mul(abs(Ld))))
  const L0 = float(0.5).mul(float(1.0).add(sign(Ld).mul(e1.sub(sqrt(inside)))))

  const cusp = findCusp(a_, b_)
  const t = clamp(findGamutIntersection(a_, b_, L, C, L0, cusp), float(0.0), float(1.0))

  const L_clipped = mix(L0, L, t)
  const C_clipped = t.mul(C)

  return vec3(L_clipped, C_clipped.mul(a_), C_clipped.mul(b_))
})

const gamutClipAdaptiveL0Cusp = Fn(([lab, alpha]: [Vec3Node, FloatNode]) => {
  const L = lab.x

  const nab = normalizeAB(lab) // (C, aN, bN)
  const C = nab.x
  const a_ = nab.y
  const b_ = nab.z

  const cusp = findCusp(a_, b_)
  const cuspL = cusp.x

  const Ld = L.sub(cuspL)

  // k = 2 * select(cusp.L, 1 - cusp.L, Ld > 0) 
  const k = float(2.0).mul(select(Ld.greaterThan(float(0.0)), float(1.0).sub(cuspL), cuspL))

  const e1 = float(0.5).mul(k).add(abs(Ld)).add(alpha.mul(C).div(k))
  const inside = max(float(0.0), e1.mul(e1).sub(float(2.0).mul(k).mul(abs(Ld))))
  const L0 = cuspL.add(float(0.5).mul(sign(Ld).mul(e1.sub(sqrt(inside)))))

  const t = clamp(findGamutIntersection(a_, b_, L, C, L0, cusp), float(0.0), float(1.0))

  const L_clipped = mix(L0, L, t)
  const C_clipped = t.mul(C)

  return vec3(L_clipped, C_clipped.mul(a_), C_clipped.mul(b_))
})

/**
 * 0: preserveChroma
 * 1: adaptiveL05 (default)
 * 2: adaptiveL0Cusp
 */

type ClipMode = 'PreserveChroma' | 'AdaptiveL0.5' | 'AdaptiveLCusp' | 'none'

export const oklabGamutClip = Fn(([lab, clipMode = 'AdaptiveL0.5', alpha]: [Vec3Node, ClipMode, FloatNode]) => {
  // JS if compile-time selection
  if (clipMode === 'PreserveChroma') {
    return gamutClipPreserveChroma(lab)
  }

  if (clipMode === 'AdaptiveLCusp') {
    return gamutClipAdaptiveL0Cusp(lab, alpha)
  }

  // default: adaptiveL05
  if (clipMode === 'AdaptiveL0.5') {
    return gamutClipAdaptiveL05(lab, alpha)
  }

  if (clipMode === 'none') {
    return lab
  }
})

/**
 * Public convenience wrappers
 */

type ColorSpace = 'srgb' | 'linear'

export const oklabToRgb = Fn(
  ([lab, clipMode = 'AdaptiveL0.5', alpha = float(0.2), outputSpace = 'srgb']: [
    Vec3Node,
    ClipMode,
    FloatNode,
    ColorSpace,
  ]) => {
    const labClipped = oklabGamutClip(lab, clipMode, alpha)
    const rgbLinear = oklabToLinearRgb(labClipped)

    return outputSpace === 'srgb' ? linearToSrgb(rgbLinear) : rgbLinear
  },
)

export const rgbToOklab = Fn(([rgb, inputSpace]: [Vec3Node, ColorSpace]) => {
  // sRGB -> linear sRGB -> OKLab
  const rgbLinear = inputSpace === 'srgb' ? srgbToLinear(rgb) : rgb
  return linearRgbToOklab(rgbLinear)
})

//* Lightness only helpers (Only lightness is calculated on the GPU) *\\

export const gamutClipPreserveChroma_WithCusp = Fn(([lab, cusp]: [Vec3Node, Vec2Node]) => {
  const L = lab.x

  const nab = normalizeAB(lab) // (C, aN, bN)
  const C = nab.x
  const aN = nab.y
  const bN = nab.z

  const L0 = clamp(L, float(0.0), float(1.0))

  const t = clamp(findGamutIntersection(aN, bN, L, C, L0, cusp), float(0.0), float(1.0))

  const Lc = mix(L0, L, t)
  const Cc = t.mul(C)

  return vec3(Lc, Cc.mul(aN), Cc.mul(bN))
})

export const gamutClipAdaptiveL0Cusp_WithCuspAlpha = Fn(([lab, cusp, alpha]: [Vec3Node, Vec2Node, FloatNode]) => {
  const L = lab.x

  const nab = normalizeAB(lab) // (C, aN, bN)
  const C = nab.x
  const aN = nab.y
  const bN = nab.z

  const cuspL = cusp.x

  const Ld = L.sub(cuspL)
  const k = float(2.0).mul(select(Ld.greaterThan(float(0.0)), float(1.0).sub(cuspL), cuspL))

  const e1 = float(0.5).mul(k).add(abs(Ld)).add(alpha.mul(C).div(k))
  const inside = max(float(0.0), e1.mul(e1).sub(float(2.0).mul(k).mul(abs(Ld))))
  const L0 = cuspL.add(float(0.5).mul(sign(Ld).mul(e1.sub(sqrt(inside)))))

  const t = clamp(findGamutIntersection(aN, bN, L, C, L0, cusp), float(0.0), float(1.0))

  const Lc = mix(L0, L, t)
  const Cc = t.mul(C)

  return vec3(Lc, Cc.mul(aN), Cc.mul(bN))
})

export const gamutClipAdaptiveL05_WithCuspAlpha = Fn(([lab, cusp, alpha]: [Vec3Node, Vec2Node, FloatNode]) => {
  const L = lab.x

  const nab = normalizeAB(lab)
  const C = nab.x
  const aN = nab.y
  const bN = nab.z

  const Ld = L.sub(0.5)
  const e1 = float(0.5).add(abs(Ld)).add(alpha.mul(C))
  const inside = max(float(0.0), e1.mul(e1).sub(float(2.0).mul(abs(Ld))))
  const L0 = float(0.5).mul(float(1.0).add(sign(Ld).mul(e1.sub(sqrt(inside)))))

  const t = clamp(findGamutIntersection(aN, bN, L, C, L0, cusp), float(0.0), float(1.0))

  const Lc = mix(L0, L, t)
  const Cc = t.mul(C)

  return vec3(Lc, Cc.mul(aN), Cc.mul(bN))
})

export const gamutClipAdaptiveL05_WithCusp = Fn(([lab, cusp, alpha]: [Vec3Node, Vec2Node, FloatNode]) => {

  const L = lab.x

  const nab = normalizeAB(lab) // (C, aN, bN)
  const C = nab.x
  const a_ = nab.y
  const b_ = nab.z

  const Ld = L.sub(0.5)
  const e1 = float(0.5).add(abs(Ld)).add(alpha.mul(C))
  const inside = max(float(0.0), e1.mul(e1).sub(float(2.0).mul(abs(Ld))))
  const L0 = float(0.5).mul(float(1.0).add(sign(Ld).mul(e1.sub(sqrt(inside)))))

  // cusp passed in

  const t = clamp(findGamutIntersection(a_, b_, L, C, L0, cusp), float(0.0), float(1.0))

  const L_clipped = mix(L0, L, t)
  const C_clipped = t.mul(C)

  return vec3(L_clipped, C_clipped.mul(a_), C_clipped.mul(b_))
})

export const oklchLightnessToLinearRgb = Fn(
  ({
    l,
    a,
    b,
    cusp,
    clipMode,
    alpha,
  }: {
    l: FloatNode
    a: FloatNode
    b: FloatNode
    cusp: Vec2Node
    clipMode: ClipMode
    alpha: FloatNode
  }) => {
    const lab = vec3(l, a, b)

    // JS if compile-time selection
    if (clipMode === 'PreserveChroma') {
      const clipped = gamutClipPreserveChroma_WithCusp(lab, cusp)
      return oklabToLinearRgb(clipped)
    }

    if (clipMode === 'AdaptiveLCusp') {
      const clipped = gamutClipAdaptiveL0Cusp_WithCuspAlpha(lab, cusp, alpha)
      return oklabToLinearRgb(clipped)
    }

    // default: adaptiveL05
    if (clipMode === 'AdaptiveL0.5') {
      const clipped = gamutClipAdaptiveL05_WithCuspAlpha(lab, cusp, alpha)
      return oklabToLinearRgb(clipped)
    }

    if (clipMode === 'none') {
      return oklabToLinearRgb(lab)
    }
  },
)

//* CPU Versions *\\

const cbrt_cpu = (x: number) => Math.sign(x) * Math.pow(Math.abs(x), 1 / 3)

const oklabToLinearRgb_cpu = (L: number, a: number, b: number) => {
  const l_ = L + 0.3963377774 * a + 0.2158037573 * b
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b
  const s_ = L - 0.0894841775 * a - 1.291485548 * b

  const l = l_ * l_ * l_
  const m = m_ * m_ * m_
  const s = s_ * s_ * s_

  return {
    r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
  }
}

const computeMaxSaturation_cpu = (a: number, b: number) => {
  let k0: number, k1: number, k2: number, k3: number, k4: number
  let wl: number, wm: number, ws: number

  if (-1.88170328 * a - 0.80936493 * b > 1) {
    k0 = 1.19086277
    k1 = 1.76576728
    k2 = 0.59662641
    k3 = 0.75515197
    k4 = 0.56771245
    wl = 4.0767416621
    wm = -3.3077115913
    ws = 0.2309699292
  } else if (1.81444104 * a - 1.19445276 * b > 1) {
    k0 = 0.73956515
    k1 = -0.45954404
    k2 = 0.08285427
    k3 = 0.1254107
    k4 = 0.14503204
    wl = -1.2684380046
    wm = 2.6097574011
    ws = -0.3413193965
  } else {
    k0 = 1.35733652
    k1 = -0.00915799
    k2 = -1.1513021
    k3 = -0.50559606
    k4 = 0.00692167
    wl = -0.0041960863
    wm = -0.7034186147
    ws = 1.707614701
  }

  const k_l = 0.3963377774 * a + 0.2158037573 * b
  const k_m = -0.1055613458 * a - 0.0638541728 * b
  const k_s = -0.0894841775 * a - 1.291485548 * b

  let S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b

  const l_ = 1 + S * k_l
  const m_ = 1 + S * k_m
  const s_ = 1 + S * k_s

  const l = l_ * l_ * l_
  const m = m_ * m_ * m_
  const s = s_ * s_ * s_

  const l_dS = 3 * k_l * l_ * l_
  const m_dS = 3 * k_m * m_ * m_
  const s_dS = 3 * k_s * s_ * s_

  const l_dS2 = 6 * k_l * k_l * l_
  const m_dS2 = 6 * k_m * k_m * m_
  const s_dS2 = 6 * k_s * k_s * s_

  const f = wl * l + wm * m + ws * s
  const f1 = wl * l_dS + wm * m_dS + ws * s_dS
  const f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2

  S = S - (f * f1) / (f1 * f1 - 0.5 * f * f2)
  return S
}

export const computeCuspFromHue_cpu = (h: number) => {
  const aN = Math.cos(h)
  const bN = Math.sin(h)

  const S_cusp = computeMaxSaturation_cpu(aN, bN)

  const rgb = oklabToLinearRgb_cpu(1, S_cusp * aN, S_cusp * bN)
  const maxRGB = Math.max(rgb.r, rgb.g, rgb.b)

  const L_cusp = cbrt_cpu(1 / maxRGB)
  const C_cusp = L_cusp * S_cusp

  return { L_cusp, C_cusp }
}

@Mugen87 Mugen87 requested a review from donmccurdy February 23, 2026 10:30
@Mugen87
Copy link
Collaborator

Mugen87 commented Feb 23, 2026

LGTM!

@donmccurdy What do you think about this change?

Copy link
Collaborator

@donmccurdy donmccurdy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think allowing users to get/set/lerp THREE.Color instances using the Oklch color model makes sense, especially as this is widely available in CSS, thanks @RenaudRohlinger!

That said... the gamut mapping question mentioned by @thelazylamaGit is important and we will need to decide how to handle that. I believe our choices are:

  • (A) Expect users to specify Oklch inputs that land within the gamut of the chosen working color space
  • (B) Implement gamut mapping, as either an implicit effect or an explicit API

We already support wide gamut color spaces Display P3 and Rec. 2020 to some extent, and — while not widely used, currently — I don't feel that gamut mapping would improve the usability of those spaces. Whether Oklch is somehow "different" in terms of requirements and/or conventions for usage, I'm not sure...

I would also caution... I consider the color science behind gamut mapping to be very questionable. Oklab and Oklch are premised on that foundation, so it may be difficult to get around this problem entirely here. But I am not keen to bring gamut mapping into the image formation (tone mapping -> output color space) pipeline as a core workflow. For picking input colors, this may not be a problem, and basic gamut mapping utilities available in JS or TSL might be OK.

/cc @WestLangley may have some insight on this as well!

Comment on lines +341 to +354
setOKLCH( l, c, h, colorSpace = ColorManagement.workingColorSpace ) {

// l,c,h ranges: l in 0-1, c >= 0, h in 0-1 (normalized)
h = euclideanModulo( h, 1 );
l = clamp( l, 0, 1 );
c = Math.max( c, 0 );

oklchToLinearSRGB( l, c, h, this );

ColorManagement.colorSpaceToWorking( this, colorSpace );

return this;

}
Copy link
Collaborator

@donmccurdy donmccurdy Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To my understanding, OKLCH implies not just a color model (RGB vs. LCH) but also a color space, so calling...

color.setOKLCH( l, c, h, THREE.SRGBColorSpace )

... would be giving conflicting information about the input color space. So, we probably shouldn't include a colorSpace parameter on the setOKLCH method.

* @param {string} [colorSpace=ColorManagement.workingColorSpace] - The color space.
* @return {{l:number,c:number,h:number}} The OKLCH representation of this color.
*/
getOKLCH( target, colorSpace = ColorManagement.workingColorSpace ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, I think we cannot include a colorSpace parameter on this method.

@WestLangley
Copy link
Collaborator

WestLangley commented Feb 24, 2026

Thank you @RenaudRohlinger and @thelazylamaGit.

  1. I think gamut mapping is critical, but the CSS Color Level 4 gamut mapping algorithm is a bit heavy. Simple RGB clamping can result in hue shifts. For now, I'd suggest implementing gamut mapping by scaling by max( RGB) while clipping negative values. Later, add an option for more accurate gamut mapping.

  2. I would move the OKLCH utilities from the Color class to ColorConverter.js for now. The code can always be promoted later. Follow the pattern in the file.

static setOKLCH( color, l, c, h ) // transform to working color space and gamut map

static getOKLCH( color, target )
  1. For TSL, we need working-space-agnostic methods. Support linear-SRGB and display-P3.
OKLCHToWorking( oklch ) // be sure to gamut map

workingToOKLCH( rgb )

lerpOKLCH( colorA, colorB, alpha ) // working-to-OKLCH, mix, convert back to working, gamut map

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants