@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.
- package/README.md +48 -15
- package/package.json +11 -25
- package/schemas/engine-capabilities.json +10 -10
- package/schemas/entity2d.schema.json +468 -0
- package/schemas/mat.schema.json +2 -33
- package/schemas/prefab.schema.json +16 -172
- package/schemas/scn2d.schema.json +42 -23
- package/schemas/vgai-game.schema.json +34 -0
- package/schemas/vscn.schema.json +16 -172
- package/src/adapter/authoring.ts +152 -2
- package/src/adapter/colyseus-networking-adapter.ts +35 -1
- package/src/adapter/first-party-systems.ts +7 -1
- package/src/adapter/game-adapter.ts +13 -0
- package/src/adapter/index.ts +25 -0
- package/src/adapter/rapier-physics-adapter.ts +55 -2
- package/src/adapter/system-adapter.ts +249 -2
- package/src/adapter/vgai-scene-game-adapter.ts +149 -25
- package/src/ai/navigation.ts +28 -0
- package/src/animation/clip-map.ts +1 -8
- package/src/animation/theatre-director.ts +50 -0
- package/src/animation/xstate-animation-binding.ts +6 -0
- package/src/audio/audio-introspection.ts +290 -0
- package/src/audio/tone-context.ts +46 -0
- package/src/dev/chrome-trace.ts +153 -0
- package/src/dev/performance-profiler.ts +93 -6
- package/src/dev/render-debug-adapter.ts +199 -0
- package/src/dev/render-memory.ts +243 -0
- package/src/dev/webgl-frame-capture.ts +424 -0
- package/src/ecs/component-manager.ts +43 -10
- package/src/ecs/game-component.ts +39 -10
- package/src/input/input-manager.ts +24 -19
- package/src/input/input-types.ts +1 -1
- package/src/loader.ts +7 -0
- package/src/manifest/load.ts +14 -0
- package/src/manifest/schema.ts +65 -0
- package/src/react/game-state.tsx +1 -1
- package/src/render/render-batch-system.ts +26 -12
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/runtime/create-runtime.ts +33 -5
- package/src/runtime/debug-bridge.ts +5 -5
- package/src/runtime/game.ts +102 -20
- package/src/runtime/mount-manifest.ts +1 -1
- package/src/runtime/render-control.ts +121 -0
- package/src/runtime/types.ts +1 -1
- package/src/scene/asset-loaders.ts +77 -3
- package/src/scene/instance-mesh.ts +25 -0
- package/src/scene/material-factory.ts +4 -15
- package/src/scene/mesh-shadow.ts +18 -0
- package/src/scene/particles-factory.ts +59 -0
- package/src/scene/scene-loader.ts +41 -16
- package/src/scene/schema/instances.ts +1 -2
- package/src/scene/schema/material.ts +83 -94
- package/src/scene/schema/mesh.ts +76 -90
- package/src/scene/schema/scene-file.ts +1 -2
- package/src/scene/user-data.ts +30 -14
- package/src/setup/setup-renderer.ts +6 -1
- package/src/world2d/asset-paths2d.ts +44 -0
- package/src/world2d/collision-2d.ts +7 -14
- package/src/world2d/entity2d-asset.ts +22 -0
- package/src/world2d/index.ts +27 -2
- package/src/world2d/physics2d-transform.ts +173 -0
- package/src/world2d/physics2d-units.ts +10 -0
- package/src/world2d/pixi-game-adapter.ts +148 -36
- package/src/world2d/scene2d-identity.ts +49 -0
- package/src/world2d/scene2d-loader.ts +243 -119
- package/src/world2d/schema/entity2d.ts +51 -33
- package/src/world2d/schema/physics2d.ts +14 -3
- package/src/world2d/schema/sprite.ts +32 -4
- package/src/world2d/schema/tilemap.ts +26 -9
- package/src/world2d/transform-writer-2d.ts +29 -11
- package/src/world2d/types.ts +21 -8
- package/src/world3d-react/behavior.tsx +138 -0
- package/src/world3d-react/engine-bridge.ts +48 -0
- package/src/world3d-react/index.ts +44 -0
- package/src/world3d-react/r3f-adapter.tsx +303 -0
- package/src/world3d-react/world-context.ts +294 -0
- package/vendor/realism-effects/LICENSE.md +21 -0
- package/vendor/realism-effects/UPSTREAM.md +19 -0
- package/vendor/realism-effects/dist/index.cjs +3447 -0
- package/vendor/realism-effects/dist/index.d.ts +59 -0
- package/vendor/realism-effects/dist/index.js +3434 -0
- package/vendor/realism-effects/package.json +23 -0
- package/src/character/cloth-sim.ts +0 -533
- package/src/character/spring-chain.ts +0 -307
- package/src/humanoid/body.ts +0 -663
- package/src/humanoid/clips.ts +0 -149
- package/src/humanoid/compose.ts +0 -209
- package/src/humanoid/generate.ts +0 -189
- package/src/humanoid/index.ts +0 -36
- package/src/humanoid/schema.ts +0 -108
- package/src/humanoid/skeleton.ts +0 -345
- package/src/react/humanoid-bake.document.tsx +0 -337
- package/src/scene/geometries/index.ts +0 -7
- package/src/scene/geometries/terrain.ts +0 -42
- package/src/scene/geometry-registry.ts +0 -42
- package/src/scene/instance-registry.ts +0 -84
- package/src/scene/instancers/grid.ts +0 -38
- package/src/scene/instancers/index.ts +0 -7
- package/src/scene/material-registry.ts +0 -73
- package/src/scene/materials/index.ts +0 -7
- package/src/scene/materials/water.ts +0 -56
- package/src/world2d/components-2d.ts +0 -86
- 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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
}
|