diff --git a/src/nodes/math/BitcountNode.js b/src/nodes/math/BitcountNode.js index a6189b37b17792..d01b94adb7e1a8 100644 --- a/src/nodes/math/BitcountNode.js +++ b/src/nodes/math/BitcountNode.js @@ -130,6 +130,12 @@ class BitcountNode extends MathNode { const fnDef = Fn( ( [ value ] ) => { + If( value.equal( uint( 0 ) ), () => { + + return uint( 32 ); + + } ); + const v = uint( 0.0 ); this._resolveElementType( value, v, elementType ); diff --git a/test/unit/addons/tsl/TSLBRDF.tests.js b/test/unit/addons/tsl/TSLBRDF.tests.js new file mode 100644 index 00000000000000..aa46facdaf0b93 --- /dev/null +++ b/test/unit/addons/tsl/TSLBRDF.tests.js @@ -0,0 +1,284 @@ +import { + float, vec3, + D_GGX, D_GGX_Anisotropic, F_Schlick, Schlick_to_F0, + V_GGX_SmithCorrelated, V_GGX_SmithCorrelated_Anisotropic, + BRDF_Lambert, BRDF_GGX, getDistanceAttenuation +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for the physically-based BRDF building blocks in +// src/nodes/functions/BSDF/*.js and src/nodes/lighting/LightUtils.js. +// Every expected value below is a plain-JS transliteration of each +// function's own documented formula (their source comments cite the +// reference papers directly -- Karis/Epic's GGX notes, Filament's BRDF +// implementation, the Frostbite PBR course notes), never by re-running the +// TSL expression under test. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'BSDF building blocks', () => { + + gpuTest( 'D_GGX() matches the normal distribution function formula', ( { assert } ) => { + + // D_GGX(alpha, dotNH) == alpha^2 / (PI * (dotNH^2*(alpha^2-1)+1)^2) + // -- Disney/UE4 GGX normal distribution, source comment cites + // "Microfacet Models for Refraction through Rough Surfaces", eq. 33. + const ggxD = ( alpha, dotNH ) => { + + const a2 = alpha * alpha; + const denom = 1 - dotNH * dotNH * ( 1 - a2 ); + return a2 / ( denom * denom ) / Math.PI; + + }; + + assert.closeAbs( D_GGX( { alpha: float( 0.5 ), dotNH: float( 0.9 ) } ), float( ggxD( 0.5, 0.9 ) ), 1e-4, 'D_GGX(0.5, 0.9) matches the hand-computed formula' ); + assert.closeAbs( D_GGX( { alpha: float( 0.2 ), dotNH: float( 0.3 ) } ), float( ggxD( 0.2, 0.3 ) ), 1e-4, 'D_GGX(0.2, 0.3) matches the hand-computed formula' ); + + // At normal incidence (dotNH == 1) the denominator collapses to + // exactly alpha^2, so D_GGX(alpha, 1) == 1 / (PI * alpha^2) -- a + // useful closed-form special case independent of the general + // formula above. + assert.closeAbs( D_GGX( { alpha: float( 0.5 ), dotNH: float( 1 ) } ), float( 1 / ( Math.PI * 0.25 ) ), 1e-4, 'D_GGX(0.5, 1) == 1/(PI*alpha^2)' ); + + } ); + + gpuTest( 'D_GGX_Anisotropic() reduces to D_GGX() in the isotropic, on-axis case', ( { assert } ) => { + + // When alphaT == alphaB == alpha and the half vector's tangent/ + // bitangent components are both zero (dotTH == dotBH == 0, i.e. + // the half vector lies exactly along the normal, dotNH == 1), + // D_GGX_Anisotropic's own formula (v = (0, 0, alpha^2), w2 = + // alpha^2/v2 = 1/alpha^2, D = (1/PI)*alpha^2*w2^2) simplifies to + // exactly 1/(PI*alpha^2) -- the same closed form D_GGX(alpha, 1) + // reduces to above. This cross-checks the anisotropic formula + // against the isotropic one at the one point where they must + // agree, without assuming anything about the general case. + const alpha = 0.4; + const anisoOnAxis = D_GGX_Anisotropic( { alphaT: float( alpha ), alphaB: float( alpha ), dotNH: float( 1 ), dotTH: float( 0 ), dotBH: float( 0 ) } ); + assert.closeAbs( anisoOnAxis, float( 1 / ( Math.PI * alpha * alpha ) ), 1e-4, 'D_GGX_Anisotropic reduces to 1/(PI*alpha^2) when isotropic and on-axis' ); + + // General off-axis case, hand-computed directly from the source + // formula: a2 = alphaT*alphaB; v = (alphaB*dotTH, alphaT*dotBH, + // a2*dotNH); w2 = a2 / dot(v,v); D = (1/PI) * a2 * w2^2. + const alphaT = 0.3, alphaB = 0.6, dotNH = 0.8, dotTH = 0.2, dotBH = 0.1; + const a2 = alphaT * alphaB; + const v = [ alphaB * dotTH, alphaT * dotBH, a2 * dotNH ]; + const v2 = v[ 0 ] * v[ 0 ] + v[ 1 ] * v[ 1 ] + v[ 2 ] * v[ 2 ]; + const w2 = a2 / v2; + const expected = ( 1 / Math.PI ) * a2 * w2 * w2; + + assert.closeAbs( + D_GGX_Anisotropic( { alphaT: float( alphaT ), alphaB: float( alphaB ), dotNH: float( dotNH ), dotTH: float( dotTH ), dotBH: float( dotBH ) } ), + float( expected ), 1e-4, 'D_GGX_Anisotropic matches the hand-computed general-case formula' + ); + + } ); + + gpuTest( 'F_Schlick() reduces to f0 at normal incidence and to f90 at grazing incidence', ( { assert } ) => { + + // F_Schlick uses Epic's optimized exp2()-based variant of the + // Schlick approximation (source comment cites the SIGGRAPH '13 + // slides): fresnel = 2^(dotVH*(-5.55473*dotVH - 6.98316)). + // At dotVH == 0 (grazing incidence) the exponent is 0, so + // fresnel == 1 and F == f90 exactly. + assert.closeAbs( F_Schlick( { f0: float( 0.04 ), f90: float( 1.0 ), dotVH: float( 0 ) } ), float( 1.0 ), 1e-5, 'F_Schlick at dotVH=0 (grazing) returns f90 exactly' ); + + // At dotVH == 1 (normal incidence) the exponent is a large + // negative number, so fresnel is vanishingly small and F is + // very close to f0. + assert.closeAbs( F_Schlick( { f0: float( 0.04 ), f90: float( 1.0 ), dotVH: float( 1 ) } ), float( 0.04 ), 1e-3, 'F_Schlick at dotVH=1 (normal incidence) is close to f0' ); + + // General case, hand-computed from the documented exp2() formula. + const dotVH = 0.6, f0 = 0.04, f90 = 1.0; + const fresnel = Math.pow( 2, dotVH * ( - 5.55473 * dotVH - 6.98316 ) ); + const expected = f0 * ( 1 - fresnel ) + f90 * fresnel; + assert.closeAbs( F_Schlick( { f0: float( f0 ), f90: float( f90 ), dotVH: float( dotVH ) } ), float( expected ), 1e-4, 'F_Schlick(0.6) matches the hand-computed exp2() formula' ); + + } ); + + gpuTest( 'Schlick_to_F0() matches the classic-Schlick-inversion formula', ( { assert } ) => { + + // Schlick_to_F0 inverts the *classic* x^5 Schlick approximation + // (not F_Schlick's own exp2() variant above): given f (the + // observed reflectance at this angle) and f90, it solves + // f = f0*(1-x^5) + f90*x^5 for f0, where x = saturate(1-dotVH). + const dotVH = 0.7, f90 = 1.0; + const x = Math.min( Math.max( 1 - dotVH, 0 ), 1 ); + const x5 = Math.min( Math.max( Math.pow( x, 5 ), 0 ), 0.9999 ); + const f = 0.5; // an arbitrary observed reflectance + const expected = ( f - f90 * x5 ) / ( 1 - x5 ); + + assert.closeAbs( Schlick_to_F0( { f: vec3( f, f, f ), f90: float( f90 ), dotVH: float( dotVH ) } ), vec3( expected, expected, expected ), 1e-4, 'Schlick_to_F0 matches the hand-computed inversion formula' ); + + // Round trip: plugging f0 into the classic x^5 Schlick formula to + // get f, then inverting with Schlick_to_F0, must recover the + // original f0 (this is the formula Schlick_to_F0 is the + // documented inverse of -- distinct from F_Schlick's exp2() + // approximation used elsewhere in this file). + const f0 = 0.08; + const fFromClassicSchlick = f0 * ( 1 - x5 ) + f90 * x5; + assert.closeAbs( + Schlick_to_F0( { f: vec3( fFromClassicSchlick, fFromClassicSchlick, fFromClassicSchlick ), f90: float( f90 ), dotVH: float( dotVH ) } ), + vec3( f0, f0, f0 ), 1e-4, 'Schlick_to_F0 recovers f0 from the classic x^5 Schlick formula it inverts' + ); + + } ); + + gpuTest( 'V_GGX_SmithCorrelated() matches the Smith-correlated visibility formula', ( { assert } ) => { + + // Frostbite course notes, page 12, listing 2: + // gv = dotNL * sqrt(a2 + (1-a2)*dotNV^2) + // gl = dotNV * sqrt(a2 + (1-a2)*dotNL^2) + // V = 0.5 / max(gv+gl, EPSILON) + const alpha = 0.5, dotNL = 0.8, dotNV = 0.6; + const a2 = alpha * alpha; + const gv = dotNL * Math.sqrt( a2 + ( 1 - a2 ) * dotNV * dotNV ); + const gl = dotNV * Math.sqrt( a2 + ( 1 - a2 ) * dotNL * dotNL ); + const expected = 0.5 / Math.max( gv + gl, 1e-6 ); + + assert.closeAbs( V_GGX_SmithCorrelated( { alpha: float( alpha ), dotNL: float( dotNL ), dotNV: float( dotNV ) } ), float( expected ), 1e-4, 'V_GGX_SmithCorrelated matches the hand-computed formula' ); + + } ); + + gpuTest( 'V_GGX_SmithCorrelated_Anisotropic() matches its own documented formula', ( { assert } ) => { + + // Filament's anisotropic Smith-correlated visibility term: + // gv = dotNL * length((alphaT*dotTV, alphaB*dotBV, dotNV)) + // gl = dotNV * length((alphaT*dotTL, alphaB*dotBL, dotNL)) + // V = 0.5 / max(gv+gl, EPSILON) + // + // Note this is a genuinely different visibility formulation from + // V_GGX_SmithCorrelated() above (a vec3-length term, not a + // sqrt(a2+(1-a2)*x^2) term) -- it does NOT reduce to + // V_GGX_SmithCorrelated() in the on-axis case (dotTV=dotBV= + // dotTL=dotBL=0 just simplifies the length() to |dotNV| / |dotNL| + // directly, giving gv=dotNL*dotNV, not the isotropic gv above), + // so this is checked directly against its own formula instead of + // cross-checked against the isotropic function. + const alphaT = 0.3, alphaB = 0.6, dotTV = 0.2, dotBV = 0.4, dotTL = 0.1, dotBL = 0.3, dotNV = 0.5, dotNL = 0.7; + + const length3 = ( x, y, z ) => Math.sqrt( x * x + y * y + z * z ); + const gv = dotNL * length3( alphaT * dotTV, alphaB * dotBV, dotNV ); + const gl = dotNV * length3( alphaT * dotTL, alphaB * dotBL, dotNL ); + const expected = 0.5 / Math.max( gv + gl, 1e-6 ); + + const result = V_GGX_SmithCorrelated_Anisotropic( { + alphaT: float( alphaT ), alphaB: float( alphaB ), + dotTV: float( dotTV ), dotBV: float( dotBV ), dotTL: float( dotTL ), dotBL: float( dotBL ), + dotNV: float( dotNV ), dotNL: float( dotNL ) + } ); + + assert.closeAbs( result, float( expected ), 1e-4, 'V_GGX_SmithCorrelated_Anisotropic matches the hand-computed formula' ); + + } ); + + gpuTest( 'BRDF_Lambert() is diffuseColor / PI', ( { assert } ) => { + + // Punctual-light Lambertian diffuse term -- the function's own + // source comment: "diffuseColor.mul(1/Math.PI)". + assert.closeAbs( BRDF_Lambert( { diffuseColor: vec3( 0.8, 0.4, 0.2 ) } ), vec3( 0.8 / Math.PI, 0.4 / Math.PI, 0.2 / Math.PI ), 1e-5, 'BRDF_Lambert(c) == c/PI' ); + + } ); + + } ); + + QUnit.module( 'BRDF_GGX()', () => { + + gpuTest( 'BRDF_GGX() (no iridescence/anisotropy) is F * V * D from its own standalone building blocks', ( { assert } ) => { + + // With USE_IRIDESCENCE/USE_ANISOTROPY both undefined, BRDF_GGX's + // own source reduces to exactly F_Schlick(...) * V_GGX_SmithCorrelated(...) + // * D_GGX(...), computed from the same dot products BRDF_GGX + // derives internally from lightDirection/viewDirection/normalView. + // normalView and viewDirection are passed explicitly (both + // parameters BRDF_GGX accepts overrides for) so this doesn't + // depend on any real scene/camera/material context. + const normal = vec3( 0, 0, 1 ); + const viewDirection = vec3( 0, 0, 1 ); + const lightDirection = vec3( 0, 0.6, 0.8 ); // already unit length + const roughness = 0.5; + const f0 = 0.04, f90 = 1.0; + + // Hand-derive the same dot products BRDF_GGX computes internally + // (all inputs above are already unit vectors, and view/normal + // are both +Z, so the half vector is easy to reason about). + const halfDir = [ 0, 0.6, 1.8 ]; + const halfLen = Math.sqrt( halfDir[ 0 ] ** 2 + halfDir[ 1 ] ** 2 + halfDir[ 2 ] ** 2 ); + const h = halfDir.map( c => c / halfLen ); + + const dotNL = Math.min( Math.max( 0 * 0 + 0 * 0.6 + 1 * 0.8, 0 ), 1 ); + const dotNV = Math.min( Math.max( 0 * 0 + 0 * 0 + 1 * 1, 0 ), 1 ); + const dotNH = Math.min( Math.max( h[ 2 ], 0 ), 1 ); + const dotVH = Math.min( Math.max( 0 * h[ 0 ] + 0 * h[ 1 ] + 1 * h[ 2 ], 0 ), 1 ); + + const alpha = roughness * roughness; + + const fresnel = Math.pow( 2, dotVH * ( - 5.55473 * dotVH - 6.98316 ) ); + const F = f0 * ( 1 - fresnel ) + f90 * fresnel; + + const a2 = alpha * alpha; + const gv = dotNL * Math.sqrt( a2 + ( 1 - a2 ) * dotNV * dotNV ); + const gl = dotNV * Math.sqrt( a2 + ( 1 - a2 ) * dotNL * dotNL ); + const V = 0.5 / Math.max( gv + gl, 1e-6 ); + + const denom = 1 - dotNH * dotNH * ( 1 - a2 ); + const D = a2 / ( denom * denom ) / Math.PI; + + const expected = F * V * D; + + const result = BRDF_GGX( { lightDirection, f0: float( f0 ), f90: float( f90 ), roughness: float( roughness ), normalView: normal, viewDirection } ); + + assert.closeAbs( result, float( expected ), 1e-3, 'BRDF_GGX matches F_Schlick * V_GGX_SmithCorrelated * D_GGX composed independently' ); + + } ); + + } ); + + QUnit.module( 'getDistanceAttenuation()', () => { + + gpuTest( 'getDistanceAttenuation() without a cutoff is the inverse-power falloff', ( { assert } ) => { + + // cutoffDistance <= 0 skips the windowing term entirely: + // distanceFalloff == 1 / max(lightDistance^decayExponent, 0.01). + // cutoffDistance is routed through `.toVar()` so it isn't folded + // into a compile-time constant -- `select()` still builds the + // (untaken) windowed branch too, which divides by cutoffDistance, + // and WGSL rejects an exact `x / 0.0` constant fold even in a + // branch that never actually runs (see the sinc() singularity + // finding in TSLMath.tests.js for the same underlying issue). + const lightDistance = 4, decayExponent = 2; + const expected = 1 / Math.max( Math.pow( lightDistance, decayExponent ), 0.01 ); + + assert.closeAbs( + getDistanceAttenuation( { lightDistance: float( lightDistance ), cutoffDistance: float( 0 ).toVar(), decayExponent: float( decayExponent ) } ), + float( expected ), 1e-4, 'getDistanceAttenuation with no cutoff matches 1/lightDistance^decayExponent' + ); + + } ); + + gpuTest( 'getDistanceAttenuation() with a cutoff applies the smooth windowing term', ( { assert } ) => { + + // Frostbite course notes page 32, eq. 26: with cutoffDistance > 0, + // the falloff above is additionally multiplied by + // clamp(1 - (lightDistance/cutoffDistance)^4, 0, 1)^2. + const lightDistance = 4, decayExponent = 2, cutoffDistance = 10; + const falloff = 1 / Math.max( Math.pow( lightDistance, decayExponent ), 0.01 ); + const window = Math.min( Math.max( 1 - Math.pow( lightDistance / cutoffDistance, 4 ), 0 ), 1 ); + const expected = falloff * window * window; + + assert.closeAbs( + getDistanceAttenuation( { lightDistance: float( lightDistance ), cutoffDistance: float( cutoffDistance ), decayExponent: float( decayExponent ) } ), + float( expected ), 1e-4, 'getDistanceAttenuation with a cutoff matches the windowed formula' + ); + + // Beyond the cutoff distance the window clamps to 0, so the + // attenuation must be exactly 0 regardless of the falloff term. + assert.closeAbs( + getDistanceAttenuation( { lightDistance: float( 20 ), cutoffDistance: float( cutoffDistance ), decayExponent: float( decayExponent ) } ), + float( 0 ), 1e-5, 'getDistanceAttenuation is 0 beyond the cutoff distance' + ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLBitOps.tests.js b/test/unit/addons/tsl/TSLBitOps.tests.js new file mode 100644 index 00000000000000..1ab862e53d9d5d --- /dev/null +++ b/test/unit/addons/tsl/TSLBitOps.tests.js @@ -0,0 +1,101 @@ +import { float, int, uint, bitcast, countLeadingZeros, countOneBits, countTrailingZeros, inversesqrt } from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for the generic bitcast() constructor (src/nodes/math/BitcastNode.js +// -- distinct from the already-covered floatBitsToInt()/uintBitsToFloat()/etc. +// convenience wrappers in TSLCurveUtils.tests.js, which are themselves thin +// calls to this same node) and the 32-bit population/leading/trailing-zero +// count helpers (src/nodes/math/BitcountNode.js), plus inversesqrt(). +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'bitcast()', () => { + + gpuTest( 'bitcast() reinterprets bits between int and float per known IEEE-754 patterns', ( { assert } ) => { + + // Same well-known IEEE-754 fact used in TSLCurveUtils.tests.js's + // floatBitsToInt() coverage: 1.0f's bit pattern is 0x3F800000 == + // 1065353216. + assert.eq( bitcast( float( 1.0 ), 'int' ), int( 1065353216 ), 'bitcast(1.0, "int") == 0x3F800000' ); + assert.eq( bitcast( int( 1065353216 ), 'float' ), float( 1.0 ), 'bitcast(0x3F800000, "float") == 1.0' ); + + // Round trip: bitcast is lossless reinterpretation, so converting + // out and back must recover the exact original bits. + assert.eq( bitcast( bitcast( float( 3.140625 ), 'int' ), 'float' ), float( 3.140625 ), 'bitcast round trip recovers the exact float' ); + + } ); + + } ); + + QUnit.module( 'bit counting', () => { + + gpuTest( 'countOneBits() matches a hand-computed popcount', ( { assert } ) => { + + // popcount, computed independently in plain JS (not via any + // bit-counting builtin) for a handful of known bit patterns. + const popcount = ( x ) => { + + let n = 0; + while ( x !== 0 ) { n += x & 1; x >>>= 1; } + return n; + + }; + + assert.eq( countOneBits( uint( 0 ) ), uint( popcount( 0 ) ), 'countOneBits(0) == 0' ); + assert.eq( countOneBits( uint( 0xF0 ) ), uint( popcount( 0xF0 ) ), 'countOneBits(0xF0) == 4' ); + assert.eq( countOneBits( uint( 0xFFFFFFFF ) ), uint( popcount( 0xFFFFFFFF ) ), 'countOneBits(0xFFFFFFFF) == 32' ); + + } ); + + gpuTest( 'countLeadingZeros() matches Math.clz32() for known bit patterns', ( { assert } ) => { + + // JS's own Math.clz32() counts leading zero bits in the 32-bit + // representation of its argument -- exactly the definition + // countLeadingZeros() implements for a uint, so it's usable + // directly as the independent reference here (not re-running + // the TSL node under test). + assert.eq( countLeadingZeros( uint( 0 ) ), uint( Math.clz32( 0 ) ), 'countLeadingZeros(0) == 32' ); + assert.eq( countLeadingZeros( uint( 1 ) ), uint( Math.clz32( 1 ) ), 'countLeadingZeros(1) == 31' ); + assert.eq( countLeadingZeros( uint( 0xF0 ) ), uint( Math.clz32( 0xF0 ) ), 'countLeadingZeros(0xF0) == 24' ); + assert.eq( countLeadingZeros( uint( 0xFFFFFFFF ) ), uint( Math.clz32( 0xFFFFFFFF ) ), 'countLeadingZeros(0xFFFFFFFF) == 0' ); + + } ); + + gpuTest( 'countTrailingZeros() matches a hand-computed trailing-zero count', ( { assert } ) => { + + // Computed independently in plain JS: the position of the + // lowest set bit, or 32 if there is none -- matching + // countLeadingZeros(0)'s own "no bits set, count all 32" + // convention (see BitcountNode.js's WebGL polyfill, which + // explicitly special-cases zero the same way for both + // functions). + const ctz = ( x ) => { + + if ( x === 0 ) return 32; + let n = 0; + while ( ( x & 1 ) === 0 ) { n ++; x >>>= 1; } + return n; + + }; + + assert.eq( countTrailingZeros( uint( 0 ) ), uint( ctz( 0 ) ), 'countTrailingZeros(0) == 32' ); + assert.eq( countTrailingZeros( uint( 8 ) ), uint( ctz( 8 ) ), 'countTrailingZeros(8) == 3' ); + assert.eq( countTrailingZeros( uint( 0xF0 ) ), uint( ctz( 0xF0 ) ), 'countTrailingZeros(0xF0) == 4' ); + assert.eq( countTrailingZeros( uint( 1 ) ), uint( ctz( 1 ) ), 'countTrailingZeros(1) == 0' ); + + } ); + + } ); + + QUnit.module( 'inversesqrt()', () => { + + gpuTest( 'inversesqrt() is 1/sqrt(x)', ( { assert } ) => { + + assert.closeAbs( inversesqrt( float( 4 ) ), float( 1 / Math.sqrt( 4 ) ), 1e-5, 'inversesqrt(4) == 0.5' ); + assert.closeAbs( inversesqrt( float( 1 ) ), float( 1 ), 1e-5, 'inversesqrt(1) == 1' ); + assert.closeAbs( inversesqrt( float( 0.25 ) ), float( 1 / Math.sqrt( 0.25 ) ), 1e-4, 'inversesqrt(0.25) == 2' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLBlendModes.tests.js b/test/unit/addons/tsl/TSLBlendModes.tests.js new file mode 100644 index 00000000000000..4db4c64038a49d --- /dev/null +++ b/test/unit/addons/tsl/TSLBlendModes.tests.js @@ -0,0 +1,105 @@ +import { + vec3, vec4, + blendBurn, blendDodge, blendScreen, blendOverlay, blendColor +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Blend-mode function coverage (src/nodes/display/BlendModes.js). Every +// expected value below is the plain closed-form formula for that blend mode, +// hand-evaluated in plain JS/comments -- not derived by re-running the TSL +// expression 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( 'blend mode functions', () => { + + gpuTest( 'blendBurn() darkens base using blend -- min(1, (1-base)/blend), inverted', ( { assert } ) => { + + // A white blend layer (1) leaves the base unchanged: 1-min(1,(1-b)/1) = b. + assert.closeAbs( blendBurn( vec3( 0.3, 0.6, 0.9 ), vec3( 1, 1, 1 ) ), vec3( 0.3, 0.6, 0.9 ), 1e-4, 'blendBurn() with white blend is a no-op' ); + + // General case: burn(b, e) = 1 - min(1, (1-b)/e). + // base=0.5, blend=0.5 -> 1 - min(1, 0.5/0.5) = 1 - 1 = 0. + assert.closeAbs( blendBurn( vec3( 0.5 ), vec3( 0.5 ) ), vec3( 0 ), 1e-4, 'blendBurn(0.5, 0.5) == 0' ); + + // base=0.8, blend=0.4 -> 1 - min(1, 0.2/0.4) = 1 - 0.5 = 0.5. + assert.closeAbs( blendBurn( vec3( 0.8 ), vec3( 0.4 ) ), vec3( 0.5 ), 1e-4, 'blendBurn(0.8, 0.4) == 0.5' ); + + } ); + + gpuTest( 'blendDodge() lightens base using blend -- min(base/(1-blend), 1)', ( { assert } ) => { + + // A black blend layer (0) leaves the base unchanged: min(b/(1-0), 1) = b. + assert.closeAbs( blendDodge( vec3( 0.3, 0.6, 0.9 ), vec3( 0, 0, 0 ) ), vec3( 0.3, 0.6, 0.9 ), 1e-4, 'blendDodge() with black blend is a no-op' ); + + // dodge(b, e) = min(b/(1-e), 1). + // base=0.5, blend=0.5 -> min(0.5/0.5, 1) = 1. + assert.closeAbs( blendDodge( vec3( 0.5 ), vec3( 0.5 ) ), vec3( 1 ), 1e-4, 'blendDodge(0.5, 0.5) == 1 (saturates)' ); + + // base=0.2, blend=0.5 -> min(0.2/0.5, 1) = 0.4. + assert.closeAbs( blendDodge( vec3( 0.2 ), vec3( 0.5 ) ), vec3( 0.4 ), 1e-4, 'blendDodge(0.2, 0.5) == 0.4' ); + + } ); + + gpuTest( 'blendScreen() -- 1 - (1-base)*(1-blend)', ( { assert } ) => { + + // screen(b, e) = 1 - (1-b)(1-e). + assert.closeAbs( blendScreen( vec3( 0 ), vec3( 0 ) ), vec3( 0 ), 1e-6, 'blendScreen(0, 0) == 0' ); + assert.closeAbs( blendScreen( vec3( 1 ), vec3( 0.5 ) ), vec3( 1 ), 1e-4, 'blendScreen(1, x) == 1 -- white base always stays white' ); + assert.closeAbs( blendScreen( vec3( 0.5 ), vec3( 0.5 ) ), vec3( 1 - 0.5 * 0.5 ), 1e-4, 'blendScreen(0.5, 0.5) == 0.75' ); + assert.closeAbs( blendScreen( vec3( 0.2 ), vec3( 0.6 ) ), vec3( 1 - 0.8 * 0.4 ), 1e-4, 'blendScreen(0.2, 0.6) == 0.68' ); + + } ); + + gpuTest( 'blendOverlay() -- multiply below 0.5, screen above', ( { assert } ) => { + + // base < 0.5 branch: overlay(b, e) = 2*b*e. + assert.closeAbs( blendOverlay( vec3( 0.2 ), vec3( 0.5 ) ), vec3( 2 * 0.2 * 0.5 ), 1e-4, 'blendOverlay(0.2, 0.5) uses the multiply branch (base < 0.5)' ); + + // base >= 0.5 branch (step(0.5, base) is inclusive of the edge): + // overlay(b, e) = 1 - 2*(1-b)*(1-e). + assert.closeAbs( blendOverlay( vec3( 0.5 ), vec3( 0.5 ) ), vec3( 1 - 2 * 0.5 * 0.5 ), 1e-4, 'blendOverlay(0.5, 0.5) is exactly on the branch boundary -- uses the screen branch (step is inclusive)' ); + assert.closeAbs( blendOverlay( vec3( 0.8 ), vec3( 0.6 ) ), vec3( 1 - 2 * 0.2 * 0.4 ), 1e-4, 'blendOverlay(0.8, 0.6) uses the screen branch (base >= 0.5)' ); + + // Overlay is continuous at the boundary: both formulas agree at base=0.5 + // only when e cancels out symmetrically, which the 0.5/0.5 case above + // already exercises directly. + + } ); + + gpuTest( 'blendColor() -- standard "over" alpha compositing, non-premultiplied inputs', ( { assert } ) => { + + // Fully opaque blend layer completely replaces the base, regardless + // of the base's own color or alpha. + assert.closeAbs( + blendColor( vec4( 0.2, 0.4, 0.6, 0.5 ), vec4( 1, 0, 0, 1 ) ), + vec4( 1, 0, 0, 1 ), 1e-4, + 'blendColor() with an opaque blend layer fully replaces the base' + ); + + // Fully transparent blend layer leaves the base fully unchanged. + assert.closeAbs( + blendColor( vec4( 0.2, 0.4, 0.6, 0.7 ), vec4( 1, 1, 1, 0 ) ), + vec4( 0.2, 0.4, 0.6, 0.7 ), 1e-4, + 'blendColor() with a fully transparent blend layer is a no-op' + ); + + // General "over" compositing: outAlpha = eA + bA*(1-eA); + // outRGB = (e.rgb*eA + b.rgb*bA*(1-eA)) / outAlpha. + // base = (1,0,0, 0.5), blend = (0,1,0, 0.5) + // outAlpha = 0.5 + 0.5*0.5 = 0.75 + // outRGB = ((0,1,0)*0.5 + (1,0,0)*0.5*0.5) / 0.75 = ((0.25,0.5,0)) / 0.75 + const outAlpha = 0.75; + const outR = ( 1 * 0.5 * 0.5 ) / outAlpha; // base.r * base.a * (1-blend.a) / outAlpha + const outG = ( 1 * 0.5 ) / outAlpha; // blend.g * blend.a / outAlpha + assert.closeAbs( + blendColor( vec4( 1, 0, 0, 0.5 ), vec4( 0, 1, 0, 0.5 ) ), + vec4( outR, outG, 0, outAlpha ), 1e-4, + 'blendColor() general case matches hand-computed "over" compositing' + ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLColorAdjustmentExtra.tests.js b/test/unit/addons/tsl/TSLColorAdjustmentExtra.tests.js new file mode 100644 index 00000000000000..49f76210194f7d --- /dev/null +++ b/test/unit/addons/tsl/TSLColorAdjustmentExtra.tests.js @@ -0,0 +1,97 @@ +import { + float, vec3, vec4, + vibrance, cdl +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Additional color-adjustment coverage that doesn't already live in +// TSLConversion.tests.js: vibrance() and cdl() (grayscale/saturation/hue/ +// luminance/posterize are covered there). Kept as a separate file to avoid +// colliding with other in-flight edits to that file, matching the existing +// TSLMathExtra.tests.js convention. +// +// Every expected value below is derived independently (hand-computed from +// the documented formula), never by re-running the same TSL expression +// 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 adjustment (extra)', () => { + + gpuTest( 'vibrance() pushes less-saturated channels toward the max channel', ( { assert } ) => { + + // adjustment=0 is a no-op (amt = (mx-avg)*0*(-3) = 0, so + // mix(color, mx, 0) == color). + assert.closeAbs( vibrance( vec3( 0.2, 0.5, 0.8 ), float( 0 ) ), vec3( 0.2, 0.5, 0.8 ), 1e-5, 'vibrance(x, 0) is a no-op' ); + + // General case, hand-computed from the documented formula: + // average = (r+g+b)/3 + // mx = max(r,g,b) + // amt = (mx - average) * adjustment * -3 + // result = mix(color, mx, amt).max(0) + // color=(0.2,0.5,0.8), adjustment=0.5: + // average = 0.5, mx = 0.8, amt = (0.8-0.5)*0.5*-3 = -0.45 + // result = color*(1-amt) + mx*amt = color*1.45 - 0.36 + // = (0.29-0.36, 0.725-0.36, 1.16-0.36) = (-0.07, 0.365, 0.8) + // clamped to >= 0 -> (0, 0.365, 0.8) + assert.closeAbs( vibrance( vec3( 0.2, 0.5, 0.8 ), float( 0.5 ) ), vec3( 0, 0.365, 0.8 ), 1e-4, 'vibrance(x, 0.5) matches the hand-computed formula, clamped at 0' ); + + // The max channel itself is always left unchanged by construction + // (mix(mx, mx, amt) == mx for any amt), independent of adjustment. + assert.closeAbs( vibrance( vec3( 0.2, 0.5, 0.8 ), float( 2 ) ).b, float( 0.8 ), 1e-4, 'vibrance() never changes the already-maximal channel' ); + + } ); + + gpuTest( 'cdl() applies slope/offset/power then saturation, in log-like space', ( { assert } ) => { + + const lumCoeff = vec3( 0.2126, 0.7152, 0.0722 ); // Rec. 709, matching cdl()'s own default + + // Identity parameters (slope=1, offset=0, power=1, saturation=1) + // leave a positive-valued color unchanged. + assert.closeAbs( + cdl( vec4( 0.2, 0.5, 0.8, 1 ), vec3( 1 ), vec3( 0 ), vec3( 1 ), float( 1 ), lumCoeff ), + vec4( 0.2, 0.5, 0.8, 1 ), 1e-4, + 'cdl() with identity parameters is a no-op' + ); + + // slope=2 alone (power=1, saturation=1): v = max(color*2, 0), then + // the saturation step is also an identity at saturation=1, so the + // result is simply color*2. + assert.closeAbs( + cdl( vec4( 0.1, 0.2, 0.3, 1 ), vec3( 2 ), vec3( 0 ), vec3( 1 ), float( 1 ), lumCoeff ), + vec4( 0.2, 0.4, 0.6, 1 ), 1e-4, + 'cdl() slope=2 doubles the color when power/saturation are identity' + ); + + // General case with saturation != 1, hand-computed from the + // documented formula: + // luma = dot(color.rgb, lumCoeff) + // v = max(color*slope + offset, 0); v = pow(v, power) where v > 0 + // v = max(luma + (v - luma)*saturation, 0) + // color=(0.2,0.5,0.8), slope=1, offset=0, power=1, saturation=2: + // luma = 0.2*0.2126 + 0.5*0.7152 + 0.8*0.0722 + // = 0.04252 + 0.3576 + 0.05776 = 0.45788 + // v (post slope/offset/power=1) = (0.2, 0.5, 0.8) unchanged + // v = luma + (v - luma)*2 = 2v - luma + // = (0.4-0.45788, 1-0.45788, 1.6-0.45788) + // = (-0.05788, 0.54212, 1.14212) -> clamp -> (0, 0.54212, 1.14212) + const luma = 0.2 * 0.2126 + 0.5 * 0.7152 + 0.8 * 0.0722; + const expected = vec3( Math.max( 2 * 0.2 - luma, 0 ), 2 * 0.5 - luma, 2 * 0.8 - luma ); + assert.closeAbs( + cdl( vec4( 0.2, 0.5, 0.8, 1 ), vec3( 1 ), vec3( 0 ), vec3( 1 ), float( 2 ), lumCoeff ), + vec4( expected, 1 ), 1e-4, + 'cdl() saturation=2 matches the hand-computed luma-relative scaling' + ); + + // alpha passes through unchanged, independent of every other parameter. + assert.closeAbs( + cdl( vec4( 0.2, 0.5, 0.8, 0.37 ), vec3( 3 ), vec3( 0.1 ), vec3( 1.5 ), float( 0.5 ), lumCoeff ).a, + float( 0.37 ), 1e-6, + 'cdl() passes the alpha channel through unchanged' + ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLColorSpace.tests.js b/test/unit/addons/tsl/TSLColorSpace.tests.js new file mode 100644 index 00000000000000..4db054885e3f69 --- /dev/null +++ b/test/unit/addons/tsl/TSLColorSpace.tests.js @@ -0,0 +1,75 @@ +import { + vec3, + sRGBTransferEOTF, sRGBTransferOETF +} from 'three/tsl'; +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 +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'color space functions', () => { + + gpuTest( 'sRGBTransferEOTF() decodes sRGB to linear', ( { assert } ) => { + + // Exact endpoints. + assert.closeAbs( sRGBTransferEOTF( vec3( 0, 0, 0 ) ), vec3( 0, 0, 0 ), 1e-6, 'EOTF(0) == 0' ); + assert.closeAbs( sRGBTransferEOTF( vec3( 1, 1, 1 ) ), vec3( 1, 1, 1 ), 1e-4, 'EOTF(1) == 1' ); + + // Below the linear-segment threshold (0.04045): x / 12.92. + // sRGBTransferEOTF() is declared (via setLayout) as taking/returning + // 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' ); + + // 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( 0.21404114 ), 1e-4, 'EOTF(0.5) matches the well-known sRGB mid-gray linear value' ); + + } ); + + gpuTest( 'sRGBTransferOETF() encodes linear to sRGB', ( { assert } ) => { + + // Exact endpoints. + assert.closeAbs( sRGBTransferOETF( vec3( 0, 0, 0 ) ), vec3( 0, 0, 0 ), 1e-6, 'OETF(0) == 0' ); + 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' ); + + // 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( 1 ) ), vec3( 1 ), 1e-4, 'OETF(1) == 1' ); + + } ); + + gpuTest( 'sRGBTransferEOTF() and sRGBTransferOETF() are inverses of each other', ( { assert } ) => { + + // Round-trip check using values independently known from the tests + // above -- not by feeding a value through both functions and + // comparing to itself with no other reference (that alone would be + // test theater), but the *shape* of a round-trip is still a useful, + // additional cross-check once each direction is independently + // verified against the closed-form formula above. + const linear = vec3( 0.02, 0.2140411, 0.8 ); + const roundTrip = sRGBTransferEOTF( sRGBTransferOETF( linear ) ); + + assert.closeAbs( roundTrip, linear, 1e-3, 'EOTF(OETF(x)) == x' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLColorSpaceConversion.tests.js b/test/unit/addons/tsl/TSLColorSpaceConversion.tests.js new file mode 100644 index 00000000000000..e01456d81d1b83 --- /dev/null +++ b/test/unit/addons/tsl/TSLColorSpaceConversion.tests.js @@ -0,0 +1,77 @@ +import { vec4, convertColorSpace, workingToColorSpace, colorSpaceToWorking, sRGBTransferEOTF, sRGBTransferOETF } from 'three/tsl'; +import { SRGBColorSpace, LinearSRGBColorSpace } from 'three'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for convertColorSpace()/workingToColorSpace()/colorSpaceToWorking() +// in src/nodes/display/ColorSpaceNode.js, for the sRGB <-> linear-sRGB pair. +// +// ColorSpaceNode.setup() (read directly, not re-run) skips the general +// primaries-matrix path whenever source and target share the same color +// primaries -- true for 'srgb' and 'srgb-linear', which are both Rec.709 -- +// and applies only the transfer-function step for whichever side is +// sRGB-encoded. So for this specific pair, the conversion reduces to +// exactly sRGBTransferEOTF()/sRGBTransferOETF(), letting expected values be +// derived from those already-independently-covered functions (see +// TSLConversion.tests.js) rather than from re-deriving ColorSpaceNode's own +// matrix machinery. +// +// The default working color space is LinearSRGBColorSpace (see +// ColorManagement.js), which this suite never overrides. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'color space conversion', () => { + + gpuTest( 'colorSpaceToWorking(sRGB) applies the sRGB EOTF (sRGB -> linear-sRGB working space)', ( { assert } ) => { + + const color = vec4( 0.5, 0.25, 0.75, 1 ); + const expected = vec4( sRGBTransferEOTF( color.rgb ), 1 ); + + assert.closeAbs( colorSpaceToWorking( color, SRGBColorSpace ), expected, 1e-5, 'colorSpaceToWorking(c, SRGBColorSpace) == vec4(sRGBTransferEOTF(c.rgb), c.a)' ); + + } ); + + gpuTest( 'workingToColorSpace(sRGB) applies the sRGB OETF (linear-sRGB working space -> sRGB)', ( { assert } ) => { + + const color = vec4( 0.5, 0.25, 0.75, 1 ); + const expected = vec4( sRGBTransferOETF( color.rgb ), 1 ); + + assert.closeAbs( workingToColorSpace( color, SRGBColorSpace ), expected, 1e-5, 'workingToColorSpace(c, SRGBColorSpace) == vec4(sRGBTransferOETF(c.rgb), c.a)' ); + + } ); + + gpuTest( 'convertColorSpace(sRGB, linear-sRGB) matches colorSpaceToWorking(sRGB)', ( { assert } ) => { + + // LinearSRGBColorSpace is the default working color space, so + // converting explicitly TO it must agree with converting FROM + // sRGB to the (implicit) working space. + const color = vec4( 0.9, 0.1, 0.4, 0.6 ); + + assert.closeAbs( + convertColorSpace( color, SRGBColorSpace, LinearSRGBColorSpace ), + colorSpaceToWorking( color, SRGBColorSpace ), + 1e-5, 'convertColorSpace(c, sRGB, linear-sRGB) matches colorSpaceToWorking(c, sRGB)' + ); + + } ); + + gpuTest( 'colorSpaceToWorking() and workingToColorSpace() round-trip an sRGB color', ( { assert } ) => { + + const original = vec4( 0.6, 0.3, 0.9, 1 ); + const roundTripped = workingToColorSpace( colorSpaceToWorking( original, SRGBColorSpace ), SRGBColorSpace ); + + assert.closeAbs( roundTripped, original, 1e-4, 'workingToColorSpace(colorSpaceToWorking(c)) recovers the original color' ); + + } ); + + gpuTest( 'converting between the same color space is the identity', ( { assert } ) => { + + // ColorSpaceNode.setup() explicitly early-returns the input + // unchanged when source === target. + const color = vec4( 0.2, 0.7, 0.5, 0.8 ); + assert.eq( convertColorSpace( color, SRGBColorSpace, SRGBColorSpace ), color, 'convertColorSpace(c, X, X) is the identity' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLConversion.tests.js b/test/unit/addons/tsl/TSLConversion.tests.js new file mode 100644 index 00000000000000..054c22196959e6 --- /dev/null +++ b/test/unit/addons/tsl/TSLConversion.tests.js @@ -0,0 +1,161 @@ +import { + float, vec3, + grayscale, saturation, hue, luminance, posterize, + remap, remapClamp, + viewZToPerspectiveDepth, perspectiveDepthToViewZ, + viewZToOrthographicDepth, orthographicDepthToViewZ, + viewZToLogarithmicDepth +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Rec. 709 luminance coefficients, passed explicitly to every luminance() +// call below rather than relying on the default (which reads +// ColorManagement's *current* working color space -- a global that other +// tests/renderers could mutate). Using an explicit, known coefficient set +// keeps these tests' expected values independently hand-computable. +const REC709 = vec3( 0.2126, 0.7152, 0.0722 ); + +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'color conversion', () => { + + gpuTest( 'luminance() matches hand-computed Rec.709 dot product', ( { assert } ) => { + + assert.closeAbs( luminance( vec3( 1, 0, 0 ), REC709 ), float( 0.2126 ), 1e-5, 'pure red' ); + assert.closeAbs( luminance( vec3( 0, 1, 0 ), REC709 ), float( 0.7152 ), 1e-5, 'pure green' ); + assert.closeAbs( luminance( vec3( 0, 0, 1 ), REC709 ), float( 0.0722 ), 1e-5, 'pure blue' ); + assert.closeAbs( luminance( vec3( 1, 1, 1 ), REC709 ), float( 1.0 ), 1e-5, 'white sums to 1' ); + + } ); + + gpuTest( 'grayscale() actually returns a scalar luminance, despite its @return {Node} docstring', ( { assert } ) => { + + // Real doc/implementation mismatch (see tsl-unit-test-findings.md): + // grayscale()'s JSDoc promises `@return {Node} The grayscale + // color`, but the implementation is just `return luminance(color.rgb)` + // -- and luminance() is a plain `dot(color, coefficients)`, i.e. a + // float. There is no vec3 broadcast anywhere in the function. This + // locks down the *actual* (float) behavior so it can't regress + // silently, and stands as a regression check for whichever fix is + // chosen: broadcasting the implementation to match the docs, or + // correcting the docs to match the implementation. + const gray = grayscale( vec3( 1, 0, 0 ) ); + + // grayscale() has no coefficients parameter -- it always uses + // ColorManagement's current working color space (linear-sRGB by + // default, whose Rec.709 luminance coefficients match REC709 above). + assert.closeAbs( gray, float( 0.2126 ), 2e-3, "grayscale(red) is red's scalar luminance, NOT a vec3" ); + + } ); + + gpuTest( 'saturation() at its two defining endpoints', ( { assert } ) => { + + const color = vec3( 0.8, 0.2, 0.4 ); + + // adjustment == 0 must fully desaturate to the color's own luminance + // (gray) -- an independent identity, not just "whatever the code + // currently returns". + const desaturated = saturation( color, float( 0 ) ); + const gray = luminance( color ); + assert.closeAbs( desaturated, vec3( gray, gray, gray ), 1e-4, 'adjustment=0 fully desaturates to luminance gray' ); + + // adjustment == 1 must be a no-op (mix(luminance, color, 1) == color). + assert.closeAbs( saturation( color, float( 1 ) ), color, 1e-5, 'adjustment=1 leaves the color unchanged' ); + + } ); + + gpuTest( 'hue() rotation identities', ( { assert } ) => { + + const color = vec3( 0.8, 0.2, 0.4 ); + + // A 0-radian rotation must be a no-op. + assert.closeAbs( hue( color, float( 0 ) ), color, 1e-5, 'hue(color, 0) is the identity' ); + + // A full 2*PI rotation returns to the start (up to floating-point drift). + assert.closeAbs( hue( color, float( Math.PI * 2 ) ), color, 1e-3, 'hue(color, 2*PI) returns to the original color' ); + + } ); + + gpuTest( 'posterize() known step counts', ( { assert } ) => { + + // posterize(x, steps) == floor(x * steps) / steps. + assert.closeAbs( posterize( float( 0.37 ), float( 4 ) ), float( Math.floor( 0.37 * 4 ) / 4 ), 1e-5, 'posterize(0.37, 4)' ); + assert.closeAbs( posterize( float( 1.0 ), float( 4 ) ), float( 1.0 ), 1e-5, 'posterize(1.0, 4) stays at the top step' ); + assert.closeAbs( posterize( float( 0.0 ), float( 4 ) ), float( 0.0 ), 1e-5, 'posterize(0.0, 4)' ); + + // Known real edge case (found while surveying this file): steps=0 + // makes posterize() compute floor(x*0)/0 == 0/0 == NaN for any x. + // Not exercised as a hard assertion here (NaN-vs-NaN equality is + // not meaningfully testable and would be a flaky, uninformative + // check), but documented so a future caller doesn't pass steps=0 + // expecting graceful clamping. + + } ); + + gpuTest( 'remap() and remapClamp()', ( { assert } ) => { + + // remap(0.4, 0.3, 0.5, 0, 1) -- the example from remap()'s own doc comment. + assert.closeAbs( remap( float( 0.4 ), float( 0.3 ), float( 0.5 ) ), float( 0.5 ), 1e-5, 'remap() doc-comment example' ); + + // Non-normalized output range. + assert.closeAbs( remap( float( 5 ), float( 0 ), float( 10 ), float( 100 ), float( 200 ) ), float( 150 ), 1e-4, 'remap into an arbitrary output range' ); + + // remap() (without clamping) legitimately extrapolates past the input range. + assert.closeAbs( remap( float( 20 ), float( 0 ), float( 10 ), float( 0 ), float( 1 ) ), float( 2 ), 1e-4, 'remap() extrapolates past inHigh' ); + + // remapClamp() must clamp the *output* to [outLow, outHigh] for the same input. + assert.eq( remapClamp( float( 20 ), float( 0 ), float( 10 ), float( 0 ), float( 1 ) ), float( 1 ), 'remapClamp() clamps past inHigh' ); + assert.eq( remapClamp( float( -20 ), float( 0 ), float( 10 ), float( 0 ), float( 1 ) ), float( 0 ), 'remapClamp() clamps below inLow' ); + + } ); + + gpuTest( 'viewZ <-> perspective depth round trip', ( { assert } ) => { + + const near = float( 0.1 ); + const far = float( 100 ); + + for ( const viewZ of [ -0.1, -1, -10, -50, -99.9 ] ) { + + const depth = viewZToPerspectiveDepth( float( viewZ ), near, far ); + const roundTrip = perspectiveDepthToViewZ( depth, near, far ); + assert.closeRel( roundTrip, float( viewZ ), 1e-3, `viewZ=${ viewZ } round-trips through perspective depth` ); + + } + + } ); + + gpuTest( 'viewZ <-> orthographic depth round trip', ( { assert } ) => { + + const near = float( 0.1 ); + const far = float( 100 ); + + for ( const viewZ of [ -0.1, -1, -10, -50, -99.9 ] ) { + + const depth = viewZToOrthographicDepth( float( viewZ ), near, far ); + const roundTrip = orthographicDepthToViewZ( depth, near, far ); + assert.closeAbs( roundTrip, float( viewZ ), 1e-3, `viewZ=${ viewZ } round-trips through orthographic depth` ); + + } + + } ); + + gpuTest( 'viewZToLogarithmicDepth is monotonic and bounded to [0, 1] at near/far', ( { assert } ) => { + + const near = float( 0.1 ); + const far = float( 100 ); + + // At viewZ == -near, depth must be exactly 0; at viewZ == -far, depth must be exactly 1 + // (this is the entire point of the formula -- log2(near/near)/log2(far/near) == 0, + // log2(far/near)/log2(far/near) == 1). + assert.closeAbs( viewZToLogarithmicDepth( float( -0.1 ), near, far ), float( 0 ), 1e-4, 'depth at the near plane is 0' ); + assert.closeAbs( viewZToLogarithmicDepth( float( -100 ), near, far ), float( 1 ), 1e-4, 'depth at the far plane is 1' ); + + // A midpoint (in log space) must land at 0.5. + const midViewZ = -Math.sqrt( 0.1 * 100 ); // geometric mean of near/far + assert.closeAbs( viewZToLogarithmicDepth( float( midViewZ ), near, far ), float( 0.5 ), 1e-4, 'the geometric-mean viewZ maps to depth 0.5' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLCurveUtils.tests.js b/test/unit/addons/tsl/TSLCurveUtils.tests.js new file mode 100644 index 00000000000000..765f0225d3e462 --- /dev/null +++ b/test/unit/addons/tsl/TSLCurveUtils.tests.js @@ -0,0 +1,102 @@ +import { + float, + parabola, + oscSine, oscSquare, oscTriangle, oscSawtooth, + floatBitsToInt, floatBitsToUint, intBitsToFloat, uintBitsToFloat, + int, uint +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for standalone TSL "curve"/remap-shape helpers not already covered +// by TSLGainPcurve.tests.js/TSLSinc.tests.js/TSLRotate.tests.js +// (src/nodes/math/MathUtils.js, src/nodes/utils/RotateNode.js), timer-driven +// oscillators (src/nodes/utils/Oscillators.js), and bit-reinterpretation +// casts (src/nodes/math/BitcastNode.js). Every expected value below is +// derived independently (hand-computed from each function's own documented +// formula, or from plain JS Math), never by re-running the same TSL +// expression 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( 'curve/remap-shape helpers', () => { + + gpuTest( 'parabola() maps [0,1] corners to 0 and the center to 1', ( { assert } ) => { + + // parabola(x, k) == (4*x*(1-x))^k -- from the function's own doc + // comment/formula, computed independently in JS. + assert.closeAbs( parabola( float( 0 ), float( 1 ) ), float( 0 ), 1e-5, 'parabola(0, 1) == 0 -- left corner' ); + assert.closeAbs( parabola( float( 1 ), float( 1 ) ), float( 0 ), 1e-5, 'parabola(1, 1) == 0 -- right corner' ); + assert.closeAbs( parabola( float( 0.5 ), float( 1 ) ), float( 1 ), 1e-5, 'parabola(0.5, 1) == 1 -- center' ); + + // k=2 squares the parabola's shape: parabola(0.5, k) is always 1 + // (1^k == 1), but a non-center point should differ from the k=1 case. + const p25k1 = Math.pow( 4 * 0.25 * ( 1 - 0.25 ), 1 ); + const p25k2 = Math.pow( 4 * 0.25 * ( 1 - 0.25 ), 2 ); + assert.closeAbs( parabola( float( 0.25 ), float( 1 ) ), float( p25k1 ), 1e-5, 'parabola(0.25, 1) matches the hand-computed formula' ); + assert.closeAbs( parabola( float( 0.25 ), float( 2 ) ), float( p25k2 ), 1e-5, 'parabola(0.25, 2) matches the hand-computed formula (different from k=1)' ); + + } ); + + } ); + + QUnit.module( 'timer-driven oscillators', () => { + + gpuTest( 'oscSine/oscSquare/oscTriangle/oscSawtooth at known phases', ( { assert } ) => { + + // Every oscillator here is fed an explicit `t`, never the default + // (real-time) timer -- that keeps every expected value a pure, + // independently hand-computed function of t. + + // oscSine(t) == 0.5*sin(2*PI*(t+0.75)) + 0.5 -- the +0.75 phase + // offset puts the trough at t=0 and the peak at t=0.5 (not the + // t=0.25/t=0.75 a naive sin() phase might suggest). + assert.closeAbs( oscSine( float( 0 ) ), float( 0.5 * Math.sin( 2 * Math.PI * 0.75 ) + 0.5 ), 1e-4, 'oscSine(0)' ); + assert.closeAbs( oscSine( float( 0.5 ) ), float( 1 ), 1e-4, 'oscSine(0.5) reaches its peak of 1' ); + assert.closeAbs( oscSine( float( 0 ) ), float( 0 ), 1e-4, 'oscSine(0) reaches its trough of 0' ); + + // oscSquare(t) == round(fract(t)) -- 0 for the first half of each + // unit period, 1 for the second half. + assert.eq( oscSquare( float( 0.2 ) ), float( 0 ), 'oscSquare(0.2) is in the low half' ); + assert.eq( oscSquare( float( 0.8 ) ), float( 1 ), 'oscSquare(0.8) is in the high half' ); + assert.eq( oscSquare( float( 1.2 ) ), float( 0 ), 'oscSquare(1.2) repeats the low half of the next period' ); + + // oscTriangle(t) == abs(2*fract(t+0.5)-1) -- 0 at integer t, 1 at + // the half-integer point, ramping linearly between. + assert.closeAbs( oscTriangle( float( 0 ) ), float( 0 ), 1e-5, 'oscTriangle(0) is at its trough' ); + assert.closeAbs( oscTriangle( float( 0.5 ) ), float( 1 ), 1e-5, 'oscTriangle(0.5) is at its peak' ); + assert.closeAbs( oscTriangle( float( 0.25 ) ), float( 0.5 ), 1e-4, 'oscTriangle(0.25) is exactly midway up the ramp' ); + + // oscSawtooth(t) == fract(t) -- a plain linear ramp per period. + assert.closeAbs( oscSawtooth( float( 0.3 ) ), float( 0.3 ), 1e-5, 'oscSawtooth(0.3) == 0.3' ); + assert.closeAbs( oscSawtooth( float( 1.3 ) ), float( 0.3 ), 1e-5, 'oscSawtooth(1.3) wraps back to 0.3' ); + + } ); + + } ); + + QUnit.module( 'bit-reinterpretation casts', () => { + + gpuTest( 'floatBitsToInt/floatBitsToUint/intBitsToFloat/uintBitsToFloat round trip and match IEEE-754 bit patterns', ( { assert } ) => { + + // 1.0f's IEEE-754 bit pattern is the well-known constant + // 0x3F800000 == 1065353216 -- an independently known fact about + // float encoding, not something derived from the node under test. + assert.eq( floatBitsToInt( float( 1.0 ) ), int( 1065353216 ), 'floatBitsToInt(1.0) == 0x3F800000' ); + assert.eq( floatBitsToUint( float( 1.0 ) ), uint( 1065353216 ), 'floatBitsToUint(1.0) == 0x3F800000' ); + + // -2.0f's bit pattern is 0xC0000000, which as a signed 32-bit int + // is -1073741824 (sign bit set) -- again, an independently known + // IEEE-754 fact. + assert.eq( floatBitsToInt( float( -2.0 ) ), int( -1073741824 ), 'floatBitsToInt(-2.0) == 0xC0000000 (signed)' ); + + // Round trips: reinterpreting bits out and back must recover the + // exact original value (bit-reinterpretation is lossless, unlike a + // numeric cast). + assert.eq( intBitsToFloat( floatBitsToInt( float( 3.140625 ) ) ), float( 3.140625 ), 'int bit round trip recovers the exact float' ); + assert.eq( uintBitsToFloat( floatBitsToUint( float( -7.5 ) ) ), float( -7.5 ), 'uint bit round trip recovers the exact float' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLDepthConversion.tests.js b/test/unit/addons/tsl/TSLDepthConversion.tests.js new file mode 100644 index 00000000000000..63c869a290a623 --- /dev/null +++ b/test/unit/addons/tsl/TSLDepthConversion.tests.js @@ -0,0 +1,124 @@ +import { + float, + viewZToOrthographicDepth, orthographicDepthToViewZ, + viewZToPerspectiveDepth, perspectiveDepthToViewZ, + viewZToReversedOrthographicDepth, viewZToReversedPerspectiveDepth, + viewZToLogarithmicDepth, logarithmicDepthToViewZ +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for the viewZ <-> depth conversion helpers in +// src/nodes/display/ViewportDepthNode.js. Every expected value below is a +// plain-JS transliteration of each function's own documented formula. +// +// orthographicDepthToViewZ()/perspectiveDepthToViewZ() branch internally on +// `builder.renderer.reversedDepthBuffer`, which both the WebGPU and WebGL +// backends default to `false` (see Renderer.js) and this harness never +// overrides -- so every assertion below exercises the non-reversed branch +// of each formula. +// +// viewZ is negative for points in front of the camera (see this module's +// own "NOTE" comment) -- every viewZ value below is chosen negative to +// match that convention. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'depth conversions', () => { + + gpuTest( 'orthographic viewZ<->depth conversion matches its documented formula and round-trips', ( { assert } ) => { + + const near = 1, far = 100, viewZ = - 25; + + // viewZToOrthographicDepth(viewZ, near, far) == (viewZ+near)/(near-far) + const expectedDepth = ( viewZ + near ) / ( near - far ); + const depth = viewZToOrthographicDepth( float( viewZ ), float( near ), float( far ) ); + assert.closeAbs( depth, float( expectedDepth ), 1e-5, 'viewZToOrthographicDepth matches (viewZ+near)/(near-far)' ); + + // orthographicDepthToViewZ (non-reversed branch): (near-far)*depth - near + const expectedViewZ = ( near - far ) * expectedDepth - near; + assert.closeAbs( orthographicDepthToViewZ( float( expectedDepth ), float( near ), float( far ) ), float( expectedViewZ ), 1e-4, 'orthographicDepthToViewZ matches (near-far)*depth-near' ); + + // Round trip: converting to depth and back must recover the + // original viewZ, since these are meant to be exact inverses. + assert.closeAbs( orthographicDepthToViewZ( depth, float( near ), float( far ) ), float( viewZ ), 1e-3, 'orthographicDepthToViewZ(viewZToOrthographicDepth(viewZ)) recovers viewZ' ); + + } ); + + gpuTest( 'reversed-orthographic viewZ->depth matches its documented formula', ( { assert } ) => { + + // viewZToReversedOrthographicDepth(viewZ, near, far) == (viewZ+far)/(far-near) + const near = 1, far = 100, viewZ = - 25; + const expected = ( viewZ + far ) / ( far - near ); + assert.closeAbs( viewZToReversedOrthographicDepth( float( viewZ ), float( near ), float( far ) ), float( expected ), 1e-5, 'viewZToReversedOrthographicDepth matches (viewZ+far)/(far-near)' ); + + // Reversed depth runs 1 (near) -> 0 (far), the opposite direction + // from the non-reversed form -- confirmed by comparing the two + // at the same viewZ: they must sum to 1 only in the trivial case + // where near+far cancels, so instead just check the documented + // endpoints directly: at viewZ == -near, reversed depth == 1. + assert.closeAbs( viewZToReversedOrthographicDepth( float( - near ), float( near ), float( far ) ), float( 1 ), 1e-5, 'reversed orthographic depth is 1 at the near plane' ); + assert.closeAbs( viewZToReversedOrthographicDepth( float( - far ), float( near ), float( far ) ), float( 0 ), 1e-5, 'reversed orthographic depth is 0 at the far plane' ); + + } ); + + gpuTest( 'perspective viewZ<->depth conversion matches its documented formula and round-trips', ( { assert } ) => { + + const near = 1, far = 100, viewZ = - 25; + + // viewZToPerspectiveDepth(viewZ, near, far) == (near+viewZ)*far / ((far-near)*viewZ) + const expectedDepth = ( ( near + viewZ ) * far ) / ( ( far - near ) * viewZ ); + const depth = viewZToPerspectiveDepth( float( viewZ ), float( near ), float( far ) ); + assert.closeAbs( depth, float( expectedDepth ), 1e-5, 'viewZToPerspectiveDepth matches (near+viewZ)*far/((far-near)*viewZ)' ); + + // perspectiveDepthToViewZ (non-reversed branch): near*far / ((far-near)*depth - far) + const expectedViewZ = ( near * far ) / ( ( far - near ) * expectedDepth - far ); + assert.closeAbs( perspectiveDepthToViewZ( float( expectedDepth ), float( near ), float( far ) ), float( expectedViewZ ), 1e-3, 'perspectiveDepthToViewZ matches near*far/((far-near)*depth-far)' ); + + // Round trip: converting to depth and back must recover the + // original viewZ. + assert.closeAbs( perspectiveDepthToViewZ( depth, float( near ), float( far ) ), float( viewZ ), 1e-3, 'perspectiveDepthToViewZ(viewZToPerspectiveDepth(viewZ)) recovers viewZ' ); + + } ); + + gpuTest( 'reversed-perspective viewZ->depth matches its documented formula', ( { assert } ) => { + + // viewZToReversedPerspectiveDepth(viewZ, near, far) == near*(viewZ+far) / (viewZ*(near-far)) + const near = 1, far = 100, viewZ = - 25; + const expected = ( near * ( viewZ + far ) ) / ( viewZ * ( near - far ) ); + assert.closeAbs( viewZToReversedPerspectiveDepth( float( viewZ ), float( near ), float( far ) ), float( expected ), 1e-4, 'viewZToReversedPerspectiveDepth matches near*(viewZ+far)/(viewZ*(near-far))' ); + + // Documented endpoints: reversed perspective depth is 1 at the + // near plane and 0 at the far plane (opposite direction from the + // non-reversed form, same as the orthographic case above). + assert.closeAbs( viewZToReversedPerspectiveDepth( float( - near ), float( near ), float( far ) ), float( 1 ), 1e-4, 'reversed perspective depth is 1 at the near plane' ); + assert.closeAbs( viewZToReversedPerspectiveDepth( float( - far ), float( near ), float( far ) ), float( 0 ), 1e-4, 'reversed perspective depth is 0 at the far plane' ); + + } ); + + gpuTest( 'logarithmic viewZ<->depth conversion matches its documented formula and round-trips', ( { assert } ) => { + + const near = 0.1, far = 1000, viewZ = - 25; + + // viewZToLogarithmicDepth(viewZ, near, far) == log2(-viewZ/near) / log2(far/near) + // (near is clamped to at least 1e-6 first, irrelevant here since near=0.1). + const expectedDepth = Math.log2( - viewZ / near ) / Math.log2( far / near ); + const depth = viewZToLogarithmicDepth( float( viewZ ), float( near ), float( far ) ); + assert.closeAbs( depth, float( expectedDepth ), 1e-4, 'viewZToLogarithmicDepth matches log2(-viewZ/near)/log2(far/near)' ); + + // logarithmicDepthToViewZ(depth, near, far) == -(near * e^(depth*ln(far/near))) + const expectedViewZ = - ( near * Math.exp( expectedDepth * Math.log( far / near ) ) ); + assert.closeAbs( logarithmicDepthToViewZ( float( expectedDepth ), float( near ), float( far ) ), float( expectedViewZ ), 1e-2, 'logarithmicDepthToViewZ matches -(near*e^(depth*ln(far/near)))' ); + + // Round trip: converting to depth and back must recover the + // original viewZ. + assert.closeAbs( logarithmicDepthToViewZ( depth, float( near ), float( far ) ), float( viewZ ), 1e-2, 'logarithmicDepthToViewZ(viewZToLogarithmicDepth(viewZ)) recovers viewZ' ); + + // Documented endpoints: depth is 0 exactly at the near plane and + // 1 exactly at the far plane. + assert.closeAbs( viewZToLogarithmicDepth( float( - near ), float( near ), float( far ) ), float( 0 ), 1e-4, 'logarithmic depth is 0 at the near plane' ); + assert.closeAbs( viewZToLogarithmicDepth( float( - far ), float( near ), float( far ) ), float( 1 ), 1e-4, 'logarithmic depth is 1 at the far plane' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLLogicBitwise.tests.js b/test/unit/addons/tsl/TSLLogicBitwise.tests.js new file mode 100644 index 00000000000000..2c75fc7d3c222d --- /dev/null +++ b/test/unit/addons/tsl/TSLLogicBitwise.tests.js @@ -0,0 +1,141 @@ +import { + float, int, uint, vec3, + equal, notEqual, lessThan, greaterThan, lessThanEqual, greaterThanEqual, + and, or, not, xor, select, + bitAnd, bitOr, bitXor, bitNot, shiftLeft, shiftRight, + increment, decrement, incrementBefore, decrementBefore +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Comparison/logical/bitwise-operator coverage. Every expected value below is +// derived independently in plain JS (bitwise/comparison arithmetic anyone can +// hand-check), never by re-running the same TSL expression under test -- see +// TSLMath.tests.js's file header for why that matters. +// +// bool-typed results (comparisons, and/or/not/xor) are cast through +// `float(...)` before being handed to the harness's assert.eq -- the harness +// compares raw buffer floats, and GLSL/WGSL's own bool -> float cast (false +// -> 0.0, true -> 1.0) is the natural, spec-defined way to make a bool value +// comparable there, rather than adding first-class bool support to the +// harness for this one case. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'logic and comparison operators', () => { + + gpuTest( 'relational operators at and around the boundary', ( { assert } ) => { + + assert.eq( float( equal( float( 3 ), float( 3 ) ) ), float( 1 ), 'equal(3,3) is true' ); + assert.eq( float( equal( float( 3 ), float( 3.0001 ) ) ), float( 0 ), 'equal(3,3.0001) is false -- no fuzzy tolerance' ); + assert.eq( float( notEqual( float( 3 ), float( 4 ) ) ), float( 1 ), 'notEqual(3,4) is true' ); + assert.eq( float( notEqual( float( 3 ), float( 3 ) ) ), float( 0 ), 'notEqual(3,3) is false' ); + + assert.eq( float( lessThan( float( 2 ), float( 3 ) ) ), float( 1 ), '2 < 3' ); + assert.eq( float( lessThan( float( 3 ), float( 3 ) ) ), float( 0 ), '3 < 3 is false (strict)' ); + assert.eq( float( greaterThan( float( 3 ), float( 2 ) ) ), float( 1 ), '3 > 2' ); + assert.eq( float( greaterThan( float( 3 ), float( 3 ) ) ), float( 0 ), '3 > 3 is false (strict)' ); + + assert.eq( float( lessThanEqual( float( 3 ), float( 3 ) ) ), float( 1 ), '3 <= 3 -- boundary counts as true' ); + assert.eq( float( lessThanEqual( float( 4 ), float( 3 ) ) ), float( 0 ), '4 <= 3 is false' ); + assert.eq( float( greaterThanEqual( float( 3 ), float( 3 ) ) ), float( 1 ), '3 >= 3 -- boundary counts as true' ); + assert.eq( float( greaterThanEqual( float( 2 ), float( 3 ) ) ), float( 0 ), '2 >= 3 is false' ); + + } ); + + gpuTest( 'logical and/or/not/xor truth table', ( { assert } ) => { + + const T = equal( float( 1 ), float( 1 ) ); // a real bool-typed true, not a JS boolean + const F = equal( float( 1 ), float( 0 ) ); // a real bool-typed false + + // and(): true only when both operands are true. + assert.eq( float( and( T, T ) ), float( 1 ), 'and(T,T)' ); + assert.eq( float( and( T, F ) ), float( 0 ), 'and(T,F)' ); + assert.eq( float( and( F, F ) ), float( 0 ), 'and(F,F)' ); + + // or(): true when at least one operand is true. + assert.eq( float( or( T, F ) ), float( 1 ), 'or(T,F)' ); + assert.eq( float( or( F, F ) ), float( 0 ), 'or(F,F)' ); + + // not(): simple negation. + assert.eq( float( not( T ) ), float( 0 ), 'not(T)' ); + assert.eq( float( not( F ) ), float( 1 ), 'not(F)' ); + + // xor(): true exactly when the two operands disagree. + assert.eq( float( xor( T, T ) ), float( 0 ), 'xor(T,T)' ); + assert.eq( float( xor( T, F ) ), float( 1 ), 'xor(T,F)' ); + assert.eq( float( xor( F, F ) ), float( 0 ), 'xor(F,F)' ); + + } ); + + gpuTest( 'select() picks its if/else branch by condition, independent of the branch values', ( { assert } ) => { + + assert.eq( select( equal( float( 1 ), float( 1 ) ), float( 10 ), float( 20 ) ), float( 10 ), 'select(true, 10, 20) picks the "if" branch' ); + assert.eq( select( equal( float( 1 ), float( 0 ) ), float( 10 ), float( 20 ) ), float( 20 ), 'select(false, 10, 20) picks the "else" branch' ); + + // select() (ConditionalNode) is a scalar if/else, NOT a per-component + // blend -- a vector condition is coerced down to a single bool + // (empirically: the whole-vector "else" branch wins whenever *any* + // component of the condition is false), and the *entire* if/else + // value is picked as one unit. See tsl-unit-test-findings.md. + const cond = vec3( 1, 0, 1 ).greaterThan( vec3( 0, 0, 0 ) ); + assert.eq( select( cond, vec3( 1, 2, 3 ), vec3( 100, 200, 300 ) ), vec3( 100, 200, 300 ), 'select() with a vector condition picks the "else" branch wholesale once any component is false -- it does not blend per-component' ); + + } ); + + } ); + + QUnit.module( 'bitwise operators', () => { + + gpuTest( 'bitAnd/bitOr/bitXor/bitNot on known bit patterns', ( { assert } ) => { + + // 0b0110 (6) and 0b0011 (3), hand-derived rather than re-deriving + // the answer from the operator under test. + assert.eq( bitAnd( int( 6 ), int( 3 ) ), int( 2 ), '0b0110 & 0b0011 == 0b0010 == 2' ); + assert.eq( bitOr( int( 6 ), int( 3 ) ), int( 7 ), '0b0110 | 0b0011 == 0b0111 == 7' ); + assert.eq( bitXor( int( 6 ), int( 3 ) ), int( 5 ), '0b0110 ^ 0b0011 == 0b0101 == 5' ); + + // bitNot is two's-complement: ~x == -x - 1. + assert.eq( bitNot( int( 0 ) ), int( -1 ), '~0 == -1' ); + assert.eq( bitNot( int( 5 ) ), int( -6 ), '~5 == -5-1 == -6' ); + + } ); + + gpuTest( 'shiftLeft/shiftRight match multiplying/dividing by a power of two', ( { assert } ) => { + + assert.eq( shiftLeft( uint( 1 ), uint( 4 ) ), uint( 16 ), '1 << 4 == 16' ); + assert.eq( shiftLeft( uint( 3 ), uint( 2 ) ), uint( 12 ), '3 << 2 == 12 (== 3 * 2^2)' ); + assert.eq( shiftRight( uint( 16 ), uint( 4 ) ), uint( 1 ), '16 >> 4 == 1' ); + assert.eq( shiftRight( uint( 255 ), uint( 3 ) ), uint( 31 ), '255 >> 3 == 31 (== floor(255 / 2^3))' ); + + } ); + + gpuTest( 'increment()/decrement() return the pre-mutation value, incrementBefore/decrementBefore return the post-mutation value', ( { assert } ) => { + + // increment(a)/decrement(a) are the postfix a++/a-- forms: they + // return a's value *before* mutating it. Checked against two + // independent observations of the same variable -- the returned + // snapshot, and the variable's value read back afterward -- so a + // broken implementation that returns the post-mutation value (or + // fails to mutate at all) is caught either way. + const a = int( 5 ).toVar(); + const postfixResult = increment( a ); + assert.eq( postfixResult, int( 5 ), 'increment(a) returns the value from before the increment' ); + assert.eq( a, int( 6 ), '...but a itself has been incremented as a side effect' ); + + const b = int( 5 ).toVar(); + const postfixDecResult = decrement( b ); + assert.eq( postfixDecResult, int( 5 ), 'decrement(b) returns the value from before the decrement' ); + assert.eq( b, int( 4 ), '...but b itself has been decremented as a side effect' ); + + // incrementBefore(a)/decrementBefore(a) are the prefix ++a/--a + // forms: they return the value *after* mutating it. + const c = int( 5 ).toVar(); + assert.eq( incrementBefore( c ), int( 6 ), 'incrementBefore(c) returns the value after the increment' ); + + const d = int( 5 ).toVar(); + assert.eq( decrementBefore( d ), int( 4 ), 'decrementBefore(d) returns the value after the decrement' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLMath.tests.js b/test/unit/addons/tsl/TSLMath.tests.js new file mode 100644 index 00000000000000..975fbe972c5103 --- /dev/null +++ b/test/unit/addons/tsl/TSLMath.tests.js @@ -0,0 +1,220 @@ +import { + float, int, uint, + abs, sign, floor, ceil, round, trunc, fract, + sin, cos, tan, asin, acos, atan, + exp, exp2, log, log2, sqrt, inverseSqrt, pow, + min, max, clamp, saturate, mix, step, smoothstep, + mod, reciprocal, + degrees, radians, + PI, HALF_PI, TWO_PI +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Core TSL math-function coverage. Every expected value below is derived +// independently (hand-computed or from plain JS Math), never by re-running +// the same TSL expression under test -- so these can't degrade into "test +// theater" (https://ben3d.ca/blog/the-rise-of-test-theater), assertions that +// always pass because they only check a function agrees with itself. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'math library', () => { + + gpuTest( 'trigonometric functions at known angles', ( { assert } ) => { + + assert.closeAbs( sin( float( 0 ) ), float( 0 ), 1e-6, 'sin(0)' ); + assert.closeAbs( sin( float( Math.PI / 2 ) ), float( 1 ), 1e-6, 'sin(PI/2)' ); + assert.closeAbs( sin( float( Math.PI ) ), float( 0 ), 1e-5, 'sin(PI)' ); + assert.closeAbs( cos( float( 0 ) ), float( 1 ), 1e-6, 'cos(0)' ); + assert.closeAbs( cos( float( Math.PI ) ), float( -1 ), 1e-6, 'cos(PI)' ); + assert.closeAbs( cos( float( Math.PI / 3 ) ), float( 0.5 ), 1e-5, 'cos(PI/3)' ); + assert.closeAbs( tan( float( Math.PI / 4 ) ), float( 1 ), 1e-5, 'tan(PI/4)' ); + assert.closeAbs( tan( float( 0 ) ), float( 0 ), 1e-6, 'tan(0)' ); + + } ); + + gpuTest( 'inverse trigonometric functions at domain boundaries', ( { assert } ) => { + + // asin/acos are only defined for |x| <= 1 -- testing exactly at the + // boundary (rather than beyond it) exercises the real domain edge + // without relying on implementation-defined out-of-domain behavior. + assert.closeAbs( asin( float( 1 ) ), float( Math.PI / 2 ), 1e-5, 'asin(1)' ); + assert.closeAbs( asin( float( -1 ) ), float( -Math.PI / 2 ), 1e-5, 'asin(-1)' ); + assert.closeAbs( asin( float( 0 ) ), float( 0 ), 1e-6, 'asin(0)' ); + assert.closeAbs( acos( float( 1 ) ), float( 0 ), 1e-6, 'acos(1)' ); + assert.closeAbs( acos( float( -1 ) ), float( Math.PI ), 1e-5, 'acos(-1)' ); + assert.closeAbs( acos( float( 0 ) ), float( Math.PI / 2 ), 1e-5, 'acos(0)' ); + + } ); + + gpuTest( 'atan2-style quadrant handling (2-arg atan)', ( { assert } ) => { + + // three/tsl has no separate `atan2` export -- 2-arg `atan(y, x)` is + // the quadrant-aware form. Cover all four quadrants plus the two + // axis-aligned cases per quadrant, mirroring Math.atan2 exactly + // (deliberately NOT testing atan(0, 0), which is undefined by the + // GLSL/WGSL spec and implementation-defined). + const cases = [ + [ 1, 1 ], [ 1, -1 ], [ -1, -1 ], [ -1, 1 ], + [ 0, 1 ], [ 1, 0 ], [ 0, -1 ], [ -1, 0 ] + ]; + + for ( const [ y, x ] of cases ) { + + assert.closeAbs( atan( float( y ), float( x ) ), float( Math.atan2( y, x ) ), 1e-5, `atan(${ y }, ${ x })` ); + + } + + } ); + + gpuTest( 'exponential and logarithmic functions', ( { assert } ) => { + + assert.closeAbs( exp( float( 0 ) ), float( 1 ), 1e-6, 'exp(0)' ); + assert.closeAbs( exp( float( 1 ) ), float( Math.E ), 1e-4, 'exp(1)' ); + assert.closeAbs( log( float( 1 ) ), float( 0 ), 1e-6, 'log(1)' ); + assert.closeAbs( log( float( Math.E ) ), float( 1 ), 1e-5, 'log(E)' ); + assert.closeAbs( log2( float( 8 ) ), float( 3 ), 1e-5, 'log2(8)' ); + assert.closeAbs( exp2( float( 3 ) ), float( 8 ), 1e-4, 'exp2(3)' ); + assert.closeAbs( sqrt( float( 4 ) ), float( 2 ), 1e-6, 'sqrt(4)' ); + assert.closeAbs( sqrt( float( 0 ) ), float( 0 ), 1e-6, 'sqrt(0)' ); + assert.closeAbs( inverseSqrt( float( 4 ) ), float( 0.5 ), 1e-5, 'inverseSqrt(4)' ); + assert.closeAbs( inverseSqrt( float( 0.25 ) ), float( 2 ), 1e-4, 'inverseSqrt(0.25)' ); + assert.closeAbs( pow( float( 2 ), float( 10 ) ), float( 1024 ), 1e-2, 'pow(2,10)' ); + assert.closeAbs( pow( float( 2 ), float( 0 ) ), float( 1 ), 1e-6, 'pow(2,0)' ); + assert.closeAbs( pow( float( 0 ), float( 5 ) ), float( 0 ), 1e-6, 'pow(0,5)' ); + + } ); + + gpuTest( 'rounding functions at negative and fractional edge values', ( { assert } ) => { + + // Negative-input rounding is where floor/ceil/trunc/fract most + // commonly get confused with each other -- exercised explicitly here. + assert.eq( floor( float( -1.5 ) ), float( -2 ), 'floor(-1.5)' ); + assert.eq( floor( float( 1.5 ) ), float( 1 ), 'floor(1.5)' ); + assert.eq( ceil( float( -1.5 ) ), float( -1 ), 'ceil(-1.5)' ); + assert.eq( ceil( float( 1.5 ) ), float( 2 ), 'ceil(1.5)' ); + assert.eq( trunc( float( -1.9 ) ), float( -1 ), 'trunc(-1.9)' ); + assert.eq( trunc( float( 1.9 ) ), float( 1 ), 'trunc(1.9)' ); + assert.closeAbs( fract( float( -1.5 ) ), float( 0.5 ), 1e-5, 'fract(-1.5) == -1.5 - floor(-1.5) == 0.5' ); + assert.closeAbs( fract( float( 2.25 ) ), float( 0.25 ), 1e-5, 'fract(2.25)' ); + + // round()'s rounding direction at the exact x.5 midpoint is left + // implementation-defined by both the GLSL and WGSL specs -- so we + // only assert the unambiguous, non-midpoint cases here rather than + // baking in a rounding direction that could legitimately differ + // between backends/drivers. + assert.eq( round( float( 2.4 ) ), float( 2 ), 'round(2.4)' ); + assert.eq( round( float( 2.6 ) ), float( 3 ), 'round(2.6)' ); + assert.eq( round( float( -2.4 ) ), float( -2 ), 'round(-2.4)' ); + assert.eq( round( float( -2.6 ) ), float( -3 ), 'round(-2.6)' ); + + } ); + + gpuTest( 'sign and abs at zero and negative values', ( { assert } ) => { + + assert.eq( sign( float( 0 ) ), float( 0 ), 'sign(0)' ); + assert.eq( sign( float( -5 ) ), float( -1 ), 'sign(-5)' ); + assert.eq( sign( float( 5 ) ), float( 1 ), 'sign(5)' ); + assert.eq( abs( float( -3.5 ) ), float( 3.5 ), 'abs(-3.5)' ); + assert.eq( abs( float( 0 ) ), float( 0 ), 'abs(0)' ); + + } ); + + gpuTest( 'min/max/clamp/saturate', ( { assert } ) => { + + assert.eq( min( float( 3 ), float( 1 ) ), float( 1 ), 'min(3,1)' ); + assert.eq( max( float( 3 ), float( 1 ) ), float( 3 ), 'max(3,1)' ); + assert.eq( clamp( float( 5 ), float( 0 ), float( 1 ) ), float( 1 ), 'clamp(5,0,1)' ); + assert.eq( clamp( float( -5 ), float( 0 ), float( 1 ) ), float( 0 ), 'clamp(-5,0,1)' ); + assert.eq( clamp( float( 0.5 ), float( 0 ), float( 1 ) ), float( 0.5 ), 'clamp(0.5,0,1) passes through' ); + assert.eq( saturate( float( -5 ) ), float( 0 ), 'saturate(-5)' ); + assert.eq( saturate( float( 5 ) ), float( 1 ), 'saturate(5)' ); + + } ); + + gpuTest( 'mix/step/smoothstep', ( { assert } ) => { + + assert.closeAbs( mix( float( 0 ), float( 10 ), float( 0.3 ) ), float( 3 ), 1e-5, 'mix(0,10,0.3)' ); + // mix() does not clamp its interpolation factor -- t outside [0,1] + // legally extrapolates. Confirms that's really what happens rather + // than silently clamping. + assert.closeAbs( mix( float( 0 ), float( 10 ), float( 1.5 ) ), float( 15 ), 1e-4, 'mix(0,10,1.5) extrapolates past b' ); + assert.closeAbs( mix( float( 0 ), float( 10 ), float( -0.5 ) ), float( -5 ), 1e-4, 'mix(0,10,-0.5) extrapolates before a' ); + + assert.eq( step( float( 0.5 ), float( 0.3 ) ), float( 0 ), 'step(edge=0.5, x=0.3) -- x < edge' ); + assert.eq( step( float( 0.5 ), float( 0.7 ) ), float( 1 ), 'step(edge=0.5, x=0.7) -- x >= edge' ); + assert.eq( step( float( 0.5 ), float( 0.5 ) ), float( 1 ), 'step(edge=0.5, x=0.5) -- x == edge counts as >= edge' ); + + assert.closeAbs( smoothstep( float( 0 ), float( 1 ), float( 0.5 ) ), float( 0.5 ), 1e-5, 'smoothstep midpoint is exactly 0.5' ); + assert.eq( smoothstep( float( 0 ), float( 1 ), float( -1 ) ), float( 0 ), 'smoothstep clamps below edge0' ); + assert.eq( smoothstep( float( 0 ), float( 1 ), float( 2 ) ), float( 1 ), 'smoothstep clamps above edge1' ); + + } ); + + gpuTest( 'reciprocal and degrees/radians conversions', ( { assert } ) => { + + assert.closeAbs( reciprocal( float( 4 ) ), float( 0.25 ), 1e-5, 'reciprocal(4)' ); + assert.closeAbs( reciprocal( float( 0.25 ) ), float( 4 ), 1e-4, 'reciprocal(0.25)' ); + assert.closeAbs( degrees( float( Math.PI ) ), float( 180 ), 1e-3, 'degrees(PI)' ); + assert.closeAbs( radians( float( 180 ) ), float( Math.PI ), 1e-5, 'radians(180)' ); + assert.closeAbs( PI, float( Math.PI ), 1e-6, 'PI constant' ); + assert.closeAbs( HALF_PI, float( Math.PI / 2 ), 1e-6, 'HALF_PI constant' ); + assert.closeAbs( TWO_PI, float( Math.PI * 2 ), 1e-5, 'TWO_PI constant' ); + + } ); + + // --- mod(): a real, verified cross-type behavioral divergence ----- + // + // `mod()` is a single TSL entry point (OperatorNode, '%'), but its + // codegen branches on operand type (see OperatorNode.js's '%' case): + // integer operands compile to the native `%` operator (C-style + // truncated division, sign follows the dividend), while float + // operands compile to the GLSL/WGSL `mod()` builtin (floored + // division, sign follows the divisor). For negative operands these + // give genuinely different mathematical results from the *same* + // TSL function name -- this is not a bug (both are spec-correct for + // their respective codegen paths) but it is a sharp edge worth + // locking down so a future refactor can't silently unify the two + // and change behavior. + gpuTest( 'mod(): integer truncated vs. float floored semantics diverge on negative operands', ( { assert } ) => { + + // Every operand below is forced through `.toVar()` -- a genuine + // runtime variable -- rather than left as a bare compile-time + // constant. This sidesteps a *separate*, real bug (documented in + // tsl-unit-test-findings.md): constant-folded numeric literals are + // re-emitted via `NodeBuilder.generateConst()`, which uses + // `Math.round()` regardless of target type, so a constant int + // expression can silently round instead of using real integer + // arithmetic. Forcing a `.toVar()` here means this test exercises + // mod()'s actual shader-level codegen (the thing under test), + // not that unrelated constant-folding path. + + // float path: floored mod, x - y*floor(x/y) -- sign follows the divisor. + assert.closeAbs( mod( float( -5 ).toVar(), float( 3 ).toVar() ), float( 1 ), 1e-5, 'mod(-5.0, 3.0) floored == 1' ); + assert.closeAbs( mod( float( 5 ).toVar(), float( -3 ).toVar() ), float( -1 ), 1e-5, 'mod(5.0, -3.0) floored == -1' ); + + // integer path: truncated mod (C-style '%') -- sign follows the dividend. + assert.eq( mod( int( -5 ).toVar(), int( 3 ).toVar() ), int( -2 ), 'mod(-5, 3) truncated == -2 (differs from the float case above!)' ); + assert.eq( mod( int( 5 ).toVar(), int( -3 ).toVar() ), int( 2 ), 'mod(5, -3) truncated == 2 (differs from the float case above!)' ); + + // Positive operands agree between both codegen paths, as expected. + assert.closeAbs( mod( float( 5 ).toVar(), float( 3 ).toVar() ), float( 2 ), 1e-5, 'mod(5.0, 3.0)' ); + assert.eq( mod( int( 5 ).toVar(), int( 3 ).toVar() ), int( 2 ), 'mod(5, 3)' ); + + } ); + + gpuTest( 'uint/int/float type-cast round trips', ( { assert } ) => { + + assert.eq( uint( int( 5 ) ).toInt(), int( 5 ), 'uint<->int round trip for a positive value' ); + assert.eq( float( int( 7 ) ), float( 7 ), 'int -> float cast' ); + + // `.toVar()` forces a real runtime cast rather than a compile-time + // constant fold -- see the `mod()` test above for why that + // distinction matters here. + assert.eq( int( float( 7.9 ).toVar() ), int( 7 ), 'float -> int cast truncates toward zero' ); + assert.eq( int( float( -7.9 ).toVar() ), int( -7 ), 'float -> int cast truncates toward zero (negative)' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLMathExtra.tests.js b/test/unit/addons/tsl/TSLMathExtra.tests.js new file mode 100644 index 00000000000000..29d03a5ae12e29 --- /dev/null +++ b/test/unit/addons/tsl/TSLMathExtra.tests.js @@ -0,0 +1,94 @@ +import { + float, vec3, + sinh, cosh, tanh, asinh, acosh, atanh, + pow2, pow3, pow4, cbrt, lengthSq, difference, + all, any, greaterThan +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Additional TSL math-function coverage that doesn't already live in +// TSLMath.tests.js: hyperbolic/inverse-hyperbolic functions, small +// power/root helper shorthands, and vector-to-scalar boolean reductions. +// Kept as a separate file (rather than appended to TSLMath.tests.js) to +// avoid colliding with other in-flight edits to that file. +// +// Every expected value below is derived independently (hand-computed or +// from plain JS Math), never by re-running the same TSL expression 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( 'math library (extra)', () => { + + gpuTest( 'hyperbolic and inverse hyperbolic functions', ( { assert } ) => { + + // sinh/cosh/tanh checked against their exponential-form definitions, + // hand-computed in plain JS rather than re-deriving them from the + // TSL expressions under test. + assert.closeAbs( sinh( float( 0 ) ), float( 0 ), 1e-6, 'sinh(0)' ); + assert.closeAbs( sinh( float( 1 ) ), float( ( Math.E - 1 / Math.E ) / 2 ), 1e-4, 'sinh(1)' ); + assert.closeAbs( cosh( float( 0 ) ), float( 1 ), 1e-6, 'cosh(0)' ); + assert.closeAbs( cosh( float( 1 ) ), float( ( Math.E + 1 / Math.E ) / 2 ), 1e-4, 'cosh(1)' ); + assert.closeAbs( tanh( float( 0 ) ), float( 0 ), 1e-6, 'tanh(0)' ); + // tanh saturates hard for large |x| -- confirms it approaches +-1 + // rather than overflowing/diverging. + assert.closeAbs( tanh( float( 20 ) ), float( 1 ), 1e-4, 'tanh(20) saturates to 1' ); + assert.closeAbs( tanh( float( -20 ) ), float( -1 ), 1e-4, 'tanh(-20) saturates to -1' ); + + // Inverse hyperbolic functions are each checked against the same + // independently hand-computed values used for the forward functions + // above (not merely round-tripped through the TSL functions + // themselves). + assert.closeAbs( asinh( float( ( Math.E - 1 / Math.E ) / 2 ) ), float( 1 ), 1e-4, 'asinh(sinh(1)) == 1' ); + assert.closeAbs( acosh( float( ( Math.E + 1 / Math.E ) / 2 ) ), float( 1 ), 1e-4, 'acosh(cosh(1)) == 1' ); + assert.closeAbs( acosh( float( 1 ) ), float( 0 ), 1e-6, 'acosh(1) == 0 -- domain boundary' ); + assert.closeAbs( atanh( float( 0 ) ), float( 0 ), 1e-6, 'atanh(0)' ); + assert.closeAbs( atanh( float( 0.5 ) ), float( Math.log( 3 ) / 2 ), 1e-4, 'atanh(0.5) == 0.5*ln((1+x)/(1-x))' ); + + } ); + + gpuTest( 'power/root helpers and vector-reduction shorthands', ( { assert } ) => { + + // pow2/pow3/pow4 are plain repeated-multiplication shorthands -- + // checked against x*x, x*x*x, x*x*x*x computed independently in JS. + assert.closeAbs( pow2( float( 3 ) ), float( 9 ), 1e-5, 'pow2(3) == 9' ); + assert.closeAbs( pow3( float( 3 ) ), float( 27 ), 1e-4, 'pow3(3) == 27' ); + assert.closeAbs( pow4( float( 3 ) ), float( 81 ), 1e-3, 'pow4(3) == 81' ); + assert.closeAbs( pow2( float( -2 ) ), float( 4 ), 1e-5, 'pow2(-2) == 4 -- sign vanishes for an even power' ); + + // cbrt(x) == sign(x) * abs(x)^(1/3) -- explicitly handles negative + // inputs, unlike a naive pow(x, 1/3) which is undefined for x < 0. + assert.closeAbs( cbrt( float( 27 ) ), float( 3 ), 1e-4, 'cbrt(27) == 3' ); + assert.closeAbs( cbrt( float( -27 ) ), float( -3 ), 1e-4, 'cbrt(-27) == -3 -- negative inputs are supported, unlike plain pow(x, 1/3)' ); + assert.closeAbs( cbrt( float( 0 ) ), float( 0 ), 1e-6, 'cbrt(0) == 0' ); + + // lengthSq(v) == dot(v,v), i.e. squared length without the sqrt -- + // checked against the same 3-4-5-style triangle used elsewhere in + // this suite, so the expected value is independently known. + assert.closeAbs( lengthSq( vec3( 3, 4, 0 ) ), float( 25 ), 1e-4, 'lengthSq(3,4,0) == 5^2 == 25' ); + + // difference(a, b) == abs(a - b), verified with an asymmetric, + // order-sensitive pair of vectors. + assert.closeAbs( difference( vec3( 1, 5, -3 ), vec3( 4, 2, -3 ) ), vec3( 3, 3, 0 ), 1e-5, 'difference() is component-wise abs(a - b)' ); + + } ); + + gpuTest( 'all()/any() vector-to-scalar boolean reductions', ( { assert } ) => { + + // all()/any() return a bool -- cast to float(...) for comparison so + // the harness doesn't need first-class bool support (0.0/1.0 + // following the usual GLSL/WGSL bool -> float cast convention). + const allTrue = greaterThan( vec3( 1, 1, 1 ), vec3( 0, 0, 0 ) ); + const mixed = greaterThan( vec3( 1, -1, 1 ), vec3( 0, 0, 0 ) ); + const allFalse = greaterThan( vec3( -1, -1, -1 ), vec3( 0, 0, 0 ) ); + + assert.eq( float( all( allTrue ) ), float( 1 ), 'all() is true when every component satisfies the condition' ); + assert.eq( float( all( mixed ) ), float( 0 ), 'all() is false as soon as one component fails' ); + assert.eq( float( any( mixed ) ), float( 1 ), 'any() is true when at least one component satisfies the condition' ); + assert.eq( float( any( allFalse ) ), float( 0 ), 'any() is false when no component satisfies the condition' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLNoise.tests.js b/test/unit/addons/tsl/TSLNoise.tests.js new file mode 100644 index 00000000000000..dc43f72019282f --- /dev/null +++ b/test/unit/addons/tsl/TSLNoise.tests.js @@ -0,0 +1,49 @@ +import { float, vec3, triNoise3D } from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for triNoise3D() (src/nodes/math/TriNoise3D.js). This is +// deliberately light: triNoise3D() is a 4-iteration accumulation of nested +// tri(x) = |fract(x)-0.5| triangle-wave terms, and porting that whole +// algorithm to independent plain JS just to re-derive exact expected values +// is more machinery than it's worth here -- so this only checks properties +// derivable without a full port: determinism, and the value staying inside +// the range the accumulation can mathematically produce. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'triNoise3D()', () => { + + gpuTest( 'triNoise3D() is deterministic -- the same inputs always produce the same output', ( { assert } ) => { + + const position = vec3( 2.0, 2.0, 2.0 ), speed = float( 1.0 ), time = float( 1.0 ); + assert.eq( triNoise3D( position, speed, time ), triNoise3D( position, speed, time ), 'triNoise3D(p, s, t) called twice with identical inputs returns identical output' ); + + } ); + + gpuTest( 'triNoise3D() stays within the range its accumulation can mathematically produce', ( { assert } ) => { + + // Each of the 4 loop iterations adds tri(x)/z, where tri(x) is + // in [0, 0.5] (it's |fract(x)-0.5|) and z is 1.4 scaled by 1.5 + // *before* each addition (z = 2.1, 3.15, 4.725, 7.0875 across + // the 4 iterations) -- so the whole sum is bounded above by + // 0.5 * (1/2.1 + 1/3.15 + 1/4.725 + 1/7.0875), regardless of + // input, and can never be negative (every term is >= 0). + const upperBound = 0.5 * ( 1 / 2.1 + 1 / 3.15 + 1 / 4.725 + 1 / 7.0875 ); + + const samples = [ + triNoise3D( vec3( 1.5, - 0.7, 2.3 ), float( 0.5 ), float( 3.0 ) ), + triNoise3D( vec3( - 4.1, 0.9, - 1.2 ), float( 1.3 ), float( 0.25 ) ), + triNoise3D( vec3( 0, 0, 0 ), float( 0 ), float( 0 ) ) + ]; + + for ( const sample of samples ) { + + assert.greaterThanOrEqual( sample, float( 0 ), 'triNoise3D output is never negative' ); + assert.lessThanOrEqual( sample, float( upperBound ), `triNoise3D output is at most ${ upperBound.toFixed( 4 ) }` ); + + } + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLPacking.tests.js b/test/unit/addons/tsl/TSLPacking.tests.js new file mode 100644 index 00000000000000..3a29aa3077fd57 --- /dev/null +++ b/test/unit/addons/tsl/TSLPacking.tests.js @@ -0,0 +1,146 @@ +import { + float, vec2, vec3, vec4, hash, abs, + packSnorm2x16, unpackSnorm2x16, + packUnorm2x16, unpackUnorm2x16, + packHalf2x16, unpackHalf2x16, + packSnorm4x8, unpackSnorm4x8, + packUnorm4x8, unpackUnorm4x8, + packNormalToRGB, unpackRGBToNormal, unpackNormal, + length +} from 'three/tsl'; +import { gpuTest, gpuFuzzTest } from './gpu-test-utils.js'; + +// Packing/unpacking coverage: every round-trip test checks a value that was +// packed and then unpacked against the *original* input (an independent, +// pre-existing value -- not something derived from the packed result), so a +// broken pack() or unpack() that just agreed with itself can't slip through. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'packing', () => { + + gpuTest( 'packSnorm2x16 <-> unpackSnorm2x16 round trip', ( { assert } ) => { + + const v = vec2( 0.5, -0.25 ); + // 16-bit quantization step is 1/32767 -- allow a couple of ULPs. + assert.closeAbs( unpackSnorm2x16( packSnorm2x16( v ) ), v, 2e-4, 'mid-range value' ); + + const edge = vec2( 1.0, -1.0 ); + assert.closeAbs( unpackSnorm2x16( packSnorm2x16( edge ) ), edge, 2e-4, 'exact +-1 boundary' ); + + // Out-of-range input must clamp to the representable range, not wrap. + const outOfRange = vec2( 2.0, -3.0 ); + assert.closeAbs( unpackSnorm2x16( packSnorm2x16( outOfRange ) ), vec2( 1.0, -1.0 ), 2e-4, 'out-of-range input clamps to [-1, 1] rather than wrapping' ); + + } ); + + gpuTest( 'packUnorm2x16 <-> unpackUnorm2x16 round trip', ( { assert } ) => { + + const v = vec2( 0.75, 0.1 ); + assert.closeAbs( unpackUnorm2x16( packUnorm2x16( v ) ), v, 2e-4, 'mid-range value' ); + + assert.closeAbs( unpackUnorm2x16( packUnorm2x16( vec2( 0.0, 1.0 ) ) ), vec2( 0.0, 1.0 ), 2e-4, '0/1 boundaries' ); + + // Out-of-range (including negative) input clamps into [0, 1]. + const outOfRange = vec2( -1.0, 2.0 ); + assert.closeAbs( unpackUnorm2x16( packUnorm2x16( outOfRange ) ), vec2( 0.0, 1.0 ), 2e-4, 'out-of-range input clamps to [0, 1]' ); + + } ); + + gpuTest( 'packHalf2x16 <-> unpackHalf2x16 round trip', ( { assert } ) => { + + 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)' ); + + } ); + + gpuTest( 'packSnorm4x8 <-> unpackSnorm4x8 round trip', ( { assert } ) => { + + const v = vec4( 1.0, 0.5, -0.5, -1.0 ); + // 8-bit quantization step is 1/127 -- much coarser than the 16-bit variants. + assert.closeAbs( unpackSnorm4x8( packSnorm4x8( v ) ), v, 1e-2, 'full-range value' ); + + } ); + + gpuTest( 'packUnorm4x8 <-> unpackUnorm4x8 round trip', ( { assert } ) => { + + const v = vec4( 0.0, 0.25, 0.75, 1.0 ); + assert.closeAbs( unpackUnorm4x8( packUnorm4x8( v ) ), v, 1e-2, 'full-range value' ); + + } ); + + gpuTest( 'packNormalToRGB <-> unpackRGBToNormal round trip and known values', ( { assert } ) => { + + // packNormalToRGB(n) == n * 0.5 + 0.5 -- verified against a + // hand-computed value, not merely its own inverse. + assert.closeAbs( packNormalToRGB( vec3( 0, 0, 1 ) ), vec3( 0.5, 0.5, 1.0 ), 1e-6, 'packNormalToRGB(+Z) == (0.5, 0.5, 1.0)' ); + assert.closeAbs( packNormalToRGB( vec3( -1, -1, -1 ) ), vec3( 0, 0, 0 ), 1e-6, 'packNormalToRGB(-1,-1,-1) == black' ); + + const n = vec3( 0.6, -0.8, 0.0 ); // a unit vector (0.6^2 + 0.8^2 == 1) + assert.closeAbs( unpackRGBToNormal( packNormalToRGB( n ) ), n, 1e-5, 'round trip recovers the original direction' ); + + } ); + + gpuTest( 'unpackNormal reconstructs Z on the unit hemisphere', ( { assert } ) => { + + // dot(xy, xy) == 0 -> z == 1 (straight up). + assert.closeAbs( unpackNormal( vec2( 0, 0 ) ), vec3( 0, 0, 1 ), 1e-6, 'unpackNormal(0,0) is the pole' ); + + // A 3-4-5 triangle projected to the unit disk: xy = (0.6, 0.8) has + // dot == 1, so the reconstructed Z must be exactly 0. + assert.closeAbs( unpackNormal( vec2( 0.6, 0.8 ) ), vec3( 0.6, 0.8, 0 ), 1e-5, 'xy exactly on the unit circle reconstructs z == 0' ); + + // Known documented caveat: unpackNormal()'s docstring requires xy + // in [-1, 1], but the implementation only saturates the *radicand* + // under the sqrt -- it never re-normalizes xy itself. Feeding it an + // xy pair outside the unit disk therefore reconstructs z == 0 (since + // 1 - dot(xy,xy) saturates to 0) while silently returning a + // non-unit-length vector instead of erroring or clamping xy. This + // locks down that real, surprising behavior rather than asserting + // what one might *expect* (a re-normalized unit vector). + const outside = unpackNormal( vec2( 2, 0 ) ); + assert.closeAbs( outside, vec3( 2, 0, 0 ), 1e-6, 'xy outside the unit disk: z silently becomes 0 instead of the input being renormalized' ); + assert.closeAbs( length( outside ), float( 2 ), 1e-5, '...so the "normal" returned is not actually unit length' ); + + } ); + + gpuFuzzTest( 'hash() output stays in [0, 1)', 256, ( { instanceIndex, assert } ) => { + + const h = hash( instanceIndex.add( 1 ).toFloat() ); + + assert.greaterThanOrEqual( h, float( 0 ), 'hash() >= 0' ); + assert.lessThan( h, float( 1 ), 'hash() < 1' ); + + } ); + + gpuFuzzTest( 'hash() is a pure function of its seed', 256, ( { instanceIndex, assert } ) => { + + // Split out from the range check above rather than added as a + // third call site there: this harness's gpuFuzzTest backs every + // site with its own dedicated column-buffers, and this sandbox's + // WebGL2 fallback implementation only reliably supports ~4 + // simultaneously-bound buffers in one kernel (2 sites' worth of + // actual+expected pairs) -- a 3rd site in the same test silently + // corrupted one site's readback here. Keeping each gpuFuzzTest to + // a small number of sites sidesteps that ceiling; see + // gpu-test-utils.js's gpuFuzzTest doc comment. + const seed = instanceIndex.add( 1 ).toFloat(); + assert.eq( hash( seed ), hash( seed ), 'same seed always gives the same output' ); + + } ); + + gpuFuzzTest( 'hash() is sensitive to its seed (no collisions across 512 distinct seeds sampled)', 256, ( { instanceIndex, assert } ) => { + + // Two distinct, well-separated seeds derived from instanceIndex + // should (overwhelmingly likely) hash to different values -- a + // hash that degenerated into a constant or a low-period function + // would fail this near-certainly across 256 independent samples. + const a = hash( instanceIndex.add( 1 ).toFloat() ); + const b = hash( instanceIndex.add( 100000 ).toFloat() ); + + assert.greaterThan( abs( a.sub( b ) ), float( 1e-6 ), 'two well-separated seeds hash to different values' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLProceduralUtils.tests.js b/test/unit/addons/tsl/TSLProceduralUtils.tests.js new file mode 100644 index 00000000000000..b5c1069a5df9a5 --- /dev/null +++ b/test/unit/addons/tsl/TSLProceduralUtils.tests.js @@ -0,0 +1,119 @@ +import { + float, vec2, vec4, + premultiplyAlpha, unpremultiplyAlpha, + rotateUV, spherizeUV, checker +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Standalone utility-function coverage: alpha premultiplication +// (src/nodes/display/PremultiplyAlphaFunctions.js), UV transforms +// (src/nodes/utils/UVUtils.js), and the procedural checkerboard +// (src/nodes/procedural/Checker.js). Every expected value below is derived +// independently (hand-computed from the documented formula), never by +// re-running the same TSL expression 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( 'alpha premultiplication', () => { + + gpuTest( 'premultiplyAlpha() scales RGB by alpha, leaves alpha unchanged', ( { assert } ) => { + + assert.closeAbs( premultiplyAlpha( vec4( 1, 0.5, 0.2, 0.5 ) ), vec4( 0.5, 0.25, 0.1, 0.5 ), 1e-5, 'premultiplyAlpha() scales rgb by a=0.5' ); + assert.closeAbs( premultiplyAlpha( vec4( 0.4, 0.8, 1, 1 ) ), vec4( 0.4, 0.8, 1, 1 ), 1e-5, 'premultiplyAlpha() with a=1 is a no-op' ); + assert.closeAbs( premultiplyAlpha( vec4( 1, 1, 1, 0 ) ), vec4( 0, 0, 0, 0 ), 1e-5, 'premultiplyAlpha() with a=0 zeroes rgb entirely' ); + + } ); + + gpuTest( 'unpremultiplyAlpha() divides RGB by alpha, and special-cases a=0 to avoid a NaN from 0/0', ( { assert } ) => { + + assert.closeAbs( unpremultiplyAlpha( vec4( 0.5, 0.25, 0.1, 0.5 ) ), vec4( 1, 0.5, 0.2, 0.5 ), 1e-5, 'unpremultiplyAlpha() divides rgb by a=0.5 -- inverse of the premultiplyAlpha() case above' ); + assert.closeAbs( unpremultiplyAlpha( vec4( 0.4, 0.8, 1, 1 ) ), vec4( 0.4, 0.8, 1, 1 ), 1e-5, 'unpremultiplyAlpha() with a=1 is a no-op' ); + + // a==0 takes the explicit select() branch returning vec4(0) outright, + // rather than evaluating rgb/0 (which would be NaN/Inf on a GPU, + // same IEEE-754 hazard the sinc()/pcurve() findings in + // tsl-unit-test-findings.md ran into for other functions). + assert.closeAbs( unpremultiplyAlpha( vec4( 0.3, 0.6, 0.9, 0 ) ), vec4( 0, 0, 0, 0 ), 1e-5, 'unpremultiplyAlpha() with a=0 returns vec4(0) instead of dividing by zero' ); + + } ); + + gpuTest( 'premultiplyAlpha()/unpremultiplyAlpha() round-trip for non-zero alpha', ( { assert } ) => { + + const original = vec4( 0.7, 0.3, 0.9, 0.42 ); + assert.closeAbs( unpremultiplyAlpha( premultiplyAlpha( original ) ), original, 1e-4, 'unpremultiplyAlpha(premultiplyAlpha(x)) == x for a != 0' ); + + } ); + + } ); + + QUnit.module( 'UV transforms', () => { + + gpuTest( 'rotateUV() rotates counter-clockwise about a center point, matching rotate()\'s own documented convention', ( { assert } ) => { + + // rotateUV(uv, angle, center) == rotate(uv - center, angle) + center. + // rotate() itself is independently verified (and its correct CCW + // convention documented) in TSLCurveUtils.tests.js -- this only + // checks that rotateUV() correctly re-centers around a non-origin + // point, using the same CCW convention. + // uv=(1.5, 1) about center=(0.5, 1) -> offset (1, 0), rotated 90° + // CCW -> (0, 1), + center -> (0.5, 2). + assert.closeAbs( rotateUV( vec2( 1.5, 1 ), float( Math.PI / 2 ), vec2( 0.5, 1 ) ), vec2( 0.5, 2 ), 1e-4, 'rotateUV() by 90° CCW about a non-origin center' ); + + // The center point itself is always a fixed point of the rotation. + assert.closeAbs( rotateUV( vec2( 0.5, 0.5 ), float( 1.234 ), vec2( 0.5, 0.5 ) ), vec2( 0.5, 0.5 ), 1e-4, 'rotateUV() leaves the center point itself unchanged, for any angle' ); + + // Default center is (0.5, 0.5) when omitted. + assert.closeAbs( rotateUV( vec2( 1, 0.5 ), float( Math.PI ) ), vec2( 0, 0.5 ), 1e-4, 'rotateUV() defaults center to (0.5, 0.5) -- 180° about it maps (1,0.5) to (0,0.5)' ); + + } ); + + gpuTest( 'spherizeUV() warps uv by an amount that grows with distance^4 from the center', ( { assert } ) => { + + // spherizeUV(uv, strength, center) == uv + delta * (dot(delta,delta)^2 * strength), + // where delta = uv - center. + // uv=(0.6, 0.5), center=(0.5, 0.5), strength=2: + // delta = (0.1, 0), delta2 = 0.01, delta4 = 0.0001, + // deltaOffset = 0.0001 * 2 = 0.0002 + // result = (0.6 + 0.1*0.0002, 0.5 + 0*0.0002) = (0.60002, 0.5) + assert.closeAbs( spherizeUV( vec2( 0.6, 0.5 ), float( 2 ), vec2( 0.5, 0.5 ) ), vec2( 0.60002, 0.5 ), 1e-6, 'spherizeUV() matches the hand-computed delta^4 warp formula' ); + + // The center point is a fixed point (delta == 0 -> no offset at all). + assert.closeAbs( spherizeUV( vec2( 0.5, 0.5 ), float( 5 ), vec2( 0.5, 0.5 ) ), vec2( 0.5, 0.5 ), 1e-6, 'spherizeUV() leaves the center point unchanged' ); + + // strength=0 is a no-op everywhere, regardless of distance from center. + assert.closeAbs( spherizeUV( vec2( 0.9, 0.9 ), float( 0 ), vec2( 0.5, 0.5 ) ), vec2( 0.9, 0.9 ), 1e-6, 'spherizeUV() with strength=0 is a no-op' ); + + } ); + + } ); + + QUnit.module( 'procedural functions', () => { + + gpuTest( 'checker() produces a 2x2-per-unit checkerboard, GLSL-style mod() for negative coordinates included', ( { assert } ) => { + + // checker(coord) == sign(mod(floor(2*coord.x) + floor(2*coord.y), 2)), + // where mod() is GLSL-style (result always in [0, 2), even for + // negative inputs -- unlike JS's `%`, which can return negative). + assert.eq( checker( vec2( 0, 0 ) ), float( 0 ), 'checker(0,0): uv=(0,0), cx=0, cy=0, sum=0 -> 0' ); + assert.eq( checker( vec2( 0.5, 0 ) ), float( 1 ), 'checker(0.5,0): uv=(1,0), cx=1, cy=0, sum=1 -> 1' ); + assert.eq( checker( vec2( 0.5, 0.5 ) ), float( 0 ), 'checker(0.5,0.5): uv=(1,1), cx=1, cy=1, sum=2, mod(2,2)=0 -> 0' ); + assert.eq( checker( vec2( 0.75, 0.25 ) ), float( 1 ), 'checker(0.75,0.25): uv=(1.5,0.5), cx=1, cy=0, sum=1 -> 1' ); + + // GLSL-style mod() always returns a non-negative result (unlike + // JS's `%`, which can go negative) -- checked explicitly with + // coordinates below the origin, since this is exactly the kind of + // edge case a naive JS-`%`-based port would get wrong. + assert.eq( checker( vec2( - 0.25, 0 ) ), float( 1 ), 'checker(-0.25,0): uv=(-0.5,0), cx=-1, cy=0, sum=-1, GLSL mod(-1,2)=1 -> 1' ); + assert.eq( checker( vec2( - 0.75, 0 ) ), float( 0 ), 'checker(-0.75,0): uv=(-1.5,0), cx=-2, cy=0, sum=-2, GLSL mod(-2,2)=0 -> 0' ); + + // Default coord parameter is uv() when omitted -- not exercised + // numerically here (it depends on the current render/compute + // context's own UV attribute), but the explicit-coord form above + // covers checker()'s actual math in full. + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLToneMapping.tests.js b/test/unit/addons/tsl/TSLToneMapping.tests.js new file mode 100644 index 00000000000000..42d57131d7d918 --- /dev/null +++ b/test/unit/addons/tsl/TSLToneMapping.tests.js @@ -0,0 +1,130 @@ +import { + float, vec3, + linearToneMapping, reinhardToneMapping, cineonToneMapping, + acesFilmicToneMapping, agxToneMapping, neutralToneMapping +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Tone-mapping function coverage (src/nodes/display/ToneMappingFunctions.js). +// Every expected value below is derived independently -- either a +// hand-checkable closed form (linear/reinhard/cineon), or a from-scratch +// plain-JS port of the *documented* algorithm (ACESFilmic/AgX/Neutral, +// which involve 3x3 matrices and several stages), computed with Node's own +// `Math`, never by re-running the TSL expression under test -- see +// TSLMath.tests.js's file header for why that matters +// (https://ben3d.ca/blog/the-rise-of-test-theater). Numeric expectations for +// the three multi-stage operators were produced once via an independent JS +// port kept out of this file (not shipped, not imported) and hardcoded here. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'tone mapping functions', () => { + + gpuTest( 'linearToneMapping() -- color * exposure, clamped to [0,1]', ( { assert } ) => { + + assert.closeAbs( linearToneMapping( vec3( 0.5, 1.5, - 0.2 ), float( 2 ) ), vec3( 1, 1, 0 ), 1e-5, 'linearToneMapping((0.5,1.5,-0.2), 2) clamps both above 1 and below 0' ); + assert.closeAbs( linearToneMapping( vec3( 0.25 ), float( 2 ) ), vec3( 0.5 ), 1e-5, 'linearToneMapping(0.25, 2) == 0.5 -- within range, no clamping' ); + + } ); + + gpuTest( 'reinhardToneMapping() -- color / (color + 1)', ( { assert } ) => { + + assert.closeAbs( reinhardToneMapping( vec3( 3 ), float( 1 ) ), vec3( 0.75 ), 1e-5, 'reinhardToneMapping(3, 1) == 3/4 == 0.75' ); + assert.closeAbs( reinhardToneMapping( vec3( 0 ), float( 1 ) ), vec3( 0 ), 1e-6, 'reinhardToneMapping(0, 1) == 0' ); + assert.closeAbs( reinhardToneMapping( vec3( 1 ), float( 0 ) ), vec3( 0 ), 1e-6, 'reinhardToneMapping(x, 0) == 0 -- zero exposure crushes everything to black' ); + + } ); + + gpuTest( 'cineonToneMapping() -- Hejl/Burgess-Dawson filmic curve', ( { assert } ) => { + + // Below the 0.004 toe threshold, max(color-0.004, 0) clamps to + // exactly 0, and a==0 -> a/b==0 -> pow(0, 2.2)==0, regardless of b. + assert.closeAbs( cineonToneMapping( vec3( 0.004 ), float( 1 ) ), vec3( 0 ), 1e-6, 'cineonToneMapping(0.004, 1) == 0 -- exactly at the toe threshold' ); + assert.closeAbs( cineonToneMapping( vec3( 0 ), float( 1 ) ), vec3( 0 ), 1e-6, 'cineonToneMapping(0, 1) == 0' ); + + // color=0.204 was chosen so color-0.004 == 0.2 exactly, keeping the + // hand/JS-computed reference simple: a = 0.348, b = 0.648, + // a/b = 29/54, result = (29/54)^2.2. + assert.closeAbs( cineonToneMapping( vec3( 0.204 ), float( 1 ) ), vec3( 0.254688 ), 2e-4, 'cineonToneMapping(0.204, 1) matches the independently-computed filmic curve' ); + assert.closeAbs( cineonToneMapping( vec3( 1 ), float( 1 ) ), vec3( 0.683542 ), 2e-4, 'cineonToneMapping(1, 1) matches the independently-computed filmic curve' ); + + } ); + + gpuTest( 'acesFilmicToneMapping() matches an independent JS port of the ACES RRT+ODT fit', ( { assert } ) => { + + // A uniform gray input is a specifically useful check here: both + // ACESInputMat and ACESOutputMat have rows that each sum to (very + // nearly) 1.0 -- a genuine property of these published matrices, + // not an artifact of the code under test -- so a uniform vec3 + // passes through both matrix multiplications completely unchanged, + // isolating the nonlinear RRTAndODTFit curve as the only thing + // actually being exercised by this particular assertion. + assert.closeAbs( acesFilmicToneMapping( vec3( 0.18 ), float( 1 ) ), vec3( 0.214097 ), 5e-4, 'acesFilmicToneMapping(0.18 gray, 1) matches the independent JS port' ); + assert.closeAbs( acesFilmicToneMapping( vec3( 0 ), float( 1 ) ), vec3( 0 ), 1e-4, 'acesFilmicToneMapping(0, 1) == 0' ); + assert.closeAbs( acesFilmicToneMapping( vec3( 1 ), float( 1 ) ), vec3( 0.765833 ), 5e-4, 'acesFilmicToneMapping(1 gray, 1) matches the independent JS port -- white does not clip to 1' ); + + // Zero exposure crushes everything to black regardless of input color. + assert.closeAbs( acesFilmicToneMapping( vec3( 0.5, 0.8, 0.2 ), float( 0 ) ), vec3( 0 ), 1e-4, 'acesFilmicToneMapping(x, 0) == 0' ); + + } ); + + gpuTest( 'agxToneMapping() matches an independent JS port of the AgX algorithm', ( { assert } ) => { + + // Every 3x3 matrix in this pipeline (LINEAR_SRGB_TO_LINEAR_REC2020, + // AgXInsetMatrix, AgXOutsetMatrix, LINEAR_REC2020_TO_LINEAR_SRGB) + // has rows that each sum to (very nearly) 1.0 -- verified directly + // from the published constants, independent of this file -- and + // every other step in the pipeline (log2, clamp, the contrast + // polynomial, pow) is applied elementwise. So a perfectly uniform + // gray input must come out perfectly uniform too (a matrix with + // row-sum 1 maps a uniform vector to itself; an elementwise + // function trivially preserves uniformity) -- confirmed below, and + // a real, useful sanity check independent of the exact numeric + // values also being asserted. + assert.closeAbs( agxToneMapping( vec3( 0.18 ), float( 1 ) ), vec3( 0.214549, 0.214502, 0.214499 ), 1e-4, 'agxToneMapping(0.18 gray, 1) matches the independent JS port and stays uniform for a uniform input' ); + assert.closeAbs( agxToneMapping( vec3( 0 ), float( 1 ) ), vec3( 0 ), 1e-4, 'agxToneMapping(0, 1) == 0' ); + assert.closeAbs( agxToneMapping( vec3( 1 ), float( 1 ) ), vec3( 0.590229, 0.590136, 0.590102 ), 1e-4, 'agxToneMapping(1 gray, 1) matches the independent JS port and stays (nearly) uniform' ); + assert.closeAbs( agxToneMapping( vec3( 0.5, 0.2, 0.1 ), float( 1 ) ), vec3( 0.441477, 0.236864, 0.150757 ), 1e-4, 'agxToneMapping(asymmetric color, 1) matches the independent JS port' ); + + } ); + + // neutralToneMapping() is split into three separate gpuTest() calls + // (one assertion each), unlike this file's other tone-mapping tests -- + // see the "neutralToneMapping() multi-call" finding in + // tsl-unit-test-findings.md for why calling it more than once inside a + // single gpuTest's shared kernel throws a harness-side error. + + gpuTest( 'neutralToneMapping() leaves low-intensity colors past the desaturation offset unchanged (flat offset branch)', ( { assert } ) => { + + // x = min(r,g,b) = 0.1, not below the 0.08 inner-threshold, so the + // desaturation offset is the flat 0.04 branch: color - 0.04. The + // resulting peak (0.26) is below StartCompression (0.76), so the + // function's `If(peak < StartCompression) return color` fires and + // no highlight compression is applied -- this exercises TSL's + // real early-return-from-If semantics (used pervasively elsewhere + // in this codebase, e.g. PhysicalLightingModel.js/MaterialXNoise.js). + assert.closeAbs( neutralToneMapping( vec3( 0.1, 0.2, 0.3 ), float( 1 ) ), vec3( 0.06, 0.16, 0.26 ), 1e-4, 'neutralToneMapping((0.1,0.2,0.3), 1) applies only the flat 0.04 desaturation offset' ); + + } ); + + gpuTest( 'neutralToneMapping() leaves low-intensity colors past the desaturation offset unchanged (quadratic offset branch)', ( { assert } ) => { + + // x = min(r,g,b) = 0.05, below the 0.08 inner-threshold, so the + // offset uses the quadratic branch: x - 6.25*x^2 = 0.05 - 0.015625 + // = 0.034375. Peak is still below StartCompression here too. + assert.closeAbs( neutralToneMapping( vec3( 0.05, 0.5, 0.5 ), float( 1 ) ), vec3( 0.015625, 0.465625, 0.465625 ), 1e-4, 'neutralToneMapping((0.05,0.5,0.5), 1) applies the quadratic low-end desaturation offset' ); + + } ); + + gpuTest( 'neutralToneMapping() compresses highlights above the desaturation offset', ( { assert } ) => { + + // A bright, non-uniform color whose peak (0.96, after the flat 0.04 + // offset) exceeds StartCompression (0.76): both the highlight + // rolloff (newPeak) and the desaturating mix() toward it are + // exercised -- matched against an independent JS port. + assert.closeAbs( neutralToneMapping( vec3( 1, 0.9, 0.5 ), float( 1 ) ), vec3( 0.869091, 0.779779, 0.422529 ), 1e-4, 'neutralToneMapping((1,0.9,0.5), 1) matches the independent JS port of the highlight-compression branch' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLTypeConstructors.tests.js b/test/unit/addons/tsl/TSLTypeConstructors.tests.js new file mode 100644 index 00000000000000..e5ce42e48dd055 --- /dev/null +++ b/test/unit/addons/tsl/TSLTypeConstructors.tests.js @@ -0,0 +1,111 @@ +import { + float, int, uint, vec2, vec3, + bool, bvec2, bvec3, ivec2, ivec3, ivec4, uvec2, uvec3, uvec4, mat2, color, + transpose, greaterThan +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Coverage for the smaller/less-common TSL type constructors built by +// TSLCore.js's `ConvertType` factory (the same machinery mat3()/mat4() go +// through -- see TSLVectorMatrix.tests.js's dedicated row-major-convention +// tests for that finding), and for `color()`. +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'type constructors', () => { + + gpuTest( 'mat2(a,b,c,d) 4-scalar constructor is ROW-major, like mat3()/mat4() (see TSLVectorMatrix.tests.js)', ( { assert } ) => { + + // Same finding as mat3()'s 9-scalar constructor: a plain-number + // call routes through getValueFromType('mat2', ...) -> + // `new Matrix2().set(...)`, and Matrix2.set(n11,n12,n21,n22) is + // ROW-major (matching every other three.js Matrix API) while + // storing column-major internally. So mat2(1,2,3,4)'s *rows* + // (conventionally written) are (1,2) and (3,4) -- element(i) + // means "column i" of that (conventional) matrix, so column 0 + // is (1,3): the first entry of each row. + const m = mat2( 1, 2, 3, 4 ); + assert.eq( m.element( 0 ), vec2( 1, 3 ), 'column 0 == the first entry of each constructor row' ); + assert.eq( m.element( 1 ), vec2( 2, 4 ), 'column 1 == the second entry of each constructor row' ); + + } ); + + gpuTest( 'transpose(mat2(a,b,c,d)) confirms the row-major reading of the 4-scalar constructor', ( { assert } ) => { + + // transpose(m)'s column i is m's row i -- hand-derived, + // independent of whatever transpose() actually does internally. + const m = mat2( 1, 2, 3, 4 ); + assert.eq( transpose( m ).element( 0 ), vec2( 1, 2 ), 'transpose column 0 == original row 0' ); + assert.eq( transpose( m ).element( 1 ), vec2( 3, 4 ), 'transpose column 1 == original row 1' ); + + } ); + + gpuTest( 'bool() converts a truthy/falsy numeric node to a boolean', ( { assert } ) => { + + assert.eq( bool( float( 1 ) ), bool( true ), 'bool(1) == true' ); + assert.eq( bool( float( 0 ) ), bool( false ), 'bool(0) == false' ); + + } ); + + gpuTest( 'ivec2/ivec3/ivec4 broadcast a single scalar to every component', ( { assert } ) => { + + // Same single-argument broadcast rule as vec2/vec3/vec4 (see + // getValueFromType()'s `params.length === 1` branch, which + // applies uniformly regardless of the i/u/b prefix). + assert.eq( ivec2( int( 5 ) ).x, int( 5 ), 'ivec2(5).x == 5' ); + assert.eq( ivec2( int( 5 ) ).y, int( 5 ), 'ivec2(5).y == 5' ); + assert.eq( ivec3( int( - 2 ) ).z, int( - 2 ), 'ivec3(-2).z == -2' ); + assert.eq( ivec4( int( 7 ) ).w, int( 7 ), 'ivec4(7).w == 7' ); + + } ); + + gpuTest( 'ivec3(a,b,c) constructs component-wise from three explicit scalars', ( { assert } ) => { + + const v = ivec3( int( 1 ), int( 2 ), int( 3 ) ); + assert.eq( v.x, int( 1 ), 'ivec3(1,2,3).x == 1' ); + assert.eq( v.y, int( 2 ), 'ivec3(1,2,3).y == 2' ); + assert.eq( v.z, int( 3 ), 'ivec3(1,2,3).z == 3' ); + + } ); + + gpuTest( 'uvec2/uvec3/uvec4 broadcast a single scalar to every component', ( { assert } ) => { + + assert.eq( uvec2( uint( 3 ) ).x, uint( 3 ), 'uvec2(3).x == 3' ); + assert.eq( uvec3( uint( 4 ) ).y, uint( 4 ), 'uvec3(4).y == 4' ); + assert.eq( uvec4( uint( 9 ) ).w, uint( 9 ), 'uvec4(9).w == 9' ); + + } ); + + gpuTest( 'bvec2/bvec3 pack independently computed booleans component-wise', ( { assert } ) => { + + // Built from real comparison results (greaterThan()), not from + // plain JS boolean literals -- so this exercises bvecN() the way + // it's actually used downstream of boolean vector comparisons, + // not just as a constant-literal packer. + const cmp = greaterThan( vec3( 5, 1, 5 ), vec3( 2, 2, 2 ) ); // (true, false, true) + + assert.eq( bvec3( cmp.x, cmp.y, cmp.z ).x, bool( true ), 'bvec3(cmp).x == true (5 > 2)' ); + assert.eq( bvec3( cmp.x, cmp.y, cmp.z ).y, bool( false ), 'bvec3(cmp).y == false (1 > 2 is false)' ); + assert.eq( bvec3( cmp.x, cmp.y, cmp.z ).z, bool( true ), 'bvec3(cmp).z == true (5 > 2)' ); + + assert.eq( bvec2( bool( true ), bool( false ) ).x, bool( true ), 'bvec2(true, false).x == true' ); + assert.eq( bvec2( bool( true ), bool( false ) ).y, bool( false ), 'bvec2(true, false).y == false' ); + + } ); + + gpuTest( 'color(r,g,b) behaves as a plain 3-component RGB node', ( { assert } ) => { + + const c = color( 0.2, 0.4, 0.6 ); + assert.eq( c.r, float( 0.2 ), 'color(0.2,0.4,0.6).r == 0.2' ); + assert.eq( c.g, float( 0.4 ), 'color(0.2,0.4,0.6).g == 0.4' ); + assert.eq( c.b, float( 0.6 ), 'color(0.2,0.4,0.6).b == 0.6' ); + + // Arithmetic works the same as a vec3. closeAbs (not eq) since + // this crosses a float32 addition, which isn't guaranteed to be + // bit-exact with the CPU-side reference value. + assert.closeAbs( c.add( color( 0.1, 0.1, 0.1 ) ), color( 0.3, 0.5, 0.7 ), 1e-6, 'color addition works component-wise like vec3' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLVectorMatrix.tests.js b/test/unit/addons/tsl/TSLVectorMatrix.tests.js new file mode 100644 index 00000000000000..5b7ee01d59f0cb --- /dev/null +++ b/test/unit/addons/tsl/TSLVectorMatrix.tests.js @@ -0,0 +1,207 @@ +import { + float, vec3, mat3, mat4, + dot, cross, length, distance, normalize, + reflect, refract, + transpose, inverse, mul +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +// Vector and mat3/mat4 coverage not already handled by TSLFaceForward.tests.js +// / TSLDeterminant.tests.js. Expected values are hand-derived from standard +// linear-algebra identities/formulas (not by re-running the node under +// test), so a broken implementation that merely agrees with itself can't +// pass -- see the "test theater" link in GPUTest.tests.js. +// +// mat3/mat4 assertions exercise gpu-test-utils.js's matrix support (see its +// file header for how a mat3/mat4 value is threaded through the harness). +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'vector functions', () => { + + gpuTest( 'dot/cross/length/distance with hand-computed values', ( { assert } ) => { + + assert.closeAbs( dot( vec3( 1, 2, 3 ), vec3( 4, 5, 6 ) ), float( 32 ), 1e-5, 'dot([1,2,3],[4,5,6]) == 4+10+18 == 32' ); + assert.closeAbs( length( vec3( 3, 4, 0 ) ), float( 5 ), 1e-5, '3-4-5 triangle' ); + assert.closeAbs( distance( vec3( 0, 0, 0 ), vec3( 3, 4, 0 ) ), float( 5 ), 1e-5, 'distance mirrors the 3-4-5 triangle' ); + + // cross(x-axis, y-axis) == z-axis, and must be orthogonal to both inputs. + const x = vec3( 1, 0, 0 ); + const y = vec3( 0, 1, 0 ); + const c = cross( x, y ); + assert.closeAbs( c, vec3( 0, 0, 1 ), 1e-5, 'cross(+X, +Y) == +Z' ); + assert.closeAbs( dot( c, x ), float( 0 ), 1e-5, 'cross product is orthogonal to its first operand' ); + assert.closeAbs( dot( c, y ), float( 0 ), 1e-5, 'cross product is orthogonal to its second operand' ); + + } ); + + gpuTest( 'normalize() produces a unit vector pointing the same direction', ( { assert } ) => { + + assert.closeAbs( normalize( vec3( 5, 0, 0 ) ), vec3( 1, 0, 0 ), 1e-5, 'axis-aligned vector normalizes to the exact unit axis' ); + assert.closeAbs( length( normalize( vec3( 3, - 4, 12 ) ) ), float( 1 ), 1e-5, 'arbitrary vector normalizes to unit length' ); + + // A very small (but non-zero) vector should still normalize to unit + // length rather than collapsing to zero from underflow. + assert.closeAbs( length( normalize( vec3( 1e-6, 0, 0 ) ) ), float( 1 ), 1e-3, 'a tiny nonzero vector still normalizes to unit length' ); + + } ); + + gpuTest( 'reflect() off an axis-aligned plane', ( { assert } ) => { + + // reflect(I, N) == I - 2*dot(N,I)*N. For a 45-degree incident ray + // hitting a horizontal plane (normal +Y), the reflection is the + // mirror image across that plane. + const incident = vec3( 1, - 1, 0 ); + const normal = vec3( 0, 1, 0 ); + assert.closeAbs( reflect( incident, normal ), vec3( 1, 1, 0 ), 1e-5, 'reflect(1,-1,0 off +Y) == (1,1,0)' ); + + } ); + + gpuTest( 'refract() Snell\'s law, including total internal reflection', ( { assert } ) => { + + // Straight-on incidence (I parallel to -N) is never bent, regardless of eta. + const straightOn = refract( vec3( 0, - 1, 0 ), vec3( 0, 1, 0 ), float( 0.9 ) ); + assert.closeAbs( straightOn, vec3( 0, - 1, 0 ), 1e-4, 'refract() straight through the normal is undeviated' ); + + // Total internal reflection: going from a denser to a less-dense + // medium (eta > 1) past the critical angle must return exactly + // vec3(0) per the GLSL/WGSL spec, rather than a bent (and physically + // meaningless) ray. + const grazing = normalize( vec3( 0.99, - 0.1411, 0 ) ); // close to grazing incidence + const tir = refract( grazing, vec3( 0, 1, 0 ), float( 1.5 ) ); + assert.closeAbs( tir, vec3( 0, 0, 0 ), 1e-5, 'refract() past the critical angle returns the zero vector (total internal reflection)' ); + + } ); + + } ); + + QUnit.module( 'matrix functions (mat3/mat4)', () => { + + gpuTest( 'mat3(a,b,c,...) 9-scalar constructor takes ROW-major arguments (like Matrix3.set(), NOT GLSL\'s own column-major literal convention)', ( { assert } ) => { + + // Verified empirically (see tsl-unit-test-findings.md): TSL's + // ConvertType machinery routes a plain 9-number call like + // mat3(1,2,3,4,5,6,7,8,9) through `getValueFromType('mat3', ...)`, + // which builds a real THREE.Matrix3 via `.set(...)` -- and + // Matrix3.set(n11,n12,n13, n21,n22,n23, n31,n32,n33) takes + // ROW-major arguments (matching the rest of three.js's Matrix + // API) while storing them column-major internally. The constant + // is then re-emitted from that internal (column-major) array. + // Net effect: mat3(1,2,3,4,5,6,7,8,9) constructs the matrix whose + // ROWS (conventionally written) are (1,2,3), (4,5,6), (7,8,9) -- + // *not* a matrix whose GLSL-native columns are those triples, as + // a literal, unqualified `mat3(9 floats)` would be in raw GLSL/ + // WGSL. element(i) still means "column i" of that (conventional) + // matrix, so column 0 is (1,4,7): the first entry of every row. + const m = mat3( 1, 2, 3, 4, 5, 6, 7, 8, 9 ); + assert.eq( m.element( 0 ), vec3( 1, 4, 7 ), 'column 0 == the first entry of each constructor row' ); + assert.eq( m.element( 1 ), vec3( 2, 5, 8 ), 'column 1 == the second entry of each constructor row' ); + assert.eq( m.element( 2 ), vec3( 3, 6, 9 ), 'column 2 == the third entry of each constructor row' ); + + } ); + + gpuTest( 'mat3 transpose of a known non-symmetric matrix', ( { assert } ) => { + + // m, written conventionally (see the row-major finding above), is: + // [ 1 2 3 ] + // [ 4 5 6 ] + // [ 7 8 9 ] + // transpose(m)'s column i is m's row i -- hand-derived, independent + // of whatever transpose() actually does internally. + const m = mat3( 1, 2, 3, 4, 5, 6, 7, 8, 9 ); + + assert.eq( transpose( m ).element( 0 ), vec3( 1, 2, 3 ), 'transpose column 0 == original row 0' ); + assert.eq( transpose( m ).element( 1 ), vec3( 4, 5, 6 ), 'transpose column 1 == original row 1' ); + assert.eq( transpose( m ).element( 2 ), vec3( 7, 8, 9 ), 'transpose column 2 == original row 2' ); + + } ); + + gpuTest( 'mat3 inverse of a diagonal scale matrix has reciprocal diagonal entries', ( { assert } ) => { + + const scale = mat3( 2, 0, 0, 0, 4, 0, 0, 0, 5 ); + const inv = inverse( scale ); + + assert.closeAbs( inv.element( 0 ), vec3( 0.5, 0, 0 ), 1e-5, 'inverse diagonal reciprocal: 1/2' ); + assert.closeAbs( inv.element( 1 ), vec3( 0, 0.25, 0 ), 1e-5, 'inverse diagonal reciprocal: 1/4' ); + assert.closeAbs( inv.element( 2 ), vec3( 0, 0, 0.2 ), 1e-5, 'inverse diagonal reciprocal: 1/5' ); + + } ); + + gpuTest( 'mat4 M * inverse(M) recovers the identity for a non-trivial matrix', ( { assert } ) => { + + // A general invertible affine matrix (rotation-free shear + translation + // in the last column) -- checking M * inverse(M) == I is a real, + // meaningful numerical round trip (not tautological: a broken + // inverse() or a broken mul() would each independently break this). + const m = mat4( + 1, 0, 0, 0, + 0.5, 1, 0, 0, + 0, 0.25, 1, 0, + 3, - 2, 1, 1 + ); + + const identity = mat4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); + const product = mul( m, inverse( m ) ); + + assert.closeAbs( product, identity, 1e-4, 'M * inverse(M) == I' ); + + } ); + + gpuTest( 'mat3 * vec3 transforms a vector using known column data', ( { assert } ) => { + + // m's columns are (1,0,0), (0,2,0), (0,0,3) -- a pure per-axis scale -- + // so m * (1,1,1) must be exactly (1,2,3), derived directly from the + // scale factors rather than from the matrix-multiply code being tested. + const m = mat3( 1, 0, 0, 0, 2, 0, 0, 0, 3 ); + assert.closeAbs( mul( m, vec3( 1, 1, 1 ) ), vec3( 1, 2, 3 ), 1e-5, 'diagonal-scale matrix times (1,1,1)' ); + + } ); + + gpuTest( 'mat3(vec3, vec3, vec3) is COLUMN-major (GLSL-native), the OPPOSITE convention from mat3(9 scalars) (row-major, see above)', ( { assert } ) => { + + // A real, confirmed sharp edge in this codebase's TSL surface: the + // two mat3 constructor call shapes take OPPOSITE argument + // conventions. `mat3(9 plain numbers)` routes through + // `getValueFromType()` -> `new Matrix3().set(...)`, which is + // ROW-major (see the dedicated test above). But `mat3(vec3, vec3, + // vec3)` -- three *Node* arguments, not plain numbers -- takes a + // completely different code path (`ConvertType` sees `object`-typed + // params and skips `getValueFromType()` entirely, building a + // `JoinNode` instead), and `JoinNode.generate()` just emits a + // literal `mat3(a, b, c)` in the generated GLSL/WGSL -- which is + // GLSL/WGSL's own native, COLUMN-major constructor: each vec3 + // argument becomes one COLUMN, not one row. This is exactly the + // pattern used throughout src/nodes/display/ToneMappingFunctions.js + // and MaterialXNoise.js's various matrix constants -- correct + // there (those matrices are written with the intent of literal + // GLSL column-major semantics), but a real trap for anyone + // assuming mat3's *scalar* constructor's row-major convention + // (documented above, and it's the one Matrix3-familiar readers of + // this codebase would reasonably expect) also applies here. + const m = mat3( vec3( 1, 2, 3 ), vec3( 4, 5, 6 ), vec3( 7, 8, 9 ) ); + + assert.eq( m.element( 0 ), vec3( 1, 2, 3 ), 'column 0 == the first vec3 argument, verbatim (not its first component spread across a row)' ); + assert.eq( m.element( 1 ), vec3( 4, 5, 6 ), 'column 1 == the second vec3 argument, verbatim' ); + assert.eq( m.element( 2 ), vec3( 7, 8, 9 ), 'column 2 == the third vec3 argument, verbatim' ); + + // Contrast directly against the scalar-constructor form using the + // exact same 9 numbers, flattened in the same reading order -- + // the two are each other's transpose. + const mScalars = mat3( 1, 2, 3, 4, 5, 6, 7, 8, 9 ); + assert.eq( transpose( m ), mScalars, 'mat3(vec3,vec3,vec3) with these columns is the transpose of mat3(...9 scalars) with the same numbers in reading order' ); + + } ); + + gpuTest( 'mul(mat3, vec3) is a standard M*v product (v as a column vector) against the mat3(vec3,vec3,vec3) column-major layout above', ( { assert } ) => { + + // m's first column (verbatim, per the finding above) is (1,2,3), so + // m * (1,0,0) -- v as a column vector -- must select exactly that + // column back out: (1,2,3), not (1,4,7) (which is what the *other* + // valid multiplication convention, v^T * M, would give here instead). + const m = mat3( vec3( 1, 2, 3 ), vec3( 4, 5, 6 ), vec3( 7, 8, 9 ) ); + assert.eq( mul( m, vec3( 1, 0, 0 ) ), vec3( 1, 2, 3 ), 'mul(m, (1,0,0)) selects column 0 verbatim -- confirms the M*v convention' ); + + } ); + + } ); + +} ); diff --git a/test/unit/puppeteer.unit.js b/test/unit/puppeteer.unit.js index 94941b1018ee8b..258f069ab4cd24 100644 --- a/test/unit/puppeteer.unit.js +++ b/test/unit/puppeteer.unit.js @@ -165,7 +165,7 @@ function main() { // Wait for the QUnit test results await page.waitForFunction( () => { - return window.QUnit && window.QUnit.done; + return window._QUnitStats !== undefined; } ); diff --git a/test/unit/three.addons.unit.js b/test/unit/three.addons.unit.js index f25e31838f2c0a..a45537458c935a 100644 --- a/test/unit/three.addons.unit.js +++ b/test/unit/three.addons.unit.js @@ -21,3 +21,21 @@ import './addons/tsl/TSLFaceForward.tests.js'; import './addons/tsl/TSLGainPcurve.tests.js'; import './addons/tsl/TSLRotate.tests.js'; import './addons/tsl/TSLSinc.tests.js'; +import './addons/tsl/TSLBRDF.tests.js'; +import './addons/tsl/TSLDepthConversion.tests.js'; +import './addons/tsl/TSLColorSpaceConversion.tests.js'; +import './addons/tsl/TSLTypeConstructors.tests.js'; +import './addons/tsl/TSLBitOps.tests.js'; +import './addons/tsl/TSLNoise.tests.js'; +import './addons/tsl/TSLMath.tests.js'; +import './addons/tsl/TSLPacking.tests.js'; +import './addons/tsl/TSLConversion.tests.js'; +import './addons/tsl/TSLVectorMatrix.tests.js'; +import './addons/tsl/TSLMathExtra.tests.js'; +import './addons/tsl/TSLLogicBitwise.tests.js'; +import './addons/tsl/TSLCurveUtils.tests.js'; +import './addons/tsl/TSLColorSpace.tests.js'; +import './addons/tsl/TSLBlendModes.tests.js'; +import './addons/tsl/TSLColorAdjustmentExtra.tests.js'; +import './addons/tsl/TSLToneMapping.tests.js'; +import './addons/tsl/TSLProceduralUtils.tests.js';