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

@@ -119,6 +119,19 @@ These defaults are intentionally fixed for the early slices unless a later slice
- placed imported models are **model instances**, not typed entities - placed imported models are **model instances**, not typed entities
- keep model instances in a document collection separate from `entities` - keep model instances in a document collection separate from `entities`
### Imported model collision scope
- collision authoring for imported models belongs on `modelInstances`, not on asset records
- the canonical source of truth is authored collision settings, not cooked collider bytes
- generated collider data may be cached or rebuilt, but it is derived from:
- imported model asset geometry
- model instance transform
- authored collision settings
- for imported-model collider support beyond simple boxes, prefer integrating a real collision/query library such as Rapier over inventing custom broad-phase/narrow-phase code in-house
- let the collision/query layer own broad-phase and narrow-phase pruning instead of re-implementing that manually in app code
- do not turn this slice into a full physics sandbox or general rigidbody architecture rewrite unless the roadmap explicitly asks for that
- near-term slices may adapt or replace the current handcrafted runner collision path where necessary so brush and imported-model colliders can participate in one coherent collision/query system
### Runtime interaction scope ### Runtime interaction scope
- keep trigger/action/target links explicit and typed - keep trigger/action/target links explicit and typed
@@ -161,6 +174,7 @@ Use these terms consistently:
- **Texture**: image resource backing material channels - **Texture**: image resource backing material channels
- **Asset**: imported external resource, usually GLB/GLTF or audio and related media - **Asset**: imported external resource, usually GLB/GLTF or audio and related media
- **Model Instance**: placed scene instance of an imported asset - **Model Instance**: placed scene instance of an imported asset
- **Collider**: runtime collision representation derived from brushes or imported models
- **Prefab**: reusable asset/entity package placeable in scenes - **Prefab**: reusable asset/entity package placeable in scenes
- **Entity**: typed scene object with runtime/editor semantics - **Entity**: typed scene object with runtime/editor semantics
- **Project Package**: portable editable bundle containing canonical scene JSON plus referenced assets - **Project Package**: portable editable bundle containing canonical scene JSON plus referenced assets
@@ -168,7 +182,7 @@ Use these terms consistently:
- **Runner**: browser runtime that loads and plays scenes - **Runner**: browser runtime that loads and plays scenes
- **Viewport**: editor rendering surface - **Viewport**: editor rendering surface
- **Command**: undoable state transition - **Command**: undoable state transition
- **Tool**: editor interaction mode such as select, move, box-create, face-edit - **Tool**: editor interaction mode such as select, create, transform, or face-edit
- **Build**: deterministic transformation from document -> runtime scene data - **Build**: deterministic transformation from document -> runtime scene data
- **Export**: downstream transformation to deployable or interchange deliverables such as runner packages or optional later GLB - **Export**: downstream transformation to deployable or interchange deliverables such as runner packages or optional later GLB
@@ -315,6 +329,21 @@ Do not store raw three.js objects inside canonical document state.
- entity validation must happen at document/build boundaries - entity validation must happen at document/build boundaries
- model instances remain separate from entities - model instances remain separate from entities
### Imported model collision rules
- collision settings for imported models live on `modelInstances`
- supported collision modes must be explicit and typed
- generated collision geometry is derived data, not the canonical source document
- collision debug visibility is editor/runtime UI state driven by authored settings, not a hidden renderer-only toggle
- avoid implicit “always collide with render mesh” behavior
- if broad-phase/narrow-phase pruning or non-box collider support is needed, prefer Rapier over ad hoc custom collision math
- collision modes mean:
- `none` = no collider
- `terrain` = heightfield collider, static only
- `static` = triangle mesh collider, fixed only
- `dynamic` = convex decomposition into compound collider, dynamic/kinematic capable
- `simple` = one cheap primitive or one convex hull
--- ---
## Performance rules ## Performance rules

View File

@@ -113,6 +113,10 @@ Per face, keep explicit UV transform values such as:
- imported assets live in the asset registry - imported assets live in the asset registry
- placed imported models live in `modelInstances` - placed imported models live in `modelInstances`
- typed scene objects like `PlayerStart`, `TriggerVolume`, or lights live in `entities` - typed scene objects like `PlayerStart`, `TriggerVolume`, or lights live in `entities`
- collision authoring for imported models belongs on `modelInstances`, not asset records
- generated imported-model collider data should be derived from asset geometry + instance transform + authored settings
- for imported-model collider types beyond simple boxes, prefer a Rapier-backed collision/query layer over extending the handcrafted collision code indefinitely
- broad-phase and narrow-phase pruning should come from that collision/query layer, not custom app code
### Interaction scope ### Interaction scope

View File

