Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/viewer/src/components/viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
73 changes: 16 additions & 57 deletions packages/viewer/src/lib/materials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/viewer/src/lib/texture-node-guard.ts
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback assignment can stick on node

Medium Severity

When value is null, the guard writes the shared fallback onto this.value and leaves it there. Recovery assumes a reference will overwrite it later; if that pull is skipped or only runs on material changes, the node keeps sampling the 1×1 black texture after a real map is available, or keeps a fallback where the slot should stay empty.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 01acd19. Configure here.

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update patch drops frame argument

Medium Severity

The TextureNode.prototype.update wrapper doesn't forward the frame argument to the original update function. While three's node runtime invokes update(frame), the original implementation always receives undefined for frame, breaking frame-dependent texture updates like video textures.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 01acd19. Configure here.

}
Loading