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