@@ -167,6 +167,21 @@ Placed imported models are not typed entities.
- placed scene instances of those assets live in `modelInstances` - placed scene instances of those assets live in `modelInstances`
- typed runtime/editor objects such as `PlayerStart` or `TriggerVolume` live in `entities` - typed runtime/editor objects such as `PlayerStart` or `TriggerVolume` live in `entities`
### Imported model collision scope
Imported model collision should extend the current architecture, not replace it.
- authored collision settings belong on `modelInstances`
- generated collider data is derived from:
- imported model asset geometry
- model instance transform
- authored collision settings
- do not make cooked/generated collider bytes the canonical source document by default
- for non-box imported-model collider support and broad-phase/narrow-phase pruning, prefer integrating Rapier as a collision/query subsystem over inventing custom collision code in-house
- broad-phase and narrow-phase pair pruning should be delegated to that collision/query layer rather than re-implemented manually in app code
- do not turn this into a full general-purpose physics sandbox unless the product actually needs one
- near-term slices may adapt or replace the current handcrafted runner collision path so brushes and imported models can participate in one coherent collision/query system
### Trigger/action/target scope ### Trigger/action/target scope
Keep interaction links explicit and typed. Keep interaction links explicit and typed.
@@ -344,6 +359,8 @@ interface SceneDocument {
} }
``` ```
Imported model collision settings should be represented canonically on the relevant `ModelInstance` records or a tightly related typed sub-structure, not as hidden renderer/runtime state.
`WorldSettings` is the correct home for: `WorldSettings` is the correct home for:
- background mode and background color/gradient - background mode and background color/gradient
@@ -525,6 +542,14 @@ Responsibilities:
- `AnimationSystem` - `AnimationSystem`
- `RuntimeUIBridge` - `RuntimeUIBridge`
### Collision strategy progression
Near-term runtime collision is intentionally narrower than a full game-engine physics stack.
- early runner slices use deterministic explicit collision data for navigation and authored interactions
- imported model collision should first plug into that same runtime build/query path
- only introduce a broader physics world when dynamic rigid bodies, richer contact response, or other product requirements make it necessary
### Navigation modes ### Navigation modes
Initial modes: Initial modes:
@@ -697,6 +722,59 @@ There should be a clear distinction between:
- model instance - model instance
- prefab definition - prefab definition
### Imported model collision authoring
Imported models need an explicit authored collision path that coexists with brush collision.
Recommended authored settings shape:
```ts
type ModelInstanceCollisionMode =
| "none"
| "terrain"
| "static"
| "dynamic"
| "simple";
interface ModelInstanceCollisionSettings {
mode: ModelInstanceCollisionMode;
visible: boolean;
simpleShape?: "box" | "sphere" | "capsule" | "convexHull";
terrainResolution?: number;
dynamicQuality?: "low" | "medium" | "high";
}
```
Rules:
- these settings are canonical authoring data
- generated collider geometry is derived/cacheable data
- collision generation is triggered by authored settings, not hidden import-time guesses
- `modelInstances` remain separate from `entities`
- collision debug rendering should be visually inspectable in editor and runner when enabled
- preferred implementation path is:
- Rapier 3D WASM provides the collision/query layer
- Rapier owns broad-phase and narrow-phase pruning
- the editor document owns authored collider settings
- generated collider geometry/handles are derived runtime/cache data
- collision modes should mean exactly:
- `none` = no collider
- `terrain` = heightfield collider, static only
- `static` = triangle mesh collider, fixed only
- `dynamic` = convex decomposition into compound collider, dynamic/kinematic capable
- `simple` = one cheap primitive or one convex hull
- initial support may be staged, but unsupported modes must fail clearly instead of silently degrading to random approximations
### Collision strategy progression
- early slices used handcrafted box-based player collision because only brush boxes existed
- once imported-model colliders require terrain, trimesh, convex hull, or compound collision, the preferred next step is a Rapier-backed collision/query layer
- this does not require a full gameplay physics sandbox in the same slice
- a valid intermediate state is:
- fixed/queryable colliders for brushes and imported models
- existing first-person movement adapted to query against Rapier-backed colliders
- dynamic collider generation represented canonically even if full rigidbody simulation lands later
--- ---
## `serialization` ## `serialization`
@@ -905,11 +983,19 @@ SceneDocument
-> validate -> validate
-> resolve assets/materials/entities/model instances -> resolve assets/materials/entities/model instances
-> build brush meshes -> build brush meshes
-> build model-instance collider data
-> build colliders -> build colliders
-> build runtime entity graph -> build runtime entity graph
-> assemble runtime scene package -> assemble runtime scene package
``` ```
In the near-term architecture, `build colliders` may include a mix of:
- brush-derived colliders
- imported-model-instance-derived colliders
without requiring a full general-purpose physics world.
### Project package build stages ### Project package build stages
```txt ```txt

View File

