Files
spark/src/controls.ts
T

696 lines
23 KiB
TypeScript

import * as THREE from "three";
// Forge controls for keyboard + mouse, game pad, or mobile multi-touch
const DEFAULT_MOVEMENT_SPEED = 1.0;
const DEFAULT_ROLL_SPEED = 2.0;
const DEFAULT_ROTATE_SPEED = 0.002;
const DEFAULT_SLIDE_SPEED = 0.006;
const DEFAULT_SCROLL_SPEED = 0.0015;
const DEFAULT_ROLL_SPRING = 0.0;
const DEFAULT_ROTATE_INERTIA = 0.15;
const DEFAULT_MOVE_INERTIA = 0.15;
const DEFAULT_STICK_THRESHOLD = 0.1;
const DEFAULT_FPS_ROTATE_SPEED = 2.0;
// Time limit for double-finger press (pinch etc)
const DUAL_PRESS_MS = 200;
// Time limit for double-click/double-tap
const DOUBLE_PRESS_LIMIT_MS = 400;
// Distance limit for double-click
const DOUBLE_PRESS_DISTANCE = 50;
// Standard WASD movement keys with R+F for up/down
const WASD_KEYCODE_MOVE = {
KeyW: new THREE.Vector3(0, 0, -1),
KeyS: new THREE.Vector3(0, 0, 1),
KeyA: new THREE.Vector3(-1, 0, 0),
KeyD: new THREE.Vector3(1, 0, 0),
KeyR: new THREE.Vector3(0, 1, 0),
KeyF: new THREE.Vector3(0, -1, 0),
};
// Arrow key movement with PageUp/PageDown
const ARROW_KEYCODE_MOVE = {
ArrowUp: new THREE.Vector3(0, 0, -1),
ArrowDown: new THREE.Vector3(0, 0, 1),
ArrowLeft: new THREE.Vector3(-1, 0, 0),
ArrowRight: new THREE.Vector3(1, 0, 0),
PageUp: new THREE.Vector3(0, 1, 0),
PageDown: new THREE.Vector3(0, -1, 0),
};
// Rolling with Q/E
const QE_KEYCODE_ROTATE = {
KeyQ: new THREE.Vector3(0, 0, 1),
KeyE: new THREE.Vector3(0, 0, -1),
};
// Home/End/Insert/Delete for rotation
const ARROW_KEYCODE_ROTATE = {
Home: new THREE.Vector3(0, -1, 0),
End: new THREE.Vector3(0, 1, 0),
Insert: new THREE.Vector3(-1, 0, 0),
Delete: new THREE.Vector3(1, 0, 0),
};
// ForgeControls provides simple, intuitive controls for navigating 3D space that
// use the keyboard + mouse, game pad, or mobile multi-touch. Internally it
// instantiates and updates a `FpsMovement` and `PointerControls` instance.
export class ForgeControls {
fpsMovement: FpsMovement;
pointerControls: PointerControls;
lastTime = 0;
constructor({ canvas }: { canvas: HTMLCanvasElement }) {
this.fpsMovement = new FpsMovement({});
this.pointerControls = new PointerControls({ canvas });
}
update(control: THREE.Object3D) {
const time = performance.now();
const deltaTime = (time - (this.lastTime || time)) / 1000;
this.lastTime = time;
this.fpsMovement.update(deltaTime, control);
this.pointerControls.update(deltaTime, control);
}
}
// FpsMovement implements controls that will be familiar to anyone who plays
// First Person Shooters using keyboard + mouse or a gamepad. Creating a FpsMovement
// instance provides many parameters for configuring the controls.
//
// When gamepads are connected, FpsMovement will always use gamepad index 0
// for twin-stick movement and rotation.
//
// If xr is passed in, the WebXR controllers can be used as a split gamepad
// to control movement and rotation. (tested on Quest 3)
export class FpsMovement {
moveSpeed: number;
rollSpeed: number;
stickThreshold: number;
rotateSpeed: number;
keycodeMoveMapping: { [key: string]: THREE.Vector3 };
keycodeRotateMapping: { [key: string]: THREE.Vector3 };
gamepadMapping: {
[button: number]: "shift" | "ctrl" | "rollLeft" | "rollRight";
};
capsMultiplier: number;
shiftMultiplier: number;
ctrlMultiplier: number;
xr?: THREE.WebXRManager;
// Enable/disable controls updates
enable = true;
// Currently active event.key values
keydown: { [key: string]: boolean };
// Currently active event.code values
keycode: { [key: string]: boolean };
constructor({
moveSpeed,
rollSpeed,
stickThreshold,
rotateSpeed,
keycodeMoveMapping,
keycodeRotateMapping,
gamepadMapping,
capsMultiplier,
shiftMultiplier,
ctrlMultiplier,
xr,
}: {
// Base movement speed (default DEFAULT_MOVEMENT_SPEED)
moveSpeed?: number;
// Base roll speed (default DEFAULT_ROLL_SPEED)
rollSpeed?: number;
// Stick threshold (default DEFAULT_STICK_THRESHOLD)
stickThreshold?: number;
// Speed of rotation when using gamepad or keys (default DEFAULT_FPS_ROTATE_SPEED)
rotateSpeed?: number;
// Maps keyboard keys to movement directions
// (default {...WASD_KEYCODE_MOVE, ...ARROW_KEYCODE_MOVE})
keycodeMoveMapping?: { [key: string]: THREE.Vector3 };
// Maps keyboard keys to rotation directions
// (default {...QE_KEYCODE_ROTATE, ...ARROW_KEYCODE_ROTATE})
keycodeRotateMapping?: { [key: string]: THREE.Vector3 };
// Maps gamepad buttons to control actions
// (default {4: "rollLeft", 5: "rollRight", 6: "ctrl", 7: "shift"})
gamepadMapping?: {
[button: number]: "shift" | "ctrl" | "rollLeft" | "rollRight";
};
// Speed multiplier when Caps Lock is active (default: 10)
capsMultiplier?: number;
// Speed multiplier when Shift is active (default: 5)
shiftMultiplier?: number;
// Speed multiplier when Ctrl is active (default: 1/5)
ctrlMultiplier?: number;
// Optional WebXR manager for XR controller stick support
xr?: THREE.WebXRManager;
} = {}) {
this.moveSpeed = moveSpeed ?? DEFAULT_MOVEMENT_SPEED;
this.rollSpeed = rollSpeed ?? DEFAULT_ROLL_SPEED;
this.stickThreshold = stickThreshold ?? DEFAULT_STICK_THRESHOLD;
this.rotateSpeed = rotateSpeed ?? DEFAULT_FPS_ROTATE_SPEED;
this.keycodeMoveMapping = keycodeMoveMapping ?? {
...WASD_KEYCODE_MOVE,
...ARROW_KEYCODE_MOVE,
};
this.keycodeRotateMapping = keycodeRotateMapping ?? {
...QE_KEYCODE_ROTATE,
...ARROW_KEYCODE_ROTATE,
};
this.gamepadMapping = gamepadMapping ?? {
4: "rollLeft",
5: "rollRight",
6: "ctrl",
7: "shift",
};
this.capsMultiplier = capsMultiplier ?? 10.0;
this.shiftMultiplier = shiftMultiplier ?? 5.0;
this.ctrlMultiplier = ctrlMultiplier ?? 1.0 / 5.0;
this.xr = xr;
this.keydown = {};
this.keycode = {};
document.addEventListener("keydown", (event) => {
this.keydown[event.key] = true;
this.keycode[event.code] = true;
});
document.addEventListener("keyup", (event) => {
this.keydown[event.key] = false;
this.keycode[event.code] = false;
});
}
// Call this method in your render loop with `control` set to the object to control
// (`THREE.Camera` or a `THREE.Object3D` that contains it), with `deltaTime`
// in seconds since the last update.
update(deltaTime: number, control: THREE.Object3D) {
if (!this.enable) {
return;
}
// Update gamepad / XR controllers
const sticks = [new THREE.Vector2(), new THREE.Vector2()];
const gamepad = navigator.getGamepads()[0];
if (gamepad) {
sticks[0].set(gamepad.axes[0], gamepad.axes[1]);
sticks[1].set(gamepad.axes[2], gamepad.axes[3]);
}
const gamepadButtons =
gamepad?.buttons.map((button) => button.pressed) || [];
const xrSources = Array.from(this.xr?.getSession()?.inputSources ?? []);
for (const source of xrSources) {
const gamepad = source.gamepad;
if (gamepad) {
switch (source.handedness) {
case "none": {
sticks[0].x += gamepad.axes[0];
sticks[0].y += gamepad.axes[1];
sticks[1].x += gamepad.axes[2];
sticks[1].y += gamepad.axes[3];
break;
}
case "left": {
sticks[0].x += gamepad.axes[2];
sticks[0].y += gamepad.axes[3];
break;
}
case "right": {
sticks[1].x += gamepad.axes[2];
sticks[1].y += gamepad.axes[3];
break;
}
}
}
}
for (const stick of sticks) {
stick.x = Math.abs(stick.x) >= this.stickThreshold ? stick.x : 0;
stick.y = Math.abs(stick.y) >= this.stickThreshold ? stick.y : 0;
}
// Rotation
const rotate = new THREE.Vector3(
sticks[1].x,
sticks[1].y,
0,
).multiplyScalar(this.rotateSpeed);
for (const [keycode, rot] of Object.entries(this.keycodeRotateMapping)) {
if (this.keycode[keycode]) {
rotate.add(rot);
}
}
for (const button in this.gamepadMapping) {
if (gamepadButtons[Number.parseInt(button)]) {
switch (this.gamepadMapping[button]) {
case "rollLeft":
rotate.z += 1;
break;
case "rollRight":
rotate.z -= 1;
break;
}
}
}
rotate.multiply(
new THREE.Vector3(this.rotateSpeed, this.rotateSpeed, this.rollSpeed),
);
if (rotate.manhattanLength() > 0.0) {
rotate.multiplyScalar(deltaTime);
const eulers = new THREE.Euler().setFromQuaternion(
control.quaternion,
"YXZ",
);
eulers.y -= rotate.x;
eulers.x = Math.max(
-Math.PI / 2,
Math.min(Math.PI / 2, eulers.x - rotate.y),
);
eulers.z = Math.max(-Math.PI, Math.min(Math.PI, eulers.z + rotate.z));
control.quaternion.setFromEuler(eulers);
}
// Movement
const moveVector = new THREE.Vector3(sticks[0].x, 0, sticks[0].y);
for (const [keycode, move] of Object.entries(this.keycodeMoveMapping)) {
if (this.keycode[keycode]) {
moveVector.add(move);
}
}
let speedMultiplier = 1.0;
if (this.keydown.CapsLock) {
speedMultiplier *= this.capsMultiplier;
}
if (this.keycode.ShiftLeft || this.keycode.ShiftRight) {
speedMultiplier *= this.shiftMultiplier;
}
if (this.keycode.ControlLeft || this.keycode.ControlRight) {
speedMultiplier *= this.ctrlMultiplier;
}
for (const button in this.gamepadMapping) {
if (gamepadButtons[Number.parseInt(button)]) {
switch (this.gamepadMapping[button]) {
case "shift":
speedMultiplier *= this.shiftMultiplier;
break;
case "ctrl":
speedMultiplier *= this.ctrlMultiplier;
break;
}
}
}
// Apply movement in view direction
moveVector.applyQuaternion(control.quaternion);
control.position.add(
moveVector.multiplyScalar(this.moveSpeed * speedMultiplier * deltaTime),
);
}
}
type PointerState = {
initial: THREE.Vector2;
last: THREE.Vector2;
position: THREE.Vector2;
pointerId: number;
button?: number;
timeStamp: DOMHighResTimeStamp;
};
// `PointerControls` implements pointer/mouse/touch controls on the canvas,
// for both desktop and mobile web applications.
export class PointerControls {
canvas: HTMLCanvasElement;
rotateSpeed: number;
slideSpeed: number;
scrollSpeed: number;
swapRotateSlide: boolean;
reverseRotate: boolean;
reverseSlide: boolean;
reverseSwipe: boolean;
reverseScroll: boolean;
moveInertia: number;
rotateInertia: number;
// Enable/disable controls updates
enable = true;
doublePress: ({
position,
intervalMs,
}: { position: THREE.Vector2; intervalMs: number }) => void;
// Time limit for double press (default DOUBLE_PRESS_LIMIT_MS)
doublePressLimitMs: number;
// Distance limit for double press (default DOUBLE_PRESS_DISTANCE)
doublePressDistance: number;
// Last pointer up event (default: null)
lastUp: { position: THREE.Vector2; time: number } | null;
// Pointer state for currently active rotating pointer
rotating: PointerState | null;
// Pointer state for currently active sliding pointer
sliding: PointerState | null;
// Whether we pressed two pointers at the same time
dualPress: boolean;
// Cumulative scroll movement
scroll: THREE.Vector3;
// Current rotation velocity
rotateVelocity: THREE.Vector3;
// Current movement velocity
moveVelocity: THREE.Vector3;
constructor({
// The HTML canvas element to attach pointer events to
canvas,
// Speed of rotation (default DEFAULT_ROTATE_SPEED)
rotateSpeed,
// Speed of sliding when dragging with right/middle mouse button or two fingers
// (default DEFAULT_SLIDE_SPEED)
slideSpeed,
// Speed of movement when using mouse scroll wheel (default DEFAULT_SCROLL_SPEED)
scrollSpeed,
// Swap the direction of rotation and sliding (default: false)
swapRotateSlide,
// Reverse the direction of rotation (default: false)
reverseRotate,
// Reverse the direction of sliding (default: false)
reverseSlide,
// Reverse the direction of swipe gestures (default: false)
reverseSwipe,
// Reverse the direction of scroll wheel movement (default: false)
reverseScroll,
// Inertia factor for movement (default: DEFAULT_MOVE_INERTIA)
moveInertia,
// Inertia factor for rotation (default: DEFAULT_ROTATE_INERTIA)
rotateInertia,
// Callback for double press events (default: () => {})
doublePress,
}: {
canvas: HTMLCanvasElement;
rotateSpeed?: number;
slideSpeed?: number;
scrollSpeed?: number;
swapRotateSlide?: boolean;
reverseRotate?: boolean;
reverseSlide?: boolean;
reverseSwipe?: boolean;
reverseScroll?: boolean;
moveInertia?: number;
rotateInertia?: number;
doublePress?: ({
position,
intervalMs,
}: { position: THREE.Vector2; intervalMs: number }) => void;
}) {
this.canvas = canvas;
this.rotateSpeed = rotateSpeed ?? DEFAULT_ROTATE_SPEED;
this.slideSpeed = slideSpeed ?? DEFAULT_SLIDE_SPEED;
this.scrollSpeed = scrollSpeed ?? DEFAULT_SCROLL_SPEED;
this.swapRotateSlide = swapRotateSlide ?? false;
this.reverseRotate = reverseRotate ?? false;
this.reverseSlide = reverseSlide ?? false;
this.reverseSwipe = reverseSwipe ?? false;
this.reverseScroll = reverseScroll ?? false;
this.moveInertia = moveInertia ?? DEFAULT_MOVE_INERTIA;
this.rotateInertia = rotateInertia ?? DEFAULT_ROTATE_INERTIA;
this.doublePress = doublePress ?? (() => {});
this.doublePressLimitMs = DOUBLE_PRESS_LIMIT_MS;
this.doublePressDistance = DOUBLE_PRESS_DISTANCE;
this.lastUp = null;
this.rotating = null;
this.sliding = null;
this.dualPress = false;
this.scroll = new THREE.Vector3();
this.rotateVelocity = new THREE.Vector3();
this.moveVelocity = new THREE.Vector3();
canvas.addEventListener("pointerdown", (event: PointerEvent) => {
const position = this.getPointerPosition(event);
const initial = position.clone();
const last = position.clone();
// Determine if we're starting a rotation pointer action
const isRotate =
(!this.swapRotateSlide &&
!this.rotating &&
(event.pointerType !== "mouse" || event.button === 0)) ||
(this.swapRotateSlide &&
this.sliding &&
!this.rotating &&
(event.pointerType !== "mouse" || event.button === 1));
// const isRotate =
// !this.rotating && (event.pointerType !== "mouse" || event.button === 0);
const { pointerId, timeStamp } = event;
if (isRotate) {
this.rotating = { initial, last, position, pointerId, timeStamp };
// Capture the pointer so events continue to be delivered even if it leaves the canvas.
canvas.setPointerCapture(event.pointerId);
this.dualPress = false;
} else if (!this.sliding) {
// If it's not a rotation action and we're not yet sliding, the next
// pointer activates a sliding action
const button = event.pointerType === "mouse" ? event.button : undefined;
this.sliding = {
initial,
last,
position,
pointerId,
button,
timeStamp,
};
// Capture the pointer so events continue to be delivered even if it leaves the canvas.
canvas.setPointerCapture(event.pointerId);
// Check if we pressed both pointers at roughly the same time
this.dualPress =
this.rotating != null &&
timeStamp - this.rotating.timeStamp < DUAL_PRESS_MS;
}
});
const pointerUp = (event: PointerEvent) => {
if (this.rotating?.pointerId === event.pointerId) {
this.rotating = null;
canvas.releasePointerCapture(event.pointerId);
if (this.dualPress && this.sliding) {
canvas.releasePointerCapture(this.sliding.pointerId);
this.sliding = null;
}
} else if (this.sliding?.pointerId === event.pointerId) {
this.sliding = null;
canvas.releasePointerCapture(event.pointerId);
if (this.dualPress && this.rotating) {
canvas.releasePointerCapture(this.rotating.pointerId);
this.rotating = null;
}
}
const position = this.getPointerPosition(event);
const lastUp = this.lastUp;
this.lastUp = { position, time: event.timeStamp };
if (lastUp) {
const distance = lastUp.position.distanceTo(position);
if (distance < this.doublePressDistance) {
const intervalMs = event.timeStamp - lastUp.time;
if (intervalMs < this.doublePressLimitMs) {
// We pressed and release twice within the time and distance limits
this.lastUp = null;
this.doublePress({ position, intervalMs });
}
}
}
};
document.addEventListener("pointerup", pointerUp);
document.addEventListener("pointercancel", pointerUp);
document.addEventListener("pointermove", (event: PointerEvent) => {
if (this.rotating?.pointerId === event.pointerId) {
this.rotating.position = this.getPointerPosition(event);
} else if (this.sliding?.pointerId === event.pointerId) {
this.sliding.position = this.getPointerPosition(event);
}
});
canvas.addEventListener("contextmenu", (event: MouseEvent) => {
// Prevent context menu appearing on right click
event.preventDefault();
});
canvas.addEventListener("wheel", (event: WheelEvent) => {
this.scroll.add(
new THREE.Vector3(event.deltaX, event.deltaY, event.deltaZ),
);
event.preventDefault();
});
}
getPointerPosition(event: PointerEvent): THREE.Vector2 {
const rect = this.canvas.getBoundingClientRect();
return new THREE.Vector2(
event.clientX - rect.left,
event.clientY - rect.top,
);
}
update(deltaTime: number, control: THREE.Object3D) {
if (!this.enable) {
return;
}
if (this.dualPress && this.rotating && this.sliding) {
// We pressed both pointers at the same time, either pinching or sliding
const motion = [
this.rotating.position.clone().sub(this.rotating.last),
this.sliding.position.clone().sub(this.sliding.last),
];
const coincidence = motion[0].dot(motion[1]);
if (coincidence >= 0.2) {
// Similar directions so slide the camera on the XY plane
const totalMotion = motion[0].clone().add(motion[1]);
const slide = new THREE.Vector3(totalMotion.x, -totalMotion.y, 0);
slide.multiplyScalar(this.slideSpeed * (this.reverseSwipe ? 1 : -1));
slide.applyQuaternion(control.quaternion);
control.position.add(slide);
this.moveVelocity = slide.clone().multiplyScalar(1 / deltaTime);
} else if (coincidence <= -0.2) {
// Opposite directions so either pinch or roll motion
const deltaDir = this.sliding.last.clone().sub(this.rotating.last);
const deltaDist = deltaDir.length();
deltaDir.multiplyScalar(1 / deltaDist).normalize();
const orthoDir = new THREE.Vector2(-deltaDir.y, deltaDir.x);
const motionDir = [motion[0].dot(deltaDir), motion[1].dot(deltaDir)];
const motionOrtho = [motion[0].dot(orthoDir), motion[1].dot(orthoDir)];
// Pinching motion
const midpoint = this.rotating.last
.clone()
.add(this.sliding.last)
.multiplyScalar(0.5);
let midpointDir = new THREE.Vector3();
if (control instanceof THREE.Camera) {
const ndcMidpoint = new THREE.Vector2(
(midpoint.x / this.canvas.clientWidth) * 2 - 1,
-(midpoint.y / this.canvas.clientHeight) * 2 + 1,
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(ndcMidpoint, control);
midpointDir = raycaster.ray.direction;
}
const pinchOut = motionDir[1] - motionDir[0];
const slide = midpointDir.multiplyScalar(pinchOut * this.slideSpeed);
control.position.add(slide);
this.moveVelocity = slide.clone().multiplyScalar(1 / deltaTime);
// Rolling motion
// Calculate angle of orthogonal motion change over distance deltaDist/2
// motionOrtho[0] and 1 are already in float distance
const angles = [
Math.atan(motionOrtho[0] / (-0.5 * deltaDist)),
Math.atan(motionOrtho[1] / (0.5 * deltaDist)),
];
const rotate = 0.5 * (angles[0] + angles[1]);
const eulers = new THREE.Euler().setFromQuaternion(
control.quaternion,
"YXZ",
);
eulers.z = Math.max(
-Math.PI,
Math.min(Math.PI, eulers.z + 0.5 * rotate),
);
control.quaternion.setFromEuler(eulers);
}
this.rotating.last.copy(this.rotating.position);
this.sliding.last.copy(this.sliding.position);
} else {
// Didn't press both pointers at the same time, so we're in rotating
// or FPS mode
const rotate = new THREE.Vector3();
if (this.rotating && !this.dualPress) {
const delta = this.rotating.position.clone().sub(this.rotating.last);
this.rotating.last.copy(this.rotating.position);
rotate.set(delta.x, delta.y, 0);
rotate.multiplyScalar(this.rotateSpeed * (this.reverseRotate ? -1 : 1));
// Update rotation velocity from last delta
this.rotateVelocity = rotate.clone().multiplyScalar(1 / deltaTime);
} else {
// Continue to rotate with inertia
this.rotateVelocity.multiplyScalar(
Math.exp(-deltaTime / this.rotateInertia),
);
rotate.addScaledVector(this.rotateVelocity, deltaTime);
}
// Apply rotation in Euler angles space
const eulers = new THREE.Euler().setFromQuaternion(
control.quaternion,
"YXZ",
);
eulers.y -= rotate.x;
eulers.x = Math.max(
-Math.PI / 2,
Math.min(Math.PI / 2, eulers.x - rotate.y),
);
eulers.z *= Math.exp(-DEFAULT_ROLL_SPRING * deltaTime);
control.quaternion.setFromEuler(eulers);
if (this.sliding && !this.dualPress) {
const delta = this.sliding.position.clone().sub(this.sliding.last);
this.sliding.last.copy(this.sliding.position);
// Slide on plane depending on center/right mouse button
const slide =
this.sliding.button !== 2
? new THREE.Vector3(delta.x, 0, delta.y)
: new THREE.Vector3(delta.x, -delta.y, 0);
slide.multiplyScalar(this.slideSpeed * (this.reverseSlide ? -1 : 1));
slide.applyQuaternion(control.quaternion);
control.position.add(slide);
// Update movement velocity from last delta
this.moveVelocity = slide.clone().multiplyScalar(1 / deltaTime);
} else {
// Continue to move with inertia
this.moveVelocity.multiplyScalar(
Math.exp(-deltaTime / this.moveInertia),
);
control.position.addScaledVector(this.moveVelocity, deltaTime);
}
}
const scroll = this.scroll.multiplyScalar(this.scrollSpeed);
scroll.set(scroll.x, scroll.z, scroll.y);
if (this.reverseScroll) {
scroll.multiplyScalar(-1);
}
scroll.applyQuaternion(control.quaternion);
control.position.add(scroll);
this.scroll.set(0, 0, 0);
}
}