import { Tab } from '../ui/Tab.js'; import { Graph } from '../ui/Graph.js'; import { getItem, setItem } from '../Inspector.js'; import { ByteType, FloatType, HalfFloatType, IntType, ShortType, UnsignedByteType, UnsignedInt101111Type, UnsignedInt248Type, UnsignedInt5999Type, UnsignedIntType, UnsignedShort4444Type, UnsignedShort5551Type, UnsignedShortType, AlphaFormat, RGBFormat, RGBAFormat, DepthFormat, DepthStencilFormat, RedFormat, RedIntegerFormat, RGFormat, RGIntegerFormat, RGBIntegerFormat, RGBAIntegerFormat } from 'three'; const LIMIT = 500; const TRIANGLES_GRAPH_LIMIT = 60; class Timeline extends Tab { constructor( options = {} ) { super( 'Timeline', options ); this.isRecording = false; this.frames = []; // Array of { id: number, calls: [] } this.baseTriangles = 0; this.currentFrame = null; this.isHierarchicalView = true; this.callBlocks = new WeakMap(); this.fallbackBlocks = []; 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.graph.addLine( 'triangles', 'var( --color-red )' ); const scrollWrapper = document.createElement( 'div' ); scrollWrapper.className = 'list-scroll-wrapper'; this.scrollWrapper = scrollWrapper; this.content.appendChild( scrollWrapper ); 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 timelineSettings = getItem( 'timeline' ); timelineSettings.recording = true; setItem( 'timeline', timelineSettings ); window.location.reload(); } ); this.exportButton = document.createElement( 'button' ); this.exportButton.className = 'console-copy-button'; this.exportButton.title = 'Export'; this.exportButton.innerHTML = ''; this.exportButton.style.padding = '0 10px'; this.exportButton.style.lineHeight = '24px'; this.exportButton.style.display = 'flex'; this.exportButton.style.alignItems = 'center'; this.exportButton.addEventListener( 'click', () => this.exportData() ); const buttonsGroup = document.createElement( 'div' ); buttonsGroup.className = 'console-buttons-group'; buttonsGroup.appendChild( this.viewModeButton ); buttonsGroup.appendChild( this.recordButton ); buttonsGroup.appendChild( this.recordRefreshButton ); buttonsGroup.appendChild( this.exportButton ); 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'; titleElement.style.display = 'flex'; titleElement.style.alignItems = 'center'; titleElement.style.color = 'var(--text-primary)'; titleElement.style.alignSelf = 'center'; titleElement.style.paddingLeft = '5px'; this.frameInfo = document.createElement( 'span' ); this.frameInfo.style.display = 'inline-flex'; this.frameInfo.style.alignItems = 'center'; 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.scrollWrapper.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.scrollWrapper.appendChild( container ); } setRenderer( renderer ) { this.renderer = renderer; const timelineSettings = getItem( 'timeline' ); if ( timelineSettings.recording ) { timelineSettings.recording = false; setItem( 'timeline', timelineSettings ); 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; } const t = this.currentFrame.triangles || 0; if ( t > this.baseTriangles ) { const oldBase = this.baseTriangles; this.baseTriangles = t; if ( oldBase > 0 ) { const ratio = oldBase / this.baseTriangles; const points = this.graph.lines[ 'triangles' ].points; for ( let i = 0; i < points.length; i ++ ) { points[ i ] *= ratio; } } } const normalizedTriangles = this.baseTriangles > 0 ? ( t / this.baseTriangles ) * TRIANGLES_GRAPH_LIMIT : 0; this.graph.addPoint( 'calls', this.currentFrame.calls.length ); this.graph.addPoint( 'fps', this.currentFrame.fps ); this.graph.addPoint( 'triangles', normalizedTriangles ); this.graph.update(); } this.currentFrame = { id: frameNumber, calls: [], fps: 0, triangles: 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 ); } } } } const call = { method: prop, target: args[ 0 ] }; const details = this.getCallDetail( prop, args ); if ( details ) { call.details = details; if ( details.triangles !== undefined ) { this.currentFrame.triangles += details.triangles; } } this.currentFrame.calls.push( call ); 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.baseTriangles = 0; this.graph.lines[ 'calls' ].points = []; this.graph.lines[ 'fps' ].points = []; this.graph.lines[ 'triangles' ].points = []; this.graph.resetLimit(); this.graph.update(); } exportData() { if ( this.frames.length === 0 ) return; const data = JSON.stringify( this.frames, null, '\t' ); const blob = new Blob( [ data ], { type: 'application/json' } ); const url = URL.createObjectURL( blob ); const a = document.createElement( 'a' ); a.href = url; a.download = 'threejs-timeline.json'; a.click(); URL.revokeObjectURL( url ); } getRenderTargetDetails( renderTarget ) { const textures = renderTarget.textures; const attachments = []; const getBPC = ( texture ) => { switch ( texture.type ) { case ByteType: case UnsignedByteType: return '8'; case ShortType: case UnsignedShortType: case HalfFloatType: case UnsignedShort4444Type: case UnsignedShort5551Type: return '16'; case IntType: case UnsignedIntType: case FloatType: case UnsignedInt248Type: case UnsignedInt5999Type: case UnsignedInt101111Type: return '32'; default: return '?'; } }; const getFormat = ( texture ) => { switch ( texture.format ) { case AlphaFormat: return 'a'; case RedFormat: case RedIntegerFormat: return 'r'; case RGFormat: case RGIntegerFormat: return 'rg'; case RGBFormat: case RGBIntegerFormat: return 'rgb'; case DepthFormat: return 'depth'; case DepthStencilFormat: return 'depth-stencil'; case RGBAFormat: case RGBAIntegerFormat: default: return 'rgba'; } }; for ( let i = 0; i < textures.length; i ++ ) { const texture = textures[ i ]; const bpc = getBPC( texture ); const format = getFormat( texture ); let description = `[${ i }]`; if ( texture.name && ! ( texture.isDepthTexture && texture.name === 'depth' ) ) { description += ` ${ texture.name }`; } description += ` ${ format } ${ bpc } bpc`; attachments.push( description ); } const details = { target: renderTarget.name || 'RenderTarget', [ `attachments(${ textures.length })` ]: attachments.join( ', ' ) }; if ( renderTarget.depthTexture ) { details.depth = `${ getBPC( renderTarget.depthTexture ) } bpc`; } return details; } getCallDetail( method, args ) { switch ( method ) { case 'draw': { const renderObject = args[ 0 ]; const details = { object: renderObject.object.name || renderObject.object.type, material: renderObject.material.name || renderObject.material.type, geometry: renderObject.geometry.name || renderObject.geometry.type }; if ( renderObject.getDrawParameters ) { const drawParams = renderObject.getDrawParameters(); if ( drawParams ) { if ( renderObject.object.isMesh || renderObject.object.isSprite ) { details.triangles = drawParams.vertexCount / 3; if ( renderObject.object.count > 1 ) { details.instance = renderObject.object.count; details[ 'triangles per instance' ] = details.triangles; details.triangles *= details.instance; } } } } return details; } case 'beginRender': { const renderContext = args[ 0 ]; const details = { scene: this.renderer.inspector.currentRender.name || 'unknown', camera: renderContext.camera.name || renderContext.camera.type }; if ( renderContext.renderTarget && ! renderContext.renderTarget.isPostProcessingRenderTarget ) { Object.assign( details, this.getRenderTargetDetails( renderContext.renderTarget ) ); } else { details.target = 'CanvasTarget'; } return details; } case 'beginCompute': { const details = { compute: this.renderer.inspector.currentCompute.name || 'unknown' }; return details; } case 'compute': { const computeNode = args[ 1 ]; const bindings = args[ 2 ]; const dispatchSize = args[ 4 ] || computeNode.dispatchSize || computeNode.count; const node = computeNode.name || computeNode.type || 'unknown'; // bindings let bindingsCount = 0; if ( bindings ) { bindingsCount = bindings.length; } // dispatch let dispatch; if ( dispatchSize.isIndirectStorageBufferAttribute ) { dispatch = 'indirect'; } else if ( Array.isArray( dispatchSize ) ) { dispatch = dispatchSize.join( ', ' ); } else { dispatch = dispatchSize; } // details return { node, bindings: bindingsCount, dispatch }; } case 'updateBinding': { const binding = args[ 0 ]; return { group: binding.name || 'unknown' }; } case 'clear': { const renderContext = args[ 3 ]; const details = { color: args[ 0 ], depth: args[ 1 ], stencil: args[ 2 ] }; if ( renderContext.renderTarget && ! renderContext.renderTarget.isPostProcessingRenderTarget ) { const renderTargetDetails = this.getRenderTargetDetails( renderContext.renderTarget ); if ( renderTargetDetails.depth ) { renderTargetDetails[ 'depth texture' ] = renderTargetDetails.depth; delete renderTargetDetails.depth; } Object.assign( details, renderTargetDetails ); } else { details.target = 'CanvasTarget'; } return details; } case 'updateViewport': { const renderContext = args[ 0 ]; const { x, y, width, height } = renderContext.viewportValue; return { x, y, width, height }; } case 'updateScissor': { const renderContext = args[ 0 ]; const { x, y, width, height } = renderContext.scissorValue; return { x, y, width, height }; } case 'createProgram': case 'destroyProgram': { const program = args[ 0 ]; return { stage: program.stage, name: program.name || 'unknown' }; } case 'createRenderPipeline': { const renderObject = args[ 0 ]; const details = { object: renderObject.object ? ( renderObject.object.name || renderObject.object.type || 'unknown' ) : 'unknown', material: renderObject.material ? ( renderObject.material.name || renderObject.material.type || 'unknown' ) : 'unknown' }; return details; } case 'createComputePipeline': case 'destroyComputePipeline': { const pipeline = args[ 0 ]; return { name: pipeline.name || 'unknown' }; } case 'createBindings': case 'updateBindings': { const bindGroup = args[ 0 ]; const details = { group: bindGroup.name || 'unknown' }; if ( bindGroup.bindings ) { details.count = bindGroup.bindings.length; } return details; } case 'createNodeBuilder': { const object = args[ 0 ]; const details = { object: object.name || object.type || 'unknown' }; if ( object.material ) { details.material = object.material.name || object.material.type || 'unknown'; } return details; } case 'createAttribute': case 'createIndexAttribute': case 'createStorageAttribute': case 'destroyAttribute': case 'destroyIndexAttribute': case 'destroyStorageAttribute': { const attribute = args[ 0 ]; const details = {}; if ( attribute.name ) details.name = attribute.name; if ( attribute.count !== undefined ) details.count = attribute.count; if ( attribute.itemSize !== undefined ) details.itemSize = attribute.itemSize; return details; } case 'copyFramebufferToTexture': { const target = args[ 0 ]; const rectangle = args[ 2 ]; const details = { target: this.getTextureName( target ), width: rectangle.z, height: rectangle.w }; return details; } case 'copyTextureToTexture': { const srcTexture = args[ 0 ]; const dstTexture = args[ 1 ]; const details = { source: this.getTextureName( srcTexture ), destination: this.getTextureName( dstTexture ) }; return details; } case 'updateSampler': { const texture = args[ 0 ]; const details = { magFilter: this.getTextureFilterName( texture.magFilter ), minFilter: this.getTextureFilterName( texture.minFilter ), wrapS: this.getTextureWrapName( texture.wrapS ), wrapT: this.getTextureWrapName( texture.wrapT ), anisotropy: texture.anisotropy }; return details; } case 'updateTexture': case 'generateMipmaps': case 'createTexture': case 'destroyTexture': { const texture = args[ 0 ]; const name = this.getTextureName( texture ); const details = { texture: name }; if ( texture.image ) { if ( texture.image.width !== undefined ) details.width = texture.image.width; if ( texture.image.height !== undefined ) details.height = texture.image.height; } return details; } } return null; } getTextureName( texture ) { if ( texture.name ) return texture.name; const types = [ 'isFramebufferTexture', 'isDepthTexture', 'isDataArrayTexture', 'isData3DTexture', 'isDataTexture', 'isCompressedArrayTexture', 'isCompressedTexture', 'isCubeTexture', 'isVideoTexture', 'isCanvasTexture', 'isTexture' ]; for ( const type of types ) { if ( texture[ type ] ) return type.replace( 'is', '' ); } return 'Texture'; } getTextureFilterName( filter ) { const filters = { 1003: 'Nearest', 1004: 'NearestMipmapNearest', 1005: 'NearestMipmapLinear', 1006: 'Linear', 1007: 'LinearMipmapNearest', 1008: 'LinearMipmapLinear' }; return filters[ filter ] || filter; } getTextureWrapName( wrap ) { const wrappings = { 1000: 'Repeat', 1001: 'ClampToEdge', 1002: 'MirroredRepeat' }; return wrappings[ wrap ] || wrap; } formatDetails( details ) { const parts = []; for ( const key in details ) { if ( details[ key ] !== undefined ) { parts.push( `${key}: ${details[ key ]}` ); } } if ( parts.length === 0 ) return ''; return `{ ${parts.join( ', ' )} }`; } 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.lines[ 'triangles' ].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 } let maxTriangles = 0; for ( let i = 0; i < framesToRender.length; i ++ ) { const t = framesToRender[ i ].triangles || 0; if ( t > maxTriangles ) { maxTriangles = t; } } for ( let i = 0; i < framesToRender.length; i ++ ) { const t = framesToRender[ i ].triangles || 0; const normalizedTriangles = maxTriangles > 0 ? ( t / maxTriangles ) * TRIANGLES_GRAPH_LIMIT : 0; // 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.addPoint( 'triangles', normalizedTriangles ); } 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 const group = ( c, text ) => `${text}`; const maxTriangles = Math.max( this.baseTriangles, frame.triangles || 0 ); this.frameInfo.innerHTML = 'Frame: ' + frame.id + group( 'var(--color-fps)', ( frame.fps || 0 ).toFixed( 1 ) + ' FPS' ) + group( 'var(--color-call)', frame.calls.length + ' calls' ) + group( 'var(--color-red)', ( frame.triangles || 0 ) + ' / ' + maxTriangles + ' triangles' ); // 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'; } } getCallBlock( call, fallbackIndex, instanceIndex = 0 ) { const target = call.target; let block; if ( target && typeof target === 'object' ) { let blocks = this.callBlocks.get( target ); if ( ! blocks ) { blocks = []; this.callBlocks.set( target, blocks ); } block = blocks[ instanceIndex ]; } else { block = this.fallbackBlocks[ fallbackIndex ]; } if ( ! block ) { block = document.createElement( 'div' ); block.style.padding = '4px 8px'; block.style.margin = '2px 0'; 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'; block.arrow = document.createElement( 'span' ); block.arrow.style.fontSize = '10px'; block.arrow.style.marginRight = '10px'; block.arrow.style.cursor = 'pointer'; block.arrow.style.width = '26px'; block.arrow.style.textAlign = 'center'; block.appendChild( block.arrow ); block.titleSpan = document.createElement( 'span' ); block.appendChild( block.titleSpan ); block.addEventListener( 'click', ( e ) => { if ( ! block._groupId ) return; e.stopPropagation(); if ( this.collapsedGroups.has( block._groupId ) ) { this.collapsedGroups.delete( block._groupId ); } else { this.collapsedGroups.add( block._groupId ); } this.renderTimelineTrack( this.frames[ this.selectedFrameIndex ] ); } ); if ( target && typeof target === 'object' ) { this.callBlocks.get( target )[ instanceIndex ] = block; } else { this.fallbackBlocks[ fallbackIndex ] = block; } } block.style.cursor = 'default'; block._groupId = null; block.arrow.style.display = 'none'; return block; } renderTimelineTrack( frame ) { if ( ! frame || frame.calls.length === 0 ) { this.timelineTrack.innerHTML = ''; return; } // Track collapsed states if ( ! this.collapsedGroups ) { this.collapsedGroups = new Set(); } let blockIndex = 0; const trackChildren = this.timelineTrack.children; let childIndex = 0; const instanceCounts = new WeakMap(); 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' ); const formatedDetails = call.details ? this.formatDetails( call.details ) : ''; if ( currentGroup && currentGroup.method === call.method && currentGroup.formatedDetails === formatedDetails && ! isStructural ) { currentGroup.count ++; } else { currentGroup = { method: call.method, count: 1, formatedDetails, target: call.target }; groupedCalls.push( currentGroup ); } } let currentIndent = 0; const indentSize = 24; // Stack to keep track of parent elements and their collapsed state const elementStack = [ { element: this.timelineTrack, isCollapsed: false, id: '', beginCount: 0 } ]; for ( let i = 0; i < groupedCalls.length; i ++ ) { const call = groupedCalls[ i ]; let instanceIndex = 0; if ( call.target && typeof call.target === 'object' ) { instanceIndex = instanceCounts.get( call.target ) || 0; instanceCounts.set( call.target, instanceIndex + 1 ); } const block = this.getCallBlock( call, blockIndex ++, instanceIndex ); block.style.marginLeft = ( currentIndent * indentSize ) + 'px'; block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method ); const currentParent = elementStack[ elementStack.length - 1 ]; // Only add to DOM if parent is not collapsed if ( ! currentParent.isCollapsed ) { if ( trackChildren[ childIndex ] !== block ) { this.timelineTrack.insertBefore( block, trackChildren[ childIndex ] ); } childIndex ++; } if ( call.method.startsWith( 'begin' ) ) { const beginIndex = currentParent.beginCount ++; const groupId = currentParent.id + '/' + call.method + '-' + beginIndex; const isCollapsed = this.collapsedGroups.has( groupId ); block._groupId = groupId; block.style.cursor = 'pointer'; block.arrow.style.display = 'inline-block'; block.arrow.textContent = isCollapsed ? '[ + ]' : '[ - ]'; block.titleSpan.innerHTML = call.method + ( call.formatedDetails ? call.formatedDetails : '' ) + ( call.count > 1 ? ` ( ${call.count} )` : '' ); currentIndent ++; elementStack.push( { element: block, isCollapsed: currentParent.isCollapsed || isCollapsed, id: groupId, beginCount: 0 } ); } else if ( call.method.startsWith( 'finish' ) ) { block.titleSpan.innerHTML = call.method + ( call.formatedDetails ? call.formatedDetails : '' ) + ( call.count > 1 ? ` ( ${call.count} )` : '' ); currentIndent = Math.max( 0, currentIndent - 1 ); elementStack.pop(); } else { block.titleSpan.innerHTML = call.method + ( call.formatedDetails ? call.formatedDetails : '' ) + ( 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 = this.getCallBlock( call, blockIndex ++ ); block.style.marginLeft = '0px'; block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method ); block.titleSpan.innerHTML = call.method + ( call.count > 1 ? ` ( ${call.count} )` : '' ); if ( trackChildren[ childIndex ] !== block ) { this.timelineTrack.insertBefore( block, trackChildren[ childIndex ] ); } childIndex ++; } } while ( this.timelineTrack.children.length > childIndex ) { this.timelineTrack.removeChild( this.timelineTrack.lastChild ); } } 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 };