From 6b4d65d2ff799610a320960ee7ea11490e58c1f0 Mon Sep 17 00:00:00 2001 From: RenaudRohlinger Date: Thu, 2 Apr 2026 10:46:30 +0900 Subject: [PATCH 01/19] Add node material rebuild debug reporting --- examples/files.json | 1 + .../webgpu_materials_debug_rebuild.jpg | Bin 0 -> 20834 bytes examples/webgpu_materials_debug_rebuild.html | 502 +++++++++++++++++ src/renderers/common/NodeMaterialDebug.js | 523 ++++++++++++++++++ src/renderers/common/RenderObjects.js | 5 + src/renderers/common/Renderer.js | 38 ++ test/e2e/image.js | 14 +- .../unit/src/renderers/RenderObjects.tests.js | 190 +++++++ test/unit/three.source.unit.js | 1 + 9 files changed, 1273 insertions(+), 1 deletion(-) create mode 100644 examples/screenshots/webgpu_materials_debug_rebuild.jpg create mode 100644 examples/webgpu_materials_debug_rebuild.html create mode 100644 src/renderers/common/NodeMaterialDebug.js create mode 100644 test/unit/src/renderers/RenderObjects.tests.js diff --git a/examples/files.json b/examples/files.json index 85dc5dc3d49bd8..f4df96e26796fe 100644 --- a/examples/files.json +++ b/examples/files.json @@ -382,6 +382,7 @@ "webgpu_materials_displacementmap", "webgpu_materials_envmaps_bpcem", "webgpu_materials_envmaps", + "webgpu_materials_debug_rebuild", "webgpu_materials_lightmap", "webgpu_materials_matcap", "webgpu_materials_sss", diff --git a/examples/screenshots/webgpu_materials_debug_rebuild.jpg b/examples/screenshots/webgpu_materials_debug_rebuild.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8c529c6a76d27d1772e7acd389fc48bc14b8d701 GIT binary patch literal 20834 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 literal 0 HcmV?d00001 diff --git a/examples/webgpu_materials_debug_rebuild.html b/examples/webgpu_materials_debug_rebuild.html new file mode 100644 index 00000000000000..027f52df5d71d7 --- /dev/null +++ b/examples/webgpu_materials_debug_rebuild.html @@ -0,0 +1,502 @@ + + + + three.js webgpu - node material rebuild debug + + + + + + + +
+ +
+ + +
+ three.jsNode Material Rebuild Debug +
+ + + Uses renderer.debug.onNodeMaterialInvalidation to report why a material makes the node material rebuild.
+ Toggle clearcoat/transmission, or add/remove the accent light to trigger rebuilds from material or scene changes. +
+
+ +
+
+ + + + + +
+
+
Waiting for the next rebuild event.
+
+
+
+ +
+
NodeMaterial needs rebuild
+
Waiting for a rebuild event
+
Use the buttons or start the auto demo.
+
This banner is driven directly by the debug callback.
+
+ + + + + + + diff --git a/src/renderers/common/NodeMaterialDebug.js b/src/renderers/common/NodeMaterialDebug.js new file mode 100644 index 00000000000000..6ca3224269b015 --- /dev/null +++ b/src/renderers/common/NodeMaterialDebug.js @@ -0,0 +1,523 @@ +import { BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap } from '../../constants.js'; +import { warn } from '../../utils.js'; + +function getKeys( obj ) { + + const keys = Object.keys( obj ); + + let proto = Object.getPrototypeOf( obj ); + + while ( proto ) { + + const descriptors = Object.getOwnPropertyDescriptors( proto ); + + for ( const key in descriptors ) { + + if ( descriptors[ key ] !== undefined ) { + + const descriptor = descriptors[ key ]; + + if ( descriptor && typeof descriptor.get === 'function' ) { + + keys.push( key ); + + } + + } + + } + + proto = Object.getPrototypeOf( proto ); + + } + + return keys; + +} + +function getDebugValue( value ) { + + if ( value === undefined ) return 'undefined'; + if ( value === null ) return 'null'; + + const type = typeof value; + + if ( type === 'string' ) return `"${ value }"`; + if ( type === 'number' || type === 'boolean' || type === 'bigint' ) return String( value ); + + if ( Array.isArray( value ) ) { + + const values = value.slice( 0, 4 ).map( getDebugValue ).join( ', ' ); + return `[${ values }${ value.length > 4 ? ', ...' : '' }]`; + + } + + if ( value.isTexture === true ) return `${ value.type }#${ value.id } v${ value.version }`; + if ( value.isColor === true ) return `Color(${ value.r }, ${ value.g }, ${ value.b })`; + if ( value.isVector2 === true || value.isVector3 === true || value.isVector4 === true ) return `${ value.constructor.name }(${ value.toArray().join( ', ' ) })`; + if ( value.isMatrix3 === true || value.isMatrix4 === true ) return `${ value.constructor.name }(...)`; + if ( value.uuid !== undefined ) return `${ value.type || 'Object' }(${ value.uuid })`; + + return String( value ); + +} + +function getShadowMapTypeName( type ) { + + if ( type === BasicShadowMap ) return 'BasicShadowMap'; + if ( type === PCFShadowMap ) return 'PCFShadowMap'; + if ( type === PCFSoftShadowMap ) return 'PCFSoftShadowMap'; + if ( type === VSMShadowMap ) return 'VSMShadowMap'; + + return String( type ); + +} + +function getNodeValue( node ) { + + if ( node === null ) return 'none'; + + const name = node.name !== undefined && node.name !== '' ? ` "${ node.name }"` : ''; + return `${ node.type || node.constructor.name }#${ node.id }${ name }`; + +} + +function getLightValueKey( light ) { + + let valueKey = `${ light.type }:${ light.id }:${ light.castShadow === true ? 1 : 0 }`; + + if ( light.isSpotLight === true ) { + + valueKey += `:${ light.map !== null ? light.map.id : - 1 }`; + valueKey += `:${ light.colorNode ? light.colorNode.getCacheKey() : - 1 }`; + + } + + return valueKey; + +} + +function getLightValue( light ) { + + let value = `${ light.type }#${ light.id }`; + + if ( light.castShadow === true ) value += ' shadow'; + + if ( light.isSpotLight === true ) { + + if ( light.map !== null ) value += ` map:${ light.map.id }`; + if ( light.colorNode ) value += ' colorNode'; + + } + + return value; + +} + +function getLightsValue( lightsNode ) { + + if ( lightsNode === null ) return '0 lights'; + + const lights = lightsNode.getLights().slice().sort( ( a, b ) => a.id - b.id ); + const values = lights.map( getLightValue ); + const label = `${ lights.length } light${ lights.length === 1 ? '' : 's' }`; + + return values.length > 0 ? `${ label } [${ values.join( ', ' ) }]` : label; + +} + +function getLightsValueKey( lightsNode ) { + + if ( lightsNode === null ) return ''; + + return lightsNode.getLights().slice().sort( ( a, b ) => a.id - b.id ).map( getLightValueKey ).join( ',' ); + +} + +function getCacheKeyDifference( previousComponents, currentComponents ) { + + const currentMap = new Map(); + + for ( const component of currentComponents ) { + + currentMap.set( component.property, component ); + + } + + for ( const component of previousComponents ) { + + const current = currentMap.get( component.property ); + + if ( current === undefined ) { + + return { property: component.property, previousValue: component.value, value: 'undefined' }; + + } + + if ( component.valueKey !== current.valueKey ) { + + return { property: component.property, previousValue: component.value, value: current.value }; + + } + + } + + const previousMap = new Map(); + + for ( const component of previousComponents ) { + + previousMap.set( component.property, component ); + + } + + for ( const component of currentComponents ) { + + if ( previousMap.has( component.property ) === false ) { + + return { property: component.property, previousValue: 'undefined', value: component.value }; + + } + + } + + return null; + +} + +function getMaterialCacheKeyComponents( renderObject ) { + + const { object, material, renderer } = renderObject; + const cacheKeyComponents = []; + const customProgramCacheKey = material.customProgramCacheKey(); + + cacheKeyComponents.push( { + property: 'material.customProgramCacheKey', + valueKey: customProgramCacheKey, + value: getDebugValue( customProgramCacheKey ) + } ); + + for ( const property of getKeys( material ) ) { + + if ( /^(is[A-Z]|_)|^(visible|version|uuid|name|opacity|userData)$/.test( property ) ) continue; + + const value = material[ property ]; + let valueKey; + + if ( value !== null ) { + + const type = typeof value; + + if ( type === 'number' ) { + + valueKey = value !== 0 ? '1' : '0'; + + } else if ( type === 'object' ) { + + valueKey = '{'; + + if ( value.isTexture ) { + + valueKey += value.mapping; + + if ( renderer.backend.isWebGPUBackend === true ) { + + valueKey += value.magFilter; + valueKey += value.minFilter; + valueKey += value.wrapS; + valueKey += value.wrapT; + valueKey += value.wrapR; + + } + + } + + valueKey += '}'; + + } else { + + valueKey = String( value ); + + } + + } else { + + valueKey = String( value ); + + } + + cacheKeyComponents.push( { + property: `material.${ property }`, + valueKey, + value: getDebugValue( value ) + } ); + + } + + cacheKeyComponents.push( { + property: 'clippingContext.cacheKey', + valueKey: renderObject.clippingContextCacheKey, + value: getDebugValue( renderObject.clippingContextCacheKey ) + } ); + + if ( object.geometry ) { + + const geometryCacheKey = renderObject.getGeometryCacheKey(); + + cacheKeyComponents.push( { + property: 'geometry.cacheKey', + valueKey: geometryCacheKey, + value: getDebugValue( geometryCacheKey ) + } ); + + } + + if ( object.skeleton ) { + + cacheKeyComponents.push( { + property: 'object.skeleton.bones.length', + valueKey: String( object.skeleton.bones.length ), + value: String( object.skeleton.bones.length ) + } ); + + } + + if ( object.isBatchedMesh ) { + + cacheKeyComponents.push( { + property: 'object._matricesTexture', + valueKey: object._matricesTexture.uuid, + value: getDebugValue( object._matricesTexture ) + } ); + + if ( object._colorsTexture !== null ) { + + cacheKeyComponents.push( { + property: 'object._colorsTexture', + valueKey: object._colorsTexture.uuid, + value: getDebugValue( object._colorsTexture ) + } ); + + } + + } + + if ( object.isInstancedMesh || object.count > 1 || Array.isArray( object.morphTargetInfluences ) ) { + + cacheKeyComponents.push( { + property: 'object.uuid', + valueKey: object.uuid, + value: object.uuid + } ); + + } + + cacheKeyComponents.push( { + property: 'renderContext.id', + valueKey: String( renderObject.context.id ), + value: String( renderObject.context.id ) + } ); + + cacheKeyComponents.push( { + property: 'object.receiveShadow', + valueKey: String( object.receiveShadow ), + value: String( object.receiveShadow ) + } ); + + return cacheKeyComponents; + +} + +function getDynamicCacheKeyComponents( renderObject ) { + + const cacheKeyComponents = [ { + property: 'scene.lights', + valueKey: getLightsValueKey( renderObject.lightsNode ), + value: getLightsValue( renderObject.lightsNode ) + }, { + property: 'object.receiveShadow', + valueKey: String( renderObject.object.receiveShadow ), + value: String( renderObject.object.receiveShadow ) + }, { + property: 'renderer.contextNode', + valueKey: `${ renderObject.renderer.contextNode.id },${ renderObject.renderer.contextNode.version }`, + value: `id:${ renderObject.renderer.contextNode.id }, version:${ renderObject.renderer.contextNode.version }` + } ]; + + if ( renderObject.material.isShadowPassMaterial !== true ) { + + const environmentNode = renderObject._nodes.getEnvironmentNode( renderObject.scene ); + const fogNode = renderObject._nodes.getFogNode( renderObject.scene ); + const outputRenderTarget = renderObject.renderer.getOutputRenderTarget(); + + cacheKeyComponents.push( { + property: 'scene.environmentNode', + valueKey: environmentNode ? String( environmentNode.getCacheKey() ) : 'null', + value: getNodeValue( environmentNode ) + }, { + property: 'scene.fogNode', + valueKey: fogNode ? String( fogNode.getCacheKey() ) : 'null', + value: getNodeValue( fogNode ) + }, { + property: 'renderTarget.multiview', + valueKey: outputRenderTarget && outputRenderTarget.multiview ? 'true' : 'false', + value: outputRenderTarget && outputRenderTarget.multiview ? 'true' : 'false' + }, { + property: 'renderer.shadowMap.enabled', + valueKey: renderObject.renderer.shadowMap.enabled ? 'true' : 'false', + value: renderObject.renderer.shadowMap.enabled ? 'true' : 'false' + }, { + property: 'renderer.shadowMap.type', + valueKey: String( renderObject.renderer.shadowMap.type ), + value: getShadowMapTypeName( renderObject.renderer.shadowMap.type ) + } ); + + } + + if ( renderObject.camera.isArrayCamera ) { + + cacheKeyComponents.push( { + property: 'camera.cameras.length', + valueKey: String( renderObject.camera.cameras.length ), + value: String( renderObject.camera.cameras.length ) + } ); + + } + + return cacheKeyComponents; + +} + +/** + * Renderer component for node material invalidation debugging. + * + * @private + */ +class NodeMaterialDebug { + + /** + * Constructs a new node material debug component. + * + * @param {Renderer} renderer - The renderer. + */ + constructor( renderer ) { + + /** + * The renderer. + * + * @type {Renderer} + */ + this.renderer = renderer; + + /** + * Weak map with cache-key snapshots per render object. + * + * @type {WeakMap} + */ + this.cache = new WeakMap(); + + } + + /** + * Whether node material invalidation debugging is enabled. + * + * @type {boolean} + * @readonly + */ + get enabled() { + + return this.renderer.debug.traceNodeMaterialInvalidation === true || typeof this.renderer.debug.onNodeMaterialInvalidation === 'function'; + + } + + /** + * Updates the cached debug data for the given render object. + * + * @param {RenderObject} renderObject - The render object. + */ + update( renderObject ) { + + if ( this.enabled === false ) return; + + this.cache.set( renderObject, { + material: getMaterialCacheKeyComponents( renderObject ), + dynamic: getDynamicCacheKeyComponents( renderObject ) + } ); + + } + + /** + * Reports cache invalidation for the given render object. + * + * @param {RenderObject} renderObject - The render object. + */ + report( renderObject ) { + + if ( this.enabled === false ) return; + + const previousData = this.cache.get( renderObject ); + + if ( previousData === undefined ) return; + + const materialCacheDifference = getCacheKeyDifference( previousData.material, getMaterialCacheKeyComponents( renderObject ) ); + + if ( materialCacheDifference !== null ) { + + this._dispatch( { + stage: 'material-cache', + property: materialCacheDifference.property, + previousValue: materialCacheDifference.previousValue, + value: materialCacheDifference.value, + rebuild: true, + needsRefresh: true, + material: renderObject.material, + renderObject + } ); + + return; + + } + + const dynamicCacheDifference = getCacheKeyDifference( previousData.dynamic, getDynamicCacheKeyComponents( renderObject ) ); + + if ( dynamicCacheDifference !== null ) { + + this._dispatch( { + stage: 'dynamic-cache', + property: dynamicCacheDifference.property, + previousValue: dynamicCacheDifference.previousValue, + value: dynamicCacheDifference.value, + rebuild: true, + needsRefresh: true, + material: renderObject.material, + renderObject + } ); + + } + + } + + _dispatch( data ) { + + const callback = this.renderer.debug.onNodeMaterialInvalidation; + const materialLabel = data.material.name !== '' ? data.material.name : data.material.type; + const event = { ...data, materialLabel }; + + if ( typeof callback === 'function' ) { + + callback( event ); + return; + + } + + if ( this.renderer.debug.traceNodeMaterialInvalidation !== true ) return; + + const property = event.property !== undefined ? ` via ${ event.property }` : ''; + const values = event.previousValue !== undefined && event.value !== undefined ? ` (${ event.previousValue } -> ${ event.value })` : ''; + + warn( `Renderer: NodeMaterial needs rebuild for "${ materialLabel }"${ property }${ values }.` ); + + } + +} + +export default NodeMaterialDebug; diff --git a/src/renderers/common/RenderObjects.js b/src/renderers/common/RenderObjects.js index 676cc2bd416942..8f338977543c6d 100644 --- a/src/renderers/common/RenderObjects.js +++ b/src/renderers/common/RenderObjects.js @@ -90,6 +90,7 @@ class RenderObjects { get( object, material, scene, camera, lightsNode, renderContext, clippingContext, passId ) { const chainMap = this.getChainMap( passId ); + const nodeMaterialDebug = this.renderer._getNodeMaterialDebug(); // set chain keys @@ -128,6 +129,8 @@ class RenderObjects { if ( renderObject.initialCacheKey !== renderObject.getCacheKey() ) { + if ( nodeMaterialDebug !== null ) nodeMaterialDebug.report( renderObject ); + renderObject.dispose(); renderObject = this.get( object, material, scene, camera, lightsNode, renderContext, clippingContext, passId ); @@ -151,6 +154,8 @@ class RenderObjects { // + if ( nodeMaterialDebug !== null ) nodeMaterialDebug.update( renderObject ); + return renderObject; } diff --git a/src/renderers/common/Renderer.js b/src/renderers/common/Renderer.js index a8967b5afd005e..30f11329ac260b 100644 --- a/src/renderers/common/Renderer.js +++ b/src/renderers/common/Renderer.js @@ -10,6 +10,7 @@ import RenderContexts from './RenderContexts.js'; import Textures from './Textures.js'; import Background from './Background.js'; import NodeManager from './nodes/NodeManager.js'; +import NodeMaterialDebug from './NodeMaterialDebug.js'; import Color4 from './Color4.js'; import ClippingContext from './ClippingContext.js'; import QuadMesh from './QuadMesh.js'; @@ -703,6 +704,8 @@ class Renderer { * @typedef {Object} DebugConfig * @property {boolean} checkShaderErrors - Whether shader errors should be checked or not. * @property {?Function} onShaderError - A callback function that is executed when a shader error happens. Only supported with WebGL 2 right now. + * @property {boolean} traceNodeMaterialInvalidation - Whether node material invalidations that trigger render object cache misses should be reported. + * @property {?Function} onNodeMaterialInvalidation - A callback function that receives node material invalidation data. Assigning a custom function disables default reporting. * @property {Function} getShaderAsync - Allows the get the raw shader code for the given scene, camera and 3D object. */ @@ -711,9 +714,13 @@ class Renderer { * * @type {DebugConfig} */ + this._nodeMaterialDebug = null; + this.debug = { checkShaderErrors: true, onShaderError: null, + traceNodeMaterialInvalidation: false, + onNodeMaterialInvalidation: null, getShaderAsync: async ( scene, camera, object ) => { await this.compileAsync( scene, camera ); @@ -734,6 +741,37 @@ class Renderer { } + /** + * Returns the node material debug helper when the feature is enabled. + * + * @private + * @return {?NodeMaterialDebug} The debug helper. + */ + _getNodeMaterialDebug() { + + const debug = this.debug; + let nodeMaterialDebug = this._nodeMaterialDebug; + + if ( debug.traceNodeMaterialInvalidation === true || typeof debug.onNodeMaterialInvalidation === 'function' ) { + + if ( nodeMaterialDebug === null ) { + + nodeMaterialDebug = new NodeMaterialDebug( this ); + this._nodeMaterialDebug = nodeMaterialDebug; + + } + + } else if ( nodeMaterialDebug !== null ) { + + this._nodeMaterialDebug = null; + nodeMaterialDebug = null; + + } + + return nodeMaterialDebug; + + } + /** * Initializes the renderer so it is ready for usage. * diff --git a/test/e2e/image.js b/test/e2e/image.js index cecdc5598e827d..56875ec08ea6ce 100644 --- a/test/e2e/image.js +++ b/test/e2e/image.js @@ -169,10 +169,22 @@ class Image { buffer = await fs.readFile( input ); - } else { + } else if ( Buffer.isBuffer( input ) ) { buffer = input; + } else if ( ArrayBuffer.isView( input ) ) { + + buffer = Buffer.from( input.buffer, input.byteOffset, input.byteLength ); + + } else if ( input instanceof ArrayBuffer ) { + + buffer = Buffer.from( input ); + + } else { + + buffer = Buffer.from( input ); + } // Check if PNG (starts with PNG signature) diff --git a/test/unit/src/renderers/RenderObjects.tests.js b/test/unit/src/renderers/RenderObjects.tests.js new file mode 100644 index 00000000000000..c2b97e7876ad94 --- /dev/null +++ b/test/unit/src/renderers/RenderObjects.tests.js @@ -0,0 +1,190 @@ +import RenderObjects from '../../../../src/renderers/common/RenderObjects.js'; +import NodeMaterialDebug from '../../../../src/renderers/common/NodeMaterialDebug.js'; +import { BufferGeometry } from '../../../../src/core/BufferGeometry.js'; +import { MeshBasicMaterial } from '../../../../src/materials/MeshBasicMaterial.js'; +import { PerspectiveCamera } from '../../../../src/cameras/PerspectiveCamera.js'; +import { Mesh } from '../../../../src/objects/Mesh.js'; +import { Scene } from '../../../../src/scenes/Scene.js'; + +function createRenderer( events ) { + + const renderer = { + backend: { isWebGPUBackend: false }, + contextNode: { id: 1, version: 0 }, + shadowMap: { enabled: false, type: 1 }, + debug: { + traceNodeMaterialInvalidation: false, + onNodeMaterialInvalidation: events ? ( event ) => events.push( event ) : null + }, + getOutputRenderTarget() { + + return null; + + } + }; + + renderer._nodeMaterialDebug = events ? new NodeMaterialDebug( renderer ) : null; + renderer._getNodeMaterialDebug = () => renderer._nodeMaterialDebug; + + return renderer; + +} + +function createNodes() { + + return { + getCacheKey: () => 0, + getEnvironmentNode: () => null, + getFogNode: () => null, + delete: () => {} + }; + +} + +function createRenderObjects( events, nodes = createNodes() ) { + + return new RenderObjects( + createRenderer( events ), + nodes, + {}, + { delete: () => {} }, + { deleteForRender: () => {} }, + {} + ); + +} + +function createState() { + + const material = new MeshBasicMaterial(); + const object = new Mesh( new BufferGeometry(), material ); + const scene = new Scene(); + const camera = new PerspectiveCamera(); + const lights = []; + const lightsNode = { + getLights() { + + return lights; + + }, + getCacheKey() { + + return lights.map( ( light ) => `${ light.type }:${ light.id }:${ light.castShadow === true ? 1 : 0 }` ).join( '|' ); + + } + }; + + return { + object, + material, + scene, + camera, + lights, + lightsNode, + renderContext: { id: 1 }, + clippingContext: { cacheKey: '' } + }; + +} + +export default QUnit.module( 'Renderers', () => { + + QUnit.module( 'RenderObjects', () => { + + QUnit.test( 'reports the cache contributor that invalidates a node material', ( assert ) => { + + const events = []; + const renderObjects = createRenderObjects( events ); + const { object, material, scene, camera, lightsNode, renderContext, clippingContext } = createState(); + + material.name = 'DebugMaterial'; + + const initialRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + material.transparent = true; + material.needsUpdate = true; + + const refreshedRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + assert.notStrictEqual( refreshedRenderObject, initialRenderObject, 'The render object is recreated after the cache miss.' ); + assert.strictEqual( events.length, 1, 'One invalidation event was reported.' ); + assert.strictEqual( events[ 0 ].material, material, 'The event exposes the invalidated material.' ); + assert.strictEqual( events[ 0 ].materialLabel, 'DebugMaterial', 'The material label prefers material.name.' ); + assert.strictEqual( events[ 0 ].property, 'material.transparent', 'The changed cache contributor is reported.' ); + assert.strictEqual( events[ 0 ].previousValue, 'false', 'The previous contributor value is reported.' ); + assert.strictEqual( events[ 0 ].value, 'true', 'The next contributor value is reported.' ); + assert.strictEqual( events[ 0 ].rebuild, true, 'The event identifies the render object rebuild.' ); + assert.strictEqual( events[ 0 ].needsRefresh, true, 'The event identifies the forced refresh.' ); + + } ); + + QUnit.test( 'reports dynamic cache invalidations with readable light changes', ( assert ) => { + + let nodeCacheKey = 1; + + const nodes = { + getCacheKey: () => nodeCacheKey, + getEnvironmentNode: () => null, + getFogNode: () => null, + delete: () => {} + }; + + const events = []; + const renderObjects = createRenderObjects( events, nodes ); + const { object, material, scene, camera, lights, lightsNode, renderContext, clippingContext } = createState(); + + lights.push( { type: 'HemisphereLight', id: 1, castShadow: false } ); + lights.push( { type: 'DirectionalLight', id: 2, castShadow: true } ); + + const initialRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + lights.push( { type: 'PointLight', id: 3, castShadow: false } ); + nodeCacheKey = 2; + + const refreshedRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + assert.notStrictEqual( refreshedRenderObject, initialRenderObject, 'The render object is recreated after the dynamic cache changes.' ); + assert.strictEqual( events.length, 1, 'One invalidation event was reported.' ); + assert.strictEqual( events[ 0 ].property, 'scene.lights', 'The report points to the light setup instead of an opaque hash.' ); + assert.strictEqual( events[ 0 ].previousValue, '2 lights [HemisphereLight#1, DirectionalLight#2 shadow]', 'The previous light setup is human readable.' ); + assert.strictEqual( events[ 0 ].value, '3 lights [HemisphereLight#1, DirectionalLight#2 shadow, PointLight#3]', 'The next light setup is human readable.' ); + + } ); + + QUnit.test( 'ignores material changes that do not affect the render object cache', ( assert ) => { + + const events = []; + const renderObjects = createRenderObjects( events ); + const { object, material, scene, camera, lightsNode, renderContext, clippingContext } = createState(); + + const initialRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + material.opacity = 0.25; + material.needsUpdate = true; + + const refreshedRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + assert.strictEqual( refreshedRenderObject, initialRenderObject, 'The existing render object is reused when the cache key stays stable.' ); + assert.strictEqual( events.length, 0, 'No invalidation event is reported for cache-stable changes.' ); + + } ); + + QUnit.test( 'works without node material debug enabled', ( assert ) => { + + const renderObjects = createRenderObjects( null ); + const { object, material, scene, camera, lightsNode, renderContext, clippingContext } = createState(); + + const initialRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + material.transparent = true; + material.needsUpdate = true; + + const refreshedRenderObject = renderObjects.get( object, material, scene, camera, lightsNode, renderContext, clippingContext ); + + assert.notStrictEqual( refreshedRenderObject, initialRenderObject, 'The render object is recreated without requiring debug support.' ); + + } ); + + } ); + +} ); diff --git a/test/unit/three.source.unit.js b/test/unit/three.source.unit.js index 96c8e2ca0c3602..815b626cfc3aa6 100644 --- a/test/unit/three.source.unit.js +++ b/test/unit/three.source.unit.js @@ -235,6 +235,7 @@ import './src/renderers/WebGLArrayRenderTarget.tests.js'; import './src/renderers/WebGLCubeRenderTarget.tests.js'; import './src/renderers/WebGLRenderer.tests.js'; import './src/renderers/WebGLRenderTarget.tests.js'; +import './src/renderers/RenderObjects.tests.js'; //src/renderers/shaders import './src/renderers/shaders/ShaderChunk.tests.js'; From 14f2a3889237aba138184b8e3491228ef4a2a84a Mon Sep 17 00:00:00 2001 From: RenaudRohlinger Date: Thu, 2 Apr 2026 11:03:50 +0900 Subject: [PATCH 02/19] Fix image buffer normalization branch --- test/e2e/image.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/e2e/image.js b/test/e2e/image.js index 56875ec08ea6ce..916d9b0e9e1424 100644 --- a/test/e2e/image.js +++ b/test/e2e/image.js @@ -177,10 +177,6 @@ class Image { buffer = Buffer.from( input.buffer, input.byteOffset, input.byteLength ); - } else if ( input instanceof ArrayBuffer ) { - - buffer = Buffer.from( input ); - } else { buffer = Buffer.from( input ); From 44296dc8da7b5e63620c27e25c96622c6b488429 Mon Sep 17 00:00:00 2001 From: RenaudRohlinger Date: Thu, 2 Apr 2026 13:21:21 +0900 Subject: [PATCH 03/19] Tests: ignore webgpu materials debug rebuild screenshot --- test/e2e/puppeteer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/puppeteer.js b/test/e2e/puppeteer.js index 6445cf1a66c1d4..2191b7927d2655 100644 --- a/test/e2e/puppeteer.js +++ b/test/e2e/puppeteer.js @@ -27,6 +27,7 @@ const exceptionList = [ 'webgl_worker_offscreencanvas', 'webgpu_backdrop_water', 'webgpu_lightprobe_cubecamera', + 'webgpu_materials_debug_rebuild', 'webgpu_portal', 'webgpu_postprocessing_ao', 'webgpu_postprocessing_dof', From 254e6e61e420ef258ea949db45c220145de3498f Mon Sep 17 00:00:00 2001 From: RenaudRohlinger Date: Sat, 4 Apr 2026 10:51:09 +0900 Subject: [PATCH 04/19] save node comp --- src/renderers/common/NodeMaterialDebug.js | 485 +++++++++++++++++++++- 1 file changed, 466 insertions(+), 19 deletions(-) diff --git a/src/renderers/common/NodeMaterialDebug.js b/src/renderers/common/NodeMaterialDebug.js index 6ca3224269b015..3e10701c60ba6b 100644 --- a/src/renderers/common/NodeMaterialDebug.js +++ b/src/renderers/common/NodeMaterialDebug.js @@ -1,5 +1,6 @@ import { BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap } from '../../constants.js'; import { warn } from '../../utils.js'; +import ChainMap from './ChainMap.js'; function getKeys( obj ) { @@ -82,18 +83,181 @@ function getNodeValue( node ) { } -function getLightValueKey( light ) { +function getPath( basePath, property, index = undefined ) { - let valueKey = `${ light.type }:${ light.id }:${ light.castShadow === true ? 1 : 0 }`; + let path = `${ basePath }.${ property }`; - if ( light.isSpotLight === true ) { + if ( index !== undefined ) { + + path += Number.isInteger( index ) ? `[${ index }]` : `.${ index }`; + + } + + return path; + +} + +function getNodeCustomCacheStateComponents( node, path ) { + + const cacheKeyComponents = []; + + if ( node.type === 'ToneMappingNode' && typeof node.getToneMapping === 'function' ) { + + const toneMapping = node.getToneMapping(); + + cacheKeyComponents.push( { + property: `${ path }.toneMapping`, + valueKey: String( toneMapping ), + value: String( toneMapping ) + } ); + + } else if ( node.isPropertyNode === true ) { + + cacheKeyComponents.push( { + property: `${ path }.name`, + valueKey: String( node.name ), + value: getDebugValue( node.name ) + }, { + property: `${ path }.varying`, + valueKey: String( node.varying ), + value: String( node.varying ) + } ); + + } else if ( typeof node.getLights === 'function' ) { + + cacheKeyComponents.push( { + property: `${ path }.lights`, + valueKey: getLightsNodeValueKey( node ), + value: getLightsNodeValue( node ) + } ); + + } + + return cacheKeyComponents; + +} + +function getNodeComponent( node, path, withSnapshot = false ) { + + const component = { + node, + property: path, + valueKey: node.getCacheKey(), + value: getNodeValue( node ), + nodeId: node.id, + nodeType: node.type || node.constructor.name, + customCacheKey: node.customCacheKey(), + customCacheState: getNodeCustomCacheStateComponents( node, path ) + }; + + if ( withSnapshot === true ) { + + component.snapshot = getNodeSnapshot( node, path ); + + } + + return component; + +} + +function getComponentByProperty( components, property ) { + + for ( const component of components ) { + + if ( component.property === property ) return component; + + } + + return null; + +} + +function getNodeSnapshot( node, path, ignores = new Set() ) { + + const component = getNodeComponent( node, path ); + + const nextIgnores = new Set( ignores ); + nextIgnores.add( node ); + + const children = []; + + for ( const { property, index, childNode } of node._getChildren( new Set( ignores ) ) ) { + + const childPath = getPath( path, property, index ); - valueKey += `:${ light.map !== null ? light.map.id : - 1 }`; - valueKey += `:${ light.colorNode ? light.colorNode.getCacheKey() : - 1 }`; + children.push( { + property: childPath, + valueKey: childNode.getCacheKey(), + value: getNodeValue( childNode ), + snapshot: getNodeSnapshot( childNode, childPath, nextIgnores ) + } ); + + } + + return { + path, + value: component.value, + valueKey: component.valueKey, + nodeId: component.nodeId, + nodeType: component.nodeType, + customCacheKey: component.customCacheKey, + customCacheState: component.customCacheState, + children + }; + +} + +function getMaterialNodeComponents( material, withSnapshots = false ) { + + if ( material.isNodeMaterial !== true || typeof material._getNodeChildren !== 'function' ) return []; + + const cacheKeyComponents = []; + + for ( const { property, childNode } of material._getNodeChildren() ) { + + const path = `material.${ property }`; + + cacheKeyComponents.push( getNodeComponent( childNode, path, withSnapshots ) ); + + } + + return cacheKeyComponents; + +} + +function getTraceMaterialNodeComponents( previousComponents, currentComponents ) { + + const previousMap = new Map(); + + for ( const component of previousComponents ) { + + previousMap.set( component.property, component ); + + } + + for ( const component of currentComponents ) { + + const previousComponent = previousMap.get( component.property ); + + if ( + previousComponent !== undefined && + previousComponent.valueKey === component.valueKey && + previousComponent.nodeId === component.nodeId && + previousComponent.nodeType === component.nodeType && + previousComponent.snapshot !== undefined + ) { + + component.snapshot = previousComponent.snapshot; + + } else { + + component.snapshot = getNodeSnapshot( component.node, component.property ); + + } } - return valueKey; + return currentComponents; } @@ -126,15 +290,25 @@ function getLightsValue( lightsNode ) { } -function getLightsValueKey( lightsNode ) { +function getLightsNodeValue( lightsNode ) { - if ( lightsNode === null ) return ''; + if ( lightsNode === null ) return 'none'; - return lightsNode.getLights().slice().sort( ( a, b ) => a.id - b.id ).map( getLightValueKey ).join( ',' ); + const lightsNodeType = lightsNode.type || lightsNode.constructor.name; + + return `${ lightsNodeType } ${ getLightsValue( lightsNode ) }`; } -function getCacheKeyDifference( previousComponents, currentComponents ) { +function getLightsNodeValueKey( lightsNode ) { + + if ( lightsNode === null ) return 'null'; + + return String( lightsNode.getCacheKey( true ) ); + +} + +function getCacheKeyDifference( previousComponents, currentComponents, resolveDifference = null ) { const currentMap = new Map(); @@ -156,6 +330,14 @@ function getCacheKeyDifference( previousComponents, currentComponents ) { if ( component.valueKey !== current.valueKey ) { + if ( resolveDifference !== null ) { + + const resolvedDifference = resolveDifference( component, current ); + + if ( resolvedDifference !== null ) return resolvedDifference; + + } + return { property: component.property, previousValue: component.value, value: current.value }; } @@ -184,6 +366,86 @@ function getCacheKeyDifference( previousComponents, currentComponents ) { } +function getNodeSnapshotDifference( previousSnapshot, currentSnapshot ) { + + if ( previousSnapshot.valueKey === currentSnapshot.valueKey ) return null; + + if ( previousSnapshot.nodeId !== currentSnapshot.nodeId || previousSnapshot.nodeType !== currentSnapshot.nodeType ) { + + return { + property: previousSnapshot.path, + previousValue: previousSnapshot.value, + value: currentSnapshot.value + }; + + } + + const childDifference = getCacheKeyDifference( + previousSnapshot.children, + currentSnapshot.children, + ( previousComponent, currentComponent ) => getNodeSnapshotDifference( previousComponent.snapshot, currentComponent.snapshot ) + ); + + if ( childDifference !== null ) return childDifference; + + if ( previousSnapshot.customCacheKey !== currentSnapshot.customCacheKey ) { + + const customCacheStateDifference = getCacheKeyDifference( previousSnapshot.customCacheState, currentSnapshot.customCacheState ); + + if ( customCacheStateDifference !== null ) return customCacheStateDifference; + + return { + property: `${ previousSnapshot.path }.customCacheKey()`, + previousValue: String( previousSnapshot.customCacheKey ), + value: String( currentSnapshot.customCacheKey ) + }; + + } + + return { + property: previousSnapshot.path, + previousValue: `${ previousSnapshot.value } cacheKey:${ previousSnapshot.valueKey }`, + value: `${ currentSnapshot.value } cacheKey:${ currentSnapshot.valueKey }` + }; + +} + +function getNodeComponentDifference( previousComponent, currentComponent ) { + + if ( previousComponent.valueKey === currentComponent.valueKey ) return null; + + if ( previousComponent.nodeId !== currentComponent.nodeId || previousComponent.nodeType !== currentComponent.nodeType ) { + + return { + property: previousComponent.property, + previousValue: previousComponent.value, + value: currentComponent.value + }; + + } + + if ( previousComponent.customCacheKey !== currentComponent.customCacheKey ) { + + const customCacheStateDifference = getCacheKeyDifference( previousComponent.customCacheState, currentComponent.customCacheState ); + + if ( customCacheStateDifference !== null ) return customCacheStateDifference; + + return { + property: `${ previousComponent.property }.customCacheKey()`, + previousValue: String( previousComponent.customCacheKey ), + value: String( currentComponent.customCacheKey ) + }; + + } + + return { + property: previousComponent.property, + previousValue: `${ previousComponent.value } cacheKey:${ previousComponent.valueKey }`, + value: `${ currentComponent.value } cacheKey:${ currentComponent.valueKey }` + }; + +} + function getMaterialCacheKeyComponents( renderObject ) { const { object, material, renderer } = renderObject; @@ -330,9 +592,9 @@ function getMaterialCacheKeyComponents( renderObject ) { function getDynamicCacheKeyComponents( renderObject ) { const cacheKeyComponents = [ { - property: 'scene.lights', - valueKey: getLightsValueKey( renderObject.lightsNode ), - value: getLightsValue( renderObject.lightsNode ) + property: 'scene.lightsNode', + valueKey: getLightsNodeValueKey( renderObject.lightsNode ), + value: getLightsNodeValue( renderObject.lightsNode ) }, { property: 'object.receiveShadow', valueKey: String( renderObject.object.receiveShadow ), @@ -387,6 +649,20 @@ function getDynamicCacheKeyComponents( renderObject ) { } +function getNodesCacheKey( renderObject ) { + + let cacheKey = 0; + + if ( renderObject.material.isShadowPassMaterial !== true ) { + + cacheKey = renderObject._nodes.getCacheKey( renderObject.scene, renderObject.lightsNode ); + + } + + return String( cacheKey ); + +} + /** * Renderer component for node material invalidation debugging. * @@ -415,6 +691,13 @@ class NodeMaterialDebug { */ this.cache = new WeakMap(); + /** + * Cache snapshots handed off to replacement render objects after a rebuild. + * + * @type {ChainMap} + */ + this.handoffs = new ChainMap(); + } /** @@ -429,6 +712,18 @@ class NodeMaterialDebug { } + /** + * Whether detailed material-node tracing is enabled. + * + * @type {boolean} + * @readonly + */ + get traceEnabled() { + + return this.renderer.debug.traceNodeMaterialInvalidation === true; + + } + /** * Updates the cached debug data for the given render object. * @@ -438,10 +733,54 @@ class NodeMaterialDebug { if ( this.enabled === false ) return; - this.cache.set( renderObject, { + const previousData = this.cache.get( renderObject ); + const geometryId = renderObject.geometry !== null ? renderObject.geometry.id : null; + + if ( previousData === undefined ) { + + const handoffData = this.handoffs.get( renderObject.getChainArray() ); + + if ( handoffData !== undefined ) { + + this.handoffs.delete( renderObject.getChainArray() ); + this.cache.set( renderObject, handoffData ); + return; + + } + + } + + if ( previousData !== undefined ) { + + // Callback mode only needs the baseline captured when the render object became current. + if ( this.traceEnabled === false ) return; + + if ( + previousData.materialNodes !== undefined && + previousData.version === renderObject.version && + previousData.geometryId === geometryId && + previousData.clippingContextCacheKey === renderObject.clippingContextCacheKey + ) { + + return; + + } + + } + + const data = { + version: renderObject.version, + geometryId, + clippingContextCacheKey: renderObject.clippingContextCacheKey, material: getMaterialCacheKeyComponents( renderObject ), - dynamic: getDynamicCacheKeyComponents( renderObject ) - } ); + materialNodes: getMaterialNodeComponents( renderObject.material, this.traceEnabled ), + materialNodesTrace: this.traceEnabled, + dynamic: getDynamicCacheKeyComponents( renderObject ), + dynamicCacheKey: String( renderObject.getDynamicCacheKey() ), + nodesCacheKey: getNodesCacheKey( renderObject ) + }; + + this.cache.set( renderObject, data ); } @@ -458,15 +797,114 @@ class NodeMaterialDebug { if ( previousData === undefined ) return; - const materialCacheDifference = getCacheKeyDifference( previousData.material, getMaterialCacheKeyComponents( renderObject ) ); + let material = null; + let materialNodes = null; + let traceMaterialNodes = null; + let dynamic = null; + let dynamicCacheKey = null; + let nodesCacheKey = null; + const currentMaterial = () => material || ( material = getMaterialCacheKeyComponents( renderObject ) ); + const currentMaterialNodes = () => materialNodes || ( materialNodes = getMaterialNodeComponents( renderObject.material ) ); + const currentTraceMaterialNodes = () => { + + if ( traceMaterialNodes === null ) { + + traceMaterialNodes = getTraceMaterialNodeComponents( previousData.materialNodes, currentMaterialNodes() ); + + } + + return traceMaterialNodes; + + }; + + const currentDynamic = () => dynamic || ( dynamic = getDynamicCacheKeyComponents( renderObject ) ); + const currentDynamicCacheKey = () => dynamicCacheKey || ( dynamicCacheKey = String( renderObject.getDynamicCacheKey() ) ); + const currentNodesCacheKey = () => nodesCacheKey || ( nodesCacheKey = getNodesCacheKey( renderObject ) ); + const createCurrentData = () => { + + const data = { + version: renderObject.version, + geometryId: renderObject.geometry !== null ? renderObject.geometry.id : null, + clippingContextCacheKey: renderObject.clippingContextCacheKey, + material: currentMaterial(), + dynamic: currentDynamic(), + dynamicCacheKey: currentDynamicCacheKey(), + nodesCacheKey: currentNodesCacheKey() + }; + + if ( previousData.materialNodes !== undefined ) { + + data.materialNodes = previousData.materialNodesTrace === true ? currentTraceMaterialNodes() : currentMaterialNodes(); + data.materialNodesTrace = previousData.materialNodesTrace; + + } + + return data; + + }; + + const materialCacheDifference = getCacheKeyDifference( + previousData.material, + currentMaterial(), + ( previousComponent, currentComponent ) => { + + if ( previousData.materialNodes === undefined ) return null; + if ( previousComponent.property !== 'material.customProgramCacheKey' ) return null; + + let nodeDifference = null; + + nodeDifference = getCacheKeyDifference( previousData.materialNodes, currentMaterialNodes(), getNodeComponentDifference ); + + if ( nodeDifference === null ) return null; + + if ( previousData.materialNodesTrace === true ) { + + const previousNode = getComponentByProperty( previousData.materialNodes, nodeDifference.property ); + + if ( + previousNode !== null && + previousNode.snapshot !== undefined && + previousNode.property === nodeDifference.property + ) { + + const currentNode = getComponentByProperty( currentTraceMaterialNodes(), nodeDifference.property ); + + if ( currentNode !== null && currentNode.snapshot !== undefined ) { + + const snapshotDifference = getNodeSnapshotDifference( previousNode.snapshot, currentNode.snapshot ); + + if ( snapshotDifference !== null ) nodeDifference = snapshotDifference; + + } + + } + + } + + return { + property: nodeDifference.property, + previousValue: nodeDifference.previousValue, + value: nodeDifference.value, + sourceProperty: previousComponent.property, + sourcePreviousValue: previousComponent.value, + sourceValue: currentComponent.value + }; + + } + ); if ( materialCacheDifference !== null ) { + this.handoffs.set( renderObject.getChainArray(), createCurrentData() ); + this._dispatch( { stage: 'material-cache', property: materialCacheDifference.property, previousValue: materialCacheDifference.previousValue, value: materialCacheDifference.value, + sourceProperty: materialCacheDifference.sourceProperty, + sourcePreviousValue: materialCacheDifference.sourcePreviousValue, + sourceValue: materialCacheDifference.sourceValue, rebuild: true, needsRefresh: true, material: renderObject.material, @@ -481,6 +919,8 @@ class NodeMaterialDebug { if ( dynamicCacheDifference !== null ) { + this.handoffs.set( renderObject.getChainArray(), createCurrentData() ); + this._dispatch( { stage: 'dynamic-cache', property: dynamicCacheDifference.property, @@ -488,6 +928,10 @@ class NodeMaterialDebug { value: dynamicCacheDifference.value, rebuild: true, needsRefresh: true, + dynamicCacheKeyPrevious: previousData.dynamicCacheKey, + dynamicCacheKey: currentDynamicCacheKey(), + nodesCacheKeyPrevious: previousData.nodesCacheKey, + nodesCacheKey: currentNodesCacheKey(), material: renderObject.material, renderObject } ); @@ -500,7 +944,9 @@ class NodeMaterialDebug { const callback = this.renderer.debug.onNodeMaterialInvalidation; const materialLabel = data.material.name !== '' ? data.material.name : data.material.type; - const event = { ...data, materialLabel }; + const event = Object.assign( {}, data ); + + event.materialLabel = materialLabel; if ( typeof callback === 'function' ) { @@ -513,8 +959,9 @@ class NodeMaterialDebug { const property = event.property !== undefined ? ` via ${ event.property }` : ''; const values = event.previousValue !== undefined && event.value !== undefined ? ` (${ event.previousValue } -> ${ event.value })` : ''; + const source = event.sourceProperty !== undefined && event.sourceProperty !== event.property ? ` [${ event.sourceProperty }]` : ''; - warn( `Renderer: NodeMaterial needs rebuild for "${ materialLabel }"${ property }${ values }.` ); + warn( `Renderer: NodeMaterial needs rebuild for "${ materialLabel }"${ property }${ values }${ source }.` ); } From e5da40bd581ce550a44c8cd6de477eaacb1b7f97 Mon Sep 17 00:00:00 2001 From: RenaudRohlinger Date: Fri, 10 Apr 2026 11:19:05 +0900 Subject: [PATCH 05/19] Move material rebuild debug to inspector --- examples/jsm/inspector/Inspector.js | 65 +++- examples/jsm/inspector/NodeMaterialDebug.js | 94 ++++++ .../inspector/NodeMaterialDebugAnalyzer.js | 41 +-- examples/jsm/inspector/tabs/Console.js | 35 +++ examples/webgpu_materials_debug_rebuild.html | 295 ++---------------- src/renderers/common/RenderObjects.js | 3 - src/renderers/common/Renderer.js | 38 --- 7 files changed, 224 insertions(+), 347 deletions(-) create mode 100644 examples/jsm/inspector/NodeMaterialDebug.js rename src/renderers/common/NodeMaterialDebug.js => examples/jsm/inspector/NodeMaterialDebugAnalyzer.js (95%) diff --git a/examples/jsm/inspector/Inspector.js b/examples/jsm/inspector/Inspector.js index 97534de1851c34..74e169f6fbf5ae 100644 --- a/examples/jsm/inspector/Inspector.js +++ b/examples/jsm/inspector/Inspector.js @@ -9,12 +9,13 @@ import { Settings } from './tabs/Settings.js'; import { Viewer } from './tabs/Viewer.js'; import { Timeline } from './tabs/Timeline.js'; import { setText } from './ui/utils.js'; +import NodeMaterialDebug from './NodeMaterialDebug.js'; import { setConsoleFunction, REVISION } from 'three/webgpu'; class Inspector extends RendererInspector { - constructor() { + constructor( options = {} ) { super(); @@ -43,7 +44,12 @@ class Inspector extends RendererInspector { const timeline = new Timeline(); profiler.addTab( timeline ); - const consoleTab = new Console(); + const consoleTab = new Console( { traceNodeMaterialInvalidation: options.traceNodeMaterialInvalidation } ); + consoleTab.addEventListener( 'trace-node-material-invalidation', ( event ) => { + + this.setTraceNodeMaterialInvalidation( event.enabled ); + + } ); profiler.addTab( consoleTab ); const settings = new Settings(); @@ -68,6 +74,9 @@ class Inspector extends RendererInspector { this.settings = settings; this.once = {}; this.extensionsData = new WeakMap(); + this.nodeMaterialDebug = null; + this.onNodeMaterialInvalidation = null; + this.traceNodeMaterialInvalidation = options.traceNodeMaterialInvalidation === true; this.displayCycle = { text: { @@ -257,21 +266,32 @@ class Inspector extends RendererInspector { } + this.updateNodeMaterialDebug(); + } setRenderer( renderer ) { + if ( this.nodeMaterialDebug !== null ) { + + this.nodeMaterialDebug.dispose(); + this.nodeMaterialDebug = null; + + } + super.setRenderer( renderer ); if ( renderer !== null ) { setConsoleFunction( this.resolveConsole.bind( this ) ); + this.setTraceNodeMaterialInvalidation( this.traceNodeMaterialInvalidation ); if ( this.isAvailable ) { renderer.init().then( () => { renderer.backend.trackTimestamp = true; + this.updateNodeMaterialDebug(); if ( renderer.hasFeature( 'timestamp-query' ) !== true ) { @@ -291,6 +311,47 @@ class Inspector extends RendererInspector { } + updateNodeMaterialDebug() { + + if ( this.nodeMaterialDebug !== null ) this.nodeMaterialDebug.updateRenderer(); + + return this; + + } + + setTraceNodeMaterialInvalidation( enabled ) { + + this.traceNodeMaterialInvalidation = enabled === true; + + const renderer = this.getRenderer(); + + if ( this.traceNodeMaterialInvalidation === true && renderer !== null ) { + + if ( this.nodeMaterialDebug === null ) this.nodeMaterialDebug = new NodeMaterialDebug( renderer ); + this.nodeMaterialDebug.onNodeMaterialInvalidation = ( event ) => { + + const property = event.property !== undefined ? ` via ${ event.property }` : ''; + const values = event.previousValue !== undefined && event.value !== undefined ? ` (${ event.previousValue } -> ${ event.value })` : ''; + const source = event.sourceProperty !== undefined && event.sourceProperty !== event.property ? ` [${ event.sourceProperty }]` : ''; + + this.console.addMessage( 'warn', `Renderer: NodeMaterial needs rebuild for "${ event.materialLabel }"${ property }${ values }${ source }.` ); + + if ( typeof this.onNodeMaterialInvalidation === 'function' ) this.onNodeMaterialInvalidation( event ); + + }; + this.nodeMaterialDebug.updateRenderer(); + + } else if ( this.nodeMaterialDebug !== null ) { + + this.nodeMaterialDebug.dispose(); + this.nodeMaterialDebug = null; + + } + + return this; + + } + createParameters( name ) { if ( this.parameters.isVisible === false ) { diff --git a/examples/jsm/inspector/NodeMaterialDebug.js b/examples/jsm/inspector/NodeMaterialDebug.js new file mode 100644 index 00000000000000..e4314f70420bfc --- /dev/null +++ b/examples/jsm/inspector/NodeMaterialDebug.js @@ -0,0 +1,94 @@ +import NodeMaterialDebugAnalyzer from './NodeMaterialDebugAnalyzer.js'; + +class NodeMaterialDebug { + + constructor( renderer ) { + + this.renderer = renderer; + this.analyzer = new NodeMaterialDebugAnalyzer( renderer ); + this.onNodeMaterialInvalidation = null; + + this._objects = null; + this._originalGet = null; + + this.updateRenderer(); + + } + + updateRenderer() { + + const renderObjects = this.renderer._objects; + + if ( renderObjects === null || renderObjects === undefined || this._objects === renderObjects ) return this; + + this.dispose(); + + const originalGet = renderObjects.get; + const nodeMaterialDebug = this; + + renderObjects.get = function ( object, material, scene, camera, lightsNode, renderContext, clippingContext, passId ) { + + const chainMap = this.getChainMap( passId ); + const previousRenderObject = chainMap.get( [ object, material, renderContext, lightsNode ] ); + + if ( previousRenderObject !== undefined ) { + + previousRenderObject.camera = camera; + previousRenderObject.updateClipping( clippingContext ); + + if ( previousRenderObject.needsGeometryUpdate ) previousRenderObject.setGeometry( object.geometry ); + + if ( ( previousRenderObject.version !== material.version || previousRenderObject.needsUpdate ) && previousRenderObject.initialCacheKey !== previousRenderObject.getCacheKey() ) { + + nodeMaterialDebug.report( previousRenderObject ); + + } + + } + + const renderObject = originalGet.call( this, object, material, scene, camera, lightsNode, renderContext, clippingContext, passId ); + + nodeMaterialDebug.analyzer.update( renderObject ); + + return renderObject; + + }; + + this._objects = renderObjects; + this._originalGet = originalGet; + + return this; + + } + + report( renderObject ) { + + const previousCallback = this.analyzer.onNodeMaterialInvalidation; + + this.analyzer.onNodeMaterialInvalidation = ( event ) => { + + if ( typeof this.onNodeMaterialInvalidation === 'function' ) this.onNodeMaterialInvalidation( event ); + + }; + + this.analyzer.report( renderObject ); + this.analyzer.onNodeMaterialInvalidation = previousCallback; + + } + + dispose() { + + if ( this._objects !== null && this._originalGet !== null ) { + + this._objects.get = this._originalGet; + + } + + this._objects = null; + this._originalGet = null; + + } + +} + +export default NodeMaterialDebug; diff --git a/src/renderers/common/NodeMaterialDebug.js b/examples/jsm/inspector/NodeMaterialDebugAnalyzer.js similarity index 95% rename from src/renderers/common/NodeMaterialDebug.js rename to examples/jsm/inspector/NodeMaterialDebugAnalyzer.js index 3e10701c60ba6b..3c11107dfc5daf 100644 --- a/src/renderers/common/NodeMaterialDebug.js +++ b/examples/jsm/inspector/NodeMaterialDebugAnalyzer.js @@ -1,6 +1,6 @@ -import { BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap } from '../../constants.js'; -import { warn } from '../../utils.js'; -import ChainMap from './ChainMap.js'; +import { BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap } from 'three/webgpu'; +import { warn } from 'three/webgpu'; + function getKeys( obj ) { @@ -668,7 +668,7 @@ function getNodesCacheKey( renderObject ) { * * @private */ -class NodeMaterialDebug { +class NodeMaterialDebugAnalyzer { /** * Constructs a new node material debug component. @@ -691,12 +691,6 @@ class NodeMaterialDebug { */ this.cache = new WeakMap(); - /** - * Cache snapshots handed off to replacement render objects after a rebuild. - * - * @type {ChainMap} - */ - this.handoffs = new ChainMap(); } @@ -708,7 +702,7 @@ class NodeMaterialDebug { */ get enabled() { - return this.renderer.debug.traceNodeMaterialInvalidation === true || typeof this.renderer.debug.onNodeMaterialInvalidation === 'function'; + return true; } @@ -720,7 +714,7 @@ class NodeMaterialDebug { */ get traceEnabled() { - return this.renderer.debug.traceNodeMaterialInvalidation === true; + return true; } @@ -736,20 +730,6 @@ class NodeMaterialDebug { const previousData = this.cache.get( renderObject ); const geometryId = renderObject.geometry !== null ? renderObject.geometry.id : null; - if ( previousData === undefined ) { - - const handoffData = this.handoffs.get( renderObject.getChainArray() ); - - if ( handoffData !== undefined ) { - - this.handoffs.delete( renderObject.getChainArray() ); - this.cache.set( renderObject, handoffData ); - return; - - } - - } - if ( previousData !== undefined ) { // Callback mode only needs the baseline captured when the render object became current. @@ -895,8 +875,6 @@ class NodeMaterialDebug { if ( materialCacheDifference !== null ) { - this.handoffs.set( renderObject.getChainArray(), createCurrentData() ); - this._dispatch( { stage: 'material-cache', property: materialCacheDifference.property, @@ -919,8 +897,6 @@ class NodeMaterialDebug { if ( dynamicCacheDifference !== null ) { - this.handoffs.set( renderObject.getChainArray(), createCurrentData() ); - this._dispatch( { stage: 'dynamic-cache', property: dynamicCacheDifference.property, @@ -942,7 +918,7 @@ class NodeMaterialDebug { _dispatch( data ) { - const callback = this.renderer.debug.onNodeMaterialInvalidation; + const callback = this.onNodeMaterialInvalidation; const materialLabel = data.material.name !== '' ? data.material.name : data.material.type; const event = Object.assign( {}, data ); @@ -955,7 +931,6 @@ class NodeMaterialDebug { } - if ( this.renderer.debug.traceNodeMaterialInvalidation !== true ) return; const property = event.property !== undefined ? ` via ${ event.property }` : ''; const values = event.previousValue !== undefined && event.value !== undefined ? ` (${ event.previousValue } -> ${ event.value })` : ''; @@ -967,4 +942,4 @@ class NodeMaterialDebug { } -export default NodeMaterialDebug; +export default NodeMaterialDebugAnalyzer; diff --git a/examples/jsm/inspector/tabs/Console.js b/examples/jsm/inspector/tabs/Console.js index 2668c114bfc389..da546c3dce719d 100644 --- a/examples/jsm/inspector/tabs/Console.js +++ b/examples/jsm/inspector/tabs/Console.js @@ -8,6 +8,7 @@ class Console extends Tab { this.filters = { info: true, warn: true, error: true }; this.filterText = ''; + this.traceNodeMaterialInvalidation = false; this.buildHeader(); @@ -15,6 +16,13 @@ class Console extends Tab { this.logContainer.id = 'console-log'; this.content.appendChild( this.logContainer ); + if ( options.traceNodeMaterialInvalidation === true ) { + + this.traceNodeMaterialInvalidation = true; + this.nodeMaterialCheckbox.checked = true; + + } + } buildHeader() { @@ -42,6 +50,23 @@ class Console extends Tab { const buttonsGroup = document.createElement( 'div' ); buttonsGroup.className = 'console-buttons-group'; + const nodeMaterialLabel = document.createElement( 'label' ); + nodeMaterialLabel.className = 'custom-checkbox'; + nodeMaterialLabel.title = 'Trace node material rebuild reasons'; + + const nodeMaterialCheckbox = document.createElement( 'input' ); + nodeMaterialCheckbox.type = 'checkbox'; + nodeMaterialCheckbox.dataset.action = 'trace-node-material-invalidation'; + this.nodeMaterialCheckbox = nodeMaterialCheckbox; + + const nodeMaterialCheckmark = document.createElement( 'span' ); + nodeMaterialCheckmark.className = 'checkmark'; + + nodeMaterialLabel.appendChild( nodeMaterialCheckbox ); + nodeMaterialLabel.appendChild( nodeMaterialCheckmark ); + nodeMaterialLabel.append( 'Materials' ); + buttonsGroup.appendChild( nodeMaterialLabel ); + Object.keys( this.filters ).forEach( type => { const label = document.createElement( 'label' ); @@ -65,6 +90,16 @@ class Console extends Tab { buttonsGroup.addEventListener( 'change', ( e ) => { + const action = e.target.dataset.action; + + if ( action === 'trace-node-material-invalidation' ) { + + this.traceNodeMaterialInvalidation = e.target.checked; + this.dispatchEvent( { type: 'trace-node-material-invalidation', enabled: this.traceNodeMaterialInvalidation } ); + return; + + } + const type = e.target.dataset.type; if ( type in this.filters ) { diff --git a/examples/webgpu_materials_debug_rebuild.html b/examples/webgpu_materials_debug_rebuild.html index 027f52df5d71d7..35ee25677036f6 100644 --- a/examples/webgpu_materials_debug_rebuild.html +++ b/examples/webgpu_materials_debug_rebuild.html @@ -11,28 +11,23 @@ height: 100%; } - #panel { + #actions { position: absolute; left: 12px; bottom: 12px; - width: min( 720px, calc( 100% - 24px ) ); - padding: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: calc( 100% - 24px ); + padding: 10px; box-sizing: border-box; border: 1px solid rgba( 255, 255, 255, 0.12 ); border-radius: 6px; background: rgba( 0, 0, 0, 0.55 ); color: #e8e8e8; - font: 400 12px 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; pointer-events: auto; } - #actions { - display: grid; - grid-template-columns: repeat( 5, minmax( 0, 1fr ) ); - gap: 8px; - margin-bottom: 10px; - } - #actions button { padding: 7px 8px; border: 1px solid rgba( 255, 255, 255, 0.18 ); @@ -48,133 +43,6 @@ background: rgba( 255, 255, 255, 0.2 ); } - #stateBar, - #status, - #log { - font-family: monospace; - font-size: 11px; - line-height: 1.5; - } - - #stateBar { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 8px; - } - - .badge { - padding: 4px 8px; - border: 1px solid rgba( 255, 255, 255, 0.14 ); - border-radius: 999px; - background: rgba( 255, 255, 255, 0.06 ); - color: #dddddd; - } - - .badge.on { - background: rgba( 140, 220, 255, 0.18 ); - color: #ffffff; - } - - #recentEvent { - margin-bottom: 10px; - padding: 8px 10px; - border-left: 4px solid var( --recent-color, #8ce0ff ); - border-radius: 4px; - background: rgba( 255, 255, 255, 0.04 ); - font: 700 12px/1.4 monospace; - color: #eef4fb; - } - - #status { - margin-bottom: 10px; - color: #c7c7c7; - } - - #log { - max-height: 170px; - overflow-y: auto; - color: #ffffff; - } - - .log-entry { - padding: 6px 8px; - border-left: 4px solid var( --entry-color, #8ce0ff ); - border-radius: 4px; - background: rgba( 255, 255, 255, 0.04 ); - } - - .log-entry + .log-entry { - margin-top: 6px; - } - - .log-entry .time { - color: #95a3b8; - } - - .log-entry .reason { - color: var( --entry-color, #8ce0ff ); - } - - .log-entry .values { - color: #d5deea; - } - - #eventBanner { - position: absolute; - top: 104px; - right: 12px; - width: min( 620px, calc( 100% - 24px ) ); - padding: 14px 16px; - box-sizing: border-box; - border: 1px solid rgba( 255, 255, 255, 0.14 ); - border-radius: 10px; - background: rgba( 10, 12, 16, 0.78 ); - backdrop-filter: blur( 10px ); - color: #f1f5f9; - opacity: 0; - transform: translateY( - 12px ) scale( 0.98 ); - pointer-events: none; - } - - #eventBannerTitle { - font: 700 18px/1.15 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - letter-spacing: 0.02em; - text-transform: uppercase; - } - - #eventBannerReason { - margin-top: 8px; - font: 700 14px/1.3 monospace; - color: var( --event-color, #8ce0ff ); - } - - #eventBannerValues { - margin-top: 6px; - font: 400 12px/1.45 monospace; - color: #d4dbe3; - word-break: break-word; - } - - #hint { - margin-top: 8px; - font: 400 11px/1.5 monospace; - color: #96a3b3; - } - - @media ( max-width: 900px ) { - - #eventBanner { - top: auto; - right: 12px; - bottom: 226px; - } - - #actions { - grid-template-columns: repeat( 2, minmax( 0, 1fr ) ); - } - - } @@ -189,31 +57,18 @@ - Uses renderer.debug.onNodeMaterialInvalidation to report why a material makes the node material rebuild.
- Toggle clearcoat/transmission, or add/remove the accent light to trigger rebuilds from material or scene changes. + Open the Inspector Console tab to see material rebuild reasons.
+ The Materials trace option is enabled when this example starts.
-
-
- - - - - -
-
-
Waiting for the next rebuild event.
-
-
+
+ + + +
-
-
NodeMaterial needs rebuild
-
Waiting for a rebuild event
-
Use the buttons or start the auto demo.
-
This banner is driven directly by the debug callback.
-