Release Healer Man 0.1.26

This commit is contained in:
phenom committed 2026-09-09 22:18:15 -04:00
1 parent e7cc588bfc
commit 147c2804d4
27 files changed
+1010 -55

No files matched your search

+29 -10
View File
@@ -25,9 +25,12 @@ import {
type Material,
type Mesh,
type Object3D,
type Skeleton,
type SkinnedMesh,
type Texture,
} from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { shareClonedSkeletons } from "./sharedSkeletons";
import type {
CharacterAppearance,
ClassId,
@@ -123,6 +126,7 @@ function configureMaterial(material: Material): void {
function cloneAvatarScene(root: Object3D): Object3D {
const scene = cloneSkeleton(root);
shareClonedSkeletons(scene);
scene.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
@@ -267,13 +271,17 @@ function LoadedCharacterModel({
useEffect(() => () => {
const ownedMaterials = new Set<Material>();
const ownedSkeletons = new Set<Skeleton>();
scene.traverse((node) => {
const skinned = node as SkinnedMesh;
if (skinned.isSkinnedMesh) ownedSkeletons.add(skinned.skeleton);
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((material) => ownedMaterials.add(material));
});
ownedMaterials.forEach((material) => material.dispose());
ownedSkeletons.forEach((skeleton) => skeleton.dispose());
}, [scene]);
useLayoutEffect(() => {
@@ -359,22 +367,33 @@ function LoadedCharacterModel({
?? gltf.animations.find((candidate) => candidate.name.toLowerCase().includes(normalized));
};
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
?? namedClip(entry.animations[motion])
?? namedClip(entry.animations.idle)
?? gltf.animations[0]
);
// The GLTF clip library and weapon stance change far less often than frames.
// Resolve locomotion once so five party members do not repeatedly parse and
// sort the same hundreds of animation names while standing or running.
const baseClips = useMemo(() => Object.fromEntries(
(["idle", "walk", "run"] as const).map((motion) => [
motion,
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
?? namedClip(entry.animations[motion])
?? namedClip(entry.animations.idle)
?? gltf.animations[0],
]),
) as Record<CharacterBaseMotion, AnimationClip | undefined>, [gltf.animations, entry.animations, weaponStance]);
const verticalClips = useMemo(() => ({
rising: [selectCharacterVerticalClip(gltf.animations, "rising", false), selectCharacterVerticalClip(gltf.animations, "rising", true)],
falling: [selectCharacterVerticalClip(gltf.animations, "falling", false), selectCharacterVerticalClip(gltf.animations, "falling", true)],
landing: [selectCharacterVerticalClip(gltf.animations, "landing", false), selectCharacterVerticalClip(gltf.animations, "landing", true)],
}), [gltf.animations]);
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
if (overrideAction.current) return;
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
? selectCharacterVerticalClip(gltf.animations, motion, moving)
? verticalClips[motion][moving ? 1 : 0]
: null;
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
const clip = verticalSelection?.clip ?? selectClip(
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
);
const clip = verticalSelection?.clip ?? baseClips[
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion
];
if (!clip) return;
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
const loop = verticalSelection?.loop ?? true;
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import { Bone, BufferGeometry, Float32BufferAttribute, Group, Matrix4, MeshBasicMaterial, Skeleton, SkinnedMesh, Vector3 } from "three";
import { clone } from "three/examples/jsm/utils/SkeletonUtils.js";
import { shareClonedSkeletons } from "./sharedSkeletons";
function skinnedMesh(bone: Bone, inverse = new Matrix4()): SkinnedMesh {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([1, 0, 0], 3));
geometry.setAttribute("skinIndex", new Float32BufferAttribute([0, 0, 0, 0], 4));
geometry.setAttribute("skinWeight", new Float32BufferAttribute([1, 0, 0, 0], 4));
const mesh = new SkinnedMesh(geometry, new MeshBasicMaterial());
mesh.bind(new Skeleton([bone], [inverse]), new Matrix4());
return mesh;
}
function character(): Group {
const root = new Group();
const bone = new Bone();
bone.name = "spine";
root.add(bone, skinnedMesh(bone), skinnedMesh(bone));
return root;
}
describe("shared skeleton palettes in character clones", () => {
it("preserves posed vertices and each mesh's bind transform while sharing uploads", () => {
const root = character();
const first = root.children[1] as SkinnedMesh;
const second = root.children[2] as SkinnedMesh;
second.bindMatrix.makeTranslation(0, 2, 0);
root.children[0].rotation.z = 0.5;
root.updateMatrixWorld(true);
const poseBefore = [first, second].map((mesh) => mesh.applyBoneTransform(0, new Vector3(1, 0, 0)));
const bindBefore = second.bindMatrix.clone();
expect(shareClonedSkeletons(root)).toBe(1);
expect(first.skeleton).toBe(second.skeleton);
expect(second.bindMatrix.equals(bindBefore)).toBe(true);
[first, second].forEach((mesh, index) => {
expect(mesh.applyBoneTransform(0, new Vector3(1, 0, 0)).distanceTo(poseBefore[index])).toBeLessThan(1e-9);
});
});
it("keeps separate cloned characters and the loader source independently animated", () => {
const source = character();
const first = clone(source);
const second = clone(source);
shareClonedSkeletons(first);
shareClonedSkeletons(second);
const firstMesh = first.children[1] as SkinnedMesh;
const secondMesh = second.children[1] as SkinnedMesh;
expect(firstMesh.skeleton).not.toBe(secondMesh.skeleton);
expect(firstMesh.skeleton.bones[0]).not.toBe(secondMesh.skeleton.bones[0]);
first.children[0].position.y = 3;
first.updateMatrixWorld(true);
second.updateMatrixWorld(true);
expect(firstMesh.applyBoneTransform(0, new Vector3(1, 0, 0)).y).toBe(3);
expect(secondMesh.applyBoneTransform(0, new Vector3(1, 0, 0)).y).toBe(0);
expect(source.children[0].position.y).toBe(0);
});
it("retains different inverse bind matrices and different bone objects", () => {
const root = character();
const otherBone = new Bone();
otherBone.name = "spine";
root.add(skinnedMesh(root.children[0] as Bone, new Matrix4().makeTranslation(1, 0, 0)));
root.add(otherBone, skinnedMesh(otherBone));
expect(shareClonedSkeletons(root)).toBe(1);
const meshes = root.children.filter((node) => (node as SkinnedMesh).isSkinnedMesh) as SkinnedMesh[];
expect(new Set(meshes.map((mesh) => mesh.skeleton)).size).toBe(3);
});
it("disposes redundant bone textures once and tolerates already shared skeletons", () => {
const root = character();
const first = root.children[1] as SkinnedMesh;
const second = root.children[2] as SkinnedMesh;
second.skeleton.computeBoneTexture();
const dispose = vi.spyOn(second.skeleton.boneTexture!, "dispose");
expect(shareClonedSkeletons(root)).toBe(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect(shareClonedSkeletons(root)).toBe(0);
expect(first.skeleton).toBe(second.skeleton);
expect(dispose).toHaveBeenCalledTimes(1);
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { Object3D, Skeleton, SkinnedMesh } from "three";
function samePalette(left: Skeleton, right: Skeleton): boolean {
return left.bones.length === right.bones.length
&& left.boneInverses.length === right.boneInverses.length
&& left.bones.every((bone, index) => bone === right.bones[index])
&& left.boneInverses.every((inverse, index) => inverse.equals(right.boneInverses[index]));
}
/** Share identical palettes within an owned clone, never across characters. */
export function shareClonedSkeletons(root: Object3D): number {
const palettes = new Map<Skeleton["bones"][number] | undefined, Skeleton[]>();
const redundant = new Set<Skeleton>();
root.traverse((node) => {
const mesh = node as SkinnedMesh;
if (!mesh.isSkinnedMesh) return;
const skeleton = mesh.skeleton;
const firstBone = skeleton.bones[0];
const candidates = palettes.get(firstBone) ?? [];
const shared = candidates.find((candidate) => samePalette(candidate, skeleton));
if (shared) {
if (shared !== skeleton) {
mesh.skeleton = shared;
redundant.add(skeleton);
}
} else {
candidates.push(skeleton);
palettes.set(firstBone, candidates);
}
});
for (const skeleton of redundant) skeleton.dispose();
return redundant.size;
}
+47
View File
@@ -0,0 +1,47 @@
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useRapier } from "@react-three/rapier";
import { useRef, useState, type ComponentPropsWithoutRef } from "react";
import { Group, Vector3 } from "three";
import { hasDungeonLabelLineOfSight } from "./dungeonLabelVisibility";
const VISIBILITY_INTERVAL = 0.1;
/** Keeps world labels behind solid geometry without raycasting visual GLBs. */
export function DungeonOccludedHtml({
position = [0, 0, 0],
style,
...props
}: Omit<ComponentPropsWithoutRef<typeof Html>, "position" | "occlude" | "onOcclude"> & {
readonly position?: readonly [number, number, number];
}) {
const { rapier, world } = useRapier();
const anchor = useRef<Group>(null);
const cameraPosition = useRef(new Vector3());
const labelPosition = useRef(new Vector3());
const projected = useRef(new Vector3());
const elapsed = useRef(VISIBILITY_INTERVAL);
const visibleRef = useRef(false);
const [visible, setVisible] = useState(false);
useFrame(({ camera }, delta) => {
elapsed.current += delta;
if (elapsed.current < VISIBILITY_INTERVAL || !anchor.current) return;
elapsed.current = 0;
anchor.current.getWorldPosition(labelPosition.current);
projected.current.copy(labelPosition.current).project(camera);
const point = projected.current;
const onScreen = Math.abs(point.x) <= 1 && Math.abs(point.y) <= 1 && Math.abs(point.z) <= 1;
camera.getWorldPosition(cameraPosition.current);
const next = onScreen && hasDungeonLabelLineOfSight(rapier, world, cameraPosition.current, labelPosition.current);
if (next === visibleRef.current) return;
visibleRef.current = next;
setVisible(next);
});
return (
<group ref={anchor} position={[...position]}>
<Html {...props} style={{ ...style, visibility: visible ? "visible" : "hidden" }} />
</group>
);
}
+17 -12
View File
@@ -1,13 +1,14 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Canvas } from "@react-three/fiber";
import { Canvas, useThree } from "@react-three/fiber";
import { Physics } from "@react-three/rapier";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { spawnMatchesDifficulty } from "../game/mobPopulation";
@@ -19,6 +20,7 @@ import {
} from "../game/inputManager";
import { useGameStore } from "../game/store";
import { DungeonEnvironment } from "./DungeonEnvironment";
import { dungeonRenderDistance } from "./dungeonRenderDistance";
import { MobPopulation } from "./MobPopulation";
import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
@@ -69,6 +71,17 @@ import {
suppressCompletedDungeonPopulation,
} from "../game/dungeonEncounterSpawns";
function DungeonCameraRange({ far }: { readonly far: number }) {
const camera = useThree((state) => state.camera);
useLayoutEffect(() => {
// Canvas preserves its camera across encounters; initial camera props alone
// do not update the clipping distance when switching dungeon packages.
camera.far = far;
camera.updateProjectionMatrix();
}, [camera, far]);
return null;
}
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
@@ -85,16 +98,7 @@ export function GameScene() {
const completedObjectiveIds = gameMode === "dungeon"
? activeDungeonCompletedObjectiveIds(activeDungeonId)
: [];
const cameraFar = Math.max(
950,
Math.min(
10_000,
Math.hypot(
dungeon.bounds.max[0] - dungeon.bounds.min[0],
dungeon.bounds.max[2] - dungeon.bounds.min[2],
) * 1.35,
),
);
const cameraFar = dungeonRenderDistance(dungeon);
const stagePresentation = resolveStagePresentation(gameMode, manastormEncounter, {
title: dungeon.title,
mapId: dungeon.mapId,
@@ -332,6 +336,7 @@ export function GameScene() {
powerPreference: safeGraphics ? "default" : "high-performance",
}}
>
<DungeonCameraRange far={cameraFar} />
{frameloop === "never" && targetFps !== null && (
<CappedFrameLoop
active={documentVisible || sharedOnline}
@@ -355,7 +360,7 @@ export function GameScene() {
/>
)}
{import.meta.env.DEV && <DevelopmentPerformanceProbe />}
<color attach="background" args={[dungeon.presentation.background]} />
<color attach="background" args={[dungeon.presentation.fog.color]} />
<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} />
<hemisphereLight args={[
+3 -2
View File
@@ -5,9 +5,10 @@ const source = readFileSync(new URL("./MobPopulation.tsx", import.meta.url), "ut
describe("mob nameplate presentation", () => {
it("occludes boss names against dungeon geometry", () => {
const nameplate = source.match(/\{showLabel && !dead \? \([\s\S]*?<\/Html>\s*\) : null\}/)?.[0] ?? "";
const nameplate = source.match(/\{showLabel && !dead \? \([\s\S]*?<\/DungeonOccludedHtml>\s*\) : null\}/)?.[0] ?? "";
expect(nameplate).toContain('occlude="raycast"');
expect(nameplate).toContain("<DungeonOccludedHtml");
expect(source).not.toContain('occlude="raycast"');
});
it("keeps boss names larger and differently colored", () => {
+3 -3
View File
@@ -78,6 +78,7 @@ import {
import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -1037,9 +1038,8 @@ function ProxyMob({
<MobHealthPlate instanceId={instanceId} height={labelHeight - (showLabel ? 0.38 : 0)} boss={boss} />
{showLabel && !dead ? (
<Html
<DungeonOccludedHtml
center
occlude="raycast"
position={[0, labelHeight, 0]}
distanceFactor={13}
style={boss ? bossLabelStyle : mobLabelStyle}
@@ -1048,7 +1048,7 @@ function ProxyMob({
<div>{definition.name}</div>
{boss ? <div style={{ fontSize: "0.78em", fontWeight: 600, opacity: 0.85 }}>{definition.title}</div> : null}
</div>
</Html>
</DungeonOccludedHtml>
) : null}
{lootable ? (
<Html
+40 -3
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import {
AdditiveBlending,
BufferAttribute,
@@ -17,6 +18,7 @@ import {
prepareCollision,
} from "./productionDungeonCollision";
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
import { WAILING_CAVERNS_DEFINITION } from "../game/dungeons/wailingCaverns";
const EMPTY_MATERIAL_ROLES = {
cutoutPatterns: [],
@@ -110,10 +112,45 @@ describe("streamed dungeon collision preparation", () => {
});
describe("streamed dungeon material preparation", () => {
it("renders the shipped Wailing Caverns walls as opaque depth-writing surfaces", () => {
const buffer = readFileSync(new URL(
"../assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb",
import.meta.url,
));
const gltf = JSON.parse(buffer.toString("utf8", 20, 20 + buffer.readUInt32LE(12))) as {
materials: { name: string; alphaMode?: string }[];
};
const walls = gltf.materials.filter((material) => /cavernwall|cavernfloor|cavernceiling/.test(material.name));
expect(walls.length).toBeGreaterThan(0);
for (const wall of walls) {
const source = new MeshStandardMaterial({ name: wall.name, transparent: wall.alphaMode === "BLEND" });
const prepared = prepareDungeonMaterial(
source,
WAILING_CAVERNS_DEFINITION.presentation.materials,
WAILING_CAVERNS_DEFINITION.environmentMode,
);
expect(prepared.transparent, wall.name).toBe(false);
expect(prepared.depthWrite, wall.name).toBe(true);
expect(prepared.opacity, wall.name).toBe(1);
}
});
it.each(["global-wmo", "adt-hybrid"] as const)("keeps authored WoW transparency in %s", (mode) => {
const roles = WAILING_CAVERNS_DEFINITION.presentation.materials;
const vine = prepareDungeonMaterial(new MeshStandardMaterial({ name: "mat_snktmpvine", transparent: true }), roles, mode);
const water = prepareDungeonMaterial(new MeshStandardMaterial({ name: "WailingWater", opacity: 0.37 }), roles, mode);
expect(vine.transparent).toBe(false);
expect(vine.alphaTest).toBe(0.5);
expect(vine.depthWrite).toBe(true);
expect(water.transparent).toBe(true);
expect(water.opacity).toBe(0.37);
expect(water.depthWrite).toBe(false);
});
it("preserves alpha blending declared by the source GLB", () => {
const source = new MeshStandardMaterial({ transparent: true, opacity: 0.2 });
const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES);
const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES, "runewaker-ros");
expect(prepared).not.toBe(source);
expect(prepared.transparent).toBe(true);
@@ -126,7 +163,7 @@ describe("streamed dungeon material preparation", () => {
it("preserves alpha masking declared by the source GLB", () => {
const source = new MeshStandardMaterial({ alphaTest: 0.37, transparent: false });
const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES);
const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES, "runewaker-ros");
expect(prepared.transparent).toBe(false);
expect(prepared.opacity).toBe(1);
@@ -141,7 +178,7 @@ describe("streamed dungeon material preparation", () => {
additiveNames: ["LIGHT-VOLUME"],
};
const prepared = prepareDungeonMaterial(source, roles);
const prepared = prepareDungeonMaterial(source, roles, "runewaker-ros");
expect(prepared.transparent).toBe(true);
expect(prepared.depthWrite).toBe(false);
+14 -8
View File
@@ -1,5 +1,6 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RigidBody, TrimeshCollider } from "@react-three/rapier";
import { useThree } from "@react-three/fiber";
import {
Mesh,
type InstancedMesh,
@@ -7,7 +8,7 @@ import {
type Object3D,
} from "three";
import type { ResolvedDungeonAssets } from "../game/dungeonAssets";
import type { DungeonDefinition, DungeonMaterialRoles } from "../game/dungeonTypes";
import type { DungeonDefinition } from "../game/dungeonTypes";
import { useGameStore } from "../game/store";
import {
collisionLineOfSightTrimeshes,
@@ -19,7 +20,8 @@ import {
DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS,
} from "./dungeonLineOfSight";
import { disposeObject3DResources } from "./threeResourceDisposal";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
import { createStaticVisibilityCells, registerStaticVisibility } from "./staticSceneVisibility";
import { useGameGLTF } from "./useGameGLTF";
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
import {
@@ -28,7 +30,7 @@ import {
advanceDungeonAssetCount,
} from "./dungeonLoadSequencing";
function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
function prepareVisual(root: Object3D, definition: DungeonDefinition) {
const clone = root.clone(true);
const clonedMaterials = new Map<string, Material>();
clone.traverse((node) => {
@@ -38,7 +40,7 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
const prepared = sources.map((source) => {
const cached = clonedMaterials.get(source.uuid);
if (cached) return cached;
const next = prepareDungeonMaterial(source, roles);
const next = prepareDungeonMaterial(source, definition.presentation.materials, definition.environmentMode);
clonedMaterials.set(source.uuid, next);
return next;
});
@@ -52,15 +54,19 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D {
}
});
instanceRepeatedStaticMeshes(clone);
return clone;
const cells = createStaticVisibilityCells(clone);
cacheStaticMatrices(clone);
return { visual: clone, cells };
}
function VisualChunk({ url, definition }: { readonly url: string; readonly definition: DungeonDefinition }) {
const gltf = useGameGLTF(url);
const visual = useMemo(
() => prepareVisual(gltf.scene, definition.presentation.materials),
[definition.presentation.materials, gltf.scene],
const scene = useThree((state) => state.scene);
const { visual, cells } = useMemo(
() => prepareVisual(gltf.scene, definition),
[definition, gltf.scene],
);
useEffect(() => registerStaticVisibility(scene, cells), [scene, cells]);
useEffect(() => () => { disposeObject3DResources(visual); }, [visual]);
const { position, rotation, scale } = definition.transform;
return <primitive object={visual} position={position} rotation={rotation} scale={scale} />;
+2 -1
View File
@@ -15,7 +15,8 @@ describe("world NPC prompts", () => {
const promptSource = readFileSync(new URL("./WorldNpcPrompt.tsx", import.meta.url), "utf8");
const naralexSource = readFileSync(new URL("./WailingNaralexEvent.tsx", import.meta.url), "utf8");
expect(promptSource).toContain('occlude="raycast"');
expect(promptSource).toContain("<DungeonOccludedHtml");
expect(promptSource).not.toContain('occlude="raycast"');
expect(naralexSource).toContain("<WorldNpcPrompt");
expect(naralexSource).not.toContain("<Html");
});
+3 -3
View File
@@ -1,9 +1,9 @@
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useRef, useState, type ReactNode } from "react";
import { Group, Vector3 } from "three";
import type { MobVector3 } from "../game/mobPopulation";
import { useGameStore } from "../game/store";
import { DungeonOccludedHtml } from "./DungeonOccludedHtml";
export const WORLD_NPC_PROMPT_MAX_DISTANCE = 22;
@@ -61,9 +61,9 @@ export function WorldNpcPrompt({
return (
<group ref={anchorRef} position={[position[0], position[1], position[2]]}>
{visible ? (
<Html center occlude="raycast" zIndexRange={[zIndexRange[0], zIndexRange[1]]}>
<DungeonOccludedHtml center zIndexRange={[zIndexRange[0], zIndexRange[1]]}>
{children}
</Html>
</DungeonOccludedHtml>
) : null}
</group>
);
+58
View File
@@ -0,0 +1,58 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { hasDungeonLabelLineOfSight } from "./dungeonLabelVisibility";
beforeAll(async () => { await RAPIER.init(); });
const CAMERA = { x: 0, y: 2, z: 0 };
const LABEL = { x: 4, y: 2, z: 0 };
describe("dungeon label occlusion", () => {
it("hides labels behind solid dungeon walls using the physical spatial index", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3)
.setTranslation(2, 2, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(false);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, { x: 1, y: 2, z: 0 })).toBe(true);
} finally { world.free(); }
});
it("occludes across floors and ceilings at the exact camera and label heights", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 5)
.setTranslation(0, 1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, { x: 0, y: 0, z: 0 })).toBe(false);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(true);
} finally { world.free(); }
});
it("ignores actors, trigger volumes, and the duplicate combat sight layer", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
const actor = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased());
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(1, 2, 0), actor);
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(2, 2, 0)
.setSensor(true).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(3, 2, 0)
.setSensor(true).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(true);
} finally { world.free(); }
});
it("does not query past a label or hide one at the camera position", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
try {
world.createCollider(RAPIER.ColliderDesc.cuboid(0.1, 3, 3).setTranslation(5, 2, 0));
world.step();
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, LABEL)).toBe(true);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, CAMERA)).toBe(true);
expect(hasDungeonLabelLineOfSight(RAPIER, world, CAMERA, { x: NaN, y: 2, z: 0 })).toBe(false);
} finally { world.free(); }
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { Ray, Vector, World } from "@dimforge/rapier3d-compat";
import { interactionGroups } from "@react-three/rapier";
interface LabelRapierApi {
readonly Ray: new (origin: Vector, direction: Vector) => Ray;
readonly QueryFilterFlags: {
readonly ONLY_FIXED: number;
readonly EXCLUDE_SENSORS: number;
};
}
const LABEL_OCCLUSION_GROUPS = interactionGroups(0, 0);
const LABEL_SURFACE_MARGIN = 0.08;
/** Uses Rapier's spatial index, bounded to the camera-to-label segment. */
export function hasDungeonLabelLineOfSight(
rapier: LabelRapierApi,
world: World,
camera: Vector,
label: Vector,
): boolean {
const dx = label.x - camera.x;
const dy = label.y - camera.y;
const dz = label.z - camera.z;
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= LABEL_SURFACE_MARGIN) return true;
const ray = new rapier.Ray(camera, { x: dx / distance, y: dy / distance, z: dz / distance });
return !world.castRay(
ray,
distance - LABEL_SURFACE_MARGIN,
true,
rapier.QueryFilterFlags.ONLY_FIXED | rapier.QueryFilterFlags.EXCLUDE_SENSORS,
LABEL_OCCLUSION_GROUPS,
);
}
+72
View File
@@ -0,0 +1,72 @@
import { closeSync, openSync, readSync } from "node:fs";
import path from "node:path";
import { MeshStandardMaterial } from "three";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
interface GltfMaterial {
name?: string;
alphaMode?: string;
alphaCutoff?: number;
pbrMetallicRoughness?: { baseColorFactor?: number[] };
}
function shippedMaterials(file: string): GltfMaterial[] {
const descriptor = openSync(file, "r");
try {
const header = Buffer.alloc(20);
expect(readSync(descriptor, header, 0, 20, 0)).toBe(20);
expect(header.toString("utf8", 0, 4)).toBe("glTF");
const json = Buffer.alloc(header.readUInt32LE(12));
expect(readSync(descriptor, json, 0, json.length, 20)).toBe(json.length);
return (JSON.parse(json.toString("utf8")) as { materials: GltfMaterial[] }).materials;
} finally {
closeSync(descriptor);
}
}
const wowDungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion !== "runewaker");
describe("shipped WoW dungeon material policies", () => {
it("covers all 54 campaign dungeons", () => {
expect(wowDungeons).toHaveLength(54);
});
it.each(wowDungeons)("retains authored opacity and effect roles in $title", (definition) => {
let opaqueCount = 0;
const roles = definition.presentation.materials;
const matches = (name: string, patterns: readonly string[], names: readonly string[]) => (
names.some((candidate) => candidate.toLowerCase() === name.toLowerCase())
|| patterns.some((pattern) => new RegExp(pattern, "i").test(name))
);
for (const chunk of definition.assets.visual) {
const file = chunk.url
? path.join(process.cwd(), "public", chunk.url)
: path.join(process.cwd(), "src/assets/game/dungeons", definition.id, chunk.fileName);
for (const raw of shippedMaterials(file)) {
const name = raw.name ?? "";
const source = new MeshStandardMaterial({
name,
transparent: raw.alphaMode === "BLEND",
alphaTest: raw.alphaMode === "MASK" ? raw.alphaCutoff ?? 0.5 : 0,
opacity: raw.pbrMetallicRoughness?.baseColorFactor?.[3] ?? 1,
});
const prepared = prepareDungeonMaterial(source, roles, definition.environmentMode);
const cutout = matches(name, roles.cutoutPatterns, roles.cutoutNames);
const blended = !cutout && (matches(name, roles.blendPatterns, roles.blendNames)
|| matches(name, roles.additivePatterns, roles.additiveNames));
expect(prepared.transparent, name).toBe(blended);
expect(prepared.depthWrite, name).toBe(!blended);
if (!cutout && !blended) {
opaqueCount += 1;
expect(prepared.opacity, name).toBe(1);
expect(prepared.alphaTest, name).toBe(0);
}
prepared.dispose();
source.dispose();
}
}
expect(opaqueCount).toBeGreaterThan(0);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { dungeonRenderDistance } from "./dungeonRenderDistance";
describe("dungeon render distance", () => {
it.each(DUNGEON_DEFINITIONS)("clips only beyond complete fog in $title", (dungeon) => {
const far = dungeonRenderDistance(dungeon);
expect(far).toBeGreaterThan(dungeon.presentation.fog.near);
expect(far).toBe(dungeon.presentation.fog.far);
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { DungeonDefinition } from "../game/dungeonTypes";
export function dungeonRenderDistance(dungeon: DungeonDefinition): number {
const boundsDistance = Math.max(950, Math.min(10_000, Math.hypot(
dungeon.bounds.max[0] - dungeon.bounds.min[0],
dungeon.bounds.max[2] - dungeon.bounds.min[2],
) * 1.35));
const fogFar = dungeon.presentation.fog.far;
// Linear fog has completely obscured surfaces at this camera-space depth.
// The background must use the fog color so clipping cannot reveal a horizon.
return Number.isFinite(fogFar) && fogFar > 1
? Math.min(boundsDistance, fogFar)
: boundsDistance;
}
+16
View File
@@ -17,6 +17,22 @@ function renderCount(targetFps: 30 | 60, sourceFps: number, durationMs = 1_000):
}
describe("frame-rate governor", () => {
it.each([60, 120])("keeps 60 FPS evenly spaced with rounded %i Hz callbacks", (sourceFps) => {
let state = createFrameRateGovernor(60);
const rendered: number[] = [];
for (let frame = 0; frame <= sourceFps * 5; frame += 1) {
const timestamp = Math.round(frame * 1_000 / sourceFps * 10) / 10;
const step = stepFrameRateGovernor(state, timestamp);
state = step.state;
if (step.shouldRender) rendered.push(timestamp);
}
expect(rendered).toHaveLength(301);
for (let index = 1; index < rendered.length; index += 1) {
expect(rendered[index] - rendered[index - 1]).toBeGreaterThan(16.5);
expect(rendered[index] - rendered[index - 1]).toBeLessThan(16.8);
}
});
it("holds capped modes near their target on high-refresh displays", () => {
expect(renderCount(30, 144)).toBeGreaterThanOrEqual(30);
expect(renderCount(30, 144)).toBeLessThanOrEqual(31);
+9 -2
View File
@@ -1,5 +1,10 @@
export type FrameRateGovernorMode = number | "display";
// rAF timestamps can fall just before a deadline due to rounding/jitter.
// Keep the fixed deadline sequence, but avoid skipping a whole refresh for a
// sub-millisecond difference (especially 60 FPS on a 120 Hz display).
const DEADLINE_TOLERANCE_MS = 0.5;
export interface FrameRateGovernorState {
readonly mode: FrameRateGovernorMode;
readonly lastTimestampMs: number | null;
@@ -66,7 +71,7 @@ export function stepFrameRateGovernor(
}
const deadlineMs = state.nextDeadlineMs ?? timestampMs;
if (timestampMs < deadlineMs) {
if (timestampMs + DEADLINE_TOLERANCE_MS < deadlineMs) {
const nextState: FrameRateGovernorState = {
...state,
lastTimestampMs: timestampMs,
@@ -75,7 +80,9 @@ export function stepFrameRateGovernor(
return { state: nextState, shouldRender: false, elapsedSeconds };
}
const missedIntervals = Math.floor(Math.max(0, timestampMs - deadlineMs) / intervalMs);
const missedIntervals = Math.floor(
Math.max(0, timestampMs + DEADLINE_TOLERANCE_MS - deadlineMs) / intervalMs,
);
const nextState: FrameRateGovernorState = {
...state,
lastTimestampMs: timestampMs,
+10 -4
View File
@@ -4,7 +4,7 @@ import {
NormalBlending,
type Material,
} from "three";
import type { DungeonMaterialRoles } from "../game/dungeonTypes";
import type { DungeonEnvironmentMode, DungeonMaterialRoles } from "../game/dungeonTypes";
function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean {
const lower = name.toLowerCase();
@@ -12,7 +12,11 @@ function matchesRole(name: string, patterns: readonly string[], names: readonly
|| patterns.some((pattern) => new RegExp(pattern, "i").test(name));
}
export function prepareDungeonMaterial(source: Material, roles: DungeonMaterialRoles): Material {
export function prepareDungeonMaterial(
source: Material,
roles: DungeonMaterialRoles,
environmentMode: DungeonEnvironmentMode,
): Material {
const material = source.clone();
const name = material.name;
material.side = DoubleSide;
@@ -31,17 +35,19 @@ export function prepareDungeonMaterial(source: Material, roles: DungeonMaterialR
material.transparent = true;
material.depthWrite = false;
material.blending = NormalBlending;
} else if (material.alphaTest > 0) {
} else if (environmentMode === "runewaker-ros" && material.alphaTest > 0) {
// Preserve a source GLB's MASK semantics when no presentation override exists.
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.blending = NormalBlending;
} else if (material.transparent) {
} else if (environmentMode === "runewaker-ros" && material.transparent) {
// Preserve a source GLB's BLEND semantics when no presentation override exists.
material.depthWrite = false;
material.blending = NormalBlending;
} else {
// Legacy WoW WMO exports label solid walls/floors BLEND. Their authored
// presentation roles, rather than the GLB alpha mode, define transparency.
material.transparent = false;
material.opacity = 1;
material.alphaTest = 0;
+49 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
BoxGeometry,
Group,
@@ -8,7 +8,7 @@ import {
MeshBasicMaterial,
Vector3,
} from "three";
import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "./staticSceneInstancing";
function batchIn(root: Group): InstancedMesh {
let result: InstancedMesh | null = null;
@@ -28,6 +28,33 @@ function batchesIn(root: Group): readonly InstancedMesh[] {
}
describe("static scene instancing", () => {
it("caches local transforms without freezing dungeon placement or imported matrices", () => {
const parent = new Group();
const root = new Group();
const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial());
mesh.matrixAutoUpdate = false;
mesh.matrix.makeTranslation(3, 0, 0);
parent.add(root);
root.add(mesh);
const updateChild = vi.spyOn(mesh, "updateMatrixWorld");
cacheStaticMatrices(root);
root.position.x = 10;
parent.position.x = 20;
parent.updateMatrixWorld(true);
expect(new Vector3().setFromMatrixPosition(mesh.matrixWorld).x).toBe(33);
parent.updateMatrixWorld(true);
expect(updateChild).toHaveBeenCalledTimes(1);
root.position.x = 15;
parent.updateMatrixWorld(true);
expect(new Vector3().setFromMatrixPosition(mesh.matrixWorld).x).toBe(38);
parent.position.x = 30;
parent.updateMatrixWorld(true);
expect(new Vector3().setFromMatrixPosition(mesh.matrixWorld).x).toBe(48);
expect(updateChild).toHaveBeenCalledTimes(3);
expect(root.matrixAutoUpdate).toBe(true);
expect(mesh.matrixWorldAutoUpdate).toBe(true);
});
it("batches repeated meshes while retaining their world transforms", () => {
const root = new Group();
root.position.set(10, 0, 0);
@@ -72,6 +99,26 @@ describe("static scene instancing", () => {
expect(root.children).toContain(existing);
});
it("removes containers emptied by batching while preserving authored empty nodes", () => {
const root = new Group();
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial();
const authoredMarker = new Group();
authoredMarker.name = "authored-marker";
root.add(authoredMarker);
for (let index = 0; index < 3; index += 1) {
const outer = new Group();
const inner = new Group();
outer.add(inner);
inner.add(new Mesh(geometry, material));
root.add(outer);
}
instanceRepeatedStaticMeshes(root);
expect(root.children).toHaveLength(2);
expect(root.children).toContain(authoredMarker);
expect(batchIn(root).count).toBe(3);
});
it("does not combine meshes whose render state differs", () => {
const root = new Group();
const geometry = new BoxGeometry();
+40
View File
@@ -9,6 +9,32 @@ import {
export const STATIC_INSTANCE_CELL_SIZE = 192;
/** Cache an immutable visual subtree while still following its root/parent. */
export function cacheStaticMatrices(root: Object3D): void {
root.traverse((node) => {
if (node === root) return;
if (node.matrixAutoUpdate) node.updateMatrix();
node.matrixAutoUpdate = false;
});
const updateMatrixWorld = root.updateMatrixWorld;
const previousLocal = new Matrix4();
const previousParent = new Matrix4();
const identity = new Matrix4();
let initialized = false;
root.updateMatrixWorld = () => {
if (root.matrixAutoUpdate) root.updateMatrix();
const parentWorld = root.parent?.matrixWorld ?? identity;
if (initialized && previousLocal.equals(root.matrix) && previousParent.equals(parentWorld)) {
root.matrixWorldNeedsUpdate = false;
return;
}
updateMatrixWorld.call(root, true);
previousLocal.copy(root.matrix);
previousParent.copy(parentWorld);
initialized = true;
};
}
export interface StaticSceneInstancingReport {
readonly batches: number;
readonly instances: number;
@@ -86,6 +112,7 @@ export function instanceRepeatedStaticMeshes(
let batches = 0;
let instances = 0;
let removedMeshes = 0;
const emptiedParents = new Set<Object3D>();
for (const meshes of groups.values()) {
if (meshes.length < minimumInstances) continue;
@@ -101,6 +128,7 @@ export function instanceRepeatedStaticMeshes(
for (let index = 0; index < meshes.length; index += 1) {
const mesh = meshes[index];
batch.setMatrixAt(index, new Matrix4().multiplyMatrices(inverseRootWorld, mesh.matrixWorld));
if (mesh.parent) emptiedParents.add(mesh.parent);
mesh.removeFromParent();
}
batch.instanceMatrix.needsUpdate = true;
@@ -112,5 +140,17 @@ export function instanceRepeatedStaticMeshes(
removedMeshes += meshes.length;
}
// Imported prop containers can outnumber their meshes after batching. Remove
// only containers emptied by this operation, preserving authored scene nodes.
for (const candidate of emptiedParents) {
let empty: Object3D | null = candidate;
while (empty && empty !== root && empty.children.length === 0
&& (empty.type === "Group" || empty.type === "Object3D")) {
const parent: Object3D | null = empty.parent;
empty.removeFromParent();
empty = parent;
}
}
return { batches, instances, removedMeshes };
}
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
import {
BoxGeometry,
BufferGeometry,
Frustum,
Group,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
PerspectiveCamera,
PointLight,
Scene,
SkinnedMesh,
Vector3,
type WebGLRenderer,
} from "three";
import { cacheStaticMatrices } from "./staticSceneInstancing";
import { createStaticVisibilityCells, registerStaticVisibility, updateStaticVisibility } from "./staticSceneVisibility";
function cube(x: number, z: number, width = 1): Mesh {
const mesh = new Mesh(new BoxGeometry(width, 1, width), new MeshBasicMaterial());
mesh.position.set(x, 0, z);
return mesh;
}
function cameraFrustum(camera: PerspectiveCamera): Frustum {
camera.updateMatrixWorld();
return new Frustum().setFromProjectionMatrix(
new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse),
);
}
describe("static dungeon visibility cells", () => {
it("preserves hierarchy transforms, render ordering, and authored visibility", () => {
const root = new Group();
root.position.set(7, 3, -2);
root.rotation.y = 0.4;
root.scale.set(2, 1, -3);
root.renderOrder = 4;
const nested = new Group();
nested.position.set(2, 0, 1);
nested.add(cube(1, -8));
const hidden = cube(0, -5);
hidden.visible = false;
root.add(nested, hidden);
root.updateMatrixWorld(true);
const mesh = nested.children[0];
const before = mesh.matrixWorld.clone();
const cells = createStaticVisibilityCells(root, 64, 1);
cacheStaticMatrices(root);
root.updateMatrixWorld(true);
expect(mesh.matrixWorld.equals(before)).toBe(true);
expect(hidden.visible).toBe(false);
expect(cells.every((cell) => cell.group.renderOrder === 4)).toBe(true);
expect(root.getObjectsByProperty("isMesh", true)).toHaveLength(2);
});
it("keeps walls crossing the view and distant instances visible regardless of their origin", () => {
const root = new Group();
const front = cube(0, -10);
const behind = cube(0, 20);
const crossingWall = cube(30, -10, 60);
const instances = new InstancedMesh(new BoxGeometry(), new MeshBasicMaterial(), 1);
instances.position.z = 150;
instances.setMatrixAt(0, new Matrix4().makeTranslation(0, 0, -160));
root.add(front, behind, crossingWall, instances);
const cells = createStaticVisibilityCells(root, 4, 1);
root.updateMatrixWorld(true);
const camera = new PerspectiveCamera(60, 1, 0.1, 100);
updateStaticVisibility(cells, cameraFrustum(camera));
expect(front.parent?.visible).toBe(true);
expect(behind.parent?.visible).toBe(false);
expect(crossingWall.parent?.visible).toBe(true);
expect(instances.parent?.visible).toBe(true);
camera.rotation.y = Math.PI;
updateStaticVisibility(cells, cameraFrustum(camera));
expect(behind.parent?.visible).toBe(true);
});
it("refreshes bounds after mirrored, scaled parent placement changes", () => {
const parent = new Group();
const root = new Group();
const mesh = cube(0, -10);
root.add(mesh);
parent.add(root);
const cells = createStaticVisibilityCells(root, 64, 1);
cacheStaticMatrices(root);
parent.updateMatrixWorld(true);
const frustum = cameraFrustum(new PerspectiveCamera(60, 1, 0.1, 100));
updateStaticVisibility(cells, frustum);
expect(mesh.parent?.visible).toBe(true);
parent.scale.set(2, 1, -1);
parent.updateMatrixWorld(true);
updateStaticVisibility(cells, frustum);
expect(mesh.parent?.visible).toBe(false);
parent.position.z = -30;
parent.updateMatrixWorld(true);
updateStaticVisibility(cells, frustum);
expect(mesh.parent?.visible).toBe(true);
const position = new Vector3().setFromMatrixPosition(mesh.matrixWorld);
expect(cells[0].worldBounds.containsPoint(position)).toBe(true);
});
it("leaves lights, animated meshes, and meshes that disable culling alone", () => {
const root = new Group();
const light = new PointLight();
const skin = new SkinnedMesh(new BoxGeometry(), new MeshBasicMaterial());
const uncullable = cube(0, -10);
uncullable.frustumCulled = false;
const plain = cube(0, -10);
root.add(light, skin, uncullable, plain);
expect(createStaticVisibilityCells(root, 64, 1)).toHaveLength(1);
expect(light.parent).toBe(root);
expect(skin.parent).toBe(root);
expect(uncullable.parent).toBe(root);
expect(plain.parent).not.toBe(root);
});
it("shares a render hook across streamed chunks and restores it after cleanup", () => {
const scene = new Scene();
const previous = vi.fn();
scene.onBeforeRender = previous;
const roots = [new Group(), new Group()];
const sets = roots.map((root) => {
root.add(cube(0, 20));
scene.add(root);
return createStaticVisibilityCells(root, 64, 1);
});
scene.updateMatrixWorld(true);
const first = registerStaticVisibility(scene, sets[0]);
const callback = scene.onBeforeRender;
const second = registerStaticVisibility(scene, sets[1]);
expect(scene.onBeforeRender).toBe(callback);
const camera = new PerspectiveCamera(60, 1, 0.1, 100);
camera.updateMatrixWorld();
scene.onBeforeRender({} as WebGLRenderer, scene, camera, new BufferGeometry(), new MeshBasicMaterial(), new Group());
expect(previous).toHaveBeenCalledTimes(1);
expect(sets.every((cells) => !cells[0].group.visible)).toBe(true);
first();
expect(sets[0][0].group.visible).toBe(true);
expect(scene.onBeforeRender).toBe(callback);
second();
expect(scene.onBeforeRender).toBe(previous);
});
});
+132
View File
@@ -0,0 +1,132 @@
import {
Box3,
Frustum,
Group,
Matrix4,
Sphere,
Vector3,
type Mesh,
type Object3D,
type Scene,
} from "three";
export interface StaticVisibilityCell {
readonly group: Group;
readonly bounds: Box3;
readonly worldBounds: Box3;
readonly worldSphere: Sphere;
readonly previousWorld: Matrix4;
initialized: boolean;
}
function canCullSubtree(root: Object3D): boolean {
let safe = true;
root.traverse((node) => {
const mesh = node as Mesh & { isSkinnedMesh?: boolean };
if (mesh.isMesh) {
if (!mesh.frustumCulled || mesh.isSkinnedMesh || Object.keys(mesh.geometry.morphAttributes).length) safe = false;
} else if (node.type !== "Group" && node.type !== "Object3D") {
// Do not hide lights, cameras, sprites, or other renderable object types.
safe = false;
}
});
return safe;
}
/** Group static siblings under conservative bounds without merging geometry. */
export function createStaticVisibilityCells(
root: Object3D,
cellSize = 64,
minimumChildren = 24,
): readonly StaticVisibilityCell[] {
let container = root;
while (container.children.length === 1 && container.children[0].type === "Group") {
container = container.children[0];
}
if (container.children.length < minimumChildren) return [];
container.updateWorldMatrix(true, true);
const inverseWorld = container.matrixWorld.clone().invert();
const safeCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : 64;
const center = new Vector3();
const cells = new Map<string, StaticVisibilityCell>();
for (const child of [...container.children]) {
if (!canCullSubtree(child)) continue;
const bounds = new Box3().setFromObject(child).applyMatrix4(inverseWorld);
if (bounds.isEmpty() || ![...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite)) continue;
bounds.getCenter(center);
const key = [center.x, center.y, center.z].map((value) => Math.floor(value / safeCellSize)).join(":");
let cell = cells.get(key);
if (!cell) {
const group = new Group();
group.name = `static-visibility:${key}`;
group.renderOrder = container.renderOrder;
cell = {
group,
bounds: new Box3(),
worldBounds: new Box3(),
worldSphere: new Sphere(),
previousWorld: new Matrix4(),
initialized: false,
};
cells.set(key, cell);
container.add(group);
}
// The new group has an identity local transform under the same parent.
cell.group.add(child);
cell.bounds.union(bounds);
}
return [...cells.values()];
}
export function updateStaticVisibility(cells: readonly StaticVisibilityCell[], frustum: Frustum): void {
for (const cell of cells) {
if (!cell.initialized || !cell.previousWorld.equals(cell.group.matrixWorld)) {
// Transform the entire box before making a sphere, retaining conservative
// bounds with rotated, mirrored, or nonuniformly scaled dungeon roots.
cell.worldBounds.copy(cell.bounds).applyMatrix4(cell.group.matrixWorld);
cell.worldBounds.getBoundingSphere(cell.worldSphere);
cell.previousWorld.copy(cell.group.matrixWorld);
cell.initialized = true;
}
cell.group.visible = frustum.intersectsSphere(cell.worldSphere);
}
}
interface SceneVisibilityRegistration {
readonly sets: Set<readonly StaticVisibilityCell[]>;
readonly previous: Scene["onBeforeRender"];
readonly callback: Scene["onBeforeRender"];
}
const registrations = new WeakMap<Scene, SceneVisibilityRegistration>();
/** Run after current camera/world matrices are updated, before mesh traversal. */
export function registerStaticVisibility(scene: Scene, cells: readonly StaticVisibilityCell[]): () => void {
if (!cells.length) return () => undefined;
let registration = registrations.get(scene);
if (!registration) {
const sets = new Set<readonly StaticVisibilityCell[]>();
const previous = scene.onBeforeRender;
const frustum = new Frustum();
const projectionView = new Matrix4();
const callback: Scene["onBeforeRender"] = (...args) => {
previous.apply(scene, args);
const camera = args[2];
projectionView.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
frustum.setFromProjectionMatrix(projectionView, camera.coordinateSystem, camera.reversedDepth);
for (const set of sets) updateStaticVisibility(set, frustum);
};
registration = { sets, previous, callback };
registrations.set(scene, registration);
scene.onBeforeRender = callback;
}
const active = registration;
active.sets.add(cells);
return () => {
active.sets.delete(cells);
for (const cell of cells) cell.group.visible = true;
if (active.sets.size) return;
if (scene.onBeforeRender === active.callback) scene.onBeforeRender = active.previous;
registrations.delete(scene);
};
}