diff --git a/editor/js/Editor.js b/editor/js/Editor.js index cfe7dbd01178df..98873d47289b2e 100644 --- a/editor/js/Editor.js +++ b/editor/js/Editor.js @@ -553,7 +553,7 @@ Editor.prototype = { setViewportCamera: function ( uuid ) { - this.viewportCamera = this.cameras[ uuid ]; + this.viewportCamera = this.cameras[ uuid ] || this.camera; this.signals.viewportCameraChanged.dispatch(); }, diff --git a/examples/jsm/loaders/USDLoader.js b/examples/jsm/loaders/USDLoader.js index 4a8db4481f6c48..15673638f75681 100644 --- a/examples/jsm/loaders/USDLoader.js +++ b/examples/jsm/loaders/USDLoader.js @@ -90,46 +90,60 @@ class USDLoader extends Loader { const usda = new USDAParser(); const usdc = new USDCParser(); + const textDecoder = new TextDecoder(); - function parseAssets( zip ) { + function toArrayBuffer( data ) { - const data = {}; - const loader = new FileLoader(); - loader.setResponseType( 'arraybuffer' ); + if ( data instanceof ArrayBuffer ) return data; - for ( const filename in zip ) { + if ( data.byteOffset === 0 && data.byteLength === data.buffer.byteLength ) { - if ( filename.endsWith( 'png' ) || filename.endsWith( 'jpg' ) || filename.endsWith( 'jpeg' ) ) { + return data.buffer; - const type = filename.endsWith( 'png' ) ? 'image/png' : 'image/jpeg'; - const blob = new Blob( [ zip[ filename ] ], { type } ); - data[ filename ] = URL.createObjectURL( blob ); + } - } + return data.buffer.slice( data.byteOffset, data.byteOffset + data.byteLength ); - } + } + + function getLowercaseExtension( filename ) { + + const lastDot = filename.lastIndexOf( '.' ); + if ( lastDot < 0 ) return ''; + + const lastSlash = filename.lastIndexOf( '/' ); + if ( lastSlash > lastDot ) return ''; + + return filename.slice( lastDot + 1 ).toLowerCase(); + + } + + function parseAssets( zip ) { + + const data = {}; for ( const filename in zip ) { - if ( filename.endsWith( 'usd' ) || filename.endsWith( 'usda' ) || filename.endsWith( 'usdc' ) ) { + const fileBytes = zip[ filename ]; + const ext = getLowercaseExtension( filename ); + + if ( ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'avif' ) { + + // Keep raw image bytes and create object URLs lazily in USDComposer. + data[ filename ] = fileBytes; + continue; + + } - if ( isCrateFile( zip[ filename ] ) ) { + if ( ext !== 'usd' && ext !== 'usda' && ext !== 'usdc' ) continue; - // Store parsed data (specsByPath) for on-demand composition - const parsedData = usdc.parseData( zip[ filename ].buffer ); - data[ filename ] = parsedData; - // Store raw buffer for re-parsing with variant selections - data[ filename + ':buffer' ] = zip[ filename ].buffer; + if ( isCrateFile( fileBytes ) ) { - } else { + data[ filename ] = usdc.parseData( toArrayBuffer( fileBytes ) ); - const text = new TextDecoder().decode( zip[ filename ] ); - // Store parsed data (specsByPath) for on-demand composition - data[ filename ] = usda.parseData( text ); - // Store raw text for re-parsing with variant selections - data[ filename + ':text' ] = text; + } else { - } + data[ filename ] = usda.parseData( textDecoder.decode( fileBytes ) ); } @@ -142,10 +156,9 @@ class USDLoader extends Loader { function isCrateFile( buffer ) { const crateHeader = new Uint8Array( [ 0x50, 0x58, 0x52, 0x2D, 0x55, 0x53, 0x44, 0x43 ] ); // PXR-USDC + const view = buffer instanceof Uint8Array ? buffer : new Uint8Array( buffer ); - if ( buffer.byteLength < crateHeader.length ) return false; - - const view = new Uint8Array( buffer, 0, crateHeader.length ); + if ( view.byteLength < crateHeader.length ) return false; for ( let i = 0; i < crateHeader.length; i ++ ) { @@ -159,29 +172,30 @@ class USDLoader extends Loader { function findUSD( zip ) { - if ( zip.length < 1 ) return { file: undefined, basePath: '' }; + const fileNames = Object.keys( zip ); + if ( fileNames.length < 1 ) return { file: undefined, filename: '', basePath: '' }; - const firstFileName = Object.keys( zip )[ 0 ]; + const firstFileName = fileNames[ 0 ]; + const ext = getLowercaseExtension( firstFileName ); let isCrate = false; const lastSlash = firstFileName.lastIndexOf( '/' ); const basePath = lastSlash >= 0 ? firstFileName.slice( 0, lastSlash ) : ''; - // As per the USD specification, the first entry in the zip archive is used as the main file ("UsdStage"). + // Per AOUSD core spec v1.0.1 section 16.4.1.2, the first ZIP entry is the root layer. // ASCII files can end in either .usda or .usd. - // See https://openusd.org/release/spec_usdz.html#layout - if ( firstFileName.endsWith( 'usda' ) ) return { file: zip[ firstFileName ], basePath }; + if ( ext === 'usda' ) return { file: zip[ firstFileName ], filename: firstFileName, basePath }; - if ( firstFileName.endsWith( 'usdc' ) ) { + if ( ext === 'usdc' ) { isCrate = true; - } else if ( firstFileName.endsWith( 'usd' ) ) { + } else if ( ext === 'usd' ) { // If this is not a crate file, we assume it is a plain USDA file. if ( ! isCrateFile( zip[ firstFileName ] ) ) { - return { file: zip[ firstFileName ], basePath }; + return { file: zip[ firstFileName ], filename: firstFileName, basePath }; } else { @@ -193,11 +207,11 @@ class USDLoader extends Loader { if ( isCrate ) { - return { file: zip[ firstFileName ], basePath }; + return { file: zip[ firstFileName ], filename: firstFileName, basePath }; } - return { file: undefined, basePath: '' }; + return { file: undefined, filename: '', basePath: '' }; } @@ -218,7 +232,7 @@ class USDLoader extends Loader { if ( isCrateFile( buffer ) ) { const composer = new USDComposer( scope.manager ); - const data = usdc.parseData( buffer ); + const data = usdc.parseData( toArrayBuffer( buffer ) ); return composer.compose( data, {} ); } @@ -230,22 +244,20 @@ class USDLoader extends Loader { if ( bytes[ 0 ] === 0x50 && bytes[ 1 ] === 0x4B ) { const zip = unzipSync( bytes ); - const assets = parseAssets( zip ); + const { file, filename, basePath } = findUSD( zip ); - const { file, basePath } = findUSD( zip ); + if ( ! file ) { - const composer = new USDComposer( scope.manager ); - let data; + throw new Error( 'USDLoader: Invalid USDZ package. The first ZIP entry must be a USD layer (.usd/.usda/.usdc).' ); - if ( isCrateFile( file ) ) { - - data = usdc.parseData( file.buffer ); + } - } else { + const composer = new USDComposer( scope.manager ); + const data = assets[ filename ]; + if ( ! data ) { - const text = new TextDecoder().decode( file ); - data = usda.parseData( text ); + throw new Error( 'USDLoader: Failed to parse root layer "' + filename + '".' ); } @@ -256,7 +268,7 @@ class USDLoader extends Loader { // USDA (standalone, as ArrayBuffer) const composer = new USDComposer( scope.manager ); - const text = new TextDecoder().decode( bytes ); + const text = textDecoder.decode( bytes ); const data = usda.parseData( text ); return composer.compose( data, {} ); diff --git a/examples/jsm/loaders/usd/USDComposer.js b/examples/jsm/loaders/usd/USDComposer.js index 4a20de859bae82..fcfb21430cc4a4 100644 --- a/examples/jsm/loaders/usd/USDComposer.js +++ b/examples/jsm/loaders/usd/USDComposer.js @@ -3559,7 +3559,9 @@ class USDComposer { } - // Use geomBindTransform if available, otherwise compute from mesh/skeleton alignment + // Use geomBindTransform if available, otherwise fall back to identity. + // Estimating bind transforms from vertex/joint samples is not robust and can + // produce severe skinning distortion for valid assets. let bindMatrix = new Matrix4(); if ( geomBindTransform && geomBindTransform.length === 16 ) { @@ -3573,11 +3575,6 @@ class USDComposer { m[ 3 ], m[ 7 ], m[ 11 ], m[ 15 ] ); - } else { - - // Compute geomBindTransform by comparing mesh vertices with skeleton bind positions - bindMatrix = this._computeGeomBindTransform( mesh, skeleton ); - } mesh.bind( skeleton, bindMatrix ); @@ -3586,73 +3583,6 @@ class USDComposer { } - _computeGeomBindTransform( mesh, skeleton ) { - - const bindMatrix = new Matrix4(); - const geometry = mesh.geometry; - const position = geometry.attributes.position; - const skinIndex = geometry.attributes.skinIndex; - - if ( ! position || ! skinIndex || position.count === 0 ) { - - return bindMatrix; - - } - - // Sample vertices and their influencing joints to compute average scale - const boneInverses = skeleton.boneInverses; - const sampleCount = Math.min( 50, position.count ); - let sumRatioX = 0, sumRatioY = 0, sumRatioZ = 0; - let validSamples = 0; - - for ( let i = 0; i < sampleCount; i ++ ) { - - const vi = Math.floor( i * position.count / sampleCount ); - const vx = position.getX( vi ); - const vy = position.getY( vi ); - const vz = position.getZ( vi ); - - // Get primary joint for this vertex - const jointIdx = skinIndex.getX( vi ); - if ( jointIdx >= boneInverses.length ) continue; - - // Get joint bind position from inverse bind matrix - const inverseBindMatrix = boneInverses[ jointIdx ]; - const bindTransform = inverseBindMatrix.clone().invert(); - const jx = bindTransform.elements[ 12 ]; - const jy = bindTransform.elements[ 13 ]; - const jz = bindTransform.elements[ 14 ]; - - // Compute ratio if both values are non-zero - if ( Math.abs( vx ) > 0.001 && Math.abs( jx ) > 0.001 ) { - - sumRatioX += jx / vx; - sumRatioY += jy / vy; - sumRatioZ += jz / vz; - validSamples ++; - - } - - } - - if ( validSamples > 0 ) { - - // Use average scale to create geomBindTransform - const avgScale = ( sumRatioX + sumRatioY + sumRatioZ ) / ( validSamples * 3 ); - - // Only apply if scale is significantly different from 1 - if ( Math.abs( avgScale - 1 ) > 0.1 ) { - - bindMatrix.makeScale( avgScale, avgScale, avgScale ); - - } - - } - - return bindMatrix; - - } - _buildAnimations() { const animations = []; diff --git a/src/nodes/accessors/StorageTextureNode.js b/src/nodes/accessors/StorageTextureNode.js index e249c03c1924b7..62c6ac5809222b 100644 --- a/src/nodes/accessors/StorageTextureNode.js +++ b/src/nodes/accessors/StorageTextureNode.js @@ -162,6 +162,28 @@ class StorageTextureNode extends TextureNode { } + /** + * Generates the snippet for the storage texture. + * + * @param {NodeBuilder} builder - The current node builder. + * @param {string} textureProperty - The texture property. + * @param {string} uvSnippet - The uv snippet. + * @param {?string} levelSnippet - The level snippet. + * @param {?string} biasSnippet - The bias snippet. + * @param {?string} depthSnippet - The depth snippet. + * @param {?string} compareSnippet - The compare snippet. + * @param {?Array} gradSnippet - The grad snippet. + * @param {?string} offsetSnippet - The offset snippet. + * @return {string} The generated code snippet. + */ + generateSnippet( builder, textureProperty, uvSnippet, levelSnippet, biasSnippet, depthSnippet, compareSnippet, gradSnippet, offsetSnippet ) { + + const texture = this.value; + + return builder.generateStorageTextureLoad( texture, textureProperty, uvSnippet, levelSnippet, depthSnippet, offsetSnippet ); + + } + /** * Convenience method for configuring a read/write node access. * diff --git a/src/renderers/common/Sampler.js b/src/renderers/common/Sampler.js index d7ae005b360c45..fbeba4972a9829 100644 --- a/src/renderers/common/Sampler.js +++ b/src/renderers/common/Sampler.js @@ -35,7 +35,7 @@ class Sampler extends Binding { this._onTextureDispose = () => { this.generation = null; - this.version = 0; + this.version = - 1; }; @@ -47,7 +47,7 @@ class Sampler extends Binding { * * @type {number} */ - this.version = texture ? texture.version : 0; + this.version = texture ? texture.version : - 1; /** * The binding's generation which is an additional version @@ -95,7 +95,7 @@ class Sampler extends Binding { this._texture = value; this.generation = null; - this.version = 0; + this.version = - 1; if ( this._texture ) { @@ -150,7 +150,7 @@ class Sampler extends Binding { clonedSampler._onTextureDispose = () => { clonedSampler.generation = null; - clonedSampler.version = 0; + clonedSampler.version = - 1; }; diff --git a/src/renderers/webgpu/nodes/WGSLNodeBuilder.js b/src/renderers/webgpu/nodes/WGSLNodeBuilder.js index e1abfaa83945f7..12d41fe4b02837 100644 --- a/src/renderers/webgpu/nodes/WGSLNodeBuilder.js +++ b/src/renderers/webgpu/nodes/WGSLNodeBuilder.js @@ -582,7 +582,7 @@ class WGSLNodeBuilder extends NodeBuilder { } /** - * Generates the WGSL snippet that reads a single texel from a texture without sampling or filtering. + * Generates the WGSL snippet that reads a single texel from a storage texture. * * @param {Texture} texture - The texture. * @param {string} textureProperty - The name of the texture uniform in the shader. @@ -592,11 +592,7 @@ class WGSLNodeBuilder extends NodeBuilder { * @param {?string} offsetSnippet - A WGSL snippet that represents the offset that will be applied to the unnormalized texture coordinate before sampling the texture. * @return {string} The WGSL snippet. */ - generateTextureLoad( texture, textureProperty, uvIndexSnippet, levelSnippet, depthSnippet, offsetSnippet ) { - - const isStorageTexture = texture.isStorageTexture === true; - - if ( levelSnippet === null && ! isStorageTexture ) levelSnippet = '0u'; + generateStorageTextureLoad( texture, textureProperty, uvIndexSnippet, levelSnippet, depthSnippet, offsetSnippet ) { if ( offsetSnippet ) { @@ -608,33 +604,52 @@ class WGSLNodeBuilder extends NodeBuilder { if ( depthSnippet ) { - // Storage textures don't take a level parameter in WGSL - if ( isStorageTexture ) { + snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ depthSnippet } )`; - snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ depthSnippet } )`; + } else { - } else { + snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet } )`; - snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ depthSnippet }, u32( ${ levelSnippet } ) )`; + } - } + return snippet; - } else { + } + + /** + * Generates the WGSL snippet that reads a single texel from a texture without sampling or filtering. + * + * @param {Texture} texture - The texture. + * @param {string} textureProperty - The name of the texture uniform in the shader. + * @param {string} uvIndexSnippet - A WGSL snippet that represents texture coordinates used for sampling. + * @param {?string} levelSnippet - A WGSL snippet that represents the mip level, with level 0 containing a full size version of the texture. + * @param {?string} depthSnippet - A WGSL snippet that represents 0-based texture array index to sample. + * @param {?string} offsetSnippet - A WGSL snippet that represents the offset that will be applied to the unnormalized texture coordinate before sampling the texture. + * @return {string} The WGSL snippet. + */ + generateTextureLoad( texture, textureProperty, uvIndexSnippet, levelSnippet, depthSnippet, offsetSnippet ) { - // Storage textures don't take a level parameter in WGSL - if ( isStorageTexture ) { + if ( levelSnippet === null ) levelSnippet = '0u'; - snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet } )`; + if ( offsetSnippet ) { - } else { + uvIndexSnippet = `${ uvIndexSnippet } + ${ offsetSnippet }`; - snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, u32( ${ levelSnippet } ) )`; + } - if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { + let snippet; - snippet += '.x'; + if ( depthSnippet ) { - } + snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ depthSnippet }, u32( ${ levelSnippet } ) )`; + + } else { + + snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, u32( ${ levelSnippet } ) )`; + + if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { + + snippet += '.x'; } diff --git a/test/unit/src/nodes/accessors/StorageTextureNode.tests.js b/test/unit/src/nodes/accessors/StorageTextureNode.tests.js deleted file mode 100644 index f3291c7bc7a3d8..00000000000000 --- a/test/unit/src/nodes/accessors/StorageTextureNode.tests.js +++ /dev/null @@ -1,39 +0,0 @@ -import { storageTexture } from '../../../../../src/nodes/accessors/StorageTextureNode.js'; -import { NodeAccess } from '../../../../../src/nodes/core/constants.js'; -import StorageTexture from '../../../../../src/renderers/common/StorageTexture.js'; - -export default QUnit.module( 'Nodes', () => { - - QUnit.module( 'Accessors', () => { - - QUnit.module( 'StorageTextureNode', () => { - - QUnit.test( 'clone preserves access property', ( assert ) => { - - const texture = new StorageTexture( 512, 512 ); - const node = storageTexture( texture ).setAccess( NodeAccess.READ_ONLY ); - - assert.strictEqual( node.access, NodeAccess.READ_ONLY, 'original has READ_ONLY access' ); - - const cloned = node.clone(); - - assert.strictEqual( cloned.access, NodeAccess.READ_ONLY, 'cloned node preserves READ_ONLY access' ); - - } ); - - QUnit.test( 'clone preserves READ_WRITE access', ( assert ) => { - - const texture = new StorageTexture( 512, 512 ); - const node = storageTexture( texture ).setAccess( NodeAccess.READ_WRITE ); - - const cloned = node.clone(); - - assert.strictEqual( cloned.access, NodeAccess.READ_WRITE, 'cloned node preserves READ_WRITE access' ); - - } ); - - } ); - - } ); - -} ); diff --git a/test/unit/src/renderers/webgpu/nodes/WGSLNodeBuilder.tests.js b/test/unit/src/renderers/webgpu/nodes/WGSLNodeBuilder.tests.js deleted file mode 100644 index e71ba70551d65a..00000000000000 --- a/test/unit/src/renderers/webgpu/nodes/WGSLNodeBuilder.tests.js +++ /dev/null @@ -1,69 +0,0 @@ -import WGSLNodeBuilder from '../../../../../../src/renderers/webgpu/nodes/WGSLNodeBuilder.js'; - -export default QUnit.module( 'Renderers', () => { - - QUnit.module( 'WebGPU', () => { - - QUnit.module( 'Nodes', () => { - - QUnit.module( 'WGSLNodeBuilder', () => { - - // generateTextureLoad is essentially a pure function (texture info -> WGSL string) - // The only 'this' access is renderer.backend.compatibilityMode for a depth texture edge case - // We test the real method with minimal context to verify WGSL output - - QUnit.test( 'generateTextureLoad omits level for storage textures', ( assert ) => { - - const context = { - renderer: { backend: { compatibilityMode: false } } - }; - - const storageTexture = { isStorageTexture: true }; - - const snippet = WGSLNodeBuilder.prototype.generateTextureLoad.call( - context, - storageTexture, - 'testTexture', - 'uvec2(0, 0)', - null, // levelSnippet - null, // depthSnippet - null // offsetSnippet - ); - - // Storage textures should NOT have level parameter (WGSL spec) - assert.notOk( snippet.includes( 'u32(' ), 'storage texture load should not include level parameter' ); - assert.strictEqual( snippet, 'textureLoad( testTexture, uvec2(0, 0) )', 'correct WGSL for storage texture' ); - - } ); - - QUnit.test( 'generateTextureLoad includes level for regular textures', ( assert ) => { - - const context = { - renderer: { backend: { compatibilityMode: false } } - }; - - const regularTexture = { isStorageTexture: false }; - - const snippet = WGSLNodeBuilder.prototype.generateTextureLoad.call( - context, - regularTexture, - 'testTexture', - 'uvec2(0, 0)', - null, // levelSnippet - should default to '0u' - null, - null - ); - - // Regular textures SHOULD have level parameter - assert.ok( snippet.includes( 'u32( 0u )' ), 'regular texture load should include default level parameter' ); - assert.strictEqual( snippet, 'textureLoad( testTexture, uvec2(0, 0), u32( 0u ) )' ); - - } ); - - } ); - - } ); - - } ); - -} ); diff --git a/test/unit/three.source.unit.js b/test/unit/three.source.unit.js index 168016d347f44e..37ea95329d7bd2 100644 --- a/test/unit/three.source.unit.js +++ b/test/unit/three.source.unit.js @@ -265,12 +265,6 @@ import './src/renderers/webgl/WebGLTextures.tests.js'; import './src/renderers/webgl/WebGLUniforms.tests.js'; import './src/renderers/webgl/WebGLUtils.tests.js'; -//src/renderers/webgpu/nodes -import './src/renderers/webgpu/nodes/WGSLNodeBuilder.tests.js'; - -//src/nodes/accessors -import './src/nodes/accessors/StorageTextureNode.tests.js'; - //src/scenes import './src/scenes/Fog.tests.js';