@volter/editor-game 0.5.65 → 0.5.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/canvas/component-board.service.ts +16 -0
  5. package/contributions/canvas/design-time-mount.service.ts +45 -0
  6. package/contributions/canvas-story-capture.service.ts +12 -0
  7. package/contributions/edit-mode-audio.service.ts +2 -2
  8. package/contributions/edit-mode-networking.service.ts +1 -1
  9. package/contributions/gameplay.command.ts +4 -4
  10. package/contributions/generation.service.ts +3 -3
  11. package/contributions/generations.status.tsx +1 -1
  12. package/contributions/godot.palette.json +99 -0
  13. package/contributions/godot.style.ts +82 -0
  14. package/contributions/godot.view.ts +75 -0
  15. package/contributions/ingest.service.ts +4 -4
  16. package/contributions/instances.command.ts +1 -1
  17. package/contributions/navmesh.menu.ts +1 -1
  18. package/contributions/network-observer.service.ts +14 -0
  19. package/contributions/play.command.ts +11 -5
  20. package/contributions/react/component-board.service.ts +1 -1
  21. package/contributions/react/design-time-mount.service.ts +1 -1
  22. package/contributions/react/react-inspector.service.ts +2 -2
  23. package/contributions/scene-document.service.ts +5 -5
  24. package/contributions/state-watch.menu.ts +1 -1
  25. package/contributions/state-watch.utility.tsx +1 -1
  26. package/contributions/team-playtest.service.ts +4 -4
  27. package/contributions/three/component-board.service.ts +1 -1
  28. package/contributions/three/component-verbs.command.ts +8 -8
  29. package/contributions/three/three-authoring.service.ts +8 -5
  30. package/contributions/three-story-capture.service.ts +12 -0
  31. package/contributions/unity.palette.json +102 -0
  32. package/contributions/unity.style.ts +72 -0
  33. package/contributions/unity.view.ts +82 -0
  34. package/contributions/unreal.palette.json +105 -0
  35. package/contributions/unreal.style.ts +66 -0
  36. package/contributions/unreal.view.ts +93 -0
  37. package/dist-node/serving.mjs +16 -0
  38. package/package.json +33 -16
  39. package/serving/index.ts +27 -0
  40. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  41. package/src/asset-budget/asset-budget-model.ts +4 -4
  42. package/src/asset-budget/optimize-apply.ts +7 -7
  43. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  44. package/src/bridge/dispatch.ts +16 -16
  45. package/src/bridge/live-frames.ts +1 -1
  46. package/src/bridge/screenshot.ts +6 -6
  47. package/src/build/BuildProfilesPanel.tsx +3 -3
  48. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
  49. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  50. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  51. package/src/canvas/canvas-design-mount.ts +524 -0
  52. package/src/canvas/design-time-canvas-mount.ts +79 -0
  53. package/src/coverage/live-authoring-surface.ts +6 -6
  54. package/src/coverage/live-project-verbs.ts +2 -2
  55. package/src/coverage/native-system-coverage.ts +6 -6
  56. package/src/coverage/root-coverage.ts +1 -1
  57. package/src/coverage/session-coverage.ts +5 -5
  58. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  59. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +13 -53
  60. package/src/edit-mode/edit-mode-audio.ts +4 -4
  61. package/src/edit-mode/edit-mode-networking.ts +2 -2
  62. package/src/game-document/GameCaptureFrameButton.tsx +2 -2
  63. package/src/game-document/GameDocument.tsx +2 -2
  64. package/src/game-document/GamePanel.tsx +8 -8
  65. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  66. package/src/game-document/crowd-debug.ts +1 -1
  67. package/src/game-document/device-preview.ts +10 -11
  68. package/src/game-document/physics-debug.ts +1 -1
  69. package/src/generation/GenerationActivity.tsx +6 -8
  70. package/src/generation/generation-documents.tsx +8 -8
  71. package/src/generation/generation-jobs.ts +2 -2
  72. package/src/generation/generation-presentation.ts +1 -1
  73. package/src/host/adapter-reach.ts +1 -1
  74. package/src/host/adapter-runtime-bindings.ts +97 -6
  75. package/src/host/api/configurations.ts +2 -2
  76. package/src/host/authoring/babylon-authoring-adapter.ts +8 -8
  77. package/src/host/authoring/contract-hierarchy-authoring.ts +1 -1
  78. package/src/host/authoring/contract-scenes-stories.ts +1 -1
  79. package/src/host/authoring/creation-site-related.ts +2 -2
  80. package/src/host/authoring/ephemeral-persistence.ts +1 -1
  81. package/src/host/authoring/gesture-persist.ts +2 -2
  82. package/src/host/authoring/ingest-data-writer.ts +1 -1
  83. package/src/host/authoring/ingest-source-persistence.ts +8 -8
  84. package/src/host/authoring/mount-isolated-pixi-screen.ts +1 -1
  85. package/src/host/authoring/mounted-authoring.ts +4 -4
  86. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  87. package/src/host/authoring/pixi-authoring-adapter.ts +42 -17
  88. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  89. package/src/host/authoring/pixi-live-write-target.ts +7 -7
  90. package/src/host/authoring/pixi-source-identity.ts +3 -3
  91. package/src/host/authoring/pixi-source-write-target.ts +1614 -0
  92. package/src/host/authoring/pixi-still-presentation.ts +2 -2
  93. package/src/host/authoring/pixi-structure-history.ts +3 -3
  94. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  95. package/src/host/authoring/source-persistence-backend.ts +6 -6
  96. package/src/host/authoring/struct-write-pipe.ts +3 -3
  97. package/src/host/binding-resolver.ts +8 -9
  98. package/src/host/browser-transpile.ts +2 -2
  99. package/src/host/canvas-entry-runtime.ts +59 -48
  100. package/src/host/canvas-preview-frames.ts +482 -0
  101. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  102. package/src/host/components/HeaderTelemetry.tsx +9 -16
  103. package/src/host/components/PixiIsolationSceneContent.tsx +17 -16
  104. package/src/host/components/ThreeIsolationSceneContent.tsx +5 -5
  105. package/src/host/components/frame-debugger-model.ts +2 -2
  106. package/src/host/components/header-telemetry-model.ts +2 -2
  107. package/src/host/components/scene-document.tsx +29 -61
  108. package/src/host/components/utility-view-state.ts +1 -1
  109. package/src/host/components/world-root-stage-binding.tsx +17 -26
  110. package/src/host/components/world-root-stage.ts +130 -63
  111. package/src/host/coverage/capability-coverage.ts +13 -1
  112. package/src/host/coverage/game-contract-seam-evidence.ts +1 -1
  113. package/src/host/coverage/project-verb-coverage.ts +2 -17
  114. package/src/host/coverage/system-adapter-coverage.ts +4 -5
  115. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +14 -8
  116. package/src/host/document-preview-three.ts +2 -2
  117. package/src/host/entry-adjudication.ts +6 -6
  118. package/src/host/game-css-scope-transform.ts +4 -0
  119. package/src/host/game-module-access.ts +1 -1
  120. package/src/host/game-realm-page.ts +16 -8
  121. package/src/host/game-realm-reclaim.ts +1 -1
  122. package/src/host/gameplay-export.ts +25 -14
  123. package/src/host/gameplay-recording.ts +5 -5
  124. package/src/host/gated-globals.ts +6 -6
  125. package/src/host/history/json-history-resource.ts +3 -3
  126. package/src/host/instance-extract-actions.ts +1 -1
  127. package/src/host/instance-fork-actions.ts +1 -1
  128. package/src/host/projection/dom.ts +2 -2
  129. package/src/host/projection/pixi.ts +25 -3
  130. package/src/host/r3f-entry-runtime.ts +65 -34
  131. package/src/host/react-mount-runtime.ts +8 -49
  132. package/src/host/realm-services.ts +2 -2
  133. package/src/host/roots/canvas-root.tsx +361 -0
  134. package/src/host/roots/module-root.ts +1 -1
  135. package/src/host/roots/r3f-root.tsx +473 -0
  136. package/src/host/roots/react-root.ts +9 -43
  137. package/src/host/scene-view-drawability.ts +2 -2
  138. package/src/host/served-bundle-runtime-modules.ts +1 -19
  139. package/src/host/server-log-bridge.ts +3 -3
  140. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  141. package/src/host/stories/pixi-story-model.ts +30 -0
  142. package/src/host/stories/story-media-captures.ts +46 -0
  143. package/src/host/stories/story-media-presence.ts +3 -3
  144. package/src/host/stories/story-pixi-preview.ts +408 -0
  145. package/src/host/stories/story-three-preview.ts +806 -0
  146. package/src/host/stories/three-story-captures.ts +35 -0
  147. package/src/host/story-three-preview-runtime.ts +56 -0
  148. package/src/host/three-ingest-runtime.ts +1 -1
  149. package/src/host/use-active-performance-source.ts +3 -3
  150. package/src/host/viewport-pose-memory.ts +1 -1
  151. package/src/host/viewport-root-presentation.ts +6 -5
  152. package/src/ingest/active-ingest.ts +2 -2
  153. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  154. package/src/ingest/authoring/ingest-root-adapter.ts +13 -13
  155. package/src/ingest/capture-wait-report.ts +1 -1
  156. package/src/ingest/deferred-ingest-play.ts +11 -10
  157. package/src/ingest/discovery-public-ingest.ts +2 -2
  158. package/src/ingest/ingest-boot-viewport.ts +6 -8
  159. package/src/ingest/ingest-canvas-scene-document.tsx +17 -16
  160. package/src/ingest/ingest-canvas-scene.ts +5 -8
  161. package/src/ingest/ingest-evidence-hook.ts +1 -1
  162. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  163. package/src/ingest/ingest-play-control.ts +4 -4
  164. package/src/ingest/ingest-render-debug.ts +11 -11
  165. package/src/ingest/ingest-siblings.ts +16 -28
  166. package/src/ingest/module-mode.ts +18 -19
  167. package/src/ingest/mount-canvas-ingest-root.ts +27 -27
  168. package/src/ingest/mount-coverage.ts +4 -4
  169. package/src/ingest/mount-dom-ingest-root.ts +16 -16
  170. package/src/ingest/mount-ingest-root.ts +14 -14
  171. package/src/ingest/mount-three-ingest-root.ts +12 -12
  172. package/src/ingest/resolve-canvas.ts +1 -1
  173. package/src/ingest/served-html-boot.ts +1 -1
  174. package/src/ingest/surface-canvas.ts +1 -1
  175. package/src/ingest/unmount-ingest-root.ts +7 -7
  176. package/src/navmesh/navmesh-handler.ts +24 -16
  177. package/src/network/NetworkInspectorPanel.tsx +449 -14
  178. package/src/network/network-inspector-model.ts +14 -2
  179. package/src/play/play-log-events.ts +1 -1
  180. package/src/play/play-mode.ts +99 -142
  181. package/src/play/play-recording.ts +2 -2
  182. package/src/play/react-play-live-authoring.ts +3 -3
  183. package/src/play/run-selection.ts +93 -0
  184. package/src/play-bar/PlayBar.tsx +20 -39
  185. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  186. package/src/profiler/PerformancePanel.tsx +4 -4
  187. package/src/react/design-time-react-mount.ts +37 -85
  188. package/src/react/dom-authoring-adapter.ts +11 -11
  189. package/src/react/react-inspector-section.tsx +13 -13
  190. package/src/react/react-world-authoring-adapter.ts +31 -143
  191. package/src/react/story-documents/story-documents.tsx +18 -22
  192. package/src/react/ui-board-document.tsx +10 -11
  193. package/src/react/ui-component-board.ts +5 -5
  194. package/src/runtime/adapter/audio-meter.ts +21 -0
  195. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  196. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  197. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  198. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  199. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  200. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  201. package/src/runtime/adapter/native-debug-module.ts +498 -0
  202. package/src/runtime/audio/bus-mixer.ts +161 -0
  203. package/src/runtime/audio/pose-guard.ts +80 -0
  204. package/src/runtime/core/frame-pacing.ts +126 -0
  205. package/src/runtime/core/game-loop.ts +225 -0
  206. package/src/runtime/core/game-scoped-slot.ts +28 -0
  207. package/src/runtime/core/seeded-random.ts +162 -0
  208. package/src/runtime/core/sim-clock.ts +391 -0
  209. package/src/runtime/core/system-runner.ts +269 -0
  210. package/src/runtime/core/types.ts +104 -0
  211. package/src/runtime/create-runtime.ts +1128 -0
  212. package/src/runtime/debug-bridge.ts +570 -0
  213. package/src/runtime/debug-registry.ts +899 -0
  214. package/src/runtime/dev/chrome-trace.ts +153 -0
  215. package/src/runtime/dev/instruments.ts +403 -0
  216. package/src/runtime/dev/logger.ts +119 -0
  217. package/src/runtime/dev/performance-profiler.ts +367 -0
  218. package/src/runtime/dev/register-render-vitals.ts +276 -0
  219. package/src/runtime/dev/render-census.ts +354 -0
  220. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  221. package/src/runtime/dev/render-memory.ts +226 -0
  222. package/src/runtime/dev/render-vitals.ts +338 -0
  223. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  224. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  225. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  226. package/src/runtime/dev-build.ts +47 -0
  227. package/src/runtime/game.ts +1636 -0
  228. package/src/runtime/gameplay-rng-trap.ts +135 -0
  229. package/src/runtime/host-context.ts +64 -0
  230. package/src/runtime/input-router.ts +169 -0
  231. package/src/runtime/mount-manifest.ts +480 -0
  232. package/src/runtime/pixi/authoring.ts +706 -0
  233. package/src/runtime/pixi/ingest.ts +116 -0
  234. package/src/runtime/pixi/physics-registry.ts +49 -0
  235. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  236. package/src/runtime/pixi/scene-capture.ts +179 -0
  237. package/src/runtime/pixi/system-adapters.ts +69 -0
  238. package/src/runtime/playtest.ts +22 -0
  239. package/src/runtime/presentation.ts +141 -0
  240. package/src/runtime/render-control.ts +642 -0
  241. package/src/runtime/render-seed.ts +77 -0
  242. package/src/runtime/run-ticks-settled.ts +73 -0
  243. package/src/runtime/setup/setup-audio.ts +72 -0
  244. package/src/services/audio-pose-guard.ts +2 -2
  245. package/src/services/game-audio.ts +152 -0
  246. package/src/services/game-network.ts +657 -0
  247. package/src/services/game-physics.ts +334 -0
  248. package/src/state-watch/StateWatchPanel.tsx +1 -1
  249. package/src/three/authoring/camera-runtime-inspector-section.tsx +4 -3
  250. package/src/three/authoring/constraint-inspector-section.tsx +8 -7
  251. package/src/three/authoring/model-asset-inspector-section.tsx +14 -17
  252. package/src/three/authoring/oid-source-persistence.ts +13 -13
  253. package/src/three/authoring/r3f-design-session.ts +79 -61
  254. package/src/three/authoring/r3f-source-authoring-adapter.ts +112 -108
  255. package/src/three/authoring/reflection-probe-inspector-section.tsx +5 -4
  256. package/src/three/authoring/spatial-joint-handles.ts +1 -1
  257. package/src/three/authoring/spatial-lod-handles.ts +1 -1
  258. package/src/three/authoring/spatial-particle-handles.ts +1 -1
  259. package/src/three/authoring/three-authoring-adapter.ts +33 -33
  260. package/src/three/component-verbs/extract-menu.ts +8 -7
  261. package/src/three/component-verbs/fork-menu.ts +8 -7
  262. package/src/three/component-verbs/internals-menu.ts +3 -3
  263. package/src/three/drei-asset-caches.ts +13 -0
  264. package/src/three/story-documents/three-story-documents.tsx +24 -34
  265. package/src/three/three-board/ThreeBoardDocument.tsx +25 -25
  266. package/src/three/three-board/board-scene.ts +7 -7
  267. package/src/three/three-board/three-component-board.ts +5 -5
  268. package/contributions/react/pasteboard.action.ts +0 -41
  269. package/contributions/xstate-behavior.action.ts +0 -42
  270. package/contributions/xstate-behavior.document.tsx +0 -73
  271. package/contributions/xstate-behavior.inspector.tsx +0 -28
  272. package/contributions/xstate-behavior.menu.ts +0 -23
  273. package/src/react/pasteboard-materialize.ts +0 -154
  274. package/src/services/game-audio-unlock.ts +0 -48
  275. package/src/xstate/XStateBehaviorSection.tsx +0 -130
  276. package/src/xstate/XStateMachineInspector.tsx +0 -566
  277. package/src/xstate/character-animation-machine.fixture.ts +0 -74
  278. package/src/xstate/live-behaviors.ts +0 -106
  279. package/src/xstate/use-live-actor-state.ts +0 -44
  280. package/src/xstate/xstate-graph.ts +0 -235
  281. package/src/xstate/xstate-layout.ts +0 -76
