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 + + + + + + +
+ + +
+ 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..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' } + ] +} );