import { AmbientLight, AnimationClip, AnimationMixer, BoxGeometry, DirectionalLight, Group, Mesh, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, Vector3, SpotLight, WebGLRenderer } from "three"; import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; import type { LoadedModelAsset } from "../assets/gltf-model-import"; import type { LoadedImageAsset } from "../assets/image-assets"; import type { ProjectAssetRecord } from "../assets/project-assets"; import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs"; import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; import { FirstPersonNavigationController } from "./first-person-navigation-controller"; import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext } from "./navigation-controller"; import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system"; import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller"; import type { RuntimeBoxBrushInstance, RuntimeLocalLightCollection, RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeTeleportTarget } from "./runtime-scene-build"; interface CachedMaterialTexture { signature: string; texture: ReturnType; } interface LocalLightRenderObjects { group: Group; } const FALLBACK_FACE_COLOR = 0x747d89; export class RuntimeHost { private readonly scene = new Scene(); private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000); private readonly cameraForward = new Vector3(); private readonly domElement: HTMLCanvasElement; private readonly ambientLight = new AmbientLight(); private readonly sunLight = new DirectionalLight(); private readonly localLightGroup = new Group(); private readonly brushGroup = new Group(); private readonly modelGroup = new Group(); private readonly firstPersonController = new FirstPersonNavigationController(); private readonly orbitVisitorController = new OrbitVisitorNavigationController(); private readonly interactionSystem = new RuntimeInteractionSystem(); private readonly brushMeshes = new Map>(); private readonly localLightObjects = new Map(); private readonly modelRenderObjects = new Map(); private readonly materialTextureCache = new Map(); private readonly animationMixers = new Map(); private readonly instanceAnimationClips = new Map(); private readonly controllerContext: RuntimeControllerContext; private readonly renderer: WebGLRenderer | null; private runtimeScene: RuntimeSceneDefinition | null = null; private currentWorld: RuntimeSceneDefinition["world"] | null = null; private projectAssets: Record = {}; private loadedModelAssets: Record = {}; private loadedImageAssets: Record = {}; private resizeObserver: ResizeObserver | null = null; private animationFrame = 0; private previousFrameTime = 0; private container: HTMLElement | null = null; private activeController: NavigationController | null = null; private runtimeMessageHandler: ((message: string | null) => void) | null = null; private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null; private interactionPromptHandler: ((prompt: RuntimeInteractionPrompt | null) => void) | null = null; private currentRuntimeMessage: string | null = null; private currentFirstPersonTelemetry: FirstPersonTelemetry | null = null; private currentInteractionPrompt: RuntimeInteractionPrompt | null = null; constructor(options: { enableRendering?: boolean } = {}) { const enableRendering = options.enableRendering ?? true; this.scene.add(this.ambientLight); this.scene.add(this.sunLight); this.scene.add(this.localLightGroup); this.scene.add(this.brushGroup); this.scene.add(this.modelGroup); this.renderer = enableRendering ? new WebGLRenderer({ antialias: true, alpha: true }) : null; this.domElement = this.renderer?.domElement ?? document.createElement("canvas"); if (this.renderer !== null) { this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setClearAlpha(0); } else { this.domElement.className = "runner-canvas__surface"; } this.controllerContext = { camera: this.camera, domElement: this.domElement, getRuntimeScene: () => { if (this.runtimeScene === null) { throw new Error("Runtime scene has not been loaded."); } return this.runtimeScene; }, setRuntimeMessage: (message) => { if (message === this.currentRuntimeMessage) { return; } this.currentRuntimeMessage = message; this.runtimeMessageHandler?.(message); }, setFirstPersonTelemetry: (telemetry) => { this.currentFirstPersonTelemetry = telemetry; this.firstPersonTelemetryHandler?.(telemetry); } }; } mount(container: HTMLElement) { this.container = container; container.appendChild(this.domElement); this.domElement.addEventListener("click", this.handleRuntimeClick); this.resize(); this.resizeObserver = new ResizeObserver(() => { this.resize(); }); this.resizeObserver.observe(container); this.previousFrameTime = performance.now(); this.render(); } loadScene(runtimeScene: RuntimeSceneDefinition) { 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); } updateAssets( projectAssets: Record, loadedModelAssets: Record, loadedImageAssets: Record ) { this.projectAssets = projectAssets; this.loadedModelAssets = loadedModelAssets; this.loadedImageAssets = loadedImageAssets; if (this.currentWorld !== null) { this.applyWorld(); } if (this.runtimeScene !== null) { this.rebuildModelInstances(this.runtimeScene.modelInstances); } } setNavigationMode(mode: RuntimeNavigationMode) { 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: ((message: string | null) => void) | null) { this.runtimeMessageHandler = handler; } setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) { this.firstPersonTelemetryHandler = handler; } setInteractionPromptHandler(handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null) { 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(); for (const cachedTexture of this.materialTextureCache.values()) { cachedTexture.texture.dispose(); } this.materialTextureCache.clear(); this.renderer?.dispose(); this.domElement.removeEventListener("click", this.handleRuntimeClick); if (this.container !== null && this.container.contains(this.domElement)) { this.container.removeChild(this.domElement); } this.container = null; } private 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; return; } this.scene.background = null; this.scene.environment = null; this.scene.environmentIntensity = 1; } private rebuildLocalLights(localLights: RuntimeLocalLightCollection) { 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); } } private createPointLightRuntimeObjects(pointLight: RuntimeLocalLightCollection["pointLights"][number]): LocalLightRenderObjects { 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 }; } private createSpotLightRuntimeObjects(spotLight: RuntimeLocalLightCollection["spotLights"][number]): LocalLightRenderObjects { 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 }; } private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) { this.clearBrushMeshes(); for (const brush of brushes) { const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z); applyBoxBrushFaceUvsToGeometry(geometry, brush); const materials = [ this.createFaceMaterial(brush.faces.posX.material), this.createFaceMaterial(brush.faces.negX.material), this.createFaceMaterial(brush.faces.posY.material), this.createFaceMaterial(brush.faces.negY.material), this.createFaceMaterial(brush.faces.posZ.material), this.createFaceMaterial(brush.faces.negZ.material) ]; const mesh = new Mesh(geometry, materials); mesh.position.set(brush.center.x, brush.center.y, brush.center.z); this.brushGroup.add(mesh); this.brushMeshes.set(brush.id, mesh); } } private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["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 }, 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(); } } } } } private createFaceMaterial(material: RuntimeBoxBrushInstance["faces"]["posX"]["material"]): MeshStandardMaterial { 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 }); } private getOrCreateTexture(material: NonNullable) { 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; } private clearLocalLights() { for (const renderGroup of this.localLightObjects.values()) { this.localLightGroup.remove(renderGroup); } this.localLightObjects.clear(); } private clearBrushMeshes() { for (const mesh of this.brushMeshes.values()) { this.brushGroup.remove(mesh); mesh.geometry.dispose(); for (const material of mesh.material) { material.dispose(); } } this.brushMeshes.clear(); } private 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(); } private 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); } private 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); if (this.runtimeScene !== null && this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null) { this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher()); this.camera.getWorldDirection(this.cameraForward); this.setInteractionPrompt( this.interactionSystem.resolveClickInteractionPrompt( this.currentFirstPersonTelemetry.eyePosition, { x: this.cameraForward.x, y: this.cameraForward.y, z: this.cameraForward.z }, this.runtimeScene ) ); } else { this.setInteractionPrompt(null); } this.renderer?.render(this.scene, this.camera); }; private applyTeleportPlayerAction(target: RuntimeTeleportTarget) { this.firstPersonController.teleportTo(target.position, target.yawDegrees); } private applyToggleBrushVisibilityAction(brushId: string, visible: boolean | undefined) { const mesh = this.brushMeshes.get(brushId); if (mesh === undefined) { return; } mesh.visible = visible ?? !mesh.visible; } private applyPlayAnimationAction(instanceId: string, clipName: string) { 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; } mixer.stopAllAction(); mixer.clipAction(clip).play(); } private applyStopAnimationAction(instanceId: string) { const mixer = this.animationMixers.get(instanceId); if (!mixer) { console.warn(`stopAnimation: no mixer for instance ${instanceId}`); return; } mixer.stopAllAction(); } private createInteractionDispatcher(): RuntimeInteractionDispatcher { return { teleportPlayer: (target) => { this.applyTeleportPlayerAction(target); }, toggleBrushVisibility: (brushId, visible) => { this.applyToggleBrushVisibilityAction(brushId, visible); }, playAnimation: (instanceId, clipName) => { this.applyPlayAnimationAction(instanceId, clipName); }, stopAnimation: (instanceId) => { this.applyStopAnimationAction(instanceId); } }; } private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) { 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); } private handleRuntimeClick = () => { if (this.runtimeScene === null || this.activeController !== this.firstPersonController || this.currentInteractionPrompt === null) { return; } this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher()); }; }