diff --git a/src/lib/index.ts b/src/lib/index.ts index 41d3d39a..70ca3e4e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -30,6 +30,7 @@ export { export { VoxelmapVisibilityComputer } from './terrain/voxelmap/voxelmap-visibility-computer'; export { type CheckerboardType } from './terrain/voxelmap/voxelsRenderable/voxelsRenderableFactory/voxels-renderable-factory-base'; +export { EVoxelStatus, type IVoxelmapCollider } from './physics/i-voxelmap-collider'; export { VoxelmapCollider } from './physics/voxelmap-collider'; export { VoxelmapCollisions } from './physics/voxelmap-collisions'; diff --git a/src/lib/physics/i-voxelmap-collider.ts b/src/lib/physics/i-voxelmap-collider.ts new file mode 100644 index 00000000..6939d891 --- /dev/null +++ b/src/lib/physics/i-voxelmap-collider.ts @@ -0,0 +1,13 @@ +import type * as THREE from '../libs/three-usage'; + +enum EVoxelStatus { + EMPTY, + FULL, + NOT_LOADED, +} + +interface IVoxelmapCollider { + getVoxel(worldVoxelCoords: THREE.Vector3Like): EVoxelStatus; +} + +export { EVoxelStatus, type IVoxelmapCollider }; diff --git a/src/lib/physics/voxelmap-collider.ts b/src/lib/physics/voxelmap-collider.ts index 2784e3b3..10377a7d 100644 --- a/src/lib/physics/voxelmap-collider.ts +++ b/src/lib/physics/voxelmap-collider.ts @@ -6,11 +6,7 @@ import { type VoxelsChunkOrdering } from '../terrain/voxelmap/i-voxelmap'; import { PatchId } from '../terrain/voxelmap/patch/patch-id'; import { VoxelmapDataPacking } from '../terrain/voxelmap/voxelmap-data-packing'; -enum EVoxelStatus { - EMPTY, - FULL, - NOT_LOADED, -} +import { EVoxelStatus, type IVoxelmapCollider } from './i-voxelmap-collider'; type ChunkData = { readonly data: Uint16Array; @@ -38,7 +34,7 @@ type Parameters = { readonly voxelsChunkOrdering: VoxelsChunkOrdering; }; -class VoxelmapCollider { +class VoxelmapCollider implements IVoxelmapCollider { private readonly chunkSize: THREE.Vector3Like; private readonly voxelsChunkOrdering: VoxelsChunkOrdering; private readonly indexFactors: THREE.Vector3Like; @@ -131,7 +127,7 @@ class VoxelmapCollider { const patchId = new PatchId(chunkId); if (this.chunkCollidersMap[patchId.asString]) { - throw new Error(`Chunk "${patchId.asString}" already exists.`); + logger.warn(`Chunk "${patchId.asString}" already exists.`); } if (chunk.isEmpty) { @@ -212,4 +208,4 @@ class VoxelmapCollider { } } -export { EVoxelStatus, VoxelmapCollider }; +export { VoxelmapCollider }; diff --git a/src/lib/physics/voxelmap-collisions.ts b/src/lib/physics/voxelmap-collisions.ts index 812c5ab3..27dbcd61 100644 --- a/src/lib/physics/voxelmap-collisions.ts +++ b/src/lib/physics/voxelmap-collisions.ts @@ -1,9 +1,10 @@ +import { EVoxelStatus } from '..'; import * as THREE from '../libs/three-usage'; -import { EVoxelStatus, type VoxelmapCollider } from './voxelmap-collider'; +import { type IVoxelmapCollider } from './i-voxelmap-collider'; type Parameters = { - readonly voxelmapCollider: VoxelmapCollider; + readonly voxelmapCollider: IVoxelmapCollider; }; type RayIntersection = { @@ -11,8 +12,42 @@ type RayIntersection = { readonly point: THREE.Vector3Like; }; +type SphereIntersection = { + readonly normal: THREE.Vector3; + readonly depth: number; +}; + +type EntityCollider = { + readonly radius: number; + readonly height: number; + readonly position: THREE.Vector3Like; + readonly velocity: THREE.Vector3Like; +}; + +type EntityCollisionOptions = { + readonly deltaTime: number; + readonly gravity: number; + readonly considerMissingVoxelAs: 'empty' | 'blocking'; +}; + +type EntityCollisionOutput = { + computationStatus: 'ok' | 'partial'; + position: THREE.Vector3; + velocity: THREE.Vector3; + isOnGround: boolean; +}; + +function clamp(x: number, min: number, max: number): number { + if (x < min) { + return min; + } else if (x > max) { + return max; + } + return x; +} + class VoxelmapCollisions { - private readonly voxelmapCollider: VoxelmapCollider; + private readonly voxelmapCollider: IVoxelmapCollider; public constructor(params: Parameters) { this.voxelmapCollider = params.voxelmapCollider; @@ -83,6 +118,342 @@ class VoxelmapCollisions { return null; } + + public sphereIntersect(sphere: THREE.Sphere): SphereIntersection | null { + const displacements: THREE.Vector3Like[] = []; + const addDisplacementIfNeeded = (sphereCenterProjection: THREE.Vector3Like) => { + const projectionToCenter = new THREE.Vector3().subVectors(sphere.center, sphereCenterProjection); + const distanceFromCenter = projectionToCenter.length(); + if (distanceFromCenter < sphere.radius) { + displacements.push(projectionToCenter.normalize().multiplyScalar(sphere.radius - distanceFromCenter)); + } + }; + + const voxelFrom = sphere.center.clone().subScalar(sphere.radius).floor(); + const voxelTo = sphere.center.clone().addScalar(sphere.radius).floor(); + + const voxel = { x: 0, y: 0, z: 0 }; + for (voxel.z = voxelFrom.z; voxel.z <= voxelTo.z; voxel.z++) { + for (voxel.y = voxelFrom.y; voxel.y <= voxelTo.y; voxel.y++) { + for (voxel.x = voxelFrom.x; voxel.x <= voxelTo.x; voxel.x++) { + const localSphereCenter: THREE.Vector3Like = new THREE.Vector3().subVectors(sphere.center, voxel); + const sphereCenterLocalProjection2dX = this.pointSquareProjection({ x: localSphereCenter.z, y: localSphereCenter.y }); + const sphereCenterLocalProjection2dY = this.pointSquareProjection({ x: localSphereCenter.x, y: localSphereCenter.z }); + const sphereCenterLocalProjection2dZ = this.pointSquareProjection({ x: localSphereCenter.x, y: localSphereCenter.y }); + + const sphereCenterProjection2dX = { + x: sphereCenterLocalProjection2dX.x + voxel.z, + y: sphereCenterLocalProjection2dX.y + voxel.y, + }; + const sphereCenterProjection2dY = { + x: sphereCenterLocalProjection2dY.x + voxel.x, + y: sphereCenterLocalProjection2dY.y + voxel.z, + }; + const sphereCenterProjection2dZ = { + x: sphereCenterLocalProjection2dZ.x + voxel.x, + y: sphereCenterLocalProjection2dZ.y + voxel.y, + }; + + const voxelIsFull = this.voxelmapCollider.getVoxel(voxel) !== EVoxelStatus.EMPTY; + + const voxelBelowIsFull = + this.voxelmapCollider.getVoxel({ x: voxel.x, y: voxel.y - 1, z: voxel.z }) !== EVoxelStatus.EMPTY; + const voxelAboveIsFull = + this.voxelmapCollider.getVoxel({ x: voxel.x, y: voxel.y + 1, z: voxel.z }) !== EVoxelStatus.EMPTY; + const voxelLeftIsFull = + this.voxelmapCollider.getVoxel({ x: voxel.x - 1, y: voxel.y, z: voxel.z }) !== EVoxelStatus.EMPTY; + const voxelRightIsFull = + this.voxelmapCollider.getVoxel({ x: voxel.x + 1, y: voxel.y, z: voxel.z }) !== EVoxelStatus.EMPTY; + const voxelBackIsFull = + this.voxelmapCollider.getVoxel({ x: voxel.x, y: voxel.y, z: voxel.z - 1 }) !== EVoxelStatus.EMPTY; + const voxelFrontIsFull = + this.voxelmapCollider.getVoxel({ x: voxel.x, y: voxel.y, z: voxel.z + 1 }) !== EVoxelStatus.EMPTY; + + if (voxelIsFull !== voxelBelowIsFull) { + addDisplacementIfNeeded({ x: sphereCenterProjection2dY.x, y: voxel.y, z: sphereCenterProjection2dY.y }); + } + if (voxelIsFull !== voxelAboveIsFull) { + addDisplacementIfNeeded({ x: sphereCenterProjection2dY.x, y: voxel.y + 1, z: sphereCenterProjection2dY.y }); + } + if (voxelIsFull !== voxelLeftIsFull) { + addDisplacementIfNeeded({ x: voxel.x, y: sphereCenterProjection2dX.y, z: sphereCenterProjection2dX.x }); + } + if (voxelIsFull !== voxelRightIsFull) { + addDisplacementIfNeeded({ x: voxel.x + 1, y: sphereCenterProjection2dX.y, z: sphereCenterProjection2dX.x }); + } + if (voxelIsFull !== voxelBackIsFull) { + addDisplacementIfNeeded({ x: sphereCenterProjection2dZ.x, y: sphereCenterProjection2dZ.y, z: voxel.z }); + } + if (voxelIsFull !== voxelFrontIsFull) { + addDisplacementIfNeeded({ x: sphereCenterProjection2dZ.x, y: sphereCenterProjection2dZ.y, z: voxel.z + 1 }); + } + } + } + } + + if (displacements.length > 0) { + const totalDisplacement = new THREE.Vector3(); + for (const displacement of displacements) { + totalDisplacement.add(displacement); + } + totalDisplacement.divideScalar(displacements.length); + const totalDepth = totalDisplacement.length(); + return { + normal: totalDisplacement.normalize(), + depth: totalDepth, + }; + } + + return null; + } + + public entityMovement(entityCollider: EntityCollider, options: EntityCollisionOptions): EntityCollisionOutput { + const maxDeltaTime = 10 / 1000; + + let currentState = entityCollider; + const output: EntityCollisionOutput = { + computationStatus: 'ok', + position: new THREE.Vector3().copy(entityCollider.position), + velocity: new THREE.Vector3().copy(entityCollider.velocity), + isOnGround: false, + }; + + const applyAndMergeStep = (deltaTime: number) => { + const localOutput = this.entityMovementInternal(currentState, { + ...options, + deltaTime, + }); + + currentState = { + radius: currentState.radius, + height: currentState.height, + position: localOutput.position, + velocity: localOutput.velocity, + }; + + if (localOutput.computationStatus === 'partial') { + output.computationStatus = 'partial'; + } + output.position = localOutput.position; + output.velocity = localOutput.velocity; + output.isOnGround = localOutput.isOnGround; + }; + + let remainingDeltaTime = options.deltaTime; + while (remainingDeltaTime > 0) { + const localDeltaTime = Math.min(remainingDeltaTime, maxDeltaTime); + remainingDeltaTime -= localDeltaTime; + applyAndMergeStep(localDeltaTime); + } + + for (let i = 0; i < 3; i++) { + applyAndMergeStep(0); + applyAndMergeStep(0); + } + + return output; + } + + private entityMovementInternal(entityCollider: EntityCollider, options: EntityCollisionOptions): EntityCollisionOutput { + const ascendSpeed = 10; + const epsilon = 1e-5; + + let allVoxelmapDataIsAvailable = true; + + const { deltaTime, gravity } = options; + if (gravity < 0) { + throw new Error(`Invert gravity not supported.`); + } + + const playerPosition = new THREE.Vector3().copy(entityCollider.position); + const playerVelocity = new THREE.Vector3().copy(entityCollider.velocity); + const playerRadius = entityCollider.radius; + const playerRadiusSquared = playerRadius * playerRadius; + const playerHeight = entityCollider.height; + const previousPosition = playerPosition.clone(); + + const fromX = Math.floor(playerPosition.x - playerRadius); + const toX = Math.floor(playerPosition.x + playerRadius); + const fromZ = Math.floor(playerPosition.z - playerRadius); + const toZ = Math.floor(playerPosition.z + playerRadius); + + playerPosition.addScaledVector(playerVelocity, deltaTime); + + const closetPointFromVoxel = (voxelX: number, voxelZ: number) => { + const projection = { + x: clamp(playerPosition.x, voxelX, voxelX + 1), + z: clamp(playerPosition.z, voxelZ, voxelZ + 1), + }; + return { + x: projection.x - playerPosition.x, + z: projection.z - playerPosition.z, + }; + }; + + const isXZRelevant = (voxelX: number, voxelZ: number) => { + const fromVoxel = closetPointFromVoxel(voxelX, voxelZ); + const distanceSquared = fromVoxel.x ** 2 + fromVoxel.z ** 2; + return distanceSquared < playerRadiusSquared; + }; + + let isVoxelFull: (voxel: THREE.Vector3Like) => boolean; + if (options.considerMissingVoxelAs === 'blocking') { + isVoxelFull = (voxel: THREE.Vector3Like) => { + const voxelStatus = this.voxelmapCollider.getVoxel(voxel); + allVoxelmapDataIsAvailable &&= voxelStatus !== EVoxelStatus.NOT_LOADED; + return voxelStatus === EVoxelStatus.FULL || voxelStatus === EVoxelStatus.NOT_LOADED; + }; + } else if (options.considerMissingVoxelAs === 'empty') { + isVoxelFull = (voxel: THREE.Vector3Like) => { + const voxelStatus = this.voxelmapCollider.getVoxel(voxel); + allVoxelmapDataIsAvailable &&= voxelStatus !== EVoxelStatus.NOT_LOADED; + return voxelStatus === EVoxelStatus.FULL; + }; + } else { + throw new Error('Invalid parameter'); + } + + const isLevelFree = (y: number) => { + for (let iX = fromX; iX <= toX; iX++) { + for (let iZ = fromZ; iZ <= toZ; iZ++) { + if (isXZRelevant(iX, iZ) && isVoxelFull({ x: iX, y, z: iZ })) { + return false; + } + } + } + return true; + }; + + const previousLevel = Math.floor(previousPosition.y); + const newLevel = Math.floor(playerPosition.y); + + if (newLevel < previousLevel && !isLevelFree(previousLevel - 1)) { + // we just entered the ground -> rollback + playerVelocity.y = 0; + playerPosition.y = previousLevel; + } + + let isOnGround = false; + + const levelBelow = Number.isInteger(playerPosition.y) ? playerPosition.y - 1 : Math.floor(playerPosition.y); + const belowIsEmpty = isLevelFree(levelBelow); + if (belowIsEmpty) { + playerVelocity.y -= gravity * deltaTime; + playerVelocity.y = Math.max(-gravity, playerVelocity.y); + } else { + isOnGround = Number.isInteger(playerPosition.y); + + let isAscending = false; + const currentLevel = Math.floor(playerPosition.y); + if (!isLevelFree(currentLevel)) { + // we are partially in the map + let aboveLevelsAreFree = true; + const aboveLevelsFrom = currentLevel + 1; + const aboveLevelsTo = Math.floor(aboveLevelsFrom + playerHeight); + for (let iY = aboveLevelsFrom; iY <= aboveLevelsTo && aboveLevelsAreFree; iY++) { + if (!isLevelFree(iY)) { + aboveLevelsAreFree = false; + } + } + + if (aboveLevelsAreFree) { + isAscending = true; + } + } + + if (isAscending) { + const upwardsMovement = ascendSpeed * options.deltaTime; + const boundary = Number.isInteger(playerPosition.y) ? playerPosition.y + 1 : Math.ceil(playerPosition.y); + playerPosition.y = Math.min(boundary, playerPosition.y + upwardsMovement); + } else { + const displacements: THREE.Vector3Like[] = []; + + type XZ = { readonly x: number; readonly z: number }; + const addDisplacement = (normal: XZ, projection: XZ) => { + const fromCenter = { x: projection.x - playerPosition.x, z: projection.z - playerPosition.z }; + const distanceSquared = fromCenter.x ** 2 + fromCenter.z ** 2; + if (distanceSquared < playerRadiusSquared) { + const distance = Math.sqrt(distanceSquared); + if (fromCenter.x * normal.x + fromCenter.z * normal.z < 0) { + const depth = playerRadius - distance + epsilon; + + displacements.push({ + x: normal.x * depth, + y: 0, + z: normal.z * depth, + }); + } + } + }; + + const levelFrom = Math.floor(playerPosition.y); + const levelTo = Math.floor(playerPosition.y + playerHeight); + const voxel = { x: 0, y: 0, z: 0 }; + for (voxel.y = levelFrom; voxel.y <= levelTo; voxel.y++) { + for (voxel.x = fromX; voxel.x <= toX; voxel.x++) { + for (voxel.z = fromZ; voxel.z <= toZ; voxel.z++) { + const isFull = isVoxelFull(voxel); + const isLeftFull = isVoxelFull({ x: voxel.x - 1, y: voxel.y, z: voxel.z }); + const isRightFull = isVoxelFull({ x: voxel.x + 1, y: voxel.y, z: voxel.z }); + const isBackFull = isVoxelFull({ x: voxel.x, y: voxel.y, z: voxel.z - 1 }); + const isFrontFull = isVoxelFull({ x: voxel.x, y: voxel.y, z: voxel.z + 1 }); + + if (isFull) { + if (!isLeftFull) { + const normal = { x: -1, z: 0 }; + const projection = { x: voxel.x, z: clamp(playerPosition.z, voxel.z, voxel.z + 1) }; + addDisplacement(normal, projection); + } + if (!isRightFull) { + const normal = { x: 1, z: 0 }; + const projection = { x: voxel.x + 1, z: clamp(playerPosition.z, voxel.z, voxel.z + 1) }; + addDisplacement(normal, projection); + } + if (!isBackFull) { + const normal = { x: 0, z: -1 }; + const projection = { x: clamp(playerPosition.x, voxel.x, voxel.x + 1), z: voxel.z }; + addDisplacement(normal, projection); + } + if (!isFrontFull) { + const normal = { x: 0, z: 1 }; + const projection = { x: clamp(playerPosition.x, voxel.x, voxel.x + 1), z: voxel.z + 1 }; + addDisplacement(normal, projection); + } + } + } + } + } + + if (displacements.length > 0) { + const averageDisplacement = new THREE.Vector3(); + for (const displacement of displacements) { + averageDisplacement.add(displacement); + } + averageDisplacement.divideScalar(displacements.length); + playerPosition.add(averageDisplacement); + + if (averageDisplacement.x !== 0) { + playerVelocity.x = 0; + } + if (averageDisplacement.z !== 0) { + playerVelocity.z = 0; + } + } + } + } + + return { + computationStatus: allVoxelmapDataIsAvailable ? 'ok' : 'partial', + position: playerPosition, + velocity: playerVelocity, + isOnGround, + }; + } + + /* Computes the projection of a point onto the {0,1}² square. */ + private pointSquareProjection(point: THREE.Vector2Like): THREE.Vector2 { + return new THREE.Vector2(clamp(point.x, 0, 1), clamp(point.y, 0, 1)); + } } export { VoxelmapCollisions }; diff --git a/src/test/libs/three-usage-test.ts b/src/test/libs/three-usage-test.ts index a8a24504..93254c36 100644 --- a/src/test/libs/three-usage-test.ts +++ b/src/test/libs/three-usage-test.ts @@ -1,5 +1,4 @@ import Stats from 'three/examples/jsm/libs/stats.module.js'; - export { AdditiveBlending, AmbientLight, @@ -13,14 +12,15 @@ export { Matrix4, PCFShadowMap, PerspectiveCamera, + SRGBColorSpace, Scene, SphereGeometry, - SRGBColorSpace, WebGLRenderer, } from 'three'; export { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; export { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; -export { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; export { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; +export { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +export { clamp } from 'three/src/math/MathUtils.js'; export * from '../../lib/libs/three-usage'; export { Stats }; diff --git a/src/test/test-base.ts b/src/test/test-base.ts index 221ea87f..b10bba72 100644 --- a/src/test/test-base.ts +++ b/src/test/test-base.ts @@ -10,6 +10,8 @@ abstract class TestBase { private started: boolean = false; + protected maxFps: number = Infinity; + public constructor() { this.stats = new THREE.Stats(); document.body.appendChild(this.stats.dom); @@ -46,12 +48,21 @@ abstract class TestBase { } this.started = true; + let lastRenderTimestamp = performance.now(); + const render = () => { - this.stats.update(); + const now = performance.now(); + const minDeltaTime = 1000 / this.maxFps; + const deltaTime = now - lastRenderTimestamp; + + if (deltaTime >= minDeltaTime) { + this.stats.update(); - this.cameraControl.update(); - this.update(); - this.renderer.render(this.scene, this.camera); + this.cameraControl.update(); + this.update(); + this.renderer.render(this.scene, this.camera); + lastRenderTimestamp = now; + } window.requestAnimationFrame(render); }; window.requestAnimationFrame(render); diff --git a/src/test/test-physics.ts b/src/test/test-physics.ts index 171d660c..85f973de 100644 --- a/src/test/test-physics.ts +++ b/src/test/test-physics.ts @@ -1,3 +1,4 @@ +import GUI from 'lil-gui'; import * as THREE from 'three-usage-test'; import { @@ -13,6 +14,12 @@ import { import { TestBase } from './test-base'; +type SolidSphere = { + readonly mesh: THREE.Mesh; + readonly collider: THREE.Sphere; + readonly velocity: THREE.Vector3; +}; + class TestPhysics extends TestBase { private readonly map: IVoxelMap; @@ -29,11 +36,31 @@ class TestPhysics extends TestBase { readonly intersectionMesh: THREE.Mesh; }; + private readonly spheres: SolidSphere[] = []; + + private readonly player: { + readonly size: { + readonly radius: number; + readonly height: number; + }; + + readonly container: THREE.Object3D; + readonly velocity: THREE.Vector3; + }; + + private lastUpdate: number | null = null; + + private readonly keyDown = new Map(); + private readonly keysPressed: Set = new Set(); + public constructor(map: IVoxelMap) { super(); - this.camera.position.set(50, 200, 50); - this.cameraControl.target.set(0, 170, 0); + const gui = new GUI(); + gui.add(this, 'maxFps', 1, 120, 1); + + this.camera.position.set(-10, 170, 0); + this.cameraControl.target.set(0, 150, 0); const ambientLight = new THREE.AmbientLight(0xffffff, 2); this.scene.add(ambientLight); @@ -61,6 +88,7 @@ class TestPhysics extends TestBase { checkerboardType: 'xz', voxelsChunkOrdering, }); + this.scene.add(this.voxelmapViewer.container); this.promisesQueue = new PromisesQueue(this.voxelmapViewer.maxPatchesComputedInParallel + 5); @@ -75,7 +103,7 @@ class TestPhysics extends TestBase { minChunkIdY, maxChunkIdY ); - this.voxelmapVisibilityComputer.showMapAroundPosition({ x: 0, y: 0, z: 0 }, 500); + this.voxelmapVisibilityComputer.showMapAroundPosition({ x: 0, y: 0, z: 0 }, 200); this.displayMap(); @@ -108,9 +136,64 @@ class TestPhysics extends TestBase { this.scene.add(rayControls); this.setRayLength(10); + + const playerSize = { + radius: 0.2, + height: 1.4, + }; + const playerMesh = new THREE.Mesh( + new THREE.CylinderGeometry(playerSize.radius, playerSize.radius, playerSize.height), + new THREE.MeshPhongMaterial({ color: 0xdddddd }) + ); + const playerContainer = new THREE.Group(); + playerContainer.add(playerMesh); + playerMesh.position.y = playerSize.height / 2; + playerContainer.position.y = 160; + + this.player = { + size: playerSize, + container: playerContainer, + velocity: new THREE.Vector3(0, 0, 0), + }; + this.scene.add(this.player.container); + + const sphereRadius = 1.1; + const sphereMesh = new THREE.Mesh(new THREE.SphereGeometry(sphereRadius), new THREE.MeshPhongMaterial({ color: 0xdddddd })); + + window.addEventListener('keyup', event => { + if (event.code === 'KeyP') { + const direction = this.camera.getWorldDirection(new THREE.Vector3()); + const position = this.camera.getWorldPosition(new THREE.Vector3()).addScaledVector(direction, 2); + const mesh = sphereMesh.clone(); + mesh.position.copy(position); + const collider = new THREE.Sphere(position, sphereRadius); + const velocity = new THREE.Vector3().addScaledVector(direction, 80); + + const solidSphere: SolidSphere = { mesh, collider, velocity }; + this.scene.add(solidSphere.mesh); + this.spheres.push(solidSphere); + } + + this.keyDown.set(event.code, false); + this.keysPressed.add(event.code); + }); + window.addEventListener('keydown', event => { + this.keyDown.set(event.code, true); + }); } protected override update(): void { + for (const [keyCode, isPressed] of this.keyDown.entries()) { + if (isPressed && keyCode !== 'Space') { + this.keysPressed.add(keyCode); + } + } + + this.updateRay(); + this.updateSpheresAndPlayer(); + } + + private updateRay(): void { const maxDistance = 500; const rayFrom = this.ray.group.getWorldPosition(new THREE.Vector3()); const rayDirection = new THREE.Vector3(0, 1, 0).transformDirection(this.ray.group.matrixWorld); @@ -120,6 +203,99 @@ class TestPhysics extends TestBase { this.setRayLength(intersectionDistance); } + private updateSpheresAndPlayer(): void { + const now = performance.now(); + const lastUpdate = this.lastUpdate ?? now; + const deltaTime = (now - lastUpdate) / 1000; + this.lastUpdate = now; + + const maxDeltaTime = 10 / 1000; + let remainingDeltaTime = deltaTime; + while (remainingDeltaTime > 0) { + const localDeltaTime = Math.min(remainingDeltaTime, maxDeltaTime); + this.updateSpheres(localDeltaTime); + remainingDeltaTime -= localDeltaTime; + } + this.updatePlayer(deltaTime); + } + + private updateSpheres(deltaTime: number): void { + const gravity = 80; + + for (const sphere of this.spheres) { + sphere.collider.center.addScaledVector(sphere.velocity, deltaTime); + sphere.mesh.position.copy(sphere.collider.center); + + const result = this.voxelmapCollisions.sphereIntersect(sphere.collider); + if (result) { + sphere.velocity.addScaledVector(result.normal, -result.normal.dot(sphere.velocity) * 1.5); + sphere.collider.center.add(result.normal.multiplyScalar(result.depth)); + } else { + sphere.velocity.y -= gravity * deltaTime; + } + + const damping = Math.exp(-0.5 * deltaTime) - 1; + sphere.velocity.addScaledVector(sphere.velocity, damping); + } + } + + private updatePlayer(deltaTime: number): void { + const entityCollisionOutput = this.voxelmapCollisions.entityMovement( + { + radius: this.player.size.radius, + height: this.player.size.height, + position: this.player.container.position, + velocity: this.player.velocity, + }, + { + deltaTime, + gravity: 250, + considerMissingVoxelAs: 'blocking', + } + ); + + this.player.container.position.copy(entityCollisionOutput.position); + this.player.velocity.copy(entityCollisionOutput.velocity); + + const movementSpeed = 5; + if (entityCollisionOutput.isOnGround) { + let isMoving = false; + const directiond2d = new THREE.Vector2(0, 0); + if (this.keysPressed.has('KeyW')) { + isMoving = true; + directiond2d.y++; + } + if (this.keysPressed.has('KeyS')) { + isMoving = true; + directiond2d.y--; + } + if (this.keysPressed.has('KeyA')) { + isMoving = true; + directiond2d.x--; + } + if (this.keysPressed.has('KeyD')) { + isMoving = true; + directiond2d.x++; + } + if (this.keysPressed.has('Space')) { + this.player.velocity.y = 20; + } + this.keysPressed.clear(); + + if (isMoving) { + const cameraFront = new THREE.Vector3(0, 0, -1).applyQuaternion(this.camera.quaternion).setY(0).normalize(); + const cameraRight = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion).setY(0).normalize(); + + directiond2d.normalize().multiplyScalar(movementSpeed); + this.player.velocity.x = cameraRight.x * directiond2d.x + cameraFront.x * directiond2d.y; + this.player.velocity.z = cameraRight.z * directiond2d.x + cameraFront.z * directiond2d.y; + } else { + this.player.velocity.x = 0; + this.player.velocity.z = 0; + } + } + } + private async displayMap(): Promise { const patchesToDisplay = this.voxelmapVisibilityComputer.getRequestedPatches(); const patchesIdToDisplay = patchesToDisplay.map(patchToDisplay => patchToDisplay.id);