diff --git a/packages/3d-web-client-core/src/camera/CameraManager.ts b/packages/3d-web-client-core/src/camera/CameraManager.ts index 2b793182..e2f735f8 100644 --- a/packages/3d-web-client-core/src/camera/CameraManager.ts +++ b/packages/3d-web-client-core/src/camera/CameraManager.ts @@ -145,11 +145,11 @@ export class CameraManager { this.camera.position, this.target.clone().sub(this.camera.position).normalize(), ); - const minimumDistance = this.collisionsManager.raycastFirstDistance(this.rayCaster.ray); + const firstRaycastHit = this.collisionsManager.raycastFirst(this.rayCaster.ray); const cameraToPlayerDistance = this.camera.position.distanceTo(this.target); - if (minimumDistance !== null && minimumDistance <= cameraToPlayerDistance) { - this.targetDistance = cameraToPlayerDistance - minimumDistance; + if (firstRaycastHit !== null && firstRaycastHit[0] <= cameraToPlayerDistance) { + this.targetDistance = cameraToPlayerDistance - firstRaycastHit[0]; this.distance = this.targetDistance; } else { this.targetDistance += (this.desiredDistance - this.targetDistance) * this.dampingFactor * 4; diff --git a/packages/3d-web-client-core/src/character/CharacterManager.ts b/packages/3d-web-client-core/src/character/CharacterManager.ts index fb90cf76..6531e130 100644 --- a/packages/3d-web-client-core/src/character/CharacterManager.ts +++ b/packages/3d-web-client-core/src/character/CharacterManager.ts @@ -142,6 +142,11 @@ export class CharacterManager { this.localCharacter.speakingIndicator?.setSpeaking(this.speakingCharacters.get(this.id)!); } + this.localController.update(); + if (this.timeManager.frame % 2 === 0) { + this.sendUpdate(this.localController.networkState); + } + this.cameraOffsetTarget = this.cameraManager.targetDistance <= 0.4 ? 0.13 : 0; this.cameraOffset += ease(this.cameraOffsetTarget, this.cameraOffset, 0.1); const targetOffset = new Vector3(0, 0, this.cameraOffset); @@ -149,11 +154,6 @@ export class CharacterManager { targetOffset.applyQuaternion(this.localCharacter.quaternion); this.cameraManager.setTarget(targetOffset.add(this.localCharacter.position)); - this.localController.update(); - if (this.timeManager.frame % 2 === 0) { - this.sendUpdate(this.localController.networkState); - } - for (const [id, update] of this.clientStates) { if (this.remoteCharacters.has(id) && this.speakingCharacters.has(id)) { const character = this.remoteCharacters.get(id); diff --git a/packages/3d-web-client-core/src/character/LocalController.ts b/packages/3d-web-client-core/src/character/LocalController.ts index 8143c4a0..dc4956a3 100644 --- a/packages/3d-web-client-core/src/character/LocalController.ts +++ b/packages/3d-web-client-core/src/character/LocalController.ts @@ -1,26 +1,32 @@ -import { Line3, Matrix4, Quaternion, Raycaster, Vector3 } from "three"; +import { Euler, Line3, Matrix4, Quaternion, Ray, Raycaster, Vector3 } from "three"; import { CameraManager } from "../camera/CameraManager"; -import { CollisionsManager } from "../collisions/CollisionsManager"; -import { ease } from "../helpers/math-helpers"; +import { CollisionMeshState, CollisionsManager } from "../collisions/CollisionsManager"; import { KeyInputManager } from "../input/KeyInputManager"; import { TimeManager } from "../time/TimeManager"; import { Character } from "./Character"; import { AnimationState, CharacterState } from "./CharacterState"; -export class LocalController { - private collisionDetectionSteps = 15; +const downVector = new Vector3(0, -1, 0); + +const airResistance = 0.5; +const groundResistance = 0.99999999; +const airControlModifier = 0.05; +const groundWalkControl = 0.75; +const groundRunControl = 1.0; +const baseControl = 200; +const collisionDetectionSteps = 15; +const minimumSurfaceAngle = 0.9; +export class LocalController { public capsuleInfo = { radius: 0.4, segment: new Line3(new Vector3(), new Vector3(0, 1.05, 0)), }; - private maxWalkSpeed = 6; - private maxRunSpeed = 8.5; private gravity: number = -42; - private jumpForce: number = 16; + private jumpForce: number = 20; private coyoteTimeThreshold: number = 70; private coyoteTime: boolean = false; @@ -29,6 +35,7 @@ export class LocalController { private characterWasOnGround: boolean = false; private characterAirborneSince: number = 0; private currentHeight: number = 0; + private currentSurfaceAngle = new Vector3(); private characterVelocity: Vector3 = new Vector3(); private vectorUp: Vector3 = new Vector3(0, 1, 0); @@ -39,10 +46,30 @@ export class LocalController { private tempMatrix: Matrix4 = new Matrix4(); private tempSegment: Line3 = new Line3(); + private tempQuaternion: Quaternion = new Quaternion(); + private tempEuler: Euler = new Euler(); private tempVector: Vector3 = new Vector3(); private tempVector2: Vector3 = new Vector3(); + private tempVector3: Vector3 = new Vector3(); private rayCaster: Raycaster = new Raycaster(); + private surfaceTempQuaternion = new Quaternion(); + private surfaceTempQuaternion2 = new Quaternion(); + private surfaceTempVector1 = new Vector3(); + private surfaceTempVector2 = new Vector3(); + private surfaceTempVector3 = new Vector3(); + private surfaceTempVector4 = new Vector3(); + private surfaceTempVector5 = new Vector3(); + private surfaceTempRay = new Ray(); + private lastFrameSurfaceState: + | [ + CollisionMeshState, + { + lastMatrix: Matrix4; + }, + ] + | null = null; + private forward: boolean; private backward: boolean; private left: boolean; @@ -52,9 +79,6 @@ export class LocalController { private anyDirection: boolean; private conflictingDirections: boolean; - private speed: number = 0; - private targetSpeed: number = 0; - public networkState: CharacterState; constructor( @@ -73,53 +97,62 @@ export class LocalController { }; } - public update(): void { - const { forward, backward, left, right, run, jump, anyDirection, conflictingDirection } = - this.keyInputManager; - - this.forward = forward; - this.backward = backward; - this.left = left; - this.right = right; - this.run = run; - this.jump = jump; - this.anyDirection = anyDirection; - this.conflictingDirections = conflictingDirection; + private updateControllerState(): void { + this.forward = this.keyInputManager.forward; + this.backward = this.keyInputManager.backward; + this.left = this.keyInputManager.left; + this.right = this.keyInputManager.right; + this.run = this.keyInputManager.run; + this.jump = this.keyInputManager.jump; + this.anyDirection = this.keyInputManager.anyDirection; + this.conflictingDirections = this.keyInputManager.conflictingDirection; + } - this.targetSpeed = this.run ? this.maxRunSpeed : this.maxWalkSpeed; - this.speed += ease(this.targetSpeed, this.speed, 0.07); + public update(): void { + this.updateControllerState(); this.rayCaster.set(this.character.position, this.vectorDown); - const minimumDistance = this.collisionsManager.raycastFirstDistance(this.rayCaster.ray); - if (minimumDistance !== null) { - this.currentHeight = minimumDistance; + const firstRaycastHit = this.collisionsManager.raycastFirst(this.rayCaster.ray); + if (firstRaycastHit !== null) { + this.currentHeight = firstRaycastHit[0]; + this.currentSurfaceAngle.copy(firstRaycastHit[1]); } - if (anyDirection || !this.characterOnGround) { + if (this.anyDirection || !this.characterOnGround) { const targetAnimation = this.getTargetAnimation(); this.character.updateAnimation(targetAnimation); } else { this.character.updateAnimation(AnimationState.idle); } - if (this.anyDirection) this.updateRotation(); + if (this.anyDirection) { + this.updateRotation(); + } - for (let i = 0; i < this.collisionDetectionSteps; i++) { - this.updatePosition(this.timeManager.deltaTime / this.collisionDetectionSteps, i); + for (let i = 0; i < collisionDetectionSteps; i++) { + this.updatePosition( + this.timeManager.deltaTime, + this.timeManager.deltaTime / collisionDetectionSteps, + i, + ); } - if (this.character.position.y < 0) this.resetPosition(); + if (this.character.position.y < 0) { + this.resetPosition(); + } this.updateNetworkState(); } private getTargetAnimation(): AnimationState { if (!this.character) return AnimationState.idle; - if (this.conflictingDirections) return AnimationState.idle; const jumpHeight = this.characterVelocity.y > 0 ? 0.2 : 1.8; if (this.currentHeight > jumpHeight && !this.characterOnGround) { return AnimationState.air; } + if (this.conflictingDirections) { + return AnimationState.idle; + } return this.run && this.anyDirection ? AnimationState.running : this.anyDirection @@ -150,9 +183,9 @@ export class LocalController { ); const isCameraFirstPerson = camToModelDistance < 2; if (isCameraFirstPerson) { - const cameraForward = new Vector3(0, 0, 1).applyQuaternion( - this.cameraManager.camera.quaternion, - ); + const cameraForward = this.tempVector + .set(0, 0, 1) + .applyQuaternion(this.cameraManager.camera.quaternion); this.azimuthalAngle = Math.atan2(cameraForward.x, cameraForward.z); } else { this.azimuthalAngle = Math.atan2( @@ -169,8 +202,10 @@ export class LocalController { private updateRotation(): void { this.updateRotationOffset(); this.updateAzimuthalAngle(); - const rotationQuaternion = new Quaternion(); - rotationQuaternion.setFromAxisAngle(this.vectorUp, this.azimuthalAngle + this.rotationOffset); + const rotationQuaternion = this.tempQuaternion.setFromAxisAngle( + this.vectorUp, + this.azimuthalAngle + this.rotationOffset, + ); const angularDifference = this.computeAngularDifference(rotationQuaternion); const desiredTime = 0.07; const angularSpeed = angularDifference / desiredTime; @@ -178,77 +213,113 @@ export class LocalController { this.character.quaternion.rotateTowards(rotationQuaternion, frameRotation); } - private addScaledVectorToCharacter(deltaTime: number) { - this.character.position.addScaledVector(this.tempVector, this.speed * deltaTime); - } + private applyControls(deltaTime: number) { + const resistance = this.characterOnGround ? groundResistance : airResistance; + + // Dampen the velocity based on the resistance + const speedFactor = Math.pow(1 - resistance, deltaTime); + this.characterVelocity.multiplyScalar(speedFactor); + + const acceleration = this.tempVector.set(0, 0, 0); - private updatePosition(deltaTime: number, _iter: number): void { if (this.characterOnGround) { - if (!this.jump) this.canJump = true; + if (!this.jump) { + this.canJump = true; + } if (this.jump && this.canJump) { - this.characterVelocity.y += this.jumpForce; + acceleration.y += this.jumpForce / deltaTime; this.canJump = false; } else { - this.characterVelocity.y = deltaTime * this.gravity; + if (this.currentSurfaceAngle.y < minimumSurfaceAngle) { + acceleration.y += this.gravity; + } } } else if (this.jump && this.coyoteTime) { - this.characterVelocity.y = this.jumpForce; + acceleration.y += this.jumpForce / deltaTime; this.canJump = false; } else { - this.characterVelocity.y += deltaTime * this.gravity; + acceleration.y += this.gravity; this.canJump = false; } - this.character.position.addScaledVector(this.characterVelocity, deltaTime); + const control = + (this.characterOnGround + ? this.run + ? groundRunControl + : groundWalkControl + : airControlModifier) * baseControl; + + const controlAcceleration = this.tempVector2.set(0, 0, 0); + + if (!this.conflictingDirections) { + if (this.forward) { + const forward = this.tempVector3 + .set(0, 0, -1) + .applyAxisAngle(this.vectorUp, this.azimuthalAngle); + controlAcceleration.add(forward); + } - this.tempVector.set(0, 0, 0); + if (this.backward) { + const backward = this.tempVector3 + .set(0, 0, 1) + .applyAxisAngle(this.vectorUp, this.azimuthalAngle); + controlAcceleration.add(backward); + } - if (this.forward) { - const forward = new Vector3(0, 0, -1).applyAxisAngle(this.vectorUp, this.azimuthalAngle); - this.tempVector.add(forward); - } + if (this.left) { + const left = this.tempVector3 + .set(-1, 0, 0) + .applyAxisAngle(this.vectorUp, this.azimuthalAngle); + controlAcceleration.add(left); + } - if (this.backward) { - const backward = new Vector3(0, 0, 1).applyAxisAngle(this.vectorUp, this.azimuthalAngle); - this.tempVector.add(backward); + if (this.right) { + const right = this.tempVector3 + .set(1, 0, 0) + .applyAxisAngle(this.vectorUp, this.azimuthalAngle); + controlAcceleration.add(right); + } } - - if (this.left) { - const left = new Vector3(-1, 0, 0).applyAxisAngle(this.vectorUp, this.azimuthalAngle); - this.tempVector.add(left); + if (controlAcceleration.length() > 0) { + controlAcceleration.normalize(); + controlAcceleration.multiplyScalar(control); } + acceleration.add(controlAcceleration); + this.characterVelocity.addScaledVector(acceleration, deltaTime); - if (this.right) { - const right = new Vector3(1, 0, 0).applyAxisAngle(this.vectorUp, this.azimuthalAngle); - this.tempVector.add(right); - } + this.character.position.addScaledVector(this.characterVelocity, deltaTime); + } - if (this.tempVector.length() > 0) { - this.tempVector.normalize(); - this.addScaledVectorToCharacter(deltaTime); + private updatePosition(deltaTime: number, stepDeltaTime: number, iter: number): void { + this.applyControls(stepDeltaTime); + + if (iter === 0) { + const lastMovement = this.getMovementFromSurfaces(this.character.position, deltaTime); + if (lastMovement) { + this.character.position.add(lastMovement.position); + const asQuaternion = this.tempQuaternion.setFromEuler(this.character.rotation); + const lastMovementEuler = this.tempEuler.setFromQuaternion(lastMovement.rotation); + lastMovementEuler.x = 0; + lastMovementEuler.z = 0; + lastMovement.rotation.setFromEuler(lastMovementEuler); + asQuaternion.multiply(lastMovement.rotation); + this.character.rotation.setFromQuaternion(asQuaternion); + } } - this.character.updateMatrixWorld(); - this.tempSegment.copy(this.capsuleInfo.segment!); - this.tempSegment.start.applyMatrix4(this.character.matrixWorld).applyMatrix4(this.tempMatrix); - this.tempSegment.end.applyMatrix4(this.character.matrixWorld).applyMatrix4(this.tempMatrix); - - this.collisionsManager.applyColliders(this.tempSegment, this.capsuleInfo.radius!); - - const newPosition = this.tempVector; - newPosition.copy(this.tempSegment.start); - - const deltaVector = this.tempVector2; - deltaVector.subVectors(newPosition, this.character.position); - - const offset = Math.max(0.0, deltaVector.length() - 1e-5); - deltaVector.normalize().multiplyScalar(offset); + const avatarSegment = this.tempSegment; + avatarSegment.copy(this.capsuleInfo.segment!); + avatarSegment.start.applyMatrix4(this.character.matrixWorld).applyMatrix4(this.tempMatrix); + avatarSegment.end.applyMatrix4(this.character.matrixWorld).applyMatrix4(this.tempMatrix); - this.character.position.add(deltaVector); + const positionBeforeCollisions = this.tempVector.copy(avatarSegment.start); + this.collisionsManager.applyColliders(avatarSegment, this.capsuleInfo.radius!); + this.character.position.copy(avatarSegment.start); + const deltaCollisionPosition = avatarSegment.start.sub(positionBeforeCollisions); - this.characterOnGround = deltaVector.y > Math.abs(deltaTime * this.characterVelocity.y * 0.25); + this.characterOnGround = deltaCollisionPosition.y > 0; if (this.characterWasOnGround && !this.characterOnGround) { this.characterAirborneSince = Date.now(); @@ -260,25 +331,107 @@ export class LocalController { Date.now() - this.characterAirborneSince < this.coyoteTimeThreshold; this.characterWasOnGround = this.characterOnGround; + } - if (this.characterOnGround) { - this.characterVelocity.set(0, 0, 0); + public getMovementFromSurfaces(userPosition: Vector3, deltaTime: number) { + let lastMovement: { rotation: Quaternion; position: Vector3 } | null = null; + + // If we have a last frame state, we can calculate the movement of the mesh to apply it to the user + if (this.lastFrameSurfaceState !== null) { + const meshState = this.lastFrameSurfaceState[0]; + + // Extract the matrix from the current frame and the last frame + const currentFrameMatrix = meshState.matrix; + const lastFrameMatrix = this.lastFrameSurfaceState[1].lastMatrix; + + if (lastFrameMatrix.equals(currentFrameMatrix)) { + // No movement from this mesh - do nothing + } else { + // The mesh has moved since the last frame - calculate the movement + + // Get the position of the mesh in the last frame + const lastMeshPosition = this.surfaceTempVector1; + const lastMeshRotation = this.surfaceTempQuaternion; + lastFrameMatrix.decompose(lastMeshPosition, lastMeshRotation, this.surfaceTempVector3); + + // Get the position of the mesh in the current frame + const currentMeshPosition = this.surfaceTempVector2; + const currentMeshRotation = this.surfaceTempQuaternion2; + currentFrameMatrix.decompose( + currentMeshPosition, + currentMeshRotation, + this.surfaceTempVector3, + ); + + // Calculate the difference between the new position and the old position to determine the movement due to translation of position + const meshTranslationDelta = this.surfaceTempVector5 + .copy(currentMeshPosition) + .sub(lastMeshPosition); + + // Calculate the relative position of the user to the mesh in the last frame + const lastFrameRelativeUserPosition = this.surfaceTempVector3 + .copy(userPosition) + .sub(lastMeshPosition); + + // Calculate the relative quaternion of the mesh in the last frame to the mesh in the current frame + const meshRotationDelta = lastMeshRotation.invert().multiply(currentMeshRotation); + + // Apply the relative quaternion to the relative user position to determine the new position of the user given just the rotation + const translationDueToRotation = this.surfaceTempVector4 + .copy(lastFrameRelativeUserPosition) + .applyQuaternion(meshRotationDelta) + .sub(lastFrameRelativeUserPosition); + + // Combine the mesh translation delta and the rotation translation delta to determine the total movement of the user + const translationAndRotationPositionDelta = this.surfaceTempVector1 + .copy(meshTranslationDelta) + .add(translationDueToRotation); + + lastMovement = { + position: translationAndRotationPositionDelta, + rotation: meshRotationDelta, + }; + lastFrameMatrix.copy(currentFrameMatrix); + } + } + + const newPosition = this.surfaceTempVector3.copy(userPosition); + if (lastMovement) { + newPosition.add(lastMovement.position); + } + newPosition.setY(newPosition.y + 0.05); + + // Raycast down from the new position to see if there is a surface below the user which will be tracked in the next frame + const ray = this.surfaceTempRay.set(newPosition, downVector); + const hit = this.collisionsManager.raycastFirst(ray); + if (hit && hit[0] < 0.8) { + // There is a surface below the user + const currentCollisionMeshState = hit[2]; + this.lastFrameSurfaceState = [ + currentCollisionMeshState, + { lastMatrix: currentCollisionMeshState.matrix.clone() }, + ]; } else { - deltaVector.normalize(); - this.characterVelocity.addScaledVector(deltaVector, -deltaVector.dot(this.characterVelocity)); + if (this.lastFrameSurfaceState !== null && lastMovement) { + // Apply the last movement to the user's velocity + this.characterVelocity.add( + lastMovement.position.clone().divideScalar(deltaTime), // The position delta is the result of one tick which is deltaTime seconds, so we need to divide by deltaTime to get the velocity per second + ); + } + this.lastFrameSurfaceState = null; } + return lastMovement; } private updateNetworkState(): void { - const characterQuaternion = this.character.getWorldQuaternion(new Quaternion()); - const positionUpdate = new Vector3( - this.character.position.x, - this.character.position.y, - this.character.position.z, - ); + const characterQuaternion = this.character.getWorldQuaternion(this.tempQuaternion); this.networkState = { id: this.id, - position: positionUpdate, + position: { + x: this.character.position.x, + y: this.character.position.y, + z: this.character.position.z, + }, rotation: { quaternionY: characterQuaternion.y, quaternionW: characterQuaternion.w }, state: this.character.getCurrentAnimation(), }; diff --git a/packages/3d-web-client-core/src/collisions/CollisionsManager.ts b/packages/3d-web-client-core/src/collisions/CollisionsManager.ts index ece58c2a..464c84cf 100644 --- a/packages/3d-web-client-core/src/collisions/CollisionsManager.ts +++ b/packages/3d-web-client-core/src/collisions/CollisionsManager.ts @@ -16,6 +16,7 @@ import { Mesh, MeshBasicMaterial, Object3D, + Quaternion, Ray, Scene, Vector3, @@ -24,7 +25,7 @@ import { VertexNormalsHelper } from "three/examples/jsm/helpers/VertexNormalsHel import * as BufferGeometryUtils from "three/examples/jsm/utils/BufferGeometryUtils.js"; import { MeshBVH, MeshBVHVisualizer } from "three-mesh-bvh"; -type CollisionMeshState = { +export type CollisionMeshState = { matrix: Matrix4; source: Group; meshBVH: MeshBVH; @@ -38,6 +39,7 @@ export class CollisionsManager { private tempVector: Vector3 = new Vector3(); private tempVector2: Vector3 = new Vector3(); private tempVector3: Vector3 = new Vector3(); + private tempQuaternion: Quaternion = new Quaternion(); private tempRay: Ray = new Ray(); private tempMatrix = new Matrix4(); private tempMatrix2 = new Matrix4(); @@ -54,22 +56,32 @@ export class CollisionsManager { this.collisionTrigger = MMLCollisionTrigger.init(); } - public raycastFirstDistance(ray: Ray): number | null { + public raycastFirst(ray: Ray): [number, Vector3, CollisionMeshState] | null { let minimumDistance: number | null = null; - for (const [, value] of this.collisionMeshState) { - this.tempRay.copy(ray).applyMatrix4(this.tempMatrix.copy(value.matrix).invert()); - const hit = value.meshBVH.raycastFirst(this.tempRay, DoubleSide); + let minimumHit: CollisionMeshState | null = null; + let minimumNormal: Vector3 | null = new Vector3(); + for (const [, collisionMeshState] of this.collisionMeshState) { + this.tempRay.copy(ray).applyMatrix4(this.tempMatrix.copy(collisionMeshState.matrix).invert()); + const hit = collisionMeshState.meshBVH.raycastFirst(this.tempRay, DoubleSide); if (hit) { this.tempSegment.start.copy(this.tempRay.origin); this.tempSegment.end.copy(hit.point); - this.tempSegment.applyMatrix4(value.matrix); + this.tempSegment.applyMatrix4(collisionMeshState.matrix); const dist = this.tempSegment.distance(); if (minimumDistance === null || dist < minimumDistance) { minimumDistance = dist; + minimumHit = collisionMeshState; + minimumNormal = (hit.normal ? minimumNormal.copy(hit.normal) : minimumNormal) + // Apply the rotation of the mesh to the normal + .applyQuaternion(this.tempQuaternion.setFromRotationMatrix(collisionMeshState.matrix)) + .normalize(); } } } - return minimumDistance; + if (minimumDistance === null || minimumNormal === null || minimumHit === null) { + return null; + } + return [minimumDistance, minimumNormal, minimumHit]; } private createCollisionMeshState(group: Group, trackCollisions: boolean): CollisionMeshState {