@vgai/engine 0.4.1 → 0.5.0-canary.20260719.0

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 (103) hide show
  1. package/README.md +48 -15
  2. package/package.json +11 -25
  3. package/schemas/engine-capabilities.json +10 -10
  4. package/schemas/entity2d.schema.json +468 -0
  5. package/schemas/mat.schema.json +2 -33
  6. package/schemas/prefab.schema.json +16 -172
  7. package/schemas/scn2d.schema.json +42 -23
  8. package/schemas/vgai-game.schema.json +34 -0
  9. package/schemas/vscn.schema.json +16 -172
  10. package/src/adapter/authoring.ts +152 -2
  11. package/src/adapter/colyseus-networking-adapter.ts +35 -1
  12. package/src/adapter/first-party-systems.ts +7 -1
  13. package/src/adapter/game-adapter.ts +13 -0
  14. package/src/adapter/index.ts +25 -0
  15. package/src/adapter/rapier-physics-adapter.ts +55 -2
  16. package/src/adapter/system-adapter.ts +249 -2
  17. package/src/adapter/vgai-scene-game-adapter.ts +149 -25
  18. package/src/ai/navigation.ts +28 -0
  19. package/src/animation/clip-map.ts +1 -8
  20. package/src/animation/theatre-director.ts +50 -0
  21. package/src/animation/xstate-animation-binding.ts +6 -0
  22. package/src/audio/audio-introspection.ts +290 -0
  23. package/src/audio/tone-context.ts +46 -0
  24. package/src/dev/chrome-trace.ts +153 -0
  25. package/src/dev/performance-profiler.ts +93 -6
  26. package/src/dev/render-debug-adapter.ts +199 -0
  27. package/src/dev/render-memory.ts +243 -0
  28. package/src/dev/webgl-frame-capture.ts +424 -0
  29. package/src/ecs/component-manager.ts +43 -10
  30. package/src/ecs/game-component.ts +39 -10
  31. package/src/input/input-manager.ts +24 -19
  32. package/src/input/input-types.ts +1 -1
  33. package/src/loader.ts +7 -0
  34. package/src/manifest/load.ts +14 -0
  35. package/src/manifest/schema.ts +65 -0
  36. package/src/react/game-state.tsx +1 -1
  37. package/src/render/render-batch-system.ts +26 -12
  38. package/src/render/spark-renderer-lifecycle.ts +64 -0
  39. package/src/runtime/create-runtime.ts +33 -5
  40. package/src/runtime/debug-bridge.ts +5 -5
  41. package/src/runtime/game.ts +102 -20
  42. package/src/runtime/mount-manifest.ts +1 -1
  43. package/src/runtime/render-control.ts +121 -0
  44. package/src/runtime/types.ts +1 -1
  45. package/src/scene/asset-loaders.ts +77 -3
  46. package/src/scene/instance-mesh.ts +25 -0
  47. package/src/scene/material-factory.ts +4 -15
  48. package/src/scene/mesh-shadow.ts +18 -0
  49. package/src/scene/particles-factory.ts +59 -0
  50. package/src/scene/scene-loader.ts +41 -16
  51. package/src/scene/schema/instances.ts +1 -2
  52. package/src/scene/schema/material.ts +83 -94
  53. package/src/scene/schema/mesh.ts +76 -90
  54. package/src/scene/schema/scene-file.ts +1 -2
  55. package/src/scene/user-data.ts +30 -14
  56. package/src/setup/setup-renderer.ts +6 -1
  57. package/src/world2d/asset-paths2d.ts +44 -0
  58. package/src/world2d/collision-2d.ts +7 -14
  59. package/src/world2d/entity2d-asset.ts +22 -0
  60. package/src/world2d/index.ts +27 -2
  61. package/src/world2d/physics2d-transform.ts +173 -0
  62. package/src/world2d/physics2d-units.ts +10 -0
  63. package/src/world2d/pixi-game-adapter.ts +148 -36
  64. package/src/world2d/scene2d-identity.ts +49 -0
  65. package/src/world2d/scene2d-loader.ts +243 -119
  66. package/src/world2d/schema/entity2d.ts +51 -33
  67. package/src/world2d/schema/physics2d.ts +14 -3
  68. package/src/world2d/schema/sprite.ts +32 -4
  69. package/src/world2d/schema/tilemap.ts +26 -9
  70. package/src/world2d/transform-writer-2d.ts +29 -11
  71. package/src/world2d/types.ts +21 -8
  72. package/src/world3d-react/behavior.tsx +138 -0
  73. package/src/world3d-react/engine-bridge.ts +48 -0
  74. package/src/world3d-react/index.ts +44 -0
  75. package/src/world3d-react/r3f-adapter.tsx +303 -0
  76. package/src/world3d-react/world-context.ts +294 -0
  77. package/vendor/realism-effects/LICENSE.md +21 -0
  78. package/vendor/realism-effects/UPSTREAM.md +19 -0
  79. package/vendor/realism-effects/dist/index.cjs +3447 -0
  80. package/vendor/realism-effects/dist/index.d.ts +59 -0
  81. package/vendor/realism-effects/dist/index.js +3434 -0
  82. package/vendor/realism-effects/package.json +23 -0
  83. package/src/character/cloth-sim.ts +0 -533
  84. package/src/character/spring-chain.ts +0 -307
  85. package/src/humanoid/body.ts +0 -663
  86. package/src/humanoid/clips.ts +0 -149
  87. package/src/humanoid/compose.ts +0 -209
  88. package/src/humanoid/generate.ts +0 -189
  89. package/src/humanoid/index.ts +0 -36
  90. package/src/humanoid/schema.ts +0 -108
  91. package/src/humanoid/skeleton.ts +0 -345
  92. package/src/react/humanoid-bake.document.tsx +0 -337
  93. package/src/scene/geometries/index.ts +0 -7
  94. package/src/scene/geometries/terrain.ts +0 -42
  95. package/src/scene/geometry-registry.ts +0 -42
  96. package/src/scene/instance-registry.ts +0 -84
  97. package/src/scene/instancers/grid.ts +0 -38
  98. package/src/scene/instancers/index.ts +0 -7
  99. package/src/scene/material-registry.ts +0 -73
  100. package/src/scene/materials/index.ts +0 -7
  101. package/src/scene/materials/water.ts +0 -56
  102. package/src/world2d/components-2d.ts +0 -86
  103. package/tools/humanoid-bake.tool.ts +0 -274
