From c4965f01d6744167bdeff5510982bacb12f6674c Mon Sep 17 00:00:00 2001 From: Renaud Rohlinger Date: Sat, 21 Feb 2026 10:39:06 +0900 Subject: [PATCH 1/5] WebGPURenderer: Make `compileAsync()` truly non-blocking (#32984) --- examples/files.json | 1 + examples/screenshots/webgpu_compile_async.jpg | Bin 0 -> 7551 bytes examples/webgpu_compile_async.html | 355 ++++++++++++++++++ src/nodes/core/NodeBuilder.js | 85 ++++- src/renderers/common/Pipelines.js | 20 + src/renderers/common/Renderer.js | 80 +++- src/renderers/common/nodes/NodeManager.js | 248 ++++++++++-- src/renderers/webgl-fallback/WebGLBackend.js | 3 +- src/renderers/webgpu/WebGPUBackend.js | 1 + src/utils.js | 24 +- test/e2e/puppeteer.js | 1 + 11 files changed, 776 insertions(+), 42 deletions(-) create mode 100644 examples/screenshots/webgpu_compile_async.jpg create mode 100644 examples/webgpu_compile_async.html diff --git a/examples/files.json b/examples/files.json index b7b5f00e34375a..023ca2c07e02e8 100644 --- a/examples/files.json +++ b/examples/files.json @@ -308,6 +308,7 @@ "webgpu_centroid_sampling", "webgpu_clearcoat", "webgpu_clipping", + "webgpu_compile_async", "webgpu_compute_audio", "webgpu_compute_birds", "webgpu_compute_cloth", diff --git a/examples/screenshots/webgpu_compile_async.jpg b/examples/screenshots/webgpu_compile_async.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ff3afe1088211efb974a4d9fd2621b7dd5969d4d GIT binary patch literal 7551 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 im8fO#tD5_3;r{1WgkS#&{`nQ + + + three.js webgpu - async node compilation + + + + + + + +
+ + +
+ three.jsAsync Node Compilation +
+ + NodeMaterial shaders compile asynchronously without blocking the render loop. Animation stays smooth while 256 unique TSL materials build in the background. +
+ +
+
Longest frame: -
+
Meshes added: 0 / 256
+
Mode: -
+
+ + + + + + + diff --git a/src/nodes/core/NodeBuilder.js b/src/nodes/core/NodeBuilder.js index 237096e9f89b7e..360c4f5a58a01e 100644 --- a/src/nodes/core/NodeBuilder.js +++ b/src/nodes/core/NodeBuilder.js @@ -30,7 +30,7 @@ import { Vector2 } from '../../math/Vector2.js'; import { Vector3 } from '../../math/Vector3.js'; import { Vector4 } from '../../math/Vector4.js'; import { Float16BufferAttribute } from '../../core/BufferAttribute.js'; -import { warn, error } from '../../utils.js'; +import { warn, error, yieldToMain } from '../../utils.js'; let _id = 0; @@ -3019,6 +3019,89 @@ class NodeBuilder { } + /** + * Async version of build() that yields to main thread between shader stages. + * Use this in compileAsync() to prevent blocking the main thread. + * + * @return {Promise} A promise that resolves to this node builder. + */ + async buildAsync() { + + const { object, material, renderer } = this; + + if ( material !== null ) { + + let nodeMaterial = renderer.library.fromMaterial( material ); + + if ( nodeMaterial === null ) { + + error( `NodeMaterial: Material "${ material.type }" is not compatible.` ); + + nodeMaterial = new NodeMaterial(); + + } + + nodeMaterial.build( this ); + + } else { + + this.addFlow( 'compute', object ); + + } + + // setup() -> stage 1: create possible new nodes and/or return an output reference node + // analyze() -> stage 2: analyze nodes to possible optimization and validation + // generate() -> stage 3: generate shader + + for ( const buildStage of defaultBuildStages ) { + + this.setBuildStage( buildStage ); + + if ( this.context.position && this.context.position.isNode ) { + + this.flowNodeFromShaderStage( 'vertex', this.context.position ); + + } + + for ( const shaderStage of shaderStages ) { + + this.setShaderStage( shaderStage ); + + const flowNodes = this.flowNodes[ shaderStage ]; + + for ( const node of flowNodes ) { + + if ( buildStage === 'generate' ) { + + this.flowNode( node ); + + } else { + + node.build( this ); + + } + + } + + // Yield to main thread after each shader stage to prevent blocking + await yieldToMain(); + + } + + } + + this.setBuildStage( null ); + this.setShaderStage( null ); + + // stage 4: build code for a specific output + + this.buildCode(); + this.buildUpdateNodes(); + + return this; + + } + /** * Returns shared data object for the given node. * diff --git a/src/renderers/common/Pipelines.js b/src/renderers/common/Pipelines.js index d0adbea0795dfa..a965abe9a11219 100644 --- a/src/renderers/common/Pipelines.js +++ b/src/renderers/common/Pipelines.js @@ -234,6 +234,26 @@ class Pipelines extends DataMap { } + /** + * Checks if the render pipeline for the given render object is ready for drawing. + * Returns false if the GPU pipeline is still being compiled asynchronously. + * + * @param {RenderObject} renderObject - The render object. + * @return {boolean} True if the pipeline is ready for drawing. + */ + isReady( renderObject ) { + + const data = this.get( renderObject ); + const pipeline = data.pipeline; + + if ( pipeline === undefined ) return false; + + const pipelineData = this.backend.get( pipeline ); + + return pipelineData.pipeline !== undefined && pipelineData.pipeline !== null; + + } + /** * Deletes the pipeline for the given render object. * diff --git a/src/renderers/common/Renderer.js b/src/renderers/common/Renderer.js index 5b6175c8ecf486..756a2b4ed5f905 100644 --- a/src/renderers/common/Renderer.js +++ b/src/renderers/common/Renderer.js @@ -36,7 +36,7 @@ import { float, vec3, vec4, Fn } from '../../nodes/tsl/TSLCore.js'; import { reference } from '../../nodes/accessors/ReferenceNode.js'; import { highpModelNormalViewMatrix, highpModelViewMatrix } from '../../nodes/accessors/ModelNode.js'; import { context } from '../../nodes/core/ContextNode.js'; -import { error, warn, warnOnce } from '../../utils.js'; +import { error, warn, warnOnce, yieldToMain } from '../../utils.js'; const _scene = /*@__PURE__*/ new Scene(); const _drawingBufferSize = /*@__PURE__*/ new Vector2(); @@ -877,11 +877,15 @@ class Renderer { // - const sceneRef = ( scene.isScene === true ) ? scene : _scene; - if ( targetScene === null ) targetScene = scene; - const renderTarget = this._renderTarget; + // Use the actual scene for caching when compiling individual objects + // This ensures cache keys match between compileAsync and render + const sceneRef = ( scene.isScene === true ) ? scene : ( targetScene.isScene === true ) ? targetScene : _scene; + + // Match render()'s logic: use frameBufferTarget when needsFrameBufferTarget is true + const useFrameBufferTarget = this.needsFrameBufferTarget && this._renderTarget === null; + const renderTarget = useFrameBufferTarget ? this._getFrameBufferTarget() : ( this._renderTarget || this._outputRenderTarget ); const renderContext = this._renderContexts.get( renderTarget, this._mrt ); const activeMipmapLevel = this._activeMipmapLevel; @@ -914,7 +918,8 @@ class Renderer { // - const renderList = this._renderLists.get( scene, camera ); + // Use sceneRef for render list to ensure lightsNode matches between compileAsync and render + const renderList = this._renderLists.get( sceneRef, camera ); renderList.begin(); this._projectObject( scene, camera, 0, renderList, renderContext.clippingContext ); @@ -966,7 +971,7 @@ class Renderer { } - // process render lists + // process render lists - _createObjectPipeline will push async promises to _compilationPromises const opaqueObjects = renderList.opaque; const transparentObjects = renderList.transparent; @@ -985,9 +990,39 @@ class Renderer { this._handleObjectFunction = previousHandleObjectFunction; this._compilationPromises = previousCompilationPromises; - // wait for all promises setup by backends awaiting compilation/linking/pipeline creation to complete + // Process compilation work items sequentially to avoid freezing + // Yields between objects to keep animation smooth + + for ( const item of compilationPromises ) { + + const renderObject = this._objects.get( item.object, item.material, item.scene, item.camera, item.lightsNode, item.renderContext, item.clippingContext, item.passId ); + renderObject.drawRange = item.object.geometry.drawRange; + renderObject.group = item.group; + + this._geometries.updateForRender( renderObject ); + + // Use async node building to yield to main thread + await this._nodes.getForRenderAsync( renderObject ); + + this._nodes.updateBefore( renderObject ); + this._nodes.updateForRender( renderObject ); + this._bindings.updateForRender( renderObject ); - await Promise.all( compilationPromises ); + // Wait for pipeline creation + const pipelinePromises = []; + this._pipelines.getForRender( renderObject, pipelinePromises ); + if ( pipelinePromises.length > 0 ) { + + await Promise.all( pipelinePromises ); + + } + + this._nodes.updateAfter( renderObject ); + + // Yield between objects to allow animation frames + await yieldToMain(); + + } } @@ -3395,9 +3430,13 @@ class Renderer { // - this.backend.draw( renderObject, this.info ); + if ( this._pipelines.isReady( renderObject ) ) { + + this.backend.draw( renderObject, this.info ); - if ( needsRefresh ) this._nodes.updateAfter( renderObject ); + if ( needsRefresh ) this._nodes.updateAfter( renderObject ); + + } } @@ -3417,6 +3456,27 @@ class Renderer { */ _createObjectPipeline( object, material, scene, camera, lightsNode, group, clippingContext, passId ) { + // If in async compilation mode, queue the work for sequential execution + if ( this._compilationPromises !== null ) { + + // Store work items instead of promises - will be processed sequentially + this._compilationPromises.push( { + object, + material, + scene, + camera, + lightsNode, + group, + clippingContext, + passId, + renderContext: this._currentRenderContext + } ); + + return; + + } + + // Sync path const renderObject = this._objects.get( object, material, scene, camera, lightsNode, this._currentRenderContext, clippingContext, passId ); renderObject.drawRange = object.geometry.drawRange; renderObject.group = group; diff --git a/src/renderers/common/nodes/NodeManager.js b/src/renderers/common/nodes/NodeManager.js index 8d9eadd158d728..82eef1c9e3673f 100644 --- a/src/renderers/common/nodes/NodeManager.js +++ b/src/renderers/common/nodes/NodeManager.js @@ -76,6 +76,22 @@ class NodeManager extends DataMap { */ this.groupsData = new ChainMap(); + /** + * Queue for pending async builds to limit concurrent compilation. + * + * @private + * @type {Array} + */ + this._buildQueue = []; + + /** + * Whether an async build is currently in progress. + * + * @private + * @type {boolean} + */ + this._buildInProgress = false; + /** * A cache for managing node objects of * scene properties like fog or environments. @@ -174,13 +190,44 @@ class NodeManager extends DataMap { } + /** + * Creates a node builder configured for the given render object and material. + * + * @private + * @param {RenderObject} renderObject - The render object. + * @param {Material} material - The material to use. + * @return {NodeBuilder} The configured node builder. + */ + _createNodeBuilder( renderObject, material ) { + + const nodeBuilder = this.backend.createNodeBuilder( renderObject.object, this.renderer ); + nodeBuilder.scene = renderObject.scene; + nodeBuilder.material = material; + nodeBuilder.camera = renderObject.camera; + nodeBuilder.context.material = material; + nodeBuilder.lightsNode = renderObject.lightsNode; + nodeBuilder.environmentNode = this.getEnvironmentNode( renderObject.scene ); + nodeBuilder.fogNode = this.getFogNode( renderObject.scene ); + nodeBuilder.clippingContext = renderObject.clippingContext; + + if ( this.renderer.getOutputRenderTarget() ? this.renderer.getOutputRenderTarget().multiview : false ) { + + nodeBuilder.enableMultiview(); + + } + + return nodeBuilder; + + } + /** * Returns a node builder state for the given render object. * * @param {RenderObject} renderObject - The render object. - * @return {NodeBuilderState} The node builder state. + * @param {boolean} [useAsync=false] - Whether to use async build with yielding. + * @return {NodeBuilderState|Promise} The node builder state (or Promise if async). */ - getForRender( renderObject ) { + getForRender( renderObject, useAsync = false ) { const renderObjectData = this.get( renderObject ); @@ -196,20 +243,37 @@ class NodeManager extends DataMap { if ( nodeBuilderState === undefined ) { - const createNodeBuilder = ( material ) => { + const buildNodeBuilder = async () => { + + let nodeBuilder = this._createNodeBuilder( renderObject, renderObject.material ); - const nodeBuilder = this.backend.createNodeBuilder( renderObject.object, this.renderer ); - nodeBuilder.scene = renderObject.scene; - nodeBuilder.material = material; - nodeBuilder.camera = renderObject.camera; - nodeBuilder.context.material = material; - nodeBuilder.lightsNode = renderObject.lightsNode; - nodeBuilder.environmentNode = this.getEnvironmentNode( renderObject.scene ); - nodeBuilder.fogNode = this.getFogNode( renderObject.scene ); - nodeBuilder.clippingContext = renderObject.clippingContext; - if ( this.renderer.getOutputRenderTarget() ? this.renderer.getOutputRenderTarget().multiview : false ) { + try { - nodeBuilder.enableMultiview(); + if ( useAsync ) { + + await nodeBuilder.buildAsync(); + + } else { + + nodeBuilder.build(); + + } + + } catch ( e ) { + + nodeBuilder = this._createNodeBuilder( renderObject, new NodeMaterial() ); + + if ( useAsync ) { + + await nodeBuilder.buildAsync(); + + } else { + + nodeBuilder.build(); + + } + + error( 'TSL: ' + e ); } @@ -217,34 +281,52 @@ class NodeManager extends DataMap { }; - let nodeBuilder = createNodeBuilder( renderObject.material ); + if ( useAsync ) { - try { + return buildNodeBuilder().then( ( nodeBuilder ) => { - nodeBuilder.build(); + nodeBuilderState = this._createNodeBuilderState( nodeBuilder ); + nodeBuilderCache.set( cacheKey, nodeBuilderState ); + nodeBuilderState.usedTimes ++; + renderObjectData.nodeBuilderState = nodeBuilderState; - } catch ( e ) { + return nodeBuilderState; - nodeBuilder = createNodeBuilder( new NodeMaterial() ); - nodeBuilder.build(); + } ); - let stackTrace = e.stackTrace; + } else { - if ( ! stackTrace && e.stack ) { + // Synchronous path - call buildNodeBuilder but don't await + let nodeBuilder = this._createNodeBuilder( renderObject, renderObject.material ); - // Capture stack trace for JavaScript errors + try { - stackTrace = new StackTrace( e.stack ); + nodeBuilder.build(); - } + } catch ( e ) { - error( 'TSL: ' + e, stackTrace ); + nodeBuilder = this._createNodeBuilder( renderObject, new NodeMaterial() ); + nodeBuilder.build(); - } + let stackTrace = e.stackTrace; + + if ( ! stackTrace && e.stack ) { - nodeBuilderState = this._createNodeBuilderState( nodeBuilder ); + // Capture stack trace for JavaScript errors - nodeBuilderCache.set( cacheKey, nodeBuilderState ); + stackTrace = new StackTrace( e.stack ); + + } + + error( 'TSL: ' + e, stackTrace ); + + } + + nodeBuilderState = this._createNodeBuilderState( nodeBuilder ); + + nodeBuilderCache.set( cacheKey, nodeBuilderState ); + + } } @@ -258,6 +340,114 @@ class NodeManager extends DataMap { } + /** + * Async version of getForRender() that yields to main thread during build. + * Use this in compileAsync() to prevent blocking the main thread. + * + * @param {RenderObject} renderObject - The render object. + * @return {Promise} A promise that resolves to the node builder state. + */ + getForRenderAsync( renderObject ) { + + const result = this.getForRender( renderObject, true ); + + // Ensure we always return a Promise (cache hit returns nodeBuilderState directly) + if ( result.then ) { + + return result; + + } + + return Promise.resolve( result ); + + } + + /** + * Returns nodeBuilderState if ready, null if pending async build. + * Queues async build on first call for cache miss. + * Use this in render() path to enable non-blocking compilation. + * + * @param {RenderObject} renderObject - The render object. + * @return {?NodeBuilderState} The node builder state, or null if still building. + */ + getForRenderDeferred( renderObject ) { + + const renderObjectData = this.get( renderObject ); + + // Already built for this renderObject + if ( renderObjectData.nodeBuilderState !== undefined ) { + + return renderObjectData.nodeBuilderState; + + } + + // Check cache with stable key + const cacheKey = this.getForRenderCacheKey( renderObject ); + const nodeBuilderState = this.nodeBuilderCache.get( cacheKey ); + + if ( nodeBuilderState !== undefined ) { + + // Cache hit - use it + nodeBuilderState.usedTimes ++; + renderObjectData.nodeBuilderState = nodeBuilderState; + return nodeBuilderState; + + } + + // Cache miss - check if async build already queued + if ( renderObjectData.pendingBuild !== true ) { + + // Mark as pending and add to build queue + renderObjectData.pendingBuild = true; + + this._buildQueue.push( () => { + + return this.getForRenderAsync( renderObject ).then( () => { + + renderObjectData.pendingBuild = false; + + } ); + + } ); + + // Start processing queue if not already running + this._processBuildQueue(); + + } + + return null; // Not ready + + } + + /** + * Processes the build queue one item at a time. + * This ensures builds don't all run simultaneously and freeze the main thread. + * + * @private + */ + _processBuildQueue() { + + if ( this._buildInProgress || this._buildQueue.length === 0 ) { + + return; + + } + + this._buildInProgress = true; + + const buildFn = this._buildQueue.shift(); + + buildFn().then( () => { + + this._buildInProgress = false; + + // Process next item in queue + this._processBuildQueue(); + + } ); + + } + /** * Deletes the given object from the internal data map * diff --git a/src/renderers/webgl-fallback/WebGLBackend.js b/src/renderers/webgl-fallback/WebGLBackend.js index 0c13695e476339..b00231b5ea2d13 100644 --- a/src/renderers/webgl-fallback/WebGLBackend.js +++ b/src/renderers/webgl-fallback/WebGLBackend.js @@ -1618,7 +1618,8 @@ class WebGLBackend extends Backend { // this.set( pipeline, { - programGPU + programGPU, + pipeline: programGPU } ); } diff --git a/src/renderers/webgpu/WebGPUBackend.js b/src/renderers/webgpu/WebGPUBackend.js index 3d6f8042127dd9..b23217b0d0a412 100644 --- a/src/renderers/webgpu/WebGPUBackend.js +++ b/src/renderers/webgpu/WebGPUBackend.js @@ -1498,6 +1498,7 @@ class WebGPUBackend extends Backend { const pipelineData = this.get( pipeline ); const pipelineGPU = pipelineData.pipeline; + // Skip if pipeline has error if ( pipelineData.error === true ) return; const index = renderObject.getIndex(); diff --git a/src/utils.js b/src/utils.js index 40ed0bb310a346..829e88ec623cea 100644 --- a/src/utils.js +++ b/src/utils.js @@ -347,6 +347,28 @@ function warnOnce( ...params ) { } +/** + * Yields execution to the main thread to allow rendering and other tasks. + * Uses scheduler.yield() when available (Chrome 115+), falls back to requestAnimationFrame. + * + * @return {Promise} + */ +function yieldToMain() { + + if ( typeof self !== 'undefined' && typeof self.scheduler !== 'undefined' && typeof self.scheduler.yield !== 'undefined' ) { + + return self.scheduler.yield(); + + } + + return new Promise( resolve => { + + requestAnimationFrame( resolve ); + + } ); + +} + /** * Asynchronously probes for WebGL sync object completion. * @@ -468,4 +490,4 @@ const ReversedDepthFuncs = { [ GreaterEqualDepth ]: LessEqualDepth, }; -export { arrayMin, arrayMax, arrayNeedsUint32, getTypedArray, createElementNS, createCanvasElement, setConsoleFunction, getConsoleFunction, log, warn, error, warnOnce, probeAsync, toNormalizedProjectionMatrix, toReversedProjectionMatrix, isTypedArray, ReversedDepthFuncs }; +export { arrayMin, arrayMax, arrayNeedsUint32, getTypedArray, createElementNS, createCanvasElement, setConsoleFunction, getConsoleFunction, log, warn, error, warnOnce, probeAsync, yieldToMain, toNormalizedProjectionMatrix, toReversedProjectionMatrix, isTypedArray, ReversedDepthFuncs }; diff --git a/test/e2e/puppeteer.js b/test/e2e/puppeteer.js index b2aff0101c0114..49ab0bf6a2dec1 100644 --- a/test/e2e/puppeteer.js +++ b/test/e2e/puppeteer.js @@ -63,6 +63,7 @@ const exceptionList = [ 'webgpu_shadowmap', // WebGPU needed + 'webgpu_compile_async', 'webgpu_compute_audio', 'webgpu_compute_birds', 'webgpu_compute_cloth', From 9a141d0f9c0528275a7f3ca3bf7745a5cca70de2 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Sat, 21 Feb 2026 12:28:44 +0900 Subject: [PATCH 2/5] USDCParser: handle empty array payload and validate offsets --- examples/jsm/loaders/usd/USDCParser.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/examples/jsm/loaders/usd/USDCParser.js b/examples/jsm/loaders/usd/USDCParser.js index 98425b668f7cc7..e78348d7565012 100644 --- a/examples/jsm/loaders/usd/USDCParser.js +++ b/examples/jsm/loaders/usd/USDCParser.js @@ -1092,6 +1092,19 @@ class USDCParser { // Seek to payload offset and read value const offset = valueRep.payload; + if ( offset === 0 && isArray ) { + + // Spec 16.3.9.3: Array payload 0 is an explicit empty-array sentinel. + return []; + + } + + if ( offset < 0 || offset >= this.buffer.byteLength ) { + + throw new RangeError( 'USDCParser: Invalid payload offset ' + offset + ' for type ' + type + '.' ); + + } + const savedOffset = this.reader.tell(); this.reader.seek( offset ); @@ -1569,6 +1582,19 @@ class USDCParser { } + if ( ! Number.isSafeInteger( size ) || size < 0 ) { + + throw new RangeError( 'USDCParser: Invalid array size ' + size + ' for type ' + type + '.' ); + + } + + if ( size > 0x7FFFFFFF ) { + + // Crate stores counts as uint64, but JS typed arrays cannot represent all such sizes. + throw new RangeError( 'USDCParser: Array size ' + size + ' exceeds implementation limits.' ); + + } + if ( size === 0 ) return []; // Handle compressed arrays From db5f5e5496e7844736aecb012e039018c1e43b0f Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Sat, 21 Feb 2026 12:52:09 +0900 Subject: [PATCH 3/5] USDComposer: recurse mesh children and skip GeomSubset nodes --- examples/jsm/loaders/usd/USDComposer.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/jsm/loaders/usd/USDComposer.js b/examples/jsm/loaders/usd/USDComposer.js index fcfb21430cc4a4..bd81e8bf92f4d4 100644 --- a/examples/jsm/loaders/usd/USDComposer.js +++ b/examples/jsm/loaders/usd/USDComposer.js @@ -653,12 +653,13 @@ class USDComposer { if ( obj ) { parent.add( obj ); + this._buildHierarchy( obj, path ); } - } else if ( typeName === 'Material' || typeName === 'Shader' ) { + } else if ( typeName === 'Material' || typeName === 'Shader' || typeName === 'GeomSubset' ) { - // Skip materials/shaders, they're referenced by meshes + // Skip materials/shaders/subsets, they're referenced by meshes } else { From 52133860a3021871867a9f194e92062c5931e4e6 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Sat, 21 Feb 2026 12:52:39 +0900 Subject: [PATCH 4/5] Editor: add deterministic dropped-file finder for URL resolution --- editor/js/Loader.js | 96 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 5 deletions(-) diff --git a/editor/js/Loader.js b/editor/js/Loader.js index 5a5515388d02f5..2733ff8baf69f0 100644 --- a/editor/js/Loader.js +++ b/editor/js/Loader.js @@ -33,18 +33,104 @@ function Loader( editor ) { filesMap = filesMap || LoaderUtils.createFilesMap( files ); + const normalizeLookupPath = function ( path ) { + + let normalized = String( path || '' ).replace( /\\/g, '/' ); + const queryIndex = normalized.indexOf( '?' ); + if ( queryIndex !== - 1 ) normalized = normalized.slice( 0, queryIndex ); + const hashIndex = normalized.indexOf( '#' ); + if ( hashIndex !== - 1 ) normalized = normalized.slice( 0, hashIndex ); + + while ( normalized.startsWith( './' ) ) normalized = normalized.slice( 2 ); + while ( normalized.startsWith( '../' ) ) normalized = normalized.slice( 3 ); + while ( normalized.startsWith( '/' ) ) normalized = normalized.slice( 1 ); + + return normalized; + + }; + + const createFileFinder = function ( map ) { + + const suffixMap = {}; + const warnedAmbiguous = new Set(); + + const addCandidate = function ( suffix, candidate ) { + + if ( ! suffixMap[ suffix ] ) suffixMap[ suffix ] = []; + suffixMap[ suffix ].push( candidate ); + + }; + + for ( const rawKey in map ) { + + const key = normalizeLookupPath( rawKey ); + const file = map[ rawKey ]; + if ( key === '' || ! file ) continue; + + const parts = key.split( '/' ); + + for ( let i = 0; i < parts.length; i ++ ) { + + const suffix = parts.slice( i ).join( '/' ); + if ( suffix !== '' ) addCandidate( suffix, { key, file } ); + + } + + } + + for ( const suffix in suffixMap ) { + + suffixMap[ suffix ].sort( function ( a, b ) { + + if ( a.key.length !== b.key.length ) return a.key.length - b.key.length; + if ( a.key < b.key ) return - 1; + if ( a.key > b.key ) return 1; + return 0; + + } ); + + } + + return function findFile( url ) { + + const lookup = normalizeLookupPath( url ); + if ( lookup === '' ) return null; + + const candidates = suffixMap[ lookup ]; + if ( ! candidates || candidates.length === 0 ) return null; + if ( candidates.length === 1 ) return candidates[ 0 ]; + + for ( let i = 0; i < candidates.length; i ++ ) { + + if ( candidates[ i ].key === lookup ) return candidates[ i ]; + + } + + if ( ! warnedAmbiguous.has( lookup ) ) { + + console.warn( 'Loader: Ambiguous file reference "' + lookup + '". Using "' + candidates[ 0 ].key + '".' ); + warnedAmbiguous.add( lookup ); + + } + + return candidates[ 0 ]; + + }; + + }; + + const findFile = createFileFinder( filesMap ); + const manager = new THREE.LoadingManager(); manager.setURLModifier( function ( url ) { - url = url.replace( /^(\.?\/)/, '' ); // remove './' - - const file = filesMap[ url ]; + const resolved = findFile( url ); - if ( file ) { + if ( resolved ) { console.log( 'Loading', url ); - return URL.createObjectURL( file ); + return URL.createObjectURL( resolved.file ); } From 7f89d9b273ef32af1666fbd2b61c6dc4d5c39822 Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Sat, 21 Feb 2026 13:14:56 +0900 Subject: [PATCH 5/5] USDComposer: avoid global displayOpacity on subset materials --- examples/jsm/loaders/usd/USDComposer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/jsm/loaders/usd/USDComposer.js b/examples/jsm/loaders/usd/USDComposer.js index bd81e8bf92f4d4..fcc3dfc2683ba2 100644 --- a/examples/jsm/loaders/usd/USDComposer.js +++ b/examples/jsm/loaders/usd/USDComposer.js @@ -1113,13 +1113,13 @@ class USDComposer { } const displayOpacity = attrs[ 'primvars:displayOpacity' ]; - if ( displayOpacity && displayOpacity.length >= 1 ) { + if ( displayOpacity && displayOpacity.length === 1 && geomSubsets.length === 0 ) { const opacity = displayOpacity[ 0 ]; const applyDisplayOpacity = ( mat ) => { - if ( opacity < 1 ) { + if ( opacity < 1 && mat.opacity === 1 && mat.transparent === false ) { mat.opacity = opacity; mat.transparent = true;