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 ];
}