auto-git:
[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
This commit is contained in:
203
src/runtime-three/first-person-navigation-controller.js
Normal file
203
src/runtime-three/first-person-navigation-controller.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Euler, Vector3 } from "three";
|
||||
import { getFirstPersonPlayerEyeHeight } from "./player-collision";
|
||||
const LOOK_SENSITIVITY = 0.0022;
|
||||
const MOVE_SPEED = 4.5;
|
||||
const GRAVITY = 22;
|
||||
const MAX_PITCH_RADIANS = Math.PI * 0.48;
|
||||
function clampPitch(pitchRadians) {
|
||||
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians));
|
||||
}
|
||||
function toEyePosition(feetPosition, eyeHeight) {
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + eyeHeight,
|
||||
z: feetPosition.z
|
||||
};
|
||||
}
|
||||
export class FirstPersonNavigationController {
|
||||
id = "firstPerson";
|
||||
context = null;
|
||||
pressedKeys = new Set();
|
||||
cameraRotation = new Euler(0, 0, 0, "YXZ");
|
||||
forwardVector = new Vector3();
|
||||
rightVector = new Vector3();
|
||||
feetPosition = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
yawRadians = 0;
|
||||
pitchRadians = 0;
|
||||
verticalVelocity = 0;
|
||||
grounded = false;
|
||||
pointerLocked = false;
|
||||
initializedFromSpawn = false;
|
||||
activate(ctx) {
|
||||
this.context = ctx;
|
||||
if (!this.initializedFromSpawn) {
|
||||
const spawn = ctx.getRuntimeScene().spawn;
|
||||
this.feetPosition = {
|
||||
...spawn.position
|
||||
};
|
||||
this.yawRadians = (spawn.yawDegrees * Math.PI) / 180;
|
||||
this.pitchRadians = 0;
|
||||
this.verticalVelocity = 0;
|
||||
this.grounded = false;
|
||||
this.initializedFromSpawn = true;
|
||||
}
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
window.addEventListener("keyup", this.handleKeyUp);
|
||||
window.addEventListener("blur", this.handleBlur);
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||
document.addEventListener("pointerlockerror", this.handlePointerLockError);
|
||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||
this.syncPointerLockState();
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
deactivate(ctx) {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
window.removeEventListener("keyup", this.handleKeyUp);
|
||||
window.removeEventListener("blur", this.handleBlur);
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
|
||||
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||
this.pressedKeys.clear();
|
||||
if (document.pointerLockElement === ctx.domElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
this.pointerLocked = false;
|
||||
ctx.setRuntimeMessage(null);
|
||||
ctx.setFirstPersonTelemetry(null);
|
||||
this.context = null;
|
||||
}
|
||||
update(dt) {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const playerShape = this.context.getRuntimeScene().playerCollider;
|
||||
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0);
|
||||
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0);
|
||||
const inputLength = Math.hypot(inputX, inputZ);
|
||||
let horizontalX = 0;
|
||||
let horizontalZ = 0;
|
||||
if (inputLength > 0) {
|
||||
const normalizedInputX = inputX / inputLength;
|
||||
const normalizedInputZ = inputZ / inputLength;
|
||||
const moveDistance = MOVE_SPEED * dt;
|
||||
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians));
|
||||
this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians));
|
||||
horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance;
|
||||
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance;
|
||||
}
|
||||
if (playerShape.mode === "none") {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
else {
|
||||
this.verticalVelocity -= GRAVITY * dt;
|
||||
}
|
||||
const resolvedMotion = this.context.resolveFirstPersonMotion(this.feetPosition, {
|
||||
x: horizontalX,
|
||||
y: playerShape.mode === "none" ? 0 : this.verticalVelocity * dt,
|
||||
z: horizontalZ
|
||||
}, playerShape);
|
||||
if (resolvedMotion === null) {
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
return;
|
||||
}
|
||||
this.feetPosition = resolvedMotion.feetPosition;
|
||||
this.grounded = resolvedMotion.grounded;
|
||||
if (this.grounded && this.verticalVelocity < 0) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
teleportTo(feetPosition, yawDegrees) {
|
||||
this.feetPosition = {
|
||||
...feetPosition
|
||||
};
|
||||
this.yawRadians = (yawDegrees * Math.PI) / 180;
|
||||
this.pitchRadians = 0;
|
||||
this.verticalVelocity = 0;
|
||||
this.grounded = false;
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
}
|
||||
updateCameraTransform() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
||||
this.cameraRotation.x = this.pitchRadians;
|
||||
// Authoring yaw treats 0 degrees as facing +Z, while a three.js camera
|
||||
// looks down -Z by default. Offset by 180 degrees so runtime view matches
|
||||
// the authored PlayerStart marker and movement basis.
|
||||
this.cameraRotation.y = this.yawRadians + Math.PI;
|
||||
this.cameraRotation.z = 0;
|
||||
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z);
|
||||
this.context.camera.rotation.copy(this.cameraRotation);
|
||||
}
|
||||
publishTelemetry() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
this.context.setFirstPersonTelemetry({
|
||||
feetPosition: {
|
||||
...this.feetPosition
|
||||
},
|
||||
eyePosition: toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)),
|
||||
grounded: this.grounded,
|
||||
pointerLocked: this.pointerLocked,
|
||||
spawn: this.context.getRuntimeScene().spawn
|
||||
});
|
||||
}
|
||||
syncPointerLockState() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const pointerLocked = document.pointerLockElement === this.context.domElement;
|
||||
this.pointerLocked = pointerLocked;
|
||||
this.context.setRuntimeMessage(pointerLocked
|
||||
? "Mouse look active. Press Escape to release the cursor or switch to Orbit Visitor."
|
||||
: "Click inside the runner viewport to capture mouse look. If pointer lock fails, switch to Orbit Visitor.");
|
||||
this.publishTelemetry();
|
||||
}
|
||||
handleKeyDown = (event) => {
|
||||
this.pressedKeys.add(event.code);
|
||||
};
|
||||
handleKeyUp = (event) => {
|
||||
this.pressedKeys.delete(event.code);
|
||||
};
|
||||
handleBlur = () => {
|
||||
this.pressedKeys.clear();
|
||||
};
|
||||
handleMouseMove = (event) => {
|
||||
if (!this.pointerLocked) {
|
||||
return;
|
||||
}
|
||||
this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
|
||||
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY);
|
||||
};
|
||||
handlePointerLockChange = () => {
|
||||
this.syncPointerLockState();
|
||||
};
|
||||
handlePointerLockError = () => {
|
||||
this.context?.setRuntimeMessage("Pointer lock was unavailable in this browser context. Orbit Visitor remains available as the non-FPS fallback.");
|
||||
};
|
||||
handlePointerDown = () => {
|
||||
if (this.context === null || document.pointerLockElement === this.context.domElement) {
|
||||
return;
|
||||
}
|
||||
const pointerLockCapableElement = this.context.domElement;
|
||||
const pointerLockResult = pointerLockCapableElement.requestPointerLock();
|
||||
if (pointerLockResult instanceof Promise) {
|
||||
pointerLockResult.catch(() => {
|
||||
this.context?.setRuntimeMessage("Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation.");
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
1
src/runtime-three/navigation-controller.js
Normal file
1
src/runtime-three/navigation-controller.js
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
117
src/runtime-three/orbit-visitor-navigation-controller.js
Normal file
117
src/runtime-three/orbit-visitor-navigation-controller.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Vector3 } from "three";
|
||||
const MIN_DISTANCE = 2;
|
||||
const MAX_DISTANCE = 48;
|
||||
const MIN_PITCH = 0.15;
|
||||
const MAX_PITCH = Math.PI * 0.48;
|
||||
function clampDistance(distance) {
|
||||
return Math.max(MIN_DISTANCE, Math.min(MAX_DISTANCE, distance));
|
||||
}
|
||||
function clampPitch(pitchRadians) {
|
||||
return Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitchRadians));
|
||||
}
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
export class OrbitVisitorNavigationController {
|
||||
id = "orbitVisitor";
|
||||
context = null;
|
||||
lookAtVector = new Vector3();
|
||||
target = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
};
|
||||
distance = 8;
|
||||
yawRadians = Math.PI * 0.25;
|
||||
pitchRadians = Math.PI * 0.35;
|
||||
dragging = false;
|
||||
lastPointerClientX = 0;
|
||||
lastPointerClientY = 0;
|
||||
initializedFromScene = false;
|
||||
activate(ctx) {
|
||||
this.context = ctx;
|
||||
if (!this.initializedFromScene) {
|
||||
const runtimeScene = ctx.getRuntimeScene();
|
||||
const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target;
|
||||
const focusDistance = runtimeScene.sceneBounds
|
||||
? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1
|
||||
: 8;
|
||||
this.target = cloneVec3(focusPoint);
|
||||
this.distance = clampDistance(focusDistance);
|
||||
this.initializedFromScene = true;
|
||||
}
|
||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||
ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
|
||||
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
||||
window.addEventListener("pointermove", this.handlePointerMove);
|
||||
window.addEventListener("pointerup", this.handlePointerUp);
|
||||
ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom.");
|
||||
ctx.setFirstPersonTelemetry(null);
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
deactivate(ctx) {
|
||||
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||
ctx.domElement.removeEventListener("wheel", this.handleWheel);
|
||||
ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu);
|
||||
window.removeEventListener("pointermove", this.handlePointerMove);
|
||||
window.removeEventListener("pointerup", this.handlePointerUp);
|
||||
ctx.setRuntimeMessage(null);
|
||||
this.dragging = false;
|
||||
this.context = null;
|
||||
}
|
||||
update(_dt) {
|
||||
void _dt;
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
setFocusPoint(target) {
|
||||
this.target = cloneVec3(target);
|
||||
this.updateCameraTransform();
|
||||
}
|
||||
updateCameraTransform() {
|
||||
if (this.context === null) {
|
||||
return;
|
||||
}
|
||||
const horizontalDistance = Math.cos(this.pitchRadians) * this.distance;
|
||||
const cameraPosition = {
|
||||
x: this.target.x + Math.sin(this.yawRadians) * horizontalDistance,
|
||||
y: this.target.y + Math.sin(this.pitchRadians) * this.distance,
|
||||
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
|
||||
};
|
||||
this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
||||
this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
|
||||
this.context.camera.lookAt(this.lookAtVector);
|
||||
}
|
||||
handlePointerDown = (event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
this.dragging = true;
|
||||
this.lastPointerClientX = event.clientX;
|
||||
this.lastPointerClientY = event.clientY;
|
||||
};
|
||||
handlePointerMove = (event) => {
|
||||
if (!this.dragging) {
|
||||
return;
|
||||
}
|
||||
const deltaX = event.clientX - this.lastPointerClientX;
|
||||
const deltaY = event.clientY - this.lastPointerClientY;
|
||||
this.lastPointerClientX = event.clientX;
|
||||
this.lastPointerClientY = event.clientY;
|
||||
this.yawRadians -= deltaX * 0.008;
|
||||
this.pitchRadians = clampPitch(this.pitchRadians + deltaY * 0.008);
|
||||
};
|
||||
handlePointerUp = () => {
|
||||
this.dragging = false;
|
||||
};
|
||||
handleWheel = (event) => {
|
||||
event.preventDefault();
|
||||
this.distance = clampDistance(this.distance + event.deltaY * 0.01);
|
||||
};
|
||||
handleContextMenu = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
19
src/runtime-three/player-collision.js
Normal file
19
src/runtime-three/player-collision.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export const FIRST_PERSON_PLAYER_SHAPE = {
|
||||
mode: "capsule",
|
||||
radius: 0.3,
|
||||
height: 1.8,
|
||||
eyeHeight: 1.6
|
||||
};
|
||||
export function getFirstPersonPlayerEyeHeight(shape) {
|
||||
return shape.eyeHeight;
|
||||
}
|
||||
export function getFirstPersonPlayerHeight(shape) {
|
||||
switch (shape.mode) {
|
||||
case "capsule":
|
||||
return shape.height;
|
||||
case "box":
|
||||
return shape.size.y;
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
272
src/runtime-three/rapier-collision-world.js
Normal file
272
src/runtime-three/rapier-collision-world.js
Normal file
@@ -0,0 +1,272 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
289
src/runtime-three/runtime-audio-system.js
Normal file
289
src/runtime-three/runtime-audio-system.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import { AudioListener, Group, PositionalAudio, Scene, Vector3 } from "three";
|
||||
const _listenerPosition = /*@__PURE__*/ new Vector3();
|
||||
const _emitterPosition = /*@__PURE__*/ new Vector3();
|
||||
function getErrorDetail(error) {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return "Unknown error.";
|
||||
}
|
||||
function formatSoundEmitterLabel(entityId, link) {
|
||||
return link === null ? entityId : `${entityId} (${link.id})`;
|
||||
}
|
||||
export function computeSoundEmitterDistanceGain(distance, refDistance, maxDistance) {
|
||||
if (!Number.isFinite(distance) || !Number.isFinite(refDistance) || !Number.isFinite(maxDistance)) {
|
||||
return 0;
|
||||
}
|
||||
if (distance <= refDistance) {
|
||||
return 1;
|
||||
}
|
||||
if (maxDistance <= refDistance) {
|
||||
return 0;
|
||||
}
|
||||
if (distance >= maxDistance) {
|
||||
return 0;
|
||||
}
|
||||
const normalizedDistance = (distance - refDistance) / (maxDistance - refDistance);
|
||||
const clampedDistance = Math.min(1, Math.max(0, normalizedDistance));
|
||||
const proximity = 1 - clampedDistance;
|
||||
const easedProximity = proximity * proximity * proximity * proximity;
|
||||
return easedProximity;
|
||||
}
|
||||
export class RuntimeAudioSystem {
|
||||
camera;
|
||||
scene;
|
||||
soundGroup = new Group();
|
||||
soundEmitters = new Map();
|
||||
pendingPlayEmitterIds = new Set();
|
||||
listener;
|
||||
runtimeScene = null;
|
||||
projectAssets = {};
|
||||
loadedAudioAssets = {};
|
||||
runtimeMessageHandler;
|
||||
currentRuntimeMessage = null;
|
||||
unlockRequested = false;
|
||||
constructor(scene, camera, runtimeMessageHandler) {
|
||||
this.scene = scene;
|
||||
this.camera = camera;
|
||||
this.runtimeMessageHandler = runtimeMessageHandler;
|
||||
this.scene.add(this.soundGroup);
|
||||
let listener = null;
|
||||
try {
|
||||
listener = new AudioListener();
|
||||
this.camera.add(listener);
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Audio is unavailable in this browser environment: ${getErrorDetail(error)}`);
|
||||
}
|
||||
this.listener = listener;
|
||||
}
|
||||
setRuntimeMessageHandler(handler) {
|
||||
this.runtimeMessageHandler = handler;
|
||||
}
|
||||
loadScene(runtimeScene) {
|
||||
this.runtimeScene = runtimeScene;
|
||||
this.rebuildSoundEmitters();
|
||||
this.queueAutoplayEmitters();
|
||||
}
|
||||
updateAssets(projectAssets, loadedAudioAssets) {
|
||||
this.projectAssets = projectAssets;
|
||||
this.loadedAudioAssets = loadedAudioAssets;
|
||||
this.rebuildSoundEmitters();
|
||||
this.queueAutoplayEmitters();
|
||||
}
|
||||
updateListenerTransform() {
|
||||
this.listener?.updateMatrixWorld(true);
|
||||
this.updateSoundEmitterVolumes();
|
||||
}
|
||||
handleUserGesture() {
|
||||
if (this.listener === null) {
|
||||
return;
|
||||
}
|
||||
const context = this.listener.context;
|
||||
if (context.state === "running") {
|
||||
if (this.unlockRequested) {
|
||||
this.unlockRequested = false;
|
||||
this.setRuntimeMessage(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.unlockRequested = true;
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
this.unlockRequested = false;
|
||||
this.flushPendingPlays();
|
||||
this.setRuntimeMessage(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setRuntimeMessage(`Audio unlock failed: ${getErrorDetail(error)}`);
|
||||
});
|
||||
}
|
||||
playSound(soundEmitterId, link) {
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
if (soundEmitter === undefined) {
|
||||
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} could not be found.`);
|
||||
return;
|
||||
}
|
||||
if (this.listener === null) {
|
||||
this.setRuntimeMessage("Audio is unavailable in this browser environment.");
|
||||
return;
|
||||
}
|
||||
if (soundEmitter.buffer === null) {
|
||||
const assetLabel = this.describeAudioAssetAvailability(soundEmitter.entity.audioAssetId);
|
||||
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} cannot play because ${assetLabel}.`);
|
||||
console.warn(`playSound: ${soundEmitterId} has no playable audio buffer.`);
|
||||
return;
|
||||
}
|
||||
if (this.listener.context.state !== "running") {
|
||||
this.pendingPlayEmitterIds.add(soundEmitterId);
|
||||
this.setRuntimeMessage("Audio is locked. Click the runner to enable sound.");
|
||||
return;
|
||||
}
|
||||
this.playBufferedSound(soundEmitterId);
|
||||
}
|
||||
stopSound(soundEmitterId) {
|
||||
this.pendingPlayEmitterIds.delete(soundEmitterId);
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
if (soundEmitter === undefined || soundEmitter.audio === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
soundEmitter.audio.stop();
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`stopSound: ${soundEmitterId} could not be stopped: ${getErrorDetail(error)}`);
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
for (const soundEmitterId of this.soundEmitters.keys()) {
|
||||
this.stopSound(soundEmitterId);
|
||||
}
|
||||
this.pendingPlayEmitterIds.clear();
|
||||
for (const soundEmitter of this.soundEmitters.values()) {
|
||||
this.soundGroup.remove(soundEmitter.group);
|
||||
if (soundEmitter.audio !== null) {
|
||||
soundEmitter.group.remove(soundEmitter.audio);
|
||||
}
|
||||
}
|
||||
this.soundEmitters.clear();
|
||||
this.scene.remove(this.soundGroup);
|
||||
if (this.listener !== null) {
|
||||
this.camera.remove(this.listener);
|
||||
}
|
||||
}
|
||||
setRuntimeMessage(message) {
|
||||
if (this.currentRuntimeMessage === message) {
|
||||
return;
|
||||
}
|
||||
this.currentRuntimeMessage = message;
|
||||
this.runtimeMessageHandler?.(message);
|
||||
}
|
||||
rebuildSoundEmitters() {
|
||||
if (this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
for (const soundEmitter of this.soundEmitters.values()) {
|
||||
this.stopSound(soundEmitter.entity.entityId);
|
||||
this.soundGroup.remove(soundEmitter.group);
|
||||
if (soundEmitter.audio !== null) {
|
||||
soundEmitter.group.remove(soundEmitter.audio);
|
||||
}
|
||||
}
|
||||
this.soundEmitters.clear();
|
||||
for (const entity of this.runtimeScene.entities.soundEmitters) {
|
||||
const group = new Group();
|
||||
group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
||||
let audio = null;
|
||||
if (this.listener !== null) {
|
||||
audio = new PositionalAudio(this.listener);
|
||||
this.configurePositionalAudio(audio, entity);
|
||||
audio.position.set(0, 0, 0);
|
||||
group.add(audio);
|
||||
}
|
||||
const buffer = this.resolveAudioBuffer(entity.audioAssetId);
|
||||
if (audio !== null && buffer !== null) {
|
||||
audio.setBuffer(buffer);
|
||||
}
|
||||
this.soundGroup.add(group);
|
||||
this.soundEmitters.set(entity.entityId, {
|
||||
entity,
|
||||
group,
|
||||
audio,
|
||||
buffer
|
||||
});
|
||||
}
|
||||
}
|
||||
resolveAudioBuffer(audioAssetId) {
|
||||
if (audioAssetId === null) {
|
||||
return null;
|
||||
}
|
||||
const loadedAsset = this.loadedAudioAssets[audioAssetId];
|
||||
if (loadedAsset !== undefined) {
|
||||
return loadedAsset.buffer;
|
||||
}
|
||||
const asset = this.projectAssets[audioAssetId];
|
||||
if (asset === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (asset.kind !== "audio") {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
describeAudioAssetAvailability(audioAssetId) {
|
||||
if (audioAssetId === null) {
|
||||
return "no assigned audio asset";
|
||||
}
|
||||
const asset = this.projectAssets[audioAssetId];
|
||||
if (asset === undefined) {
|
||||
return `missing audio asset ${audioAssetId}`;
|
||||
}
|
||||
if (asset.kind !== "audio") {
|
||||
return `asset ${audioAssetId} is not an audio asset`;
|
||||
}
|
||||
return `audio asset ${audioAssetId} is unavailable`;
|
||||
}
|
||||
queueAutoplayEmitters() {
|
||||
if (this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
for (const entity of this.runtimeScene.entities.soundEmitters) {
|
||||
if (entity.autoplay) {
|
||||
this.pendingPlayEmitterIds.add(entity.entityId);
|
||||
}
|
||||
}
|
||||
this.flushPendingPlays();
|
||||
}
|
||||
flushPendingPlays() {
|
||||
if (this.listener === null || this.listener.context.state !== "running") {
|
||||
return;
|
||||
}
|
||||
const pendingEmitterIds = [...this.pendingPlayEmitterIds];
|
||||
this.pendingPlayEmitterIds.clear();
|
||||
for (const soundEmitterId of pendingEmitterIds) {
|
||||
this.playBufferedSound(soundEmitterId);
|
||||
}
|
||||
}
|
||||
playBufferedSound(soundEmitterId) {
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
if (soundEmitter === undefined || soundEmitter.audio === null || soundEmitter.buffer === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
soundEmitter.audio.stop();
|
||||
}
|
||||
catch {
|
||||
// three.js audio.stop() can throw when the underlying source is not active yet.
|
||||
}
|
||||
this.configurePositionalAudio(soundEmitter.audio, soundEmitter.entity);
|
||||
this.updateSoundEmitterVolume(soundEmitter);
|
||||
soundEmitter.audio.setBuffer(soundEmitter.buffer);
|
||||
soundEmitter.audio.play();
|
||||
}
|
||||
configurePositionalAudio(audio, entity) {
|
||||
audio.setLoop(entity.loop);
|
||||
audio.setRefDistance(entity.refDistance);
|
||||
audio.setMaxDistance(entity.maxDistance);
|
||||
audio.setDistanceModel("inverse");
|
||||
audio.setRolloffFactor(0);
|
||||
}
|
||||
updateSoundEmitterVolumes() {
|
||||
if (this.listener === null) {
|
||||
return;
|
||||
}
|
||||
for (const soundEmitter of this.soundEmitters.values()) {
|
||||
this.updateSoundEmitterVolume(soundEmitter);
|
||||
}
|
||||
}
|
||||
updateSoundEmitterVolume(soundEmitter) {
|
||||
if (soundEmitter.audio === null) {
|
||||
return;
|
||||
}
|
||||
this.camera.getWorldPosition(_listenerPosition);
|
||||
soundEmitter.group.getWorldPosition(_emitterPosition);
|
||||
const distance = _listenerPosition.distanceTo(_emitterPosition);
|
||||
const attenuation = computeSoundEmitterDistanceGain(distance, soundEmitter.entity.refDistance, soundEmitter.entity.maxDistance);
|
||||
soundEmitter.audio.setVolume(soundEmitter.entity.volume * attenuation);
|
||||
}
|
||||
}
|
||||
564
src/runtime-three/runtime-host.js
Normal file
564
src/runtime-three/runtime-host.js
Normal file
@@ -0,0 +1,564 @@
|
||||
import { AmbientLight, AnimationClip, AnimationMixer, BoxGeometry, DirectionalLight, Group, LoopOnce, LoopRepeat, Mesh, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, Vector3, SpotLight, WebGLRenderer } from "three";
|
||||
import { EffectComposer } from "postprocessing";
|
||||
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
|
||||
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
|
||||
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
||||
import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer } from "../rendering/advanced-rendering";
|
||||
import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings } from "../document/world-settings";
|
||||
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
||||
import { RapierCollisionWorld } from "./rapier-collision-world";
|
||||
import { RuntimeInteractionSystem } from "./runtime-interaction-system";
|
||||
import { RuntimeAudioSystem } from "./runtime-audio-system";
|
||||
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
||||
const FALLBACK_FACE_COLOR = 0x747d89;
|
||||
export class RuntimeHost {
|
||||
scene = new Scene();
|
||||
camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
||||
cameraForward = new Vector3();
|
||||
domElement;
|
||||
ambientLight = new AmbientLight();
|
||||
sunLight = new DirectionalLight();
|
||||
localLightGroup = new Group();
|
||||
brushGroup = new Group();
|
||||
modelGroup = new Group();
|
||||
firstPersonController = new FirstPersonNavigationController();
|
||||
orbitVisitorController = new OrbitVisitorNavigationController();
|
||||
interactionSystem = new RuntimeInteractionSystem();
|
||||
audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null);
|
||||
brushMeshes = new Map();
|
||||
localLightObjects = new Map();
|
||||
modelRenderObjects = new Map();
|
||||
materialTextureCache = new Map();
|
||||
animationMixers = new Map();
|
||||
instanceAnimationClips = new Map();
|
||||
controllerContext;
|
||||
renderer;
|
||||
runtimeScene = null;
|
||||
collisionWorld = null;
|
||||
collisionWorldRequestId = 0;
|
||||
currentWorld = null;
|
||||
currentAdvancedRenderingSettings = null;
|
||||
advancedRenderingComposer = null;
|
||||
projectAssets = {};
|
||||
loadedModelAssets = {};
|
||||
loadedImageAssets = {};
|
||||
resizeObserver = null;
|
||||
animationFrame = 0;
|
||||
previousFrameTime = 0;
|
||||
container = null;
|
||||
activeController = null;
|
||||
runtimeMessageHandler = null;
|
||||
firstPersonTelemetryHandler = null;
|
||||
interactionPromptHandler = null;
|
||||
currentRuntimeMessage = null;
|
||||
currentFirstPersonTelemetry = null;
|
||||
currentInteractionPrompt = null;
|
||||
constructor(options = {}) {
|
||||
const enableRendering = options.enableRendering ?? true;
|
||||
this.scene.add(this.ambientLight);
|
||||
this.scene.add(this.sunLight);
|
||||
this.scene.add(this.localLightGroup);
|
||||
this.scene.add(this.brushGroup);
|
||||
this.scene.add(this.modelGroup);
|
||||
this.renderer = enableRendering ? new WebGLRenderer({ antialias: true, alpha: true }) : null;
|
||||
this.domElement = this.renderer?.domElement ?? document.createElement("canvas");
|
||||
if (this.renderer !== null) {
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.setClearAlpha(0);
|
||||
}
|
||||
else {
|
||||
this.domElement.className = "runner-canvas__surface";
|
||||
}
|
||||
this.controllerContext = {
|
||||
camera: this.camera,
|
||||
domElement: this.domElement,
|
||||
getRuntimeScene: () => {
|
||||
if (this.runtimeScene === null) {
|
||||
throw new Error("Runtime scene has not been loaded.");
|
||||
}
|
||||
return this.runtimeScene;
|
||||
},
|
||||
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
|
||||
setRuntimeMessage: (message) => {
|
||||
if (message === this.currentRuntimeMessage) {
|
||||
return;
|
||||
}
|
||||
this.currentRuntimeMessage = message;
|
||||
this.runtimeMessageHandler?.(message);
|
||||
},
|
||||
setFirstPersonTelemetry: (telemetry) => {
|
||||
this.currentFirstPersonTelemetry = telemetry;
|
||||
this.firstPersonTelemetryHandler?.(telemetry);
|
||||
}
|
||||
};
|
||||
}
|
||||
mount(container) {
|
||||
this.container = container;
|
||||
container.appendChild(this.domElement);
|
||||
this.domElement.addEventListener("click", this.handleRuntimeClick);
|
||||
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown);
|
||||
this.resize();
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.resize();
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
this.previousFrameTime = performance.now();
|
||||
this.render();
|
||||
}
|
||||
loadScene(runtimeScene) {
|
||||
this.runtimeScene = runtimeScene;
|
||||
this.currentWorld = runtimeScene.world;
|
||||
this.interactionSystem.reset();
|
||||
this.setInteractionPrompt(null);
|
||||
this.applyWorld();
|
||||
this.rebuildLocalLights(runtimeScene.localLights);
|
||||
this.rebuildBrushMeshes(runtimeScene.brushes);
|
||||
this.rebuildModelInstances(runtimeScene.modelInstances);
|
||||
void this.rebuildCollisionWorld(runtimeScene.colliders, runtimeScene.playerCollider);
|
||||
this.audioSystem.loadScene(runtimeScene);
|
||||
}
|
||||
updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets) {
|
||||
this.projectAssets = projectAssets;
|
||||
this.loadedModelAssets = loadedModelAssets;
|
||||
this.loadedImageAssets = loadedImageAssets;
|
||||
if (this.currentWorld !== null) {
|
||||
this.applyWorld();
|
||||
}
|
||||
if (this.runtimeScene !== null) {
|
||||
this.rebuildModelInstances(this.runtimeScene.modelInstances);
|
||||
}
|
||||
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
|
||||
}
|
||||
setNavigationMode(mode) {
|
||||
if (this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
const nextController = mode === "firstPerson" ? this.firstPersonController : this.orbitVisitorController;
|
||||
if (this.activeController?.id === nextController.id) {
|
||||
return;
|
||||
}
|
||||
if (this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null && nextController === this.orbitVisitorController) {
|
||||
this.orbitVisitorController.setFocusPoint(this.currentFirstPersonTelemetry.feetPosition);
|
||||
}
|
||||
this.activeController?.deactivate(this.controllerContext);
|
||||
this.interactionSystem.reset();
|
||||
this.setInteractionPrompt(null);
|
||||
this.activeController = nextController;
|
||||
this.activeController.activate(this.controllerContext);
|
||||
}
|
||||
setRuntimeMessageHandler(handler) {
|
||||
this.runtimeMessageHandler = handler;
|
||||
this.audioSystem.setRuntimeMessageHandler(handler);
|
||||
}
|
||||
setFirstPersonTelemetryHandler(handler) {
|
||||
this.firstPersonTelemetryHandler = handler;
|
||||
}
|
||||
setInteractionPromptHandler(handler) {
|
||||
this.interactionPromptHandler = handler;
|
||||
}
|
||||
dispose() {
|
||||
if (this.animationFrame !== 0) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
this.animationFrame = 0;
|
||||
}
|
||||
this.activeController?.deactivate(this.controllerContext);
|
||||
this.activeController = null;
|
||||
this.setInteractionPrompt(null);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.clearLocalLights();
|
||||
this.clearBrushMeshes();
|
||||
this.clearModelInstances();
|
||||
this.collisionWorldRequestId += 1;
|
||||
this.clearCollisionWorld();
|
||||
this.audioSystem.dispose();
|
||||
this.advancedRenderingComposer?.dispose();
|
||||
this.advancedRenderingComposer = null;
|
||||
this.currentAdvancedRenderingSettings = null;
|
||||
if (this.renderer !== null) {
|
||||
this.renderer.autoClear = true;
|
||||
}
|
||||
for (const cachedTexture of this.materialTextureCache.values()) {
|
||||
cachedTexture.texture.dispose();
|
||||
}
|
||||
this.materialTextureCache.clear();
|
||||
this.renderer?.dispose();
|
||||
this.domElement.removeEventListener("click", this.handleRuntimeClick);
|
||||
this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown);
|
||||
if (this.container !== null && this.container.contains(this.domElement)) {
|
||||
this.container.removeChild(this.domElement);
|
||||
}
|
||||
this.container = null;
|
||||
}
|
||||
applyWorld() {
|
||||
if (this.currentWorld === null) {
|
||||
return;
|
||||
}
|
||||
const world = this.currentWorld;
|
||||
this.ambientLight.color.set(world.ambientLight.colorHex);
|
||||
this.ambientLight.intensity = world.ambientLight.intensity;
|
||||
this.sunLight.color.set(world.sunLight.colorHex);
|
||||
this.sunLight.intensity = world.sunLight.intensity;
|
||||
this.sunLight.position
|
||||
.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z)
|
||||
.normalize()
|
||||
.multiplyScalar(18);
|
||||
if (world.background.mode === "image") {
|
||||
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
|
||||
this.scene.background = texture;
|
||||
this.scene.environment = texture;
|
||||
this.scene.environmentIntensity = world.background.environmentIntensity;
|
||||
}
|
||||
else {
|
||||
this.scene.background = null;
|
||||
this.scene.environment = null;
|
||||
this.scene.environmentIntensity = 1;
|
||||
}
|
||||
if (this.renderer !== null) {
|
||||
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering);
|
||||
this.syncAdvancedRenderingComposer(world.advancedRendering);
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
async rebuildCollisionWorld(colliders, playerShape) {
|
||||
const requestId = ++this.collisionWorldRequestId;
|
||||
this.clearCollisionWorld();
|
||||
try {
|
||||
const nextCollisionWorld = await RapierCollisionWorld.create(colliders, playerShape);
|
||||
if (requestId !== this.collisionWorldRequestId) {
|
||||
nextCollisionWorld.dispose();
|
||||
return;
|
||||
}
|
||||
this.collisionWorld = nextCollisionWorld;
|
||||
}
|
||||
catch (error) {
|
||||
if (requestId !== this.collisionWorldRequestId) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Runner collision initialization failed.";
|
||||
this.currentRuntimeMessage = `Runner collision initialization failed: ${message}`;
|
||||
this.runtimeMessageHandler?.(this.currentRuntimeMessage);
|
||||
}
|
||||
}
|
||||
clearCollisionWorld() {
|
||||
this.collisionWorld?.dispose();
|
||||
this.collisionWorld = null;
|
||||
}
|
||||
syncAdvancedRenderingComposer(settings) {
|
||||
if (this.renderer === null) {
|
||||
return;
|
||||
}
|
||||
const shouldUseComposer = settings.enabled;
|
||||
const settingsChanged = this.currentAdvancedRenderingSettings === null ||
|
||||
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
|
||||
if (!shouldUseComposer) {
|
||||
if (this.advancedRenderingComposer !== null) {
|
||||
this.advancedRenderingComposer.dispose();
|
||||
this.advancedRenderingComposer = null;
|
||||
}
|
||||
this.currentAdvancedRenderingSettings = null;
|
||||
this.renderer.autoClear = true;
|
||||
return;
|
||||
}
|
||||
if (this.advancedRenderingComposer !== null && !settingsChanged) {
|
||||
return;
|
||||
}
|
||||
if (this.advancedRenderingComposer !== null) {
|
||||
this.advancedRenderingComposer.dispose();
|
||||
}
|
||||
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings);
|
||||
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
|
||||
this.renderer.autoClear = false;
|
||||
}
|
||||
applyShadowState() {
|
||||
if (this.currentWorld === null) {
|
||||
return;
|
||||
}
|
||||
const advancedRendering = this.currentWorld.advancedRendering;
|
||||
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled;
|
||||
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
|
||||
for (const renderGroup of this.localLightObjects.values()) {
|
||||
applyAdvancedRenderingLightShadowFlags(renderGroup, advancedRendering);
|
||||
}
|
||||
for (const mesh of this.brushMeshes.values()) {
|
||||
applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled);
|
||||
}
|
||||
for (const renderGroup of this.modelRenderObjects.values()) {
|
||||
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
|
||||
}
|
||||
}
|
||||
rebuildLocalLights(localLights) {
|
||||
this.clearLocalLights();
|
||||
for (const pointLight of localLights.pointLights) {
|
||||
const renderObjects = this.createPointLightRuntimeObjects(pointLight);
|
||||
this.localLightGroup.add(renderObjects.group);
|
||||
this.localLightObjects.set(pointLight.entityId, renderObjects.group);
|
||||
}
|
||||
for (const spotLight of localLights.spotLights) {
|
||||
const renderObjects = this.createSpotLightRuntimeObjects(spotLight);
|
||||
this.localLightGroup.add(renderObjects.group);
|
||||
this.localLightObjects.set(spotLight.entityId, renderObjects.group);
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
createPointLightRuntimeObjects(pointLight) {
|
||||
const group = new Group();
|
||||
const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance);
|
||||
group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z);
|
||||
light.position.set(0, 0, 0);
|
||||
group.add(light);
|
||||
return {
|
||||
group
|
||||
};
|
||||
}
|
||||
createSpotLightRuntimeObjects(spotLight) {
|
||||
const group = new Group();
|
||||
const light = new SpotLight(spotLight.colorHex, spotLight.intensity, spotLight.distance, (spotLight.angleDegrees * Math.PI) / 180, 0.18, 1);
|
||||
const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize();
|
||||
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction);
|
||||
group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z);
|
||||
group.quaternion.copy(orientation);
|
||||
light.position.set(0, 0, 0);
|
||||
light.target.position.set(0, 1, 0);
|
||||
group.add(light);
|
||||
group.add(light.target);
|
||||
return {
|
||||
group
|
||||
};
|
||||
}
|
||||
rebuildBrushMeshes(brushes) {
|
||||
this.clearBrushMeshes();
|
||||
for (const brush of brushes) {
|
||||
const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z);
|
||||
applyBoxBrushFaceUvsToGeometry(geometry, brush);
|
||||
const materials = [
|
||||
this.createFaceMaterial(brush.faces.posX.material),
|
||||
this.createFaceMaterial(brush.faces.negX.material),
|
||||
this.createFaceMaterial(brush.faces.posY.material),
|
||||
this.createFaceMaterial(brush.faces.negY.material),
|
||||
this.createFaceMaterial(brush.faces.posZ.material),
|
||||
this.createFaceMaterial(brush.faces.negZ.material)
|
||||
];
|
||||
const mesh = new Mesh(geometry, materials);
|
||||
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
||||
mesh.rotation.set((brush.rotationDegrees.x * Math.PI) / 180, (brush.rotationDegrees.y * Math.PI) / 180, (brush.rotationDegrees.z * Math.PI) / 180);
|
||||
this.brushGroup.add(mesh);
|
||||
this.brushMeshes.set(brush.id, mesh);
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
rebuildModelInstances(modelInstances) {
|
||||
this.clearModelInstances();
|
||||
for (const modelInstance of modelInstances) {
|
||||
const asset = this.projectAssets[modelInstance.assetId];
|
||||
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
|
||||
const renderGroup = createModelInstanceRenderGroup({
|
||||
id: modelInstance.instanceId,
|
||||
kind: "modelInstance",
|
||||
assetId: modelInstance.assetId,
|
||||
name: modelInstance.name,
|
||||
position: modelInstance.position,
|
||||
rotationDegrees: modelInstance.rotationDegrees,
|
||||
scale: modelInstance.scale,
|
||||
collision: {
|
||||
mode: "none",
|
||||
visible: false
|
||||
}
|
||||
}, asset, loadedAsset, false);
|
||||
this.modelGroup.add(renderGroup);
|
||||
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
||||
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
||||
const mixer = new AnimationMixer(renderGroup);
|
||||
this.animationMixers.set(modelInstance.instanceId, mixer);
|
||||
this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations);
|
||||
if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) {
|
||||
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName);
|
||||
if (clip) {
|
||||
mixer.clipAction(clip).play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
createFaceMaterial(material) {
|
||||
if (material === null) {
|
||||
return new MeshStandardMaterial({
|
||||
color: FALLBACK_FACE_COLOR,
|
||||
roughness: 0.9,
|
||||
metalness: 0.05
|
||||
});
|
||||
}
|
||||
return new MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
map: this.getOrCreateTexture(material),
|
||||
roughness: 0.92,
|
||||
metalness: 0.02
|
||||
});
|
||||
}
|
||||
getOrCreateTexture(material) {
|
||||
const signature = createStarterMaterialSignature(material);
|
||||
const cachedTexture = this.materialTextureCache.get(material.id);
|
||||
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
|
||||
return cachedTexture.texture;
|
||||
}
|
||||
cachedTexture?.texture.dispose();
|
||||
const texture = createStarterMaterialTexture(material);
|
||||
this.materialTextureCache.set(material.id, {
|
||||
signature,
|
||||
texture
|
||||
});
|
||||
return texture;
|
||||
}
|
||||
clearLocalLights() {
|
||||
for (const renderGroup of this.localLightObjects.values()) {
|
||||
this.localLightGroup.remove(renderGroup);
|
||||
}
|
||||
this.localLightObjects.clear();
|
||||
}
|
||||
clearBrushMeshes() {
|
||||
for (const mesh of this.brushMeshes.values()) {
|
||||
this.brushGroup.remove(mesh);
|
||||
mesh.geometry.dispose();
|
||||
for (const material of mesh.material) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
this.brushMeshes.clear();
|
||||
}
|
||||
clearModelInstances() {
|
||||
for (const mixer of this.animationMixers.values()) {
|
||||
mixer.stopAllAction();
|
||||
}
|
||||
this.animationMixers.clear();
|
||||
this.instanceAnimationClips.clear();
|
||||
for (const renderGroup of this.modelRenderObjects.values()) {
|
||||
this.modelGroup.remove(renderGroup);
|
||||
disposeModelInstance(renderGroup);
|
||||
}
|
||||
this.modelRenderObjects.clear();
|
||||
}
|
||||
resize() {
|
||||
if (this.container === null) {
|
||||
return;
|
||||
}
|
||||
const width = this.container.clientWidth;
|
||||
const height = this.container.clientHeight;
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.domElement.width = width;
|
||||
this.domElement.height = height;
|
||||
this.renderer?.setSize(width, height, false);
|
||||
this.advancedRenderingComposer?.setSize(width, height);
|
||||
}
|
||||
render = () => {
|
||||
this.animationFrame = window.requestAnimationFrame(this.render);
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
|
||||
this.previousFrameTime = now;
|
||||
this.activeController?.update(dt);
|
||||
this.audioSystem.updateListenerTransform();
|
||||
for (const mixer of this.animationMixers.values()) {
|
||||
mixer.update(dt);
|
||||
}
|
||||
if (this.runtimeScene !== null && this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null) {
|
||||
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher());
|
||||
this.camera.getWorldDirection(this.cameraForward);
|
||||
this.setInteractionPrompt(this.interactionSystem.resolveClickInteractionPrompt(this.currentFirstPersonTelemetry.eyePosition, {
|
||||
x: this.cameraForward.x,
|
||||
y: this.cameraForward.y,
|
||||
z: this.cameraForward.z
|
||||
}, this.runtimeScene));
|
||||
}
|
||||
else {
|
||||
this.setInteractionPrompt(null);
|
||||
}
|
||||
if (this.advancedRenderingComposer !== null) {
|
||||
this.advancedRenderingComposer.render(dt);
|
||||
return;
|
||||
}
|
||||
this.renderer?.render(this.scene, this.camera);
|
||||
};
|
||||
applyTeleportPlayerAction(target) {
|
||||
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
||||
}
|
||||
applyToggleBrushVisibilityAction(brushId, visible) {
|
||||
const mesh = this.brushMeshes.get(brushId);
|
||||
if (mesh === undefined) {
|
||||
return;
|
||||
}
|
||||
mesh.visible = visible ?? !mesh.visible;
|
||||
}
|
||||
applyPlayAnimationAction(instanceId, clipName, loop) {
|
||||
const mixer = this.animationMixers.get(instanceId);
|
||||
const clips = this.instanceAnimationClips.get(instanceId);
|
||||
if (!mixer || !clips) {
|
||||
console.warn(`playAnimation: no mixer for instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
const clip = AnimationClip.findByName(clips, clipName);
|
||||
if (!clip) {
|
||||
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
// LoopRepeat is the three.js default; LoopOnce plays the clip a single time then stops.
|
||||
const action = mixer.clipAction(clip);
|
||||
action.loop = loop === false ? LoopOnce : LoopRepeat;
|
||||
action.clampWhenFinished = loop === false;
|
||||
mixer.stopAllAction();
|
||||
action.reset().play();
|
||||
}
|
||||
applyStopAnimationAction(instanceId) {
|
||||
const mixer = this.animationMixers.get(instanceId);
|
||||
if (!mixer) {
|
||||
console.warn(`stopAnimation: no mixer for instance ${instanceId}`);
|
||||
return;
|
||||
}
|
||||
mixer.stopAllAction();
|
||||
}
|
||||
createInteractionDispatcher() {
|
||||
return {
|
||||
teleportPlayer: (target) => {
|
||||
this.applyTeleportPlayerAction(target);
|
||||
},
|
||||
toggleBrushVisibility: (brushId, visible) => {
|
||||
this.applyToggleBrushVisibilityAction(brushId, visible);
|
||||
},
|
||||
playAnimation: (instanceId, clipName, loop) => {
|
||||
this.applyPlayAnimationAction(instanceId, clipName, loop);
|
||||
},
|
||||
stopAnimation: (instanceId) => {
|
||||
this.applyStopAnimationAction(instanceId);
|
||||
},
|
||||
playSound: (soundEmitterId, link) => {
|
||||
this.audioSystem.playSound(soundEmitterId, link);
|
||||
},
|
||||
stopSound: (soundEmitterId) => {
|
||||
this.audioSystem.stopSound(soundEmitterId);
|
||||
}
|
||||
};
|
||||
}
|
||||
setInteractionPrompt(prompt) {
|
||||
if (this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId &&
|
||||
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
|
||||
this.currentInteractionPrompt?.distance === prompt?.distance &&
|
||||
this.currentInteractionPrompt?.range === prompt?.range) {
|
||||
return;
|
||||
}
|
||||
this.currentInteractionPrompt = prompt;
|
||||
this.interactionPromptHandler?.(prompt);
|
||||
}
|
||||
handleRuntimeClick = () => {
|
||||
this.audioSystem.handleUserGesture();
|
||||
if (this.runtimeScene === null || this.activeController !== this.firstPersonController || this.currentInteractionPrompt === null) {
|
||||
return;
|
||||
}
|
||||
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
|
||||
};
|
||||
handleRuntimePointerDown = () => {
|
||||
this.audioSystem.handleUserGesture();
|
||||
};
|
||||
}
|
||||
163
src/runtime-three/runtime-interaction-system.js
Normal file
163
src/runtime-three/runtime-interaction-system.js
Normal file
@@ -0,0 +1,163 @@
|
||||
const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75;
|
||||
function subtractVec3(left, right) {
|
||||
return {
|
||||
x: left.x - right.x,
|
||||
y: left.y - right.y,
|
||||
z: left.z - right.z
|
||||
};
|
||||
}
|
||||
function scaleVec3(vector, scalar) {
|
||||
return {
|
||||
x: vector.x * scalar,
|
||||
y: vector.y * scalar,
|
||||
z: vector.z * scalar
|
||||
};
|
||||
}
|
||||
function dotVec3(left, right) {
|
||||
return left.x * right.x + left.y * right.y + left.z * right.z;
|
||||
}
|
||||
function lengthSquaredVec3(vector) {
|
||||
return dotVec3(vector, vector);
|
||||
}
|
||||
function distanceBetweenVec3(left, right) {
|
||||
return Math.sqrt(lengthSquaredVec3(subtractVec3(left, right)));
|
||||
}
|
||||
function normalizeVec3(vector) {
|
||||
const lengthSquared = lengthSquaredVec3(vector);
|
||||
if (lengthSquared <= Number.EPSILON) {
|
||||
return null;
|
||||
}
|
||||
return scaleVec3(vector, 1 / Math.sqrt(lengthSquared));
|
||||
}
|
||||
function isPointInsideTriggerVolume(position, triggerVolume) {
|
||||
const halfSize = {
|
||||
x: triggerVolume.size.x * 0.5,
|
||||
y: triggerVolume.size.y * 0.5,
|
||||
z: triggerVolume.size.z * 0.5
|
||||
};
|
||||
return (position.x >= triggerVolume.position.x - halfSize.x &&
|
||||
position.x <= triggerVolume.position.x + halfSize.x &&
|
||||
position.y >= triggerVolume.position.y - halfSize.y &&
|
||||
position.y <= triggerVolume.position.y + halfSize.y &&
|
||||
position.z >= triggerVolume.position.z - halfSize.z &&
|
||||
position.z <= triggerVolume.position.z + halfSize.z);
|
||||
}
|
||||
function raySphereHitDistance(origin, direction, center, radius) {
|
||||
const offset = subtractVec3(origin, center);
|
||||
const halfB = dotVec3(offset, direction);
|
||||
const c = dotVec3(offset, offset) - radius * radius;
|
||||
const discriminant = halfB * halfB - c;
|
||||
if (discriminant < 0) {
|
||||
return null;
|
||||
}
|
||||
const discriminantRoot = Math.sqrt(discriminant);
|
||||
const nearestHit = -halfB - discriminantRoot;
|
||||
if (nearestHit >= 0) {
|
||||
return nearestHit;
|
||||
}
|
||||
const farHit = -halfB + discriminantRoot;
|
||||
return farHit >= 0 ? 0 : null;
|
||||
}
|
||||
function resolveTeleportTarget(runtimeScene, entityId) {
|
||||
return runtimeScene.entities.teleportTargets.find((teleportTarget) => teleportTarget.entityId === entityId) ?? null;
|
||||
}
|
||||
function hasTriggerLinks(runtimeScene, sourceEntityId, trigger) {
|
||||
return runtimeScene.interactionLinks.some((link) => link.sourceEntityId === sourceEntityId && link.trigger === trigger);
|
||||
}
|
||||
function getInteractableTargetRadius(interactable) {
|
||||
return Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, interactable.radius);
|
||||
}
|
||||
export class RuntimeInteractionSystem {
|
||||
occupiedTriggerVolumes = new Set();
|
||||
reset() {
|
||||
this.occupiedTriggerVolumes.clear();
|
||||
}
|
||||
updatePlayerPosition(feetPosition, runtimeScene, dispatcher) {
|
||||
for (const triggerVolume of runtimeScene.entities.triggerVolumes) {
|
||||
const containsPlayer = isPointInsideTriggerVolume(feetPosition, triggerVolume);
|
||||
const wasOccupied = this.occupiedTriggerVolumes.has(triggerVolume.entityId);
|
||||
if (!wasOccupied && containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "enter")) {
|
||||
this.dispatchLinks(triggerVolume.entityId, "enter", runtimeScene, dispatcher);
|
||||
}
|
||||
else if (wasOccupied && !containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "exit")) {
|
||||
this.dispatchLinks(triggerVolume.entityId, "exit", runtimeScene, dispatcher);
|
||||
}
|
||||
if (containsPlayer) {
|
||||
this.occupiedTriggerVolumes.add(triggerVolume.entityId);
|
||||
}
|
||||
else {
|
||||
this.occupiedTriggerVolumes.delete(triggerVolume.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolveClickInteractionPrompt(viewOrigin, viewDirection, runtimeScene) {
|
||||
const normalizedViewDirection = normalizeVec3(viewDirection);
|
||||
if (normalizedViewDirection === null) {
|
||||
return null;
|
||||
}
|
||||
let bestPrompt = null;
|
||||
let bestHitDistance = Number.POSITIVE_INFINITY;
|
||||
for (const interactable of runtimeScene.entities.interactables) {
|
||||
if (!interactable.enabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click")) {
|
||||
continue;
|
||||
}
|
||||
const distance = distanceBetweenVec3(viewOrigin, interactable.position);
|
||||
if (distance > interactable.radius) {
|
||||
continue;
|
||||
}
|
||||
const hitDistance = raySphereHitDistance(viewOrigin, normalizedViewDirection, interactable.position, getInteractableTargetRadius(interactable));
|
||||
if (hitDistance === null) {
|
||||
continue;
|
||||
}
|
||||
const nextPrompt = {
|
||||
sourceEntityId: interactable.entityId,
|
||||
prompt: interactable.prompt,
|
||||
distance,
|
||||
range: interactable.radius
|
||||
};
|
||||
if (hitDistance < bestHitDistance ||
|
||||
(hitDistance === bestHitDistance &&
|
||||
(bestPrompt === null ||
|
||||
distance < bestPrompt.distance ||
|
||||
(distance === bestPrompt.distance && interactable.entityId.localeCompare(bestPrompt.sourceEntityId) < 0)))) {
|
||||
bestHitDistance = hitDistance;
|
||||
bestPrompt = nextPrompt;
|
||||
}
|
||||
}
|
||||
return bestPrompt;
|
||||
}
|
||||
dispatchClickInteraction(sourceEntityId, runtimeScene, dispatcher) {
|
||||
this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher);
|
||||
}
|
||||
dispatchLinks(sourceEntityId, trigger, runtimeScene, dispatcher) {
|
||||
for (const link of runtimeScene.interactionLinks) {
|
||||
if (link.sourceEntityId !== sourceEntityId || link.trigger !== trigger) {
|
||||
continue;
|
||||
}
|
||||
switch (link.action.type) {
|
||||
case "teleportPlayer": {
|
||||
const teleportTarget = resolveTeleportTarget(runtimeScene, link.action.targetEntityId);
|
||||
if (teleportTarget !== null) {
|
||||
dispatcher.teleportPlayer(teleportTarget, link);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "toggleVisibility":
|
||||
dispatcher.toggleBrushVisibility(link.action.targetBrushId, link.action.visible, link);
|
||||
break;
|
||||
case "playAnimation":
|
||||
dispatcher.playAnimation(link.action.targetModelInstanceId, link.action.clipName, link.action.loop, link);
|
||||
break;
|
||||
case "stopAnimation":
|
||||
dispatcher.stopAnimation(link.action.targetModelInstanceId, link);
|
||||
break;
|
||||
case "playSound":
|
||||
dispatcher.playSound(link.action.targetSoundEmitterId, link);
|
||||
break;
|
||||
case "stopSound":
|
||||
dispatcher.stopSound(link.action.targetSoundEmitterId, link);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
331
src/runtime-three/runtime-scene-build.js
Normal file
331
src/runtime-three/runtime-scene-build.js
Normal file
@@ -0,0 +1,331 @@
|
||||
import { getModelInstances } from "../assets/model-instances";
|
||||
import { cloneWorldSettings } from "../document/world-settings";
|
||||
import { getEntityInstances, getPrimaryPlayerStartEntity } from "../entities/entity-instances";
|
||||
import { getBoxBrushBounds } from "../geometry/box-brush";
|
||||
import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation";
|
||||
import { cloneInteractionLink, getInteractionLinks } from "../interactions/interaction-links";
|
||||
import { cloneMaterialDef } from "../materials/starter-material-library";
|
||||
import { cloneFaceUvState } from "../document/brushes";
|
||||
import { assertRuntimeSceneBuildable } from "./runtime-scene-validation";
|
||||
import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision";
|
||||
function cloneVec3(vector) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
function resolveRuntimeMaterial(document, materialId) {
|
||||
if (materialId === null) {
|
||||
return null;
|
||||
}
|
||||
const material = document.materials[materialId];
|
||||
if (material === undefined) {
|
||||
throw new Error(`Runtime build could not resolve material ${materialId}.`);
|
||||
}
|
||||
return cloneMaterialDef(material);
|
||||
}
|
||||
function buildRuntimeBrush(brush, document) {
|
||||
return {
|
||||
id: brush.id,
|
||||
kind: "box",
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
faces: {
|
||||
posX: {
|
||||
materialId: brush.faces.posX.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.posX.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.posX.uv)
|
||||
},
|
||||
negX: {
|
||||
materialId: brush.faces.negX.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.negX.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.negX.uv)
|
||||
},
|
||||
posY: {
|
||||
materialId: brush.faces.posY.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.posY.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.posY.uv)
|
||||
},
|
||||
negY: {
|
||||
materialId: brush.faces.negY.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.negY.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.negY.uv)
|
||||
},
|
||||
posZ: {
|
||||
materialId: brush.faces.posZ.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.posZ.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.posZ.uv)
|
||||
},
|
||||
negZ: {
|
||||
materialId: brush.faces.negZ.materialId,
|
||||
material: resolveRuntimeMaterial(document, brush.faces.negZ.materialId),
|
||||
uv: cloneFaceUvState(brush.faces.negZ.uv)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildRuntimeCollider(brush) {
|
||||
const bounds = getBoxBrushBounds(brush);
|
||||
return {
|
||||
kind: "box",
|
||||
source: "brush",
|
||||
brushId: brush.id,
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
worldBounds: {
|
||||
min: cloneVec3(bounds.min),
|
||||
max: cloneVec3(bounds.max)
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildRuntimeModelInstance(modelInstance) {
|
||||
return {
|
||||
instanceId: modelInstance.id,
|
||||
assetId: modelInstance.assetId,
|
||||
name: modelInstance.name,
|
||||
position: cloneVec3(modelInstance.position),
|
||||
rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
|
||||
scale: cloneVec3(modelInstance.scale),
|
||||
animationClipName: modelInstance.animationClipName,
|
||||
animationAutoplay: modelInstance.animationAutoplay
|
||||
};
|
||||
}
|
||||
function getColliderBounds(collider) {
|
||||
if (collider.source === "brush") {
|
||||
return {
|
||||
min: cloneVec3(collider.worldBounds.min),
|
||||
max: cloneVec3(collider.worldBounds.max)
|
||||
};
|
||||
}
|
||||
return {
|
||||
min: cloneVec3(collider.worldBounds.min),
|
||||
max: cloneVec3(collider.worldBounds.max)
|
||||
};
|
||||
}
|
||||
function combineColliderBounds(colliders) {
|
||||
if (colliders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const firstBounds = getColliderBounds(colliders[0]);
|
||||
const min = cloneVec3(firstBounds.min);
|
||||
const max = cloneVec3(firstBounds.max);
|
||||
for (const collider of colliders.slice(1)) {
|
||||
const bounds = getColliderBounds(collider);
|
||||
min.x = Math.min(min.x, bounds.min.x);
|
||||
min.y = Math.min(min.y, bounds.min.y);
|
||||
min.z = Math.min(min.z, bounds.min.z);
|
||||
max.x = Math.max(max.x, bounds.max.x);
|
||||
max.y = Math.max(max.y, bounds.max.y);
|
||||
max.z = Math.max(max.z, bounds.max.z);
|
||||
}
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
center: {
|
||||
x: (min.x + max.x) * 0.5,
|
||||
y: (min.y + max.y) * 0.5,
|
||||
z: (min.z + max.z) * 0.5
|
||||
},
|
||||
size: {
|
||||
x: max.x - min.x,
|
||||
y: max.y - min.y,
|
||||
z: max.z - min.z
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildFallbackSpawn(sceneBounds) {
|
||||
if (sceneBounds === null) {
|
||||
return {
|
||||
source: "fallback",
|
||||
entityId: null,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: -4
|
||||
},
|
||||
yawDegrees: 0
|
||||
};
|
||||
}
|
||||
return {
|
||||
source: "fallback",
|
||||
entityId: null,
|
||||
position: {
|
||||
x: sceneBounds.center.x,
|
||||
y: sceneBounds.max.y + 0.1,
|
||||
z: sceneBounds.max.z + 3
|
||||
},
|
||||
yawDegrees: 180
|
||||
};
|
||||
}
|
||||
function buildRuntimeSceneCollections(document) {
|
||||
const runtimeEntities = {
|
||||
playerStarts: [],
|
||||
soundEmitters: [],
|
||||
triggerVolumes: [],
|
||||
teleportTargets: [],
|
||||
interactables: []
|
||||
};
|
||||
const localLights = {
|
||||
pointLights: [],
|
||||
spotLights: []
|
||||
};
|
||||
for (const entity of getEntityInstances(document.entities)) {
|
||||
switch (entity.kind) {
|
||||
case "pointLight":
|
||||
localLights.pointLights.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
colorHex: entity.colorHex,
|
||||
intensity: entity.intensity,
|
||||
distance: entity.distance
|
||||
});
|
||||
break;
|
||||
case "spotLight":
|
||||
localLights.spotLights.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
direction: cloneVec3(entity.direction),
|
||||
colorHex: entity.colorHex,
|
||||
intensity: entity.intensity,
|
||||
distance: entity.distance,
|
||||
angleDegrees: entity.angleDegrees
|
||||
});
|
||||
break;
|
||||
case "playerStart":
|
||||
runtimeEntities.playerStarts.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
yawDegrees: entity.yawDegrees,
|
||||
collider: buildRuntimePlayerShape(entity)
|
||||
});
|
||||
break;
|
||||
case "soundEmitter":
|
||||
runtimeEntities.soundEmitters.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
audioAssetId: entity.audioAssetId,
|
||||
volume: entity.volume,
|
||||
refDistance: entity.refDistance,
|
||||
maxDistance: entity.maxDistance,
|
||||
autoplay: entity.autoplay,
|
||||
loop: entity.loop
|
||||
});
|
||||
break;
|
||||
case "triggerVolume":
|
||||
runtimeEntities.triggerVolumes.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
size: cloneVec3(entity.size),
|
||||
// Derive from links so flags are always correct regardless of stored entity state
|
||||
triggerOnEnter: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "enter"),
|
||||
triggerOnExit: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "exit")
|
||||
});
|
||||
break;
|
||||
case "teleportTarget":
|
||||
runtimeEntities.teleportTargets.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
yawDegrees: entity.yawDegrees
|
||||
});
|
||||
break;
|
||||
case "interactable":
|
||||
runtimeEntities.interactables.push({
|
||||
entityId: entity.id,
|
||||
position: cloneVec3(entity.position),
|
||||
radius: entity.radius,
|
||||
prompt: entity.prompt,
|
||||
enabled: entity.enabled
|
||||
});
|
||||
break;
|
||||
default:
|
||||
assertNever(entity);
|
||||
}
|
||||
}
|
||||
return {
|
||||
entities: runtimeEntities,
|
||||
localLights
|
||||
};
|
||||
}
|
||||
function assertNever(value) {
|
||||
throw new Error(`Unsupported runtime entity: ${String(value.kind)}`);
|
||||
}
|
||||
function buildRuntimePlayerShape(playerStartEntity) {
|
||||
if (playerStartEntity === null) {
|
||||
return FIRST_PERSON_PLAYER_SHAPE;
|
||||
}
|
||||
switch (playerStartEntity.collider.mode) {
|
||||
case "capsule":
|
||||
return {
|
||||
mode: "capsule",
|
||||
radius: playerStartEntity.collider.capsuleRadius,
|
||||
height: playerStartEntity.collider.capsuleHeight,
|
||||
eyeHeight: playerStartEntity.collider.eyeHeight
|
||||
};
|
||||
case "box":
|
||||
return {
|
||||
mode: "box",
|
||||
size: cloneVec3(playerStartEntity.collider.boxSize),
|
||||
eyeHeight: playerStartEntity.collider.eyeHeight
|
||||
};
|
||||
case "none":
|
||||
return {
|
||||
mode: "none",
|
||||
eyeHeight: playerStartEntity.collider.eyeHeight
|
||||
};
|
||||
}
|
||||
}
|
||||
export function buildRuntimeSceneFromDocument(document, options = {}) {
|
||||
assertRuntimeSceneBuildable(document, {
|
||||
navigationMode: options.navigationMode ?? "orbitVisitor",
|
||||
loadedModelAssets: options.loadedModelAssets
|
||||
});
|
||||
const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document));
|
||||
const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush));
|
||||
const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance);
|
||||
const collections = buildRuntimeSceneCollections(document);
|
||||
const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link));
|
||||
const playerStartEntity = getPrimaryPlayerStartEntity(document.entities);
|
||||
const playerCollider = buildRuntimePlayerShape(playerStartEntity);
|
||||
for (const modelInstance of getModelInstances(document.modelInstances)) {
|
||||
const asset = document.assets[modelInstance.assetId];
|
||||
if (asset === undefined || asset.kind !== "model") {
|
||||
continue;
|
||||
}
|
||||
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
|
||||
if (generatedCollider !== null) {
|
||||
colliders.push(generatedCollider);
|
||||
}
|
||||
}
|
||||
const combinedSceneBounds = combineColliderBounds(colliders);
|
||||
const playerStart = playerStartEntity === null
|
||||
? null
|
||||
: {
|
||||
entityId: playerStartEntity.id,
|
||||
position: cloneVec3(playerStartEntity.position),
|
||||
yawDegrees: playerStartEntity.yawDegrees,
|
||||
collider: playerCollider
|
||||
};
|
||||
return {
|
||||
world: cloneWorldSettings(document.world),
|
||||
localLights: collections.localLights,
|
||||
brushes,
|
||||
colliders,
|
||||
sceneBounds: combinedSceneBounds,
|
||||
modelInstances,
|
||||
entities: collections.entities,
|
||||
interactionLinks,
|
||||
playerStart,
|
||||
playerCollider,
|
||||
spawn: playerStart === null
|
||||
? buildFallbackSpawn(combinedSceneBounds)
|
||||
: {
|
||||
source: "playerStart",
|
||||
entityId: playerStart.entityId,
|
||||
position: cloneVec3(playerStart.position),
|
||||
yawDegrees: playerStart.yawDegrees
|
||||
}
|
||||
};
|
||||
}
|
||||
42
src/runtime-three/runtime-scene-validation.js
Normal file
42
src/runtime-three/runtime-scene-validation.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getModelInstances } from "../assets/model-instances";
|
||||
import { assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary } from "../document/scene-document-validation";
|
||||
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
|
||||
import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation";
|
||||
export function validateRuntimeSceneBuild(document, options) {
|
||||
const diagnostics = [];
|
||||
if (options.navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) {
|
||||
diagnostics.push(createDiagnostic("error", "missing-player-start", "First-person run requires an authored Player Start. Place one or switch to Orbit Visitor.", "entities", "build"));
|
||||
}
|
||||
for (const modelInstance of getModelInstances(document.modelInstances)) {
|
||||
const path = `modelInstances.${modelInstance.id}.collision.mode`;
|
||||
const asset = document.assets[modelInstance.assetId];
|
||||
if (modelInstance.collision.mode === "none" || asset === undefined || asset.kind !== "model") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
|
||||
if (generatedCollider?.mode === "dynamic") {
|
||||
diagnostics.push(createDiagnostic("warning", "dynamic-model-collider-fixed-query-only", "Dynamic model collision currently generates convex compound pieces for Rapier queries, but the runner still uses them as fixed world collision rather than fully simulated rigid bodies.", path, "build"));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Imported model collision generation failed.";
|
||||
const code = error instanceof ModelColliderGenerationError
|
||||
? error.code
|
||||
: "invalid-model-instance-collision-mode";
|
||||
diagnostics.push(createDiagnostic("error", code, message, path, "build"));
|
||||
}
|
||||
}
|
||||
return {
|
||||
diagnostics,
|
||||
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"),
|
||||
warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning")
|
||||
};
|
||||
}
|
||||
export function assertRuntimeSceneBuildable(document, options) {
|
||||
assertSceneDocumentIsValid(document);
|
||||
const validation = validateRuntimeSceneBuild(document, options);
|
||||
if (validation.errors.length > 0) {
|
||||
throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user