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 { 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user