[add] playwright.config.js [add] src/app/App.js [add] src/app/editor-store.js [add] src/app/use-editor-store.js [add] src/assets/audio-assets.js [add] src/assets/gltf-model-import.js [add] src/assets/image-assets.js [add] src/assets/model-instance-labels.js [add] src/assets/model-instance-rendering.js [add] src/assets/model-instances.js [add] src/assets/project-asset-storage.js [add] src/assets/project-assets.js [add] src/commands/brush-command-helpers.js [add] src/commands/command-history.js [add] src/commands/command.js [add] src/commands/commit-transform-session-command.js [add] src/commands/create-box-brush-command.js [add] src/commands/delete-box-brush-command.js [add] src/commands/delete-entity-command.js [add] src/commands/delete-interaction-link-command.js [add] src/commands/delete-model-instance-command.js [add] src/commands/import-audio-asset-command.js [add] src/commands/import-background-image-asset-command.js [add] src/commands/import-model-asset-command.js [add] src/commands/move-box-brush-command.js [add] src/commands/resize-box-brush-command.js [add] src/commands/rotate-box-brush-command.js [add] src/commands/set-box-brush-face-material-command.js [add] src/commands/set-box-brush-face-uv-state-command.js [add] src/commands/set-box-brush-name-command.js [add] src/commands/set-box-brush-transform-command.js [add] src/commands/set-entity-name-command.js [add] src/commands/set-model-instance-name-command.js [add] src/commands/set-player-start-command.js [add] src/commands/set-scene-name-command.js [add] src/commands/set-world-settings-command.js [add] src/commands/upsert-entity-command.js [add] src/commands/upsert-interaction-link-command.js [add] src/commands/upsert-model-instance-command.js [add] src/core/ids.js [add] src/core/selection.js [add] src/core/tool-mode.js [add] src/core/transform-session.js [add] src/core/vector.js [add] src/core/whitebox-selection-feedback.js [add] src/core/whitebox-selection-mode.js [add] src/document/brushes.js [add] src/document/migrate-scene-document.js [add] src/document/scene-document-validation.js [add] src/document/scene-document.js [add] src/document/world-settings.js [add] src/entities/entity-instances.js [add] src/entities/entity-labels.js [add] src/geometry/box-brush-components.js [add] src/geometry/box-brush-mesh.js [add] src/geometry/box-brush.js [add] src/geometry/box-face-uvs.js [add] src/geometry/grid-snapping.js [add] src/geometry/model-instance-collider-debug-mesh.js [add] src/geometry/model-instance-collider-generation.js [add] src/interactions/interaction-links.js [add] src/main.js [add] src/materials/starter-material-library.js [add] src/materials/starter-material-textures.js [add] src/rendering/advanced-rendering.js [add] src/runner-web/RunnerCanvas.js [add] src/runtime-three/first-person-navigation-controller.js [add] src/runtime-three/navigation-controller.js [add] src/runtime-three/orbit-visitor-navigation-controller.js [add] src/runtime-three/player-collision.js [add] src/runtime-three/rapier-collision-world.js [add] src/runtime-three/runtime-audio-system.js [add] src/runtime-three/runtime-host.js [add] src/runtime-three/runtime-interaction-system.js [add] src/runtime-three/runtime-scene-build.js [add] src/runtime-three/runtime-scene-validation.js [add] src/serialization/local-draft-storage.js [add] src/serialization/scene-document-json.js [add] src/shared-ui/HierarchicalMenu.js [add] src/shared-ui/Panel.js [add] src/shared-ui/world-background-style.js [add] src/viewport-three/ViewportCanvas.js [add] src/viewport-three/ViewportPanel.js [add] src/viewport-three/viewport-entity-markers.js [add] src/viewport-three/viewport-focus.js [add] src/viewport-three/viewport-host.js [add] src/viewport-three/viewport-layout.js [add] src/viewport-three/viewport-transient-state.js [add] src/viewport-three/viewport-view-modes.js [add] tests/domain/box-brush-face-editing.command.test.js [add] tests/domain/build-runtime-scene.test.js [add] tests/domain/create-box-brush.command.test.js [add] tests/domain/create-empty-scene-document.test.js [add] tests/domain/editor-store.test.js [add] tests/domain/entity.command.test.js [add] tests/domain/interaction-links.validation.test.js [add] tests/domain/model-import.test.js [add] tests/domain/model-instance.command.test.js [add] tests/domain/player-start.command.test.js [add] tests/domain/rapier-collision-world.test.js [add] tests/domain/runtime-audio-system.test.js [add] tests/domain/runtime-interaction-system.test.js [add] tests/domain/runtime-scene-validation.test.js [add] tests/domain/scene-document-validation.test.js [add] tests/domain/transform-session.command.test.js [add] tests/domain/world-settings.command.test.js [add] tests/domain/world-settings.test.js [add] tests/e2e/app-smoke.e2e.js [add] tests/e2e/box-brush-authoring.e2e.js [add] tests/e2e/entities-foundation.e2e.js [add] tests/e2e/face-material-authoring.e2e.js [add] tests/e2e/first-room-workflow.e2e.js [add] tests/e2e/import-draco-model-asset.e2e.js [add] tests/e2e/import-external-model-asset.e2e.js [add] tests/e2e/import-model-asset.e2e.js [add] tests/e2e/local-lights-and-background.e2e.js [add] tests/e2e/orthographic-views.e2e.js [add] tests/e2e/runner-v1.e2e.js [add] tests/e2e/runtime-click-interaction.e2e.js [add] tests/e2e/runtime-trigger-teleport.e2e.js [add] tests/e2e/viewport-quad-layout.e2e.js [add] tests/e2e/viewport-test-helpers.js [add] tests/e2e/whitebox-component-selection.e2e.js [add] tests/e2e/world-environment.e2e.js [add] tests/geometry/box-brush-geometry.test.js [add] tests/geometry/box-face-uvs.test.js [add] tests/geometry/model-instance-collider-generation.test.js [add] tests/helpers/model-collider-fixtures.js [add] tests/serialization/local-draft-storage.test.js [add] tests/serialization/project-asset-storage.test.js [add] tests/serialization/scene-document-json.test.js [add] tests/setup/vitest.setup.js [add] tests/unit/audio-assets.test.js [add] tests/unit/entity-instances.test.js [add] tests/unit/package-scripts.test.js [add] tests/unit/transform-foundation.integration.test.js [add] tests/unit/viewport-canvas.test.js [add] tests/unit/viewport-entity-markers.test.js [add] tests/unit/viewport-focus.test.js [add] tests/unit/viewport-layout.test.js [add] tests/unit/viewport-view-modes.test.js [add] vite.config.js [add] vitest.config.js
273 lines
11 KiB
JavaScript
273 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)));
|
|
const halfExtents = {
|
|
x: collider.size.x * 0.5,
|
|
y: collider.size.y * 0.5,
|
|
z: collider.size.z * 0.5
|
|
};
|
|
world.createCollider(RAPIER.ColliderDesc.cuboid(halfExtents.x, halfExtents.y, halfExtents.z), 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();
|
|
}
|
|
}
|