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:
2026-04-11 15:48:39 +02:00
parent 277706e950
commit 9b7706bf5b
97 changed files with 0 additions and 21624 deletions

View File

@@ -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.");
});
}
};
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -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();
};
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
};
}

View File

@@ -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;
}
}
}
}

View File

@@ -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
}
};
}

View File

@@ -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)}`);
}
}

View File

@@ -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)
};
}