Files
healer-man/src/game/partyNavigationAssetCache.ts
T
2026-10-06 22:25:34 -04:00

226 lines
9.1 KiB
TypeScript

import { BufferGeometry, Float32BufferAttribute, Vector3, type Group, type Mesh } from "three";
import type {
DungeonNavigationComponentPolicy,
DungeonNavigationLink,
DungeonNavigationTopologySummary,
DungeonTraversalRamp,
} from "./dungeonTypes";
import { bridgeDungeonStairNavigation, dungeonRampNavigationBridges, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
import {
buildPartyNavigationGraph,
buildPartyNavigationGraphFromDetourTopology,
type PartyNavigationGraph,
type PartyNavigationDetourTopology,
} from "./partyNavigation";
import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy";
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
const graphCache = new Map<string, PartyNavigationGraph>();
export function cachedPartyNavigationGraph(
url: string,
links: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[] = [],
retainSurfaceTriangles = false,
detourTopologySummary?: DungeonNavigationTopologySummary,
navigationComponentPolicy?: DungeonNavigationComponentPolicy,
navigationAssetChecksum?: string,
): PartyNavigationGraph | null {
return graphCache.get(graphCacheKey(url, links, ramps)
+ (retainSurfaceTriangles ? ":floor-validation" : "")
+ detourTopologyCacheKey(detourTopologySummary)
+ navigationIdentityCacheKey(navigationComponentPolicy, navigationAssetChecksum)) ?? null;
}
function navigationIdentityCacheKey(
policy: DungeonNavigationComponentPolicy | undefined,
checksum: string | undefined,
): string {
if (!policy && !checksum) return "";
return `:navigation-${policy ?? "default"}-${encodeURIComponent(checksum ?? "unversioned")}`;
}
function detourTopologyCacheKey(summary: DungeonNavigationTopologySummary | undefined): string {
return summary
? `:detour-${summary.schemaVersion}-${summary.polygonCount}-${summary.directedLinkCount}-${summary.detailTriangleCount}`
+ (summary.assetChecksum ? `-${encodeURIComponent(summary.assetChecksum)}` : "")
: "";
}
function validateDetourTopologyManifestCounts(
rawTopology: PartyNavigationDetourTopology | undefined,
summary: DungeonNavigationTopologySummary | undefined,
): void {
if (!rawTopology && !summary) return;
if (!rawTopology || !summary) {
throw new Error("Source-pinned Detour navigation requires both GLB topology extras and a manifest summary.");
}
if (
rawTopology.schemaVersion !== summary.schemaVersion
|| rawTopology.polygonCount !== summary.polygonCount
|| rawTopology.directedLinkCount !== summary.directedLinkCount
|| rawTopology.detailTriangleCount !== summary.detailTriangleCount
|| rawTopology.geometryTriangleCount !== summary.detailTriangleCount
|| rawTopology.unrepresentedLinkCount !== 0
|| rawTopology.polygons.length !== summary.polygonCount
|| rawTopology.links.length !== summary.directedLinkCount
) {
throw new Error("Source-pinned Detour GLB topology counts do not match its reviewed manifest summary.");
}
}
function graphCacheKey(
url: string,
offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[],
): string {
const links = offMeshLinks.map((link) => [
link.id,
link.start,
link.end,
link.bidirectional,
]);
return `${url}:${JSON.stringify([links, ramps])}`;
}
function navigationGeometry(
scene: Group,
componentPolicy: DungeonNavigationComponentPolicy | undefined,
maxSourceTriangles: number,
): BufferGeometry {
scene.updateMatrixWorld(true);
if (componentPolicy === "all-components") {
const sourceMeshes: Mesh[] = [];
let sourceTriangleCount = 0;
scene.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const position = mesh.geometry.getAttribute("position");
if (!position || position.count < 3) return;
const indexCount = mesh.geometry.getIndex()?.count ?? position.count;
if (indexCount < 3 || indexCount % 3 !== 0) {
throw new Error("An all-components party navigation mesh must contain complete triangles.");
}
sourceMeshes.push(mesh);
sourceTriangleCount += indexCount / 3;
});
if (!sourceMeshes.length) throw new Error("The party navigation GLB has no triangle geometry.");
if (sourceTriangleCount > maxSourceTriangles) {
throw new Error(
`Party navigation geometry has ${sourceTriangleCount} triangles; maximum is ${maxSourceTriangles}.`,
);
}
const mergedPositions = new Float32Array(sourceTriangleCount * 9);
const point = new Vector3();
let outputOffset = 0;
for (const mesh of sourceMeshes) {
const geometry = mesh.geometry;
const position = geometry.getAttribute("position");
const index = geometry.getIndex();
const indexCount = index?.count ?? position.count;
for (let sourceIndex = 0; sourceIndex < indexCount; sourceIndex += 1) {
const vertexIndex = index ? index.getX(sourceIndex) : sourceIndex;
if (!Number.isInteger(vertexIndex) || vertexIndex < 0 || vertexIndex >= position.count) {
throw new Error(`Party navigation index ${sourceIndex} references an invalid vertex.`);
}
point.fromBufferAttribute(position, vertexIndex).applyMatrix4(mesh.matrixWorld);
mergedPositions[outputOffset++] = point.x;
mergedPositions[outputOffset++] = point.y;
mergedPositions[outputOffset++] = point.z;
}
}
const merged = new BufferGeometry();
merged.setAttribute("position", new Float32BufferAttribute(mergedPositions, 3));
return merged;
}
let sourceMesh: Mesh | null = null;
let largestVertexCount = 0;
scene.traverse((node) => {
const mesh = node as Mesh;
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
if (count > largestVertexCount) {
sourceMesh = mesh;
largestVertexCount = count;
}
});
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone();
const position = geometry.getAttribute("position");
// BufferAttribute.applyMatrix4 writes back through the source component
// type. Quantized normalized integers therefore clamp world coordinates to
// [-1, 1] while baking their node transform. Expand them to floats first.
if (position.normalized) {
const expanded = new Float32Array(position.count * 3);
for (let index = 0; index < position.count; index += 1) {
expanded[index * 3] = position.getX(index);
expanded[index * 3 + 1] = position.getY(index);
expanded[index * 3 + 2] = position.getZ(index);
}
geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3));
}
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry;
}
export function preparePartyNavigationGraph(
url: string,
scene: Group,
offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[] = [],
maxSourceTriangles = MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
retainSurfaceTriangles = false,
detourTopology?: PartyNavigationDetourTopology,
detourTopologySummary?: DungeonNavigationTopologySummary,
navigationComponentPolicy?: DungeonNavigationComponentPolicy,
navigationAssetChecksum?: string,
): PartyNavigationGraph {
validateDetourTopologyManifestCounts(detourTopology, detourTopologySummary);
if (detourTopology && navigationComponentPolicy) {
throw new Error("Source-pinned Detour topology cannot be combined with inferred all-components navigation.");
}
const cacheKey = graphCacheKey(url, offMeshLinks, ramps)
+ (retainSurfaceTriangles ? ":floor-validation" : "")
+ detourTopologyCacheKey(detourTopologySummary)
+ navigationIdentityCacheKey(navigationComponentPolicy, navigationAssetChecksum);
const cached = graphCache.get(cacheKey);
if (cached) {
graphCache.delete(cacheKey);
graphCache.set(cacheKey, cached);
return cached;
}
const geometry = navigationGeometry(scene, navigationComponentPolicy, maxSourceTriangles);
let graph: PartyNavigationGraph;
try {
if (detourTopology) {
if (ramps.length || offMeshLinks.length) {
throw new Error("Source-pinned Detour topology cannot be combined with inferred ramp or off-mesh links.");
}
graph = buildPartyNavigationGraphFromDetourTopology(geometry, detourTopology);
} else {
if (ramps.length) {
// Recast normally lifts its walkable surface slightly above collision.
smoothDungeonStairGeometry(geometry, ramps, 0.05);
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
}
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles,
retainSurfaceTriangles,
retainAllComponents: navigationComponentPolicy === "all-components",
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
});
}
} finally {
geometry.dispose();
}
graphCache.set(cacheKey, graph);
if (graphCache.size > MAX_CACHED_NAVIGATION_GRAPHS) {
const oldest = graphCache.keys().next().value as string | undefined;
if (oldest) graphCache.delete(oldest);
}
return graph;
}