import * as THREE from 'three' import Stats from 'stats.js' import StatsGl from 'stats-gl' import * as tween from '@tweenjs/tween.js' import { GraphicsBackendConfig, GraphicsInitOptions } from '../../../src/appViewer' import { WorldRendererConfig } from '../lib/worldrendererCommon' export class DocumentRenderer { readonly canvas = document.createElement('canvas') readonly renderer: THREE.WebGLRenderer private animationFrameId?: number private lastRenderTime = 0 private previousWindowWidth = window.innerWidth private previousWindowHeight = window.innerHeight private renderedFps = 0 private fpsInterval: any private readonly stats: TopRightStats private paused = false disconnected = false preRender = () => { } render = (sizeChanged: boolean) => { } postRender = () => { } sizeChanged = () => { } droppedFpsPercentage: number config: GraphicsBackendConfig onRender = [] as Array<(sizeChanged: boolean) => void> inWorldRenderingConfig: WorldRendererConfig | undefined constructor (initOptions: GraphicsInitOptions) { this.config = initOptions.config try { this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, preserveDrawingBuffer: true, logarithmicDepthBuffer: true, powerPreference: this.config.powerPreference }) } catch (err) { initOptions.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`)) throw err } this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace this.updatePixelRatio() this.updateSize() this.addToPage() this.stats = new TopRightStats(this.canvas, this.config.statsVisible) this.setupFpsTracking() this.startRenderLoop() } updatePixelRatio () { let pixelRatio = window.devicePixelRatio || 1 // todo this value is too high on ios, need to check, probably we should use avg, also need to make it configurable if (!this.renderer.capabilities.isWebGL2) { pixelRatio = 1 // webgl1 has issues with high pixel ratio (sometimes screen is clipped) } this.renderer.setPixelRatio(pixelRatio) } updateSize () { this.renderer.setSize(window.innerWidth, window.innerHeight) } private addToPage () { this.canvas.id = 'viewer-canvas' this.canvas.style.width = '100%' this.canvas.style.height = '100%' document.body.appendChild(this.canvas) } private setupFpsTracking () { let max = 0 this.fpsInterval = setInterval(() => { if (max > 0) { this.droppedFpsPercentage = this.renderedFps / max } max = Math.max(this.renderedFps, max) this.renderedFps = 0 }, 1000) } // private handleResize () { // const width = window.innerWidth // const height = window.innerHeight // viewer.camera.aspect = width / height // viewer.camera.updateProjectionMatrix() // this.renderer.setSize(width, height) // viewer.world.handleResize() // } private startRenderLoop () { const animate = () => { if (this.disconnected) return this.animationFrameId = requestAnimationFrame(animate) if (this.paused || (this.renderer.xr.isPresenting && !this.inWorldRenderingConfig?.vrPageGameRendering)) return // Handle FPS limiting if (this.config.fpsLimit) { const now = performance.now() const elapsed = now - this.lastRenderTime const fpsInterval = 1000 / this.config.fpsLimit if (elapsed < fpsInterval) { return } this.lastRenderTime = now - (elapsed % fpsInterval) } let sizeChanged = false if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) { this.previousWindowWidth = window.innerWidth this.previousWindowHeight = window.innerHeight this.updateSize() sizeChanged = true } this.frameRender(sizeChanged) // Update stats visibility each frame if (this.config.statsVisible !== undefined) { this.stats.setVisibility(this.config.statsVisible) } } animate() } frameRender (sizeChanged: boolean) { this.preRender() this.stats.markStart() tween.update() if (!window.freezeRender) { this.render(sizeChanged) } for (const fn of this.onRender) { fn(sizeChanged) } this.renderedFps++ this.stats.markEnd() this.postRender() } setPaused (paused: boolean) { this.paused = paused } dispose () { this.disconnected = true if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId) } this.canvas.remove() this.renderer.dispose() clearInterval(this.fpsInterval) this.stats.dispose() } } class TopRightStats { private readonly stats: Stats private readonly stats2: Stats private readonly statsGl: StatsGl private total = 0 private readonly denseMode: boolean constructor (private readonly canvas: HTMLCanvasElement, initialStatsVisible = 0) { this.stats = new Stats() this.stats2 = new Stats() this.statsGl = new StatsGl({ minimal: true }) this.stats2.showPanel(2) this.denseMode = process.env.NODE_ENV === 'production' || window.innerHeight < 500 this.initStats() this.setVisibility(initialStatsVisible) } private addStat (dom: HTMLElement, size = 80) { dom.style.position = 'absolute' if (this.denseMode) dom.style.height = '12px' dom.style.overflow = 'hidden' dom.style.left = '' dom.style.top = '0' dom.style.right = `${this.total}px` dom.style.width = '80px' dom.style.zIndex = '1' dom.style.opacity = '0.8' document.body.appendChild(dom) this.total += size } private initStats () { const hasRamPanel = this.stats2.dom.children.length === 3 this.addStat(this.stats.dom) if (process.env.NODE_ENV === 'development' && document.exitPointerLock) { this.stats.dom.style.top = '' this.stats.dom.style.bottom = '0' } if (hasRamPanel) { this.addStat(this.stats2.dom) } this.statsGl.init(this.canvas) this.statsGl.container.style.display = 'flex' this.statsGl.container.style.justifyContent = 'flex-end' let i = 0 for (const _child of this.statsGl.container.children) { const child = _child as HTMLElement if (i++ === 0) { child.style.display = 'none' } child.style.position = '' } } setVisibility (level: number) { const visible = level > 0 if (visible) { this.stats.dom.style.display = 'block' this.stats2.dom.style.display = level >= 2 ? 'block' : 'none' this.statsGl.container.style.display = level >= 2 ? 'block' : 'none' } else { this.stats.dom.style.display = 'none' this.stats2.dom.style.display = 'none' this.statsGl.container.style.display = 'none' } } markStart () { this.stats.begin() this.stats2.begin() this.statsGl.begin() } markEnd () { this.stats.end() this.stats2.end() this.statsGl.end() } dispose () { this.stats.dom.remove() this.stats2.dom.remove() this.statsGl.container.remove() } }