@@ -17,6 +17,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "^0.19.3",
"postprocessing": "^6.39.0", "postprocessing": "^6.39.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -341,6 +341,46 @@ Do not:
--- ---
## Slice Body 8B — Imported model collider authoring
```text
Relevant docs to inspect after the common preamble:
- architecture.md: model instances, assets, runtime build, collision scope
- roadmap.md: Slice 3.1B
- testing.md: serialization, geometry/runtime, collision coverage
Implement Slice 3.1B.
Requirements:
- canonical collision settings on model instances
- inspector selector for:
- none
- terrain
- static
- dynamic
- simple
- collision visibility/debug toggle
- generated collider data derived from model asset geometry + model instance transform + authored settings
- Rapier-backed collision/query integration for broad-phase/narrow-phase handling
- runtime integration with the existing runner collision path
- debug visualization for generated collision where enabled
- explicit collider semantics:
- none = no collider
- terrain = heightfield collider, static only
- static = triangle mesh collider, fixed only
- dynamic = convex decomposition into compound collider, dynamic/kinematic capable
- simple = one cheap primitive or one convex hull
Constraints:
- do not merge model instances into entities
- use Rapier instead of inventing custom broad-phase/narrow-phase code
- do not rewrite the runner into a full gameplay-physics sandbox in this slice
- keep authored settings canonical and generated collider data derived/cacheable
- if terrain/dynamic support is partial, fail clearly and document it
```
---
## Slice Body 9 — Animation playback ## Slice Body 9 — Animation playback
```text ```text

View File

@@ -698,6 +698,100 @@ Please implement fully and report:
--- ---
## Prompt 8B — Imported model collider authoring
```text
You are implementing Slice 3.1B — Imported model collider authoring.
Read and follow:
- AGENTS.md
- CHAT_CONTEXT.md
- architecture.md
- roadmap.md
- testing.md
Before coding:
- Inspect the current repo and extend the existing implementation.
- Build on the existing model asset / model instance path. Do not merge model instances into entities.
- Integrate with the existing runtime scene build, but use Rapier 3D WASM as the collision/query layer for this slice instead of inventing custom broad-phase/narrow-phase logic.
- Do not turn this slice into a full gameplay-physics sandbox or broad entity rigidbody rewrite.
- If you change persisted schema, update versioning, migrations, and at least one compatibility test.
Current goal:
Make imported model instances participate in collision by adding explicit authored collider settings, generated collider data, Rapier-backed collision/query integration, and runtime/debug support that fits the current codebase.
Requirements:
- Add canonical collision settings to model instances.
- Add an inspector UI for imported model instances with an explicit collision mode selector:
- none
- terrain
- static
- dynamic
- simple
- Add a collision visibility/debug toggle for model instances.
- Generated collider data must be derived from:
- imported model asset geometry
- model instance transform
- authored collision settings
- Keep the authored settings canonical. Do not make cooked/generated collider bytes the source of truth in the document.
- If you add caching, keep it explicit and derived.
- Use Rapier 3D WASM for broad-phase/narrow-phase pruning and collision/query evaluation in this slice.
Collision-mode expectations:
- `none`:
- no collider
- `terrain`:
- heightfield collider
- static only
- if the source mesh cannot support the chosen terrain path cleanly, fail clearly with diagnostics instead of silently pretending it worked
- `static`:
- triangle mesh collider
- fixed only
- `dynamic`:
- convex decomposition into a compound collider
- dynamic/kinematic capable representation
- this slice does not require a full general rigidbody gameplay system, but the generated representation must be explicit and correct
- if actual runtime behavior is limited in this slice, document exactly what works now
- `simple`:
- one cheap primitive or one convex hull
- keep the initial option set small and explicit if needed
Runtime/build requirements:
- Extend runtime scene build so imported model colliders participate in one coherent Rapier-backed collision/query path together with brush-authored world collision.
- Broad-phase and narrow-phase handling should come from Rapier, not app-specific custom collision pruning code.
- Keep first-person traversal/collision working. It is acceptable to adapt the current player collision path to query against Rapier-backed colliders instead of the handwritten box-only collision path.
- Add debug visualization for generated imported-model collision in editor and/or runner when the visibility toggle is enabled.
- Keep the implementation understandable and explicit.
Important:
- Do not build a giant general-purpose physics framework around Rapier in this slice.
- Do not add arbitrary rigidbody components to every scene object just because Rapier is present.
- Do not hide collider generation behind import-time magic; it should be driven by authored collision settings.
- Do not treat the render mesh as automatically equal to the collision mesh in all modes.
- Keep model-instance collision separate from brush collision and separate from typed entity schemas.
Testing expectations:
- Round-trip tests for model-instance collision settings
- Migration/compatibility test if schema changes
- Validation/build tests for invalid or unsupported collision-mode assumptions
- Geometry/runtime tests for generated collider outputs where practical
- Runtime/domain tests proving the generated colliders participate in the actual Rapier-backed collision/query path
- Runner/domain tests proving imported-model collision works together with existing brush/world collision
- Browser/e2e or manual verification for collision debug visibility if practical
Please implement fully and report:
- model-instance collision schema/settings
- supported behavior for each collision mode
- how generated collider data is represented and rebuilt/cached
- how Rapier is integrated into the current runner collision/query architecture
- how broad-phase/narrow-phase responsibilities are handled
- how debug visibility works
- tests added or updated
- what limitations remain, especially for `terrain` and `dynamic`
```
---
## Prompt 9 — Animation playback ## Prompt 9 — Animation playback
```text ```text

