Release Healer Man 0.1.7
This commit is contained in:
1 parent
b12fb84859
commit
eca6b5db9b
99 files changed
+1931
-150
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function dprForPerformanceFactor(factor: number): number {
|
||||
return 1 + Math.max(0, Math.min(1, factor)) * 0.5;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user