Files
webeditor3d/src/runtime-three/runtime-scene-build.ts

2043 lines
56 KiB
TypeScript

import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { Vec3 } from "../core/vector";
import { getModelInstances } from "../assets/model-instances";
import {
createActorControlTargetRef,
createActiveSceneControlTargetRef,
createAmbientLightIntensityControlChannelDescriptor,
createCameraRigControlTargetRef,
createControlTargetDescriptor,
createDefaultResolvedControlSource,
createEmptyRuntimeResolvedControlState,
createInteractionControlTargetRef,
createLightControlTargetRef,
createLightIntensityControlChannelDescriptor,
createModelInstanceControlTargetRef,
createProjectGlobalControlTargetRef,
createResolvedAmbientLightColorState,
createResolvedActorAnimationPlaybackState,
createResolvedActorPathAssignmentState,
createResolvedAmbientLightIntensityChannelValue,
createResolvedCameraRigOverrideState,
createResolvedInteractionEnabledState,
createResolvedLightColorState,
createResolvedLightEnabledState,
createResolvedLightIntensityChannelValue,
createResolvedModelAnimationPlaybackState,
createResolvedModelInstanceVisibilityState,
createResolvedProjectTimePausedState,
createResolvedSoundPlaybackState,
createResolvedSoundVolumeChannelValue,
createResolvedSunLightColorState,
createResolvedSunLightIntensityChannelValue,
createRuntimeControlSurfaceDefinition,
createSoundEmitterControlTargetRef,
createSoundVolumeControlChannelDescriptor,
createSunLightIntensityControlChannelDescriptor,
type RuntimeControlSurfaceDefinition
} from "../controls/control-surface";
import {
cloneBrushGeometry,
cloneBoxBrushVolumeSettings,
cloneFaceUvState,
type Brush,
type BrushGeometry,
type BrushKind,
type BoxBrushLightFalloffMode,
type BoxBrushVolumeSettings,
type WhiteboxFaceId,
type FaceUvState
} from "../document/brushes";
import type { SceneDocument } from "../document/scene-document";
import {
cloneProjectTimeSettings,
type ProjectTimeSettings
} from "../document/project-time-settings";
import {
getScenePaths,
resolveScenePath,
type ScenePath,
type ScenePathPoint
} from "../document/paths";
import {
getTerrainBounds,
getTerrainFootprintDepth,
getTerrainFootprintWidth,
getTerrains,
type Terrain
} from "../document/terrains";
import {
cloneWorldSettings,
type WorldSettings
} from "../document/world-settings";
import {
cloneProjectDialogue,
type ProjectDialogue
} from "../dialogues/project-dialogues";
import {
cloneProjectSequenceLibrary,
type ProjectSequenceLibrary
} from "../sequencer/project-sequences";
import {
cloneCameraRigLookAroundSettings,
cloneCameraRigTargetRef,
type CharacterColliderSettings,
type CameraRigLookAroundSettings,
type CameraRigRailPlacementMode,
type CameraRigTargetRef,
type CameraRigTransitionMode,
clonePlayerStartInputBindings,
createPlayerStartMovementTemplate,
createPlayerStartInputBindings,
getEntityInstances,
getPrimaryEnabledPlayerStartEntity,
type EntityInstance,
type PlayerStartInputBindings,
type PlayerStartJumpSettings,
type PlayerStartMovementCapabilities,
type PlayerStartCrouchSettings,
type PlayerStartSprintSettings,
type PlayerStartMovementTemplate
} from "../entities/entity-instances";
import { getBrushBounds } from "../geometry/whitebox-brush";
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
import { getBrushFaceIds } from "../geometry/whitebox-topology";
import {
buildGeneratedModelCollider,
type GeneratedColliderBounds,
type GeneratedModelCollider
} from "../geometry/model-instance-collider-generation";
import {
cloneInteractionLink,
getInteractionLinks,
type InteractionLink
} from "../interactions/interaction-links";
import { getInteractionLinkImpulseSteps } from "../sequencer/project-sequence-steps";
import {
cloneMaterialDef,
type MaterialDef
} from "../materials/starter-material-library";
import { cloneProjectScheduler } from "../scheduler/project-scheduler";
import {
applyRuntimeProjectScheduleToControlState,
createRuntimeProjectSchedulerState,
resolveRuntimeProjectScheduleState,
type RuntimeProjectSchedulerState,
type RuntimeResolvedProjectScheduleState
} from "./runtime-project-scheduler";
import {
deriveBoxLightVolumePointLights,
type DerivedLightVolumePointLight
} from "./light-volume-utils";
import { assertRuntimeSceneBuildable } from "./runtime-scene-validation";
import type { RuntimeClockState } from "./runtime-project-time";
import {
FIRST_PERSON_PLAYER_SHAPE,
type FirstPersonPlayerShape
} from "./player-collision";
export type RuntimeNavigationMode = "firstPerson" | "thirdPerson";
export interface RuntimeBrushFace {
materialId: string | null;
material: MaterialDef | null;
uv: FaceUvState;
}
export interface RuntimeBoxBrushInstance {
id: string;
kind: BrushKind;
sideCount?: number;
majorSegmentCount?: number;
tubeSegmentCount?: number;
visible: boolean;
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
geometry: BrushGeometry;
faces: Record<WhiteboxFaceId, RuntimeBrushFace>;
volume: BoxBrushVolumeSettings;
}
export interface RuntimeFogVolume {
brushId: string;
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
colorHex: string;
density: number;
padding: number;
}
export interface RuntimeWaterVolume {
brushId: string;
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
colorHex: string;
surfaceOpacity: number;
waveStrength: number;
}
export interface RuntimeLightVolume {
brushId: string;
enabled: boolean;
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
colorHex: string;
intensity: number;
padding: number;
falloff: BoxBrushLightFalloffMode;
lights: DerivedLightVolumePointLight[];
}
export interface RuntimeBoxVolumeCollection {
fog: RuntimeFogVolume[];
water: RuntimeWaterVolume[];
light: RuntimeLightVolume[];
}
export interface RuntimeTerrain {
id: string;
name?: string;
visible: boolean;
collisionEnabled: boolean;
position: Vec3;
sampleCountX: number;
sampleCountZ: number;
cellSize: number;
heights: number[];
layers: Array<{
materialId: string | null;
material: MaterialDef | null;
}>;
paintWeights: number[];
}
export interface RuntimeTerrainHeightfieldCollider {
kind: "heightfield";
source: "terrain";
terrainId: string;
position: Vec3;
rows: number;
cols: number;
heights: Float32Array;
minX: number;
maxX: number;
minZ: number;
maxZ: number;
worldBounds: {
min: Vec3;
max: Vec3;
};
}
export interface RuntimeBrushTriMeshCollider {
kind: "trimesh";
source: "brush";
brushId: string;
center: Vec3;
rotationDegrees: Vec3;
vertices: Float32Array;
indices: Uint32Array;
worldBounds: {
min: Vec3;
max: Vec3;
};
}
export interface RuntimeNpcCollider {
kind: "character";
source: "npc";
entityId: string;
position: Vec3;
rotationDegrees: Vec3;
shape: FirstPersonPlayerShape;
worldBounds: {
min: Vec3;
max: Vec3;
};
}
export type RuntimeSceneCollider =
| RuntimeBrushTriMeshCollider
| RuntimeTerrainHeightfieldCollider
| GeneratedModelCollider
| RuntimeNpcCollider;
export interface RuntimeSceneBounds {
min: Vec3;
max: Vec3;
center: Vec3;
size: Vec3;
}
export interface RuntimePlayerStart {
entityId: string;
position: Vec3;
yawDegrees: number;
navigationMode: RuntimeNavigationMode;
interactionReachMeters: number;
interactionAngleDegrees: number;
allowLookInputTargetSwitch: boolean;
targetButtonCyclesActiveTarget: boolean;
movement: RuntimePlayerMovement;
inputBindings: PlayerStartInputBindings;
collider: FirstPersonPlayerShape;
}
export interface RuntimePlayerMovement {
templateKind: PlayerStartMovementTemplate["kind"];
moveSpeed: number;
maxSpeed: number;
maxStepHeight: number;
capabilities: PlayerStartMovementCapabilities;
jump: PlayerStartJumpSettings;
sprint: PlayerStartSprintSettings;
crouch: PlayerStartCrouchSettings;
}
export interface RuntimeSceneEntry {
entityId: string;
position: Vec3;
yawDegrees: number;
}
interface RuntimeCameraRigBase {
entityId: string;
priority: number;
defaultActive: boolean;
target: CameraRigTargetRef;
targetOffset: Vec3;
transitionMode: CameraRigTransitionMode;
transitionDurationSeconds: number;
lookAround: CameraRigLookAroundSettings;
}
export interface RuntimeFixedCameraRig extends RuntimeCameraRigBase {
rigType: "fixed";
position: Vec3;
}
interface RuntimeRailCameraRigBase extends RuntimeCameraRigBase {
rigType: "rail";
pathId: string;
railPlacementMode: CameraRigRailPlacementMode;
}
export interface RuntimeNearestRailCameraRig extends RuntimeRailCameraRigBase {
railPlacementMode: "nearestToTarget";
}
export interface RuntimeMappedRailCameraRig extends RuntimeRailCameraRigBase {
railPlacementMode: "mapTargetBetweenPoints";
trackStartPoint: Vec3;
trackEndPoint: Vec3;
railStartProgress: number;
railEndProgress: number;
}
export type RuntimeRailCameraRig =
| RuntimeNearestRailCameraRig
| RuntimeMappedRailCameraRig;
export type RuntimeCameraRig = RuntimeFixedCameraRig | RuntimeRailCameraRig;
export interface RuntimeNpc {
entityId: string;
actorId: string;
name?: string;
visible: boolean;
position: Vec3;
yawDegrees: number;
modelAssetId: string | null;
dialogues: ProjectDialogue[];
defaultDialogueId: string | null;
collider: FirstPersonPlayerShape;
activeRoutineTitle: string | null;
animationClipName: string | null;
animationLoop: boolean | undefined;
resolvedPath: RuntimeResolvedNpcPathState | null;
}
export interface RuntimeNpcDefinition extends RuntimeNpc {
active: boolean;
activeRoutineId: string | null;
authoredPosition: Vec3;
authoredYawDegrees: number;
}
export interface RuntimeResolvedNpcPathState {
pathId: string;
progressMode: "deriveFromTime";
speed: number;
loop: boolean;
smoothPath: boolean;
elapsedHours: number;
distance: number;
progress: number;
position: Vec3;
tangent: Vec3;
yawDegrees: number | null;
}
export interface RuntimeSoundEmitter {
entityId: string;
position: Vec3;
audioAssetId: string | null;
volume: number;
refDistance: number;
maxDistance: number;
autoplay: boolean;
loop: boolean;
}
export interface RuntimeTriggerVolume {
entityId: string;
position: Vec3;
size: Vec3;
triggerOnEnter: boolean;
triggerOnExit: boolean;
}
export interface RuntimeTeleportTarget {
entityId: string;
position: Vec3;
yawDegrees: number;
}
export interface RuntimeInteractable {
entityId: string;
position: Vec3;
radius: number;
prompt: string;
interactionEnabled: boolean;
}
export interface RuntimePointLight {
entityId: string;
enabled: boolean;
position: Vec3;
colorHex: string;
intensity: number;
distance: number;
}
export interface RuntimeSpotLight {
entityId: string;
enabled: boolean;
position: Vec3;
direction: Vec3;
colorHex: string;
intensity: number;
distance: number;
angleDegrees: number;
}
export interface RuntimeLocalLightCollection {
pointLights: RuntimePointLight[];
spotLights: RuntimeSpotLight[];
}
export interface RuntimeModelInstance {
instanceId: string;
assetId: string;
name?: string;
visible: boolean;
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
animationClipName?: string;
animationAutoplay?: boolean;
animationLoop?: boolean;
}
export interface RuntimePathPoint {
pointId: string;
position: Vec3;
}
export interface RuntimePathSegment {
index: number;
startPointId: string;
endPointId: string;
start: Vec3;
end: Vec3;
length: number;
distanceStart: number;
distanceEnd: number;
tangent: Vec3;
}
export interface RuntimePath {
id: string;
name?: string;
visible: boolean;
enabled: boolean;
loop: boolean;
points: RuntimePathPoint[];
segments: RuntimePathSegment[];
totalLength: number;
}
export interface RuntimeEntityCollection {
playerStarts: RuntimePlayerStart[];
sceneEntries: RuntimeSceneEntry[];
cameraRigs: RuntimeCameraRig[];
npcs: RuntimeNpc[];
soundEmitters: RuntimeSoundEmitter[];
triggerVolumes: RuntimeTriggerVolume[];
teleportTargets: RuntimeTeleportTarget[];
interactables: RuntimeInteractable[];
}
export interface RuntimeSpawnPoint {
source: "playerStart" | "sceneEntry" | "fallback";
entityId: string | null;
position: Vec3;
yawDegrees: number;
}
export interface RuntimeSceneDefinition {
time: ProjectTimeSettings;
scheduler: RuntimeProjectSchedulerState;
sequences: ProjectSequenceLibrary;
world: WorldSettings;
control: RuntimeControlSurfaceDefinition;
localLights: RuntimeLocalLightCollection;
brushes: RuntimeBoxBrushInstance[];
terrains: RuntimeTerrain[];
volumes: RuntimeBoxVolumeCollection;
staticColliders: RuntimeSceneCollider[];
colliders: RuntimeSceneCollider[];
sceneBounds: RuntimeSceneBounds | null;
modelInstances: RuntimeModelInstance[];
paths: RuntimePath[];
npcDefinitions: RuntimeNpcDefinition[];
entities: RuntimeEntityCollection;
interactionLinks: InteractionLink[];
playerStart: RuntimePlayerStart | null;
playerCollider: FirstPersonPlayerShape;
playerMovement: RuntimePlayerMovement;
playerInputBindings: PlayerStartInputBindings;
navigationMode: RuntimeNavigationMode;
spawn: RuntimeSpawnPoint;
}
export interface BuildRuntimeSceneOptions {
navigationMode?: RuntimeNavigationMode;
loadedModelAssets?: Record<string, LoadedModelAsset>;
runtimeClock?: RuntimeClockState;
sceneEntryId?: string | null;
}
export function resolveRuntimeNavigationMode(
playerStartEntity: ReturnType<typeof getPrimaryEnabledPlayerStartEntity>,
authoredOverride?: RuntimeNavigationMode
): RuntimeNavigationMode {
if (authoredOverride !== undefined) {
return authoredOverride;
}
return playerStartEntity?.navigationMode ?? "thirdPerson";
}
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function createRuntimeCharacterShape(
collider: CharacterColliderSettings
): FirstPersonPlayerShape {
switch (collider.mode) {
case "capsule":
return {
mode: "capsule",
radius: collider.capsuleRadius,
height: collider.capsuleHeight,
eyeHeight: collider.eyeHeight
};
case "box":
return {
mode: "box",
size: cloneVec3(collider.boxSize),
eyeHeight: collider.eyeHeight
};
case "none":
return {
mode: "none",
eyeHeight: collider.eyeHeight
};
}
}
function cloneRuntimeCharacterShape(
shape: FirstPersonPlayerShape
): FirstPersonPlayerShape {
switch (shape.mode) {
case "capsule":
return {
mode: "capsule",
radius: shape.radius,
height: shape.height,
eyeHeight: shape.eyeHeight
};
case "box":
return {
mode: "box",
size: cloneVec3(shape.size),
eyeHeight: shape.eyeHeight
};
case "none":
return {
mode: "none",
eyeHeight: shape.eyeHeight
};
}
}
function cloneRuntimeResolvedNpcPathState(
pathState: RuntimeResolvedNpcPathState
): RuntimeResolvedNpcPathState {
return {
pathId: pathState.pathId,
progressMode: pathState.progressMode,
speed: pathState.speed,
loop: pathState.loop,
smoothPath: pathState.smoothPath,
elapsedHours: pathState.elapsedHours,
distance: pathState.distance,
progress: pathState.progress,
position: cloneVec3(pathState.position),
tangent: cloneVec3(pathState.tangent),
yawDegrees: pathState.yawDegrees
};
}
export function createRuntimeNpcFromDefinition(
npc: RuntimeNpcDefinition
): RuntimeNpc {
return {
entityId: npc.entityId,
actorId: npc.actorId,
name: npc.name,
visible: npc.visible,
position: cloneVec3(npc.position),
yawDegrees: npc.yawDegrees,
modelAssetId: npc.modelAssetId,
dialogues: npc.dialogues.map(cloneProjectDialogue),
defaultDialogueId: npc.defaultDialogueId,
collider: cloneRuntimeCharacterShape(npc.collider),
activeRoutineTitle: npc.activeRoutineTitle,
animationClipName: npc.animationClipName,
animationLoop: npc.animationLoop,
resolvedPath:
npc.resolvedPath === null
? null
: cloneRuntimeResolvedNpcPathState(npc.resolvedPath)
};
}
function clonePlayerStartMovementCapabilities(
capabilities: PlayerStartMovementCapabilities
): PlayerStartMovementCapabilities {
return {
jump: capabilities.jump,
sprint: capabilities.sprint,
crouch: capabilities.crouch
};
}
function clonePlayerStartJumpSettings(
jump: PlayerStartJumpSettings
): PlayerStartJumpSettings {
return {
speed: jump.speed,
bufferMs: jump.bufferMs,
coyoteTimeMs: jump.coyoteTimeMs,
variableHeight: jump.variableHeight,
maxHoldMs: jump.maxHoldMs,
moveWhileJumping: jump.moveWhileJumping,
moveWhileFalling: jump.moveWhileFalling,
directionOnly: jump.directionOnly,
bunnyHop: jump.bunnyHop,
bunnyHopBoost: jump.bunnyHopBoost
};
}
function clonePlayerStartSprintSettings(
sprint: PlayerStartSprintSettings
): PlayerStartSprintSettings {
return {
speedMultiplier: sprint.speedMultiplier
};
}
function clonePlayerStartCrouchSettings(
crouch: PlayerStartCrouchSettings
): PlayerStartCrouchSettings {
return {
speedMultiplier: crouch.speedMultiplier
};
}
function cloneRuntimePlayerMovement(
movement: RuntimePlayerMovement
): RuntimePlayerMovement {
return {
templateKind: movement.templateKind,
moveSpeed: movement.moveSpeed,
maxSpeed: movement.maxSpeed,
maxStepHeight: movement.maxStepHeight,
capabilities: clonePlayerStartMovementCapabilities(movement.capabilities),
jump: clonePlayerStartJumpSettings(movement.jump),
sprint: clonePlayerStartSprintSettings(movement.sprint),
crouch: clonePlayerStartCrouchSettings(movement.crouch)
};
}
function buildRuntimePlayerMovement(
template: PlayerStartMovementTemplate | undefined
): RuntimePlayerMovement {
const resolvedTemplate = createPlayerStartMovementTemplate(template);
return {
templateKind: resolvedTemplate.kind,
moveSpeed: resolvedTemplate.moveSpeed,
maxSpeed: resolvedTemplate.maxSpeed,
maxStepHeight: resolvedTemplate.maxStepHeight,
capabilities: clonePlayerStartMovementCapabilities(
resolvedTemplate.capabilities
),
jump: clonePlayerStartJumpSettings(resolvedTemplate.jump),
sprint: clonePlayerStartSprintSettings(resolvedTemplate.sprint),
crouch: clonePlayerStartCrouchSettings(resolvedTemplate.crouch)
};
}
function resolveRuntimeMaterial(
document: SceneDocument,
materialId: string | null
): MaterialDef | null {
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: Brush,
document: SceneDocument
): RuntimeBoxBrushInstance {
return {
id: brush.id,
kind: brush.kind,
sideCount:
brush.kind === "radialPrism" || brush.kind === "cone"
? brush.sideCount
: undefined,
majorSegmentCount:
brush.kind === "torus" ? brush.majorSegmentCount : undefined,
tubeSegmentCount:
brush.kind === "torus" ? brush.tubeSegmentCount : undefined,
visible: brush.visible,
center: cloneVec3(brush.center),
rotationDegrees: cloneVec3(brush.rotationDegrees),
size: cloneVec3(brush.size),
geometry: cloneBrushGeometry(brush.geometry),
volume: cloneBoxBrushVolumeSettings(brush.volume),
faces: Object.fromEntries(
getBrushFaceIds(brush).map((faceId) => {
const face = brush.faces[faceId];
return [
faceId,
{
materialId: face.materialId,
material: resolveRuntimeMaterial(document, face.materialId),
uv: cloneFaceUvState(face.uv)
}
];
})
) as Record<WhiteboxFaceId, RuntimeBrushFace>
};
}
function buildRuntimeFogVolume(brush: Brush): RuntimeFogVolume {
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: Brush): RuntimeWaterVolume {
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 buildRuntimeLightVolume(brush: Brush): RuntimeLightVolume {
if (brush.volume.mode !== "light") {
throw new Error(
`Cannot build light volume from non-light brush ${brush.id}.`
);
}
return {
brushId: brush.id,
enabled: brush.visible,
center: cloneVec3(brush.center),
rotationDegrees: cloneVec3(brush.rotationDegrees),
size: cloneVec3(brush.size),
colorHex: brush.volume.light.colorHex,
intensity: brush.volume.light.intensity,
padding: brush.volume.light.padding,
falloff: brush.volume.light.falloff,
lights: deriveBoxLightVolumePointLights({
size: brush.size,
intensity: brush.volume.light.intensity,
padding: brush.volume.light.padding,
falloff: brush.volume.light.falloff
})
};
}
function buildRuntimeCollider(brush: Brush): RuntimeBrushTriMeshCollider {
const bounds = getBrushBounds(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 buildRuntimeTerrain(
terrain: Terrain,
document: SceneDocument
): RuntimeTerrain {
return {
id: terrain.id,
name: terrain.name,
visible: terrain.visible,
collisionEnabled: terrain.collisionEnabled,
position: cloneVec3(terrain.position),
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: terrain.cellSize,
heights: [...terrain.heights],
layers: terrain.layers.map((layer) => ({
materialId: layer.materialId,
material: resolveRuntimeMaterial(document, layer.materialId)
})),
paintWeights: [...terrain.paintWeights]
};
}
function buildRuntimeTerrainCollider(
terrain: Terrain
): RuntimeTerrainHeightfieldCollider | null {
if (!terrain.collisionEnabled) {
return null;
}
const bounds = getTerrainBounds(terrain);
return {
kind: "heightfield",
source: "terrain",
terrainId: terrain.id,
position: cloneVec3(terrain.position),
rows: terrain.sampleCountX,
cols: terrain.sampleCountZ,
heights: new Float32Array(terrain.heights),
minX: 0,
maxX: getTerrainFootprintWidth(terrain),
minZ: 0,
maxZ: getTerrainFootprintDepth(terrain),
worldBounds: {
min: cloneVec3(bounds.min),
max: cloneVec3(bounds.max)
}
};
}
function buildRuntimeModelInstance(
modelInstance: SceneDocument["modelInstances"][string]
): RuntimeModelInstance {
return {
instanceId: modelInstance.id,
assetId: modelInstance.assetId,
name: modelInstance.name,
visible: modelInstance.visible,
position: cloneVec3(modelInstance.position),
rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
scale: cloneVec3(modelInstance.scale),
animationClipName: modelInstance.animationClipName,
animationAutoplay: modelInstance.animationAutoplay,
animationLoop: undefined
};
}
function buildRuntimePathPoint(point: ScenePathPoint): RuntimePathPoint {
return {
pointId: point.id,
position: cloneVec3(point.position)
};
}
function buildRuntimePath(path: ScenePath): RuntimePath {
const resolvedPath = resolveScenePath(path);
return {
id: path.id,
name: path.name,
visible: path.visible,
enabled: path.enabled,
loop: path.loop,
points: resolvedPath.points.map(buildRuntimePathPoint),
segments: resolvedPath.segments.map((segment) => ({
index: segment.index,
startPointId: segment.startPointId,
endPointId: segment.endPointId,
start: cloneVec3(segment.start),
end: cloneVec3(segment.end),
length: segment.length,
distanceStart: segment.distanceStart,
distanceEnd: segment.distanceEnd,
tangent: cloneVec3(segment.tangent)
})),
totalLength: resolvedPath.totalLength
};
}
function createRuntimePathLookup(
paths: RuntimePath[]
): ReadonlyMap<string, RuntimePath> {
return new Map(paths.map((path) => [path.id, path]));
}
function cloneResolvedActorPathToNpcPathState(
pathState: NonNullable<
RuntimeResolvedProjectScheduleState["actors"][number]["resolvedPath"]
>
): RuntimeResolvedNpcPathState {
return {
pathId: pathState.pathId,
progressMode: pathState.progressMode,
speed: pathState.speed,
loop: pathState.loop,
smoothPath: pathState.smoothPath,
elapsedHours: pathState.elapsedHours,
distance: pathState.distance,
progress: pathState.progress,
position: cloneVec3(pathState.position),
tangent: cloneVec3(pathState.tangent),
yawDegrees: pathState.yawDegrees
};
}
export function applyActorScheduleStateToNpcDefinition(
npc: RuntimeNpcDefinition,
actorState: RuntimeResolvedProjectScheduleState["actors"][number] | null
) {
npc.active = actorState?.active ?? true;
npc.activeRoutineId = actorState?.activeRoutineId ?? null;
npc.activeRoutineTitle = actorState?.activeRoutineTitle ?? null;
npc.animationClipName = actorState?.animationEffect?.clipName ?? null;
npc.animationLoop = actorState?.animationEffect?.loop;
npc.resolvedPath =
actorState?.resolvedPath === null || actorState?.resolvedPath === undefined
? null
: cloneResolvedActorPathToNpcPathState(actorState.resolvedPath);
if (npc.resolvedPath !== null) {
npc.position = cloneVec3(npc.resolvedPath.position);
npc.yawDegrees = npc.resolvedPath.yawDegrees ?? npc.authoredYawDegrees;
return;
}
npc.position = cloneVec3(npc.authoredPosition);
npc.yawDegrees = npc.authoredYawDegrees;
}
function getColliderBounds(
collider: RuntimeSceneCollider
): GeneratedColliderBounds {
return {
min: cloneVec3(collider.worldBounds.min),
max: cloneVec3(collider.worldBounds.max)
};
}
export function buildRuntimeNpcCollider(
npc: RuntimeNpc | RuntimeNpcDefinition
): RuntimeNpcCollider | null {
if (npc.collider.mode === "none") {
return null;
}
const rotationDegrees = {
x: 0,
y: npc.yawDegrees,
z: 0
};
switch (npc.collider.mode) {
case "capsule": {
return {
kind: "character",
source: "npc",
entityId: npc.entityId,
position: cloneVec3(npc.position),
rotationDegrees,
shape: {
mode: "capsule",
radius: npc.collider.radius,
height: npc.collider.height,
eyeHeight: npc.collider.eyeHeight
},
worldBounds: {
min: {
x: npc.position.x - npc.collider.radius,
y: npc.position.y,
z: npc.position.z - npc.collider.radius
},
max: {
x: npc.position.x + npc.collider.radius,
y: npc.position.y + npc.collider.height,
z: npc.position.z + npc.collider.radius
}
}
};
}
case "box": {
const halfExtents = {
x: npc.collider.size.x * 0.5,
y: npc.collider.size.y * 0.5,
z: npc.collider.size.z * 0.5
};
const yawRadians = (rotationDegrees.y * Math.PI) / 180;
const cosine = Math.abs(Math.cos(yawRadians));
const sine = Math.abs(Math.sin(yawRadians));
const rotatedHalfX = halfExtents.x * cosine + halfExtents.z * sine;
const rotatedHalfZ = halfExtents.x * sine + halfExtents.z * cosine;
return {
kind: "character",
source: "npc",
entityId: npc.entityId,
position: cloneVec3(npc.position),
rotationDegrees,
shape: {
mode: "box",
size: cloneVec3(npc.collider.size),
eyeHeight: npc.collider.eyeHeight
},
worldBounds: {
min: {
x: npc.position.x - rotatedHalfX,
y: npc.position.y,
z: npc.position.z - rotatedHalfZ
},
max: {
x: npc.position.x + rotatedHalfX,
y: npc.position.y + npc.collider.size.y,
z: npc.position.z + rotatedHalfZ
}
}
};
}
}
}
function combineColliderBounds(
colliders: RuntimeSceneCollider[]
): RuntimeSceneBounds | null {
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 getRuntimeTerrainSceneBounds(terrain: RuntimeTerrain): RuntimeSceneBounds {
const bounds = getTerrainBounds({
...terrain,
kind: "terrain",
enabled: true
});
const min = cloneVec3(bounds.min);
const max = cloneVec3(bounds.max);
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 combineSceneBounds(
colliderBounds: RuntimeSceneBounds | null,
terrains: RuntimeTerrain[]
): RuntimeSceneBounds | null {
const terrainBounds = terrains.map(getRuntimeTerrainSceneBounds);
if (colliderBounds === null && terrainBounds.length === 0) {
return null;
}
const min = colliderBounds?.min
? cloneVec3(colliderBounds.min)
: cloneVec3(terrainBounds[0]!.min);
const max = colliderBounds?.max
? cloneVec3(colliderBounds.max)
: cloneVec3(terrainBounds[0]!.max);
for (const bounds of terrainBounds) {
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: RuntimeSceneBounds | null
): RuntimeSpawnPoint {
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
};
}
interface RuntimeSceneCollections {
entities: RuntimeEntityCollection;
localLights: RuntimeLocalLightCollection;
npcDefinitions: RuntimeNpcDefinition[];
scheduler: RuntimeResolvedProjectScheduleState;
}
function buildRuntimeControlSurface(
document: SceneDocument,
collections: RuntimeSceneCollections,
modelInstances: RuntimeModelInstance[]
): RuntimeControlSurfaceDefinition {
const targets: RuntimeControlSurfaceDefinition["targets"] = [];
const channels: RuntimeControlSurfaceDefinition["channels"] = [];
const resolved = createEmptyRuntimeResolvedControlState();
const defaultSource = createDefaultResolvedControlSource();
const seenActorIds = new Set<string>();
const globalTarget = createProjectGlobalControlTargetRef();
const sceneTarget = createActiveSceneControlTargetRef();
const ambientLightDescriptor =
createAmbientLightIntensityControlChannelDescriptor({
target: sceneTarget,
defaultValue: document.world.ambientLight.intensity
});
const sunLightDescriptor = createSunLightIntensityControlChannelDescriptor({
target: sceneTarget,
defaultValue: document.world.sunLight.intensity
});
targets.push(
createControlTargetDescriptor(globalTarget, ["projectTimePause"])
);
resolved.discrete.push(
createResolvedProjectTimePausedState({
target: globalTarget,
value: false,
source: defaultSource
}),
createResolvedCameraRigOverrideState({
target: globalTarget,
entityId: null,
source: defaultSource
})
);
targets.push(
createControlTargetDescriptor(sceneTarget, [
"ambientLightIntensity",
"ambientLightColor",
"sunLightIntensity",
"sunLightColor"
])
);
channels.push(ambientLightDescriptor, sunLightDescriptor);
resolved.discrete.push(
createResolvedAmbientLightColorState({
target: sceneTarget,
value: document.world.ambientLight.colorHex,
source: defaultSource
}),
createResolvedSunLightColorState({
target: sceneTarget,
value: document.world.sunLight.colorHex,
source: defaultSource
})
);
resolved.channels.push(
createResolvedAmbientLightIntensityChannelValue({
descriptor: ambientLightDescriptor,
value: document.world.ambientLight.intensity,
source: defaultSource
}),
createResolvedSunLightIntensityChannelValue({
descriptor: sunLightDescriptor,
value: document.world.sunLight.intensity,
source: defaultSource
})
);
for (const npc of collections.npcDefinitions) {
if (seenActorIds.has(npc.actorId)) {
continue;
}
seenActorIds.add(npc.actorId);
const target = createActorControlTargetRef(npc.actorId);
const capabilities: Array<
"actorPresence" | "actorAnimationPlayback" | "actorPathFollow"
> = ["actorPresence"];
const actorModelAsset =
npc.modelAssetId === null ? undefined : document.assets[npc.modelAssetId];
if (
actorModelAsset !== undefined &&
actorModelAsset.kind === "model" &&
actorModelAsset.metadata.animationNames.length > 0
) {
capabilities.push("actorAnimationPlayback");
}
if (getScenePaths(document.paths).some((path) => path.enabled)) {
capabilities.push("actorPathFollow");
}
targets.push(
createControlTargetDescriptor(target, capabilities)
);
resolved.discrete.push(
createResolvedActorAnimationPlaybackState({
target,
clipName: null,
loop: undefined,
source: defaultSource
}),
createResolvedActorPathAssignmentState({
target,
pathId: null,
speed: null,
loop: false,
smoothPath: true,
progressMode: null,
source: defaultSource
})
);
}
for (const cameraRig of collections.entities.cameraRigs) {
targets.push(
createControlTargetDescriptor(
createCameraRigControlTargetRef(cameraRig.entityId),
["cameraRigOverride"]
)
);
}
for (const pointLight of collections.localLights.pointLights) {
const target = createLightControlTargetRef(
"pointLight",
pointLight.entityId
);
const descriptor = createLightIntensityControlChannelDescriptor({
target,
defaultValue: pointLight.intensity
});
targets.push(
createControlTargetDescriptor(target, [
"lightEnabled",
"lightIntensity",
"lightColor"
])
);
channels.push(descriptor);
resolved.discrete.push(
createResolvedLightEnabledState({
target,
value: pointLight.enabled,
source: defaultSource
}),
createResolvedLightColorState({
target,
value: pointLight.colorHex,
source: defaultSource
})
);
resolved.channels.push(
createResolvedLightIntensityChannelValue({
descriptor,
value: pointLight.intensity,
source: defaultSource
})
);
}
for (const spotLight of collections.localLights.spotLights) {
const target = createLightControlTargetRef("spotLight", spotLight.entityId);
const descriptor = createLightIntensityControlChannelDescriptor({
target,
defaultValue: spotLight.intensity
});
targets.push(
createControlTargetDescriptor(target, [
"lightEnabled",
"lightIntensity",
"lightColor"
])
);
channels.push(descriptor);
resolved.discrete.push(
createResolvedLightEnabledState({
target,
value: spotLight.enabled,
source: defaultSource
}),
createResolvedLightColorState({
target,
value: spotLight.colorHex,
source: defaultSource
})
);
resolved.channels.push(
createResolvedLightIntensityChannelValue({
descriptor,
value: spotLight.intensity,
source: defaultSource
})
);
}
for (const soundEmitter of collections.entities.soundEmitters) {
if (soundEmitter.audioAssetId === null) {
continue;
}
const target = createSoundEmitterControlTargetRef(soundEmitter.entityId);
const descriptor = createSoundVolumeControlChannelDescriptor({
target,
defaultValue: soundEmitter.volume
});
targets.push(
createControlTargetDescriptor(target, ["soundPlayback", "soundVolume"])
);
channels.push(descriptor);
resolved.discrete.push(
createResolvedSoundPlaybackState({
target,
value: soundEmitter.autoplay,
source: defaultSource
})
);
resolved.channels.push(
createResolvedSoundVolumeChannelValue({
descriptor,
value: soundEmitter.volume,
source: defaultSource
})
);
}
for (const interactable of collections.entities.interactables) {
const target = createInteractionControlTargetRef(
"interactable",
interactable.entityId
);
targets.push(
createControlTargetDescriptor(target, ["interactionAvailability"])
);
resolved.discrete.push(
createResolvedInteractionEnabledState({
target,
value: interactable.interactionEnabled,
source: defaultSource
})
);
}
for (const modelInstance of modelInstances) {
const authoredModelInstance =
document.modelInstances[modelInstance.instanceId];
const asset =
authoredModelInstance === undefined
? undefined
: document.assets[authoredModelInstance.assetId];
const target = createModelInstanceControlTargetRef(modelInstance.instanceId);
const hasAnimationPlayback =
asset !== undefined &&
asset.kind === "model" &&
asset.metadata.animationNames.length > 0;
const capabilities: Array<"animationPlayback" | "modelVisibility"> = [
"modelVisibility"
];
if (hasAnimationPlayback) {
capabilities.unshift("animationPlayback");
}
targets.push(createControlTargetDescriptor(target, capabilities));
resolved.discrete.push(
createResolvedModelInstanceVisibilityState({
target,
value: modelInstance.visible,
source: defaultSource
})
);
if (hasAnimationPlayback) {
resolved.discrete.push(
createResolvedModelAnimationPlaybackState({
target,
clipName:
modelInstance.animationAutoplay === true &&
typeof modelInstance.animationClipName === "string"
? modelInstance.animationClipName
: null,
loop: modelInstance.animationLoop,
source: defaultSource
})
);
}
}
return createRuntimeControlSurfaceDefinition({
targets,
channels,
baselineResolved: resolved,
resolved: applyRuntimeProjectScheduleToControlState(
resolved,
collections.scheduler,
resolved
)
});
}
function buildRuntimeSceneCollections(
document: SceneDocument,
runtimeClock: RuntimeClockState | null,
paths: RuntimePath[]
): RuntimeSceneCollections {
const runtimeEntities: RuntimeEntityCollection = {
playerStarts: [],
sceneEntries: [],
cameraRigs: [],
npcs: [],
soundEmitters: [],
triggerVolumes: [],
teleportTargets: [],
interactables: []
};
const localLights: RuntimeLocalLightCollection = {
pointLights: [],
spotLights: []
};
const npcDefinitions: RuntimeNpcDefinition[] = [];
for (const entity of getEntityInstances(document.entities)) {
if (!entity.enabled) {
continue;
}
switch (entity.kind) {
case "pointLight":
localLights.pointLights.push({
entityId: entity.id,
enabled: entity.visible,
position: cloneVec3(entity.position),
colorHex: entity.colorHex,
intensity: entity.intensity,
distance: entity.distance
});
break;
case "spotLight":
localLights.spotLights.push({
entityId: entity.id,
enabled: entity.visible,
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,
navigationMode: entity.navigationMode,
interactionReachMeters: entity.interactionReachMeters,
interactionAngleDegrees: entity.interactionAngleDegrees,
allowLookInputTargetSwitch: entity.allowLookInputTargetSwitch,
targetButtonCyclesActiveTarget: entity.targetButtonCyclesActiveTarget,
movement: buildRuntimePlayerMovement(entity.movementTemplate),
inputBindings: clonePlayerStartInputBindings(entity.inputBindings),
collider: buildRuntimePlayerShape(entity)
});
break;
case "sceneEntry":
runtimeEntities.sceneEntries.push({
entityId: entity.id,
position: cloneVec3(entity.position),
yawDegrees: entity.yawDegrees
});
break;
case "cameraRig":
runtimeEntities.cameraRigs.push(
entity.rigType === "fixed"
? {
entityId: entity.id,
rigType: "fixed",
priority: entity.priority,
defaultActive: entity.defaultActive,
position: cloneVec3(entity.position),
target: cloneCameraRigTargetRef(entity.target),
targetOffset: cloneVec3(entity.targetOffset),
transitionMode: entity.transitionMode,
transitionDurationSeconds: entity.transitionDurationSeconds,
lookAround: cloneCameraRigLookAroundSettings(entity.lookAround)
}
: entity.railPlacementMode === "mapTargetBetweenPoints"
? {
entityId: entity.id,
rigType: "rail",
priority: entity.priority,
defaultActive: entity.defaultActive,
pathId: entity.pathId,
railPlacementMode: "mapTargetBetweenPoints",
trackStartPoint: cloneVec3(entity.trackStartPoint),
trackEndPoint: cloneVec3(entity.trackEndPoint),
railStartProgress: entity.railStartProgress,
railEndProgress: entity.railEndProgress,
target: cloneCameraRigTargetRef(entity.target),
targetOffset: cloneVec3(entity.targetOffset),
transitionMode: entity.transitionMode,
transitionDurationSeconds: entity.transitionDurationSeconds,
lookAround: cloneCameraRigLookAroundSettings(
entity.lookAround
)
}
: {
entityId: entity.id,
rigType: "rail",
priority: entity.priority,
defaultActive: entity.defaultActive,
pathId: entity.pathId,
railPlacementMode: "nearestToTarget",
target: cloneCameraRigTargetRef(entity.target),
targetOffset: cloneVec3(entity.targetOffset),
transitionMode: entity.transitionMode,
transitionDurationSeconds: entity.transitionDurationSeconds,
lookAround: cloneCameraRigLookAroundSettings(
entity.lookAround
)
}
);
break;
case "npc": {
const npc: RuntimeNpcDefinition = {
entityId: entity.id,
actorId: entity.actorId,
name: entity.name,
visible: entity.visible,
position: cloneVec3(entity.position),
active: true,
activeRoutineId: null,
activeRoutineTitle: null,
authoredPosition: cloneVec3(entity.position),
yawDegrees: entity.yawDegrees,
authoredYawDegrees: entity.yawDegrees,
modelAssetId: entity.modelAssetId,
dialogues: entity.dialogues.map(cloneProjectDialogue),
defaultDialogueId: entity.defaultDialogueId,
collider: createRuntimeCharacterShape(entity.collider),
animationClipName: null,
animationLoop: undefined,
resolvedPath: null
};
npcDefinitions.push(npc);
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,
interactionEnabled: entity.interactionEnabled
});
break;
default:
assertNever(entity);
}
}
const scheduler = resolveRuntimeProjectScheduleState({
scheduler: document.scheduler,
sequences: document.sequences,
actorIds: npcDefinitions.map((npc) => npc.actorId),
dayNumber:
runtimeClock === null
? document.time.startDayNumber
: runtimeClock.dayCount + 1,
timeOfDayHours:
runtimeClock === null
? document.time.startTimeOfDayHours
: runtimeClock.timeOfDayHours,
pathsById: createRuntimePathLookup(paths)
});
const scheduleByActorId = new Map(
scheduler.actors.map((actorState) => [actorState.actorId, actorState])
);
for (const npc of npcDefinitions) {
const actorState = scheduleByActorId.get(npc.actorId);
applyActorScheduleStateToNpcDefinition(npc, actorState ?? null);
if (npc.active) {
runtimeEntities.npcs.push(createRuntimeNpcFromDefinition(npc));
}
}
return {
entities: runtimeEntities,
localLights,
npcDefinitions,
scheduler
};
}
function assertNever(value: never): never {
throw new Error(
`Unsupported runtime entity: ${String((value as EntityInstance).kind)}`
);
}
function buildRuntimePlayerShape(
playerStartEntity: ReturnType<typeof getPrimaryEnabledPlayerStartEntity>
): FirstPersonPlayerShape {
if (playerStartEntity === null) {
return FIRST_PERSON_PLAYER_SHAPE;
}
return createRuntimeCharacterShape(playerStartEntity.collider);
}
function resolveRuntimeSpawn(
playerStart: RuntimePlayerStart | null,
sceneEntries: RuntimeSceneEntry[],
sceneBounds: RuntimeSceneBounds | null,
sceneEntryId: string | null | undefined
): RuntimeSpawnPoint {
if (sceneEntryId !== undefined && sceneEntryId !== null) {
const sceneEntry =
sceneEntries.find((entry) => entry.entityId === sceneEntryId) ?? null;
if (sceneEntry === null) {
throw new Error(
`Runtime build could not resolve Scene Entry ${sceneEntryId}.`
);
}
return {
source: "sceneEntry",
entityId: sceneEntry.entityId,
position: cloneVec3(sceneEntry.position),
yawDegrees: sceneEntry.yawDegrees
};
}
if (playerStart !== null) {
return {
source: "playerStart",
entityId: playerStart.entityId,
position: cloneVec3(playerStart.position),
yawDegrees: playerStart.yawDegrees
};
}
return buildFallbackSpawn(sceneBounds);
}
export function buildRuntimeSceneFromDocument(
document: SceneDocument,
options: BuildRuntimeSceneOptions = {}
): RuntimeSceneDefinition {
const playerStartEntity = getPrimaryEnabledPlayerStartEntity(
document.entities
);
const navigationMode = resolveRuntimeNavigationMode(
playerStartEntity,
options.navigationMode
);
assertRuntimeSceneBuildable(document, {
navigationMode,
loadedModelAssets: options.loadedModelAssets
});
const enabledBrushes = Object.values(document.brushes).filter(
(brush) => brush.enabled
);
const brushes = enabledBrushes.map((brush) =>
buildRuntimeBrush(brush, document)
);
const enabledTerrains = getTerrains(document.terrains).filter(
(terrain) => terrain.enabled
);
const terrains = enabledTerrains.map((terrain) =>
buildRuntimeTerrain(terrain, document)
);
const staticColliders: RuntimeSceneCollider[] = [];
const volumes: RuntimeBoxVolumeCollection = {
fog: [],
water: [],
light: []
};
for (const brush of enabledBrushes) {
if (brush.volume.mode === "none") {
staticColliders.push(buildRuntimeCollider(brush));
continue;
}
if (brush.kind !== "box") {
staticColliders.push(buildRuntimeCollider(brush));
continue;
}
if (brush.volume.mode === "fog") {
volumes.fog.push(buildRuntimeFogVolume(brush));
continue;
}
if (brush.volume.mode === "light") {
volumes.light.push(buildRuntimeLightVolume(brush));
continue;
}
volumes.water.push(buildRuntimeWaterVolume(brush));
}
for (const terrain of enabledTerrains) {
const terrainCollider = buildRuntimeTerrainCollider(terrain);
if (terrainCollider !== null) {
staticColliders.push(terrainCollider);
}
}
const enabledModelInstances = getModelInstances(
document.modelInstances
).filter((modelInstance) => modelInstance.enabled);
const modelInstances = enabledModelInstances.map(buildRuntimeModelInstance);
const paths = getScenePaths(document.paths)
.filter((path) => path.enabled)
.map(buildRuntimePath);
const collections = buildRuntimeSceneCollections(
document,
options.runtimeClock ?? null,
paths
);
const control = buildRuntimeControlSurface(
document,
collections,
modelInstances
);
const enabledBrushIds = new Set(enabledBrushes.map((brush) => brush.id));
const enabledModelInstanceIds = new Set(
enabledModelInstances.map((modelInstance) => modelInstance.id)
);
const enabledEntityIds = new Set(
getEntityInstances(document.entities)
.filter((entity) => entity.enabled)
.map((entity) => entity.id)
);
const interactionLinks = getInteractionLinks(document.interactionLinks)
.filter((link) => {
if (!enabledEntityIds.has(link.sourceEntityId)) {
return false;
}
switch (link.action.type) {
case "teleportPlayer":
return enabledEntityIds.has(link.action.targetEntityId);
case "toggleVisibility":
return enabledBrushIds.has(link.action.targetBrushId);
case "playAnimation":
case "stopAnimation":
return enabledModelInstanceIds.has(link.action.targetModelInstanceId);
case "playSound":
case "stopSound":
return enabledEntityIds.has(link.action.targetSoundEmitterId);
case "runSequence":
return getInteractionLinkImpulseSteps(link, document.sequences).length > 0;
case "control":
switch (link.action.effect.target.kind) {
case "entity":
case "interaction":
return enabledEntityIds.has(link.action.effect.target.entityId);
case "modelInstance":
return enabledModelInstanceIds.has(
link.action.effect.target.modelInstanceId
);
}
}
})
.map((link) => cloneInteractionLink(link));
const playerCollider = buildRuntimePlayerShape(playerStartEntity);
const playerMovement = buildRuntimePlayerMovement(
playerStartEntity?.movementTemplate
);
const playerInputBindings = createPlayerStartInputBindings(
playerStartEntity?.inputBindings
);
const colliders = [...staticColliders];
for (const npc of collections.entities.npcs) {
const collider = buildRuntimeNpcCollider(npc);
if (collider !== null) {
colliders.push(collider);
}
}
for (const modelInstance of enabledModelInstances) {
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) {
staticColliders.push(generatedCollider);
colliders.push(generatedCollider);
}
}
const combinedSceneBounds = combineSceneBounds(
combineColliderBounds(colliders),
terrains
);
const playerStart =
playerStartEntity === null
? null
: {
entityId: playerStartEntity.id,
position: cloneVec3(playerStartEntity.position),
yawDegrees: playerStartEntity.yawDegrees,
navigationMode,
interactionReachMeters: playerStartEntity.interactionReachMeters,
interactionAngleDegrees: playerStartEntity.interactionAngleDegrees,
allowLookInputTargetSwitch:
playerStartEntity.allowLookInputTargetSwitch,
targetButtonCyclesActiveTarget:
playerStartEntity.targetButtonCyclesActiveTarget,
movement: cloneRuntimePlayerMovement(playerMovement),
inputBindings: clonePlayerStartInputBindings(playerInputBindings),
collider: playerCollider
};
return {
time: cloneProjectTimeSettings(document.time),
scheduler: createRuntimeProjectSchedulerState({
document: cloneProjectScheduler(document.scheduler),
resolved: collections.scheduler
}),
sequences: cloneProjectSequenceLibrary(document.sequences),
world: cloneWorldSettings(document.world),
control,
localLights: collections.localLights,
brushes,
terrains,
volumes,
staticColliders,
colliders,
sceneBounds: combinedSceneBounds,
modelInstances,
paths,
npcDefinitions: collections.npcDefinitions,
entities: collections.entities,
interactionLinks,
playerStart,
playerCollider,
playerMovement,
playerInputBindings,
navigationMode,
spawn: resolveRuntimeSpawn(
playerStart,
collections.entities.sceneEntries,
combinedSceneBounds,
options.sceneEntryId
)
};
}