@@ -0,0 +1,354 @@
1
+ /**
2
+ * THE ADDRESS BOOK behind the render vitals (`dev/render-vitals.ts`): which
3
+ * subtree owns the draw calls, and which meshes are identical enough to
4
+ * instance. A draw-call count with no address is a symptom you cannot act on.
5
+ *
6
+ * Pure functions over a live `THREE.Object3D` — no renderer, no profiler, no
7
+ * game. They are what the `render.census` / `render.families` debug commands
8
+ * run; keeping them here (rather than inline in the adapter) is what lets a
9
+ * headless test build a scene by hand and check the arithmetic.
10
+ *
11
+ * ── WHAT "VISIBLE" MEANS HERE, AND WHY IT IS A WALK ─────────────────────────
12
+ * Visibility in three is HIERARCHICAL: a hidden parent hides its whole
13
+ * subtree, and `Object3D.traverse` cannot prune. So these walk manually and
14
+ * stop descending at an invisible node. That is not a detail — `render.toggle`
15
+ * works by hiding a PARENT, and a census that kept counting the children under
16
+ * it would report that nothing changed.
17
+ *
18
+ * ── TWO FAMILY SCANS, AND WHY BOTH ──────────────────────────────────────────
19
+ * {@link meshFamilies} keys on object IDENTITY: meshes sharing one geometry
20
+ * instance and one material instance (`uuid`) — exactly the precondition for
21
+ * collapsing them into a bare `InstancedMesh` as they stand. It deliberately
22
+ * does not compare material PARAMETERS.
23
+ *
24
+ * {@link structuralBatchScan} keys on STRUCTURE (`render/structural-
25
+ * signature.ts`): meshes that are the same DRAW, however many distinct
26
+ * material objects describe it. A project that writes a material per mesh —
27
+ * which is what inline `<meshStandardMaterial>` in a TSX world does, i.e. what
28
+ * every fresh scaffold does — reads as all families-of-one under the first
29
+ * scan and as one big collapsible family under the second. Both readings are
30
+ * true; they answer different questions, and only the second one routes an
31
+ * author to the fix (`dev/static-batch-advisor.ts`).
32
+ *
33
+ * Nothing here assumes a project interns its materials or that its world is
34
+ * static — the STATIC half is a claim only the author can make, which is why
35
+ * the advisory asks rather than acts.
36
+ */
37
+
38
+ import {
39
+ materialMergeSignature,
40
+ staticBatchSkipReason,
41
+ } from '@volter/threejs-runtime/render/structural-signature';
42
+ import type * as THREE from 'three';
43
+
44
+ /** How many rows a census/family report carries. A shortlist is actionable;
45
+ * a full dump through a JSON relay is a wall of text nobody reads. */
46
+ export const CENSUS_ROW_LIMIT = 40;
47
+ export const FAMILY_ROW_LIMIT = 30;
48
+
49
+ interface MeshLike {
50
+ isMesh?: boolean;
51
+ isSkinnedMesh?: boolean;
52
+ isInstancedMesh?: boolean;
53
+ count?: number;
54
+ geometry?: THREE.BufferGeometry;
55
+ material?: THREE.Material | THREE.Material[];
56
+ }
57
+
58
+ function asMesh(node: THREE.Object3D): (MeshLike & THREE.Object3D) | null {
59
+ const mesh = node as THREE.Object3D & MeshLike;
60
+ return mesh.isMesh || mesh.isSkinnedMesh ? mesh : null;
61
+ }
62
+
63
+ /** Triangles one mesh submits: its geometry's own count times its instance
64
+ * count. Non-indexed geometry falls back to the position attribute; a
65
+ * geometry with neither contributes 0 rather than a guess. */
66
+ function triangleCount(mesh: MeshLike): number {
67
+ const geometry = mesh.geometry;
68
+ if (!geometry) return 0;
69
+ const index = geometry.getIndex?.() ?? null;
70
+ const position = geometry.getAttribute?.('position') ?? null;
71
+ const vertices = index ? index.count : (position?.count ?? 0);
72
+ const instances = mesh.isInstancedMesh ? (mesh.count ?? 1) : 1;
73
+ return Math.round(vertices / 3) * instances;
74
+ }
75
+
76
+ /** Walk `node` and its visible descendants, calling `visit` for every visible
77
+ * mesh. Stops at an invisible node — see the module note. */
78
+ function walkVisibleMeshes(
79
+ node: THREE.Object3D,
80
+ visit: (mesh: MeshLike & THREE.Object3D) => void,
81
+ ): void {
82
+ if (!node.visible) return;
83
+ const mesh = asMesh(node);
84
+ if (mesh) visit(mesh);
85
+ for (const child of node.children) walkVisibleMeshes(child, visit);
86
+ }
87
+
88
+ /** One addressable subtree's share of the frame. */
89
+ export interface CensusRow {
90
+ /** The node's own name, or a stable placeholder — this is the string
91
+ * `render.census`/`render.toggle` take as their drill-down argument, so an
92
+ * unnamed node is honestly reported as unaddressable rather than given an
93
+ * invented name that would not resolve. */
94
+ readonly name: string;
95
+ readonly meshes: number;
96
+ readonly triangles: number;
97
+ }
98
+
99
+ export interface CensusReport {
100
+ /** The subtree this census covers (`'(scene)'` for the whole world). */
101
+ readonly root: string;
102
+ readonly totalMeshes: number;
103
+ readonly totalTriangles: number;
104
+ /** How many rows exist in total, so a truncated report says so out loud
105
+ * instead of reading as complete. */
106
+ readonly subtreeCount: number;
107
+ /** Heaviest first, capped at {@link CENSUS_ROW_LIMIT}. */
108
+ readonly subtrees: readonly CensusRow[];
109
+ }
110
+
111
+ const UNNAMED = '(unnamed)';
112
+
113
+ /**
114
+ * Tally visible meshes and triangles per direct child of `root`. Pure.
115
+ *
116
+ * One level, deliberately: the report is a set of addresses to drill into
117
+ * with the same command, and a recursive dump is not something you can act on
118
+ * one step at a time. `root` itself contributes its own meshes under its own
119
+ * name when it has any.
120
+ */
121
+ export function sceneCensus(root: THREE.Object3D, rootLabel = '(scene)'): CensusReport {
122
+ const rows = new Map<string, { meshes: number; triangles: number }>();
123
+ let totalMeshes = 0;
124
+ let totalTriangles = 0;
125
+
126
+ if (!root.visible) {
127
+ return { root: rootLabel, totalMeshes: 0, totalTriangles: 0, subtreeCount: 0, subtrees: [] };
128
+ }
129
+
130
+ const tally = (key: string, node: THREE.Object3D): void => {
131
+ walkVisibleMeshes(node, (mesh) => {
132
+ const triangles = triangleCount(mesh);
133
+ const row = rows.get(key) ?? { meshes: 0, triangles: 0 };
134
+ row.meshes += 1;
135
+ row.triangles += triangles;
136
+ rows.set(key, row);
137
+ totalMeshes += 1;
138
+ totalTriangles += triangles;
139
+ });
140
+ };
141
+
142
+ // `root`'s own mesh, if it is one, is charged to `root` — not silently
143
+ // dropped because the grouping key is "direct child".
144
+ const selfMesh = asMesh(root);
145
+ if (selfMesh) {
146
+ const triangles = triangleCount(selfMesh);
147
+ rows.set(root.name || rootLabel, { meshes: 1, triangles });
148
+ totalMeshes += 1;
149
+ totalTriangles += triangles;
150
+ }
151
+ for (const child of root.children) tally(child.name || UNNAMED, child);
152
+
153
+ const sorted = [...rows.entries()]
154
+ .map(([name, row]) => ({ name, meshes: row.meshes, triangles: row.triangles }))
155
+ .sort((a, b) => b.triangles - a.triangles || b.meshes - a.meshes);
156
+
157
+ return {
158
+ root: rootLabel,
159
+ totalMeshes,
160
+ totalTriangles,
161
+ subtreeCount: sorted.length,
162
+ subtrees: sorted.slice(0, CENSUS_ROW_LIMIT),
163
+ };
164
+ }
165
+
166
+ /** First node named `name` in `root`'s subtree, or `null`. Depth-first, so the
167
+ * shallowest match under an ordinary hierarchy wins. */
168
+ export function findByName(root: THREE.Object3D, name: string): THREE.Object3D | null {
169
+ if (root.name === name) return root;
170
+ for (const child of root.children) {
171
+ const hit = findByName(child, name);
172
+ if (hit) return hit;
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /** One (geometry, material) identity and how many meshes share it. */
178
+ export interface FamilyRow {
179
+ /** How many meshes share this identity — the instancing win, minus one. */
180
+ readonly meshes: number;
181
+ /** Triangles ONE member submits. */
182
+ readonly triangles: number;
183
+ /** A representative address (`parent/child`), so the row is findable. */
184
+ readonly example: string;
185
+ }
186
+
187
+ export interface FamilyReport {
188
+ /** Visible, non-instanced meshes considered. `InstancedMesh`es are excluded:
189
+ * they are already the thing this report recommends becoming. */
190
+ readonly plainMeshes: number;
191
+ readonly familyCount: number;
192
+ /** Largest first, capped at {@link FAMILY_ROW_LIMIT}. */
193
+ readonly top: readonly FamilyRow[];
194
+ /** How many of {@link plainMeshes} the reported rows cover — the honest
195
+ * headroom figure, so a long tail is not mistaken for a short one. */
196
+ readonly meshesInTop: number;
197
+ }
198
+
199
+ /** Group visible non-instanced meshes by (geometry, material) identity. Pure.
200
+ * See the module note for what identity does and does not assume. */
201
+ export function meshFamilies(root: THREE.Object3D): FamilyReport {
202
+ const families = new Map<string, { meshes: number; triangles: number; example: string }>();
203
+ let plainMeshes = 0;
204
+
205
+ walkVisibleMeshes(root, (mesh) => {
206
+ if (mesh.isInstancedMesh) return;
207
+ const geometry = mesh.geometry;
208
+ if (!geometry) return;
209
+ const material = mesh.material;
210
+ const materialKey = Array.isArray(material)
211
+ ? material.map((entry) => entry.uuid).join('+')
212
+ : (material?.uuid ?? 'none');
213
+ const key = `${geometry.uuid}/${materialKey}`;
214
+ plainMeshes += 1;
215
+ const existing = families.get(key);
216
+ if (existing) {
217
+ existing.meshes += 1;
218
+ return;
219
+ }
220
+ families.set(key, {
221
+ meshes: 1,
222
+ triangles: triangleCount(mesh),
223
+ example: `${mesh.parent?.name || UNNAMED}/${mesh.name || UNNAMED}`,
224
+ });
225
+ });
226
+
227
+ const sorted = [...families.values()].sort((a, b) => b.meshes - a.meshes);
228
+ const top = sorted.slice(0, FAMILY_ROW_LIMIT);
229
+ return {
230
+ plainMeshes,
231
+ familyCount: sorted.length,
232
+ top,
233
+ meshesInTop: top.reduce((total, row) => total + row.meshes, 0),
234
+ };
235
+ }
236
+
237
+ /** One top-level subtree's share of the collapsible meshes — the ADDRESS the
238
+ * advisory names, so the fix is a wrapper someone can actually place. */
239
+ export interface StructuralSubtreeRow {
240
+ readonly name: string;
241
+ readonly collapsible: number;
242
+ }
243
+
244
+ /** What {@link structuralBatchScan} answers with. A {@link FamilyReport} whose
245
+ * families are keyed by STRUCTURE, plus the two figures the advisory needs. */
246
+ export interface StructuralBatchReport extends FamilyReport {
247
+ /** Meshes sitting in a family of two or more — what would collapse. The
248
+ * saving is a little less than this (each family leaves one product
249
+ * behind), which is why the advisory says "~". */
250
+ readonly collapsible: number;
251
+ /** Meshes the batcher would REFUSE, by reason — the honest other half. A
252
+ * scene of 4,000 transparent quads has no draw-call diet available, and a
253
+ * scan that reported only the upside would send an author to a wrapper
254
+ * that declines every member. */
255
+ readonly skipped: Readonly<Record<string, number>>;
256
+ /** Collapsible meshes per direct child of the scanned root, largest first. */
257
+ readonly bySubtree: readonly StructuralSubtreeRow[];
258
+ }
259
+
260
+ /**
261
+ * Group visible meshes by STRUCTURAL identity — the same key the batchers use
262
+ * (`render/structural-signature.ts`), so this measures what `<Frozen>` would
263
+ * actually do rather than what a uuid comparison can see.
264
+ *
265
+ * This is the companion to {@link meshFamilies}, not a replacement, and the
266
+ * difference between the two is the finding: `meshFamilies` asks "which meshes
267
+ * SHARE a geometry and material object", which is what a bare `InstancedMesh`
268
+ * needs; this asks "which meshes are the same draw", which is what a merging
269
+ * batcher needs. On a freshly scaffolded world writing inline materials the
270
+ * first reports families of one and the second reports the win — see the uuid
271
+ * trap in `render/structural-signature.ts`.
272
+ *
273
+ * Pure. One walk, plus a tally.
274
+ */
275
+ export function structuralBatchScan(root: THREE.Object3D): StructuralBatchReport {
276
+ interface Family {
277
+ meshes: number;
278
+ triangles: number;
279
+ example: string;
280
+ }
281
+ const families = new Map<string, Family>();
282
+ const skipped: Record<string, number> = {};
283
+ // Which subtree each candidate sits under, kept per mesh so attribution can
284
+ // wait until the families are known (a family of one is not collapsible, and
285
+ // charging its member to a subtree would inflate that subtree's row).
286
+ const members: { key: string; subtree: string }[] = [];
287
+ let plainMeshes = 0;
288
+
289
+ /** One node, no recursion. Answers `false` when its subtree must be pruned. */
290
+ const visit = (node: THREE.Object3D, subtree: string): boolean => {
291
+ if (!node.visible) return false;
292
+ const reason = staticBatchSkipReason(node);
293
+ if (reason === 'opted-out') {
294
+ skipped['opted-out'] = (skipped['opted-out'] ?? 0) + 1;
295
+ return false; // an opt-out covers its whole subtree
296
+ }
297
+ if (reason === null) {
298
+ const mesh = node as THREE.Object3D & MeshLike;
299
+ const material = mesh.material as THREE.Material;
300
+ const key = `${materialMergeSignature(material)}|${node.castShadow ? 1 : 0}${node.receiveShadow ? 1 : 0}`;
301
+ plainMeshes += 1;
302
+ members.push({ key, subtree });
303
+ const existing = families.get(key);
304
+ if (existing) existing.meshes += 1;
305
+ else {
306
+ families.set(key, {
307
+ meshes: 1,
308
+ triangles: triangleCount(mesh),
309
+ example: `${node.parent?.name || UNNAMED}/${node.name || UNNAMED}`,
310
+ });
311
+ }
312
+ } else if (reason !== 'not-a-mesh') {
313
+ skipped[reason] = (skipped[reason] ?? 0) + 1;
314
+ }
315
+ return true;
316
+ };
317
+
318
+ const scan = (node: THREE.Object3D, subtree: string): void => {
319
+ if (!visit(node, subtree)) return;
320
+ for (const child of node.children) scan(child, subtree);
321
+ };
322
+
323
+ // `root`'s own mesh, if it is one, is charged to `root` — the same rule
324
+ // `sceneCensus` uses, so the two reports address the same graph.
325
+ if (visit(root, root.name || UNNAMED)) {
326
+ for (const child of root.children) scan(child, child.name || UNNAMED);
327
+ }
328
+
329
+ const collapsibleKeys = new Set(
330
+ [...families.entries()].filter(([, family]) => family.meshes >= 2).map(([key]) => key),
331
+ );
332
+ const perSubtree = new Map<string, number>();
333
+ let collapsible = 0;
334
+ for (const member of members) {
335
+ if (!collapsibleKeys.has(member.key)) continue;
336
+ collapsible += 1;
337
+ perSubtree.set(member.subtree, (perSubtree.get(member.subtree) ?? 0) + 1);
338
+ }
339
+
340
+ const sorted = [...families.values()].sort((a, b) => b.meshes - a.meshes);
341
+ const top = sorted.slice(0, FAMILY_ROW_LIMIT);
342
+ return {
343
+ plainMeshes,
344
+ familyCount: sorted.length,
345
+ top,
346
+ meshesInTop: top.reduce((total, row) => total + row.meshes, 0),
347
+ collapsible,
348
+ skipped,
349
+ bySubtree: [...perSubtree.entries()]
350
+ .map(([name, count]) => ({ name, collapsible: count }))
351
+ .sort((a, b) => b.collapsible - a.collapsible)
352
+ .slice(0, CENSUS_ROW_LIMIT),
353
+ };
354
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * First-party {@link RenderDebugAdapter} wiring (W4b, F11) — the glue between
3
+ * the WebGL2 frame capture (`webgl-frame-capture.ts`), a live three.js scene,
4
+ * and the render adapter's per-frame render pass.
5
+ *
6
+ * Split OUT of `editor-game/src/host/roots/r3f-root.tsx` so the capture/attribution/restore
7
+ * flow is unit-testable in a headless Node test with a mock GL context and a
8
+ * plain scene — a real non-headless GPU mount (EffectComposer + WebGLRenderer)
9
+ * cannot run under vitest here. `mount()` owns the
10
+ * DECISION to construct this (only under a real WebGL2 context, never
11
+ * headless — see `frameCaptureContextFor`); this module owns the BEHAVIOR.
12
+ *
13
+ * Attribution model (honest by construction): `captureFrame()` wraps every
14
+ * renderable's `onBeforeRender` to feed one annotation to the capture. three
15
+ * calls `onBeforeRender` immediately before that object's MAIN-pass draw, so
16
+ * main-pass draws are attributed and shadow/composer-pass draws record
17
+ * `annotation: null` (counted as unattributed) — never a fabricated guess. The
18
+ * wrappers are all restored when the capture resolves, rejects, times out, or
19
+ * the adapter is disposed.
20
+ *
21
+ * That attribution is a THREE capability, not a property of frame capture: a
22
+ * surface whose renderer has no per-draw annotation seam omits `scene` entirely
23
+ * (the Pixi ingest lane does — `editor/src/ingest/ingest-render-debug.ts`), and
24
+ * every draw is then recorded unattributed. The capture itself is GL-level and
25
+ * unchanged either way; only the names beside the draws go away.
26
+ */
27
+
28
+ import type { RenderDebugAdapter } from '@volter/editor-project/adapter/system-adapter';
29
+ import type * as THREE from 'three';
30
+ import type { RenderMemorySnapshot } from './render-memory';
31
+ import type { DrawAnnotation, FrameCapture, WebGLFrameCapture } from './webgl-frame-capture';
32
+
33
+ /** Decide whether a mount can host frame capture — a real WebGL2 context with
34
+ * the draw entry points, never a headless/stub renderer. Exported so the
35
+ * mount uses ONE predicate and a unit test can assert both sides (mock GL ⇒
36
+ * the context; headless/missing ⇒ null) without a GPU. */
37
+ export function frameCaptureContextFor(
38
+ headless: boolean,
39
+ rendererContext: unknown,
40
+ ): WebGL2RenderingContext | null {
41
+ if (headless) return null;
42
+ if (!rendererContext || typeof rendererContext !== 'object') return null;
43
+ const gl = rendererContext as Partial<WebGL2RenderingContext>;
44
+ if (typeof gl.getExtension !== 'function') return null;
45
+ if (typeof gl.drawArrays !== 'function' || typeof gl.drawElements !== 'function') return null;
46
+ return rendererContext as WebGL2RenderingContext;
47
+ }
48
+
49
+ export interface RenderDebugAdapterDeps {
50
+ /** The armed-on-demand frame capture over the mount's real context. */
51
+ capture: WebGLFrameCapture;
52
+ /**
53
+ * The live three scene root traversed to attribute draws to the objects that
54
+ * issue them.
55
+ *
56
+ * OMITTED on a surface whose renderer publishes no per-draw annotation seam —
57
+ * the Pixi lane, where display objects have no `onBeforeRender` and the
58
+ * batcher merges many display objects into one draw, so there is no object to
59
+ * name for a given draw at all. Then every draw is recorded with
60
+ * `annotation: null` and lands in `totals.unattributed`: the honest "this
61
+ * seam cannot say who drew this", never a guess derived from the scene's
62
+ * shape.
63
+ */
64
+ scene?: THREE.Object3D;
65
+ /** Memory snapshot supplier (renderer.info-backed). When omitted, the
66
+ * adapter's `memorySnapshot` is absent (honest capability degradation). */
67
+ memory?: () => RenderMemorySnapshot;
68
+ /** Timeout (ms) after which a pending `captureFrame` rejects — the world
69
+ * never rendered (paused/stopped). Default 5000. */
70
+ timeoutMs?: number;
71
+ /** Injected timer, for tests. Defaults to the real `setTimeout`/
72
+ * `clearTimeout`. */
73
+ setTimeoutFn?: (fn: () => void, ms: number) => unknown;
74
+ clearTimeoutFn?: (handle: unknown) => void;
75
+ }
76
+
77
+ export interface RenderDebugWiring {
78
+ readonly adapter: RenderDebugAdapter;
79
+ /** Call at the START of the host render pass, before draws. */
80
+ beforeRender(): void;
81
+ /** Call at the END of the host render pass, after draws (in a `finally`). */
82
+ afterRender(): void;
83
+ /** Restore any pending attribution wrappers and reject a pending capture. */
84
+ dispose(): void;
85
+ }
86
+
87
+ interface Pending {
88
+ resolve(capture: FrameCapture): void;
89
+ reject(error: Error): void;
90
+ restores: (() => void)[];
91
+ timer: unknown;
92
+ settled: boolean;
93
+ }
94
+
95
+ /** Objects three actually issues draws for (and thus calls `onBeforeRender`
96
+ * on before the main-pass draw). */
97
+ function isRenderable(object: THREE.Object3D): boolean {
98
+ const o = object as unknown as {
99
+ isMesh?: boolean;
100
+ isSkinnedMesh?: boolean;
101
+ isPoints?: boolean;
102
+ isLine?: boolean;
103
+ isSprite?: boolean;
104
+ };
105
+ return Boolean(o.isMesh || o.isSkinnedMesh || o.isPoints || o.isLine || o.isSprite);
106
+ }
107
+
108
+ function annotationFor(object: THREE.Object3D): DrawAnnotation {
109
+ const mesh = object as THREE.Mesh;
110
+ const geometry = mesh.geometry as THREE.BufferGeometry | undefined;
111
+ const rawMaterial = mesh.material;
112
+ const material = (Array.isArray(rawMaterial) ? rawMaterial[0] : rawMaterial) as
113
+ | THREE.Material
114
+ | undefined;
115
+ const entityId = object.userData?.['entityId'];
116
+ return {
117
+ object: {
118
+ name: object.name || object.type,
119
+ type: object.type,
120
+ ...(entityId !== undefined ? { entityId: String(entityId) } : {}),
121
+ },
122
+ geometry: {
123
+ type: geometry?.type ?? 'unknown',
124
+ name: geometry?.name ?? '',
125
+ attributes: geometry ? Object.keys(geometry.attributes) : [],
126
+ indexed: Boolean(geometry?.index),
127
+ },
128
+ material: {
129
+ type: material?.type ?? 'unknown',
130
+ name: material?.name ?? '',
131
+ },
132
+ };
133
+ }
134
+
135
+ export function createRenderDebugAdapter(deps: RenderDebugAdapterDeps): RenderDebugWiring {
136
+ const { capture, scene, memory } = deps;
137
+ const timeoutMs = deps.timeoutMs ?? 5000;
138
+ const setTimer = deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms));
139
+ const clearTimer =
140
+ deps.clearTimeoutFn ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
141
+
142
+ let pending: Pending | null = null;
143
+
144
+ function wrapOnBeforeRender(): (() => void)[] {
145
+ const restores: (() => void)[] = [];
146
+ // No scene ⇒ no annotation seam on this surface (see `scene` above): wrap
147
+ // nothing, restore nothing, and let every draw record `annotation: null`.
148
+ if (!scene) return restores;
149
+ scene.traverse((object) => {
150
+ if (!isRenderable(object)) return;
151
+ const original = object.onBeforeRender;
152
+ object.onBeforeRender = function patched(this: THREE.Object3D, ...args) {
153
+ capture.annotateNextDraw(annotationFor(object));
154
+ original.apply(this, args as Parameters<THREE.Object3D['onBeforeRender']>);
155
+ };
156
+ restores.push(() => {
157
+ object.onBeforeRender = original;
158
+ });
159
+ });
160
+ return restores;
161
+ }
162
+
163
+ function settlePending(): Pending | null {
164
+ const p = pending;
165
+ if (p && !p.settled) {
166
+ p.settled = true;
167
+ clearTimer(p.timer);
168
+ for (const restore of p.restores) restore();
169
+ pending = null;
170
+ return p;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ const adapter: RenderDebugAdapter = {
176
+ captureFrame(): Promise<FrameCapture> {
177
+ if (pending) {
178
+ return Promise.reject(new Error('RenderDebugAdapter: a frame capture is already pending'));
179
+ }
180
+ return new Promise<FrameCapture>((resolve, reject) => {
181
+ const restores = wrapOnBeforeRender();
182
+ capture.arm();
183
+ // Assign `pending` BEFORE arming the timer so a synchronous injected
184
+ // timer (test) sees a settle-able pending rather than a null one.
185
+ const p: Pending = { resolve, reject, restores, timer: null, settled: false };
186
+ pending = p;
187
+ p.timer = setTimer(() => {
188
+ if (settlePending()) {
189
+ reject(
190
+ new Error(
191
+ `RenderDebugAdapter: no frame rendered within ${timeoutMs}ms — the world may be paused or stopped`,
192
+ ),
193
+ );
194
+ }
195
+ }, timeoutMs);
196
+ });
197
+ },
198
+ ...(memory ? { memorySnapshot: (): RenderMemorySnapshot => memory() } : {}),
199
+ };
200
+
201
+ return {
202
+ adapter,
203
+ beforeRender(): void {
204
+ capture.beginPass();
205
+ },
206
+ afterRender(): void {
207
+ const result = capture.endPass();
208
+ if (result && pending) {
209
+ const p = settlePending();
210
+ p?.resolve(result);
211
+ }
212
+ },
213
+ dispose(): void {
214
+ const p = settlePending();
215
+ if (p) p.reject(new Error('RenderDebugAdapter: disposed before a frame was captured'));
216
+ },
217
+ };
218
+ }