From 8d9c591c1c93a5c583a69e5ec6083d26b85a028d Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Sat, 21 Feb 2026 15:52:49 +0900 Subject: [PATCH 1/3] USDComposer: add Camera prim parsing --- examples/jsm/loaders/usd/USDComposer.js | 117 ++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/examples/jsm/loaders/usd/USDComposer.js b/examples/jsm/loaders/usd/USDComposer.js index fcc3dfc2683ba2..7862e1fed054bf 100644 --- a/examples/jsm/loaders/usd/USDComposer.js +++ b/examples/jsm/loaders/usd/USDComposer.js @@ -11,6 +11,8 @@ import { MirroredRepeatWrapping, NoColorSpace, Object3D, + OrthographicCamera, + PerspectiveCamera, Quaternion, QuaternionKeyframeTrack, RepeatWrapping, @@ -44,6 +46,19 @@ const SpecType = { VariantSet: 11 }; +// UsdGeomCamera fallback values (OpenUSD schema) +const USD_CAMERA_DEFAULTS = { + projection: 'perspective', + clippingRange: [ 1, 1000000 ], + horizontalAperture: 20.955, + verticalAperture: 15.2908, + horizontalApertureOffset: 0, + verticalApertureOffset: 0, + focalLength: 50, + focusDistance: 0, + fStop: 0 +}; + /** * USDComposer handles scene composition from parsed USD data. * This includes reference resolution, variant selection, transform handling, @@ -657,6 +672,15 @@ class USDComposer { } + } else if ( typeName === 'Camera' ) { + + const obj = this._buildCamera( path ); + obj.name = name; + const attrs = this._getAttributes( path ); + this.applyTransform( obj, spec.fields, attrs ); + parent.add( obj ); + this._buildHierarchy( obj, path ); + } else if ( typeName === 'Material' || typeName === 'Shader' || typeName === 'GeomSubset' ) { // Skip materials/shaders/subsets, they're referenced by meshes @@ -1191,6 +1215,99 @@ class USDComposer { } + /** + * Build a camera from a Camera spec. + */ + _buildCamera( path ) { + + const attrs = this._getAttributes( path ); + const projectionToken = attrs[ 'projection' ]; + const projection = typeof projectionToken === 'string' + ? projectionToken.toLowerCase() + : USD_CAMERA_DEFAULTS.projection; + const clippingRange = attrs[ 'clippingRange' ] || USD_CAMERA_DEFAULTS.clippingRange; + const near = Math.max( + Number.EPSILON, + this._parseNumber( clippingRange[ 0 ], USD_CAMERA_DEFAULTS.clippingRange[ 0 ] ) + ); + const far = Math.max( + near + Number.EPSILON, + this._parseNumber( clippingRange[ 1 ], USD_CAMERA_DEFAULTS.clippingRange[ 1 ] ) + ); + const horizontalAperture = this._parseNumber( + attrs[ 'horizontalAperture' ], + USD_CAMERA_DEFAULTS.horizontalAperture + ); + const verticalAperture = this._parseNumber( + attrs[ 'verticalAperture' ], + USD_CAMERA_DEFAULTS.verticalAperture + ); + const horizontalApertureOffset = this._parseNumber( + attrs[ 'horizontalApertureOffset' ], + USD_CAMERA_DEFAULTS.horizontalApertureOffset + ); + const verticalApertureOffset = this._parseNumber( + attrs[ 'verticalApertureOffset' ], + USD_CAMERA_DEFAULTS.verticalApertureOffset + ); + const focalLength = this._parseNumber( attrs[ 'focalLength' ], USD_CAMERA_DEFAULTS.focalLength ); + const focusDistance = this._parseNumber( attrs[ 'focusDistance' ], USD_CAMERA_DEFAULTS.focusDistance ); + const fStop = this._parseNumber( attrs[ 'fStop' ], USD_CAMERA_DEFAULTS.fStop ); + + let camera; + + if ( projection === 'orthographic' ) { + + // USD orthographic apertures are in tenths of a world unit. + const width = horizontalAperture / 10; + const height = verticalAperture / 10; + const offsetX = horizontalApertureOffset / 10; + const offsetY = verticalApertureOffset / 10; + + camera = new OrthographicCamera( + offsetX - width * 0.5, + offsetX + width * 0.5, + offsetY + height * 0.5, + offsetY - height * 0.5, + near, + far + ); + + } else { + + const safeVerticalAperture = Math.max( Number.EPSILON, verticalAperture ); + const safeFocalLength = Math.max( Number.EPSILON, focalLength ); + const aspect = horizontalAperture / safeVerticalAperture; + const fov = 2 * Math.atan( safeVerticalAperture / ( 2 * safeFocalLength ) ) * 180 / Math.PI; + + camera = new PerspectiveCamera( fov, aspect, near, far ); + camera.filmGauge = Math.max( horizontalAperture, verticalAperture ); + camera.filmOffset = horizontalApertureOffset; + camera.focus = focusDistance; + camera.setFocalLength( safeFocalLength ); + + if ( verticalApertureOffset !== 0 ) { + + // Three.js supports only horizontal film offset directly. + camera.userData.verticalApertureOffset = verticalApertureOffset; + + } + + } + + camera.userData.fStop = fStop; + camera.userData.usdProjection = projection; + return camera; + + } + + _parseNumber( value, fallback ) { + + const n = Number( value ); + return Number.isFinite( n ) ? n : fallback; + + } + _getGeomSubsets( meshPath ) { const subsets = []; From 5f6753b296df6faca9e4474ccc497ffd86372fa4 Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Sat, 21 Feb 2026 10:21:06 +0100 Subject: [PATCH 2/3] AnimationAction: Fix `timeScale` reversal jump. (#33035) --- src/animation/AnimationAction.js | 1 + .../src/animation/AnimationAction.tests.js | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/animation/AnimationAction.js b/src/animation/AnimationAction.js index f6f953b61b35c6..5630db5f5d9a67 100644 --- a/src/animation/AnimationAction.js +++ b/src/animation/AnimationAction.js @@ -856,6 +856,7 @@ class AnimationAction { } else { + this._loopCount = loopCount; this.time = time; } diff --git a/test/unit/src/animation/AnimationAction.tests.js b/test/unit/src/animation/AnimationAction.tests.js index 60661d96fdea24..fb399f38e431bd 100644 --- a/test/unit/src/animation/AnimationAction.tests.js +++ b/test/unit/src/animation/AnimationAction.tests.js @@ -446,6 +446,62 @@ export default QUnit.module( 'Animation', () => { } ); + QUnit.test( 'LoopRepeat with timeScale reversal during first loop', ( assert ) => { + + // Regression test for #19151 + const root = new Object3D(); + const mixer = new AnimationMixer( root ); + const track = new NumberKeyframeTrack( '.rotation[x]', [ 0, 1000 ], [ 0, 360 ] ); + const clip = new AnimationClip( 'clip1', 1000, [ track ] ); + + const animationAction = mixer.clipAction( clip ); + animationAction.setLoop( LoopRepeat, Infinity ); + animationAction.play(); + + // Advance partway into the first loop + mixer.update( 500 ); + assert.equal( root.rotation.x, 180, 'At 500ms, rotation is 180 (halfway).' ); + + // Reverse timeScale + mixer.timeScale = - 1; + + // Step backward — should smoothly reverse, not jump + mixer.update( 250 ); + assert.equal( root.rotation.x, 90, 'After reversing and stepping 250ms back, rotation is 90.' ); + + mixer.update( 250 ); + assert.equal( root.rotation.x, 0, 'After reversing and stepping another 250ms back, rotation is 0 (start).' ); + + } ); + + QUnit.test( 'LoopPingPong with timeScale reversal during first loop', ( assert ) => { + + // Regression test for #19151 + const root = new Object3D(); + const mixer = new AnimationMixer( root ); + const track = new NumberKeyframeTrack( '.rotation[x]', [ 0, 1000 ], [ 0, 360 ] ); + const clip = new AnimationClip( 'clip1', 1000, [ track ] ); + + const animationAction = mixer.clipAction( clip ); + animationAction.setLoop( LoopPingPong, Infinity ); + animationAction.play(); + + // Advance partway into the first loop + mixer.update( 500 ); + assert.equal( root.rotation.x, 180, 'At 500ms, rotation is 180 (halfway).' ); + + // Reverse timeScale + mixer.timeScale = - 1; + + // Step backward — should smoothly reverse, not jump + mixer.update( 250 ); + assert.equal( root.rotation.x, 90, 'After reversing and stepping 250ms back, rotation is 90.' ); + + mixer.update( 250 ); + assert.equal( root.rotation.x, 0, 'After reversing and stepping another 250ms back, rotation is 0 (start).' ); + + } ); + } ); } ); From 83569322e8b110eb35089ca23f893b72676d2c0c Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Sat, 21 Feb 2026 11:29:58 +0100 Subject: [PATCH 3/3] WebGLRenderer: Use working color space for render targets. (#33036) --- src/renderers/WebGLRenderer.js | 3 +-- src/renderers/webgl/WebGLPrograms.js | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderers/WebGLRenderer.js b/src/renderers/WebGLRenderer.js index 3eb7ae3b26b2dc..0290a58642c0d2 100644 --- a/src/renderers/WebGLRenderer.js +++ b/src/renderers/WebGLRenderer.js @@ -8,7 +8,6 @@ import { NoToneMapping, LinearMipmapLinearFilter, SRGBColorSpace, - LinearSRGBColorSpace, RGBAIntegerFormat, RGIntegerFormat, RedIntegerFormat, @@ -2272,7 +2271,7 @@ class WebGLRenderer { const fog = scene.fog; const environment = ( material.isMeshStandardMaterial || material.isMeshLambertMaterial || material.isMeshPhongMaterial ) ? scene.environment : null; - const colorSpace = ( _currentRenderTarget === null ) ? _this.outputColorSpace : ( _currentRenderTarget.isXRRenderTarget === true ? _currentRenderTarget.texture.colorSpace : LinearSRGBColorSpace ); + const colorSpace = ( _currentRenderTarget === null ) ? _this.outputColorSpace : ( _currentRenderTarget.isXRRenderTarget === true ? _currentRenderTarget.texture.colorSpace : ColorManagement.workingColorSpace ); const usePMREM = material.isMeshStandardMaterial || ( material.isMeshLambertMaterial && ! material.envMap ) || ( material.isMeshPhongMaterial && ! material.envMap ); const envMap = environments.get( material.envMap || environment, usePMREM ); const vertexAlphas = material.vertexColors === true && !! geometry.attributes.color && geometry.attributes.color.itemSize === 4; diff --git a/src/renderers/webgl/WebGLPrograms.js b/src/renderers/webgl/WebGLPrograms.js index 7236f33743397c..a534e0c8818d91 100644 --- a/src/renderers/webgl/WebGLPrograms.js +++ b/src/renderers/webgl/WebGLPrograms.js @@ -1,4 +1,4 @@ -import { BackSide, DoubleSide, CubeUVReflectionMapping, ObjectSpaceNormalMap, TangentSpaceNormalMap, NoToneMapping, NormalBlending, LinearSRGBColorSpace, SRGBTransfer } from '../../constants.js'; +import { BackSide, DoubleSide, CubeUVReflectionMapping, ObjectSpaceNormalMap, TangentSpaceNormalMap, NoToneMapping, NormalBlending, SRGBTransfer } from '../../constants.js'; import { Layers } from '../../core/Layers.js'; import { WebGLProgram } from './WebGLProgram.js'; import { WebGLShaderCache } from './WebGLShaderCache.js'; @@ -200,7 +200,7 @@ function WebGLPrograms( renderer, environments, extensions, capabilities, bindin instancingColor: IS_INSTANCEDMESH && object.instanceColor !== null, instancingMorph: IS_INSTANCEDMESH && object.morphTexture !== null, - outputColorSpace: ( currentRenderTarget === null ) ? renderer.outputColorSpace : ( currentRenderTarget.isXRRenderTarget === true ? currentRenderTarget.texture.colorSpace : LinearSRGBColorSpace ), + outputColorSpace: ( currentRenderTarget === null ) ? renderer.outputColorSpace : ( currentRenderTarget.isXRRenderTarget === true ? currentRenderTarget.texture.colorSpace : ColorManagement.workingColorSpace ), alphaToCoverage: !! material.alphaToCoverage, map: HAS_MAP,