UNPKG

model-viewer-module

Version:

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

906 lines (767 loc) 26.5 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 {AnimationAction, AnimationClip, AnimationMixer, Box3, Camera, Event as ThreeEvent, LoopPingPong, LoopRepeat, Material, Matrix3, Mesh, Object3D, PerspectiveCamera, Raycaster, Scene, Sphere, Texture, Vector2, Vector3, WebGLRenderer} from 'three'; import {CSS2DRenderer} from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import ModelViewerElementBase, {$renderer, RendererInterface} from '../model-viewer-base.js'; import {ModelViewerElement} from '../model-viewer.js'; import {resolveDpr} from '../utilities.js'; import {Damper, SETTLING_TIME} from './Damper.js'; import {ModelViewerGLTFInstance} from './gltf-instance/ModelViewerGLTFInstance.js'; import {Hotspot} from './Hotspot.js'; import {reduceVertices} from './ModelUtils.js'; import {Shadow} from './Shadow.js'; const MIN_SHADOW_RATIO = 100; export interface ModelLoadEvent extends ThreeEvent { url: string; } export interface ModelSceneConfig { element: ModelViewerElementBase; canvas: HTMLCanvasElement; width: number; height: number; } export type IlluminationRole = 'primary'|'secondary'; export const IlluminationRole: {[index: string]: IlluminationRole} = { Primary: 'primary', Secondary: 'secondary' }; const view = new Vector3(); const target = new Vector3(); const normalWorld = new Vector3(); const raycaster = new Raycaster(); const vector3 = new Vector3(); const ndc = new Vector2(); /** * 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 class ModelScene extends Scene { public element: ModelViewerElement; public canvas: HTMLCanvasElement; public context: CanvasRenderingContext2D|ImageBitmapRenderingContext|null = null; public annotationRenderer = new CSS2DRenderer(); public schemaElement = document.createElement('script'); public width = 1; public height = 1; public aspect = 1; public renderCount = 0; public externalRenderer: RendererInterface|null = null; // 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); public xrCamera: Camera|null = null; public url: string|null = null; public target = new Object3D(); public modelContainer = new Object3D(); public animationNames: Array<string> = []; public boundingBox = new Box3(); public boundingSphere = new Sphere(); public size = new Vector3(); public idealAspect = 0; public framedFoVDeg = 0; public shadow: Shadow|null = null; public shadowIntensity = 0; public shadowSoftness = 1; public bakedShadows = new Set<Mesh>(); public exposure = 1; public canScale = true; public tightBounds = false; private isDirty = false; private goalTarget = new Vector3(); private targetDamperX = new Damper(); private targetDamperY = new Damper(); private targetDamperZ = new Damper(); private _currentGLTF: ModelViewerGLTFInstance|null = null; private mixer: AnimationMixer; private cancelPendingSourceChange: (() => void)|null = null; private animationsByName: Map<string, AnimationClip> = new Map(); private currentAnimationAction: AnimationAction|null = null; constructor({canvas, element, width, height}: ModelSceneConfig) { super(); this.name = 'ModelScene'; this.element = element as ModelViewerElement; this.canvas = canvas; // 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.add(this.target); this.setSize(width, height); this.target.name = 'Target'; this.modelContainer.name = 'ModelContainer'; this.target.add(this.modelContainer); this.mixer = new AnimationMixer(this.modelContainer); const {domElement} = this.annotationRenderer; const {style} = domElement; style.display = 'none'; style.pointerEvents = 'none'; style.position = 'absolute'; style.top = '0'; this.element.shadowRoot!.querySelector('.default')!.appendChild(domElement); this.schemaElement.setAttribute('type', 'application/ld+json'); } /** * Function to create the context lazily, as when there is only one * <model-viewer> element, the renderer's 3D context can be displayed * directly. This extra context is necessary to copy the renderings into when * there are more than one. */ createContext() { this.context = this.canvas.getContext('2d')!; } getCamera(): Camera { return this.xrCamera != null ? this.xrCamera : this.camera; } queueRender() { this.isDirty = true; } shouldRender() { return this.isDirty; } hasRendered() { this.isDirty = false; } /** * Pass in a THREE.Object3D to be controlled * by this model. */ async setObject(model: Object3D) { this.reset(); this.modelContainer.add(model); await this.setupScene(); } /** * Sets the model via URL. */ async setSource( url: string|null, progressCallback: (progress: number) => void = () => {}) { if (!url || url === this.url) { progressCallback(1); return; } this.reset(); this.url = url; if (this.externalRenderer != null) { const framingInfo = await this.externalRenderer.load(progressCallback); this.boundingSphere.radius = framingInfo.framedRadius; this.idealAspect = framingInfo.fieldOfViewAspect; this.dispatchEvent({type: 'model-load', url: this.url}); return; } // If we have pending work due to a previous source change in progress, // cancel it so that we do not incur a race condition: if (this.cancelPendingSourceChange != null) { this.cancelPendingSourceChange!(); this.cancelPendingSourceChange = null; } let gltf: ModelViewerGLTFInstance; try { gltf = await new Promise<ModelViewerGLTFInstance>( async (resolve, reject) => { this.cancelPendingSourceChange = () => reject(); try { const result = await this.element[$renderer].loader.load( url, this.element, progressCallback); resolve(result); } catch (error) { reject(error); } }); } catch (error) { if (error == null) { // Loading was cancelled, so silently return return; } throw error; } this.reset(); this.url = url; this._currentGLTF = gltf; if (gltf != null) { this.modelContainer.add(gltf.scene); } const {animations} = gltf!; const animationsByName = new Map(); const animationNames = []; for (const animation of animations) { animationsByName.set(animation.name, animation); animationNames.push(animation.name); } this.animations = animations; this.animationsByName = animationsByName; this.animationNames = animationNames; await this.setupScene(); } private async setupScene() { this.updateBoundingBox(); await this.updateFraming(); this.updateShadow(); this.setShadowIntensity(this.shadowIntensity); this.dispatchEvent({type: 'model-load', url: this.url}); } reset() { this.url = null; this.queueRender(); if (this.shadow != null) { this.shadow.setIntensity(0); } this.bakedShadows.clear(); const gltf = this._currentGLTF; // Remove all current children if (gltf != null) { for (const child of this.modelContainer.children) { this.modelContainer.remove(child); } gltf.dispose(); this._currentGLTF = null; } if (this.currentAnimationAction != null) { this.currentAnimationAction.stop(); this.currentAnimationAction = null; } this.mixer.stopAllAction(); this.mixer.uncacheRoot(this); } get currentGLTF() { return this._currentGLTF; } /** * Updates the ModelScene for a new container size in CSS pixels. */ setSize(width: number, height: number) { if (this.width === width && this.height === height) { return; } this.width = Math.max(width, 1); this.height = Math.max(height, 1); this.annotationRenderer.setSize(width, height); this.aspect = this.width / this.height; if (this.externalRenderer != null) { const dpr = resolveDpr(); this.externalRenderer.resize(width * dpr, height * dpr); } this.queueRender(); } markBakedShadow(mesh: Mesh) { mesh.userData.shadow = true; this.bakedShadows.add(mesh); } unmarkBakedShadow(mesh: Mesh) { mesh.userData.shadow = false; mesh.visible = true; this.bakedShadows.delete(mesh); this.boundingBox.expandByObject(mesh); } findBakedShadows(group: Object3D) { const boundingBox = new Box3(); group.traverse((object: Object3D) => { const mesh = object as Mesh; if (!mesh.isMesh) { return; } const material = mesh.material as Material; if (!material.transparent) { return; } boundingBox.setFromObject(mesh); const size = boundingBox.getSize(vector3); const minDim = Math.min(size.x, size.y, size.z); const maxDim = Math.max(size.x, size.y, size.z); if (maxDim < MIN_SHADOW_RATIO * minDim) { return; } this.markBakedShadow(mesh); }); } checkBakedShadows() { const {min, max} = this.boundingBox; const shadowBox = new Box3(); this.boundingBox.getSize(this.size); for (const mesh of this.bakedShadows) { shadowBox.setFromObject(mesh); if (shadowBox.min.y < min.y + this.size.y / MIN_SHADOW_RATIO && shadowBox.min.x <= min.x && shadowBox.max.x >= max.x && shadowBox.min.z <= min.z && shadowBox.max.z >= max.z) { // floor shadow continue; } if (shadowBox.min.z < min.z + this.size.z / MIN_SHADOW_RATIO && shadowBox.min.x <= min.x && shadowBox.max.x >= max.x && shadowBox.min.y <= min.y && shadowBox.max.y >= max.y) { // wall shadow continue; } this.unmarkBakedShadow(mesh); } } updateBoundingBox() { this.target.remove(this.modelContainer); this.findBakedShadows(this.modelContainer); if (this.tightBounds === true) { const bound = (box: Box3, vertex: Vector3): Box3 => { return box.expandByPoint(vertex); }; this.setBakedShadowVisibility(false); this.boundingBox = reduceVertices(this.modelContainer, bound, new Box3()); // If there's nothing but the baked shadow, then it's not a baked shadow. if (this.boundingBox.isEmpty()) { this.setBakedShadowVisibility(true); this.bakedShadows.forEach((mesh) => this.unmarkBakedShadow(mesh)); this.boundingBox = reduceVertices(this.modelContainer, bound, new Box3()); } this.checkBakedShadows(); this.setBakedShadowVisibility(); } else { this.boundingBox.setFromObject(this.modelContainer); } this.boundingBox.getSize(this.size); this.target.add(this.modelContainer); } /** * Calculates the boundingSphere and idealAspect that allows the 3D * object to be framed tightly in a 2D window of any aspect ratio without * clipping at any camera orbit. The camera's center target point can be * optionally specified. If no center is specified, it defaults to the center * of the bounding box, which means asymmetric models will tend to be tight on * one side instead of both. Proper choice of center can correct this. */ async updateFraming() { this.target.remove(this.modelContainer); this.setBakedShadowVisibility(false); const {center} = this.boundingSphere; if (this.tightBounds === true) { this.element.requestUpdate('cameraTarget'); await this.element.updateComplete; center.copy(this.getTarget()); } else { this.boundingBox.getCenter(center); } const radiusSquared = (value: number, vertex: Vector3): number => { return Math.max(value, center!.distanceToSquared(vertex)); }; this.boundingSphere.radius = Math.sqrt(reduceVertices(this.modelContainer, radiusSquared, 0)); const horizontalTanFov = (value: number, vertex: Vector3): number => { vertex.sub(center!); const radiusXZ = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z); return Math.max( value, radiusXZ / (this.idealCameraDistance() - Math.abs(vertex.y))); }; this.idealAspect = reduceVertices(this.modelContainer, horizontalTanFov, 0) / Math.tan((this.framedFoVDeg / 2) * Math.PI / 180); this.setBakedShadowVisibility(); this.target.add(this.modelContainer); } setBakedShadowVisibility(visible: boolean = this.shadowIntensity <= 0) { for (const shadow of this.bakedShadows) { shadow.visible = visible; } } idealCameraDistance(): number { const halfFovRad = (this.framedFoVDeg / 2) * Math.PI / 180; return this.boundingSphere.radius / Math.sin(halfFovRad); } /** * Set's the framedFieldOfView based on the aspect ratio of the window in * order to keep the model fully visible at any camera orientation. */ adjustedFoV(fovDeg: number): number { const vertical = Math.tan((fovDeg / 2) * Math.PI / 180) * Math.max(1, this.idealAspect / this.aspect); return 2 * Math.atan(vertical) * 180 / Math.PI; } getNDC(clientX: number, clientY: number): Vector2 { if (this.xrCamera != null) { ndc.set(clientX / window.screen.width, clientY / window.screen.height); } else { const rect = this.element.getBoundingClientRect(); ndc.set( (clientX - rect.x) / this.width, (clientY - rect.y) / this.height); } ndc.multiplyScalar(2).subScalar(1); ndc.y *= -1; return ndc; } /** * Returns the size of the corresponding canvas element. */ getSize(): {width: number, height: number} { return {width: this.width, height: this.height}; } setEnvironmentAndSkybox(environment: Texture|null, skybox: Texture|null) { if (this.element[$renderer].arRenderer.presentedScene === this) { return; } this.environment = environment; this.background = skybox; this.queueRender(); } /** * Sets the point in model coordinates the model should orbit/pivot around. */ setTarget(modelX: number, modelY: number, modelZ: number) { this.goalTarget.set(-modelX, -modelY, -modelZ); } /** * Set the decay time of, affects the speed of target transitions. */ setTargetDamperDecayTime(decayMilliseconds: number) { this.targetDamperX.setDecayTime(decayMilliseconds); this.targetDamperY.setDecayTime(decayMilliseconds); this.targetDamperZ.setDecayTime(decayMilliseconds); } /** * Gets the point in model coordinates the model should orbit/pivot around. */ getTarget(): Vector3 { return vector3.copy(this.goalTarget).multiplyScalar(-1); } /** * Shifts the model to the target point immediately instead of easing in. */ jumpToGoal() { this.updateTarget(SETTLING_TIME); } /** * This should be called every frame with the frame delta to cause the target * to transition to its set point. */ updateTarget(delta: number): boolean { const goal = this.goalTarget; const target = this.target.position; if (!goal.equals(target)) { const normalization = this.boundingSphere.radius / 10; let {x, y, z} = target; x = this.targetDamperX.update(x, goal.x, delta, normalization); y = this.targetDamperY.update(y, goal.y, delta, normalization); z = this.targetDamperZ.update(z, goal.z, delta, normalization); this.target.position.set(x, y, z); this.target.updateMatrixWorld(); this.queueRender(); return true; } else { return false; } } /** * Yaw the +z (front) of the model toward the indicated world coordinates. */ pointTowards(worldX: number, worldZ: number) { const {x, z} = this.position; this.yaw = Math.atan2(worldX - x, worldZ - z); } /** * Yaw is the scene's orientation about the y-axis, around the rotation * center. */ set yaw(radiansY: number) { this.rotation.y = radiansY; this.queueRender(); } get yaw(): number { return this.rotation.y; } set animationTime(value: number) { this.mixer.setTime(value); this.queueShadowRender(); } get animationTime(): number { if (this.currentAnimationAction != null) { const loopCount = Math.max((this.currentAnimationAction as any)._loopCount, 0); if (this.currentAnimationAction.loop === LoopPingPong && (loopCount & 1) === 1) { return this.duration - this.currentAnimationAction.time } else { return this.currentAnimationAction.time; } } return 0; } set animationTimeScale(value: number) { this.mixer.timeScale = value; } get animationTimeScale(): number { return this.mixer.timeScale; } get duration(): number { if (this.currentAnimationAction != null && this.currentAnimationAction.getClip()) { return this.currentAnimationAction.getClip().duration; } return 0; } get hasActiveAnimation(): boolean { return this.currentAnimationAction != null; } /** * Plays an animation if there are any associated with the current model. * Accepts an optional string name of an animation to play. If no name is * provided, or if no animation is found by the given name, always falls back * to playing the first animation. */ playAnimation( name: string|null = null, crossfadeTime: number = 0, loopMode: number = LoopRepeat, repetitionCount: number = Infinity) { if (this._currentGLTF == null) { return; } const {animations} = this; if (animations == null || animations.length === 0) { console.warn( `Cannot play animation (model does not have any animations)`); return; } let animationClip = null; if (name != null) { animationClip = this.animationsByName.get(name); if (animationClip == null) { const parsedAnimationIndex = parseInt(name); if (!isNaN(parsedAnimationIndex) && parsedAnimationIndex >= 0 && parsedAnimationIndex < animations.length) { animationClip = animations[parsedAnimationIndex]; } } } if (animationClip == null) { animationClip = animations[0]; } try { const {currentAnimationAction: lastAnimationAction} = this; const action = this.mixer.clipAction(animationClip, this); this.currentAnimationAction = action; if ((this.element as any).paused) { this.mixer.stopAllAction(); } else { action.paused = false; if (lastAnimationAction != null && action !== lastAnimationAction) { action.crossFadeFrom(lastAnimationAction, crossfadeTime, false); } else if ( this.animationTimeScale > 0 && this.animationTime == this.duration) { // This is a workaround for what I believe is a three.js bug. this.animationTime = 0; } } action.setLoop(loopMode, repetitionCount); action.enabled = true; action.clampWhenFinished = true; action.play(); } catch (error) { console.error(error); } } stopAnimation() { this.currentAnimationAction = null; this.mixer.stopAllAction(); } updateAnimation(step: number) { this.mixer.update(step); this.queueShadowRender(); } subscribeMixerEvent(event: string, callback: (...args: any[]) => void) { this.mixer.addEventListener(event, callback); } /** * Call if the object has been changed in such a way that the shadow's shape * has changed (not a rotation about the Y axis). */ updateShadow() { const shadow = this.shadow; if (shadow != null) { const side = (this.element as any).arPlacement === 'wall' ? 'back' : 'bottom'; shadow.setScene(this, this.shadowSoftness, side); shadow.needsUpdate = true; } } renderShadow(renderer: WebGLRenderer) { const shadow = this.shadow; if (shadow != null && shadow.needsUpdate == true) { shadow.render(renderer, this); shadow.needsUpdate = false; } } private queueShadowRender() { if (this.shadow != null) { this.shadow.needsUpdate = true; } } /** * Sets the shadow's intensity, lazily creating the shadow as necessary. */ setShadowIntensity(shadowIntensity: number) { this.shadowIntensity = shadowIntensity; if (this._currentGLTF == null) { return; } this.setBakedShadowVisibility(); if (shadowIntensity <= 0 && this.shadow == null) { return; } if (this.shadow == null) { const side = (this.element as any).arPlacement === 'wall' ? 'back' : 'bottom'; this.shadow = new Shadow(this, this.shadowSoftness, side); } this.shadow.setIntensity(shadowIntensity); } /** * Sets the shadow's softness by mapping a [0, 1] softness parameter to the * shadow's resolution. This involves reallocation, so it should not be * changed frequently. Softer shadows are cheaper to render. */ setShadowSoftness(softness: number) { this.shadowSoftness = softness; const shadow = this.shadow; if (shadow != null) { shadow.setSoftness(softness); } } /** * Shift the floor vertically from the bottom of the model's bounding box by * offset (should generally be negative). */ setShadowOffset(offset: number) { const shadow = this.shadow; if (shadow != null) { shadow.setOffset(offset); } } get raycaster() { return raycaster; } /** * This method returns the world position, model-space normal and texture * coordinate of the point on the mesh corresponding to the input pixel * coordinates given relative to the model-viewer element. If the mesh * is not hit, the result is null. */ positionAndNormalFromPoint(ndcPosition: Vector2, object: Object3D = this): {position: Vector3, normal: Vector3, uv: Vector2|null}|null { this.raycaster.setFromCamera(ndcPosition, this.getCamera()); const hits = this.raycaster.intersectObject(object, true); const hit = hits.find((hit) => hit.object.visible && !hit.object.userData.shadow); if (hit == null || hit.face == null) { return null; } if (hit.uv == null) { return {position: hit.point, normal: hit.face.normal, uv: null}; } hit.face.normal.applyNormalMatrix( new Matrix3().getNormalMatrix(hit.object.matrixWorld)); return {position: hit.point, normal: hit.face.normal, uv: hit.uv}; } /** * The following methods are for operating on the set of Hotspot objects * attached to the scene. These come from DOM elements, provided to slots by * the Annotation Mixin. */ addHotspot(hotspot: Hotspot) { this.target.add(hotspot); // This happens automatically in render(), but we do it early so that // the slots appear in the shadow DOM and the elements get attached, // allowing us to dispatch events on them. this.annotationRenderer.domElement.appendChild(hotspot.element); } removeHotspot(hotspot: Hotspot) { this.target.remove(hotspot); } /** * Helper method to apply a function to all hotspots. */ forHotspots(func: (hotspot: Hotspot) => void) { const {children} = this.target; for (let i = 0, l = children.length; i < l; i++) { const hotspot = children[i]; if (hotspot instanceof Hotspot) { func(hotspot); } } } /** * Update the CSS visibility of the hotspots based on whether their normals * point toward the camera. */ updateHotspots(viewerPosition: Vector3) { this.forHotspots((hotspot) => { view.copy(viewerPosition); target.setFromMatrixPosition(hotspot.matrixWorld); view.sub(target); normalWorld.copy(hotspot.normal) .transformDirection(this.target.matrixWorld); if (view.dot(normalWorld) < 0) { hotspot.hide(); } else { hotspot.show(); } }); } /** * Rotate all hotspots to an absolute orientation given by the input number of * radians. Zero returns them to upright. */ orientHotspots(radians: number) { this.forHotspots((hotspot) => { hotspot.orient(radians); }); } /** * Set the rendering visibility of all hotspots. This is used to hide them * during transitions and such. */ setHotspotsVisibility(visible: boolean) { this.forHotspots((hotspot) => { hotspot.visible = visible; }); } updateSchema(src: string|null) { const {schemaElement, element} = this; const {alt, poster, iosSrc} = element; if (src != null) { const encoding = [{ '@type': 'MediaObject', contentUrl: src, encodingFormat: src.split('.').pop()?.toLowerCase() === 'gltf' ? 'model/gltf+json' : 'model/gltf-binary' }]; if (iosSrc) { encoding.push({ '@type': 'MediaObject', contentUrl: iosSrc, encodingFormat: 'model/vnd.usdz+zip' }); } const structuredData = { '@context': 'http://schema.org/', '@type': '3DModel', image: poster ?? undefined, name: alt ?? undefined, encoding }; schemaElement.textContent = JSON.stringify(structuredData); document.head.appendChild(schemaElement); } else if (schemaElement.parentElement != null) { schemaElement.parentElement.removeChild(schemaElement); } } }