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..a89ce58fd3d015 100644 --- a/examples/jsm/math/ColorConverter.js +++ b/examples/jsm/math/ColorConverter.js @@ -1,6 +1,70 @@ -import { MathUtils } from 'three'; +import { Color, ColorManagement, LinearSRGBColorSpace, MathUtils } from 'three'; const _hsl = {}; +const _color = /*@__PURE__*/ new Color(); + +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 +117,51 @@ class ColorConverter { } + /** + * Sets the given OKLCH color definition to the given color object. + * + * The result is written into the configured working color space and + * gamut-mapped to that space's primaries, so a color that lies outside + * the sRGB gamut but inside a wider working gamut (e.g. linear + * Display-P3) is preserved instead of being clipped to sRGB. + * + * @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 ); + + ColorManagement.colorSpaceToWorking( color, LinearSRGBColorSpace ); + + 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 ) { + + _color.copy( color ); + + ColorManagement.workingToColorSpace( _color, LinearSRGBColorSpace ); + + 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..1fda6e2c91ea37 --- /dev/null +++ b/examples/webgpu_tsl_oklch.html @@ -0,0 +1,293 @@ + + + + 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/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..1ae3ed2b9b6575 --- /dev/null +++ b/src/nodes/display/OKLCHFunctions.js @@ -0,0 +1,217 @@ +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 into [0,1] by clipping negative values and + * scaling so the largest component is at most one. The mapping is gamut + * agnostic; it operates on whatever set of primaries the input was expressed + * in, so it can be applied in any working color space. + * + * @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. + * + * The OKLab→linear sRGB conversion uses Björn Ottosson's matrices, then the + * result is transformed into the working color space. Gamut mapping is + * deferred until after the working-space conversion so that wider working + * spaces (e.g. linear Display-P3) keep colors that would have been outside + * the sRGB gamut. + * + * @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 ) => linearRGBToRGBGamut( colorSpaceToWorking( 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' } + ] +} );