Ver Fonte

WebGPURenderer: Make `compileAsync()` truly non-blocking (#32984)

Renaud Rohlinger há 4 dias atrás
pai
commit
c4965f01d6

+ 1 - 0
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",

BIN
examples/screenshots/webgpu_compile_async.jpg


+ 355 - 0
examples/webgpu_compile_async.html

@@ -0,0 +1,355 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgpu - async node compilation</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<link type="text/css" rel="stylesheet" href="example.css">
+		<style>
+			#stats {
+				position: fixed;
+				top: 80px;
+				left: 20px;
+				background: rgba(0,0,0,0.7);
+				color: #fff;
+				padding: 10px 15px;
+				font-family: monospace;
+				font-size: 12px;
+				border-radius: 4px;
+				z-index: 100;
+				min-width: 220px;
+			}
+			#stats .label { color: #888; }
+			#stats .value { color: #0f0; font-weight: bold; }
+			#stats .value.bad { color: #f55; }
+		</style>
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Async Node Compilation</span>
+			</div>
+
+			<small id="description">NodeMaterial shaders compile asynchronously without blocking the render loop. Animation stays smooth while <span id="materialCount">256</span> unique TSL materials build in the background.</small>
+		</div>
+
+		<div id="stats">
+			<div><span class="label">Longest frame: </span><span id="longestFrame" class="value">-</span></div>
+			<div><span class="label">Meshes added: </span><span id="meshCount" class="value">0 / 256</span></div>
+			<div><span class="label">Mode: </span><span id="compileMode" class="value">-</span></div>
+		</div>
+
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.webgpu.js",
+					"three/webgpu": "../build/three.webgpu.js",
+					"three/tsl": "../build/three.tsl.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three/webgpu';
+			import { uv, float, vec3, hash, mx_noise_vec3, mx_worley_noise_vec3, mx_cell_noise_float, mx_fractal_noise_vec3 } from 'three/tsl';
+
+			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
+
+			let MESH_COUNT = 256;
+			let GRID_SIZE = 16;
+			const ADD_DELAY = 1000;
+
+			let camera, scene, renderer;
+			let sphere;
+			let gui;
+
+			// Frame timing
+			let lastFrameTime = 0;
+			let longestFrameTime = 0;
+			let isTracking = false;
+			let shouldStartTracking = false;
+			let sphereStartTime = 0;
+			let currentMode = '';
+			let framesAfterComplete = 0;
+			let testDone = false;
+			let meshGroup = null;
+
+			const longestFrameEl = document.getElementById( 'longestFrame' );
+			const meshCountEl = document.getElementById( 'meshCount' );
+			const compileModeEl = document.getElementById( 'compileMode' );
+
+			const params = {
+				withoutCompile: function () {
+
+					window.location.href = window.location.pathname + '?mode=no-compile';
+
+				},
+				withCompileAsync: function () {
+
+					window.location.href = window.location.pathname + '?mode=compile-async';
+
+				}
+			};
+
+			init();
+
+			async function init() {
+
+				// GUI
+				gui = new GUI();
+				gui.add( params, 'withoutCompile' ).name( 'Build on render' );
+				gui.add( params, 'withCompileAsync' ).name( 'Pre-build (compileAsync)' );
+
+				window.addEventListener( 'resize', onWindowResize );
+
+				// Orthographic camera
+				const aspect = window.innerWidth / window.innerHeight;
+				const frustumSize = 20;
+				camera = new THREE.OrthographicCamera(
+					frustumSize * aspect / - 2,
+					frustumSize * aspect / 2,
+					frustumSize / 2,
+					frustumSize / - 2,
+					0.1,
+					100
+				);
+				camera.position.set( 0, 0, 20 );
+				camera.lookAt( 0, 0, 0 );
+
+				scene = new THREE.Scene();
+				scene.background = new THREE.Color( 0x111111 );
+
+				// Create animated sphere
+				const sphereGeometry = new THREE.SphereGeometry( 0.5, 32, 32 );
+				const sphereMaterial = new THREE.MeshNormalMaterial();
+				sphere = new THREE.Mesh( sphereGeometry, sphereMaterial );
+				scene.add( sphere );
+
+				// Renderer
+				renderer = new THREE.WebGPURenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				document.body.appendChild( renderer.domElement );
+
+				await renderer.init();
+
+				// Reduce mesh count for WebGL (slower compilation)
+				if ( renderer.backend.isWebGLBackend ) {
+
+					MESH_COUNT = 64;
+					GRID_SIZE = 8;
+					document.getElementById( 'materialCount' ).textContent = '64';
+					meshCountEl.textContent = '0 / 64';
+
+				}
+
+				// Get mode from URL
+				const urlParams = new URLSearchParams( window.location.search );
+				const mode = urlParams.get( 'mode' ) || 'compile-async';
+
+				const modeLabel = mode === 'compile-async' ? 'Pre-build (compileAsync)' : 'Build on render';
+				compileModeEl.textContent = modeLabel;
+
+				// Start sphere animation
+				sphereStartTime = performance.now();
+
+				// Schedule mesh addition after delay
+				setTimeout( () => addMeshes( mode ), ADD_DELAY );
+
+			}
+
+			function createUniqueMaterial( index ) {
+
+				const material = new THREE.MeshBasicNodeMaterial();
+
+				const seed = float( index * 0.1 + 1.0 );
+				const scale = float( ( index % 5 ) + 2.0 );
+				const uvNode = uv().mul( scale ).add( hash( float( index ) ) );
+
+				const noiseType = index % 4;
+				let colorNode;
+
+				switch ( noiseType ) {
+
+					case 0:
+						colorNode = mx_noise_vec3( uvNode.mul( seed ) ).mul( 0.5 ).add( 0.5 );
+						break;
+
+					case 1:
+						colorNode = mx_worley_noise_vec3( uvNode.mul( seed.mul( 0.5 ) ) );
+						break;
+
+					case 2:
+						const cellNoise = mx_cell_noise_float( uvNode.mul( seed ) );
+						colorNode = vec3( cellNoise, cellNoise.mul( 0.7 ), cellNoise.mul( 0.4 ) );
+						break;
+
+					case 3:
+						colorNode = mx_fractal_noise_vec3( uvNode.mul( seed.mul( 0.3 ) ), float( 3 ), float( 2.0 ), float( 0.5 ) )
+							.mul( 0.5 ).add( 0.5 );
+						break;
+
+				}
+
+				const tintR = hash( float( index * 3 ) );
+				const tintG = hash( float( index * 3 + 1 ) );
+				const tintB = hash( float( index * 3 + 2 ) );
+				const tint = vec3( tintR, tintG, tintB ).mul( 0.3 ).add( 0.7 );
+
+				material.colorNode = colorNode.mul( tint );
+
+				return material;
+
+			}
+
+			async function addMeshes( mode ) {
+
+				const geometry = new THREE.PlaneGeometry( 0.9, 0.9 );
+				const startX = - ( GRID_SIZE - 1 ) / 2;
+				const startY = - ( GRID_SIZE - 1 ) / 2;
+
+				meshGroup = new THREE.Group();
+
+				for ( let i = 0; i < MESH_COUNT; i ++ ) {
+
+					const material = createUniqueMaterial( i );
+					const mesh = new THREE.Mesh( geometry, material );
+
+					const col = i % GRID_SIZE;
+					const row = Math.floor( i / GRID_SIZE );
+
+					mesh.position.x = startX + col;
+					mesh.position.y = startY + row;
+
+					meshGroup.add( mesh );
+
+				}
+
+				currentMode = mode;
+
+				if ( mode === 'compile-async' ) {
+
+					// Pre-compile all meshes before adding to scene
+					// Start tracking BEFORE compile to measure longest frame during compile
+					shouldStartTracking = true;
+
+					await renderer.compileAsync( meshGroup, camera, scene );
+
+					// Add all meshes at once (already compiled - should render instantly)
+					scene.add( meshGroup );
+					testDone = true;
+
+				} else {
+
+					// Add all meshes at once - renderer compiles on-demand
+					// Meshes appear progressively as they compile
+					scene.add( meshGroup );
+
+					// Start tracking on next animate() frame
+					shouldStartTracking = true;
+					testDone = true;
+
+				}
+
+			}
+
+			function finishTest() {
+
+				isTracking = false;
+
+				longestFrameEl.textContent = longestFrameTime.toFixed( 1 ) + ' ms';
+				longestFrameEl.className = longestFrameTime > 100 ? 'value bad' : 'value';
+
+			}
+
+			function onWindowResize() {
+
+				if ( ! renderer || ! camera ) return;
+
+				const aspect = window.innerWidth / window.innerHeight;
+				const frustumSize = 12;
+
+				camera.left = frustumSize * aspect / - 2;
+				camera.right = frustumSize * aspect / 2;
+				camera.top = frustumSize / 2;
+				camera.bottom = frustumSize / - 2;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			function animate() {
+
+				const now = performance.now();
+
+				// Initialize tracking on first frame after meshes added
+				if ( shouldStartTracking ) {
+
+					shouldStartTracking = false;
+					isTracking = true;
+					lastFrameTime = now;
+					longestFrameTime = 0;
+					framesAfterComplete = 0;
+
+				}
+
+				// Track longest frame during test
+				if ( isTracking && lastFrameTime > 0 && lastFrameTime !== now ) {
+
+					const frameTime = now - lastFrameTime;
+					if ( frameTime > longestFrameTime ) {
+
+						longestFrameTime = frameTime;
+
+					}
+
+				}
+
+				lastFrameTime = now;
+
+				// Animate sphere left to right
+				if ( sphere && sphereStartTime > 0 ) {
+
+					const elapsed = ( now - sphereStartTime ) / 1000;
+					sphere.position.x = Math.sin( elapsed * 2 ) * 8;
+
+				}
+
+				renderer.render( scene, camera );
+
+				// Update mesh count display
+				if ( meshGroup ) {
+
+					meshCountEl.textContent = meshGroup.children.length + ' / ' + MESH_COUNT;
+
+				}
+
+				// Finish test - wait a few frames after testDone to capture frame times
+				if ( isTracking && testDone ) {
+
+					framesAfterComplete ++;
+
+					// Wait longer for deferred mode (meshes compile progressively)
+					const framesToWait = currentMode === 'compile-async' ? 2 : 60;
+
+					if ( framesAfterComplete >= framesToWait ) {
+
+						finishTest();
+
+					}
+
+				}
+
+			}
+
+		</script>
+	</body>
+</html>

+ 84 - 1
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<NodeBuilder>} 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.
 	 *

+ 20 - 0
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.
 	 *

+ 70 - 10
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;

+ 219 - 29
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<Function>}
+		 */
+		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<NodeBuilderState>} 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<NodeBuilderState>} 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
 	 *

+ 2 - 1
src/renderers/webgl-fallback/WebGLBackend.js

@@ -1618,7 +1618,8 @@ class WebGLBackend extends Backend {
 		//
 
 		this.set( pipeline, {
-			programGPU
+			programGPU,
+			pipeline: programGPU
 		} );
 
 	}

+ 1 - 0
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();

+ 23 - 1
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<void>}
+ */
+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 };

+ 1 - 0
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',

粤ICP备19079148号