@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
302 lines • 12.6 kB
JavaScript
/*
* Copyright 2018 Google Inc. 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.
*/
var _a, _b;
import { BackSide, BoxBufferGeometry, Color, DirectionalLight, Object3D, PerspectiveCamera, Scene, ShaderLib, ShaderMaterial, Vector3 } from 'three';
import { Mesh } from 'three';
import { resolveDpr } from '../utilities.js';
import Model from './Model.js';
import { cubeUVChunk } from './shader-chunk/cube_uv_reflection_fragment.glsl.js';
import StaticShadow from './StaticShadow.js';
export const IlluminationRole = {
Primary: 'primary',
Secondary: 'secondary'
};
const $paused = Symbol('paused');
const $modelAlignmentMask = Symbol('modelAlignmentMask');
/**
* 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 {
constructor({ canvas, element, width, height, renderer }) {
super();
this[_a] = false;
this[_b] = new Vector3(1, 1, 1);
this.target = new Vector3();
this.framedHeight = 1;
this.modelDepth = 1;
this.isVisible = false;
this.isDirty = false;
this.name = 'ModelScene';
this.element = element;
this.canvas = canvas;
this.context = canvas.getContext('2d');
this.renderer = renderer;
this.exposure = 1;
this.model = new Model();
this.shadow = new StaticShadow();
this.shadowLight = new DirectionalLight(0xffffff, 1.0);
this.shadowLight.position.set(0, 10, 0);
this.shadowLight.name = 'ShadowLight';
this.width = width;
this.height = height;
this.aspect = width / height;
// 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, this.aspect, 0.1, 100);
this.camera.name = 'MainCamera';
this.activeCamera = this.camera;
this.pivot = new Object3D();
this.pivot.name = 'Pivot';
this.skyboxMesh = this.createSkyboxMesh();
this.add(this.pivot);
this.add(this.shadowLight);
this.pivot.add(this.model);
this.setSize(width, height);
this.background = new Color(0xffffff);
this.model.addEventListener('model-load', (event) => this.onModelLoad(event));
}
get paused() {
return this[$paused];
}
pause() {
this[$paused] = true;
}
resume() {
this[$paused] = false;
}
/**
* Sets the model via URL.
*
* @param {String?} source
* @param {Function?} progressCallback
*/
async setModelSource(source, progressCallback) {
try {
await this.model.setSource(source, progressCallback);
}
catch (e) {
throw new Error(`Could not set model source to '${source}': ${e.message}`);
}
}
/**
* Configures the alignment of the model within the frame based on value
* "masks". By default, the model will be aligned so that the center of its
* bounding box volume is in the center of the frame on all axes. In order to
* center the model this way, the model is translated by the delta between
* the world center of the bounding volume and world center of the frame.
*
* The alignment mask allows this translation to be scaled or eliminated
* completely for each of the three axes. So, setModelAlignment(1, 1, 1) will
* center the model in the frame. setModelAlignment(0, 0, 0) will align the
* model so that its root node origin is at [0, 0, 0] in the scene.
*
* @param {number} x
* @param {number} y
* @param {number} z
*/
setModelAlignmentMask(...alignmentMaskValues) {
this[$modelAlignmentMask].set(...alignmentMaskValues);
this.alignModel();
this.isDirty = true;
}
/**
* Receives the size of the 2D canvas element to make according
* adjustments in the scene.
*
* @param {number} width
* @param {number} height
*/
setSize(width, height) {
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:
this.updateFraming();
}
}
/**
* To frame the scene, a box is fit around the model such that the X and Z
* dimensions (modelDepth) are the same (for Y-rotation) and the X/Y ratio is
* the aspect ratio of the canvas (framedHeight is the Y dimension). For
* non-centered models, the box is fit symmetrically about the XZ origin to
* keep them in frame as they are rotated. At the ideal distance, the camera's
* fov exactly covers the front face of this box when looking down the Z-axis.
*/
updateFraming() {
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;
const { boundingBox, position, size } = this.model;
if (size.x != 0 || size.y != 0 || size.z != 0) {
const boxHalfX = Math.max(Math.abs(boundingBox.min.x + position.x), Math.abs(boundingBox.max.x + position.x));
const boxHalfZ = Math.max(Math.abs(boundingBox.min.z + position.z), Math.abs(boundingBox.max.z + position.z));
const modelMinY = Math.min(0, boundingBox.min.y + position.y);
const modelMaxY = Math.max(0, boundingBox.max.y + position.y);
this.target.y = this[$modelAlignmentMask].y * (modelMaxY + modelMinY) / 2;
const boxHalfY = Math.max(modelMaxY - this.target.y, this.target.y - modelMinY);
this.modelDepth = 2 * Math.max(boxHalfX, boxHalfZ);
this.framedHeight = Math.max(2 * boxHalfY, this.modelDepth / this.aspect);
}
// 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());
});
}
configureStageLighting(intensityScale) {
this.shadowLight.intensity = intensityScale;
this.isDirty = true;
}
/**
* Returns the size of the corresponding canvas element.
* @return {Object}
*/
getSize() {
return { width: this.width, height: this.height };
}
/**
* Moves the model to be centered at the XZ origin, with Y = 0 being the floor
* under the model, taking into account setModelAlignmentMask(), described
* above.
*/
alignModel() {
if (!this.model.hasModel() || this.model.size.length() === 0) {
return;
}
this.resetModelPose();
let centeredOrigin = this.model.boundingBox.getCenter(new Vector3());
centeredOrigin.y -= this.model.size.y / 2;
this.model.position.copy(centeredOrigin)
.multiply(this[$modelAlignmentMask])
.multiplyScalar(-1);
this.updateFraming();
this.updateStaticShadow();
}
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.
* @return {THREE.Camera}
*/
getCamera() {
return this.activeCamera;
}
/**
* Sets the passed in camera to be used for rendering.
* @param {THREE.Camera}
*/
setCamera(camera) {
this.activeCamera = camera;
}
/**
* Called when the model's contents have loaded, or changed.
*/
onModelLoad(event) {
this.alignModel();
this.dispatchEvent({ type: 'model-load', url: event.url });
}
/**
* Called to update the shadow rendering when the room or 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.pivot.rotation.y = 0;
const modelPosition = this.model.boundingBox.getCenter(new Vector3())
.add(this.model.position);
this.shadow.scale.x = 2 * Math.abs(modelPosition.x) + this.model.size.x;
this.shadow.scale.z = 2 * Math.abs(modelPosition.z) + this.model.size.z;
this.shadow.render(this.renderer.renderer, this, this.shadowLight);
// Lazily add the shadow so we're only displaying it once it has
// a generated texture.
this.pivot.add(this.shadow);
this.pivot.rotation.y = currentRotation;
// TODO(#453) When we add a configurable camera target we should put the
// floor back at y=0 for a consistent coordinate system.
if (this[$modelAlignmentMask].y == 0) {
this.shadow.position.y = modelPosition.y - this.model.size.y / 2;
}
}
createSkyboxMesh() {
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.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() {
return this.skyboxMesh.material;
}
}
_a = $paused, _b = $modelAlignmentMask;
//# sourceMappingURL=ModelScene.js.map