feat(collision): include static map models in octree

This commit is contained in:
Tom Boullay
2026-05-28 00:47:40 +02:00
parent 7a72743e5c
commit 3881e38a6d
4 changed files with 46 additions and 16 deletions
@@ -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";
}
+2 -1
View File
@@ -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 };
+25 -8
View File
@@ -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<typeof useTerrainHeightSampler>;
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<number>());
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}
/>
</Suspense>
</CollisionErrorBoundary>
@@ -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 (
<primitive
object={sceneInstance}
position={position}
position={collisionPosition}
rotation={rotation}
scale={scale}
scale={normalizedScale}
/>
);
}
+15 -7
View File
@@ -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);
}