feat(materials): dynamic library registry, picker source tabs, Other category (#525)
* feat(materials): dynamic library registry, picker source tabs, create-material entry point Core gains a runtime material registry (registerLibraryMaterials/ unregisterLibraryMaterials/subscribeLibraryMaterials) so embedders can feed user/community materials; library: refs to registered materials resolve in the viewer unchanged. MaterialCatalogItem carries an optional source (pascal|community|mine|workspace). MaterialPicker gets a source filter row and an optional onCreateMaterialRequest '+ New material' tile, threaded through MaterialPaintPanel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(materials): underline source tabs in picker, matching catalog browse surfaces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(materials): add 'other' category for uncategorized library materials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): rebuild node material when a texture slot is cleared Reused cached WebGPU materials keep a compiled TextureNode per slot; nulling the slot for a cold texture load (or a preset without the map) without needsUpdate leaves the node's per-frame material reference pulling null, crashing the render pass in TextureNode.update. Cold loads are the norm for freshly generated library materials, whose maps aren't in the texture cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(viewer): standard materials always carry a displacement texture three's WebGPU shadow pass copies each object's displacementMap onto one shared per-light shadow material while caching per-mesh shadow node graphs against that shared override. A mesh whose shadow graph was built with a displacement TextureNode (painted with a generated material — the only presets carrying height maps) crashes the render pass with "null (reading 'matrix')" the moment a material without a displacement texture is swapped onto it, which is exactly what the paint hover preview does. Give every MeshStandardNodeMaterial a shared 1x1 black displacement texel (zero offset) when it has no real height map, so the copied slot is never null in either direction. 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
bf89b5bcf2
commit
603f5d2242
@@ -286,6 +286,40 @@ 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,
|
||||
@@ -360,7 +394,14 @@ function queueTextureAssignment(
|
||||
const textureMaterial = material as TextureMaterial
|
||||
|
||||
if (!path) {
|
||||
textureMaterial[slot] = null
|
||||
const cleared = clearedSlotValue(material, slot)
|
||||
if (textureMaterial[slot] !== cleared) {
|
||||
// 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
|
||||
material.needsUpdate = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -379,7 +420,15 @@ function queueTextureAssignment(
|
||||
return
|
||||
}
|
||||
|
||||
textureMaterial[slot] = null
|
||||
// Cold load: clear the slot for the fetch window, and rebuild the node
|
||||
// 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
|
||||
material.needsUpdate = true
|
||||
}
|
||||
|
||||
loadPresetTexture(path, props, slot).then((texture) => {
|
||||
if (!texture) return
|
||||
@@ -496,8 +545,9 @@ export function createMaterialFromPreset(
|
||||
return materialCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const material =
|
||||
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial()
|
||||
const material = ensureDisplacementFallback(
|
||||
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial(),
|
||||
)
|
||||
applyMaterialPresetToMaterials(material, preset)
|
||||
maybeApplyGlassFresnel(material)
|
||||
material.userData.__pascalCachedMaterial = true
|
||||
@@ -541,14 +591,15 @@ export function createMaterial(
|
||||
|
||||
if (map) materialParams.map = map
|
||||
|
||||
const threeMaterial =
|
||||
const threeMaterial = ensureDisplacementFallback(
|
||||
shading === 'solid'
|
||||
? new MeshLambertNodeMaterial(materialParams)
|
||||
: new MeshStandardNodeMaterial({
|
||||
...materialParams,
|
||||
roughness: props.roughness,
|
||||
metalness: props.metalness,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
maybeApplyGlassFresnel(threeMaterial)
|
||||
threeMaterial.userData.__pascalCachedMaterial = true
|
||||
@@ -608,12 +659,14 @@ export function createDefaultMaterial(
|
||||
})
|
||||
}
|
||||
|
||||
return new MeshStandardNodeMaterial({
|
||||
color,
|
||||
roughness,
|
||||
metalness: 0,
|
||||
side: resolvedSide,
|
||||
})
|
||||
return ensureDisplacementFallback(
|
||||
new MeshStandardNodeMaterial({
|
||||
color,
|
||||
roughness,
|
||||
metalness: 0,
|
||||
side: resolvedSide,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function cachedDefaultMaterial(
|
||||
@@ -704,14 +757,15 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH
|
||||
transparent: true,
|
||||
side: THREE.FrontSide,
|
||||
}
|
||||
const material =
|
||||
const material = ensureDisplacementFallback(
|
||||
shading === 'solid'
|
||||
? new MeshLambertNodeMaterial(params)
|
||||
: new MeshStandardNodeMaterial({
|
||||
...params,
|
||||
roughness: 0.1,
|
||||
metalness: 0.1,
|
||||
})
|
||||
}),
|
||||
)
|
||||
maybeApplyGlassFresnel(material)
|
||||
defaultMaterialCache.set(cacheKey, material)
|
||||
return material
|
||||
|
||||
Reference in New Issue
Block a user