From 6252f6b12e8d7950e362c62722a8ed498a15aa97 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 13 Apr 2026 17:16:37 +0200 Subject: [PATCH] Add NPC collider support in runtime scene build --- src/runtime-three/runtime-scene-build.ts | 167 +++++++++++++++++++---- 1 file changed, 138 insertions(+), 29 deletions(-) diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts index 33fc3df3..68ae302e 100644 --- a/src/runtime-three/runtime-scene-build.ts +++ b/src/runtime-three/runtime-scene-build.ts @@ -18,6 +18,7 @@ import { } from "../document/project-time-settings"; import { cloneWorldSettings, type WorldSettings } from "../document/world-settings"; import { + type CharacterColliderSettings, clonePlayerStartInputBindings, createPlayerStartMovementTemplate, createPlayerStartInputBindings, @@ -98,7 +99,23 @@ export interface RuntimeBrushTriMeshCollider { }; } -export type RuntimeSceneCollider = RuntimeBrushTriMeshCollider | GeneratedModelCollider; +export interface RuntimeNpcCollider { + kind: "character"; + source: "npc"; + entityId: string; + position: Vec3; + rotationDegrees: Vec3; + shape: FirstPersonPlayerShape; + worldBounds: { + min: Vec3; + max: Vec3; + }; +} + +export type RuntimeSceneCollider = + | RuntimeBrushTriMeshCollider + | GeneratedModelCollider + | RuntimeNpcCollider; export interface RuntimeSceneBounds { min: Vec3; @@ -142,6 +159,7 @@ export interface RuntimeNpc { position: Vec3; yawDegrees: number; modelAssetId: string | null; + collider: FirstPersonPlayerShape; } export interface RuntimeSoundEmitter { @@ -284,6 +302,31 @@ function cloneVec3(vector: Vec3): Vec3 { }; } +function createRuntimeCharacterShape( + collider: CharacterColliderSettings +): FirstPersonPlayerShape { + switch (collider.mode) { + case "capsule": + return { + mode: "capsule", + radius: collider.capsuleRadius, + height: collider.capsuleHeight, + eyeHeight: collider.eyeHeight + }; + case "box": + return { + mode: "box", + size: cloneVec3(collider.boxSize), + eyeHeight: collider.eyeHeight + }; + case "none": + return { + mode: "none", + eyeHeight: collider.eyeHeight + }; + } +} + function clonePlayerStartMovementCapabilities( capabilities: PlayerStartMovementCapabilities ): PlayerStartMovementCapabilities { @@ -486,19 +529,95 @@ function buildRuntimeModelInstance(modelInstance: SceneDocument["modelInstances" } function getColliderBounds(collider: RuntimeSceneCollider): GeneratedColliderBounds { - if (collider.source === "brush") { - return { - min: cloneVec3(collider.worldBounds.min), - max: cloneVec3(collider.worldBounds.max) - }; - } - return { min: cloneVec3(collider.worldBounds.min), max: cloneVec3(collider.worldBounds.max) }; } +function buildRuntimeNpcCollider(npc: RuntimeNpc): RuntimeNpcCollider | null { + if (npc.collider.mode === "none") { + return null; + } + + const rotationDegrees = { + x: 0, + y: npc.yawDegrees, + z: 0 + }; + + switch (npc.collider.mode) { + case "capsule": { + const halfHeight = npc.collider.height * 0.5; + + return { + kind: "character", + source: "npc", + entityId: npc.entityId, + position: cloneVec3(npc.position), + rotationDegrees, + shape: { + mode: "capsule", + radius: npc.collider.radius, + height: npc.collider.height, + eyeHeight: npc.collider.eyeHeight + }, + worldBounds: { + min: { + x: npc.position.x - npc.collider.radius, + y: npc.position.y, + z: npc.position.z - npc.collider.radius + }, + max: { + x: npc.position.x + npc.collider.radius, + y: npc.position.y + npc.collider.height, + z: npc.position.z + npc.collider.radius + } + } + }; + } + case "box": { + const halfExtents = { + x: npc.collider.size.x * 0.5, + y: npc.collider.size.y * 0.5, + z: npc.collider.size.z * 0.5 + }; + const yawRadians = (rotationDegrees.y * Math.PI) / 180; + const cosine = Math.abs(Math.cos(yawRadians)); + const sine = Math.abs(Math.sin(yawRadians)); + const rotatedHalfX = halfExtents.x * cosine + halfExtents.z * sine; + const rotatedHalfZ = halfExtents.x * sine + halfExtents.z * cosine; + + return { + kind: "character", + source: "npc", + entityId: npc.entityId, + position: cloneVec3(npc.position), + rotationDegrees, + shape: { + mode: "box", + size: cloneVec3(npc.collider.size), + eyeHeight: npc.collider.eyeHeight + }, + worldBounds: { + min: { + x: npc.position.x - rotatedHalfX, + y: npc.position.y, + z: npc.position.z - rotatedHalfZ + }, + max: { + x: npc.position.x + rotatedHalfX, + y: npc.position.y + npc.collider.size.y, + z: npc.position.z + rotatedHalfZ + } + } + }; + } + case "none": + return null; + } +} + function combineColliderBounds(colliders: RuntimeSceneCollider[]): RuntimeSceneBounds | null { if (colliders.length === 0) { return null; @@ -633,7 +752,8 @@ function buildRuntimeSceneCollections(document: SceneDocument): RuntimeSceneColl visible: entity.visible, position: cloneVec3(entity.position), yawDegrees: entity.yawDegrees, - modelAssetId: entity.modelAssetId + modelAssetId: entity.modelAssetId, + collider: createRuntimeCharacterShape(entity.collider) }); break; case "soundEmitter": @@ -711,26 +831,7 @@ function buildRuntimePlayerShape( return FIRST_PERSON_PLAYER_SHAPE; } - switch (playerStartEntity.collider.mode) { - case "capsule": - return { - mode: "capsule", - radius: playerStartEntity.collider.capsuleRadius, - height: playerStartEntity.collider.capsuleHeight, - eyeHeight: playerStartEntity.collider.eyeHeight - }; - case "box": - return { - mode: "box", - size: cloneVec3(playerStartEntity.collider.boxSize), - eyeHeight: playerStartEntity.collider.eyeHeight - }; - case "none": - return { - mode: "none", - eyeHeight: playerStartEntity.collider.eyeHeight - }; - } + return createRuntimeCharacterShape(playerStartEntity.collider); } function resolveRuntimeSpawn( @@ -840,6 +941,14 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options: playerStartEntity?.inputBindings ); + for (const npc of collections.entities.npcs) { + const collider = buildRuntimeNpcCollider(npc); + + if (collider !== null) { + colliders.push(collider); + } + } + for (const modelInstance of enabledModelInstances) { const asset = document.assets[modelInstance.assetId];