itowns
Version:
A JS/WebGL framework for 3D geospatial data visualization
281 lines (269 loc) • 12.1 kB
JavaScript
import * as THREE from 'three';
import { Coordinates } from '@itowns/geographic';
import DEMUtils from "../Utils/DEMUtils.js";
/**
* @typedef {object} Camera~CAMERA_TYPE
* Stores the different types of camera usable in iTowns.
*
* @property {number} PERSPECTIVE Perspective type of camera
* @property {number} ORTHOGRAPHIC Orthographic type of camera
*/
export const CAMERA_TYPE = {
PERSPECTIVE: 0,
ORTHOGRAPHIC: 1
};
const tmp = {
frustum: new THREE.Frustum(),
matrix: new THREE.Matrix4(),
box3: new THREE.Box3()
};
const ndcBox3 = new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1));
function updatePreSse(camera, height, fov) {
// sse = projected geometric error on screen plane from distance
// We're using an approximation, assuming that the geometric error of all
// objects is perpendicular to the camera view vector (= we always compute
// for worst case).
//
// screen plane object
// | __
// | / \
// | geometric{|
// < fov angle . } sse error {| |
// | \__/
// |
// |<--------------------->
// | distance
//
// geometric_error * screen_width (resp. screen_height)
// = ---------------------------------------
// 2 * distance * tan (horizontal_fov / 2) (resp. vertical_fov)
//
//
// We pre-compute the preSSE (= constant part of the screen space error formula) once here
// Note: the preSSE for the horizontal FOV is the same value
// focal = (this.height * 0.5) / Math.tan(verticalFOV * 0.5);
// horizontalFOV = 2 * Math.atan(this.width * 0.5 / focal);
// horizontalPreSSE = this.width / (2.0 * Math.tan(horizontalFOV * 0.5)); (1)
// => replacing horizontalFOV in Math.tan(horizontalFOV * 0.5)
// Math.tan(horizontalFOV * 0.5) = Math.tan(2 * Math.atan(this.width * 0.5 / focal) * 0.5)
// = Math.tan(Math.atan(this.width * 0.5 / focal))
// = this.width * 0.5 / focal
// => now replacing focal
// = this.width * 0.5 / (this.height * 0.5) / Math.tan(verticalFOV * 0.5)
// = Math.tan(verticalFOV * 0.5) * this.width / this.height
// => back to (1)
// horizontalPreSSE = this.width / (2.0 * Math.tan(verticalFOV * 0.5) * this.width / this.height)
// = this.height / 2.0 * Math.tan(verticalFOV * 0.5)
// = verticalPreSSE
if (camera.camera3D.isOrthographicCamera) {
camera._preSSE = height;
} else {
const verticalFOV = THREE.MathUtils.degToRad(fov);
camera._preSSE = height / (2.0 * Math.tan(verticalFOV * 0.5));
}
}
/**
* Wrapper around Three.js camera to expose some geographic helpers.
*
* @property {string} crs The camera's coordinate projection system.
* @property {THREE.Camera} camera3D The Three.js camera that is wrapped around.
* @property {number} width The width of the camera.
* @property {number} height The height of the camera.
* @property {number} _preSSE The precomputed constant part of the screen space error.
*/
class Camera {
#_viewMatrixNeedsUpdate = true;
#_viewMatrix = (() => new THREE.Matrix4())();
/**
* @param {string} crs The camera's coordinate projection system.
* @param {number} width The width (in pixels) of the view the
* camera is associated to.
* @param {number} height The height (in pixels) of the view the
* camera is associated to.
* @param {Object} [options] Options for the camera.
* @param {THREE.Camera} [options.cameraThree] A custom Three.js camera object to wrap
* around.
* @param {Camera~CAMERA_TYPE} [options.type=CAMERA_TYPE.PERSPECTIVE] The type of the camera. See {@link
* CAMERA_TYPE}.
* @constructor
*/
constructor(crs, width, height) {
let options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
this.crs = crs;
if (options.isCamera) {
console.warn('options.camera parameter is deprecated. Use options.camera.cameraThree to place a custom ' + 'camera as a parameter. See the documentation of Camera.');
this.camera3D = options;
} else if (options.cameraThree) {
this.camera3D = options.cameraThree;
} else {
switch (options.type) {
case CAMERA_TYPE.ORTHOGRAPHIC:
this.camera3D = new THREE.OrthographicCamera();
break;
case CAMERA_TYPE.PERSPECTIVE:
default:
this.camera3D = new THREE.PerspectiveCamera(30);
break;
}
}
this.camera3D.aspect = this.camera3D.aspect ?? 1;
this.width = width;
this.height = height;
this.resize(width, height);
this._preSSE = Infinity;
if (this.camera3D.isPerspectiveCamera) {
let fov = this.camera3D.fov;
Object.defineProperty(this.camera3D, 'fov', {
get: () => fov,
set: newFov => {
fov = newFov;
updatePreSse(this, this.height, fov);
}
});
}
}
/**
* Resize the camera to a given width and height.
*
* @param {number} width The width to resize the camera to. Must be strictly positive, won't resize otherwise.
* @param {number} height The height to resize the camera to. Must be strictly positive, won't resize otherwise.
*/
resize(width, height) {
if (!width || width <= 0 || !height || height <= 0) {
console.warn(`Trying to resize the Camera with invalid height (${height}) or width (${width}). Skipping resize.`);
return;
}
const ratio = width / height;
if (this.camera3D.aspect !== ratio) {
if (this.camera3D.isOrthographicCamera) {
this.camera3D.zoom *= this.width / width;
const halfH = this.camera3D.top * this.camera3D.aspect / ratio;
this.camera3D.bottom = -halfH;
this.camera3D.top = halfH;
} else if (this.camera3D.isPerspectiveCamera) {
this.camera3D.fov = 2 * THREE.MathUtils.radToDeg(Math.atan(height / this.height * Math.tan(THREE.MathUtils.degToRad(this.camera3D.fov) / 2)));
}
this.camera3D.aspect = ratio;
}
this.width = width;
this.height = height;
updatePreSse(this, this.height, this.camera3D.fov);
if (this.camera3D.updateProjectionMatrix) {
this.camera3D.updateProjectionMatrix();
this.#_viewMatrixNeedsUpdate = true;
}
}
update() {
// update matrix
this.camera3D.updateMatrixWorld();
this.#_viewMatrixNeedsUpdate = true;
}
/**
* Return the position in the requested CRS, or in camera's CRS if undefined.
*
* @param {string} [crs] If defined (e.g 'EPSG:4326'), the camera position will be returned in this CRS.
*
* @return {Coordinates} Coordinates object holding camera's position.
*/
position(crs) {
return new Coordinates(this.crs).setFromVector3(this.camera3D.position).as(crs || this.crs);
}
/**
* Set the position of the camera using a Coordinates object.
* If you want to modify the position directly using x,y,z values then use `camera.camera3D.position.set(x, y, z)`
*
* @param {Coordinates} position The new position of the camera.
*/
setPosition(position) {
this.camera3D.position.copy(position.as(this.crs));
}
isBox3Visible(box3, matrixWorld) {
return this.box3SizeOnScreen(box3, matrixWorld).intersectsBox(ndcBox3);
}
isSphereVisible(sphere, matrixWorld) {
if (this.#_viewMatrixNeedsUpdate) {
// update visibility testing matrix
this.#_viewMatrix.multiplyMatrices(this.camera3D.projectionMatrix, this.camera3D.matrixWorldInverse);
this.#_viewMatrixNeedsUpdate = false;
}
if (matrixWorld) {
tmp.matrix.multiplyMatrices(this.#_viewMatrix, matrixWorld);
tmp.frustum.setFromProjectionMatrix(tmp.matrix);
} else {
tmp.frustum.setFromProjectionMatrix(this.#_viewMatrix);
}
return tmp.frustum.intersectsSphere(sphere);
}
box3SizeOnScreen(box3, matrixWorld) {
const pts = projectBox3PointsInCameraSpace(this, box3, matrixWorld);
// All points are in front of the near plane -> box3 is invisible
if (!pts) {
return tmp.box3.makeEmpty();
}
// Project points on screen
for (let i = 0; i < 8; i++) {
pts[i].applyMatrix4(this.camera3D.projectionMatrix);
}
return tmp.box3.setFromPoints(pts);
}
/**
* Test for collision between camera and a geometry layer (DTM/DSM) to adjust camera position.
* It could be modified later to handle an array of geometry layers.
* TODO Improve Coordinates class to handle altitude for any coordinate system (even projected one)
*
* @param {View} view The view where we test the collision between geometry layers
* and the camera
* @param {ElevationLayer} elevationLayer The elevation layer (DTM/DSM) used to test the collision
* with the camera. Could be another geometry layer.
* @param {number} minDistanceCollision The minimum distance allowed between the camera and the
* surface.
*/
adjustAltitudeToAvoidCollisionWithLayer(view, elevationLayer, minDistanceCollision) {
// We put the camera location in geographic by default to easily handle altitude.
// (Should be improved in Coordinates class for all ref)
const camLocation = view.camera.position().as('EPSG:4326');
if (elevationLayer !== undefined) {
const elevationUnderCamera = DEMUtils.getElevationValueAt(elevationLayer, camLocation);
if (elevationUnderCamera !== undefined) {
const difElevation = camLocation.altitude - (elevationUnderCamera + minDistanceCollision);
// We move the camera to avoid collision if too close to terrain
if (difElevation < 0) {
camLocation.altitude = elevationUnderCamera + minDistanceCollision;
view.camera3D.position.copy(camLocation.as(view.referenceCrs));
view.notifyChange(this.camera3D);
}
}
}
}
}
const points = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
function projectBox3PointsInCameraSpace(camera, box3, matrixWorld) {
// Projects points in camera space
// We don't project directly on screen to avoid artifacts when projecting
// points behind the near plane.
let m = camera.camera3D.matrixWorldInverse;
if (matrixWorld) {
m = tmp.matrix.multiplyMatrices(camera.camera3D.matrixWorldInverse, matrixWorld);
}
points[0].set(box3.min.x, box3.min.y, box3.min.z).applyMatrix4(m);
points[1].set(box3.min.x, box3.min.y, box3.max.z).applyMatrix4(m);
points[2].set(box3.min.x, box3.max.y, box3.min.z).applyMatrix4(m);
points[3].set(box3.min.x, box3.max.y, box3.max.z).applyMatrix4(m);
points[4].set(box3.max.x, box3.min.y, box3.min.z).applyMatrix4(m);
points[5].set(box3.max.x, box3.min.y, box3.max.z).applyMatrix4(m);
points[6].set(box3.max.x, box3.max.y, box3.min.z).applyMatrix4(m);
points[7].set(box3.max.x, box3.max.y, box3.max.z).applyMatrix4(m);
// In camera space objects are along the -Z axis
// So if min.z is > -near, the object is invisible
let atLeastOneInFrontOfNearPlane = false;
for (let i = 0; i < 8; i++) {
if (points[i].z <= -camera.camera3D.near) {
atLeastOneInFrontOfNearPlane = true;
} else {
// Clamp to near plane
points[i].z = -camera.camera3D.near;
}
}
return atLeastOneInFrontOfNearPlane ? points : undefined;
}
export default Camera;