auto-git:

[change] src/app/app.css
 [change] src/commands/set-scene-loading-screen-command.ts
 [change] src/document/migrate-scene-document.ts
 [change] src/document/scene-document-validation.ts
 [change] src/document/scene-document.ts
 [change] src/runner-web/RunnerCanvas.tsx
 [change] src/runtime-three/first-person-navigation-controller.ts
 [change] src/runtime-three/navigation-controller.ts
 [change] src/runtime-three/orbit-visitor-navigation-controller.ts
 [change] src/runtime-three/runtime-host.ts
 [change] tests/domain/editor-store.test.ts
 [change] tests/serialization/local-draft-storage.test.ts
 [change] tests/serialization/project-document-json.test.ts
 [change] tests/serialization/project-package.test.ts
 [change] tests/unit/runner-canvas.test.tsx
 [change] tests/unit/runtime-host.test.ts
This commit is contained in:
2026-04-11 04:19:50 +02:00
parent 75986da19d
commit e205cea50c
16 changed files with 2232 additions and 563 deletions

View File

@@ -26,13 +26,19 @@ import {
} from "three";
import { EffectComposer } from "postprocessing";
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
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 { LoadedAudioAsset } from "../assets/audio-assets";
import type { ProjectAssetRecord } from "../assets/project-assets";
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
import {
createStarterMaterialSignature,
createStarterMaterialTexture
} from "../materials/starter-material-textures";
import {
applyAdvancedRenderingLightShadowFlags,
applyAdvancedRenderingRenderableShadowFlags,
@@ -57,9 +63,18 @@ import {
} from "../document/world-settings";
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext, RuntimePlayerVolumeState } from "./navigation-controller";
import type {
FirstPersonTelemetry,
NavigationController,
RuntimeControllerContext,
RuntimePlayerVolumeState
} from "./navigation-controller";
import { RapierCollisionWorld } from "./rapier-collision-world";
import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system";
import {
RuntimeInteractionSystem,
type RuntimeInteractionDispatcher,
type RuntimeInteractionPrompt
} from "./runtime-interaction-system";
import { RuntimeAudioSystem } from "./runtime-audio-system";
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
import { resolveUnderwaterFogState } from "./underwater-fog";
@@ -115,19 +130,32 @@ export class RuntimeHost {
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 firstPersonController =
new FirstPersonNavigationController();
private readonly orbitVisitorController =
new OrbitVisitorNavigationController();
private readonly interactionSystem = new RuntimeInteractionSystem();
private readonly audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null);
private readonly audioSystem = new RuntimeAudioSystem(
this.scene,
this.camera,
null
);
private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
private readonly waterReflectionCamera = new PerspectiveCamera();
private readonly brushMeshes = new Map<string, Mesh<BufferGeometry, Material[]>>();
private readonly brushMeshes = new Map<
string,
Mesh<BufferGeometry, Material[]>
>();
private volumeTime = 0;
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = [];
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] =
[];
private readonly localLightObjects = new Map<string, Group>();
private readonly modelRenderObjects = new Map<string, Group>();
private readonly materialTextureCache = new Map<string, CachedMaterialTexture>();
private readonly materialTextureCache = new Map<
string,
CachedMaterialTexture
>();
private readonly animationMixers = new Map<string, AnimationMixer>();
private readonly instanceAnimationClips = new Map<string, AnimationClip[]>();
private readonly controllerContext: RuntimeControllerContext;
@@ -138,7 +166,8 @@ export class RuntimeHost {
private desiredNavigationMode: RuntimeNavigationMode = "firstPerson";
private sceneReady = false;
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null =
null;
private advancedRenderingComposer: EffectComposer | null = null;
private projectAssets: Record<string, ProjectAssetRecord> = {};
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
@@ -148,9 +177,14 @@ export class RuntimeHost {
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 runtimeMessageHandler: ((message: string | null) => void) | null =
null;
private firstPersonTelemetryHandler:
| ((telemetry: FirstPersonTelemetry | null) => void)
| null = null;
private interactionPromptHandler:
| ((prompt: RuntimeInteractionPrompt | null) => void)
| null = null;
private sceneLoadStateHandler:
| ((state: RuntimeSceneLoadState) => void)
| null = null;
@@ -169,8 +203,11 @@ export class RuntimeHost {
this.scene.add(this.modelGroup);
this.underwaterSceneFog.density = 0;
this.scene.fog = this.underwaterSceneFog;
this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, alpha: true }) : null;
this.domElement = this.renderer?.domElement ?? document.createElement("canvas");
this.renderer = enableRendering
? new WebGLRenderer({ antialias: false, alpha: true })
: null;
this.domElement =
this.renderer?.domElement ?? document.createElement("canvas");
if (this.renderer !== null) {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -189,8 +226,14 @@ export class RuntimeHost {
return this.runtimeScene;
},
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition),
resolveFirstPersonMotion: (feetPosition, motion, shape) =>
this.collisionWorld?.resolveFirstPersonMotion(
feetPosition,
motion,
shape
) ?? null,
resolvePlayerVolumeState: (feetPosition) =>
this.resolvePlayerVolumeState(feetPosition),
setRuntimeMessage: (message) => {
if (message === this.currentRuntimeMessage) {
return;
@@ -206,7 +249,11 @@ export class RuntimeHost {
};
}
private resolvePlayerVolumeState(feetPosition: { x: number; y: number; z: number }): RuntimePlayerVolumeState {
private resolvePlayerVolumeState(feetPosition: {
x: number;
y: number;
z: number;
}): RuntimePlayerVolumeState {
if (this.runtimeScene === null) {
return {
inWater: false,
@@ -214,8 +261,12 @@ export class RuntimeHost {
};
}
const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
const inWater = this.runtimeScene.volumes.water.some((volume) =>
this.isPointInsideOrientedVolume(feetPosition, volume)
);
const inFog = this.runtimeScene.volumes.fog.some((volume) =>
this.isPointInsideOrientedVolume(feetPosition, volume)
);
return {
inWater,
@@ -225,9 +276,17 @@ export class RuntimeHost {
private isPointInsideOrientedVolume(
point: { x: number; y: number; z: number },
volume: { center: { x: number; y: number; z: number }; rotationDegrees: { x: number; y: number; z: number }; size: { x: number; y: number; z: number } }
volume: {
center: { x: number; y: number; z: number };
rotationDegrees: { x: number; y: number; z: number };
size: { x: number; y: number; z: number };
}
): boolean {
this.volumeOffset.set(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
this.volumeOffset.set(
point.x - volume.center.x,
point.y - volume.center.y,
point.z - volume.center.z
);
this.volumeInverseRotation
.setFromEuler(
@@ -257,7 +316,10 @@ export class RuntimeHost {
this.container = container;
container.appendChild(this.domElement);
this.domElement.addEventListener("click", this.handleRuntimeClick);
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown);
this.domElement.addEventListener(
"pointerdown",
this.handleRuntimePointerDown
);
this.resize();
this.resizeObserver = new ResizeObserver(() => {
@@ -338,11 +400,15 @@ export class RuntimeHost {
this.audioSystem.setRuntimeMessageHandler(handler);
}
setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) {
setFirstPersonTelemetryHandler(
handler: ((telemetry: FirstPersonTelemetry | null) => void) | null
) {
this.firstPersonTelemetryHandler = handler;
}
setInteractionPromptHandler(handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null) {
setInteractionPromptHandler(
handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null
) {
this.interactionPromptHandler = handler;
}
@@ -389,7 +455,10 @@ export class RuntimeHost {
this.renderer?.forceContextLoss();
this.renderer?.dispose();
this.domElement.removeEventListener("click", this.handleRuntimeClick);
this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown);
this.domElement.removeEventListener(
"pointerdown",
this.handleRuntimePointerDown
);
if (this.container !== null && this.container.contains(this.domElement)) {
this.container.removeChild(this.domElement);
@@ -505,7 +574,8 @@ export class RuntimeHost {
.multiplyScalar(18);
if (world.background.mode === "image") {
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
const texture =
this.loadedImageAssets[world.background.assetId]?.texture ?? null;
this.scene.background = texture;
this.scene.environment = texture;
this.scene.environmentIntensity = world.background.environmentIntensity;
@@ -516,7 +586,10 @@ export class RuntimeHost {
}
if (this.renderer !== null) {
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering);
configureAdvancedRenderingRenderer(
this.renderer,
world.advancedRendering
);
this.syncAdvancedRenderingComposer(world.advancedRendering);
}
@@ -554,7 +627,10 @@ export class RuntimeHost {
const shouldUseComposer = settings.enabled;
const settingsChanged =
this.currentAdvancedRenderingSettings === null ||
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
!areAdvancedRenderingSettingsEqual(
this.currentAdvancedRenderingSettings,
settings
);
if (!shouldUseComposer) {
if (this.advancedRenderingComposer !== null) {
@@ -575,8 +651,14 @@ export class RuntimeHost {
this.advancedRenderingComposer.dispose();
}
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings);
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
this.advancedRenderingComposer = createAdvancedRenderingComposer(
this.renderer,
this.scene,
this.camera,
settings
);
this.currentAdvancedRenderingSettings =
cloneAdvancedRenderingSettings(settings);
this.renderer.autoClear = false;
}
@@ -586,7 +668,8 @@ export class RuntimeHost {
}
const advancedRendering = this.currentWorld.advancedRendering;
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled;
const shadowsEnabled =
advancedRendering.enabled && advancedRendering.shadows.enabled;
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
@@ -621,11 +704,21 @@ export class RuntimeHost {
this.applyShadowState();
}
private createPointLightRuntimeObjects(pointLight: RuntimeLocalLightCollection["pointLights"][number]): LocalLightRenderObjects {
private createPointLightRuntimeObjects(
pointLight: RuntimeLocalLightCollection["pointLights"][number]
): LocalLightRenderObjects {
const group = new Group();
const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance);
const light = new PointLight(
pointLight.colorHex,
pointLight.intensity,
pointLight.distance
);
group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z);
group.position.set(
pointLight.position.x,
pointLight.position.y,
pointLight.position.z
);
light.position.set(0, 0, 0);
group.add(light);
@@ -634,7 +727,9 @@ export class RuntimeHost {
};
}
private createSpotLightRuntimeObjects(spotLight: RuntimeLocalLightCollection["spotLights"][number]): LocalLightRenderObjects {
private createSpotLightRuntimeObjects(
spotLight: RuntimeLocalLightCollection["spotLights"][number]
): LocalLightRenderObjects {
const group = new Group();
const light = new SpotLight(
spotLight.colorHex,
@@ -644,10 +739,21 @@ export class RuntimeHost {
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);
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.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);
@@ -662,26 +768,75 @@ export class RuntimeHost {
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
this.clearBrushMeshes();
const volumeRenderPaths: ResolvedBoxVolumeRenderPaths =
this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
this.currentWorld === null
? { fog: "performance", water: "performance" }
: resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
for (const brush of brushes) {
const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : [];
const staticContactPatches =
brush.volume.mode === "water"
? this.collectRuntimeStaticWaterContactPatches(brush)
: [];
const contactPatches =
brush.volume.mode === "water"
? this.mergeRuntimeWaterContactPatches(brush, staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush))
? this.mergeRuntimeWaterContactPatches(
brush,
staticContactPatches,
this.collectRuntimePlayerWaterContactPatches(brush)
)
: [];
const materials =
this.createFogMaterialSet(brush, volumeRenderPaths) ??
[
this.createFaceMaterial(brush, "posX", brush.faces.posX.material, volumeRenderPaths, contactPatches, staticContactPatches),
this.createFaceMaterial(brush, "negX", brush.faces.negX.material, volumeRenderPaths, contactPatches, staticContactPatches),
this.createFaceMaterial(brush, "posY", brush.faces.posY.material, volumeRenderPaths, contactPatches, staticContactPatches),
this.createFaceMaterial(brush, "negY", brush.faces.negY.material, volumeRenderPaths, contactPatches, staticContactPatches),
this.createFaceMaterial(brush, "posZ", brush.faces.posZ.material, volumeRenderPaths, contactPatches, staticContactPatches),
this.createFaceMaterial(brush, "negZ", brush.faces.negZ.material, volumeRenderPaths, contactPatches, staticContactPatches)
];
const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? [
this.createFaceMaterial(
brush,
"posX",
brush.faces.posX.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"negX",
brush.faces.negX.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"posY",
brush.faces.posY.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"negY",
brush.faces.negY.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"posZ",
brush.faces.posZ.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"negZ",
brush.faces.negZ.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
)
];
const mesh = new Mesh(geometry, materials);
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
@@ -700,7 +855,10 @@ export class RuntimeHost {
private createFogMaterialSet(
brush: RuntimeBoxBrushInstance,
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" }
volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
}
): Material[] | null {
if (brush.volume.mode !== "fog") {
return null;
@@ -720,10 +878,16 @@ export class RuntimeHost {
});
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial.material);
return Array.from(
{ length: BOX_FACE_MATERIAL_COUNT },
() => fogMaterial.material
);
}
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
const densityOpacity = Math.max(
0.06,
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
);
const fogMaterial = new MeshBasicMaterial({
color: brush.volume.fog.colorHex,
transparent: true,
@@ -734,9 +898,14 @@ export class RuntimeHost {
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial);
}
private configureFogVolumeMesh(mesh: Mesh<BufferGeometry, Material[]>, materials: Material[]) {
private configureFogVolumeMesh(
mesh: Mesh<BufferGeometry, Material[]>,
materials: Material[]
) {
const fogMaterials = materials.filter(
(material): material is ShaderMaterial => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined
(material): material is ShaderMaterial =>
material instanceof ShaderMaterial &&
material.uniforms["localCameraPosition"] !== undefined
);
if (fogMaterials.length === 0) {
@@ -744,15 +913,21 @@ export class RuntimeHost {
}
mesh.onBeforeRender = (_renderer, _scene, camera) => {
const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position));
const localCameraPosition = mesh.worldToLocal(
this.fogLocalCameraPosition.copy(camera.position)
);
for (const material of fogMaterials) {
(material.uniforms["localCameraPosition"] as { value: Vector3 }).value.copy(localCameraPosition);
(
material.uniforms["localCameraPosition"] as { value: Vector3 }
).value.copy(localCameraPosition);
}
};
}
private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"]) {
private rebuildModelInstances(
modelInstances: RuntimeSceneDefinition["modelInstances"]
) {
this.clearModelInstances();
for (const modelInstance of modelInstances) {
@@ -782,10 +957,19 @@ export class RuntimeHost {
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);
this.instanceAnimationClips.set(
modelInstance.instanceId,
loadedAsset.animations
);
if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) {
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName);
if (
modelInstance.animationAutoplay === true &&
modelInstance.animationClipName
) {
const clip = AnimationClip.findByName(
loadedAsset.animations,
modelInstance.animationClipName
);
if (clip) {
mixer.clipAction(clip).play();
}
@@ -800,18 +984,28 @@ export class RuntimeHost {
brush: RuntimeBoxBrushInstance,
faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ",
material: RuntimeBoxBrushInstance["faces"]["posX"]["material"],
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" },
volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
},
contactPatches: ReturnType<typeof collectWaterContactPatches>,
staticContactPatches: ReturnType<typeof collectWaterContactPatches>
): Material {
if (brush.volume.mode === "water") {
const baseOpacity = Math.max(0.05, Math.min(1, brush.volume.water.surfaceOpacity));
const baseOpacity = Math.max(
0.05,
Math.min(1, brush.volume.water.surfaceOpacity)
);
const waterMaterial = createWaterMaterial({
colorHex: brush.volume.water.colorHex,
surfaceOpacity: brush.volume.water.surfaceOpacity,
waveStrength: brush.volume.water.waveStrength,
surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled,
opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5,
surfaceDisplacementEnabled:
brush.volume.water.surfaceDisplacementEnabled,
opacity:
faceId === "posY"
? Math.min(1, baseOpacity + 0.18)
: baseOpacity * 0.5,
quality: volumeRenderPaths.water === "quality",
wireframe: false,
isTopFace: faceId === "posY",
@@ -831,17 +1025,26 @@ export class RuntimeHost {
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
}
if (faceId === "posY" && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null) {
if (
faceId === "posY" &&
waterMaterial.contactPatchesUniform !== null &&
waterMaterial.contactPatchAxesUniform !== null
) {
this.runtimeWaterContactUniforms.push({
brush,
uniform: waterMaterial.contactPatchesUniform,
axisUniform: waterMaterial.contactPatchAxesUniform,
shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] },
shapeUniform: waterMaterial.contactPatchShapesUniform ?? {
value: []
},
staticContactPatches,
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null,
reflectionRenderTarget:
this.getWaterReflectionMode() !== "none"
? this.createWaterReflectionRenderTarget()
: null,
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
});
}
@@ -867,7 +1070,10 @@ export class RuntimeHost {
return fogMaterial.material;
}
// Performance fallback: simple transparent material
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
const densityOpacity = Math.max(
0.06,
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
);
return new MeshBasicMaterial({
color: brush.volume.fog.colorHex,
transparent: true,
@@ -895,7 +1101,10 @@ export class RuntimeHost {
private updateUnderwaterSceneFog() {
const fogState =
this.activeController === this.firstPersonController
? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry)
? resolveUnderwaterFogState(
this.runtimeScene,
this.currentFirstPersonTelemetry
)
: null;
if (fogState === null) {
@@ -908,7 +1117,11 @@ export class RuntimeHost {
}
private getWaterReflectionMode() {
if (this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality") {
if (
this.currentWorld === null ||
!this.currentWorld.advancedRendering.enabled ||
this.currentWorld.advancedRendering.waterPath !== "quality"
) {
return "none" as const;
}
@@ -957,7 +1170,8 @@ export class RuntimeHost {
}
if (binding.reflectionRenderTarget === null) {
binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget();
binding.reflectionRenderTarget =
this.createWaterReflectionRenderTarget();
}
const canRenderReflection = updatePlanarReflectionCamera(
@@ -972,12 +1186,19 @@ export class RuntimeHost {
continue;
}
if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) {
if (
binding.reflectionTextureUniform.value !== null &&
now - binding.lastReflectionUpdateTime <
WATER_REFLECTION_UPDATE_INTERVAL_MS
) {
binding.reflectionEnabledUniform.value = 0.36;
continue;
}
const hiddenWaterMeshes: Array<{ mesh: Mesh<BufferGeometry, Material[]>; visible: boolean }> = [];
const hiddenWaterMeshes: Array<{
mesh: Mesh<BufferGeometry, Material[]>;
visible: boolean;
}> = [];
for (const runtimeBrush of this.runtimeScene.brushes) {
if (runtimeBrush.volume.mode !== "water") {
continue;
@@ -1000,11 +1221,13 @@ export class RuntimeHost {
const previousAutoClear = this.renderer.autoClear;
const previousRenderTarget = this.renderer.getRenderTarget();
const previousFogDensity = this.underwaterSceneFog.density;
const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({
binding: waterBinding,
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
texture: waterBinding.reflectionTextureUniform?.value ?? null
}));
const previousReflectionStates = this.runtimeWaterContactUniforms.map(
(waterBinding) => ({
binding: waterBinding,
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
texture: waterBinding.reflectionTextureUniform?.value ?? null
})
);
try {
this.underwaterSceneFog.density = 0;
@@ -1037,13 +1260,16 @@ export class RuntimeHost {
}
}
binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture;
binding.reflectionTextureUniform.value =
binding.reflectionRenderTarget.texture;
binding.reflectionEnabledUniform.value = 0.36;
binding.lastReflectionUpdateTime = now;
}
}
private getOrCreateTexture(material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>) {
private getOrCreateTexture(
material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>
) {
const signature = createStarterMaterialSignature(material);
const cachedTexture = this.materialTextureCache.get(material.id);
@@ -1092,7 +1318,10 @@ export class RuntimeHost {
}
private createPlayerWaterContactBounds() {
if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) {
if (
this.runtimeScene === null ||
this.currentFirstPersonTelemetry === null
) {
return null;
}
@@ -1131,16 +1360,27 @@ export class RuntimeHost {
}
}
private collectRuntimeStaticWaterContactPatches(brush: RuntimeBoxBrushInstance) {
private collectRuntimeStaticWaterContactPatches(
brush: RuntimeBoxBrushInstance
) {
const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = [];
const runtimeBrushesById = new Map((this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [runtimeBrush.id, runtimeBrush]));
const runtimeBrushesById = new Map(
(this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [
runtimeBrush.id,
runtimeBrush
])
);
for (const collider of this.runtimeScene?.colliders ?? []) {
if (collider.source === "brush") {
const otherBrush = runtimeBrushesById.get(collider.brushId);
if (otherBrush === undefined || otherBrush.id === brush.id || otherBrush.volume.mode !== "none") {
if (
otherBrush === undefined ||
otherBrush.id === brush.id ||
otherBrush.volume.mode !== "none"
) {
continue;
}
@@ -1189,7 +1429,9 @@ export class RuntimeHost {
);
}
private collectRuntimePlayerWaterContactPatches(brush: RuntimeBoxBrushInstance) {
private collectRuntimePlayerWaterContactPatches(
brush: RuntimeBoxBrushInstance
) {
const playerBounds = this.createPlayerWaterContactBounds();
if (playerBounds === null) {
@@ -1208,7 +1450,9 @@ export class RuntimeHost {
}
private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) {
return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0;
return brush.volume.mode === "water"
? brush.volume.water.foamContactLimit
: 0;
}
private mergeRuntimeWaterContactPatches(
@@ -1216,7 +1460,10 @@ export class RuntimeHost {
staticContactPatches: ReturnType<typeof collectWaterContactPatches>,
dynamicContactPatches: ReturnType<typeof collectWaterContactPatches>
) {
return [...dynamicContactPatches, ...staticContactPatches].slice(0, this.getRuntimeWaterFoamContactLimit(brush));
return [...dynamicContactPatches, ...staticContactPatches].slice(
0,
this.getRuntimeWaterFoamContactLimit(brush)
);
}
private updateRuntimeWaterContactUniforms() {
@@ -1226,9 +1473,12 @@ export class RuntimeHost {
binding.staticContactPatches,
this.collectRuntimePlayerWaterContactPatches(binding.brush)
);
binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches);
binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches);
binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches);
binding.uniform.value =
createWaterContactPatchUniformValue(mergedPatches);
binding.axisUniform.value =
createWaterContactPatchAxisUniformValue(mergedPatches);
binding.shapeUniform.value =
createWaterContactPatchShapeUniformValue(mergedPatches);
}
}
@@ -1293,7 +1543,11 @@ export class RuntimeHost {
this.activeController === this.firstPersonController &&
this.currentFirstPersonTelemetry !== null
) {
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher());
this.interactionSystem.updatePlayerPosition(
this.currentFirstPersonTelemetry.feetPosition,
this.runtimeScene,
this.createInteractionDispatcher()
);
this.camera.getWorldDirection(this.cameraForward);
this.setInteractionPrompt(
this.interactionSystem.resolveClickInteractionPrompt(
@@ -1329,7 +1583,10 @@ export class RuntimeHost {
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
}
private applyToggleBrushVisibilityAction(brushId: string, visible: boolean | undefined) {
private applyToggleBrushVisibilityAction(
brushId: string,
visible: boolean | undefined
) {
const mesh = this.brushMeshes.get(brushId);
if (mesh === undefined) {
@@ -1339,7 +1596,11 @@ export class RuntimeHost {
mesh.visible = visible ?? !mesh.visible;
}
private applyPlayAnimationAction(instanceId: string, clipName: string, loop: boolean | undefined) {
private applyPlayAnimationAction(
instanceId: string,
clipName: string,
loop: boolean | undefined
) {
const mixer = this.animationMixers.get(instanceId);
const clips = this.instanceAnimationClips.get(instanceId);
@@ -1351,7 +1612,9 @@ export class RuntimeHost {
const clip = AnimationClip.findByName(clips, clipName);
if (!clip) {
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`);
console.warn(
`playAnimation: clip "${clipName}" not found on instance ${instanceId}`
);
return;
}
@@ -1399,7 +1662,8 @@ export class RuntimeHost {
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
if (
this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId &&
this.currentInteractionPrompt?.sourceEntityId ===
prompt?.sourceEntityId &&
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
this.currentInteractionPrompt?.distance === prompt?.distance &&
this.currentInteractionPrompt?.range === prompt?.range
@@ -1422,7 +1686,11 @@ export class RuntimeHost {
}
this.audioSystem.handleUserGesture();
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
this.interactionSystem.dispatchClickInteraction(
this.currentInteractionPrompt.sourceEntityId,
this.runtimeScene,
this.createInteractionDispatcher()
);
};
private handleRuntimePointerDown = () => {