@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
653 lines (557 loc) • 20.8 kB
text/typescript
/* @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 {ReactiveElement} from 'lit';
import {property} from 'lit/decorators.js';
import {Camera as ThreeCamera, Event as ThreeEvent, Vector2, Vector3, WebGLRenderer} from 'three';
import {HAS_INTERSECTION_OBSERVER, HAS_RESIZE_OBSERVER} from './constants.js';
import {$updateEnvironment} from './features/environment.js';
import {makeTemplate} from './template.js';
import {$evictionPolicy, CachingGLTFLoader} from './three-components/CachingGLTFLoader.js';
import {ModelScene} from './three-components/ModelScene.js';
import {ContextLostEvent, Renderer} from './three-components/Renderer.js';
import {clamp, debounce} from './utilities.js';
import {ProgressTracker} from './utilities/progress-tracker.js';
const CLEAR_MODEL_TIMEOUT_MS = 10;
const FALLBACK_SIZE_UPDATE_THRESHOLD_MS = 50;
const ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD = 0;
const UNSIZED_MEDIA_WIDTH = 300;
const UNSIZED_MEDIA_HEIGHT = 150;
export const blobCanvas = document.createElement('canvas');
const $fallbackResizeHandler = Symbol('fallbackResizeHandler');
const $defaultAriaLabel = Symbol('defaultAriaLabel');
const $resizeObserver = Symbol('resizeObserver');
const $clearModelTimeout = Symbol('clearModelTimeout');
const $onContextLost = Symbol('onContextLost');
const $loaded = Symbol('loaded');
const $status = Symbol('status');
const $onFocus = Symbol('onFocus');
const $onBlur = Symbol('onBlur');
export const $updateSize = Symbol('updateSize');
export const $intersectionObserver = Symbol('intersectionObserver');
export const $isElementInViewport = Symbol('isElementInViewport');
export const $announceModelVisibility = Symbol('announceModelVisibility');
export const $ariaLabel = Symbol('ariaLabel');
export const $altDefaulted = Symbol('altDefaulted');
export const $statusElement = Symbol('statusElement');
export const $updateStatus = Symbol('updateStatus');
export const $loadedTime = Symbol('loadedTime');
export const $updateSource = Symbol('updateSource');
export const $markLoaded = Symbol('markLoaded');
export const $container = Symbol('container');
export const $userInputElement = Symbol('input');
export const $canvas = Symbol('canvas');
export const $scene = Symbol('scene');
export const $needsRender = Symbol('needsRender');
export const $tick = Symbol('tick');
export const $onModelLoad = Symbol('onModelLoad');
export const $onResize = Symbol('onResize');
export const $renderer = Symbol('renderer');
export const $progressTracker = Symbol('progressTracker');
export const $getLoaded = Symbol('getLoaded');
export const $getModelIsVisible = Symbol('getModelIsVisible');
export const $shouldAttemptPreload = Symbol('shouldAttemptPreload');
export interface Vector3D {
x: number
y: number
z: number
toString(): string
}
export const toVector3D = (v: Vector3) => {
return {
x: v.x,
y: v.y,
z: v.z,
toString() {
return `${this.x}m ${this.y}m ${this.z}m`;
}
};
};
export interface Vector2D {
u: number
v: number
toString(): string
}
export const toVector2D = (v: Vector2) => {
return {
u: v.x,
v: v.y,
toString() {
return `${this.u} ${this.v}`;
}
};
};
interface ToBlobOptions {
mimeType?: string, qualityArgument?: number, idealAspect?: boolean
}
export interface FramingInfo {
framedRadius: number;
fieldOfViewAspect: number;
}
export interface Camera {
viewMatrix: Array<number>;
projectionMatrix: Array<number>;
}
export interface EffectComposerInterface {
setRenderer(renderer: WebGLRenderer): void;
setMainScene(scene: ModelScene): void;
setMainCamera(camera: ThreeCamera): void;
setSize(width: number, height: number): void;
beforeRender(time: DOMHighResTimeStamp, delta: DOMHighResTimeStamp): void;
render(deltaTime?: DOMHighResTimeStamp): void;
}
export interface RendererInterface {
load(progressCallback: (progress: number) => void): Promise<FramingInfo>;
render(camera: Camera): void;
resize(width: number, height: number): void;
}
/**
* Definition for a basic <model-viewer> element.
*/
export default class ModelViewerElementBase extends ReactiveElement {
static get is() {
return 'model-viewer';
}
/** @export */
static set modelCacheSize(value: number) {
CachingGLTFLoader[$evictionPolicy].evictionThreshold = value;
}
/** @export */
static get modelCacheSize(): number {
return CachingGLTFLoader[$evictionPolicy].evictionThreshold
}
/** @export */
static set minimumRenderScale(value: number) {
if (value > 1) {
console.warn(
'<model-viewer> minimumRenderScale has been clamped to a maximum value of 1.');
}
if (value <= 0) {
console.warn(
'<model-viewer> minimumRenderScale has been clamped to a minimum value of 0.25.');
}
Renderer.singleton.minScale = value;
}
/** @export */
static get minimumRenderScale(): number {
return Renderer.singleton.minScale;
}
alt: string|null = null;
src: string|null = null;
withCredentials: boolean = false;
/**
* Generates a 3D model schema https://schema.org/3DModel associated with
* the loaded src and inserts it into the header of the page for search
* engines to crawl.
*/
generateSchema = false;
protected[$isElementInViewport] = false;
protected[$loaded] = false;
protected[$loadedTime] = 0;
protected[$scene]: ModelScene;
protected[$container]: HTMLDivElement;
protected[$userInputElement]: HTMLDivElement;
protected[$canvas]: HTMLCanvasElement;
protected[$statusElement]: HTMLSpanElement;
protected[$status] = '';
protected[$defaultAriaLabel]: string;
protected[$clearModelTimeout]: number|null = null;
protected[$fallbackResizeHandler] = debounce(() => {
const boundingRect = this.getBoundingClientRect();
this[$updateSize](boundingRect);
}, FALLBACK_SIZE_UPDATE_THRESHOLD_MS);
protected[$announceModelVisibility] = debounce((oldVisibility: boolean) => {
const newVisibility = this.modelIsVisible;
if (newVisibility !== oldVisibility) {
this.dispatchEvent(new CustomEvent(
'model-visibility', {detail: {visible: newVisibility}}));
}
}, ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD);
protected[$resizeObserver]: ResizeObserver|null = null;
protected[$intersectionObserver]: IntersectionObserver|null = null;
protected[$progressTracker]: ProgressTracker = new ProgressTracker();
/** @export */
get loaded() {
return this[$getLoaded]();
}
get[$renderer]() {
return Renderer.singleton;
}
/** @export */
get modelIsVisible() {
return this[$getModelIsVisible]();
}
/**
* Creates a new ModelViewerElement.
*/
constructor() {
super();
this.attachShadow({mode: 'open'});
const shadowRoot = this.shadowRoot!;
makeTemplate(shadowRoot);
this[$container] = shadowRoot.querySelector('.container') as HTMLDivElement;
this[$userInputElement] =
shadowRoot.querySelector('.userInput') as HTMLDivElement;
this[$canvas] = shadowRoot.querySelector('canvas') as HTMLCanvasElement;
this[$statusElement] =
shadowRoot.querySelector('#status') as HTMLSpanElement;
this[$defaultAriaLabel] =
this[$userInputElement].getAttribute('aria-label')!;
// Because of potential race conditions related to invoking the constructor
// we only use the bounding rect to set the initial size if the element is
// already connected to the document:
let width, height;
if (this.isConnected) {
const rect = this.getBoundingClientRect();
width = rect.width;
height = rect.height;
} else {
width = UNSIZED_MEDIA_WIDTH;
height = UNSIZED_MEDIA_HEIGHT;
}
// Create the underlying ModelScene.
this[$scene] =
new ModelScene({canvas: this[$canvas], element: this, width, height});
// Update initial size on microtask timing so that subclasses have a
// chance to initialize
Promise.resolve().then(() => {
this[$updateSize](this.getBoundingClientRect());
});
if (HAS_RESIZE_OBSERVER) {
// Set up a resize observer so we can scale our canvas
// if our <model-viewer> changes
this[$resizeObserver] =
new ResizeObserver((entries: Array<ResizeObserverEntry>) => {
// Don't resize anything if in AR mode; otherwise the canvas
// scaling to fullscreen on entering AR will clobber the flat/2d
// dimensions of the element.
if (this[$renderer].isPresenting) {
return;
}
for (let entry of entries) {
if (entry.target === this) {
this[$updateSize](entry.contentRect);
}
}
});
}
if (HAS_INTERSECTION_OBSERVER) {
this[$intersectionObserver] = new IntersectionObserver(entries => {
for (let entry of entries) {
if (entry.target === this) {
const oldVisibility = this.modelIsVisible;
this[$isElementInViewport] = entry.isIntersecting;
this[$announceModelVisibility](oldVisibility);
if (this[$isElementInViewport] && !this.loaded) {
this[$updateSource]();
}
}
}
}, {
root: null,
// We used to have margin here, but it was causing animated models below
// the fold to steal the frame budget. Weirder still, it would also
// cause input events to be swallowed, sometimes for seconds on the
// model above the fold, but only when the animated model was completely
// below. Setting this margin to zero fixed it.
rootMargin: '0px',
// With zero threshold, an element adjacent to but not intersecting the
// viewport will be reported as intersecting, which will cause
// unnecessary rendering. Any slight positive threshold alleviates this.
threshold: 0.00001,
});
} else {
// If there is no intersection observer, then all models should be visible
// at all times:
this[$isElementInViewport] = true;
}
}
connectedCallback() {
super.connectedCallback && super.connectedCallback();
if (HAS_RESIZE_OBSERVER) {
this[$resizeObserver]!.observe(this);
} else {
self.addEventListener('resize', this[$fallbackResizeHandler]);
}
if (HAS_INTERSECTION_OBSERVER) {
this[$intersectionObserver]!.observe(this);
}
this.addEventListener('focus', this[$onFocus]);
this.addEventListener('blur', this[$onBlur]);
const renderer = this[$renderer];
renderer.addEventListener(
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
renderer.registerScene(this[$scene]);
if (this[$clearModelTimeout] != null) {
self.clearTimeout(this[$clearModelTimeout]!);
this[$clearModelTimeout] = null;
// Force an update in case the model has been evicted from our GLTF cache
// @see https://lit-element.polymer-project.org/guide/lifecycle#requestupdate
this.requestUpdate('src', null);
}
}
disconnectedCallback() {
super.disconnectedCallback && super.disconnectedCallback();
if (HAS_RESIZE_OBSERVER) {
this[$resizeObserver]!.unobserve(this);
} else {
self.removeEventListener('resize', this[$fallbackResizeHandler]);
}
if (HAS_INTERSECTION_OBSERVER) {
this[$intersectionObserver]!.unobserve(this);
}
this.removeEventListener('focus', this[$onFocus]);
this.removeEventListener('blur', this[$onBlur]);
const renderer = this[$renderer];
renderer.removeEventListener(
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
renderer.unregisterScene(this[$scene]);
this[$clearModelTimeout] = self.setTimeout(() => {
this[$scene].dispose();
this[$clearModelTimeout] = null;
}, CLEAR_MODEL_TIMEOUT_MS);
}
updated(changedProperties: Map<string|number|symbol, any>) {
super.updated(changedProperties);
// NOTE(cdata): If a property changes from values A -> B -> A in the space
// of a microtask, LitElement/UpdatingElement will notify of a change even
// though the value has effectively not changed, so we need to check to make
// sure that the value has actually changed before changing the loaded flag.
if (changedProperties.has('src')) {
if (this.src == null) {
this[$loaded] = false;
this[$loadedTime] = 0;
this[$scene].reset();
} else if (this.src !== this[$scene].url) {
this[$loaded] = false;
this[$loadedTime] = 0;
this[$updateSource]();
}
}
if (changedProperties.has('alt')) {
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
}
if (changedProperties.has('withCredentials')) {
CachingGLTFLoader.withCredentials = this.withCredentials;
this[$renderer].textureUtils!.withCredentials = this.withCredentials;
}
if (changedProperties.has('generateSchema')) {
if (this.generateSchema) {
this[$scene].updateSchema(this.src);
} else {
this[$scene].updateSchema(null);
}
}
}
/** @export */
toDataURL(type?: string, encoderOptions?: number): string {
return this[$renderer]
.displayCanvas(this[$scene])
.toDataURL(type, encoderOptions);
}
/** @export */
async toBlob(options?: ToBlobOptions): Promise<Blob> {
const mimeType = options ? options.mimeType : undefined;
const qualityArgument = options ? options.qualityArgument : undefined;
const useIdealAspect = options ? options.idealAspect : undefined;
const {width, height, idealAspect, aspect} = this[$scene];
const {dpr, scaleFactor} = this[$renderer];
let outputWidth = width * scaleFactor * dpr;
let outputHeight = height * scaleFactor * dpr;
let offsetX = 0;
let offsetY = 0;
if (useIdealAspect === true) {
if (idealAspect > aspect) {
const oldHeight = outputHeight;
outputHeight = Math.round(outputWidth / idealAspect);
offsetY = (oldHeight - outputHeight) / 2;
} else {
const oldWidth = outputWidth;
outputWidth = Math.round(outputHeight * idealAspect);
offsetX = (oldWidth - outputWidth) / 2;
}
}
blobCanvas.width = outputWidth;
blobCanvas.height = outputHeight;
try {
return new Promise<Blob>(async (resolve, reject) => {
blobCanvas.getContext('2d')!.drawImage(
this[$renderer].displayCanvas(this[$scene]),
offsetX,
offsetY,
outputWidth,
outputHeight,
0,
0,
outputWidth,
outputHeight);
blobCanvas.toBlob((blob) => {
if (!blob) {
return reject(new Error('Unable to retrieve canvas blob'));
}
resolve(blob);
}, mimeType, qualityArgument);
});
} finally {
this[$updateSize]({width, height});
};
}
/**
* Registers a new EffectComposer as the main rendering pipeline,
* instead of the default ThreeJs renderer.
* This method also calls setRenderer, setMainScene, and setMainCamera on
* your effectComposer.
* @param effectComposer An EffectComposer from `pmndrs/postprocessing`
*/
registerEffectComposer(effectComposer: EffectComposerInterface) {
effectComposer.setRenderer(this[$renderer].threeRenderer);
effectComposer.setMainCamera(this[$scene].getCamera());
effectComposer.setMainScene(this[$scene]);
this[$scene].effectRenderer = effectComposer;
}
/**
* Removes the registered EffectComposer
*/
unregisterEffectComposer() {
this[$scene].effectRenderer = null;
}
registerRenderer(renderer: RendererInterface) {
this[$scene].externalRenderer = renderer;
}
unregisterRenderer() {
this[$scene].externalRenderer = null;
}
get[$ariaLabel]() {
return this[$altDefaulted];
}
get[$altDefaulted]() {
return (this.alt == null || this.alt === 'null') ? this[$defaultAriaLabel] :
this.alt;
}
// NOTE(cdata): Although this may seem extremely redundant, it is required in
// order to support overloading when TypeScript is compiled to ES5
// @see https://github.com/Polymer/lit-element/pull/745
// @see https://github.com/microsoft/TypeScript/issues/338
[$getLoaded](): boolean {
return this[$loaded];
}
// @see [$getLoaded]
[$getModelIsVisible](): boolean {
return this.loaded && this[$isElementInViewport];
}
[$shouldAttemptPreload](): boolean {
return !!this.src && this[$isElementInViewport];
}
/**
* Called on initialization and when the resize observer fires.
*/
[$updateSize]({width, height}: {width: number, height: number}) {
if (width === 0 || height === 0) {
return;
}
this[$container].style.width = `${width}px`;
this[$container].style.height = `${height}px`;
this[$onResize]({width, height});
}
[$tick](time: number, delta: number) {
this[$scene].effectRenderer?.beforeRender(time, delta);
}
[$markLoaded]() {
if (this[$loaded]) {
return;
}
this[$loaded] = true;
this[$loadedTime] = performance.now();
}
[$needsRender]() {
this[$scene].queueRender();
}
[$onModelLoad]() {
}
[$updateStatus](status: string) {
this[$status] = status;
const rootNode = this.getRootNode() as Document | ShadowRoot | null;
// Only change the aria-label if <model-viewer> is currently focused:
if (rootNode != null && rootNode.activeElement === this &&
this[$statusElement].textContent != status) {
this[$statusElement].textContent = status;
}
}
[$onFocus] = () => {
this[$statusElement].textContent = this[$status];
};
[$onBlur] = () => {
this[$statusElement].textContent = '';
};
[$onResize](e: {width: number, height: number}) {
this[$scene].setSize(e.width, e.height);
}
[$onContextLost] = (event: ContextLostEvent) => {
this.dispatchEvent(new CustomEvent(
'error',
{detail: {type: 'webglcontextlost', sourceError: event.sourceEvent}}));
};
/**
* Parses the element for an appropriate source URL and
* sets the views to use the new model based.
*/
async[$updateSource]() {
const scene = this[$scene];
if (this.loaded || !this[$shouldAttemptPreload]() ||
this.src === scene.url) {
return;
}
if (this.generateSchema) {
scene.updateSchema(this.src);
}
this[$updateStatus]('Loading');
// If we are loading a new model, we need to stop the animation of
// the current one (if any is playing). Otherwise, we might lose
// the reference to the scene root and running actions start to
// throw exceptions and/or behave in unexpected ways:
scene.stopAnimation();
const updateSourceProgress = this[$progressTracker].beginActivity('model-load');
const source = this.src;
try {
const srcUpdated = scene.setSource(
source,
(progress: number) =>
updateSourceProgress(clamp(progress, 0, 1) * 0.95));
const envUpdated = (this as any)[$updateEnvironment]();
await Promise.all([srcUpdated, envUpdated]);
this[$markLoaded]();
this[$onModelLoad]();
this.updateComplete.then(() => {
this.dispatchEvent(new CustomEvent('before-render'));
});
// Wait for shaders to compile and pixels to be drawn.
await new Promise<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.dispatchEvent(
new CustomEvent('load', {detail: {url: source}}));
resolve();
});
});
});
} catch (error) {
this.dispatchEvent(new CustomEvent(
'error', {detail: {type: 'loadfailure', sourceError: error}}));
} finally {
updateSourceProgress(1.0);
}
}
}