Color: OKLCH/OKLab Color Space Support#33043
Color: OKLCH/OKLab Color Space Support#33043RenaudRohlinger wants to merge 3 commits intomrdoob:devfrom
Conversation
📦 Bundle sizeFull ESM build, minified and gzipped.
🌳 Bundle size after tree-shakingMinimal build including a renderer, camera, empty scene, and dependencies.
|
|
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. Codeimport {
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 }
} |
|
LGTM! @donmccurdy What do you think about this change? |
There was a problem hiding this comment.
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!
| 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; | ||
|
|
||
| } |
There was a problem hiding this comment.
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 ) { |
There was a problem hiding this comment.
Similar to above, I think we cannot include a colorSpace parameter on this method.
|
Thank you @RenaudRohlinger and @thelazylamaGit.
static setOKLCH( color, l, c, h ) // transform to working color space and gamut map
static getOKLCH( color, target )
OKLCHToWorking( oklch ) // be sure to gamut map
workingToOKLCH( rgb )
lerpOKLCH( colorA, colorB, alpha ) // working-to-OKLCH, mix, convert back to working, gamut map |
Description
Adds perceptually uniform color manipulation to three.js, both CPU-side (Color) and GPU-side (TSL).
Why OKLCH over HSL?
This contribution is funded by Spawn