Skip to content
Open
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
117 changes: 99 additions & 18 deletions client/src/core/Camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Euler, Object3D, PerspectiveCamera, Quaternion, Raycaster, Vector2, Vec
import Entity from "../entities/Entity";
import EventRouter from '../events/EventRouter';
import Game from "../Game";
import MobileManager from '../mobile/MobileManager';
import { NetworkManagerEventType } from '../network/NetworkManager';
import type { Ray } from 'three';
import type { NetworkManagerEventPayload } from '../network/NetworkManager';
Expand All @@ -11,12 +12,18 @@ const MIN_ZOOM = 3.0;
const MAX_ZOOM = 10.0;
const INITIAL_ZOOM = 6.0;
const CAMERA_LERP_TIME = 0.2;
const CAMERA_COLLISION_RAYCAST_INTERVAL_DESKTOP_S = 1 / 30;
const CAMERA_COLLISION_RAYCAST_INTERVAL_MOBILE_S = 1 / 15;
const CAMERA_COLLISION_RAYCAST_ORIGIN_DELTA_SQ_THRESHOLD = 0.1 * 0.1;
const CAMERA_COLLISION_RAYCAST_DIRECTION_DOT_THRESHOLD = 0.9995;
const CAMERA_COLLISION_RAYCAST_DISTANCE_DELTA_THRESHOLD = 0.1;

// Working variables
const vec2 = new Vector2();
const vec3 = new Vector3();
const vec3b = new Vector3();
const vec3c = new Vector3();
const vec3d = new Vector3();
const modelViewEuler = new Euler(0, 0, 0, 'YXZ');
const yawOnlyEuler = new Euler(0, 0, 0, 'YXZ');
const entityYawEuler = new Euler(0, 0, 0, 'YXZ');
Expand Down Expand Up @@ -77,6 +84,17 @@ export default class Camera {
private _gameCameraYaw: number = 0;
private _gameCameraViewDir: Vector3 = new Vector3();
private _gameCameraCollisionDistance: number = Infinity; // Current collision-adjusted distance
private _gameCameraCollisionTargetDistance: number = Infinity;
private _gameCameraCollisionRaycastHasSample: boolean = false;
private _gameCameraCollisionRaycastIntervalRemainingS: number = 0;
private _gameCameraCollisionRaycastIntervalS: number =
MobileManager.isMobile
? CAMERA_COLLISION_RAYCAST_INTERVAL_MOBILE_S
: CAMERA_COLLISION_RAYCAST_INTERVAL_DESKTOP_S;
private _gameCameraCollisionRaycastDesiredDistance: number = 0;
private _gameCameraCollisionRaycastOrigin: Vector3 = new Vector3();
private _gameCameraCollisionRaycastDirection: Vector3 = new Vector3();
private _gameCameraShoulderPositionOffset: Vector3 = new Vector3();

private _spectatorCamera: PerspectiveCamera;
private _spectatorCameraPitch: number = 0;
Expand Down Expand Up @@ -116,6 +134,10 @@ export default class Camera {
return this._gameCameraAttachedEntity;
}

public get gameCameraYaw(): number {
return this._gameCameraYaw;
}

public get isGameCameraActive(): boolean {
return this._activeCamera === this._gameCamera;
}
Expand Down Expand Up @@ -496,6 +518,67 @@ export default class Camera {
}
}

private _shouldSampleGameCameraCollision(
lookAtTarget: Vector3,
direction: Vector3,
desiredDistance: number,
frameDeltaS: number,
): boolean {
this._gameCameraCollisionRaycastIntervalRemainingS = Math.max(
0,
this._gameCameraCollisionRaycastIntervalRemainingS - frameDeltaS,
);

if (!this._gameCameraCollisionRaycastHasSample) {
return true;
}

if (this._gameCameraCollisionRaycastIntervalRemainingS <= 0) {
return true;
}

if (
lookAtTarget.distanceToSquared(this._gameCameraCollisionRaycastOrigin)
> CAMERA_COLLISION_RAYCAST_ORIGIN_DELTA_SQ_THRESHOLD
) {
return true;
}

if (
direction.dot(this._gameCameraCollisionRaycastDirection)
< CAMERA_COLLISION_RAYCAST_DIRECTION_DOT_THRESHOLD
) {
return true;
}

return Math.abs(desiredDistance - this._gameCameraCollisionRaycastDesiredDistance)
> CAMERA_COLLISION_RAYCAST_DISTANCE_DELTA_THRESHOLD;
}

private _sampleGameCameraCollisionDistance(
lookAtTarget: Vector3,
direction: Vector3,
desiredDistance: number,
): void {
this._raycaster.set(lookAtTarget, direction);
this._raycaster.far = desiredDistance;

let targetDistance = desiredDistance;
const intersects = this._raycaster.intersectObjects(this._game.chunkMeshManager.solidMeshesInScene, false);
if (intersects.length > 0) {
// Account for near plane so the camera frustum doesn't graze the block face.
const nearPadding = this._gameCamera.near + 0.1;
targetDistance = Math.max(0.5, intersects[0].distance - nearPadding);
}

this._gameCameraCollisionTargetDistance = targetDistance;
this._gameCameraCollisionRaycastOrigin.copy(lookAtTarget);
this._gameCameraCollisionRaycastDirection.copy(direction);
this._gameCameraCollisionRaycastDesiredDistance = desiredDistance;
this._gameCameraCollisionRaycastIntervalRemainingS = this._gameCameraCollisionRaycastIntervalS;
this._gameCameraCollisionRaycastHasSample = true;
}

