From 3881e38a6d8fe4a298baa4e63d0bc94363cd23cf Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 28 May 2026 00:47:40 +0200 Subject: [PATCH] feat(collision): include static map models in octree --- src/utils/map/mapRuntimeClassification.ts | 4 +++ src/world/GameMap.tsx | 3 ++- src/world/GameMapCollision.tsx | 33 +++++++++++++++++------ src/world/player/PlayerController.tsx | 22 ++++++++++----- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/utils/map/mapRuntimeClassification.ts b/src/utils/map/mapRuntimeClassification.ts index 52e021c..694e6f1 100644 --- a/src/utils/map/mapRuntimeClassification.ts +++ b/src/utils/map/mapRuntimeClassification.ts @@ -30,6 +30,10 @@ export function isRuntimeSingleMapNode(node: MapNode): boolean { ); } +export function isRuntimeCollisionMapNode(node: MapNode): boolean { + return node.name === "terrain" || isRuntimeSingleMapNode(node); +} + export function isEditorVisibleMapNode(node: MapNode): boolean { return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh"; } diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 8bbebe9..12cf900 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -31,6 +31,7 @@ import { logger } from "@/utils/core/Logger"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; import { getTerrainMapNode, + isRuntimeCollisionMapNode, isRuntimeSingleMapNode, } from "@/utils/map/mapRuntimeClassification"; import { logModelLoadError } from "@/utils/three/modelLoadLogger"; @@ -178,7 +179,7 @@ export function GameMap({ return { node, modelUrl: modelUrl ?? null }; }); const loadedCollisionNodes = sceneData.mapNodes - .filter((node) => node.name === "terrain") + .filter(isRuntimeCollisionMapNode) .map((node) => { const modelUrl = sceneData.models.get(node.name); return { node, modelUrl: modelUrl ?? null }; diff --git a/src/world/GameMapCollision.tsx b/src/world/GameMapCollision.tsx index 7ce3d21..8308f33 100644 --- a/src/world/GameMapCollision.tsx +++ b/src/world/GameMapCollision.tsx @@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -11,6 +12,11 @@ import * as THREE from "three"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode"; +import { + getObjectBottomOffset, + normalizeMapScale, + useTerrainHeightSampler, +} from "@/hooks/three/useTerrainHeight"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; import type { MapNode } from "@/types/editor/editor"; import type { OctreeReadyHandler } from "@/types/three/three"; @@ -27,6 +33,8 @@ interface ResolvedGameMapCollisionNode { modelUrl: string; } +type TerrainHeightSampler = ReturnType; + interface GameMapCollisionProps { buildOctree?: boolean; mapReady: boolean; @@ -47,8 +55,6 @@ interface CollisionErrorBoundaryState { hasError: boolean; } -const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]); - class CollisionErrorBoundary extends Component< CollisionErrorBoundaryProps, CollisionErrorBoundaryState @@ -88,9 +94,7 @@ class CollisionErrorBoundary extends Component< function isCollisionNode( mapNode: GameMapCollisionNode, ): mapNode is ResolvedGameMapCollisionNode { - return ( - mapNode.modelUrl !== null && MAP_COLLISION_NODE_NAMES.has(mapNode.node.name) - ); + return mapNode.modelUrl !== null; } export function GameMapCollision({ @@ -105,6 +109,7 @@ export function GameMapCollision({ const settledCollisionNodesRef = useRef(new Set()); const loadedNotifiedRef = useRef(false); const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0); + const terrainHeight = useTerrainHeightSampler(); const collisionNodes = nodes.filter(isCollisionNode); const collisionReady = mapReady && settledCollisionNodeCount >= collisionNodes.length; @@ -188,6 +193,7 @@ export function GameMapCollision({ node={mapNode.node} modelUrl={mapNode.modelUrl} onLoaded={() => handleCollisionNodeSettled(index)} + terrainHeight={terrainHeight} /> @@ -201,19 +207,30 @@ function CollisionModelInstance({ node, modelUrl, onLoaded, + terrainHeight, }: { node: MapNode; modelUrl: string; onLoaded: () => void; + terrainHeight: TerrainHeightSampler; }): React.JSX.Element { const { position, rotation, scale } = node; + const normalizedScale = normalizeMapScale(scale); const { scene } = useLoggedGLTF(modelUrl, { scope: "GameMapCollision.ModelInstance", position, rotation, - scale, + scale: normalizedScale, }); const sceneInstance = useClonedObject(scene); + const collisionPosition = useMemo(() => { + if (node.name === "terrain") return position; + + const [x, y, z] = position; + const height = terrainHeight.getHeight(x, z); + const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale); + return [x, height !== null ? height + bottomOffset : y, z] as const; + }, [node.name, normalizedScale, position, sceneInstance, terrainHeight]); useEffect(() => { onLoaded(); @@ -222,9 +239,9 @@ function CollisionModelInstance({ return ( ); } diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index a799236..6aa9d98 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -47,6 +47,9 @@ const DEFAULT_KEYS: Keys = { jump: false, }; +const PLAYER_COLLISION_ITERATIONS = 3; +const PLAYER_FLOOR_NORMAL_MIN = 0.5; + interface PlayerControllerProps { octree: Octree | null; spawnPosition: Vector3Tuple; @@ -300,16 +303,21 @@ export function PlayerController({ capsule.current.translate(_translateVec); if (octree) { - const result = octree.capsuleIntersect(capsule.current); onFloor.current = false; - if (result) { - onFloor.current = result.normal.y > 0; + for (let index = 0; index < PLAYER_COLLISION_ITERATIONS; index++) { + const result = octree.capsuleIntersect(capsule.current); + if (!result) break; - if (!onFloor.current) { - const vn = result.normal.dot(velocity.current); - velocity.current.addScaledVector(result.normal, -vn); - } else { + const isFloorCollision = result.normal.y > PLAYER_FLOOR_NORMAL_MIN; + onFloor.current ||= isFloorCollision; + const normalVelocity = result.normal.dot(velocity.current); + + if (!isFloorCollision && normalVelocity < 0) { + velocity.current.addScaledVector(result.normal, -normalVelocity); + } + + if (isFloorCollision) { velocity.current.y = Math.max(0, velocity.current.y); }