Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Precise and mobile controls with jump (#158) #164

Merged
merged 4 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading