Skip to content

WebGPURenderer: a failed node build silently falls back to a bare NodeMaterial, and the errors that follow describe the fallback #34720

Description

@johnfewell

Description

When a node material fails to build, WebGPURenderer silently substitutes a bare NodeMaterial, and the errors that follow describe the substitute rather than the material that failed.

In NodeManager.getForRender(), if nodeBuilder.build() (or buildAsync()) throws, the error is logged as 'TSL: ' + e, a new builder is created for the same render object with new NodeMaterial(), and that state is cached under the render object's cache key.

This causes three problems:

  1. On a multi-attachment target, the loudest error is not the real one. The fallback material has no mrtNode, so its fragment stage declares only @location( 0 ). WebGPU then rejects the pipeline with "Color target has no corresponding fragment stage output" for targets[1], followed by two follow-on validation errors. All three describe the fallback material, not the user's material or the exception that caused the fallback.
  2. The fallback output can look like a real render. It writes a constant vec4( 0, 0, 0, 1 ). In a post-processing or data pass, black is often a plausible value. In our case it read as "the feature has no effect" for several hours.
  3. The async path loses the stack. The sync path attaches e.stack as a StackTrace, but the useAsync branch (used by compileAsync) logs only 'TSL: ' + e.

Reproduction steps

  1. Create a RenderTarget with count: 2 and a NodeMaterial with an mrtNode writing both attachments.
  2. Give it a fragmentNode that throws during setup.
  3. Render it into the target.

A standalone page is below (import map to three@0.186.0 on jsDelivr; serve it from any static server and open it in Chrome).

Code

const target = new THREE.RenderTarget( 64, 64, { count: 2 } );
target.textures[ 0 ].name = 'output';
target.textures[ 1 ].name = 'extra';

const material = new THREE.NodeMaterial();
material.fragmentNode = Fn( () => { throw new Error( 'deliberate build failure' ); } )();
material.mrtNode = mrt( { output, extra: vec4( 0, 1, 0, 1 ) } );

renderer.setRenderTarget( target );
renderer.render( scene, camera );

Expected: the build error is reported against this render object, and nothing is drawn (or the fallback is opt-in).

Actual console output, in order:

THREE.TSL: Error: deliberate build failure
THREE.WebGPURenderer: Render pipeline creation failed (renderPipeline_NodeMaterial_17): Color target has no corresponding fragment stage output but writeMask (ColorWriteMask::(Red|Green|Blue|Alpha)) is not zero.
 - While validating targets[1] framebuffer output.
 - While validating fragment state.
 - While calling [Device].CreateRenderPipeline([RenderPipelineDescriptor ""renderPipeline_NodeMaterial_17""]).
THREE.WebGPURenderer: Uncaptured WebGPU GPUValidationError: [Invalid RenderPipeline "renderPipeline_NodeMaterial_17"] is invalid due to a previous error.
 - While encoding [RenderPassEncoder (unlabeled)].SetPipeline([Invalid RenderPipeline "renderPipeline_NodeMaterial_17"]).
 - While finishing [CommandEncoder "renderContext_0"].
THREE.WebGPURenderer: Uncaptured WebGPU GPUValidationError: [Invalid CommandBuffer from CommandEncoder "renderContext_0"] is invalid due to a previous error.
 - While calling [Queue].Submit([[Invalid CommandBuffer from CommandEncoder "renderContext_0"]])

The build error is one line. The three validation errors after it all come from the fallback pipeline.

We also confirmed this without a GPU using three's own WGSLNodeBuilder: the same material without the throwing node declares @location( 0 ) and @location( 1 ); with it, build() throws; and a bare NodeMaterial built for the same target declares only @location( 0 ).

Full standalone repro page
<!doctype html>
<meta charset="utf-8">
<title>three r186: silent NodeMaterial fallback on an MRT target</title>
<pre id="log"></pre>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.webgpu.js",
  "three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.webgpu.js",
  "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.tsl.js"
} }
</script>
<script type="module">
import * as THREE from 'three/webgpu'
import { Fn, mrt, output, vec4 } from 'three/tsl'

const log = (...a) => { document.getElementById('log').textContent += a.join(' ') + '\n'; console.log(...a) }
for (const k of ['error', 'warn']) { const orig = console[k]; console[k] = (...a) => { document.getElementById('log').textContent += `[console.${k}] ` + a.map(String).join(' ') + '\n'; orig(...a) } }

const renderer = new THREE.WebGPURenderer()
await renderer.init()
renderer.backend.device.addEventListener('uncapturederror', e => log('[GPU uncapturederror]', e.error.message))

const target = new THREE.RenderTarget(64, 64, { count: 2 })
target.textures[0].name = 'output'
target.textures[1].name = 'extra'

const material = new THREE.NodeMaterial()
material.fragmentNode = Fn(() => { throw new Error('deliberate build failure') })()
material.mrtNode = mrt({ output, extra: vec4(0, 1, 0, 1) })

const scene = new THREE.Scene()
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material))
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)

renderer.setRenderTarget(target)
renderer.render(scene, camera)
await renderer.backend.device.queue.onSubmittedWorkDone()
log('done: see the console.')
</script>

Suggestions

  • Make the fallback opt-in (for example behind a renderer.debug flag). Otherwise, skip drawing the render object and report the failure once.
  • If the fallback stays, log the material name or type and the original exception with its stack on both the sync and async paths, and mark the cached state as a fallback so a later validation error can point back to the build failure.
  • renderer.debug.onNodeBuilderCreated (Nodes: Add renderer.debug.onNodeBuilderCreated callback #34068) helps catch the builder, but doesn't change the misleading output.

Version

r186 (three@0.186.0). Also observed in r185.

Device / Browser / OS

Apple M4 (Mac mini), Chrome 154.0.8037.58, macOS 26.6.2

Activity

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