feat(world): add octree collision proxies

This commit is contained in:
Tom Boullay
2026-06-01 14:11:23 +02:00
parent d52ec7e5a9
commit 7a378afad3
3 changed files with 236 additions and 10 deletions
+57
View File
@@ -0,0 +1,57 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface OctreeCollisionBox {
center: Vector3Tuple;
size: Vector3Tuple;
}
export interface MapOctreeCollisionBox extends OctreeCollisionBox {
bottomY: number;
}
export const MAP_OCTREE_COLLISION_BOXES = {
immeuble1: {
center: [-0.0308, 5.8389, 0],
size: [17.2522, 11.6098, 9.2668],
bottomY: 0.034,
},
maison1: {
center: [0, 1.3638, 0.0536],
size: [2.7813, 3.022, 2.8609],
bottomY: -0.1472,
},
} as const satisfies Record<string, MapOctreeCollisionBox>;
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
{
center: [-6.9351, 2.278, -0.0001],
size: [0.2, 1.94, 3.711],
},
{
center: [0.8026, 0.719, -3.639],
size: [4.346, 1.108, 1.181],
},
{
center: [-5.8519, 0.9362, 2.5742],
size: [1.67, 1.551, 2.566],
},
{
center: [-2.0627, 1.4875, -1.2243],
size: [0.691, 0.723, 0.687],
},
{
center: [-3.5502, 1.4378, -1.2485],
size: [1.055, 0.657, 0.563],
},
] as const satisfies readonly OctreeCollisionBox[];
export const CHARACTER_OCTREE_COLLISION_BOX = {
center: [0, 0.875, 0],
size: [0.62, 1.75, 0.62],
} as const satisfies OctreeCollisionBox;
export function hasMapOctreeCollisionBox(
name: string,
): name is keyof typeof MAP_OCTREE_COLLISION_BOXES {
return name in MAP_OCTREE_COLLISION_BOXES;
}
+11
View File
@@ -27,6 +27,7 @@ import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
import { GameMapCollision } from "@/world/GameMapCollision"; import { GameMapCollision } from "@/world/GameMapCollision";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance"; import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig"; import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
import { hasMapOctreeCollisionBox } from "@/data/world/octreeCollisionConfig";
import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig"; import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem"; import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -115,6 +116,9 @@ export function GameMap({
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>( const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
[], [],
); );
const [proxyCollisionMapNodes, setProxyCollisionMapNodes] = useState<
MapNode[]
>([]);
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null); const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
const [mapLoaded, setMapLoaded] = useState(false); const [mapLoaded, setMapLoaded] = useState(false);
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0); const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
@@ -134,6 +138,7 @@ export function GameMap({
(currentStep: string) => { (currentStep: string) => {
setRenderMapNodes([]); setRenderMapNodes([]);
setCollisionMapNodes([]); setCollisionMapNodes([]);
setProxyCollisionMapNodes([]);
setTerrainNode(null); setTerrainNode(null);
setMapLoaded(true); setMapLoaded(true);
settledMapNodesRef.current.clear(); settledMapNodesRef.current.clear();
@@ -191,6 +196,10 @@ export function GameMap({
const modelUrl = sceneData.models.get(node.name); const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null }; return { node, modelUrl: modelUrl ?? null };
}); });
const loadedProxyCollisionNodes = sceneData.mapNodes.filter(
(node) =>
node.type === "Object3D" && hasMapOctreeCollisionBox(node.name),
);
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes); const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
const repairMissionAnchors = getRepairMissionMapAnchors( const repairMissionAnchors = getRepairMissionMapAnchors(
sceneData.mapNodes, sceneData.mapNodes,
@@ -211,6 +220,7 @@ export function GameMap({
setRenderMapNodes(loadedMapNodes); setRenderMapNodes(loadedMapNodes);
setCollisionMapNodes(loadedCollisionNodes); setCollisionMapNodes(loadedCollisionNodes);
setProxyCollisionMapNodes(loadedProxyCollisionNodes);
setTerrainNode(loadedTerrainNode); setTerrainNode(loadedTerrainNode);
setRepairMissionAnchors(repairMissionAnchors); setRepairMissionAnchors(repairMissionAnchors);
setMapLoaded(true); setMapLoaded(true);
@@ -285,6 +295,7 @@ export function GameMap({
buildOctree={buildOctree} buildOctree={buildOctree}
mapReady={mapReady} mapReady={mapReady}
nodes={collisionMapNodes} nodes={collisionMapNodes}
proxyNodes={proxyCollisionMapNodes}
onLoaded={onLoaded} onLoaded={onLoaded}
onLoadingStateChange={onLoadingStateChange} onLoadingStateChange={onLoadingStateChange}
onOctreeReady={onOctreeReady} onOctreeReady={onOctreeReady}
+162 -4
View File
@@ -17,9 +17,23 @@ import {
normalizeMapScale, normalizeMapScale,
useTerrainHeightSampler, useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight"; } from "@/hooks/three/useTerrainHeight";
import {
CHARACTER_CONFIGS,
CHARACTER_IDS,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import {
CHARACTER_OCTREE_COLLISION_BOX,
LA_FABRIK_INTERIOR_COLLISION_BOXES,
MAP_OCTREE_COLLISION_BOXES,
hasMapOctreeCollisionBox,
type OctreeCollisionBox,
} from "@/data/world/octreeCollisionConfig";
import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import type { MapNode } from "@/types/map/mapScene"; import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three"; import type { OctreeReadyHandler, Vector3Tuple } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
@@ -39,6 +53,7 @@ interface GameMapCollisionProps {
buildOctree?: boolean; buildOctree?: boolean;
mapReady: boolean; mapReady: boolean;
nodes: readonly GameMapCollisionNode[]; nodes: readonly GameMapCollisionNode[];
proxyNodes: readonly MapNode[];
onLoaded?: (() => void) | undefined; onLoaded?: (() => void) | undefined;
onLoadingStateChange?: SceneLoadingChangeHandler | undefined; onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
onOctreeReady: OctreeReadyHandler; onOctreeReady: OctreeReadyHandler;
@@ -101,6 +116,7 @@ export function GameMapCollision({
buildOctree = true, buildOctree = true,
mapReady, mapReady,
nodes, nodes,
proxyNodes,
onLoaded, onLoaded,
onLoadingStateChange, onLoadingStateChange,
onOctreeReady, onOctreeReady,
@@ -111,8 +127,23 @@ export function GameMapCollision({
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0); const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const terrainHeight = useTerrainHeightSampler(); const terrainHeight = useTerrainHeightSampler();
const collisionNodes = nodes.filter(isCollisionNode); const collisionNodes = nodes.filter(isCollisionNode);
const collisionSourceCount =
collisionNodes.length + proxyNodes.length + CHARACTER_IDS.length;
const collisionReady = const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length; mapReady && settledCollisionNodeCount >= collisionNodes.length;
const characterCollisionSignature = useCharacterDebugStore((state) =>
CHARACTER_IDS.map((id) => {
const character = state.characters[id];
return [
...character.position,
...character.rotation,
...character.scale,
].join(",");
}).join("|"),
);
const collisionRebuildKey = collisionReady
? `${collisionNodes.length}:${collisionSourceCount}:${characterCollisionSignature}`
: "pending";
const notifyLoaded = useCallback(() => { const notifyLoaded = useCallback(() => {
if (loadedNotifiedRef.current) return; if (loadedNotifiedRef.current) return;
@@ -144,14 +175,14 @@ export function GameMapCollision({
useOctreeGraphNode( useOctreeGraphNode(
groupRef, groupRef,
handleOctreeReady, handleOctreeReady,
collisionReady ? collisionNodes.length : 0, collisionRebuildKey,
buildOctree && collisionReady && collisionNodes.length > 0, buildOctree && collisionReady && collisionSourceCount > 0,
); );
useEffect(() => { useEffect(() => {
if (!mapReady) return; if (!mapReady) return;
if (collisionNodes.length === 0) { if (collisionSourceCount === 0) {
notifyLoaded(); notifyLoaded();
return; return;
} }
@@ -171,6 +202,7 @@ export function GameMapCollision({
}, [ }, [
buildOctree, buildOctree,
collisionNodes.length, collisionNodes.length,
collisionSourceCount,
collisionReady, collisionReady,
mapReady, mapReady,
notifyLoaded, notifyLoaded,
@@ -180,6 +212,18 @@ export function GameMapCollision({
return ( return (
<group ref={groupRef} visible={false}> <group ref={groupRef} visible={false}>
{mapReady ? <WorldBoundsCollision /> : null} {mapReady ? <WorldBoundsCollision /> : null}
{mapReady
? proxyNodes.map((node, index) => (
<MapCollisionBoxProxy
key={`proxy-collision-${index}`}
node={node}
terrainHeight={terrainHeight}
/>
))
: null}
{mapReady ? (
<CharacterCollisionProxies terrainHeight={terrainHeight} />
) : null}
{mapReady {mapReady
? collisionNodes.map((mapNode, index) => ( ? collisionNodes.map((mapNode, index) => (
<CollisionErrorBoundary <CollisionErrorBoundary
@@ -253,11 +297,125 @@ function CollisionModelInstance({
}, [onLoaded]); }, [onLoaded]);
return ( return (
<>
<primitive <primitive
object={sceneInstance} object={sceneInstance}
position={collisionPosition} position={collisionPosition}
rotation={rotation} rotation={rotation}
scale={normalizedScale} scale={normalizedScale}
/> />
{node.name === "lafabrik" ? (
<group
name="lafabrik-interior-collision-proxies"
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
>
{LA_FABRIK_INTERIOR_COLLISION_BOXES.map((box, index) => (
<CollisionBox key={`lafabrik-interior-${index}`} box={box} />
))}
</group>
) : null}
</>
);
}
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
return (
<mesh position={box.center}>
<boxGeometry args={box.size} />
<meshBasicMaterial />
</mesh>
);
}
function createScaledMapNodeScale(node: MapNode): Vector3Tuple {
const baseScale = normalizeMapScale(node.scale);
const scaleMultiplier = getMapModelScaleMultiplier(node.name);
return [
baseScale[0] * scaleMultiplier,
baseScale[1] * scaleMultiplier,
baseScale[2] * scaleMultiplier,
];
}
function MapCollisionBoxProxy({
node,
terrainHeight,
}: {
node: MapNode;
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element | null {
const collisionBox = hasMapOctreeCollisionBox(node.name)
? MAP_OCTREE_COLLISION_BOXES[node.name]
: null;
const normalizedScale = useMemo(() => createScaledMapNodeScale(node), [node]);
const position = useMemo(() => {
const [x, y, z] = node.position;
if (!collisionBox) return [x, y, z] satisfies Vector3Tuple;
const height = terrainHeight.getHeight(x, z);
const bottomOffset = -collisionBox.bottomY * normalizedScale[1];
return [x, (height ?? y) + bottomOffset, z] satisfies Vector3Tuple;
}, [collisionBox, node.position, normalizedScale, terrainHeight]);
if (!collisionBox) return null;
return (
<group
name={`${node.name}-octree-collision-proxy`}
position={position}
rotation={node.rotation}
scale={normalizedScale}
>
<CollisionBox box={collisionBox} />
</group>
);
}
function CharacterCollisionProxies({
terrainHeight,
}: {
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element {
return (
<>
{CHARACTER_IDS.map((id) => (
<CharacterCollisionProxy
key={`character-collision-${id}`}
id={id}
terrainHeight={terrainHeight}
/>
))}
</>
);
}
function CharacterCollisionProxy({
id,
terrainHeight,
}: {
id: CharacterId;
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element {
const config = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]);
const position = useMemo(() => {
const [x, y, z] = state.position;
const height = terrainHeight.getHeight(x, z);
return [x, height ?? y, z] satisfies Vector3Tuple;
}, [state.position, terrainHeight]);
return (
<group
name={`${config.id}-octree-collision-proxy`}
position={position}
rotation={state.rotation}
>
<CollisionBox box={CHARACTER_OCTREE_COLLISION_BOX} />
</group>
); );
} }