diff --git a/examples/jsm/inspector/Inspector.js b/examples/jsm/inspector/Inspector.js index bc00e78a0884ab..65d4c771820e6d 100644 --- a/examples/jsm/inspector/Inspector.js +++ b/examples/jsm/inspector/Inspector.js @@ -6,6 +6,7 @@ import { Console } from './tabs/Console.js'; import { Parameters } from './tabs/Parameters.js'; import { Settings } from './tabs/Settings.js'; import { Viewer } from './tabs/Viewer.js'; +import { Timeline } from './tabs/Timeline.js'; import { setText, splitPath, splitCamelCase } from './ui/utils.js'; import { QuadMesh, NodeMaterial, CanvasTarget, setConsoleFunction, REVISION, NoToneMapping } from 'three/webgpu'; @@ -57,6 +58,9 @@ class Inspector extends RendererInspector { const performance = new Performance(); profiler.addTab( performance ); + const timeline = new Timeline(); + profiler.addTab( timeline ); + const consoleTab = new Console(); profiler.addTab( consoleTab ); @@ -78,6 +82,7 @@ class Inspector extends RendererInspector { this.console = consoleTab; this.parameters = parameters; this.viewer = viewer; + this.timeline = timeline; this.once = {}; this.displayCycle = { @@ -214,6 +219,8 @@ class Inspector extends RendererInspector { } ); + this.timeline.setRenderer( renderer ); + } } diff --git a/examples/jsm/inspector/tabs/Performance.js b/examples/jsm/inspector/tabs/Performance.js index 9fd75088d4a137..a06ae566c336d0 100644 --- a/examples/jsm/inspector/tabs/Performance.js +++ b/examples/jsm/inspector/tabs/Performance.js @@ -25,8 +25,8 @@ class Performance extends Tab { graphContainer.className = 'graph-container'; const graph = new Graph(); - graph.addLine( 'fps', '--accent-color' ); - //graph.addLine( 'gpu', '--color-yellow' ); + graph.addLine( 'fps', 'var( --color-fps )' ); + //graph.addLine( 'gpu', 'var( --color-yellow )' ); graphContainer.append( graph.domElement ); // diff --git a/examples/jsm/inspector/tabs/Timeline.js b/examples/jsm/inspector/tabs/Timeline.js new file mode 100644 index 00000000000000..603ab4b45b8673 --- /dev/null +++ b/examples/jsm/inspector/tabs/Timeline.js @@ -0,0 +1,934 @@ +import { Tab } from '../ui/Tab.js'; +import { Graph } from '../ui/Graph.js'; + +const LIMIT = 500; + +class Timeline extends Tab { + + constructor( options = {} ) { + + super( 'Timeline', options ); + + this.isRecording = false; + this.frames = []; // Array of { id: number, calls: [] } + this.currentFrame = null; + this.isHierarchicalView = true; + + this.originalBackend = null; + this.originalMethods = new Map(); + this.renderer = null; + + this.graph = new Graph( LIMIT ); // Accommodate standard graph points + // Make lines in timeline graph + this.graph.addLine( 'fps', 'var( --color-fps )' ); + this.graph.addLine( 'calls', 'var( --color-call )' ); + + this.buildHeader(); + this.buildUI(); + + // Bind window resize to update graph bounds + window.addEventListener( 'resize', () => { + + if ( ! this.isRecording && this.frames.length > 0 ) { + + this.renderSlider(); + + } + + } ); + + } + + buildHeader() { + + const header = document.createElement( 'div' ); + header.className = 'console-header'; + + this.recordButton = document.createElement( 'button' ); + this.recordButton.className = 'console-copy-button'; // Reusing style + this.recordButton.title = 'Record'; + this.recordButton.innerHTML = ''; + this.recordButton.style.padding = '0 10px'; + this.recordButton.style.lineHeight = '24px'; // Match other buttons height + this.recordButton.style.display = 'flex'; + this.recordButton.style.alignItems = 'center'; + this.recordButton.addEventListener( 'click', () => this.toggleRecording() ); + + const clearButton = document.createElement( 'button' ); + clearButton.className = 'console-copy-button'; + clearButton.title = 'Clear'; + clearButton.innerHTML = ''; + clearButton.style.padding = '0 10px'; + clearButton.style.lineHeight = '24px'; + clearButton.style.display = 'flex'; + clearButton.style.alignItems = 'center'; + clearButton.addEventListener( 'click', () => this.clear() ); + + this.viewModeButton = document.createElement( 'button' ); + this.viewModeButton.className = 'console-copy-button'; + this.viewModeButton.title = 'Toggle View Mode'; + this.viewModeButton.textContent = 'Mode: Hierarchy'; + this.viewModeButton.style.padding = '0 10px'; + this.viewModeButton.style.lineHeight = '24px'; + this.viewModeButton.addEventListener( 'click', () => { + + this.isHierarchicalView = ! this.isHierarchicalView; + this.viewModeButton.textContent = this.isHierarchicalView ? 'Mode: Hierarchy' : 'Mode: Counts'; + + if ( this.selectedFrameIndex !== undefined && this.selectedFrameIndex !== - 1 ) { + + this.selectFrame( this.selectedFrameIndex ); + + } + + } ); + + this.recordRefreshButton = document.createElement( 'button' ); + this.recordRefreshButton.className = 'console-copy-button'; // Reusing style + this.recordRefreshButton.title = 'Refresh & Record'; + this.recordRefreshButton.innerHTML = ''; + this.recordRefreshButton.style.padding = '0 10px'; + this.recordRefreshButton.style.lineHeight = '24px'; + this.recordRefreshButton.style.display = 'flex'; + this.recordRefreshButton.style.alignItems = 'center'; + this.recordRefreshButton.addEventListener( 'click', () => { + + const storage = JSON.parse( localStorage.getItem( 'threejs-inspector' ) || '{}' ); + storage.timeline = storage.timeline || {}; + storage.timeline.recording = true; + localStorage.setItem( 'threejs-inspector', JSON.stringify( storage ) ); + + window.location.reload(); + + } ); + + const buttonsGroup = document.createElement( 'div' ); + buttonsGroup.className = 'console-buttons-group'; + buttonsGroup.appendChild( this.viewModeButton ); + buttonsGroup.appendChild( this.recordButton ); + buttonsGroup.appendChild( this.recordRefreshButton ); + buttonsGroup.appendChild( clearButton ); + + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.padding = '6px'; + header.style.borderBottom = '1px solid var(--border-color)'; + + const titleElement = document.createElement( 'div' ); + titleElement.textContent = 'Backend Calls Timeline'; + titleElement.style.color = 'var(--text-primary)'; + titleElement.style.alignSelf = 'center'; + titleElement.style.paddingLeft = '5px'; + + this.frameInfo = document.createElement( 'span' ); + this.frameInfo.style.marginLeft = '15px'; + this.frameInfo.style.fontFamily = 'monospace'; + this.frameInfo.style.color = 'var(--text-secondary)'; + this.frameInfo.style.fontSize = '12px'; + titleElement.appendChild( this.frameInfo ); + + header.appendChild( titleElement ); + header.appendChild( buttonsGroup ); + this.content.appendChild( header ); + + } + + buildUI() { + + const container = document.createElement( 'div' ); + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.height = 'calc(100% - 37px)'; // Subtract header height + container.style.width = '100%'; + + // Top Player/Graph Slider using Graph.js SVG + const graphContainer = document.createElement( 'div' ); + graphContainer.style.height = '60px'; + graphContainer.style.minHeight = '60px'; + graphContainer.style.borderBottom = '1px solid var(--border-color)'; + graphContainer.style.backgroundColor = 'var(--background-color)'; + + this.graphSlider = document.createElement( 'div' ); + this.graphSlider.style.height = '100%'; + this.graphSlider.style.margin = '0 10px'; + this.graphSlider.style.position = 'relative'; + this.graphSlider.style.cursor = 'crosshair'; + + graphContainer.appendChild( this.graphSlider ); + + // Setup SVG from Graph + this.graph.domElement.style.width = '100%'; + this.graph.domElement.style.height = '100%'; + this.graphSlider.appendChild( this.graph.domElement ); + + // Hover indicator + this.hoverIndicator = document.createElement( 'div' ); + this.hoverIndicator.style.position = 'absolute'; + this.hoverIndicator.style.top = '0'; + this.hoverIndicator.style.bottom = '0'; + this.hoverIndicator.style.width = '1px'; + this.hoverIndicator.style.backgroundColor = 'rgba(255, 255, 255, 0.3)'; + this.hoverIndicator.style.pointerEvents = 'none'; + this.hoverIndicator.style.display = 'none'; + this.hoverIndicator.style.zIndex = '9'; + this.hoverIndicator.style.transform = 'translateX(-50%)'; + this.graphSlider.appendChild( this.hoverIndicator ); + + // Playhead indicator (vertical line) + this.playhead = document.createElement( 'div' ); + this.playhead.style.position = 'absolute'; + this.playhead.style.top = '0'; + this.playhead.style.bottom = '0'; + this.playhead.style.width = '2px'; + this.playhead.style.backgroundColor = 'var(--color-red)'; + this.playhead.style.boxShadow = '0 0 4px rgba(255,0,0,0.5)'; + this.playhead.style.pointerEvents = 'none'; + this.playhead.style.display = 'none'; + this.playhead.style.zIndex = '10'; + this.playhead.style.transform = 'translateX(-50%)'; + this.graphSlider.appendChild( this.playhead ); + + // Playhead handle (triangle/pointer) + const playheadHandle = document.createElement( 'div' ); + playheadHandle.style.position = 'absolute'; + playheadHandle.style.top = '0'; + playheadHandle.style.left = '50%'; + playheadHandle.style.transform = 'translate(-50%, 0)'; + playheadHandle.style.width = '0'; + playheadHandle.style.height = '0'; + playheadHandle.style.borderLeft = '6px solid transparent'; + playheadHandle.style.borderRight = '6px solid transparent'; + playheadHandle.style.borderTop = '8px solid var(--color-red)'; + this.playhead.appendChild( playheadHandle ); + + // Make it focusable to accept keyboard events + this.graphSlider.tabIndex = 0; + this.graphSlider.style.outline = 'none'; + + // Mouse interactivity on the graph + let isDragging = false; + + const updatePlayheadFromEvent = ( e ) => { + + if ( this.frames.length === 0 ) return; + + const rect = this.graphSlider.getBoundingClientRect(); + let x = e.clientX - rect.left; + + // Clamp + x = Math.max( 0, Math.min( x, rect.width ) ); + + this.fixedScreenX = x; + + // The graph stretches its points across the width + // Find closest frame index based on exact point coordinates + const pointCount = this.graph.lines[ 'calls' ].points.length; + if ( pointCount === 0 ) return; + + const pointStep = rect.width / ( this.graph.maxPoints - 1 ); + const offset = rect.width - ( ( pointCount - 1 ) * pointStep ); + + let localFrameIndex = Math.round( ( x - offset ) / pointStep ); + localFrameIndex = Math.max( 0, Math.min( localFrameIndex, pointCount - 1 ) ); + + if ( localFrameIndex >= pointCount - 2 ) { + + this.isTrackingLatest = true; + + } else { + + this.isTrackingLatest = false; + + } + + let frameIndex = localFrameIndex; + + if ( this.frames.length > pointCount ) { + + frameIndex += this.frames.length - pointCount; + + } + + this.playhead.style.display = 'block'; + this.selectFrame( frameIndex ); + + }; + + this.graphSlider.addEventListener( 'mousedown', ( e ) => { + + isDragging = true; + this.isManualScrubbing = true; + this.graphSlider.focus(); + updatePlayheadFromEvent( e ); + + } ); + + this.graphSlider.addEventListener( 'mouseenter', () => { + + if ( this.frames.length > 0 && ! this.isRecording ) { + + this.hoverIndicator.style.display = 'block'; + + } + + } ); + + this.graphSlider.addEventListener( 'mouseleave', () => { + + this.hoverIndicator.style.display = 'none'; + + } ); + + this.graphSlider.addEventListener( 'mousemove', ( e ) => { + + if ( this.frames.length === 0 || this.isRecording ) return; + + const rect = this.graphSlider.getBoundingClientRect(); + let x = e.clientX - rect.left; + x = Math.max( 0, Math.min( x, rect.width ) ); + + const pointCount = this.graph.lines[ 'calls' ].points.length; + if ( pointCount > 0 ) { + + const pointStep = rect.width / ( this.graph.maxPoints - 1 ); + const offset = rect.width - ( ( pointCount - 1 ) * pointStep ); + + let localFrameIndex = Math.round( ( x - offset ) / pointStep ); + localFrameIndex = Math.max( 0, Math.min( localFrameIndex, pointCount - 1 ) ); + + let snappedX = offset + localFrameIndex * pointStep; + snappedX = Math.max( 1, Math.min( snappedX, rect.width - 1 ) ); + this.hoverIndicator.style.left = snappedX + 'px'; + + } else { + + const clampedX = Math.max( 1, Math.min( x, rect.width - 1 ) ); + this.hoverIndicator.style.left = clampedX + 'px'; + + } + + } ); + + this.graphSlider.addEventListener( 'keydown', ( e ) => { + + if ( this.frames.length === 0 || this.isRecording ) return; + + let newIndex = this.selectedFrameIndex; + + if ( e.key === 'ArrowLeft' ) { + + newIndex = Math.max( 0, this.selectedFrameIndex - 1 ); + e.preventDefault(); + + } else if ( e.key === 'ArrowRight' ) { + + newIndex = Math.min( this.frames.length - 1, this.selectedFrameIndex + 1 ); + e.preventDefault(); + + } + + if ( newIndex !== this.selectedFrameIndex ) { + + this.selectFrame( newIndex ); + + // Update playhead tracking state + const pointCount = this.graph.lines[ 'calls' ].points.length; + if ( pointCount > 0 ) { + + let localIndex = newIndex; + if ( this.frames.length > pointCount ) { + + localIndex = newIndex - ( this.frames.length - pointCount ); + + } + + if ( localIndex >= pointCount - 2 ) { + + this.isTrackingLatest = true; + + } else { + + this.isTrackingLatest = false; + + } + + const rect = this.graphSlider.getBoundingClientRect(); + const pointStep = rect.width / ( this.graph.maxPoints - 1 ); + const offset = rect.width - ( ( pointCount - 1 ) * pointStep ); + this.fixedScreenX = offset + localIndex * pointStep; + + } + + } + + } ); + + window.addEventListener( 'mousemove', ( e ) => { + + if ( isDragging ) { + + updatePlayheadFromEvent( e ); + + // Also move hover indicator to match playback + const rect = this.graphSlider.getBoundingClientRect(); + let x = e.clientX - rect.left; + x = Math.max( 0, Math.min( x, rect.width ) ); + + const pointCount = this.graph.lines[ 'calls' ].points.length; + if ( pointCount > 0 ) { + + const pointStep = rect.width / ( this.graph.maxPoints - 1 ); + const offset = rect.width - ( ( pointCount - 1 ) * pointStep ); + + let localFrameIndex = Math.round( ( x - offset ) / pointStep ); + localFrameIndex = Math.max( 0, Math.min( localFrameIndex, pointCount - 1 ) ); + + let snappedX = offset + localFrameIndex * pointStep; + snappedX = Math.max( 1, Math.min( snappedX, rect.width - 1 ) ); + this.hoverIndicator.style.left = snappedX + 'px'; + + } else { + + const clampedX = Math.max( 1, Math.min( x, rect.width - 1 ) ); + this.hoverIndicator.style.left = clampedX + 'px'; + + } + + } + + } ); + + window.addEventListener( 'mouseup', () => { + + isDragging = false; + this.isManualScrubbing = false; + + } ); + + container.appendChild( graphContainer ); + + // Bottom Main Area (Timeline Sequence) + const mainArea = document.createElement( 'div' ); + mainArea.style.flex = '1'; + mainArea.style.display = 'flex'; + mainArea.style.flexDirection = 'column'; + mainArea.style.overflow = 'hidden'; + + // Timeline Track + this.timelineTrack = document.createElement( 'div' ); + this.timelineTrack.style.flex = '1'; + this.timelineTrack.style.overflowY = 'auto'; + this.timelineTrack.style.margin = '10px'; + this.timelineTrack.style.backgroundColor = 'var(--background-color)'; + mainArea.appendChild( this.timelineTrack ); + + container.appendChild( mainArea ); + this.content.appendChild( container ); + + } + + setRenderer( renderer ) { + + this.renderer = renderer; + + const storage = JSON.parse( localStorage.getItem( 'threejs-inspector' ) || '{}' ); + + if ( storage.timeline && storage.timeline.recording ) { + + storage.timeline.recording = false; + localStorage.setItem( 'threejs-inspector', JSON.stringify( storage ) ); + + this.toggleRecording(); + + } + + } + + toggleRecording() { + + if ( ! this.renderer ) { + + console.warn( 'Timeline: No renderer defined.' ); + return; + + } + + this.isRecording = ! this.isRecording; + + if ( this.isRecording ) { + + this.recordButton.title = 'Stop'; + this.recordButton.innerHTML = ''; + this.recordButton.style.color = 'var(--color-red)'; + this.startRecording(); + + } else { + + this.recordButton.title = 'Record'; + this.recordButton.innerHTML = ''; + this.recordButton.style.color = ''; + this.stopRecording(); + this.renderSlider(); + + } + + } + + startRecording() { + + this.frames = []; + this.currentFrame = null; + this.selectedFrameIndex = - 1; + this.fixedScreenX = 0; + this.isTrackingLatest = true; + this.isManualScrubbing = false; + this.clear(); + this.frameInfo.textContent = 'Recording...'; + + const backend = this.renderer.backend; + const methods = Object.getOwnPropertyNames( Object.getPrototypeOf( backend ) ).filter( prop => prop !== 'constructor' ); + + for ( const prop of methods ) { + + const descriptor = Object.getOwnPropertyDescriptor( Object.getPrototypeOf( backend ), prop ); + + if ( descriptor && ( descriptor.get || descriptor.set ) ) continue; + + const originalFunc = backend[ prop ]; + + if ( typeof originalFunc === 'function' && typeof prop === 'string' ) { + + this.originalMethods.set( prop, originalFunc ); + + backend[ prop ] = ( ...args ) => { + + if ( prop.toLowerCase().includes( 'timestamp' ) || prop.startsWith( 'get' ) || prop.startsWith( 'set' ) || prop.startsWith( 'has' ) || prop.startsWith( '_' ) || prop.startsWith( 'needs' ) ) { + + return originalFunc.apply( backend, args ); + + } + + // Check for frame change + const frameNumber = this.renderer.info.frame; + + if ( ! this.currentFrame || this.currentFrame.id !== frameNumber ) { + + if ( this.currentFrame ) { + + this.currentFrame.fps = this.renderer.inspector ? this.renderer.inspector.fps : 0; + + if ( ! isFinite( this.currentFrame.fps ) ) { + + this.currentFrame.fps = 0; + + } + + this.graph.addPoint( 'calls', this.currentFrame.calls.length ); + this.graph.addPoint( 'fps', this.currentFrame.fps ); + this.graph.update(); + + } + + this.currentFrame = { id: frameNumber, calls: [], fps: 0 }; + this.frames.push( this.currentFrame ); + + if ( this.frames.length > LIMIT ) { + + this.frames.shift(); + + } + + // Sync playhead when new frames are added if user is actively watching a frame + if ( ! this.isManualScrubbing ) { + + if ( this.isTrackingLatest ) { + + const targetIndex = this.frames.length > 1 ? this.frames.length - 2 : 0; + this.selectFrame( targetIndex ); + + } else if ( this.selectedFrameIndex !== - 1 ) { + + const pointCount = this.graph.lines[ 'calls' ].points.length; + + if ( pointCount > 0 ) { + + const rect = this.graphSlider.getBoundingClientRect(); + const pointStep = rect.width / ( this.graph.maxPoints - 1 ); + const offset = rect.width - ( ( pointCount - 1 ) * pointStep ); + + let localFrameIndex = Math.round( ( this.fixedScreenX - offset ) / pointStep ); + localFrameIndex = Math.max( 0, Math.min( localFrameIndex, pointCount - 1 ) ); + + let newFrameIndex = localFrameIndex; + + if ( this.frames.length > pointCount ) { + + newFrameIndex += this.frames.length - pointCount; + + } + + this.selectFrame( newFrameIndex ); + + } + + } + + } + + } + + let methodLabel = prop; + + if ( prop === 'beginRender' ) { + + if ( this.renderer.inspector && this.renderer.inspector.currentRender ) { + + methodLabel += ' - ' + this.renderer.inspector.currentRender.name; + + } + + } else if ( prop === 'beginCompute' ) { + + if ( this.renderer.inspector && this.renderer.inspector.currentCompute ) { + + methodLabel += ' - ' + this.renderer.inspector.currentCompute.name; + + } + + } + + // Only record method name as requested, skipping detail arguments + this.currentFrame.calls.push( { method: methodLabel } ); + + return originalFunc.apply( backend, args ); + + }; + + } + + } + + } + + stopRecording() { + + if ( this.originalMethods.size > 0 ) { + + const backend = this.renderer.backend; + + for ( const [ prop, originalFunc ] of this.originalMethods.entries() ) { + + backend[ prop ] = originalFunc; + + } + + this.originalMethods.clear(); + + if ( this.currentFrame ) { + + this.currentFrame.fps = this.renderer.inspector ? this.renderer.inspector.fps : 0; + + } + + } + + } + + clear() { + + this.frames = []; + this.timelineTrack.innerHTML = ''; + this.playhead.style.display = 'none'; + this.frameInfo.textContent = ''; + this.graph.lines[ 'calls' ].points = []; + this.graph.lines[ 'fps' ].points = []; + this.graph.resetLimit(); + this.graph.update(); + + } + + renderSlider() { + + if ( this.frames.length === 0 ) { + + this.playhead.style.display = 'none'; + this.frameInfo.textContent = ''; + return; + + } + + // Reset graph safely to fit recorded frames exactly up to maxPoints + this.graph.lines[ 'calls' ].points = []; + this.graph.lines[ 'fps' ].points = []; + this.graph.resetLimit(); + + // If recorded frames exceed SVG Graph maxPoints, we sample/slice it + // (Graph.js inherently handles shifting for real-time, + // but statically we want to visualize as much up to max bounds) + let framesToRender = this.frames; + if ( framesToRender.length > this.graph.maxPoints ) { + + framesToRender = framesToRender.slice( - this.graph.maxPoints ); + this.frames = framesToRender; // Adjust our internal array to match what's visible + + } + + for ( let i = 0; i < framesToRender.length; i ++ ) { + + // Adding calls length to the Graph SVG to visualize workload geometry + this.graph.addPoint( 'calls', framesToRender[ i ].calls.length ); + this.graph.addPoint( 'fps', framesToRender[ i ].fps || 0 ); + + } + + this.graph.update(); + + this.playhead.style.display = 'block'; + + // Select the previously selected frame, or the last one if tracking, or 0 + let targetFrame = 0; + if ( this.selectedFrameIndex !== - 1 && this.selectedFrameIndex < this.frames.length ) { + + targetFrame = this.selectedFrameIndex; + + } else if ( this.frames.length > 0 ) { + + targetFrame = this.frames.length - 1; + + } + + this.selectFrame( targetFrame ); + + } + + selectFrame( index ) { + + if ( index < 0 || index >= this.frames.length ) return; + + this.selectedFrameIndex = index; + + const frame = this.frames[ index ]; + this.renderTimelineTrack( frame ); + + // Update UI texts + this.frameInfo.textContent = 'Frame: ' + frame.id + ' [' + frame.calls.length + ' calls] [' + ( frame.fps || 0 ).toFixed( 1 ) + ' FPS]'; + + // Update playhead position + const rect = this.graphSlider.getBoundingClientRect(); + const pointCount = this.graph.lines[ 'calls' ].points.length; + + if ( pointCount > 0 ) { + + // Calculate point width step + const pointStep = rect.width / ( this.graph.maxPoints - 1 ); + + let localIndex = index; + + if ( this.frames.length > pointCount ) { + + localIndex = index - ( this.frames.length - pointCount ); + + } + + // x offset calculation from SVG update logic + // The graph translates (slides) back if points length < maxPoints + // which means point 0 is at offset + const offset = rect.width - ( ( pointCount - 1 ) * pointStep ); + let xPos = offset + ( localIndex * pointStep ); + xPos = Math.max( 1, Math.min( xPos, rect.width - 1 ) ); + + this.playhead.style.left = xPos + 'px'; + this.playhead.style.display = 'block'; + + } + + } + + renderTimelineTrack( frame ) { + + this.timelineTrack.innerHTML = ''; + + if ( ! frame || frame.calls.length === 0 ) return; + + // Track collapsed states + if ( ! this.collapsedGroups ) { + + this.collapsedGroups = new Set(); + + } + + const frag = document.createDocumentFragment(); + + if ( this.isHierarchicalView ) { + + const groupedCalls = []; + let currentGroup = null; + + for ( let i = 0; i < frame.calls.length; i ++ ) { + + const call = frame.calls[ i ]; + const isStructural = call.method.startsWith( 'begin' ) || call.method.startsWith( 'finish' ); + + if ( currentGroup && currentGroup.method === call.method && ! isStructural ) { + + currentGroup.count ++; + + } else { + + currentGroup = { method: call.method, count: 1 }; + groupedCalls.push( currentGroup ); + + } + + } + + let currentIndent = 0; + const indentSize = 24; + + // Stack to keep track of parent elements and their collapsed state + const elementStack = [ { element: frag, isCollapsed: false, id: '' } ]; + + for ( let i = 0; i < groupedCalls.length; i ++ ) { + + const call = groupedCalls[ i ]; + + const block = document.createElement( 'div' ); + block.style.padding = '4px 8px'; + block.style.margin = '2px 0'; + block.style.marginLeft = ( currentIndent * indentSize ) + 'px'; + block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method ); + block.style.backgroundColor = 'rgba(255, 255, 255, 0.03)'; + block.style.fontFamily = 'monospace'; + block.style.fontSize = '12px'; + block.style.color = 'var(--text-primary)'; + block.style.whiteSpace = 'nowrap'; + block.style.overflow = 'hidden'; + block.style.textOverflow = 'ellipsis'; + block.style.display = 'flex'; + block.style.alignItems = 'center'; + + const currentParent = elementStack[ elementStack.length - 1 ]; + + // Only add to DOM if parent is not collapsed + if ( ! currentParent.isCollapsed ) { + + frag.appendChild( block ); + + } + + if ( call.method.startsWith( 'begin' ) ) { + + const groupId = currentParent.id + '/' + call.method + '-' + i; + const isCollapsed = this.collapsedGroups.has( groupId ); + + // Add toggle arrow + const arrow = document.createElement( 'span' ); + arrow.textContent = isCollapsed ? '[ + ]' : '[ - ]'; + arrow.style.fontSize = '10px'; + arrow.style.marginRight = '10px'; + arrow.style.cursor = 'pointer'; + arrow.style.width = '26px'; + arrow.style.display = 'inline-block'; + arrow.style.textAlign = 'center'; + block.appendChild( arrow ); + + block.style.cursor = 'pointer'; + + // Title + const title = document.createElement( 'span' ); + title.textContent = call.method + ( call.count > 1 ? ` ( ${call.count} )` : '' ); + block.appendChild( title ); + + block.addEventListener( 'click', ( e ) => { + + e.stopPropagation(); + if ( isCollapsed ) { + + this.collapsedGroups.delete( groupId ); + + } else { + + this.collapsedGroups.add( groupId ); + + } + + // Re-render to apply changes + this.renderTimelineTrack( this.frames[ this.selectedFrameIndex ] ); + + } ); + + currentIndent ++; + elementStack.push( { element: block, isCollapsed: currentParent.isCollapsed || isCollapsed, id: groupId } ); + + } else if ( call.method.startsWith( 'finish' ) ) { + + block.textContent = call.method + ( call.count > 1 ? ` ( ${call.count} )` : '' ); + + currentIndent = Math.max( 0, currentIndent - 1 ); + elementStack.pop(); + + } else { + + block.textContent = call.method + ( call.count > 1 ? ` ( ${call.count} )` : '' ); + + } + + } + + } else { + + const callCounts = {}; + + for ( let i = 0; i < frame.calls.length; i ++ ) { + + const method = frame.calls[ i ].method; + + if ( method.startsWith( 'finish' ) ) continue; + + callCounts[ method ] = ( callCounts[ method ] || 0 ) + 1; + + } + + const sortedCalls = Object.keys( callCounts ).map( method => ( { method, count: callCounts[ method ] } ) ); + sortedCalls.sort( ( a, b ) => b.count - a.count ); + + for ( let i = 0; i < sortedCalls.length; i ++ ) { + + const call = sortedCalls[ i ]; + + const block = document.createElement( 'div' ); + block.style.padding = '4px 8px'; + block.style.margin = '2px 0'; + block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method ); + block.style.backgroundColor = 'rgba(255, 255, 255, 0.03)'; + block.style.fontFamily = 'monospace'; + block.style.fontSize = '12px'; + block.style.color = 'var(--text-primary)'; + block.style.whiteSpace = 'nowrap'; + block.style.overflow = 'hidden'; + block.style.textOverflow = 'ellipsis'; + + block.textContent = call.method + ( call.count > 1 ? ` ( ${call.count} )` : '' ); + + frag.appendChild( block ); + + } + + } + + this.timelineTrack.appendChild( frag ); + + } + + getColorForMethod( method ) { + + if ( method.startsWith( 'begin' ) ) return 'var(--color-green)'; + if ( method.startsWith( 'finish' ) || method.startsWith( 'destroy' ) ) return 'var(--color-red)'; + if ( method.startsWith( 'draw' ) || method.startsWith( 'compute' ) || method.startsWith( 'create' ) || method.startsWith( 'generate' ) ) return 'var(--color-yellow)'; + return 'var(--text-secondary)'; + + } + +} + +export { Timeline }; diff --git a/examples/jsm/inspector/ui/Graph.js b/examples/jsm/inspector/ui/Graph.js index bab3d22b5d98f5..10271d5d76b27c 100644 --- a/examples/jsm/inspector/ui/Graph.js +++ b/examples/jsm/inspector/ui/Graph.js @@ -17,8 +17,8 @@ export class Graph { const path = document.createElementNS( 'http://www.w3.org/2000/svg', 'path' ); path.setAttribute( 'class', 'graph-path' ); - path.style.stroke = `var(${color})`; - path.style.fill = `var(${color})`; + path.style.stroke = color; + path.style.fill = color; this.domElement.appendChild( path ); this.lines[ id ] = { path, color, points: [] }; diff --git a/examples/jsm/inspector/ui/Profiler.js b/examples/jsm/inspector/ui/Profiler.js index bdcc80e47cb810..dae9349055fbb2 100644 --- a/examples/jsm/inspector/ui/Profiler.js +++ b/examples/jsm/inspector/ui/Profiler.js @@ -1440,6 +1440,8 @@ export class Profiler { } + this.saveLayout(); + } togglePanel() { @@ -1468,6 +1470,8 @@ export class Profiler { } ); + this.saveLayout(); + } togglePosition() { @@ -1565,12 +1569,15 @@ export class Profiler { saveLayout() { + if ( this.isLoadingLayout ) return; + const layout = { position: this.position, lastHeightBottom: this.lastHeightBottom, lastWidthRight: this.lastWidthRight, activeTabId: this.activeTabId, - detachedTabs: [] + detachedTabs: [], + isVisible: this.panel.classList.contains( 'visible' ) }; // Save detached windows state @@ -1614,6 +1621,8 @@ export class Profiler { loadLayout() { + this.isLoadingLayout = true; + try { const savedData = localStorage.getItem( 'threejs-inspector' ); @@ -1751,16 +1760,16 @@ export class Profiler { } - if ( layout.activeTabId ) { + if ( layout.isVisible ) { - const willBeDetached = layout.detachedTabs && - layout.detachedTabs.some( dt => dt.tabId === layout.activeTabId ); + this.panel.classList.add( 'visible' ); + this.toggleButton.classList.add( 'panel-open' ); - if ( willBeDetached ) { + } - this.setActiveTab( layout.activeTabId ); + if ( layout.activeTabId ) { - } + this.setActiveTab( layout.activeTabId ); } @@ -1785,6 +1794,10 @@ export class Profiler { console.warn( 'Failed to load profiler layout:', e ); + } finally { + + this.isLoadingLayout = false; + } } diff --git a/examples/jsm/inspector/ui/Style.js b/examples/jsm/inspector/ui/Style.js index a5a2d469858fc1..5243ec91c390ec 100644 --- a/examples/jsm/inspector/ui/Style.js +++ b/examples/jsm/inspector/ui/Style.js @@ -16,6 +16,8 @@ export class Style { --color-green: #4caf50; --color-yellow: #ffc107; --color-red: #f44336; + --color-fps: rgb(63, 81, 181); + --color-call: rgba(255, 185, 34, 1); --font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; --font-mono: 'Fira Code', 'Courier New', Courier, monospace; } @@ -775,7 +777,7 @@ export class Style { content: '⋮⋮'; position: absolute; left: 3px; - top: calc(50% + 1px); + top: calc(50% - .1rem); transform: translateY(-50%); color: var(--profiler-border); font-size: 18px; diff --git a/examples/jsm/loaders/usd/USDComposer.js b/examples/jsm/loaders/usd/USDComposer.js index 759c4ea0f1dc7a..239255747d8cbe 100644 --- a/examples/jsm/loaders/usd/USDComposer.js +++ b/examples/jsm/loaders/usd/USDComposer.js @@ -112,6 +112,17 @@ class USDComposer { // Bind skeletons to skinned meshes this._bindSkeletons(); + // Expose skeleton on the root group so that AnimationMixer's + // PropertyBinding.findNode resolves bone names before scene objects. + // Without this, Xform prims that share a name with a skeleton joint + // would be animated instead of the bone. + const skeletonPaths = Object.keys( this.skeletons ); + if ( skeletonPaths.length === 1 ) { + + group.skeleton = this.skeletons[ skeletonPaths[ 0 ] ].skeleton; + + } + // Build animations group.animations = this._buildAnimations(); @@ -1673,25 +1684,7 @@ class USDComposer { const skinIndices = new Uint16Array( numVertices * 4 ); const skinWeights = new Float32Array( numVertices * 4 ); - for ( let i = 0; i < numVertices; i ++ ) { - - for ( let j = 0; j < 4; j ++ ) { - - if ( j < elementSize ) { - - skinIndices[ i * 4 + j ] = skinIndexData[ i * elementSize + j ] || 0; - skinWeights[ i * 4 + j ] = skinWeightData[ i * elementSize + j ] || 0; - - } else { - - skinIndices[ i * 4 + j ] = 0; - skinWeights[ i * 4 + j ] = 0; - - } - - } - - } + this._selectTopWeights( skinIndexData, skinWeightData, elementSize, numVertices, skinIndices, skinWeights ); geometry.setAttribute( 'skinIndex', new BufferAttribute( skinIndices, 4 ) ); geometry.setAttribute( 'skinWeight', new BufferAttribute( skinWeights, 4 ) ); @@ -1867,8 +1860,8 @@ class USDComposer { const uvData = uvs ? new Float32Array( vertexCount * 2 ) : null; const uv1Data = uvs2 ? new Float32Array( vertexCount * 2 ) : null; const normalData = ( normals || vertexNormals ) ? new Float32Array( vertexCount * 3 ) : null; - const skinIndexData = jointIndices ? new Uint16Array( vertexCount * 4 ) : null; - const skinWeightData = jointWeights ? new Float32Array( vertexCount * 4 ) : null; + const skinSrcIndices = jointIndices ? new Uint16Array( vertexCount * elementSize ) : null; + const skinSrcWeights = jointWeights ? new Float32Array( vertexCount * elementSize ) : null; for ( let i = 0; i < sortedTriangles.length; i ++ ) { @@ -1943,21 +1936,12 @@ class USDComposer { } - if ( skinIndexData && skinWeightData && jointIndices && jointWeights ) { - - for ( let j = 0; j < 4; j ++ ) { + if ( skinSrcIndices && skinSrcWeights && jointIndices && jointWeights ) { - if ( j < elementSize ) { + for ( let j = 0; j < elementSize; j ++ ) { - skinIndexData[ newIdx * 4 + j ] = jointIndices[ pointIdx * elementSize + j ] || 0; - skinWeightData[ newIdx * 4 + j ] = jointWeights[ pointIdx * elementSize + j ] || 0; - - } else { - - skinIndexData[ newIdx * 4 + j ] = 0; - skinWeightData[ newIdx * 4 + j ] = 0; - - } + skinSrcIndices[ newIdx * elementSize + j ] = jointIndices[ pointIdx * elementSize + j ] || 0; + skinSrcWeights[ newIdx * elementSize + j ] = jointWeights[ pointIdx * elementSize + j ] || 0; } @@ -1983,19 +1967,115 @@ class USDComposer { geometry.setAttribute( 'normal', new BufferAttribute( normalData, 3 ) ); - if ( skinIndexData ) { + if ( skinSrcIndices && skinSrcWeights ) { + + const skinIndexData = new Uint16Array( vertexCount * 4 ); + const skinWeightData = new Float32Array( vertexCount * 4 ); + + this._selectTopWeights( skinSrcIndices, skinSrcWeights, elementSize, vertexCount, skinIndexData, skinWeightData ); geometry.setAttribute( 'skinIndex', new BufferAttribute( skinIndexData, 4 ) ); + geometry.setAttribute( 'skinWeight', new BufferAttribute( skinWeightData, 4 ) ); } - if ( skinWeightData ) { + return geometry; - geometry.setAttribute( 'skinWeight', new BufferAttribute( skinWeightData, 4 ) ); + } + + _selectTopWeights( srcIndices, srcWeights, elementSize, numVertices, dstIndices, dstWeights ) { + + if ( elementSize <= 4 ) { + + for ( let i = 0; i < numVertices; i ++ ) { + + for ( let j = 0; j < 4; j ++ ) { + + if ( j < elementSize ) { + + dstIndices[ i * 4 + j ] = srcIndices[ i * elementSize + j ] || 0; + dstWeights[ i * 4 + j ] = srcWeights[ i * elementSize + j ] || 0; + + } else { + + dstIndices[ i * 4 + j ] = 0; + dstWeights[ i * 4 + j ] = 0; + + } + + } + + } + + return; } - return geometry; + // When elementSize > 4, find the 4 largest weights per vertex + // using a partial selection sort (4 iterations of O(elementSize)). + const order = new Uint32Array( elementSize ); + + for ( let i = 0; i < numVertices; i ++ ) { + + const base = i * elementSize; + + for ( let j = 0; j < elementSize; j ++ ) order[ j ] = j; + + for ( let k = 0; k < 4; k ++ ) { + + let maxIdx = k; + let maxW = srcWeights[ base + order[ k ] ] || 0; + + for ( let j = k + 1; j < elementSize; j ++ ) { + + const w = srcWeights[ base + order[ j ] ] || 0; + + if ( w > maxW ) { + + maxW = w; + maxIdx = j; + + } + + } + + if ( maxIdx !== k ) { + + const tmp = order[ k ]; + order[ k ] = order[ maxIdx ]; + order[ maxIdx ] = tmp; + + } + + } + + let total = 0; + + for ( let j = 0; j < 4; j ++ ) { + + total += srcWeights[ base + order[ j ] ] || 0; + + } + + for ( let j = 0; j < 4; j ++ ) { + + const s = order[ j ]; + + if ( total > 0 ) { + + dstIndices[ i * 4 + j ] = srcIndices[ base + s ] || 0; + dstWeights[ i * 4 + j ] = ( srcWeights[ base + s ] || 0 ) / total; + + } else { + + dstIndices[ i * 4 + j ] = 0; + dstWeights[ i * 4 + j ] = 0; + + } + + } + + } } @@ -3730,13 +3810,15 @@ class USDComposer { } - // Apply rest transforms to bones (local transforms) + // Apply rest transforms as bone local transforms. + // Rest transforms are the skeleton's default local-space pose and match + // the reference frame used by SkelAnimation data. Bind transforms are + // world-space matrices used only for computing inverse bind matrices. if ( restTransforms && restTransforms.length >= joints.length * 16 ) { for ( let i = 0; i < joints.length; i ++ ) { const matrix = new Matrix4(); - // USD matrices are row-major, Three.js is column-major - need to transpose const m = restTransforms.slice( i * 16, ( i + 1 ) * 16 ); matrix.set( m[ 0 ], m[ 4 ], m[ 8 ], m[ 12 ], diff --git a/src/renderers/common/Renderer.js b/src/renderers/common/Renderer.js index 756a2b4ed5f905..3e3fba59f6a759 100644 --- a/src/renderers/common/Renderer.js +++ b/src/renderers/common/Renderer.js @@ -3369,14 +3369,19 @@ class Renderer { } /** - * Checks if the given compatibility is supported by the selected backend. If the - * renderer has not been initialized, this method always returns `false`. + * Checks if the given compatibility is supported by the selected backend. * * @param {string} name - The compatibility's name. * @return {boolean} Whether the compatibility is supported or not. */ hasCompatibility( name ) { + if ( this._initialized === false ) { + + throw new Error( 'Renderer: .hasCompatibility() called before the backend is initialized. Use "await renderer.init();" before using this method.' ); + + } + return this.backend.hasCompatibility( name ); } diff --git a/src/renderers/webgpu/WebGPUBackend.js b/src/renderers/webgpu/WebGPUBackend.js index b23217b0d0a412..e57d8cf4a5e433 100644 --- a/src/renderers/webgpu/WebGPUBackend.js +++ b/src/renderers/webgpu/WebGPUBackend.js @@ -2539,9 +2539,9 @@ class WebGPUBackend extends Backend { */ hasCompatibility( name ) { - if ( this._compatibility[ Compatibility.TEXTURE_COMPARE ] !== undefined ) { + if ( this._compatibility[ name ] !== undefined ) { - return this._compatibility[ Compatibility.TEXTURE_COMPARE ]; + return this._compatibility[ name ]; }