Add various runtime-three components

This commit is contained in:
2026-03-31 03:04:15 +02:00
parent 8c21debc44
commit b672ba9b10
8 changed files with 1215 additions and 0 deletions

View 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."
);
});
}
};
}

View 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;
}

View 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();
};
}

View 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
}
};
}

View 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);
};
}

View 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
}
};
}