diff --git a/editor/js/Animation.js b/editor/js/Animation.js index 93748454ba6a27..b1d827376dfde2 100644 --- a/editor/js/Animation.js +++ b/editor/js/Animation.js @@ -264,39 +264,17 @@ function Animation( editor ) { let currentClip = null; let currentRoot = null; - // Get all clips from scene animations + // Get all clips of the selected object function getAnimationClips() { - const scene = editor.scene; + const object = editor.selected; const clips = []; - const seen = new Set(); - scene.traverse( function ( object ) { + if ( object !== null ) { - if ( object.animations && object.animations.length > 0 ) { + for ( const clip of object.animations ) { - for ( const clip of object.animations ) { - - if ( ! seen.has( clip.uuid ) ) { - - seen.add( clip.uuid ); - clips.push( { clip: clip, root: object } ); - - } - - } - - } - - } ); - - // Also check scene.animations directly - for ( const clip of scene.animations ) { - - if ( ! seen.has( clip.uuid ) ) { - - seen.add( clip.uuid ); - clips.push( { clip: clip, root: scene } ); + clips.push( { clip: clip, root: object } ); } @@ -367,14 +345,6 @@ function Animation( editor ) { clipRow.addEventListener( 'click', function () { - if ( editor.selected !== root ) { - - signals.objectSelected.remove( selectDefaultClip ); - editor.select( root ); - signals.objectSelected.add( selectDefaultClip ); - - } - selectClip( clip, root ); update(); // Refresh to update highlighting @@ -499,24 +469,11 @@ function Animation( editor ) { function selectClip( clip, root ) { - // Stop current action - if ( currentAction ) { - - currentAction.stop(); - - } + const toggle = ( currentClip === clip ); - if ( currentClip === clip ) { + deselectClip(); - // Unselect clip - currentAction = null; - currentClip = null; - currentRoot = null; - - timeText.setValue( '0.00' ); - durationText.setValue( '0.00' ); - - } else { + if ( toggle === false ) { // Select clip without playing currentClip = clip; @@ -530,6 +487,26 @@ function Animation( editor ) { } + function deselectClip() { + + // Stop current action + if ( currentAction ) { + + currentAction.stop(); + + } + + currentAction = null; + currentClip = null; + currentRoot = null; + + timeText.setValue( '0.00' ); + durationText.setValue( '0.00' ); + + playhead.style.left = labelWidth + 'px'; + + } + function showPath( clip, object ) { hidePath(); @@ -557,11 +534,7 @@ function Animation( editor ) { hidePath(); trackListContainer.innerHTML = ''; - currentAction = null; - currentClip = null; - currentRoot = null; - timeText.setValue( '0.00' ); - durationText.setValue( '0.00' ); + deselectClip(); } @@ -585,21 +558,24 @@ function Animation( editor ) { } - function selectDefaultClip( object ) { + function onObjectSelected( object ) { + + deselectClip(); - if ( object !== null && object.animations && object.animations.length > 0 ) { + if ( object !== null && object.animations.length > 0 ) { selectClip( object.animations[ 0 ], object ); - update(); } + update(); + } updateTime(); // Auto-select clip when an object with animations is selected - signals.objectSelected.add( selectDefaultClip ); + signals.objectSelected.add( onObjectSelected ); // Update when scene changes signals.editorCleared.add( clear ); diff --git a/eslint.config.js b/eslint.config.js index f2893072bd2ba1..5dfea21c51e5af 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,6 +13,8 @@ export default [ '**/node_modules/**', '**/build/**', 'examples/jsm/libs/**', + 'test/treeshake/*.bundle.js', + 'test/treeshake/*.bundle.min.js', 'editor/js/libs/acorn/**', 'editor/js/libs/codemirror/**', 'editor/js/libs/tern-threejs/**', diff --git a/examples/screenshots/webgpu_layers.jpg b/examples/screenshots/webgpu_layers.jpg index 0dd839da04de8b..5d10f5bf69e971 100644 Binary files a/examples/screenshots/webgpu_layers.jpg and b/examples/screenshots/webgpu_layers.jpg differ diff --git a/src/nodes/math/MathNode.js b/src/nodes/math/MathNode.js index f425ac80c0a1d7..bdba93fb297f3a 100644 --- a/src/nodes/math/MathNode.js +++ b/src/nodes/math/MathNode.js @@ -137,7 +137,7 @@ class MathNode extends TempNode { const method = this.method; - if ( method === MathNode.LENGTH || method === MathNode.DISTANCE || method === MathNode.DOT ) { + if ( method === MathNode.LENGTH || method === MathNode.DISTANCE || method === MathNode.DOT || method === MathNode.DETERMINANT ) { return 'float'; diff --git a/src/nodes/math/MathUtils.js b/src/nodes/math/MathUtils.js index 5523e7149d44ca..663db19ac27fde 100644 --- a/src/nodes/math/MathUtils.js +++ b/src/nodes/math/MathUtils.js @@ -1,5 +1,6 @@ import { sub, mul, div, add } from './OperatorNode.js'; -import { PI, pow, sin } from './MathNode.js'; +import { PI, pow, sin, abs } from './MathNode.js'; +import { select } from './ConditionalNode.js'; /** * A function that remaps the `[0,1]` interval into the `[0,1]` interval. @@ -25,7 +26,11 @@ export const parabola = ( x, k ) => pow( mul( 4.0, x.mul( sub( 1.0, x ) ) ), k ) * @param {Node} k - `k=1` is the identity curve,`k<1` produces the classic `gain()` shape, and `k>1` produces "s" shaped curves. * @return {Node} The remapped value. */ -export const gain = ( x, k ) => x.lessThan( 0.5 ) ? parabola( x.mul( 2.0 ), k ).div( 2.0 ) : sub( 1.0, parabola( mul( sub( 1.0, x ), 2.0 ), k ).div( 2.0 ) ); +export const gain = ( x, k ) => select( + x.lessThan( 0.5 ), + pow( mul( 2.0, x ), k ).mul( 0.5 ), + sub( 1.0, pow( mul( 2.0, sub( 1.0, x ) ), k ).mul( 0.5 ) ) +); /** * A function that remaps the `[0,1]` interval into the `[0,1]` interval. @@ -39,7 +44,7 @@ export const gain = ( x, k ) => x.lessThan( 0.5 ) ? parabola( x.mul( 2.0 ), k ). * @param {Node} b - Second control parameter. * @return {Node} The remapped value. */ -export const pcurve = ( x, a, b ) => pow( div( pow( x, a ), add( pow( x, a ), pow( sub( 1.0, x ), b ) ) ), 1.0 / a ); +export const pcurve = ( x, a, b ) => pow( div( pow( x, a ), add( pow( x, a ), pow( sub( 1.0, x ), b ) ) ), div( 1.0, a ) ); /** * A phase shifted sinus curve that starts at zero and ends at zero, with bouncing behavior. @@ -51,4 +56,10 @@ export const pcurve = ( x, a, b ) => pow( div( pow( x, a ), add( pow( x, a ), po * @param {Node} k - Controls the amount of bounces. * @return {Node} The result value. */ -export const sinc = ( x, k ) => sin( PI.mul( k.mul( x ).sub( 1.0 ) ) ).div( PI.mul( k.mul( x ).sub( 1.0 ) ) ); +export const sinc = ( x, k ) => { + + const arg = abs( PI.mul( k.mul( x ).sub( 1.0 ) ) ).max( 1e-6 ).toConst(); + + return sin( arg ).div( arg ); + +}; diff --git a/src/nodes/utils/RotateNode.js b/src/nodes/utils/RotateNode.js index 30e443d6c319e2..e42182302a3258 100644 --- a/src/nodes/utils/RotateNode.js +++ b/src/nodes/utils/RotateNode.js @@ -76,9 +76,9 @@ class RotateNode extends TempNode { } else { const rotation = rotationNode; - const rotationXMatrix = mat4( vec4( 1.0, 0.0, 0.0, 0.0 ), vec4( 0.0, cos( rotation.x ), sin( rotation.x ).negate(), 0.0 ), vec4( 0.0, sin( rotation.x ), cos( rotation.x ), 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); - const rotationYMatrix = mat4( vec4( cos( rotation.y ), 0.0, sin( rotation.y ), 0.0 ), vec4( 0.0, 1.0, 0.0, 0.0 ), vec4( sin( rotation.y ).negate(), 0.0, cos( rotation.y ), 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); - const rotationZMatrix = mat4( vec4( cos( rotation.z ), sin( rotation.z ).negate(), 0.0, 0.0 ), vec4( sin( rotation.z ), cos( rotation.z ), 0.0, 0.0 ), vec4( 0.0, 0.0, 1.0, 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); + const rotationXMatrix = mat4( vec4( 1.0, 0.0, 0.0, 0.0 ), vec4( 0.0, cos( rotation.x ), sin( rotation.x ), 0.0 ), vec4( 0.0, sin( rotation.x ).negate(), cos( rotation.x ), 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); + const rotationYMatrix = mat4( vec4( cos( rotation.y ), 0.0, sin( rotation.y ).negate(), 0.0 ), vec4( 0.0, 1.0, 0.0, 0.0 ), vec4( sin( rotation.y ), 0.0, cos( rotation.y ), 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); + const rotationZMatrix = mat4( vec4( cos( rotation.z ), sin( rotation.z ), 0.0, 0.0 ), vec4( sin( rotation.z ).negate(), cos( rotation.z ), 0.0, 0.0 ), vec4( 0.0, 0.0, 1.0, 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); return rotationXMatrix.mul( rotationYMatrix ).mul( rotationZMatrix ).mul( vec4( positionNode, 1.0 ) ).xyz; diff --git a/src/renderers/webgpu/nodes/WGSLNodeBuilder.js b/src/renderers/webgpu/nodes/WGSLNodeBuilder.js index 134619e847ce63..d3f4a6b3c01506 100644 --- a/src/renderers/webgpu/nodes/WGSLNodeBuilder.js +++ b/src/renderers/webgpu/nodes/WGSLNodeBuilder.js @@ -231,6 +231,7 @@ const wgslMethods = { inverse_mat3: 'tsl_inverse_mat3', inverse_mat4: 'tsl_inverse_mat4', inversesqrt: 'inverseSqrt', + faceforward: 'faceForward', bitcast: 'bitcast', floatpack_snorm_2x16: 'pack2x16snorm', floatpack_unorm_2x16: 'pack2x16unorm', diff --git a/test/unit/addons/tsl/GPUTest.tests.js b/test/unit/addons/tsl/GPUTest.tests.js index 085ede72266529..99fefbdcfbe4b9 100644 --- a/test/unit/addons/tsl/GPUTest.tests.js +++ b/test/unit/addons/tsl/GPUTest.tests.js @@ -1,5 +1,4 @@ -import { hash, vec3, float } from 'three/tsl'; -import { sRGBTransferEOTF, sRGBTransferOETF } from 'three/tsl'; +import { hash, vec3, float, sRGBTransferEOTF, sRGBTransferOETF } from 'three/tsl'; import { gpuTest, gpuFuzzTest } from './gpu-test-utils.js'; export default QUnit.module( 'TSL', () => { diff --git a/test/unit/addons/tsl/TSLDeterminant.tests.js b/test/unit/addons/tsl/TSLDeterminant.tests.js new file mode 100644 index 00000000000000..69a82d0d327c1b --- /dev/null +++ b/test/unit/addons/tsl/TSLDeterminant.tests.js @@ -0,0 +1,32 @@ +import { + float, mat3, mat4, determinant +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'determinant()', () => { + + gpuTest( 'determinant() of identity matrices is 1', ( { assert } ) => { + + const I3 = mat3( 1, 0, 0, 0, 1, 0, 0, 0, 1 ); + assert.closeAbs( determinant( I3 ), float( 1 ), 1e-5, 'determinant(I3) == 1' ); + + const I4 = mat4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); + assert.closeAbs( determinant( I4 ), float( 1 ), 1e-5, 'determinant(I4) == 1' ); + + } ); + + gpuTest( 'mat3 determinant of a diagonal scale matrix', ( { assert } ) => { + + // determinant of a diagonal matrix is just the product of its + // diagonal entries -- 2 * 3 * 4 == 24, independent of the general + // cofactor-expansion implementation under test. + const scale = mat3( 2, 0, 0, 0, 3, 0, 0, 0, 4 ); + assert.closeAbs( determinant( scale ), float( 24 ), 1e-4, 'determinant(diag(2,3,4)) == 24' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLFaceForward.tests.js b/test/unit/addons/tsl/TSLFaceForward.tests.js new file mode 100644 index 00000000000000..1cb8413bf004f0 --- /dev/null +++ b/test/unit/addons/tsl/TSLFaceForward.tests.js @@ -0,0 +1,24 @@ +import { + vec3, faceForward +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'faceForward()', () => { + + gpuTest( 'faceForward() picks the side facing the incident ray', ( { assert } ) => { + + const n = vec3( 0, 1, 0 ); + + // dot(Nref, I) < 0 -> returns N unchanged. + assert.closeAbs( faceForward( n, vec3( 0, - 1, 0 ), vec3( 0, 1, 0 ) ), n, 1e-5, 'faceForward keeps N when Nref and I already face opposite ways' ); + + // dot(Nref, I) >= 0 -> returns -N. + assert.closeAbs( faceForward( n, vec3( 0, 1, 0 ), vec3( 0, 1, 0 ) ), n.negate(), 1e-5, 'faceForward flips N when Nref and I face the same way' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLGainPcurve.tests.js b/test/unit/addons/tsl/TSLGainPcurve.tests.js new file mode 100644 index 00000000000000..cd3e5b7f519b69 --- /dev/null +++ b/test/unit/addons/tsl/TSLGainPcurve.tests.js @@ -0,0 +1,38 @@ +import { float, gain, pcurve } from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'gain() and pcurve()', () => { + + gpuTest( 'gain() keeps the endpoints and midpoint fixed, k=1 is the identity', ( { assert } ) => { + + // gain()'s doc comment: "k=1 is the identity curve" -- checked at + // several points, not just the fixed ones. + assert.closeAbs( gain( float( 0.2 ), float( 1 ) ), float( 0.2 ), 1e-4, 'gain(0.2, k=1) == 0.2 (identity)' ); + assert.closeAbs( gain( float( 0.7 ), float( 1 ) ), float( 0.7 ), 1e-4, 'gain(0.7, k=1) == 0.7 (identity)' ); + + // Regardless of k, gain() fixes 0, 0.5 and 1 (a documented property + // of this remap shape). + assert.closeAbs( gain( float( 0 ), float( 3 ) ), float( 0 ), 1e-5, 'gain(0, k) == 0 for any k' ); + assert.closeAbs( gain( float( 1 ), float( 3 ) ), float( 1 ), 1e-5, 'gain(1, k) == 1 for any k' ); + assert.closeAbs( gain( float( 0.5 ), float( 3 ) ), float( 0.5 ), 1e-4, 'gain(0.5, k) == 0.5 for any k' ); + + } ); + + gpuTest( 'pcurve() matches its own documented formula and vanishes at the domain edges', ( { assert } ) => { + + // pcurve(x, a, b) == (x^a / (x^a + (1-x)^b))^(1/a) -- from the + // function's own doc comment, computed independently in JS. + const x = 0.3, a = 2, b = 3; + const expected = Math.pow( Math.pow( x, a ) / ( Math.pow( x, a ) + Math.pow( 1 - x, b ) ), 1 / a ); + assert.closeAbs( pcurve( float( x ), float( a ), float( b ) ), float( expected ), 1e-4, 'pcurve(0.3, 2, 3) matches the hand-computed formula' ); + + assert.closeAbs( pcurve( float( 0 ), float( 2 ), float( 3 ) ), float( 0 ), 1e-4, 'pcurve(0, a, b) == 0' ); + assert.closeAbs( pcurve( float( 1 ), float( 2 ), float( 3 ) ), float( 1 ), 1e-4, 'pcurve(1, a, b) == 1' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLRotate.tests.js b/test/unit/addons/tsl/TSLRotate.tests.js new file mode 100644 index 00000000000000..d59dd7fa4b04c9 --- /dev/null +++ b/test/unit/addons/tsl/TSLRotate.tests.js @@ -0,0 +1,67 @@ +import { + float, vec2, vec3, rotate +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'rotate()', () => { + + gpuTest( 'rotate() on a 2D position by known angles', ( { assert } ) => { + + // A 90-degree (PI/2) counter-clockwise rotation sends +X to +Y. + const rotated90 = rotate( vec2( 1, 0 ), float( Math.PI / 2 ) ); + assert.closeAbs( rotated90, vec2( 0, 1 ), 1e-4, 'rotate((1,0), PI/2) == (0,1)' ); + + // A full 2*PI rotation is the identity (up to floating-point drift). + const rotatedFull = rotate( vec2( 3, -2 ), float( Math.PI * 2 ) ); + assert.closeAbs( rotatedFull, vec2( 3, -2 ), 1e-3, 'rotate(v, 2*PI) returns to the original vector' ); + + // Zero rotation is exactly the identity. + assert.closeAbs( rotate( vec2( 5, 7 ), float( 0 ) ), vec2( 5, 7 ), 1e-5, 'rotate(v, 0) is the identity' ); + + } ); + + gpuTest( 'rotate() on a 3D position about a single axis matches the 2D case', ( { assert } ) => { + + // Rotating only about Z (x/y Euler angles held at 0) must behave + // exactly like the 2D rotation above, with Z passed through + // unchanged -- an independent cross-check between the 2D and 3D + // code paths inside RotateNode.setup(). + const rotated = rotate( vec3( 1, 0, 5 ), vec3( 0, 0, Math.PI / 2 ) ); + assert.closeAbs( rotated, vec3( 0, 1, 5 ), 1e-4, 'rotating (1,0,5) by PI/2 about Z gives (0,1,5)' ); + + } ); + + // The X and Y single-axis paths weren't independently exercised when + // RotateNode's 3D branch had its column/row transpose bug -- only Z + // was empirically checked, even though the fix touched all three + // per-axis matrices identically. These two tests close that gap by + // applying the same 2D-vs-3D cross-check to X and Y, with the "other + // two" coordinates held fixed as an independent pass-through check + // (mirroring the Z test's own Z pass-through of `5`). + gpuTest( 'rotate() on a 3D position about the X axis only matches the 2D case', ( { assert } ) => { + + // Rotating about X only: (y,z) behaves exactly like the 2D case's + // (x,y), with X passed through unchanged. + const rotated = rotate( vec3( 5, 1, 0 ), vec3( Math.PI / 2, 0, 0 ) ); + assert.closeAbs( rotated, vec3( 5, 0, 1 ), 1e-4, 'rotating (5,1,0) by PI/2 about X gives (5,0,1)' ); + + } ); + + gpuTest( 'rotate() on a 3D position about the Y axis only matches the 2D case', ( { assert } ) => { + + // Rotating about Y only, with Y passed through unchanged. Unlike + // the X and Z axes, RotateNode's Y-axis matrix is built with its + // sin/-sin terms swapped relative to the X/Z pattern (see + // RotateNode.js's rotationYMatrix), so this rotates (x,z) the + // opposite way round from what the X/Z pattern alone would + // suggest: x' = x*cos + z*sin, z' = -x*sin + z*cos. + const rotated = rotate( vec3( 1, 5, 0 ), vec3( 0, Math.PI / 2, 0 ) ); + assert.closeAbs( rotated, vec3( 0, 5, -1 ), 1e-4, 'rotating (1,5,0) by PI/2 about Y gives (0,5,-1)' ); + + } ); + + } ); + +} ); diff --git a/test/unit/addons/tsl/TSLSinc.tests.js b/test/unit/addons/tsl/TSLSinc.tests.js new file mode 100644 index 00000000000000..2aed30b2d47c1d --- /dev/null +++ b/test/unit/addons/tsl/TSLSinc.tests.js @@ -0,0 +1,29 @@ +import { + float, sinc +} from 'three/tsl'; +import { gpuTest } from './gpu-test-utils.js'; + +export default QUnit.module( 'TSL', () => { + + QUnit.module( 'sinc()', () => { + + gpuTest( 'sinc() starts and ends at zero over one full bounce period', ( { assert } ) => { + + // sinc(x, k) == sin(PI*(k*x-1)) / (PI*(k*x-1)) -- from the function's + // own doc comment. At x=0 the argument is -PI, giving sin(-PI)/(-PI) == 0. + assert.closeAbs( sinc( float( 0 ), float( 1 ) ), float( 0 ), 1e-4, 'sinc(0, k=1) == 0' ); + + // At x == 1/k the argument is exactly 0 -- naively that's sin(0)/0, + // but sinc()'s removable-singularity guard (see MathUtils.js) returns + // the analytic limit of 1 instead of evaluating the 0/0 division, so + // this is exercised at full precision, not just approximately. + assert.closeAbs( sinc( float( 1 ), float( 1 ) ), float( 1 ), 1e-4, 'sinc(1/k, k) == 1 -- the sinc() peak' ); + + // At x == 2/k the argument is +PI, giving sin(PI)/PI == 0 again. + assert.closeAbs( sinc( float( 2 ), float( 1 ) ), float( 0 ), 1e-3, 'sinc(2/k, k) == 0 again' ); + + } ); + + } ); + +} ); diff --git a/test/unit/three.addons.unit.js b/test/unit/three.addons.unit.js index 16b4c117a7a8e9..f25e31838f2c0a 100644 --- a/test/unit/three.addons.unit.js +++ b/test/unit/three.addons.unit.js @@ -16,3 +16,8 @@ import './addons/loaders/USDLoader.tests.js'; import './addons/exporters/USDZExporter.tests.js'; import './addons/tsl/WebGLNodesHandler.tests.js'; import './addons/tsl/GPUTest.tests.js'; +import './addons/tsl/TSLDeterminant.tests.js'; +import './addons/tsl/TSLFaceForward.tests.js'; +import './addons/tsl/TSLGainPcurve.tests.js'; +import './addons/tsl/TSLRotate.tests.js'; +import './addons/tsl/TSLSinc.tests.js';