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/jsm/inspector/Inspector.js b/examples/jsm/inspector/Inspector.js index 97534de1851c34..c2e490363a4170 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( { nodeMaterialDebugEnabled: options.nodeMaterialDebugEnabled } ); + consoleTab.addEventListener( 'node-material-debug', ( event ) => { + + this.setNodeMaterialDebug( 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.nodeMaterialDebugEnabled = consoleTab.nodeMaterialDebugEnabled === 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.setNodeMaterialDebug( this.nodeMaterialDebugEnabled ); if ( this.isAvailable ) { renderer.init().then( () => { renderer.backend.trackTimestamp = true; + this.updateNodeMaterialDebug(); if ( renderer.hasFeature( 'timestamp-query' ) !== true ) { @@ -291,6 +311,73 @@ class Inspector extends RendererInspector { } + beginNodeBuild( info ) { + + super.beginNodeBuild( info ); + + if ( this.nodeMaterialDebug !== null ) this.nodeMaterialDebug.updatePendingBuildInfo( info ); + + } + + finishNodeBuild( info ) { + + super.finishNodeBuild( info ); + + if ( this.nodeMaterialDebug !== null ) this.nodeMaterialDebug.flushPendingInvalidations( info ); + + } + + updateNodeMaterialDebug() { + + if ( this.nodeMaterialDebug !== null ) this.nodeMaterialDebug.updateRenderer(); + + return this; + + } + + setNodeMaterialDebug( enabled ) { + + this.nodeMaterialDebugEnabled = enabled === true; + + const renderer = this.getRenderer(); + + if ( this.nodeMaterialDebugEnabled === true && renderer !== null ) { + + if ( this.nodeMaterialDebug === null ) this.nodeMaterialDebug = new NodeMaterialDebug( renderer ); + this.nodeMaterialDebug.onNodeMaterialInvalidation = ( event ) => { + + const label = event.compute === true ? ( event.computeLabel || 'unknown compute node' ) : ( event.materialLabel || ( event.material ? event.material.name || event.material.type : 'unknown material' ) ); + const type = event.compute === true ? 'Compute node' : 'NodeMaterial'; + + 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 }]` : ''; + const reason = event.reason !== undefined ? ` because ${ event.reason }` : ''; + const buildInfo = event.buildInfo; + const timing = buildInfo && buildInfo.durationMs !== undefined ? ` in ${ buildInfo.durationMs.toFixed( 3 ) } ms` : ''; + + const level = event.rebuild === true || event.needsRefresh === true ? 'warn' : 'info'; + const action = event.action || ( level === 'warn' ? 'needs rebuild' : 'debug event' ); + + this.console.addMessage( level, `Renderer: ${ type } ${ action } for "${ label }"${ property }${ values }${ source }${ reason }${ timing }.` ); + + 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..3d0ad3b134c357 --- /dev/null +++ b/examples/jsm/inspector/NodeMaterialDebug.js @@ -0,0 +1,659 @@ +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._nodes = null; + this._pipelines = null; + this._originalNeedsRefresh = null; + this._originalGetForRender = null; + this._originalGetForCompute = null; + this._originalPipelinesGetForRender = null; + this._originalGetRenderPipeline = null; + this._computeBuilds = new WeakMap(); + this._computeInvalidations = new WeakMap(); + this._pendingInvalidations = new WeakMap(); + this._pipelineCreates = new WeakMap(); + + this.updateRenderer(); + + } + + updateRenderer() { + + const renderObjects = this.renderer._objects; + const nodes = this.renderer._nodes; + const pipelines = this.renderer._pipelines; + const isWebGPU = this.renderer.backend && this.renderer.backend.isWebGPUBackend === true; + + if ( renderObjects === null || renderObjects === undefined || nodes === null || nodes === undefined ) return this; + if ( this._objects === renderObjects && this._nodes === nodes && this._pipelines === pipelines ) return this; + + this.dispose(); + + this._patchRenderObjects( renderObjects ); + this._patchNodeManager( nodes ); + + if ( isWebGPU === true && pipelines !== null && pipelines !== undefined ) { + + this._patchWebGPUPipelines( pipelines ); + + } + + return this; + + } + + _patchRenderObjects( renderObjects ) { + + 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; + + } + + _patchNodeManager( nodes ) { + + const originalNeedsRefresh = nodes.needsRefresh; + const originalGetForRender = nodes.getForRender; + const originalGetForCompute = nodes.getForCompute; + const nodeMaterialDebug = this; + + nodes.needsRefresh = function ( renderObject ) { + + const previousCacheKey = renderObject.getCacheKey(); + const needsRefresh = originalNeedsRefresh.call( this, renderObject ); + const cacheKey = renderObject.getCacheKey(); + + if ( needsRefresh === true && previousCacheKey !== cacheKey ) { + + nodeMaterialDebug.reportRefresh( renderObject, previousCacheKey, cacheKey ); + + } + + return needsRefresh; + + }; + + nodes.getForRender = function ( renderObject, useAsync = false ) { + + const renderObjectData = this.get( renderObject ); + const previousNodeBuilderState = renderObjectData.nodeBuilderState; + const cacheKey = previousNodeBuilderState === undefined ? this.getForRenderCacheKey( renderObject ) : null; + const cachedNodeBuilderState = cacheKey !== null ? this.nodeBuilderCache.get( cacheKey ) : undefined; + const nodeBuilderState = originalGetForRender.call( this, renderObject, useAsync ); + + if ( previousNodeBuilderState === undefined && cachedNodeBuilderState !== undefined && nodeBuilderState === cachedNodeBuilderState ) { + + nodeMaterialDebug.reportNodeBuilderCacheHit( renderObject, cacheKey, nodeBuilderState ); + + } + + if ( previousNodeBuilderState === undefined && cachedNodeBuilderState !== undefined && nodeBuilderState && typeof nodeBuilderState.then === 'function' ) { + + nodeBuilderState.then( ( resolvedNodeBuilderState ) => { + + if ( resolvedNodeBuilderState === cachedNodeBuilderState ) nodeMaterialDebug.reportNodeBuilderCacheHit( renderObject, cacheKey, resolvedNodeBuilderState ); + + } ); + + } + + return nodeBuilderState; + + }; + + nodes.getForCompute = function ( computeNode ) { + + const computeData = this.get( computeNode ); + const previousNodeBuilderState = computeData.nodeBuilderState; + const invalidation = nodeMaterialDebug._computeInvalidations.get( computeNode ); + const startTime = previousNodeBuilderState === undefined ? performance.now() : 0; + const nodeBuilderState = originalGetForCompute.call( this, computeNode ); + + if ( previousNodeBuilderState === undefined && nodeBuilderState !== undefined ) { + + nodeMaterialDebug.reportComputeBuild( computeNode, nodeBuilderState, invalidation, performance.now() - startTime ); + + } + + return nodeBuilderState; + + }; + + this._nodes = nodes; + this._originalNeedsRefresh = originalNeedsRefresh; + this._originalGetForRender = originalGetForRender; + this._originalGetForCompute = originalGetForCompute; + + } + + _patchWebGPUPipelines( pipelines ) { + + const originalPipelinesGetForRender = pipelines.getForRender; + const originalGetRenderPipeline = pipelines._getRenderPipeline; + const nodeMaterialDebug = this; + + pipelines.getForRender = function ( renderObject, promises = null ) { + + const data = this.get( renderObject ); + const previousPipeline = data.pipeline; + const needsUpdate = this._needsRenderUpdate( renderObject ); + const previousCacheKey = previousPipeline ? previousPipeline.cacheKey : null; + const pipeline = originalPipelinesGetForRender.call( this, renderObject, promises ); + + if ( needsUpdate === true ) { + + nodeMaterialDebug.reportPipelineUpdate( renderObject, previousPipeline, pipeline, previousCacheKey ); + + } + + return pipeline; + + }; + + pipelines._getRenderPipeline = function ( renderObject, stageVertex, stageFragment, cacheKey, promises ) { + + const renderCacheKey = cacheKey || this._getRenderCacheKey( renderObject, stageVertex, stageFragment ); + const hadPipeline = this.caches.has( renderCacheKey ); + const pipeline = originalGetRenderPipeline.call( this, renderObject, stageVertex, stageFragment, cacheKey, promises ); + + if ( hadPipeline === false ) { + + nodeMaterialDebug.reportPipelineCreate( renderObject, pipeline, renderCacheKey ); + nodeMaterialDebug.markPipelineCreated( renderObject, pipeline ); + + } + + return pipeline; + + }; + + this._pipelines = pipelines; + this._originalPipelinesGetForRender = originalPipelinesGetForRender; + this._originalGetRenderPipeline = originalGetRenderPipeline; + + } + + reportNodeBuilderCacheHit( renderObject, cacheKey, nodeBuilderState ) { + + this.dispatch( { + stage: 'node-builder-cache', + action: 'reused node builder cache', + property: 'NodeManager.nodeBuilderCache', + reason: 'matching node builder state already exists', + previousValue: 'missing renderObject nodeBuilderState', + value: `cache key ${ cacheKey }`, + rebuild: false, + needsRefresh: false, + cacheKey, + nodeBuilderState, + material: renderObject.material, + renderObject + } ); + + } + + reportPipelineUpdate( renderObject, previousPipeline, pipeline, previousCacheKey ) { + + const createdPipelines = this._pipelineCreates.get( renderObject ); + + if ( createdPipelines !== undefined && createdPipelines.has( pipeline ) ) return; + + const pipelineDifference = getPipelineCacheKeyDifference( previousCacheKey, pipeline ? pipeline.cacheKey : null ); + + this.dispatch( { + stage: 'webgpu-pipeline-update', + action: 'updated WebGPU pipeline state', + property: pipelineDifference.property, + reason: previousPipeline === undefined ? 'missing render pipeline' : 'backend requested render pipeline update', + previousValue: pipelineDifference.previousValue, + value: pipelineDifference.value, + rebuild: false, + needsRefresh: false, + pipeline, + previousPipeline, + material: renderObject.material, + renderObject + } ); + + } + + markPipelineCreated( renderObject, pipeline ) { + + let createdPipelines = this._pipelineCreates.get( renderObject ); + + if ( createdPipelines === undefined ) { + + createdPipelines = new WeakSet(); + this._pipelineCreates.set( renderObject, createdPipelines ); + + } + + createdPipelines.add( pipeline ); + + } + + reportPipelineCreate( renderObject, pipeline, cacheKey ) { + + this.dispatch( { + stage: 'webgpu-pipeline-create', + action: 'created WebGPU render pipeline', + property: 'pipeline layout', + reason: 'first use of this shader and render-state combination', + previousValue: 'not cached', + value: getPipelineCacheKeySummary( cacheKey ), + rebuild: false, + needsRefresh: false, + pipeline, + material: renderObject.material, + renderObject + } ); + + } + + reportComputeBuild( computeNode, nodeBuilderState, invalidation, durationMs ) { + + const computeShader = nodeBuilderState.computeShader; + const computeLabel = computeNode.name || computeNode.type || 'ComputeNode'; + const previousBuild = invalidation !== undefined ? invalidation.previousBuild : undefined; + const reason = invalidation !== undefined ? invalidation.reason : 'initial compute build'; + const difference = previousBuild !== undefined ? getComputeBuildDifference( previousBuild, computeShader ) : null; + const event = { + stage: invalidation !== undefined ? 'compute-cache' : 'compute-build', + property: invalidation !== undefined ? invalidation.property : 'NodeManager.getForCompute', + reason, + rebuild: true, + needsRefresh: true, + compute: true, + buildInfo: { + durationMs, + result: nodeBuilderState + }, + computeNode, + computeLabel + }; + + if ( difference !== null ) { + + event.property = difference.property; + event.previousValue = difference.previousValue; + event.value = difference.value; + event.previousComputeShader = difference.previousComputeShader; + event.computeShader = difference.computeShader; + + } + + this._computeBuilds.set( computeNode, { + computeShader + } ); + + this.dispatch( event ); + + this._computeInvalidations.delete( computeNode ); + + } + + markComputeDisposed( computeNode, reason = 'computeNode.dispose() cleared cached compute state', property = 'computeNode.dispose' ) { + + this._computeInvalidations.set( computeNode, { + property, + reason, + previousBuild: this._computeBuilds.get( computeNode ) + } ); + + } + + reportRefresh( renderObject, previousCacheKey, cacheKey ) { + + const previousCallback = this.analyzer.onNodeMaterialInvalidation; + let dispatched = false; + + this.analyzer.onNodeMaterialInvalidation = ( event ) => { + + dispatched = true; + event.stage = event.stage || 'node-refresh'; + event.needsRefresh = true; + event.refreshCacheKeyPrevious = String( previousCacheKey ); + event.refreshCacheKey = String( cacheKey ); + this.dispatch( event ); + + }; + + this.analyzer.report( renderObject ); + this.analyzer.update( renderObject ); + this.analyzer.onNodeMaterialInvalidation = previousCallback; + + if ( dispatched === true ) return; + + this.dispatch( { + stage: 'node-refresh', + property: 'NodeMaterialObserver.needsRefresh', + previousValue: String( previousCacheKey ), + value: String( cacheKey ), + refreshCacheKeyPrevious: String( previousCacheKey ), + refreshCacheKey: String( cacheKey ), + currentDebugData: typeof this.analyzer.getCurrentDebugData === 'function' ? this.analyzer.getCurrentDebugData( renderObject ) : null, + rebuild: false, + needsRefresh: true, + material: renderObject.material, + renderObject + } ); + + } + + report( renderObject ) { + + const previousCallback = this.analyzer.onNodeMaterialInvalidation; + + this.analyzer.onNodeMaterialInvalidation = ( event ) => this.dispatch( event ); + + this.analyzer.report( renderObject ); + this.analyzer.update( renderObject ); + this.analyzer.onNodeMaterialInvalidation = previousCallback; + + } + + updatePendingBuildInfo( info ) { + + if ( info.material === undefined ) return; + + const pendingInvalidations = this._pendingInvalidations.get( info.material ); + + if ( pendingInvalidations !== undefined ) { + + for ( const event of pendingInvalidations ) { + + event.buildInfo = info; + + } + + } + + } + + flushPendingInvalidations( info ) { + + if ( info.material === undefined ) return; + + const material = info.material; + const pendingInvalidations = this._pendingInvalidations.get( material ); + + if ( pendingInvalidations !== undefined ) { + + this._pendingInvalidations.delete( material ); + + for ( const event of pendingInvalidations ) { + + event.buildInfo = info; + this.dispatch( event ); + + } + + } + + } + + dispatch( event ) { + + if ( event.rebuild === true && event.material && event.buildInfo === undefined ) { + + let pendingInvalidations = this._pendingInvalidations.get( event.material ); + + if ( pendingInvalidations === undefined ) { + + pendingInvalidations = []; + this._pendingInvalidations.set( event.material, pendingInvalidations ); + + } + + pendingInvalidations.push( event ); + + return; + + } + + if ( typeof this.onNodeMaterialInvalidation === 'function' ) this.onNodeMaterialInvalidation( event ); + + } + + dispose() { + + if ( this._objects !== null && this._originalGet !== null ) { + + this._objects.get = this._originalGet; + + } + + if ( this._nodes !== null && this._originalNeedsRefresh !== null ) { + + this._nodes.needsRefresh = this._originalNeedsRefresh; + + } + + if ( this._nodes !== null && this._originalGetForCompute !== null ) { + + this._nodes.getForCompute = this._originalGetForCompute; + + } + + if ( this._nodes !== null && this._originalGetForRender !== null ) { + + this._nodes.getForRender = this._originalGetForRender; + + } + + if ( this._pipelines !== null && this._originalPipelinesGetForRender !== null ) { + + this._pipelines.getForRender = this._originalPipelinesGetForRender; + + } + + if ( this._pipelines !== null && this._originalGetRenderPipeline !== null ) { + + this._pipelines._getRenderPipeline = this._originalGetRenderPipeline; + + } + + this._objects = null; + this._originalGet = null; + this._nodes = null; + this._pipelines = null; + this._originalNeedsRefresh = null; + this._originalGetForRender = null; + this._originalGetForCompute = null; + this._originalPipelinesGetForRender = null; + this._originalGetRenderPipeline = null; + this._computeBuilds = new WeakMap(); + this._computeInvalidations = new WeakMap(); + this._pendingInvalidations = new WeakMap(); + this._pipelineCreates = new WeakMap(); + + } + +} + +function getComputeBuildDifference( previousBuild, computeShader ) { + + if ( previousBuild.computeShader !== computeShader ) { + + return { + property: 'computeShader', + previousValue: 'previous compute shader', + value: 'changed compute shader', + previousComputeShader: previousBuild.computeShader, + computeShader + }; + + } + + return null; + +} + +const WEBGPU_PIPELINE_CACHE_KEY_PROPERTIES = [ + 'material.transparent', + 'material.blending', + 'material.premultipliedAlpha', + 'material.blendSrc', + 'material.blendDst', + 'material.blendEquation', + 'material.blendSrcAlpha', + 'material.blendDstAlpha', + 'material.blendEquationAlpha', + 'material.colorWrite', + 'material.depthWrite', + 'material.depthTest', + 'material.depthFunc', + 'material.stencilWrite', + 'material.stencilFunc', + 'material.stencilFail', + 'material.stencilZFail', + 'material.stencilZPass', + 'material.stencilFuncMask', + 'material.stencilWriteMask', + 'material.side', + 'object.frontFaceCW', + 'renderContext.sampleCount', + 'renderContext.colorSpace', + 'renderContext.colorFormat', + 'renderContext.depthStencilFormat', + 'geometry.primitiveTopology', + 'geometry.cacheKey', + 'clippingContext.cacheKey' +]; + +function getPipelineCacheKeyDifference( previousCacheKey, cacheKey ) { + + if ( previousCacheKey === null || previousCacheKey === undefined ) { + + return { + property: 'WebGPU render pipeline', + previousValue: 'none', + value: getPipelineCacheKeySummary( cacheKey ) + }; + + } + + if ( cacheKey === null || cacheKey === undefined ) { + + return { + property: 'WebGPU render pipeline', + previousValue: getPipelineCacheKeySummary( previousCacheKey ), + value: 'none' + }; + + } + + const previousValues = getBackendPipelineCacheKeyValues( previousCacheKey ); + const values = getBackendPipelineCacheKeyValues( cacheKey ); + const length = Math.max( previousValues.length, values.length ); + + for ( let i = 0; i < length; i ++ ) { + + if ( previousValues[ i ] !== values[ i ] ) { + + return { + property: WEBGPU_PIPELINE_CACHE_KEY_PROPERTIES[ i ] || `WebGPU render pipeline cache segment ${ i }`, + previousValue: previousValues[ i ] || 'undefined', + value: values[ i ] || 'undefined' + }; + + } + + } + + return { + property: 'WebGPU render pipeline', + previousValue: 'same cache key', + value: getPipelineCacheKeySummary( cacheKey ) + }; + +} + +function getPipelineCacheKeySummary( cacheKey ) { + + if ( cacheKey === null || cacheKey === undefined ) return 'none'; + + const values = getBackendPipelineCacheKeyValues( cacheKey ); + const colorSpace = values[ 23 ] || 'unknown color space'; + const colorFormat = values[ 24 ] || 'unknown color format'; + const depthStencilFormat = values[ 25 ] || 'unknown depth format'; + const primitiveTopology = values[ 26 ] || 'unknown topology'; + const geometryCacheKey = getGeometryPipelineCacheKeySummary( values.slice( 27, - 1 ) ); + const clippingContextCacheKey = values[ values.length - 1 ] || 'unknown clipping'; + + return `${ colorSpace}, ${ colorFormat}/${ depthStencilFormat}, ${ primitiveTopology}, geometry:${ geometryCacheKey }, clipping:${ clippingContextCacheKey }`; + +} + +function getGeometryPipelineCacheKeySummary( values ) { + + if ( values.length === 0 ) return 'unknown geometry'; + + const attributes = []; + let index = 0; + + while ( index < values.length ) { + + const name = values[ index ++ ]; + const size = values[ index ++ ]; + + if ( name === '' || name === undefined ) continue; + + attributes.push( size !== '' && size !== undefined ? `${ name }:${ size }` : name ); + + } + + return attributes.length > 0 ? attributes.join( ', ' ) : 'unknown geometry'; + +} + +function getBackendPipelineCacheKeyValues( cacheKey ) { + + const values = String( cacheKey ).split( ',' ); + + if ( values.length > WEBGPU_PIPELINE_CACHE_KEY_PROPERTIES.length ) return values.slice( 2 ); + + return values; + +} + +export default NodeMaterialDebug; diff --git a/examples/jsm/inspector/NodeMaterialDebugAnalyzer.js b/examples/jsm/inspector/NodeMaterialDebugAnalyzer.js new file mode 100644 index 00000000000000..400ce96f27637a --- /dev/null +++ b/examples/jsm/inspector/NodeMaterialDebugAnalyzer.js @@ -0,0 +1,959 @@ +import { BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap, warn } from 'three/webgpu'; + + +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 getPath( basePath, property, index = undefined ) { + + let path = `${ basePath }.${ property }`; + + 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 ( Object.prototype.hasOwnProperty.call( node, 'enabled' ) && typeof node.enabled === 'boolean' ) { + + cacheKeyComponents.push( { + property: `${ path }.enabled`, + valueKey: String( node.enabled ), + value: node.enabled ? 'enabled' : 'disabled' + } ); + + } 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 ); + + 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 currentComponents; + +} + +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 ) { + + 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 getLightsNodeValue( lightsNode ) { + + if ( lightsNode === null ) return 'none'; + + const lightsNodeType = lightsNode.type || lightsNode.constructor.name; + + return `${ lightsNodeType } ${ getLightsValue( lightsNode ) }`; + +} + +function getLightsNodeValueKey( lightsNode ) { + + if ( lightsNode === null ) return 'null'; + + return String( lightsNode.getCacheKey( true ) ); + +} + +function getCacheKeyDifference( previousComponents, currentComponents, resolveDifference = null ) { + + 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 ) { + + if ( resolveDifference !== null ) { + + const resolvedDifference = resolveDifference( component, current ); + + if ( resolvedDifference !== null ) return resolvedDifference; + + } + + 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 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; + 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.lightsNode', + valueKey: getLightsNodeValueKey( renderObject.lightsNode ), + value: getLightsNodeValue( 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; + +} + +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. + * + * @private + */ +class NodeMaterialDebugAnalyzer { + + /** + * 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 true; + + } + + /** + * Whether detailed material-node tracing is enabled. + * + * @type {boolean} + * @readonly + */ + get traceEnabled() { + + return true; + + } + + /** + * Updates the cached debug data for the given render object. + * + * @param {RenderObject} renderObject - The render object. + */ + update( renderObject ) { + + if ( this.enabled === false ) return; + + const previousData = this.cache.get( renderObject ); + const geometryId = renderObject.geometry !== null ? renderObject.geometry.id : null; + + 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 ), + materialNodes: getMaterialNodeComponents( renderObject.material, this.traceEnabled ), + materialNodesTrace: this.traceEnabled, + dynamic: getDynamicCacheKeyComponents( renderObject ), + dynamicCacheKey: String( renderObject.getDynamicCacheKey() ), + nodesCacheKey: getNodesCacheKey( renderObject ) + }; + + this.cache.set( renderObject, data ); + + } + + getCurrentDebugData( renderObject ) { + + const geometryId = renderObject.geometry !== null ? renderObject.geometry.id : null; + + return { + version: renderObject.version, + geometryId, + clippingContextCacheKey: renderObject.clippingContextCacheKey, + material: getMaterialCacheKeyComponents( renderObject ), + materialNodes: getMaterialNodeComponents( renderObject.material, this.traceEnabled ), + dynamic: getDynamicCacheKeyComponents( renderObject ), + dynamicCacheKey: String( renderObject.getDynamicCacheKey() ), + nodesCacheKey: getNodesCacheKey( 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; + + let material = null; + let materialNodes = null; + let traceMaterialNodes = 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 currentDynamicCacheKey = () => dynamicCacheKey || ( dynamicCacheKey = String( renderObject.getDynamicCacheKey() ) ); + const currentNodesCacheKey = () => nodesCacheKey || ( nodesCacheKey = getNodesCacheKey( renderObject ) ); + + 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._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, + 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, + dynamicCacheKeyPrevious: previousData.dynamicCacheKey, + dynamicCacheKey: currentDynamicCacheKey(), + nodesCacheKeyPrevious: previousData.nodesCacheKey, + nodesCacheKey: currentNodesCacheKey(), + material: renderObject.material, + renderObject + } ); + + return; + + } + + const previousCustomProgramCacheKey = previousData.material.find( ( component ) => component.property === 'material.customProgramCacheKey' ); + const currentCustomProgramCacheKey = currentMaterial().find( ( component ) => component.property === 'material.customProgramCacheKey' ); + + this._dispatch( { + stage: 'node-cache', + property: 'material.customProgramCacheKey', + previousValue: previousCustomProgramCacheKey ? previousCustomProgramCacheKey.value : previousData.materialCacheKey, + value: currentCustomProgramCacheKey ? currentCustomProgramCacheKey.value : renderObject.getMaterialCacheKey(), + rebuild: true, + needsRefresh: true, + material: renderObject.material, + renderObject + } ); + + } + + _dispatch( data ) { + + const callback = this.onNodeMaterialInvalidation; + const materialLabel = data.material.name !== '' ? data.material.name : data.material.type; + const event = Object.assign( {}, data ); + + event.materialLabel = materialLabel; + + if ( typeof callback === 'function' ) { + + callback( event ); + return; + + } + + + 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 }${ source }.` ); + + } + +} + +export default NodeMaterialDebugAnalyzer; diff --git a/examples/jsm/inspector/tabs/Console.js b/examples/jsm/inspector/tabs/Console.js index 2668c114bfc389..e9adeb24a30b9f 100644 --- a/examples/jsm/inspector/tabs/Console.js +++ b/examples/jsm/inspector/tabs/Console.js @@ -1,4 +1,23 @@ import { Tab } from '../ui/Tab.js'; +import { getItem, setItem } from '../Inspector.js'; + +function loadConsoleState() { + + const consoleSettings = getItem( 'console' ); + + return { + nodeMaterialDebugEnabled: consoleSettings.nodeMaterialDebugEnabled !== undefined ? consoleSettings.nodeMaterialDebugEnabled : false + }; + +} + +function setNodeMaterialDebug( enabled ) { + + const consoleSettings = getItem( 'console' ); + consoleSettings.nodeMaterialDebugEnabled = enabled === true; + setItem( 'console', consoleSettings ); + +} class Console extends Tab { @@ -6,8 +25,11 @@ class Console extends Tab { super( 'Console', options ); + const consoleState = loadConsoleState(); + this.filters = { info: true, warn: true, error: true }; this.filterText = ''; + this.nodeMaterialDebugEnabled = consoleState.nodeMaterialDebugEnabled === true || options.nodeMaterialDebugEnabled === true; this.buildHeader(); @@ -15,6 +37,12 @@ class Console extends Tab { this.logContainer.id = 'console-log'; this.content.appendChild( this.logContainer ); + if ( this.nodeMaterialDebugEnabled === true ) { + + this.nodeMaterialCheckbox.checked = true; + + } + } buildHeader() { @@ -39,9 +67,32 @@ class Console extends Tab { copyButton.innerHTML = ''; copyButton.addEventListener( 'click', () => this.copyAll( copyButton ) ); + const clearButton = document.createElement( 'button' ); + clearButton.className = 'console-copy-button'; + clearButton.title = 'Clear console'; + clearButton.innerHTML = ''; + clearButton.addEventListener( 'click', () => this.clear() ); + 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 = 'node-material-debug'; + 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 +116,17 @@ class Console extends Tab { buttonsGroup.addEventListener( 'change', ( e ) => { + const action = e.target.dataset.action; + + if ( action === 'node-material-debug' ) { + + this.nodeMaterialDebugEnabled = e.target.checked; + setNodeMaterialDebug( this.nodeMaterialDebugEnabled ); + this.dispatchEvent( { type: 'node-material-debug', enabled: this.nodeMaterialDebugEnabled } ); + return; + + } + const type = e.target.dataset.type; if ( type in this.filters ) { @@ -75,6 +137,7 @@ class Console extends Tab { } ); + buttonsGroup.appendChild( clearButton ); buttonsGroup.appendChild( copyButton ); header.appendChild( filterInput ); @@ -126,6 +189,12 @@ class Console extends Tab { } + clear() { + + this.logContainer.replaceChildren(); + + } + _getIcon( type, subType ) { let icon; @@ -184,11 +253,17 @@ class Console extends Tab { } - const parts = content.split( /(".*?"|'.*?'|`.*?`)/g ).map( p => p.trim() ).filter( Boolean ); + const parts = content.split( /(.*?<\/strong>|".*?"|'.*?'|`.*?`)/g ).map( p => p.trim() ).filter( Boolean ); parts.forEach( ( part, index ) => { - if ( /^("|'|`)/.test( part ) ) { + if ( part.startsWith( '' ) ) { + + const strong = document.createElement( 'strong' ); + strong.textContent = part.slice( 8, - 9 ); + fragment.appendChild( strong ); + + } else if ( /^("|'|`)/.test( part ) ) { const codeSpan = document.createElement( 'span' ); codeSpan.className = 'log-code'; diff --git a/examples/screenshots/webgpu_materials_debug_rebuild.jpg b/examples/screenshots/webgpu_materials_debug_rebuild.jpg new file mode 100644 index 00000000000000..82674e6b371682 Binary files /dev/null and b/examples/screenshots/webgpu_materials_debug_rebuild.jpg differ diff --git a/examples/webgpu_materials_debug_rebuild.html b/examples/webgpu_materials_debug_rebuild.html new file mode 100644 index 00000000000000..115bae9ad6847a --- /dev/null +++ b/examples/webgpu_materials_debug_rebuild.html @@ -0,0 +1,331 @@ + + + + three.js webgpu - node material rebuild debug + + + + + + + +
+ +
+ + +
+ three.jsNode Material Rebuild Debug +
+ + + Open the Inspector Console tab to see material rebuild reasons.
+ The Materials trace option is enabled when this example starts. +
+
+ +
+ + + + + + +
+ + + + + + + + diff --git a/src/renderers/common/InspectorBase.js b/src/renderers/common/InspectorBase.js index b1e61902c7df5a..e6ee159239d417 100644 --- a/src/renderers/common/InspectorBase.js +++ b/src/renderers/common/InspectorBase.js @@ -57,6 +57,20 @@ class InspectorBase extends EventDispatcher { } + /** + * Called before a missing node builder state is built. + * + * @param {Object} info - Information about the node build. + */ + beginNodeBuild( /*info*/ ) { } + + /** + * Called after a missing node builder state has been built. + * + * @param {Object} info - Information about the node build. + */ + finishNodeBuild( /*info*/ ) { } + /** * Returns the renderer associated with this inspector. * diff --git a/src/renderers/common/nodes/NodeManager.js b/src/renderers/common/nodes/NodeManager.js index 2caf90c8efea77..0b691e7d8def0d 100644 --- a/src/renderers/common/nodes/NodeManager.js +++ b/src/renderers/common/nodes/NodeManager.js @@ -14,6 +14,34 @@ import { error } from '../../../utils.js'; const _chainKeys = []; const _cacheKeyValues = []; +function getNodeBuildInfo( renderer, renderObject, useAsync, cacheKey ) { + + const object = renderObject.object || null; + const material = renderObject.material || null; + const geometry = renderObject.geometry || object && object.geometry || null; + + return { + renderer, + renderObject, + object, + material, + geometry, + lightsNode: renderObject.lightsNode || null, + scene: renderObject.scene || null, + camera: renderObject.camera || null, + useAsync, + cacheKey: cacheKey || null, + materialVersion: material ? material.version : null, + materialUuid: material ? material.uuid : null, + geometryUuid: geometry ? geometry.uuid : null, + objectUuid: object ? object.uuid : null, + reason: 'missing-node-builder-state', + durationMs: 0, + result: null + }; + +} + /** * This renderer module manages node-related objects and is the * primary interface between the renderer and the node system. @@ -197,6 +225,19 @@ class NodeManager extends DataMap { if ( nodeBuilderState === undefined ) { + const renderer = this.renderer; + const inspector = renderer.inspector; + const nodeBuildInfo = inspector !== null ? getNodeBuildInfo( renderer, renderObject, useAsync, cacheKey ) : null; + let startTime = 0; + + if ( inspector !== null ) { + + startTime = performance.now(); + + inspector.beginNodeBuild( nodeBuildInfo ); + + } + const buildNodeBuilder = async () => { let nodeBuilder = this._createNodeBuilder( renderObject, renderObject.material ); @@ -244,6 +285,15 @@ class NodeManager extends DataMap { nodeBuilderState.usedTimes ++; renderObjectData.nodeBuilderState = nodeBuilderState; + if ( inspector !== null ) { + + nodeBuildInfo.durationMs = performance.now() - startTime; + nodeBuildInfo.result = nodeBuilderState; + + inspector.finishNodeBuild( nodeBuildInfo ); + + } + return nodeBuilderState; } ); @@ -280,6 +330,15 @@ class NodeManager extends DataMap { nodeBuilderCache.set( cacheKey, nodeBuilderState ); + if ( inspector !== null ) { + + nodeBuildInfo.durationMs = performance.now() - startTime; + nodeBuildInfo.result = nodeBuilderState; + + inspector.finishNodeBuild( nodeBuildInfo ); + + } + } } diff --git a/test/e2e/image.js b/test/e2e/image.js index cecdc5598e827d..916d9b0e9e1424 100644 --- a/test/e2e/image.js +++ b/test/e2e/image.js @@ -169,10 +169,18 @@ 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 { + + buffer = Buffer.from( input ); + } // Check if PNG (starts with PNG signature) 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',