Просмотр исходного кода

WebGPURenderer: Introduce `Inspector` (#31869)

* Node: add `name` property

* Introduce `backend.getTimestampUID()`

* Introduce `InspectorBase`

* Introduce `renderer.getAnimationLoop()`

* export InspectorBase

* add some node names

* Add `RendererInspector` for render/compute stats

* Introduce `Inspector`

* revision messages

* add output color transform name

* add inspector for some examples and new info layout

* cleanup

* revision & sync frames - webgpu backend

* updates

* revision

* revision

* Update RendererInspector.js

* add miscellaneous stats

* cleanup

* revision

* improve interface

* simplify api

* Update Style.js

* Update Style.js

* rev

* add Point Light Shadow description

* Update PointShadowNode.js

* rev style

* Update Performance.js

* add shadowmap description

* Update Style.js

* add calls
sunag 7 месяцев назад
Родитель
Сommit
fc8fc0d515
38 измененных файлов с 3535 добавлено и 136 удалено
  1. 90 0
      examples/example.css
  2. 339 0
      examples/jsm/inspector/Inspector.js
  3. 335 0
      examples/jsm/inspector/RendererInspector.js
  4. 200 0
      examples/jsm/inspector/tabs/Console.js
  5. 239 0
      examples/jsm/inspector/tabs/Parameters.js
  6. 259 0
      examples/jsm/inspector/tabs/Performance.js
  7. 95 0
      examples/jsm/inspector/ui/Graph.js
  8. 163 0
      examples/jsm/inspector/ui/Item.js
  9. 75 0
      examples/jsm/inspector/ui/List.js
  10. 170 0
      examples/jsm/inspector/ui/Profiler.js
  11. 635 0
      examples/jsm/inspector/ui/Style.js
  12. 43 0
      examples/jsm/inspector/ui/Tab.js
  13. 321 0
      examples/jsm/inspector/ui/Values.js
  14. 42 0
      examples/jsm/inspector/ui/utils.js
  15. 2 0
      examples/jsm/tsl/display/GaussianBlurNode.js
  16. 1 0
      examples/jsm/tsl/display/SSGINode.js
  17. 1 0
      examples/jsm/tsl/display/TRAANode.js
  18. 14 15
      examples/webgpu_backdrop_water.html
  19. 22 40
      examples/webgpu_compute_birds.html
  20. 25 25
      examples/webgpu_compute_particles_snow.html
  21. 16 15
      examples/webgpu_postprocessing_ssgi.html
  22. 12 2
      examples/webgpu_shadowmap_opacity.html
  23. 1 0
      src/Three.WebGPU.Nodes.js
  24. 1 0
      src/Three.WebGPU.js
  25. 8 0
      src/nodes/core/Node.js
  26. 6 0
      src/nodes/lighting/PointShadowNode.js
  27. 6 0
      src/nodes/lighting/ShadowNode.js
  28. 10 0
      src/nodes/utils/RTTNode.js
  29. 13 1
      src/renderers/common/Animation.js
  30. 47 8
      src/renderers/common/Backend.js
  31. 139 0
      src/renderers/common/InspectorBase.js
  32. 1 0
      src/renderers/common/PostProcessing.js
  33. 90 2
      src/renderers/common/Renderer.js
  34. 51 1
      src/renderers/common/TimestampQueryPool.js
  35. 0 9
      src/renderers/webgl-fallback/WebGLBackend.js
  36. 36 6
      src/renderers/webgl-fallback/utils/WebGLTimestampQueryPool.js
  37. 1 9
      src/renderers/webgpu/WebGPUBackend.js
  38. 26 3
      src/renderers/webgpu/utils/WebGPUTimestampQueryPool.js

+ 90 - 0
examples/example.css

@@ -0,0 +1,90 @@
+* {
+	box-sizing: border-box;
+	-webkit-font-smoothing: antialiased;
+	-moz-osx-font-smoothing: grayscale;
+}
+
+body {
+	margin: 0;
+	background-color: #000;
+	overscroll-behavior: none;
+	overflow: hidden;
+	height: 100%;
+}
+
+a {
+	text-decoration: none;
+	color: inherit;
+}
+
+#info {
+	position: fixed;
+	top: 15px;
+	left: 15px;
+	z-index: 1001;
+
+	display: grid;
+	grid-template-columns: 50px auto;
+	grid-template-rows: auto auto;
+	column-gap: 10px;
+	align-items: center;
+	color: #e0e0e0;
+	text-shadow: 1px 1px 5px rgba(0, 0, 0, .7);
+	font: 400 14px 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+}
+
+#info > a.logo-link {
+	grid-column: 1;
+	grid-row: 1 / span 2;
+	display: block;
+	width: 50px;
+	height: 50px;
+	background: no-repeat center / contain;
+	background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 226.77 226.77"><g transform="translate(8.964 4.2527)" stroke="%23ffffff" stroke-linecap="butt" stroke-linejoin="round" stroke-width="4" fill="none"><path d="m63.02 200.61-43.213-174.94 173.23 49.874z"/><path d="m106.39 50.612 21.591 87.496-86.567-24.945z"/><path d="m84.91 125.03-10.724-43.465 43.008 12.346z"/><path d="m63.458 38.153 10.724 43.465-43.008-12.346z"/><path d="m149.47 62.93 10.724 43.465-43.008-12.346z"/><path d="m84.915 125.06 10.724 43.465-43.008-12.346z"/></g></svg>');
+}
+
+.title-wrapper {
+	grid-column: 2;
+	grid-row: 1;
+	display: flex;
+	align-items: center;
+}
+
+#info > small {
+	grid-column: 2;
+	grid-row: 2;
+	font-size: 12px;
+	color: #e0e0e0;
+}
+
+.title-wrapper > a {
+	font-weight: 600;
+}
+
+.title-wrapper > span {
+	opacity: .7;
+	position: relative;
+	padding-left: 12px;
+	margin-left: 10px;
+}
+
+#info > small a {
+	color: #ff0;
+	text-decoration: none;
+}
+
+#info > small a:hover {
+	text-decoration: underline;
+}
+
+.title-wrapper > span::before {
+	content: "";
+	position: absolute;
+	left: 1px;
+	top: calc(50% + 1px);
+	transform: translateY(-50%);
+	width: 1px;
+	height: 12px;
+	background: #c3c3c3;
+	opacity: .5;
+}

+ 339 - 0
examples/jsm/inspector/Inspector.js

@@ -0,0 +1,339 @@
+
+import { RendererInspector } from './RendererInspector.js';
+import { Profiler } from './ui/Profiler.js';
+import { Performance } from './tabs/Performance.js';
+import { Console } from './tabs/Console.js';
+import { Parameters } from './tabs/Parameters.js';
+import { setText, ease } from './ui/utils.js';
+
+import { setConsoleFunction, REVISION } from 'three/webgpu';
+
+const EASE_FACTOR = 0.1;
+
+class Inspector extends RendererInspector {
+
+	constructor() {
+
+		super();
+
+		// init profiler
+
+		const profiler = new Profiler();
+
+		const parameters = new Parameters();
+		parameters.hide();
+		profiler.addTab( parameters );
+
+		const performance = new Performance();
+		profiler.addTab( performance );
+
+		const console = new Console();
+		profiler.addTab( console );
+
+		profiler.setActiveTab( performance.id );
+
+		//
+
+		this.deltaTime = 0;
+		this.softDeltaTime = 0;
+
+		this.statsData = new Map();
+		this.profiler = profiler;
+		this.performance = performance;
+		this.console = console;
+		this.parameters = parameters;
+		this.once = {};
+
+		this.displayCycle = {
+			text: {
+				needsUpdate: false,
+				duration: .25,
+				time: 0
+			},
+			graph: {
+				needsUpdate: false,
+				duration: .05,
+				time: 0
+			}
+		};
+
+	}
+
+	get domElement() {
+
+		return this.profiler.domElement;
+
+	}
+
+	computeAsync() {
+
+		const renderer = this.getRenderer();
+		const animationLoop = renderer.getAnimationLoop();
+
+		if ( renderer.info.frame > 1 && animationLoop !== null ) {
+
+			this.resolveConsoleOnce( 'info', 'TIP: "computeAsync()" was called while a "setAnimationLoop()" is active. This is probably not necessary, use "compute()" instead.' );
+
+		}
+
+	}
+
+	resolveConsoleOnce( type, message ) {
+
+		const key = type + message;
+
+		if ( this.once[ key ] !== true ) {
+
+			this.resolveConsole( 'log', message );
+			this.once[ key ] = true;
+
+		}
+
+	}
+
+	resolveConsole( type, message ) {
+
+		switch ( type ) {
+
+			case 'log':
+
+				this.console.addMessage( 'info', message );
+
+				console.log( message );
+
+				break;
+
+			case 'warn':
+
+				this.console.addMessage( 'warn', message );
+
+				console.warn( message );
+
+				break;
+
+			case 'error':
+
+				this.console.addMessage( 'error', message );
+
+				console.error( message );
+
+				break;
+
+		}
+
+	}
+
+	init() {
+
+		const renderer = this.getRenderer();
+
+		let sign = `🚀 "WebGPURenderer" - ${ REVISION } [ "`;
+
+		if ( renderer.backend.isWebGPUBackend ) {
+
+			sign += 'WebGPU';
+
+		} else if ( renderer.backend.isWebGLBackend ) {
+
+			sign += 'WebGL2';
+
+		}
+
+		sign += '" ]';
+
+		this.console.addMessage( 'info', sign );
+
+		//
+
+		if ( renderer.inspector.domElement.parentElement === null && renderer.domElement.parentElement !== null ) {
+
+			renderer.domElement.parentElement.appendChild( renderer.inspector.domElement );
+
+		}
+
+	}
+
+	setRenderer( renderer ) {
+
+		if ( renderer !== null ) {
+
+			setConsoleFunction( this.resolveConsole.bind( this ) );
+
+			renderer.backend.trackTimestamp = true;
+
+			renderer.hasFeatureAsync( 'timestamp-query' ).then( ( available ) => {
+
+				if ( available !== true ) {
+
+					this.console.addMessage( 'error', 'THREE.Inspector: GPU Timestamp Queries not available.' );
+
+				}
+
+			} );
+
+		}
+
+		return super.setRenderer( renderer );
+
+	}
+
+	createParameters( name ) {
+
+		if ( this.parameters.isVisible === false ) {
+
+			this.parameters.show();
+			this.profiler.setActiveTab( this.parameters.id );
+
+		}
+
+		return this.parameters.createGroup( name );
+
+	}
+
+	getStatsData( cid ) {
+
+		let data = this.statsData.get( cid );
+
+		if ( data === undefined ) {
+
+			data = {};
+
+			this.statsData.set( cid, data );
+
+		}
+
+		return data;
+
+	}
+
+	resolveStats( stats ) {
+
+		const data = this.getStatsData( stats.cid );
+
+		if ( data.initialized !== true ) {
+
+			data.cpu = stats.cpu;
+			data.gpu = stats.gpu;
+
+			data.initialized = true;
+
+		}
+
+		// TODO: Smooth values
+
+		data.cpu = stats.cpu; // ease( .. )
+		data.gpu = stats.gpu;
+		data.total = data.cpu + data.gpu;
+
+		//
+
+		for ( const child of stats.children ) {
+
+			this.resolveStats( child );
+
+			const childData = this.getStatsData( child.cid );
+
+			data.cpu += childData.cpu;
+			data.gpu += childData.gpu;
+			data.total += childData.total;
+
+		}
+
+	}
+
+	resolveFrame( frame ) {
+
+		const nextFrame = this.getFrameById( frame.frameId + 1 );
+
+		if ( ! nextFrame ) return;
+
+		frame.cpu = 0;
+		frame.gpu = 0;
+		frame.total = 0;
+
+		for ( const stats of frame.children ) {
+
+			this.resolveStats( stats );
+
+			const data = this.getStatsData( stats.cid );
+
+			frame.cpu += data.cpu;
+			frame.gpu += data.gpu;
+			frame.total += data.total;
+
+		}
+
+		// improve stats using next frame
+
+		frame.deltaTime = nextFrame.startTime - frame.startTime;
+		frame.miscellaneous = frame.deltaTime - frame.total;
+
+		if ( frame.miscellaneous < 0 ) {
+
+			// Frame desync, probably due to async GPU timing.
+
+			return;
+
+		}
+
+		//
+
+		if ( this.softDeltaTime === 0 ) {
+
+			this.softDeltaTime = frame.deltaTime;
+
+		}
+
+		this.deltaTime = frame.deltaTime;
+		this.softDeltaTime = ease( this.softDeltaTime, frame.deltaTime, this.nodeFrame.deltaTime, EASE_FACTOR );
+
+		this.updateCycle( this.displayCycle.text );
+		this.updateCycle( this.displayCycle.graph );
+
+		if ( this.displayCycle.text.needsUpdate ) {
+
+			setText( 'fps-counter', this.fps.toFixed() );
+
+			this.performance.updateText( this, frame );
+
+		}
+
+		if ( this.displayCycle.graph.needsUpdate ) {
+
+			this.performance.updateGraph( this, frame );
+
+		}
+
+		this.displayCycle.text.needsUpdate = false;
+		this.displayCycle.graph.needsUpdate = false;
+
+	}
+
+	get fps() {
+
+		return 1000 / this.deltaTime;
+
+	}
+
+	get softFPS() {
+
+		return 1000 / this.softDeltaTime;
+
+	}
+
+	updateCycle( cycle ) {
+
+		cycle.time += this.nodeFrame.deltaTime;
+
+		if ( cycle.time >= cycle.duration ) {
+
+			cycle.needsUpdate = true;
+			cycle.time = 0;
+
+		}
+
+	}
+
+}
+
+export { Inspector };

+ 335 - 0
examples/jsm/inspector/RendererInspector.js

