viewer/camera.js
import * as mat4 from "./glmatrix/mat4.js";
import * as mat3 from "./glmatrix/mat3.js";
import * as vec3 from "./glmatrix/vec3.js";
import * as vec4 from "./glmatrix/vec4.js";
import {AnimatedVec3} from "./animatedvec3.js";
import {Perspective} from "./perspective.js";
import {Orthographic} from "./orthographic.js";
/**
A **Camera** defines viewing and projection transforms for its Viewer.
*/
export class Camera {
constructor(viewer) {
this.viewer = viewer;
this.perspective = new Perspective(viewer);
this.orthographic = new Orthographic(viewer);
this._projection = this.perspective; // Currently active projection
this._viewMatrix = mat4.create();
this._viewProjMatrix = mat4.create();
this._viewMatrixInverted = mat4.create();
this._viewProjMatrixInverted = mat4.create();
this._viewNormalMatrix = mat3.create();
this._eye = new AnimatedVec3(0.0, 0.0, -10.0); // World-space eye position
this._target = new AnimatedVec3(0.0, 0.0, 0.0); // World-space point-of-interest
this._up = vec3.fromValues(0.0, 1.0, 0.0); // Camera's "up" vector, always orthogonal to eye->target
this._center = vec3.copy(vec3.create(), this._target.get());
this._negatedCenter = vec3.create();
vec3.negate(this._negatedCenter, this._center);
this._worldAxis = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
this._worldUp = vec3.fromValues(0.0, 1.0, 0.0); // Direction of "up" in World-space
this._worldRight = vec3.fromValues(1, 0, 0); // Direction of "right" in World-space
this._worldForward = vec3.fromValues(0, 0, -1); // Direction of "forward" in World-space
this._gimbalLock = true; // When true, orbiting world-space "up", else orbiting camera's local "up"
this._constrainPitch = true; // When true, will prevent camera} from being rotated upside-down
this._dirty = true; // Lazy-builds view matrix
this._locked = false;
this._modelBounds = null;
this.tempMat4 = mat4.create();
this.tempMat3b = mat3.create();
this.tempVec3 = vec3.create();
this.tempVec3b = vec3.create();
this.tempVec3c = vec3.create();
this.tempVec3d = vec3.create();
this.tempVec3e = vec3.create();
this.tempVecBuild = vec3.create();
this.tmp_modelBounds = vec3.create();
this.yawMatrix = mat4.create();
// Until there is a proper event handler mechanism, just do it manually.
this.listeners = [];
this.lowVolumeListeners = [];
this._orbitting = false;
this._tmp_interpolate_current_dir = vec3.create();
this._tmp_interpolate_new_dir = vec3.create();
this._tmp_interpolate_a = vec3.create();
this._tmp_interpolate_b = vec3.create();
this._tmp_interpolate_c = vec3.create();
this._tmp_interpolate_d = vec4.create();
this._tmp_interpolate_e = mat4.create();
this._tmp_interpolate_f = vec3.create();
this._tmp_eye = vec3.create();
this._tmp_target = vec3.create();
}
lock() {
this._locked = true;
}
unlock() {
this._locked = false;
this._build();
}
_setDirty() {
this._dirty = true;
this.viewer.dirty = 2;
}
setModelBounds(bounds) {
this._modelBounds = [];
this.perspective.setModelBounds(vec3.clone(bounds));
this.orthographic.setModelBounds(vec3.clone(bounds));
// Store aabb calculated from points
let a = vec3.fromValues(+Infinity, +Infinity, +Infinity);
let b = vec3.fromValues(-Infinity, -Infinity, -Infinity);
let zero_one = [0,1];
for (let i of zero_one) {
for (let j of zero_one) {
for (let k of zero_one) {
let v = vec3.fromValues(bounds[3*i+0], bounds[3*j+1], bounds[3*k+2]);
this._modelBounds.push(v);
for (let l = 0; l < 3; ++l) {
if (v[l] < a[l]) {
a[l] = v[l];
}
if (v[l] > b[l]) {
b[l] = v[l];
}
}
}
}
}
vec3.add(a, a, b);
vec3.scale(a, a, 0.5);
this._center.set(a);
vec3.negate(this._negatedCenter, this._center);
this._dirty = true;
}
forceBuild() {
let eye = this._eye.get();
let target = this._target.get();
vec3.set(this._up, 0, 0, 1);
vec3.subtract(this.tempVecBuild, target, eye);
vec3.normalize(this.tempVecBuild, this.tempVecBuild);
vec3.cross(this._up, this.tempVecBuild, this._up);
vec3.cross(this._up, this._up, this.tempVecBuild);
if (vec3.equals(this._up, vec3.fromValues(0, 0, 0))) {
// Not good, choose something
vec3.set(this._up, 0, 1, 0);
}
mat4.lookAt(this._viewMatrix, eye, target, this._up);
mat3.fromMat4(this.tempMat3b, this._viewMatrix);
mat3.invert(this.tempMat3b, this.tempMat3b);
mat3.transpose(this._viewNormalMatrix, this.tempMat3b);
let [near, far] = [+Infinity, -Infinity];
if (!this.viewer.geospatialMode && this._modelBounds) {
for (var v of this._modelBounds) {
vec3.transformMat4(this.tmp_modelBounds, v, this._viewMatrix);
let z = -this.tmp_modelBounds[2];
if (z < near) {
near = z;
}
if (z > far) {
far = z;
}
}
if (near < 1.e-3) {
near = far / 1000.;
}
} else {
[near, far] = [+1000, +200000. + vec3.length(this.eye)];
}
this.perspective.near = near - 1e-2;
this.perspective.far = far + 1e-2;
this.orthographic.near = near - 1e-2;
this.orthographic.far = far + 1e-2;
mat4.invert(this._viewMatrixInverted, this._viewMatrix);
mat4.multiply(this._viewProjMatrix, this.projMatrix, this._viewMatrix);
mat4.invert(this._viewProjMatrixInverted, this._viewProjMatrix);
this._dirty = false;
for (var listener of this.listeners) {
listener();
}
}
_build() {
if (this._dirty && !this._locked && this._modelBounds) {
this.forceBuild();
}
}
/**
Gets the current viewing transform matrix.
@return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
*/
get viewMatrix() {
if (this._dirty) {
this._build();
}
return this._viewMatrix;
}
/**
Gets the current view projection matrix.
@return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
*/
get viewProjMatrix() {
if (this._dirty) {
this._build();
}
return this._viewProjMatrix;
}
/**
Gets the current inverted view projection matrix.
@return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
*/
get viewProjMatrixInverted() {
if (this._dirty) {
this._build();
}
return this._viewProjMatrixInverted;
}
get viewMatrixInverted() {
if (this._dirty) {
this._build();
}
return this._viewMatrixInverted;
}
/**
Gets the current viewing transform matrix for normals.
This is the transposed inverse of the view matrix.
@return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
*/
get viewNormalMatrix() {
if (this._dirty) {
this._build();
}
return this._viewNormalMatrix;
}
/**
Gets the current projection transform matrix.
@return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
*/
get projMatrix() {
return this._projection.projMatrix;
}
/**
Selects the current projection type.
@param {String} projectionType Accepted values are "persp" or "ortho".
*/
set projectionType(projectionType) {
if (projectionType.toLowerCase().startsWith("persp")) {
this._projection = this.perspective;
} else if (projectionType.toLowerCase().startsWith("ortho")) {
this._projection = this.orthographic;
} else {
console.error("Unsupported projectionType: " + projectionType);
}
this.viewer.dirty = 2;
}
/**
Gets the current projection type.
@return {String} projectionType "persp" or "ortho".
*/
get projectionType() {
return this._projection.constructor.name.substr(0,5).toLowerCase();
}
/**
Gets the component that represents the current projection type.
@return {Perspective|Orthographic}
*/
get projection() {
return this._projection;
}
/**
Sets the position of the camera.
@param {Float32Array} eye 3D position of the camera in World space.
*/
set eye(eye) {
if (!vec3.equals(this._eye.get(), eye)) {
this._eye.get().set(eye);
this._setDirty();
for (var listener of this.lowVolumeListeners) {
listener();
}
}
}
/**
Gets the position of the camera.
@return {Float32Array} 3D position of the camera in World space.
*/
get eye() {
return this._eye.get();
}
/**
Sets the point the camera is looking at.
@param {Float32Array} target 3D position of the point of interest in World space.
*/
set target(target) {
if (!vec3.equals(this._target.get(), target)) {
this._target.get().set(target);
this._setDirty();
for (var listener of this.lowVolumeListeners) {
listener();
}
}
}
/**
Gets the point tha camera is looking at.
@return {Float32Array} 3D position of the point of interest in World space.
*/
get target() {
return this._target.get();
}
set center(v) {
if (!vec3.equals(this._center, v)) {
this._center.set(v);
vec3.negate(this._negatedCenter, this._center);
this.listeners.forEach((fn) => { fn(); });
}
}
get center() {
return this._center;
}
/**
Sets the camera's "up" direction.
@param {Float32Array} up 3D vector indicating the camera's "up" direction in World-space.
*/
set up(up) {
this._up.set(up || [0.0, 1.0, 0.0]);
this._setDirty();
}
/**
Gets the camera's "up" direction.
@return {Float32Array} 3D vector indicating the camera's "up" direction in World-space.
*/
get up() {
return this._up;
}
/**
Sets whether camera rotation is gimbal locked.
When true, yaw rotation will always pivot about the World-space "up" axis.
@param {Boolean} gimbalLock Whether or not to enable gimbal locking.
*/
set gimbalLock(gimbalLock) {
this._gimbalLock = gimbalLock;
}
/**
Sets whether camera rotation is gimbal locked.
When true, yaw rotation will always pivot about the World-space "up" axis.
@return {Boolean} True if gimbal locking is enabled.
*/
get gimbalLock() {
return this._gimbalLock;
}
/**
Sets whether its currently possible to pitch the camera to look at the model upside-down.
When this is true, camera will ignore attempts to orbit (camera or model) about the horizontal axis
that would result in the model being viewed upside-down.
@param {Boolean} constrainPitch Whether or not to activate the constraint.
*/
set constrainPitch(constrainPitch) {
this._constrainPitch = constrainPitch;
}
/**
Gets whether its currently possible to pitch the camera to look at the model upside-down.
@return {Boolean}
*/
get constrainPitch() {
return this._constrainPitch;
}
/**
Indicates the up, right and forward axis of the World coordinate system.
This is used for deriving rotation axis for yaw orbiting, and for moving camera to axis-aligned positions.
Has format: ````[rightX, rightY, rightZ, upX, upY, upZ, forwardX, forwardY, forwardZ]````
@type {Float32Array}
*/
set worldAxis(worldAxis) {
this._worldAxis.set(worldAxis || [1, 0, 0, 0, 1, 0, 0, 0, 1]);
this._worldRight[0] = this._worldAxis[0];
this._worldRight[1] = this._worldAxis[1];
this._worldRight[2] = this._worldAxis[2];
this._worldUp[0] = this._worldAxis[3];
this._worldUp[1] = this._worldAxis[4];
this._worldUp[2] = this._worldAxis[5];
this._worldForward[0] = this._worldAxis[6];
this._worldForward[1] = this._worldAxis[7];
this._worldForward[2] = this._worldAxis[8];
this._setDirty();
}
/**
Indicates the up, right and forward axis of the World coordinate system.
This is used for deriving rotation axis for yaw orbiting, and for moving camera to axis-aligned positions.
Has format: ````[rightX, rightY, rightZ, upX, upY, upZ, forwardX, forwardY, forwardZ]````
@type {Float32Array}
*/
get worldAxis() {
return this._worldAxis;
}
/**
Direction of World-space "up".
@type Float32Array
*/
get worldUp() {
return this._worldUp;
}
/**
Direction of World-space "right".
@type Float32Array
*/
get worldRight() {
return this._worldRight;
}
/**
Direction of World-space "forwards".
@type Float32Array
*/
get worldForward() {
return this._worldForward;
}
set orbitting(orbitting) {
if (this._orbitting != orbitting) {
for (var listener of this.lowVolumeListeners) {
listener();
}
}
this._orbitting = orbitting;
}
get orbitting() {
return this._orbitting;
}
/**
Rotates the eye position about the target position, pivoting around the up vector.
@param {Number} degrees Angle of rotation in degrees
*/
orbitYaw(degrees) {
let eye = this._eye.get();
let target = this._target.get();
// @todo, these functions are not efficient nor numerically stable, but simple to understand.
mat4.identity(this.yawMatrix);
mat4.translate(this.yawMatrix, this.yawMatrix, this._center);
mat4.rotate(this.yawMatrix, this.yawMatrix, degrees * 0.0174532925 * 2, this._worldUp);
mat4.translate(this.yawMatrix, this.yawMatrix, this._negatedCenter);
vec3.transformMat4(eye, eye, this.yawMatrix);
vec3.transformMat4(target, target, this.yawMatrix);
this._setDirty();
return;
}
/**
Rotates the eye position about the target position, pivoting around the right axis (orthogonal to up vector and eye->target vector).
@param {Number} degrees Angle of rotation in degrees
*/
orbitPitch(degrees) { // Rotate (pitch) 'eye' and 'up' about 'target', pivoting around vector ortho to (target->eye) and camera 'up'
let currentPitch = Math.acos(this._viewMatrix[10]);
let adjustment = - degrees * 0.0174532925 * 2;
if (currentPitch + adjustment < 0.01) {
adjustment = 0.01 - currentPitch;
}
if (currentPitch + adjustment > Math.PI - 0.01) {
adjustment = Math.PI - 0.01 - currentPitch;
}
if (Math.abs(adjustment) < 1.e-5) {
return;
}
let eye = this._eye.get();
let target = this._target.get();
var T1 = mat4.fromTranslation(mat4.create(), this._center);
var R = mat4.fromRotation(mat4.create(), adjustment, this._viewMatrixInverted);
var T2 = mat4.fromTranslation(mat4.create(), vec3.negate(vec3.create(), this._center));
vec3.transformMat4(eye, eye, T2);
vec3.transformMat4(eye, eye, R);
vec3.transformMat4(eye, eye, T1);
vec3.transformMat4(target, target, T2);
vec3.transformMat4(target, target, R);
vec3.transformMat4(target, target, T1);
this._setDirty();
return;
}
/**
Rotates the target position about the eye, pivoting around the right axis (orthogonal to up vector and eye->target vector).
@param {Number} degrees Angle of rotation in degrees
*/
pitch(degrees) { // Rotate (pitch) 'eye' and 'up' about 'target', pivoting around horizontal vector ortho to (target->eye) and camera 'up'
let eye = this._eye.get();
let target = this._target.get();
var eyeToTarget = vec3.subtract(this.tempVec3, target, eye);
var a = vec3.normalize(this.tempVec3c, eyeToTarget);
var b = vec3.normalize(this.tempVec3d, this._up);
var axis = vec3.cross(this.tempVec3b, a, b); // Pivot vector is orthogonal to target->eye
mat4.fromRotation(this.tempMat4, degrees * 0.0174532925, axis);
vec3.transformMat4(eyeToTarget, eyeToTarget, this.tempMat4); // Rotate vector
var newUp = vec3.transformMat4(this.tempVec3d, this._up, this.tempMat4); // Rotate 'up' vector
if (this._constrainPitch) {
var angle = vec3.dot(newUp, this._worldUp) / 0.0174532925; // Don't allow 'up' to go up[side-down with respect to World 'up'
if (angle < 1) {
return;
}
}
this._up.set(newUp);
vec3.add(target, eye, eyeToTarget); // Derive 'target'} from eye and vector
this._setDirty();
}
/**
Pans the camera along the camera's local X, Y and Z axis.
@param {Array} pan The pan vector
*/
pan(pan) { // Translate 'eye' and 'target' along local camera axis
let eye = this._eye.get();
let target = this._target.get();
var eyeToTarget = vec3.subtract(this.tempVec3, eye, target);
var vec = [0, 0, 0];
if (pan[0] !== 0) {
let a = vec3.normalize(this.tempVec3b, eyeToTarget); // Get vector orthogonal to 'up' and eye->target
let b = vec3.normalize(this.tempVec3c, this._up);
let v = vec3.cross(this.tempVec3d, a, b);
vec3.scale(v, v, pan[0]);
vec[0] += v[0];
vec[1] += v[1];
vec[2] += v[2];
}
if (pan[1] !== 0) {
let v = vec3.scale(this.tempVec3, vec3.normalize(this.tempVec3b, this._up), pan[1]);
vec[0] += v[0];
vec[1] += v[1];
vec[2] += v[2];
}
if (pan[2] !== 0) {
let v = vec3.scale(this.tempVec3, vec3.normalize(this.tempVec3b, eyeToTarget), pan[2]);
vec[0] += v[0];
vec[1] += v[1];
vec[2] += v[2];
}
vec3.add(eye, eye, vec);
this.target = vec3.add(target, target, vec);
this._setDirty();
}
/**
Moves the camera along a ray through unprojected mouse coordinates
@param {Number} delta Zoom increment
@param canvasPos Mouse position relative to canvas to determine ray along which to move
*/
zoom(delta, canvasPos) { // Translate 'eye' by given increment on (eye->target) vector
// @todo: also not efficient
let eye = this._eye.get();
let target = this._target.get();
this.orthographic.zoom(delta);
let [x,y] = canvasPos;
vec3.set(this.tempVec3, x / this.viewer.width * 2 - 1, - y / this.viewer.height * 2 + 1, 1.);
vec3.transformMat4(this.tempVec3, this.tempVec3, this.projection.projMatrixInverted);
vec3.transformMat4(this.tempVec3, this.tempVec3, this.viewMatrixInverted);
vec3.subtract(this.tempVec3, this.tempVec3, eye);
vec3.normalize(this.tempVec3, this.tempVec3);
vec3.scale(this.tempVec3, this.tempVec3, -delta);
vec3.add(eye, eye, this.tempVec3);
vec3.add(target, target, this.tempVec3);
this._setDirty();
this.updateLowVolumeListeners();
}
updateLowVolumeListeners() {
for (var listener of this.lowVolumeListeners) {
listener();
}
}
calcViewFit(aabb, fitFOV, eye, target) {
aabb = aabb || this.viewer.modelBounds;
fitFOV = fitFOV || this.perspective.fov;
var eyeToTarget = vec3.normalize(this.tempVec3b, vec3.subtract(this.tempVec3, eye, target));
var diagonal = Math.sqrt(
Math.pow(aabb[3] - aabb[0], 2) +
Math.pow(aabb[4] - aabb[1], 2) +
Math.pow(aabb[5] - aabb[2], 2));
var center = [
(aabb[3] + aabb[0]) / 2,
(aabb[4] + aabb[1]) / 2,
(aabb[5] + aabb[2]) / 2
];
target.set(center);
var sca = Math.abs(diagonal / Math.tan(fitFOV * 0.0174532925));
eye[0] = target[0] + (eyeToTarget[0] * sca);
eye[1] = target[1] + (eyeToTarget[1] * sca);
eye[2] = target[2] + (eyeToTarget[2] * sca);
this._setDirty();
}
interpolateView(newEye, newTarget) {
this._eye.deanimate();
this._target.deanimate();
vec3.sub(this._tmp_interpolate_current_dir, this._target.get(), this._eye.get());
vec3.normalize(this._tmp_interpolate_current_dir, this._tmp_interpolate_current_dir);
vec3.sub(this._tmp_interpolate_new_dir, newTarget, newEye);
vec3.normalize(this._tmp_interpolate_new_dir, this._tmp_interpolate_new_dir);
let d = vec3.dot(this._tmp_interpolate_current_dir, this._tmp_interpolate_new_dir);
if (d > 0.5) {
// More or less pointing in the same direction
this._eye.b.set(newEye);
this._target.b.set(newTarget);
this._eye.animate(1000);
this._target.animate(1000);
} else {
// Add an additional intermediate interpolation point
// Add a point on the bisectrice of d1 d2
let an = Math.acos(d);
let d1 = vec3.subtract(this._tmp_interpolate_a, newEye, newTarget);
let d2 = vec3.subtract(this._tmp_interpolate_b, this.eye, newTarget);
let l1 = vec3.len(d1);
let l2 = vec3.len(d2);
vec3.normalize(d1, d1);
vec3.normalize(d2, d2);
let d3;
if (d < -0.99) {
// parallel view vecs, choose arbitrary axis
let temp;
if (Math.abs(d1[1]) > 0.99) {
temp = vec3.fromValues(1,0,0);
} else {
temp = vec3.fromValues(0,1,0);
}
d3 = vec3.cross(this._tmp_interpolate_c, d1, temp);
} else {
d3 = vec3.cross(this._tmp_interpolate_c, d1, d2);
}
vec3.normalize(d3, d3);
let rot = mat4.fromRotation(this._tmp_interpolate_e, an / 2., d3);
let intermediate = this._tmp_interpolate_d;
vec3.copy(intermediate, d1);
vec4.transformMat4(intermediate, intermediate, rot);
vec3.normalize(intermediate, intermediate);
vec3.scale(intermediate, intermediate, (l1 + l2) / 2.);
vec3.add(intermediate, intermediate, newTarget);
let intermediate3 = intermediate.subarray(0,3);
this._eye.b.set(intermediate3);
this._target.b.set(vec3.lerp(this._tmp_interpolate_f, this.target, newTarget, 0.5));
this._eye.c.set(newEye);
this._target.c.set(newTarget);
this._eye.animate(500, 500);
this._target.animate(500, 500);
}
}
/**
Jumps the camera to look at the given axis-aligned World-space bounding box.
@param {Float32Array} aabb The axis-aligned World-space bounding box (AABB).
@param {Number} fitFOV Field-of-view occupied by the AABB when the camera has fitted it to view.
*/
viewFit(params) {
let eye, target, eye2, target2;
if (params.animate) {
eye = this._eye.get();
target = this._target.get();
eye2 = this._tmp_eye;
target2 = this._tmp_target;
} else {
eye2 = eye = this._eye.get();
target2 = target = this._target.get();
}
if (params.viewDirection) {
eye = params.viewDirection;
}
const aabb = params.aabb || this.viewer.modelBounds;
const fitFOV = this.perspective.fov;
var eyeToTarget = vec3.normalize(this.tempVec3b, vec3.subtract(this.tempVec3, eye, target));
var diagonal = Math.sqrt(
Math.pow(aabb[3] - aabb[0], 2) +
Math.pow(aabb[4] - aabb[1], 2) +
Math.pow(aabb[5] - aabb[2], 2));
var center = [
(aabb[3] + aabb[0]) / 2,
(aabb[4] + aabb[1]) / 2,
(aabb[5] + aabb[2]) / 2
];
target2.set(center);
var sca = Math.abs(diagonal / Math.tan(fitFOV * 0.0174532925));
eye2[0] = target2[0] + (eyeToTarget[0] * sca);
eye2[1] = target2[1] + (eyeToTarget[1] * sca);
eye2[2] = target2[2] + (eyeToTarget[2] * sca);
if (params.animate) {
this.interpolateView(this._tmp_eye, this._tmp_target);
}
this._setDirty();
}
restore(params) {
if (params.type) {
this.projectionType = params.type;
}
if (this._projection instanceof Perspective && params.fovy) {
this._projection.fov = params.fovy;
}
["eye", "target", "up"].forEach((k) => {
if (params[k]) {
let fn_set = Object.getOwnPropertyDescriptor(this, k).set;
let fn_get = Object.getOwnPropertyDescriptor(this, k).get;
let fn_update;
if (params[k] instanceof AnimatedVec3) {
fn_update = (t, v) => { fn_get(t).set(v); };
} else {
fn_update = fn_set;
}
fn_update(this, params[k]);
}
});
}
}