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/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/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/screenshots/webgpu_tsl_oklch.jpg b/examples/screenshots/webgpu_tsl_oklch.jpg
new file mode 100644
index 00000000000000..31742d379b3e45
Binary files /dev/null and b/examples/screenshots/webgpu_tsl_oklch.jpg differ
diff --git a/examples/webgpu_tsl_oklch.html b/examples/webgpu_tsl_oklch.html
new file mode 100644
index 00000000000000..04e5bed1ed9f6e
--- /dev/null
+++ b/examples/webgpu_tsl_oklch.html
@@ -0,0 +1,292 @@
+
+
+
+ three.js webgpu - OKLCH 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..aec5d7fc9500bd 100644
--- a/src/Three.TSL.js
+++ b/src/Three.TSL.js
@@ -255,7 +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;
@@ -401,6 +405,9 @@ 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 OKLCHToWorking = TSL.OKLCHToWorking;
export const OnBeforeObjectUpdate = TSL.OnBeforeObjectUpdate;
export const OnBeforeMaterialUpdate = TSL.OnBeforeMaterialUpdate;
export const OnObjectUpdate = TSL.OnObjectUpdate;
@@ -624,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/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/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..74b5ed9220310f
--- /dev/null
+++ b/src/nodes/display/OKLCHFunctions.js
@@ -0,0 +1,208 @@
+import { Fn, vec3 } from '../tsl/TSLCore.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;
+
+/**
+ * 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' }
+ ]
+} );
+
+/**
+ * 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' }
+ ]
+} );