Accueil / npm / three@0.186.1 / examples / jsm / inspector / tabs / Timeline.js

Timeline.js

import { Tab } from '../ui/Tab.js';
import { Graph } from '../ui/Graph.js';
import { getItem, setItem } from '../Inspector.js';
import { info } from '../ui/utils.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.content.style.overflow = 'hidden';
		this.isRecording = false;
		this.frames = []; // Array of { id: number, calls: [] }

		this.baseTriangles = 0;
		this.currentFrame = null;
		this.isHierarchicalView = true;
		this.activeBlocks = new Map();
		this.domPool = [];
		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 )' );

		this.buildHeader();
		this.buildUI();

		// Bind window resize to update graph bounds
		window.addEventListener( 'resize', () => {

			if ( this.isActive && ! this.isRecording && this.frames.length > 0 ) {

				this.renderSlider();

			}

		} );

	}

	init( inspector ) {

		super.init( inspector );

		this.profiler.addEventListener( 'resize', () => {

			if ( this.isActive && ! this.isRecording && this.frames.length > 0 ) {

				this.renderSlider();

			}

		} );

	}

	setActive( isActive ) {

		super.setActive( isActive );

		if ( isActive && ! this.isRecording && this.frames.length > 0 ) {

			this.renderSlider();

		}

	}

	buildHeader() {

		const header = document.createElement( 'div' );
		header.className = 'toolbar';

		this.recordButton = document.createElement( 'button' );
		this.recordButton.className = 'console-copy-button'; // Reusing style
		this.recordButton.title = 'Record';
		this.recordButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4" fill="currentColor"></circle></svg>';
		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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
		clearButton.style.padding = '0 10px';
		clearButton.style.lineHeight = '24px';
		clearButton.style.display = 'flex';
		clearButton.style.alignItems = 'center';
		clearButton.addEventListener( 'click', () => this.clear() );

		this.viewModeSelect = document.createElement( 'select' );
		this.viewModeSelect.className = 'select';
		this.viewModeSelect.style.width = '120px';
		this.viewModeSelect.style.marginRight = '10px';

		const hierarchyOption = document.createElement( 'option' );
		hierarchyOption.value = 'hierarchy';
		hierarchyOption.textContent = 'Hierarchy';
		this.viewModeSelect.appendChild( hierarchyOption );

		const countsOption = document.createElement( 'option' );
		countsOption.value = 'counts';
		countsOption.textContent = 'Count';
		this.viewModeSelect.appendChild( countsOption );

		this.viewModeSelect.value = this.isHierarchicalView ? 'hierarchy' : 'counts';

		this.viewModeSelect.addEventListener( 'change', () => {

			this.isHierarchicalView = this.viewModeSelect.value === 'hierarchy';

			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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><circle cx="12" cy="12" r="3" fill="currentColor"></circle></svg>';
		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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
		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.recordButton );
		buttonsGroup.appendChild( this.recordRefreshButton );
		buttonsGroup.appendChild( this.exportButton );
		buttonsGroup.appendChild( clearButton );

		const titleElement = document.createElement( 'div' );
		titleElement.style.display = 'flex';
		titleElement.style.alignItems = 'center';
		titleElement.style.color = 'var(--text-primary)';
		titleElement.style.alignSelf = 'center';

		this.frameInfo = document.createElement( 'span' );
		this.frameInfo.style.display = 'inline-flex';
		this.frameInfo.style.alignItems = 'center';
		this.frameInfo.style.marginLeft = '5px';
		this.frameInfo.style.fontFamily = 'monospace';
		this.frameInfo.style.color = 'var(--text-secondary)';
		this.frameInfo.style.fontSize = '12px';

		titleElement.appendChild( this.viewModeSelect );
		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.flex = '1';
		container.style.minHeight = '0';
		container.style.marginTop = '10px';
		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';
		this.graphSlider.style.touchAction = 'none';

		graphContainer.appendChild( this.graphSlider );

		// Setup SVG from Graph
		this.graph.domElement.style.width = '0';
		this.graph.domElement.style.minWidth = '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';

		// Pointer interactivity on the graph
		let isDragging = false;

		const updatePlayheadFromEvent = ( e ) => {

			if ( this.isRecording || 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( 'pointerdown', ( e ) => {

			if ( this.isRecording ) return;

			isDragging = true;
			this.isManualScrubbing = true;
			this.graphSlider.focus();
			updatePlayheadFromEvent( e );

		} );

		this.graphSlider.addEventListener( 'pointerenter', () => {

			if ( this.frames.length > 0 && ! this.isRecording ) {

				this.hoverIndicator.style.display = 'block';

			}

		} );

		this.graphSlider.addEventListener( 'pointerleave', () => {

			this.hoverIndicator.style.display = 'none';

		} );

		this.graphSlider.addEventListener( 'pointermove', ( 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( 'pointermove', ( 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( 'pointerup', () => {

			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.marginTop = '8px';
		this.timelineTrack.style.backgroundColor = 'var(--background-color)';
		this.timelineTrack.style.position = 'relative';
		mainArea.appendChild( this.timelineTrack );

		this.timelineContent = document.createElement( 'div' );
		this.timelineContent.style.position = 'relative';
		this.timelineContent.style.width = '100%';
		this.timelineTrack.appendChild( this.timelineContent );

		this.timelineTrack.addEventListener( 'scroll', () => {

			if ( ! this.isRecording && this.frames.length > 0 ) {

				this.updateVisibleBlocks();

			}

		} );

		container.appendChild( mainArea );
		this.content.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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>';
			this.recordButton.style.color = 'var(--color-red)';
			this.startRecording();

		} else {

			this.recordButton.title = 'Record';
			this.recordButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4" fill="currentColor"></circle></svg>';
			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();

						}



					}

					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.clearActiveBlocks();
		this.timelineContent.innerHTML = '';
		this.timelineContent.style.height = '0px';
		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 })` ]: '\n' + attachments.join( '\n' )
		};

		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',
					count: bindGroup.bindings.length
				};

				return details;

			}

			case 'createUniformBuffer':
			case 'destroyUniformBuffer': {

				const binding = args[ 0 ];

				const details = {
					group: binding.groupNode.name || 'unknown',
					size: binding.byteLength + ' bytes'
				};

				if ( binding.name !== details.group ) {

					details.name = binding.name;

				}

				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( `<span class="timeline-detail-key">${key}:</span> <span class="timeline-detail-value">${details[ key ]}</span>` );

			}

		}

		if ( parts.length === 0 ) return '';

		return `<span class="timeline-detail-block">{ ${parts.join( '<span class="timeline-detail-sep">, </span>' )} }</span>`;

	}

	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 ( this.isRecording ) return;

		if ( index < 0 || index >= this.frames.length ) return;

		this.selectedFrameIndex = index;

		const frame = this.frames[ index ];
		this.renderTimelineTrack( frame );

		// Update UI texts
		const panelWidth = this.content.offsetWidth;

		const isCompact = panelWidth < 800;
		const frameLabel = isCompact ? '' : 'Frame: ';
		const fpsSuffix = isCompact ? '' : ' FPS';
		const callsSuffix = isCompact ? '' : ' calls';
		const trianglesSuffix = isCompact ? '' : ' triangles';

		const group = ( type, text ) => `<span class="timeline-info-group"><span class="timeline-info-dot ${type}"></span>${text}</span>`;
		const maxTriangles = Math.max( this.baseTriangles, frame.triangles || 0 );
		const trianglesText = isCompact ? ( frame.triangles || 0 ) : ( frame.triangles || 0 ) + ' / ' + maxTriangles + trianglesSuffix;
		this.frameInfo.innerHTML = frameLabel + frame.id + group( 'fps', ( frame.fps || 0 ).toFixed( 1 ) + fpsSuffix ) + group( 'call', frame.calls.length + callsSuffix ) + group( 'red', trianglesText );

		// 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';

		}

	}

	createBlock() {

		const block = document.createElement( 'div' );
		block.style.display = 'flex';
		block.style.alignItems = 'center';
		block.style.padding = '4px 8px';
		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.overflow = 'hidden';
		block.style.position = 'absolute';
		block.style.left = '0';
		block.style.right = '0';
		block.style.height = '24px';
		block.style.boxSizing = 'border-box';

		block.arrow = document.createElement( 'span' );
		block.arrow.style.fontSize = '10px';
		block.arrow.style.marginRight = '8px';
		block.arrow.style.cursor = 'pointer';
		block.arrow.style.width = '35px';
		block.arrow.style.textAlign = 'center';
		block.arrow.style.flexShrink = '0';
		block.arrow.style.whiteSpace = 'nowrap';
		block.appendChild( block.arrow );

		block.titleSpan = document.createElement( 'span' );
		block.titleSpan.style.flex = '1';
		block.titleSpan.style.minWidth = '0';
		block.titleSpan.style.overflow = 'hidden';
		block.titleSpan.style.textOverflow = 'ellipsis';
		block.titleSpan.style.whiteSpace = 'nowrap';
		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 ] );

		} );

		return block;

	}

	clearActiveBlocks() {

		if ( this.activeBlocks ) {

			for ( const block of this.activeBlocks.values() ) {

				block.remove();
				this.domPool.push( block );

			}

			this.activeBlocks.clear();

		}

	}

	updateVisibleBlocks() {

		if ( ! this.flatList || this.flatList.length === 0 ) {

			this.clearActiveBlocks();
			return;

		}

		const ROW_HEIGHT = 26;
		const BUFFER_ROWS = 5;

		const scrollTop = this.timelineTrack.scrollTop;
		const viewportHeight = this.timelineTrack.clientHeight;

		let startIndex = Math.floor( scrollTop / ROW_HEIGHT ) - BUFFER_ROWS;
		let endIndex = Math.ceil( ( scrollTop + viewportHeight ) / ROW_HEIGHT ) + BUFFER_ROWS;

		startIndex = Math.max( 0, startIndex );
		endIndex = Math.min( this.flatList.length - 1, endIndex );

		const nextActiveBlocks = new Map();

		for ( const [ index, block ] of this.activeBlocks.entries() ) {

			if ( index < startIndex || index > endIndex ) {

				block.remove();
				this.domPool.push( block );

			} else {

				nextActiveBlocks.set( index, block );

			}

		}

		this.activeBlocks = nextActiveBlocks;

		for ( let i = startIndex; i <= endIndex; i ++ ) {

			let block = this.activeBlocks.get( i );

			if ( ! block ) {

				block = this.domPool.pop() || this.createBlock();
				this.timelineContent.appendChild( block );
				this.activeBlocks.set( i, block );

			}

			const item = this.flatList[ i ];
			const call = item.call;

			block.style.top = ( i * ROW_HEIGHT ) + 'px';
			block.style.marginLeft = ( item.indent * 24 ) + 'px';
			block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method );
			block._groupId = item.groupId;

			const directInfoIcon = block.querySelector( ':scope > .info-icon' );
			if ( directInfoIcon ) {

				directInfoIcon.remove();

			}

			block.titleSpan.textContent = '';

			const methodSpan = document.createElement( 'span' );
			methodSpan.textContent = call.method;
			block.titleSpan.appendChild( methodSpan );

			if ( call.details ) {

				let tooltipText = `### ${call.method}\n`;
				for ( const key in call.details ) {

					if ( call.details[ key ] !== undefined ) {

						tooltipText += `**${key}**: ${call.details[ key ]}\n`;

					}

				}

				const infoIcon = info( block.titleSpan, tooltipText );
				infoIcon.style.flexShrink = '0';
				infoIcon.style.marginLeft = '6px';
				infoIcon.style.display = 'inline-flex';
				infoIcon.style.verticalAlign = 'middle';

			}

			const detailsAndCountSpan = document.createElement( 'span' );
			let detailsAndCountHTML = ( call.formatedDetails ? call.formatedDetails : '' );
			if ( call.count > 1 ) {

				detailsAndCountHTML += ` <span class="timeline-call-count">( ${call.count} )</span>`;

			}

			if ( detailsAndCountHTML ) {

				detailsAndCountSpan.innerHTML = detailsAndCountHTML;
				block.titleSpan.appendChild( detailsAndCountSpan );

			}

			if ( item.groupId ) {

				block.style.cursor = 'pointer';
				block.arrow.style.display = 'inline-block';
				block.arrow.textContent = item.isCollapsed ? '[ + ]' : '[ - ]';

			} else {

				block.style.cursor = 'default';
				block.arrow.style.display = 'none';

			}

		}

	}

	renderTimelineTrack( frame ) {

		if ( this.isRecording ) return;

		this.flatList = [];

		if ( ! frame || frame.calls.length === 0 ) {

			this.clearActiveBlocks();
			this.timelineContent.innerHTML = '';
			this.timelineContent.style.height = '0px';
			return;

		}

		if ( ! this.collapsedGroups ) {

			this.collapsedGroups = new Set();

		}

		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, details: call.details };
					groupedCalls.push( currentGroup );

				}

			}

			let currentIndent = 0;
			const elementStack = [ { isCollapsed: false, id: '', beginCount: 0 } ];
			const instanceCounts = new WeakMap();

			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 currentParent = elementStack[ elementStack.length - 1 ];

				let groupId = null;
				let isCollapsed = false;

				if ( call.method.startsWith( 'begin' ) ) {

					const beginIndex = currentParent.beginCount ++;
					groupId = currentParent.id + '/' + call.method + '-' + beginIndex;
					isCollapsed = this.collapsedGroups.has( groupId );

				}

				if ( ! currentParent.isCollapsed ) {

					this.flatList.push( {
						call,
						indent: currentIndent,
						groupId,
						isCollapsed,
						instanceIndex
					} );

				}

				if ( call.method.startsWith( 'begin' ) ) {

					currentIndent ++;
					elementStack.push( { isCollapsed: currentParent.isCollapsed || isCollapsed, id: groupId, beginCount: 0 } );

				} else if ( call.method.startsWith( 'finish' ) ) {

					currentIndent = Math.max( 0, currentIndent - 1 );
					elementStack.pop();

				}

			}

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

				this.flatList.push( {
					call,
					indent: 0,
					groupId: null,
					isCollapsed: false,
					instanceIndex: 0
				} );

			}

		}

		const ROW_HEIGHT = 26;
		this.timelineContent.style.height = ( this.flatList.length * ROW_HEIGHT ) + 'px';
		this.updateVisibleBlocks();

	}

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