[add] playwright.config.js [add] src/app/App.js [add] src/app/editor-store.js [add] src/app/use-editor-store.js [add] src/assets/audio-assets.js [add] src/assets/gltf-model-import.js [add] src/assets/image-assets.js [add] src/assets/model-instance-labels.js [add] src/assets/model-instance-rendering.js [add] src/assets/model-instances.js [add] src/assets/project-asset-storage.js [add] src/assets/project-assets.js [add] src/commands/brush-command-helpers.js [add] src/commands/command-history.js [add] src/commands/command.js [add] src/commands/commit-transform-session-command.js [add] src/commands/create-box-brush-command.js [add] src/commands/delete-box-brush-command.js [add] src/commands/delete-entity-command.js [add] src/commands/delete-interaction-link-command.js [add] src/commands/delete-model-instance-command.js [add] src/commands/import-audio-asset-command.js [add] src/commands/import-background-image-asset-command.js [add] src/commands/import-model-asset-command.js [add] src/commands/move-box-brush-command.js [add] src/commands/resize-box-brush-command.js [add] src/commands/rotate-box-brush-command.js [add] src/commands/set-box-brush-face-material-command.js [add] src/commands/set-box-brush-face-uv-state-command.js [add] src/commands/set-box-brush-name-command.js [add] src/commands/set-box-brush-transform-command.js [add] src/commands/set-entity-name-command.js [add] src/commands/set-model-instance-name-command.js [add] src/commands/set-player-start-command.js [add] src/commands/set-scene-name-command.js [add] src/commands/set-world-settings-command.js [add] src/commands/upsert-entity-command.js [add] src/commands/upsert-interaction-link-command.js [add] src/commands/upsert-model-instance-command.js [add] src/core/ids.js [add] src/core/selection.js [add] src/core/tool-mode.js [add] src/core/transform-session.js [add] src/core/vector.js [add] src/core/whitebox-selection-feedback.js [add] src/core/whitebox-selection-mode.js [add] src/document/brushes.js [add] src/document/migrate-scene-document.js [add] src/document/scene-document-validation.js [add] src/document/scene-document.js [add] src/document/world-settings.js [add] src/entities/entity-instances.js [add] src/entities/entity-labels.js [add] src/geometry/box-brush-components.js [add] src/geometry/box-brush-mesh.js [add] src/geometry/box-brush.js [add] src/geometry/box-face-uvs.js [add] src/geometry/grid-snapping.js [add] src/geometry/model-instance-collider-debug-mesh.js [add] src/geometry/model-instance-collider-generation.js [add] src/interactions/interaction-links.js [add] src/main.js [add] src/materials/starter-material-library.js [add] src/materials/starter-material-textures.js [add] src/rendering/advanced-rendering.js [add] src/runner-web/RunnerCanvas.js [add] src/runtime-three/first-person-navigation-controller.js [add] src/runtime-three/navigation-controller.js [add] src/runtime-three/orbit-visitor-navigation-controller.js [add] src/runtime-three/player-collision.js [add] src/runtime-three/rapier-collision-world.js [add] src/runtime-three/runtime-audio-system.js [add] src/runtime-three/runtime-host.js [add] src/runtime-three/runtime-interaction-system.js [add] src/runtime-three/runtime-scene-build.js [add] src/runtime-three/runtime-scene-validation.js [add] src/serialization/local-draft-storage.js [add] src/serialization/scene-document-json.js [add] src/shared-ui/HierarchicalMenu.js [add] src/shared-ui/Panel.js [add] src/shared-ui/world-background-style.js [add] src/viewport-three/ViewportCanvas.js [add] src/viewport-three/ViewportPanel.js [add] src/viewport-three/viewport-entity-markers.js [add] src/viewport-three/viewport-focus.js [add] src/viewport-three/viewport-host.js [add] src/viewport-three/viewport-layout.js [add] src/viewport-three/viewport-transient-state.js [add] src/viewport-three/viewport-view-modes.js [add] tests/domain/box-brush-face-editing.command.test.js [add] tests/domain/build-runtime-scene.test.js [add] tests/domain/create-box-brush.command.test.js [add] tests/domain/create-empty-scene-document.test.js [add] tests/domain/editor-store.test.js [add] tests/domain/entity.command.test.js [add] tests/domain/interaction-links.validation.test.js [add] tests/domain/model-import.test.js [add] tests/domain/model-instance.command.test.js [add] tests/domain/player-start.command.test.js [add] tests/domain/rapier-collision-world.test.js [add] tests/domain/runtime-audio-system.test.js [add] tests/domain/runtime-interaction-system.test.js [add] tests/domain/runtime-scene-validation.test.js [add] tests/domain/scene-document-validation.test.js [add] tests/domain/transform-session.command.test.js [add] tests/domain/world-settings.command.test.js [add] tests/domain/world-settings.test.js [add] tests/e2e/app-smoke.e2e.js [add] tests/e2e/box-brush-authoring.e2e.js [add] tests/e2e/entities-foundation.e2e.js [add] tests/e2e/face-material-authoring.e2e.js [add] tests/e2e/first-room-workflow.e2e.js [add] tests/e2e/import-draco-model-asset.e2e.js [add] tests/e2e/import-external-model-asset.e2e.js [add] tests/e2e/import-model-asset.e2e.js [add] tests/e2e/local-lights-and-background.e2e.js [add] tests/e2e/orthographic-views.e2e.js [add] tests/e2e/runner-v1.e2e.js [add] tests/e2e/runtime-click-interaction.e2e.js [add] tests/e2e/runtime-trigger-teleport.e2e.js [add] tests/e2e/viewport-quad-layout.e2e.js [add] tests/e2e/viewport-test-helpers.js [add] tests/e2e/whitebox-component-selection.e2e.js [add] tests/e2e/world-environment.e2e.js [add] tests/geometry/box-brush-geometry.test.js [add] tests/geometry/box-face-uvs.test.js [add] tests/geometry/model-instance-collider-generation.test.js [add] tests/helpers/model-collider-fixtures.js [add] tests/serialization/local-draft-storage.test.js [add] tests/serialization/project-asset-storage.test.js [add] tests/serialization/scene-document-json.test.js [add] tests/setup/vitest.setup.js [add] tests/unit/audio-assets.test.js [add] tests/unit/entity-instances.test.js [add] tests/unit/package-scripts.test.js [add] tests/unit/transform-foundation.integration.test.js [add] tests/unit/viewport-canvas.test.js [add] tests/unit/viewport-entity-markers.test.js [add] tests/unit/viewport-focus.test.js [add] tests/unit/viewport-layout.test.js [add] tests/unit/viewport-view-modes.test.js [add] vite.config.js [add] vitest.config.js
565 lines
24 KiB
JavaScript
565 lines
24 KiB
JavaScript
import { AmbientLight, AnimationClip, AnimationMixer, BoxGeometry, DirectionalLight, Group, LoopOnce, LoopRepeat, Mesh, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, Vector3, SpotLight, WebGLRenderer } from "three";
|
|
import { EffectComposer } from "postprocessing";
|
|
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
|
|
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
|
|
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
|
import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer } from "../rendering/advanced-rendering";
|
|
import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings } from "../document/world-settings";
|
|
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
|
import { RapierCollisionWorld } from "./rapier-collision-world";
|
|
import { RuntimeInteractionSystem } from "./runtime-interaction-system";
|
|
import { RuntimeAudioSystem } from "./runtime-audio-system";
|
|
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
|
const FALLBACK_FACE_COLOR = 0x747d89;
|
|
export class RuntimeHost {
|
|
scene = new Scene();
|
|
camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
|
cameraForward = new Vector3();
|
|
domElement;
|
|
ambientLight = new AmbientLight();
|
|
sunLight = new DirectionalLight();
|
|
localLightGroup = new Group();
|
|
brushGroup = new Group();
|
|
modelGroup = new Group();
|
|
firstPersonController = new FirstPersonNavigationController();
|
|
orbitVisitorController = new OrbitVisitorNavigationController();
|
|
interactionSystem = new RuntimeInteractionSystem();
|
|
audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null);
|
|
brushMeshes = new Map();
|
|
localLightObjects = new Map();
|
|
modelRenderObjects = new Map();
|
|
materialTextureCache = new Map();
|
|
animationMixers = new Map();
|
|
instanceAnimationClips = new Map();
|
|
controllerContext;
|
|
renderer;
|
|
runtimeScene = null;
|
|
collisionWorld = null;
|
|
collisionWorldRequestId = 0;
|
|
currentWorld = null;
|
|
currentAdvancedRenderingSettings = null;
|
|
advancedRenderingComposer = null;
|
|
projectAssets = {};
|
|
loadedModelAssets = {};
|
|
loadedImageAssets = {};
|
|
resizeObserver = null;
|
|
animationFrame = 0;
|
|
previousFrameTime = 0;
|
|
container = null;
|
|
activeController = null;
|
|
runtimeMessageHandler = null;
|
|
firstPersonTelemetryHandler = null;
|
|
interactionPromptHandler = null;
|
|
currentRuntimeMessage = null;
|
|
currentFirstPersonTelemetry = null;
|
|
currentInteractionPrompt = null;
|
|
constructor(options = {}) {
|
|
const enableRendering = options.enableRendering ?? true;
|
|
this.scene.add(this.ambientLight);
|
|
this.scene.add(this.sunLight);
|
|
this.scene.add(this.localLightGroup);
|
|
this.scene.add(this.brushGroup);
|
|
this.scene.add(this.modelGroup);
|
|
this.renderer = enableRendering ? new WebGLRenderer({ antialias: true, alpha: true }) : null;
|
|
this.domElement = this.renderer?.domElement ?? document.createElement("canvas");
|
|
if (this.renderer !== null) {
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.renderer.setClearAlpha(0);
|
|
}
|
|
else {
|
|
this.domElement.className = "runner-canvas__surface";
|
|
}
|
|
this.controllerContext = {
|
|
camera: this.camera,
|
|
domElement: this.domElement,
|
|
getRuntimeScene: () => {
|
|
if (this.runtimeScene === null) {
|
|
throw new Error("Runtime scene has not been loaded.");
|
|
}
|
|
return this.runtimeScene;
|
|
},
|
|
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
|
|
setRuntimeMessage: (message) => {
|
|
if (message === this.currentRuntimeMessage) {
|
|
return;
|
|
}
|
|
this.currentRuntimeMessage = message;
|
|
this.runtimeMessageHandler?.(message);
|
|
},
|
|
setFirstPersonTelemetry: (telemetry) => {
|
|
this.currentFirstPersonTelemetry = telemetry;
|
|
this.firstPersonTelemetryHandler?.(telemetry);
|
|
}
|
|
};
|
|
}
|
|
mount(container) {
|
|
this.container = container;
|
|
container.appendChild(this.domElement);
|
|
this.domElement.addEventListener("click", this.handleRuntimeClick);
|
|
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown);
|
|
this.resize();
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.resize();
|
|
});
|
|
this.resizeObserver.observe(container);
|
|
this.previousFrameTime = performance.now();
|
|
this.render();
|
|
}
|
|
loadScene(runtimeScene) {
|
|
this.runtimeScene = runtimeScene;
|
|
this.currentWorld = runtimeScene.world;
|
|
this.interactionSystem.reset();
|
|
this.setInteractionPrompt(null);
|
|
this.applyWorld();
|
|
this.rebuildLocalLights(runtimeScene.localLights);
|
|
this.rebuildBrushMeshes(runtimeScene.brushes);
|
|
this.rebuildModelInstances(runtimeScene.modelInstances);
|
|
void this.rebuildCollisionWorld(runtimeScene.colliders, runtimeScene.playerCollider);
|
|
this.audioSystem.loadScene(runtimeScene);
|
|
}
|
|
updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets) {
|
|
this.projectAssets = projectAssets;
|
|
this.loadedModelAssets = loadedModelAssets;
|
|
this.loadedImageAssets = loadedImageAssets;
|
|
if (this.currentWorld !== null) {
|
|
this.applyWorld();
|
|
}
|
|
if (this.runtimeScene !== null) {
|
|
this.rebuildModelInstances(this.runtimeScene.modelInstances);
|
|
}
|
|
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
|
|
}
|
|
setNavigationMode(mode) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
const nextController = mode === "firstPerson" ? this.firstPersonController : this.orbitVisitorController;
|
|
if (this.activeController?.id === nextController.id) {
|
|
return;
|
|
}
|
|
if (this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null && nextController === this.orbitVisitorController) {
|
|
this.orbitVisitorController.setFocusPoint(this.currentFirstPersonTelemetry.feetPosition);
|
|
}
|
|
this.activeController?.deactivate(this.controllerContext);
|
|
this.interactionSystem.reset();
|
|
this.setInteractionPrompt(null);
|
|
this.activeController = nextController;
|
|
this.activeController.activate(this.controllerContext);
|
|
}
|
|
setRuntimeMessageHandler(handler) {
|
|
this.runtimeMessageHandler = handler;
|
|
this.audioSystem.setRuntimeMessageHandler(handler);
|
|
}
|
|
setFirstPersonTelemetryHandler(handler) {
|
|
this.firstPersonTelemetryHandler = handler;
|
|
}
|
|
setInteractionPromptHandler(handler) {
|
|
this.interactionPromptHandler = handler;
|
|
}
|
|
dispose() {
|
|
if (this.animationFrame !== 0) {
|
|
cancelAnimationFrame(this.animationFrame);
|
|
this.animationFrame = 0;
|
|
}
|
|
this.activeController?.deactivate(this.controllerContext);
|
|
this.activeController = null;
|
|
this.setInteractionPrompt(null);
|
|
this.resizeObserver?.disconnect();
|
|
this.resizeObserver = null;
|
|
this.clearLocalLights();
|
|
this.clearBrushMeshes();
|
|
this.clearModelInstances();
|
|
this.collisionWorldRequestId += 1;
|
|
this.clearCollisionWorld();
|
|
this.audioSystem.dispose();
|
|
this.advancedRenderingComposer?.dispose();
|
|
this.advancedRenderingComposer = null;
|
|
this.currentAdvancedRenderingSettings = null;
|
|
if (this.renderer !== null) {
|
|
this.renderer.autoClear = true;
|
|
}
|
|
for (const cachedTexture of this.materialTextureCache.values()) {
|
|
cachedTexture.texture.dispose();
|
|
}
|
|
this.materialTextureCache.clear();
|
|
this.renderer?.dispose();
|
|
this.domElement.removeEventListener("click", this.handleRuntimeClick);
|
|
this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown);
|
|
if (this.container !== null && this.container.contains(this.domElement)) {
|
|
this.container.removeChild(this.domElement);
|
|
}
|
|
this.container = null;
|
|
}
|
|
applyWorld() {
|
|
if (this.currentWorld === null) {
|
|
return;
|
|
}
|
|
const world = this.currentWorld;
|
|
this.ambientLight.color.set(world.ambientLight.colorHex);
|
|
this.ambientLight.intensity = world.ambientLight.intensity;
|
|
this.sunLight.color.set(world.sunLight.colorHex);
|
|
this.sunLight.intensity = world.sunLight.intensity;
|
|
this.sunLight.position
|
|
.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z)
|
|
.normalize()
|
|
.multiplyScalar(18);
|
|
if (world.background.mode === "image") {
|
|
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
|
|
this.scene.background = texture;
|
|
this.scene.environment = texture;
|
|
this.scene.environmentIntensity = world.background.environmentIntensity;
|
|
}
|
|
else {
|
|
this.scene.background = null;
|
|
this.scene.environment = null;
|
|
this.scene.environmentIntensity = 1;
|
|
}
|
|
if (this.renderer !== null) {
|
|
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering);
|
|
this.syncAdvancedRenderingComposer(world.advancedRendering);
|
|
}
|
|
this.applyShadowState();
|
|
}
|
|
async rebuildCollisionWorld(colliders, playerShape) {
|
|
const requestId = ++this.collisionWorldRequestId;
|
|
this.clearCollisionWorld();
|
|
try {
|
|
const nextCollisionWorld = await RapierCollisionWorld.create(colliders, playerShape);
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
nextCollisionWorld.dispose();
|
|
return;
|
|
}
|
|
this.collisionWorld = nextCollisionWorld;
|
|
}
|
|
catch (error) {
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
return;
|
|
}
|
|
const message = error instanceof Error ? error.message : "Runner collision initialization failed.";
|
|
this.currentRuntimeMessage = `Runner collision initialization failed: ${message}`;
|
|
this.runtimeMessageHandler?.(this.currentRuntimeMessage);
|
|
}
|
|
}
|
|
clearCollisionWorld() {
|
|
this.collisionWorld?.dispose();
|
|
this.collisionWorld = null;
|
|
}
|
|
syncAdvancedRenderingComposer(settings) {
|
|
if (this.renderer === null) {
|
|
return;
|
|
}
|
|
const shouldUseComposer = settings.enabled;
|
|
const settingsChanged = this.currentAdvancedRenderingSettings === null ||
|
|
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
|
|
if (!shouldUseComposer) {
|
|
if (this.advancedRenderingComposer !== null) {
|
|
this.advancedRenderingComposer.dispose();
|
|
this.advancedRenderingComposer = null;
|
|
}
|
|
this.currentAdvancedRenderingSettings = null;
|
|
this.renderer.autoClear = true;
|
|
return;
|
|
}
|
|
if (this.advancedRenderingComposer !== null && !settingsChanged) {
|
|
return;
|
|
}
|
|
if (this.advancedRenderingComposer !== null) {
|
|
this.advancedRenderingComposer.dispose();
|
|
}
|
|
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings);
|
|
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
|
|
this.renderer.autoClear = false;
|
|
}
|
|
applyShadowState() {
|
|
if (this.currentWorld === null) {
|
|
return;
|
|
}
|
|
const advancedRendering = this.currentWorld.advancedRendering;
|
|
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled;
|
|
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
|
|
for (const renderGroup of this.localLightObjects.values()) {
|
|
applyAdvancedRenderingLightShadowFlags(renderGroup, advancedRendering);
|
|
}
|
|
for (const mesh of this.brushMeshes.values()) {
|
|
applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled);
|
|
}
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
|
|
}
|
|
}
|
|
rebuildLocalLights(localLights) {
|
|
this.clearLocalLights();
|
|
for (const pointLight of localLights.pointLights) {
|
|
const renderObjects = this.createPointLightRuntimeObjects(pointLight);
|
|
this.localLightGroup.add(renderObjects.group);
|
|
this.localLightObjects.set(pointLight.entityId, renderObjects.group);
|
|
}
|
|
for (const spotLight of localLights.spotLights) {
|
|
const renderObjects = this.createSpotLightRuntimeObjects(spotLight);
|
|
this.localLightGroup.add(renderObjects.group);
|
|
this.localLightObjects.set(spotLight.entityId, renderObjects.group);
|
|
}
|
|
this.applyShadowState();
|
|
}
|
|
createPointLightRuntimeObjects(pointLight) {
|
|
const group = new Group();
|
|
const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance);
|
|
group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z);
|
|
light.position.set(0, 0, 0);
|
|
group.add(light);
|
|
return {
|
|
group
|
|
};
|
|
}
|
|
createSpotLightRuntimeObjects(spotLight) {
|
|
const group = new Group();
|
|
const light = new SpotLight(spotLight.colorHex, spotLight.intensity, spotLight.distance, (spotLight.angleDegrees * Math.PI) / 180, 0.18, 1);
|
|
const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize();
|
|
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction);
|
|
group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z);
|
|
group.quaternion.copy(orientation);
|
|
light.position.set(0, 0, 0);
|
|
light.target.position.set(0, 1, 0);
|
|
group.add(light);
|
|
group.add(light.target);
|
|
return {
|
|
group
|
|
};
|
|
}
|
|
rebuildBrushMeshes(brushes) {
|
|
this.clearBrushMeshes();
|
|
for (const brush of brushes) {
|
|
const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z);
|
|
applyBoxBrushFaceUvsToGeometry(geometry, brush);
|
|
const materials = [
|
|
this.createFaceMaterial(brush.faces.posX.material),
|
|
this.createFaceMaterial(brush.faces.negX.material),
|
|
this.createFaceMaterial(brush.faces.posY.material),
|
|
this.createFaceMaterial(brush.faces.negY.material),
|
|
this.createFaceMaterial(brush.faces.posZ.material),
|
|
this.createFaceMaterial(brush.faces.negZ.material)
|
|
];
|
|
const mesh = new Mesh(geometry, materials);
|
|
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
|
mesh.rotation.set((brush.rotationDegrees.x * Math.PI) / 180, (brush.rotationDegrees.y * Math.PI) / 180, (brush.rotationDegrees.z * Math.PI) / 180);
|
|
this.brushGroup.add(mesh);
|
|
this.brushMeshes.set(brush.id, mesh);
|
|
}
|
|
this.applyShadowState();
|
|
}
|
|
rebuildModelInstances(modelInstances) {
|
|
this.clearModelInstances();
|
|
for (const modelInstance of modelInstances) {
|
|
const asset = this.projectAssets[modelInstance.assetId];
|
|
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
|
|
const renderGroup = createModelInstanceRenderGroup({
|
|
id: modelInstance.instanceId,
|
|
kind: "modelInstance",
|
|
assetId: modelInstance.assetId,
|
|
name: modelInstance.name,
|
|
position: modelInstance.position,
|
|
rotationDegrees: modelInstance.rotationDegrees,
|
|
scale: modelInstance.scale,
|
|
collision: {
|
|
mode: "none",
|
|
visible: false
|
|
}
|
|
}, asset, loadedAsset, false);
|
|
this.modelGroup.add(renderGroup);
|
|
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
|
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
|
const mixer = new AnimationMixer(renderGroup);
|
|
this.animationMixers.set(modelInstance.instanceId, mixer);
|
|
this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations);
|
|
if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) {
|
|
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName);
|
|
if (clip) {
|
|
mixer.clipAction(clip).play();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.applyShadowState();
|
|
}
|
|
createFaceMaterial(material) {
|
|
if (material === null) {
|
|
return new MeshStandardMaterial({
|
|
color: FALLBACK_FACE_COLOR,
|
|
roughness: 0.9,
|
|
metalness: 0.05
|
|
});
|
|
}
|
|
return new MeshStandardMaterial({
|
|
color: 0xffffff,
|
|
map: this.getOrCreateTexture(material),
|
|
roughness: 0.92,
|
|
metalness: 0.02
|
|
});
|
|
}
|
|
getOrCreateTexture(material) {
|
|
const signature = createStarterMaterialSignature(material);
|
|
const cachedTexture = this.materialTextureCache.get(material.id);
|
|
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
|
|
return cachedTexture.texture;
|
|
}
|
|
cachedTexture?.texture.dispose();
|
|
const texture = createStarterMaterialTexture(material);
|
|
this.materialTextureCache.set(material.id, {
|
|
signature,
|
|
texture
|
|
});
|
|
return texture;
|
|
}
|
|
clearLocalLights() {
|
|
for (const renderGroup of this.localLightObjects.values()) {
|
|
this.localLightGroup.remove(renderGroup);
|
|
}
|
|
this.localLightObjects.clear();
|
|
}
|
|
clearBrushMeshes() {
|
|
for (const mesh of this.brushMeshes.values()) {
|
|
this.brushGroup.remove(mesh);
|
|
mesh.geometry.dispose();
|
|
for (const material of mesh.material) {
|
|
material.dispose();
|
|
}
|
|
}
|
|
this.brushMeshes.clear();
|
|
}
|
|
clearModelInstances() {
|
|
for (const mixer of this.animationMixers.values()) {
|
|
mixer.stopAllAction();
|
|
}
|
|
this.animationMixers.clear();
|
|
this.instanceAnimationClips.clear();
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
this.modelGroup.remove(renderGroup);
|
|
disposeModelInstance(renderGroup);
|
|
}
|
|
this.modelRenderObjects.clear();
|
|
}
|
|
resize() {
|
|
if (this.container === null) {
|
|
return;
|
|
}
|
|
const width = this.container.clientWidth;
|
|
const height = this.container.clientHeight;
|
|
if (width === 0 || height === 0) {
|
|
return;
|
|
}
|
|
this.camera.aspect = width / height;
|
|
this.camera.updateProjectionMatrix();
|
|
this.domElement.width = width;
|
|
this.domElement.height = height;
|
|
this.renderer?.setSize(width, height, false);
|
|
this.advancedRenderingComposer?.setSize(width, height);
|
|
}
|
|
render = () => {
|
|
this.animationFrame = window.requestAnimationFrame(this.render);
|
|
const now = performance.now();
|
|
const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
|
|
this.previousFrameTime = now;
|
|
this.activeController?.update(dt);
|
|
this.audioSystem.updateListenerTransform();
|
|
for (const mixer of this.animationMixers.values()) {
|
|
mixer.update(dt);
|
|
}
|
|
if (this.runtimeScene !== null && this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null) {
|
|
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher());
|
|
this.camera.getWorldDirection(this.cameraForward);
|
|
this.setInteractionPrompt(this.interactionSystem.resolveClickInteractionPrompt(this.currentFirstPersonTelemetry.eyePosition, {
|
|
x: this.cameraForward.x,
|
|
y: this.cameraForward.y,
|
|
z: this.cameraForward.z
|
|
}, this.runtimeScene));
|
|
}
|
|
else {
|
|
this.setInteractionPrompt(null);
|
|
}
|
|
if (this.advancedRenderingComposer !== null) {
|
|
this.advancedRenderingComposer.render(dt);
|
|
return;
|
|
}
|
|
this.renderer?.render(this.scene, this.camera);
|
|
};
|
|
applyTeleportPlayerAction(target) {
|
|
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
|
}
|
|
applyToggleBrushVisibilityAction(brushId, visible) {
|
|
const mesh = this.brushMeshes.get(brushId);
|
|
if (mesh === undefined) {
|
|
return;
|
|
}
|
|
mesh.visible = visible ?? !mesh.visible;
|
|
}
|
|
applyPlayAnimationAction(instanceId, clipName, loop) {
|
|
const mixer = this.animationMixers.get(instanceId);
|
|
const clips = this.instanceAnimationClips.get(instanceId);
|
|
if (!mixer || !clips) {
|
|
console.warn(`playAnimation: no mixer for instance ${instanceId}`);
|
|
return;
|
|
}
|
|
const clip = AnimationClip.findByName(clips, clipName);
|
|
if (!clip) {
|
|
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`);
|
|
return;
|
|
}
|
|
// LoopRepeat is the three.js default; LoopOnce plays the clip a single time then stops.
|
|
const action = mixer.clipAction(clip);
|
|
action.loop = loop === false ? LoopOnce : LoopRepeat;
|
|
action.clampWhenFinished = loop === false;
|
|
mixer.stopAllAction();
|
|
action.reset().play();
|
|
}
|
|
applyStopAnimationAction(instanceId) {
|
|
const mixer = this.animationMixers.get(instanceId);
|
|
if (!mixer) {
|
|
console.warn(`stopAnimation: no mixer for instance ${instanceId}`);
|
|
return;
|
|
}
|
|
mixer.stopAllAction();
|
|
}
|
|
createInteractionDispatcher() {
|
|
return {
|
|
teleportPlayer: (target) => {
|
|
this.applyTeleportPlayerAction(target);
|
|
},
|
|
toggleBrushVisibility: (brushId, visible) => {
|
|
this.applyToggleBrushVisibilityAction(brushId, visible);
|
|
},
|
|
playAnimation: (instanceId, clipName, loop) => {
|
|
this.applyPlayAnimationAction(instanceId, clipName, loop);
|
|
},
|
|
stopAnimation: (instanceId) => {
|
|
this.applyStopAnimationAction(instanceId);
|
|
},
|
|
playSound: (soundEmitterId, link) => {
|
|
this.audioSystem.playSound(soundEmitterId, link);
|
|
},
|
|
stopSound: (soundEmitterId) => {
|
|
this.audioSystem.stopSound(soundEmitterId);
|
|
}
|
|
};
|
|
}
|
|
setInteractionPrompt(prompt) {
|
|
if (this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId &&
|
|
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
|
|
this.currentInteractionPrompt?.distance === prompt?.distance &&
|
|
this.currentInteractionPrompt?.range === prompt?.range) {
|
|
return;
|
|
}
|
|
this.currentInteractionPrompt = prompt;
|
|
this.interactionPromptHandler?.(prompt);
|
|
}
|
|
handleRuntimeClick = () => {
|
|
this.audioSystem.handleUserGesture();
|
|
if (this.runtimeScene === null || this.activeController !== this.firstPersonController || this.currentInteractionPrompt === null) {
|
|
return;
|
|
}
|
|
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
|
|
};
|
|
handleRuntimePointerDown = () => {
|
|
this.audioSystem.handleUserGesture();
|
|
};
|
|
}
|