Release Healer Man 0.1.26
This commit is contained in:
1 parent
e7cc588bfc
commit
147c2804d4
27 files changed
+1010
-55
No files matched your search
@@ -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.
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.25",
|
||||
"versionCode": 1025
|
||||
"versionName": "0.1.26",
|
||||
"versionCode": 1026
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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={[
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user