diff --git a/editor/js/GLTFImportDialog.js b/editor/js/GLTFImportDialog.js new file mode 100644 index 00000000000000..3f9a39fc35b1ec --- /dev/null +++ b/editor/js/GLTFImportDialog.js @@ -0,0 +1,112 @@ +import { UIRow, UIText, UICheckbox, UIButton } from './libs/ui.js'; + +class GLTFImportDialog { + + constructor( strings ) { + + this.strings = strings; + + const dom = document.createElement( 'div' ); + dom.className = 'Dialog'; + this.dom = dom; + + const background = document.createElement( 'div' ); + background.className = 'Dialog-background'; + background.addEventListener( 'click', () => this.cancel() ); + dom.appendChild( background ); + + const content = document.createElement( 'div' ); + content.className = 'Dialog-content'; + dom.appendChild( content ); + + // Title + + const titleBar = document.createElement( 'div' ); + titleBar.className = 'Dialog-title'; + titleBar.textContent = strings.getKey( 'dialog/gltf/title' ); + content.appendChild( titleBar ); + + // Body + + const body = document.createElement( 'div' ); + body.className = 'Dialog-body'; + content.appendChild( body ); + + // As Scene Checkbox + + const asSceneRow = new UIRow(); + body.appendChild( asSceneRow.dom ); + + this.asSceneCheckbox = new UICheckbox( false ); + asSceneRow.add( this.asSceneCheckbox ); + + asSceneRow.add( new UIText( strings.getKey( 'dialog/gltf/asScene' ) ).setMarginLeft( '6px' ) ); + + // Buttons + + const buttonsRow = document.createElement( 'div' ); + buttonsRow.className = 'Dialog-buttons'; + body.appendChild( buttonsRow ); + + const okButton = new UIButton( strings.getKey( 'dialog/ok' ) ); + okButton.setWidth( '80px' ); + okButton.onClick( () => this.confirm() ); + buttonsRow.appendChild( okButton.dom ); + + const cancelButton = new UIButton( strings.getKey( 'dialog/cancel' ) ); + cancelButton.setWidth( '80px' ); + cancelButton.setMarginLeft( '8px' ); + cancelButton.onClick( () => this.cancel() ); + buttonsRow.appendChild( cancelButton.dom ); + + // Promise handlers + + this.resolve = null; + this.reject = null; + + } + + show() { + + document.body.appendChild( this.dom ); + + return new Promise( ( resolve, reject ) => { + + this.resolve = resolve; + this.reject = reject; + + } ); + + } + + confirm() { + + const result = { + asScene: this.asSceneCheckbox.getValue() + }; + + this.dom.remove(); + + if ( this.resolve ) { + + this.resolve( result ); + + } + + } + + cancel() { + + this.dom.remove(); + + if ( this.reject ) { + + this.reject( new Error( 'Import cancelled' ) ); + + } + + } + +} + +export { GLTFImportDialog }; diff --git a/editor/js/Loader.js b/editor/js/Loader.js index fb1997557f64cf..5a5515388d02f5 100644 --- a/editor/js/Loader.js +++ b/editor/js/Loader.js @@ -3,9 +3,12 @@ import * as THREE from 'three'; import { TGALoader } from 'three/addons/loaders/TGALoader.js'; import { AddObjectCommand } from './commands/AddObjectCommand.js'; +import { SetSceneCommand } from './commands/SetSceneCommand.js'; import { LoaderUtils } from './LoaderUtils.js'; +import { GLTFImportDialog } from './GLTFImportDialog.js'; + import { unzipSync, strFromU8 } from 'three/addons/libs/fflate.module.js'; function Loader( editor ) { @@ -268,20 +271,40 @@ function Loader( editor ) { const contents = event.target.result; - const loader = await createGLTFLoader(); + try { + + const dialog = new GLTFImportDialog( editor.strings ); + const options = await dialog.show(); - loader.parse( contents, '', function ( result ) { + const loader = await createGLTFLoader(); - const scene = result.scene; - scene.name = filename; + loader.parse( contents, '', function ( result ) { - scene.animations.push( ...result.animations ); - editor.execute( new AddObjectCommand( editor, scene ) ); + const scene = result.scene; + scene.name = filename; - loader.dracoLoader.dispose(); - loader.ktx2Loader.dispose(); + scene.animations.push( ...result.animations ); - } ); + if ( options.asScene ) { + + editor.execute( new SetSceneCommand( editor, scene ) ); + + } else { + + editor.execute( new AddObjectCommand( editor, scene ) ); + + } + + loader.dracoLoader.dispose(); + loader.ktx2Loader.dispose(); + + } ); + + } catch ( e ) { + + // Import cancelled + + } }, false ); reader.readAsArrayBuffer( file ); @@ -298,20 +321,40 @@ function Loader( editor ) { const contents = event.target.result; - const loader = await createGLTFLoader( manager ); + try { - loader.parse( contents, '', function ( result ) { + const dialog = new GLTFImportDialog( editor.strings ); + const options = await dialog.show(); - const scene = result.scene; - scene.name = filename; + const loader = await createGLTFLoader( manager ); - scene.animations.push( ...result.animations ); - editor.execute( new AddObjectCommand( editor, scene ) ); + loader.parse( contents, '', function ( result ) { - loader.dracoLoader.dispose(); - loader.ktx2Loader.dispose(); + const scene = result.scene; + scene.name = filename; - } ); + scene.animations.push( ...result.animations ); + + if ( options.asScene ) { + + editor.execute( new SetSceneCommand( editor, scene ) ); + + } else { + + editor.execute( new AddObjectCommand( editor, scene ) ); + + } + + loader.dracoLoader.dispose(); + loader.ktx2Loader.dispose(); + + } ); + + } catch ( e ) { + + // Import cancelled + + } }, false ); reader.readAsArrayBuffer( file ); @@ -635,7 +678,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 +803,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 + ').' ); @@ -905,19 +958,39 @@ function Loader( editor ) { { - const loader = await createGLTFLoader(); + try { + + const dialog = new GLTFImportDialog( editor.strings ); + const options = await dialog.show(); - loader.parse( file.buffer, '', function ( result ) { + const loader = await createGLTFLoader(); - const scene = result.scene; + loader.parse( file.buffer, '', function ( result ) { - scene.animations.push( ...result.animations ); - editor.execute( new AddObjectCommand( editor, scene ) ); + const scene = result.scene; - loader.dracoLoader.dispose(); - loader.ktx2Loader.dispose(); + scene.animations.push( ...result.animations ); - } ); + if ( options.asScene ) { + + editor.execute( new SetSceneCommand( editor, scene ) ); + + } else { + + editor.execute( new AddObjectCommand( editor, scene ) ); + + } + + loader.dracoLoader.dispose(); + loader.ktx2Loader.dispose(); + + } ); + + } catch ( e ) { + + // Import cancelled + + } break; @@ -927,19 +1000,39 @@ function Loader( editor ) { { - const loader = await createGLTFLoader( manager ); + try { - loader.parse( strFromU8( file ), '', function ( result ) { + const dialog = new GLTFImportDialog( editor.strings ); + const options = await dialog.show(); - const scene = result.scene; + const loader = await createGLTFLoader( manager ); - scene.animations.push( ...result.animations ); - editor.execute( new AddObjectCommand( editor, scene ) ); + loader.parse( strFromU8( file ), '', function ( result ) { - loader.dracoLoader.dispose(); - loader.ktx2Loader.dispose(); + const scene = result.scene; - } ); + scene.animations.push( ...result.animations ); + + if ( options.asScene ) { + + editor.execute( new SetSceneCommand( editor, scene ) ); + + } else { + + editor.execute( new AddObjectCommand( editor, scene ) ); + + } + + loader.dracoLoader.dispose(); + loader.ktx2Loader.dispose(); + + } ); + + } catch ( e ) { + + // Import cancelled + + } break; diff --git a/editor/js/Strings.js b/editor/js/Strings.js index acb9ce95285b8f..a6150471418936 100644 --- a/editor/js/Strings.js +++ b/editor/js/Strings.js @@ -409,7 +409,12 @@ function Strings( config ) { 'script/title/vertexShader': 'شیدر راس', 'script/title/fragmentShader': 'شیدر فرگمنت', - 'script/title/programInfo': 'خواص برنامه' + 'script/title/programInfo': 'خواص برنامه', + + 'dialog/gltf/title': 'Import glTF', + 'dialog/gltf/asScene': 'Import glTF as root scene', + 'dialog/ok': 'OK', + 'dialog/cancel': 'Cancel' }, en: { @@ -819,7 +824,12 @@ function Strings( config ) { 'script/title/vertexShader': 'Vertex Shader', 'script/title/fragmentShader': 'Fragment Shader', - 'script/title/programInfo': 'Program Properties' + 'script/title/programInfo': 'Program Properties', + + 'dialog/gltf/title': 'Import glTF', + 'dialog/gltf/asScene': 'Import glTF as root scene', + 'dialog/ok': 'OK', + 'dialog/cancel': 'Cancel' }, @@ -1230,7 +1240,12 @@ function Strings( config ) { 'script/title/vertexShader': 'Vertex Shader', 'script/title/fragmentShader': 'Fragment Shader', - 'script/title/programInfo': 'Propriétés du programme' + 'script/title/programInfo': 'Propriétés du programme', + + 'dialog/gltf/title': 'Importer glTF', + 'dialog/gltf/asScene': 'Importer glTF comme scène racine', + 'dialog/ok': 'OK', + 'dialog/cancel': 'Annuler' }, @@ -1641,7 +1656,12 @@ function Strings( config ) { 'script/title/vertexShader': '顶点着色器', 'script/title/fragmentShader': '片段着色器', - 'script/title/programInfo': '程序属性' + 'script/title/programInfo': '程序属性', + + 'dialog/gltf/title': '导入 glTF', + 'dialog/gltf/asScene': '将 glTF 导入为根场景', + 'dialog/ok': '确定', + 'dialog/cancel': '取消' }, @@ -2052,7 +2072,12 @@ function Strings( config ) { 'script/title/vertexShader': '頂点シェーダー', 'script/title/fragmentShader': 'フラグメントシェーダ', - 'script/title/programInfo': 'プログラムのプロパティ' + 'script/title/programInfo': 'プログラムのプロパティ', + + 'dialog/gltf/title': 'glTFをインポート', + 'dialog/gltf/asScene': 'glTFをルートシーンとしてインポート', + 'dialog/ok': 'OK', + 'dialog/cancel': 'キャンセル' }, @@ -2462,7 +2487,12 @@ function Strings( config ) { 'script/title/vertexShader': '버텍스 셰이더', 'script/title/fragmentShader': '프래그먼트 셰이더', - 'script/title/programInfo': '프로그램 속성' + 'script/title/programInfo': '프로그램 속성', + + 'dialog/gltf/title': 'glTF 가져오기', + 'dialog/gltf/asScene': 'glTF를 루트 씬으로 가져오기', + 'dialog/ok': '확인', + 'dialog/cancel': '취소' } }; diff --git a/editor/sw.js b/editor/sw.js index ae39be71d4c93c..f504482c416b8d 100644 --- a/editor/sw.js +++ b/editor/sw.js @@ -138,6 +138,7 @@ const assets = [ './js/History.js', './js/Loader.js', './js/LoaderUtils.js', + './js/GLTFImportDialog.js', './js/Menubar.js', './js/Menubar.File.js', './js/Menubar.Edit.js', diff --git a/examples/jsm/loaders/USDLoader.js b/examples/jsm/loaders/USDLoader.js index dcc4a7746d1db5..4a8db4481f6c48 100644 --- a/examples/jsm/loaders/USDLoader.js +++ b/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, {} ); diff --git a/examples/jsm/loaders/usd/USDAParser.js b/examples/jsm/loaders/usd/USDAParser.js index ebb0e9a08eac88..a95037f1fbca24 100644 --- a/examples/jsm/loaders/usd/USDAParser.js +++ b/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 ]; diff --git a/examples/jsm/loaders/usd/USDCParser.js b/examples/jsm/loaders/usd/USDCParser.js index d4965ba210f187..98425b668f7cc7 100644 --- a/examples/jsm/loaders/usd/USDCParser.js +++ b/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 ); } diff --git a/examples/jsm/loaders/usd/USDComposer.js b/examples/jsm/loaders/usd/USDComposer.js index 154e4e0d96c7b7..3b415c3b56277a 100644 --- a/examples/jsm/loaders/usd/USDComposer.js +++ b/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; diff --git a/package-lock.json b/package-lock.json index 5c22509a4ef40e..08197fa153f329 100644 --- a/package-lock.json +++ b/package-lock.json @@ -888,7 +888,6 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -950,7 +949,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1238,7 +1236,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1514,8 +1511,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -1697,7 +1693,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3211,7 +3206,6 @@ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" },