@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
295 lines (255 loc) • 9.32 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 {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;
}
}