--!nolint --!strict -- Oklab C implementation provided by Björn Ottosson: -- https://bottosson.github.io/posts/gamutclipping/ -- Luau port and Roblox/Lch extensions by Elttob: -- https://elttob.uk/ -- Licensed under MIT local TAU = 2 * math.pi local function cbrt(x: number) return math.sign(x) * math.abs(x) ^ (1/3) end local Oklab = {} function Oklab.linear_srgb_to_oklab( c: Vector3 ): Vector3 local l = 0.4122214708 * c.X + 0.5363325363 * c.Y + 0.0514459929 * c.Z local m = 0.2119034982 * c.X + 0.6806995451 * c.Y + 0.1073969566 * c.Z local s = 0.0883024619 * c.X + 0.2817188376 * c.Y + 0.6299787005 * c.Z local l_ = cbrt(l) local m_ = cbrt(m) local s_ = cbrt(s) return Vector3.new( 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ ) end function Oklab.oklab_to_linear_srgb( c: Vector3 ): Vector3 local l_ = c.X + 0.3963377774 * c.Y + 0.2158037573 * c.Z local m_ = c.X - 0.1055613458 * c.Y - 0.0638541728 * c.Z local s_ = c.X - 0.0894841775 * c.Y - 1.2914855480 * c.Z local l = l_ * l_ * l_ local m = m_ * m_ * m_ local s = s_ * s_ * s_ return Vector3.new( 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s ) end -- Finds the maximum saturation possible for a given hue that fits in sRGB. -- Saturation here is defined as S = C/L -- a and b must be normalised so a^2 + b^2 == 1 function Oklab.compute_max_saturation( a: number, b: number ): number -- Max saturation will be when one of r, g or b goes below zero. -- Select different coefficients depending on which component goes below zero first local k0, k1, k2, k3, k4, wl, wm, ws if -1.88170328 * a - 0.80936493 * b > 1 then -- Red component k0, k1, k2, k3, k4 = 1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245 wl, wm, ws = 4.0767416621, -3.3077115913, 0.2309699292 elseif 1.81444104 * a - 1.19445276 * b > 1 then -- Green component k0, k1, k2, k3, k4 = 0.73956515, -0.45954404, 0.08285427, 0.12541070, 0.14503204 wl, wm, ws = -1.2684380046, 2.6097574011, -0.3413193965 else -- Blue component k0, k1, k2, k3, k4 = 1.35733652, -0.00915799, -1.15130210, -0.50559606, 0.00692167 wl, wm, ws = -0.0041960863, -0.7034186147, 1.7076147010 end -- Approximate max saturation using a polynomial local S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b -- Do one step Halley's method to get closer -- this gives an error less than 10e6, except for some blue hues where the dS/dh is close to infinite -- this should be sufficient for most applications, otherwise do two/three steps local k_l = 0.3963377774 * a + 0.2158037573 * b local k_m = -0.1055613458 * a - 0.0638541728 * b local k_s = -0.0894841775 * a - 1.2914855480 * b do local l_ = 1 + S * k_l local m_ = 1 + S * k_m local s_ = 1 + S * k_s local l = l_ * l_ * l_ local m = m_ * m_ * m_ local s = s_ * s_ * s_ local l_dS = 3 * k_l * l_ * l_ local m_dS = 3 * k_m * m_ * m_ local s_dS = 3 * k_s * s_ * s_ local l_dS2 = 6 * k_l * k_l * l_ local m_dS2 = 6 * k_m * k_m * m_ local s_dS2 = 6 * k_s * k_s * s_ local f = wl * l + wm * m + ws * s local f1 = wl * l_dS + wm * m_dS + ws * s_dS local f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2 S = S - f * f1 / (f1*f1 - 0.5 * f * f2) end return S end -- finds L_cusp and C_cusp for a given hue -- a and b must be normalised so a^2 + b^2 == 1 function Oklab.find_cusp( a: number, b: number ): (number, number) -- First, find the maximum saturation (saturation S = C/L) local S_cusp = Oklab.compute_max_saturation(a, b) -- Convert to linear sRGB to find the first point where at least one of r,g or b >= 1: local rgb_at_max = Oklab.oklab_to_linear_srgb(Vector3.new(1, S_cusp * a, S_cusp * b)) local L_cusp = cbrt(1 / math.max(rgb_at_max.X, rgb_at_max.Y, rgb_at_max.Z)) local C_cusp = L_cusp * S_cusp return L_cusp, C_cusp end -- Finds intersection of the line defined by -- L = L0 * (1 - t) + t * L1; -- C = t * C1; -- a and b must be normalized so a^2 + b^2 == 1 function Oklab.find_gamut_intersection( a: number, b: number, L1: number, C1: number, L0: number ): number -- Find the cusp of the gamut triangle local L_cusp, C_cusp = Oklab.find_cusp(a, b) -- Find the intersection for upper and lower half seprately local t if ((L1 - L0) * C_cusp - (L_cusp - L0) * C1) <= 0 then -- Lower half t = C_cusp * L0 / (C1 * L_cusp + C_cusp * (L0 - L1)) else -- Upper half -- First intersect with triangle t = C_cusp * (L0 - 1) / (C1 * (L_cusp - 1) + C_cusp * (L0 - L1)) -- Then one step Halley's method do local dL = L1 - L0 local dC = C1 local k_l = 0.3963377774 * a + 0.2158037573 * b local k_m = -0.1055613458 * a - 0.0638541728 * b local k_s = -0.0894841775 * a - 1.2914855480 * b local l_dt = dL + dC * k_l local m_dt = dL + dC * k_m local s_dt = dL + dC * k_s -- If higher accuracy is required, 2 or 3 iterations of the following block can be used: do local L = L0 * (1 - t) + t * L1 local C = t * C1 local l_ = L + C * k_l local m_ = L + C * k_m local s_ = L + C * k_s local l = l_ * l_ * l_ local m = m_ * m_ * m_ local s = s_ * s_ * s_ local ldt = 3 * l_dt * l_ * l_ local mdt = 3 * m_dt * m_ * m_ local sdt = 3 * s_dt * s_ * s_ local ldt2 = 6 * l_dt * l_dt * l_ local mdt2 = 6 * m_dt * m_dt * m_ local sdt2 = 6 * s_dt * s_dt * s_ local r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1 local r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt local r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2 local u_r = r1 / (r1 * r1 - 0.5 * r * r2) local t_r = -r * u_r local g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1 local g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt local g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2 local u_g = g1 / (g1 * g1 - 0.5 * g * g2) local t_g = -g * u_g local b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s - 1 local b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt local b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2 local u_b = b1 / (b1 * b1 - 0.5 * b * b2) local t_b = -b * u_b t_r = if u_r >= 0 then t_r else math.huge t_g = if u_g >= 0 then t_g else math.huge t_b = if u_b >= 0 then t_b else math.huge t += math.min(t_r, t_g, t_b) end end end return t end function Oklab.gamut_clip_preserve_chroma( rgb: Vector3 ): Vector3 if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then return rgb end local lab = Oklab.linear_srgb_to_oklab(rgb) local L = lab.X local eps = 0.00001 local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) local a_ = if C == 0 then 0 else lab.Y / C local b_ = if C == 0 then 0 else lab.Z / C local L0 = math.clamp(L, 0, 1) local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) local L_clipped = L0 * (1 - t) + t * L local C_clipped = t * C return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) end function Oklab.gamut_clip_project_to_0_5( rgb: Vector3 ): Vector3 if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then return rgb end local lab = Oklab.linear_srgb_to_oklab(rgb) local L = lab.X local eps = 0.00001 local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) local a_ = lab.Y / C local b_ = lab.Z / C local L0 = 0.5 local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) local L_clipped = L0 * (1 - t) + t * L local C_clipped = t * C return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) end function Oklab.gamut_clip_project_to_L_cusp( rgb: Vector3 ): Vector3 if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then return rgb end local lab = Oklab.linear_srgb_to_oklab(rgb) local L = lab.X local eps = 0.00001 local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) local a_ = lab.Y / C local b_ = lab.Z / C -- The cusp is computed here and in find_gamut_intersection, an optimised solution would only compute it once. local L_cusp, C_cusp = Oklab.find_cusp(a_, b_) local L0 = L_cusp local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) local L_clipped = L0 * (1 - t) + t * L local C_clipped = t * C return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) end function Oklab.gamut_clip_adaptive_L0_0_5( rgb: Vector3, alpha: number? ): Vector3 if rgb.X <= 1 and rgb.Y <= 1 and rgb.Z <= 1 and rgb.X >= 0 and rgb.Y >= 0 and rgb.Z >= 0 then return rgb end local alpha = alpha or 0.05 local lab = Oklab.linear_srgb_to_oklab(rgb) local L = lab.X local eps = 0.00001 local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) local a_ = lab.Y / C local b_ = lab.Z / C local Ld = L - 0.5 local e1 = 0.5 + math.abs(Ld) + alpha * C local L0 = 0.5 * (1 + math.sign(Ld) * (e1 - math.sqrt(e1*e1 - 2 * math.abs(Ld)))) local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) local L_clipped = L0 * (1 - t) + t * L local C_clipped = t * C return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) end function Oklab.gamut_clip_adaptive_L0_L_cusp( rgb: Vector3, alpha: number? ): Vector3 if rgb.X < 1 and rgb.Y < 1 and rgb.Z < 1 and rgb.X > 0 and rgb.Y > 0 and rgb.Z > 0 then return rgb end local alpha = alpha or 0.05 local lab = Oklab.linear_srgb_to_oklab(rgb) local L = lab.X local eps = 0.00001 local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z)) local a_ = lab.Y / C local b_ = lab.Z / C -- The cusp is computed here and in find_gamut_intersection, an optimized solution would only compute it once. local L_cusp, C_cusp = Oklab.find_cusp(a_, b_) local Ld = L - L_cusp local k = 2 * (if Ld > 0 then 1 - L_cusp else L_cusp) local e1 = 0.5*k + math.abs(Ld) + alpha * C/k local L0 = L_cusp + 0.5 * (math.sign(Ld) * (e1 - math.sqrt(e1 * e1 - 2 * k * math.abs(Ld)))) local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0) local L_clipped = L0 * (1 - t) + t * L local C_clipped = t * C return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_)) end --[[ ROBLOX EXTENSIONS ]] Oklab.default_gamut_clip = Oklab.gamut_clip_adaptive_L0_0_5 local function component_to_gamma(x: number): number if x >= 0.0031308 then return (1.055) * x^(1.0/2.4) - 0.055 else return 12.92 * x end end local function component_to_linear(x: number): number if x >= 0.04045 then return ((x + 0.055)/(1 + 0.055))^2.4 else return x / 12.92 end end function Oklab.color3_to_linear_srgb( c: Color3 ): Vector3 return Vector3.new( component_to_linear(c.R), component_to_linear(c.G), component_to_linear(c.B) ) end function Oklab.linear_srgb_to_color3( c: Vector3, use_default_gamut_clip: boolean? ): Color3 if use_default_gamut_clip == false then return Color3.new( component_to_gamma(c.X), component_to_gamma(c.Y), component_to_gamma(c.Z) ) else local c = Oklab.default_gamut_clip(c) return Color3.new( math.clamp(component_to_gamma(c.X), 0, 1), math.clamp(component_to_gamma(c.Y), 0, 1), math.clamp(component_to_gamma(c.Z), 0, 1) ) end end --[[ LCH EXTENSIONS ]] function Oklab.oklch_to_oklab(oklch: Vector3): Vector3 return Vector3.new( oklch.X, oklch.Y * math.cos(oklch.Z * TAU), oklch.Y * math.sin(oklch.Z * TAU) ) end function Oklab.oklab_to_oklch(oklab: Vector3): Vector3 return Vector3.new( oklab.X, math.sqrt(oklab.Y^2 + oklab.Z^2), (math.atan2(oklab.Z, oklab.Y) / TAU) % 1 ) end return Oklab