Skip to content

PassNode: compileAsync() prepares a render context the pass is never rendered in (regression since r183) #34681

Description

@dgesteves

Description

PassNode.compileAsync() (#31501) exists so that "when the animation loop starts and the pass is
rendered for the first time, the frame time should be lower due to the pre-compilation". Since r183
it no longer does that: the pass's first render builds every object of its scene again,
synchronously, on the main thread, and an InstancedMesh also compiles a second shader and render
pipeline. It affects the WebGPU backend and the WebGL 2 backend alike.

Expected: after await scenePass.compileAsync( renderer ), the first renderPipeline.render()
builds nothing for the scene's objects — only the RenderPipeline's own full-screen quad, which
nothing precompiles.

Actual: the first render creates a new render object and runs a new node build for every object
in the scene; for an InstancedMesh the new build emits different code, so a new shader module
and a new render pipeline are created, synchronously.

Cause

Render contexts are keyed by call depth, and the precompile and the render ask for different
depths:

  • Since RenderContext: Refactor context key. #32546 (r183) a render context's key is attachmentState-mrtState-callDepth
    (RenderContexts.js#L67),
    and a render object is keyed by its render context
    (RenderObjects.js#L107).

  • render() asks for its context at this._callDepth
    (Renderer.js#L1733),
    which is -1 at rest and raised by each nested render()
    (#L1683).

  • A pass is rendered from PassNode.updateBefore()
    (PassNode.js#L909),
    that is, inside the render of the node that reads it — for a RenderPipeline, inside
    this._quadMesh.render( renderer )
    (RenderPipeline.js#L149).
    So the pass renders at call depth 1.

  • Renderer.compileAsync() asks for its context with no depth, so 0
    (Renderer.js#L933),
    and PassNode.compileAsync() calls it from the top level
    (PassNode.js#L791).

  • So the precompile fills render objects for a depth-0 context the pass never uses, and the first
    render builds the depth-1 ones. compileAsync() itself intends the keys to match ("This ensures
    cache keys match between compileAsync and render",
    Renderer.js#L925).

  • An InstancedMesh shows it most: its vertex shader names its matrix buffer's node, so the second
    build emits different code. The two shaders of the reproduction below differ only in that:

    -struct NodeBuffer_1747Struct {
    -var<uniform> NodeBuffer_1747 : NodeBuffer_1747Struct;
    -positionLocal = ( NodeBuffer_1747.value[ instanceIndex ] * vec4<f32>( positionLocal, 1.0 ) ).xyz;
    +struct NodeBuffer_2989Struct {
    +var<uniform> NodeBuffer_2989 : NodeBuffer_2989Struct;
    +positionLocal = ( NodeBuffer_2989.value[ instanceIndex ] * vec4<f32>( positionLocal, 1.0 ) ).xyz;

    A plain mesh builds the same code again, so its pipeline is reused, but its render object and
    node build are still redone.

On dev (eccc422), #34506 made compileAsync() raise _callDepth as render() does
(_beginPreCompile(),
Renderer.js#L1133;
the context at
#L943).
A top-level call still lands at depth 0, so the pass is still affected. The depth is also read
after await this.init()
(#L900),
which matters for the fix below.

Affected versions

Changing only the depth changes the rebuild

Same scene, camera, render target and MRT in every row; only the call depth of the precompile and
of the render differ. WebGPU backend; node builds counted with renderer.debug.onNodeBuilderCreated.

three Precompiled at Rendered at The first render rebuilds
r186 0: renderer.compileAsync() with the pass's target set 0: renderer.render() into that target nothing
r186 0: scenePass.compileAsync() 1: renderPipeline.render() box, instances
r186 + fix 1: scenePass.compileAsync() 1: renderPipeline.render() nothing
r186 + fix 1: scenePass.compileAsync() 0: renderer.render() into the pass's target box, instances

The rebuild follows the depth in both directions. The same holds on dev.

Measurements (the reproduction below)

After await scenePass.compileAsync(), the first renderPipeline.render() r186.1 r186 + fix
Node builds, WebGPU Render Pipeline, box, instances Render Pipeline
Render pipelines created, WebGPU 2, sync: instances (rgba16float) and the quad (bgra8unorm) 1, sync: the quad
Shader modules created, WebGPU 3 2
Node builds, WebGL 2 backend Render Pipeline, box, instances Render Pipeline

The precompile itself is the same in both columns: two node builds (box, instances), two
asynchronous pipelines, four shader modules. r182 and r183 have no onNodeBuilderCreated; counting
pipelines only, r182's first render creates only the quad's, and r183's creates a second
instances pipeline, as r186 does.

In a larger scene the cost scales with it: in one with many instanced meshes, rendered through a
RenderPipeline and precompiled with scenePass.compileAsync(), the first frames created 323
render pipelines on WebGPU (197 when the scene was rendered directly) and the WebGL 2 backend's warm
start to the first complete frame went from 12.9–21.2 s to 21.9–23.8 s; with the fix below, 205
pipelines and 12.9–16.7 s.

Proposed fix

A pass precompiles for the depth it is rendered at, one below the render that reads it; and
compileAsync() takes the depth a render issued at the moment of the call would take, captured
before its first await. Against dev:

--- a/src/renderers/common/Renderer.js
+++ b/src/renderers/common/Renderer.js
@@ async compileAsync( scene, camera, targetScene = null, onProgress = null ) {

+		// The call depth a render issued now would take, 0 at the top level: render contexts are
+		// keyed by it, and a precompile keyed otherwise builds render objects no render uses.
+
+		const callDepth = this._callDepth + 1;
+
 		if ( this._isDeviceLost === true ) return;
@@
-		const renderContext = this._renderContexts.get( renderTarget, this._mrt, this._callDepth );
+		const renderContext = this._renderContexts.get( renderTarget, this._mrt, callDepth );
--- a/src/nodes/display/PassNode.js
+++ b/src/nodes/display/PassNode.js
@@ async compileAsync( renderer ) {
 		renderer.setRenderTarget( this.renderTarget );
 		renderer.setMRT( this._mrt );

-		await renderer.compileAsync( this.scene, this.camera );
+		// A pass is rendered inside the render of the node that reads it, one call depth below, and
+		// render contexts are keyed by call depth: compile at that depth, or the first render of the
+		// pass builds every object again.
+
+		renderer._callDepth ++;
+
+		const compilation = renderer.compileAsync( this.scene, this.camera );
+
+		renderer._callDepth --;
+
+		await compilation;

Both hunks are needed on dev: with the PassNode hunk alone, a renderer that is not yet
initialized still precompiles at depth 0, because compileAsync() awaits init() before it reads
the depth (the test below fails its two "not yet initialized" cases). At the top level the
Renderer hunk changes nothing (-1 + 1 = 0). The same change against r186 is what we run as a
local patch.

A pass read inside another pass renders two levels down and is covered by neither hunk; keying
render objects independently of the call depth where the attachment state is equal would cover
it, at a larger cost to review.

Regression test

A unit test in the style of test/unit/addons/tsl/ (a real WebGPURenderer, both backends,
QUnit in the browser). Run against each build, headless Chromium 153 on macOS:

three Passed
r186.1 0 of 4
dev (eccc422) 0 of 4
dev + the PassNode hunk only 2 of 4 (fails both "not yet initialized")
dev + both hunks 4 of 4
PassNode.compileAsync.tests.js
import { BoxGeometry, DirectionalLight, InstancedMesh, Mesh, MeshStandardNodeMaterial, PerspectiveCamera, RenderPipeline, Scene, WebGPURenderer } from 'three/webgpu';
import { pass } from 'three/tsl';

// PassNode.compileAsync() must prepare what the pass's first render uses. The pass renders inside
// the render that reads it (a RenderPipeline's quad), one call depth below, and render contexts are
// keyed by call depth, so a precompile at the top level builds render objects no render uses.

const BACKENDS = { webgpu: {}, webgl: { forceWebGL: true } };

function setup() {

	const scene = new Scene();
	scene.add( new DirectionalLight() );

	const box = new Mesh( new BoxGeometry(), new MeshStandardNodeMaterial() );
	box.name = 'box';

	const instances = new InstancedMesh( new BoxGeometry(), new MeshStandardNodeMaterial(), 3 );
	instances.name = 'instances';

	scene.add( box, instances );

	const camera = new PerspectiveCamera();
	camera.position.z = 5;

	return { scene, camera };

}

async function firstRenderBuilds( options, initFirst ) {

	const renderer = new WebGPURenderer( { antialias: false, ...options } );
	if ( initFirst ) await renderer.init();

	const { scene, camera } = setup();
	const scenePass = pass( scene, camera );
	const renderPipeline = new RenderPipeline( renderer, scenePass );

	const built = [];
	renderer.debug.onNodeBuilderCreated = ( builder, renderObject ) => built.push( renderObject.object.name );

	await scenePass.compileAsync( renderer ); // initializes the renderer when it has not been

	const compiled = built.splice( 0 );

	renderPipeline.render();

	renderer.dispose();

	return { compiled, rendered: built };

}

export default QUnit.module( 'Nodes', () => {

	QUnit.module( 'PassNode', () => {

		for ( const [ backend, options ] of Object.entries( BACKENDS ) ) {

			for ( const initFirst of [ true, false ] ) {

				QUnit.test( `compileAsync() prepares the first render of the pass (${ backend }, renderer ${ initFirst ? 'initialized' : 'not yet initialized' })`, async ( assert ) => {

					const { compiled, rendered } = await firstRenderBuilds( options, initFirst );

					assert.deepEqual( compiled.sort(), [ 'box', 'instances' ], 'compileAsync() builds the scene' );
					assert.notOk( rendered.includes( 'box' ) || rendered.includes( 'instances' ), `the first render builds nothing of the scene again (built: ${ rendered.join( ', ' ) })` );

				} );

			}

		}

	} );

} );

Reproduction steps

  1. Open the live example in a browser with WebGPU.
  2. It builds a Mesh and an InstancedMesh (count 3), precompiles them with
    scenePass.compileAsync( renderer ), renders the RenderPipeline once, and prints the node
    builds and render pipelines of each phase — on the WebGPU backend, then on the WebGL 2 backend.
  3. The first render builds box and instances again on both backends.

Code

The live example's code (three r186.1 from jsDelivr)
<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js",
      "three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js",
      "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.tsl.js"
    }
  }
</script>
<pre id="out">running...</pre>
<script type="module">
  import * as THREE from "three/webgpu";
  import { pass } from "three/tsl";

  // Render pipelines as the WebGPU device creates them, by phase.
  let phase = "setup";
  let pipelines = [];
  const { createRenderPipeline, createRenderPipelineAsync } = GPUDevice.prototype;
  GPUDevice.prototype.createRenderPipeline = function (d) {
    pipelines.push(`${phase}: sync ${d.fragment?.targets?.[0]?.format}`);
    return createRenderPipeline.call(this, d);
  };
  GPUDevice.prototype.createRenderPipelineAsync = function (d) {
    pipelines.push(`${phase}: async ${d.fragment?.targets?.[0]?.format}`);
    return createRenderPipelineAsync.call(this, d);
  };

  // A mesh and an instanced mesh, precompiled with scenePass.compileAsync(), then rendered once
  // through a RenderPipeline. Returns what each phase built.
  async function run(forceWebGL) {
    phase = "setup";
    pipelines = [];
    const renderer = new THREE.WebGPURenderer({ forceWebGL });
    renderer.setSize(256, 256);
    await renderer.init();

    const builds = [];
    renderer.debug.onNodeBuilderCreated = (builder, renderObject) =>
      builds.push(`${phase}: ${renderObject.object.name}`);

    const scene = new THREE.Scene();
    scene.add(new THREE.DirectionalLight(0xffffff, 2));
    const box = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardNodeMaterial());
    box.name = "box";
    const instances = new THREE.InstancedMesh(new THREE.BoxGeometry(0.3, 0.3, 0.3), new THREE.MeshStandardNodeMaterial(), 3);
    instances.name = "instances";
    scene.add(box, instances);
    const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
    camera.position.z = 5;

    const scenePass = pass(scene, camera);
    const renderPipeline = new THREE.RenderPipeline(renderer);
    renderPipeline.outputNode = scenePass;

    phase = "compileAsync";
    await scenePass.compileAsync(renderer);

    phase = "first render";
    renderPipeline.render();

    renderer.dispose();
    return [
      `three r${THREE.REVISION}, ${forceWebGL ? "WebGL 2" : "WebGPU"} backend`,
      "node builds:", ...builds.map((b) => "  " + b),
      ...(forceWebGL ? [] : ["render pipelines:", ...pipelines.map((p) => "  " + p)]),
      "",
    ];
  }

  const report = [...(await run(false)), ...(await run(true))];
  document.getElementById("out").textContent = report.join("\n");
  console.log(report.join("\n"));
</script>

Live example

https://jsfiddle.net/vanf8bs0/ (three r186.1: prints the node builds and render pipelines of the precompile and of the first render, on the WebGPU backend and then the WebGL 2 backend)

Screenshots

No response

Version

r186.1 (regression since r183; dev at eccc422 is affected)

Device

Desktop

Browser

Chrome

OS

MacOS

Activity

  1. Mugen87 commented on Sep 28, 2026

    @Mugen87
    Collaborator

    The suggested fix does not handle the use case of nested render passes. Also, it's not ideal if PassNode directly access _callDepth. That's an internal information that should not leak in other components. I'm looking for a different solution.

  2. samuel7james commented on Sep 30, 2026

    @samuel7james
  3. dilanshah commented on Oct 2, 2026

    @dilanshah
  4. dilanshah commented on Oct 2, 2026

    @dilanshah
  5. billpwchan commented on Oct 6, 2026

    @billpwchan

    A data point for the nested case. In my app the scene pass sits under FXAA, and fxaa() wraps its input in convertToTexture(), so the scene pass is drawn at call depth 2, not 1. The context it actually renders into is keyed 1:1023:1016:0:true:false-default-2. A fix that assumes "one level below the top-level render" would still miss this setup.

    Impact in a real scene (r186, WebGPU backend, a few hundred materials): the first RenderPipeline.render() ran 294 synchronous node builds, about 1.7 s of main-thread time on an M4 Max.

    What I'm doing until there's a supported way, in case it helps someone who lands here: set the pass's render target, and for that one compileAsync() call wrap renderer._renderContexts.get so it asks for the depth the pass is really rendered at. Synchronous builds on the first frame went from 294 to 8. It reads internals and hard-codes the depth, so it breaks as soon as the post chain changes: http://github.com/billpwchan/neon-zenith/blob/27ad04ef04aa9e1e1366fccc63e15a379b999f01/src/main.js#L243-L256

    Something like a way to ask "precompile this pass as the pipeline will render it" (or resolving the depth from the node graph at precompile time) would cover both the plain and the nested case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions