fix(viewer): guard TextureNode updates against null textures (#527)

* fix(viewer): guard TextureNode updates against null textures

three's override-material passes (shadow, prepasses) copy per-object texture
slots onto shared materials whose cached per-mesh node graphs can disagree
about a slot's presence; when a graph with a TextureNode pulls a null slot,
the exception kills the whole render pass and the scene goes black. Patch
TextureNode.prototype.update to skip null values (with a throttled warning)
so the slot just renders textureless for a frame and recovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* revert(viewer): drop the always-on neutral displacement texture

Giving every standard material a displacement texture put displacement
TextureNodes into the shared shadow/prepass override graphs; in scenes mixing
preset materials with GLB item materials (no displacementMap), an item's
shadow render can hit a cached graph that expects the texture and pull null
— build-order dependent, which is why it broke many-but-not-all projects at
open right after the release. The TextureNode fallback guard covers the
original paint-hover crash (black texel = zero displacement), so the broad
neutral-texture approach is retired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: sort guard imports

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-21 11:34:07 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 603f5d2242
commit adc1ec89f4
3 changed files with 65 additions and 57 deletions
@@ -23,6 +23,7 @@ import { applyIsolation, clearIsolation } from '../../lib/isolation'
import { ensureKtx2Support } from '../../lib/ktx2-loader' import { ensureKtx2Support } from '../../lib/ktx2-loader'
import type { ColorPreset, RenderShading } from '../../lib/materials' import type { ColorPreset, RenderShading } from '../../lib/materials'
import { getSceneTheme } from '../../lib/scene-themes' import { getSceneTheme } from '../../lib/scene-themes'
import { installTextureNodeNullGuard } from '../../lib/texture-node-guard'
import useViewer, { type RenderContext } from '../../store/use-viewer' import useViewer, { type RenderContext } from '../../store/use-viewer'
import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system'
import { GeometrySystem } from '../../systems/geometry/geometry-system' import { GeometrySystem } from '../../systems/geometry/geometry-system'
@@ -37,6 +38,10 @@ import { SceneBvh } from './scene-bvh'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera' import { ViewerCamera } from './viewer-camera'
// Must be in place before any node material builds — a null texture pulled by
// a shared override-material pass otherwise kills the render pass outright.
installTextureNodeNullGuard()
declare module '@react-three/fiber' { declare module '@react-three/fiber' {
// The TS 7 native compiler (tsgo) rejects mapping the entire `three/webgpu` // The TS 7 native compiler (tsgo) rejects mapping the entire `three/webgpu`
// namespace into JSX — `ThreeToJSXElements<typeof THREE>` triggers a TS2320 // namespace into JSX — `ThreeToJSXElements<typeof THREE>` triggers a TS2320
+16 -57
View File
@@ -286,40 +286,6 @@ function getPresetTexture(
return texture return texture
} }
// three's WebGPU shadow pass copies each object's base-material
// `displacementMap` onto one shared per-light shadow material, while the
// per-mesh shadow node graph is cached against that shared override. A mesh
// whose graph was built with a displacement TextureNode crashes the render
// pass ("Cannot read properties of null (reading 'matrix')") as soon as a
// material *without* a displacement texture is swapped onto it — the paint
// hover preview does exactly that. Standard node materials therefore always
// carry a displacement texture: a shared 1×1 black texel (zero offset, so
// shading is unchanged) whenever there is no real height map.
let neutralDisplacement: THREE.Texture | null = null
function getNeutralDisplacementTexture(): THREE.Texture {
if (!neutralDisplacement) {
neutralDisplacement = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1)
neutralDisplacement.needsUpdate = true
}
return neutralDisplacement
}
function ensureDisplacementFallback<T extends THREE.Material>(material: T): T {
const textureMaterial = material as THREE.Material as TextureMaterial
if (material instanceof MeshStandardNodeMaterial && !textureMaterial.displacementMap) {
textureMaterial.displacementMap = getNeutralDisplacementTexture()
}
return material
}
/** The value a cleared slot falls back to (never null for displacement). */
function clearedSlotValue(material: CommonMaterial, slot: TextureSlot): THREE.Texture | null {
return slot === 'displacementMap' && material instanceof MeshStandardNodeMaterial
? getNeutralDisplacementTexture()
: null
}
function createAssignedTexture( function createAssignedTexture(
source: THREE.Texture, source: THREE.Texture,
props: MaterialMapProperties, props: MaterialMapProperties,
@@ -394,12 +360,11 @@ function queueTextureAssignment(
const textureMaterial = material as TextureMaterial const textureMaterial = material as TextureMaterial
if (!path) { if (!path) {
const cleared = clearedSlotValue(material, slot) if (textureMaterial[slot] != null) {
if (textureMaterial[slot] !== cleared) {
// Rebuild the node graph: a cached WebGPU material keeps a TextureNode // Rebuild the node graph: a cached WebGPU material keeps a TextureNode
// for the slot, whose per-frame material reference would pull the null // for the slot, whose per-frame material reference would pull the null
// and crash in TextureNode.update ("null (reading 'matrix')"). // and crash in TextureNode.update ("null (reading 'matrix')").
textureMaterial[slot] = cleared textureMaterial[slot] = null
material.needsUpdate = true material.needsUpdate = true
} }
return return
@@ -424,9 +389,8 @@ function queueTextureAssignment(
// graph if it previously held a texture — reused cached materials otherwise // graph if it previously held a texture — reused cached materials otherwise
// keep a TextureNode whose reference pulls the null and crashes the render // keep a TextureNode whose reference pulls the null and crashes the render
// pass. Cold loads are the norm for freshly generated library materials. // pass. Cold loads are the norm for freshly generated library materials.
const placeholder = clearedSlotValue(material, slot) if (textureMaterial[slot] != null) {
if (textureMaterial[slot] !== placeholder) { textureMaterial[slot] = null
textureMaterial[slot] = placeholder
material.needsUpdate = true material.needsUpdate = true
} }
@@ -545,9 +509,8 @@ export function createMaterialFromPreset(
return materialCache.get(cacheKey)! return materialCache.get(cacheKey)!
} }
const material = ensureDisplacementFallback( const material =
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial(), shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial()
)
applyMaterialPresetToMaterials(material, preset) applyMaterialPresetToMaterials(material, preset)
maybeApplyGlassFresnel(material) maybeApplyGlassFresnel(material)
material.userData.__pascalCachedMaterial = true material.userData.__pascalCachedMaterial = true
@@ -591,15 +554,14 @@ export function createMaterial(
if (map) materialParams.map = map if (map) materialParams.map = map
const threeMaterial = ensureDisplacementFallback( const threeMaterial =
shading === 'solid' shading === 'solid'
? new MeshLambertNodeMaterial(materialParams) ? new MeshLambertNodeMaterial(materialParams)
: new MeshStandardNodeMaterial({ : new MeshStandardNodeMaterial({
...materialParams, ...materialParams,
roughness: props.roughness, roughness: props.roughness,
metalness: props.metalness, metalness: props.metalness,
}), })
)
maybeApplyGlassFresnel(threeMaterial) maybeApplyGlassFresnel(threeMaterial)
threeMaterial.userData.__pascalCachedMaterial = true threeMaterial.userData.__pascalCachedMaterial = true
@@ -659,14 +621,12 @@ export function createDefaultMaterial(
}) })
} }
return ensureDisplacementFallback( return new MeshStandardNodeMaterial({
new MeshStandardNodeMaterial({ color,
color, roughness,
roughness, metalness: 0,
metalness: 0, side: resolvedSide,
side: resolvedSide, })
}),
)
} }
function cachedDefaultMaterial( function cachedDefaultMaterial(
@@ -757,15 +717,14 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH
transparent: true, transparent: true,
side: THREE.FrontSide, side: THREE.FrontSide,
} }
const material = ensureDisplacementFallback( const material =
shading === 'solid' shading === 'solid'
? new MeshLambertNodeMaterial(params) ? new MeshLambertNodeMaterial(params)
: new MeshStandardNodeMaterial({ : new MeshStandardNodeMaterial({
...params, ...params,
roughness: 0.1, roughness: 0.1,
metalness: 0.1, metalness: 0.1,
}), })
)
maybeApplyGlassFresnel(material) maybeApplyGlassFresnel(material)
defaultMaterialCache.set(cacheKey, material) defaultMaterialCache.set(cacheKey, material)
return material return material
@@ -0,0 +1,44 @@
import type * as THREE from 'three/webgpu'
import { DataTexture, TextureNode } from 'three/webgpu'
let installed = false
let reported = 0
let fallbackTexture: THREE.Texture | null = null
function getFallbackTexture(): THREE.Texture {
if (!fallbackTexture) {
fallbackTexture = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1)
fallbackTexture.needsUpdate = true
}
return fallbackTexture
}
/**
* three's node system pulls texture uniforms from materials each frame via
* reference nodes, and several override-material passes (shadow, depth/normal
* prepasses) copy per-object texture slots onto shared materials whose cached
* per-mesh node graphs can disagree about a slot's presence. When they do,
* `TextureNode.update` dereferences a null texture and the exception kills the
* whole render pass — the scene goes black. Substitute a 1×1 black fallback
* instead (skipping is not enough: the null would still reach the backend's
* texture-binding WeakMap). The slot renders black for a frame and recovers
* as soon as the reference pulls a real value again.
*/
export function installTextureNodeNullGuard(): void {
if (installed) return
installed = true
const prototype = TextureNode.prototype as { update: () => void }
const originalUpdate = prototype.update
prototype.update = function update(this: { value: unknown; uuid: string }) {
if (this.value == null) {
if (reported < 5) {
reported += 1
console.warn(`[viewer] TextureNode ${this.uuid} has no texture — using fallback`)
}
this.value = getFallbackTexture()
}
originalUpdate.call(this)
}
}