Skip to content

Commit

Permalink
Precise and mobile controls with jump (#158) (#164)
Browse files Browse the repository at this point in the history
* Precise and mobile controls

* Re-added camera lerping

* Fix camera desired distance

* Fixed zoom lerping
  • Loading branch information
MarcusLongmuir authored Jul 23, 2024
1 parent 42d909b commit c5bc6e3
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 394 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class LocalAvatarClient {
public readonly composer: Composer;
private readonly timeManager = new TimeManager();
private readonly keyInputManager = new KeyInputManager(() => {
return this.cameraManager.dragging;
return this.cameraManager.hasActiveInput();
});
private readonly characterManager: CharacterManager;
private readonly cameraManager: CameraManager;
Expand Down
241 changes: 117 additions & 124 deletions packages/3d-web-client-core/src/camera/CameraManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,42 @@ import { PerspectiveCamera, Raycaster, Vector3 } from "three";
import { CollisionsManager } from "../collisions/CollisionsManager";
import { remap } from "../helpers/math-helpers";
import { EventHandlerCollection } from "../input/EventHandlerCollection";
import { VirtualJoystick } from "../input/VirtualJoystick";
import { camValues } from "../tweakpane/blades/cameraFolder";
import { TweakPane } from "../tweakpane/TweakPane";
import { getTweakpaneActive } from "../tweakpane/tweakPaneActivity";

const cameraPanSensitivity = 20;
const scrollZoomSensitivity = 0.1;
const pinchZoomSensitivity = 0.025;

export class CameraManager {
public readonly camera: PerspectiveCamera;

public initialDistance: number = camValues.initialDistance;
public minDistance: number = camValues.minDistance;
public maxDistance: number = camValues.maxDistance;
public initialFOV: number = camValues.initialFOV;
public maxFOV: number = camValues.maxFOV;
public minFOV: number = camValues.minFOV;
public damping: number = camValues.damping;
public dampingScale: number = 0.01;
public zoomScale: number = camValues.zoomScale;
public zoomDamping: number = camValues.zoomDamping;

public initialFOV: number = camValues.initialFOV;
public maxFOV: number = camValues.maxFOV;
public minFOV: number = camValues.minFOV;
public invertFOVMapping: boolean = camValues.invertFOVMapping;
public fov: number = this.initialFOV;

private targetFOV: number = this.initialFOV;

public minPolarAngle: number = Math.PI * 0.25;
private maxPolarAngle: number = Math.PI * 0.95;

public targetDistance: number = this.initialDistance;
public distance: number = this.initialDistance;
public targetDistance: number = this.initialDistance;
public desiredDistance: number = this.initialDistance;

private targetPhi: number | null;
private phi: number = Math.PI / 2;
private targetTheta: number | null;
private targetPhi: number = this.phi;
private theta: number = Math.PI / 2;
public dragging: boolean = false;
private targetTheta: number = this.theta;

private target: Vector3 = new Vector3(0, 1.55, 0);
private hadTarget: boolean = false;
Expand All @@ -46,121 +47,112 @@ export class CameraManager {

private eventHandlerCollection: EventHandlerCollection;

private isLerping: boolean = false;
private finalTarget: Vector3 = new Vector3();
private isLerping: boolean = false;
private lerpTarget: Vector3 = new Vector3();

private lerpFactor: number = 0;
private lerpDuration: number = 2.1;

private hasTouchControl: boolean = false;
private lastTouchX: number = 0;
private lastTouchY: number = 0;
private activePointers = new Map<number, { x: number; y: number }>();

constructor(
targetElement: HTMLElement,
private targetElement: HTMLElement,
private collisionsManager: CollisionsManager,
initialPhi = Math.PI / 2,
initialTheta = -Math.PI / 2,
) {
this.targetElement.style.touchAction = "pinch-zoom";
this.phi = initialPhi;
this.targetPhi = initialPhi;
this.theta = initialTheta;
this.targetTheta = initialTheta;
this.camera = new PerspectiveCamera(this.fov, window.innerWidth / window.innerHeight, 0.1, 400);
this.camera.position.set(0, 1.4, -this.initialDistance);
this.rayCaster = new Raycaster();

this.hasTouchControl = VirtualJoystick.checkForTouch();

this.eventHandlerCollection = EventHandlerCollection.create([
[targetElement, "mousedown", this.onMouseDown.bind(this)],
[document, "mouseup", this.onMouseUp.bind(this)],
[document, "mousemove", this.onMouseMove.bind(this)],
[targetElement, "pointerdown", this.onPointerDown.bind(this)],
[targetElement, "gesturestart", this.preventDefaultAndStopPropagation.bind(this)],
[document, "pointerup", this.onPointerUp.bind(this)],
[document, "pointercancel", this.onPointerUp.bind(this)],
[document, "pointermove", this.onPointerMove.bind(this)],
[targetElement, "wheel", this.onMouseWheel.bind(this)],
[targetElement, "contextmenu", this.onContextMenu.bind(this)],
]);
}

if (this.hasTouchControl) {
this.eventHandlerCollection.add(targetElement, "touchstart", this.onTouchStart.bind(this), {
passive: false,
});
this.eventHandlerCollection.add(document, "touchmove", this.onTouchMove.bind(this), {
passive: false,
});
this.eventHandlerCollection.add(document, "touchend", this.onTouchEnd.bind(this), {
passive: false,
});
}
private preventDefaultAndStopPropagation(evt: PointerEvent): void {
evt.preventDefault();
evt.stopPropagation();
}

public setupTweakPane(tweakPane: TweakPane) {
tweakPane.setupCamPane(this);
}

private onTouchStart(evt: TouchEvent): void {
Array.from(evt.touches).forEach((touch) => {
this.dragging = true;
this.lastTouchX = touch.clientX;
this.lastTouchY = touch.clientY;
});
}

private onTouchMove(evt: TouchEvent): void {
if (!this.dragging || getTweakpaneActive()) {
return;
}
evt.preventDefault();
private onPointerDown(event: PointerEvent): void {
if (event.button === 0 || event.button === 2) {
// Left or right mouse button

// TODO - handle multi-touch correctly
const touch = Array.from(evt.touches).find((t) => true);
if (touch) {
const dx = touch.clientX - this.lastTouchX;
const dy = touch.clientY - this.lastTouchY;
this.lastTouchX = touch.clientX;
this.lastTouchY = touch.clientY;

if (this.targetTheta !== null && this.targetPhi !== null) {
this.targetTheta += dx * this.dampingScale;
this.targetPhi -= dy * this.dampingScale;
this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi));
}
const pointerInfo = { x: event.clientX, y: event.clientY };
this.activePointers.set(event.pointerId, pointerInfo);
document.body.style.cursor = "none";
}
}

private onTouchEnd(evt: TouchEvent): void {
if (this.dragging) {
// TODO - handle multi-touch correctly
const touchEnded = Array.from(evt.changedTouches).some((t) => true);
if (touchEnded) {
this.dragging = false;
private onPointerUp(event: PointerEvent): void {
const existingPointer = this.activePointers.get(event.pointerId);
if (existingPointer) {
this.activePointers.delete(event.pointerId);
if (this.activePointers.size === 0) {
document.body.style.cursor = "default";
}
}
}

private onMouseDown(event: MouseEvent): void {
if (event.button === 0 || event.button === 2) {
// Left or right mouse button
this.dragging = true;
document.body.style.cursor = "none";
}
}
private getAveragePointerPositionAndSpread(): { pos: { x: number; y: number }; spread: number } {
const existingSum = { x: 0, y: 0 };
this.activePointers.forEach((p) => {
existingSum.x += p.x;
existingSum.y += p.y;
});
const aX = existingSum.x / this.activePointers.size;
const aY = existingSum.y / this.activePointers.size;

private onMouseUp(event: MouseEvent): void {
if (event.button === 0 || event.button === 2) {
this.dragging = false;
document.body.style.cursor = "default";
}
let sumOfDistances = 0;
this.activePointers.forEach((p) => {
const distance = Math.sqrt((p.x - aX) ** 2 + (p.y - aY) ** 2);
sumOfDistances += distance;
});
return { pos: { x: aX, y: aY }, spread: sumOfDistances / this.activePointers.size };
}

private onMouseMove(event: MouseEvent): void {
private onPointerMove(event: PointerEvent): void {
if (getTweakpaneActive()) {
return;
}
if (this.dragging) {
if (this.targetTheta === null || this.targetPhi === null) return;
this.targetTheta += event.movementX * this.dampingScale;
this.targetPhi -= event.movementY * this.dampingScale;

const existingPointer = this.activePointers.get(event.pointerId);
if (existingPointer) {
const previous = this.getAveragePointerPositionAndSpread();

// Replace the pointer info and recalculate to determine the delta
existingPointer.x = event.clientX;
existingPointer.y = event.clientY;

const latest = this.getAveragePointerPositionAndSpread();

const sX = latest.pos.x - previous.pos.x;
const sY = latest.pos.y - previous.pos.y;

const dx = (sX / this.targetElement.clientWidth) * cameraPanSensitivity;
const dy = (sY / this.targetElement.clientHeight) * cameraPanSensitivity;

if (this.activePointers.size > 1) {
const zoomDelta = latest.spread - previous.spread;
this.zoom(-zoomDelta * pinchZoomSensitivity);
}

this.targetTheta += dx;
this.targetPhi -= dy;
this.targetPhi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.targetPhi));
event.preventDefault();
}
Expand All @@ -170,17 +162,21 @@ export class CameraManager {
if (getTweakpaneActive()) {
return;
}
const scrollAmount = event.deltaY * this.zoomScale * 0.1;
this.targetDistance += scrollAmount;
event.preventDefault();
const scrollAmount = event.deltaY * this.zoomScale * scrollZoomSensitivity;
this.zoom(scrollAmount);
}

private zoom(delta: number) {
this.targetDistance += delta;
this.targetDistance = Math.max(
this.minDistance,
Math.min(this.maxDistance, this.targetDistance),
);
this.desiredDistance = this.targetDistance;
event.preventDefault();
}

private onContextMenu(event: MouseEvent): void {
private onContextMenu(event: PointerEvent): void {
event.preventDefault();
}

Expand Down Expand Up @@ -211,12 +207,12 @@ export class CameraManager {
const dy = this.camera.position.y - this.target.y;
const dz = this.camera.position.z - this.target.z;
this.targetDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
this.targetTheta = Math.atan2(dz, dx);
this.targetPhi = Math.acos(dy / this.targetDistance);
this.phi = this.targetPhi;
this.theta = this.targetTheta;
this.distance = this.targetDistance;
this.desiredDistance = this.targetDistance;
this.theta = Math.atan2(dz, dx);
this.targetTheta = this.theta;
this.phi = Math.acos(dy / this.targetDistance);
this.targetPhi = this.phi;
this.recomputeFoV(true);
}

Expand All @@ -225,17 +221,17 @@ export class CameraManager {
const offset = new Vector3(0, 0, offsetDistance);
offset.applyEuler(this.camera.rotation);
const rayOrigin = this.camera.position.clone().add(offset);
const rayDirection = this.target.clone().sub(rayOrigin).normalize();
const rayDirection = rayOrigin.sub(this.target.clone()).normalize();

this.rayCaster.set(rayOrigin, rayDirection);
this.rayCaster.set(this.target.clone(), rayDirection);
const firstRaycastHit = this.collisionsManager.raycastFirst(this.rayCaster.ray);
const cameraToPlayerDistance = this.camera.position.distanceTo(this.target);

if (firstRaycastHit !== null && firstRaycastHit[0] <= cameraToPlayerDistance) {
this.targetDistance = cameraToPlayerDistance - firstRaycastHit[0];
this.distance = this.targetDistance;
if (firstRaycastHit !== null && firstRaycastHit[0] <= this.desiredDistance) {
const distanceToCollision = firstRaycastHit[0] - 0.1;
this.targetDistance = distanceToCollision;
this.distance = distanceToCollision;
} else {
this.targetDistance += (this.desiredDistance - this.targetDistance) * this.damping * 4;
this.targetDistance = this.desiredDistance;
}
}

Expand Down Expand Up @@ -274,32 +270,29 @@ export class CameraManager {
this.adjustCameraPosition();
}

if (
this.phi !== null &&
this.targetPhi !== null &&
this.theta !== null &&
this.targetTheta !== null
) {
this.distance +=
(this.targetDistance - this.distance) * this.damping * (0.21 + this.zoomDamping);
this.phi += (this.targetPhi - this.phi) * this.damping;
this.theta += (this.targetTheta - this.theta) * this.damping;

const x = this.target.x + this.distance * Math.sin(this.phi) * Math.cos(this.theta);
const y = this.target.y + this.distance * Math.cos(this.phi);
const z = this.target.z + this.distance * Math.sin(this.phi) * Math.sin(this.theta);

this.recomputeFoV();
this.fov += (this.targetFOV - this.fov) * this.damping;
this.camera.fov = this.fov;
this.camera.updateProjectionMatrix();

this.camera.position.set(x, y, z);
this.camera.lookAt(this.target);

if (this.isLerping && this.lerpFactor >= 1) {
this.isLerping = false;
}
this.distance += (this.targetDistance - this.distance) * this.zoomDamping;

this.theta += (this.targetTheta - this.theta) * this.damping;
this.phi += (this.targetPhi - this.phi) * this.damping;

const x = this.target.x + this.distance * Math.sin(this.phi) * Math.cos(this.theta);
const y = this.target.y + this.distance * Math.cos(this.phi);
const z = this.target.z + this.distance * Math.sin(this.phi) * Math.sin(this.theta);

this.recomputeFoV();
this.fov += (this.targetFOV - this.fov) * this.zoomDamping;
this.camera.fov = this.fov;
this.camera.updateProjectionMatrix();

this.camera.position.set(x, y, z);
this.camera.lookAt(this.target);

if (this.isLerping && this.lerpFactor >= 1) {
this.isLerping = false;
}
}

public hasActiveInput(): boolean {
return this.activePointers.size > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,12 @@ export class CharacterManager {
if (
this.config.updateURLLocation &&
this.config.timeManager.frame % 60 === 0 &&
document.hasFocus()
document.hasFocus() &&
/*
Don't update the URL if the camera is being controlled as some browsers (e.g. Chrome) cause a hitch to Pointer
events when the url is updated
*/
!this.config.cameraManager.hasActiveInput()
) {
const hash = encodeCharacterAndCamera(
this.localCharacter,
Expand Down
Loading

0 comments on commit c5bc6e3

Please sign in to comment.