From 24b302bafa4d45c7e37512142d3761ce3b9bfb40 Mon Sep 17 00:00:00 2001 From: Renaud Rohlinger Date: Sun, 22 Feb 2026 18:07:07 +0900 Subject: [PATCH 1/4] Color: Add OKLCH Support --- examples/files.json | 1 + examples/screenshots/webgpu_tsl_oklch.jpg | Bin 0 -> 22509 bytes examples/webgpu_tsl_oklch.html | 291 ++++++++++++++++++++++ src/Three.TSL.js | 4 + src/math/Color.js | 148 +++++++++++ src/nodes/TSL.js | 1 + src/nodes/display/OKLCHFunctions.js | 129 ++++++++++ 7 files changed, 574 insertions(+) create mode 100644 examples/screenshots/webgpu_tsl_oklch.jpg create mode 100644 examples/webgpu_tsl_oklch.html create mode 100644 src/nodes/display/OKLCHFunctions.js diff --git a/examples/files.json b/examples/files.json index 023ca2c07e02e8..8cea29b742b92b 100644 --- a/examples/files.json +++ b/examples/files.json @@ -474,6 +474,7 @@ "webgpu_tsl_galaxy", "webgpu_tsl_halftone", "webgpu_tsl_interoperability", + "webgpu_tsl_oklch", "webgpu_tsl_procedural_terrain", "webgpu_tsl_raging_sea", "webgpu_tsl_transpiler", diff --git a/examples/screenshots/webgpu_tsl_oklch.jpg b/examples/screenshots/webgpu_tsl_oklch.jpg new file mode 100644 index 0000000000000000000000000000000000000000..428ee6878f47b819edbe59ea41d73a80ec92300b GIT binary patch literal 22509 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 z z;_bYb?!mu$OU_T4y+g1ZnA~AL!-2quN_QMYnX{?Y_EiVs%?>bab4X3{l^KLXOH8?L zx|R;Ry1Ry#&gi{8Z*5$Pc!6nx}IN_<8Qdw-7|_Z z?`jYld7yN`DoA6-SN<4eNZvuXX@SH1p+=Fp5*8{@0(8F5mBgII5rW&N#Qh>Ry!7If zUZ>-wH}-TKO7Sa6(Lg}BPYYKSqF%?A4b+3k=O-@&X>8hNHrV;xrC(osXY{yfK-OW0 zwaQsj>~Yd1*+6|Q+A6G>eiug`Yf)r1$-c2tAtQ${8AzC+)}@VwSUk^}<6PgQ2%&-0 zX|}E{$fLm2T zx{wXfYGnb^X&+RhYujM(J3n~bccbxT;Vzz4oZv=Mxp(wLA%T`o=ORn$S46@{=5LMr zwMQaOy{W#AFE`F)-;C|ircE{0A0Zr)xn3z`3mLf&NhK9UEc>iq8>-*!TlaL2kFHF; zogMD;@WS}jQa7^-yhXTg;NoxWIixlCt?7PaKKy*k%S;m;AM-1LSI+Z9dPAB!f==*v zrET{YAaYWDkU!J3G`_OD$h>FgUnX+*ZqC$%mQL4gx}L!Tab)qlbtxh05SM>(_w86B zzI)vd(`GdTG|>^1MvE(T#L2vhp5n^aS=Lao7c|0}#>qVys_n-)pS2Q|yo5oW(bj+z z$BDI+EkBvLo^c}As6q^DhG=rr?UaL^T{Gz-vK;{nw YCd-N%BLV-=d<#VH`B&@ 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 literal 0 HcmV?d00001 diff --git a/examples/webgpu_tsl_oklch.html b/examples/webgpu_tsl_oklch.html new file mode 100644 index 00000000000000..bfaf4be510ba9d --- /dev/null +++ b/examples/webgpu_tsl_oklch.html @@ -0,0 +1,291 @@ + + + + three.js webgpu - OKLCH color space + + + + + + +
+ + +
+ three.jsOKLCH Color Space +
+ + + HSL (left) vs OKLCH (right) — OKLCH maintains uniform perceived brightness.
+ Gradients: RGB / HSL / OKLCH. 3D objects: HSL hues (top) vs OKLCH hues (bottom). +
+
+ + + + + + diff --git a/src/Three.TSL.js b/src/Three.TSL.js index 21c31a436e02e6..ddfafea09a3172 100644 --- a/src/Three.TSL.js +++ b/src/Three.TSL.js @@ -256,6 +256,8 @@ export const lightViewPosition = TSL.lightViewPosition; export const lightingContext = TSL.lightingContext; export const lights = TSL.lights; export const linearDepth = TSL.linearDepth; +export const linearSRGBToOKLab = TSL.linearSRGBToOKLab; +export const linearSRGBToOKLCH = TSL.linearSRGBToOKLCH; export const linearToneMapping = TSL.linearToneMapping; export const localId = TSL.localId; export const log = TSL.log; @@ -401,6 +403,8 @@ export const objectRadius = TSL.objectRadius; export const objectScale = TSL.objectScale; export const objectViewPosition = TSL.objectViewPosition; export const objectWorldMatrix = TSL.objectWorldMatrix; +export const okLabToLinearSRGB = TSL.okLabToLinearSRGB; +export const oklchToLinearSRGB = TSL.oklchToLinearSRGB; export const OnBeforeObjectUpdate = TSL.OnBeforeObjectUpdate; export const OnBeforeMaterialUpdate = TSL.OnBeforeMaterialUpdate; export const OnObjectUpdate = TSL.OnObjectUpdate; diff --git a/src/math/Color.js b/src/math/Color.js index f42d664478a695..63b73544b741cf 100644 --- a/src/math/Color.js +++ b/src/math/Color.js @@ -31,6 +31,9 @@ const _colorKeywords = { 'aliceblue': 0xF0F8FF, 'antiquewhite': 0xFAEBD7, 'aqua' const _hslA = { h: 0, s: 0, l: 0 }; const _hslB = { h: 0, s: 0, l: 0 }; +const _oklchA = { l: 0, c: 0, h: 0 }; +const _oklchB = { l: 0, c: 0, h: 0 }; + function hue2rgb( p, q, t ) { if ( t < 0 ) t += 1; @@ -42,6 +45,58 @@ function hue2rgb( p, q, t ) { } +function linearSRGBToOKLCH( r, g, b, target ) { + + // Linear sRGB → LMS + const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + // LMS → OKLab (cube root, then M2) + const l_ = Math.cbrt( l ); + const m_ = Math.cbrt( m ); + const s_ = Math.cbrt( s ); + + const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + const bLab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; + + // OKLab → OKLCH + target.l = L; + target.c = Math.sqrt( a * a + bLab * bLab ); + + let h = Math.atan2( bLab, a ) / ( 2 * Math.PI ); + if ( h < 0 ) h += 1; + target.h = h; + + return target; + +} + +function oklchToLinearSRGB( L, C, H, target ) { + + const hRad = H * 2 * Math.PI; + const a = C * Math.cos( hRad ); + const b = C * Math.sin( hRad ); + + // OKLab → LMS (inverse M2) + 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.2914855480 * b; + + // LMS → Linear sRGB (cube, then inverse M1) + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + + target.r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + target.g = - 1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + target.b = - 0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + + return target; + +} + /** * A Color instance is represented by RGB components in the linear working * color space, which defaults to `LinearSRGBColorSpace`. Inputs @@ -273,6 +328,31 @@ class Color { } + /** + * Sets this color from OKLCH values. OKLCH is a perceptually uniform + * cylindrical color space based on OKLab. + * + * @param {number} l - Lightness value between `0.0` and `1.0`. + * @param {number} c - Chroma value, typically between `0.0` and `0.4`. + * @param {number} h - Hue value between `0.0` and `1.0` (normalized). + * @param {string} [colorSpace=ColorManagement.workingColorSpace] - The color space. + * @return {Color} A reference to this color. + */ + 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; + + } + /** * Sets this color from a CSS-style string. For example, `rgb(250, 0,0)`, * `rgb(100%, 0%, 0%)`, `hsl(0, 100%, 50%)`, `#ff0000`, `#f00`, or `red` ( or @@ -365,6 +445,26 @@ class Color { break; + case 'oklch': + + if ( color = /^\s*(\d*\.?\d+)(%?)\s+(\d*\.?\d+)\s+(\d*\.?\d+)\s*(?:\/\s*(\d*\.?\d+)\s*)?$/.exec( components ) ) { + + // oklch(0.7 0.15 180) oklch(70% 0.15 180) + + handleAlpha( color[ 5 ] ); + + const l = color[ 2 ] === '%' ? parseFloat( color[ 1 ] ) / 100 : parseFloat( color[ 1 ] ); + + return this.setOKLCH( + l, + parseFloat( color[ 3 ] ), + parseFloat( color[ 4 ] ) / 360 + ); + + } + + break; + default: warn( 'Color: Unknown color model ' + style ); @@ -609,6 +709,24 @@ class Color { } + /** + * Converts the colors RGB values into the OKLCH format and stores them into the + * given target object. OKLCH is a perceptually uniform cylindrical color space. + * + * @param {{l:number,c:number,h:number}} target - The target object that is used to store the method's result. + * @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 ) { + + ColorManagement.workingToColorSpace( _color.copy( this ), colorSpace ); + + linearSRGBToOKLCH( _color.r, _color.g, _color.b, target ); + + return target; + + } + /** * Returns the RGB values of this color and stores them into the given target object. * @@ -831,6 +949,36 @@ class Color { } + /** + * Linearly interpolates this color's OKLCH values toward the OKLCH values of the + * given color. Uses shortest-path hue interpolation for perceptually smooth transitions. + * The alpha argument can be thought of as the ratio between the two colors, + * where 0.0 is this color and 1.0 is the first argument. + * + * @param {Color} color - The color to converge on. + * @param {number} alpha - The interpolation factor in the closed interval `[0,1]`. + * @return {Color} A reference to this color. + */ + lerpOKLCH( color, alpha ) { + + this.getOKLCH( _oklchA ); + color.getOKLCH( _oklchB ); + + // shortest-path hue interpolation + let dh = _oklchB.h - _oklchA.h; + if ( dh > 0.5 ) dh -= 1; + if ( dh < - 0.5 ) dh += 1; + + const h = _oklchA.h + dh * alpha; + const c = lerp( _oklchA.c, _oklchB.c, alpha ); + const l = lerp( _oklchA.l, _oklchB.l, alpha ); + + this.setOKLCH( l, c, h ); + + return this; + + } + /** * Sets the color's RGB components from the given 3D vector. * diff --git a/src/nodes/TSL.js b/src/nodes/TSL.js index 6f47628c02449a..4253d89cb48fa9 100644 --- a/src/nodes/TSL.js +++ b/src/nodes/TSL.js @@ -111,6 +111,7 @@ export * from './display/ToonOutlinePassNode.js'; export * from './display/PassNode.js'; export * from './display/ColorSpaceFunctions.js'; +export * from './display/OKLCHFunctions.js'; export * from './display/ToneMappingFunctions.js'; // code diff --git a/src/nodes/display/OKLCHFunctions.js b/src/nodes/display/OKLCHFunctions.js new file mode 100644 index 00000000000000..24ec623114b253 --- /dev/null +++ b/src/nodes/display/OKLCHFunctions.js @@ -0,0 +1,129 @@ +import { Fn, vec3 } from '../tsl/TSLCore.js'; +import { atan, cbrt, cos, fract, sin, sqrt } from '../math/MathNode.js'; + +const TWO_PI = 2 * Math.PI; + +/** + * Converts a linear sRGB color to OKLab color space. + * + * @tsl + * @function + * @param {Node} color - The linear sRGB color. + * @return {Node} The OKLab color (L, a, b). + */ +export const linearSRGBToOKLab = /*@__PURE__*/ Fn( ( [ color ] ) => { + + const r = color.x, g = color.y, b = color.z; + + // Linear sRGB → LMS + const l = r.mul( 0.4122214708 ).add( g.mul( 0.5363325363 ) ).add( b.mul( 0.0514459929 ) ); + const m = r.mul( 0.2119034982 ).add( g.mul( 0.6806995451 ) ).add( b.mul( 0.1073969566 ) ); + const s = r.mul( 0.0883024619 ).add( g.mul( 0.2817188376 ) ).add( b.mul( 0.6299787005 ) ); + + // LMS → OKLab (cube root, then M2) + const l_ = cbrt( l ); + const m_ = cbrt( m ); + const s_ = cbrt( s ); + + const L = l_.mul( 0.2104542553 ).add( m_.mul( 0.7936177850 ) ).sub( s_.mul( 0.0040720468 ) ); + const a = l_.mul( 1.9779984951 ).sub( m_.mul( 2.4285922050 ) ).add( s_.mul( 0.4505937099 ) ); + const bLab = l_.mul( 0.0259040371 ).add( m_.mul( 0.7827717662 ) ).sub( s_.mul( 0.8086757660 ) ); + + return vec3( L, a, bLab ); + +} ).setLayout( { + name: 'linearSRGBToOKLab', + type: 'vec3', + inputs: [ + { name: 'color', type: 'vec3' } + ] +} ); + +/** + * Converts an OKLab color to linear sRGB color space. + * + * @tsl + * @function + * @param {Node} lab - The OKLab color (L, a, b). + * @return {Node} The linear sRGB color. + */ +export const okLabToLinearSRGB = /*@__PURE__*/ Fn( ( [ lab ] ) => { + + const L = lab.x, a = lab.y, b = lab.z; + + // OKLab → LMS (inverse M2) + const l_ = L.add( a.mul( 0.3963377774 ) ).add( b.mul( 0.2158037573 ) ); + const m_ = L.sub( a.mul( 0.1055613458 ) ).sub( b.mul( 0.0638541728 ) ); + const s_ = L.sub( a.mul( 0.0894841775 ) ).sub( b.mul( 1.2914855480 ) ); + + // cube + const l = l_.mul( l_ ).mul( l_ ); + const m = m_.mul( m_ ).mul( m_ ); + const s = s_.mul( s_ ).mul( s_ ); + + // LMS → Linear sRGB (inverse M1) + const r = l.mul( 4.0767416621 ).sub( m.mul( 3.3077115913 ) ).add( s.mul( 0.2309699292 ) ); + const g = l.mul( - 1.2684380046 ).add( m.mul( 2.6097574011 ) ).sub( s.mul( 0.3413193965 ) ); + const bOut = l.mul( - 0.0041960863 ).sub( m.mul( 0.7034186147 ) ).add( s.mul( 1.7076147010 ) ); + + return vec3( r, g, bOut ); + +} ).setLayout( { + name: 'okLabToLinearSRGB', + type: 'vec3', + inputs: [ + { name: 'lab', type: 'vec3' } + ] +} ); + +/** + * Converts a linear sRGB color to OKLCH color space. + * + * @tsl + * @function + * @param {Node} color - The linear sRGB color. + * @return {Node} The OKLCH color (L, C, H) where H is normalized 0-1. + */ +export const linearSRGBToOKLCH = /*@__PURE__*/ Fn( ( [ color ] ) => { + + const lab = linearSRGBToOKLab( color ); + const L = lab.x, a = lab.y, b = lab.z; + + const C = sqrt( a.mul( a ).add( b.mul( b ) ) ); + const H = fract( atan( b, a ).div( TWO_PI ) ); + + return vec3( L, C, H ); + +} ).setLayout( { + name: 'linearSRGBToOKLCH', + type: 'vec3', + inputs: [ + { name: 'color', type: 'vec3' } + ] +} ); + +/** + * Converts an OKLCH color to linear sRGB color space. + * + * @tsl + * @function + * @param {Node} lch - The OKLCH color (L, C, H) where H is normalized 0-1. + * @return {Node} The linear sRGB color. + */ +export const oklchToLinearSRGB = /*@__PURE__*/ Fn( ( [ lch ] ) => { + + const L = lch.x, C = lch.y, H = lch.z; + + const hRad = H.mul( TWO_PI ); + const a = C.mul( cos( hRad ) ); + const b = C.mul( sin( hRad ) ); + + return okLabToLinearSRGB( vec3( L, a, b ) ); + +} ).setLayout( { + name: 'oklchToLinearSRGB', + type: 'vec3', + inputs: [ + { name: 'lch', type: 'vec3' } + ] +} ); From 5a53b6b4cd40fdcdcefa664b9606be7830a79a58 Mon Sep 17 00:00:00 2001 From: Renaud Rohlinger Date: Sun, 22 Feb 2026 18:17:11 +0900 Subject: [PATCH 2/4] cleanup --- examples/webgpu_tsl_oklch.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/webgpu_tsl_oklch.html b/examples/webgpu_tsl_oklch.html index bfaf4be510ba9d..51696aec943208 100644 --- a/examples/webgpu_tsl_oklch.html +++ b/examples/webgpu_tsl_oklch.html @@ -37,7 +37,7 @@ import * as THREE from 'three/webgpu'; import { Fn, uv, uniform, vec3, float, - abs, atan, clamp, cos, fract, mix, sin, sqrt, + abs, atan, clamp, fract, mix, sqrt, oklchToLinearSRGB, linearSRGBToOKLCH, sRGBTransferEOTF } from 'three/tsl'; From c1c1a7f84962cc73897c771adedc3b35cb470830 Mon Sep 17 00:00:00 2001 From: Renaud Rohlinger Date: Sun, 22 Feb 2026 20:45:10 +0900 Subject: [PATCH 3/4] test --- examples/screenshots/webgpu_tsl_oklch.jpg | Bin 22509 -> 22257 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/examples/screenshots/webgpu_tsl_oklch.jpg b/examples/screenshots/webgpu_tsl_oklch.jpg index 428ee6878f47b819edbe59ea41d73a80ec92300b..31742d379b3e45f34e6b2f9abb102ad8c93ff3e7 100644 GIT binary patch delta 8766 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 b74VRJL5 delta 9007 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 TtsXMv{|EKM)Z(Jx+2sEK_Qk2t From f8f2017aa39f40f5c8d5638b729c61f45211143e Mon Sep 17 00:00:00 2001 From: RenaudRohlinger Date: Fri, 10 Apr 2026 09:47:48 +0900 Subject: [PATCH 4/4] Address OKLCH review feedback --- docs/pages/ColorConverter.html.md | 38 ++++++- examples/jsm/math/ColorConverter.js | 97 ++++++++++++++++++ examples/webgpu_tsl_oklch.html | 17 ++-- src/Three.TSL.js | 4 + src/constants.js | 16 +++ src/math/Color.js | 148 ---------------------------- src/math/ColorManagement.js | 37 ++++++- src/nodes/display/OKLCHFunctions.js | 81 ++++++++++++++- 8 files changed, 279 insertions(+), 159 deletions(-) diff --git a/docs/pages/ColorConverter.html.md b/docs/pages/ColorConverter.html.md index a54d5fc364cdf8..d138436a4f7704 100644 --- a/docs/pages/ColorConverter.html.md +++ b/docs/pages/ColorConverter.html.md @@ -26,6 +26,20 @@ The target object that is used to store the method's result. **Returns:** The HSV color. +### .getOKLCH( color : Color, target : Object ) : Object + +Returns an OKLCH color representation of the given color object. + +**color** + +The color to get OKLCH values from. + +**target** + +The target object that is used to store the method's result. + +**Returns:** The OKLCH color. + ### .setHSV( color : Color, h : number, s : number, v : number ) : Color Sets the given HSV color definition to the given color object. @@ -48,6 +62,28 @@ The value. **Returns:** The update color. +### .setOKLCH( color : Color, l : number, c : number, h : number ) : Color + +Sets the given OKLCH color definition to the given color object. + +**color** + +The color to set. + +**l** + +The lightness. + +**c** + +The chroma. + +**h** + +The hue. + +**Returns:** The updated color. + ## Source -[examples/jsm/math/ColorConverter.js](https://github.com/mrdoob/three.js/blob/master/examples/jsm/math/ColorConverter.js) \ No newline at end of file +[examples/jsm/math/ColorConverter.js](https://github.com/mrdoob/three.js/blob/master/examples/jsm/math/ColorConverter.js) diff --git a/examples/jsm/math/ColorConverter.js b/examples/jsm/math/ColorConverter.js index c257f4446fa114..d867d03e5815bc 100644 --- a/examples/jsm/math/ColorConverter.js +++ b/examples/jsm/math/ColorConverter.js @@ -2,6 +2,69 @@ import { MathUtils } from 'three'; const _hsl = {}; +function linearSRGBToOKLCH( r, g, b, target ) { + + const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + const l_ = Math.cbrt( l ); + const m_ = Math.cbrt( m ); + const s_ = Math.cbrt( s ); + + const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + const bLab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; + + target.l = L; + target.c = Math.sqrt( a * a + bLab * bLab ); + + let h = Math.atan2( bLab, a ) / ( 2 * Math.PI ); + if ( h < 0 ) h += 1; + target.h = h; + + return target; + +} + +function oklchToLinearSRGB( L, C, H, target ) { + + const hRad = H * 2 * Math.PI; + const a = C * Math.cos( hRad ); + const b = C * Math.sin( hRad ); + + 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.2914855480 * b; + + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + + target.r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + target.g = - 1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + target.b = - 0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + + return target; + +} + +function scaleToRGBGamut( color ) { + + color.r = Math.max( color.r, 0 ); + color.g = Math.max( color.g, 0 ); + color.b = Math.max( color.b, 0 ); + + const maxComponent = Math.max( color.r, color.g, color.b, 1 ); + + color.r /= maxComponent; + color.g /= maxComponent; + color.b /= maxComponent; + + return color; + +} + /** * A utility class with helper functions for color conversion. * @@ -53,6 +116,40 @@ class ColorConverter { } + /** + * Sets the given OKLCH color definition to the given color object. + * + * @param {Color} color - The color to set. + * @param {number} l - The lightness. + * @param {number} c - The chroma. + * @param {number} h - The hue. + * @return {Color} The updated color. + */ + static setOKLCH( color, l, c, h ) { + + l = MathUtils.clamp( l, 0, 1 ); + c = Math.max( c, 0 ); + h = MathUtils.euclideanModulo( h, 1 ); + + oklchToLinearSRGB( l, c, h, color ); + + return scaleToRGBGamut( color ); + + } + + /** + * Returns an OKLCH color representation of the given color object. + * + * @param {Color} color - The color to get OKLCH values from. + * @param {{l:number,c:number,h:number}} target - The target object that is used to store the method's result. + * @return {{l:number,c:number,h:number}} The OKLCH color. + */ + static getOKLCH( color, target ) { + + return linearSRGBToOKLCH( color.r, color.g, color.b, target ); + + } + } export { ColorConverter }; diff --git a/examples/webgpu_tsl_oklch.html b/examples/webgpu_tsl_oklch.html index 51696aec943208..04e5bed1ed9f6e 100644 --- a/examples/webgpu_tsl_oklch.html +++ b/examples/webgpu_tsl_oklch.html @@ -38,11 +38,12 @@ import { Fn, uv, uniform, vec3, float, abs, atan, clamp, fract, mix, sqrt, - oklchToLinearSRGB, linearSRGBToOKLCH, sRGBTransferEOTF + OKLCHToWorking, workingToOKLCH, sRGBTransferEOTF } from 'three/tsl'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { Inspector } from 'three/addons/inspector/Inspector.js'; + import { ColorConverter } from 'three/addons/math/ColorConverter.js'; let renderer, camera, scene, controls; @@ -114,7 +115,7 @@ const angle = fract( atan( p.y, p.x ).div( 2 * Math.PI ) ); const lch = vec3( lightnessUniform, radius.mul( chromaMaxUniform ), angle ); - const rgb = clamp( oklchToLinearSRGB( lch ), 0.0, 1.0 ); + const rgb = OKLCHToWorking( lch ); const mask = clamp( float( 1.0 ).sub( radius ).mul( 100.0 ), 0.0, 1.0 ); @@ -163,8 +164,8 @@ const t = uv().x; - const redLCH = linearSRGBToOKLCH( redLinear ); - const blueLCH = linearSRGBToOKLCH( blueLinear ); + const redLCH = workingToOKLCH( redLinear ); + const blueLCH = workingToOKLCH( blueLinear ); const L = mix( redLCH.x, blueLCH.x, t ); const C = mix( redLCH.y, blueLCH.y, t ); @@ -172,7 +173,7 @@ const dh = fract( blueLCH.z.sub( redLCH.z ).add( 0.5 ) ).sub( 0.5 ); const H = redLCH.z.add( dh.mul( t ) ); - return clamp( oklchToLinearSRGB( vec3( L, C, H ) ), 0.0, 1.0 ); + return OKLCHToWorking( vec3( L, C, H ) ); } ); @@ -219,10 +220,10 @@ hslSphere.position.set( x, - 0.5, 0 ); scene.add( hslSphere ); - // OKLCH sphere — uses Color.setOKLCH() + // OKLCH sphere — uses ColorConverter.setOKLCH() const oklchSphereMat = new THREE.MeshStandardMaterial( { roughness: 0.35 } ); - oklchSphereMat.color.setOKLCH( 0.7, 0.15, hue ); + ColorConverter.setOKLCH( oklchSphereMat.color, 0.7, 0.15, hue ); oklchSphereMaterials.push( { material: oklchSphereMat, hue: hue } ); const oklchSphere = new THREE.Mesh( sphereGeometry, oklchSphereMat ); @@ -268,7 +269,7 @@ entry.material.color.setHSL( entry.hue, 1.0, L, THREE.SRGBColorSpace ); const oklchEntry = oklchSphereMaterials[ i ]; - oklchEntry.material.color.setOKLCH( L, C, oklchEntry.hue ); + ColorConverter.setOKLCH( oklchEntry.material.color, L, C, oklchEntry.hue ); } diff --git a/src/Three.TSL.js b/src/Three.TSL.js index ddfafea09a3172..aec5d7fc9500bd 100644 --- a/src/Three.TSL.js +++ b/src/Three.TSL.js @@ -255,9 +255,11 @@ export const lightTargetPosition = TSL.lightTargetPosition; export const lightViewPosition = TSL.lightViewPosition; export const lightingContext = TSL.lightingContext; export const lights = TSL.lights; +export const lerpOKLCH = TSL.lerpOKLCH; export const linearDepth = TSL.linearDepth; export const linearSRGBToOKLab = TSL.linearSRGBToOKLab; export const linearSRGBToOKLCH = TSL.linearSRGBToOKLCH; +export const linearRGBToRGBGamut = TSL.linearRGBToRGBGamut; export const linearToneMapping = TSL.linearToneMapping; export const localId = TSL.localId; export const log = TSL.log; @@ -405,6 +407,7 @@ export const objectViewPosition = TSL.objectViewPosition; export const objectWorldMatrix = TSL.objectWorldMatrix; export const okLabToLinearSRGB = TSL.okLabToLinearSRGB; export const oklchToLinearSRGB = TSL.oklchToLinearSRGB; +export const OKLCHToWorking = TSL.OKLCHToWorking; export const OnBeforeObjectUpdate = TSL.OnBeforeObjectUpdate; export const OnBeforeMaterialUpdate = TSL.OnBeforeMaterialUpdate; export const OnObjectUpdate = TSL.OnObjectUpdate; @@ -628,6 +631,7 @@ export const workgroupArray = TSL.workgroupArray; export const workgroupBarrier = TSL.workgroupBarrier; export const workgroupId = TSL.workgroupId; export const workingToColorSpace = TSL.workingToColorSpace; +export const workingToOKLCH = TSL.workingToOKLCH; export const xor = TSL.xor; /* diff --git a/src/constants.js b/src/constants.js index 5adf5f67c95a90..d5dd28b1bbb1e9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1315,6 +1315,22 @@ export const SRGBColorSpace = 'srgb'; */ export const LinearSRGBColorSpace = 'srgb-linear'; +/** + * Display-P3 color space. + * + * @type {string} + * @constant + */ +export const DisplayP3ColorSpace = 'display-p3'; + +/** + * Display-P3-linear color space. + * + * @type {string} + * @constant + */ +export const LinearDisplayP3ColorSpace = 'display-p3-linear'; + /** * Linear transfer function. * diff --git a/src/math/Color.js b/src/math/Color.js index 63b73544b741cf..f42d664478a695 100644 --- a/src/math/Color.js +++ b/src/math/Color.js @@ -31,9 +31,6 @@ const _colorKeywords = { 'aliceblue': 0xF0F8FF, 'antiquewhite': 0xFAEBD7, 'aqua' const _hslA = { h: 0, s: 0, l: 0 }; const _hslB = { h: 0, s: 0, l: 0 }; -const _oklchA = { l: 0, c: 0, h: 0 }; -const _oklchB = { l: 0, c: 0, h: 0 }; - function hue2rgb( p, q, t ) { if ( t < 0 ) t += 1; @@ -45,58 +42,6 @@ function hue2rgb( p, q, t ) { } -function linearSRGBToOKLCH( r, g, b, target ) { - - // Linear sRGB → LMS - const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; - const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; - const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; - - // LMS → OKLab (cube root, then M2) - const l_ = Math.cbrt( l ); - const m_ = Math.cbrt( m ); - const s_ = Math.cbrt( s ); - - const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; - const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; - const bLab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; - - // OKLab → OKLCH - target.l = L; - target.c = Math.sqrt( a * a + bLab * bLab ); - - let h = Math.atan2( bLab, a ) / ( 2 * Math.PI ); - if ( h < 0 ) h += 1; - target.h = h; - - return target; - -} - -function oklchToLinearSRGB( L, C, H, target ) { - - const hRad = H * 2 * Math.PI; - const a = C * Math.cos( hRad ); - const b = C * Math.sin( hRad ); - - // OKLab → LMS (inverse M2) - 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.2914855480 * b; - - // LMS → Linear sRGB (cube, then inverse M1) - const l = l_ * l_ * l_; - const m = m_ * m_ * m_; - const s = s_ * s_ * s_; - - target.r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; - target.g = - 1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; - target.b = - 0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; - - return target; - -} - /** * A Color instance is represented by RGB components in the linear working * color space, which defaults to `LinearSRGBColorSpace`. Inputs @@ -328,31 +273,6 @@ class Color { } - /** - * Sets this color from OKLCH values. OKLCH is a perceptually uniform - * cylindrical color space based on OKLab. - * - * @param {number} l - Lightness value between `0.0` and `1.0`. - * @param {number} c - Chroma value, typically between `0.0` and `0.4`. - * @param {number} h - Hue value between `0.0` and `1.0` (normalized). - * @param {string} [colorSpace=ColorManagement.workingColorSpace] - The color space. - * @return {Color} A reference to this color. - */ - 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; - - } - /** * Sets this color from a CSS-style string. For example, `rgb(250, 0,0)`, * `rgb(100%, 0%, 0%)`, `hsl(0, 100%, 50%)`, `#ff0000`, `#f00`, or `red` ( or @@ -445,26 +365,6 @@ class Color { break; - case 'oklch': - - if ( color = /^\s*(\d*\.?\d+)(%?)\s+(\d*\.?\d+)\s+(\d*\.?\d+)\s*(?:\/\s*(\d*\.?\d+)\s*)?$/.exec( components ) ) { - - // oklch(0.7 0.15 180) oklch(70% 0.15 180) - - handleAlpha( color[ 5 ] ); - - const l = color[ 2 ] === '%' ? parseFloat( color[ 1 ] ) / 100 : parseFloat( color[ 1 ] ); - - return this.setOKLCH( - l, - parseFloat( color[ 3 ] ), - parseFloat( color[ 4 ] ) / 360 - ); - - } - - break; - default: warn( 'Color: Unknown color model ' + style ); @@ -709,24 +609,6 @@ class Color { } - /** - * Converts the colors RGB values into the OKLCH format and stores them into the - * given target object. OKLCH is a perceptually uniform cylindrical color space. - * - * @param {{l:number,c:number,h:number}} target - The target object that is used to store the method's result. - * @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 ) { - - ColorManagement.workingToColorSpace( _color.copy( this ), colorSpace ); - - linearSRGBToOKLCH( _color.r, _color.g, _color.b, target ); - - return target; - - } - /** * Returns the RGB values of this color and stores them into the given target object. * @@ -949,36 +831,6 @@ class Color { } - /** - * Linearly interpolates this color's OKLCH values toward the OKLCH values of the - * given color. Uses shortest-path hue interpolation for perceptually smooth transitions. - * The alpha argument can be thought of as the ratio between the two colors, - * where 0.0 is this color and 1.0 is the first argument. - * - * @param {Color} color - The color to converge on. - * @param {number} alpha - The interpolation factor in the closed interval `[0,1]`. - * @return {Color} A reference to this color. - */ - lerpOKLCH( color, alpha ) { - - this.getOKLCH( _oklchA ); - color.getOKLCH( _oklchB ); - - // shortest-path hue interpolation - let dh = _oklchB.h - _oklchA.h; - if ( dh > 0.5 ) dh -= 1; - if ( dh < - 0.5 ) dh += 1; - - const h = _oklchA.h + dh * alpha; - const c = lerp( _oklchA.c, _oklchB.c, alpha ); - const l = lerp( _oklchA.l, _oklchB.l, alpha ); - - this.setOKLCH( l, c, h ); - - return this; - - } - /** * Sets the color's RGB components from the given 3D vector. * diff --git a/src/math/ColorManagement.js b/src/math/ColorManagement.js index b4c1bd94f81a58..f434056eaa8cbc 100644 --- a/src/math/ColorManagement.js +++ b/src/math/ColorManagement.js @@ -1,4 +1,4 @@ -import { SRGBColorSpace, LinearSRGBColorSpace, SRGBTransfer, LinearTransfer, NoColorSpace } from '../constants.js'; +import { SRGBColorSpace, LinearSRGBColorSpace, DisplayP3ColorSpace, LinearDisplayP3ColorSpace, SRGBTransfer, LinearTransfer, NoColorSpace } from '../constants.js'; import { Matrix3 } from './Matrix3.js'; import { warnOnce } from '../utils.js'; @@ -14,6 +14,18 @@ const XYZ_TO_LINEAR_REC709 = /*@__PURE__*/ new Matrix3().set( 0.0556301, - 0.2039770, 1.0569715 ); +const LINEAR_DISPLAY_P3_TO_XYZ = /*@__PURE__*/ new Matrix3().set( + 0.4865709, 0.2656677, 0.1982173, + 0.2289746, 0.6917385, 0.0792869, + 0.0000000, 0.0451134, 1.0439444 +); + +const XYZ_TO_LINEAR_DISPLAY_P3 = /*@__PURE__*/ new Matrix3().set( + 2.4934969, - 0.9313836, - 0.4027108, + - 0.8294890, 1.7626641, 0.0236247, + 0.0358458, - 0.0761724, 0.9568845 +); + function createColorManagement() { const ColorManagement = { @@ -169,6 +181,8 @@ function createColorManagement() { const REC709_PRIMARIES = [ 0.640, 0.330, 0.300, 0.600, 0.150, 0.060 ]; const REC709_LUMINANCE_COEFFICIENTS = [ 0.2126, 0.7152, 0.0722 ]; + const P3_PRIMARIES = [ 0.680, 0.320, 0.265, 0.690, 0.150, 0.060 ]; + const P3_LUMINANCE_COEFFICIENTS = [ 0.2289, 0.6917, 0.0793 ]; const D65 = [ 0.3127, 0.3290 ]; ColorManagement.define( { @@ -194,6 +208,27 @@ function createColorManagement() { outputColorSpaceConfig: { drawingBufferColorSpace: SRGBColorSpace } }, + [ LinearDisplayP3ColorSpace ]: { + primaries: P3_PRIMARIES, + whitePoint: D65, + transfer: LinearTransfer, + toXYZ: LINEAR_DISPLAY_P3_TO_XYZ, + fromXYZ: XYZ_TO_LINEAR_DISPLAY_P3, + luminanceCoefficients: P3_LUMINANCE_COEFFICIENTS, + workingColorSpaceConfig: { unpackColorSpace: DisplayP3ColorSpace }, + outputColorSpaceConfig: { drawingBufferColorSpace: DisplayP3ColorSpace } + }, + + [ DisplayP3ColorSpace ]: { + primaries: P3_PRIMARIES, + whitePoint: D65, + transfer: SRGBTransfer, + toXYZ: LINEAR_DISPLAY_P3_TO_XYZ, + fromXYZ: XYZ_TO_LINEAR_DISPLAY_P3, + luminanceCoefficients: P3_LUMINANCE_COEFFICIENTS, + outputColorSpaceConfig: { drawingBufferColorSpace: DisplayP3ColorSpace } + }, + } ); return ColorManagement; diff --git a/src/nodes/display/OKLCHFunctions.js b/src/nodes/display/OKLCHFunctions.js index 24ec623114b253..74b5ed9220310f 100644 --- a/src/nodes/display/OKLCHFunctions.js +++ b/src/nodes/display/OKLCHFunctions.js @@ -1,5 +1,7 @@ import { Fn, vec3 } from '../tsl/TSLCore.js'; -import { atan, cbrt, cos, fract, sin, sqrt } from '../math/MathNode.js'; +import { atan, cbrt, cos, fract, max, mix, sin, sqrt } from '../math/MathNode.js'; +import { colorSpaceToWorking, workingToColorSpace } from './ColorSpaceNode.js'; +import { LinearSRGBColorSpace } from '../../constants.js'; const TWO_PI = 2 * Math.PI; @@ -127,3 +129,80 @@ export const oklchToLinearSRGB = /*@__PURE__*/ Fn( ( [ lch ] ) => { { name: 'lch', type: 'vec3' } ] } ); + +/** + * Gamut maps a linear RGB color by clipping negative values and scaling values above one. + * + * @tsl + * @function + * @param {Node} color - The linear RGB color. + * @return {Node} The gamut mapped color. + */ +export const linearRGBToRGBGamut = /*@__PURE__*/ Fn( ( [ color ] ) => { + + const clipped = max( color, 0.0 ); + const maxComponent = max( max( clipped.x, clipped.y ), max( clipped.z, 1.0 ) ); + + return clipped.div( maxComponent ); + +} ).setLayout( { + name: 'linearRGBToRGBGamut', + type: 'vec3', + inputs: [ + { name: 'color', type: 'vec3' } + ] +} ); + +/** + * Converts an OKLCH color to the current working color space. + * + * @tsl + * @function + * @param {Node} lch - The OKLCH color (L, C, H) where H is normalized 0-1. + * @return {Node} The working color. + */ +export const OKLCHToWorking = ( lch ) => colorSpaceToWorking( linearRGBToRGBGamut( oklchToLinearSRGB( lch ) ), LinearSRGBColorSpace ).rgb; + +/** + * Converts a color from the current working color space to OKLCH. + * + * @tsl + * @function + * @param {Node} color - The working color. + * @return {Node} The OKLCH color (L, C, H) where H is normalized 0-1. + */ +export const workingToOKLCH = ( color ) => linearSRGBToOKLCH( workingToColorSpace( color, LinearSRGBColorSpace ).rgb ); + +/** + * Interpolates two working colors in OKLCH color space. + * + * @tsl + * @function + * @param {Node} colorA - The first working color. + * @param {Node} colorB - The second working color. + * @param {Node} alpha - The interpolation factor. + * @return {Node} The interpolated working color. + */ +export const lerpOKLCH = /*@__PURE__*/ Fn( ( [ colorA, colorB, alpha ] ) => { + + const a = workingToOKLCH( colorA ); + const b = workingToOKLCH( colorB ); + + const hueDelta = fract( b.z.sub( a.z ).add( 0.5 ) ).sub( 0.5 ); + const lch = vec3( + mix( a.x, b.x, alpha ), + mix( a.y, b.y, alpha ), + fract( a.z.add( hueDelta.mul( alpha ) ) ) + ); + + return OKLCHToWorking( lch ); + +} ).setLayout( { + name: 'lerpOKLCH', + type: 'vec3', + inputs: [ + { name: 'colorA', type: 'vec3' }, + { name: 'colorB', type: 'vec3' }, + { name: 'alpha', type: 'float' } + ] +} );