View File

@@ -342,6 +342,40 @@ The tool becomes more than brush-only by supporting imported assets, authored li
--- ---
### Slice 3.1B — Imported model collider authoring
#### Deliverables
- canonical collision settings on model instances
- explicit collision mode selector for:
- none
- terrain
- static
- dynamic
- simple
- collision visibility/debug toggle
- generated collider data derived from imported model geometry plus authored settings
- Rapier-backed collision/query integration for imported-model colliders and broad-phase/narrow-phase handling
- runtime build support for imported-model colliders in the runner collision path
- editor/runner debug visualization of generated collision where enabled
#### Acceptance criteria
- author can choose a collision mode per model instance in the inspector
- imported models can participate in runner collision without hand-authored invisible geometry
- collider modes mean exactly:
- none = no collider
- terrain = heightfield collider, static only
- static = triangle mesh collider, fixed only
- dynamic = convex decomposition into compound collider, dynamic/kinematic capable
- simple = one cheap primitive or one convex hull
- generated collision survives save/load through canonical settings and deterministic rebuild behavior
- broad-phase and narrow-phase pruning are handled by Rapier instead of app-specific custom collision code
- the slice does not require a full gameplay-physics sandbox
- unsupported or partial modes fail clearly instead of silently pretending to work
---
### Slice 3.2 — Local lights and skyboxes ### Slice 3.2 — Local lights and skyboxes
#### Deliverables #### Deliverables
@@ -593,6 +627,7 @@ A slice is complete only when:
- per-face UV persistence - per-face UV persistence
- picking accuracy - picking accuracy
- collision generation from brush data - collision generation from brush data
- collision generation from imported model data
- imported asset/material compatibility - imported asset/material compatibility
- browser audio unlock behavior - browser audio unlock behavior
- input edge cases across browsers - input edge cases across browsers

View File

