@volter/editor-game 0.5.66 → 0.5.68

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 (239) 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/build-player.document.tsx +95 -0
  5. package/contributions/canvas/component-board.service.ts +16 -0
  6. package/contributions/canvas/design-time-mount.service.ts +45 -0
  7. package/contributions/canvas-story-capture.service.ts +12 -0
  8. package/contributions/edit-mode-audio.service.ts +2 -2
  9. package/contributions/edit-mode-networking.service.ts +1 -1
  10. package/contributions/gameplay.command.ts +4 -4
  11. package/contributions/generation.service.ts +1 -1
  12. package/contributions/godot.style.ts +26 -6
  13. package/contributions/godot.view.ts +6 -0
  14. package/contributions/ingest.service.ts +2 -2
  15. package/contributions/instances.command.ts +1 -1
  16. package/contributions/navmesh.menu.ts +1 -1
  17. package/contributions/network-observer.service.ts +14 -0
  18. package/contributions/play.command.ts +10 -4
  19. package/contributions/react/component-board.service.ts +1 -1
  20. package/contributions/react/design-time-mount.service.ts +1 -1
  21. package/contributions/scene-document.service.ts +3 -3
  22. package/contributions/state-watch.menu.ts +1 -1
  23. package/contributions/state-watch.utility.tsx +1 -1
  24. package/contributions/team-playtest.service.ts +4 -4
  25. package/contributions/three/component-board.service.ts +1 -1
  26. package/contributions/three/component-verbs.command.ts +6 -6
  27. package/contributions/three/three-authoring.service.ts +8 -5
  28. package/contributions/three-story-capture.service.ts +12 -0
  29. package/contributions/unity.style.ts +17 -7
  30. package/contributions/unity.view.ts +6 -0
  31. package/contributions/unreal.style.ts +10 -2
  32. package/contributions/unreal.view.ts +5 -0
  33. package/package.json +21 -10
  34. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  35. package/src/asset-budget/asset-budget-model.ts +1 -1
  36. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  37. package/src/bridge/dispatch.ts +14 -14
  38. package/src/bridge/live-frames.ts +1 -1
  39. package/src/bridge/screenshot.ts +3 -3
  40. package/src/build/BuildProfilesPanel.tsx +14 -3
  41. package/src/build/build-session.ts +35 -0
  42. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
  43. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  44. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  45. package/src/canvas/canvas-design-mount.ts +524 -0
  46. package/src/canvas/design-time-canvas-mount.ts +79 -0
  47. package/src/coverage/live-authoring-surface.ts +5 -5
  48. package/src/coverage/live-project-verbs.ts +1 -1
  49. package/src/coverage/native-system-coverage.ts +5 -5
  50. package/src/coverage/root-coverage.ts +1 -1
  51. package/src/coverage/session-coverage.ts +3 -3
  52. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  53. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  54. package/src/edit-mode/edit-mode-audio.ts +4 -4
  55. package/src/edit-mode/edit-mode-networking.ts +2 -2
  56. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  57. package/src/game-document/GameDocument.tsx +2 -2
  58. package/src/game-document/GamePanel.tsx +4 -4
  59. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  60. package/src/game-document/crowd-debug.ts +1 -1
  61. package/src/game-document/device-preview.ts +10 -11
  62. package/src/game-document/physics-debug.ts +1 -1
  63. package/src/generation/GenerationActivity.tsx +3 -3
  64. package/src/generation/generation-documents.tsx +5 -5
  65. package/src/generation/generation-jobs.ts +1 -1
  66. package/src/host/adapter-runtime-bindings.ts +96 -5
  67. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  68. package/src/host/authoring/gesture-persist.ts +1 -1
  69. package/src/host/authoring/ingest-data-writer.ts +1 -1
  70. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  71. package/src/host/authoring/mounted-authoring.ts +4 -4
  72. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  73. package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
  74. package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
  75. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  76. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  77. package/src/host/authoring/pixi-source-identity.ts +3 -3
  78. package/src/host/authoring/pixi-source-write-target.ts +1709 -0
  79. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  80. package/src/host/authoring/pixi-structure-history.ts +2 -2
  81. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  82. package/src/host/authoring/source-persistence-backend.ts +3 -3
  83. package/src/host/authoring/struct-write-pipe.ts +1 -1
  84. package/src/host/binding-resolver.ts +8 -9
  85. package/src/host/browser-transpile.ts +1 -1
  86. package/src/host/canvas-entry-runtime.ts +58 -47
  87. package/src/host/canvas-preview-frames.ts +482 -0
  88. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  89. package/src/host/components/HeaderTelemetry.tsx +4 -4
  90. package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
  91. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  92. package/src/host/components/frame-debugger-model.ts +2 -2
  93. package/src/host/components/header-telemetry-model.ts +2 -2
  94. package/src/host/components/scene-document.tsx +14 -14
  95. package/src/host/components/utility-view-state.ts +1 -1
  96. package/src/host/components/world-root-stage-binding.tsx +12 -12
  97. package/src/host/components/world-root-stage.ts +70 -49
  98. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  99. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  100. package/src/host/document-preview-three.ts +1 -1
  101. package/src/host/entry-adjudication.ts +6 -6
  102. package/src/host/game-css-scope-transform.ts +4 -0
  103. package/src/host/game-realm-page.ts +1 -1
  104. package/src/host/gameplay-export.ts +25 -14
  105. package/src/host/gameplay-recording.ts +5 -5
  106. package/src/host/gated-globals.ts +2 -2
  107. package/src/host/history/json-history-resource.ts +1 -1
  108. package/src/host/projection/pixi.ts +24 -2
  109. package/src/host/r3f-entry-runtime.ts +65 -34
  110. package/src/host/react-mount-runtime.ts +7 -48
  111. package/src/host/realm-services.ts +1 -1
  112. package/src/host/roots/canvas-root.tsx +373 -0
  113. package/src/host/roots/r3f-root.tsx +473 -0
  114. package/src/host/roots/react-root.ts +9 -43
  115. package/src/host/served-bundle-runtime-modules.ts +3 -19
  116. package/src/host/server-log-bridge.ts +2 -2
  117. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  118. package/src/host/stories/pixi-story-model.ts +30 -0
  119. package/src/host/stories/story-media-captures.ts +46 -0
  120. package/src/host/stories/story-media-presence.ts +3 -3
  121. package/src/host/stories/story-pixi-preview.ts +408 -0
  122. package/src/host/stories/story-three-preview.ts +806 -0
  123. package/src/host/stories/three-story-captures.ts +35 -0
  124. package/src/host/story-three-preview-runtime.ts +56 -0
  125. package/src/host/use-active-performance-source.ts +2 -2
  126. package/src/host/viewport-pose-memory.ts +1 -1
  127. package/src/host/viewport-root-presentation.ts +6 -5
  128. package/src/ingest/active-ingest.ts +1 -1
  129. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  130. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  131. package/src/ingest/deferred-ingest-play.ts +6 -6
  132. package/src/ingest/discovery-public-ingest.ts +2 -2
  133. package/src/ingest/ingest-boot-viewport.ts +2 -2
  134. package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
  135. package/src/ingest/ingest-canvas-scene.ts +3 -3
  136. package/src/ingest/ingest-evidence-hook.ts +1 -1
  137. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  138. package/src/ingest/ingest-play-control.ts +1 -1
  139. package/src/ingest/ingest-render-debug.ts +10 -10
  140. package/src/ingest/ingest-siblings.ts +13 -25
  141. package/src/ingest/module-mode.ts +14 -14
  142. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  143. package/src/ingest/mount-coverage.ts +2 -2
  144. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  145. package/src/ingest/mount-ingest-root.ts +8 -8
  146. package/src/ingest/mount-three-ingest-root.ts +8 -8
  147. package/src/ingest/resolve-canvas.ts +1 -1
  148. package/src/ingest/served-html-boot.ts +1 -1
  149. package/src/ingest/surface-canvas.ts +1 -1
  150. package/src/ingest/unmount-ingest-root.ts +5 -5
  151. package/src/navmesh/navmesh-handler.ts +24 -16
  152. package/src/network/NetworkInspectorPanel.tsx +939 -37
  153. package/src/network/network-inspector-model.ts +20 -2
  154. package/src/play/play-log-events.ts +1 -1
  155. package/src/play/play-mode.ts +76 -133
  156. package/src/play/play-recording.ts +1 -1
  157. package/src/play/react-play-live-authoring.ts +3 -3
  158. package/src/play/run-selection.ts +93 -0
  159. package/src/play-bar/PlayBar.tsx +20 -39
  160. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  161. package/src/profiler/PerformancePanel.tsx +3 -3
  162. package/src/react/design-time-react-mount.ts +23 -65
  163. package/src/react/dom-authoring-adapter.ts +9 -9
  164. package/src/react/react-inspector-section.tsx +5 -5
  165. package/src/react/react-world-authoring-adapter.ts +11 -11
  166. package/src/react/story-documents/story-documents.tsx +9 -9
  167. package/src/react/ui-board-document.tsx +7 -7
  168. package/src/react/ui-component-board.ts +2 -2
  169. package/src/runtime/adapter/audio-meter.ts +21 -0
  170. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  171. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  172. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  173. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  174. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  175. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  176. package/src/runtime/adapter/native-debug-module.ts +498 -0
  177. package/src/runtime/audio/bus-mixer.ts +161 -0
  178. package/src/runtime/audio/pose-guard.ts +80 -0
  179. package/src/runtime/core/frame-pacing.ts +126 -0
  180. package/src/runtime/core/game-loop.ts +225 -0
  181. package/src/runtime/core/game-scoped-slot.ts +28 -0
  182. package/src/runtime/core/seeded-random.ts +162 -0
  183. package/src/runtime/core/sim-clock.ts +391 -0
  184. package/src/runtime/core/system-runner.ts +269 -0
  185. package/src/runtime/core/types.ts +104 -0
  186. package/src/runtime/create-runtime.ts +1128 -0
  187. package/src/runtime/debug-bridge.ts +570 -0
  188. package/src/runtime/debug-registry.ts +899 -0
  189. package/src/runtime/dev/chrome-trace.ts +153 -0
  190. package/src/runtime/dev/instruments.ts +403 -0
  191. package/src/runtime/dev/logger.ts +119 -0
  192. package/src/runtime/dev/performance-profiler.ts +367 -0
  193. package/src/runtime/dev/register-render-vitals.ts +276 -0
  194. package/src/runtime/dev/render-census.ts +354 -0
  195. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  196. package/src/runtime/dev/render-memory.ts +226 -0
  197. package/src/runtime/dev/render-vitals.ts +338 -0
  198. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  199. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  200. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  201. package/src/runtime/dev-build.ts +47 -0
  202. package/src/runtime/game.ts +1636 -0
  203. package/src/runtime/gameplay-rng-trap.ts +135 -0
  204. package/src/runtime/host-context.ts +64 -0
  205. package/src/runtime/input-router.ts +182 -0
  206. package/src/runtime/mount-manifest.ts +480 -0
  207. package/src/runtime/pixi/authoring.ts +706 -0
  208. package/src/runtime/pixi/ingest.ts +116 -0
  209. package/src/runtime/pixi/physics-registry.ts +49 -0
  210. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  211. package/src/runtime/pixi/scene-capture.ts +179 -0
  212. package/src/runtime/pixi/system-adapters.ts +69 -0
  213. package/src/runtime/playtest.ts +22 -0
  214. package/src/runtime/presentation.ts +141 -0
  215. package/src/runtime/render-control.ts +642 -0
  216. package/src/runtime/render-seed.ts +77 -0
  217. package/src/runtime/run-ticks-settled.ts +73 -0
  218. package/src/runtime/setup/setup-audio.ts +72 -0
  219. package/src/services/audio-pose-guard.ts +2 -2
  220. package/src/services/game-audio.ts +152 -0
  221. package/src/services/game-network.ts +767 -0
  222. package/src/services/game-physics.ts +334 -0
  223. package/src/state-watch/StateWatchPanel.tsx +1 -1
  224. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  225. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  226. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  227. package/src/three/authoring/oid-source-persistence.ts +7 -7
  228. package/src/three/authoring/r3f-design-session.ts +58 -54
  229. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  230. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  231. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  232. package/src/three/component-verbs/extract-menu.ts +7 -6
  233. package/src/three/component-verbs/fork-menu.ts +7 -6
  234. package/src/three/component-verbs/internals-menu.ts +2 -2
  235. package/src/three/story-documents/three-story-documents.tsx +13 -13
  236. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  237. package/src/three/three-board/board-scene.ts +6 -6
  238. package/src/three/three-board/three-component-board.ts +2 -2
  239. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,188 @@
