Release Healer Man 0.1.7

This commit is contained in:
phenom committed 2026-08-22 15:53:43 -04:00
1 parent b12fb84859
commit eca6b5db9b
99 files changed
+1931 -150

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
describe("adaptive graphics quality", () => {
it("maps the performance range onto the supported pixel ratio", () => {
expect(dprForPerformanceFactor(0)).toBe(1);
expect(dprForPerformanceFactor(0.5)).toBe(1.25);
expect(dprForPerformanceFactor(1)).toBe(1.5);
});
it("clamps out-of-range monitor factors", () => {
expect(dprForPerformanceFactor(-1)).toBe(1);
expect(dprForPerformanceFactor(2)).toBe(1.5);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { PerformanceMonitor } from "@react-three/drei";
import { dprForPerformanceFactor } from "./adaptiveGraphicsQualityMath";
export function AdaptiveGraphicsQuality({
onDprChange,
}: {
readonly onDprChange: (dpr: number) => void;
}) {
return (
<PerformanceMonitor
factor={1}
iterations={6}
ms={250}
step={0.25}
threshold={0.75}
bounds={(refreshRate) => refreshRate > 100 ? [60, 100] : [52, 58]}
onChange={({ factor }) => onDprChange(dprForPerformanceFactor(factor))}
/>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useFrame } from "@react-three/fiber";
import { useEffect, useRef } from "react";
const DATASET_KEY = "gamePerformance";
/** Publishes low-overhead renderer counters for local profiling only. */
export function DevelopmentPerformanceProbe() {
const elapsedRef = useRef(0);
const framesRef = useRef(0);
const slowestFrameRef = useRef(0);
useFrame(({ gl }, delta) => {
elapsedRef.current += delta;
framesRef.current += 1;
slowestFrameRef.current = Math.max(slowestFrameRef.current, delta);
if (elapsedRef.current < 1) return;
document.documentElement.dataset[DATASET_KEY] = JSON.stringify({
fps: framesRef.current / elapsedRef.current,
averageFrameMs: elapsedRef.current * 1_000 / framesRef.current,
slowestFrameMs: slowestFrameRef.current * 1_000,
drawCalls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
pixelRatio: gl.getPixelRatio(),
});
elapsedRef.current = 0;
framesRef.current = 0;
slowestFrameRef.current = 0;
});
useEffect(() => () => {
delete document.documentElement.dataset[DATASET_KEY];
}, []);
return null;
}
+20 -1
View File
@@ -28,6 +28,7 @@ import {
encounterWorldIsReady,
encounterWorldKey,
rendererCanvasKey,
sceneFrameLoop,
} from "./sceneLifecycle";
import { manastormChaoticLinkRuntimeIds } from "../game/manastormChaoticLink";
import { manastormStagePopulation, manastormStageRoamingPacks } from "../game/manastormStagePopulation";
@@ -44,6 +45,8 @@ import { GmPlacementMarkers } from "./GmPlacementMarkers";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
import { DevelopmentPerformanceProbe } from "./DevelopmentPerformanceProbe";
import { AdaptiveGraphicsQuality } from "./AdaptiveGraphicsQuality";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -97,12 +100,21 @@ export function GameScene() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false);
const [adaptiveDpr, setAdaptiveDpr] = useState(1.5);
const [canvasRevision, setCanvasRevision] = useState(0);
const [graphicsRecovery, setGraphicsRecovery] = useState<"lost" | "recovering" | null>(null);
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({
worldReady,
graphicsRecovering: graphicsRecovery !== null,
sharedOnline,
paused,
mapOpen,
companionOpen,
});
const allowedRuntimeIds = useMemo(
() => gameMode === "manastorm" && manastormEncounter
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
@@ -153,6 +165,10 @@ export function GameScene() {
useWailingEncounterStore.getState().reset();
}, [activeDungeonId, gameMode, sessionRevision]);
useEffect(() => {
setAdaptiveDpr(1.5);
}, [activeDungeonId, sessionRevision]);
const endMouseLook = useCallback(() => {
endMouseLookDrag();
}, []);
@@ -193,7 +209,8 @@ export function GameScene() {
key={rendererCanvasKey(canvasRevision)}
ref={canvasRef}
shadows={!safeGraphics}
dpr={safeGraphics ? 1 : [1, 1.5]}
dpr={safeGraphics ? 1 : adaptiveDpr}
frameloop={frameloop}
camera={{
fov: 62,
near: 0.08,
@@ -210,6 +227,8 @@ export function GameScene() {
}}
>
<GameGltfLoaderLifecycle>
{!safeGraphics && worldReady && <AdaptiveGraphicsQuality onDprChange={setAdaptiveDpr} />}
{import.meta.env.DEV && <DevelopmentPerformanceProbe />}
<color attach="background" args={[dungeon.presentation.background]} />
<fog attach="fog" args={[dungeon.presentation.fog.color, dungeon.presentation.fog.near, dungeon.presentation.fog.far]} />
<ambientLight intensity={dungeon.presentation.ambientLight.intensity} color={dungeon.presentation.ambientLight.color} />
+2 -19
View File
@@ -78,6 +78,7 @@ import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore";
import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
@@ -378,25 +379,7 @@ function PartyActors({
const hasStaticLineOfSight = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= 0.08) return true;
const ray = new rapier.Ray(
{ x: start[0], y: start[1] + 1.05, z: start[2] },
{ x: dx / distance, y: dy / distance, z: dz / distance },
);
const hit = world.castRay(
ray,
distance,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
);
return !hit || hit.timeOfImpact >= distance - 0.08;
}, [rapier, world]);
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
const routeSurfaceSegmentAllowed = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
+40 -1
View File
@@ -7,7 +7,11 @@ import {
MeshBasicMaterial,
} from "three";
import { describe, expect, it } from "vitest";
import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision";
import {
collisionLineOfSightTrimeshes,
collisionTrimeshes,
prepareCollision,
} from "./productionDungeonCollision";
describe("streamed dungeon collision preparation", () => {
it("dequantizes normalized Meshopt position buffers before Rapier consumes them", () => {
@@ -54,4 +58,39 @@ describe("streamed dungeon collision preparation", () => {
expect(Array.from(trimesh.indices)).toEqual([0, 1, 2]);
});
it("builds sight collision from walls while excluding ground and ramps", () => {
const geometry = new BufferGeometry();
geometry.setAttribute(
"position",
new Float32BufferAttribute([
0, 0, 0,
2, 0, 0,
0, 0, 2,
0, 0, 3,
2, 0, 3,
0, 2, 5,
4, 0, 0,
4, 2, 0,
4, 0, 2,
], 3),
);
geometry.setIndex([
0, 1, 2,
3, 4, 5,
6, 7, 8,
]);
const root = new Group();
root.add(new Mesh(geometry, new MeshBasicMaterial()));
const prepared = prepareCollision(root);
const [blocker] = collisionLineOfSightTrimeshes(prepared);
expect(Array.from(blocker.indices)).toEqual([0, 1, 2]);
expect(Array.from(blocker.vertices)).toEqual([
4, 0, 0,
4, 2, 0,
4, 0, 2,
]);
});
});
+21 -1
View File
@@ -12,8 +12,17 @@ import {
import type { ResolvedDungeonAssets } from "../game/dungeonAssets";
import type { DungeonDefinition, DungeonMaterialRoles } from "../game/dungeonTypes";
import { useGameStore } from "../game/store";
import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision";
import {
collisionLineOfSightTrimeshes,
collisionTrimeshes,
prepareCollision,
} from "./productionDungeonCollision";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
} from "./dungeonLineOfSight";
import { disposeObject3DResources } from "./threeResourceDisposal";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
import { useGameGLTF } from "./useGameGLTF";
import {
DUNGEON_COLLISION_LOAD_BATCH_SIZE,
@@ -81,6 +90,7 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
instanced.computeBoundingSphere();
}
});
instanceRepeatedStaticMeshes(clone);
return clone;
}
@@ -141,6 +151,7 @@ function CollisionChunk({
const gltf = useGameGLTF(url);
const collision = useMemo(() => prepareCollision(gltf.scene), [gltf.scene]);
const trimeshes = useMemo(() => collisionTrimeshes(collision), [collision]);
const sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]);
useEffect(() => () => {
disposeObject3DResources(collision, {
materials: false,
@@ -166,6 +177,15 @@ function CollisionChunk({
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS}
/>
))}
{sightBlockers.map((trimesh) => (
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS}
sensor
/>
))}
<primitive object={collision} />
+25 -6
View File
@@ -1,17 +1,36 @@
import { Sparkles } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import type { DungeonDefinition } from "../game/dungeonTypes";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_WALL_COLLISION_GROUPS,
} from "./dungeonLineOfSight";
interface SlabProps {
position: [number, number, number];
size: [number, number, number];
rotationY?: number;
color?: string;
blocksLineOfSight?: boolean;
}
function CaveSlab({ position, size, rotationY = 0, color = "#25352b" }: SlabProps) {
function CaveSlab({
position,
size,
rotationY = 0,
color = "#25352b",
blocksLineOfSight = false,
}: SlabProps) {
return (
<RigidBody type="fixed" colliders="cuboid" position={position} rotation={[0, rotationY, 0]}>
<RigidBody
type="fixed"
colliders="cuboid"
collisionGroups={blocksLineOfSight
? DUNGEON_WALL_COLLISION_GROUPS
: DUNGEON_PHYSICAL_COLLISION_GROUPS}
position={position}
rotation={[0, rotationY, 0]}
>
<mesh receiveShadow castShadow>
<boxGeometry args={size} />
<meshStandardMaterial color={color} roughness={0.96} metalness={0.02} />
@@ -45,11 +64,11 @@ export function PrototypeDungeon({ definition }: { readonly definition: DungeonD
rotation={[0, entrance.yaw, 0]}
>
<CaveSlab position={[0, -0.3, 11]} size={[7.4, 0.6, 28]} />
<CaveSlab position={[-4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" />
<CaveSlab position={[4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" />
<CaveSlab position={[-4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" blocksLineOfSight />
<CaveSlab position={[4.1, 2.15, 11]} size={[1.1, 4.9, 28]} color="#1c2c24" blocksLineOfSight />
<CaveSlab position={[0, -0.36, 31]} size={[18, 0.72, 15]} color="#2b3d31" />
<CaveSlab position={[-9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" />
<CaveSlab position={[9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" />
<CaveSlab position={[-9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" blocksLineOfSight />
<CaveSlab position={[9.25, 2, 31]} size={[1.1, 4.7, 15]} color="#1b2a23" blocksLineOfSight />
<CaveSlab position={[3.3, -0.3, 44]} size={[9, 0.6, 19]} rotationY={-0.34} />
{stonePositions.map((position, index) => <Stone key={index} position={position} />)}
+3
View File
@@ -0,0 +1,3 @@
export function dprForPerformanceFactor(factor: number): number {
return 1 + Math.max(0, Math.min(1, factor)) * 0.5;
}
+57
View File
@@ -0,0 +1,57 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import {
DUNGEON_PHYSICAL_COLLISION_GROUPS,
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
hasDungeonLineOfSight,
} from "./dungeonLineOfSight";
beforeAll(async () => {
await RAPIER.init();
});
function fixedCuboid(
world: RAPIER.World,
translation: readonly [number, number, number],
collisionGroups: number,
sensor = false,
): void {
const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
world.createCollider(
RAPIER.ColliderDesc.cuboid(0.1, 2, 2)
.setTranslation(...translation)
.setCollisionGroups(collisionGroups)
.setSensor(sensor),
body,
);
}
function kinematicCaster(world: RAPIER.World): void {
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(0, 1.05, 0),
);
world.createCollider(RAPIER.ColliderDesc.capsule(0.5, 0.3), body);
}
describe("dungeon line of sight", () => {
it("ignores physical ground geometry even when it crosses the sight ray", () => {
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
fixedCuboid(world, [2, 1.05, 0], DUNGEON_PHYSICAL_COLLISION_GROUPS);
kinematicCaster(world);
world.step();
expect(hasDungeonLineOfSight(RAPIER, world, [0, 0, 0], [4, 0, 0])).toBe(true);
world.free();
});
it("is blocked by dedicated wall and pillar geometry", () => {
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
fixedCuboid(world, [2, 1.05, 0], DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, true);
world.step();
expect(hasDungeonLineOfSight(RAPIER, world, [0, 0, 0], [4, 0, 0])).toBe(false);
world.free();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { interactionGroups } from "@react-three/rapier";
import type {
QueryFilterFlags,
Ray,
Vector,
World,
} from "@dimforge/rapier3d-compat";
interface RapierApi {
readonly Ray: new (origin: Vector, direction: Vector) => Ray;
readonly QueryFilterFlags: {
readonly EXCLUDE_DYNAMIC: QueryFilterFlags;
readonly EXCLUDE_KINEMATIC: QueryFilterFlags;
};
}
type WorldPosition = readonly [number, number, number];
export const DUNGEON_LINE_OF_SIGHT_GROUP = 15;
export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0);
export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP);
export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([0, DUNGEON_LINE_OF_SIGHT_GROUP]);
export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups(
DUNGEON_LINE_OF_SIGHT_GROUP,
DUNGEON_LINE_OF_SIGHT_GROUP,
);
const ACTOR_SIGHT_HEIGHT = 1.05;
const TARGET_MARGIN = 0.08;
/**
* Checks only the dedicated wall/pillar collision layer. Walkable terrain and
* ceilings remain physical, but never participate in combat visibility.
*/
export function hasDungeonLineOfSight(
rapier: RapierApi,
world: World,
start: WorldPosition,
end: WorldPosition,
): boolean {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= TARGET_MARGIN) return true;
const ray = new rapier.Ray(
{ x: start[0], y: start[1] + ACTOR_SIGHT_HEIGHT, z: start[2] },
{ x: dx / distance, y: dy / distance, z: dz / distance },
);
const hit = world.castRay(
ray,
distance,
true,
rapier.QueryFilterFlags.EXCLUDE_DYNAMIC | rapier.QueryFilterFlags.EXCLUDE_KINEMATIC,
DUNGEON_SIGHT_QUERY_GROUPS,
);
return !hit || hit.timeOfImpact >= distance - TARGET_MARGIN;
}
+62
View File
@@ -11,6 +11,10 @@ export interface PreparedCollisionTrimesh {
readonly indices: ArrayLike<number>;
}
// A surface must be within 30 degrees of vertical to occlude combat sight.
// This deliberately rejects floors, ramps, slopes, and ceilings.
const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5;
export function prepareCollision(root: Object3D): Group {
root.updateWorldMatrix(true, true);
const inverseRootMatrix = root.matrixWorld.clone().invert();
@@ -60,3 +64,61 @@ export function collisionTrimeshes(root: Object3D): readonly PreparedCollisionTr
});
return result;
}
export function collisionLineOfSightTrimeshes(root: Object3D): readonly PreparedCollisionTrimesh[] {
const result: PreparedCollisionTrimesh[] = [];
root.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const position = mesh.geometry.getAttribute("position");
const sourceIndices = mesh.geometry.index?.array
?? Uint32Array.from({ length: position.count }, (_, vertex) => vertex);
const blockerSourceIndices: number[] = [];
for (let offset = 0; offset + 2 < sourceIndices.length; offset += 3) {
const first = Number(sourceIndices[offset]);
const second = Number(sourceIndices[offset + 1]);
const third = Number(sourceIndices[offset + 2]);
const firstX = position.getX(first);
const firstY = position.getY(first);
const firstZ = position.getZ(first);
const abX = position.getX(second) - firstX;
const abY = position.getY(second) - firstY;
const abZ = position.getZ(second) - firstZ;
const acX = position.getX(third) - firstX;
const acY = position.getY(third) - firstY;
const acZ = position.getZ(third) - firstZ;
const normalX = abY * acZ - abZ * acY;
const normalY = abZ * acX - abX * acZ;
const normalZ = abX * acY - abY * acX;
const normalLength = Math.hypot(normalX, normalY, normalZ);
if (normalLength <= Number.EPSILON) continue;
if (Math.abs(normalY) / normalLength > MAX_SIGHT_BLOCKER_NORMAL_Y) continue;
blockerSourceIndices.push(first, second, third);
}
if (!blockerSourceIndices.length) return;
const remappedVertices: number[] = [];
const remappedIndices = new Uint32Array(blockerSourceIndices.length);
const remap = new Map<number, number>();
blockerSourceIndices.forEach((sourceIndex, indexOffset) => {
let remappedIndex = remap.get(sourceIndex);
if (remappedIndex === undefined) {
remappedIndex = remap.size;
remap.set(sourceIndex, remappedIndex);
remappedVertices.push(
position.getX(sourceIndex),
position.getY(sourceIndex),
position.getZ(sourceIndex),
);
}
remappedIndices[indexOffset] = remappedIndex;
});
result.push({
id: `${mesh.uuid}-line-of-sight`,
vertices: Float32Array.from(remappedVertices),
indices: remappedIndices,
});
});
return result;
}
+19
View File
@@ -3,6 +3,7 @@ import {
encounterWorldIsReady,
encounterWorldKey,
rendererCanvasKey,
sceneFrameLoop,
} from "./sceneLifecycle";
describe("R3F scene lifecycle", () => {
@@ -18,4 +19,22 @@ describe("R3F scene lifecycle", () => {
expect(encounterWorldIsReady(4, 4)).toBe(true);
expect(encounterWorldIsReady(4, 5)).toBe(false);
});
it("renders local paused worlds on demand without stalling loading or online worlds", () => {
const readyWorld = {
worldReady: true,
graphicsRecovering: false,
sharedOnline: false,
paused: false,
mapOpen: false,
companionOpen: false,
};
expect(sceneFrameLoop({ ...readyWorld, paused: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, mapOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, companionOpen: true })).toBe("demand");
expect(sceneFrameLoop({ ...readyWorld, paused: true, sharedOnline: true })).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, paused: true, worldReady: false })).toBe("always");
expect(sceneFrameLoop({ ...readyWorld, paused: true, graphicsRecovering: true })).toBe("always");
expect(sceneFrameLoop(readyWorld)).toBe("always");
});
});
+21
View File
@@ -1,4 +1,5 @@
import type { DungeonId } from "../game/dungeonRegistry";
import type { Frameloop } from "@react-three/fiber";
/**
* Encounter transitions must never remount the Canvas: R3F intentionally loses
@@ -27,3 +28,23 @@ export function encounterWorldIsReady(
return readySessionRevision !== null
&& readySessionRevision === Math.max(0, Math.trunc(sessionRevision));
}
/**
* A paused local world only needs a frame when React or R3F invalidates it.
* Loading, recovery, and shared-online worlds keep the continuous loop so
* asynchronous scene setup and authoritative remote simulation can progress.
*/
export function sceneFrameLoop(options: {
readonly worldReady: boolean;
readonly graphicsRecovering: boolean;
readonly sharedOnline: boolean;
readonly paused: boolean;
readonly mapOpen: boolean;
readonly companionOpen: boolean;
}): Frameloop {
const locallyPaused = options.worldReady
&& !options.graphicsRecovering
&& !options.sharedOnline
&& (options.paused || options.mapOpen || options.companionOpen);
return locallyPaused ? "demand" : "always";
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
BoxGeometry,
Group,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
Vector3,
} from "three";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
function batchIn(root: Group): InstancedMesh {
let result: InstancedMesh | null = null;
root.traverse((node) => {
if ((node as InstancedMesh).isInstancedMesh) result = node as InstancedMesh;
});
if (!result) throw new Error("Expected an instanced mesh batch.");
return result;
}
function batchesIn(root: Group): readonly InstancedMesh[] {
const result: InstancedMesh[] = [];
root.traverse((node) => {
if ((node as InstancedMesh).isInstancedMesh) result.push(node as InstancedMesh);
});
return result;
}
describe("static scene instancing", () => {
it("batches repeated meshes while retaining their world transforms", () => {
const root = new Group();
root.position.set(10, 0, 0);
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
for (const x of [1, 3, 7]) {
const mesh = new Mesh(geometry, material);
mesh.position.x = x;
root.add(mesh);
}
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 1,
instances: 3,
removedMeshes: 3,
});
const batch = batchIn(root);
const matrix = new Matrix4();
const position = new Vector3();
const positions = Array.from({ length: batch.count }, (_, index) => {
batch.getMatrixAt(index, matrix);
position.setFromMatrixPosition(matrix);
return position.x;
});
expect(positions).toEqual([1, 3, 7]);
});
it("leaves small groups and existing instance batches unchanged", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
root.add(new Mesh(geometry, material), new Mesh(geometry, material));
const existing = new InstancedMesh(geometry, material, 4);
root.add(existing);
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 0,
instances: 0,
removedMeshes: 0,
});
expect(root.children).toHaveLength(3);
expect(root.children).toContain(existing);
});
it("does not combine meshes whose render state differs", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
for (let index = 0; index < 3; index += 1) {
const mesh = new Mesh(geometry, material);
mesh.receiveShadow = index !== 2;
root.add(mesh);
}
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 0,
instances: 0,
removedMeshes: 0,
});
});
it("keeps distant repetitions in separate cullable spatial batches", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
for (const x of [1, 2, 3, 385, 386, 387]) {
const mesh = new Mesh(geometry, material);
mesh.position.x = x;
root.add(mesh);
}
expect(instanceRepeatedStaticMeshes(root)).toEqual({
batches: 2,
instances: 6,
removedMeshes: 6,
});
expect(batchesIn(root).map((batch) => batch.count)).toEqual([3, 3]);
});
});
+116
View File
@@ -0,0 +1,116 @@
import {
InstancedMesh,
Matrix4,
type Material,
type Mesh,
type Object3D,
Vector3,
} from "three";
export const STATIC_INSTANCE_CELL_SIZE = 192;
export interface StaticSceneInstancingReport {
readonly batches: number;
readonly instances: number;
readonly removedMeshes: number;
}
function materialKey(material: Material | readonly Material[]): string {
return (Array.isArray(material) ? material : [material])
.map((entry) => entry.uuid)
.join(",");
}
function candidateKey(mesh: Mesh): string {
return [
mesh.geometry.uuid,
materialKey(mesh.material),
mesh.castShadow ? "cast" : "no-cast",
mesh.receiveShadow ? "receive" : "no-receive",
mesh.renderOrder,
mesh.layers.mask,
mesh.frustumCulled ? "culled" : "not-culled",
].join("|");
}
function candidateCell(mesh: Mesh, cellSize: number, center: Vector3): string {
if (!mesh.geometry.boundingSphere) mesh.geometry.computeBoundingSphere();
const geometryCenter = mesh.geometry.boundingSphere?.center;
if (geometryCenter) center.copy(geometryCenter);
else center.set(0, 0, 0);
center.applyMatrix4(mesh.matrixWorld);
return [
Math.floor(center.x / cellSize),
Math.floor(center.y / cellSize),
Math.floor(center.z / cellSize),
].join(":");
}
function isStaticInstancingCandidate(root: Object3D, node: Object3D): node is Mesh {
const mesh = node as Mesh;
return node !== root
&& mesh.isMesh === true
&& (mesh as Mesh & { readonly isInstancedMesh?: boolean }).isInstancedMesh !== true
&& (mesh as Mesh & { readonly isSkinnedMesh?: boolean }).isSkinnedMesh !== true
&& mesh.visible
&& mesh.children.length === 0
&& Object.keys(mesh.geometry.morphAttributes).length === 0
&& mesh.matrixWorld.determinant() > 0;
}
/**
* Flattens repeated static meshes into InstancedMesh batches once, immediately
* after a dungeon visual is cloned. This keeps legacy packs efficient without
* moving scene work into the render loop; newly optimized GLBs are left alone.
*/
export function instanceRepeatedStaticMeshes(
root: Object3D,
minimumInstances = 3,
cellSize = STATIC_INSTANCE_CELL_SIZE,
): StaticSceneInstancingReport {
root.updateWorldMatrix(true, true);
const safeCellSize = Number.isFinite(cellSize) && cellSize > 0
? cellSize
: STATIC_INSTANCE_CELL_SIZE;
const center = new Vector3();
const groups = new Map<string, Mesh[]>();
root.traverse((node) => {
if (!isStaticInstancingCandidate(root, node)) return;
const key = `${candidateKey(node)}|cell:${candidateCell(node, safeCellSize, center)}`;
const group = groups.get(key);
if (group) group.push(node);
else groups.set(key, [node]);
});
const inverseRootWorld = new Matrix4().copy(root.matrixWorld).invert();
let batches = 0;
let instances = 0;
let removedMeshes = 0;
for (const meshes of groups.values()) {
if (meshes.length < minimumInstances) continue;
const prototype = meshes[0];
const batch = new InstancedMesh(prototype.geometry, prototype.material, meshes.length);
batch.name = `${prototype.name || prototype.geometry.name || "mesh"}-instances`;
batch.castShadow = prototype.castShadow;
batch.receiveShadow = prototype.receiveShadow;
batch.renderOrder = prototype.renderOrder;
batch.layers.mask = prototype.layers.mask;
batch.frustumCulled = prototype.frustumCulled;
for (let index = 0; index < meshes.length; index += 1) {
const mesh = meshes[index];
batch.setMatrixAt(index, new Matrix4().multiplyMatrices(inverseRootWorld, mesh.matrixWorld));
mesh.removeFromParent();
}
batch.instanceMatrix.needsUpdate = true;
batch.computeBoundingBox();
batch.computeBoundingSphere();
root.add(batch);
batches += 1;
instances += meshes.length;
removedMeshes += meshes.length;
}
return { batches, instances, removedMeshes };
}