@@ -2,6 +2,15 @@ import { createOpaqueId } from "../core/ids";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import type { ModelAssetRecord } from "./project-assets"; 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 { export interface ModelInstance {
id: string; id: string;
kind: "modelInstance"; kind: "modelInstance";
@@ -10,6 +19,7 @@ export interface ModelInstance {
position: Vec3; position: Vec3;
rotationDegrees: Vec3; rotationDegrees: Vec3;
scale: Vec3; scale: Vec3;
collision: ModelInstanceCollisionSettings;
animationClipName?: string; animationClipName?: string;
animationAutoplay?: boolean; animationAutoplay?: boolean;
} }
@@ -32,6 +42,11 @@ export const DEFAULT_MODEL_INSTANCE_SCALE: Vec3 = {
z: 1 z: 1
}; };
export const DEFAULT_MODEL_INSTANCE_COLLISION_SETTINGS: ModelInstanceCollisionSettings = {
mode: "none",
visible: false
};
function cloneVec3(vector: Vec3): Vec3 { function cloneVec3(vector: Vec3): Vec3 {
return { return {
x: vector.x, 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; 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 { export function normalizeModelInstanceName(name: string | null | undefined): string | undefined {
if (name === undefined || name === null) { if (name === undefined || name === null) {
return undefined; return undefined;
@@ -68,11 +116,15 @@ function assertPositiveFiniteVec3(vector: Vec3, label: string) {
} }
export function createModelInstance( 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 { ): ModelInstance {
const position = cloneVec3(overrides.position ?? DEFAULT_MODEL_INSTANCE_POSITION); const position = cloneVec3(overrides.position ?? DEFAULT_MODEL_INSTANCE_POSITION);
const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES); const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES);
const scale = cloneVec3(overrides.scale ?? DEFAULT_MODEL_INSTANCE_SCALE); 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) { if (overrides.assetId.trim().length === 0) {
throw new Error("Model instance assetId must be a non-empty string."); throw new Error("Model instance assetId must be a non-empty string.");
@@ -90,6 +142,7 @@ export function createModelInstance(
position, position,
rotationDegrees, rotationDegrees,
scale, scale,
collision,
animationClipName: overrides.animationClipName, animationClipName: overrides.animationClipName,
animationAutoplay: overrides.animationAutoplay animationAutoplay: overrides.animationAutoplay
}; };
@@ -128,6 +181,7 @@ export function areModelInstancesEqual(left: ModelInstance, right: ModelInstance
areVec3Equal(left.position, right.position) && areVec3Equal(left.position, right.position) &&
areVec3Equal(left.rotationDegrees, right.rotationDegrees) && areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
areVec3Equal(left.scale, right.scale) && areVec3Equal(left.scale, right.scale) &&
areModelInstanceCollisionSettingsEqual(left.collision, right.collision) &&
left.animationClipName === right.animationClipName && left.animationClipName === right.animationClipName &&
left.animationAutoplay === right.animationAutoplay left.animationAutoplay === right.animationAutoplay
); );

View File

@@ -1,7 +1,10 @@
import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library"; import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library";
import { import {
createModelInstanceCollisionSettings,
createModelInstance, createModelInstance,
isModelInstanceCollisionMode,
normalizeModelInstanceName, normalizeModelInstanceName,
type ModelInstanceCollisionSettings,
type ModelInstance type ModelInstance
} from "../assets/model-instances"; } from "../assets/model-instances";
import { import {
@@ -51,6 +54,7 @@ import {
FACE_MATERIALS_SCENE_DOCUMENT_VERSION, FACE_MATERIALS_SCENE_DOCUMENT_VERSION,
FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION, FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
FOUNDATION_SCENE_DOCUMENT_VERSION, FOUNDATION_SCENE_DOCUMENT_VERSION,
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION, LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION, MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
RUNNER_V1_SCENE_DOCUMENT_VERSION, RUNNER_V1_SCENE_DOCUMENT_VERSION,
@@ -503,6 +507,23 @@ function readAssets(value: unknown): SceneDocument["assets"] {
return 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 { function readModelInstance(value: unknown, label: string, assets: SceneDocument["assets"]): ModelInstance {
if (!isRecord(value)) { if (!isRecord(value)) {
throw new Error(`${label} must be an object.`); 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`), position: readVec3(value.position, `${label}.position`),
rotationDegrees: readVec3(value.rotationDegrees, `${label}.rotationDegrees`), rotationDegrees: readVec3(value.rotationDegrees, `${label}.rotationDegrees`),
scale: readVec3(value.scale, `${label}.scale`), scale: readVec3(value.scale, `${label}.scale`),
collision: readModelInstanceCollisionSettings(value.collision, `${label}.collision`),
animationClipName: (() => { animationClipName: (() => {
const raw = expectOptionalString(value.animationClipName, `${label}.animationClipName`); const raw = expectOptionalString(value.animationClipName, `${label}.animationClipName`);
if (raw === undefined) return undefined; 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. // v14 -> v15: entities gained an optional authored name field.
if (source.version === 14) { if (source.version === 14) {
const materials = readMaterialRegistry(source.materials, "materials"); const materials = readMaterialRegistry(source.materials, "materials");

View File

@@ -6,6 +6,7 @@ import {
type ProjectAssetRecord type ProjectAssetRecord
} from "../assets/project-assets"; } from "../assets/project-assets";
import type { ModelInstance } from "../assets/model-instances"; import type { ModelInstance } from "../assets/model-instances";
import { isModelInstanceCollisionMode } from "../assets/model-instances";
import { import {
type InteractableEntity, type InteractableEntity,
type PointLightEntity, 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`)); 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]; const asset = document.assets[modelInstance.assetId];
if (asset === undefined) { 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 { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library";
import { createDefaultWorldSettings, type WorldSettings } from "./world-settings"; 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 ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const;
export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const; export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const;
export const ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION = 12 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 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"; import type { NavigationController, RuntimeControllerContext } from "./navigation-controller";
const LOOK_SENSITIVITY = 0.0022; const LOOK_SENSITIVITY = 0.0022;
@@ -116,17 +116,22 @@ export class FirstPersonNavigationController implements NavigationController {
this.verticalVelocity -= GRAVITY * dt; this.verticalVelocity -= GRAVITY * dt;
const resolvedMotion = resolveFirstPersonMotion( const resolvedMotion = this.context.resolveFirstPersonMotion(
this.feetPosition, this.feetPosition,
{ {
x: horizontalX, x: horizontalX,
y: this.verticalVelocity * dt, y: this.verticalVelocity * dt,
z: horizontalZ z: horizontalZ
}, },
FIRST_PERSON_PLAYER_SHAPE, FIRST_PERSON_PLAYER_SHAPE
this.context.getRuntimeScene().colliders
); );
if (resolvedMotion === null) {
this.updateCameraTransform();
this.publishTelemetry();
return;
}
this.feetPosition = resolvedMotion.feetPosition; this.feetPosition = resolvedMotion.feetPosition;
this.grounded = resolvedMotion.grounded; this.grounded = resolvedMotion.grounded;

View File

@@ -2,6 +2,7 @@ import type { PerspectiveCamera } from "three";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision";
import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build"; import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build";
export interface FirstPersonTelemetry { export interface FirstPersonTelemetry {
@@ -16,6 +17,7 @@ export interface RuntimeControllerContext {
camera: PerspectiveCamera; camera: PerspectiveCamera;
domElement: HTMLCanvasElement; domElement: HTMLCanvasElement;
getRuntimeScene(): RuntimeSceneDefinition; getRuntimeScene(): RuntimeSceneDefinition;
resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion | null;
setRuntimeMessage(message: string | null): void; setRuntimeMessage(message: string | null): void;
setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | 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 { FirstPersonNavigationController } from "./first-person-navigation-controller";
import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext } from "./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 { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system";
import { RuntimeAudioSystem } from "./runtime-audio-system"; import { RuntimeAudioSystem } from "./runtime-audio-system";
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller"; import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
@@ -85,6 +87,8 @@ export class RuntimeHost {
private readonly controllerContext: RuntimeControllerContext; private readonly controllerContext: RuntimeControllerContext;
private readonly renderer: WebGLRenderer | null; private readonly renderer: WebGLRenderer | null;
private runtimeScene: RuntimeSceneDefinition | null = null; private runtimeScene: RuntimeSceneDefinition | null = null;
private collisionWorld: RapierCollisionWorld | null = null;
private collisionWorldRequestId = 0;
private currentWorld: RuntimeSceneDefinition["world"] | null = null; private currentWorld: RuntimeSceneDefinition["world"] | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null; private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null;
private advancedRenderingComposer: EffectComposer | null = null; private advancedRenderingComposer: EffectComposer | null = null;
@@ -131,6 +135,7 @@ export class RuntimeHost {
return this.runtimeScene; return this.runtimeScene;
}, },
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
setRuntimeMessage: (message) => { setRuntimeMessage: (message) => {
if (message === this.currentRuntimeMessage) { if (message === this.currentRuntimeMessage) {
return; return;
@@ -171,6 +176,7 @@ export class RuntimeHost {
this.rebuildLocalLights(runtimeScene.localLights); this.rebuildLocalLights(runtimeScene.localLights);
this.rebuildBrushMeshes(runtimeScene.brushes); this.rebuildBrushMeshes(runtimeScene.brushes);
this.rebuildModelInstances(runtimeScene.modelInstances); this.rebuildModelInstances(runtimeScene.modelInstances);
void this.rebuildCollisionWorld(runtimeScene.colliders);
this.audioSystem.loadScene(runtimeScene); this.audioSystem.loadScene(runtimeScene);
} }
@@ -244,6 +250,8 @@ export class RuntimeHost {
this.clearLocalLights(); this.clearLocalLights();
this.clearBrushMeshes(); this.clearBrushMeshes();
this.clearModelInstances(); this.clearModelInstances();
this.collisionWorldRequestId += 1;
this.clearCollisionWorld();
this.audioSystem.dispose(); this.audioSystem.dispose();
this.advancedRenderingComposer?.dispose(); this.advancedRenderingComposer?.dispose();
this.advancedRenderingComposer = null; this.advancedRenderingComposer = null;
@@ -306,6 +314,36 @@ export class RuntimeHost {
this.applyShadowState(); 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) { private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) {
if (this.renderer === null) { if (this.renderer === null) {
return; return;

View File

@@ -1,3 +1,4 @@
import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import { getModelInstances } from "../assets/model-instances"; import { getModelInstances } from "../assets/model-instances";
import type { BoxBrush, BoxFaceId, FaceUvState } from "../document/brushes"; 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 { cloneWorldSettings, type WorldSettings } from "../document/world-settings";
import { getEntityInstances, getPrimaryPlayerStartEntity, type EntityInstance } from "../entities/entity-instances"; import { getEntityInstances, getPrimaryPlayerStartEntity, type EntityInstance } from "../entities/entity-instances";
import { getBoxBrushBounds } from "../geometry/box-brush"; 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 { cloneInteractionLink, getInteractionLinks, type InteractionLink } from "../interactions/interaction-links";
import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library"; import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library";
import { cloneFaceUvState } from "../document/brushes"; import { cloneFaceUvState } from "../document/brushes";
@@ -28,11 +30,14 @@ export interface RuntimeBoxBrushInstance {
export interface RuntimeBoxCollider { export interface RuntimeBoxCollider {
kind: "box"; kind: "box";
source: "brush";
brushId: string; brushId: string;
min: Vec3; min: Vec3;
max: Vec3; max: Vec3;
} }
export type RuntimeSceneCollider = RuntimeBoxCollider | GeneratedModelCollider;
export interface RuntimeSceneBounds { export interface RuntimeSceneBounds {
min: Vec3; min: Vec3;
max: Vec3; max: Vec3;
@@ -132,7 +137,7 @@ export interface RuntimeSceneDefinition {
world: WorldSettings; world: WorldSettings;
localLights: RuntimeLocalLightCollection; localLights: RuntimeLocalLightCollection;
brushes: RuntimeBoxBrushInstance[]; brushes: RuntimeBoxBrushInstance[];
colliders: RuntimeBoxCollider[]; colliders: RuntimeSceneCollider[];
sceneBounds: RuntimeSceneBounds | null; sceneBounds: RuntimeSceneBounds | null;
modelInstances: RuntimeModelInstance[]; modelInstances: RuntimeModelInstance[];
entities: RuntimeEntityCollection; entities: RuntimeEntityCollection;
@@ -143,6 +148,7 @@ export interface RuntimeSceneDefinition {
interface BuildRuntimeSceneOptions { interface BuildRuntimeSceneOptions {
navigationMode?: RuntimeNavigationMode; navigationMode?: RuntimeNavigationMode;
loadedModelAssets?: Record<string, LoadedModelAsset>;
} }
function cloneVec3(vector: Vec3): Vec3 { function cloneVec3(vector: Vec3): Vec3 {
@@ -213,6 +219,7 @@ function buildRuntimeCollider(brush: BoxBrush): RuntimeBoxCollider {
return { return {
kind: "box", kind: "box",
source: "brush",
brushId: brush.id, brushId: brush.id,
min: cloneVec3(bounds.min), min: cloneVec3(bounds.min),
max: cloneVec3(bounds.max) 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) { if (colliders.length === 0) {
return null; return null;
} }
const min = cloneVec3(colliders[0].min); const firstBounds = getColliderBounds(colliders[0]);
const max = cloneVec3(colliders[0].max); const min = cloneVec3(firstBounds.min);
const max = cloneVec3(firstBounds.max);
for (const collider of colliders.slice(1)) { for (const collider of colliders.slice(1)) {
min.x = Math.min(min.x, collider.min.x); const bounds = getColliderBounds(collider);
min.y = Math.min(min.y, collider.min.y); min.x = Math.min(min.x, bounds.min.x);
min.z = Math.min(min.z, collider.min.z); min.y = Math.min(min.y, bounds.min.y);
max.x = Math.max(max.x, collider.max.x); min.z = Math.min(min.z, bounds.min.z);
max.y = Math.max(max.y, collider.max.y); max.x = Math.max(max.x, bounds.max.x);
max.z = Math.max(max.z, collider.max.z); max.y = Math.max(max.y, bounds.max.y);
max.z = Math.max(max.z, bounds.max.z);
} }
return { return {
@@ -396,15 +419,33 @@ function assertNever(value: never): never {
} }
export function buildRuntimeSceneFromDocument(document: SceneDocument, options: BuildRuntimeSceneOptions = {}): RuntimeSceneDefinition { 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 brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document));
const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush)); const colliders: RuntimeSceneCollider[] = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush));
const sceneBounds = combineColliderBounds(colliders);
const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance); const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance);
const collections = buildRuntimeSceneCollections(document); const collections = buildRuntimeSceneCollections(document);
const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link)); const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link));
const playerStartEntity = getPrimaryPlayerStartEntity(document.entities); 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 = const playerStart =
playerStartEntity === null playerStartEntity === null
? null ? null
@@ -419,14 +460,14 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
localLights: collections.localLights, localLights: collections.localLights,
brushes, brushes,
colliders, colliders,
sceneBounds, sceneBounds: combinedSceneBounds,
modelInstances, modelInstances,
entities: collections.entities, entities: collections.entities,
interactionLinks, interactionLinks,
playerStart, playerStart,
spawn: spawn:
playerStart === null playerStart === null
? buildFallbackSpawn(sceneBounds) ? buildFallbackSpawn(combinedSceneBounds)
: { : {
source: "playerStart", source: "playerStart",
entityId: playerStart.entityId, 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 type { SceneDocument } from "../document/scene-document";
import { import {
assertSceneDocumentIsValid, assertSceneDocumentIsValid,
@@ -6,6 +8,7 @@ import {
type SceneDiagnostic type SceneDiagnostic
} from "../document/scene-document-validation"; } from "../document/scene-document-validation";
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances"; import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation";
export interface RuntimeSceneBuildValidationResult { export interface RuntimeSceneBuildValidationResult {
diagnostics: SceneDiagnostic[]; diagnostics: SceneDiagnostic[];
@@ -13,13 +16,18 @@ export interface RuntimeSceneBuildValidationResult {
warnings: SceneDiagnostic[]; warnings: SceneDiagnostic[];
} }
interface ValidateRuntimeSceneBuildOptions {
navigationMode: "firstPerson" | "orbitVisitor";
loadedModelAssets?: Record<string, LoadedModelAsset>;
}
export function validateRuntimeSceneBuild( export function validateRuntimeSceneBuild(
document: SceneDocument, document: SceneDocument,
navigationMode: "firstPerson" | "orbitVisitor" options: ValidateRuntimeSceneBuildOptions
): RuntimeSceneBuildValidationResult { ): RuntimeSceneBuildValidationResult {
const diagnostics: SceneDiagnostic[] = []; const diagnostics: SceneDiagnostic[] = [];
if (navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) { if (options.navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) {
diagnostics.push( diagnostics.push(
createDiagnostic( createDiagnostic(
"error", "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 { return {
diagnostics, diagnostics,
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), 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); assertSceneDocumentIsValid(document);
const validation = validateRuntimeSceneBuild(document, navigationMode); const validation = validateRuntimeSceneBuild(document, options);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`); 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("pointerleave", this.handlePointerLeave);
this.renderer.domElement.addEventListener("wheel", this.handleWheel, { passive: false }); this.renderer.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
this.renderer.domElement.addEventListener("auxclick", this.handleAuxClick); this.renderer.domElement.addEventListener("auxclick", this.handleAuxClick);
this.renderer.domElement.addEventListener("contextmenu", this.handleContextMenu);
window.addEventListener("pointermove", this.handleWindowPointerMove); window.addEventListener("pointermove", this.handleWindowPointerMove);
this.resize(); this.resize();
@@ -554,6 +555,7 @@ export class ViewportHost {
this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave); this.renderer.domElement.removeEventListener("pointerleave", this.handlePointerLeave);
this.renderer.domElement.removeEventListener("wheel", this.handleWheel); this.renderer.domElement.removeEventListener("wheel", this.handleWheel);
this.renderer.domElement.removeEventListener("auxclick", this.handleAuxClick); this.renderer.domElement.removeEventListener("auxclick", this.handleAuxClick);
this.renderer.domElement.removeEventListener("contextmenu", this.handleContextMenu);
window.removeEventListener("pointermove", this.handleWindowPointerMove); window.removeEventListener("pointermove", this.handleWindowPointerMove);
this.clearLocalLights(); this.clearLocalLights();
this.clearBrushMeshes(); this.clearBrushMeshes();
@@ -2289,6 +2291,16 @@ export class ViewportHost {
return; return;
} }
if (event.button === 2) {
event.preventDefault();
if (this.currentTransformSession.kind === "active") {
this.transformCancelHandler?.();
}
return;
}
if (event.button !== 0) { if (event.button !== 0) {
return; return;
} }
@@ -2657,11 +2669,15 @@ export class ViewportHost {
}; };
private handleAuxClick = (event: MouseEvent) => { private handleAuxClick = (event: MouseEvent) => {
if (event.button === 1) { if (event.button === 1 || event.button === 2) {
event.preventDefault(); event.preventDefault();
} }
}; };
private handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
private findModelInstanceId(object: Object3D): string | null { private findModelInstanceId(object: Object3D): string | null {
let current: Object3D | null = object; let current: Object3D | null = object;

View File

@@ -37,10 +37,11 @@ Highest-priority confidence areas:
4. per-face material/UV persistence 4. per-face material/UV persistence
5. runtime build correctness 5. runtime build correctness
6. asset import survival 6. asset import survival
7. project package portability once binary assets exist 7. imported-model collider generation and runtime collision correctness
8. runner navigation/input reliability 8. project package portability once binary assets exist
9. spatial audio and interaction basics 9. runner navigation/input reliability
10. critical regressions caught in CI 10. spatial audio and interaction basics
11. critical regressions caught in CI
--- ---
@@ -158,6 +159,8 @@ Scope:
- face generation - face generation
- topology expectations - topology expectations
- collision mesh generation - collision mesh generation
- imported-model collider generation
- Rapier-backed collider/query integration where relevant
- UV projection generation - UV projection generation
- clipping results - clipping results
- derived mesh determinism - derived mesh determinism
@@ -170,6 +173,13 @@ Examples:
- clipping yields valid child brushes - clipping yields valid child brushes
- generated geometry contains no NaNs - generated geometry contains no NaNs
- rebuild is deterministic for the same input - rebuild is deterministic for the same input
- imported model collider generation produces finite valid data for the selected mode
- imported-model collider generation honors the authored mode semantics:
- terrain -> heightfield
- static -> triangle mesh
- dynamic -> compound convex pieces
- simple -> primitive or convex hull
- unsupported imported-model collision modes fail clearly instead of producing silent garbage
### Geometry test principles ### Geometry test principles
@@ -212,6 +222,12 @@ For every substantial document feature, add at least:
- one round-trip save/load test - one round-trip save/load test
- one migration or backward-compatibility consideration if schema changed - one migration or backward-compatibility consideration if schema changed
For authored imported-model collision settings, also add at least:
- one round-trip test for the selected collision mode/settings
- one validation/build-path test for missing asset or incompatible collision-mode assumptions where relevant
- one runtime/query-path test proving the generated collider participates in the actual collision/query layer rather than only existing as dead metadata
--- ---
## 5. Browser integration tests ## 5. Browser integration tests