@playcanvas/react
Version:
A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.
563 lines • 21.6 kB
JavaScript
import { Script, math, Vec3, BoundingBox, Quat, Vec2, PROJECTION_PERSPECTIVE, MOUSEBUTTON_LEFT, MOUSEBUTTON_RIGHT, MOUSEBUTTON_MIDDLE, EVENT_TOUCHCANCEL, EVENT_TOUCHSTART, EVENT_TOUCHEND, EVENT_TOUCHMOVE, EVENT_MOUSEDOWN, EVENT_MOUSEMOVE, EVENT_MOUSEUP, EVENT_MOUSEWHEEL } from 'playcanvas';
export class OrbitCamera extends Script {
static __name = 'orbitCamera';
/**
* @attribute
* @title Distance Max
* @type {number}
*/
set distanceMax(v) {
this._distanceMax = v;
this._distance = this._clampDistance(this._distance);
}
get distanceMax() {
return this._distanceMax;
}
/**
* @attribute
* @title Distance Min
* @type {number}
*/
set distanceMin(v) {
this._distanceMin = v;
this._distance = this._clampDistance(this._distance);
}
get distanceMin() {
return this._distanceMin;
}
/**
* @attribute
* @title Pitch Angle Max (degrees)
* @type {number}
*/
set pitchAngleMax(v) {
this._pitchAngleMax = v;
this._pitch = this._clampPitchAngle(this._pitch);
}
get pitchAngleMax() {
return this._pitchAngleMax;
}
/**
* @attribute
* @title Pitch Angle Min (degrees)
* @type {number}
*/
set pitchAngleMin(v) {
this._pitchAngleMin = v;
this._pitch = this._clampPitchAngle(this._pitchAngleMin);
}
get pitchAngleMin() {
return this._pitchAngleMin;
}
/**
* Higher value means that the camera will continue moving after the user has stopped dragging. 0 is fully responsive.
*
* @attribute
* @title Inertia Factor
* @type {number}
*/
inertiaFactor = 0;
/**
* Entity for the camera to focus on. If blank, then the camera will use the whole scene
*
* @attribute
* @title Focus Entity
* @type {Entity}
*/
set focusEntity(value) {
this._focusEntity = value || this.app.root;
if (this.frameOnStart) {
this.focus(this._focusEntity);
}
else {
this.resetAndLookAtEntity(this.entity.getPosition(), this._focusEntity);
}
}
get focusEntity() {
return this._focusEntity;
}
/**
* Frames the entity or scene at the start of the application."
*
* @attribute
* @title Frame on Start
* @type {boolean}
*/
set frameOnStart(value) {
this._frameOnStart = value;
if (this._frameOnStart) {
this.focus(this.focusEntity || this.app.root);
}
}
get frameOnStart() {
return this._frameOnStart;
}
/**
* Property to get and set the distance between the pivot point and camera.
* Clamped between this.distanceMin and this.distanceMax
*
* @type {number}
*/
set distance(value) {
this._targetDistance = this._clampDistance(value);
}
get distance() {
return this._targetDistance;
}
/**
* Property to get and set the camera orthoHeight
*
* @type {number}
*/
set orthoHeight(value) {
this.entity.camera.orthoHeight = Math.max(0, value);
}
get orthoHeight() {
return this.entity.camera.orthoHeight;
}
/**
* Property to get and set the pitch of the camera around the pivot point (degrees).
* Clamped between this.pitchAngleMin and this.pitchAngleMax.
* When set at 0, the camera angle is flat, looking along the horizon
*
* @type {number}
*/
set pitch(value) {
this._targetPitch = this._clampPitchAngle(value);
}
get pitch() {
return this._targetPitch;
}
/**
* Property to get and set the yaw of the camera around the pivot point (degrees)
*
* @type {number}
*/
set yaw(value) {
this._targetYaw = value;
// Ensure that the yaw takes the shortest route by making sure that
// the difference between the targetYaw and the actual is 180 degrees
// in either direction
const diff = this._targetYaw - this._yaw;
const reminder = diff % 360;
if (reminder > 180) {
this._targetYaw = this._yaw - (360 - reminder);
}
else if (reminder < -180) {
this._targetYaw = this._yaw + (360 + reminder);
}
else {
this._targetYaw = this._yaw + reminder;
}
}
get yaw() {
return this._targetYaw;
}
/**
* Property to get and set the world position of the pivot point that the camera orbits around
*
* @type {number}
*/
set pivotPoint(value) {
this._pivotPoint.copy(value);
}
get pivotPoint() {
return this._pivotPoint;
}
/** @private */
_distance = 10;
/** @private */
_distanceMin = 10;
/** @private */
_distanceMax = 200;
/** @private */
_frameOnStart = true;
/** @private */
_focusEntity;
/** @private */
_modelsAabb = new BoundingBox();
_pivotPoint = new Vec3();
static fromWorldPoint = new Vec3();
static toWorldPoint = new Vec3();
static worldDiff = new Vec3();
static distanceBetween = new Vec3();
static quatWithoutYaw = new Quat();
static yawOffset = new Quat();
focus(focusEntity) {
// Calculate an bounding box that encompasses all the models to frame in the camera view
this._buildAabb(focusEntity);
const halfExtents = this._modelsAabb.halfExtents;
const radius = Math.max(halfExtents.x, halfExtents.y, halfExtents.z);
this.distance =
(radius * 1.5) /
Math.sin(0.5 * this.entity.camera.fov * math.DEG_TO_RAD);
this._removeInertia();
this._pivotPoint.copy(this._modelsAabb.center);
}
resetAndLookAtPoint(resetPoint, lookAtPoint) {
this.pivotPoint.copy(lookAtPoint);
this.entity.setPosition(resetPoint);
this.entity.lookAt(lookAtPoint);
const distance = OrbitCamera.distanceBetween;
distance.sub2(lookAtPoint, resetPoint);
this.distance = distance.length();
this.pivotPoint.copy(lookAtPoint);
const cameraQuat = this.entity.getRotation();
this.yaw = this._calcYaw(cameraQuat);
this.pitch = this._calcPitch(cameraQuat, this.yaw);
this._removeInertia();
this._updatePosition();
}
resetAndLookAtEntity(resetPoint, entity) {
this._buildAabb(entity);
this.resetAndLookAtPoint(resetPoint, this._modelsAabb.center);
}
reset(yaw, pitch, distance) {
this.pitch = pitch;
this.yaw = yaw;
this.distance = distance;
this._removeInertia();
}
initialize() {
const onWindowResize = () => this._checkAspectRatio();
window.addEventListener('resize', onWindowResize, false);
this._checkAspectRatio();
// Find all the models in the scene that are under the focused entity
this._buildAabb(this.focusEntity || this.app.root);
this.entity.lookAt(this._modelsAabb.center);
this._pivotPoint.copy(this._modelsAabb.center);
// Calculate the camera euler angle rotation around x and y axes
// This allows us to place the camera at a particular rotation to begin with in the scene
const cameraQuat = this.entity.getRotation();
// Preset the camera
this._yaw = this._calcYaw(cameraQuat);
this._pitch = this._clampPitchAngle(this._calcPitch(cameraQuat, this._yaw));
this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0);
this._distance = 0;
this._targetYaw = this._yaw;
this._targetPitch = this._pitch;
// If we have ticked focus on start, then attempt to position the camera where it frames
// the focused entity and move the pivot point to entity's position otherwise, set the distance
// to be between the camera position in the scene and the pivot point
if (this.frameOnStart) {
this.focus(this.focusEntity || this.app.root);
}
else {
const distanceBetween = new Vec3();
distanceBetween.sub2(this.entity.getPosition(), this._pivotPoint);
this._distance = this._clampDistance(distanceBetween.length());
}
this._targetDistance = this._distance;
this.on('destroy', () => {
window.removeEventListener('resize', onWindowResize, false);
});
}
update(dt) {
// Add inertia, if any
const t = this.inertiaFactor === 0 ? 1 : Math.min(dt / this.inertiaFactor, 1);
this._distance = math.lerp(this._distance, this._targetDistance, t);
this._yaw = math.lerp(this._yaw, this._targetYaw, t);
this._pitch = math.lerp(this._pitch, this._targetPitch, t);
this._updatePosition();
}
_updatePosition() {
// Work out the camera position based on the pivot point, pitch, yaw and distance
this.entity.setLocalPosition(0, 0, 0);
this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0);
const position = this.entity.getPosition();
position.copy(this.entity.forward);
position.mulScalar(-this._distance);
position.add(this.pivotPoint);
this.entity.setPosition(position);
}
_removeInertia() {
this._yaw = this._targetYaw;
this._pitch = this._targetPitch;
this._distance = this._targetDistance;
}
_checkAspectRatio() {
const height = this.app.graphicsDevice.height;
const width = this.app.graphicsDevice.width;
// Match the axis of FOV to match the aspect ratio of the canvas so
// the focused entities is always in frame
this.entity.camera.horizontalFov = height > width;
}
_buildAabb(entity) {
let i, m;
const meshInstances = [];
const renders = entity.findComponents('render');
for (i = 0; i < renders.length; i++) {
const render = renders[i];
for (m = 0; m < render.meshInstances.length; m++) {
meshInstances.push(render.meshInstances[m]);
}
}
const models = entity.findComponents('model');
for (i = 0; i < models.length; i++) {
const model = models[i];
for (m = 0; m < model.meshInstances.length; m++) {
meshInstances.push(model.meshInstances[m]);
}
}
const gsplats = entity.findComponents('gsplat');
for (i = 0; i < gsplats.length; i++) {
const gsplat = gsplats[i];
const instance = gsplat.instance;
if (instance?.meshInstance) {
meshInstances.push(instance.meshInstance);
}
}
for (i = 0; i < meshInstances.length; i++) {
if (i === 0) {
this._modelsAabb.copy(meshInstances[i].aabb);
}
else {
this._modelsAabb.add(meshInstances[i].aabb);
}
}
}
_calcYaw(quat) {
const transformedForward = new Vec3();
quat.transformVector(Vec3.FORWARD, transformedForward);
return (Math.atan2(-transformedForward.x, -transformedForward.z) *
math.RAD_TO_DEG);
}
_clampDistance(distance) {
if (this.distanceMax > 0) {
return math.clamp(distance, this.distanceMin, this.distanceMax);
}
return Math.max(distance, this.distanceMin);
}
_clampPitchAngle(pitch) {
// Negative due as the pitch is inversed since the camera is orbiting the entity
return math.clamp(pitch, -this.pitchAngleMax, -this.pitchAngleMin);
}
_calcPitch(quat, yaw) {
const quatWithoutYaw = OrbitCamera.quatWithoutYaw;
const yawOffset = OrbitCamera.yawOffset;
yawOffset.setFromEulerAngles(0, -yaw, 0);
quatWithoutYaw.mul2(yawOffset, quat);
const transformedForward = new Vec3();
quatWithoutYaw.transformVector(Vec3.FORWARD, transformedForward);
return (Math.atan2(transformedForward.y, -transformedForward.z) *
math.RAD_TO_DEG);
}
}
export class OrbitCameraInputMouse extends Script {
static __name = 'orbitCameraInputMouse';
/**
* How fast the camera moves around the orbit. Higher is faster
*
* @attribute
* @type {number.0}
*/
orbitSensitivity = 0.3;
/**
* How fast the camera moves in and out. Higher is faster
*
* @attribute
* @type {number}
*/
distanceSensitivity = 0.15;
static fromWorldPoint = new Vec3();
static toWorldPoint = new Vec3();
static worldDiff = new Vec3();
initialize() {
this.orbitCamera = this.entity.script.orbitCamera;
if (this.orbitCamera) {
const onMouseOut = () => this.onMouseOut();
this.app.mouse.on(EVENT_MOUSEDOWN, this.onMouseDown, this);
this.app.mouse.on(EVENT_MOUSEUP, this.onMouseUp, this);
this.app.mouse.on(EVENT_MOUSEMOVE, this.onMouseMove, this);
this.app.mouse.on(EVENT_MOUSEWHEEL, this.onMouseWheel, this);
// Listen to when the mouse travels out of the window
window.addEventListener('mouseout', onMouseOut, false);
// Remove the listeners so if this entity is destroyed
this.on('destroy', function () {
this.app.mouse?.off(EVENT_MOUSEDOWN, this.onMouseDown, this);
this.app.mouse?.off(EVENT_MOUSEUP, this.onMouseUp, this);
this.app.mouse?.off(EVENT_MOUSEMOVE, this.onMouseMove, this);
this.app.mouse?.off(EVENT_MOUSEWHEEL, this.onMouseWheel, this);
window.removeEventListener('mouseout', onMouseOut, false);
});
}
// Disabling the context menu stops the browser displaying a menu when
// you right-click the page
this.app.mouse.disableContextMenu();
this.lookButtonDown = false;
this.panButtonDown = false;
this.lastPoint = new Vec2();
}
pan(screenPoint) {
const fromWorldPoint = OrbitCameraInputMouse.fromWorldPoint;
const toWorldPoint = OrbitCameraInputMouse.toWorldPoint;
const worldDiff = OrbitCameraInputMouse.worldDiff;
// For panning to work at any zoom level, we use screen point to world projection
// to work out how far we need to pan the pivotEntity in world space
const camera = this.entity.camera;
const distance = this.orbitCamera.distance;
camera.screenToWorld(screenPoint.x, screenPoint.y, distance, fromWorldPoint);
camera.screenToWorld(this.lastPoint.x, this.lastPoint.y, distance, toWorldPoint);
worldDiff.sub2(toWorldPoint, fromWorldPoint);
this.orbitCamera.pivotPoint.add(worldDiff);
}
onMouseDown(event) {
switch (event.button) {
case MOUSEBUTTON_LEFT:
this.lookButtonDown = true;
break;
case MOUSEBUTTON_MIDDLE:
case MOUSEBUTTON_RIGHT:
this.panButtonDown = true;
break;
}
}
onMouseUp(event) {
switch (event.button) {
case MOUSEBUTTON_LEFT:
this.lookButtonDown = false;
break;
case MOUSEBUTTON_MIDDLE:
case MOUSEBUTTON_RIGHT:
this.panButtonDown = false;
break;
}
}
onMouseMove(event) {
if (this.lookButtonDown) {
this.orbitCamera.pitch -= event.dy * this.orbitSensitivity;
this.orbitCamera.yaw -= event.dx * this.orbitSensitivity;
}
else if (this.panButtonDown) {
this.pan(event);
}
this.lastPoint.set(event.x, event.y);
}
onMouseWheel(event) {
if (this.entity.camera.projection === PROJECTION_PERSPECTIVE) {
this.orbitCamera.distance -=
event.wheelDelta * -2 *
this.distanceSensitivity *
(this.orbitCamera.distance * 0.1);
}
else {
this.orbitCamera.orthoHeight -=
event.wheelDelta * -2 * this.distanceSensitivity;
}
event.event.preventDefault();
}
onMouseOut() {
this.lookButtonDown = false;
this.panButtonDown = false;
}
}
export class OrbitCameraInputTouch extends Script {
static __name = 'orbitCameraInputTouch';
/**
* How fast the camera moves around the orbit. Higher is faster
*
* @attribute
* @type {number}
*/
orbitSensitivity = 0.4;
/**
* How fast the camera moves in and out. Higher is faster
*
* @attribute
* @type {number}
*/
distanceSensitivity = 0.2;
static pinchMidPoint = new Vec2();
static fromWorldPoint = new Vec3();
static toWorldPoint = new Vec3();
static worldDiff = new Vec3();
initialize() {
this.orbitCamera = this.entity.script.orbitCamera;
// Store the position of the touch so we can calculate the distance moved
this.lastTouchPoint = new Vec2();
this.lastPinchMidPoint = new Vec2();
this.lastPinchDistance = 0;
if (this.orbitCamera && this.app.touch) {
// Use the same callback for the touchStart, touchEnd and touchCancel events as they
// all do the same thing which is to deal the possible multiple touches to the screen
this.app.touch.on(EVENT_TOUCHSTART, this.onTouchStartEndCancel, this);
this.app.touch.on(EVENT_TOUCHEND, this.onTouchStartEndCancel, this);
this.app.touch.on(EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this);
this.app.touch.on(EVENT_TOUCHMOVE, this.onTouchMove, this);
this.on('destroy', function () {
this.app.touch?.off(EVENT_TOUCHSTART, this.onTouchStartEndCancel, this);
this.app.touch?.off(EVENT_TOUCHEND, this.onTouchStartEndCancel, this);
this.app.touch?.off(EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this);
this.app.touch?.off(EVENT_TOUCHMOVE, this.onTouchMove, this);
});
}
}
getPinchDistance(pointA, pointB) {
// Return the distance between the two points
const dx = pointA.x - pointB.x;
const dy = pointA.y - pointB.y;
return Math.sqrt(dx * dx + dy * dy);
}
calcMidPoint(pointA, pointB, result) {
result.set(pointB.x - pointA.x, pointB.y - pointA.y);
result.mulScalar(0.5);
result.x += pointA.x;
result.y += pointA.y;
}
onTouchStartEndCancel(event) {
// We only care about the first touch for camera rotation. As the user touches the screen,
// we stored the current touch position
const touches = event.touches;
if (touches.length === 1) {
this.lastTouchPoint.set(touches[0].x, touches[0].y);
}
else if (touches.length === 2) {
// If there are 2 touches on the screen, then set the pinch distance
this.lastPinchDistance = this.getPinchDistance(touches[0], touches[1]);
this.calcMidPoint(touches[0], touches[1], this.lastPinchMidPoint);
}
}
pan(midPoint) {
const fromWorldPoint = OrbitCameraInputTouch.fromWorldPoint;
const toWorldPoint = OrbitCameraInputTouch.toWorldPoint;
const worldDiff = OrbitCameraInputTouch.worldDiff;
// For panning to work at any zoom level, we use screen point to world projection
// to work out how far we need to pan the pivotEntity in world space
const camera = this.entity.camera;
const distance = this.orbitCamera.distance;
camera.screenToWorld(midPoint.x, midPoint.y, distance, fromWorldPoint);
camera.screenToWorld(this.lastPinchMidPoint.x, this.lastPinchMidPoint.y, distance, toWorldPoint);
worldDiff.sub2(toWorldPoint, fromWorldPoint);
this.orbitCamera.pivotPoint.add(worldDiff);
}
onTouchMove(event) {
const pinchMidPoint = OrbitCameraInputTouch.pinchMidPoint;
// We only care about the first touch for camera rotation. Work out the difference moved since the last event
// and use that to update the camera target position
const touches = event.touches;
if (touches.length === 1) {
const touch = touches[0];
this.orbitCamera.pitch -=
(touch.y - this.lastTouchPoint.y) * this.orbitSensitivity;
this.orbitCamera.yaw -=
(touch.x - this.lastTouchPoint.x) * this.orbitSensitivity;
this.lastTouchPoint.set(touch.x, touch.y);
}
else if (touches.length === 2) {
// Calculate the difference in pinch distance since the last event
const currentPinchDistance = this.getPinchDistance(touches[0], touches[1]);
const diffInPinchDistance = currentPinchDistance - this.lastPinchDistance;
this.lastPinchDistance = currentPinchDistance;
this.orbitCamera.distance -=
diffInPinchDistance *
this.distanceSensitivity *
0.1 *
(this.orbitCamera.distance * 0.1);
// Calculate pan difference
this.calcMidPoint(touches[0], touches[1], pinchMidPoint);
this.pan(pinchMidPoint);
this.lastPinchMidPoint.copy(pinchMidPoint);
}
}
}
//# sourceMappingURL=orbit-camera.js.map