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:
co-authored by
Claude Fable 5
parent
603f5d2242
commit
adc1ec89f4
@@ -23,6 +23,7 @@ import { applyIsolation, clearIsolation } from '../../lib/isolation'
|
||||
import { ensureKtx2Support } from '../../lib/ktx2-loader'
|
||||
import type { ColorPreset, RenderShading } from '../../lib/materials'
|
||||
import { getSceneTheme } from '../../lib/scene-themes'
|
||||
import { installTextureNodeNullGuard } from '../../lib/texture-node-guard'
|
||||
import useViewer, { type RenderContext } from '../../store/use-viewer'
|
||||
import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system'
|
||||
import { GeometrySystem } from '../../systems/geometry/geometry-system'
|
||||
@@ -37,6 +38,10 @@ import { SceneBvh } from './scene-bvh'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
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' {
|
||||
// The TS 7 native compiler (tsgo) rejects mapping the entire `three/webgpu`
|
||||
// namespace into JSX — `ThreeToJSXElements<typeof THREE>` triggers a TS2320
|
||||
|
||||
@@ -286,40 +286,6 @@ function getPresetTexture(
|
||||
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(
|
||||
source: THREE.Texture,
|
||||
props: MaterialMapProperties,
|
||||
@@ -394,12 +360,11 @@ function queueTextureAssignment(
|
||||
const textureMaterial = material as TextureMaterial
|
||||
|
||||
if (!path) {
|
||||
const cleared = clearedSlotValue(material, slot)
|
||||
if (textureMaterial[slot] !== cleared) {
|
||||
if (textureMaterial[slot] != null) {
|
||||
// Rebuild the node graph: a cached WebGPU material keeps a TextureNode
|
||||
// for the slot, whose per-frame material reference would pull the null
|
||||
// and crash in TextureNode.update ("null (reading 'matrix')").
|
||||
textureMaterial[slot] = cleared
|
||||
textureMaterial[slot] = null
|
||||
material.needsUpdate = true
|
||||
}
|
||||
return
|
||||
@@ -424,9 +389,8 @@ function queueTextureAssignment(
|
||||
// graph if it previously held a texture — reused cached materials otherwise
|
||||
// keep a TextureNode whose reference pulls the null and crashes the render
|
||||
// pass. Cold loads are the norm for freshly generated library materials.
|
||||
const placeholder = clearedSlotValue(material, slot)
|
||||
if (textureMaterial[slot] !== placeholder) {
|
||||
textureMaterial[slot] = placeholder
|
||||
if (textureMaterial[slot] != null) {
|
||||
textureMaterial[slot] = null
|
||||
material.needsUpdate = true
|
||||
}
|
||||
|
||||
@@ -545,9 +509,8 @@ export function createMaterialFromPreset(
|
||||
return materialCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const material = ensureDisplacementFallback(
|
||||
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial(),
|
||||
)
|
||||
const material =
|
||||
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial()
|
||||
applyMaterialPresetToMaterials(material, preset)
|
||||
maybeApplyGlassFresnel(material)
|
||||
material.userData.__pascalCachedMaterial = true
|
||||
@@ -591,15 +554,14 @@ export function createMaterial(
|
||||
|
||||
if (map) materialParams.map = map
|
||||
|
||||
const threeMaterial = ensureDisplacementFallback(
|
||||
const threeMaterial =
|
||||
shading === 'solid'
|
||||
? new MeshLambertNodeMaterial(materialParams)
|
||||
: new MeshStandardNodeMaterial({
|
||||
...materialParams,
|
||||
roughness: props.roughness,
|
||||
metalness: props.metalness,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
maybeApplyGlassFresnel(threeMaterial)
|
||||
threeMaterial.userData.__pascalCachedMaterial = true
|
||||
@@ -659,14 +621,12 @@ export function createDefaultMaterial(
|
||||
})
|
||||
}
|
||||
|
||||
return ensureDisplacementFallback(
|
||||
new MeshStandardNodeMaterial({
|
||||
color,
|
||||
roughness,
|
||||
metalness: 0,
|
||||
side: resolvedSide,
|
||||
}),
|
||||
)
|
||||
return new MeshStandardNodeMaterial({
|
||||
color,
|
||||
roughness,
|
||||
metalness: 0,
|
||||
side: resolvedSide,
|
||||
})
|
||||
}
|
||||
|
||||
function cachedDefaultMaterial(
|
||||
@@ -757,15 +717,14 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH
|
||||
transparent: true,
|
||||
side: THREE.FrontSide,
|
||||
}
|
||||
const material = ensureDisplacementFallback(
|
||||
const material =
|
||||
shading === 'solid'
|
||||
? new MeshLambertNodeMaterial(params)
|
||||
: new MeshStandardNodeMaterial({
|
||||
...params,
|
||||
roughness: 0.1,
|
||||
metalness: 0.1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
maybeApplyGlassFresnel(material)
|
||||
defaultMaterialCache.set(cacheKey, 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user