auto-git:

[add] src/geometry/model-instance-collider-generation.ts
 [add] src/runtime-three/rapier-collision-world.ts
 [change] AGENTS.md
 [change] CHAT_CONTEXT.md
 [change] architecture.md
 [change] package.json
 [change] prompts-lite.txt
 [change] prompts.txt
 [change] roadmap.md
 [change] src/assets/model-instances.ts
 [change] src/document/migrate-scene-document.ts
 [change] src/document/scene-document-validation.ts
 [change] src/document/scene-document.ts
 [change] src/runtime-three/first-person-navigation-controller.ts
 [change] src/runtime-three/navigation-controller.ts
 [change] src/runtime-three/runtime-host.ts
 [change] src/runtime-three/runtime-scene-build.ts
 [change] src/runtime-three/runtime-scene-validation.ts
 [change] src/viewport-three/viewport-host.ts
 [change] testing.md
This commit is contained in:
2026-04-04 07:51:38 +02:00
parent d82e37861e
commit 3d1dd3fe63
20 changed files with 1553 additions and 31 deletions

View File

@@ -2,6 +2,15 @@ import { createOpaqueId } from "../core/ids";
import type { Vec3 } from "../core/vector";
import type { ModelAssetRecord } from "./project-assets";
export const MODEL_INSTANCE_COLLISION_MODES = ["none", "terrain", "static", "dynamic", "simple"] as const;
export type ModelInstanceCollisionMode = (typeof MODEL_INSTANCE_COLLISION_MODES)[number];
export interface ModelInstanceCollisionSettings {
mode: ModelInstanceCollisionMode;
visible: boolean;
}
export interface ModelInstance {
id: string;
kind: "modelInstance";
@@ -10,6 +19,7 @@ export interface ModelInstance {
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
collision: ModelInstanceCollisionSettings;
animationClipName?: string;
animationAutoplay?: boolean;
}
@@ -32,6 +42,11 @@ export const DEFAULT_MODEL_INSTANCE_SCALE: Vec3 = {
z: 1
};
export const DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS: ModelInstanceCollisionSettings = {
mode: "none",
visible: false
};
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
@@ -44,6 +59,39 @@ function areVec3Equal(left: Vec3, right: Vec3): boolean {
return left.x === right.x && left.y === right.y && left.z === right.z;
}
export function isModelInstanceCollisionMode(value: unknown): value is ModelInstanceCollisionMode {
return MODEL_INSTANCE_COLLISION_MODES.includes(value as ModelInstanceCollisionMode);
}
export function createModelInstanceCollisionSettings(
overrides: Partial<ModelInstanceCollisionSettings> = {}
): ModelInstanceCollisionSettings {
const mode = overrides.mode ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.mode;
if (!isModelInstanceCollisionMode(mode)) {
throw new Error("Model instance collision mode must be a supported value.");
}
const visible = overrides.visible ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS.visible;
if (typeof visible !== "boolean") {
throw new Error("Model instance collision visibility must be a boolean.");
}
return {
mode,
visible
};
}
export function cloneModelInstanceCollisionSettings(settings: ModelInstanceCollisionSettings): ModelInstanceCollisionSettings {
return createModelInstanceCollisionSettings(settings);
}
export function areModelInstanceCollisionSettingsEqual(left: ModelInstanceCollisionSettings, right: ModelInstanceCollisionSettings): boolean {
return left.mode === right.mode && left.visible === right.visible;
}
export function normalizeModelInstanceName(name: string | null | undefined): string | undefined {
if (name === undefined || name === null) {
return undefined;
@@ -68,11 +116,15 @@ function assertPositiveFiniteVec3(vector: Vec3, label: string) {
}
export function createModelInstance(
overrides: Partial<Pick<ModelInstance, "id" | "name" | "position" | "rotationDegrees" | "scale" | "animationClipName" | "animationAutoplay">> & Pick<ModelInstance, "assetId">
overrides: Partial<
Pick<ModelInstance, "id" | "name" | "position" | "rotationDegrees" | "scale" | "collision" | "animationClipName" | "animationAutoplay">
> &
Pick<ModelInstance, "assetId">
): ModelInstance {
const position = cloneVec3(overrides.position ?? DEFAULT_MODEL_INSTANCE_POSITION);
const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES);
const scale = cloneVec3(overrides.scale ?? DEFAULT_MODEL_INSTANCE_SCALE);
const collision = cloneModelInstanceCollisionSettings(overrides.collision ?? DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS);
if (overrides.assetId.trim().length === 0) {
throw new Error("Model instance assetId must be a non-empty string.");
@@ -90,6 +142,7 @@ export function createModelInstance(
position,
rotationDegrees,
scale,
collision,
animationClipName: overrides.animationClipName,
animationAutoplay: overrides.animationAutoplay
};
@@ -128,6 +181,7 @@ export function areModelInstancesEqual(left: ModelInstance, right: ModelInstance
areVec3Equal(left.position, right.position) &&
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
areVec3Equal(left.scale, right.scale) &&
areModelInstanceCollisionSettingsEqual(left.collision, right.collision) &&
left.animationClipName === right.animationClipName &&
left.animationAutoplay === right.animationAutoplay
);

View File

@@ -1,7 +1,10 @@
import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library";
import {
createModelInstanceCollisionSettings,
createModelInstance,
isModelInstanceCollisionMode,
normalizeModelInstanceName,
type ModelInstanceCollisionSettings,
type ModelInstance
} from "../assets/model-instances";
import {
@@ -51,6 +54,7 @@ import {
FACE_MATERIALS_SCENE_DOCUMENT_VERSION,
FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
FOUNDATION_SCENE_DOCUMENT_VERSION,
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
RUNNER_V1_SCENE_DOCUMENT_VERSION,
@@ -503,6 +507,23 @@ function readAssets(value: unknown): SceneDocument["assets"] {
return assets;
}
function readModelInstanceCollisionSettings(value: unknown, label: string): ModelInstanceCollisionSettings {
if (value === undefined) {
return createModelInstanceCollisionSettings();
}
if (!isRecord(value)) {
throw new Error(`${label} must be an object.`);
}
const mode = readOptionalAllowedValue(value.mode, `${label}.mode`, "none", isModelInstanceCollisionMode);
return createModelInstanceCollisionSettings({
mode,
visible: readOptionalBoolean(value.visible, `${label}.visible`, false)
});
}
function readModelInstance(value: unknown, label: string, assets: SceneDocument["assets"]): ModelInstance {
if (!isRecord(value)) {
throw new Error(`${label} must be an object.`);
@@ -526,6 +547,7 @@ function readModelInstance(value: unknown, label: string, assets: SceneDocument[
position: readVec3(value.position, `${label}.position`),
rotationDegrees: readVec3(value.rotationDegrees, `${label}.rotationDegrees`),
scale: readVec3(value.scale, `${label}.scale`),
collision: readModelInstanceCollisionSettings(value.collision, `${label}.collision`),
animationClipName: (() => {
const raw = expectOptionalString(value.animationClipName, `${label}.animationClipName`);
if (raw === undefined) return undefined;
@@ -1442,6 +1464,25 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
};
}
// v15 -> v16: model instances gained authored collider settings.
if (source.version === ENTITY_NAMES_SCENE_DOCUMENT_VERSION) {
const materials = readMaterialRegistry(source.materials, "materials");
const assets = readAssets(source.assets);
return {
version: IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
name: expectString(source.name, "name"),
world: readWorldSettings(source.world),
materials,
textures: expectEmptyCollection(source.textures, "textures"),
assets,
brushes: readBrushes(source.brushes, materials, false),
modelInstances: readModelInstances(source.modelInstances, assets),
entities: readEntities(source.entities, { legacySoundEmitter: false }),
interactionLinks: readInteractionLinks(source.interactionLinks)
};
}
// v14 -> v15: entities gained an optional authored name field.
if (source.version === 14) {
const materials = readMaterialRegistry(source.materials, "materials");

View File

@@ -6,6 +6,7 @@ import {
type ProjectAssetRecord
} from "../assets/project-assets";
import type { ModelInstance } from "../assets/model-instances";
import { isModelInstanceCollisionMode } from "../assets/model-instances";
import {
type InteractableEntity,
type PointLightEntity,
@@ -646,6 +647,28 @@ function validateModelInstance(modelInstance: ModelInstance, path: string, docum
diagnostics.push(createDiagnostic("error", "invalid-model-instance-scale", "Model instance scales must remain finite and positive on every axis.", `${path}.scale`));
}
if (!isModelInstanceCollisionMode(modelInstance.collision.mode)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-model-instance-collision-mode",
"Model instance collision mode must be one of none, terrain, static, dynamic, or simple.",
`${path}.collision.mode`
)
);
}
if (!isBoolean(modelInstance.collision.visible)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-model-instance-collision-visibility",
"Model instance collision visibility must be a boolean.",
`${path}.collision.visible`
)
);
}
const asset = document.assets[modelInstance.assetId];
if (asset === undefined) {

View File

@@ -6,7 +6,8 @@ import type { InteractionLink } from "../interactions/interaction-links";
import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library";
import { createDefaultWorldSettings, type WorldSettings } from "./world-settings";
export const SCENE_DOCUMENT_VERSION = 15 as const;
export const SCENE_DOCUMENT_VERSION = 16 as const;
export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16 as const;
export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const;
export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const;
export const ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION = 12 as const;

View File

@@ -0,0 +1,677 @@
import {
Euler,
Group,
MathUtils,
Matrix4,
Mesh,
Quaternion,
Vector3,
type BufferAttribute,
type BufferGeometry
} from "three";
import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { ModelInstance, ModelInstanceCollisionMode } from "../assets/model-instances";
import type { ModelAssetRecord } from "../assets/project-assets";
import type { Vec3 } from "../core/vector";
const TERRAIN_GRID_EPSILON = 1e-4;
const DYNAMIC_TRIANGLE_TARGET = 48;
const DYNAMIC_SPLIT_DEPTH_LIMIT = 3;
interface LocalTriangle {
readonly a: Vector3;
readonly b: Vector3;
readonly c: Vector3;
}
interface LocalTriangleCluster {
readonly triangles: LocalTriangle[];
}
export interface GeneratedColliderBounds {
min: Vec3;
max: Vec3;
}
export interface GeneratedModelColliderTransform {
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
}
interface GeneratedModelColliderBase {
source: "modelInstance";
instanceId: string;
assetId: string;
mode: ModelInstanceCollisionMode;
visible: boolean;
transform: GeneratedModelColliderTransform;
localBounds: GeneratedColliderBounds;
worldBounds: GeneratedColliderBounds;
}
export interface GeneratedModelBoxCollider extends GeneratedModelColliderBase {
kind: "box";
center: Vec3;
size: Vec3;
}
export interface GeneratedModelTriMeshCollider extends GeneratedModelColliderBase {
kind: "trimesh";
vertices: Float32Array;
indices: Uint32Array;
triangleCount: number;
}
export interface GeneratedModelHeightfieldCollider extends GeneratedModelColliderBase {
kind: "heightfield";
rows: number;
cols: number;
heights: Float32Array;
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
export interface GeneratedModelCompoundColliderPiece {
id: string;
points: Float32Array;
localBounds: GeneratedColliderBounds;
}
export interface GeneratedModelCompoundCollider extends GeneratedModelColliderBase {
kind: "compound";
pieces: GeneratedModelCompoundColliderPiece[];
decomposition: "spatial-bisect";
runtimeBehavior: "fixedQueryOnly";
}
export type GeneratedModelCollider =
| GeneratedModelBoxCollider
| GeneratedModelTriMeshCollider
| GeneratedModelHeightfieldCollider
| GeneratedModelCompoundCollider;
export class ModelColliderGenerationError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "ModelColliderGenerationError";
this.code = code;
}
}
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function vector3ToVec3(vector: Vector3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function createBounds(min: Vector3, max: Vector3): GeneratedColliderBounds {
return {
min: vector3ToVec3(min),
max: vector3ToVec3(max)
};
}
function createModelTransform(modelInstance: ModelInstance): GeneratedModelColliderTransform {
return {
position: cloneVec3(modelInstance.position),
rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
scale: cloneVec3(modelInstance.scale)
};
}
function createModelTransformMatrix(modelInstance: ModelInstance): Matrix4 {
const rotation = new Euler(
MathUtils.degToRad(modelInstance.rotationDegrees.x),
MathUtils.degToRad(modelInstance.rotationDegrees.y),
MathUtils.degToRad(modelInstance.rotationDegrees.z),
"XYZ"
);
const quaternion = new Quaternion().setFromEuler(rotation);
return new Matrix4().compose(
new Vector3(modelInstance.position.x, modelInstance.position.y, modelInstance.position.z),
quaternion,
new Vector3(modelInstance.scale.x, modelInstance.scale.y, modelInstance.scale.z)
);
}
function computeBoundsFromPoints(points: Iterable<Vector3>): GeneratedColliderBounds {
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
let hasPoint = false;
for (const point of points) {
hasPoint = true;
min.min(point);
max.max(point);
}
if (!hasPoint) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry.");
}
return createBounds(min, max);
}
function computeBoundsFromFloat32Points(points: Float32Array): GeneratedColliderBounds {
if (points.length < 3) {
throw new ModelColliderGenerationError("missing-model-collider-geometry", "The selected model does not contain any collision-capable geometry.");
}
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
for (let index = 0; index < points.length; index += 3) {
min.x = Math.min(min.x, points[index]);
min.y = Math.min(min.y, points[index + 1]);
min.z = Math.min(min.z, points[index + 2]);
max.x = Math.max(max.x, points[index]);
max.y = Math.max(max.y, points[index + 1]);
max.z = Math.max(max.z, points[index + 2]);
}
return createBounds(min, max);
}
function computeWorldBoundsFromLocalBox(localBounds: GeneratedColliderBounds, modelMatrix: Matrix4): GeneratedColliderBounds {
const min = localBounds.min;
const max = localBounds.max;
const corners = [
new Vector3(min.x, min.y, min.z),
new Vector3(min.x, min.y, max.z),
new Vector3(min.x, max.y, min.z),
new Vector3(min.x, max.y, max.z),
new Vector3(max.x, min.y, min.z),
new Vector3(max.x, min.y, max.z),
new Vector3(max.x, max.y, min.z),
new Vector3(max.x, max.y, max.z)
];
return computeBoundsFromPoints(corners.map((corner) => corner.applyMatrix4(modelMatrix)));
}
function readIndexedVertex(position: BufferAttribute, index: number, matrix: Matrix4): Vector3 {
return new Vector3(position.getX(index), position.getY(index), position.getZ(index)).applyMatrix4(matrix);
}
function getMeshGeometry(object: Group | Mesh): BufferGeometry | null {
const maybeMesh = object as Mesh & { isMesh?: boolean };
if (maybeMesh.isMesh !== true) {
return null;
}
return maybeMesh.geometry;
}
function collectMeshTriangleClusters(template: Group): LocalTriangleCluster[] {
template.updateMatrixWorld(true);
const clusters: LocalTriangleCluster[] = [];
template.traverse((object) => {
const geometry = getMeshGeometry(object as Group | Mesh);
if (geometry === null) {
return;
}
const position = geometry.getAttribute("position");
if (position === undefined || position.itemSize < 3 || position.count < 3) {
return;
}
const matrix = object.matrixWorld;
const index = geometry.getIndex();
const triangles: LocalTriangle[] = [];
if (index === null) {
for (let vertexIndex = 0; vertexIndex <= position.count - 3; vertexIndex += 3) {
triangles.push({
a: readIndexedVertex(position, vertexIndex, matrix),
b: readIndexedVertex(position, vertexIndex + 1, matrix),
c: readIndexedVertex(position, vertexIndex + 2, matrix)
});
}
} else {
for (let triangleIndex = 0; triangleIndex <= index.count - 3; triangleIndex += 3) {
triangles.push({
a: readIndexedVertex(position, index.getX(triangleIndex), matrix),
b: readIndexedVertex(position, index.getX(triangleIndex + 1), matrix),
c: readIndexedVertex(position, index.getX(triangleIndex + 2), matrix)
});
}
}
if (triangles.length > 0) {
clusters.push({
triangles
});
}
});
return clusters;
}
function flattenTriangleClusters(clusters: LocalTriangleCluster[]): LocalTriangle[] {
return clusters.flatMap((cluster) => cluster.triangles);
}
function buildTriMeshBuffers(triangles: LocalTriangle[]): { vertices: Float32Array; indices: Uint32Array } {
const vertices = new Float32Array(triangles.length * 9);
const indices = new Uint32Array(triangles.length * 3);
let vertexOffset = 0;
for (let triangleIndex = 0; triangleIndex < triangles.length; triangleIndex += 1) {
const triangle = triangles[triangleIndex];
vertices[vertexOffset] = triangle.a.x;
vertices[vertexOffset + 1] = triangle.a.y;
vertices[vertexOffset + 2] = triangle.a.z;
vertices[vertexOffset + 3] = triangle.b.x;
vertices[vertexOffset + 4] = triangle.b.y;
vertices[vertexOffset + 5] = triangle.b.z;
vertices[vertexOffset + 6] = triangle.c.x;
vertices[vertexOffset + 7] = triangle.c.y;
vertices[vertexOffset + 8] = triangle.c.z;
indices[triangleIndex * 3] = triangleIndex * 3;
indices[triangleIndex * 3 + 1] = triangleIndex * 3 + 1;
indices[triangleIndex * 3 + 2] = triangleIndex * 3 + 2;
vertexOffset += 9;
}
return {
vertices,
indices
};
}
function computeClusterCentroid(triangles: LocalTriangle[]): Vec3 {
const centroid = {
x: 0,
y: 0,
z: 0
};
let pointCount = 0;
for (const triangle of triangles) {
centroid.x += triangle.a.x + triangle.b.x + triangle.c.x;
centroid.y += triangle.a.y + triangle.b.y + triangle.c.y;
centroid.z += triangle.a.z + triangle.b.z + triangle.c.z;
pointCount += 3;
}
return {
x: centroid.x / pointCount,
y: centroid.y / pointCount,
z: centroid.z / pointCount
};
}
function getTriangleBounds(triangles: LocalTriangle[]): GeneratedColliderBounds {
return computeBoundsFromPoints(triangles.flatMap((triangle) => [triangle.a, triangle.b, triangle.c]));
}
type TriangleClusterSplit =
| {
kind: "leaf";
triangles: LocalTriangle[];
}
| {
kind: "split";
left: LocalTriangle[];
right: LocalTriangle[];
};
function splitTriangleCluster(triangles: LocalTriangle[], depth: number): TriangleClusterSplit {
if (triangles.length <= DYNAMIC_TRIANGLE_TARGET || depth >= DYNAMIC_SPLIT_DEPTH_LIMIT) {
return {
kind: "leaf",
triangles
};
}
const bounds = getTriangleBounds(triangles);
const size = {
x: bounds.max.x - bounds.min.x,
y: bounds.max.y - bounds.min.y,
z: bounds.max.z - bounds.min.z
};
const splitAxis = size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z";
const sortedTriangles = [...triangles].sort((left, right) => computeClusterCentroid([left])[splitAxis] - computeClusterCentroid([right])[splitAxis]);
const splitIndex = Math.floor(sortedTriangles.length * 0.5);
if (splitIndex <= 0 || splitIndex >= sortedTriangles.length) {
return {
kind: "leaf",
triangles
};
}
return {
kind: "split",
left: sortedTriangles.slice(0, splitIndex),
right: sortedTriangles.slice(splitIndex)
};
}
function collectConvexHullPointClouds(cluster: LocalTriangle[], depth = 0): Float32Array[] {
const split = splitTriangleCluster(cluster, depth);
if (split.kind === "leaf") {
return [dedupeTriangleClusterPoints(split.triangles)];
}
return [...collectConvexHullPointClouds(split.left, depth + 1), ...collectConvexHullPointClouds(split.right, depth + 1)];
}
function quantizeCoordinate(value: number): string {
return (Math.round(value / TERRAIN_GRID_EPSILON) * TERRAIN_GRID_EPSILON).toFixed(4);
}
function dedupeTriangleClusterPoints(triangles: LocalTriangle[]): Float32Array {
const pointLookup = new Map<string, Vec3>();
for (const triangle of triangles) {
for (const point of [triangle.a, triangle.b, triangle.c]) {
const key = `${quantizeCoordinate(point.x)}:${quantizeCoordinate(point.y)}:${quantizeCoordinate(point.z)}`;
if (!pointLookup.has(key)) {
pointLookup.set(key, {
x: point.x,
y: point.y,
z: point.z
});
}
}
}
if (pointLookup.size < 4) {
throw new ModelColliderGenerationError(
"unsupported-dynamic-model-collider",
"Dynamic collision requires volumetric geometry that can form at least one convex hull."
);
}
return new Float32Array(
Array.from(pointLookup.values()).flatMap((point) => [point.x, point.y, point.z])
);
}
function buildSimpleBoxCollider(modelInstance: ModelInstance, asset: ModelAssetRecord): GeneratedModelBoxCollider {
const boundingBox = asset.metadata.boundingBox;
if (boundingBox === null) {
throw new ModelColliderGenerationError(
"missing-model-collider-bounds",
`Model instance ${modelInstance.id} cannot use simple collision because the asset does not have a measurable bounding box.`
);
}
const localBounds = createBounds(
new Vector3(boundingBox.min.x, boundingBox.min.y, boundingBox.min.z),
new Vector3(boundingBox.max.x, boundingBox.max.y, boundingBox.max.z)
);
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: modelInstance.assetId,
mode: "simple",
kind: "box",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
center: {
x: (boundingBox.min.x + boundingBox.max.x) * 0.5,
y: (boundingBox.min.y + boundingBox.max.y) * 0.5,
z: (boundingBox.min.z + boundingBox.max.z) * 0.5
},
size: cloneVec3(boundingBox.size),
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
function buildTriMeshCollider(modelInstance: ModelInstance, asset: ModelAssetRecord, loadedAsset: LoadedModelAsset | undefined): GeneratedModelTriMeshCollider {
if (loadedAsset === undefined) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot build ${modelInstance.collision.mode} collision until asset geometry has loaded.`
);
}
const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template));
if (triangles.length === 0) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot use ${modelInstance.collision.mode} collision because the asset has no mesh triangles.`
);
}
const buffers = buildTriMeshBuffers(triangles);
const localBounds = computeBoundsFromFloat32Points(buffers.vertices);
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: asset.id,
mode: "static",
kind: "trimesh",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
vertices: buffers.vertices,
indices: buffers.indices,
triangleCount: triangles.length,
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
function buildTerrainCollider(
modelInstance: ModelInstance,
asset: ModelAssetRecord,
loadedAsset: LoadedModelAsset | undefined
): GeneratedModelHeightfieldCollider {
if (loadedAsset === undefined) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot build terrain collision until asset geometry has loaded.`
);
}
const triangles = flattenTriangleClusters(collectMeshTriangleClusters(loadedAsset.template));
if (triangles.length === 0) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot use terrain collision because the asset has no mesh triangles.`
);
}
const heightLookup = new Map<string, { x: number; y: number; z: number }>();
const xValues = new Map<string, number>();
const zValues = new Map<string, number>();
for (const triangle of triangles) {
for (const point of [triangle.a, triangle.b, triangle.c]) {
const xKey = quantizeCoordinate(point.x);
const zKey = quantizeCoordinate(point.z);
const key = `${xKey}:${zKey}`;
const previousPoint = heightLookup.get(key);
if (previousPoint !== undefined && Math.abs(previousPoint.y - point.y) > TERRAIN_GRID_EPSILON) {
throw new ModelColliderGenerationError(
"unsupported-terrain-model-collider",
`Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a single-valued heightfield over X/Z.`
);
}
heightLookup.set(key, {
x: point.x,
y: point.y,
z: point.z
});
xValues.set(xKey, point.x);
zValues.set(zKey, point.z);
}
}
const sortedX = Array.from(xValues.values()).sort((left, right) => left - right);
const sortedZ = Array.from(zValues.values()).sort((left, right) => left - right);
if (sortedX.length < 2 || sortedZ.length < 2) {
throw new ModelColliderGenerationError(
"unsupported-terrain-model-collider",
`Model instance ${modelInstance.id} cannot use terrain collision because the source mesh does not form a regular X/Z grid.`
);
}
const expectedTriangleCount = (sortedX.length - 1) * (sortedZ.length - 1) * 2;
if (triangles.length !== expectedTriangleCount) {
throw new ModelColliderGenerationError(
"unsupported-terrain-model-collider",
`Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is not a clean regular-grid terrain surface.`
);
}
const heights = new Float32Array(sortedX.length * sortedZ.length);
for (let zIndex = 0; zIndex < sortedZ.length; zIndex += 1) {
for (let xIndex = 0; xIndex < sortedX.length; xIndex += 1) {
const key = `${quantizeCoordinate(sortedX[xIndex])}:${quantizeCoordinate(sortedZ[zIndex])}`;
const point = heightLookup.get(key);
if (point === undefined) {
throw new ModelColliderGenerationError(
"unsupported-terrain-model-collider",
`Model instance ${modelInstance.id} cannot use terrain collision because the source mesh is missing one or more regular-grid height samples.`
);
}
heights[xIndex + zIndex * sortedX.length] = point.y;
}
}
const localBounds = computeBoundsFromPoints(
Array.from(heightLookup.values(), (point) => new Vector3(point.x, point.y, point.z))
);
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: asset.id,
mode: "terrain",
kind: "heightfield",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
rows: sortedX.length,
cols: sortedZ.length,
heights,
minX: sortedX[0],
maxX: sortedX.at(-1) ?? sortedX[0],
minZ: sortedZ[0],
maxZ: sortedZ.at(-1) ?? sortedZ[0],
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
function buildDynamicCollider(
modelInstance: ModelInstance,
asset: ModelAssetRecord,
loadedAsset: LoadedModelAsset | undefined
): GeneratedModelCompoundCollider {
if (loadedAsset === undefined) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot build dynamic collision until asset geometry has loaded.`
);
}
const triangleClusters = collectMeshTriangleClusters(loadedAsset.template);
if (triangleClusters.length === 0) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot use dynamic collision because the asset has no mesh triangles.`
);
}
const pieces = triangleClusters
.flatMap((cluster) => collectConvexHullPointClouds(cluster.triangles))
.map((points, index) => ({
id: `${modelInstance.id}-piece-${index + 1}`,
points,
localBounds: computeBoundsFromFloat32Points(points)
}));
if (pieces.length === 0) {
throw new ModelColliderGenerationError(
"unsupported-dynamic-model-collider",
`Model instance ${modelInstance.id} could not derive any convex pieces for dynamic collision.`
);
}
const localBounds = computeBoundsFromPoints(
pieces.flatMap((piece) => {
const points: Vector3[] = [];
for (let pointIndex = 0; pointIndex < piece.points.length; pointIndex += 3) {
points.push(new Vector3(piece.points[pointIndex], piece.points[pointIndex + 1], piece.points[pointIndex + 2]));
}
return points;
})
);
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: asset.id,
mode: "dynamic",
kind: "compound",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
pieces,
decomposition: "spatial-bisect",
runtimeBehavior: "fixedQueryOnly",
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
export function buildGeneratedModelCollider(
modelInstance: ModelInstance,
asset: ModelAssetRecord,
loadedAsset?: LoadedModelAsset
): GeneratedModelCollider | null {
switch (modelInstance.collision.mode) {
case "none":
return null;
case "simple":
return buildSimpleBoxCollider(modelInstance, asset);
case "static":
return buildTriMeshCollider(modelInstance, asset, loadedAsset);
case "terrain":
return buildTerrainCollider(modelInstance, asset, loadedAsset);
case "dynamic":
return buildDynamicCollider(modelInstance, asset, loadedAsset);
}
}

View File

@@ -2,7 +2,7 @@ import { Euler, Vector3 } from "three";
import type { Vec3 } from "../core/vector";
import { FIRST_PERSON_PLAYER_SHAPE, resolveFirstPersonMotion } from "./player-collision";
import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision";
import type { NavigationController, RuntimeControllerContext } from "./navigation-controller";
const LOOK_SENSITIVITY = 0.0022;
@@ -116,17 +116,22 @@ export class FirstPersonNavigationController implements NavigationController {
this.verticalVelocity -= GRAVITY * dt;
const resolvedMotion = resolveFirstPersonMotion(
const resolvedMotion = this.context.resolveFirstPersonMotion(
this.feetPosition,
{
x: horizontalX,
y: this.verticalVelocity * dt,
z: horizontalZ
},
FIRST_PERSON_PLAYER_SHAPE,
this.context.getRuntimeScene().colliders
FIRST_PERSON_PLAYER_SHAPE
);
if (resolvedMotion === null) {
this.updateCameraTransform();
this.publishTelemetry();
return;
}
this.feetPosition = resolvedMotion.feetPosition;
this.grounded = resolvedMotion.grounded;

View File

@@ -2,6 +2,7 @@ import type { PerspectiveCamera } from "three";
import type { Vec3 } from "../core/vector";
import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision";
import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build";
export interface FirstPersonTelemetry {
@@ -16,6 +17,7 @@ export interface RuntimeControllerContext {
camera: PerspectiveCamera;
domElement: HTMLCanvasElement;
getRuntimeScene(): RuntimeSceneDefinition;
resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion | null;
setRuntimeMessage(message: string | null): void;
setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void;
}

View File

@@ -0,0 +1,278 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { Euler, MathUtils, Quaternion, Vector3 } from "three";
import type { Vec3 } from "../core/vector";
import type {
GeneratedModelBoxCollider,
GeneratedModelCollider,
GeneratedModelCompoundCollider,
GeneratedModelHeightfieldCollider,
GeneratedModelTriMeshCollider
} from "../geometry/model-instance-collider-generation";
import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision";
import type { RuntimeBoxCollider, RuntimeSceneCollider } from "./runtime-scene-build";
const CHARACTER_CONTROLLER_OFFSET = 0.01;
const COLLISION_EPSILON = 1e-5;
let rapierInitPromise: Promise<typeof RAPIER> | null = null;
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function componentScale(vector: Vec3, scale: Vec3): Vec3 {
return {
x: vector.x * scale.x,
y: vector.y * scale.y,
z: vector.z * scale.z
};
}
function createRapierQuaternion(rotationDegrees: Vec3): RAPIER.Rotation {
const quaternion = new Quaternion().setFromEuler(
new Euler(
MathUtils.degToRad(rotationDegrees.x),
MathUtils.degToRad(rotationDegrees.y),
MathUtils.degToRad(rotationDegrees.z),
"XYZ"
)
);
return {
x: quaternion.x,
y: quaternion.y,
z: quaternion.z,
w: quaternion.w
};
}
function scaleVertices(vertices: Float32Array, scale: Vec3): Float32Array {
const scaledVertices = new Float32Array(vertices.length);
for (let index = 0; index < vertices.length; index += 3) {
scaledVertices[index] = vertices[index] * scale.x;
scaledVertices[index + 1] = vertices[index + 1] * scale.y;
scaledVertices[index + 2] = vertices[index + 2] * scale.z;
}
return scaledVertices;
}
function scaleBoundsCenter(bounds: { min: Vec3; max: Vec3 }, scale: Vec3): Vec3 {
return {
x: ((bounds.min.x + bounds.max.x) * 0.5) * scale.x,
y: ((bounds.min.y + bounds.max.y) * 0.5) * scale.y,
z: ((bounds.min.z + bounds.max.z) * 0.5) * scale.z
};
}
function createFixedBodyForModelCollider(world: RAPIER.World, collider: GeneratedModelCollider): RAPIER.RigidBody {
return world.createRigidBody(
RAPIER.RigidBodyDesc.fixed()
.setTranslation(collider.transform.position.x, collider.transform.position.y, collider.transform.position.z)
.setRotation(createRapierQuaternion(collider.transform.rotationDegrees))
);
}
function attachBrushCollider(world: RAPIER.World, collider: RuntimeBoxCollider) {
const center = {
x: (collider.min.x + collider.max.x) * 0.5,
y: (collider.min.y + collider.max.y) * 0.5,
z: (collider.min.z + collider.max.z) * 0.5
};
const halfExtents = {
x: (collider.max.x - collider.min.x) * 0.5,
y: (collider.max.y - collider.min.y) * 0.5,
z: (collider.max.z - collider.min.z) * 0.5
};
world.createCollider(RAPIER.ColliderDesc.cuboid(halfExtents.x, halfExtents.y, halfExtents.z).setTranslation(center.x, center.y, center.z));
}
function attachSimpleModelCollider(world: RAPIER.World, collider: GeneratedModelBoxCollider) {
const body = createFixedBodyForModelCollider(world, collider);
const scaledCenter = componentScale(collider.center, collider.transform.scale);
const scaledHalfExtents = componentScale(
{
x: collider.size.x * 0.5,
y: collider.size.y * 0.5,
z: collider.size.z * 0.5
},
collider.transform.scale
);
world.createCollider(
RAPIER.ColliderDesc.cuboid(scaledHalfExtents.x, scaledHalfExtents.y, scaledHalfExtents.z).setTranslation(
scaledCenter.x,
scaledCenter.y,
scaledCenter.z
),
body
);
}
function attachStaticModelCollider(world: RAPIER.World, collider: GeneratedModelTriMeshCollider) {
const body = createFixedBodyForModelCollider(world, collider);
world.createCollider(RAPIER.ColliderDesc.trimesh(scaleVertices(collider.vertices, collider.transform.scale), collider.indices), body);
}
function attachTerrainModelCollider(world: RAPIER.World, collider: GeneratedModelHeightfieldCollider) {
const body = createFixedBodyForModelCollider(world, collider);
const center = scaleBoundsCenter(
{
min: {
x: collider.minX,
y: 0,
z: collider.minZ
},
max: {
x: collider.maxX,
y: 0,
z: collider.maxZ
}
},
collider.transform.scale
);
world.createCollider(
RAPIER.ColliderDesc.heightfield(collider.rows, collider.cols, collider.heights, {
x: (collider.maxX - collider.minX) * collider.transform.scale.x,
y: collider.transform.scale.y,
z: (collider.maxZ - collider.minZ) * collider.transform.scale.z
}).setTranslation(center.x, center.y, center.z),
body
);
}
function attachDynamicModelCollider(world: RAPIER.World, collider: GeneratedModelCompoundCollider) {
const body = createFixedBodyForModelCollider(world, collider);
for (const piece of collider.pieces) {
const scaledPoints = scaleVertices(piece.points, collider.transform.scale);
const descriptor = RAPIER.ColliderDesc.convexHull(scaledPoints);
if (descriptor === null) {
throw new Error(`Dynamic collider piece ${piece.id} could not form a valid convex hull.`);
}
world.createCollider(descriptor, body);
}
}
function attachModelCollider(world: RAPIER.World, collider: GeneratedModelCollider) {
switch (collider.kind) {
case "box":
attachSimpleModelCollider(world, collider);
break;
case "trimesh":
attachStaticModelCollider(world, collider);
break;
case "heightfield":
attachTerrainModelCollider(world, collider);
break;
case "compound":
attachDynamicModelCollider(world, collider);
break;
}
}
function feetPositionToColliderCenter(feetPosition: Vec3, shape: FirstPersonPlayerShape): Vec3 {
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
return {
x: feetPosition.x,
y: feetPosition.y + shape.radius + cylindricalHalfHeight,
z: feetPosition.z
};
}
function colliderCenterToFeetPosition(center: Vec3, shape: FirstPersonPlayerShape): Vec3 {
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
return {
x: center.x,
y: center.y - (shape.radius + cylindricalHalfHeight),
z: center.z
};
}
export async function initializeRapierCollisionWorld(): Promise<typeof RAPIER> {
rapierInitPromise ??= RAPIER.init().then(() => RAPIER);
return rapierInitPromise;
}
export class RapierCollisionWorld {
static async create(colliders: RuntimeSceneCollider[], playerShape: FirstPersonPlayerShape): Promise<RapierCollisionWorld> {
const rapier = await initializeRapierCollisionWorld();
const world = new rapier.World({
x: 0,
y: 0,
z: 0
});
for (const collider of colliders) {
if (collider.source === "brush") {
attachBrushCollider(world, collider);
continue;
}
attachModelCollider(world, collider);
}
const playerCollider = world.createCollider(
rapier.ColliderDesc.capsule(Math.max(0, (playerShape.height - playerShape.radius * 2) * 0.5), playerShape.radius)
);
const characterController = world.createCharacterController(CHARACTER_CONTROLLER_OFFSET);
characterController.setUp({ x: 0, y: 1, z: 0 });
characterController.setSlideEnabled(true);
characterController.enableSnapToGround(0.2);
characterController.enableAutostep(0.35, 0.15, false);
characterController.setMaxSlopeClimbAngle(Math.PI * 0.45);
characterController.setMinSlopeSlideAngle(Math.PI * 0.5);
return new RapierCollisionWorld(world, characterController, playerCollider);
}
private constructor(
private readonly world: RAPIER.World,
private readonly characterController: RAPIER.KinematicCharacterController,
private readonly playerCollider: RAPIER.Collider
) {}
resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion {
const currentCenter = feetPositionToColliderCenter(feetPosition, shape);
this.playerCollider.setTranslation(currentCenter);
this.characterController.computeColliderMovement(this.playerCollider, motion);
const correctedMovement = this.characterController.computedMovement();
const nextCenter = {
x: currentCenter.x + correctedMovement.x,
y: currentCenter.y + correctedMovement.y,
z: currentCenter.z + correctedMovement.z
};
this.playerCollider.setTranslation(nextCenter);
return {
feetPosition: colliderCenterToFeetPosition(nextCenter, shape),
grounded: this.characterController.computedGrounded(),
collidedAxes: {
x: Math.abs(correctedMovement.x - motion.x) > COLLISION_EPSILON,
y: Math.abs(correctedMovement.y - motion.y) > COLLISION_EPSILON,
z: Math.abs(correctedMovement.z - motion.z) > COLLISION_EPSILON
}
};
}
dispose() {
this.world.removeCharacterController(this.characterController);
this.world.free();
}
}

View File

@@ -40,6 +40,8 @@ import {
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext } from "./navigation-controller";
import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision";
import { RapierCollisionWorld } from "./rapier-collision-world";
import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system";
import { RuntimeAudioSystem } from "./runtime-audio-system";
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
@@ -85,6 +87,8 @@ export class RuntimeHost {
private readonly controllerContext: RuntimeControllerContext;
private readonly renderer: WebGLRenderer | null;
private runtimeScene: RuntimeSceneDefinition | null = null;
private collisionWorld: RapierCollisionWorld | null = null;
private collisionWorldRequestId = 0;
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null;
private advancedRenderingComposer: EffectComposer | null = null;
@@ -131,6 +135,7 @@ export class RuntimeHost {
return this.runtimeScene;
},
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
setRuntimeMessage: (message) => {
if (message === this.currentRuntimeMessage) {
return;
@@ -171,6 +176,7 @@ export class RuntimeHost {
this.rebuildLocalLights(runtimeScene.localLights);
this.rebuildBrushMeshes(runtimeScene.brushes);
this.rebuildModelInstances(runtimeScene.modelInstances);
void this.rebuildCollisionWorld(runtimeScene.colliders);
this.audioSystem.loadScene(runtimeScene);
}
@@ -244,6 +250,8 @@ export class RuntimeHost {
this.clearLocalLights();
this.clearBrushMeshes();
this.clearModelInstances();
this.collisionWorldRequestId += 1;
this.clearCollisionWorld();
this.audioSystem.dispose();
this.advancedRenderingComposer?.dispose();
this.advancedRenderingComposer = null;
@@ -306,6 +314,36 @@ export class RuntimeHost {
this.applyShadowState();
}
private async rebuildCollisionWorld(colliders: RuntimeSceneDefinition["colliders"]) {
const requestId = ++this.collisionWorldRequestId;
this.clearCollisionWorld();
try {
const nextCollisionWorld = await RapierCollisionWorld.create(colliders, FIRST_PERSON_PLAYER_SHAPE);
if (requestId !== this.collisionWorldRequestId) {
nextCollisionWorld.dispose();
return;
}
this.collisionWorld = nextCollisionWorld;
} catch (error) {
if (requestId !== this.collisionWorldRequestId) {
return;
}
const message = error instanceof Error ? error.message : "Runner collision initialization failed.";
this.currentRuntimeMessage = `Runner collision initialization failed: ${message}`;
this.runtimeMessageHandler?.(this.currentRuntimeMessage);
}
}
private clearCollisionWorld() {
this.collisionWorld?.dispose();
this.collisionWorld = null;
}
private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) {
if (this.renderer === null) {
return;

View File

@@ -1,3 +1,4 @@
import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { Vec3 } from "../core/vector";
import { getModelInstances } from "../assets/model-instances";
import type { BoxBrush, BoxFaceId, FaceUvState } from "../document/brushes";
@@ -5,6 +6,7 @@ import type { SceneDocument } from "../document/scene-document";
import { cloneWorldSettings, type WorldSettings } from "../document/world-settings";
import { getEntityInstances, getPrimaryPlayerStartEntity, type EntityInstance } from "../entities/entity-instances";
import { getBoxBrushBounds } from "../geometry/box-brush";
import { buildGeneratedModelCollider, type GeneratedColliderBounds, type GeneratedModelCollider } from "../geometry/model-instance-collider-generation";
import { cloneInteractionLink, getInteractionLinks, type InteractionLink } from "../interactions/interaction-links";
import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library";
import { cloneFaceUvState } from "../document/brushes";
@@ -28,11 +30,14 @@ export interface RuntimeBoxBrushInstance {
export interface RuntimeBoxCollider {
kind: "box";
source: "brush";
brushId: string;
min: Vec3;
max: Vec3;
}
export type RuntimeSceneCollider = RuntimeBoxCollider | GeneratedModelCollider;
export interface RuntimeSceneBounds {
min: Vec3;
max: Vec3;
@@ -132,7 +137,7 @@ export interface RuntimeSceneDefinition {
world: WorldSettings;
localLights: RuntimeLocalLightCollection;
brushes: RuntimeBoxBrushInstance[];
colliders: RuntimeBoxCollider[];
colliders: RuntimeSceneCollider[];
sceneBounds: RuntimeSceneBounds | null;
modelInstances: RuntimeModelInstance[];
entities: RuntimeEntityCollection;
@@ -143,6 +148,7 @@ export interface RuntimeSceneDefinition {
interface BuildRuntimeSceneOptions {
navigationMode?: RuntimeNavigationMode;
loadedModelAssets?: Record<string, LoadedModelAsset>;
}
function cloneVec3(vector: Vec3): Vec3 {
@@ -213,6 +219,7 @@ function buildRuntimeCollider(brush: BoxBrush): RuntimeBoxCollider {
return {
kind: "box",
source: "brush",
brushId: brush.id,
min: cloneVec3(bounds.min),
max: cloneVec3(bounds.max)
@@ -232,21 +239,37 @@ function buildRuntimeModelInstance(modelInstance: SceneDocument["modelInstances"
};
}
function combineColliderBounds(colliders: RuntimeBoxCollider[]): RuntimeSceneBounds | null {
function getColliderBounds(collider: RuntimeSceneCollider): GeneratedColliderBounds {
if (collider.source === "brush") {
return {
min: cloneVec3(collider.min),
max: cloneVec3(collider.max)
};
}
return {
min: cloneVec3(collider.worldBounds.min),
max: cloneVec3(collider.worldBounds.max)
};
}
function combineColliderBounds(colliders: RuntimeSceneCollider[]): RuntimeSceneBounds | null {
if (colliders.length === 0) {
return null;
}
const min = cloneVec3(colliders[0].min);
const max = cloneVec3(colliders[0].max);
const firstBounds = getColliderBounds(colliders[0]);
const min = cloneVec3(firstBounds.min);
const max = cloneVec3(firstBounds.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);
const bounds = getColliderBounds(collider);
min.x = Math.min(min.x, bounds.min.x);
min.y = Math.min(min.y, bounds.min.y);
min.z = Math.min(min.z, bounds.min.z);
max.x = Math.max(max.x, bounds.max.x);
max.y = Math.max(max.y, bounds.max.y);
max.z = Math.max(max.z, bounds.max.z);
}
return {
@@ -396,15 +419,33 @@ function assertNever(value: never): never {
}
export function buildRuntimeSceneFromDocument(document: SceneDocument, options: BuildRuntimeSceneOptions = {}): RuntimeSceneDefinition {
assertRuntimeSceneBuildable(document, options.navigationMode ?? "orbitVisitor");
assertRuntimeSceneBuildable(document, {
navigationMode: options.navigationMode ?? "orbitVisitor",
loadedModelAssets: options.loadedModelAssets
});
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 colliders: RuntimeSceneCollider[] = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush));
const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance);
const collections = buildRuntimeSceneCollections(document);
const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link));
const playerStartEntity = getPrimaryPlayerStartEntity(document.entities);
for (const modelInstance of getModelInstances(document.modelInstances)) {
const asset = document.assets[modelInstance.assetId];
if (asset === undefined || asset.kind !== "model") {
continue;
}
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
if (generatedCollider !== null) {
colliders.push(generatedCollider);
}
}
const combinedSceneBounds = combineColliderBounds(colliders);
const playerStart =
playerStartEntity === null
? null
@@ -419,14 +460,14 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
localLights: collections.localLights,
brushes,
colliders,
sceneBounds,
sceneBounds: combinedSceneBounds,
modelInstances,
entities: collections.entities,
interactionLinks,
playerStart,
spawn:
playerStart === null
? buildFallbackSpawn(sceneBounds)
? buildFallbackSpawn(combinedSceneBounds)
: {
source: "playerStart",
entityId: playerStart.entityId,

View File

@@ -1,3 +1,5 @@
import type { LoadedModelAsset } from "../assets/gltf-model-import";
import { getModelInstances } from "../assets/model-instances";
import type { SceneDocument } from "../document/scene-document";
import {
assertSceneDocumentIsValid,
@@ -6,6 +8,7 @@ import {
type SceneDiagnostic
} from "../document/scene-document-validation";
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation";
export interface RuntimeSceneBuildValidationResult {
diagnostics: SceneDiagnostic[];
@@ -13,13 +16,18 @@ export interface RuntimeSceneBuildValidationResult {
warnings: SceneDiagnostic[];
}
interface ValidateRuntimeSceneBuildOptions {
navigationMode: "firstPerson" | "orbitVisitor";
loadedModelAssets?: Record<string, LoadedModelAsset>;
}
export function validateRuntimeSceneBuild(
document: SceneDocument,
navigationMode: "firstPerson" | "orbitVisitor"
options: ValidateRuntimeSceneBuildOptions
): RuntimeSceneBuildValidationResult {
const diagnostics: SceneDiagnostic[] = [];
if (navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) {
if (options.navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) {
diagnostics.push(
createDiagnostic(
"error",
@@ -31,6 +39,39 @@ export function validateRuntimeSceneBuild(
);
}
for (const modelInstance of getModelInstances(document.modelInstances)) {
const path = `modelInstances.${modelInstance.id}.collision.mode`;
const asset = document.assets[modelInstance.assetId];
if (modelInstance.collision.mode === "none" || asset === undefined || asset.kind !== "model") {
continue;
}
try {
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
if (generatedCollider?.mode === "dynamic") {
diagnostics.push(
createDiagnostic(
"warning",
"dynamic-model-collider-fixed-query-only",
"Dynamic model collision currently generates convex compound pieces for Rapier queries, but the runner still uses them as fixed world collision rather than fully simulated rigid bodies.",
path,
"build"
)
);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Imported model collision generation failed.";
const code =
error instanceof ModelColliderGenerationError
? error.code
: "invalid-model-instance-collision-mode";
diagnostics.push(createDiagnostic("error", code, message, path, "build"));
}
}
return {
diagnostics,
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"),
@@ -38,10 +79,10 @@ export function validateRuntimeSceneBuild(
};
}
export function assertRuntimeSceneBuildable(document: SceneDocument, navigationMode: "firstPerson" | "orbitVisitor") {
export function assertRuntimeSceneBuildable(document: SceneDocument, options: ValidateRuntimeSceneBuildOptions) {
assertSceneDocumentIsValid(document);
const validation = validateRuntimeSceneBuild(document, navigationMode);
const validation = validateRuntimeSceneBuild(document, options);
if (validation.errors.length > 0) {
throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`);

View File

@@ -341,6 +341,7 @@ export class ViewportHost {
this.renderer.domElement.addEventListener("pointerleave", this.handlePointerLeave);
this.renderer.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
this.renderer.domElement.addEventListener("auxclick", this.handleAuxClick);
this.renderer.domElement.addEventListener("contextmenu", this.handleContextMenu);
window.addEventListener("pointermove", this.handleWindowPointerMove);
this.resize();
@@ -554,6 +555,7 @@ export class ViewportHost {
this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
this.renderer.domElement.removeEventListener("wheel", this.handleWheel);
this.renderer.domElement.removeEventListener("auxclick", this.handleAuxClick);
this.renderer.domElement.removeEventListener("contextmenu", this.handleContextMenu);
window.removeEventListener("pointermove", this.handleWindowPointerMove);
this.clearLocalLights();
this.clearBrushMeshes();
@@ -2289,6 +2291,16 @@ export class ViewportHost {
return;
}
if (event.button === 2) {
event.preventDefault();
if (this.currentTransformSession.kind === "active") {
this.transformCancelHandler?.();
}
return;
}
if (event.button !== 0) {
return;
}
@@ -2657,11 +2669,15 @@ export class ViewportHost {
};
private handleAuxClick = (event: MouseEvent) => {
if (event.button === 1) {
if (event.button === 1 || event.button === 2) {
event.preventDefault();
}
};
private handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
private findModelInstanceId(object: Object3D): string | null {
let current: Object3D | null = object;