Add various runtime-three components
This commit is contained in:
89
src/materials/starter-material-textures.ts
Normal file
89
src/materials/starter-material-textures.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { CanvasTexture, RepeatWrapping, SRGBColorSpace } from "three";
|
||||||
|
|
||||||
|
import type { MaterialDef } from "./starter-material-library";
|
||||||
|
|
||||||
|
export function createStarterMaterialSignature(material: MaterialDef): string {
|
||||||
|
return `${material.baseColorHex}|${material.accentColorHex}|${material.pattern}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillMaterialPattern(context: CanvasRenderingContext2D, material: MaterialDef, size: number) {
|
||||||
|
context.fillStyle = material.baseColorHex;
|
||||||
|
context.fillRect(0, 0, size, size);
|
||||||
|
context.strokeStyle = material.accentColorHex;
|
||||||
|
context.fillStyle = material.accentColorHex;
|
||||||
|
|
||||||
|
switch (material.pattern) {
|
||||||
|
case "grid":
|
||||||
|
context.lineWidth = Math.max(2, size / 32);
|
||||||
|
|
||||||
|
for (let offset = 0; offset <= size; offset += size / 4) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(offset, 0);
|
||||||
|
context.lineTo(offset, size);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, offset);
|
||||||
|
context.lineTo(size, offset);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "checker": {
|
||||||
|
const checkerSize = size / 4;
|
||||||
|
|
||||||
|
for (let row = 0; row < 4; row += 1) {
|
||||||
|
for (let column = 0; column < 4; column += 1) {
|
||||||
|
if ((row + column) % 2 === 0) {
|
||||||
|
context.fillRect(column * checkerSize, row * checkerSize, checkerSize, checkerSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "stripes":
|
||||||
|
context.lineWidth = size / 6;
|
||||||
|
|
||||||
|
for (let offset = -size; offset <= size * 2; offset += size / 3) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(offset, size);
|
||||||
|
context.lineTo(offset + size, 0);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "diamond":
|
||||||
|
context.lineWidth = Math.max(2, size / 28);
|
||||||
|
|
||||||
|
for (let offset = -size; offset <= size; offset += size / 3) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(size * 0.5, offset);
|
||||||
|
context.lineTo(size - offset, size * 0.5);
|
||||||
|
context.lineTo(size * 0.5, size - offset);
|
||||||
|
context.lineTo(-offset, size * 0.5);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStarterMaterialTexture(material: MaterialDef, size = 128): CanvasTexture {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("2D canvas context is unavailable for starter material texture generation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fillMaterialPattern(context, material, size);
|
||||||
|
|
||||||
|
const texture = new CanvasTexture(canvas);
|
||||||
|
texture.wrapS = RepeatWrapping;
|
||||||
|
texture.wrapT = RepeatWrapping;
|
||||||
|
texture.colorSpace = SRGBColorSpace;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
79
src/runner-web/RunnerCanvas.tsx
Normal file
79
src/runner-web/RunnerCanvas.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller";
|
||||||
|
import { RuntimeHost } from "../runtime-three/runtime-host";
|
||||||
|
import type { RuntimeNavigationMode, RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
|
||||||
|
|
||||||
|
interface RunnerCanvasProps {
|
||||||
|
runtimeScene: RuntimeSceneDefinition;
|
||||||
|
navigationMode: RuntimeNavigationMode;
|
||||||
|
onRuntimeMessageChange(message: string | null): void;
|
||||||
|
onFirstPersonTelemetryChange(telemetry: FirstPersonTelemetry | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunnerCanvas({
|
||||||
|
runtimeScene,
|
||||||
|
navigationMode,
|
||||||
|
onRuntimeMessageChange,
|
||||||
|
onFirstPersonTelemetryChange
|
||||||
|
}: RunnerCanvasProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hostRef = useRef<RuntimeHost | null>(null);
|
||||||
|
const [runnerMessage, setRunnerMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
|
||||||
|
if (container === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCanvas = document.createElement("canvas");
|
||||||
|
const hasWebGl =
|
||||||
|
testCanvas.getContext("webgl2") !== null ||
|
||||||
|
testCanvas.getContext("webgl") !== null ||
|
||||||
|
testCanvas.getContext("experimental-webgl") !== null;
|
||||||
|
|
||||||
|
if (!hasWebGl) {
|
||||||
|
setRunnerMessage("WebGL is unavailable in this browser environment. The runner shell is visible, but runtime rendering is disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtimeHost = new RuntimeHost();
|
||||||
|
hostRef.current = runtimeHost;
|
||||||
|
runtimeHost.mount(container);
|
||||||
|
runtimeHost.setRuntimeMessageHandler(onRuntimeMessageChange);
|
||||||
|
runtimeHost.setFirstPersonTelemetryHandler(onFirstPersonTelemetryChange);
|
||||||
|
setRunnerMessage(null);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runtimeHost.dispose();
|
||||||
|
hostRef.current = null;
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Runner initialization failed.";
|
||||||
|
setRunnerMessage(`Runner initialization failed: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [onFirstPersonTelemetryChange, onRuntimeMessageChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hostRef.current?.loadScene(runtimeScene);
|
||||||
|
}, [runtimeScene]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hostRef.current?.setNavigationMode(navigationMode);
|
||||||
|
}, [navigationMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="runner-canvas" data-testid="runner-shell" aria-label="Built-in scene runner">
|
||||||
|
{runnerMessage === null ? null : (
|
||||||
|
<div className="runner-canvas__fallback" role="status">
|
||||||
|
<div className="runner-canvas__fallback-title">Runner Unavailable</div>
|
||||||
|
<div>{runnerMessage}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
src/runtime-three/first-person-navigation-controller.ts
Normal file
232
src/runtime-three/first-person-navigation-controller.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { Euler, Vector3 } from "three";
|
||||||
|
|
||||||
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
|
import { FIRST_PERSON_PLAYER_SHAPE, resolveFirstPersonMotion } from "./player-collision";
|
||||||
|
import type { NavigationController, RuntimeControllerContext } from "./navigation-controller";
|
||||||
|
|
||||||
|
const LOOK_SENSITIVITY = 0.0022;
|
||||||
|
const MOVE_SPEED = 4.5;
|
||||||
|
const GRAVITY = 22;
|
||||||
|
const MAX_PITCH_RADIANS = Math.PI * 0.48;
|
||||||
|
|
||||||
|
function clampPitch(pitchRadians: number): number {
|
||||||
|
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEyePosition(feetPosition: Vec3): Vec3 {
|
||||||
|
return {
|
||||||
|
x: feetPosition.x,
|
||||||
|
y: feetPosition.y + FIRST_PERSON_PLAYER_SHAPE.eyeHeight,
|
||||||
|
z: feetPosition.z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FirstPersonNavigationController implements NavigationController {
|
||||||
|
readonly id = "firstPerson" as const;
|
||||||
|
|
||||||
|
private context: RuntimeControllerContext | null = null;
|
||||||
|
private readonly pressedKeys = new Set<string>();
|
||||||
|
private readonly cameraRotation = new Euler(0, 0, 0, "YXZ");
|
||||||
|
private readonly forwardVector = new Vector3();
|
||||||
|
private readonly rightVector = new Vector3();
|
||||||
|
private feetPosition = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 0
|
||||||
|
};
|
||||||
|
private yawRadians = 0;
|
||||||
|
private pitchRadians = 0;
|
||||||
|
private verticalVelocity = 0;
|
||||||
|
private grounded = false;
|
||||||
|
private pointerLocked = false;
|
||||||
|
private initializedFromSpawn = false;
|
||||||
|
|
||||||
|
activate(ctx: RuntimeControllerContext): void {
|
||||||
|
this.context = ctx;
|
||||||
|
|
||||||
|
if (!this.initializedFromSpawn) {
|
||||||
|
const spawn = ctx.getRuntimeScene().spawn;
|
||||||
|
this.feetPosition = {
|
||||||
|
...spawn.position
|
||||||
|
};
|
||||||
|
this.yawRadians = (spawn.yawDegrees * Math.PI) / 180;
|
||||||
|
this.pitchRadians = 0;
|
||||||
|
this.verticalVelocity = 0;
|
||||||
|
this.grounded = false;
|
||||||
|
this.initializedFromSpawn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", this.handleKeyDown);
|
||||||
|
window.addEventListener("keyup", this.handleKeyUp);
|
||||||
|
window.addEventListener("blur", this.handleBlur);
|
||||||
|
document.addEventListener("mousemove", this.handleMouseMove);
|
||||||
|
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||||
|
document.addEventListener("pointerlockerror", this.handlePointerLockError);
|
||||||
|
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||||
|
|
||||||
|
this.syncPointerLockState();
|
||||||
|
this.updateCameraTransform();
|
||||||
|
this.publishTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(ctx: RuntimeControllerContext): void {
|
||||||
|
window.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
window.removeEventListener("blur", this.handleBlur);
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
|
||||||
|
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
|
||||||
|
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||||
|
this.pressedKeys.clear();
|
||||||
|
|
||||||
|
if (document.pointerLockElement === ctx.domElement) {
|
||||||
|
document.exitPointerLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pointerLocked = false;
|
||||||
|
ctx.setRuntimeMessage(null);
|
||||||
|
ctx.setFirstPersonTelemetry(null);
|
||||||
|
this.context = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
if (this.context === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0);
|
||||||
|
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0);
|
||||||
|
const inputLength = Math.hypot(inputX, inputZ);
|
||||||
|
|
||||||
|
let horizontalX = 0;
|
||||||
|
let horizontalZ = 0;
|
||||||
|
|
||||||
|
if (inputLength > 0) {
|
||||||
|
const normalizedInputX = inputX / inputLength;
|
||||||
|
const normalizedInputZ = inputZ / inputLength;
|
||||||
|
const moveDistance = MOVE_SPEED * dt;
|
||||||
|
|
||||||
|
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians));
|
||||||
|
this.rightVector.set(Math.cos(this.yawRadians), 0, -Math.sin(this.yawRadians));
|
||||||
|
|
||||||
|
horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance;
|
||||||
|
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.verticalVelocity -= GRAVITY * dt;
|
||||||
|
|
||||||
|
const resolvedMotion = resolveFirstPersonMotion(
|
||||||
|
this.feetPosition,
|
||||||
|
{
|
||||||
|
x: horizontalX,
|
||||||
|
y: this.verticalVelocity * dt,
|
||||||
|
z: horizontalZ
|
||||||
|
},
|
||||||
|
FIRST_PERSON_PLAYER_SHAPE,
|
||||||
|
this.context.getRuntimeScene().colliders
|
||||||
|
);
|
||||||
|
|
||||||
|
this.feetPosition = resolvedMotion.feetPosition;
|
||||||
|
this.grounded = resolvedMotion.grounded;
|
||||||
|
|
||||||
|
if (this.grounded && this.verticalVelocity < 0) {
|
||||||
|
this.verticalVelocity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCameraTransform();
|
||||||
|
this.publishTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCameraTransform() {
|
||||||
|
if (this.context === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eyePosition = toEyePosition(this.feetPosition);
|
||||||
|
this.cameraRotation.x = this.pitchRadians;
|
||||||
|
this.cameraRotation.y = this.yawRadians;
|
||||||
|
this.cameraRotation.z = 0;
|
||||||
|
|
||||||
|
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z);
|
||||||
|
this.context.camera.rotation.copy(this.cameraRotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishTelemetry() {
|
||||||
|
if (this.context === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.setFirstPersonTelemetry({
|
||||||
|
feetPosition: {
|
||||||
|
...this.feetPosition
|
||||||
|
},
|
||||||
|
eyePosition: toEyePosition(this.feetPosition),
|
||||||
|
grounded: this.grounded,
|
||||||
|
pointerLocked: this.pointerLocked,
|
||||||
|
spawn: this.context.getRuntimeScene().spawn
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncPointerLockState() {
|
||||||
|
if (this.context === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerLocked = document.pointerLockElement === this.context.domElement;
|
||||||
|
this.pointerLocked = pointerLocked;
|
||||||
|
this.context.setRuntimeMessage(
|
||||||
|
pointerLocked
|
||||||
|
? "Mouse look active. Press Escape to release the cursor or switch to Orbit Visitor."
|
||||||
|
: "Click inside the runner viewport to capture mouse look. If pointer lock fails, switch to Orbit Visitor."
|
||||||
|
);
|
||||||
|
this.publishTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
this.pressedKeys.add(event.code);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
this.pressedKeys.delete(event.code);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleBlur = () => {
|
||||||
|
this.pressedKeys.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!this.pointerLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
|
||||||
|
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePointerLockChange = () => {
|
||||||
|
this.syncPointerLockState();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePointerLockError = () => {
|
||||||
|
this.context?.setRuntimeMessage(
|
||||||
|
"Pointer lock was unavailable in this browser context. Orbit Visitor remains available as the non-FPS fallback."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePointerDown = () => {
|
||||||
|
if (this.context === null || document.pointerLockElement === this.context.domElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerLockResult = this.context.domElement.requestPointerLock();
|
||||||
|
|
||||||
|
if (pointerLockResult !== undefined && "catch" in pointerLockResult) {
|
||||||
|
pointerLockResult.catch(() => {
|
||||||
|
this.context?.setRuntimeMessage(
|
||||||
|
"Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/runtime-three/navigation-controller.ts
Normal file
28
src/runtime-three/navigation-controller.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PerspectiveCamera } from "three";
|
||||||
|
|
||||||
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
|
import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build";
|
||||||
|
|
||||||
|
export interface FirstPersonTelemetry {
|
||||||
|
feetPosition: Vec3;
|
||||||
|
eyePosition: Vec3;
|
||||||
|
grounded: boolean;
|
||||||
|
pointerLocked: boolean;
|
||||||
|
spawn: RuntimeSpawnPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeControllerContext {
|
||||||
|
camera: PerspectiveCamera;
|
||||||
|
domElement: HTMLCanvasElement;
|
||||||
|
getRuntimeScene(): RuntimeSceneDefinition;
|
||||||
|
setRuntimeMessage(message: string | null): void;
|
||||||
|
setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationController {
|
||||||
|
id: RuntimeNavigationMode;
|
||||||
|
activate(ctx: RuntimeControllerContext): void;
|
||||||
|
deactivate(ctx: RuntimeControllerContext): void;
|
||||||
|
update(dt: number): void;
|
||||||
|
}
|
||||||
145
src/runtime-three/orbit-visitor-navigation-controller.ts
Normal file
145
src/runtime-three/orbit-visitor-navigation-controller.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Vector3 } from "three";
|
||||||
|
|
||||||
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
|
import type { NavigationController, RuntimeControllerContext } from "./navigation-controller";
|
||||||
|
|
||||||
|
const MIN_DISTANCE = 2;
|
||||||
|
const MAX_DISTANCE = 48;
|
||||||
|
const MIN_PITCH = 0.15;
|
||||||
|
const MAX_PITCH = Math.PI * 0.48;
|
||||||
|
|
||||||
|
function clampDistance(distance: number): number {
|
||||||
|
return Math.max(MIN_DISTANCE, Math.min(MAX_DISTANCE, distance));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPitch(pitchRadians: number): number {
|
||||||
|
return Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitchRadians));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneVec3(vector: Vec3): Vec3 {
|
||||||
|
return {
|
||||||
|
x: vector.x,
|
||||||
|
y: vector.y,
|
||||||
|
z: vector.z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OrbitVisitorNavigationController implements NavigationController {
|
||||||
|
readonly id = "orbitVisitor" as const;
|
||||||
|
|
||||||
|
private context: RuntimeControllerContext | null = null;
|
||||||
|
private readonly lookAtVector = new Vector3();
|
||||||
|
private target: Vec3 = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 0
|
||||||
|
};
|
||||||
|
private distance = 8;
|
||||||
|
private yawRadians = Math.PI * 0.25;
|
||||||
|
private pitchRadians = Math.PI * 0.35;
|
||||||
|
private dragging = false;
|
||||||
|
private lastPointerClientX = 0;
|
||||||
|
private lastPointerClientY = 0;
|
||||||
|
private initializedFromScene = false;
|
||||||
|
|
||||||
|
activate(ctx: RuntimeControllerContext): void {
|
||||||
|
this.context = ctx;
|
||||||
|
|
||||||
|
if (!this.initializedFromScene) {
|
||||||
|
const runtimeScene = ctx.getRuntimeScene();
|
||||||
|
const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target;
|
||||||
|
const focusDistance = runtimeScene.sceneBounds
|
||||||
|
? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1
|
||||||
|
: 8;
|
||||||
|
|
||||||
|
this.target = cloneVec3(focusPoint);
|
||||||
|
this.distance = clampDistance(focusDistance);
|
||||||
|
this.initializedFromScene = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||||
|
ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
|
||||||
|
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
||||||
|
window.addEventListener("pointermove", this.handlePointerMove);
|
||||||
|
window.addEventListener("pointerup", this.handlePointerUp);
|
||||||
|
|
||||||
|
ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom.");
|
||||||
|
ctx.setFirstPersonTelemetry(null);
|
||||||
|
this.updateCameraTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(ctx: RuntimeControllerContext): void {
|
||||||
|
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||||
|
ctx.domElement.removeEventListener("wheel", this.handleWheel);
|
||||||
|
ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu);
|
||||||
|
window.removeEventListener("pointermove", this.handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", this.handlePointerUp);
|
||||||
|
ctx.setRuntimeMessage(null);
|
||||||
|
this.dragging = false;
|
||||||
|
this.context = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): void {
|
||||||
|
this.updateCameraTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocusPoint(target: Vec3): void {
|
||||||
|
this.target = cloneVec3(target);
|
||||||
|
this.updateCameraTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCameraTransform() {
|
||||||
|
if (this.context === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontalDistance = Math.cos(this.pitchRadians) * this.distance;
|
||||||
|
const cameraPosition = {
|
||||||
|
x: this.target.x + Math.sin(this.yawRadians) * horizontalDistance,
|
||||||
|
y: this.target.y + Math.sin(this.pitchRadians) * this.distance,
|
||||||
|
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
|
||||||
|
};
|
||||||
|
|
||||||
|
this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
||||||
|
this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
|
||||||
|
this.context.camera.lookAt(this.lookAtVector);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dragging = true;
|
||||||
|
this.lastPointerClientX = event.clientX;
|
||||||
|
this.lastPointerClientY = event.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePointerMove = (event: PointerEvent) => {
|
||||||
|
if (!this.dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = event.clientX - this.lastPointerClientX;
|
||||||
|
const deltaY = event.clientY - this.lastPointerClientY;
|
||||||
|
this.lastPointerClientX = event.clientX;
|
||||||
|
this.lastPointerClientY = event.clientY;
|
||||||
|
|
||||||
|
this.yawRadians -= deltaX * 0.008;
|
||||||
|
this.pitchRadians = clampPitch(this.pitchRadians + deltaY * 0.008);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePointerUp = () => {
|
||||||
|
this.dragging = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.distance = clampDistance(this.distance + event.deltaY * 0.01);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleContextMenu = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
}
|
||||||
149
src/runtime-three/player-collision.ts
Normal file
149
src/runtime-three/player-collision.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
|
import type { RuntimeBoxCollider } from "./runtime-scene-build";
|
||||||
|
|
||||||
|
export interface FirstPersonPlayerShape {
|
||||||
|
radius: number;
|
||||||
|
height: number;
|
||||||
|
eyeHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedPlayerMotion {
|
||||||
|
feetPosition: Vec3;
|
||||||
|
grounded: boolean;
|
||||||
|
collidedAxes: {
|
||||||
|
x: boolean;
|
||||||
|
y: boolean;
|
||||||
|
z: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIRST_PERSON_PLAYER_SHAPE: FirstPersonPlayerShape = {
|
||||||
|
radius: 0.3,
|
||||||
|
height: 1.8,
|
||||||
|
eyeHeight: 1.6
|
||||||
|
};
|
||||||
|
|
||||||
|
type Axis = "x" | "y" | "z";
|
||||||
|
|
||||||
|
interface RuntimeAabb {
|
||||||
|
min: Vec3;
|
||||||
|
max: Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerAabb(feetPosition: Vec3, shape: FirstPersonPlayerShape): RuntimeAabb {
|
||||||
|
return {
|
||||||
|
min: {
|
||||||
|
x: feetPosition.x - shape.radius,
|
||||||
|
y: feetPosition.y,
|
||||||
|
z: feetPosition.z - shape.radius
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
x: feetPosition.x + shape.radius,
|
||||||
|
y: feetPosition.y + shape.height,
|
||||||
|
z: feetPosition.z + shape.radius
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangesOverlap(minA: number, maxA: number, minB: number, maxB: number): boolean {
|
||||||
|
return minA < maxB && maxA > minB;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAxis(
|
||||||
|
axis: Axis,
|
||||||
|
nextFeetPosition: Vec3,
|
||||||
|
delta: number,
|
||||||
|
shape: FirstPersonPlayerShape,
|
||||||
|
colliders: RuntimeBoxCollider[]
|
||||||
|
): { collided: boolean; grounded: boolean } {
|
||||||
|
if (delta === 0) {
|
||||||
|
return {
|
||||||
|
collided: false,
|
||||||
|
grounded: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
nextFeetPosition[axis] += delta;
|
||||||
|
|
||||||
|
let collided = false;
|
||||||
|
let grounded = false;
|
||||||
|
|
||||||
|
for (const collider of colliders) {
|
||||||
|
const playerAabb = createPlayerAabb(nextFeetPosition, shape);
|
||||||
|
const overlapsOtherAxes =
|
||||||
|
axis === "x"
|
||||||
|
? rangesOverlap(playerAabb.min.y, playerAabb.max.y, collider.min.y, collider.max.y) &&
|
||||||
|
rangesOverlap(playerAabb.min.z, playerAabb.max.z, collider.min.z, collider.max.z)
|
||||||
|
: axis === "y"
|
||||||
|
? rangesOverlap(playerAabb.min.x, playerAabb.max.x, collider.min.x, collider.max.x) &&
|
||||||
|
rangesOverlap(playerAabb.min.z, playerAabb.max.z, collider.min.z, collider.max.z)
|
||||||
|
: rangesOverlap(playerAabb.min.x, playerAabb.max.x, collider.min.x, collider.max.x) &&
|
||||||
|
rangesOverlap(playerAabb.min.y, playerAabb.max.y, collider.min.y, collider.max.y);
|
||||||
|
|
||||||
|
if (!overlapsOtherAxes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (axis) {
|
||||||
|
case "x":
|
||||||
|
if (delta > 0 && playerAabb.max.x > collider.min.x && playerAabb.min.x < collider.min.x) {
|
||||||
|
nextFeetPosition.x = collider.min.x - shape.radius;
|
||||||
|
collided = true;
|
||||||
|
} else if (delta < 0 && playerAabb.min.x < collider.max.x && playerAabb.max.x > collider.max.x) {
|
||||||
|
nextFeetPosition.x = collider.max.x + shape.radius;
|
||||||
|
collided = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "y":
|
||||||
|
if (delta > 0 && playerAabb.max.y > collider.min.y && playerAabb.min.y < collider.min.y) {
|
||||||
|
nextFeetPosition.y = collider.min.y - shape.height;
|
||||||
|
collided = true;
|
||||||
|
} else if (delta < 0 && playerAabb.min.y < collider.max.y && playerAabb.max.y > collider.max.y) {
|
||||||
|
nextFeetPosition.y = collider.max.y;
|
||||||
|
collided = true;
|
||||||
|
grounded = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "z":
|
||||||
|
if (delta > 0 && playerAabb.max.z > collider.min.z && playerAabb.min.z < collider.min.z) {
|
||||||
|
nextFeetPosition.z = collider.min.z - shape.radius;
|
||||||
|
collided = true;
|
||||||
|
} else if (delta < 0 && playerAabb.min.z < collider.max.z && playerAabb.max.z > collider.max.z) {
|
||||||
|
nextFeetPosition.z = collider.max.z + shape.radius;
|
||||||
|
collided = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
collided,
|
||||||
|
grounded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFirstPersonMotion(
|
||||||
|
feetPosition: Vec3,
|
||||||
|
motion: Vec3,
|
||||||
|
shape: FirstPersonPlayerShape,
|
||||||
|
colliders: RuntimeBoxCollider[]
|
||||||
|
): ResolvedPlayerMotion {
|
||||||
|
const nextFeetPosition = {
|
||||||
|
...feetPosition
|
||||||
|
};
|
||||||
|
|
||||||
|
const xResolution = resolveAxis("x", nextFeetPosition, motion.x, shape, colliders);
|
||||||
|
const zResolution = resolveAxis("z", nextFeetPosition, motion.z, shape, colliders);
|
||||||
|
const yResolution = resolveAxis("y", nextFeetPosition, motion.y, shape, colliders);
|
||||||
|
|
||||||
|
return {
|
||||||
|
feetPosition: nextFeetPosition,
|
||||||
|
grounded: yResolution.grounded,
|
||||||
|
collidedAxes: {
|
||||||
|
x: xResolution.collided,
|
||||||
|
y: yResolution.collided,
|
||||||
|
z: zResolution.collided
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
272
src/runtime-three/runtime-host.ts
Normal file
272
src/runtime-three/runtime-host.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
AmbientLight,
|
||||||
|
BoxGeometry,
|
||||||
|
Color,
|
||||||
|
DirectionalLight,
|
||||||
|
Group,
|
||||||
|
Mesh,
|
||||||
|
MeshStandardMaterial,
|
||||||
|
PerspectiveCamera,
|
||||||
|
Scene,
|
||||||
|
WebGLRenderer
|
||||||
|
} from "three";
|
||||||
|
|
||||||
|
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 { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
||||||
|
import type { RuntimeBoxBrushInstance, RuntimeNavigationMode, RuntimeSceneDefinition } from "./runtime-scene-build";
|
||||||
|
|
||||||
|
interface CachedMaterialTexture {
|
||||||
|
signature: string;
|
||||||
|
texture: ReturnType<typeof createStarterMaterialTexture>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 renderer = new WebGLRenderer({ antialias: true });
|
||||||
|
private readonly ambientLight = new AmbientLight();
|
||||||
|
private readonly sunLight = new DirectionalLight();
|
||||||
|
private readonly brushGroup = new Group();
|
||||||
|
private readonly firstPersonController = new FirstPersonNavigationController();
|
||||||
|
private readonly orbitVisitorController = new OrbitVisitorNavigationController();
|
||||||
|
private readonly brushMeshes = new Map<string, Mesh<BoxGeometry, MeshStandardMaterial[]>>();
|
||||||
|
private readonly materialTextureCache = new Map<string, CachedMaterialTexture>();
|
||||||
|
private readonly controllerContext: RuntimeControllerContext;
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
private animationFrame = 0;
|
||||||
|
private previousFrameTime = 0;
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
private activeController: NavigationController | null = null;
|
||||||
|
private runtimeScene: RuntimeSceneDefinition | null = null;
|
||||||
|
private runtimeMessageHandler: ((message: string | null) => void) | null = null;
|
||||||
|
private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null;
|
||||||
|
private currentRuntimeMessage: string | null = null;
|
||||||
|
private currentFirstPersonTelemetry: FirstPersonTelemetry | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.scene.add(this.ambientLight);
|
||||||
|
this.scene.add(this.sunLight);
|
||||||
|
this.scene.add(this.brushGroup);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
|
this.controllerContext = {
|
||||||
|
camera: this.camera,
|
||||||
|
domElement: this.renderer.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.renderer.domElement);
|
||||||
|
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.applyWorld(runtimeScene);
|
||||||
|
this.rebuildBrushMeshes(runtimeScene.brushes);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.animationFrame !== 0) {
|
||||||
|
cancelAnimationFrame(this.animationFrame);
|
||||||
|
this.animationFrame = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeController?.deactivate(this.controllerContext);
|
||||||
|
this.activeController = null;
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
|
this.clearBrushMeshes();
|
||||||
|
|
||||||
|
for (const cachedTexture of this.materialTextureCache.values()) {
|
||||||
|
cachedTexture.texture.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.materialTextureCache.clear();
|
||||||
|
this.renderer.dispose();
|
||||||
|
|
||||||
|
if (this.container !== null && this.container.contains(this.renderer.domElement)) {
|
||||||
|
this.container.removeChild(this.renderer.domElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyWorld(runtimeScene: RuntimeSceneDefinition) {
|
||||||
|
this.scene.background = new Color(runtimeScene.world.background.colorHex);
|
||||||
|
this.ambientLight.color.set(runtimeScene.world.ambientLight.colorHex);
|
||||||
|
this.ambientLight.intensity = runtimeScene.world.ambientLight.intensity;
|
||||||
|
this.sunLight.color.set(runtimeScene.world.sunLight.colorHex);
|
||||||
|
this.sunLight.intensity = runtimeScene.world.sunLight.intensity;
|
||||||
|
this.sunLight.position
|
||||||
|
.set(
|
||||||
|
runtimeScene.world.sunLight.direction.x,
|
||||||
|
runtimeScene.world.sunLight.direction.y,
|
||||||
|
runtimeScene.world.sunLight.direction.z
|
||||||
|
)
|
||||||
|
.normalize()
|
||||||
|
.multiplyScalar(18);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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<RuntimeBoxBrushInstance["faces"]["posX"]["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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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.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);
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
}
|
||||||
221
src/runtime-three/runtime-scene-build.ts
Normal file
221
src/runtime-three/runtime-scene-build.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import type { Vec3 } from "../core/vector";
|
||||||
|
import type { BoxBrush, BoxFaceId, FaceUvState } from "../document/brushes";
|
||||||
|
import type { SceneDocument, WorldSettings } from "../document/scene-document";
|
||||||
|
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
|
||||||
|
import { getBoxBrushBounds } from "../geometry/box-brush";
|
||||||
|
import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library";
|
||||||
|
import { cloneFaceUvState } from "../document/brushes";
|
||||||
|
|
||||||
|
export type RuntimeNavigationMode = "firstPerson" | "orbitVisitor";
|
||||||
|
|
||||||
|
export interface RuntimeBrushFace {
|
||||||
|
materialId: string | null;
|
||||||
|
material: MaterialDef | null;
|
||||||
|
uv: FaceUvState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeBoxBrushInstance {
|
||||||
|
id: string;
|
||||||
|
kind: "box";
|
||||||
|
center: Vec3;
|
||||||
|
size: Vec3;
|
||||||
|
faces: Record<BoxFaceId, RuntimeBrushFace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeBoxCollider {
|
||||||
|
kind: "box";
|
||||||
|
brushId: string;
|
||||||
|
min: Vec3;
|
||||||
|
max: Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeSceneBounds {
|
||||||
|
min: Vec3;
|
||||||
|
max: Vec3;
|
||||||
|
center: Vec3;
|
||||||
|
size: Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimePlayerStart {
|
||||||
|
entityId: string;
|
||||||
|
position: Vec3;
|
||||||
|
yawDegrees: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeSpawnPoint {
|
||||||
|
source: "playerStart" | "fallback";
|
||||||
|
entityId: string | null;
|
||||||
|
position: Vec3;
|
||||||
|
yawDegrees: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeSceneDefinition {
|
||||||
|
world: WorldSettings;
|
||||||
|
brushes: RuntimeBoxBrushInstance[];
|
||||||
|
colliders: RuntimeBoxCollider[];
|
||||||
|
sceneBounds: RuntimeSceneBounds | null;
|
||||||
|
playerStart: RuntimePlayerStart | null;
|
||||||
|
spawn: RuntimeSpawnPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneVec3(vector: Vec3): Vec3 {
|
||||||
|
return {
|
||||||
|
x: vector.x,
|
||||||
|
y: vector.y,
|
||||||
|
z: vector.z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntimeBrush(brush: BoxBrush, document: SceneDocument): RuntimeBoxBrushInstance {
|
||||||
|
return {
|
||||||
|
id: brush.id,
|
||||||
|
kind: "box",
|
||||||
|
center: cloneVec3(brush.center),
|
||||||
|
size: cloneVec3(brush.size),
|
||||||
|
faces: {
|
||||||
|
posX: {
|
||||||
|
materialId: brush.faces.posX.materialId,
|
||||||
|
material: brush.faces.posX.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.posX.materialId]),
|
||||||
|
uv: cloneFaceUvState(brush.faces.posX.uv)
|
||||||
|
},
|
||||||
|
negX: {
|
||||||
|
materialId: brush.faces.negX.materialId,
|
||||||
|
material: brush.faces.negX.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.negX.materialId]),
|
||||||
|
uv: cloneFaceUvState(brush.faces.negX.uv)
|
||||||
|
},
|
||||||
|
posY: {
|
||||||
|
materialId: brush.faces.posY.materialId,
|
||||||
|
material: brush.faces.posY.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.posY.materialId]),
|
||||||
|
uv: cloneFaceUvState(brush.faces.posY.uv)
|
||||||
|
},
|
||||||
|
negY: {
|
||||||
|
materialId: brush.faces.negY.materialId,
|
||||||
|
material: brush.faces.negY.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.negY.materialId]),
|
||||||
|
uv: cloneFaceUvState(brush.faces.negY.uv)
|
||||||
|
},
|
||||||
|
posZ: {
|
||||||
|
materialId: brush.faces.posZ.materialId,
|
||||||
|
material: brush.faces.posZ.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.posZ.materialId]),
|
||||||
|
uv: cloneFaceUvState(brush.faces.posZ.uv)
|
||||||
|
},
|
||||||
|
negZ: {
|
||||||
|
materialId: brush.faces.negZ.materialId,
|
||||||
|
material: brush.faces.negZ.materialId === null ? null : cloneMaterialDef(document.materials[brush.faces.negZ.materialId]),
|
||||||
|
uv: cloneFaceUvState(brush.faces.negZ.uv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntimeCollider(brush: BoxBrush): RuntimeBoxCollider {
|
||||||
|
const bounds = getBoxBrushBounds(brush);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "box",
|
||||||
|
brushId: brush.id,
|
||||||
|
min: cloneVec3(bounds.min),
|
||||||
|
max: cloneVec3(bounds.max)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineColliderBounds(colliders: RuntimeBoxCollider[]): RuntimeSceneBounds | null {
|
||||||
|
if (colliders.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = cloneVec3(colliders[0].min);
|
||||||
|
const max = cloneVec3(colliders[0].max);
|
||||||
|
|
||||||
|
for (const collider of colliders.slice(1)) {
|
||||||
|
min.x = Math.min(min.x, collider.min.x);
|
||||||
|
min.y = Math.min(min.y, collider.min.y);
|
||||||
|
min.z = Math.min(min.z, collider.min.z);
|
||||||
|
max.x = Math.max(max.x, collider.max.x);
|
||||||
|
max.y = Math.max(max.y, collider.max.y);
|
||||||
|
max.z = Math.max(max.z, collider.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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRuntimeSceneFromDocument(document: SceneDocument): RuntimeSceneDefinition {
|
||||||
|
const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document));
|
||||||
|
const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush));
|
||||||
|
const sceneBounds = combineColliderBounds(colliders);
|
||||||
|
const playerStartEntity = getPrimaryPlayerStartEntity(document.entities);
|
||||||
|
const playerStart =
|
||||||
|
playerStartEntity === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
entityId: playerStartEntity.id,
|
||||||
|
position: cloneVec3(playerStartEntity.position),
|
||||||
|
yawDegrees: playerStartEntity.yawDegrees
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
world: {
|
||||||
|
background: {
|
||||||
|
...document.world.background
|
||||||
|
},
|
||||||
|
ambientLight: {
|
||||||
|
...document.world.ambientLight
|
||||||
|
},
|
||||||
|
sunLight: {
|
||||||
|
...document.world.sunLight,
|
||||||
|
direction: cloneVec3(document.world.sunLight.direction)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
brushes,
|
||||||
|
colliders,
|
||||||
|
sceneBounds,
|
||||||
|
playerStart,
|
||||||
|
spawn:
|
||||||
|
playerStart === null
|
||||||
|
? buildFallbackSpawn(sceneBounds)
|
||||||
|
: {
|
||||||
|
source: "playerStart",
|
||||||
|
entityId: playerStart.entityId,
|
||||||
|
position: cloneVec3(playerStart.position),
|
||||||
|
yawDegrees: playerStart.yawDegrees
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user