diff --git a/examples/example.css b/examples/example.css new file mode 100644 index 00000000000000..2f2ed9ddedf548 --- /dev/null +++ b/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,'); +} + +.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; +} diff --git a/examples/jsm/inspector/Inspector.js b/examples/jsm/inspector/Inspector.js new file mode 100644 index 00000000000000..0bb09b85e65a9d --- /dev/null +++ b/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 }; diff --git a/examples/jsm/inspector/RendererInspector.js b/examples/jsm/inspector/RendererInspector.js new file mode 100644 index 00000000000000..9eca5eabd77566 --- /dev/null +++ b/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; + + } + +} diff --git a/examples/jsm/inspector/tabs/Console.js b/examples/jsm/inspector/tabs/Console.js new file mode 100644 index 00000000000000..4dbba14d96d828 --- /dev/null +++ b/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 }; diff --git a/examples/jsm/inspector/tabs/Parameters.js b/examples/jsm/inspector/tabs/Parameters.js new file mode 100644 index 00000000000000..c65c82b7f84606 --- /dev/null +++ b/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 }; diff --git a/examples/jsm/inspector/tabs/Performance.js b/examples/jsm/inspector/tabs/Performance.js new file mode 100644 index 00000000000000..856e7c7346718a --- /dev/null +++ b/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 }; diff --git a/examples/jsm/inspector/ui/Graph.js b/examples/jsm/inspector/ui/Graph.js new file mode 100644 index 00000000000000..bab3d22b5d98f5 --- /dev/null +++ b/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(); + + } + + } + +} diff --git a/examples/jsm/inspector/ui/Item.js b/examples/jsm/inspector/ui/Item.js new file mode 100644 index 00000000000000..667afbae9a657d --- /dev/null +++ b/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; + + } + +} diff --git a/examples/jsm/inspector/ui/List.js b/examples/jsm/inspector/ui/List.js new file mode 100644 index 00000000000000..aa058addbf9359 --- /dev/null +++ b/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; + + } + +} diff --git a/examples/jsm/inspector/ui/Profiler.js b/examples/jsm/inspector/ui/Profiler.js new file mode 100644 index 00000000000000..e3e1956b108db1 --- /dev/null +++ b/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 = ` + + - + FPS + + + + + +`; + 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 = ''; + 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 = ''; + + } else { + + this.lastHeight = this.panel.offsetHeight; + this.panel.classList.add( 'maximized' ); + this.panel.style.height = '100vh'; + this.maximizeBtn.innerHTML = ''; + + } + + } + + 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' ); + + } + +} diff --git a/examples/jsm/inspector/ui/Style.js b/examples/jsm/inspector/ui/Style.js new file mode 100644 index 00000000000000..6721a0a02f3ec6 --- /dev/null +++ b/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 ); + + } + +} diff --git a/examples/jsm/inspector/ui/Tab.js b/examples/jsm/inspector/ui/Tab.js new file mode 100644 index 00000000000000..25f598bb5fb5df --- /dev/null +++ b/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; + + } + +} diff --git a/examples/jsm/inspector/ui/Values.js b/examples/jsm/inspector/ui/Values.js new file mode 100644 index 00000000000000..687e716447ddd8 --- /dev/null +++ b/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 }; diff --git a/examples/jsm/inspector/ui/utils.js b/examples/jsm/inspector/ui/utils.js new file mode 100644 index 00000000000000..99a51d493eef98 --- /dev/null +++ b/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; + +} diff --git a/examples/jsm/tsl/display/GaussianBlurNode.js b/examples/jsm/tsl/display/GaussianBlurNode.js index dc761a4987fe66..9c7df096e1fb05 100644 --- a/examples/jsm/tsl/display/GaussianBlurNode.js +++ b/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 diff --git a/examples/jsm/tsl/display/SSGINode.js b/examples/jsm/tsl/display/SSGINode.js index e704645114564e..5f0924c3e354e5 100644 --- a/examples/jsm/tsl/display/SSGINode.js +++ b/examples/jsm/tsl/display/SSGINode.js @@ -351,6 +351,7 @@ class SSGINode extends TempNode { // _quadMesh.material = this._material; + _quadMesh.name = 'SSGI'; // clear diff --git a/examples/jsm/tsl/display/TRAANode.js b/examples/jsm/tsl/display/TRAANode.js index 3d334057943589..49c9d433230c28 100644 --- a/examples/jsm/tsl/display/TRAANode.js +++ b/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 ); diff --git a/examples/webgpu_backdrop_water.html b/examples/webgpu_backdrop_water.html index 6d388de55120b1..d2ea239782129e 100644 --- a/examples/webgpu_backdrop_water.html +++ b/examples/webgpu_backdrop_water.html @@ -1,15 +1,21 @@ - three.js - WebGPU - Backdrop Water + three.js webgpu - backdrop water - +
- three.js WebGPU - Backdrop Water + + +
+ three.jsbackdrop water +
+ + Water refraction with depth effect.
@@ -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(); } diff --git a/examples/webgpu_postprocessing_ssgi.html b/examples/webgpu_postprocessing_ssgi.html index f000f2710299d3..92a76c02de5e20 100644 --- a/examples/webgpu_postprocessing_ssgi.html +++ b/examples/webgpu_postprocessing_ssgi.html @@ -1,15 +1,21 @@ - three.js WebGPU - SSGI + three.js webgpu - SSGI - +
- three.js WebGPU - Post-Processing - SSGI
+ + +
+ three.jsSSGI +
+ + Real-time indirect illumination and ambient occlusion using screen-space information.