Browse Source

USDLoader: Performance improvements and external texture support. (#32790)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
mrdoob 4 tháng trước cách đây
mục cha
commit
3b39ab8e43

+ 11 - 1
editor/js/Loader.js

@@ -635,7 +635,8 @@ function Loader( editor ) {
 
 					const { USDLoader } = await import( 'three/addons/loaders/USDLoader.js' );
 
-					const group = new USDLoader().parse( contents );
+					const loader = new USDLoader( manager );
+					const group = loader.parse( contents );
 					group.name = filename;
 
 					editor.execute( new AddObjectCommand( editor, group ) );
@@ -759,6 +760,15 @@ function Loader( editor ) {
 
 			}
 
+			case 'bmp':
+			case 'gif':
+			case 'jpg':
+			case 'jpeg':
+			case 'png':
+			case 'tga':
+
+				break; // Image files are handled as textures by other loaders
+
 			default:
 
 				console.error( 'Unsupported file format (' + extension + ').' );

+ 6 - 4
examples/jsm/loaders/USDLoader.js

@@ -201,11 +201,13 @@ class USDLoader extends Loader {
 
 		}
 
+		const scope = this;
+
 		// USDA (standalone)
 
 		if ( typeof buffer === 'string' ) {
 
-			const composer = new USDComposer();
+			const composer = new USDComposer( scope.manager );
 			const data = usda.parseData( buffer );
 			return composer.compose( data, {} );
 
@@ -215,7 +217,7 @@ class USDLoader extends Loader {
 
 		if ( isCrateFile( buffer ) ) {
 
-			const composer = new USDComposer();
+			const composer = new USDComposer( scope.manager );
 			const data = usdc.parseData( buffer );
 			return composer.compose( data, {} );
 
@@ -233,7 +235,7 @@ class USDLoader extends Loader {
 
 			const { file, basePath } = findUSD( zip );
 
-			const composer = new USDComposer();
+			const composer = new USDComposer( scope.manager );
 			let data;
 
 			if ( isCrateFile( file ) ) {
@@ -253,7 +255,7 @@ class USDLoader extends Loader {
 
 		// USDA (standalone, as ArrayBuffer)
 
-		const composer = new USDComposer();
+		const composer = new USDComposer( scope.manager );
 		const text = new TextDecoder().decode( bytes );
 		const data = usda.parseData( text );
 		return composer.compose( data, {} );

+ 69 - 3
examples/jsm/loaders/usd/USDAParser.js

@@ -1,3 +1,8 @@
+// Pre-compiled regex patterns for performance
+const DEF_MATCH_REGEX = /^def\s+(?:(\w+)\s+)?"?([^"]+)"?$/;
+const VARIANT_STRING_REGEX = /^string\s+(\w+)$/;
+const ATTR_MATCH_REGEX = /^(?:uniform\s+)?(\w+(?:\[\])?)\s+(.+)$/;
+
 class USDAParser {
 
 	parseText( text ) {
@@ -120,6 +125,9 @@ class USDAParser {
 		// Remove block comments /* ... */
 		text = this._stripBlockComments( text );
 
+		// Collapse triple-quoted strings into single lines
+		text = this._collapseTripleQuotedStrings( text );
+
 		// Remove line comments # ... (but preserve #usda header)
 		// Only remove # comments that aren't at the start of a line or after whitespace
 		const lines = text.split( '\n' );
@@ -256,6 +264,64 @@ class USDAParser {
 
 	}
 
+	_collapseTripleQuotedStrings( text ) {
+
+		let result = '';
+		let i = 0;
+
+		while ( i < text.length ) {
+
+			if ( i + 2 < text.length ) {
+
+				const triple = text.slice( i, i + 3 );
+
+				if ( triple === '\'\'\'' || triple === '"""' ) {
+
+					const quoteChar = triple;
+					result += quoteChar;
+					i += 3;
+
+					while ( i < text.length ) {
+
+						if ( i + 2 < text.length && text.slice( i, i + 3 ) === quoteChar ) {
+
+							result += quoteChar;
+							i += 3;
+							break;
+
+						} else {
+
+							if ( text[ i ] === '\n' ) {
+
+								result += '\\n';
+
+							} else if ( text[ i ] !== '\r' ) {
+
+								result += text[ i ];
+
+							}
+
+							i ++;
+
+						}
+
+					}
+
+					continue;
+
+				}
+
+			}
+
+			result += text[ i ];
+			i ++;
+
+		}
+
+		return result;
+
+	}
+
 	_stripInlineComment( line ) {
 
 		// Don't strip if line starts with #usda
@@ -405,7 +471,7 @@ class USDAParser {
 
 				// Check for primitive definitions
 				// Matches both 'def TypeName "name"' and 'def "name"' (no type)
-				const defMatch = key.match( /^def\s+(?:(\w+)\s+)?"?([^"]+)"?$/ );
+				const defMatch = key.match( DEF_MATCH_REGEX );
 				if ( defMatch ) {
 
 					const typeName = defMatch[ 1 ] || '';
@@ -474,7 +540,7 @@ class USDAParser {
 
 				for ( const vKey in variants ) {
 
-					const match = vKey.match( /^string\s+(\w+)$/ );
+					const match = vKey.match( VARIANT_STRING_REGEX );
 					if ( match ) {
 
 						const variantSetName = match[ 1 ];
@@ -522,7 +588,7 @@ class USDAParser {
 
 			// Handle typed attributes
 			// Format: [qualifier] type attrName (e.g., "uniform token[] joints", "float3 position")
-			const attrMatch = key.match( /^(?:uniform\s+)?(\w+(?:\[\])?)\s+(.+)$/ );
+			const attrMatch = key.match( ATTR_MATCH_REGEX );
 			if ( attrMatch ) {
 
 				const valueType = attrMatch[ 1 ];

+ 22 - 23
examples/jsm/loaders/usd/USDCParser.js

@@ -1,5 +1,17 @@
 const textDecoder = new TextDecoder();
 
+// Pre-computed half-float exponent lookup table for fast conversion
+// Math.pow(2, exp - 15) for exp = 0..31
+const HALF_EXPONENT_TABLE = new Float32Array( 32 );
+for ( let i = 0; i < 32; i ++ ) {
+
+	HALF_EXPONENT_TABLE[ i ] = Math.pow( 2, i - 15 );
+
+}
+
+// Pre-computed constant for denormalized half-floats: 2^-14
+const HALF_DENORM_SCALE = Math.pow( 2, - 14 );
+
 // Type enum values from crateDataTypes.h
 const TypeEnum = {
 	Invalid: 0,
@@ -502,6 +514,9 @@ class USDCParser {
 		this.reader = new BinaryReader( this.buffer );
 		this.version = { major: 0, minor: 0, patch: 0 };
 
+		this._conversionBuffer = new ArrayBuffer( 4 );
+		this._conversionView = new DataView( this._conversionBuffer );
+
 		this._readBootstrap();
 		this._readTOC();
 		this._readTokens();
@@ -1101,6 +1116,7 @@ class USDCParser {
 
 		const type = valueRep.typeEnum;
 		const payload = valueRep.getInlinedValue();
+		const view = this._conversionView;
 
 		switch ( type ) {
 
@@ -1113,18 +1129,16 @@ class USDCParser {
 				return payload;
 			case TypeEnum.Float: {
 
-				const buf = new ArrayBuffer( 4 );
-				new DataView( buf ).setUint32( 0, payload, true );
-				return new DataView( buf ).getFloat32( 0, true );
+				view.setUint32( 0, payload, true );
+				return view.getFloat32( 0, true );
 
 			}
 
 			case TypeEnum.Double: {
 
 				// When a double is inlined, it's stored as float32 bits in the payload
-				const buf = new ArrayBuffer( 4 );
-				new DataView( buf ).setUint32( 0, payload, true );
-				return new DataView( buf ).getFloat32( 0, true );
+				view.setUint32( 0, payload, true );
+				return view.getFloat32( 0, true );
 
 			}
 
@@ -1143,8 +1157,6 @@ class USDCParser {
 			// Vec2h: Two half-floats fit in 4 bytes, stored directly
 			case TypeEnum.Vec2h: {
 
-				const buf = new ArrayBuffer( 4 );
-				const view = new DataView( buf );
 				view.setUint32( 0, payload, true );
 				return [ this._halfToFloat( view.getUint16( 0, true ) ), this._halfToFloat( view.getUint16( 2, true ) ) ];
 
@@ -1155,8 +1167,6 @@ class USDCParser {
 			case TypeEnum.Vec2f:
 			case TypeEnum.Vec2i: {
 
-				const buf = new ArrayBuffer( 4 );
-				const view = new DataView( buf );
 				view.setUint32( 0, payload, true );
 				return [ view.getInt8( 0 ), view.getInt8( 1 ) ];
 
@@ -1165,8 +1175,6 @@ class USDCParser {
 			case TypeEnum.Vec3f:
 			case TypeEnum.Vec3i: {
 
-				const buf = new ArrayBuffer( 4 );
-				const view = new DataView( buf );
 				view.setUint32( 0, payload, true );
 				return [ view.getInt8( 0 ), view.getInt8( 1 ), view.getInt8( 2 ) ];
 
@@ -1175,8 +1183,6 @@ class USDCParser {
 			case TypeEnum.Vec4f:
 			case TypeEnum.Vec4i: {
 
-				const buf = new ArrayBuffer( 4 );
-				const view = new DataView( buf );
 				view.setUint32( 0, payload, true );
 				return [ view.getInt8( 0 ), view.getInt8( 1 ), view.getInt8( 2 ), view.getInt8( 3 ) ];
 
@@ -1185,8 +1191,6 @@ class USDCParser {
 			case TypeEnum.Matrix2d: {
 
 				// Inlined Matrix2d stores diagonal values as 2 signed int8 values
-				const buf = new ArrayBuffer( 4 );
-				const view = new DataView( buf );
 				view.setUint32( 0, payload, true );
 				const d0 = view.getInt8( 0 ), d1 = view.getInt8( 1 );
 				return [ d0, 0, 0, d1 ];
@@ -1196,8 +1200,6 @@ class USDCParser {
 			case TypeEnum.Matrix3d: {
 
 				// Inlined Matrix3d stores diagonal values as 3 signed int8 values
-				const buf = new ArrayBuffer( 4 );
-				const view = new DataView( buf );
 				view.setUint32( 0, payload, true );
 				const d0 = view.getInt8( 0 ), d1 = view.getInt8( 1 ), d2 = view.getInt8( 2 );
 				return [ d0, 0, 0, 0, d1, 0, 0, 0, d2 ];
@@ -1207,8 +1209,6 @@ class USDCParser {
 			case TypeEnum.Matrix4d: {
 
 				// Inlined Matrix4d stores diagonal values as 4 signed int8 values
-				const buf = new ArrayBuffer( 4 );
-				const view = new DataView( buf );
 				view.setUint32( 0, payload, true );
 				const d0 = view.getInt8( 0 ), d1 = view.getInt8( 1 ), d2 = view.getInt8( 2 ), d3 = view.getInt8( 3 );
 				return [ d0, 0, 0, 0, 0, d1, 0, 0, 0, 0, d2, 0, 0, 0, 0, d3 ];
@@ -1786,7 +1786,6 @@ class USDCParser {
 
 	_halfToFloat( h ) {
 
-		// Convert half to float (IEEE 754 half-precision)
 		const sign = ( h & 0x8000 ) >> 15;
 		const exp = ( h & 0x7C00 ) >> 10;
 		const frac = h & 0x03FF;
@@ -1801,7 +1800,7 @@ class USDCParser {
 			}
 
 			// Denormalized: value = ±2^-14 × (frac/1024)
-			return ( sign ? - 1 : 1 ) * Math.pow( 2, - 14 ) * ( frac / 1024 );
+			return ( sign ? - 1 : 1 ) * HALF_DENORM_SCALE * ( frac / 1024 );
 
 		} else if ( exp === 31 ) {
 
@@ -1809,7 +1808,7 @@ class USDCParser {
 
 		}
 
-		return ( sign ? - 1 : 1 ) * Math.pow( 2, exp - 15 ) * ( 1 + frac / 1024 );
+		return ( sign ? - 1 : 1 ) * HALF_EXPONENT_TABLE[ exp ] * ( 1 + frac / 1024 );
 
 	}
 

+ 313 - 121
examples/jsm/loaders/usd/USDComposer.js

@@ -25,6 +25,9 @@ import {
 	VectorKeyframeTrack
 } from 'three';
 
+// Pre-compiled regex patterns for performance
+const VARIANT_PATH_REGEX = /^(.+?)\/\{(\w+)=(\w+)\}\/(.+)$/;
+
 // Spec types (must match USDCParser)
 const SpecType = {
 	Unknown: 0,
@@ -50,10 +53,11 @@ const SpecType = {
  */
 class USDComposer {
 
-	constructor() {
+	constructor( manager = null ) {
 
 		this.textureCache = {};
 		this.skinnedMeshes = [];
+		this.manager = manager;
 
 	}
 
@@ -74,6 +78,9 @@ class USDComposer {
 		this.skinnedMeshes = [];
 		this.skeletons = {};
 
+		// Build indexes for O(1) lookups
+		this._buildIndexes();
+
 		// Get FPS from root spec
 		const rootSpec = this.specsByPath[ '/' ];
 		const rootFields = rootSpec ? rootSpec.fields : {};
@@ -304,6 +311,138 @@ class USDComposer {
 
 	}
 
+	/**
+	 * Build indexes for efficient lookups.
+	 * Called once during compose() to avoid O(n) scans per lookup.
+	 */
+	_buildIndexes() {
+
+		// childrenByPath: parentPath -> [childName1, childName2, ...]
+		this.childrenByPath = new Map();
+
+		// attributesByPrimPath: primPath -> Map(attrName -> attrSpec)
+		this.attributesByPrimPath = new Map();
+
+		// materialsByRoot: rootPath -> [materialPath1, materialPath2, ...]
+		this.materialsByRoot = new Map();
+
+		// shadersByMaterialPath: materialPath -> [shaderPath1, shaderPath2, ...]
+		this.shadersByMaterialPath = new Map();
+
+		// geomSubsetsByMeshPath: meshPath -> [subsetPath1, subsetPath2, ...]
+		this.geomSubsetsByMeshPath = new Map();
+
+		for ( const path in this.specsByPath ) {
+
+			const spec = this.specsByPath[ path ];
+
+			if ( spec.specType === SpecType.Prim ) {
+
+				// Build parent-child index
+				const lastSlash = path.lastIndexOf( '/' );
+
+				if ( lastSlash > 0 ) {
+
+					const parentPath = path.slice( 0, lastSlash );
+					const childName = path.slice( lastSlash + 1 );
+
+					if ( ! this.childrenByPath.has( parentPath ) ) {
+
+						this.childrenByPath.set( parentPath, [] );
+
+					}
+
+					this.childrenByPath.get( parentPath ).push( { name: childName, path: path } );
+
+				} else if ( lastSlash === 0 && path.length > 1 ) {
+
+					// Direct child of root
+					const childName = path.slice( 1 );
+
+					if ( ! this.childrenByPath.has( '/' ) ) {
+
+						this.childrenByPath.set( '/', [] );
+
+					}
+
+					this.childrenByPath.get( '/' ).push( { name: childName, path: path } );
+
+				}
+
+				const typeName = spec.fields.typeName;
+
+				// Build material index
+				if ( typeName === 'Material' ) {
+
+					const parts = path.split( '/' );
+					const rootPath = parts.length > 1 ? '/' + parts[ 1 ] : '/';
+
+					if ( ! this.materialsByRoot.has( rootPath ) ) {
+
+						this.materialsByRoot.set( rootPath, [] );
+
+					}
+
+					this.materialsByRoot.get( rootPath ).push( path );
+
+				}
+
+				// Build shader index (shaders are children of materials)
+				if ( typeName === 'Shader' && lastSlash > 0 ) {
+
+					const materialPath = path.slice( 0, lastSlash );
+
+					if ( ! this.shadersByMaterialPath.has( materialPath ) ) {
+
+						this.shadersByMaterialPath.set( materialPath, [] );
+
+					}
+
+					this.shadersByMaterialPath.get( materialPath ).push( path );
+
+				}
+
+				// Build GeomSubset index (subsets are children of meshes)
+				if ( typeName === 'GeomSubset' && lastSlash > 0 ) {
+
+					const meshPath = path.slice( 0, lastSlash );
+
+					if ( ! this.geomSubsetsByMeshPath.has( meshPath ) ) {
+
+						this.geomSubsetsByMeshPath.set( meshPath, [] );
+
+					}
+
+					this.geomSubsetsByMeshPath.get( meshPath ).push( path );
+
+				}
+
+			} else if ( spec.specType === SpecType.Attribute || spec.specType === SpecType.Relationship ) {
+
+				// Build attribute index
+				const dotIndex = path.lastIndexOf( '.' );
+
+				if ( dotIndex > 0 ) {
+
+					const primPath = path.slice( 0, dotIndex );
+					const attrName = path.slice( dotIndex + 1 );
+
+					if ( ! this.attributesByPrimPath.has( primPath ) ) {
+
+						this.attributesByPrimPath.set( primPath, new Map() );
+
+					}
+
+					this.attributesByPrimPath.get( primPath ).set( attrName, spec );
+
+				}
+
+			}
+
+		}
+
+	}
+
 	/**
 	 * Check if a path is a direct child of parentPath.
 	 */
@@ -327,30 +466,47 @@ class USDComposer {
 
 	/**
 	 * Build the scene hierarchy recursively.
+	 * Uses childrenByPath index for O(1) child lookup instead of O(n) iteration.
 	 */
 	_buildHierarchy( parent, parentPath ) {
 
-		const prefix = parentPath === '/' ? '/' : parentPath + '/';
+		// Collect children from parentPath and any active variant paths
+		const childEntries = [];
+		const seenPaths = new Set();
 
-		// Get variant paths to search
-		const variantPaths = this._getVariantPaths( parentPath );
+		// Get direct children using the index
+		const directChildren = this.childrenByPath.get( parentPath );
 
-		for ( const path in this.specsByPath ) {
+		if ( directChildren ) {
 
-			const spec = this.specsByPath[ path ];
+			for ( const child of directChildren ) {
 
-			// Check if direct child of parent or variant paths
-			let isChild = this._isDirectChild( parentPath, path, prefix );
+				if ( ! seenPaths.has( child.path ) ) {
 
-			if ( ! isChild ) {
+					seenPaths.add( child.path );
+					childEntries.push( child );
 
-				for ( const vp of variantPaths ) {
+				}
 
-					const vpPrefix = vp + '/';
-					if ( this._isDirectChild( vp, path, vpPrefix ) ) {
+			}
 
-						isChild = true;
-						break;
+		}
+
+		// Also get children from active variant paths
+		const variantPaths = this._getVariantPaths( parentPath );
+
+		for ( const vp of variantPaths ) {
+
+			const variantChildren = this.childrenByPath.get( vp );
+
+			if ( variantChildren ) {
+
+				for ( const child of variantChildren ) {
+
+					if ( ! seenPaths.has( child.path ) ) {
+
+						seenPaths.add( child.path );
+						childEntries.push( child );
 
 					}
 
@@ -358,10 +514,14 @@ class USDComposer {
 
 			}
 
-			if ( ! isChild ) continue;
-			if ( spec.specType !== SpecType.Prim ) continue;
+		}
+
+		// Process each child
+		for ( const { name, path } of childEntries ) {
+
+			const spec = this.specsByPath[ path ];
+			if ( ! spec || spec.specType !== SpecType.Prim ) continue;
 
-			const name = path.split( '/' ).pop();
 			const typeName = spec.fields.typeName;
 
 			// Check for references/payloads
@@ -587,7 +747,7 @@ class USDComposer {
 		// If it's specsByPath data, compose it
 		if ( referencedData.specsByPath ) {
 
-			const composer = new USDComposer();
+			const composer = new USDComposer( this.manager );
 			const newBasePath = this._getBasePath( resolvedPath );
 			const composedGroup = composer.compose( referencedData, this.assets, mergedVariants, newBasePath );
 
@@ -768,7 +928,7 @@ class USDComposer {
 		this._collectAttributesFromPath( path, attrs );
 
 		// Collect overrides from sibling variants (when path is inside a variant)
-		const variantMatch = path.match( /^(.+?)\/\{(\w+)=(\w+)\}\/(.+)$/ );
+		const variantMatch = path.match( VARIANT_PATH_REGEX );
 		if ( variantMatch ) {
 
 			const basePath = variantMatch[ 1 ];
@@ -811,14 +971,12 @@ class USDComposer {
 
 	_collectAttributesFromPath( path, attrs ) {
 
-		const prefix = path + '.';
+		// Use the attribute index for O(1) lookup instead of O(n) iteration
+		const attrMap = this.attributesByPrimPath.get( path );
 
-		for ( const attrPath in this.specsByPath ) {
+		if ( ! attrMap ) return;
 
-			if ( ! attrPath.startsWith( prefix ) ) continue;
-
-			const attrSpec = this.specsByPath[ attrPath ];
-			const attrName = attrPath.slice( prefix.length );
+		for ( const [ attrName, attrSpec ] of attrMap ) {
 
 			if ( attrSpec.fields?.default !== undefined ) {
 
@@ -863,7 +1021,15 @@ class USDComposer {
 		if ( geomSubsets.length > 0 ) {
 
 			geometry = this._buildGeometryWithSubsets( attrs, geomSubsets, hasSkinning );
-			material = geomSubsets.map( subset => this._buildMaterialForPath( subset.materialPath ) );
+
+			const meshMaterialPath = this._getMaterialPath( path, spec.fields );
+
+			material = geomSubsets.map( subset => {
+
+				const matPath = subset.materialPath || meshMaterialPath;
+				return this._buildMaterialForPath( matPath );
+
+			} );
 
 		} else {
 
@@ -976,14 +1142,10 @@ class USDComposer {
 	_getGeomSubsets( meshPath ) {
 
 		const subsets = [];
-		const prefix = meshPath + '/';
+		const subsetPaths = this.geomSubsetsByMeshPath.get( meshPath );
+		if ( ! subsetPaths ) return subsets;
 
-		for ( const p in this.specsByPath ) {
-
-			if ( ! p.startsWith( prefix ) ) continue;
-
-			const spec = this.specsByPath[ p ];
-			if ( spec.fields.typeName !== 'GeomSubset' ) continue;
+		for ( const p of subsetPaths ) {
 
 			const attrs = this._getAttributes( p );
 			const indices = attrs[ 'indices' ];
@@ -1985,6 +2147,36 @@ class USDComposer {
 
 	}
 
+	/**
+	 * Get the material path for a mesh, checking various binding sources.
+	 */
+	_getMaterialPath( meshPath, fields ) {
+
+		let materialPath = null;
+		let materialBinding = fields[ 'material:binding' ];
+
+		if ( ! materialBinding ) {
+
+			const bindingPath = meshPath + '.material:binding';
+			const bindingSpec = this.specsByPath[ bindingPath ];
+			if ( bindingSpec && bindingSpec.specType === SpecType.Relationship ) {
+
+				materialBinding = bindingSpec.fields.targetPaths || bindingSpec.fields.default;
+
+			}
+
+		}
+
+		if ( materialBinding ) {
+
+			materialPath = Array.isArray( materialBinding ) ? materialBinding[ 0 ] : materialBinding;
+
+		}
+
+		return materialPath;
+
+	}
+
 	_buildMaterial( meshPath, fields ) {
 
 		const material = new MeshPhysicalMaterial();
@@ -2042,20 +2234,23 @@ class USDComposer {
 
 		if ( ! materialPath ) {
 
+			// Use material index for O(1) lookup instead of O(n) iteration
 			const meshParts = meshPath.split( '/' );
 			const rootPath = '/' + meshParts[ 1 ];
 
-			for ( const path in this.specsByPath ) {
+			const materialsInRoot = this.materialsByRoot.get( rootPath );
 
-				const spec = this.specsByPath[ path ];
-				if ( spec.specType !== SpecType.Prim ) continue;
-				if ( spec.fields.typeName !== 'Material' ) continue;
+			if ( materialsInRoot ) {
 
-				if ( path.startsWith( rootPath + '/Looks/' ) ||
-					path.startsWith( rootPath + '/Materials/' ) ) {
+				for ( const path of materialsInRoot ) {
 
-					materialPath = path;
-					break;
+					if ( path.startsWith( rootPath + '/Looks/' ) ||
+						path.startsWith( rootPath + '/Materials/' ) ) {
+
+						materialPath = path;
+						break;
+
+					}
 
 				}
 
@@ -2124,14 +2319,10 @@ class USDComposer {
 
 		for ( const materialPath of materialPaths ) {
 
-			const prefix = materialPath + '/';
-
-			for ( const path in this.specsByPath ) {
-
-				if ( ! path.startsWith( prefix ) ) continue;
+			const shaderPaths = this.shadersByMaterialPath.get( materialPath );
+			if ( ! shaderPaths ) continue;
 
-				const spec = this.specsByPath[ path ];
-				if ( spec.fields.typeName !== 'Shader' ) continue;
+			for ( const path of shaderPaths ) {
 
 				const attrs = this._getAttributes( path );
 				if ( attrs[ 'info:id' ] === 'UsdUVTexture' && attrs[ 'inputs:file' ] ) {
@@ -2153,16 +2344,13 @@ class USDComposer {
 		const materialSpec = this.specsByPath[ materialPath ];
 		if ( ! materialSpec ) return;
 
-		const prefix = materialPath + '/';
-
-		for ( const path in this.specsByPath ) {
+		const shaderPaths = this.shadersByMaterialPath.get( materialPath );
+		if ( ! shaderPaths ) return;
 
-			if ( ! path.startsWith( prefix ) ) continue;
+		for ( const path of shaderPaths ) {
 
 			const spec = this.specsByPath[ path ];
-			const typeName = spec.fields.typeName;
-
-			if ( typeName !== 'Shader' ) continue;
+			if ( ! spec ) continue;
 
 			const shaderAttrs = this._getAttributes( path );
 			const infoId = shaderAttrs[ 'info:id' ] || spec.fields[ 'info:id' ];
@@ -2181,25 +2369,25 @@ class USDComposer {
 
 	}
 
-	_applyPreviewSurface( material, shaderPath ) {
-
-		const fields = this._getAttributes( shaderPath );
-
-		const getAttrSpec = ( attrName ) => {
-
-			const attrPath = shaderPath + '.' + attrName;
-			return this.specsByPath[ attrPath ];
+	/**
+	 * Shared helper for applying texture or value from shader attribute.
+	 * Reduces duplication between _applyPreviewSurface and _applyOpenPBRSurface.
+	 */
+	_applyTextureOrValue( material, shaderPath, fields, attrName, textureProperty, colorSpace, valueCallback, textureGetter ) {
 
-		};
+		const attrPath = shaderPath + '.' + attrName;
+		const spec = this.specsByPath[ attrPath ];
 
-		const applyTextureFromConnection = ( attrName, textureProperty, colorSpace, valueCallback ) => {
+		if ( spec && spec.fields.connectionPaths && spec.fields.connectionPaths.length > 0 ) {
 
-			const spec = getAttrSpec( attrName );
+			// For OpenPBR, try all connection paths; for PreviewSurface, just the first
+			const paths = textureGetter === this._getTextureFromOpenPBRConnection
+				? spec.fields.connectionPaths
+				: [ spec.fields.connectionPaths[ 0 ] ];
 
-			if ( spec && spec.fields.connectionPaths && spec.fields.connectionPaths.length > 0 ) {
+			for ( const connPath of paths ) {
 
-				const connPath = spec.fields.connectionPaths[ 0 ];
-				const texture = this._getTextureFromConnection( connPath );
+				const texture = textureGetter.call( this, connPath );
 
 				if ( texture ) {
 
@@ -2211,18 +2399,40 @@ class USDComposer {
 
 			}
 
-			if ( fields[ attrName ] !== undefined && valueCallback ) {
+		}
+
+		if ( fields[ attrName ] !== undefined && valueCallback ) {
 
-				valueCallback( fields[ attrName ] );
+			valueCallback( fields[ attrName ] );
 
-			}
+		}
+
+		return false;
+
+	}
+
+	_applyPreviewSurface( material, shaderPath ) {
+
+		const fields = this._getAttributes( shaderPath );
+
+		const applyTexture = ( attrName, textureProperty, colorSpace, valueCallback ) => {
 
-			return false;
+			return this._applyTextureOrValue(
+				material, shaderPath, fields, attrName, textureProperty, colorSpace, valueCallback,
+				this._getTextureFromConnection
+			);
+
+		};
+
+		const getAttrSpec = ( attrName ) => {
+
+			const attrPath = shaderPath + '.' + attrName;
+			return this.specsByPath[ attrPath ];
 
 		};
 
 		// Diffuse color / base color map
-		applyTextureFromConnection(
+		applyTexture(
 			'inputs:diffuseColor',
 			'map',
 			SRGBColorSpace,
@@ -2238,7 +2448,7 @@ class USDComposer {
 		);
 
 		// Emissive
-		applyTextureFromConnection(
+		applyTexture(
 			'inputs:emissiveColor',
 			'emissiveMap',
 			SRGBColorSpace,
@@ -2260,7 +2470,7 @@ class USDComposer {
 		}
 
 		// Normal map
-		applyTextureFromConnection( 'inputs:normal', 'normalMap', NoColorSpace, null );
+		applyTexture( 'inputs:normal', 'normalMap', NoColorSpace, null );
 
 		// Apply normal map scale from UsdUVTexture scale input
 		if ( material.normalMap && material.normalMap.userData.scale ) {
@@ -2272,7 +2482,7 @@ class USDComposer {
 		}
 
 		// Roughness
-		const hasRoughnessMap = applyTextureFromConnection(
+		const hasRoughnessMap = applyTexture(
 			'inputs:roughness',
 			'roughnessMap',
 			NoColorSpace,
@@ -2290,7 +2500,7 @@ class USDComposer {
 		}
 
 		// Metallic
-		const hasMetalnessMap = applyTextureFromConnection(
+		const hasMetalnessMap = applyTexture(
 			'inputs:metallic',
 			'metalnessMap',
 			NoColorSpace,
@@ -2308,7 +2518,7 @@ class USDComposer {
 		}
 
 		// Occlusion
-		applyTextureFromConnection( 'inputs:occlusion', 'aoMap', NoColorSpace, null );
+		applyTexture( 'inputs:occlusion', 'aoMap', NoColorSpace, null );
 
 		// IOR
 		if ( fields[ 'inputs:ior' ] !== undefined ) {
@@ -2318,7 +2528,7 @@ class USDComposer {
 		}
 
 		// Specular color
-		applyTextureFromConnection(
+		applyTexture(
 			'inputs:specularColor',
 			'specularColorMap',
 			SRGBColorSpace,
@@ -2390,48 +2600,17 @@ class USDComposer {
 
 		const fields = this._getAttributes( shaderPath );
 
-		const getAttrSpec = ( attrName ) => {
-
-			const attrPath = shaderPath + '.' + attrName;
-			return this.specsByPath[ attrPath ];
-
-		};
-
-		const applyTextureFromConnection = ( attrName, textureProperty, colorSpace, valueCallback ) => {
-
-			const spec = getAttrSpec( attrName );
-
-			if ( spec && spec.fields.connectionPaths && spec.fields.connectionPaths.length > 0 ) {
-
-				// Try each connection path until one resolves to a texture
-				for ( const connPath of spec.fields.connectionPaths ) {
-
-					const texture = this._getTextureFromOpenPBRConnection( connPath );
-
-					if ( texture ) {
-
-						texture.colorSpace = colorSpace;
-						material[ textureProperty ] = texture;
-						return true;
-
-					}
-
-				}
-
-			}
-
-			if ( fields[ attrName ] !== undefined && valueCallback ) {
+		const applyTexture = ( attrName, textureProperty, colorSpace, valueCallback ) => {
 
-				valueCallback( fields[ attrName ] );
-
-			}
-
-			return false;
+			return this._applyTextureOrValue(
+				material, shaderPath, fields, attrName, textureProperty, colorSpace, valueCallback,
+				this._getTextureFromOpenPBRConnection
+			);
 
 		};
 
 		// Base color (diffuse)
-		applyTextureFromConnection(
+		applyTexture(
 			'inputs:base_color',
 			'map',
 			SRGBColorSpace,
@@ -2447,7 +2626,7 @@ class USDComposer {
 		);
 
 		// Base metalness
-		applyTextureFromConnection(
+		applyTexture(
 			'inputs:base_metalness',
 			'metalnessMap',
 			NoColorSpace,
@@ -2463,7 +2642,7 @@ class USDComposer {
 		);
 
 		// Specular roughness
-		applyTextureFromConnection(
+		applyTexture(
 			'inputs:specular_roughness',
 			'roughnessMap',
 			NoColorSpace,
@@ -2479,7 +2658,7 @@ class USDComposer {
 		);
 
 		// Emission color
-		const hasEmissionMap = applyTextureFromConnection(
+		const hasEmissionMap = applyTexture(
 			'inputs:emission_color',
 			'emissiveMap',
 			SRGBColorSpace,
@@ -2628,7 +2807,7 @@ class USDComposer {
 		}
 
 		// Geometry normal (normal map)
-		applyTextureFromConnection(
+		applyTexture(
 			'inputs:geometry_normal',
 			'normalMap',
 			NoColorSpace,
@@ -2962,6 +3141,19 @@ class USDComposer {
 
 			}
 
+			// Try loading via LoadingManager if available
+			if ( this.manager ) {
+
+				const url = this.manager.resolveURL( baseName );
+				if ( url !== baseName ) {
+
+					// URL modifier found a match - load it
+					return this._createTextureFromData( url, textureAttrs, transformAttrs );
+
+				}
+
+			}
+
 			console.warn( 'USDLoader: Texture not found:', cleanPath );
 			return null;
 

粤ICP备19079148号