@@ -0,0 +1,290 @@
1
+ /**
2
+ * W3c — first-party `AudioAdapter` INTROSPECTION over the engine's own audio
3
+ * stack (the engine is its own first adapter). `attachAudioIntrospection`
4
+ * augments the mute-only adapter `createAudioSystemAdapter` built with the
5
+ * optional read-only debugger capabilities the editor's Audio tab consumes:
6
+ *
7
+ * - `graphSnapshot()` — the engine bus hierarchy (`setup-audio.ts`'s
8
+ * music/sfx/voice → master → destination, plus the THREE listener tap)
9
+ * and every Tone node a game routed through `connectToneBusToMasterGain`
10
+ * (recorded at that seam — see `tone-context.ts`). Tone-INTERNAL wiring
11
+ * (a game's private synth chains) is not visible from here and is not
12
+ * fabricated; a game wanting richer graphs registers its own adapter.
13
+ * - `transportState()` — Tone's real transport (`Tone.getTransport()`),
14
+ * reported ONLY when the game bridged Tone onto this world's context
15
+ * (`hasToneBridge`); otherwise an honest `null` ("no musical transport"),
16
+ * and Tone's global context is never instantiated just by asking.
17
+ * - `acquireMeters()` — per-bus linear-RMS levels via native `AnalyserNode`
18
+ * taps (the "Tone.Meter or equivalent": analysers work identically
19
+ * whether or not Tone is bridged, and disposal is a plain disconnect).
20
+ * Returns `null` on a headless world (no real `createAnalyser`).
21
+ * - `audioEvents()` — bounded seq-fenced ring fed by REAL observation
22
+ * points only: Tone transport start/stop/pause emitter events (attached
23
+ * lazily once a bridge exists), native context `statechange`, and the
24
+ * adapter's own mute/unmute calls.
25
+ *
26
+ * SQ-4 boundary: this module is the READ-ONLY debugger seam. The full mixer
27
+ * (sends/effects/per-bus gain EDITING, FMOD-class adaptive-audio authoring)
28
+ * waits on SQ-4 and is deliberately absent here.
29
+ *
30
+ * Uses Tone directly (no wrapper). The EDITOR never imports this module or
31
+ * Tone — it consumes only `SystemAdapters.audio` (mirror of the networking
32
+ * rule). Wired by `vgai-scene-game-adapter.ts` via dynamic import so `tone`
33
+ * stays out of the mount-critical chunk, with the returned disposer called
34
+ * from `disposeGame` (no leaked transport listeners across mounts — the
35
+ * Tone transport is a singleton that outlives any one world).
36
+ */
37
+
38
+ import * as Tone from 'tone';
39
+ import type {
40
+ AudioAdapter,
41
+ AudioDebugEvent,
42
+ AudioGraphNode,
43
+ AudioMeterFrame,
44
+ AudioMeterHandle,
45
+ AudioTransportState,
46
+ } from '../adapter/system-adapter';
47
+ import type { AudioContext as GameAudio } from '../setup/setup-audio';
48
+ import { getToneMasterGainRoutes, hasToneBridge } from './tone-context';
49
+
50
+ /** Ring capacity — mirrors the debug-event ring's bounded-drop-oldest idiom. */
51
+ const EVENT_RING_CAP = 200;
52
+
53
+ function now(): number {
54
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
55
+ }
56
+
57
+ function ctorName(value: object): string {
58
+ const name = (value as { constructor?: { name?: string } }).constructor?.name;
59
+ return name && name !== 'Object' ? name : 'AudioNode';
60
+ }
61
+
62
+ /**
63
+ * Attach the W3c introspection capabilities to `adapter` (mutating it — the
64
+ * same object stays registered on `SystemAdapters.audio`) and return a
65
+ * disposer that detaches every listener and releases any meter handle still
66
+ * alive. Idempotent per adapter object is NOT required: call once per mount.
67
+ */
68
+ export function attachAudioIntrospection(adapter: AudioAdapter, audio: GameAudio): () => void {
69
+ // --- event ring -----------------------------------------------------------
70
+ const ring: AudioDebugEvent[] = [];
71
+ let nextSeq = 1;
72
+ const record = (kind: AudioDebugEvent['kind'], detail?: string): void => {
73
+ const event: AudioDebugEvent = { seq: nextSeq++, time: now(), kind };
74
+ if (detail !== undefined) event.detail = detail;
75
+ ring.push(event);
76
+ if (ring.length > EVENT_RING_CAP) ring.splice(0, ring.length - EVENT_RING_CAP);
77
+ };
78
+
79
+ // The native context, when this world has a real one (headless worlds use
80
+ // a plain stand-in whose `listener.context` may be absent).
81
+ const nativeContext = (): (BaseAudioContext & Partial<AudioContext>) | null => {
82
+ const context = (audio.listener as { context?: BaseAudioContext } | undefined)?.context;
83
+ return context && typeof context.state === 'string'
84
+ ? (context as BaseAudioContext & Partial<AudioContext>)
85
+ : null;
86
+ };
87
+
88
+ /** Tone is CURRENTLY bridged onto this world's context. `hasToneBridge`
89
+ * gates first so `Tone.getContext()` is only consulted when a Tone global
90
+ * context already exists (never fabricating one — see tone-context.ts). */
91
+ const isBridged = (): boolean => {
92
+ const context = nativeContext();
93
+ if (!context || !hasToneBridge(context as AudioContext)) return false;
94
+ try {
95
+ return Tone.getContext().rawContext === context;
96
+ } catch {
97
+ return false; // no constructible Tone context in this environment (Node)
98
+ }
99
+ };
100
+
101
+ // --- mute/unmute events: wrap the adapter's OWN entry point ---------------
102
+ const baseSetMuted = adapter.setMuted.bind(adapter);
103
+ adapter.setMuted = (muted: boolean): void => {
104
+ baseSetMuted(muted);
105
+ record(muted ? 'mute' : 'unmute');
106
+ };
107
+
108
+ // --- context statechange --------------------------------------------------
109
+ const context = nativeContext();
110
+ const onStateChange = (): void => {
111
+ record('context-statechange', nativeContext()?.state ?? 'unknown');
112
+ };
113
+ if (context && typeof context.addEventListener === 'function') {
114
+ context.addEventListener('statechange', onStateChange);
115
+ }
116
+
117
+ // --- transport events (lazy: the game bridges Tone DURING its setup, after
118
+ // this adapter is seeded — so hooks attach on the first capability read
119
+ // that finds a live bridge) ------------------------------------------
120
+ let transportHooked = false;
121
+ const onTransportStart = (): void => record('transport-start');
122
+ const onTransportStop = (): void => record('transport-stop');
123
+ const onTransportPause = (): void => record('transport-pause');
124
+ const ensureTransportHooks = (): void => {
125
+ if (transportHooked || !isBridged()) return;
126
+ transportHooked = true;
127
+ const transport = Tone.getTransport();
128
+ transport.on('start', onTransportStart);
129
+ transport.on('stop', onTransportStop);
130
+ transport.on('pause', onTransportPause);
131
+ // A game typically bridges Tone and starts its transport DURING setup(),
132
+ // before anything polls this adapter — that start predates the listeners
133
+ // above. If the transport is already running at hook time, record that
134
+ // truthful observation (marked as such) so the log's first entry isn't a
135
+ // hole where the session's defining event happened.
136
+ if (transport.state === 'started') {
137
+ record('transport-start', 'already running when observed');
138
+ }
139
+ };
140
+
141
+ // --- capabilities ---------------------------------------------------------
142
+
143
+ adapter.graphSnapshot = (): AudioGraphNode[] => {
144
+ const nodes: AudioGraphNode[] = [];
145
+ const contextState = nativeContext()?.state;
146
+ const destination: AudioGraphNode = {
147
+ id: 'destination',
148
+ type: 'AudioDestinationNode',
149
+ label: 'Output',
150
+ outputs: [],
151
+ };
152
+ if (contextState !== undefined) destination.state = contextState;
153
+ nodes.push(destination);
154
+ nodes.push({
155
+ id: 'master',
156
+ type: ctorName(audio.masterGain),
157
+ label: 'Master',
158
+ outputs: ['destination'],
159
+ });
160
+ nodes.push({
161
+ id: 'bus-music',
162
+ type: ctorName(audio.buses.music),
163
+ label: 'Music bus',
164
+ outputs: ['master'],
165
+ });
166
+ nodes.push({
167
+ id: 'bus-sfx',
168
+ type: ctorName(audio.buses.sfx),
169
+ label: 'SFX bus',
170
+ outputs: ['master'],
171
+ });
172
+ nodes.push({
173
+ id: 'bus-voice',
174
+ type: ctorName(audio.buses.voice),
175
+ label: 'Voice bus',
176
+ outputs: ['master'],
177
+ });
178
+ if (audio.listener) {
179
+ nodes.push({
180
+ id: 'listener',
181
+ type: ctorName(audio.listener),
182
+ label: '3D listener (THREE)',
183
+ outputs: ['master'],
184
+ });
185
+ }
186
+ // Tone nodes routed into THIS world's master bus (seam-recorded; disposed
187
+ // nodes are pruned by the registry itself).
188
+ getToneMasterGainRoutes(audio.masterGain).forEach((toneNode, index) => {
189
+ nodes.push({
190
+ id: `tone-${index}`,
191
+ // Tone nodes carry their own class name (`'Gain'`, `'Volume'`…).
192
+ type: (toneNode as { name?: string }).name ?? ctorName(toneNode),
193
+ label: `Tone route ${index + 1}`,
194
+ outputs: ['master'],
195
+ });
196
+ });
197
+ return nodes;
198
+ };
199
+
200
+ adapter.transportState = (): AudioTransportState | null => {
201
+ ensureTransportHooks();
202
+ if (!isBridged()) return null; // honest: no musical transport in this world
203
+ const transport = Tone.getTransport();
204
+ return {
205
+ state: transport.state,
206
+ seconds: transport.seconds,
207
+ bpm: transport.bpm.value,
208
+ position: String(transport.position),
209
+ };
210
+ };
211
+
212
+ const liveMeterHandles = new Set<AudioMeterHandle>();
213
+ adapter.acquireMeters = (): AudioMeterHandle | null => {
214
+ const meterContext = nativeContext();
215
+ if (!meterContext || typeof meterContext.createAnalyser !== 'function') {
216
+ return null; // headless world — metering is impossible, and says so
217
+ }
218
+ const taps = (
219
+ [
220
+ ['master', 'Master', audio.masterGain],
221
+ ['bus-music', 'Music', audio.buses.music],
222
+ ['bus-sfx', 'SFX', audio.buses.sfx],
223
+ ['bus-voice', 'Voice', audio.buses.voice],
224
+ ] as const
225
+ ).map(([id, label, gain]) => {
226
+ const analyser = meterContext.createAnalyser();
227
+ analyser.fftSize = 1024;
228
+ gain.connect(analyser); // a TAP (analyser is a sink), not an insert
229
+ return { id, label, gain, analyser, buffer: new Float32Array(analyser.fftSize) };
230
+ });
231
+ let disposed = false;
232
+ const handle: AudioMeterHandle = {
233
+ read: (): AudioMeterFrame[] =>
234
+ taps.map(({ id, label, analyser, buffer }) => {
235
+ analyser.getFloatTimeDomainData(buffer);
236
+ let sum = 0;
237
+ for (let i = 0; i < buffer.length; i++) {
238
+ const sample = buffer[i] as number;
239
+ sum += sample * sample;
240
+ }
241
+ return { id, label, level: Math.sqrt(sum / buffer.length) };
242
+ }),
243
+ dispose: (): void => {
244
+ if (disposed) return;
245
+ disposed = true;
246
+ for (const tap of taps) {
247
+ try {
248
+ tap.gain.disconnect(tap.analyser);
249
+ } catch {
250
+ // already torn down with the context — nothing to release
251
+ }
252
+ }
253
+ liveMeterHandles.delete(handle);
254
+ },
255
+ };
256
+ liveMeterHandles.add(handle);
257
+ return handle;
258
+ };
259
+
260
+ adapter.audioEvents = (sinceSeq?: number): AudioDebugEvent[] => {
261
+ ensureTransportHooks();
262
+ return sinceSeq !== undefined ? ring.filter((e) => e.seq > sinceSeq) : [...ring];
263
+ };
264
+
265
+ // --- teardown -------------------------------------------------------------
266
+ return (): void => {
267
+ if (transportHooked) {
268
+ try {
269
+ const transport = Tone.getTransport();
270
+ transport.off('start', onTransportStart);
271
+ transport.off('stop', onTransportStop);
272
+ transport.off('pause', onTransportPause);
273
+ } catch {
274
+ // Tone context already closed — the listeners died with it
275
+ }
276
+ transportHooked = false;
277
+ }
278
+ if (context && typeof context.removeEventListener === 'function') {
279
+ context.removeEventListener('statechange', onStateChange);
280
+ }
281
+ // Safety net: a consumer should dispose its own handle, but Stop must
282
+ // never leak analyser taps regardless.
283
+ for (const handle of [...liveMeterHandles]) handle.dispose();
284
+ adapter.setMuted = baseSetMuted;
285
+ delete adapter.graphSnapshot;
286
+ delete adapter.transportState;
287
+ delete adapter.acquireMeters;
288
+ delete adapter.audioEvents;
289
+ };
290
+ }
@@ -95,12 +95,55 @@ export interface ToneAudioBridge {
95
95
  */
96
96
  export function bridgeToneToThreeAudio(context: AudioContext): ToneAudioBridge {
97
97
  Tone.setContext(context);
98
+ bridgedContexts.add(context);
98
99
  return {
99
100
  context,
100
101
  isBridged: () => Tone.getContext().rawContext === context,
101
102
  };
102
103
  }
103
104
 
105
+ // --- W3c introspection bookkeeping (read-only debugger seam) ----------------
106
+ //
107
+ // The registries below record what already flows through THIS module's two
108
+ // seams — nothing new is wrapped; Tone stays used through its own API. They
109
+ // exist so the first-party `AudioAdapter` introspection
110
+ // (`audio-introspection.ts`) can answer "is Tone bridged onto this world's
111
+ // context?" and "which Tone nodes feed this world's master bus?" TRUTHFULLY,
112
+ // without ever instantiating a Tone global context of its own (calling
113
+ // `Tone.getContext()` on a page whose game never used Tone would fabricate
114
+ // one just by asking).
115
+
116
+ const bridgedContexts = new WeakSet<AudioContext>();
117
+
118
+ /** `true` iff {@link bridgeToneToThreeAudio} has run for this exact native
119
+ * context. A cheap pre-check: callers still confirm the bridge is CURRENT
120
+ * via `Tone.getContext().rawContext === context` (a later re-bridge to a
121
+ * different context leaves stale entries here) — but only after this
122
+ * returns `true`, so the confirmation never forces Tone to create a
123
+ * context that didn't already exist. */
124
+ export function hasToneBridge(context: AudioContext): boolean {
125
+ return bridgedContexts.has(context);
126
+ }
127
+
128
+ interface ToneMasterRoute {
129
+ node: ToneAudioNode;
130
+ masterGain: GainNode;
131
+ }
132
+
133
+ const toneMasterRoutes: ToneMasterRoute[] = [];
134
+
135
+ /** The still-alive Tone nodes routed into `masterGain` via
136
+ * {@link connectToneBusToMasterGain}. Disposed nodes (Tone's own
137
+ * `.disposed` flag) are pruned on every call, so a game that cleans up
138
+ * after itself leaves nothing behind here. Filtered by `masterGain`
139
+ * IDENTITY: one world's snapshot never shows another world's routes. */
140
+ export function getToneMasterGainRoutes(masterGain: GainNode): ToneAudioNode[] {
141
+ for (let i = toneMasterRoutes.length - 1; i >= 0; i--) {
142
+ if (toneMasterRoutes[i]?.node.disposed) toneMasterRoutes.splice(i, 1);
143
+ }
144
+ return toneMasterRoutes.filter((r) => r.masterGain === masterGain).map((r) => r.node);
145
+ }
146
+
104
147
  /**
105
148
  * Route a Tone output node (a bus, `Tone.getDestination()`, or any
106
149
  * `Tone.Gain` used as a submix) into the SAME `masterGain`
@@ -113,6 +156,9 @@ export function bridgeToneToThreeAudio(context: AudioContext): ToneAudioBridge {
113
156
  */
114
157
  export function connectToneBusToMasterGain(toneNode: ToneAudioNode, masterGain: GainNode): void {
115
158
  toneNode.connect(masterGain);
159
+ // W3c: record the route so the Audio debugger's graph snapshot can show it
160
+ // (see the introspection-bookkeeping section below).
161
+ toneMasterRoutes.push({ node: toneNode, masterGain });
116
162
  }
117
163
 
118
164
  /**
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Chrome Trace Event Format exporter (W4b, F11 profiler → chrome://tracing /
3
+ * Perfetto). A PURE, deterministic function over recorded
4
+ * {@link PerformanceFrame}s — no clock reads, no randomness, no I/O — so the
5
+ * same frames always serialize to byte-identical output (asserted by the unit
6
+ * test).
7
+ *
8
+ * Format (https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU):
9
+ * - `M` metadata events name the process and thread.
10
+ * - One `X` (complete) event per frame — `ts` at the frame's absolute start,
11
+ * `dur` the frame's CPU time — with nested `X` events per phase and per
12
+ * system placed at their MEASURED `startMs` offsets inside the frame.
13
+ * - One `C` (counter) event per frame carries the render draw/triangle
14
+ * counters, so the trace viewer draws them as a graph track.
15
+ *
16
+ * All times are microseconds (the format's unit); our profiler measures ms, so
17
+ * every ms value is × 1000. Every event carries the full key set
18
+ * (`ph/ts/dur/pid/tid/name/cat`) for uniformity — metadata/counter events
19
+ * would normally omit `dur`, but a constant `dur: 0` is harmless to the viewer
20
+ * and lets a consumer read every event without per-`ph` branching.
21
+ */
22
+
23
+ import type { PerformanceFrame } from './performance-profiler';
24
+
25
+ export interface ChromeTraceEvent {
26
+ readonly name: string;
27
+ readonly cat: string;
28
+ readonly ph: 'X' | 'M' | 'C';
29
+ /** Timestamp, microseconds. */
30
+ readonly ts: number;
31
+ /** Duration, microseconds (0 for metadata/counter events). */
32
+ readonly dur: number;
33
+ readonly pid: number;
34
+ readonly tid: number;
35
+ readonly args?: Record<string, unknown>;
36
+ }
37
+
38
+ export interface ChromeTraceFile {
39
+ readonly traceEvents: readonly ChromeTraceEvent[];
40
+ readonly displayTimeUnit: 'ms';
41
+ readonly otherData: Record<string, unknown>;
42
+ }
43
+
44
+ export interface ChromeTraceOptions {
45
+ processName: string;
46
+ threadName: string;
47
+ }
48
+
49
+ const PID = 1;
50
+ const TID = 1;
51
+ const MS_TO_US = 1000;
52
+
53
+ function ms(value: number): number {
54
+ return Math.round(value * MS_TO_US);
55
+ }
56
+
57
+ export function buildChromeTrace(
58
+ frames: readonly PerformanceFrame[],
59
+ options: ChromeTraceOptions,
60
+ ): ChromeTraceFile {
61
+ const events: ChromeTraceEvent[] = [];
62
+
63
+ // Metadata: name the process + thread the frames live on.
64
+ events.push({
65
+ name: 'process_name',
66
+ cat: '__metadata',
67
+ ph: 'M',
68
+ ts: 0,
69
+ dur: 0,
70
+ pid: PID,
71
+ tid: TID,
72
+ args: { name: options.processName },
73
+ });
74
+ events.push({
75
+ name: 'thread_name',
76
+ cat: '__metadata',
77
+ ph: 'M',
78
+ ts: 0,
79
+ dur: 0,
80
+ pid: PID,
81
+ tid: TID,
82
+ args: { name: options.threadName },
83
+ });
84
+
85
+ for (const frame of frames) {
86
+ // Absolute frame start (ms): timestamp is the frame's END, cpuMs its span.
87
+ const frameStartMs = frame.timestamp - frame.cpuMs;
88
+ const frameStartUs = ms(frameStartMs);
89
+
90
+ events.push({
91
+ name: `frame ${frame.id}`,
92
+ cat: 'frame',
93
+ ph: 'X',
94
+ ts: frameStartUs,
95
+ dur: ms(frame.cpuMs),
96
+ pid: PID,
97
+ tid: TID,
98
+ args: {
99
+ id: frame.id,
100
+ drawCalls: frame.render.drawCalls,
101
+ triangles: frame.render.triangles,
102
+ },
103
+ });
104
+
105
+ for (const phase of frame.phases) {
106
+ events.push({
107
+ name: phase.name,
108
+ cat: 'phase',
109
+ ph: 'X',
110
+ ts: ms(frameStartMs + (phase.startMs ?? 0)),
111
+ dur: ms(phase.ms),
112
+ pid: PID,
113
+ tid: TID,
114
+ });
115
+ }
116
+
117
+ for (const system of frame.systems) {
118
+ events.push({
119
+ name: system.name,
120
+ cat: 'system',
121
+ ph: 'X',
122
+ ts: ms(frameStartMs + system.startMs),
123
+ dur: ms(system.ms),
124
+ pid: PID,
125
+ tid: TID,
126
+ args: { phase: system.phase },
127
+ });
128
+ }
129
+
130
+ // Counter track: render draw/triangle counts at this frame's start.
131
+ events.push({
132
+ name: 'render',
133
+ cat: 'counter',
134
+ ph: 'C',
135
+ ts: frameStartUs,
136
+ dur: 0,
137
+ pid: PID,
138
+ tid: TID,
139
+ args: { drawCalls: frame.render.drawCalls, triangles: frame.render.triangles },
140
+ });
141
+ }
142
+
143
+ return {
144
+ traceEvents: events,
145
+ displayTimeUnit: 'ms',
146
+ otherData: {
147
+ version: 2,
148
+ processName: options.processName,
149
+ threadName: options.threadName,
150
+ frameCount: frames.length,
151
+ },
152
+ };
153
+ }
@@ -1,6 +1,40 @@
1
1
  export interface PerformanceTiming {
2
2
  readonly name: string;
3
3
  readonly ms: number;
4
+ /**
5
+ * Offset (ms) from THIS frame's start (`frameStart`) at which the timing
6
+ * began — a MEASURED value (e.g. a phase's `phaseStart - frameStart`), never
7
+ * synthesized. Present on per-frame phase timings (W4b, so a Chrome trace
8
+ * can place each phase span under its frame at the real offset). OMITTED on
9
+ * cross-frame aggregate rollups (`PerformanceSnapshot.phases`/`systems`/
10
+ * `components`), where no single start point exists — absent, never a
11
+ * fabricated 0.
12
+ */
13
+ readonly startMs?: number;
14
+ }
15
+
16
+ /**
17
+ * One MEASURED span within a frame (W4b) — a system or component invocation,
18
+ * with the phase it ran in and its measured start offset from `frameStart`.
19
+ * Distinct from {@link PerformanceTiming}: a span is a single timed
20
+ * invocation carrying `phase` + always-present `startMs` (the trace/flame
21
+ * unit), whereas a `PerformanceTiming` may be a cross-frame rollup.
22
+ */
23
+ export interface PerformanceSpan {
24
+ readonly name: string;
25
+ readonly phase: string;
26
+ /** Measured offset (ms) from `frameStart` at which this span began. */
27
+ readonly startMs: number;
28
+ readonly ms: number;
29
+ }
30
+
31
+ /** Renderer counters accumulated over a frame (from `renderer.info`). */
32
+ export interface PerformanceRenderStats {
33
+ readonly gpuMs: number | null;
34
+ readonly drawCalls: number;
35
+ readonly triangles: number;
36
+ readonly geometries: number;
37
+ readonly textures: number;
4
38
  }
5
39
 
6
40
  export interface PerformanceFrame {
@@ -9,6 +43,25 @@ export interface PerformanceFrame {
9
43
  readonly intervalMs: number;
10
44
  readonly cpuMs: number;
11
45
  readonly phases: readonly PerformanceTiming[];
46
+ /**
47
+ * Per-invocation system spans measured this frame (W4b). Ordered by
48
+ * `startMs` (execution order), NOT accumulated — a system that ran twice
49
+ * (two substeps) appears twice. Empty when the profiler saw no named
50
+ * systems this frame.
51
+ */
52
+ readonly systems: readonly PerformanceSpan[];
53
+ /**
54
+ * Per-invocation component spans measured this frame (W4b) — populated ONLY
55
+ * while `recording` (component timing is gated on recording, matching
56
+ * `beginComponent`/`endComponent`). Empty otherwise.
57
+ */
58
+ readonly components: readonly PerformanceSpan[];
59
+ /**
60
+ * Renderer counters reported during this frame (W4b) — a snapshot of the
61
+ * accumulated `reportRender` totals taken at `endFrame`. `gpuMs` is `null`
62
+ * when no GPU timer reported (headless/SwiftShader), never a fabricated 0.
63
+ */
64
+ readonly render: PerformanceRenderStats;
12
65
  }
13
66
 
14
67
  export interface PerformanceSnapshot {
@@ -43,9 +96,15 @@ function percentile(values: readonly number[], fraction: number): number {
43
96
  export function createPerformanceProfiler(initiallyEnabled = false) {
44
97
  const listeners = new Set<() => void>();
45
98
  const frames: PerformanceFrame[] = [];
46
- const currentPhases = new Map<string, number>();
99
+ const currentPhases = new Map<string, { ms: number; startMs: number }>();
47
100
  const currentSystems = new Map<string, number>();
48
101
  const currentComponents = new Map<string, number>();
102
+ // Per-frame span logs (W4b) — cleared each beginFrame, snapshotted into the
103
+ // frame at endFrame. Distinct from the accumulate-by-name aggregate maps
104
+ // above (which back the cross-frame snapshot rollups): these preserve every
105
+ // individual invocation and its measured start offset, for the trace.
106
+ let frameSystems: PerformanceSpan[] = [];
107
+ let frameComponents: PerformanceSpan[] = [];
49
108
  let enabled = initiallyEnabled;
50
109
  let recording = false;
51
110
  let frameId = 0;
@@ -127,6 +186,8 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
127
186
  currentPhases.clear();
128
187
  currentSystems.clear();
129
188
  currentComponents.clear();
189
+ frameSystems = [];
190
+ frameComponents = [];
130
191
  render = { gpuMs: null, drawCalls: 0, triangles: 0, geometries: 0, textures: 0 };
131
192
  frameStart = now();
132
193
  },
@@ -137,7 +198,13 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
137
198
  endPhase(name: string) {
138
199
  if (!enabled) return;
139
200
  const elapsed = now() - phaseStart;
140
- currentPhases.set(name, (currentPhases.get(name) ?? 0) + elapsed);
201
+ const existing = currentPhases.get(name);
202
+ // Accumulate ms across re-entries of the same phase, but keep the FIRST
203
+ // measured start offset (earliest entry) as the span's startMs.
204
+ currentPhases.set(name, {
205
+ ms: (existing?.ms ?? 0) + elapsed,
206
+ startMs: existing?.startMs ?? phaseStart - frameStart,
207
+ });
141
208
  if (recording)
142
209
  performance.measure(`vgai.phase.${name}`, { start: phaseStart, duration: elapsed });
143
210
  },
@@ -151,15 +218,28 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
151
218
  const end = now();
152
219
  const key = `${scope} / ${phase} / ${name}`;
153
220
  currentSystems.set(key, (currentSystems.get(key) ?? 0) + end - systemStart);
221
+ frameSystems.push({
222
+ name: key,
223
+ phase,
224
+ startMs: systemStart - frameStart,
225
+ ms: end - systemStart,
226
+ });
154
227
  },
155
228
  },
156
229
  beginComponent() {
157
230
  if (!recording) return;
158
231
  componentStart = now();
159
232
  },
160
- endComponent(name: string) {
233
+ endComponent(name: string, phase = '') {
161
234
  if (!recording) return;
162
- currentComponents.set(name, (currentComponents.get(name) ?? 0) + now() - componentStart);
235
+ const end = now();
236
+ currentComponents.set(name, (currentComponents.get(name) ?? 0) + end - componentStart);
237
+ frameComponents.push({
238
+ name,
239
+ phase,
240
+ startMs: componentStart - frameStart,
241
+ ms: end - componentStart,
242
+ });
163
243
  },
164
244
  reportRender(stats: {
165
245
  gpuMs: number | null;
@@ -187,7 +267,12 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
187
267
  timestamp,
188
268
  intervalMs,
189
269
  cpuMs: timestamp - frameStart,
190
- phases: [...currentPhases].map(([name, ms]) => ({ name, ms })).sort((a, b) => b.ms - a.ms),
270
+ phases: [...currentPhases]
271
+ .map(([name, { ms, startMs }]) => ({ name, ms, startMs }))
272
+ .sort((a, b) => b.ms - a.ms),
273
+ systems: frameSystems.slice(),
274
+ components: frameComponents.slice(),
275
+ render: { ...render },
191
276
  };
192
277
  frames.push(frame);
193
278
  if (frames.length > 600) frames.splice(0, frames.length - 600);
@@ -205,7 +290,9 @@ export function createPerformanceProfiler(initiallyEnabled = false) {
205
290
  return snapshot;
206
291
  },
207
292
  exportJSON() {
208
- return JSON.stringify({ version: 1, capturedAt: new Date().toISOString(), frames }, null, 2);
293
+ // version 2 (W4b): frames now carry per-frame `systems`/`components`
294
+ // spans, per-frame `render` counters, and `startMs` on phase timings.
295
+ return JSON.stringify({ version: 2, capturedAt: new Date().toISOString(), frames }, null, 2);
209
296
  },
210
297
  };
211
298
  }