UNPKG

@google/model-viewer

Version:

Easily display interactive 3D models on the web and in AR!

471 lines (399 loc) 15.1 kB
/* @license * Copyright 2019 Google LLC. All Rights Reserved. * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {ACESFilmicToneMapping, Event, EventDispatcher, GammaEncoding, PCFSoftShadowMap, WebGL1Renderer} from 'three'; import {RoughnessMipmapper} from 'three/examples/jsm/utils/RoughnessMipmapper'; import {USE_OFFSCREEN_CANVAS} from '../constants.js'; import {$canvas, $sceneIsReady, $tick, $updateSize, $userInputElement} from '../model-viewer-base.js'; import {clamp, isDebugMode, resolveDpr} from '../utilities.js'; import {ARRenderer} from './ARRenderer.js'; import {CachingGLTFLoader} from './CachingGLTFLoader.js'; import {Debugger} from './Debugger.js'; import {ModelViewerGLTFInstance} from './gltf-instance/ModelViewerGLTFInstance.js'; import {ModelScene} from './ModelScene.js'; import TextureUtils from './TextureUtils.js'; export interface RendererOptions { debug?: boolean; } export interface ContextLostEvent extends Event { type: 'contextlost'; sourceEvent: WebGLContextEvent; } // Between 0 and 1: larger means the average responds faster and is less smooth. const DURATION_DECAY = 0.2; const LOW_FRAME_DURATION_MS = 18; const HIGH_FRAME_DURATION_MS = 26; const MAX_AVG_CHANGE_MS = 2; const SCALE_STEPS = [1, 0.79, 0.62, 0.5, 0.4, 0.31, 0.25]; const DEFAULT_LAST_STEP = 3; /** * Registers canvases with Canvas2DRenderingContexts and renders them * all in the same WebGLRenderingContext, spitting out textures to apply * to the canvases. Creates a fullscreen WebGL canvas that is not added * to the DOM, and on each frame, renders each registered canvas on a portion * of the WebGL canvas, and applies the texture on the registered canvas. * * In the future, can use ImageBitmapRenderingContext instead of * Canvas2DRenderingContext if supported for cheaper transfering of * the texture. */ export class Renderer extends EventDispatcher { static _singleton = new Renderer({debug: isDebugMode()}); static get singleton() { return this._singleton; } static resetSingleton() { this._singleton.dispose(); this._singleton = new Renderer({debug: isDebugMode()}); } public threeRenderer!: WebGL1Renderer; public canvasElement: HTMLCanvasElement; public canvas3D: HTMLCanvasElement|OffscreenCanvas; public textureUtils: TextureUtils|null; public arRenderer: ARRenderer; public roughnessMipmapper: RoughnessMipmapper; public loader = new CachingGLTFLoader(ModelViewerGLTFInstance); public width = 0; public height = 0; public dpr = 1; protected debugger: Debugger|null = null; private scenes: Set<ModelScene> = new Set(); private multipleScenesVisible = false; private lastTick: number; private scaleStep = 0; private lastStep = DEFAULT_LAST_STEP; private avgFrameDuration = (HIGH_FRAME_DURATION_MS + LOW_FRAME_DURATION_MS) / 2; get canRender() { return this.threeRenderer != null; } get scaleFactor() { return SCALE_STEPS[this.scaleStep]; } set minScale(scale: number) { let i = 1; while (i < SCALE_STEPS.length) { if (SCALE_STEPS[i] < scale) { break; } ++i; } this.lastStep = i - 1; } constructor(options?: RendererOptions) { super(); this.dpr = resolveDpr(); this.canvasElement = document.createElement('canvas'); this.canvasElement.id = 'webgl-canvas'; this.canvas3D = USE_OFFSCREEN_CANVAS ? this.canvasElement.transferControlToOffscreen() : this.canvasElement; this.canvas3D.addEventListener('webglcontextlost', this.onWebGLContextLost); try { this.threeRenderer = new WebGL1Renderer({ canvas: this.canvas3D, alpha: true, antialias: true, powerPreference: 'high-performance' as WebGLPowerPreference, preserveDrawingBuffer: true }); this.threeRenderer.autoClear = true; this.threeRenderer.outputEncoding = GammaEncoding; this.threeRenderer.physicallyCorrectLights = true; this.threeRenderer.setPixelRatio(1); // handle pixel ratio externally this.threeRenderer.shadowMap.enabled = true; this.threeRenderer.shadowMap.type = PCFSoftShadowMap; this.threeRenderer.shadowMap.autoUpdate = false; this.debugger = options != null && !!options.debug ? new Debugger(this) : null; this.threeRenderer.debug = {checkShaderErrors: !!this.debugger}; // ACESFilmicToneMapping appears to be the most "saturated", // and similar to Filament's gltf-viewer. this.threeRenderer.toneMapping = ACESFilmicToneMapping; } catch (error) { console.warn(error); } this.arRenderer = new ARRenderer(this); this.textureUtils = this.canRender ? new TextureUtils(this.threeRenderer) : null; this.roughnessMipmapper = new RoughnessMipmapper(this.threeRenderer); CachingGLTFLoader.initializeKTX2Loader(this.threeRenderer); this.updateRendererSize(); this.lastTick = performance.now(); this.avgFrameDuration = 0; } /** * Updates the renderer's size based on the largest scene and any changes to * device pixel ratio. */ private updateRendererSize() { const dpr = resolveDpr(); if (dpr !== this.dpr) { // If the device pixel ratio has changed due to page zoom, elements // specified by % width do not fire a resize event even though their CSS // pixel dimensions change, so we force them to update their size here. for (const scene of this.scenes) { const {element} = scene; element[$updateSize](element.getBoundingClientRect()); } } // Make the renderer the size of the largest scene let width = 0; let height = 0; for (const scene of this.scenes) { width = Math.max(width, scene.width); height = Math.max(height, scene.height); } if (width === this.width && height === this.height && dpr === this.dpr) { return; } this.width = width; this.height = height; this.dpr = dpr; if (this.canRender) { this.threeRenderer.setSize(width * dpr, height * dpr, false); } // Expand the canvas size to make up for shrinking the viewport. const scale = this.scaleFactor; const widthCSS = width / scale; const heightCSS = height / scale; // The canvas element must by styled outside of three due to the offscreen // canvas not being directly stylable. this.canvasElement.style.width = `${widthCSS}px`; this.canvasElement.style.height = `${heightCSS}px`; // Each scene's canvas must match the renderer size. In general they can be // larger than the element that contains them, but the overflow is hidden // and only the portion that is shown is copied over. for (const scene of this.scenes) { const {canvas} = scene; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); canvas.style.width = `${widthCSS}px`; canvas.style.height = `${heightCSS}px`; scene.isDirty = true; } } private updateRendererScale() { const scaleStep = this.scaleStep; if (this.avgFrameDuration > HIGH_FRAME_DURATION_MS && this.scaleStep < this.lastStep) { ++this.scaleStep; } else if ( this.avgFrameDuration < LOW_FRAME_DURATION_MS && this.scaleStep > 0) { --this.scaleStep; } if (scaleStep == this.scaleStep) { return; } const scale = this.scaleFactor; this.avgFrameDuration = (HIGH_FRAME_DURATION_MS + LOW_FRAME_DURATION_MS) / 2; const width = this.width / scale; const height = this.height / scale; this.canvasElement.style.width = `${width}px`; this.canvasElement.style.height = `${height}px`; for (const scene of this.scenes) { const {style} = scene.canvas; style.width = `${width}px`; style.height = `${height}px`; scene.isDirty = true; } } registerScene(scene: ModelScene) { this.scenes.add(scene); const {canvas} = scene; const scale = this.scaleFactor; canvas.width = Math.round(this.width * this.dpr); canvas.height = Math.round(this.height * this.dpr); canvas.style.width = `${this.width / scale}px`; canvas.style.height = `${this.height / scale}px`; if (this.multipleScenesVisible) { canvas.classList.add('show'); } scene.isDirty = true; if (this.canRender && this.scenes.size > 0) { this.threeRenderer.setAnimationLoop((time: number) => this.render(time)); } if (this.debugger != null) { this.debugger.addScene(scene); } } unregisterScene(scene: ModelScene) { this.scenes.delete(scene); if (this.canRender && this.scenes.size === 0) { (this.threeRenderer.setAnimationLoop as any)(null); } if (this.debugger != null) { this.debugger.removeScene(scene); } } displayCanvas(scene: ModelScene): HTMLCanvasElement { return this.multipleScenesVisible ? scene.element[$canvas] : this.canvasElement; } /** * The function enables an optimization, where when there is only a single * <model-viewer> element, we can use the renderer's 3D canvas directly for * display. Otherwise we need to use the element's 2D canvas and copy the * renderer's result into it. */ private selectCanvas() { let visibleScenes = 0; let visibleInput = null; for (const scene of this.scenes) { const {element} = scene; if (element.modelIsVisible) { ++visibleScenes; visibleInput = element[$userInputElement]; } } const multipleScenesVisible = visibleScenes > 1 || USE_OFFSCREEN_CANVAS; const {canvasElement} = this; if (multipleScenesVisible === this.multipleScenesVisible && (multipleScenesVisible || canvasElement.parentElement === visibleInput)) { return; } this.multipleScenesVisible = multipleScenesVisible; if (multipleScenesVisible) { canvasElement.classList.remove('show'); } for (const scene of this.scenes) { const userInputElement = scene.element[$userInputElement]; const canvas = scene.element[$canvas]; if (multipleScenesVisible) { canvas.classList.add('show'); scene.isDirty = true; } else if (userInputElement === visibleInput) { userInputElement.appendChild(canvasElement); canvasElement.classList.add('show'); canvas.classList.remove('show'); scene.isDirty = true; } } } /** * Returns an array version of this.scenes where the non-visible ones are * first. This allows eager scenes to be rendered before they are visible, * without needing the multi-canvas render path. */ private orderedScenes(): Array<ModelScene> { const scenes = []; for (const visible of [false, true]) { for (const scene of this.scenes) { if (scene.element.modelIsVisible === visible) { scenes.push(scene); } } } return scenes; } get isPresenting(): boolean { return this.arRenderer.isPresenting; } /** * This method takes care of updating the element and renderer state based on * the time that has passed since the last rendered frame. */ preRender(scene: ModelScene, t: number, delta: number) { const {element, exposure} = scene; element[$tick](t, delta); const exposureIsNumber = typeof exposure === 'number' && !(self as any).isNaN(exposure); this.threeRenderer.toneMappingExposure = exposureIsNumber ? exposure : 1.0; if (scene.isShadowDirty()) { this.threeRenderer.shadowMap.needsUpdate = true; } } render(t: number) { const delta = t - this.lastTick; this.lastTick = t; if (!this.canRender || this.isPresenting) { return; } this.avgFrameDuration += clamp( DURATION_DECAY * (delta - this.avgFrameDuration), -MAX_AVG_CHANGE_MS, MAX_AVG_CHANGE_MS); this.selectCanvas(); this.updateRendererSize(); this.updateRendererScale(); const {dpr, scaleFactor} = this; for (const scene of this.orderedScenes()) { if (!scene.element[$sceneIsReady]()) { continue; } this.preRender(scene, t, delta); if (!scene.isDirty) { continue; } scene.isDirty = false; ++scene.renderCount; if (!scene.element.modelIsVisible && !this.multipleScenesVisible) { // Here we are pre-rendering on the visible canvas, so we must mark the // visible scene dirty to ensure it overwrites us. for (const scene of this.scenes) { if (scene.element.modelIsVisible) { scene.isDirty = true; } } } // We avoid using the Three.js PixelRatio and handle it ourselves here so // that we can do proper rounding and avoid white boundary pixels. const width = Math.min( Math.ceil(scene.width * scaleFactor * dpr), this.canvas3D.width); const height = Math.min( Math.ceil(scene.height * scaleFactor * dpr), this.canvas3D.height); // Need to set the render target in order to prevent // clearing the depth from a different buffer this.threeRenderer.setRenderTarget(null); this.threeRenderer.setViewport( 0, Math.floor(this.height * dpr) - height, width, height); this.threeRenderer.render(scene, scene.getCamera()); if (this.multipleScenesVisible) { if (scene.context == null) { scene.createContext(); } if (USE_OFFSCREEN_CANVAS) { const contextBitmap = scene.context as ImageBitmapRenderingContext; const bitmap = (this.canvas3D as OffscreenCanvas).transferToImageBitmap(); contextBitmap.transferFromImageBitmap(bitmap); } else { const context2D = scene.context as CanvasRenderingContext2D; context2D.clearRect(0, 0, width, height); context2D.drawImage( this.canvas3D, 0, 0, width, height, 0, 0, width, height); } } } } dispose() { if (this.textureUtils != null) { this.textureUtils.dispose(); } if (this.threeRenderer != null) { this.threeRenderer.dispose(); } this.textureUtils = null; (this as any).threeRenderer = null; this.scenes.clear(); this.canvas3D.removeEventListener( 'webglcontextlost', this.onWebGLContextLost); } onWebGLContextLost = (event: Event) => { this.dispatchEvent( {type: 'contextlost', sourceEvent: event} as ContextLostEvent); }; }