Files
webeditor3d/src/runtime-three/rapier-collision-world.js

268 lines
11 KiB
JavaScript

import RAPIER from "@dimforge/rapier3d-compat";
import { Euler, MathUtils, Quaternion } from "three";
const CHARACTER_CONTROLLER_OFFSET = 0.01;
const COLLISION_EPSILON = 1e-5;
let rapierInitPromise = null;
function componentScale(vector, scale) {
return {
x: vector.x * scale.x,
y: vector.y * scale.y,
z: vector.z * scale.z
};
}
function createRapierQuaternion(rotationDegrees) {
const quaternion = new Quaternion().setFromEuler(new Euler(MathUtils.degToRad(rotationDegrees.x), MathUtils.degToRad(rotationDegrees.y), MathUtils.degToRad(rotationDegrees.z), "XYZ"));
return {
x: quaternion.x,
y: quaternion.y,
z: quaternion.z,
w: quaternion.w
};
}
function scaleVertices(vertices, scale) {
const scaledVertices = new Float32Array(vertices.length);
for (let index = 0; index < vertices.length; index += 3) {
scaledVertices[index] = vertices[index] * scale.x;
scaledVertices[index + 1] = vertices[index + 1] * scale.y;
scaledVertices[index + 2] = vertices[index + 2] * scale.z;
}
return scaledVertices;
}
function scaleBoundsCenter(bounds, scale) {
return {
x: ((bounds.min.x + bounds.max.x) * 0.5) * scale.x,
y: ((bounds.min.y + bounds.max.y) * 0.5) * scale.y,
z: ((bounds.min.z + bounds.max.z) * 0.5) * scale.z
};
}
function createRapierHeightfieldHeights(collider) {
const heights = new Float32Array(collider.heights.length);
// Rapier's heightfield samples are column-major, with the Z axis varying
// fastest inside each X column. Our generated collider stores X-major rows
// for easier editor/debug mesh reconstruction, so transpose here.
for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) {
for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) {
heights[zIndex + xIndex * collider.cols] = collider.heights[xIndex + zIndex * collider.rows];
}
}
return heights;
}
function createFixedBodyForModelCollider(world, collider) {
return world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
.setTranslation(collider.transform.position.x, collider.transform.position.y, collider.transform.position.z)
.setRotation(createRapierQuaternion(collider.transform.rotationDegrees)));
}
function attachBrushCollider(world, collider) {
const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
.setTranslation(collider.center.x, collider.center.y, collider.center.z)
.setRotation(createRapierQuaternion(collider.rotationDegrees)));
world.createCollider(RAPIER.ColliderDesc.trimesh(collider.vertices, collider.indices), body);
}
function attachSimpleModelCollider(world, collider) {
const body = createFixedBodyForModelCollider(world, collider);
const scaledCenter = componentScale(collider.center, collider.transform.scale);
const scaledHalfExtents = componentScale({
x: collider.size.x * 0.5,
y: collider.size.y * 0.5,
z: collider.size.z * 0.5
}, collider.transform.scale);
world.createCollider(RAPIER.ColliderDesc.cuboid(scaledHalfExtents.x, scaledHalfExtents.y, scaledHalfExtents.z).setTranslation(scaledCenter.x, scaledCenter.y, scaledCenter.z), body);
}
function attachStaticModelCollider(world, collider) {
const body = createFixedBodyForModelCollider(world, collider);
world.createCollider(RAPIER.ColliderDesc.trimesh(scaleVertices(collider.vertices, collider.transform.scale), collider.indices), body);
}
function attachTerrainModelCollider(world, collider) {
if (collider.rows < 2 || collider.cols < 2) {
throw new Error(`Terrain collider ${collider.instanceId} must have at least a 2x2 height sample grid.`);
}
const body = createFixedBodyForModelCollider(world, collider);
const center = scaleBoundsCenter({
min: {
x: collider.minX,
y: 0,
z: collider.minZ
},
max: {
x: collider.maxX,
y: 0,
z: collider.maxZ
}
}, collider.transform.scale);
const rowSubdivisions = collider.rows - 1;
const colSubdivisions = collider.cols - 1;
world.createCollider(
// Rapier expects the number of grid subdivisions here, while our generated
// collider stores the sampled height grid dimensions.
RAPIER.ColliderDesc.heightfield(rowSubdivisions, colSubdivisions, createRapierHeightfieldHeights(collider), {
x: (collider.maxX - collider.minX) * collider.transform.scale.x,
y: collider.transform.scale.y,
z: (collider.maxZ - collider.minZ) * collider.transform.scale.z
}).setTranslation(center.x, center.y, center.z), body);
}
function attachDynamicModelCollider(world, collider) {
const body = createFixedBodyForModelCollider(world, collider);
for (const piece of collider.pieces) {
const scaledPoints = scaleVertices(piece.points, collider.transform.scale);
const descriptor = RAPIER.ColliderDesc.convexHull(scaledPoints);
if (descriptor === null) {
throw new Error(`Dynamic collider piece ${piece.id} could not form a valid convex hull.`);
}
world.createCollider(descriptor, body);
}
}
function attachModelCollider(world, collider) {
switch (collider.kind) {
case "box":
attachSimpleModelCollider(world, collider);
break;
case "trimesh":
attachStaticModelCollider(world, collider);
break;
case "heightfield":
attachTerrainModelCollider(world, collider);
break;
case "compound":
attachDynamicModelCollider(world, collider);
break;
}
}
function feetPositionToColliderCenter(feetPosition, shape) {
switch (shape.mode) {
case "capsule": {
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
return {
x: feetPosition.x,
y: feetPosition.y + shape.radius + cylindricalHalfHeight,
z: feetPosition.z
};
}
case "box":
return {
x: feetPosition.x,
y: feetPosition.y + shape.size.y * 0.5,
z: feetPosition.z
};
case "none":
return {
...feetPosition
};
}
}
function colliderCenterToFeetPosition(center, shape) {
switch (shape.mode) {
case "capsule": {
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
return {
x: center.x,
y: center.y - (shape.radius + cylindricalHalfHeight),
z: center.z
};
}
case "box":
return {
x: center.x,
y: center.y - shape.size.y * 0.5,
z: center.z
};
case "none":
return {
...center
};
}
}
function createPlayerCollider(world, rapier, playerShape) {
switch (playerShape.mode) {
case "capsule":
return world.createCollider(rapier.ColliderDesc.capsule(Math.max(0, (playerShape.height - playerShape.radius * 2) * 0.5), playerShape.radius));
case "box":
return world.createCollider(rapier.ColliderDesc.cuboid(playerShape.size.x * 0.5, playerShape.size.y * 0.5, playerShape.size.z * 0.5));
case "none":
return null;
}
}
export async function initializeRapierCollisionWorld() {
rapierInitPromise ??= RAPIER.init().then(() => RAPIER);
return rapierInitPromise;
}
export class RapierCollisionWorld {
world;
characterController;
playerCollider;
static async create(colliders, playerShape) {
const rapier = await initializeRapierCollisionWorld();
const world = new rapier.World({
x: 0,
y: 0,
z: 0
});
for (const collider of colliders) {
if (collider.source === "brush") {
attachBrushCollider(world, collider);
continue;
}
attachModelCollider(world, collider);
}
const playerCollider = createPlayerCollider(world, rapier, playerShape);
const characterController = playerCollider === null ? null : world.createCharacterController(CHARACTER_CONTROLLER_OFFSET);
if (characterController !== null) {
characterController.setUp({ x: 0, y: 1, z: 0 });
characterController.setSlideEnabled(true);
characterController.enableSnapToGround(0.2);
characterController.enableAutostep(0.35, 0.15, false);
characterController.setMaxSlopeClimbAngle(Math.PI * 0.45);
characterController.setMinSlopeSlideAngle(Math.PI * 0.5);
}
world.step();
return new RapierCollisionWorld(world, characterController, playerCollider);
}
constructor(world, characterController, playerCollider) {
this.world = world;
this.characterController = characterController;
this.playerCollider = playerCollider;
}
resolveFirstPersonMotion(feetPosition, motion, shape) {
if (this.playerCollider === null || this.characterController === null || shape.mode === "none") {
return {
feetPosition: {
x: feetPosition.x + motion.x,
y: feetPosition.y + motion.y,
z: feetPosition.z + motion.z
},
grounded: false,
collidedAxes: {
x: false,
y: false,
z: false
}
};
}
const currentCenter = feetPositionToColliderCenter(feetPosition, shape);
this.playerCollider.setTranslation(currentCenter);
this.characterController.computeColliderMovement(this.playerCollider, motion);
const correctedMovement = this.characterController.computedMovement();
const collidedAxes = {
x: Math.abs(correctedMovement.x - motion.x) > COLLISION_EPSILON,
y: Math.abs(correctedMovement.y - motion.y) > COLLISION_EPSILON,
z: Math.abs(correctedMovement.z - motion.z) > COLLISION_EPSILON
};
const nextCenter = {
x: currentCenter.x + correctedMovement.x,
y: currentCenter.y + correctedMovement.y,
z: currentCenter.z + correctedMovement.z
};
this.playerCollider.setTranslation(nextCenter);
return {
feetPosition: colliderCenterToFeetPosition(nextCenter, shape),
grounded: this.characterController.computedGrounded() || (motion.y < 0 && collidedAxes.y),
collidedAxes
};
}
dispose() {
if (this.characterController !== null) {
this.world.removeCharacterController(this.characterController);
}
this.world.free();
}
}