playcanvas
Version:
PlayCanvas WebGL game engine
823 lines (721 loc) • 22 kB
JavaScript
import {
math,
DualGestureSource,
FlyController,
FocusController,
GamepadSource,
InputFrame,
KeyboardMouseSource,
MultiTouchSource,
OrbitController,
Pose,
PROJECTION_PERSPECTIVE,
Script,
Vec2,
Vec3
} from 'playcanvas';
/** @import { CameraComponent, InputController } from 'playcanvas' */
/**
* @typedef {object} CameraControlsState
* @property {Vec3} axis - The axis.
* @property {number} shift - The shift.
* @property {number} ctrl - The ctrl.
* @property {number[]} mouse - The mouse.
* @property {number} touches - The touches.
*/
const tmpV1 = new Vec3();
const tmpV2 = new Vec3();
const pose = new Pose();
const frame = new InputFrame({
move: [0, 0, 0],
rotate: [0, 0, 0]
});
/**
* Calculate the damp rate.
*
* @param {number} damping - The damping.
* @param {number} dt - The delta time.
* @returns {number} - The lerp rate.
*/
export const damp = (damping, dt) => 1 - Math.pow(damping, dt * 1000);
/**
* @param {number[]} stick - The stick
* @param {number} low - The low dead zone
* @param {number} high - The high dead zone
*/
const applyDeadZone = (stick, low, high) => {
const mag = Math.sqrt(stick[0] * stick[0] + stick[1] * stick[1]);
if (mag < low) {
stick.fill(0);
return;
}
const scale = (mag - low) / (high - low);
stick[0] *= scale / mag;
stick[1] *= scale / mag;
};
/**
* Converts screen space mouse deltas to world space pan vector.
*
* @param {CameraComponent} camera - The camera component.
* @param {number} dx - The mouse delta x value.
* @param {number} dy - The mouse delta y value.
* @param {number} dz - The world space zoom delta value.
* @param {Vec3} [out] - The output vector to store the pan result.
* @returns {Vec3} - The pan vector in world space.
* @private
*/
const screenToWorld = (camera, dx, dy, dz, out = new Vec3()) => {
const { system, fov, aspectRatio, horizontalFov, projection, orthoHeight } = camera;
const { width, height } = system.app.graphicsDevice.clientRect;
// normalize deltas to device coord space
out.set(
-(dx / width) * 2,
(dy / height) * 2,
0
);
// calculate half size of the view frustum at the current distance
const halfSize = tmpV2.set(0, 0, 0);
if (projection === PROJECTION_PERSPECTIVE) {
const halfSlice = dz * Math.tan(0.5 * fov * math.DEG_TO_RAD);
if (horizontalFov) {
halfSize.set(
halfSlice,
halfSlice / aspectRatio,
0
);
} else {
halfSize.set(
halfSlice * aspectRatio,
halfSlice,
0
);
}
} else {
halfSize.set(
orthoHeight * aspectRatio,
orthoHeight,
0
);
}
// scale by device coord space
out.mul(halfSize);
return out;
};
/**
* @enum {string}
*/
// eslint-disable-next-line no-unused-vars
const MobileInputLayout = {
JOYSTICK_JOYSTICK: 'joystick-joystick',
JOYSTICK_TOUCH: 'joystick-touch',
TOUCH_JOYSTICK: 'touch-joystick',
TOUCH_TOUCH: 'touch-touch'
};
class CameraControls extends Script {
static scriptName = 'cameraControls';
/**
* @type {CameraComponent}
* @private
*/
// @ts-ignore
_camera;
/**
* @type {boolean}
* @private
*/
_enableOrbit = true;
/**
* @type {boolean}
* @private
*/
_enableFly = true;
/**
* @type {number}
* @private
*/
_startZoomDist = 0;
/**
* @type {Vec2}
* @private
*/
_pitchRange = new Vec2(-360, 360);
/**
* @type {Vec2}
* @private
*/
_yawRange = new Vec2(-360, 360);
/**
* @type {Vec2}
* @private
*/
_zoomRange = new Vec2(0.01, 0);
/**
* @type {KeyboardMouseSource}
* @private
*/
_desktopInput = new KeyboardMouseSource();
/**
* @type {MultiTouchSource}
* @private
*/
_orbitMobileInput = new MultiTouchSource();
/**
* @type {DualGestureSource}
* @private
*/
_flyMobileInput = new DualGestureSource();
/**
* @type {GamepadSource}
* @private
*/
_gamepadInput = new GamepadSource();
/**
* @type {FlyController}
* @private
*/
_flyController = new FlyController();
/**
* @type {OrbitController}
* @private
*/
_orbitController = new OrbitController();
/**
* @type {FocusController}
* @private
*/
_focusController = new FocusController();
/**
* @type {InputController}
* @private
*/
// @ts-ignore
_controller;
/**
* @type {Pose}
* @private
*/
_pose = new Pose();
/**
* @type {'orbit' | 'fly' | 'focus'}
* @private
*/
// @ts-ignore
_mode;
/**
* @type {CameraControlsState}
* @private
*/
_state = {
axis: new Vec3(),
shift: 0,
ctrl: 0,
mouse: [0, 0, 0],
touches: 0
};
/**
* Enable fly camera controls.
*
* @attribute
* @title Enable Fly
* @type {boolean}
* @default true
*/
set enableFly(enable) {
this._enableFly = enable;
if (!this._enableFly && this._mode === 'fly') {
this._setMode('orbit');
}
}
get enableFly() {
return this._enableFly;
}
/**
* Enable orbit camera controls.
*
* @attribute
* @title Enable Orbit
* @type {boolean}
* @default true
*/
set enableOrbit(enable) {
this._enableOrbit = enable;
if (!this._enableOrbit && this._mode === 'orbit') {
this._setMode('fly');
}
}
get enableOrbit() {
return this._enableOrbit;
}
/**
* Enable panning.
*
* @attribute
* @title Enable Panning
* @type {boolean}
*/
enablePan = true;
/**
* The focus damping. A higher value means more damping. A value of 0 means no damping.
* The damping is applied to the orbit mode.
*
* @attribute
* @title Focus Damping
* @type {number}
* @default 0.98
*/
set focusDamping(damping) {
this._focusController.focusDamping = damping;
}
get focusDamping() {
return this._focusController.focusDamping;
}
/**
* The focus point.
*
* @attribute
* @title Focus Point
* @type {Vec3}
* @default [0, 0, 0]
*/
set focusPoint(point) {
const position = this._camera.entity.getPosition();
this._startZoomDist = position.distance(point);
this._controller.attach(this._pose.look(position, point), false);
}
get focusPoint() {
return this._pose.getFocus(tmpV1);
}
/**
* The move damping. In the range 0 to 1, where a value of 0 means no damping and 1 means full
* damping. The damping is applied to the fly mode and the orbit mode when panning.
*
* @attribute
* @title Move Damping
* @type {number}
* @default 0.98
*/
set moveDamping(damping) {
this._flyController.moveDamping = damping;
}
get moveDamping() {
return this._flyController.moveDamping;
}
/**
* The fly move speed relative to the scene size.
*
* @attribute
* @title Move Speed
* @type {number}
*/
moveSpeed = 10;
/**
* The fast fly move speed relative to the scene size.
*
* @attribute
* @title Move Fast Speed
* @type {number}
*/
moveFastSpeed = 20;
/**
* The slow fly move speed relative to the scene size.
*
* @attribute
* @title Move Slow Speed
* @type {number}
*/
moveSlowSpeed = 5;
/**
* The rotate damping. In the range 0 to 1, where a value of 0 means no damping and 1 means full
* damping. The damping is applied to both the fly and orbit modes.
*
* @attribute
* @title Rotate Damping
* @type {number}
* @default 0.98
*/
set rotateDamping(damping) {
this._flyController.rotateDamping = damping;
this._orbitController.rotateDamping = damping;
}
get rotateDamping() {
return this._orbitController.rotateDamping;
}
/**
* The rotation speed.
*
* @attribute
* @title Rotate Speed
* @type {number}
*/
rotateSpeed = 0.2;
/**
* The rotation joystick sensitivity.
*
* @attribute
* @title Rotate Joystick Sensitivity
* @type {number}
*/
rotateJoystickSens = 2;
/**
* The zoom damping. In the range 0 to 1, where a value of 0 means no damping and 1 means full
* damping. The damping is applied to the orbit mode.
*
* @attribute
* @title Zoom Damping
* @type {number}
* @default 0.98
*/
set zoomDamping(damping) {
this._orbitController.zoomDamping = damping;
}
get zoomDamping() {
return this._orbitController.zoomDamping;
}
/**
* The touch zoom pinch sensitivity.
*
* @attribute
* @title Zoom
* @type {number}
*/
zoomPinchSens = 5;
/**
* The zoom range.
*
* @attribute
* @title Zoom Range
* @type {Vec2}
* @default [0.01, 0]
*/
set zoomRange(range) {
this._zoomRange.x = range.x;
this._zoomRange.y = range.y <= range.x ? Infinity : range.y;
this._orbitController.zoomRange = this._zoomRange;
}
get zoomRange() {
return this._zoomRange;
}
/**
* The zoom speed relative to the scene size.
*
* @attribute
* @title Zoom Speed
* @type {number}
*/
zoomSpeed = 0.001;
/**
* The pitch range. In the range -360 to 360 degrees. The pitch range is applied to the fly mode
* and the orbit mode.
*
* @attribute
* @title Pitch Range
* @type {Vec2}
* @default [-360, 360]
*/
set pitchRange(range) {
this._pitchRange.x = math.clamp(range.x, -360, 360);
this._pitchRange.y = math.clamp(range.y, -360, 360);
this._flyController.pitchRange = this._pitchRange;
this._orbitController.pitchRange = this._pitchRange;
}
get pitchRange() {
return this._pitchRange;
}
/**
* The yaw range. In the range -360 to 360 degrees. The pitch range is applied to the fly mode
* and the orbit mode.
*
* @attribute
* @title Yaw Range
* @type {Vec2}
* @default [-360, 360]
*/
set yawRange(range) {
this._yawRange.x = math.clamp(range.x, -360, 360);
this._yawRange.y = math.clamp(range.y, -360, 360);
this._flyController.yawRange = this._yawRange;
this._orbitController.yawRange = this._yawRange;
}
get yawRange() {
return this._yawRange;
}
/**
* The joystick event name for the UI position for the base and stick elements.
* The event name is appended with the side: 'left' or 'right'.
*
* @attribute
* @title Joystick Base Event Name
* @type {string}
*/
joystickEventName = 'joystick';
/**
* The layout of the mobile input. The layout can be one of the following:
*
* - `joystick-joystick`: Two virtual joysticks.
* - `joystick-touch`: One virtual joystick and one touch.
* - `touch-joystick`: One touch and one virtual joystick.
* - `touch-touch`: Two touches.
*
* Default is `joystick-touch`.
*
* @attribute
* @title Use Virtual Gamepad
* @type {MobileInputLayout}
* @default joystick-touch
*/
set mobileInputLayout(layout) {
if (!/(?:joystick|touch)-(?:joystick|touch)/.test(layout)) {
console.warn(`CameraControls: invalid mobile input layout: ${layout}`);
return;
}
this._flyMobileInput.layout = layout;
}
get mobileInputLayout() {
return this._flyMobileInput.layout;
}
/**
* The gamepad dead zone.
*
* @attribute
* @title Gamepad Dead Zone
* @type {Vec2}
*/
gamepadDeadZone = new Vec2(0.3, 0.6);
constructor({ app, entity, ...args }) {
super({ app, entity, ...args });
if (!this.entity.camera) {
console.error('CameraControls: camera component not found');
return;
}
this._camera = this.entity.camera;
// set orbit controller defaults
this._orbitController.zoomRange = new Vec2(0.01, Infinity);
// attach input
this._desktopInput.attach(this.app.graphicsDevice.canvas);
this._orbitMobileInput.attach(this.app.graphicsDevice.canvas);
this._flyMobileInput.attach(this.app.graphicsDevice.canvas);
this._gamepadInput.attach(this.app.graphicsDevice.canvas);
// expose ui events
this._flyMobileInput.on('joystick:position:left', ([bx, by, sx, sy]) => {
if (this._mode !== 'fly') {
return;
}
this.app.fire(`${this.joystickEventName}:left`, bx, by, sx, sy);
});
this._flyMobileInput.on('joystick:position:right', ([bx, by, sx, sy]) => {
if (this._mode !== 'fly') {
return;
}
this.app.fire(`${this.joystickEventName}:right`, bx, by, sx, sy);
});
// pose
this._pose.look(this._camera.entity.getPosition(), Vec3.ZERO);
// mode
this._setMode('orbit');
// state
this.on('state', () => {
// discard inputs
this._desktopInput.read();
this._orbitMobileInput.read();
this._flyMobileInput.read();
this._gamepadInput.read();
});
// destroy
this.on('destroy', this._destroy, this);
}
/**
* @private
*/
_destroy() {
this._desktopInput.destroy();
this._orbitMobileInput.destroy();
this._flyMobileInput.destroy();
this._gamepadInput.destroy();
this._flyController.destroy();
this._orbitController.destroy();
}
/**
* @param {'orbit' | 'fly' | 'focus'} mode - The mode to set.
* @private
*/
_setMode(mode) {
// override mode depending on enabled features
switch (true) {
case this.enableFly && !this.enableOrbit: {
mode = 'fly';
break;
}
case !this.enableFly && this.enableOrbit: {
mode = 'orbit';
break;
}
case !this.enableFly && !this.enableOrbit: {
console.warn('CameraControls: both fly and orbit modes are disabled');
return;
}
}
// check if mode is the same
if (this._mode === mode) {
return;
}
this._mode = mode;
// detach old controller
if (this._controller) {
this._controller.detach();
}
// attach new controller
switch (this._mode) {
case 'orbit': {
this._controller = this._orbitController;
break;
}
case 'fly': {
this._controller = this._flyController;
break;
}
case 'focus': {
this._controller = this._focusController;
break;
}
}
this._controller.attach(this._pose, false);
}
/**
* @param {Vec3} focus - The focus point.
* @param {boolean} [resetZoom] - Whether to reset the zoom.
*/
focus(focus, resetZoom = false) {
this._setMode('focus');
const zoomDist = resetZoom ?
this._startZoomDist : this._camera.entity.getPosition().distance(focus);
const position = tmpV1.copy(this._camera.entity.forward)
.mulScalar(-zoomDist)
.add(focus);
this._controller.attach(pose.look(position, focus));
}
/**
* @param {Vec3} focus - The focus point.
* @param {boolean} [resetZoom] - Whether to reset the zoom.
*/
look(focus, resetZoom = false) {
this._setMode('focus');
const position = resetZoom ?
tmpV1.copy(this._camera.entity.getPosition())
.sub(focus)
.normalize()
.mulScalar(this._startZoomDist)
.add(focus) : this._camera.entity.getPosition();
this._controller.attach(pose.look(position, focus));
}
/**
* @param {Vec3} focus - The focus point.
* @param {Vec3} position - The start point.
*/
reset(focus, position) {
this._setMode('focus');
this._controller.attach(pose.look(position, focus));
}
/**
* @param {number} dt - The time delta.
*/
update(dt) {
const { keyCode } = KeyboardMouseSource;
const { key, button, mouse, wheel } = this._desktopInput.read();
const { touch, pinch, count } = this._orbitMobileInput.read();
const { leftInput, rightInput } = this._flyMobileInput.read();
const { leftStick, rightStick } = this._gamepadInput.read();
// apply dead zone to gamepad sticks
applyDeadZone(leftStick, this.gamepadDeadZone.x, this.gamepadDeadZone.y);
applyDeadZone(rightStick, this.gamepadDeadZone.x, this.gamepadDeadZone.y);
// update state
this._state.axis.add(tmpV1.set(
(key[keyCode.D] - key[keyCode.A]) + (key[keyCode.RIGHT] - key[keyCode.LEFT]),
(key[keyCode.E] - key[keyCode.Q]),
(key[keyCode.W] - key[keyCode.S]) + (key[keyCode.UP] - key[keyCode.DOWN])
));
for (let i = 0; i < this._state.mouse.length; i++) {
this._state.mouse[i] += button[i];
}
this._state.shift += key[keyCode.SHIFT];
this._state.ctrl += key[keyCode.CTRL];
this._state.touches += count[0];
if (button[0] === 1 || button[1] === 1 || wheel[0] !== 0) {
// left mouse button, middle mouse button, mouse wheel
this._setMode('orbit');
} else if (button[2] === 1 || this._state.axis.length() > 0) {
// right mouse button or any movement
this._setMode('fly');
}
const orbit = +(this._mode === 'orbit');
const fly = +(this._mode === 'fly');
const double = +(this._state.touches > 1);
const desktopPan = +(this._state.shift || this._state.mouse[1]);
const mobileJoystick = +(this._flyMobileInput.layout.endsWith('joystick'));
// multipliers
const moveMult = (this._state.shift ? this.moveFastSpeed : this._state.ctrl ?
this.moveSlowSpeed : this.moveSpeed) * dt;
const zoomMult = this.zoomSpeed * 60 * dt;
const zoomTouchMult = zoomMult * this.zoomPinchSens;
const rotateMult = this.rotateSpeed * 60 * dt;
const rotateJoystickMult = this.rotateSpeed * this.rotateJoystickSens * 60 * dt;
const { deltas } = frame;
// desktop move
const v = tmpV1.set(0, 0, 0);
const keyMove = this._state.axis.clone().normalize();
v.add(keyMove.mulScalar(fly * moveMult));
const panMove = screenToWorld(this._camera, mouse[0], mouse[1], this._pose.distance);
v.add(panMove.mulScalar(orbit * desktopPan * +this.enablePan));
const wheelMove = tmpV2.set(0, 0, wheel[0]);
v.add(wheelMove.mulScalar(orbit * zoomMult));
deltas.move.append([v.x, v.y, v.z]);
// desktop rotate
v.set(0, 0, 0);
const mouseRotate = tmpV2.set(mouse[0], mouse[1], 0);
v.add(mouseRotate.mulScalar((1 - (orbit * desktopPan)) * rotateMult));
deltas.rotate.append([v.x, v.y, v.z]);
// mobile move
v.set(0, 0, 0);
const flyMove = tmpV2.set(leftInput[0], 0, -leftInput[1]);
v.add(flyMove.mulScalar(fly * moveMult));
const orbitMove = screenToWorld(this._camera, touch[0], touch[1], this._pose.distance);
v.add(orbitMove.mulScalar(orbit * double * +this.enablePan));
const pinchMove = tmpV2.set(0, 0, pinch[0]);
v.add(pinchMove.mulScalar(orbit * double * zoomTouchMult));
deltas.move.append([v.x, v.y, v.z]);
// mobile rotate
v.set(0, 0, 0);
const orbitRotate = tmpV2.set(touch[0], touch[1], 0);
v.add(orbitRotate.mulScalar(orbit * (1 - double) * rotateMult));
const flyRotate = tmpV2.set(rightInput[0], rightInput[1], 0);
v.add(flyRotate.mulScalar(fly * (mobileJoystick ? rotateJoystickMult : rotateMult)));
deltas.rotate.append([v.x, v.y, v.z]);
// gamepad move
v.set(0, 0, 0);
const stickMove = tmpV2.set(leftStick[0], 0, -leftStick[1]);
v.add(stickMove.mulScalar(fly * moveMult));
deltas.move.append([v.x, v.y, v.z]);
// gamepad rotate
v.set(0, 0, 0);
const stickRotate = tmpV2.set(rightStick[0], rightStick[1], 0);
v.add(stickRotate.mulScalar(fly * rotateJoystickMult));
deltas.rotate.append([v.x, v.y, v.z]);
// check if XR is active for frame discard
if (this.app.xr?.active) {
frame.read();
return;
}
// check focus end
if (this._mode === 'focus') {
const focusInterrupt = deltas.move.length() + deltas.rotate.length() > 0;
const focusComplete = this._focusController.complete();
if (focusInterrupt || focusComplete) {
this._setMode('orbit');
}
}
// update controller by consuming frame
this._pose.copy(this._controller.update(frame, dt));
this._camera.entity.setPosition(this._pose.position);
this._camera.entity.setEulerAngles(this._pose.angles);
}
}
export { CameraControls };