private _updateGameCamera(frameDeltaS: number): void {
if (!this._gameCameraAttachedEntity && !this._gameCameraAttachedPosition) {
return console.warn(`Camera._updateGameCamera(): No camera attachment or position set for game camera.`);
Expand All @@ -508,7 +591,7 @@ export default class Camera {

// Calculate look direction and orientation if we have a look target
if (lookAtPosition) {
lookAtDirection = new Vector3().subVectors(attachedPosition, lookAtPosition).normalize();
lookAtDirection = vec3d.subVectors(attachedPosition, lookAtPosition).normalize();

this._updateGameCameraOrientation(
Math.asin(lookAtDirection.y),
Expand Down Expand Up @@ -579,7 +662,7 @@ export default class Camera {
if (this._gameCameraMode === CameraMode.THIRD_PERSON) {
const radius = this._gameCameraRadialZoom - 1;
const heightOffset = 1.25;
const lookAtTarget = (lookAtPosition || attachedPosition).clone();
const lookAtTarget = vec3c.copy(lookAtPosition || attachedPosition);

// Default +y 0.5 offset for better default player perspective for now.
// Devs can adjust this with their own provided offset for gameCameraOffset in the sdk.
Expand All @@ -600,9 +683,9 @@ export default class Camera {

// Apply visual rotation to camera position around the look target (skip if no rotation, if not identity quat)
if (this._gameCameraShoulderRotationOffset.w !== 1) {
const positionOffset = this._gameCamera.position.clone().sub(lookAtTarget);
positionOffset.applyQuaternion(this._gameCameraShoulderRotationOffset);
this._gameCamera.position.copy(lookAtTarget).add(positionOffset);
this._gameCameraShoulderPositionOffset.copy(this._gameCamera.position).sub(lookAtTarget);
this._gameCameraShoulderPositionOffset.applyQuaternion(this._gameCameraShoulderRotationOffset);
this._gameCamera.position.copy(lookAtTarget).add(this._gameCameraShoulderPositionOffset);
}

// Third-person offset shifts perspective while preserving orbit around the target.
Expand All @@ -617,23 +700,18 @@ export default class Camera {
const desiredDistance = this._gameCamera.position.distanceTo(lookAtTarget);

if (this._gameCameraCollidesWithBlocks) {
this._raycaster.set(lookAtTarget, direction);
this._raycaster.far = desiredDistance;

const collisionMeshes = this._game.chunkMeshManager.solidMeshesInScene;
// Determine target distance based on collision
let targetDistance = desiredDistance;
const intersects = this._raycaster.intersectObjects(collisionMeshes, false);
if (intersects.length > 0) {
// Account for near plane so the camera frustum doesn't graze the block face.
const nearPadding = this._gameCamera.near + 0.1;
targetDistance = Math.max(0.5, intersects[0].distance - nearPadding);
if (this._shouldSampleGameCameraCollision(lookAtTarget, direction, desiredDistance, frameDeltaS)) {
this._sampleGameCameraCollisionDistance(lookAtTarget, direction, desiredDistance);
}


if (!Number.isFinite(this._gameCameraCollisionDistance)) {
this._gameCameraCollisionDistance = this._gameCameraCollisionTargetDistance;
}

// Smooth camera movement both in/out to reduce jarring jumps.
const inSpeed = 20.0; // Faster to avoid noticeable clipping.
const outSpeed = 10.0; // Slower for smooth recovery.
const delta = targetDistance - this._gameCameraCollisionDistance;
const delta = this._gameCameraCollisionTargetDistance - this._gameCameraCollisionDistance;
if (delta !== 0) {
const maxStep = frameDeltaS * (delta < 0 ? inSpeed : outSpeed);
const step = Math.sign(delta) * Math.min(Math.abs(delta), maxStep);
Expand All @@ -650,6 +728,9 @@ export default class Camera {
} else {
// Reset collision distance when collision is disabled.
this._gameCameraCollisionDistance = desiredDistance;
this._gameCameraCollisionTargetDistance = desiredDistance;
this._gameCameraCollisionRaycastHasSample = false;
this._gameCameraCollisionRaycastIntervalRemainingS = 0;
}

// Look at target - this maintains proper orientation
Expand Down
18 changes: 18 additions & 0 deletions client/src/entities/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,24 @@ export default class Entity {

this._interpolatingRotation = !this._currentRotation.equals(this._targetRotation);
}

// Applies a client-side predicted transform without touching authoritative server tick tracking.
public applyClientPredictedTransform(position: Vector3Like, rotation?: QuaternionLike): void {
this._currentPosition.copy(position);
this._targetPosition.copy(position);
this._entityRoot.position.copy(this._currentPosition);
this._interpolatingPosition = false;

if (rotation) {
this._currentRotation.copy(rotation);
this._targetRotation.copy(rotation);
this._entityRoot.quaternion.copy(this._currentRotation);
this._interpolatingRotation = false;
}

this._needsMatrixUpdate.add(this._entityRoot);
this._needsWorldBoundingBoxUpdate = true;
}

public setRotationInterpolationMs(interpolationMs: number | null): void {
this._rotationInterpolationTimeS = this._resolveInterpolationTimeS(interpolationMs);
Expand Down
Loading