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/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/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/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;
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 );
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 @@
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';