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

+138
View File
@@ -1854,3 +1854,141 @@ Vite's existing large-chunk advisory remains informational.
- Verification passed all 1,133 Vitest cases across 208 files. The production
build completed with 974 modules transformed; arena, KTX2, and environment-
performance audits remained green, and the release-candidate check passed.
# 2026-09-09 — dungeon wall and nameplate performance regressions
- The 0.1.24 material fallback honored BLEND tags on legacy WoW WMO walls,
floors, and ceilings, disabling their depth writes. WoW environments now use
their authored material roles again; RuneWaker environments retain their
source MASK/BLEND semantics. Shipped Wailing Caverns wall materials, vines,
and water are covered by regression tests.
- The 0.1.25 boss-name occlusion scanned the complete rendered scene with
Three.js raycasts. A live Kalin's Shrine CPU profile was dominated by triangle
intersection and vertex decoding. Boss names and nearby NPC prompts now use
bounded camera-to-label queries against Rapier's existing physical spatial
index, at most ten times per second, skipping offscreen labels. Walls, floors,
and ceilings occlude labels; actors and trigger sensors do not.
- In an isolated offline Edge playtest at 1440 x 900 on an AMD RX 9070 XT,
Kalin's Shrine improved from about 10 FPS (98.5 ms/frame, 167 draw calls,
DPR 1.125) to 55.5-56.1 FPS (17.8-18.0 ms/frame, 168-170 draw calls,
DPR 1.5). This is a representative entrance/combat comparison, not an audit
of all thirty RoM dungeons. The environment alone took about 0.8 ms CPU and
0.5 ms GPU with 14 draw calls.
- Wailing Caverns screenshots show continuous opaque cavern walls and ceilings.
A controlled toggle of the old material flags reproduced the missing wall
sections at the same camera position; restoring depth-writing materials fixed
them. Its full scene ran at about 57 FPS. No uncaught client errors appeared.
- Evidence and repeatable local benchmark helpers are under
`playtest-artifacts/2026-09-09-dungeon-rendering/`: `kalin-before.cpuprofile`,
`kalin-after.cpuprofile`, `kalin-after-metrics.json`, `kalin-before.png`,
`kalin-after.png`, `wailing-regression.png`, and `wailing-restored.png`.
- All 26 focused material, label occlusion, combat sight, aggro visibility,
instancing, and NPC-prompt tests passed. Physical-occlusion tests exercise
actual Rapier worlds rather than just source-code assertions.
# 2026-09-09 — campaign rendering audit and Thor performance preparation
- Audited the actual visual GLB headers for all 54 WoW campaign dungeons:
9,402 materials, including 3,586 legacy BLEND materials restored to opaque
rendering by the shared policy. Every WoW package contained affected
materials. A permanent shipped-asset test covers all 54 definitions and
retains explicit cutout, blend, and additive roles. This is material-policy
coverage, not a claim that every room has been visually traversed.
- Live screenshots checked Wailing Caverns, Shadowfang Keep, Utgarde Pinnacle,
the Nexus, and Tinkertech Showdown. The sampled walls, floors, stairs, and
ceilings remain solid; authored lighting effects remain transparent. Kalin's
Shrine and Forsaken Abbey provided RoM rendering checks. Roster and in-dungeon
screenshots show character bodies and equipped weapon silhouettes after the
shared character animation change.
- A controlled Utgarde Pinnacle entrance test found about 1,739 draw calls,
1.95 million triangles, and 30.5 FPS on the desktop GPU. Clipping at the
existing full-fog distance reduced this to about 209 calls, 567,000 triangles,
and 60 FPS at the same viewpoint. The camera now follows each dungeon's fog
distance, including transitions that retain the existing Canvas/camera.
The background uses the fog color to avoid a clipping seam at the horizon.
All 84 dungeon definitions have render-distance coverage.
- Static dungeon visual transforms are cached until the root or its parent's
world transform changes. Tests verify imported matrices, repeated unchanged
frames, root movement, and parent movement. Characters, moving world objects,
and physics remain outside this immutable visual cache.
The final 63-second Utgarde sample held 60 FPS with 213 draw calls:
main-thread work averaged 9.2 ms/frame, renderer CPU averaged 7.5 ms
(p95 8.3 ms), and GPU averaged 2.7 ms (p95 4.1 ms). Movement and jump input
continued to animate the player while the environment remained stationary.
- Character locomotion and jump clip selections are cached by animation
library and weapon stance. The final Kalin CPU profile contains no sampled
per-frame base-clip selection/name parsing. Frame-cap deadlines now tolerate
sub-millisecond timestamp rounding without drifting or emitting catch-up
frames; tests cover 60 FPS on rounded 60 Hz and 120 Hz callbacks.
- A 31.5-second Kalin sample with a connected browser companion display held
60 FPS: main-thread work averaged 8.7 ms/frame, renderer CPU averaged 5.4 ms
(p95 5.9 ms), and GPU averaged 2.4 ms (p95 3.4 ms). The companion used about
5.1 ms of main-thread time per second. Recent ability-cost snapshot work was
not a dominant hotspot in this sample. Startup warmup is bounded to loading;
companion snapshots are already limited to ten per second during gameplay.
- These are isolated offline Edge desktop samples on an AMD RX 9070 XT at
1920 x 1080 CSS pixels, DPR 1.5 (2880 x 1620 renderbuffer). Party composition
can change between dungeon entries. They do not certify Android performance
or sustained busy-combat headroom. ADB listed no connected Android device.
- Thor acceptance should use the actual model, both native displays, target
resolution, and a sustained 20-minute traversal/combat run after warmup.
Provisional headroom target: p95 main-thread and GPU work each at or below
12 ms, with p99 presented frame intervals within the 16.7 ms 60-FPS budget.
Test large pulls, boss effects, menus, and repeated dungeon transitions;
record thermal behavior and frame-time spikes, not only average FPS.
The environment asset audit still has 60 grandfathered budget exceptions;
its green result means no new asset regression, not a Thor FPS guarantee.
- Evidence is in `playtest-artifacts/2026-09-09-thor-audit/`, including roster,
dungeon, and companion screenshots, CPU profiles, and CPU/GPU JSON samples.
The full material report is
`playtest-artifacts/2026-09-09-dungeon-rendering/wow-material-audit.json`.
- Validation passed 227 focused tests covering avatar rendering and animation,
camera distances, material policies, frame pacing, static transforms, label
visibility, and companion snapshots. The final static-cache change also
passed the 105-test scene subset. The production build and release-candidate
check passed; the existing large-bundle advisory remains.
# 2026-09-09 — additional CPU headroom from culling and shared skeletons
- Prepared dungeon visuals now group static siblings into conservative spatial
bounds. One scene hook checks these bounds after the current camera and world
matrices update, before Three.js traverses individual meshes. Bounds include
complete wall geometry and all instances; lights, animated meshes, and meshes
explicitly disabling frustum culling remain outside these groups. The hook
supports streamed chunks and restores its predecessor after cleanup.
- At the same Utgarde Pinnacle entrance, only 16 of 1,383 spatial groups were
visible. The renderer examined 108 static meshes instead of 7,002, retaining
the same 206 draw calls and 566,913 rendered triangles. Controlled culling
toggles produced byte-identical canvas PNGs across eight camera headings.
- In the same running scene, spatial grouping reduced average main-thread work
from 9.45 to 6.83 ms/frame, and renderer CPU from 7.81 to 5.31 ms/frame.
Removing containers emptied by static instancing reduced the final fresh-run
averages further to 6.49 ms main-thread and 4.85 ms renderer CPU (p95 5.2 ms).
The final sample lasted 87.8 seconds and held 60 FPS. Authored empty scene
nodes are preserved; only containers emptied by our batching are removed.
- The shared character renderer still uses SkeletonUtils.clone, then reuses
equivalent skeleton palettes within that owned clone. Bone identities, order,
and inverse bind matrices must all match; per-mesh bind transforms remain
unchanged. Different characters retain independent bones and animations.
Owned bone textures are now explicitly disposed during character teardown.
- A live character audit found 66 skeleton objects for one player palette and
256 objects for four party palettes. A Wailing Caverns toggle removed 309
duplicate skeletons and produced an identical rendered image. Tests verify
posed vertices, differing bind transforms, incompatible palettes, independent
cloned characters, and redundant texture disposal.
In the same Wailing scene, main-thread work fell from 7.40 to 6.70 ms/frame;
renderer CPU fell from 5.51 to 4.86 ms/frame, and allocated GPU textures fell
from 292 to 217. This measures palette sharing after spatial culling was
already enabled. Both samples held 60 FPS.
- The final built runtime showed a complete roster body and staff with one
skeleton for 66 skinned meshes. Kalin's Shrine showed one player palette and
four independent party palettes; walking retained the player body and weapon
silhouette. Wailing Caverns and Utgarde screenshots retained solid dungeon
surfaces. There were no uncaught client errors during these playtests.
- All 210 targeted avatar, visibility, instancing, material, camera-distance,
and label tests passed. The production build and release-candidate check
passed. The existing large-bundle advisory remains.
- Evidence is under `playtest-artifacts/2026-09-09-static-culling/`: paired PNGs,
eight-heading pixel comparisons, profiles, before/after timing JSON, and
screenshots. Measurements use the same desktop Edge/RX 9070 XT setup as the
preceding audit. These CPU improvements do not certify Thor performance.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.25",
"version": "0.1.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.25",
"version": "0.1.26",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.25",
"version": "0.1.26",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.25",
"versionCode": 1025
"versionName": "0.1.26",
"versionCode": 1026
}
+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);
};
}