UNPKG

@google/model-viewer

Version:

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

295 lines (255 loc) 9.32 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 {BackSide, BoxBufferGeometry, Camera, Color, Event as ThreeEvent, Object3D, PerspectiveCamera, Scene, Shader, ShaderLib, ShaderMaterial, Vector3} from 'three'; import {Mesh} from 'three'; import ModelViewerElementBase from '../model-viewer-base.js'; import {resolveDpr} from '../utilities.js'; import Model from './Model.js'; import {Renderer} from './Renderer.js'; import {cubeUVChunk} from './shader-chunk/cube_uv_reflection_fragment.glsl.js'; import StaticShadow from './StaticShadow.js'; export interface ModelLoadEvent extends ThreeEvent { url: string } export interface ModelSceneConfig { element: ModelViewerElementBase; canvas: HTMLCanvasElement; width: number; height: number; renderer: Renderer; } export type IlluminationRole = 'primary'|'secondary' export const IlluminationRole: {[index: string]: IlluminationRole} = { Primary: 'primary', Secondary: 'secondary' }; const $paused = Symbol('paused'); /** * A THREE.Scene object that takes a Model and CanvasHTMLElement and * constructs a framed scene based off of the canvas dimensions. * Provides lights and cameras to be used in a renderer. */ export default class ModelScene extends Scene { private[$paused]: boolean = false; public aspect = 1; public canvas: HTMLCanvasElement; public renderer: Renderer; public shadow: StaticShadow; public pivot: Object3D; public pivotCenter: Vector3; public width = 1; public height = 1; public isVisible: boolean = false; public isDirty: boolean = false; public element: ModelViewerElementBase; public context: CanvasRenderingContext2D; public exposure = 1; public model: Model; public skyboxMesh: Mesh; public activeCamera: Camera; // These default camera values are never used, as they are reset once the // model is loaded and framing is computed. public camera = new PerspectiveCamera(45, 1, 0.1, 100); constructor({canvas, element, width, height, renderer}: ModelSceneConfig) { super(); this.name = 'ModelScene'; this.element = element; this.canvas = canvas; this.context = canvas.getContext('2d')!; this.renderer = renderer; this.model = new Model(); this.shadow = new StaticShadow(); // These default camera values are never used, as they are reset once the // model is loaded and framing is computed. this.camera = new PerspectiveCamera(45, 1, 0.1, 100); this.camera.name = 'MainCamera'; this.activeCamera = this.camera; this.pivot = new Object3D(); this.pivot.name = 'Pivot'; this.pivotCenter = new Vector3; this.skyboxMesh = this.createSkyboxMesh(); this.add(this.pivot); this.pivot.add(this.model); this.setSize(width, height); this.background = new Color(0xffffff); this.model.addEventListener( 'model-load', (event: any) => this.onModelLoad(event)); } get paused() { return this[$paused]; } pause() { this[$paused] = true; } resume() { this[$paused] = false; } /** * Sets the model via URL. */ async setModelSource( source: string|null, progressCallback?: (progress: number) => void) { try { await this.model.setSource(source, progressCallback); } catch (e) { throw new Error( `Could not set model source to '${source}': ${e.message}`); } } /** * Receives the size of the 2D canvas element to make according * adjustments in the scene. */ setSize(width: number, height: number) { if (width !== this.width || height !== this.height) { this.width = Math.max(width, 1); this.height = Math.max(height, 1); // In practice, invocations of setSize are throttled at the element level, // so no need to throttle here: const dpr = resolveDpr(); this.canvas.width = this.width * dpr; this.canvas.height = this.height * dpr; this.canvas.style.width = `${this.width}px`; this.canvas.style.height = `${this.height}px`; this.aspect = this.width / this.height; // Immediately queue a render to happen at microtask timing. This is // necessary because setting the width and height of the canvas has the // side-effect of clearing it, and also if we wait for the next rAF to // render again we might get hit with yet-another-resize, or worse we // may not actually be marked as dirty and so render will just not // happen. Queuing a render to happen here means we will render twice on // a resize frame, but it avoids most of the visual artifacts associated // with other potential mitigations for this problem. See discussion in // https://github.com/GoogleWebComponents/model-viewer/pull/619 for // additional considerations. Promise.resolve().then(() => { this.renderer.render(performance.now()); }); } } /** * Returns the size of the corresponding canvas element. */ getSize(): {width: number, height: number} { return {width: this.width, height: this.height}; } resetModelPose() { this.model.position.set(0, 0, 0); this.model.rotation.set(0, 0, 0); this.model.scale.set(1, 1, 1); } /** * Returns the current camera. */ getCamera(): Camera { return this.activeCamera; } /** * Sets the passed in camera to be used for rendering. */ setCamera(camera: Camera) { this.activeCamera = camera; } /** * Sets the rotation of the model's pivot, around its pivotCenter point. */ setPivotRotation(radiansY: number) { this.pivot.rotation.y = radiansY; this.pivot.position.x = -this.pivotCenter.x; this.pivot.position.z = -this.pivotCenter.z; this.pivot.position.applyAxisAngle(this.pivot.up, radiansY); this.pivot.position.x += this.pivotCenter.x; this.pivot.position.z += this.pivotCenter.z; } /** * Gets the current rotation value of the pivot */ getPivotRotation(): number { return this.pivot.rotation.y; } /** * Called when the model's contents have loaded, or changed. */ onModelLoad(event: {url: string}) { this.updateStaticShadow(); this.dispatchEvent({type: 'model-load', url: event.url}); } /** * Called to update the shadow rendering when the model changes. */ updateStaticShadow() { if (!this.model.hasModel() || this.model.size.length() === 0) { this.pivot.remove(this.shadow); return; } // Remove and cache the current pivot rotation so that the shadow's // capture is unrotated so it can be freely rotated when applied // as a texture. const currentRotation = this.pivot.rotation.y; this.setPivotRotation(0); this.shadow.render(this.renderer.renderer, this); // Lazily add the shadow so we're only displaying it once it has // a generated texture. this.pivot.add(this.shadow); this.setPivotRotation(currentRotation); } createSkyboxMesh(): Mesh { const geometry = new BoxBufferGeometry(1, 1, 1); geometry.removeAttribute('normal'); geometry.removeAttribute('uv'); const material = new ShaderMaterial({ uniforms: {envMap: {value: null}, opacity: {value: 1.0}}, vertexShader: ShaderLib.cube.vertexShader, fragmentShader: ShaderLib.cube.fragmentShader, side: BackSide, // Turn off the depth buffer so that even a small box still ends up // enclosing a scene of any size. depthTest: false, depthWrite: false, fog: false, }); material.extensions = { derivatives: true, fragDepth: false, drawBuffers: false, shaderTextureLOD: false }; const samplerUV = ` #define ENVMAP_TYPE_CUBE_UV #define PI 3.14159265359 ${cubeUVChunk} uniform sampler2D envMap; `; material.onBeforeCompile = (shader: Shader) => { shader.fragmentShader = shader.fragmentShader.replace('uniform samplerCube tCube;', samplerUV) .replace( 'vec4 texColor = textureCube( tCube, vec3( tFlip * vWorldDirection.x, vWorldDirection.yz ) );', 'gl_FragColor = textureCubeUV( envMap, vWorldDirection, 0.0 );') .replace('gl_FragColor = mapTexelToLinear( texColor );', ''); }; const skyboxMesh = new Mesh(geometry, material); skyboxMesh.frustumCulled = false; // This centers the box on the camera, ensuring the view is not affected by // the camera's motion, which makes it appear inifitely large, as it should. skyboxMesh.onBeforeRender = function(_renderer, _scene, camera) { this.matrixWorld.copyPosition(camera.matrixWorld); }; return skyboxMesh; } skyboxMaterial(): ShaderMaterial { return this.skyboxMesh.material as ShaderMaterial; } }