@@ -0,0 +1,335 @@
+
+import { InspectorBase, TimestampQuery } from 'three/webgpu';
+
+class ObjectStats {
+
+	constructor( uid, name ) {
+
+		this.uid = uid;
+		this.cid = uid.match( /^(.*):f(\d+)$/ )[ 1 ]; // call id
+		this.name = name;
+		this.timestamp = 0;
+		this.cpu = 0;
+		this.gpu = 0;
+
+		this.children = [];
+		this.parent = null;
+
+	}
+
+}
+
+class RenderStats extends ObjectStats {
+
+	constructor( uid, scene, camera, renderTarget ) {
+
+		let name = scene.name;
+
+		if ( name === '' ) {
+
+			if ( scene.isScene ) {
+
+				name = 'Scene';
+
+			} else if ( scene.isQuadMesh ) {
+
+				name = 'QuadMesh';
+
+			}
+
+		}
+
+		super( uid, name );
+
+		this.scene = scene;
+		this.camera = camera;
+		this.renderTarget = renderTarget;
+
+		this.isRenderStats = true;
+
+	}
+
+}
+
+class ComputeStats extends ObjectStats {
+
+	constructor( uid, computeNode ) {
+
+		super( uid, computeNode.name );
+
+		this.computeNode = computeNode;
+
+		this.isComputeStats = true;
+
+	}
+
+}
+
+export class RendererInspector extends InspectorBase {
+
+	constructor() {
+
+		super();
+
+		this.currentFrame = null;
+		this.currentRender = null;
+
+		this.frames = [];
+		this.framesLib = {};
+		this.maxFrames = 512;
+
+		this._lastFinishTime = 0;
+		this._resolveTimestampPromise = null;
+
+		this.isRendererInspector = true;
+
+	}
+
+	begin() {
+
+		this.currentFrame = this._createFrame();
+		this.currentRender = this.currentFrame;
+
+	}
+
+	finish() {
+
+		const now = performance.now();
+
+		const frame = this.currentFrame;
+		frame.finishTime = now;
+		frame.deltaTime = now - ( this._lastFinishTime > 0 ? this._lastFinishTime : now );
+
+		this.addFrame( frame );
+
+		this.currentFrame = null;
+		this.currentRender = null;
+
+		this._lastFinishTime = now;
+
+	}
+
+	_createFrame() {
+
+		return {
+			frameId: this.nodeFrame.frameId,
+			resolvedCompute: false,
+			resolvedRender: false,
+			deltaTime: 0,
+			startTime: performance.now(),
+			finishTime: 0,
+			miscellaneous: 0,
+			children: [],
+			renders: [],
+			computes: []
+		};
+
+	}
+
+	getFrame() {
+
+		return this.currentFrame;
+
+	}
+
+	getFrameById( frameId ) {
+
+		return this.framesLib[ frameId ] || null;
+
+	}
+
+	resolveFrame( /*frame*/ ) { }
+
+	async resolveTimestamp() {
+
+		if ( this._resolveTimestampPromise !== null ) {
+
+			return this._resolveTimestampPromise;
+
+		}
+
+		this._resolveTimestampPromise = new Promise( ( resolve ) => {
+
+			requestAnimationFrame( async () => {
+
+				const renderer = this.getRenderer();
+
+				await renderer.resolveTimestampsAsync( TimestampQuery.COMPUTE );
+				await renderer.resolveTimestampsAsync( TimestampQuery.RENDER );
+
+				const computeFrames = renderer.backend.getTimestampFrames( TimestampQuery.COMPUTE );
+				const renderFrames = renderer.backend.getTimestampFrames( TimestampQuery.RENDER );
+
+				const frameIds = [ ...new Set( [ ...computeFrames, ...renderFrames ] ) ];
+
+				for ( const frameId of frameIds ) {
+
+					const frame = this.getFrameById( frameId );
+
+					if ( frame !== null ) {
+
+						// resolve compute timestamps
+
+						if ( frame.resolvedCompute === false ) {
+
+							if ( frame.computes.length > 0 ) {
+
+								if ( computeFrames.includes( frameId ) ) {
+
+									for ( const stats of frame.computes ) {
+
+										stats.gpu = renderer.backend.getTimestamp( stats.uid );
+
+									}
+
+									frame.resolvedCompute = true;
+
+								}
+
+							} else {
+
+								frame.resolvedCompute = true;
+
+							}
+
+						}
+
+						// resolve render timestamps
+
+						if ( frame.resolvedRender === false ) {
+
+							if ( frame.renders.length > 0 ) {
+
+								if ( renderFrames.includes( frameId ) ) {
+
+									for ( const stats of frame.renders ) {
+
+										stats.gpu = renderer.backend.getTimestamp( stats.uid );
+
+									}
+
+									frame.resolvedRender = true;
+
+								}
+
+							} else {
+
+								frame.resolvedRender = true;
+
+							}
+
+						}
+
+						if ( frame.resolvedCompute === true && frame.resolvedRender === true ) {
+
+							this.resolveFrame( frame );
+
+						}
+
+					}
+
+				}
+
+				this._resolveTimestampPromise = null;
+
+				resolve();
+
+			} );
+
+		} );
+
+		return this._resolveTimestampPromise;
+
+	}
+
+	addFrame( frame ) {
+
+		// Limit to max frames.
+
+		if ( this.frames.length >= this.maxFrames ) {
+
+			const removedFrame = this.frames.shift();
+			delete this.framesLib[ removedFrame.frameId ];
+
+		}
+
+		this.frames.push( frame );
+		this.framesLib[ frame.frameId ] = frame;
+
+		this.resolveTimestamp();
+
+	}
+
+	beginCompute( uid, computeNode ) {
+
+		const frame = this.getFrame();
+
+		if ( ! frame ) return;
+
+		const currentCompute = new ComputeStats( uid, computeNode );
+		currentCompute.timestamp = performance.now();
+		currentCompute.parent = this.currentRender;
+
+		frame.computes.push( currentCompute );
+
+		if ( this.currentRender !== null ) {
+
+			this.currentRender.children.push( currentCompute );
+
+		} else {
+
+			frame.children.push( currentCompute );
+
+		}
+
+		this.currentCompute = currentCompute;
+
+	}
+
+	finishCompute() {
+
+		const frame = this.getFrame();
+
+		if ( ! frame ) return;
+
+		const currentCompute = this.currentCompute;
+		currentCompute.cpu = performance.now() - currentCompute.timestamp;
+
+		this.currentCompute = null;
+
+	}
+
+	beginRender( uid, scene, camera, renderTarget ) {
+
+		const frame = this.getFrame();
+
+		const currentRender = new RenderStats( uid, scene, camera, renderTarget );
+		currentRender.timestamp = performance.now();
+		currentRender.parent = this.currentRender;
+
+		frame.renders.push( currentRender );
+
+		if ( this.currentRender !== null ) {
+
+			this.currentRender.children.push( currentRender );
+
+		} else {
+
+			frame.children.push( currentRender );
+
+		}
+
+		this.currentRender = currentRender;
+
+	}
+
+	finishRender() {
+
+		const currentRender = this.currentRender;
+		currentRender.cpu = performance.now() - currentRender.timestamp;
+
+		this.currentRender = currentRender.parent;
+
+	}
+
+}

+ 200 - 0
examples/jsm/inspector/tabs/Console.js

