auto-git:
[unlink] playwright.config.js [unlink] src/app/App.js [unlink] src/app/editor-store.js [unlink] src/app/use-editor-store.js [unlink] src/assets/audio-assets.js [unlink] src/assets/gltf-model-import.js [unlink] src/assets/image-assets.js [unlink] src/assets/model-instance-labels.js [unlink] src/assets/model-instance-rendering.js [unlink] src/assets/model-instances.js [unlink] src/assets/project-asset-storage.js [unlink] src/assets/project-assets.js [unlink] src/commands/brush-command-helpers.js [unlink] src/commands/command-history.js [unlink] src/commands/command.js [unlink] src/commands/commit-transform-session-command.js [unlink] src/commands/create-box-brush-command.js [unlink] src/commands/delete-box-brush-command.js [unlink] src/commands/delete-entity-command.js [unlink] src/commands/delete-interaction-link-command.js [unlink] src/commands/delete-model-instance-command.js [unlink] src/commands/duplicate-selection-command.js [unlink] src/commands/import-audio-asset-command.js [unlink] src/commands/import-background-image-asset-command.js [unlink] src/commands/import-model-asset-command.js [unlink] src/commands/move-box-brush-command.js [unlink] src/commands/resize-box-brush-command.js [unlink] src/commands/rotate-box-brush-command.js [unlink] src/commands/set-box-brush-face-material-command.js [unlink] src/commands/set-box-brush-face-uv-state-command.js [unlink] src/commands/set-box-brush-name-command.js [unlink] src/commands/set-box-brush-transform-command.js [unlink] src/commands/set-box-brush-volume-settings-command.js [unlink] src/commands/set-entity-name-command.js [unlink] src/commands/set-model-instance-name-command.js [unlink] src/commands/set-player-start-command.js [unlink] src/commands/set-scene-name-command.js [unlink] src/commands/set-world-settings-command.js [unlink] src/commands/upsert-entity-command.js [unlink] src/commands/upsert-interaction-link-command.js [unlink] src/commands/upsert-model-instance-command.js [unlink] src/core/ids.js [unlink] src/core/selection.js [unlink] src/core/tool-mode.js [unlink] src/core/transform-session.js [unlink] src/core/vector.js [unlink] src/core/whitebox-selection-feedback.js [unlink] src/core/whitebox-selection-mode.js [unlink] src/document/brushes.js [unlink] src/document/migrate-scene-document.js [unlink] src/document/scene-document-validation.js [unlink] src/document/scene-document.js [unlink] src/document/world-settings.js [unlink] src/entities/entity-instances.js [unlink] src/entities/entity-labels.js [unlink] src/geometry/box-brush-components.js [unlink] src/geometry/box-brush-mesh.js [unlink] src/geometry/box-brush.js [unlink] src/geometry/box-face-uvs.js [unlink] src/geometry/grid-snapping.js [unlink] src/geometry/model-instance-collider-debug-mesh.js [unlink] src/geometry/model-instance-collider-generation.js [unlink] src/interactions/interaction-links.js [unlink] src/main.js [unlink] src/materials/starter-material-library.js [unlink] src/materials/starter-material-textures.js [unlink] src/rendering/advanced-rendering.js [unlink] src/rendering/fog-material.js [unlink] src/rendering/planar-reflection.js [unlink] src/rendering/water-material.js [unlink] src/runner-web/RunnerCanvas.js [unlink] src/runtime-three/first-person-navigation-controller.js [unlink] src/runtime-three/navigation-controller.js [unlink] src/runtime-three/orbit-visitor-navigation-controller.js [unlink] src/runtime-three/player-collision.js [unlink] src/runtime-three/rapier-collision-world.js [unlink] src/runtime-three/runtime-audio-system.js [unlink] src/runtime-three/runtime-host.js [unlink] src/runtime-three/runtime-interaction-system.js [unlink] src/runtime-three/runtime-scene-build.js [unlink] src/runtime-three/runtime-scene-validation.js [unlink] src/runtime-three/underwater-fog.js [unlink] src/serialization/local-draft-storage.js [unlink] src/serialization/scene-document-json.js [unlink] src/shared-ui/HierarchicalMenu.js [unlink] src/shared-ui/Panel.js [unlink] src/shared-ui/world-background-style.js [unlink] src/viewport-three/ViewportCanvas.js [unlink] src/viewport-three/ViewportPanel.js [unlink] src/viewport-three/viewport-entity-markers.js [unlink] src/viewport-three/viewport-focus.js [unlink] src/viewport-three/viewport-host.js [unlink] src/viewport-three/viewport-layout.js [unlink] src/viewport-three/viewport-transient-state.js [unlink] src/viewport-three/viewport-view-modes.js [unlink] vite.config.js [unlink] vitest.config.js
This commit is contained in:
@@ -1,240 +0,0 @@
|
||||
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;
|
||||
locomotionState = "flying";
|
||||
inWaterVolume = false;
|
||||
inFogVolume = 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.locomotionState = "flying";
|
||||
this.inWaterVolume = false;
|
||||
this.inFogVolume = 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 currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
|
||||
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 if (currentVolumeState.inWater) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
else {
|
||||
this.verticalVelocity -= GRAVITY * dt;
|
||||
}
|
||||
const resolvedMotion = this.context.resolveFirstPersonMotion(this.feetPosition, {
|
||||
x: horizontalX,
|
||||
y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt,
|
||||
z: horizontalZ
|
||||
}, playerShape);
|
||||
if (resolvedMotion === null) {
|
||||
this.updateCameraTransform();
|
||||
this.publishTelemetry();
|
||||
return;
|
||||
}
|
||||
this.feetPosition = resolvedMotion.feetPosition;
|
||||
const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
|
||||
this.inWaterVolume = nextVolumeState.inWater;
|
||||
this.inFogVolume = nextVolumeState.inFog;
|
||||
this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded;
|
||||
if (playerShape.mode === "none") {
|
||||
this.locomotionState = "flying";
|
||||
}
|
||||
else if (this.inWaterVolume) {
|
||||
this.locomotionState = "swimming";
|
||||
}
|
||||
else if (this.grounded) {
|
||||
this.locomotionState = "grounded";
|
||||
}
|
||||
else {
|
||||
this.locomotionState = "flying";
|
||||
}
|
||||
if (this.grounded && this.verticalVelocity < 0) {
|
||||
this.verticalVelocity = 0;
|
||||
}
|
||||
else if (this.inWaterVolume) {
|
||||
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.locomotionState = "flying";
|
||||
this.inWaterVolume = false;
|
||||
this.inFogVolume = 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;
|
||||
}
|
||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
||||
const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition);
|
||||
this.context.setFirstPersonTelemetry({
|
||||
feetPosition: {
|
||||
...this.feetPosition
|
||||
},
|
||||
eyePosition,
|
||||
grounded: this.grounded,
|
||||
locomotionState: this.locomotionState,
|
||||
inWaterVolume: this.inWaterVolume,
|
||||
cameraSubmerged: cameraVolumeState.inWater,
|
||||
inFogVolume: this.inFogVolume,
|
||||
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 +0,0 @@
|
||||
export {};
|
||||
@@ -1,117 +0,0 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { Euler, MathUtils, Quaternion } from "three";
|
||||
const CHARACTER_CONTROLLER_OFFSET = 0.01;
|
||||
const COLLISION_EPSILON = 1e-5;
|
||||
let rapierInitPromise = null;
|
||||
function componentScale(vector, scale) {
|
||||
return {
|
||||
x: vector.x * scale.x,
|
||||
y: vector.y * scale.y,
|
||||
z: vector.z * scale.z
|
||||
};
|
||||
}
|
||||
function createRapierQuaternion(rotationDegrees) {
|
||||
const quaternion = new Quaternion().setFromEuler(new Euler(MathUtils.degToRad(rotationDegrees.x), MathUtils.degToRad(rotationDegrees.y), MathUtils.degToRad(rotationDegrees.z), "XYZ"));
|
||||
return {
|
||||
x: quaternion.x,
|
||||
y: quaternion.y,
|
||||
z: quaternion.z,
|
||||
w: quaternion.w
|
||||
};
|
||||
}
|
||||
function scaleVertices(vertices, scale) {
|
||||
const scaledVertices = new Float32Array(vertices.length);
|
||||
for (let index = 0; index < vertices.length; index += 3) {
|
||||
scaledVertices[index] = vertices[index] * scale.x;
|
||||
scaledVertices[index + 1] = vertices[index + 1] * scale.y;
|
||||
scaledVertices[index + 2] = vertices[index + 2] * scale.z;
|
||||
}
|
||||
return scaledVertices;
|
||||
}
|
||||
function scaleBoundsCenter(bounds, scale) {
|
||||
return {
|
||||
x: ((bounds.min.x + bounds.max.x) * 0.5) * scale.x,
|
||||
y: ((bounds.min.y + bounds.max.y) * 0.5) * scale.y,
|
||||
z: ((bounds.min.z + bounds.max.z) * 0.5) * scale.z
|
||||
};
|
||||
}
|
||||
function createRapierHeightfieldHeights(collider) {
|
||||
const heights = new Float32Array(collider.heights.length);
|
||||
// Rapier's heightfield samples are column-major, with the Z axis varying
|
||||
// fastest inside each X column. Our generated collider stores X-major rows
|
||||
// for easier editor/debug mesh reconstruction, so transpose here.
|
||||
for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) {
|
||||
for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) {
|
||||
heights[zIndex + xIndex * collider.cols] = collider.heights[xIndex + zIndex * collider.rows];
|
||||
}
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
function createFixedBodyForModelCollider(world, collider) {
|
||||
return world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
|
||||
.setTranslation(collider.transform.position.x, collider.transform.position.y, collider.transform.position.z)
|
||||
.setRotation(createRapierQuaternion(collider.transform.rotationDegrees)));
|
||||
}
|
||||
function attachBrushCollider(world, collider) {
|
||||
const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
|
||||
.setTranslation(collider.center.x, collider.center.y, collider.center.z)
|
||||
.setRotation(createRapierQuaternion(collider.rotationDegrees)));
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(collider.vertices, collider.indices), body);
|
||||
}
|
||||
function attachSimpleModelCollider(world, collider) {
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
const scaledCenter = componentScale(collider.center, collider.transform.scale);
|
||||
const scaledHalfExtents = componentScale({
|
||||
x: collider.size.x * 0.5,
|
||||
y: collider.size.y * 0.5,
|
||||
z: collider.size.z * 0.5
|
||||
}, collider.transform.scale);
|
||||
world.createCollider(RAPIER.ColliderDesc.cuboid(scaledHalfExtents.x, scaledHalfExtents.y, scaledHalfExtents.z).setTranslation(scaledCenter.x, scaledCenter.y, scaledCenter.z), body);
|
||||
}
|
||||
function attachStaticModelCollider(world, collider) {
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(scaleVertices(collider.vertices, collider.transform.scale), collider.indices), body);
|
||||
}
|
||||
function attachTerrainModelCollider(world, collider) {
|
||||
if (collider.rows < 2 || collider.cols < 2) {
|
||||
throw new Error(`Terrain collider ${collider.instanceId} must have at least a 2x2 height sample grid.`);
|
||||
}
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
const center = scaleBoundsCenter({
|
||||
min: {
|
||||
x: collider.minX,
|
||||
y: 0,
|
||||
z: collider.minZ
|
||||
},
|
||||
max: {
|
||||
x: collider.maxX,
|
||||
y: 0,
|
||||
z: collider.maxZ
|
||||
}
|
||||
}, collider.transform.scale);
|
||||
const rowSubdivisions = collider.rows - 1;
|
||||
const colSubdivisions = collider.cols - 1;
|
||||
world.createCollider(
|
||||
// Rapier expects the number of grid subdivisions here, while our generated
|
||||
// collider stores the sampled height grid dimensions.
|
||||
RAPIER.ColliderDesc.heightfield(rowSubdivisions, colSubdivisions, createRapierHeightfieldHeights(collider), {
|
||||
x: (collider.maxX - collider.minX) * collider.transform.scale.x,
|
||||
y: collider.transform.scale.y,
|
||||
z: (collider.maxZ - collider.minZ) * collider.transform.scale.z
|
||||
}).setTranslation(center.x, center.y, center.z), body);
|
||||
}
|
||||
function attachDynamicModelCollider(world, collider) {
|
||||
const body = createFixedBodyForModelCollider(world, collider);
|
||||
for (const piece of collider.pieces) {
|
||||
const scaledPoints = scaleVertices(piece.points, collider.transform.scale);
|
||||
const descriptor = RAPIER.ColliderDesc.convexHull(scaledPoints);
|
||||
if (descriptor === null) {
|
||||
throw new Error(`Dynamic collider piece ${piece.id} could not form a valid convex hull.`);
|
||||
}
|
||||
world.createCollider(descriptor, body);
|
||||
}
|
||||
}
|
||||
function attachModelCollider(world, collider) {
|
||||
switch (collider.kind) {
|
||||
case "box":
|
||||
attachSimpleModelCollider(world, collider);
|
||||
break;
|
||||
case "trimesh":
|
||||
attachStaticModelCollider(world, collider);
|
||||
break;
|
||||
case "heightfield":
|
||||
attachTerrainModelCollider(world, collider);
|
||||
break;
|
||||
case "compound":
|
||||
attachDynamicModelCollider(world, collider);
|
||||
break;
|
||||
}
|
||||
}
|
||||
function feetPositionToColliderCenter(feetPosition, shape) {
|
||||
switch (shape.mode) {
|
||||
case "capsule": {
|
||||
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + shape.radius + cylindricalHalfHeight,
|
||||
z: feetPosition.z
|
||||
};
|
||||
}
|
||||
case "box":
|
||||
return {
|
||||
x: feetPosition.x,
|
||||
y: feetPosition.y + shape.size.y * 0.5,
|
||||
z: feetPosition.z
|
||||
};
|
||||
case "none":
|
||||
return {
|
||||
...feetPosition
|
||||
};
|
||||
}
|
||||
}
|
||||
function colliderCenterToFeetPosition(center, shape) {
|
||||
switch (shape.mode) {
|
||||
case "capsule": {
|
||||
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
|
||||
return {
|
||||
x: center.x,
|
||||
y: center.y - (shape.radius + cylindricalHalfHeight),
|
||||
z: center.z
|
||||
};
|
||||
}
|
||||
case "box":
|
||||
return {
|
||||
x: center.x,
|
||||
y: center.y - shape.size.y * 0.5,
|
||||
z: center.z
|
||||
};
|
||||
case "none":
|
||||
return {
|
||||
...center
|
||||
};
|
||||
}
|
||||
}
|
||||
function createPlayerCollider(world, rapier, playerShape) {
|
||||
switch (playerShape.mode) {
|
||||
case "capsule":
|
||||
return world.createCollider(rapier.ColliderDesc.capsule(Math.max(0, (playerShape.height - playerShape.radius * 2) * 0.5), playerShape.radius));
|
||||
case "box":
|
||||
return world.createCollider(rapier.ColliderDesc.cuboid(playerShape.size.x * 0.5, playerShape.size.y * 0.5, playerShape.size.z * 0.5));
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function initializeRapierCollisionWorld() {
|
||||
rapierInitPromise ??= RAPIER.init().then(() => RAPIER);
|
||||
return rapierInitPromise;
|
||||
}
|
||||
export class RapierCollisionWorld {
|
||||
world;
|
||||
characterController;
|
||||
playerCollider;
|
||||
static async create(colliders, playerShape) {
|
||||
const rapier = await initializeRapierCollisionWorld();
|
||||
const world = new rapier.World({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0
|
||||
});
|
||||
for (const collider of colliders) {
|
||||
if (collider.source === "brush") {
|
||||
attachBrushCollider(world, collider);
|
||||
continue;
|
||||
}
|
||||
attachModelCollider(world, collider);
|
||||
}
|
||||
const playerCollider = createPlayerCollider(world, rapier, playerShape);
|
||||
const characterController = playerCollider === null ? null : world.createCharacterController(CHARACTER_CONTROLLER_OFFSET);
|
||||
if (characterController !== null) {
|
||||
characterController.setUp({ x: 0, y: 1, z: 0 });
|
||||
characterController.setSlideEnabled(true);
|
||||
characterController.enableSnapToGround(0.2);
|
||||
characterController.enableAutostep(0.35, 0.15, false);
|
||||
characterController.setMaxSlopeClimbAngle(Math.PI * 0.45);
|
||||
characterController.setMinSlopeSlideAngle(Math.PI * 0.5);
|
||||
}
|
||||
world.step();
|
||||
return new RapierCollisionWorld(world, characterController, playerCollider);
|
||||
}
|
||||
constructor(world, characterController, playerCollider) {
|
||||
this.world = world;
|
||||
this.characterController = characterController;
|
||||
this.playerCollider = playerCollider;
|
||||
}
|
||||
resolveFirstPersonMotion(feetPosition, motion, shape) {
|
||||
if (this.playerCollider === null || this.characterController === null || shape.mode === "none") {
|
||||
return {
|
||||
feetPosition: {
|
||||
x: feetPosition.x + motion.x,
|
||||
y: feetPosition.y + motion.y,
|
||||
z: feetPosition.z + motion.z
|
||||
},
|
||||
grounded: false,
|
||||
collidedAxes: {
|
||||
x: false,
|
||||
y: false,
|
||||
z: false
|
||||
}
|
||||
};
|
||||
}
|
||||
const currentCenter = feetPositionToColliderCenter(feetPosition, shape);
|
||||
this.playerCollider.setTranslation(currentCenter);
|
||||
this.characterController.computeColliderMovement(this.playerCollider, motion);
|
||||
const correctedMovement = this.characterController.computedMovement();
|
||||
const collidedAxes = {
|
||||
x: Math.abs(correctedMovement.x - motion.x) > COLLISION_EPSILON,
|
||||
y: Math.abs(correctedMovement.y - motion.y) > COLLISION_EPSILON,
|
||||
z: Math.abs(correctedMovement.z - motion.z) > COLLISION_EPSILON
|
||||
};
|
||||
const nextCenter = {
|
||||
x: currentCenter.x + correctedMovement.x,
|
||||
y: currentCenter.y + correctedMovement.y,
|
||||
z: currentCenter.z + correctedMovement.z
|
||||
};
|
||||
this.playerCollider.setTranslation(nextCenter);
|
||||
return {
|
||||
feetPosition: colliderCenterToFeetPosition(nextCenter, shape),
|
||||
grounded: this.characterController.computedGrounded() || (motion.y < 0 && collidedAxes.y),
|
||||
collidedAxes
|
||||
};
|
||||
}
|
||||
dispose() {
|
||||
if (this.characterController !== null) {
|
||||
this.world.removeCharacterController(this.characterController);
|
||||
}
|
||||
this.world.free();
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,991 +0,0 @@
|
||||
import { AmbientLight, AnimationClip, AnimationMixer, DirectionalLight, Euler, FogExp2, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, ShaderMaterial, Vector3, SpotLight, WebGLRenderTarget, WebGLRenderer } from "three";
|
||||
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
|
||||
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
||||
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
||||
import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths } from "../rendering/advanced-rendering";
|
||||
import { createFogQualityMaterial } from "../rendering/fog-material";
|
||||
import { collectWaterContactPatches, createWaterContactPatchAxisUniformValue, createWaterContactPatchShapeUniformValue, createWaterContactPatchUniformValue, createWaterMaterial } from "../rendering/water-material";
|
||||
import { updatePlanarReflectionCamera } from "../rendering/planar-reflection";
|
||||
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";
|
||||
import { resolveUnderwaterFogState } from "./underwater-fog";
|
||||
const FALLBACK_FACE_COLOR = 0xf2ece2;
|
||||
const BOX_FACE_MATERIAL_COUNT = 6;
|
||||
const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96;
|
||||
export class RuntimeHost {
|
||||
scene = new Scene();
|
||||
camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
||||
cameraForward = new Vector3();
|
||||
volumeOffset = new Vector3();
|
||||
volumeInverseRotation = new Quaternion();
|
||||
fogLocalCameraPosition = new Vector3();
|
||||
domElement;
|
||||
ambientLight = new AmbientLight();
|
||||
sunLight = new DirectionalLight();
|
||||
localLightGroup = new Group();
|
||||
brushGroup = new Group();
|
||||
modelGroup = new Group();
|
||||
waterReflectionCamera = new PerspectiveCamera();
|
||||
firstPersonController = new FirstPersonNavigationController();
|
||||
orbitVisitorController = new OrbitVisitorNavigationController();
|
||||
interactionSystem = new RuntimeInteractionSystem();
|
||||
audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null);
|
||||
underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
|
||||
brushMeshes = new Map();
|
||||
volumeTime = 0;
|
||||
volumeAnimatedUniforms = [];
|
||||
runtimeWaterContactUniforms = [];
|
||||
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.underwaterSceneFog.density = 0;
|
||||
this.scene.fog = this.underwaterSceneFog;
|
||||
this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, 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,
|
||||
resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition),
|
||||
setRuntimeMessage: (message) => {
|
||||
if (message === this.currentRuntimeMessage) {
|
||||
return;
|
||||
}
|
||||
this.currentRuntimeMessage = message;
|
||||
this.runtimeMessageHandler?.(message);
|
||||
},
|
||||
setFirstPersonTelemetry: (telemetry) => {
|
||||
this.currentFirstPersonTelemetry = telemetry;
|
||||
this.firstPersonTelemetryHandler?.(telemetry);
|
||||
}
|
||||
};
|
||||
}
|
||||
resolvePlayerVolumeState(feetPosition) {
|
||||
if (this.runtimeScene === null) {
|
||||
return {
|
||||
inWater: false,
|
||||
inFog: false
|
||||
};
|
||||
}
|
||||
const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
|
||||
const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
|
||||
return {
|
||||
inWater,
|
||||
inFog
|
||||
};
|
||||
}
|
||||
isPointInsideOrientedVolume(point, volume) {
|
||||
this.volumeOffset.set(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
|
||||
this.volumeInverseRotation
|
||||
.setFromEuler(new Euler((volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ"))
|
||||
.invert();
|
||||
this.volumeOffset.applyQuaternion(this.volumeInverseRotation);
|
||||
const halfX = volume.size.x * 0.5;
|
||||
const halfY = volume.size.y * 0.5;
|
||||
const halfZ = volume.size.z * 0.5;
|
||||
return (Math.abs(this.volumeOffset.x) <= halfX &&
|
||||
Math.abs(this.volumeOffset.y) <= halfY &&
|
||||
Math.abs(this.volumeOffset.z) <= halfZ);
|
||||
}
|
||||
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;
|
||||
this.scene.fog = null;
|
||||
if (this.renderer !== null) {
|
||||
this.renderer.autoClear = true;
|
||||
}
|
||||
for (const cachedTexture of this.materialTextureCache.values()) {
|
||||
cachedTexture.texture.dispose();
|
||||
}
|
||||
this.materialTextureCache.clear();
|
||||
this.renderer?.forceContextLoss();
|
||||
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();
|
||||
const volumeRenderPaths = this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
|
||||
for (const brush of brushes) {
|
||||
const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
|
||||
const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : [];
|
||||
const contactPatches = brush.volume.mode === "water"
|
||||
? this.mergeRuntimeWaterContactPatches(staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush))
|
||||
: [];
|
||||
const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ??
|
||||
[
|
||||
this.createFaceMaterial(brush, "posX", brush.faces.posX.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "negX", brush.faces.negX.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "posY", brush.faces.posY.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "negY", brush.faces.negY.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "posZ", brush.faces.posZ.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
||||
this.createFaceMaterial(brush, "negZ", brush.faces.negZ.material, volumeRenderPaths, contactPatches, staticContactPatches)
|
||||
];
|
||||
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.configureFogVolumeMesh(mesh, materials);
|
||||
this.brushGroup.add(mesh);
|
||||
this.brushMeshes.set(brush.id, mesh);
|
||||
}
|
||||
this.applyShadowState();
|
||||
}
|
||||
createFogMaterialSet(brush, volumeRenderPaths) {
|
||||
if (brush.volume.mode !== "fog") {
|
||||
return null;
|
||||
}
|
||||
if (volumeRenderPaths.fog === "quality") {
|
||||
const fogMaterial = createFogQualityMaterial({
|
||||
colorHex: brush.volume.fog.colorHex,
|
||||
density: brush.volume.fog.density,
|
||||
padding: brush.volume.fog.padding,
|
||||
time: this.volumeTime,
|
||||
halfSize: {
|
||||
x: brush.size.x * 0.5,
|
||||
y: brush.size.y * 0.5,
|
||||
z: brush.size.z * 0.5
|
||||
}
|
||||
});
|
||||
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
||||
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial.material);
|
||||
}
|
||||
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
|
||||
const fogMaterial = new MeshBasicMaterial({
|
||||
color: brush.volume.fog.colorHex,
|
||||
transparent: true,
|
||||
opacity: densityOpacity,
|
||||
depthWrite: false
|
||||
});
|
||||
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial);
|
||||
}
|
||||
configureFogVolumeMesh(mesh, materials) {
|
||||
const fogMaterials = materials.filter((material) => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined);
|
||||
if (fogMaterials.length === 0) {
|
||||
return;
|
||||
}
|
||||
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
||||
const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position));
|
||||
for (const material of fogMaterials) {
|
||||
material.uniforms["localCameraPosition"].value.copy(localCameraPosition);
|
||||
}
|
||||
};
|
||||
}
|
||||
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(brush, faceId, material, volumeRenderPaths, contactPatches, staticContactPatches) {
|
||||
if (brush.volume.mode === "water") {
|
||||
const baseOpacity = Math.max(0.05, Math.min(1, brush.volume.water.surfaceOpacity));
|
||||
const waterMaterial = createWaterMaterial({
|
||||
colorHex: brush.volume.water.colorHex,
|
||||
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
||||
waveStrength: brush.volume.water.waveStrength,
|
||||
surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled,
|
||||
opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5,
|
||||
quality: volumeRenderPaths.water === "quality",
|
||||
wireframe: false,
|
||||
isTopFace: faceId === "posY",
|
||||
time: this.volumeTime,
|
||||
halfSize: {
|
||||
x: brush.size.x * 0.5,
|
||||
z: brush.size.z * 0.5
|
||||
},
|
||||
contactPatches,
|
||||
reflection: {
|
||||
texture: null,
|
||||
enabled: faceId === "posY"
|
||||
}
|
||||
});
|
||||
if (waterMaterial.animationUniform !== null) {
|
||||
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
|
||||
}
|
||||
if (faceId === "posY" && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null) {
|
||||
this.runtimeWaterContactUniforms.push({
|
||||
brush,
|
||||
uniform: waterMaterial.contactPatchesUniform,
|
||||
axisUniform: waterMaterial.contactPatchAxesUniform,
|
||||
shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] },
|
||||
staticContactPatches,
|
||||
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
|
||||
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
|
||||
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
|
||||
reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null,
|
||||
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
|
||||
});
|
||||
}
|
||||
return waterMaterial.material;
|
||||
}
|
||||
if (brush.volume.mode === "fog") {
|
||||
if (volumeRenderPaths.fog === "quality") {
|
||||
const fogMaterial = createFogQualityMaterial({
|
||||
colorHex: brush.volume.fog.colorHex,
|
||||
density: brush.volume.fog.density,
|
||||
padding: brush.volume.fog.padding,
|
||||
time: this.volumeTime,
|
||||
halfSize: {
|
||||
x: brush.size.x * 0.5,
|
||||
y: brush.size.y * 0.5,
|
||||
z: brush.size.z * 0.5
|
||||
}
|
||||
});
|
||||
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
||||
return fogMaterial.material;
|
||||
}
|
||||
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
|
||||
return new MeshBasicMaterial({
|
||||
color: brush.volume.fog.colorHex,
|
||||
transparent: true,
|
||||
opacity: densityOpacity,
|
||||
depthWrite: false
|
||||
});
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
const fogState = this.activeController === this.firstPersonController
|
||||
? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry)
|
||||
: null;
|
||||
if (fogState === null) {
|
||||
this.underwaterSceneFog.density = 0;
|
||||
return;
|
||||
}
|
||||
this.underwaterSceneFog.color.set(fogState.colorHex);
|
||||
this.underwaterSceneFog.density = fogState.density;
|
||||
}
|
||||
getWaterReflectionMode() {
|
||||
if (this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality") {
|
||||
return "none";
|
||||
}
|
||||
return this.currentWorld.advancedRendering.waterReflectionMode;
|
||||
}
|
||||
createWaterReflectionRenderTarget() {
|
||||
const canvasWidth = this.container?.clientWidth ?? this.domElement.width;
|
||||
const canvasHeight = this.container?.clientHeight ?? this.domElement.height;
|
||||
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
|
||||
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
|
||||
return new WebGLRenderTarget(width, height);
|
||||
}
|
||||
resizeWaterReflectionTargets() {
|
||||
const canvasWidth = this.container?.clientWidth ?? this.domElement.width;
|
||||
const canvasHeight = this.container?.clientHeight ?? this.domElement.height;
|
||||
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
|
||||
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
binding.reflectionRenderTarget?.setSize(width, height);
|
||||
binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
updateRuntimeWaterReflections() {
|
||||
if (this.renderer === null || this.runtimeScene === null) {
|
||||
return;
|
||||
}
|
||||
const reflectionMode = this.getWaterReflectionMode();
|
||||
const now = performance.now();
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
if (reflectionMode === "none" ||
|
||||
binding.reflectionTextureUniform === null ||
|
||||
binding.reflectionMatrixUniform === null ||
|
||||
binding.reflectionEnabledUniform === null) {
|
||||
if (binding.reflectionEnabledUniform !== null) {
|
||||
binding.reflectionEnabledUniform.value = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (binding.reflectionRenderTarget === null) {
|
||||
binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget();
|
||||
}
|
||||
const canRenderReflection = updatePlanarReflectionCamera(binding.brush, this.camera, this.waterReflectionCamera, binding.reflectionMatrixUniform.value);
|
||||
if (!canRenderReflection || binding.reflectionRenderTarget === null) {
|
||||
binding.reflectionEnabledUniform.value = 0;
|
||||
continue;
|
||||
}
|
||||
if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) {
|
||||
binding.reflectionEnabledUniform.value = 0.36;
|
||||
continue;
|
||||
}
|
||||
const hiddenWaterMeshes = [];
|
||||
for (const runtimeBrush of this.runtimeScene.brushes) {
|
||||
if (runtimeBrush.volume.mode !== "water") {
|
||||
continue;
|
||||
}
|
||||
const mesh = this.brushMeshes.get(runtimeBrush.id);
|
||||
if (mesh === undefined) {
|
||||
continue;
|
||||
}
|
||||
hiddenWaterMeshes.push({ mesh, visible: mesh.visible });
|
||||
mesh.visible = false;
|
||||
}
|
||||
const previousModelGroupVisibility = this.modelGroup.visible;
|
||||
if (reflectionMode === "world") {
|
||||
this.modelGroup.visible = false;
|
||||
}
|
||||
const previousAutoClear = this.renderer.autoClear;
|
||||
const previousRenderTarget = this.renderer.getRenderTarget();
|
||||
const previousFogDensity = this.underwaterSceneFog.density;
|
||||
const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({
|
||||
binding: waterBinding,
|
||||
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
|
||||
texture: waterBinding.reflectionTextureUniform?.value ?? null
|
||||
}));
|
||||
try {
|
||||
this.underwaterSceneFog.density = 0;
|
||||
for (const state of previousReflectionStates) {
|
||||
if (state.binding.reflectionEnabledUniform !== null) {
|
||||
state.binding.reflectionEnabledUniform.value = 0;
|
||||
}
|
||||
}
|
||||
binding.reflectionTextureUniform.value = null;
|
||||
this.renderer.autoClear = true;
|
||||
this.renderer.setRenderTarget(binding.reflectionRenderTarget);
|
||||
this.renderer.clear();
|
||||
this.renderer.render(this.scene, this.waterReflectionCamera);
|
||||
}
|
||||
finally {
|
||||
this.renderer.setRenderTarget(previousRenderTarget);
|
||||
this.renderer.autoClear = previousAutoClear;
|
||||
this.modelGroup.visible = previousModelGroupVisibility;
|
||||
this.underwaterSceneFog.density = previousFogDensity;
|
||||
for (const state of previousReflectionStates) {
|
||||
if (state.binding.reflectionEnabledUniform !== null) {
|
||||
state.binding.reflectionEnabledUniform.value = state.enabled;
|
||||
}
|
||||
if (state.binding.reflectionTextureUniform !== null) {
|
||||
state.binding.reflectionTextureUniform.value = state.texture;
|
||||
}
|
||||
}
|
||||
for (const hiddenWaterMesh of hiddenWaterMeshes) {
|
||||
hiddenWaterMesh.mesh.visible = hiddenWaterMesh.visible;
|
||||
}
|
||||
}
|
||||
binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture;
|
||||
binding.reflectionEnabledUniform.value = 0.36;
|
||||
binding.lastReflectionUpdateTime = now;
|
||||
}
|
||||
}
|
||||
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();
|
||||
this.disposeUniqueMaterials(mesh.material);
|
||||
}
|
||||
this.brushMeshes.clear();
|
||||
this.volumeAnimatedUniforms.length = 0;
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
binding.reflectionRenderTarget?.dispose();
|
||||
}
|
||||
this.runtimeWaterContactUniforms.length = 0;
|
||||
}
|
||||
disposeUniqueMaterials(materials) {
|
||||
for (const material of new Set(materials)) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
createPlayerWaterContactBounds() {
|
||||
if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) {
|
||||
return null;
|
||||
}
|
||||
const feetPosition = this.currentFirstPersonTelemetry.feetPosition;
|
||||
const playerShape = this.runtimeScene.playerCollider;
|
||||
switch (playerShape.mode) {
|
||||
case "capsule":
|
||||
return {
|
||||
min: {
|
||||
x: feetPosition.x - playerShape.radius,
|
||||
y: feetPosition.y,
|
||||
z: feetPosition.z - playerShape.radius
|
||||
},
|
||||
max: {
|
||||
x: feetPosition.x + playerShape.radius,
|
||||
y: feetPosition.y + playerShape.height,
|
||||
z: feetPosition.z + playerShape.radius
|
||||
}
|
||||
};
|
||||
case "box":
|
||||
return {
|
||||
min: {
|
||||
x: feetPosition.x - playerShape.size.x * 0.5,
|
||||
y: feetPosition.y,
|
||||
z: feetPosition.z - playerShape.size.z * 0.5
|
||||
},
|
||||
max: {
|
||||
x: feetPosition.x + playerShape.size.x * 0.5,
|
||||
y: feetPosition.y + playerShape.size.y,
|
||||
z: feetPosition.z + playerShape.size.z * 0.5
|
||||
}
|
||||
};
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
collectRuntimeStaticWaterContactPatches(brush) {
|
||||
const contactBounds = [];
|
||||
const runtimeBrushesById = new Map((this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [runtimeBrush.id, runtimeBrush]));
|
||||
for (const collider of this.runtimeScene?.colliders ?? []) {
|
||||
if (collider.source === "brush") {
|
||||
const otherBrush = runtimeBrushesById.get(collider.brushId);
|
||||
if (otherBrush === undefined || otherBrush.id === brush.id || otherBrush.volume.mode !== "none") {
|
||||
continue;
|
||||
}
|
||||
contactBounds.push({
|
||||
kind: "triangleMesh",
|
||||
vertices: collider.vertices,
|
||||
indices: collider.indices,
|
||||
transform: {
|
||||
position: collider.center,
|
||||
rotationDegrees: collider.rotationDegrees,
|
||||
scale: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (collider.kind === "trimesh") {
|
||||
contactBounds.push({
|
||||
kind: "triangleMesh",
|
||||
vertices: collider.vertices,
|
||||
indices: collider.indices,
|
||||
mergeProfile: "aggressive",
|
||||
transform: collider.transform
|
||||
});
|
||||
continue;
|
||||
}
|
||||
contactBounds.push({
|
||||
min: collider.worldBounds.min,
|
||||
max: collider.worldBounds.max
|
||||
});
|
||||
}
|
||||
return collectWaterContactPatches({
|
||||
center: brush.center,
|
||||
rotationDegrees: brush.rotationDegrees,
|
||||
size: brush.size
|
||||
}, contactBounds, this.getRuntimeWaterFoamContactLimit(brush));
|
||||
}
|
||||
collectRuntimePlayerWaterContactPatches(brush) {
|
||||
const playerBounds = this.createPlayerWaterContactBounds();
|
||||
if (playerBounds === null) {
|
||||
return [];
|
||||
}
|
||||
return collectWaterContactPatches({
|
||||
center: brush.center,
|
||||
rotationDegrees: brush.rotationDegrees,
|
||||
size: brush.size
|
||||
}, [playerBounds], this.getRuntimeWaterFoamContactLimit(brush));
|
||||
}
|
||||
getRuntimeWaterFoamContactLimit(brush) {
|
||||
return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0;
|
||||
}
|
||||
mergeRuntimeWaterContactPatches(brush, staticContactPatches, dynamicContactPatches) {
|
||||
return [...dynamicContactPatches, ...staticContactPatches].slice(0, this.getRuntimeWaterFoamContactLimit(brush));
|
||||
}
|
||||
updateRuntimeWaterContactUniforms() {
|
||||
for (const binding of this.runtimeWaterContactUniforms) {
|
||||
const mergedPatches = this.mergeRuntimeWaterContactPatches(binding.brush, binding.staticContactPatches, this.collectRuntimePlayerWaterContactPatches(binding.brush));
|
||||
binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches);
|
||||
binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches);
|
||||
binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches);
|
||||
}
|
||||
}
|
||||
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);
|
||||
this.resizeWaterReflectionTargets();
|
||||
}
|
||||
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();
|
||||
this.volumeTime += dt;
|
||||
for (const uniform of this.volumeAnimatedUniforms) {
|
||||
uniform.value = this.volumeTime;
|
||||
}
|
||||
for (const mixer of this.animationMixers.values()) {
|
||||
mixer.update(dt);
|
||||
}
|
||||
if (this.sceneReady && this.runtimeScene !== null && this.currentFirstPersonTelemetry !== null) {
|
||||
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher());
|
||||
this.setInteractionPrompt(this.resolveInteractionPrompt());
|
||||
}
|
||||
else {
|
||||
this.setInteractionPrompt(null);
|
||||
}
|
||||
if (this.runtimeWaterContactUniforms.length > 0) {
|
||||
this.updateRuntimeWaterContactUniforms();
|
||||
this.updateRuntimeWaterReflections();
|
||||
}
|
||||
this.updateUnderwaterSceneFog();
|
||||
if (this.advancedRenderingComposer !== null) {
|
||||
this.advancedRenderingComposer.render(dt);
|
||||
return;
|
||||
}
|
||||
this.renderer?.render(this.scene, this.camera);
|
||||
};
|
||||
applyTeleportPlayerAction(target) {
|
||||
if (this.activeController === this.thirdPersonController) {
|
||||
this.thirdPersonController.teleportTo(target.position, target.yawDegrees);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
resolveInteractionPrompt() {
|
||||
if (this.runtimeScene === null ||
|
||||
this.currentFirstPersonTelemetry === null ||
|
||||
(this.activeController !== this.firstPersonController &&
|
||||
this.activeController !== this.thirdPersonController)) {
|
||||
return null;
|
||||
}
|
||||
this.camera.getWorldDirection(this.cameraForward);
|
||||
const interactionOrigin = this.currentFirstPersonTelemetry.eyePosition;
|
||||
const rayOrigin = this.activeController === this.thirdPersonController
|
||||
? {
|
||||
x: this.camera.position.x,
|
||||
y: this.camera.position.y,
|
||||
z: this.camera.position.z
|
||||
}
|
||||
: interactionOrigin;
|
||||
return this.interactionSystem.resolveClickInteractionPrompt(interactionOrigin, rayOrigin, {
|
||||
x: this.cameraForward.x,
|
||||
y: this.cameraForward.y,
|
||||
z: this.cameraForward.z
|
||||
}, this.runtimeScene);
|
||||
}
|
||||
handleRuntimeClick = () => {
|
||||
if (!this.sceneReady ||
|
||||
this.runtimeScene === null ||
|
||||
(this.activeController !== this.firstPersonController &&
|
||||
this.activeController !== this.thirdPersonController) ||
|
||||
this.currentInteractionPrompt === null) {
|
||||
return;
|
||||
}
|
||||
this.audioSystem.handleUserGesture();
|
||||
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
|
||||
};
|
||||
handleRuntimePointerDown = () => {
|
||||
this.audioSystem.handleUserGesture();
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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(interactionOrigin, rayOrigin, rayDirection, runtimeScene) {
|
||||
const normalizedViewDirection = normalizeVec3(rayDirection);
|
||||
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(interactionOrigin, interactable.position);
|
||||
if (distance > interactable.radius) {
|
||||
continue;
|
||||
}
|
||||
const hitDistance = raySphereHitDistance(rayOrigin, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
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 { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
||||
import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation";
|
||||
import { cloneInteractionLink, getInteractionLinks } from "../interactions/interaction-links";
|
||||
import { cloneMaterialDef } from "../materials/starter-material-library";
|
||||
import { cloneBoxBrushGeometry, cloneBoxBrushVolumeSettings, 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),
|
||||
geometry: cloneBoxBrushGeometry(brush.geometry),
|
||||
volume: cloneBoxBrushVolumeSettings(brush.volume),
|
||||
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 buildRuntimeFogVolume(brush) {
|
||||
if (brush.volume.mode !== "fog") {
|
||||
throw new Error(`Cannot build fog volume from non-fog brush ${brush.id}.`);
|
||||
}
|
||||
return {
|
||||
brushId: brush.id,
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
colorHex: brush.volume.fog.colorHex,
|
||||
density: brush.volume.fog.density,
|
||||
padding: brush.volume.fog.padding
|
||||
};
|
||||
}
|
||||
function buildRuntimeWaterVolume(brush) {
|
||||
if (brush.volume.mode !== "water") {
|
||||
throw new Error(`Cannot build water volume from non-water brush ${brush.id}.`);
|
||||
}
|
||||
return {
|
||||
brushId: brush.id,
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
size: cloneVec3(brush.size),
|
||||
colorHex: brush.volume.water.colorHex,
|
||||
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
||||
waveStrength: brush.volume.water.waveStrength
|
||||
};
|
||||
}
|
||||
function buildRuntimeCollider(brush) {
|
||||
const bounds = getBoxBrushBounds(brush);
|
||||
const derivedMesh = buildBoxBrushDerivedMeshData(brush);
|
||||
return {
|
||||
kind: "trimesh",
|
||||
source: "brush",
|
||||
brushId: brush.id,
|
||||
center: cloneVec3(brush.center),
|
||||
rotationDegrees: cloneVec3(brush.rotationDegrees),
|
||||
vertices: derivedMesh.colliderVertices,
|
||||
indices: derivedMesh.colliderIndices,
|
||||
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 = [];
|
||||
const volumes = {
|
||||
fog: [],
|
||||
water: []
|
||||
};
|
||||
for (const brush of Object.values(document.brushes)) {
|
||||
if (brush.volume.mode === "none") {
|
||||
colliders.push(buildRuntimeCollider(brush));
|
||||
continue;
|
||||
}
|
||||
if (brush.volume.mode === "fog") {
|
||||
volumes.fog.push(buildRuntimeFogVolume(brush));
|
||||
continue;
|
||||
}
|
||||
volumes.water.push(buildRuntimeWaterVolume(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,
|
||||
volumes,
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getModelInstances } from "../assets/model-instances";
|
||||
import { assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary } from "../document/scene-document-validation";
|
||||
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
|
||||
import { validateBoxBrushGeometry } from "../geometry/box-brush-mesh";
|
||||
import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation";
|
||||
function validateBrushGeometry(brush, path, diagnostics) {
|
||||
for (const diagnostic of validateBoxBrushGeometry(brush)) {
|
||||
diagnostics.push(createDiagnostic("error", diagnostic.code, diagnostic.message, `${path}.geometry`, "build"));
|
||||
}
|
||||
}
|
||||
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 brush of Object.values(document.brushes)) {
|
||||
validateBrushGeometry(brush, `brushes.${brush.id}`, diagnostics);
|
||||
}
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Euler, Quaternion, Vector3 } from "three";
|
||||
|
||||
const MIN_UNDERWATER_FOG_DENSITY = 0.018;
|
||||
const MAX_UNDERWATER_FOG_DENSITY = 0.12;
|
||||
|
||||
function clampNumber(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getWaterVolumeLocalPoint(point, volume) {
|
||||
const offset = new Vector3(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
|
||||
const inverseRotation = new Quaternion()
|
||||
.setFromEuler(new Euler((volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ"))
|
||||
.invert();
|
||||
offset.applyQuaternion(inverseRotation);
|
||||
return offset;
|
||||
}
|
||||
function isPointInsideWaterVolume(point, volume) {
|
||||
const offset = getWaterVolumeLocalPoint(point, volume);
|
||||
return (Math.abs(offset.x) <= volume.size.x * 0.5 &&
|
||||
Math.abs(offset.y) <= volume.size.y * 0.5 &&
|
||||
Math.abs(offset.z) <= volume.size.z * 0.5);
|
||||
}
|
||||
|
||||
function resolveUnderwaterFogDensity(volume, point) {
|
||||
const localPoint = getWaterVolumeLocalPoint(point, volume);
|
||||
const halfHeight = Math.max(volume.size.y * 0.5, 0.0001);
|
||||
const submersionDepth = clampNumber((halfHeight - localPoint.y) / (halfHeight * 2), 0, 1);
|
||||
return clampNumber(0.045 + volume.surfaceOpacity * 0.035 + Math.max(volume.waveStrength, 0) * 0.015 + submersionDepth * 0.03, MIN_UNDERWATER_FOG_DENSITY, MAX_UNDERWATER_FOG_DENSITY);
|
||||
}
|
||||
|
||||
export function resolveUnderwaterFogState(runtimeScene, telemetry) {
|
||||
if (runtimeScene === null || telemetry === null || telemetry.cameraSubmerged !== true) {
|
||||
return null;
|
||||
}
|
||||
const containingVolume = runtimeScene.volumes.water.find((volume) => isPointInsideWaterVolume(telemetry.eyePosition, volume));
|
||||
if (containingVolume === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
colorHex: containingVolume.colorHex,
|
||||
density: resolveUnderwaterFogDensity(containingVolume, telemetry.eyePosition)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user