diff --git a/src/materials/starter-material-textures.ts b/src/materials/starter-material-textures.ts new file mode 100644 index 00000000..2c08a3c5 --- /dev/null +++ b/src/materials/starter-material-textures.ts @@ -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; +} diff --git a/src/runner-web/RunnerCanvas.tsx b/src/runner-web/RunnerCanvas.tsx new file mode 100644 index 00000000..e42a6d69 --- /dev/null +++ b/src/runner-web/RunnerCanvas.tsx @@ -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(null); + const hostRef = useRef(null); + const [runnerMessage, setRunnerMessage] = useState(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 ( +
+ {runnerMessage === null ? null : ( +
+
Runner Unavailable
+
{runnerMessage}
+
+ )} +
+ ); +} diff --git a/src/runtime-three/first-person-navigation-controller.ts b/src/runtime-three/first-person-navigation-controller.ts new file mode 100644 index 00000000..c36910a5 --- /dev/null +++ b/src/runtime-three/first-person-navigation-controller.ts @@ -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(); + 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." + ); + }); + } + }; +} diff --git a/src/runtime-three/navigation-controller.ts b/src/runtime-three/navigation-controller.ts new file mode 100644 index 00000000..0524586b --- /dev/null +++ b/src/runtime-three/navigation-controller.ts @@ -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; +} diff --git a/src/runtime-three/orbit-visitor-navigation-controller.ts b/src/runtime-three/orbit-visitor-navigation-controller.ts new file mode 100644 index 00000000..0e34f24c --- /dev/null +++ b/src/runtime-three/orbit-visitor-navigation-controller.ts @@ -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(); + }; +} diff --git a/src/runtime-three/player-collision.ts b/src/runtime-three/player-collision.ts new file mode 100644 index 00000000..d1f3604c --- /dev/null +++ b/src/runtime-three/player-collision.ts @@ -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 + } + }; +} diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts new file mode 100644 index 00000000..ea0f69d7 --- /dev/null +++ b/src/runtime-three/runtime-host.ts @@ -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; +} + +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>(); + private readonly materialTextureCache = new Map(); + 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) { + 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); + }; +} diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts new file mode 100644 index 00000000..3a02f921 --- /dev/null +++ b/src/runtime-three/runtime-scene-build.ts @@ -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; +} + +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 + } + }; +}