@@ -0,0 +1,200 @@
+import { Tab } from '../ui/Tab.js';
+
+class Console extends Tab {
+
+	constructor() {
+
+		super( 'Console' );
+
+		this.filters = { info: true, warn: true, error: true };
+		this.filterText = '';
+
+		this.buildHeader();
+
+		this.logContainer = document.createElement( 'div' );
+		this.logContainer.id = 'console-log';
+		this.content.appendChild( this.logContainer );
+
+	}
+
+	buildHeader() {
+
+		const header = document.createElement( 'div' );
+		header.className = 'console-header';
+
+		const filterInput = document.createElement( 'input' );
+		filterInput.type = 'text';
+		filterInput.className = 'console-filter-input';
+		filterInput.placeholder = 'Filter...';
+		filterInput.addEventListener( 'input', ( e ) => {
+
+			this.filterText = e.target.value.toLowerCase();
+			this.applyFilters();
+
+		} );
+
+		const filtersGroup = document.createElement( 'div' );
+		filtersGroup.className = 'console-filters-group';
+
+		Object.keys( this.filters ).forEach( type => {
+
+			const label = document.createElement( 'label' );
+			label.className = 'custom-checkbox';
+			label.style.color = `var(--${type === 'info' ? 'text-primary' : 'color-' + ( type === 'warn' ? 'yellow' : 'red' )})`;
+
+			const checkbox = document.createElement( 'input' );
+			checkbox.type = 'checkbox';
+			checkbox.checked = this.filters[ type ];
+			checkbox.dataset.type = type;
+
+			const checkmark = document.createElement( 'span' );
+			checkmark.className = 'checkmark';
+
+			label.appendChild( checkbox );
+			label.appendChild( checkmark );
+			label.append( type.charAt( 0 ).toUpperCase() + type.slice( 1 ) );
+			filtersGroup.appendChild( label );
+
+		} );
+
+		filtersGroup.addEventListener( 'change', ( e ) => {
+
+			const type = e.target.dataset.type;
+			if ( type in this.filters ) {
+
+				this.filters[ type ] = e.target.checked;
+				this.applyFilters();
+
+			}
+
+		} );
+
+		header.appendChild( filterInput );
+		header.appendChild( filtersGroup );
+		this.content.appendChild( header );
+
+	}
+
+	applyFilters() {
+
+		const messages = this.logContainer.querySelectorAll( '.log-message' );
+		messages.forEach( msg => {
+
+			const type = msg.dataset.type;
+			const text = msg.dataset.rawText.toLowerCase();
+
+			const showByType = this.filters[ type ];
+			const showByText = text.includes( this.filterText );
+
+			msg.classList.toggle( 'hidden', ! ( showByType && showByText ) );
+
+		} );
+
+	}
+
+	_getIcon( type, subType ) {
+
+		let icon;
+
+		if ( subType === 'tip' ) {
+
+			icon = '💭';
+
+		} else if ( subType === 'tsl' ) {
+
+			icon = '✨';
+
+		} else if ( type === 'warn' ) {
+
+			icon = '⚠️';
+
+		} else if ( type === 'error' ) {
+
+			icon = '🔴';
+
+		} else if ( type === 'info' ) {
+
+			icon = 'ℹ️';
+
+		}
+
+		return icon;
+
+	}
+
+	_formatMessage( type, text ) {
+
+		const fragment = document.createDocumentFragment();
+		const prefixMatch = text.match( /^([\w\.]+:\s)/ );
+		let content = text;
+
+		if ( prefixMatch ) {
+
+			const fullPrefix = prefixMatch[ 0 ];
+			const parts = fullPrefix.slice( 0, - 2 ).split( '.' );
+			const shortPrefix = ( parts.length > 1 ? parts[ parts.length - 1 ] : parts[ 0 ] ) + ':';
+
+			const icon = this._getIcon( type, shortPrefix.split( ':' )[ 0 ].toLowerCase() );
+
+			fragment.appendChild( document.createTextNode( icon + ' ' ) );
+
+			const prefixSpan = document.createElement( 'span' );
+			prefixSpan.className = 'log-prefix';
+			prefixSpan.textContent = shortPrefix;
+			fragment.appendChild( prefixSpan );
+			content = text.substring( fullPrefix.length );
+
+		}
+
+		const parts = content.split( /(".*?"|'.*?'|`.*?`)/g ).map( p => p.trim() ).filter( Boolean );
+
+		parts.forEach( ( part, index ) => {
+
+			if ( /^("|'|`)/.test( part ) ) {
+
+				const codeSpan = document.createElement( 'span' );
+				codeSpan.className = 'log-code';
+				codeSpan.textContent = part.slice( 1, - 1 );
+				fragment.appendChild( codeSpan );
+
+			} else {
+
+				if ( index > 0 ) part = ' ' + part; // add space before parts except the first
+				if ( index < parts.length - 1 ) part += ' '; // add space between parts
+
+				fragment.appendChild( document.createTextNode( part ) );
+
+			}
+
+		} );
+
+		return fragment;
+
+	}
+
+	addMessage( type, text ) {
+
+		const msg = document.createElement( 'div' );
+		msg.className = `log-message ${type}`;
+		msg.dataset.type = type;
+		msg.dataset.rawText = text;
+
+		msg.appendChild( this._formatMessage( type, text ) );
+
+		const showByType = this.filters[ type ];
+		const showByText = text.toLowerCase().includes( this.filterText );
+		msg.classList.toggle( 'hidden', ! ( showByType && showByText ) );
+
+		this.logContainer.appendChild( msg );
+		this.logContainer.scrollTop = this.logContainer.scrollHeight;
+		if ( this.logContainer.children.length > 200 ) {
+
+			this.logContainer.removeChild( this.logContainer.firstChild );
+
+		}
+
+	}
+
+}
+
+export { Console };

+ 239 - 0
examples/jsm/inspector/tabs/Parameters.js

@@ -0,0 +1,239 @@
+import { Tab } from '../ui/Tab.js';
+import { List } from '../ui/List.js';
+import { Item } from '../ui/Item.js';
+import { createValueSpan } from '../ui/utils.js';
+import { ValueNumber, ValueSlider, ValueSelect, ValueCheckbox } from '../ui/Values.js';
+
+class ParametersGroup {
+
+	constructor( parameters, name ) {
+
+		this.parameters = parameters;
+		this.name = name;
+
+		this.item = new Item( name );
+
+	}
+
+	add( object, property, ...params ) {
+
+		const value = object[ property ];
+		const type = typeof value;
+
+		let item = null;
+
+		if ( typeof params[ 0 ] === 'object' ) {
+
+			item = this.addSelect( object, property, params[ 0 ] );
+
+		} else if ( type === 'number' ) {
+
+			if ( params.length >= 2 ) {
+
+				item = this.addSlider( object, property, ...params );
+
+			} else {
+
+				item = this.addNumber( object, property, ...params );
+
+			}
+
+		} else if ( type === 'boolean' ) {
+
+			item = this.addBoolean( object, property );
+
+		}
+
+		return item;
+
+	}
+
+	addBoolean( object, property ) {
+
+		const value = object[ property ];
+
+		const editor = new ValueCheckbox( { value } );
+		editor.addEventListener( 'change', ( { value } ) => {
+
+			object[ property ] = value;
+
+		} );
+
+		const description = createValueSpan();
+		description.textContent = property;
+
+		const subItem = new Item( description, editor.domElement );
+		this.item.add( subItem );
+
+		// extends logic to toggle checkbox when clicking on the row
+
+		const itemRow = subItem.domElement.firstChild;
+
+		itemRow.classList.add( 'actionable' );
+		itemRow.addEventListener( 'click', ( e ) => {
+
+			if ( e.target.closest( 'label' ) ) return;
+
+			const checkbox = itemRow.querySelector( 'input[type="checkbox"]' );
+
+			if ( checkbox ) {
+
+				checkbox.checked = ! checkbox.checked;
+				checkbox.dispatchEvent( new Event( 'change' ) );
+
+			}
+
+		} );
+
+		// extend object property
+
+		editor.name = ( name ) => {
+
+			description.textContent = name;
+
+			return editor;
+
+		};
+
+		return editor;
+
+	}
+
+	addSelect( object, property, options ) {
+
+		const value = object[ property ];
+
+		const editor = new ValueSelect( { options, value } );
+		editor.addEventListener( 'change', ( { value } ) => {
+
+			object[ property ] = value;
+
+		} );
+
+		const description = createValueSpan();
+		description.textContent = property;
+
+		const subItem = new Item( description, editor.domElement );
+		this.item.add( subItem );
+
+		const itemRow = subItem.domElement.firstChild;
+		itemRow.classList.add( 'actionable' );
+
+		// extend object property
+
+		editor.name = ( name ) => {
+
+			description.textContent = name;
+
+			return editor;
+
+		};
+
+		return editor;
+
+	}
+
+	addSlider( object, property, min = 0, max = 1, step = 0.01 ) {
+
+		const value = object[ property ];
+
+		const editor = new ValueSlider( { value, min, max, step } );
+		editor.addEventListener( 'change', ( { value } ) => {
+
+			object[ property ] = value;
+
+		} );
+
+		const description = createValueSpan();
+		description.textContent = property;
+
+		const subItem = new Item( description, editor.domElement );
+		this.item.add( subItem );
+
+		const itemRow = subItem.domElement.firstChild;
+		itemRow.classList.add( 'actionable' );
+
+		// extend object property
+
+		editor.name = ( name ) => {
+
+			description.textContent = name;
+
+			return editor;
+
+		};
+
+		return editor;
+
+	}
+
+	addNumber( object, property, ...params ) {
+
+		const value = object[ property ];
+		const [ min, max ] = params;
+
+		const editor = new ValueNumber( { value, min, max } );
+		editor.addEventListener( 'change', ( { value } ) => {
+
+			object[ property ] = value;
+
+		} );
+
+		const description = createValueSpan();
+		description.textContent = property;
+
+		const subItem = new Item( description, editor.domElement );
+		this.item.add( subItem );
+
+		const itemRow = subItem.domElement.firstChild;
+		itemRow.classList.add( 'actionable' );
+
+		// extend object property
+
+		editor.name = ( name ) => {
+
+			description.textContent = name;
+
+			return editor;
+
+		};
+
+		return editor;
+
+	}
+
+}
+
+class Parameters extends Tab {
+
+	constructor() {
+
+		super( 'Parameters' );
+
+		const paramList = new List( 'Property', 'Value' );
+		paramList.domElement.classList.add( 'parameters' );
+		paramList.setGridStyle( '.5fr 1fr' );
+		paramList.domElement.style.minWidth = '300px';
+
+		const scrollWrapper = document.createElement( 'div' );
+		scrollWrapper.className = 'list-scroll-wrapper';
+		scrollWrapper.appendChild( paramList.domElement );
+		this.content.appendChild( scrollWrapper );
+
+		this.paramList = paramList;
+
+	}
+
+	createGroup( name ) {
+
+		const group = new ParametersGroup( this.parameters, name );
+
+		this.paramList.add( group.item );
+
+		return group;
+
+	}
+
+}
+
+export { Parameters };

+ 259 - 0
examples/jsm/inspector/tabs/Performance.js

@@ -0,0 +1,259 @@
+import { Tab } from '../ui/Tab.js';
+import { List } from '../ui/List.js';
+import { Graph } from '../ui/Graph.js';
+import { Item } from '../ui/Item.js';
+import { createValueSpan, setText } from '../ui/utils.js';
+
+class Performance extends Tab {
+
+	constructor() {
+
+		super( 'Performance' );
+
+		const perfList = new List( 'Name', 'CPU', 'GPU', 'Total' );
+		perfList.setGridStyle( 'minmax(200px, 2fr) 80px 80px 80px' );
+		perfList.domElement.style.minWidth = '600px';
+
+		const scrollWrapper = document.createElement( 'div' );
+		scrollWrapper.className = 'list-scroll-wrapper';
+		scrollWrapper.appendChild( perfList.domElement );
+		this.content.appendChild( scrollWrapper );
+
+		//
+
+		const graphContainer = document.createElement( 'div' );
+		graphContainer.className = 'graph-container';
+
+		const graph = new Graph();
+		graph.addLine( 'fps', '--accent-color' );
+		//graph.addLine( 'gpu', '--color-yellow' );
+		graphContainer.append( graph.domElement );
+
+		//
+
+		/*
+		const label = document.createElement( 'label' );
+		label.className = 'custom-checkbox';
+
+		const checkbox = document.createElement( 'input' );
+		checkbox.type = 'checkbox';
+
+		const checkmark = document.createElement( 'span' );
+		checkmark.className = 'checkmark';
+
+		label.appendChild( checkbox );
+		label.appendChild( checkmark );
+		*/
+
+		const graphStats = new Item( 'Graph Stats', createValueSpan(), createValueSpan(), createValueSpan( 'graph-fps-counter' ) );
+		perfList.add( graphStats );
+
+		const graphItem = new Item( graphContainer );
+		graphItem.itemRow.childNodes[ 0 ].style.gridColumn = '1 / -1';
+		graphStats.add( graphItem );
+
+		//
+
+		const frameStats = new Item( 'Frame Stats', createValueSpan(), createValueSpan(), createValueSpan() );
+		perfList.add( frameStats );
+
+		const miscellaneous = new Item( 'Miscellaneous / Idle', createValueSpan(), createValueSpan(), createValueSpan() );
+		miscellaneous.domElement.firstChild.style.backgroundColor = '#00ff0b1a';
+		miscellaneous.domElement.firstChild.classList.add( 'no-hover' );
+		frameStats.add( miscellaneous );
+
+		//
+
+		this.notInUse = new Map();
+		this.frameStats = frameStats;
+		this.graphStats = graphStats;
+		this.graph = graph;
+		this.miscellaneous = miscellaneous;
+
+		//
+
+		this.currentRender = null;
+		this.currentItem = null;
+		this.frameItems = new Map();
+
+	}
+
+	resolveStats( inspector, stats ) {
+
+		const data = inspector.getStatsData( stats.cid );
+
+		let item = data.item;
+
+		if ( item === undefined ) {
+
+			item = new Item( createValueSpan(), createValueSpan(), createValueSpan(), createValueSpan() );
+
+			if ( stats.name ) {
+
+				if ( stats.isComputeStats === true ) {
+
+					stats.name = `${ stats.name } [ Compute ]`;
+
+				}
+
+			} else {
+
+				stats.name = `Unnamed ${ stats.cid }`;
+
+			}
+
+			item.userData.name = stats.name;
+
+			this.currentItem.add( item );
+			data.item = item;
+
+		} else {
+
+			item.userData.name = stats.name;
+
+			if ( this.notInUse.has( stats.cid ) ) {
+
+				item.domElement.firstElementChild.classList.remove( 'alert' );
+
+				this.notInUse.delete( stats.cid );
+
+			}
+
+			const statsIndex = stats.parent.children.indexOf( stats );
+
+			if ( item.parent === null || item.parent.children.indexOf( item ) !== statsIndex ) {
+
+				this.currentItem.add( item, statsIndex );
+
+			}
+
+		}
+
+		setText( item.data[ 0 ], item.userData.name );
+		setText( item.data[ 1 ], data.cpu.toFixed( 2 ) );
+		setText( item.data[ 2 ], data.gpu.toFixed( 2 ) );
+		setText( item.data[ 3 ], data.total.toFixed( 2 ) );
+
+		//
+
+		const previousItem = this.currentItem;
+
+		this.currentItem = item;
+
+		for ( const child of stats.children ) {
+
+			this.resolveStats( inspector, child );
+
+		}
+
+		this.currentItem = previousItem;
+
+		this.frameItems.set( stats.cid, item );
+
+	}
+
+	updateGraph( inspector/*, frame*/ ) {
+
+		this.graph.addPoint( 'fps', inspector.softFPS );
+		this.graph.update();
+
+	}
+
+	addNotInUse( cid, item ) {
+
+		item.domElement.firstElementChild.classList.add( 'alert' );
+
+		this.notInUse.set( cid, {
+			item,
+			time: performance.now()
+		} );
+
+		this.updateNotInUse( cid );
+
+	}
+
+	updateNotInUse( cid ) {
+
+		const { item, time } = this.notInUse.get( cid );
+
+		const current = performance.now();
+		const duration = 5;
+		const remaining = duration - Math.floor( ( current - time ) / 1000 );
+
+		if ( remaining >= 0 ) {
+
+			const counter = '*'.repeat( Math.max( 0, remaining ) );
+			const element = item.domElement.querySelector( '.list-item-cell .value' );
+
+			setText( element, item.userData.name + ' (not in use) ' + counter );
+
+		} else {
+
+			item.domElement.firstElementChild.classList.remove( 'alert' );
+			item.parent.remove( item );
+
+			this.notInUse.delete( cid );
+
+		}
+
+	}
+
+	updateText( inspector, frame ) {
+
+		const oldFrameItems = new Map( this.frameItems );
+
+		this.frameItems.clear();
+		this.currentItem = this.frameStats;
+
+		for ( const child of frame.children ) {
+
+			this.resolveStats( inspector, child );
+
+		}
+
+		// remove unused frame items
+
+		for ( const [ cid, item ] of oldFrameItems ) {
+
+			if ( ! this.frameItems.has( cid ) ) {
+
+				this.addNotInUse( cid, item );
+
+				oldFrameItems.delete( cid );
+
+			}
+
+		}
+
+		// update not in use items
+
+		for ( const cid of this.notInUse.keys() ) {
+
+			this.updateNotInUse( cid );
+
+		}
+
+		//
+
+		setText( 'graph-fps-counter', inspector.fps.toFixed() + ' FPS' );
+
+		//
+
+		setText( this.frameStats.data[ 1 ], frame.cpu.toFixed( 2 ) );
+		setText( this.frameStats.data[ 2 ], frame.gpu.toFixed( 2 ) );
+		setText( this.frameStats.data[ 3 ], frame.total.toFixed( 2 ) );
+
+		//
+
+		setText( this.miscellaneous.data[ 1 ], frame.miscellaneous.toFixed( 2 ) );
+		setText( this.miscellaneous.data[ 2 ], '-' );
+		setText( this.miscellaneous.data[ 3 ], frame.miscellaneous.toFixed( 2 ) );
+		//
+
+		this.currentItem = null;
+
+	}
+
+}
+
+export { Performance };

+ 95 - 0
examples/jsm/inspector/ui/Graph.js

@@ -0,0 +1,95 @@
+
+export class Graph {
+
+	constructor( maxPoints = 512 ) {
+
+		this.maxPoints = maxPoints;
+		this.lines = {};
+		this.limit = 0;
+		this.limitIndex = 0;
+
+		this.domElement = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
+		this.domElement.setAttribute( 'class', 'graph-svg' );
+
+	}
+
+	addLine( id, color ) {
+
+		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})`;
+		this.domElement.appendChild( path );
+
+		this.lines[ id ] = { path, color, points: [] };
+
+	}
+
+	addPoint( lineId, value ) {
+
+		const line = this.lines[ lineId ];
+		if ( ! line ) return;
+
+		line.points.push( value );
+		if ( line.points.length > this.maxPoints ) {
+
+			line.points.shift();
+
+		}
+
+		if ( value > this.limit ) {
+
+			this.limit = value;
+			this.limitIndex = 0;
+
+		}
+
+	}
+
+	resetLimit() {
+
+		this.limit = 0;
+		this.limitIndex = 0;
+
+	}
+
+	update() {
+
+		const svgWidth = this.domElement.clientWidth;
+		const svgHeight = this.domElement.clientHeight;
+		if ( svgWidth === 0 ) return;
+
+		const pointStep = svgWidth / ( this.maxPoints - 1 );
+
+		for ( const id in this.lines ) {
+
+			const line = this.lines[ id ];
+
+			let pathString = `M 0,${ svgHeight }`;
+			for ( let i = 0; i < line.points.length; i ++ ) {
+
+				const x = i * pointStep;
+				const y = svgHeight - ( line.points[ i ] / this.limit ) * svgHeight;
+				pathString += ` L ${ x },${ y }`;
+
+			}
+
+			pathString += ` L ${( line.points.length - 1 ) * pointStep},${ svgHeight } Z`;
+
+			const offset = svgWidth - ( ( line.points.length - 1 ) * pointStep );
+			line.path.setAttribute( 'transform', `translate(${ offset }, 0)` );
+			line.path.setAttribute( 'd', pathString );
+
+		}
+
+		//
+
+		if ( this.limitIndex ++ > this.maxPoints ) {
+
+			this.resetLimit();
+
+		}
+
+	}
+
+}

+ 163 - 0
examples/jsm/inspector/ui/Item.js

@@ -0,0 +1,163 @@
+export class Item {
+
+	constructor( ...data ) {
+
+		this.children = [];
+		this.isOpen = true;
+		this.childrenContainer = null;
+		this.parent = null;
+		this.domElement = document.createElement( 'div' );
+		this.domElement.className = 'list-item-wrapper';
+		this.itemRow = document.createElement( 'div' );
+		this.itemRow.className = 'list-item-row';
+
+		this.userData = {};
+
+		this.data = data;
+		this.data.forEach( ( cellData ) => {
+
+			const cell = document.createElement( 'div' );
+			cell.className = 'list-item-cell';
+			if ( cellData instanceof HTMLElement ) {
+
+				cell.appendChild( cellData );
+
+			} else {
+
+				cell.append( String( cellData ) );
+
+			}
+
+			this.itemRow.appendChild( cell );
+
+		} );
+
+		this.domElement.appendChild( this.itemRow );
+
+		// Bindings
+
+		this.onItemClick = this.onItemClick.bind( this );
+
+	}
+
+	onItemClick( e ) {
+
+		if ( e.target.closest( 'button, a, input, label' ) ) return;
+
+		this.toggle();
+
+	}
+
+	add( item, index = this.children.length ) {
+
+		if ( item.parent !== null ) {
+
+			item.parent.remove( item );
+
+		}
+
+		item.parent = this;
+
+		this.children.splice( index, 0, item );
+
+		this.itemRow.classList.add( 'collapsible' );
+
+		if ( ! this.childrenContainer ) {
+
+			this.childrenContainer = document.createElement( 'div' );
+			this.childrenContainer.className = 'list-children-container';
+			this.domElement.appendChild( this.childrenContainer );
+			this.itemRow.addEventListener( 'click', this.onItemClick );
+
+		}
+
+		this.childrenContainer.insertBefore(
+			item.domElement,
+			this.childrenContainer.children[ index ] || null
+		);
+
+		this.updateToggler();
+		return this;
+
+	}
+
+	remove( item ) {
+
+		const index = this.children.indexOf( item );
+
+		if ( index !== - 1 ) {
+
+			this.children.splice( index, 1 );
+			this.childrenContainer.removeChild( item.domElement );
+
+			item.parent = null;
+
+			if ( this.children.length === 0 ) {
+
+				this.itemRow.classList.remove( 'collapsible' );
+				this.itemRow.removeEventListener( 'click', this.onItemClick );
+
+				this.childrenContainer.remove();
+				this.childrenContainer = null;
+
+			}
+
+			this.updateToggler();
+
+		}
+
+		return this;
+
+	}
+
+	updateToggler() {
+
+		const firstCell = this.itemRow.querySelector( '.list-item-cell:first-child' );
+		let toggler = this.itemRow.querySelector( '.item-toggler' );
+
+		if ( this.children.length > 0 ) {
+
+			if ( ! toggler ) {
+
+				toggler = document.createElement( 'span' );
+				toggler.className = 'item-toggler';
+				firstCell.prepend( toggler );
+
+			}
+
+			if ( this.isOpen ) {
+
+				this.itemRow.classList.add( 'open' );
+
+			}
+
+		} else if ( toggler ) {
+
+			toggler.remove();
+
+		}
+
+	}
+
+	toggle() {
+
+		if ( ! this.childrenContainer ) return;
+		this.isOpen = ! this.isOpen;
+		this.itemRow.classList.toggle( 'open', this.isOpen );
+		this.childrenContainer.classList.toggle( 'closed', ! this.isOpen );
+
+	}
+
+	close() {
+
+		if ( this.isOpen ) {
+
+			this.toggle();
+
+		}
+
+		return this;
+
+	}
+
+}

+ 75 - 0
examples/jsm/inspector/ui/List.js

@@ -0,0 +1,75 @@
+
+export class List {
+
+	constructor( ...headers ) {
+
+		this.headers = headers;
+		this.children = [];
+		this.domElement = document.createElement( 'div' );
+		this.domElement.className = 'list-container';
+		this.domElement.style.padding = '10px';
+		this.id = `list-${Math.random().toString( 36 ).substr( 2, 9 )}`;
+		this.domElement.dataset.listId = this.id;
+
+		this.gridStyleElement = document.createElement( 'style' );
+		this.domElement.appendChild( this.gridStyleElement );
+
+		const headerRow = document.createElement( 'div' );
+		headerRow.className = 'list-header';
+		this.headers.forEach( headerText => {
+
+			const headerCell = document.createElement( 'div' );
+			headerCell.className = 'list-header-cell';
+			headerCell.textContent = headerText;
+			headerRow.appendChild( headerCell );
+
+		} );
+		this.domElement.appendChild( headerRow );
+
+	}
+
+	setGridStyle( gridTemplate ) {
+
+		this.gridStyleElement.textContent = `
+[data-list-id="${this.id}"] > .list-header,
+[data-list-id="${this.id}"] .list-item-row {
+	grid-template-columns: ${gridTemplate};
+}
+`;
+
+	}
+
+	add( item ) {
+
+		if ( item.parent !== null ) {
+
+			item.parent.remove( item );
+
+		}
+
+		item.domElement.classList.add( 'header-wrapper', 'section-start' );
+		item.parent = this;
+
+		this.children.push( item );
+		this.domElement.appendChild( item.domElement );
+
+	}
+
+	remove( item ) {
+
+		const index = this.children.indexOf( item );
+
+		if ( index !== - 1 ) {
+
+			this.children.splice( index, 1 );
+			this.domElement.removeChild( item.domElement );
+
+			item.parent = null;
+
+		}
+
+		return this;
+
+	}
+
+}

+ 170 - 0
examples/jsm/inspector/ui/Profiler.js

@@ -0,0 +1,170 @@
+import { Style } from './Style.js';
+
+export class Profiler {
+
+	constructor() {
+
+		this.tabs = {};
+		this.activeTabId = null;
+		this.isResizing = false;
+		this.lastHeight = 350;
+
+		Style.init();
+
+		this.setupShell();
+		this.setupResizing();
+
+	}
+
+	setupShell() {
+
+		this.domElement = document.createElement( 'div' );
+		this.domElement.id = 'profiler-shell';
+
+		this.toggleButton = document.createElement( 'button' );
+		this.toggleButton.id = 'profiler-toggle';
+		this.toggleButton.innerHTML = `
+<span id="toggle-text">
+	<span id="fps-counter">-</span>
+	<span class="fps-label">FPS</span>
+</span>
+<!-- <span class="toggle-separator"></span> -->
+<span id="toggle-icon">
+	<svg  xmlns="http://www.w3.org/2000/svg"  width="24"  height="24"  viewBox="0 0 24 24"  fill="none"  stroke="currentColor"  stroke-width="2"  stroke-linecap="round"  stroke-linejoin="round"  class="icon icon-tabler icons-tabler-outline icon-tabler-device-ipad-horizontal-search"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11.5 20h-6.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v5.5" /><path d="M9 17h2" /><path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M20.2 20.2l1.8 1.8" /></svg>
+</span>
+`;
+		this.toggleButton.onclick = () => this.togglePanel();
+
+		this.panel = document.createElement( 'div' );
+		this.panel.id = 'profiler-panel';
+
+		const header = document.createElement( 'div' );
+		header.className = 'profiler-header';
+		this.tabsContainer = document.createElement( 'div' );
+		this.tabsContainer.className = 'profiler-tabs';
+
+		const controls = document.createElement( 'div' );
+		controls.style.display = 'flex';
+
+		this.maximizeBtn = document.createElement( 'button' );
+		this.maximizeBtn.id = 'maximize-btn';
+		this.maximizeBtn.innerHTML = '<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="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
+		this.maximizeBtn.onclick = () => this.toggleMaximize();
+
+		const hideBtn = document.createElement( 'button' );
+		hideBtn.id = 'hide-panel-btn';
+		hideBtn.textContent = '-';
+		hideBtn.onclick = () => this.togglePanel();
+
+		controls.append( this.maximizeBtn, hideBtn );
+		header.append( this.tabsContainer, controls );
+
+		this.contentWrapper = document.createElement( 'div' );
+		this.contentWrapper.className = 'profiler-content-wrapper';
+
+		const resizer = document.createElement( 'div' );
+		resizer.className = 'panel-resizer';
+
+		this.panel.append( resizer, header, this.contentWrapper );
+
+		this.domElement.append( this.toggleButton, this.panel );
+
+	}
+
+	setupResizing() {
+
+		const resizer = this.panel.querySelector( '.panel-resizer' );
+
+		const onStart = ( e ) => {
+
+			this.isResizing = true;
+			this.panel.classList.add( 'resizing' );
+			const startY = e.clientY || e.touches[ 0 ].clientY;
+			const startHeight = this.panel.offsetHeight;
+
+			const onMove = ( moveEvent ) => {
+
+				if ( ! this.isResizing ) return;
+				moveEvent.preventDefault();
+				const currentY = moveEvent.clientY || moveEvent.touches[ 0 ].clientY;
+				const newHeight = startHeight - ( currentY - startY );
+				if ( newHeight > 100 && newHeight < window.innerHeight - 50 ) {
+
+					this.panel.style.height = `${newHeight}px`;
+
+				}
+
+			};
+
+			const onEnd = () => {
+
+				this.isResizing = false;
+				this.panel.classList.remove( 'resizing' );
+				document.removeEventListener( 'mousemove', onMove );
+				document.removeEventListener( 'mouseup', onEnd );
+				document.removeEventListener( 'touchmove', onMove );
+				document.removeEventListener( 'touchend', onEnd );
+				if ( ! this.panel.classList.contains( 'maximized' ) ) {
+
+					this.lastHeight = this.panel.offsetHeight;
+
+				}
+
+			};
+
+			document.addEventListener( 'mousemove', onMove );
+			document.addEventListener( 'mouseup', onEnd );
+			document.addEventListener( 'touchmove', onMove, { passive: false } );
+			document.addEventListener( 'touchend', onEnd );
+
+		};
+
+		resizer.addEventListener( 'mousedown', onStart );
+		resizer.addEventListener( 'touchstart', onStart );
+
+	}
+
+	toggleMaximize() {
+
+		if ( this.panel.classList.contains( 'maximized' ) ) {
+
+			this.panel.classList.remove( 'maximized' );
+			this.panel.style.height = `${ this.lastHeight }px`;
+			this.maximizeBtn.innerHTML = '<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="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
+
+		} else {
+
+			this.lastHeight = this.panel.offsetHeight;
+			this.panel.classList.add( 'maximized' );
+			this.panel.style.height = '100vh';
+			this.maximizeBtn.innerHTML = '<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="8" y="8" width="12" height="12" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>';
+
+		}
+
+	}
+
+	addTab( tab ) {
+
+		this.tabs[ tab.id ] = tab;
+		tab.button.onclick = () => this.setActiveTab( tab.id );
+		this.tabsContainer.appendChild( tab.button );
+		this.contentWrapper.appendChild( tab.content );
+
+	}
+
+	setActiveTab( id ) {
+
+		if ( this.activeTabId ) this.tabs[ this.activeTabId ].setActive( false );
+		this.activeTabId = id;
+		this.tabs[ id ].setActive( true );
+
+	}
+
+	togglePanel() {
+
+		this.panel.classList.toggle( 'visible' );
+		this.toggleButton.classList.toggle( 'hidden' );
+
+	}
+
+}

+ 635 - 0
examples/jsm/inspector/ui/Style.js

@@ -0,0 +1,635 @@
+export class Style {
+
+	static init() {
+
+		if ( document.getElementById( 'profiler-styles' ) ) return;
+
+		const css = `
+:root {
+	--profiler-bg: #1e1e24;
+	--profiler-header: #2a2a33;
+	--profiler-border: #4a4a5a;
+	--text-primary: #e0e0e0;
+	--text-secondary: #9a9aab;
+	--accent-color: #00aaff;
+	--color-green: #4caf50;
+	--color-yellow: #ffc107;
+	--color-red: #f44336;
+	--font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+	--font-mono: 'Fira Code', 'Courier New', Courier, monospace;
+}
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Fira+Code&display=swap');
+
+#profiler-panel * {
+	text-transform: initial;
+	line-height: normal;
+}
+
+#profiler-toggle {
+	position: fixed;
+	top: 15px;
+	right: 15px;
+	background-color: rgba(30, 30, 36, 0.85);
+	border: 1px solid #4a4a5a54;
+	border-radius: 6px 12px 12px 6px;
+	color: var(--text-primary);
+	cursor: pointer;
+	z-index: 1001;
+	transition: all 0.2s ease-in-out;
+	font-size: 14px;
+	backdrop-filter: blur(8px);
+	box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
+	display: flex;
+	align-items: stretch;
+	padding: 0;
+	overflow: hidden;
+	font-family: var(--font-family);
+}
+
+#profiler-toggle:hover {
+	border-color: var(--accent-color);
+}
+
+#profiler-toggle.hidden {
+	opacity: 0;
+	pointer-events: none;
+}
+
+#toggle-icon {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 40px;
+	font-size: 20px;
+	transition: background-color 0.2s;
+}
+
+#profiler-toggle:hover #toggle-icon {
+	background-color: rgba(255, 255, 255, 0.05);
+}
+
+.toggle-separator {
+	width: 1px;
+	background-color: var(--profiler-border);
+}
+
+#toggle-text {
+	display: flex;
+	align-items: baseline;
+	padding: 8px 14px;
+	min-width: 80px;
+	justify-content: right;
+}
+
+#toggle-text .fps-label {
+	font-size: 0.7em;
+	margin-left: 10px;
+    color: #999;
+}
+
+#profiler-panel {
+	position: fixed;
+	z-index: 1001 !important;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	height: 350px;
+	background-color: var(--profiler-bg);
+	border-top: 2px solid var(--profiler-border);
+	color: var(--text-primary);
+	display: flex;
+	flex-direction: column;
+	z-index: 1000;
+	/*box-shadow: 0 -5px 25px rgba(0, 0, 0, 0.5);*/
+	transform: translateY(100%);
+	transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), height 0.3s ease-out;
+	font-family: var(--font-mono);
+}
+
+#profiler-panel.resizing {
+	transition: none;
+}
+
+#profiler-panel.visible {
+	transform: translateY(0);
+}
+
+#profiler-panel.maximized {
+	height: 100vh;
+}
+
+
+.panel-resizer {
+	position: absolute;
+	top: -2px;
+	left: 0;
+	width: 100%;
+	height: 5px;
+	cursor: ns-resize;
+	z-index: 1001;
+}
+
+.profiler-header {
+	display: flex;
+	background-color: var(--profiler-header);
+	border-bottom: 1px solid var(--profiler-border);
+	flex-shrink: 0;
+	justify-content: space-between;
+	align-items: stretch;
+}
+
+.profiler-tabs {
+	display: flex;
+}
+
+.tab-btn {
+	background: transparent;
+	border: none;
+	/*border-right: 1px solid var(--profiler-border);*/
+	color: var(--text-secondary);
+	padding: 8px 18px;
+	cursor: pointer;
+	display: flex;
+	align-items: center;
+	font-family: var(--font-family);
+    font-weight: 600;
+	font-size: 14px;
+}
+
+.tab-btn.active {
+    border-bottom: 2px solid var(--accent-color);
+	color: white;
+}
+
+#maximize-btn,
+#hide-panel-btn {
+	background: transparent;
+	border: none;
+	border-left: 1px solid var(--profiler-border);
+	color: var(--text-secondary);
+	width: 45px;
+	cursor: pointer;
+	transition: all 0.2s;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+#maximize-btn:hover,
+#hide-panel-btn:hover {
+	background-color: rgba(255, 255, 255, 0.1);
+	color: var(--text-primary);
+}
+
+.profiler-content-wrapper {
+	flex-grow: 1;
+	overflow: hidden;
+	position: relative;
+}
+
+.profiler-content {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	overflow-y: auto;
+	font-size: 13px;
+	visibility: hidden;
+	opacity: 0;
+	transition: opacity 0.2s, visibility 0.2s;
+	box-sizing: border-box;
+	display: flex;
+	flex-direction: column;
+}
+
+.profiler-content.active {
+	visibility: visible;
+	opacity: 1;
+}
+
+.profiler-content {
+	overflow: auto; /* make sure scrollbars can appear */
+}
+
+.profiler-content::-webkit-scrollbar {
+	width: 8px;
+	height: 8px;
+}
+
+.profiler-content::-webkit-scrollbar-track {
+	background: transparent;
+}
+
+.profiler-content::-webkit-scrollbar-thumb {
+	background-color: rgba(0, 0, 0, 0.25);
+	border-radius: 10px;
+	transition: background 0.3s ease;
+}
+
+.profiler-content::-webkit-scrollbar-thumb:hover {
+	background-color: rgba(0, 0, 0, 0.4);
+}
+
+.profiler-content::-webkit-scrollbar-corner {
+	background: transparent;
+}
+
+.profiler-content {
+	scrollbar-width: thin; /* "auto" | "thin" */
+	scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
+}
+
+.list-item-row {
+	display: grid;
+	align-items: center;
+	padding: 4px 8px;
+	border-radius: 3px;
+	transition: background-color 0.2s;
+	gap: 10px;
+	border-bottom: none;
+}
+
+.list-item-wrapper {
+	margin-top: 2px;
+	margin-bottom: 2px;
+}
+
+.list-item-wrapper:first-child {
+	/*margin-top: 0;*/
+}
+
+.list-item-wrapper:not(.header-wrapper):nth-child(odd) > .list-item-row {
+	background-color: rgba(0,0,0,0.1);
+}
+
+.list-item-wrapper.header-wrapper>.list-item-row {
+	color: var(--accent-color);
+	background-color: rgba(0, 170, 255, 0.1);
+}
+
+.list-item-wrapper.header-wrapper>.list-item-row>.list-item-cell:first-child {
+	font-weight: 600;
+}
+
+.list-item-row.collapsible,
+.list-item-row.actionable {
+	cursor: pointer;
+}
+
+.list-item-row.collapsible {
+	background-color: rgba(0, 170, 255, 0.15) !important;
+}
+
+.list-item-row.collapsible.alert,
+.list-item-row.alert {
+	background-color: rgba(244, 67, 54, 0.1) !important;
+}
+
+@media (hover: hover) {
+
+	.list-item-row:hover:not(.collapsible):not(.no-hover),
+	.list-item-row:hover:not(.no-hover),
+	.list-item-row.actionable:hover,
+	.list-item-row.collapsible.actionable:hover {
+		background-color: rgba(255, 255, 255, 0.05) !important;
+	}
+
+	.list-item-row.collapsible:hover {
+		background-color: rgba(0, 170, 255, 0.25) !important;
+	}
+
+}
+
+.list-item-cell {
+	white-space: pre;
+	display: flex;
+	align-items: center;
+}
+
+.list-item-cell:not(:first-child) {
+	justify-content: flex-end;
+	font-weight: 600;
+}
+
+.list-header {
+	display: grid;
+	align-items: center;
+	padding: 4px 8px;
+	font-weight: 600;
+	color: var(--text-secondary);
+	padding-bottom: 6px;
+	border-bottom: 1px solid var(--profiler-border);
+	margin-bottom: 5px;
+	gap: 10px;
+}
+
+.list-item-wrapper.section-start {
+	margin-top: 5px;
+	margin-bottom: 5px;
+}
+
+.list-header .list-header-cell:not(:first-child) {
+	text-align: right;
+}
+
+.list-children-container {
+	padding-left: 1.5em;
+	overflow: hidden;
+	max-height: 1000px;
+	transition: max-height 0.1s ease-out;
+	margin-top: 2px;
+}
+
+.list-children-container.closed {
+	max-height: 0;
+}
+
+.item-toggler {
+	display: inline-block;
+	width: 1.5em;
+	text-align: left;
+}
+
+.list-item-row.open .item-toggler::before {
+	content: '-';
+}
+
+.list-item-row:not(.open) .item-toggler::before {
+	content: '+';
+}
+
+.list-item-cell .value.good {
+	color: var(--color-green);
+}
+
+.list-item-cell .value.warn {
+	color: var(--color-yellow);
+}
+
+.list-item-cell .value.bad {
+	color: var(--color-red);
+}
+
+.list-scroll-wrapper {
+	overflow-x: auto;
+	width: 100%;
+}
+
+.list-container.parameters .list-item-row:not(.collapsible) {
+	height: 31px;
+}
+
+.graph-container {
+	width: 100%;
+	box-sizing: border-box;
+	padding: 8px 0;
+	position: relative;
+}
+
+.graph-svg {
+	width: 100%;
+	height: 80px;
+	background-color: #2a2a33;
+	border: 1px solid var(--profiler-border);
+	border-radius: 4px;
+}
+
+.graph-path {
+	stroke-width: 2;
+	fill-opacity: 0.4;
+}
+
+.console-header {
+	padding: 10px;
+	border-bottom: 1px solid var(--profiler-border);
+	display: flex;
+	gap: 20px;
+	flex-shrink: 0;
+	align-items: center;
+	justify-content: space-between;
+}
+
+.console-filters-group {
+	display: flex;
+	gap: 20px;
+}
+
+.console-filter-input {
+	background-color: var(--profiler-bg);
+	border: 1px solid var(--profiler-border);
+	color: var(--text-primary);
+	border-radius: 4px;
+	padding: 4px 8px;
+	font-family: var(--font-mono);
+	flex-grow: 1;
+	max-width: 300px;
+	border-radius: 15px;
+}
+
+#console-log {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+	padding: 10px;
+	overflow-y: auto;
+	flex-grow: 1;
+}
+
+.log-message {
+	padding: 2px 5px;
+	white-space: pre-wrap;
+	word-break: break-all;
+	border-radius: 3px;
+	line-height: 1.5 !important;
+}
+
+.log-message.hidden {
+	display: none;
+}
+
+.log-message.info {
+	color: var(--text-primary);
+}
+
+.log-message.warn {
+	color: var(--color-yellow);
+}
+
+.log-message.error {
+	color: #f9dedc;
+	background-color: rgba(244, 67, 54, 0.1);
+}
+
+.log-prefix {
+	color: var(--text-secondary);
+	margin-right: 8px;
+}
+
+.log-code {
+	background-color: rgba(255, 255, 255, 0.1);
+	border-radius: 3px;
+	padding: 1px 4px;
+}
+
+.thumbnail-container {
+	display: flex;
+	align-items: center;
+}
+
+.thumbnail-svg {
+	width: 40px;
+	height: 22.5px;
+	flex-shrink: 0;
+	margin-right: 8px;
+}
+
+.param-control {
+	display: flex;
+	align-items: center;
+	justify-content: flex-end;
+	gap: 10px;
+	width: 100%;
+}
+
+.param-control input,
+.param-control select,
+.param-control button {
+	background-color: var(--profiler-bg);
+	border: 1px solid var(--profiler-border);
+	color: var(--text-primary);
+	border-radius: 4px;
+	padding: 4px 6px;
+	padding-bottom: 2px;
+	font-family: var(--font-mono);
+	width: 100%;
+	box-sizing: border-box;
+}
+
+.param-control select {
+	padding-top: 3px;
+	padding-bottom: 1px;
+}
+
+.param-control input[type="number"] {
+	cursor: ns-resize;
+}
+
+.param-control input[type="color"] {
+	padding: 2px;
+}
+
+.param-control button {
+	cursor: pointer;
+	transition: background-color 0.2s;
+}
+
+.param-control button:hover {
+	background-color: var(--profiler-header);
+}
+
+.param-control-vector {
+	display: flex;
+	gap: 5px;
+}
+
+.custom-checkbox {
+	display: inline-flex;
+	align-items: center;
+	cursor: pointer;
+	gap: 8px;
+}
+
+.custom-checkbox input {
+	display: none;
+}
+
+.custom-checkbox .checkmark {
+	width: 14px;
+	height: 14px;
+	border: 1px solid var(--profiler-border);
+	border-radius: 3px;
+	display: inline-flex;
+	justify-content: center;
+	align-items: center;
+	transition: background-color 0.2s, border-color 0.2s;
+}
+
+.custom-checkbox .checkmark::after {
+	content: '';
+	width: 8px;
+	height: 8px;
+	background-color: var(--accent-color);
+	border-radius: 1px;
+	display: block;
+	transform: scale(0);
+	transition: transform 0.2s;
+}
+
+.custom-checkbox input:checked+.checkmark {
+	border-color: var(--accent-color);
+}
+
+.custom-checkbox input:checked+.checkmark::after {
+	transform: scale(1);
+}
+
+.param-control input[type="range"] {
+	-webkit-appearance: none;
+	appearance: none;
+	width: 100%;
+	height: 16px;
+	background: var(--profiler-header);
+	border-radius: 5px;
+	border: 1px solid var(--profiler-border);
+	outline: none;
+	padding: 0px;
+	padding-top: 8px;
+}
+
+.param-control input[type="range"]::-webkit-slider-thumb {
+	-webkit-appearance: none;
+	appearance: none;
+	width: 18px;
+	height: 18px;
+	background: var(--profiler-bg);
+	border: 1px solid var(--accent-color);
+	border-radius: 3px;
+	cursor: pointer;
+	margin-top: -8px;
+}
+
+.param-control input[type="range"]::-moz-range-thumb {
+	width: 18px;
+	height: 18px;
+	background: var(--profiler-bg);
+	border: 2px solid var(--accent-color);
+	border-radius: 3px;
+	cursor: pointer;
+}
+
+.param-control input[type="range"]::-moz-range-track {
+	width: 100%;
+	height: 16px;
+	background: var(--profiler-header);
+	border-radius: 5px;
+	border: 1px solid var(--profiler-border);
+}
+
+@media screen and (max-width: 768px) and (orientation: portrait) {
+
+	.console-filter-input {
+		max-width: 100px;
+	}
+
+}
+`;
+		const styleElement = document.createElement( 'style' );
+		styleElement.id = 'profiler-styles';
+		styleElement.textContent = css;
+		document.head.appendChild( styleElement );
+
+	}
+
+}

+ 43 - 0
examples/jsm/inspector/ui/Tab.js

@@ -0,0 +1,43 @@
+export class Tab {
+
+	constructor( title ) {
+
+		this.id = title.toLowerCase();
+		this.button = document.createElement( 'button' );
+		this.button.className = 'tab-btn';
+		this.button.textContent = title;
+
+		this.content = document.createElement( 'div' );
+		this.content.id = `${this.id}-content`;
+		this.content.className = 'profiler-content';
+
+		this.isVisible = true;
+
+	}
+
+	setActive( isActive ) {
+
+		this.button.classList.toggle( 'active', isActive );
+		this.content.classList.toggle( 'active', isActive );
+
+	}
+
+	show() {
+
+		this.content.style.display = '';
+		this.button.style.display = '';
+
+		this.isVisible = true;
+
+	}
+
+	hide() {
+
+		this.content.style.display = 'none';
+		this.button.style.display = 'none';
+
+		this.isVisible = false;
+
+	}
+
+}

+ 321 - 0
examples/jsm/inspector/ui/Values.js

@@ -0,0 +1,321 @@
+import { EventDispatcher } from 'three';
+
+class Value extends EventDispatcher {
+
+	constructor() {
+
+		super();
+
+		this.domElement = document.createElement( 'div' );
+		this.domElement.className = 'param-control';
+
+		this._onChangeFunction = null;
+
+		this.addEventListener( 'change', ( e ) => {
+
+			// defer to avoid issues when changing multiple values in the same call stack
+
+			requestAnimationFrame( () => {
+
+				if ( this._onChangeFunction ) this._onChangeFunction( e.value );
+
+			} );
+
+		} );
+
+	}
+
+	getValue() {
+
+		return null;
+
+	}
+
+	dispatchChange() {
+
+		this.dispatchEvent( { type: 'change', value: this.getValue() } );
+
+	}
+
+	onChange( callback ) {
+
+		this._onChangeFunction = callback;
+
+		return this;
+
+	}
+
+}
+
+class ValueNumber extends Value {
+
+	constructor( { value = 0, step = 0.1, min = - Infinity, max = Infinity } ) {
+
+		super();
+
+		this.input = document.createElement( 'input' );
+		this.input.type = 'number';
+		this.input.value = value;
+		this.input.step = step;
+		this.input.min = min;
+		this.input.max = max;
+		this.input.addEventListener( 'change', this._onChangeValue.bind( this ) );
+		this.domElement.appendChild( this.input );
+		this.addDragHandler();
+
+	}
+
+	_onChangeValue() {
+
+		const value = parseFloat( this.input.value );
+		const min = parseFloat( this.input.min );
+		const max = parseFloat( this.input.max );
+
+		if ( value > max ) {
+
+			this.input.value = max;
+
+		} else if ( value < min ) {
+
+			this.input.value = min;
+
+		} else if ( isNaN( value ) ) {
+
+			this.input.value = min;
+
+		}
+
+		this.dispatchChange();
+
+	}
+
+	step( value ) {
+
+		this.input.step = value;
+		return this;
+
+	}
+
+	addDragHandler() {
+
+		let isDragging = false;
+		let startY, startValue;
+
+		this.input.addEventListener( 'mousedown', ( e ) => {
+
+			isDragging = true;
+			startY = e.clientY;
+			startValue = parseFloat( this.input.value );
+			document.body.style.cursor = 'ns-resize';
+
+		} );
+
+		document.addEventListener( 'mousemove', ( e ) => {
+
+			if ( isDragging ) {
+
+				const deltaY = startY - e.clientY;
+				const step = parseFloat( this.input.step ) || 1;
+				const min = parseFloat( this.input.min );
+				const max = parseFloat( this.input.max );
+
+				let stepSize = step;
+
+				if ( ! isNaN( max ) && isFinite( min ) ) {
+
+					stepSize = ( max - min ) / 100;
+
+				}
+
+				const change = deltaY * stepSize;
+
+				let newValue = startValue + change;
+				newValue = Math.max( min, Math.min( newValue, max ) );
+
+				const precision = ( String( step ).split( '.' )[ 1 ] || [] ).length;
+				this.input.value = newValue.toFixed( precision );
+
+				this.input.dispatchEvent( new Event( 'input' ) );
+
+				this.dispatchChange();
+
+			}
+
+		} );
+
+		document.addEventListener( 'mouseup', () => {
+
+			if ( isDragging ) {
+
+				isDragging = false;
+				document.body.style.cursor = 'default';
+
+			}
+
+		} );
+
+	}
+
+	getValue() {
+
+		return parseFloat( this.input.value );
+
+	}
+
+}
+
+class ValueCheckbox extends Value {
+
+	constructor( { value = false } ) {
+
+		super();
+
+		const label = document.createElement( 'label' );
+		label.className = 'custom-checkbox';
+
+		const checkbox = document.createElement( 'input' );
+		checkbox.type = 'checkbox';
+		checkbox.checked = value;
+		this.checkbox = checkbox;
+
+		const checkmark = document.createElement( 'span' );
+		checkmark.className = 'checkmark';
+
+		label.appendChild( checkbox );
+		label.appendChild( checkmark );
+		this.domElement.appendChild( label );
+
+		checkbox.addEventListener( 'change', () => {
+
+			this.dispatchChange();
+
+		} );
+
+	}
+
+	getValue() {
+
+		return this.checkbox.checked;
+
+	}
+
+}
+
+class ValueSlider extends Value {
+
+	constructor( { value = 0, min = 0, max = 1, step = 0.01 } ) {
+
+		super();
+
+		this.slider = document.createElement( 'input' );
+		this.slider.type = 'range';
+		this.slider.value = value;
+		this.slider.min = min;
+		this.slider.max = max;
+		this.slider.step = step;
+
+		const numberValue = new ValueNumber( { value, min, max, step } );
+		this.numberInput = numberValue.input;
+		this.numberInput.style.width = '60px';
+		this.numberInput.style.flexShrink = '0';
+
+		this.domElement.append( this.slider, this.numberInput );
+
+		this.slider.addEventListener( 'input', () => {
+
+			this.numberInput.value = this.slider.value;
+
+			this.dispatchChange();
+
+		} );
+
+		numberValue.addEventListener( 'change', () => {
+
+			this.slider.value = parseFloat( this.numberInput.value );
+
+			this.dispatchChange();
+
+		} );
+
+	}
+
+	getValue() {
+
+		return parseFloat( this.slider.value );
+
+	}
+
+	step( value ) {
+
+		this.slider.step = value;
+		this.numberInput.step = value;
+
+		return this;
+
+	}
+
+}
+
+class ValueSelect extends Value {
+
+	constructor( { options = [], value = '' } ) {
+
+		super();
+
+		const select = document.createElement( 'select' );
+		const type = typeof value;
+
+		const createOption = ( name, optionValue ) => {
+
+			const optionEl = document.createElement( 'option' );
+			optionEl.value = optionValue;
+			optionEl.textContent = name;
+
+			if ( optionValue == value ) optionEl.selected = true;
+
+			select.appendChild( optionEl );
+
+			return optionEl;
+
+		};
+
+		if ( Array.isArray( options ) ) {
+
+			options.forEach( opt => createOption( opt, opt ) );
+
+		} else {
+
+			Object.entries( options ).forEach( ( [ key, value ] ) => createOption( key, value ) );
+
+		}
+
+		this.domElement.appendChild( select );
+
+		//
+
+		select.addEventListener( 'change', () => {
+
+			this.dispatchChange();
+
+		} );
+
+		this.select = select;
+		this.type = type;
+
+	}
+
+	getValue() {
+
+		const value = this.select.value;
+		const type = this.type;
+
+		if ( type === 'number' ) return parseFloat( value );
+		if ( type === 'boolean' ) return value === 'true';
+
+		return value;
+
+	}
+
+}
+
+export { Value, ValueNumber, ValueCheckbox, ValueSlider, ValueSelect };

+ 42 - 0
examples/jsm/inspector/ui/utils.js

@@ -0,0 +1,42 @@
+export function ease( target, current, deltaTime, duration ) {
+
+	if ( duration <= 0 ) return current;
+
+	const t = Math.min( 1, deltaTime / duration );
+
+	target += ( current - target ) * t;
+
+	return target;
+
+}
+
+export function createValueSpan( id = null ) {
+
+	const span = document.createElement( 'span' );
+	span.className = 'value';
+
+	if ( id !== null ) span.id = id;
+
+	return span;
+
+}
+
+export function setText( element, text ) {
+
+	const el = element instanceof HTMLElement ? element : document.getElementById( element );
+
+	if ( el && el.textContent !== text ) {
+
+		el.textContent = text;
+
+	}
+
+}
+
+export function getText( element ) {
+
+	const el = element instanceof HTMLElement ? element : document.getElementById( element );
+
+	return el ? el.textContent : null;
+
+}

+ 2 - 0
examples/jsm/tsl/display/GaussianBlurNode.js

@@ -176,6 +176,7 @@ class GaussianBlurNode extends TempNode {
 
 		this._passDirection.value.set( 1, 0 );
 
+		_quadMesh.name = 'Gaussian Blur [ Horizontal Pass ]';
 		_quadMesh.render( renderer );
 
 		// vertical
@@ -185,6 +186,7 @@ class GaussianBlurNode extends TempNode {
 
 		this._passDirection.value.set( 0, 1 );
 
+		_quadMesh.name = 'Gaussian Blur [ Vertical Pass ]';
 		_quadMesh.render( renderer );
 
 		// restore

+ 1 - 0
examples/jsm/tsl/display/SSGINode.js

@@ -351,6 +351,7 @@ class SSGINode extends TempNode {
 		//
 
 		_quadMesh.material = this._material;
+		_quadMesh.name = 'SSGI';
 
 		// clear
 

+ 1 - 0
examples/jsm/tsl/display/TRAANode.js

@@ -349,6 +349,7 @@ class TRAANode extends TempNode {
 
 		renderer.setRenderTarget( this._resolveRenderTarget );
 		_quadMesh.material = this._resolveMaterial;
+		_quadMesh.name = 'TRAA';
 		_quadMesh.render( renderer );
 		renderer.setRenderTarget( null );
 

+ 14 - 15
examples/webgpu_backdrop_water.html

@@ -1,15 +1,21 @@
 <!DOCTYPE html>
 <html lang="en">
 	<head>
-		<title>three.js - WebGPU - Backdrop Water</title>
+		<title>three.js webgpu - backdrop water</title>
 		<meta charset="utf-8">
 		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
-		<link type="text/css" rel="stylesheet" href="main.css">
+		<link type="text/css" rel="stylesheet" href="example.css">
 	</head>
 	<body>
 
 		<div id="info">
-			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> WebGPU - Backdrop Water
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>backdrop water</span>
+			</div>
+
+			<small>Water refraction with depth effect.</small>
 		</div>
 
 		<script type="importmap">
@@ -29,20 +35,17 @@
 			import { color, vec2, pass, linearDepth, normalWorld, triplanarTexture, texture, objectPosition, screenUV, viewportLinearDepth, viewportDepthTexture, viewportSharedTexture, mx_worley_noise_float, positionWorld, time } from 'three/tsl';
 			import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js';
 
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+
 			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
-			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-
-			import Stats from 'three/addons/libs/stats.module.js';
-
 			let camera, scene, renderer;
 			let mixer, objects, clock;
 			let model, floor, floorPosition;
 			let postProcessing;
 			let controls;
-			let stats;
 
 			init();
 
@@ -195,11 +198,9 @@
 				renderer.setPixelRatio( window.devicePixelRatio );
 				renderer.setSize( window.innerWidth, window.innerHeight );
 				renderer.setAnimationLoop( animate );
+				renderer.inspector = new Inspector();
 				document.body.appendChild( renderer.domElement );
 
-				stats = new Stats();
-				document.body.appendChild( stats.dom );
-
 				controls = new OrbitControls( camera, renderer.domElement );
 				controls.minDistance = 1;
 				controls.maxDistance = 10;
@@ -211,11 +212,11 @@
 
 				// gui
 
-				const gui = new GUI();
+				const gui = renderer.inspector.createParameters( 'Settings' );
 
 				floorPosition = new THREE.Vector3( 0, .2, 0 );
 
-				gui.add( floorPosition, 'y', - 1, 1, .001 ).name( 'position' );
+				gui.add( floorPosition, 'y', - 1, 1, .001 ).name( 'floor position' );
 
 				// post processing
 
@@ -250,8 +251,6 @@
 
 			function animate() {
 
-				stats.update();
-
 				controls.update();
 
 				const delta = clock.getDelta();

+ 22 - 40
examples/webgpu_compute_birds.html

@@ -1,25 +1,24 @@
 <!DOCTYPE html>
 <html lang="en">
 	<head>
-		<title>three.js webgpu - compute - flocking</title>
+		<title>three.js webgpu - compute birds</title>
 		<meta charset="utf-8">
 		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
-		<link type="text/css" rel="stylesheet" href="main.css">
-		<style>
-			body {
-				background-color: #fff;
-				color: #444;
-			}
-			a {
-				color:#08f;
-			}
-		</style>
+		<link type="text/css" rel="stylesheet" href="example.css">
 	</head>
 	<body>
 
 		<div id="info">
-			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - webgpu compute birds<br/>
-			Move mouse to disturb birds.
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a>
+				<span>compute birds</span>
+			</div>
+
+			<small>
+				Move mouse to disturb birds.
+			</small>
 		</div>
 
 		<script type="importmap">
@@ -39,13 +38,13 @@
 			import * as THREE from 'three/webgpu';
 			import { uniform, varying, vec4, add, sub, max, dot, sin, mat3, uint, negate, instancedArray, cameraProjectionMatrix, cameraViewMatrix, positionLocal, modelWorldMatrix, sqrt, property, float, Fn, If, cos, Loop, Continue, normalize, instanceIndex, length, vertexIndex } from 'three/tsl';
 
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
-			import Stats from 'stats-gl';
-			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
 			import WebGPU from 'three/addons/capabilities/WebGPU.js';
 
-			let container, stats;
+			let container;
 			let camera, scene, renderer;
 
 			let last = performance.now();
@@ -163,8 +162,9 @@
 				renderer = new THREE.WebGPURenderer( { antialias: true, forceWebGL: false } );
 				renderer.setPixelRatio( window.devicePixelRatio );
 				renderer.setSize( window.innerWidth, window.innerHeight );
-				renderer.setAnimationLoop( animate );
+				renderer.setAnimationLoop( render );
 				renderer.toneMapping = THREE.NeutralToneMapping;
+				renderer.inspector = new Inspector();
 				container.appendChild( renderer.domElement );
 
 				const controls = new OrbitControls( camera );
@@ -411,7 +411,8 @@
 					// Write back the final velocity to storage
 					velocityStorage.element( birdIndex ).assign( velocity );
 
-				} )().compute( BIRDS );
+				} )().compute( BIRDS ).setName( 'Birds Velocity' );
+
 				computePosition = Fn( () => {
 
 					const { deltaTime } = effectController;
@@ -423,30 +424,19 @@
 					const modValue = phase.add( deltaTime ).add( length( velocity.xz ).mul( deltaTime ).mul( 3.0 ) ).add( max( velocity.y, 0.0 ).mul( deltaTime ).mul( 6.0 ) );
 					phaseStorage.element( instanceIndex ).assign( modValue.mod( 62.83 ) );
 
-				} )().compute( BIRDS );
+				} )().compute( BIRDS ).setName( 'Birds Position' );
 
 				scene.add( birdMesh );
 
-				stats = new Stats( {
-					precision: 3,
-					horizontal: false,
-					trackGPU: true,
-					trackCPT: true
-				} );
-				stats.init( renderer );
-				container.appendChild( stats.dom );
-
 				container.style.touchAction = 'none';
 				container.addEventListener( 'pointermove', onPointerMove );
 
 				window.addEventListener( 'resize', onWindowResize );
 
-				const gui = new GUI();
-
+				const gui = renderer.inspector.createParameters( 'Birds settings' );
 				gui.add( effectController.separation, 'value', 0.0, 100.0, 1.0 ).name( 'Separation' );
 				gui.add( effectController.alignment, 'value', 0.0, 100, 0.001 ).name( 'Alignment ' );
 				gui.add( effectController.cohesion, 'value', 0.0, 100, 0.025 ).name( 'Cohesion' );
-				gui.close();
 
 			}
 
@@ -468,14 +458,6 @@
 
 			}
 
-			function animate() {
-
-				render();
-				renderer.resolveTimestampsAsync();
-				stats.update();
-
-			}
-
 			function render() {
 
 				const now = performance.now();
@@ -493,7 +475,7 @@
 
 				renderer.compute( computeVelocity );
 				renderer.compute( computePosition );
-				renderer.resolveTimestampsAsync( THREE.TimestampQuery.COMPUTE );
+
 				renderer.render( scene, camera );
 
 				// Move pointer away so we only affect birds when moving the mouse

+ 25 - 25
examples/webgpu_compute_particles_snow.html

@@ -1,14 +1,24 @@
 <html lang="en">
 	<head>
-		<title>three.js - WebGPU - Compute Particles Snow</title>
+		<title>three.js webgpu - compute snow</title>
 		<meta charset="utf-8">
 		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
-		<link type="text/css" rel="stylesheet" href="main.css">
+		<link type="text/css" rel="stylesheet" href="example.css">
+		<!-- <link type="text/css" rel="stylesheet" href="main.css"> -->
 	</head>
 	<body>
 
 		<div id="info">
-			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> WebGPU - Compute Snow - 100K Particles
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a>
+				<span>compute snow</span>
+			</div>
+
+			<small>
+				100k snow particles simulation.
+			</small>
 		</div>
 
 		<script type="importmap">
@@ -17,8 +27,7 @@
 					"three": "../build/three.webgpu.js",
 					"three/webgpu": "../build/three.webgpu.js",
 					"three/tsl": "../build/three.tsl.js",
-					"three/addons/": "./jsm/",
-					"stats-gl": "https://cdn.jsdelivr.net/npm/stats-gl@3.6.0/dist/main.js"
+					"three/addons/": "./jsm/"
 				}
 			}
 		</script>
@@ -29,16 +38,16 @@
 			import { Fn, texture, vec3, pass, color, uint, screenUV, instancedArray, positionWorld, positionLocal, time, vec2, hash, instanceIndex, If } from 'three/tsl';
 			import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js';
 
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+
 			import { TeapotGeometry } from 'three/addons/geometries/TeapotGeometry.js';
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
-			import Stats from 'stats-gl';
-
 			const maxParticleCount = 100000;
 
 			let camera, scene, renderer;
-			let controls, stats;
+			let controls;
 			let computeParticles;
 			let postProcessing;
 
@@ -128,7 +137,7 @@
 					particleData.z = position.z;
 					particleData.w = randX;
 
-				} )().compute( maxParticleCount );
+				} )().compute( maxParticleCount ).setName( 'Init Particles' );
 
 				//
 
@@ -165,6 +174,7 @@
 				} );
 
 				computeParticles = computeUpdate().compute( maxParticleCount );
+				computeParticles.name = 'Update Particles';
 
 				// rain
 
@@ -253,6 +263,7 @@
 					color: 0xfcfb9e
 				} ) );
 
+				teapotTree.name = 'Teapot Pass';
 				teapotTree.position.y = 18;
 
 				scene.add( tree() );
@@ -269,18 +280,9 @@
 				renderer.setPixelRatio( window.devicePixelRatio );
 				renderer.setSize( window.innerWidth, window.innerHeight );
 				renderer.setAnimationLoop( animate );
+				renderer.inspector = new Inspector();
 				document.body.appendChild( renderer.domElement );
 
-				stats = new Stats( {
-					precision: 3,
-					horizontal: false,
-					trackGPU: true,
-					trackCPT: true
-				} );
-				stats.init( renderer );
-				document.body.appendChild( stats.dom );
-
-
 				//
 
 				controls = new OrbitControls( camera, renderer.domElement );
@@ -337,12 +339,13 @@
 
 			}
 
-			async function animate() {
+			function animate() {
 
 				controls.update();
 
 				// position
 
+				scene.name = 'Collider Position';
 				scene.overrideMaterial = collisionPosMaterial;
 				renderer.setRenderTarget( collisionPosRT );
 				renderer.render( scene, collisionCamera );
@@ -350,17 +353,14 @@
 				// compute
 
 				renderer.compute( computeParticles );
-				renderer.resolveTimestampsAsync( THREE.TimestampQuery.COMPUTE );
 
 				// result
 
+				scene.name = 'Scene';
 				scene.overrideMaterial = null;
 				renderer.setRenderTarget( null );
 
-				await postProcessing.renderAsync();
-
-				renderer.resolveTimestampsAsync();
-				stats.update();
+				postProcessing.render();
 
 			}
 

+ 16 - 15
examples/webgpu_postprocessing_ssgi.html

@@ -1,15 +1,21 @@
 <!DOCTYPE html>
 <html lang="en">
 	<head>
-		<title>three.js WebGPU - SSGI</title>
+		<title>three.js webgpu - SSGI</title>
 		<meta charset="utf-8">
 		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
-		<link type="text/css" rel="stylesheet" href="main.css">
+		<link type="text/css" rel="stylesheet" href="example.css">
 	</head>
 	<body>
 
 		<div id="info">
-			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> WebGPU - Post-Processing - SSGI<br />
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>SSGI</span>
+			</div>
+
+			<small>Real-time indirect illumination and ambient occlusion using screen-space information.</small>
 		</div>
 
 		<script type="importmap">
@@ -32,10 +38,9 @@
 
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
-			import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
-			import Stats from 'three/addons/libs/stats.module.js';
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
 
-			let camera, scene, renderer, postProcessing, controls, stats;
+			let camera, scene, renderer, postProcessing, controls;
 
 			init();
 
@@ -54,8 +59,8 @@
 				renderer.shadowMap.enabled = true;
 				document.body.appendChild( renderer.domElement );
 
-				stats = new Stats();
-				document.body.appendChild( stats.domElement );
+				renderer.inspector = new Inspector();
+				document.body.appendChild( renderer.inspector.domElement );
 
 				//
 
@@ -110,6 +115,7 @@
 				const ao = giPass.a;
 
 				const compositePass = vec4( add( scenePassColor.rgb.mul( ao ), ( scenePassDiffuse.rgb.mul( gi ) ) ), scenePassColor.a );
+				compositePass.name = 'Composite';
 			
 				// traa
 
@@ -213,8 +219,7 @@
 
 				const types = { Combined: 0, Direct: 3, AO: 1, GI: 2 };
 
-				const gui = new GUI();
-				gui.title( 'SSGI settings' );
+				const gui = renderer.inspector.createParameters( 'SSGI settings' );
 				gui.add( params, 'output', types ).onChange( updatePostprocessing );
 				gui.add( giPass.sliceCount, 'value', 1, 4 ).step( 1 ).name( 'slice count' );
 				gui.add( giPass.stepCount, 'value', 1, 32 ).step( 1 ).name( 'step count' );
@@ -228,9 +233,7 @@
 				gui.add( giPass.useScreenSpaceSampling, 'value' ).name( 'screen-space sampling' );
 				gui.add( giPass, 'useTemporalFiltering' ).name( 'temporal filtering' ).onChange( updatePostprocessing );
 
-				function updatePostprocessing() {
-
-					const value = params.output;
+				function updatePostprocessing( value ) {
 
 					if ( value === 1 ) {
 
@@ -274,8 +277,6 @@
 
 				controls.update();
 
-				stats.update();
-
 				postProcessing.render();
 
 			}

+ 12 - 2
examples/webgpu_shadowmap_opacity.html

@@ -4,12 +4,19 @@
 		<title>three.js webgpu - shadowmap + opacity</title>
 		<meta charset="utf-8">
 		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
-		<link type="text/css" rel="stylesheet" href="main.css">
+		<link type="text/css" rel="stylesheet" href="example.css">
 	</head>
 
 	<body>
+
 		<div id="info">
-			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> webgpu - shadowmap + opacity
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Opacity Shadow Map</span>
+			</div>
+
+			<small>Shadow Map with custom color and opacity by material.</small>
 		</div>
 
 		<script type="importmap">
@@ -28,6 +35,8 @@
 			import * as THREE from 'three/webgpu';
 			import { Fn, mix } from 'three/tsl';
 
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+
 			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 
@@ -50,6 +59,7 @@
 				renderer.toneMapping = THREE.AgXToneMapping;
 				renderer.toneMappingExposure = 1.5;
 				renderer.shadowMap.enabled = true;
+				renderer.inspector = new Inspector();
 				container.appendChild( renderer.domElement );
 
 				scene = new THREE.Scene();

+ 1 - 0
src/Three.WebGPU.Nodes.js

@@ -18,6 +18,7 @@ export { default as ProjectorLight } from './lights/webgpu/ProjectorLight.js';
 export { default as NodeLoader } from './loaders/nodes/NodeLoader.js';
 export { default as NodeObjectLoader } from './loaders/nodes/NodeObjectLoader.js';
 export { default as NodeMaterialLoader } from './loaders/nodes/NodeMaterialLoader.js';
+export { default as InspectorBase } from './renderers/common/InspectorBase.js';
 export { ClippingGroup } from './objects/ClippingGroup.js';
 export * from './nodes/Nodes.js';
 import * as TSL from './nodes/TSL.js';

+ 1 - 0
src/Three.WebGPU.js

@@ -20,6 +20,7 @@ export { default as ProjectorLight } from './lights/webgpu/ProjectorLight.js';
 export { default as NodeLoader } from './loaders/nodes/NodeLoader.js';
 export { default as NodeObjectLoader } from './loaders/nodes/NodeObjectLoader.js';
 export { default as NodeMaterialLoader } from './loaders/nodes/NodeMaterialLoader.js';
+export { default as InspectorBase } from './renderers/common/InspectorBase.js';
 export { ClippingGroup } from './objects/ClippingGroup.js';
 export * from './nodes/Nodes.js';
 import * as TSL from './nodes/TSL.js';

+ 8 - 0
src/nodes/core/Node.js

@@ -83,6 +83,14 @@ class Node extends EventDispatcher {
 		 */
 		this.version = 0;
 
+		/**
+		 * The name of the node.
+		 *
+		 * @type {string}
+		 * @default ''
+		 */
+		this.name = '';
+
 		/**
 		 * Whether this node is global or not. This property is relevant for the internal
 		 * node caching system. All nodes which should be declared just once should

+ 6 - 0
src/nodes/lighting/PointShadowNode.js

@@ -279,8 +279,14 @@ class PointShadowNode extends ShadowNode {
 
 			shadow.updateMatrices( light, vp );
 
+			const currentSceneName = scene.name;
+
+			scene.name = `Point Light Shadow [ ${ light.name || 'ID: ' + light.id } ] - Face ${ vp + 1 }`;
+
 			renderer.render( scene, shadow.camera );
 
+			scene.name = currentSceneName;
+
 		}
 
 		//

+ 6 - 0
src/nodes/lighting/ShadowNode.js

@@ -577,8 +577,14 @@ class ShadowNode extends ShadowBaseNode {
 
 		shadowMap.setSize( shadow.mapSize.width, shadow.mapSize.height, shadowMap.depth );
 
+		const currentSceneName = scene.name;
+
+		scene.name = `Shadow Map [ ${ light.name || 'ID: ' + light.id } ]`;
+
 		renderer.render( scene, shadow.camera );
 
+		scene.name = currentSceneName;
+
 	}
 
 	/**

+ 10 - 0
src/nodes/utils/RTTNode.js

@@ -217,7 +217,17 @@ class RTTNode extends TextureNode {
 
 		//
 
+		let name = 'RTT';
+
+		if ( this.node.name ) {
+
+			name = this.node.name + ' [ ' + name + ' ]';
+
+		}
+
+
 		this._quadMesh.material.fragmentNode = this._rttNode;
+		this._quadMesh.name = name;
 
 		//
 

+ 13 - 1
src/renderers/common/Animation.js

@@ -9,10 +9,18 @@ class Animation {
 	/**
 	 * Constructs a new animation loop management component.
 	 *
+	 * @param {Renderer} renderer - A reference to the main renderer.
 	 * @param {Nodes} nodes - Renderer component for managing nodes related logic.
 	 * @param {Info} info - Renderer component for managing metrics and monitoring data.
 	 */
-	constructor( nodes, info ) {
+	constructor( renderer, nodes, info ) {
+
+		/**
+		 * A reference to the main renderer.
+		 *
+		 * @type {Renderer}
+		 */
+		this.renderer = renderer;
 
 		/**
 		 * Renderer component for managing nodes related logic.
@@ -70,8 +78,12 @@ class Animation {
 
 			this.info.frame = this.nodes.nodeFrame.frameId;
 
+			this.renderer._inspector.begin();
+
 			if ( this._animationLoop !== null ) this._animationLoop( time, xrFrame );
 
+			this.renderer._inspector.finish();
+
 		};
 
 		update();

+ 47 - 8
src/renderers/common/Backend.js

@@ -4,7 +4,7 @@ let _color4 = null;
 import Color4 from './Color4.js';
 import { Vector2 } from '../../math/Vector2.js';
 import { createCanvasElement, warnOnce } from '../../utils.js';
-import { REVISION } from '../../constants.js';
+import { REVISION, TimestampQuery } from '../../constants.js';
 
 /**
  * Most of the rendering related logic is implemented in the
@@ -64,8 +64,8 @@ class Backend {
    		 * @type {{render: ?TimestampQueryPool, compute: ?TimestampQueryPool}}
 		 */
 		this.timestampQueryPool = {
-			'render': null,
-			'compute': null
+			[ TimestampQuery.RENDER ]: null,
+			[ TimestampQuery.COMPUTE ]: null
 		};
 
 		/**
@@ -433,6 +433,33 @@ class Backend {
 
 	// utils
 
+	/**
+	 * Updates a unique identifier for the given render context that can be used
+	 * to allocate resources like occlusion queries or timestamp queries.
+	 *
+	 * @param {RenderContext|ComputeNode} abstractRenderContext - The render context.
+	 */
+	updateTimeStampUID( abstractRenderContext ) {
+
+		const contextData = this.get( abstractRenderContext );
+		const frame = this.renderer.info.frame;
+
+		let prefix;
+
+		if ( abstractRenderContext.isComputeNode === true ) {
+
+			prefix = 'c:' + this.renderer.info.compute.frameCalls;
+
+		} else {
+
+			prefix = 'r:' + this.renderer.info.render.frameCalls;
+
+		}
+
+		contextData.timestampUID = prefix + ':' + abstractRenderContext.id + ':f' + frame;
+
+	}
+
 	/**
 	 * Returns a unique identifier for the given render context that can be used
 	 * to allocate resources like occlusion queries or timestamp queries.
@@ -442,12 +469,24 @@ class Backend {
 	 */
 	getTimestampUID( abstractRenderContext ) {
 
-		const contextData = this.get( abstractRenderContext );
+		return this.get( abstractRenderContext ).timestampUID;
 
-		let uid = abstractRenderContext.isComputeNode === true ? 'c' : 'r';
-		uid += ':' + contextData.frameCalls + ':' + abstractRenderContext.id;
+	}
+
+	getTimestampFrames( type ) {
+
+		const queryPool = this.timestampQueryPool[ type ];
 
-		return uid;
+		return queryPool ? queryPool.getTimestampFrames() : [];
+
+	}
+
+	getTimestamp( uid ) {
+
+		const type = uid.startsWith( 'c:' ) ? TimestampQuery.COMPUTE : TimestampQuery.RENDER;
+		const queryPool = this.timestampQueryPool[ type ];
+
+		return queryPool.getTimestamp( uid );
 
 	}
 
@@ -481,9 +520,9 @@ class Backend {
 		}
 
 		const queryPool = this.timestampQueryPool[ type ];
+
 		if ( ! queryPool ) {
 
-			warnOnce( `WebGPURenderer: No timestamp query pool for type '${type}' found.` );
 			return;
 
 		}

+ 139 - 0
src/renderers/common/InspectorBase.js

@@ -0,0 +1,139 @@
+/**
+ * InspectorBase is the base class for all inspectors.
+ *
+ * @class InspectorBase
+ */
+class InspectorBase {
+
+	/**
+	 * Creates a new InspectorBase.
+	 */
+	constructor() {
+
+		/**
+		 * The renderer associated with this inspector.
+		 *
+		 * @type {WebGLRenderer}
+		 * @private
+		 */
+		this._renderer = null;
+
+		/**
+		 * The current frame being processed.
+		 *
+		 * @type {Object}
+		 */
+		this.currentFrame = null;
+
+	}
+
+	/**
+	 * Returns the node frame for the current renderer.
+	 *
+	 * @return {Object} The node frame.
+	 */
+	get nodeFrame() {
+
+		return this._renderer._nodes.nodeFrame;
+
+	}
+
+	/**
+	 * Sets the renderer for this inspector.
+	 *
+	 * @param {WebGLRenderer} renderer - The renderer to associate with this inspector.
+	 * @return {InspectorBase} This inspector instance.
+	 */
+	setRenderer( renderer ) {
+
+		this._renderer = renderer;
+
+		return this;
+
+	}
+
+	/**
+	 * Returns the renderer associated with this inspector.
+	 *
+	 * @return {WebGLRenderer} The associated renderer.
+	 */
+	getRenderer() {
+
+		return this._renderer;
+
+	}
+
+	/**
+	 * Initializes the inspector.
+	 */
+	init() { }
+
+	/**
+	 * Called when a frame begins.
+	 */
+	begin() { }
+
+	/**
+	 * Called when a frame ends.
+	 */
+	finish() { }
+
+	/**
+	 * When a compute operation is performed.
+	 *
+	 * @param {ComputeNode} computeNode - The compute node being executed.
+	 * @param {number|Array<number>} dispatchSizeOrCount - The dispatch size or count.
+	 */
+	computeAsync( /*computeNode, dispatchSizeOrCount*/ ) { }
+
+	/**
+	 * Called when a compute operation begins.
+	 *
+	 * @param {string} uid - A unique identifier for the render context.
+	 * @param {ComputeNode} computeNode - The compute node being executed.
+	 */
+	beginCompute( /*uid, computeNode*/ ) { }
+
+	/**
+	 * Called when a compute operation ends.
+	 *
+	 * @param {string} uid - A unique identifier for the render context.
+	 * @param {ComputeNode} computeNode - The compute node being executed.
+	 */
+	finishCompute( /*uid*/ ) { }
+
+	/**
+	 * Called whean a render operation begins.
+	 *
+	 * @param {string} uid - A unique identifier for the render context.
+	 * @param {Scene} scene - The scene being rendered.
+	 * @param {Camera} camera - The camera being used for rendering.
+	 * @param {?WebGLRenderTarget} renderTarget - The render target, if any.
+	 */
+	beginRender( /*uid, scene, camera, renderTarget*/ ) { }
+
+	/**
+	 * Called when an animation loop ends.
+	 *
+	 * @param {string} uid - A unique identifier for the render context.
+	 */
+	finishRender( /*uid*/ ) { }
+
+	/**
+	 * Called when a texture copy operation is performed.
+	 *
+	 * @param {Texture} srcTexture - The source texture.
+	 * @param {Texture} dstTexture - The destination texture.
+	 */
+	copyTextureToTexture( /*srcTexture, dstTexture*/ ) { }
+
+	/**
+	 * Called when a framebuffer copy operation is performed.
+	 *
+	 * @param {Texture} framebufferTexture - The texture associated with the framebuffer.
+	 */
+	copyFramebufferToTexture( /*framebufferTexture*/ ) { }
+
+}
+
+export default InspectorBase;

+ 1 - 0
src/renderers/common/PostProcessing.js

@@ -82,6 +82,7 @@ class PostProcessing {
 		 * @type {QuadMesh}
 		 */
 		this._quadMesh = new QuadMesh( material );
+		this._quadMesh.name = 'Post-Processing';
 
 		/**
 		 * The context of the post processing stack.

+ 90 - 2
src/renderers/common/Renderer.js

@@ -17,6 +17,7 @@ import RenderBundles from './RenderBundles.js';
 import NodeLibrary from './nodes/NodeLibrary.js';
 import Lighting from './Lighting.js';
 import XRManager from './XRManager.js';
+import InspectorBase from './InspectorBase.js';
 
 import NodeMaterial from '../../materials/nodes/NodeMaterial.js';
 
@@ -269,6 +270,9 @@ class Renderer {
 
 		// internals
 
+		this._inspector = new InspectorBase();
+		this._inspector.setRenderer( this );
+
 		/**
 		 * This callback function can be used to provide a fallback backend, if the primary backend can't be targeted.
 		 *
@@ -442,7 +446,8 @@ class Renderer {
 		 * @type {QuadMesh}
 		 */
 		this._quad = new QuadMesh( new NodeMaterial() );
-		this._quad.material.name = 'Renderer_output';
+		this._quad.name = 'Output Color Transform';
+		this._quad.material.name = 'outputColorTransform';
 
 		/**
 		 * A reference to the current render context.
@@ -797,7 +802,7 @@ class Renderer {
 			}
 
 			this._nodes = new Nodes( this, backend );
-			this._animation = new Animation( this._nodes, this.info );
+			this._animation = new Animation( this, this._nodes, this.info );
 			this._attributes = new Attributes( backend );
 			this._background = new Background( this, this._nodes );
 			this._geometries = new Geometries( this._attributes, this.info );
@@ -814,6 +819,12 @@ class Renderer {
 			this._animation.start();
 			this._initialized = true;
 
+			//
+
+			this._inspector.init();
+
+			//
+
 			resolve( this );
 
 		} );
@@ -1004,6 +1015,32 @@ class Renderer {
 
 	}
 
+	//
+
+	/**
+	 * Sets the inspector instance. The inspector can be any class that extends from `InspectorBase`.
+	 *
+	 * @param {InspectorBase} value - The new inspector.
+	 */
+	set inspector( value ) {
+
+		if ( this._inspector !== null ) {
+
+			this._inspector.setRenderer( null );
+
+		}
+
+		this._inspector = value;
+		this._inspector.setRenderer( this );
+
+	}
+
+	get inspector() {
+
+		return this._inspector;
+
+	}
+
 	/**
 	 * Enables or disables high precision for model-view and normal-view matrices.
 	 * When enabled, will use CPU 64-bit precision for higher precision instead of GPU 32-bit for higher performance.
@@ -1209,6 +1246,18 @@ class Renderer {
 
 	}
 
+	/**
+	 * Returns whether the renderer has been initialized or not.
+	 *
+	 * @readonly
+	 * @return {boolean} Whether the renderer has been initialized or not.
+	 */
+	get initialized() {
+
+		return this._initialized;
+
+	}
+
 	/**
 	 * Returns an internal render target which is used when computing the output tone mapping
 	 * and color space conversion. Unlike in `WebGLRenderer`, this is done in a separate render
@@ -1291,6 +1340,8 @@ class Renderer {
 
 		if ( this._isDeviceLost === true ) return;
 
+		//
+
 		const frameBufferTarget = useFrameBufferTarget ? this._getFrameBufferTarget() : null;
 
 		// preserve render tree
@@ -1343,6 +1394,12 @@ class Renderer {
 
 		//
 
+		this.backend.updateTimeStampUID( renderContext );
+
+		this.inspector.beginRender( this.backend.getTimestampUID( renderContext ), scene, camera, renderTarget );
+
+		//
+
 		const coordinateSystem = this.coordinateSystem;
 		const xr = this.xr;
 
@@ -1540,6 +1597,10 @@ class Renderer {
 
 		//
 
+		this.inspector.finishRender( this.backend.getTimestampUID( renderContext ) );
+
+		//
+
 		return renderContext;
 
 	}
@@ -1636,6 +1697,17 @@ class Renderer {
 
 	}
 
+	/**
+	 * Returns the current animation loop callback.
+	 *
+	 * @return {?Function} The current animation loop callback.
+	 */
+	getAnimationLoop() {
+
+		return this._animation.getAnimationLoop();
+
+	}
+
 	/**
 	 * Can be used to transfer buffer data from a storage buffer attribute
 	 * from the GPU to the CPU in context of compute shaders.
@@ -2372,6 +2444,12 @@ class Renderer {
 
 		//
 
+		this.backend.updateTimeStampUID( computeNodes );
+
+		this.inspector.beginCompute( this.backend.getTimestampUID( computeNodes ), computeNodes );
+
+		//
+
 		const backend = this.backend;
 		const pipelines = this._pipelines;
 		const bindings = this._bindings;
@@ -2433,6 +2511,10 @@ class Renderer {
 
 		nodeFrame.renderId = previousRenderId;
 
+		//
+
+		this.inspector.finishCompute( this.backend.getTimestampUID( computeNodes ) );
+
 	}
 
 	/**
@@ -2447,6 +2529,8 @@ class Renderer {
 
 		if ( this._initialized === false ) await this.init();
 
+		this._inspector.computeAsync( computeNodes, dispatchSizeOrCount );
+
 		this.compute( computeNodes, dispatchSizeOrCount );
 
 	}
@@ -2603,6 +2687,8 @@ class Renderer {
 
 		this.backend.copyFramebufferToTexture( framebufferTexture, renderContext, rectangle );
 
+		this._inspector.copyFramebufferToTexture( framebufferTexture );
+
 	}
 
 	/**
@@ -2622,6 +2708,8 @@ class Renderer {
 
 		this.backend.copyTextureToTexture( srcTexture, dstTexture, srcRegion, dstPosition, srcLevel, dstLevel );
 
+		this._inspector.copyTextureToTexture( srcTexture, dstTexture );
+
 	}
 
 	/**

+ 51 - 1
src/renderers/common/TimestampQueryPool.js

@@ -1,3 +1,5 @@
+import { warn } from '../../utils.js';
+
 /**
  * Abstract base class of a timestamp query pool.
  *
@@ -59,6 +61,13 @@ class TimestampQueryPool {
 		 */
 		this.lastValue = 0;
 
+		/**
+		 * Stores all timestamp frames.
+		 *
+		 * @type {Array<number>}
+		 */
+		this.frames = [];
+
 		/**
 		 * TODO
 		 *
@@ -67,6 +76,46 @@ class TimestampQueryPool {
 		 */
 		this.pendingResolve = false;
 
+		/**
+		 * Stores the latest timestamp for each render context.
+		 *
+		 * @type {Map<string, number>}
+		 */
+		this.timestamps = new Map();
+
+	}
+
+	/**
+	 * Returns all timestamp frames.
+	 *
+	 * @return {Array<number>} The timestamp frames.
+	 */
+	getTimestampFrames() {
+
+		return this.frames;
+
+	}
+
+	/**
+	 * Returns the timestamp for a given render context.
+	 *
+	 * @param {string} uid - A unique identifier for the render context.
+	 * @return {?number} The timestamp, or undefined if not available.
+	 */
+	getTimestamp( uid ) {
+
+		let timestamp = this.timestamps.get( uid );
+
+		if ( timestamp === undefined ) {
+
+			warn( `TimestampQueryPool: No timestamp available for uid ${ uid }.` );
+
+			timestamp = 0;
+
+		}
+
+		return timestamp;
+
 	}
 
 	/**
@@ -74,9 +123,10 @@ class TimestampQueryPool {
 	 *
 	 * @abstract
 	 * @param {string} uid - A unique identifier for the render context.
+	 * @param {number} frameId - The current frame identifier.
 	 * @returns {?number}
 	 */
-	allocateQueriesForContext( /* uid */ ) {}
+	allocateQueriesForContext( /* uid, frameId */ ) {}
 
 	/**
 	 * Resolve all timestamps and return data (or process them).

+ 0 - 9
src/renderers/webgl-fallback/WebGLBackend.js

@@ -445,10 +445,6 @@ class WebGLBackend extends Backend {
 
 		//
 
-		renderContextData.frameCalls = this.renderer.info.render.frameCalls;
-
-		//
-
 		if ( renderContext.viewport ) {
 
 			this.updateViewport( renderContext );
@@ -807,11 +803,6 @@ class WebGLBackend extends Backend {
 	beginCompute( computeGroup ) {
 
 		const { state, gl } = this;
-		const computeGroupData = this.get( computeGroup );
-
-		//
-
-		computeGroupData.frameCalls = this.renderer.info.compute.frameCalls;
 
 		//
 

+ 36 - 6
src/renderers/webgl-fallback/utils/WebGLTimestampQueryPool.js

@@ -195,30 +195,60 @@ class WebGLTimestampQueryPool extends TimestampQueryPool {
 		try {
 
 			// Wait for all ended queries to complete
-			const resolvePromises = [];
+			const resolvePromises = new Map();
 
-			for ( const [ baseOffset, state ] of this.queryStates ) {
+			for ( const [ uid, baseOffset ] of this.queryOffsets ) {
+
+				const state = this.queryStates.get( baseOffset );
 
 				if ( state === 'ended' ) {
 
 					const query = this.queries[ baseOffset ];
-					resolvePromises.push( this.resolveQuery( query ) );
+					resolvePromises.set( uid, this.resolveQuery( query ) );
 
 				}
 
 			}
 
-			if ( resolvePromises.length === 0 ) {
+			if ( resolvePromises.size === 0 ) {
 
 				return this.lastValue;
 
 			}
 
-			const results = await Promise.all( resolvePromises );
-			const totalDuration = results.reduce( ( acc, val ) => acc + val, 0 );
+			//
+
+			const framesDuration = {};
+
+			const frames = [];
+
+			for ( const [ uid, promise ] of resolvePromises ) {
+
+				const match = uid.match( /^(.*):f(\d+)$/ );
+				const frame = parseInt( match[ 2 ] );
+
+				if ( frames.includes( frame ) === false ) {
+
+					frames.push( frame );
+
+				}
+
+				if ( framesDuration[ frame ] === undefined ) framesDuration[ frame ] = 0;
+
+				const duration = await promise;
+
+				this.timestamps.set( uid, duration );
+
+				framesDuration[ frame ] += duration;
+
+			}
+
+			// Return the total duration of the last frame
+			const totalDuration = framesDuration[ this.frames[ this.frames.length - 1 ] ];
 
 			// Store the last valid result
 			this.lastValue = totalDuration;
+			this.frames = frames;
 
 			// Reset states
 			this.currentQueryIndex = 0;

+ 1 - 9
src/renderers/webgpu/WebGPUBackend.js

@@ -570,10 +570,6 @@ class WebGPUBackend extends Backend {
 
 		//
 
-		renderContextData.frameCalls = this.renderer.info.render.frameCalls;
-
-		//
-
 		const device = this.device;
 		const occlusionQueryCount = renderContext.occlusionQueryCount;
 
@@ -1293,10 +1289,6 @@ class WebGPUBackend extends Backend {
 
 		//
 
-		groupGPU.frameCalls = this.renderer.info.compute.frameCalls;
-
-		//
-
 		const descriptor = {
 			label: 'computeGroup_' + computeGroup.id
 		};
@@ -1949,7 +1941,7 @@ class WebGPUBackend extends Backend {
 			querySet: timestampQueryPool.querySet,
 			beginningOfPassWriteIndex: baseOffset,
 			endOfPassWriteIndex: baseOffset + 1,
-		  };
+		};
 
 	}
 

+ 26 - 3
src/renderers/webgpu/utils/WebGPUTimestampQueryPool.js

@@ -64,6 +64,7 @@ class WebGPUTimestampQueryPool extends TimestampQueryPool {
 		this.currentQueryIndex += 2;
 
 		this.queryOffsets.set( uid, baseOffset );
+
 		return baseOffset;
 
 	}
@@ -177,20 +178,42 @@ class WebGPUTimestampQueryPool extends TimestampQueryPool {
 
 			}
 
+			//
+
 			const times = new BigUint64Array( this.resultBuffer.getMappedRange( 0, bytesUsed ) );
-			let totalDuration = 0;
+			const framesDuration = {};
+
+			const frames = [];
+
+			for ( const [ uid, baseOffset ] of currentOffsets ) {
+
+				const match = uid.match( /^(.*):f(\d+)$/ );
+				const frame = parseInt( match[ 2 ] );
+
+				if ( frames.includes( frame ) === false ) {
 
-			for ( const [ , baseOffset ] of currentOffsets ) {
+					frames.push( frame );
+
+				}
+
+				if ( framesDuration[ frame ] === undefined ) framesDuration[ frame ] = 0;
 
 				const startTime = times[ baseOffset ];
 				const endTime = times[ baseOffset + 1 ];
 				const duration = Number( endTime - startTime ) / 1e6;
-				totalDuration += duration;
+
+				this.timestamps.set( uid, duration );
+
+				framesDuration[ frame ] += duration;
 
 			}
 
+			// Return the total duration of the last frame
+			const totalDuration = framesDuration[ this.frames[ this.frames.length - 1 ] ];
+
 			this.resultBuffer.unmap();
 			this.lastValue = totalDuration;
+			this.frames = frames;
 
 			return totalDuration;
 

粤ICP备19079148号