1
+ /**
2
+ * THE ROUTING from a draw-call reading to the one-line fix.
3
+ *
4
+ * `dev/render-vitals.ts` can already tell a game it is submitting 4,000 draws
5
+ * and `dev/render-census.ts` can already say which subtree they are in. Both
6
+ * require someone to ASK, and both require that someone to already know that
7
+ * static batching exists, is possible here, and is spelled `<Frozen>`. An
8
+ * agent building a game does not know any of that, so the measurement has to
9
+ * do the routing itself — the same idiom as dev-tools' unconfigured-section
10
+ * warning: the reading names the exact edit.
11
+ *
12
+ * ── THE DECISION IS PURE, THE SCHEDULE IS NOT ───────────────────────────────
13
+ * {@link decideStaticBatchAdvisory} takes a draw-call count and two scan
14
+ * reports and answers with an advisory or `null`. It reads no clock, no
15
+ * scene, no console. `editor-game/src/runtime/dev/register-render-vitals.ts` owns the impure half —
16
+ * when to scan, and warning once — because that is where the profiler
17
+ * subscription already lives.
18
+ *
19
+ * ── COST ────────────────────────────────────────────────────────────────────
20
+ * The scan is TWO walks of the scene graph, once, after the frame rate has
21
+ * settled ({@link ADVISOR_SETTLE_FRAMES}). Never per frame: a walk of 4,000
22
+ * nodes every frame is itself the kind of cost this advisory exists to
23
+ * remove, and the answer does not change from one frame to the next in a world
24
+ * whose scenery is mount-static — which is the only world the advice applies
25
+ * to anyway.
26
+ *
27
+ * ── WHY IT ASKS INSTEAD OF ACTING ───────────────────────────────────────────
28
+ * "These 2,600 meshes are the same draw" is measurable. "These 2,600 meshes
29
+ * never move" is NOT — nothing in a scene graph distinguishes scenery from a
30
+ * thing that will move on the next input. Inferring it and batching anyway is
31
+ * how a batcher freezes a door half-open. So the advisory names the subtree
32
+ * and the wrapper, and the author (who knows) places it.
33
+ */
34
+
35
+ import type { CensusReport, StructuralBatchReport } from './render-census';
36
+
37
+ /**
38
+ * Draw calls below which no advisory fires, however batchable the scene.
39
+ *
40
+ * The field measurement this capability came out of: ~4,000 draws cost ~11 ms
41
+ * of CPU submission per frame — about 2.75 µs each on a desktop browser. At
42
+ * 500 draws that is ~1.4 ms, roughly 8% of a 60 Hz frame: the first point
43
+ * where halving it is a visible win rather than noise a profiler cannot
44
+ * separate from jitter. Below it, an advisory would be a nag pointing at
45
+ * something that is not costing anything, and a nag that is usually wrong is
46
+ * one nobody reads when it is right.
47
+ */
48
+ export const ADVISOR_DRAW_CALL_THRESHOLD = 500;
49
+
50
+ /**
51
+ * The share of the draw calls that must be collapsible before the advice is
52
+ * worth an edit. Half: below that, the wrapper leaves most of the cost exactly
53
+ * where it was, and "you could remove a third of a third" is not a payoff
54
+ * anyone should restructure a scene for.
55
+ */
56
+ export const ADVISOR_COLLAPSIBLE_SHARE = 0.5;
57
+
58
+ /**
59
+ * A subtree must hold at least this share of the collapsible meshes to be
60
+ * named as THE address. Under it the advisory says "across the scene" — an
61
+ * invented address is worse than none, because the reader wraps the wrong
62
+ * group and measures no change.
63
+ */
64
+ export const ADVISOR_SUBTREE_SHARE = 0.4;
65
+
66
+ /**
67
+ * Presented frames to wait before scanning — ~2 s at 60 Hz. Long enough for
68
+ * asset loads and the first setup pass to finish populating the graph (a scan
69
+ * at frame one measures an empty world and stays silent forever), short enough
70
+ * that the line lands while the author is still looking at the boot.
71
+ */
72
+ export const ADVISOR_SETTLE_FRAMES = 120;
73
+
74
+ /** The finding, as a caller can present it however it likes. */
75
+ export interface StaticBatchAdvisory {
76
+ /** The reading that triggered it. */
77
+ readonly drawCalls: number;
78
+ /** Meshes sitting in a structural family of two or more. */
79
+ readonly collapsible: number;
80
+ /** The subtree holding most of them, or `null` when they are spread out. */
81
+ readonly subtree: string | null;
82
+ /** The exact edit, ready to paste — `<Frozen name="Terminal">`. */
83
+ readonly fix: string;
84
+ /** The one-line console message: payoff, address, edit, install. */
85
+ readonly message: string;
86
+ }
87
+
88
+ export interface StaticBatchAdvisoryInput {
89
+ /** `render.vitals`' own reading. `null` before the first presented frame. */
90
+ readonly drawCalls: number | null;
91
+ /** The one-level census, used only to sanity-check the address against a
92
+ * subtree that genuinely exists in the graph. */
93
+ readonly census: CensusReport;
94
+ /** The STRUCTURAL scan — see `render-census.ts` for why the identity scan
95
+ * cannot answer this. */
96
+ readonly structural: StructuralBatchReport;
97
+ }
98
+
99
+ /** `2600` → `2,600`. Digits a person reads at a glance, in the one place the
100
+ * message is built, so every number in it is grouped the same way. */
101
+ function grouped(value: number): string {
102
+ return value.toLocaleString('en-US');
103
+ }
104
+
105
+ /**
106
+ * Decide whether this frame's cost is worth an advisory, and what it should
107
+ * say. Pure — every input is an argument, and the same arguments always
108
+ * produce the same message.
109
+ */
110
+ export function decideStaticBatchAdvisory(
111
+ input: StaticBatchAdvisoryInput,
112
+ ): StaticBatchAdvisory | null {
113
+ const { drawCalls, census, structural } = input;
114
+ if (drawCalls === null || drawCalls < ADVISOR_DRAW_CALL_THRESHOLD) return null;
115
+
116
+ const { collapsible } = structural;
117
+ if (collapsible < drawCalls * ADVISOR_COLLAPSIBLE_SHARE) return null;
118
+
119
+ // The address, when one subtree genuinely dominates. `bySubtree` is already
120
+ // sorted, and a name that no census row confirms is not offered: it would
121
+ // send the reader looking for a node the other render.* commands cannot
122
+ // find either.
123
+ const addressable = new Set(census.subtrees.map((row) => row.name));
124
+ const leader = structural.bySubtree[0];
125
+ const subtree =
126
+ leader &&
127
+ leader.collapsible >= collapsible * ADVISOR_SUBTREE_SHARE &&
128
+ addressable.has(leader.name)
129
+ ? leader.name
130
+ : null;
131
+
132
+ const fix = subtree === null ? '<Frozen>' : `<Frozen name="${subtree}">`;
133
+ const where = subtree === null ? 'spread across the scene' : `mostly under "${subtree}"`;
134
+ const message =
135
+ // `collapsible` counts meshes in the graph (culled ones included) and `drawCalls` this
136
+ // frame's submissions, so each number is stated in its own unit, never as a share.
137
+ `[static-batch] ${grouped(drawCalls)} draw calls this frame; ~${grouped(collapsible)} meshes ` +
138
+ `are repeats of ${grouped(structural.familyCount)} structural families, ` +
139
+ `${where}. If that scenery is mount-static — nothing under it moves, re-colours or ` +
140
+ `unmounts after mount — one wrapper collapses it to a few draws: wrap it in ` +
141
+ `${fix}…</Frozen> (vgai add static-batch). Reactive scenery goes outside the wrapper, ` +
142
+ `and a subtree that must stay unbatched declares it: userData={{ staticBatch: false }}.`;
143
+
144
+ return { drawCalls, collapsible, subtree, fix, message };
145
+ }
146
+
147
+ /**
148
+ * Once per PAGE, not once per module evaluation — a hot reload re-runs this
149
+ * module, and an advisory that reappears on every save is one that gets muted
150
+ * along with everything else on the console. Same mechanism, and the same
151
+ * reason, as dev-tools' unconfigured-section warning.
152
+ */
153
+ const WARNED_KEY = '__vgaiStaticBatchAdvised';
154
+
155
+ function alreadyWarned(): boolean {
156
+ return (globalThis as unknown as Record<string, boolean | undefined>)[WARNED_KEY] === true;
157
+ }
158
+
159
+ /**
160
+ * The console IS this advisory's channel: `vgai status` reports console
161
+ * warnings, which is where a building agent already looks. An in-editor
162
+ * banner would be one nobody opens, and a provider would be one nobody reads
163
+ * without already knowing to ask.
164
+ */
165
+ function warnOnConsole(message: string): void {
166
+ // biome-ignore lint/suspicious/noConsole: this function's entire job — see above.
167
+ console.warn(message);
168
+ }
169
+
170
+ /**
171
+ * Emit `advisory` on the console, at most once per page. Answers whether it
172
+ * warned, so a caller can stop scanning.
173
+ */
174
+ export function warnStaticBatchAdvisory(
175
+ advisory: StaticBatchAdvisory,
176
+ /** Injectable so the decision is testable without a console. */
177
+ warn: (message: string) => void = warnOnConsole,
178
+ ): boolean {
179
+ if (alreadyWarned()) return false;
180
+ (globalThis as unknown as Record<string, boolean>)[WARNED_KEY] = true;
181
+ warn(advisory.message);
182
+ return true;
183
+ }
184
+
185
+ /** Test-only: forget that the advisory was ever emitted. */
186
+ export function __resetStaticBatchAdvisoryForTest(): void {
187
+ (globalThis as unknown as Record<string, boolean | undefined>)[WARNED_KEY] = undefined;
188
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * First-party WebGL2 single-frame draw-call capture (W4b, F11 frame debugger).
3
+ *
4
+ * WHY FIRST-PARTY, NOT spectorjs (recorded per the "use libraries directly, no
5
+ * wrappers" rule): the capture seam we need is the WebGL2 context this engine
6
+ * ALREADY owns end-to-end (`renderer.getContext()` in
7
+ * `editor-game/src/host/roots/r3f-root.tsx`). spectorjs is absent from node_modules, and its
8
+ * actual value is a bundled inspector UI we would discard — adopting it imports
9
+ * ~2MB of library to keep ~10% of it, and it wraps the context with its own
10
+ * global patching model rather than the instance-shadow-and-restore discipline
11
+ * the rest of dev/ uses (see `webgl-gpu-timer.ts`, the raw-context precedent
12
+ * this mirrors: instance shadowing, restore-in-finally, mock-GL unit test). So
13
+ * we instrument the real context directly, at the one seam we control.
14
+ *
15
+ * DISCIPLINE (mirrors webgl-gpu-timer.ts):
16
+ * - Patching happens ONLY inside `beginPass()` and ONLY while `armed`. Every
17
+ * patch is an OWN-property shadow on the context instance; the WebGL2
18
+ * prototype is NEVER touched. `endPass()` restores every original in a
19
+ * `finally` (own props reassigned, prototype-inherited methods `delete`d so
20
+ * the real method shows through again), so a throwing draw cannot leave the
21
+ * context wrapped.
22
+ * - HONESTY (adapters never fabricate): a value we cannot measure at this seam
23
+ * is recorded as `null` with the reason implied by the field, never zeroed
24
+ * or guessed. Program/framebuffer LABELS are capture-local identity tags
25
+ * (`program#N`), not the object's real GL debug name (raw WebGL2 exposes
26
+ * none). Framebuffer dimensions are only reported when cheaply knowable
27
+ * (renderbuffer color attachment); a texture-attachment FBO's size is not
28
+ * queryable in raw WebGL2, so it is `null`, not invented (see below —
29
+ * a documented deviation from the plan's non-nullable width/height).
30
+ * - Draw attribution is a single pending-annotation slot consumed by the NEXT
31
+ * draw and then cleared: a second draw with no fresh annotation records
32
+ * `annotation: null` (counted as unattributed), never the previous draw's.
33
+ */
34
+
35
+ import type {
36
+ DrawAnnotation,
37
+ DrawTarget,
38
+ FrameCapture,
39
+ FrameCaptureDrawCall,
40
+ FrameCaptureEntryPoint,
41
+ } from '@volter/editor-project/adapter/frame-capture';
42
+
43
+ export type {
44
+ DrawAnnotation,
45
+ DrawTarget,
46
+ FrameCapture,
47
+ FrameCaptureDrawCall,
48
+ FrameCaptureEntryPoint,
49
+ } from '@volter/editor-project/adapter/frame-capture';
50
+
51
+ export interface WebGLFrameCaptureOptions {
52
+ /** Injected clock for `capturedAt`; defaults to `Date.now`. Injectable so a
53
+ * test gets deterministic timestamps (mirrors the profiler stamping time
54
+ * through a single `now()` indirection rather than a module-level call). */
55
+ now?: () => number;
56
+ /** Hard cap on recorded draws (default 5000). Draws past it are dropped and
57
+ * counted in `totals.truncated`, with a note. */
58
+ maxDrawCalls?: number;
59
+ }
60
+
61
+ export interface WebGLFrameCapture {
62
+ /** Arm a single capture; the NEXT `beginPass()` patches the context. */
63
+ arm(): void;
64
+ readonly armed: boolean;
65
+ /** If armed, shadow-patch the context. No-op otherwise (zero patching when
66
+ * not armed). Must be paired with `endPass()`. */
67
+ beginPass(): void;
68
+ /** Restore all patches (in `finally`) and, if a pass was patched, return the
69
+ * assembled {@link FrameCapture}. Returns `null` if `beginPass` did not
70
+ * patch (was not armed). One-shot: disarms after. */
71
+ endPass(): FrameCapture | null;
72
+ /** Set the annotation the next draw will consume (then cleared). */
73
+ annotateNextDraw(annotation: DrawAnnotation): void;
74
+ }
75
+
76
+ const DEFAULT_MAX_DRAW_CALLS = 5000;
77
+
78
+ /** GL primitive-mode enum → name (decoded once, from the live context). */
79
+ function buildModeTable(gl: WebGL2RenderingContext): Map<number, string> {
80
+ return new Map<number, string>([
81
+ [gl.POINTS, 'POINTS'],
82
+ [gl.LINES, 'LINES'],
83
+ [gl.LINE_LOOP, 'LINE_LOOP'],
84
+ [gl.LINE_STRIP, 'LINE_STRIP'],
85
+ [gl.TRIANGLES, 'TRIANGLES'],
86
+ [gl.TRIANGLE_STRIP, 'TRIANGLE_STRIP'],
87
+ [gl.TRIANGLE_FAN, 'TRIANGLE_FAN'],
88
+ ]);
89
+ }
90
+
91
+ export function createWebGLFrameCapture(
92
+ gl: WebGL2RenderingContext,
93
+ options: WebGLFrameCaptureOptions = {},
94
+ ): WebGLFrameCapture {
95
+ const nowFn = options.now ?? (() => Date.now());
96
+ const maxDrawCalls = options.maxDrawCalls ?? DEFAULT_MAX_DRAW_CALLS;
97
+ const modeTable = buildModeTable(gl);
98
+
99
+ // Persistent identity maps (stable labels across passes).
100
+ const programLabels = new Map<WebGLProgram, string>();
101
+ const framebufferLabels = new Map<WebGLFramebuffer, string>();
102
+ let nextProgramId = 0;
103
+ let nextFramebufferId = 0;
104
+ let captureId = 0;
105
+
106
+ // Shadow-tracked GL state (updated by the wrapped setters during a pass).
107
+ let currentProgram: WebGLProgram | null = null;
108
+ let currentFramebuffer: WebGLFramebuffer | null = null;
109
+ let currentViewport: [number, number, number, number] = [0, 0, 0, 0];
110
+
111
+ // Per-pass accumulators.
112
+ let armed = false;
113
+ let patched = false;
114
+ let drawCalls: FrameCaptureDrawCall[] = [];
115
+ let truncated = 0;
116
+ let notes: string[] = [];
117
+ let pendingAnnotation: DrawAnnotation | null = null;
118
+
119
+ // Saved originals for restore. Value is the original fn; a key present in
120
+ // `wasOwn` means it was an own property (restore by assignment), otherwise
121
+ // it was prototype-inherited (restore by delete).
122
+ const originals = new Map<string, unknown>();
123
+ const wasOwn = new Set<string>();
124
+
125
+ function decodeMode(mode: number): string {
126
+ return modeTable.get(mode) ?? `0x${mode.toString(16)}`;
127
+ }
128
+
129
+ function labelForProgram(program: WebGLProgram | null): string | null {
130
+ if (!program) return null;
131
+ let label = programLabels.get(program);
132
+ if (label === undefined) {
133
+ label = `program#${nextProgramId++}`;
134
+ programLabels.set(program, label);
135
+ }
136
+ return label;
137
+ }
138
+
139
+ /** Best-effort color-attachment dimensions for a bound draw FBO. Only a
140
+ * RENDERBUFFER color attachment is cheaply queryable in raw WebGL2; a
141
+ * texture attachment is not, so this returns nulls (honest absence). All
142
+ * reads are guarded — a mock/foreign context returns nulls, never throws. */
143
+ function measureFramebufferSize(): { width: number | null; height: number | null } {
144
+ try {
145
+ const type = gl.getFramebufferAttachmentParameter(
146
+ gl.DRAW_FRAMEBUFFER,
147
+ gl.COLOR_ATTACHMENT0,
148
+ gl.FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE,
149
+ );
150
+ if (type === gl.RENDERBUFFER) {
151
+ const rb = gl.getFramebufferAttachmentParameter(
152
+ gl.DRAW_FRAMEBUFFER,
153
+ gl.COLOR_ATTACHMENT0,
154
+ gl.FRAMEBUFFER_ATTACHMENT_OBJECT_NAME,
155
+ ) as WebGLRenderbuffer | null;
156
+ if (!rb) return { width: null, height: null };
157
+ const prev = gl.getParameter(gl.RENDERBUFFER_BINDING) as WebGLRenderbuffer | null;
158
+ gl.bindRenderbuffer(gl.RENDERBUFFER, rb);
159
+ const width = gl.getRenderbufferParameter(gl.RENDERBUFFER, gl.RENDERBUFFER_WIDTH) as number;
160
+ const height = gl.getRenderbufferParameter(
161
+ gl.RENDERBUFFER,
162
+ gl.RENDERBUFFER_HEIGHT,
163
+ ) as number;
164
+ gl.bindRenderbuffer(gl.RENDERBUFFER, prev);
165
+ return { width, height };
166
+ }
167
+ } catch {
168
+ // Foreign/mock context or an attachment we can't introspect — degrade
169
+ // to unknown dimensions rather than throwing mid-capture.
170
+ }
171
+ return { width: null, height: null };
172
+ }
173
+
174
+ function currentTarget(): DrawTarget {
175
+ if (!currentFramebuffer) return { kind: 'canvas' };
176
+ let label = framebufferLabels.get(currentFramebuffer);
177
+ if (label === undefined) {
178
+ label = `framebuffer#${nextFramebufferId++}`;
179
+ framebufferLabels.set(currentFramebuffer, label);
180
+ }
181
+ const { width, height } = measureFramebufferSize();
182
+ return { kind: 'framebuffer', width, height, label };
183
+ }
184
+
185
+ function readCull(): 'front' | 'back' | 'none' {
186
+ if (!gl.getParameter(gl.CULL_FACE)) return 'none';
187
+ return gl.getParameter(gl.CULL_FACE_MODE) === gl.FRONT ? 'front' : 'back';
188
+ }
189
+
190
+ function recordDraw(
191
+ entryPoint: FrameCaptureEntryPoint,
192
+ mode: number,
193
+ count: number,
194
+ instanceCount: number | null,
195
+ ): void {
196
+ if (drawCalls.length >= maxDrawCalls) {
197
+ truncated++;
198
+ pendingAnnotation = null;
199
+ return;
200
+ }
201
+ const annotation = pendingAnnotation;
202
+ pendingAnnotation = null;
203
+ drawCalls.push({
204
+ index: drawCalls.length,
205
+ entryPoint,
206
+ mode: decodeMode(mode),
207
+ count,
208
+ instanceCount,
209
+ programLabel: labelForProgram(currentProgram),
210
+ target: currentTarget(),
211
+ viewport: [...currentViewport] as [number, number, number, number],
212
+ state: {
213
+ blend: Boolean(gl.getParameter(gl.BLEND)),
214
+ depthTest: Boolean(gl.getParameter(gl.DEPTH_TEST)),
215
+ depthWrite: Boolean(gl.getParameter(gl.DEPTH_WRITEMASK)),
216
+ cull: readCull(),
217
+ scissor: Boolean(gl.getParameter(gl.SCISSOR_TEST)),
218
+ },
219
+ annotation: annotation ?? null,
220
+ });
221
+ }
222
+
223
+ // Cast to an index signature to shadow methods without fighting the DOM lib
224
+ // types on every wrapped name.
225
+ const target = gl as unknown as Record<string, unknown>;
226
+
227
+ function patch(name: string, wrapper: (original: (...a: unknown[]) => unknown) => unknown): void {
228
+ const original = target[name] as (...a: unknown[]) => unknown;
229
+ originals.set(name, original);
230
+ if (Object.hasOwn(gl, name)) wasOwn.add(name);
231
+ target[name] = wrapper(original.bind(gl));
232
+ }
233
+
234
+ function restoreAll(): void {
235
+ for (const [name, original] of originals) {
236
+ if (wasOwn.has(name)) target[name] = original;
237
+ else delete target[name];
238
+ }
239
+ originals.clear();
240
+ wasOwn.clear();
241
+ }
242
+
243
+ function installPatches(): void {
244
+ // Seed shadow state from the live context so the first draw's viewport /
245
+ // framebuffer / program reflect reality even before any setter fires.
246
+ try {
247
+ const vp = gl.getParameter(gl.VIEWPORT) as ArrayLike<number> | null;
248
+ if (vp && vp.length >= 4) currentViewport = [vp[0]!, vp[1]!, vp[2]!, vp[3]!];
249
+ currentFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING) as WebGLFramebuffer | null;
250
+ currentProgram = gl.getParameter(gl.CURRENT_PROGRAM) as WebGLProgram | null;
251
+ } catch {
252
+ // Mock context without full getParameter coverage — start from defaults.
253
+ }
254
+
255
+ patch('useProgram', (original) => (program: unknown) => {
256
+ currentProgram = (program as WebGLProgram) ?? null;
257
+ return original(program);
258
+ });
259
+ patch('bindFramebuffer', (original) => (bindTarget: unknown, framebuffer: unknown) => {
260
+ // Track the DRAW framebuffer binding (FRAMEBUFFER and DRAW_FRAMEBUFFER
261
+ // both affect it; READ_FRAMEBUFFER does not).
262
+ if (bindTarget === gl.FRAMEBUFFER || bindTarget === gl.DRAW_FRAMEBUFFER) {
263
+ currentFramebuffer = (framebuffer as WebGLFramebuffer) ?? null;
264
+ }
265
+ return original(bindTarget, framebuffer);
266
+ });
267
+ patch('viewport', (original) => (x: unknown, y: unknown, w: unknown, h: unknown) => {
268
+ currentViewport = [x as number, y as number, w as number, h as number];
269
+ return original(x, y, w, h);
270
+ });
271
+
272
+ patch('drawArrays', (original) => (mode: unknown, first: unknown, count: unknown) => {
273
+ recordDraw('drawArrays', mode as number, count as number, null);
274
+ return original(mode, first, count);
275
+ });
276
+ patch(
277
+ 'drawElements',
278
+ (original) => (mode: unknown, count: unknown, type: unknown, offset: unknown) => {
279
+ recordDraw('drawElements', mode as number, count as number, null);
280
+ return original(mode, count, type, offset);
281
+ },
282
+ );
283
+ patch(
284
+ 'drawArraysInstanced',
285
+ (original) => (mode: unknown, first: unknown, count: unknown, instanceCount: unknown) => {
286
+ recordDraw('drawArraysInstanced', mode as number, count as number, instanceCount as number);
287
+ return original(mode, first, count, instanceCount);
288
+ },
289
+ );
290
+ patch(
291
+ 'drawElementsInstanced',
292
+ (original) =>
293
+ (mode: unknown, count: unknown, type: unknown, offset: unknown, instanceCount: unknown) => {
294
+ recordDraw(
295
+ 'drawElementsInstanced',
296
+ mode as number,
297
+ count as number,
298
+ instanceCount as number,
299
+ );
300
+ return original(mode, count, type, offset, instanceCount);
301
+ },
302
+ );
303
+ patch(
304
+ 'drawRangeElements',
305
+ (original) =>
306
+ (
307
+ mode: unknown,
308
+ start: unknown,
309
+ end: unknown,
310
+ count: unknown,
311
+ type: unknown,
312
+ offset: unknown,
313
+ ) => {
314
+ recordDraw('drawRangeElements', mode as number, count as number, null);
315
+ return original(mode, start, end, count, type, offset);
316
+ },
317
+ );
318
+ }
319
+
320
+ return {
321
+ arm() {
322
+ armed = true;
323
+ },
324
+ get armed() {
325
+ return armed;
326
+ },
327
+ beginPass() {
328
+ if (!armed || patched) return;
329
+ patched = true;
330
+ drawCalls = [];
331
+ truncated = 0;
332
+ notes = [];
333
+ pendingAnnotation = null;
334
+ installPatches();
335
+ },
336
+ endPass(): FrameCapture | null {
337
+ if (!patched) {
338
+ armed = false;
339
+ return null;
340
+ }
341
+ try {
342
+ if (truncated > 0) {
343
+ notes.push(
344
+ `Draw list capped at ${maxDrawCalls}; ${truncated} later draw(s) were not recorded.`,
345
+ );
346
+ }
347
+ const unattributed = drawCalls.reduce((n, d) => n + (d.annotation === null ? 1 : 0), 0);
348
+ return {
349
+ id: ++captureId,
350
+ capturedAt: nowFn(),
351
+ drawCalls: drawCalls.slice(),
352
+ totals: { drawCalls: drawCalls.length, unattributed, truncated },
353
+ notes: notes.slice(),
354
+ };
355
+ } finally {
356
+ restoreAll();
357
+ patched = false;
358
+ armed = false;
359
+ pendingAnnotation = null;
360
+ }
361
+ },
362
+ annotateNextDraw(annotation: DrawAnnotation) {
363
+ pendingAnnotation = annotation;
364
+ },
365
+ };
366
+ }
@@ -0,0 +1,53 @@
1
+ interface DisjointTimerQueryExtension {
2
+ readonly TIME_ELAPSED_EXT: number;
3
+ readonly GPU_DISJOINT_EXT: number;
4
+ }
5
+
6
+ /** Non-blocking WebGL2 timer query; results are consumed on later frames. */
7
+ export function createWebGLGpuTimer(context: WebGLRenderingContext | WebGL2RenderingContext) {
8
+ const gl = context as WebGL2RenderingContext;
9
+ const extension = gl.getExtension(
10
+ 'EXT_disjoint_timer_query_webgl2',
11
+ ) as DisjointTimerQueryExtension | null;
12
+ const supported = extension !== null && typeof gl.createQuery === 'function';
13
+ const pending: WebGLQuery[] = [];
14
+ let active: WebGLQuery | null = null;
15
+ let latestMs: number | null = null;
16
+
17
+ return {
18
+ begin() {
19
+ if (!supported || active) return;
20
+ active = gl.createQuery();
21
+ if (active) gl.beginQuery(extension!.TIME_ELAPSED_EXT, active);
22
+ },
23
+ end() {
24
+ if (!supported || !active) return;
25
+ gl.endQuery(extension!.TIME_ELAPSED_EXT);
26
+ pending.push(active);
27
+ active = null;
28
+ },
29
+ poll(): number | null {
30
+ if (!supported) return null;
31
+ if (gl.getParameter(extension!.GPU_DISJOINT_EXT)) {
32
+ for (const query of pending.splice(0)) gl.deleteQuery(query);
33
+ return null;
34
+ }
35
+ while (pending.length > 0) {
36
+ const query = pending[0]!;
37
+ if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) break;
38
+ pending.shift();
39
+ latestMs = (gl.getQueryParameter(query, gl.QUERY_RESULT) as number) / 1_000_000;
40
+ gl.deleteQuery(query);
41
+ }
42
+ return latestMs;
43
+ },
44
+ dispose() {
45
+ if (active) {
46
+ gl.endQuery(extension!.TIME_ELAPSED_EXT);
47
+ gl.deleteQuery(active);
48
+ active = null;
49
+ }
50
+ for (const query of pending.splice(0)) gl.deleteQuery(query);
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * THE one owner of "is this a development context". Every reader that has to
3
+ * answer that question calls {@link devBuildEnabled}; there is deliberately NO
4
+ * second source of truth — not a module-level cached boolean, not a
5
+ * `globalThis` flag, not a per-host copy of the `import.meta.env` read below.
6
+ * Instrumentation that ships to players because two places disagreed about
7
+ * what "dev" means is exactly the failure this single owner exists to make
8
+ * impossible.
9
+ *
10
+ * Ownership, stated in one place (the build rule):
11
+ * - OWNER: this function. It resolves the answer; nothing else derives it.
12
+ * - SHARERS: the three-root adapter (`editor-game/src/host/roots/r3f-root.tsx`),
13
+ * which seeds live render vitals only under it. Any future dev-only
14
+ * instrument calls this too, with its own `override`.
15
+ * - TEARDOWN: none. This is a pure predicate over build config and one
16
+ * caller-supplied argument — it owns no resource, allocates nothing, and
17
+ * has no lifecycle to end.
18
+ *
19
+ * The three inputs, highest precedence first:
20
+ * 1. `override` — the explicit per-call answer. A headless test, a capture
21
+ * harness, or a host that knows better passes `true`/`false` and gets
22
+ * exactly that. Passing `undefined` (or omitting it) means "decide for
23
+ * me" and falls through. The editor's own preview mount is the worked
24
+ * case: it is a dev session by definition even when the editor SPA it
25
+ * runs inside is a production build.
26
+ * 2. A dev build — `import.meta.env.DEV`. The ordinary local/editor case.
27
+ * 3. A production build's EXPLICIT opt-in — `VITE_VGAI_DEV_BUILD=true`.
28
+ * Instrumenting a production bundle is a real, legitimate choice (an
29
+ * internal playtest build, a QA build), and it must be an opt-in someone
30
+ * had to type, never something a default drifts into.
31
+ *
32
+ * Anything else — a production build with no opt-in — is `false`.
33
+ */
34
+ export function devBuildEnabled(override?: boolean | undefined): boolean {
35
+ if (override !== undefined) return override;
36
+ // `import.meta` is cast whole, not just its `.env`: this module is reachable
37
+ // from programs whose tsconfig does not pull in `vite/client` (the session
38
+ // client's, for one, which reaches the three adapter transitively), and there
39
+ // `ImportMeta` has no declared `env` at all. The cast keeps the single owner
40
+ // of the dev answer importable from ANY program rather than forcing every
41
+ // downstream tsconfig to adopt Vite's ambient types.
42
+ const env = (import.meta as unknown as { env?: unknown }).env as
43
+ | { DEV?: boolean | undefined; VITE_VGAI_DEV_BUILD?: string | undefined }
44
+ | undefined;
45
+ if (env?.DEV === true) return true;
46
+ return env?.VITE_VGAI_DEV_BUILD === 'true';
47
+ }