From e8356e918ca5d6a44010cd165ac267af8e106662 Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Mon, 24 Aug 2026 13:30:17 +0200 Subject: [PATCH 1/4] NodeMaterialObserver: Track shadow map sizes. (#34361) --- .../nodes/manager/NodeMaterialObserver.js | 28 ++++++++++++++++--- src/nodes/lighting/LightsNode.js | 6 ---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/materials/nodes/manager/NodeMaterialObserver.js b/src/materials/nodes/manager/NodeMaterialObserver.js index d70ac14df0e3f7..5eb1af2737efd1 100644 --- a/src/materials/nodes/manager/NodeMaterialObserver.js +++ b/src/materials/nodes/manager/NodeMaterialObserver.js @@ -787,11 +787,15 @@ class NodeMaterialObserver { for ( let i = 0; i < lightsData.length; i ++ ) { const lightData = renderObjectData.lights[ i ]; + const currentLightData = lightsData[ i ]; - if ( lightData.map !== lightsData[ i ].map || lightData.cacheVersion !== lightsData[ i ].cacheVersion ) { + if ( lightData.map !== currentLightData.map || lightData.cacheVersion !== currentLightData.cacheVersion || + lightData.shadowMapWidth !== currentLightData.shadowMapWidth || lightData.shadowMapHeight !== currentLightData.shadowMapHeight ) { - lightData.map = lightsData[ i ].map; - lightData.cacheVersion = lightsData[ i ].cacheVersion; + lightData.map = currentLightData.map; + lightData.cacheVersion = currentLightData.cacheVersion; + lightData.shadowMapWidth = currentLightData.shadowMapWidth; + lightData.shadowMapHeight = currentLightData.shadowMapHeight; return false; @@ -857,14 +861,30 @@ class NodeMaterialObserver { for ( const light of materialLights ) { + let data = null; + if ( light.isSpotLight === true && light.map !== null ) { // only add lights that have a map - lights.push( { map: light.map.version, cacheVersion: this.getTextureData( light.map )._version } ); + data = { map: light.map.version, cacheVersion: this.getTextureData( light.map )._version }; + + } + + if ( light.castShadow === true && light.shadow !== undefined ) { + + // resizing a shadow map recreates its textures so the bindings + // of all related render objects must be updated + + if ( data === null ) data = {}; + + data.shadowMapWidth = light.shadow.mapSize.width; + data.shadowMapHeight = light.shadow.mapSize.height; } + if ( data !== null ) lights.push( data ); + } return lights; diff --git a/src/nodes/lighting/LightsNode.js b/src/nodes/lighting/LightsNode.js index 4b547801cf7045..9a1dabe607894f 100644 --- a/src/nodes/lighting/LightsNode.js +++ b/src/nodes/lighting/LightsNode.js @@ -155,12 +155,6 @@ class LightsNode extends Node { _hashData.push( light.id ); _hashData.push( light.castShadow ? 1 : 0 ); - if ( light.castShadow === true && light.shadow !== undefined ) { - - _hashData.push( light.shadow.mapSize.width, light.shadow.mapSize.height ); - - } - if ( light.isSpotLight === true ) { const hashMap = ( light.map !== null ) ? light.map.id : - 1; From 83ca65bbfce5f503554f0fafd7b8ad4a92b90544 Mon Sep 17 00:00:00 2001 From: sunag Date: Mon, 24 Aug 2026 13:21:13 -0300 Subject: [PATCH 2/4] NodeFunction: Filter initial spaces and comments (#34363) --- src/nodes/parsers/GLSLNodeFunction.js | 2 +- src/renderers/webgpu/nodes/WGSLNodeFunction.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nodes/parsers/GLSLNodeFunction.js b/src/nodes/parsers/GLSLNodeFunction.js index a851e5f83fa51c..0b7c1de30be08a 100644 --- a/src/nodes/parsers/GLSLNodeFunction.js +++ b/src/nodes/parsers/GLSLNodeFunction.js @@ -12,7 +12,7 @@ const parse = ( source ) => { const pragmaMainIndex = source.indexOf( pragmaMain ); - const mainCode = pragmaMainIndex !== - 1 ? source.slice( pragmaMainIndex + pragmaMain.length ) : source; + const mainCode = ( pragmaMainIndex !== - 1 ? source.slice( pragmaMainIndex + pragmaMain.length ) : source ).replace( /^(?:\s*\/\/[^\r\n]*|\s*\/\*[\s\S]*?\*\/|\s*)+/, '' ); const declaration = mainCode.match( declarationRegexp ); diff --git a/src/renderers/webgpu/nodes/WGSLNodeFunction.js b/src/renderers/webgpu/nodes/WGSLNodeFunction.js index 0c0b1864ee4df2..4e97f64d25b0a6 100644 --- a/src/renderers/webgpu/nodes/WGSLNodeFunction.js +++ b/src/renderers/webgpu/nodes/WGSLNodeFunction.js @@ -78,7 +78,7 @@ const wgslTypeLib = { const parse = ( source ) => { - source = source.trim(); + source = source.replace( /^(?:\s*\/\/[^\r\n]*|\s*\/\*[\s\S]*?\*\/|\s*)+/, '' ); const declaration = source.match( declarationRegexp ); From c79e1f9ebeb246984968673a6207102f14a81bab Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 24 Aug 2026 12:28:52 -0400 Subject: [PATCH 3/4] Unit tests: import unbuilt source instead of build/ output (#34362) --- test/unit/UnitTests.html | 2 +- test/unit/UnitTestsAddons.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/unit/UnitTests.html b/test/unit/UnitTests.html index 35c0de0a89cf98..3ccf812aed2a86 100644 --- a/test/unit/UnitTests.html +++ b/test/unit/UnitTests.html @@ -17,7 +17,7 @@ diff --git a/test/unit/UnitTestsAddons.html b/test/unit/UnitTestsAddons.html index 56942b00973bbd..2021fdc32dbd8e 100644 --- a/test/unit/UnitTestsAddons.html +++ b/test/unit/UnitTestsAddons.html @@ -17,9 +17,9 @@ From 22e5760357778d34c4578bfb00bf22e50c30d6b7 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 24 Aug 2026 12:30:04 -0400 Subject: [PATCH 4/4] Add TSL GPU unit test coverage for LTC area lights, point light, SH irradiance, equirect/spritesheet UV, and post-processing utils (#34358) --- src/Three.TSL.js | 3 + src/nodes/TSL.js | 1 + src/nodes/functions/BSDF/LTC.js | 2 +- test/unit/addons/tsl/TSL.Irradiance.tests.js | 63 +++ .../tsl/TSLBSDFLightingRemainder.tests.js | 412 ++++++++++++++++++ test/unit/addons/tsl/TSLColorSpace.tests.js | 25 +- test/unit/addons/tsl/TSLPacking.tests.js | 16 +- test/unit/addons/tsl/TSLUtilsMisc.tests.js | 321 ++++++++++++++ test/unit/three.addons.unit.js | 3 + 9 files changed, 831 insertions(+), 15 deletions(-) create mode 100644 test/unit/addons/tsl/TSL.Irradiance.tests.js create mode 100644 test/unit/addons/tsl/TSLBSDFLightingRemainder.tests.js create mode 100644 test/unit/addons/tsl/TSLUtilsMisc.tests.js diff --git a/src/Three.TSL.js b/src/Three.TSL.js index c9e7abee3ec722..4baa9ef1603986 100644 --- a/src/Three.TSL.js +++ b/src/Three.TSL.js @@ -19,6 +19,9 @@ export const Fn = TSL.Fn; export const HALF_PI = TSL.HALF_PI; export const INFINITY = TSL.INFINITY; export const If = TSL.If; +export const LTC_Evaluate = TSL.LTC_Evaluate; +export const LTC_Evaluate_Volume = TSL.LTC_Evaluate_Volume; +export const LTC_Uv = TSL.LTC_Uv; export const Loop = TSL.Loop; export const NodeAccess = TSL.NodeAccess; export const NodeShaderStage = TSL.NodeShaderStage; diff --git a/src/nodes/TSL.js b/src/nodes/TSL.js index 25e1cfbfdd2a6c..6f305aa348eee6 100644 --- a/src/nodes/TSL.js +++ b/src/nodes/TSL.js @@ -172,6 +172,7 @@ export { default as F_Schlick } from './functions/BSDF/F_Schlick.js'; export { default as Schlick_to_F0 } from './functions/BSDF/Schlick_to_F0.js'; export { default as V_GGX_SmithCorrelated } from './functions/BSDF/V_GGX_SmithCorrelated.js'; export { default as V_GGX_SmithCorrelated_Anisotropic } from './functions/BSDF/V_GGX_SmithCorrelated_Anisotropic.js'; +export { LTC_Evaluate, LTC_Evaluate_Volume, LTC_Uv } from './functions/BSDF/LTC.js'; export * from './lighting/LightUtils.js'; diff --git a/src/nodes/functions/BSDF/LTC.js b/src/nodes/functions/BSDF/LTC.js index 2d72f6b58e8239..6030b42b22794e 100644 --- a/src/nodes/functions/BSDF/LTC.js +++ b/src/nodes/functions/BSDF/LTC.js @@ -161,7 +161,7 @@ const LTC_Evaluate_Volume = /*@__PURE__*/ Fn( ( { P, p0, p1, p2, p3 } ) => { return result; } ).setLayout( { - name: 'LTC_Evaluate', + name: 'LTC_Evaluate_Volume', type: 'vec3', inputs: [ { name: 'P', type: 'vec3' }, diff --git a/test/unit/addons/tsl/TSL.Irradiance.tests.js b/test/unit/addons/tsl/TSL.Irradiance.tests.js new file mode 100644 index 00000000000000..26e665e0560547 --- /dev/null +++ b/test/unit/addons/tsl/TSL.Irradiance.tests.js @@ -0,0 +1,63 @@ +import { SphericalHarmonics3, Vector3 } from 'three'; +import { vec3, array, getShIrradianceAt } from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for src/nodes/functions/material/getShIrradianceAt.js. Its 9-term +// spherical harmonics evaluation is a direct port of +// SphericalHarmonics3.getIrradianceAt() (src/math/SphericalHarmonics3.js) -- +// same band constants (0.886227, 2*0.511664, 2*0.429043, 0.743125/0.247708, +// etc.) -- so the math-library method itself is used as the independent +// reference here, rather than a hand-rolled JS transliteration of the node's +// own formula. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'getShIrradianceAt()', () => { + + // A fixed, arbitrary-but-deterministic set of 9 SH coefficient triples + // (one per color channel) shared by every case below. + const shValues = [ + [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], + [ 0.5, 0.5, 0 ], [ 0.2, 0, 0.3 ], [ 0, 0.4, 0 ], + [ 0.1, 0.1, 0.1 ], [ 0, 0, 0.6 ], [ 0.3, 0, 0 ] + ]; + + const makeShArray = ( values ) => array( values.map( ( v ) => vec3( ...v ) ) ); + + gpuTest( 'getShIrradianceAt() matches SphericalHarmonics3.getIrradianceAt() for several normals', ( { assert } ) => { + + const shArray = makeShArray( shValues ); + const sh = new SphericalHarmonics3().set( shValues.map( ( v ) => new Vector3( ...v ) ) ); + + const cases = [ + [ 0, 0, 1 ], + [ 1, 0, 0 ], + [ 1 / Math.sqrt( 3 ), 1 / Math.sqrt( 3 ), 1 / Math.sqrt( 3 ) ] + ]; + + for ( const normal of cases ) { + + const expected = sh.getIrradianceAt( new Vector3( ...normal ), new Vector3() ); + + assert.closeAbs( + getShIrradianceAt( vec3( ...normal ), shArray ), + vec3( expected.x, expected.y, expected.z ), 1e-4, + `getShIrradianceAt(normal=${ JSON.stringify( normal ) }) matches SphericalHarmonics3.getIrradianceAt()` + ); + + } + + } ); + + gpuTest( 'getShIrradianceAt() is exactly zero for all-zero SH coefficients, regardless of normal', ( { assert } ) => { + + const zeroValues = new Array( 9 ).fill( [ 0, 0, 0 ] ); + const shArray = makeShArray( zeroValues ); + + assert.closeAbs( getShIrradianceAt( vec3( 0, 1, 0 ), shArray ), vec3( 0, 0, 0 ), 1e-6, 'getShIrradianceAt is 0 with all-zero SH coefficients (normal (0,1,0))' ); + assert.closeAbs( getShIrradianceAt( vec3( 0.6, 0, 0.8 ), shArray ), vec3( 0, 0, 0 ), 1e-6, 'getShIrradianceAt is 0 with all-zero SH coefficients (normal (0.6,0,0.8))' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLBSDFLightingRemainder.tests.js b/test/unit/addons/tsl/TSLBSDFLightingRemainder.tests.js new file mode 100644 index 00000000000000..3fbf1c60776a4d --- /dev/null +++ b/test/unit/addons/tsl/TSLBSDFLightingRemainder.tests.js @@ -0,0 +1,412 @@ +import { + float, vec2, vec3, mat3, + directPointLight, getDistanceAttenuation, + LTC_Evaluate, LTC_Evaluate_Volume, LTC_Uv +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for the remaining, previously-uncovered BSDF/lighting building +// blocks: src/nodes/lighting/PointLightNode.js's `directPointLight`, and the +// LTC (Linearly Transformed Cosines) area-light math in +// src/nodes/functions/BSDF/LTC.js. (getShIrradianceAt() coverage moved to +// TSL.Irradiance.tests.js.) Every expected value below is a plain-JS +// transliteration of each function's own documented formula, computed +// independently -- never by re-running the TSL expression under test. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'directPointLight()', () => { + + gpuTest( 'directPointLight() combines normalize(lightVector) with color * getDistanceAttenuation()', ( { assert } ) => { + + // directPointLight's own source (PointLightNode.js): + // lightDirection = normalize(lightVector) + // lightColor = color * getDistanceAttenuation(lightDistance, cutoffDistance, decayExponent) + // where lightDistance = length(lightVector). getDistanceAttenuation's + // own windowed formula is already independently verified in + // TSLBRDF.tests.js ("getDistanceAttenuation()" module) -- reused here + // (not re-derived) purely as the attenuation component of the + // expected lightColor. + const attenuation = ( lightDistance, cutoffDistance, decayExponent ) => { + + const falloff = 1 / Math.max( Math.pow( lightDistance, decayExponent ), 0.01 ); + + if ( cutoffDistance <= 0 ) return falloff; + + const window = Math.min( Math.max( 1 - Math.pow( lightDistance / cutoffDistance, 4 ), 0 ), 1 ); + return falloff * window * window; + + }; + + // General case: lightVector = (3,4,0) -> length 5, well inside the + // cutoff distance of 10. + const color = [ 1, 0.5, 0.25 ]; + const lightVector = [ 3, 4, 0 ]; + const lightDistance = Math.sqrt( 3 * 3 + 4 * 4 ); + const cutoffDistance = 10, decayExponent = 2; + const atten = attenuation( lightDistance, cutoffDistance, decayExponent ); + + const result = directPointLight( { + color: vec3( ...color ), + lightVector: vec3( ...lightVector ), + cutoffDistance: float( cutoffDistance ), + decayExponent: float( decayExponent ) + } ); + + assert.closeAbs( result.lightDirection, vec3( 0.6, 0.8, 0 ), 1e-5, 'directPointLight: lightDirection == normalize(lightVector)' ); + assert.closeAbs( result.lightColor, vec3( color[ 0 ] * atten, color[ 1 ] * atten, color[ 2 ] * atten ), 1e-4, 'directPointLight: lightColor == color * getDistanceAttenuation(...)' ); + + } ); + + gpuTest( 'directPointLight() with no cutoff falls back to the plain inverse-power falloff', ( { assert } ) => { + + // cutoffDistance is routed through `.toVar()` so it isn't folded into + // a compile-time constant -- see the matching note in + // TSLBRDF.tests.js's getDistanceAttenuation() coverage for why this + // matters on WGSL (an untaken `select()` branch that divides by a + // literal 0.0 still gets constant-folded and rejected at compile time). + const color = [ 1, 1, 1 ]; + const lightVector = [ 0, 0, 5 ]; + const lightDistance = 5, decayExponent = 2; + const atten = 1 / Math.max( Math.pow( lightDistance, decayExponent ), 0.01 ); + + const result = directPointLight( { + color: vec3( ...color ), + lightVector: vec3( ...lightVector ), + cutoffDistance: float( 0 ).toVar(), + decayExponent: float( decayExponent ) + } ); + + assert.closeAbs( result.lightDirection, vec3( 0, 0, 1 ), 1e-5, 'directPointLight: lightDirection == normalize(lightVector) with no cutoff' ); + assert.closeAbs( result.lightColor, vec3( atten, atten, atten ), 1e-4, 'directPointLight: lightColor == color * (1/lightDistance^decayExponent) with no cutoff' ); + + } ); + + gpuTest( 'directPointLight() beyond the cutoff distance zeroes lightColor but still normalizes lightDirection', ( { assert } ) => { + + // Beyond cutoffDistance, getDistanceAttenuation's windowing term + // clamps to exactly 0 (see TSLBRDF.tests.js's matching edge-case + // assertion), so lightColor must be exactly zero regardless of the + // falloff term -- while lightDirection is entirely independent of + // attenuation and must still be the plain normalized lightVector. + const lightVector = [ 0, 20, 0 ]; // length 20, beyond cutoffDistance 10 + const cutoffDistance = 10, decayExponent = 2; + + const result = directPointLight( { + color: vec3( 1, 1, 1 ), + lightVector: vec3( ...lightVector ), + cutoffDistance: float( cutoffDistance ), + decayExponent: float( decayExponent ) + } ); + + assert.closeAbs( result.lightDirection, vec3( 0, 1, 0 ), 1e-5, 'directPointLight: lightDirection is still normalized beyond the cutoff' ); + assert.closeAbs( result.lightColor, vec3( 0, 0, 0 ), 1e-5, 'directPointLight: lightColor is exactly 0 beyond the cutoff distance' ); + + } ); + + gpuTest( 'directPointLight() cross-check: getDistanceAttenuation() called directly matches the attenuation folded into lightColor', ( { assert } ) => { + + // Sanity cross-check against the already-covered getDistanceAttenuation() + // (TSLBRDF.tests.js) rather than only a hand-rolled JS copy of its + // formula: color=(1,1,1), so lightColor must equal + // getDistanceAttenuation(...) exactly, component-wise. + const lightVector = [ 6, 8, 0 ]; // length 10 + const cutoffDistance = 15, decayExponent = 1.5; + + const result = directPointLight( { + color: vec3( 1, 1, 1 ), + lightVector: vec3( ...lightVector ), + cutoffDistance: float( cutoffDistance ), + decayExponent: float( decayExponent ) + } ); + + const atten = getDistanceAttenuation( { + lightDistance: float( 10 ), + cutoffDistance: float( cutoffDistance ), + decayExponent: float( decayExponent ) + } ); + + assert.closeAbs( result.lightColor, vec3( atten, atten, atten ), 1e-5, 'directPointLight: lightColor with color=(1,1,1) matches getDistanceAttenuation(...) directly' ); + + } ); + + } ); + + QUnit.module( 'LTC area-light math', () => { + + gpuTest( 'LTC_Uv() matches the roughness/dotNV texture parameterization formula', ( { assert } ) => { + + // LTC_Uv's own formula (LTC.js): uv = (roughness, sqrt(1 - saturate(dot(N,V)))) + // * LUT_SCALE + LUT_BIAS, with LUT_SIZE = 64. + const LUT_SIZE = 64.0; + const LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE; + const LUT_BIAS = 0.5 / LUT_SIZE; + + const expectedUv = ( dotNV, roughness ) => { + + const clamped = Math.min( Math.max( dotNV, 0 ), 1 ); + const u = roughness * LUT_SCALE + LUT_BIAS; + const v = Math.sqrt( 1 - clamped ) * LUT_SCALE + LUT_BIAS; + return [ u, v ]; + + }; + + // General case: N=(0,0,1), V=(0,0.6,0.8) (unit length), dotNV = 0.8. + const roughness = 0.3; + const [ u1, v1 ] = expectedUv( 0.8, roughness ); + + assert.closeAbs( + LTC_Uv( { N: vec3( 0, 0, 1 ), V: vec3( 0, 0.6, 0.8 ), roughness: float( roughness ) } ), + vec2( u1, v1 ), 1e-4, 'LTC_Uv general case matches the hand-computed formula' + ); + + // Edge case: N and V pointing opposite ways -> dot(N,V) = -1, + // saturated to 0, so v == sqrt(1) * LUT_SCALE + LUT_BIAS exactly. + const [ u2, v2 ] = expectedUv( - 1, roughness ); + + assert.closeAbs( + LTC_Uv( { N: vec3( 0, 0, 1 ), V: vec3( 0, 0, - 1 ), roughness: float( roughness ) } ), + vec2( u2, v2 ), 1e-5, 'LTC_Uv saturates a negative dot(N,V) to 0 before the sqrt() term' + ); + + } ); + + // --- Shared plain-JS transliterations of LTC_EdgeVectorFormFactor and + // LTC_ClippedSphereFormFactor (LTC.js), used by both LTC_Evaluate and + // LTC_Evaluate_Volume below. These are ports of the functions' own + // documented formulas (a rational-polynomial approximation to + // theta/sin(theta)/2PI, and Heitz et al.'s horizon-clipped form-factor + // approximation), not re-runs of the TSL expressions under test. + const dot3 = ( a, b ) => a[ 0 ] * b[ 0 ] + a[ 1 ] * b[ 1 ] + a[ 2 ] * b[ 2 ]; + const cross3 = ( a, b ) => [ + a[ 1 ] * b[ 2 ] - a[ 2 ] * b[ 1 ], + a[ 2 ] * b[ 0 ] - a[ 0 ] * b[ 2 ], + a[ 0 ] * b[ 1 ] - a[ 1 ] * b[ 0 ] + ]; + const sub3 = ( a, b ) => [ a[ 0 ] - b[ 0 ], a[ 1 ] - b[ 1 ], a[ 2 ] - b[ 2 ] ]; + const length3 = ( a ) => Math.sqrt( dot3( a, a ) ); + const normalize3 = ( a ) => { + + const l = length3( a ); + return [ a[ 0 ] / l, a[ 1 ] / l, a[ 2 ] / l ]; + + }; + + const edgeVectorFormFactor = ( v1, v2 ) => { + + const x = dot3( v1, v2 ); + const y = Math.abs( x ); + + const a = ( y * 0.0145206 + 0.4965155 ) * y + 0.8543985; + const b = ( y + 4.1616724 ) * y + 3.4175940; + const v = a / b; + + const thetaSinTheta = x > 0.0 + ? v + : 0.5 / Math.sqrt( Math.max( 1 - x * x, 1e-7 ) ) - v; + + const c = cross3( v1, v2 ); + return [ c[ 0 ] * thetaSinTheta, c[ 1 ] * thetaSinTheta, c[ 2 ] * thetaSinTheta ]; + + }; + + const clippedSphereFormFactor = ( f ) => { + + const l = length3( f ); + return Math.max( ( l * l + f[ 2 ] ) / ( l + 1.0 ), 0 ); + + }; + + const vectorFormFactorSum = ( coords ) => { + + let sum = [ 0, 0, 0 ]; + + for ( let i = 0; i < 4; i ++ ) { + + const e = edgeVectorFormFactor( coords[ i ], coords[ ( i + 1 ) % 4 ] ); + sum = [ sum[ 0 ] + e[ 0 ], sum[ 1 ] + e[ 1 ], sum[ 2 ] + e[ 2 ] ]; + + } + + return sum; + + }; + + gpuTest( 'LTC_Evaluate_Volume() matches the hand-computed horizon-clipped form factor (no basis transform)', ( { assert } ) => { + + // General case: P at the origin, a CCW-wound (as seen from P) square + // light one unit above it, corners at z=1. LTC_Evaluate_Volume has + // no N/V/mInv dependence at all -- it projects (p_i - P) straight + // onto the unit sphere, so this is checkable purely from p0..p3 and P. + const P = [ 0, 0, 0 ]; + const p0 = [ - 1, - 1, 1 ], p1 = [ - 1, 1, 1 ], p2 = [ 1, 1, 1 ], p3 = [ 1, - 1, 1 ]; + + // front-side check: lightNormal = cross(p1-p0, p3-p0); must have + // dot(lightNormal, P-p0) >= 0 for the light to contribute at all. + const lightNormal = cross3( sub3( p1, p0 ), sub3( p3, p0 ) ); + if ( dot3( lightNormal, sub3( P, p0 ) ) < 0 ) throw new Error( 'test setup error: P is on the back side of the light plane' ); + + const coords = [ p0, p1, p2, p3 ].map( ( p ) => normalize3( sub3( p, P ) ) ); + const vff = vectorFormFactorSum( coords ).map( Math.abs ); // LTC_Evaluate_Volume takes abs() of the summed form factor + const expected = clippedSphereFormFactor( vff ); + + const result = LTC_Evaluate_Volume( { + P: vec3( ...P ), + p0: vec3( ...p0 ), p1: vec3( ...p1 ), p2: vec3( ...p2 ), p3: vec3( ...p3 ) + } ); + + assert.closeAbs( result, vec3( expected, expected, expected ), 1e-4, 'LTC_Evaluate_Volume matches the hand-computed form factor' ); + + } ); + + gpuTest( 'LTC_Evaluate_Volume() is exactly zero when P is behind the light plane', ( { assert } ) => { + + // Same light quad as the general case above, but P moved to the + // side where dot(lightNormal, P - p0) < 0 -- the function's own + // `If` guard means the result vector (initialized to vec3(0)) is + // never written in that case, so it must read back as exactly 0. + const P = [ 0, 0, 10 ]; + const p0 = [ - 1, - 1, 1 ], p1 = [ - 1, 1, 1 ], p2 = [ 1, 1, 1 ], p3 = [ 1, - 1, 1 ]; + + const lightNormal = cross3( sub3( p1, p0 ), sub3( p3, p0 ) ); + if ( dot3( lightNormal, sub3( P, p0 ) ) >= 0 ) throw new Error( 'test setup error: P is not actually on the back side of the light plane' ); + + const result = LTC_Evaluate_Volume( { + P: vec3( ...P ), + p0: vec3( ...p0 ), p1: vec3( ...p1 ), p2: vec3( ...p2 ), p3: vec3( ...p3 ) + } ); + + assert.closeAbs( result, vec3( 0, 0, 0 ), 1e-6, 'LTC_Evaluate_Volume is 0 when P is behind the light plane' ); + + } ); + + gpuTest( 'LTC_Evaluate() with an identity mInv matches the hand-computed form factor in the T1/T2/N orthonormal-basis frame', ( { assert } ) => { + + // With mInv == identity, LTC_Evaluate's `mat` (LTC.js) reduces to + // exactly transpose(mat3(T1, T2, N)) -- and since the 3-Node-argument + // mat3(...) constructor is COLUMN-major (see tsl-unit-test-findings.md's + // "mat3(vec3,vec3,vec3) is COLUMN-major" entry: mat3(T1,T2,N) has + // columns T1,T2,N), its transpose has ROWS T1,T2,N. So + // mat.mul(v) == (dot(T1,v), dot(T2,v), dot(N,v)) -- i.e. v expressed + // in the T1/T2/N orthonormal frame. That's independently verifiable + // from LTC_Evaluate's own T1/T2 construction: + // T1 = normalize(V - N*dot(V,N)) + // T2 = -cross(N, T1) + // which is hand-derived below for a concrete N/V pair, rather than + // assumed. + const N = [ 0, 0, 1 ]; + const V = [ 0, 1 / Math.sqrt( 2 ), 1 / Math.sqrt( 2 ) ]; // already unit length + + const dotVN = dot3( V, N ); + const T1 = normalize3( sub3( V, [ N[ 0 ] * dotVN, N[ 1 ] * dotVN, N[ 2 ] * dotVN ] ) ); + const T2raw = cross3( N, T1 ); + const T2 = [ - T2raw[ 0 ], - T2raw[ 1 ], - T2raw[ 2 ] ]; + + // Confirms the closed-form basis this concrete N/V pair produces + // (T1=(0,1,0), T2=(1,0,0), N=(0,0,1)) -- a cross-check on the + // hand-derivation above before it's used to build `expected` below. + assert.closeAbs( vec3( ...T1 ), vec3( 0, 1, 0 ), 1e-6, 'sanity check: T1 for this N/V pair is (0,1,0)' ); + assert.closeAbs( vec3( ...T2 ), vec3( 1, 0, 0 ), 1e-6, 'sanity check: T2 for this N/V pair is (1,0,0)' ); + + const toLocal = ( v ) => [ dot3( T1, v ), dot3( T2, v ), dot3( N, v ) ]; + + const P = [ 0, 0, 0 ]; + // CCW-wound (as seen from P) square light at z=2. + const p0 = [ - 1, - 1, 2 ], p1 = [ - 1, 1, 2 ], p2 = [ 1, 1, 2 ], p3 = [ 1, - 1, 2 ]; + + const lightNormal = cross3( sub3( p1, p0 ), sub3( p3, p0 ) ); + if ( dot3( lightNormal, sub3( P, p0 ) ) < 0 ) throw new Error( 'test setup error: P is on the back side of the light plane' ); + + const coords = [ p0, p1, p2, p3 ].map( ( p ) => normalize3( toLocal( sub3( p, P ) ) ) ); + const vff = vectorFormFactorSum( coords ); + const expected = clippedSphereFormFactor( vff ); + + const identity = mat3( 1, 0, 0, 0, 1, 0, 0, 0, 1 ); + + const result = LTC_Evaluate( { + N: vec3( ...N ), V: vec3( ...V ), P: vec3( ...P ), mInv: identity, + p0: vec3( ...p0 ), p1: vec3( ...p1 ), p2: vec3( ...p2 ), p3: vec3( ...p3 ) + } ); + + assert.closeAbs( result, vec3( expected, expected, expected ), 1e-4, 'LTC_Evaluate (identity mInv) matches the hand-computed T1/T2/N-frame form factor' ); + + } ); + + gpuTest( 'LTC_Evaluate() is exactly zero when P is behind the light plane', ( { assert } ) => { + + // Same N/V/mInv/light quad as the case directly above, but P moved + // to the back side -- must read back as exactly vec3(0) regardless + // of the basis-transform math, since the `If` guard skips the whole + // computation and `result` starts at vec3(0). + const N = [ 0, 0, 1 ]; + const V = [ 0, 1 / Math.sqrt( 2 ), 1 / Math.sqrt( 2 ) ]; + const P = [ 0, 0, 10 ]; + const p0 = [ - 1, - 1, 2 ], p1 = [ - 1, 1, 2 ], p2 = [ 1, 1, 2 ], p3 = [ 1, - 1, 2 ]; + + const lightNormal = cross3( sub3( p1, p0 ), sub3( p3, p0 ) ); + if ( dot3( lightNormal, sub3( P, p0 ) ) >= 0 ) throw new Error( 'test setup error: P is not actually on the back side of the light plane' ); + + const identity = mat3( 1, 0, 0, 0, 1, 0, 0, 0, 1 ); + + const result = LTC_Evaluate( { + N: vec3( ...N ), V: vec3( ...V ), P: vec3( ...P ), mInv: identity, + p0: vec3( ...p0 ), p1: vec3( ...p1 ), p2: vec3( ...p2 ), p3: vec3( ...p3 ) + } ); + + assert.closeAbs( result, vec3( 0, 0, 0 ), 1e-6, 'LTC_Evaluate is 0 when P is behind the light plane' ); + + } ); + + gpuTest( 'LTC_Evaluate() and LTC_Evaluate_Volume() both produce correct, independent results when called in the SAME shader', ( { assert } ) => { + + // Regression test for a real source bug this coverage pass found + // and fixed: LTC_Evaluate_Volume's setLayout() named its generated + // shader function 'LTC_Evaluate' (a copy/paste leftover from + // LTC_Evaluate's own layout, just above it in LTC.js) instead of + // 'LTC_Evaluate_Volume'. Each is otherwise a distinct, separately- + // cached FunctionNode (keyed by JS object identity, not by that + // name), so the collision was invisible as long as the two + // functions were never both included in one compiled shader -- true + // today (LTC_Evaluate is only reachable from PhysicalLightingModel.js, + // LTC_Evaluate_Volume only from VolumetricLightingModel.js, always + // compiled into separate shader programs) but not guaranteed by + // anything in the node graph itself, and exactly the kind of thing a + // future material combining both lighting models would hit as a + // hard WGSL "duplicate function definition" compile error (GLSL: + // same idea, second definition silently shadows/redefines the + // first). This test forces both into the very same buildFn (and + // therefore the very same compiled kernel) specifically to catch + // that: with the fix, both compile and produce their own correct, + // independent results in this one shader. + const P = [ 0, 0, 0 ]; + const p0 = [ - 1, - 1, 1 ], p1 = [ - 1, 1, 1 ], p2 = [ 1, 1, 1 ], p3 = [ 1, - 1, 1 ]; + + const coordsVolume = [ p0, p1, p2, p3 ].map( ( p ) => normalize3( sub3( p, P ) ) ); + const expectedVolume = clippedSphereFormFactor( vectorFormFactorSum( coordsVolume ).map( Math.abs ) ); + + const N = [ 0, 0, 1 ]; + const V = [ 0, 1 / Math.sqrt( 2 ), 1 / Math.sqrt( 2 ) ]; + const p0e = [ - 1, - 1, 2 ], p1e = [ - 1, 1, 2 ], p2e = [ 1, 1, 2 ], p3e = [ 1, - 1, 2 ]; + const toLocal = ( v ) => [ v[ 1 ], v[ 0 ], v[ 2 ] ]; // (dot(T1,v), dot(T2,v), dot(N,v)) for this N/V pair -- see the identity-mInv test above + const coordsEvaluate = [ p0e, p1e, p2e, p3e ].map( ( p ) => normalize3( toLocal( sub3( p, P ) ) ) ); + const expectedEvaluate = clippedSphereFormFactor( vectorFormFactorSum( coordsEvaluate ) ); + + const identity = mat3( 1, 0, 0, 0, 1, 0, 0, 0, 1 ); + + const volumeResult = LTC_Evaluate_Volume( { + P: vec3( ...P ), + p0: vec3( ...p0 ), p1: vec3( ...p1 ), p2: vec3( ...p2 ), p3: vec3( ...p3 ) + } ); + + const evaluateResult = LTC_Evaluate( { + N: vec3( ...N ), V: vec3( ...V ), P: vec3( ...P ), mInv: identity, + p0: vec3( ...p0e ), p1: vec3( ...p1e ), p2: vec3( ...p2e ), p3: vec3( ...p3e ) + } ); + + assert.closeAbs( volumeResult, vec3( expectedVolume, expectedVolume, expectedVolume ), 1e-4, 'LTC_Evaluate_Volume() still matches its own formula when compiled alongside LTC_Evaluate() in the same shader' ); + assert.closeAbs( evaluateResult, vec3( expectedEvaluate, expectedEvaluate, expectedEvaluate ), 1e-4, 'LTC_Evaluate() still matches its own formula when compiled alongside LTC_Evaluate_Volume() in the same shader' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLColorSpace.tests.js b/test/unit/addons/tsl/TSLColorSpace.tests.js index 4db054885e3f69..e2a6b643b6d7a0 100644 --- a/test/unit/addons/tsl/TSLColorSpace.tests.js +++ b/test/unit/addons/tsl/TSLColorSpace.tests.js @@ -2,18 +2,16 @@ import { vec3, sRGBTransferEOTF, sRGBTransferOETF } from 'three/tsl'; +import { SRGBToLinear, LinearToSRGB } from '../../../../src/math/ColorManagement.js'; import { gpuTest } from './gpu-test-utils.js'; // sRGB <-> linear-sRGB transfer function coverage. Every expected value below -// is the plain IEC 61966-2-1 piecewise formula, hand-evaluated independently -// in this file's comments (not derived by re-running the TSL expressions -// under test) -- see TSLMath.tests.js's file header for why that matters -// (https://ben3d.ca/blog/the-rise-of-test-theater). -// -// EOTF (decode: sRGB -> linear): -// x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ^ 2.4 -// OETF (encode: linear -> sRGB): -// x <= 0.0031308 ? x * 12.92 : 1.055 * x^(1/2.4) - 0.055 +// comes from SRGBToLinear()/LinearToSRGB() (src/math/ColorManagement.js) -- +// the same IEC 61966-2-1 piecewise formula that sRGBTransferEOTF()/ +// sRGBTransferOETF() implement in TSL, but an independent, pre-existing +// implementation, not derived by re-running the TSL expressions under test +// (see TSLMath.tests.js's file header for why that matters: +// https://ben3d.ca/blog/the-rise-of-test-theater). export default QUnit.module( 'TSL', () => { QUnit.module( 'color space functions', () => { @@ -29,11 +27,11 @@ export default QUnit.module( 'TSL', () => { // vec3, so scalar inputs must be broadcast to vec3 explicitly -- // a bare float() input still comes back typed vec3, which a bare // float() expected value can't be compared against. - assert.closeAbs( sRGBTransferEOTF( vec3( 0.02 ) ), vec3( 0.02 / 12.92 ), 1e-6, 'EOTF(0.02) uses the linear low-end segment' ); + assert.closeAbs( sRGBTransferEOTF( vec3( 0.02 ) ), vec3( SRGBToLinear( 0.02 ) ), 1e-6, 'EOTF(0.02) uses the linear low-end segment' ); // Above the threshold: ((x + 0.055) / 1.055) ^ 2.4 -- checked at a // well-known reference point (sRGB mid-gray 0.5 -> ~0.2140 linear). - assert.closeAbs( sRGBTransferEOTF( vec3( 0.5 ) ), vec3( Math.pow( ( 0.5 + 0.055 ) / 1.055, 2.4 ) ), 1e-4, 'EOTF(0.5) uses the power-curve segment' ); + assert.closeAbs( sRGBTransferEOTF( vec3( 0.5 ) ), vec3( SRGBToLinear( 0.5 ) ), 1e-4, 'EOTF(0.5) uses the power-curve segment' ); assert.closeAbs( sRGBTransferEOTF( vec3( 0.5 ) ), vec3( 0.21404114 ), 1e-4, 'EOTF(0.5) matches the well-known sRGB mid-gray linear value' ); } ); @@ -45,11 +43,12 @@ export default QUnit.module( 'TSL', () => { assert.closeAbs( sRGBTransferOETF( vec3( 1, 1, 1 ) ), vec3( 1, 1, 1 ), 1e-4, 'OETF(1) == 1' ); // Below the linear-segment threshold (0.0031308): x * 12.92. - assert.closeAbs( sRGBTransferOETF( vec3( 0.001 ) ), vec3( 0.001 * 12.92 ), 1e-6, 'OETF(0.001) uses the linear low-end segment' ); + assert.closeAbs( sRGBTransferOETF( vec3( 0.001 ) ), vec3( LinearToSRGB( 0.001 ) ), 1e-6, 'OETF(0.001) uses the linear low-end segment' ); // Above the threshold: 1.055 * x^(1/2.4) - 0.055 -- checked against // the same linear mid-gray value used above, in reverse. - assert.closeAbs( sRGBTransferOETF( vec3( 0.21404114 ) ), vec3( 0.5 ), 1e-3, 'OETF(0.21404114) round-trips back to sRGB mid-gray 0.5' ); + assert.closeAbs( sRGBTransferOETF( vec3( 0.21404114 ) ), vec3( LinearToSRGB( 0.21404114 ) ), 1e-3, 'OETF(0.21404114) round-trips back to sRGB mid-gray 0.5' ); + assert.closeAbs( sRGBTransferOETF( vec3( 0.21404114 ) ), vec3( 0.5 ), 1e-3, 'OETF(0.21404114) matches the well-known sRGB mid-gray value' ); assert.closeAbs( sRGBTransferOETF( vec3( 1 ) ), vec3( 1 ), 1e-4, 'OETF(1) == 1' ); diff --git a/test/unit/addons/tsl/TSLPacking.tests.js b/test/unit/addons/tsl/TSLPacking.tests.js index 3a29aa3077fd57..7d5f55c535bf35 100644 --- a/test/unit/addons/tsl/TSLPacking.tests.js +++ b/test/unit/addons/tsl/TSLPacking.tests.js @@ -1,5 +1,5 @@ import { - float, vec2, vec3, vec4, hash, abs, + float, uint, vec2, vec3, vec4, hash, abs, packSnorm2x16, unpackSnorm2x16, packUnorm2x16, unpackUnorm2x16, packHalf2x16, unpackHalf2x16, @@ -8,6 +8,7 @@ import { packNormalToRGB, unpackRGBToNormal, unpackNormal, length } from 'three/tsl'; +import { toHalfFloat } from '../../../../src/extras/DataUtils.js'; import { gpuTest, gpuFuzzTest } from './gpu-test-utils.js'; // Packing/unpacking coverage: every round-trip test checks a value that was @@ -51,6 +52,19 @@ export default QUnit.module( 'TSL', () => { const v = vec2( 123.5, -0.0009765625 ); // second value is an exact float16 value (2^-10) assert.closeAbs( unpackHalf2x16( packHalf2x16( v ) ), v, 1e-3, 'exact float16-representable values round-trip exactly (within tolerance)' ); + // packHalf2x16's bits themselves, checked against an independent + // FP32->FP16 conversion (DataUtils.toHalfFloat, src/extras/DataUtils.js) + // rather than only the round trip above -- a pack() and unpack() that + // were both wrong in matching ways couldn't be caught by round-tripping + // alone. GLSL/WGSL both pack the first component into the 16 + // least-significant bits and the second into the 16 most-significant + // bits (packHalf2x16 / pack2x16float). + const lo = toHalfFloat( 123.5 ) & 0xffff; + const hi = toHalfFloat( -0.0009765625 ) & 0xffff; + const expectedBits = ( lo | ( hi << 16 ) ) >>> 0; + + assert.eq( packHalf2x16( v ), uint( expectedBits ), 'packHalf2x16(v) matches the bit pattern from DataUtils.toHalfFloat()' ); + } ); gpuTest( 'packSnorm4x8 <-> unpackSnorm4x8 round trip', ( { assert } ) => { diff --git a/test/unit/addons/tsl/TSLUtilsMisc.tests.js b/test/unit/addons/tsl/TSLUtilsMisc.tests.js new file mode 100644 index 00000000000000..0cddd1737fe1e2 --- /dev/null +++ b/test/unit/addons/tsl/TSLUtilsMisc.tests.js @@ -0,0 +1,321 @@ +import { + float, int, vec2, vec3, vec4, mat4, length, + equirectUV, equirectDirection, + spritesheetUV, + interleavedGradientNoise, vogelDiskSample, + getScreenPosition, getScreenPositionFromClip +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for the utility "grab-bag" functions in src/nodes/utils/EquirectUV.js, +// src/nodes/utils/SpriteSheetUV.js and src/nodes/utils/PostProcessingUtils.js. +// Every expected value below is a plain-JS transliteration of each function's +// own documented formula, computed independently -- never by re-running the +// TSL expression under test (round-trip checks are the one deliberate +// exception: they layer the *actual* TSL functions on top of each other to +// check the documented inverse-of-each-other property, which is itself the +// thing under test, same pattern as TSLDepthConversion.tests.js's round trips). +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'equirectUV() / equirectDirection()', () => { + + gpuTest( 'equirectUV() matches its own documented spherical-coordinate formula', ( { assert } ) => { + + // EquirectUV.js: u = atan2(dir.z, dir.x) / (2*PI) + 0.5 + // v = asin(clamp(dir.y,-1,1)) / PI + 0.5 + const equirectUVJS = ( x, y, z ) => { + + const u = Math.atan2( z, x ) / ( Math.PI * 2 ) + 0.5; + const v = Math.asin( Math.min( Math.max( y, - 1 ), 1 ) ) / Math.PI + 0.5; + return [ u, v ]; + + }; + + // direction = (1,0,0): atan2(0,1) == 0, asin(0) == 0 -- both terms land exactly on the 0.5 midpoint. + const [ u1, v1 ] = equirectUVJS( 1, 0, 0 ); + assert.closeAbs( equirectUV( vec3( 1, 0, 0 ) ), vec2( u1, v1 ), 1e-5, 'equirectUV(1,0,0) matches atan2/asin formula' ); + + // direction = (0,0,1): atan2(1,0) == PI/2, a quarter turn away from the first case. + const [ u2, v2 ] = equirectUVJS( 0, 0, 1 ); + assert.closeAbs( equirectUV( vec3( 0, 0, 1 ) ), vec2( u2, v2 ), 1e-5, 'equirectUV(0,0,1) matches atan2/asin formula' ); + + // General-case unit direction, off every axis. + const a = 1 / Math.sqrt( 3 ); + const [ u3, v3 ] = equirectUVJS( a, a, a ); + assert.closeAbs( equirectUV( vec3( a, a, a ) ), vec2( u3, v3 ), 1e-5, 'equirectUV(normalize(1,1,1)) matches atan2/asin formula' ); + + } ); + + gpuTest( 'equirectDirection() matches its own documented spherical-coordinate formula', ( { assert } ) => { + + // EquirectUV.js: theta = (u-0.5)*2*PI; phi = (v-0.5)*PI + // dir = (cos(phi)*cos(theta), sin(phi), cos(phi)*sin(theta)) + const equirectDirectionJS = ( u, v ) => { + + const theta = ( u - 0.5 ) * Math.PI * 2; + const phi = ( v - 0.5 ) * Math.PI; + const cosPhi = Math.cos( phi ); + return [ cosPhi * Math.cos( theta ), Math.sin( phi ), cosPhi * Math.sin( theta ) ]; + + }; + + const [ x1, y1, z1 ] = equirectDirectionJS( 0.3, 0.4 ); + assert.closeAbs( equirectDirection( vec2( 0.3, 0.4 ) ), vec3( x1, y1, z1 ), 1e-5, 'equirectDirection(0.3,0.4) matches the theta/phi formula' ); + + const [ x2, y2, z2 ] = equirectDirectionJS( 0.75, 0.5 ); + assert.closeAbs( equirectDirection( vec2( 0.75, 0.5 ) ), vec3( x2, y2, z2 ), 1e-5, 'equirectDirection(0.75,0.5) matches the theta/phi formula' ); + + } ); + + gpuTest( 'equirectUV() and equirectDirection() round-trip each other', ( { assert } ) => { + + // direction -> uv -> direction' must recover the original unit + // direction, since the two are documented as inverses of each other. + const a = 1 / Math.sqrt( 3 ); + const dir = vec3( a, a, a ); + assert.closeAbs( equirectDirection( equirectUV( dir ) ), dir, 1e-4, 'equirectDirection(equirectUV(dir)) recovers dir' ); + + // uv -> direction -> uv' must recover the original uv, away from the + // pole singularity (see the dedicated pole test below for why the + // pole itself is excluded from this round trip). + const uv = vec2( 0.3, 0.4 ); + assert.closeAbs( equirectUV( equirectDirection( uv ) ), uv, 1e-4, 'equirectUV(equirectDirection(uv)) recovers uv' ); + + } ); + + gpuTest( 'equirectUV()/equirectDirection() at the poles: known U-coordinate singularity', ( { assert } ) => { + + // Straight up (0,1,0): v = asin(1)/PI + 0.5 == 1 exactly, regardless + // of x/z. u = atan2(0,0)/(2*PI) + 0.5 -- atan2(0,0) is only + // well-defined by convention (JS's Math.atan2(0,0) == 0), and GLSL/ + // WGSL don't guarantee the same convention as JS, so u's exact value + // at this pole is not hard-asserted here (see the findings file). + // v, however, is asserted exactly. + // + // The pole direction is routed through `.toVar()` so it isn't folded + // into a compile-time constant -- `atan2(0,0)` on an exact + // compile-time-constant `(0,0)` risks the same class of WGSL + // constant-folding rejection as the `sinc()`/`getDistanceAttenuation()` + // findings already on record (indeterminate compile-time constant + // expressions), even though atan2's actual runtime behavior at + // (0,0) is well-defined per backend. + const pole = vec3( 0, 1, 0 ).toVar(); + const southPole = vec3( 0, - 1, 0 ).toVar(); + + assert.closeAbs( equirectUV( pole ).y, float( 1 ), 1e-5, 'equirectUV(0,1,0).y == 1 exactly at the north pole' ); + assert.closeAbs( equirectUV( southPole ).y, float( 0 ), 1e-5, 'equirectUV(0,-1,0).y == 0 exactly at the south pole' ); + + // The round trip through the pole is still well-defined *as a + // direction*, independent of whatever u value equirectUV(0,1,0) + // produces: at v == 1, phi == PI/2, so cos(phi) == 0, which zeroes + // out equirectDirection's x/z terms regardless of theta (i.e. + // regardless of u). This is a robust, backend-independent invariant, + // unlike u's own value at the pole. + assert.closeAbs( equirectDirection( equirectUV( pole ) ), vec3( 0, 1, 0 ), 1e-4, 'equirectDirection(equirectUV(0,1,0)) recovers the pole direction regardless of the U singularity' ); + + } ); + + } ); + + QUnit.module( 'spritesheetUV()', () => { + + gpuTest( 'spritesheetUV() maps a frame index to its sub-rectangle, with explicit inputs', ( { assert } ) => { + + // SpriteSheetUV.js, with explicit count/uv/frame (no uv()/time defaults involved): + // frameNum = mod(frame, width*height), floored + // column = mod(frameNum, width) + // row = height - ceil((frameNum+1)/width) + // result = (uv + (column,row)) * (1/width, 1/height) + const spritesheetUVJS = ( width, height, u, v, frame ) => { + + const frameNum = Math.floor( ( ( frame % ( width * height ) ) + ( width * height ) ) % ( width * height ) ); + const column = frameNum % width; + const row = height - Math.ceil( ( frameNum + 1 ) / width ); + return [ ( u + column ) / width, ( v + row ) / height ]; + + }; + + // Frame 0 of a 6x6 sheet, base uv at the cell's own origin (0,0). + const [ x0, y0 ] = spritesheetUVJS( 6, 6, 0, 0, 0 ); + assert.closeAbs( + spritesheetUV( vec2( 6, 6 ), vec2( 0, 0 ), float( 0 ) ), vec2( x0, y0 ), 1e-5, + 'spritesheetUV frame 0 of 6x6 matches the hand-derived column/row formula' + ); + + // Frame 5: the LAST frame of the first row (width=6, so column wraps + // back to 0 at frame 6) -- the off-by-one boundary this test is + // specifically checking. + const [ x5, y5 ] = spritesheetUVJS( 6, 6, 0, 0, 5 ); + assert.closeAbs( + spritesheetUV( vec2( 6, 6 ), vec2( 0, 0 ), float( 5 ) ), vec2( x5, y5 ), 1e-5, + 'spritesheetUV frame 5 (last of row 0) matches the hand-derived column/row formula' + ); + + // Frame 6: the FIRST frame of the second row -- column must wrap + // back to 0 and row must step down by exactly one, immediately + // after frame 5 above. + const [ x6, y6 ] = spritesheetUVJS( 6, 6, 0, 0, 6 ); + assert.closeAbs( + spritesheetUV( vec2( 6, 6 ), vec2( 0, 0 ), float( 6 ) ), vec2( x6, y6 ), 1e-5, + 'spritesheetUV frame 6 (first of row 1) matches the hand-derived column/row formula' + ); + + // A non-zero base uv (sampling somewhere inside the cell, not just + // its origin) on a 2x2 sheet, to check the (uv + offset) * scale + // composition itself, not just the offset. + const [ xOff, yOff ] = spritesheetUVJS( 2, 2, 0.5, 0.5, 0 ); + assert.closeAbs( + spritesheetUV( vec2( 2, 2 ), vec2( 0.5, 0.5 ), float( 0 ) ), vec2( xOff, yOff ), 1e-5, + 'spritesheetUV composes a non-zero base uv with the frame offset correctly' + ); + + // Frame 36 on the same 6x6 (36-frame) sheet must wrap back to frame + // 0's result, exercising the mod(frame, width*height) wraparound. + const [ xWrap, yWrap ] = spritesheetUVJS( 6, 6, 0, 0, 36 ); + assert.closeAbs( + spritesheetUV( vec2( 6, 6 ), vec2( 0, 0 ), float( 36 ) ), vec2( xWrap, yWrap ), 1e-5, + 'spritesheetUV frame 36 wraps around to frame 0 on a 36-frame sheet' + ); + + } ); + + } ); + + QUnit.module( 'interleavedGradientNoise()', () => { + + gpuTest( 'interleavedGradientNoise() matches the Jimenez 2014 formula', ( { assert } ) => { + + // PostProcessingUtils.js: fract(52.9829189 * fract(dot(pos, (0.06711056, 0.00583715)))) + const ignJS = ( x, y ) => { + + const dot = x * 0.06711056 + y * 0.00583715; + const inner = dot - Math.floor( dot ); + const scaled = 52.9829189 * inner; + return scaled - Math.floor( scaled ); + + }; + + assert.closeAbs( interleavedGradientNoise( vec2( 0, 0 ) ), float( ignJS( 0, 0 ) ), 1e-4, 'interleavedGradientNoise(0,0) matches the Jimenez formula' ); + assert.closeAbs( interleavedGradientNoise( vec2( 1, 1 ) ), float( ignJS( 1, 1 ) ), 1e-4, 'interleavedGradientNoise(1,1) matches the Jimenez formula' ); + assert.closeAbs( interleavedGradientNoise( vec2( 12.9, 47.3 ) ), float( ignJS( 12.9, 47.3 ) ), 1e-3, 'interleavedGradientNoise(12.9,47.3) matches the Jimenez formula' ); + + // Determinism: the same input, evaluated twice, must produce + // exactly the same output (this is a pure, non-stochastic hash-like + // formula with no hidden per-invocation state). + assert.closeAbs( interleavedGradientNoise( vec2( 3.7, 8.2 ) ), interleavedGradientNoise( vec2( 3.7, 8.2 ) ), 1e-6, 'interleavedGradientNoise is deterministic for the same input' ); + + } ); + + } ); + + QUnit.module( 'vogelDiskSample()', () => { + + gpuTest( 'vogelDiskSample() matches the golden-angle disk-sampling formula', ( { assert } ) => { + + // PostProcessingUtils.js: + // goldenAngle = 2.399963229728653 + // r = sqrt((sampleIndex+0.5) / samplesCount) + // theta = sampleIndex*goldenAngle + phi + // sample = (cos(theta), sin(theta)) * r + const goldenAngle = 2.399963229728653; + const vogelJS = ( index, count, phi ) => { + + const r = Math.sqrt( ( index + 0.5 ) / count ); + const theta = index * goldenAngle + phi; + return [ Math.cos( theta ) * r, Math.sin( theta ) * r ]; + + }; + + // First sample (index 0) at phi == 0: theta == 0, so the sample + // lands exactly on the +X axis. + const [ x0, y0 ] = vogelJS( 0, 8, 0 ); + assert.closeAbs( vogelDiskSample( int( 0 ), int( 8 ), float( 0 ) ), vec2( x0, y0 ), 1e-4, 'vogelDiskSample(0,8,0) matches the hand-derived formula' ); + + // A general-case (index, count, phi) combination. + const [ x3, y3 ] = vogelJS( 3, 8, 0.5 ); + assert.closeAbs( vogelDiskSample( int( 3 ), int( 8 ), float( 0.5 ) ), vec2( x3, y3 ), 1e-4, 'vogelDiskSample(3,8,0.5) matches the hand-derived formula' ); + + // The last sample of a differently-sized set, with a larger phi + // rotation. + const [ x11, y11 ] = vogelJS( 11, 12, 2.1 ); + assert.closeAbs( vogelDiskSample( int( 11 ), int( 12 ), float( 2.1 ) ), vec2( x11, y11 ), 1e-4, 'vogelDiskSample(11,12,2.1) matches the hand-derived formula' ); + + // Sanity property: r = sqrt((index+0.5)/count) < 1 for every valid + // index in [0, count), so every sample must lie strictly within the + // unit disk -- checked directly via length(), independent of the + // exact x/y formula above. + for ( const index of [ 0, 4, 7 ] ) { + + assert.lessThanOrEqual( + length( vogelDiskSample( int( index ), int( 8 ), float( 1.3 ) ) ), float( 1 ), + `vogelDiskSample(${ index },8,1.3) lies within the unit disk` + ); + + } + + } ); + + } ); + + QUnit.module( 'getScreenPosition() / getScreenPositionFromClip()', () => { + + gpuTest( 'getScreenPosition() matches the clip-space-projection formula, with an explicit identity projection matrix', ( { assert } ) => { + + // PostProcessingUtils.js: + // clip = projectionMatrix * (viewPosition, 1) + // uv = (clip.xy/clip.w) * 0.5 + 0.5 + // result = (uv.x, 1 - uv.y) + // An explicit identity projection matrix is used so the projection + // step itself contributes nothing beyond a pass-through -- this is a + // safe stand-in for "some real projection matrix" precisely because + // identity is its own row-major/column-major transpose, sidestepping + // the mat4(...) 16-scalar-argument row-major convention documented + // in tsl-unit-test-findings.md entirely. + const identity = mat4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); + + const getScreenPositionJS = ( x, y, w = 1 ) => [ ( x / w ) * 0.5 + 0.5, 1 - ( ( y / w ) * 0.5 + 0.5 ) ]; + + // A view-space position that projects to dead center of the screen + // -- the natural edge case for a UV-space projection. + const [ cx, cy ] = getScreenPositionJS( 0, 0 ); + assert.closeAbs( getScreenPosition( vec3( 0, 0, - 5 ), identity ), vec2( cx, cy ), 1e-5, 'getScreenPosition at the view-space origin lands on screen center (0.5,0.5)' ); + + // General off-center case. + const [ gx, gy ] = getScreenPositionJS( 0.4, - 0.6 ); + assert.closeAbs( getScreenPosition( vec3( 0.4, - 0.6, - 5 ), identity ), vec2( gx, gy ), 1e-5, 'getScreenPosition matches the hand-derived clip/uv formula' ); + + } ); + + gpuTest( 'getScreenPositionFromClip() matches the clip-space-projection formula', ( { assert } ) => { + + // PostProcessingUtils.js: + // uv = (clip.xy/clip.w) * 0.5 + 0.5 + // result = (uv.x, 1 - uv.y) + const getScreenPositionFromClipJS = ( x, y, w ) => [ ( x / w ) * 0.5 + 0.5, 1 - ( ( y / w ) * 0.5 + 0.5 ) ]; + + // A clip position at the origin (with an arbitrary nonzero w) lands + // on screen center -- same natural edge case as above, this time + // exercised directly through clip space rather than a projection + // matrix. + const [ cx, cy ] = getScreenPositionFromClipJS( 0, 0, 3 ); + assert.closeAbs( getScreenPositionFromClip( vec4( 0, 0, 1, 3 ) ), vec2( cx, cy ), 1e-5, 'getScreenPositionFromClip at clip-space origin lands on screen center (0.5,0.5)' ); + + // General case with a non-1 w (the divide is exercised for real, + // not just as a no-op). + const [ gx, gy ] = getScreenPositionFromClipJS( 1, 2, 4 ); + assert.closeAbs( getScreenPositionFromClip( vec4( 1, 2, 3, 4 ) ), vec2( gx, gy ), 1e-5, 'getScreenPositionFromClip matches the hand-derived clip/uv formula' ); + + // NOTE: getScreenPositionFromClip() (and getScreenPosition(), via + // the same division) has no guard against w == 0 -- clip.xy/clip.w + // divides by zero unconditionally, producing +/-Infinity or NaN + // depending on the sign of clip.xy. Not hard-asserted here: same + // reasoning as the posterize(x,0) and getDistanceAttenuation(0) + // findings already on record -- there's no "more correct" behavior + // to regression-test against, and the exact NaN/Inf bit pattern + // isn't something worth locking down. See the findings file. + + } ); + + } ); + +} ); diff --git a/test/unit/three.addons.unit.js b/test/unit/three.addons.unit.js index a45537458c935a..03df4a20ed2db9 100644 --- a/test/unit/three.addons.unit.js +++ b/test/unit/three.addons.unit.js @@ -39,3 +39,6 @@ import './addons/tsl/TSLBlendModes.tests.js'; import './addons/tsl/TSLColorAdjustmentExtra.tests.js'; import './addons/tsl/TSLToneMapping.tests.js'; import './addons/tsl/TSLProceduralUtils.tests.js'; +import './addons/tsl/TSLBSDFLightingRemainder.tests.js'; +import './addons/tsl/TSL.Irradiance.tests.js'; +import './addons/tsl/TSLUtilsMisc.tests.js';