From 50e6f481756e250910c1f3cbdee781914c8fa4a1 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 11:19:40 -0400 Subject: [PATCH 1/3] 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 --- .../viewer/src/components/viewer/index.tsx | 5 +++ packages/viewer/src/lib/texture-node-guard.ts | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 packages/viewer/src/lib/texture-node-guard.ts diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index c1754da68..2de9cade6 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -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` triggers a TS2320 diff --git a/packages/viewer/src/lib/texture-node-guard.ts b/packages/viewer/src/lib/texture-node-guard.ts new file mode 100644 index 000000000..e265ead06 --- /dev/null +++ b/packages/viewer/src/lib/texture-node-guard.ts @@ -0,0 +1,33 @@ +import { TextureNode } from 'three/webgpu' + +let installed = false +let reported = 0 + +/** + * 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. Skip the update instead: the slot + * renders without its texture 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 — skipping update`) + } + return + } + originalUpdate.call(this) + } +} From 46f76a943ead4c1a6bc8721db038745d79662397 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 11:27:53 -0400 Subject: [PATCH 2/3] revert(viewer): drop the always-on neutral displacement texture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/viewer/src/lib/materials.ts | 73 ++++--------------- packages/viewer/src/lib/texture-node-guard.ts | 23 ++++-- 2 files changed, 33 insertions(+), 63 deletions(-) diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index aa1096496..5504153d4 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -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(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 diff --git a/packages/viewer/src/lib/texture-node-guard.ts b/packages/viewer/src/lib/texture-node-guard.ts index e265ead06..b8f1be831 100644 --- a/packages/viewer/src/lib/texture-node-guard.ts +++ b/packages/viewer/src/lib/texture-node-guard.ts @@ -1,7 +1,17 @@ -import { TextureNode } from 'three/webgpu' +import { DataTexture, TextureNode } from 'three/webgpu' +import type * as THREE 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 @@ -9,9 +19,10 @@ let reported = 0 * 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. Skip the update instead: the slot - * renders without its texture for a frame and recovers as soon as the - * reference pulls a real value again. + * 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 @@ -24,9 +35,9 @@ export function installTextureNodeNullGuard(): void { if (this.value == null) { if (reported < 5) { reported += 1 - console.warn(`[viewer] TextureNode ${this.uuid} has no texture — skipping update`) + console.warn(`[viewer] TextureNode ${this.uuid} has no texture — using fallback`) } - return + this.value = getFallbackTexture() } originalUpdate.call(this) } From 01acd1993fbfa2f720b409c79c083e837013d04d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 11:31:22 -0400 Subject: [PATCH 3/3] style: sort guard imports Co-Authored-By: Claude Fable 5 --- packages/viewer/src/lib/texture-node-guard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/viewer/src/lib/texture-node-guard.ts b/packages/viewer/src/lib/texture-node-guard.ts index b8f1be831..d607674ac 100644 --- a/packages/viewer/src/lib/texture-node-guard.ts +++ b/packages/viewer/src/lib/texture-node-guard.ts @@ -1,5 +1,5 @@ -import { DataTexture, TextureNode } from 'three/webgpu' import type * as THREE from 'three/webgpu' +import { DataTexture, TextureNode } from 'three/webgpu' let installed = false let reported = 0