From 147c2804d47aa8807bbe0502956a781362b12974 Mon Sep 17 00:00:00 2001 From: phenom Date: Wed, 9 Sep 2026 22:18:15 -0400 Subject: [PATCH] Release Healer Man 0.1.26 --- PLAYTEST_NOTES.md | 138 +++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- src/avatar/CharacterModel.tsx | 39 ++++-- src/avatar/sharedSkeletons.test.ts | 83 ++++++++++++ src/avatar/sharedSkeletons.ts | 33 +++++ src/scene/DungeonOccludedHtml.tsx | 47 +++++++ src/scene/GameScene.tsx | 29 ++-- src/scene/MobNameplatePresentation.test.ts | 5 +- src/scene/MobPopulation.tsx | 6 +- src/scene/ProductionDungeon.test.ts | 43 +++++- src/scene/ProductionDungeon.tsx | 22 ++-- src/scene/WorldNpcPrompt.test.ts | 3 +- src/scene/WorldNpcPrompt.tsx | 6 +- src/scene/dungeonLabelVisibility.test.ts | 58 ++++++++ src/scene/dungeonLabelVisibility.ts | 36 +++++ src/scene/dungeonMaterialAudit.test.ts | 72 ++++++++++ src/scene/dungeonRenderDistance.test.ts | 11 ++ src/scene/dungeonRenderDistance.ts | 14 ++ src/scene/frameRateGovernor.test.ts | 16 +++ src/scene/frameRateGovernor.ts | 11 +- src/scene/productionDungeonMaterials.ts | 14 +- src/scene/staticSceneInstancing.test.ts | 51 ++++++- src/scene/staticSceneInstancing.ts | 40 ++++++ src/scene/staticSceneVisibility.test.ts | 146 +++++++++++++++++++++ src/scene/staticSceneVisibility.ts | 132 +++++++++++++++++++ 27 files changed, 1010 insertions(+), 55 deletions(-) create mode 100644 src/avatar/sharedSkeletons.test.ts create mode 100644 src/avatar/sharedSkeletons.ts create mode 100644 src/scene/DungeonOccludedHtml.tsx create mode 100644 src/scene/dungeonLabelVisibility.test.ts create mode 100644 src/scene/dungeonLabelVisibility.ts create mode 100644 src/scene/dungeonMaterialAudit.test.ts create mode 100644 src/scene/dungeonRenderDistance.test.ts create mode 100644 src/scene/dungeonRenderDistance.ts create mode 100644 src/scene/staticSceneVisibility.test.ts create mode 100644 src/scene/staticSceneVisibility.ts diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index e7ed66e2..0a51a10c 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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. diff --git a/package-lock.json b/package-lock.json index e8881b24..a7133fa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e006c3da..664fd217 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release-version.json b/release-version.json index d03b82c2..76128d0f 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.25", - "versionCode": 1025 + "versionName": "0.1.26", + "versionCode": 1026 } diff --git a/src/avatar/CharacterModel.tsx b/src/avatar/CharacterModel.tsx index 6df52d4d..abd6e27b 100644 --- a/src/avatar/CharacterModel.tsx +++ b/src/avatar/CharacterModel.tsx @@ -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(); + const ownedSkeletons = new Set(); 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, [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; diff --git a/src/avatar/sharedSkeletons.test.ts b/src/avatar/sharedSkeletons.test.ts new file mode 100644 index 00000000..b4f698d1 --- /dev/null +++ b/src/avatar/sharedSkeletons.test.ts @@ -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); + }); +}); diff --git a/src/avatar/sharedSkeletons.ts b/src/avatar/sharedSkeletons.ts new file mode 100644 index 00000000..758b7d64 --- /dev/null +++ b/src/avatar/sharedSkeletons.ts @@ -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(); + const redundant = new Set(); + 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; +} diff --git a/src/scene/DungeonOccludedHtml.tsx b/src/scene/DungeonOccludedHtml.tsx new file mode 100644 index 00000000..54196a45 --- /dev/null +++ b/src/scene/DungeonOccludedHtml.tsx @@ -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, "position" | "occlude" | "onOcclude"> & { + readonly position?: readonly [number, number, number]; +}) { + const { rapier, world } = useRapier(); + const anchor = useRef(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 ( + + + + ); +} diff --git a/src/scene/GameScene.tsx b/src/scene/GameScene.tsx index 8ca52a73..c844644b 100644 --- a/src/scene/GameScene.tsx +++ b/src/scene/GameScene.tsx @@ -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", }} > + {frameloop === "never" && targetFps !== null && ( )} {import.meta.env.DEV && } - + { 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(" { diff --git a/src/scene/MobPopulation.tsx b/src/scene/MobPopulation.tsx index 4340fa3e..53d23d60 100644 --- a/src/scene/MobPopulation.tsx +++ b/src/scene/MobPopulation.tsx @@ -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 = new Set([ "stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse", @@ -1037,9 +1038,8 @@ function ProxyMob({ {showLabel && !dead ? ( - {definition.name} {boss ?
{definition.title}
: null} - + ) : null} {lootable ? ( { }); 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); diff --git a/src/scene/ProductionDungeon.tsx b/src/scene/ProductionDungeon.tsx index be8185e5..b03adf6d 100644 --- a/src/scene/ProductionDungeon.tsx +++ b/src/scene/ProductionDungeon.tsx @@ -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(); 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 ; diff --git a/src/scene/WorldNpcPrompt.test.ts b/src/scene/WorldNpcPrompt.test.ts index fcc6e315..e2526257 100644 --- a/src/scene/WorldNpcPrompt.test.ts +++ b/src/scene/WorldNpcPrompt.test.ts @@ -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(" {visible ? ( - + {children} - + ) : null}
); diff --git a/src/scene/dungeonLabelVisibility.test.ts b/src/scene/dungeonLabelVisibility.test.ts new file mode 100644 index 00000000..2bb0a72a --- /dev/null +++ b/src/scene/dungeonLabelVisibility.test.ts @@ -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(); } + }); +}); diff --git a/src/scene/dungeonLabelVisibility.ts b/src/scene/dungeonLabelVisibility.ts new file mode 100644 index 00000000..fe5e618b --- /dev/null +++ b/src/scene/dungeonLabelVisibility.ts @@ -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, + ); +} diff --git a/src/scene/dungeonMaterialAudit.test.ts b/src/scene/dungeonMaterialAudit.test.ts new file mode 100644 index 00000000..101808e4 --- /dev/null +++ b/src/scene/dungeonMaterialAudit.test.ts @@ -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); + }); +}); diff --git a/src/scene/dungeonRenderDistance.test.ts b/src/scene/dungeonRenderDistance.test.ts new file mode 100644 index 00000000..eacdfc38 --- /dev/null +++ b/src/scene/dungeonRenderDistance.test.ts @@ -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); + }); +}); diff --git a/src/scene/dungeonRenderDistance.ts b/src/scene/dungeonRenderDistance.ts new file mode 100644 index 00000000..0222318d --- /dev/null +++ b/src/scene/dungeonRenderDistance.ts @@ -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; +} diff --git a/src/scene/frameRateGovernor.test.ts b/src/scene/frameRateGovernor.test.ts index bf3b18a4..edf8afbd 100644 --- a/src/scene/frameRateGovernor.test.ts +++ b/src/scene/frameRateGovernor.test.ts @@ -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); diff --git a/src/scene/frameRateGovernor.ts b/src/scene/frameRateGovernor.ts index cfd15e0a..2c92a5b6 100644 --- a/src/scene/frameRateGovernor.ts +++ b/src/scene/frameRateGovernor.ts @@ -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, diff --git a/src/scene/productionDungeonMaterials.ts b/src/scene/productionDungeonMaterials.ts index ccc573c8..7fd6154c 100644 --- a/src/scene/productionDungeonMaterials.ts +++ b/src/scene/productionDungeonMaterials.ts @@ -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; diff --git a/src/scene/staticSceneInstancing.test.ts b/src/scene/staticSceneInstancing.test.ts index 11b20181..86bfef37 100644 --- a/src/scene/staticSceneInstancing.test.ts +++ b/src/scene/staticSceneInstancing.test.ts @@ -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(); diff --git a/src/scene/staticSceneInstancing.ts b/src/scene/staticSceneInstancing.ts index 231138ac..554ad421 100644 --- a/src/scene/staticSceneInstancing.ts +++ b/src/scene/staticSceneInstancing.ts @@ -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(); 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 }; } diff --git a/src/scene/staticSceneVisibility.test.ts b/src/scene/staticSceneVisibility.test.ts new file mode 100644 index 00000000..6d83a962 --- /dev/null +++ b/src/scene/staticSceneVisibility.test.ts @@ -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); + }); +}); diff --git a/src/scene/staticSceneVisibility.ts b/src/scene/staticSceneVisibility.ts new file mode 100644 index 00000000..894a5e0e --- /dev/null +++ b/src/scene/staticSceneVisibility.ts @@ -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(); + 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 previous: Scene["onBeforeRender"]; + readonly callback: Scene["onBeforeRender"]; +} + +const registrations = new WeakMap(); + +/** 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(); + 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); + }; +}