@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,424 @@
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
+ * `vgai-scene-game-adapter.ts`). 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
+ /** The five WebGL2 draw entry points this instrument wraps. */
36
+ export type FrameCaptureEntryPoint =
37
+ | 'drawArrays'
38
+ | 'drawElements'
39
+ | 'drawArraysInstanced'
40
+ | 'drawElementsInstanced'
41
+ | 'drawRangeElements';
42
+
43
+ /** Per-draw attribution supplied by {@link WebGLFrameCapture.annotateNextDraw}
44
+ * (the render adapter derives it from the three.js object about to draw). */
45
+ export interface DrawAnnotation {
46
+ object: { name: string; type: string; entityId?: string };
47
+ geometry: { type: string; name: string; attributes: string[]; indexed: boolean };
48
+ material: { type: string; name: string };
49
+ }
50
+
51
+ /** Where a draw rendered: the default framebuffer (canvas) or a bound FBO. */
52
+ export type DrawTarget =
53
+ | { kind: 'canvas' }
54
+ | {
55
+ kind: 'framebuffer';
56
+ /** Color-attachment width, or `null` when not cheaply queryable
57
+ * (texture attachment — raw WebGL2 has no size query). */
58
+ width: number | null;
59
+ height: number | null;
60
+ /** Capture-local identity tag (`framebuffer#N`), not a GL debug name. */
61
+ label: string | null;
62
+ };
63
+
64
+ export interface FrameCaptureDrawCall {
65
+ readonly index: number;
66
+ readonly entryPoint: FrameCaptureEntryPoint;
67
+ /** Decoded primitive mode, e.g. `'TRIANGLES'`, `'LINES'` (raw enum when
68
+ * unrecognised). */
69
+ readonly mode: string;
70
+ /** Vertex/index count passed to the draw. */
71
+ readonly count: number;
72
+ /** Instance count for the instanced entry points, else `null` (not 0 —
73
+ * a non-instanced draw has no instance count to report). */
74
+ readonly instanceCount: number | null;
75
+ /** Capture-local program identity (`program#N`), or `null` when no program
76
+ * was bound via `useProgram`. */
77
+ readonly programLabel: string | null;
78
+ readonly target: DrawTarget;
79
+ /** Shadow-tracked viewport `[x, y, w, h]` at draw time. */
80
+ readonly viewport: readonly [number, number, number, number];
81
+ readonly state: {
82
+ readonly blend: boolean;
83
+ readonly depthTest: boolean;
84
+ readonly depthWrite: boolean;
85
+ readonly cull: 'front' | 'back' | 'none';
86
+ readonly scissor: boolean;
87
+ };
88
+ /** The annotation this draw consumed, or `null` when none was pending
89
+ * (unattributed — e.g. a shadow/composer pass draw). */
90
+ readonly annotation: DrawAnnotation | null;
91
+ }
92
+
93
+ export interface FrameCapture {
94
+ readonly id: number;
95
+ /** Injected capture timestamp (ms) — see `createWebGLFrameCapture` options. */
96
+ readonly capturedAt: number;
97
+ readonly drawCalls: readonly FrameCaptureDrawCall[];
98
+ readonly totals: {
99
+ /** Draws RECORDED (excludes any dropped past the cap). */
100
+ readonly drawCalls: number;
101
+ /** Recorded draws with `annotation: null`. */
102
+ readonly unattributed: number;
103
+ /** Draws dropped because the cap was hit (0 when nothing was dropped). */
104
+ readonly truncated: number;
105
+ };
106
+ readonly notes: readonly string[];
107
+ }
108
+
109
+ export interface WebGLFrameCaptureOptions {
110
+ /** Injected clock for `capturedAt`; defaults to `Date.now`. Injectable so a
111
+ * test gets deterministic timestamps (mirrors the profiler stamping time
112
+ * through a single `now()` indirection rather than a module-level call). */
113
+ now?: () => number;
114
+ /** Hard cap on recorded draws (default 5000). Draws past it are dropped and
115
+ * counted in `totals.truncated`, with a note. */
116
+ maxDrawCalls?: number;
117
+ }
118
+
119
+ export interface WebGLFrameCapture {
120
+ /** Arm a single capture; the NEXT `beginPass()` patches the context. */
121
+ arm(): void;
122
+ readonly armed: boolean;
123
+ /** If armed, shadow-patch the context. No-op otherwise (zero patching when
124
+ * not armed). Must be paired with `endPass()`. */
125
+ beginPass(): void;
126
+ /** Restore all patches (in `finally`) and, if a pass was patched, return the
127
+ * assembled {@link FrameCapture}. Returns `null` if `beginPass` did not
128
+ * patch (was not armed). One-shot: disarms after. */
129
+ endPass(): FrameCapture | null;
130
+ /** Set the annotation the next draw will consume (then cleared). */
131
+ annotateNextDraw(annotation: DrawAnnotation): void;
132
+ }
133
+
134
+ const DEFAULT_MAX_DRAW_CALLS = 5000;
135
+
136
+ /** GL primitive-mode enum → name (decoded once, from the live context). */
137
+ function buildModeTable(gl: WebGL2RenderingContext): Map<number, string> {
138
+ return new Map<number, string>([
139
+ [gl.POINTS, 'POINTS'],
140
+ [gl.LINES, 'LINES'],
141
+ [gl.LINE_LOOP, 'LINE_LOOP'],
142
+ [gl.LINE_STRIP, 'LINE_STRIP'],
143
+ [gl.TRIANGLES, 'TRIANGLES'],
144
+ [gl.TRIANGLE_STRIP, 'TRIANGLE_STRIP'],
145
+ [gl.TRIANGLE_FAN, 'TRIANGLE_FAN'],
146
+ ]);
147
+ }
148
+
149
+ export function createWebGLFrameCapture(
150
+ gl: WebGL2RenderingContext,
151
+ options: WebGLFrameCaptureOptions = {},
152
+ ): WebGLFrameCapture {
153
+ const nowFn = options.now ?? (() => Date.now());
154
+ const maxDrawCalls = options.maxDrawCalls ?? DEFAULT_MAX_DRAW_CALLS;
155
+ const modeTable = buildModeTable(gl);
156
+
157
+ // Persistent identity maps (stable labels across passes).
158
+ const programLabels = new Map<WebGLProgram, string>();
159
+ const framebufferLabels = new Map<WebGLFramebuffer, string>();
160
+ let nextProgramId = 0;
161
+ let nextFramebufferId = 0;
162
+ let captureId = 0;
163
+
164
+ // Shadow-tracked GL state (updated by the wrapped setters during a pass).
165
+ let currentProgram: WebGLProgram | null = null;
166
+ let currentFramebuffer: WebGLFramebuffer | null = null;
167
+ let currentViewport: [number, number, number, number] = [0, 0, 0, 0];
168
+
169
+ // Per-pass accumulators.
170
+ let armed = false;
171
+ let patched = false;
172
+ let drawCalls: FrameCaptureDrawCall[] = [];
173
+ let truncated = 0;
174
+ let notes: string[] = [];
175
+ let pendingAnnotation: DrawAnnotation | null = null;
176
+
177
+ // Saved originals for restore. Value is the original fn; a key present in
178
+ // `wasOwn` means it was an own property (restore by assignment), otherwise
179
+ // it was prototype-inherited (restore by delete).
180
+ const originals = new Map<string, unknown>();
181
+ const wasOwn = new Set<string>();
182
+
183
+ function decodeMode(mode: number): string {
184
+ return modeTable.get(mode) ?? `0x${mode.toString(16)}`;
185
+ }
186
+
187
+ function labelForProgram(program: WebGLProgram | null): string | null {
188
+ if (!program) return null;
189
+ let label = programLabels.get(program);
190
+ if (label === undefined) {
191
+ label = `program#${nextProgramId++}`;
192
+ programLabels.set(program, label);
193
+ }
194
+ return label;
195
+ }
196
+
197
+ /** Best-effort color-attachment dimensions for a bound draw FBO. Only a
198
+ * RENDERBUFFER color attachment is cheaply queryable in raw WebGL2; a
199
+ * texture attachment is not, so this returns nulls (honest absence). All
200
+ * reads are guarded — a mock/foreign context returns nulls, never throws. */
201
+ function measureFramebufferSize(): { width: number | null; height: number | null } {
202
+ try {
203
+ const type = gl.getFramebufferAttachmentParameter(
204
+ gl.DRAW_FRAMEBUFFER,
205
+ gl.COLOR_ATTACHMENT0,
206
+ gl.FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE,
207
+ );
208
+ if (type === gl.RENDERBUFFER) {
209
+ const rb = gl.getFramebufferAttachmentParameter(
210
+ gl.DRAW_FRAMEBUFFER,
211
+ gl.COLOR_ATTACHMENT0,
212
+ gl.FRAMEBUFFER_ATTACHMENT_OBJECT_NAME,
213
+ ) as WebGLRenderbuffer | null;
214
+ if (!rb) return { width: null, height: null };
215
+ const prev = gl.getParameter(gl.RENDERBUFFER_BINDING) as WebGLRenderbuffer | null;
216
+ gl.bindRenderbuffer(gl.RENDERBUFFER, rb);
217
+ const width = gl.getRenderbufferParameter(gl.RENDERBUFFER, gl.RENDERBUFFER_WIDTH) as number;
218
+ const height = gl.getRenderbufferParameter(
219
+ gl.RENDERBUFFER,
220
+ gl.RENDERBUFFER_HEIGHT,
221
+ ) as number;
222
+ gl.bindRenderbuffer(gl.RENDERBUFFER, prev);
223
+ return { width, height };
224
+ }
225
+ } catch {
226
+ // Foreign/mock context or an attachment we can't introspect — degrade
227
+ // to unknown dimensions rather than throwing mid-capture.
228
+ }
229
+ return { width: null, height: null };
230
+ }
231
+
232
+ function currentTarget(): DrawTarget {
233
+ if (!currentFramebuffer) return { kind: 'canvas' };
234
+ let label = framebufferLabels.get(currentFramebuffer);
235
+ if (label === undefined) {
236
+ label = `framebuffer#${nextFramebufferId++}`;
237
+ framebufferLabels.set(currentFramebuffer, label);
238
+ }
239
+ const { width, height } = measureFramebufferSize();
240
+ return { kind: 'framebuffer', width, height, label };
241
+ }
242
+
243
+ function readCull(): 'front' | 'back' | 'none' {
244
+ if (!gl.getParameter(gl.CULL_FACE)) return 'none';
245
+ return gl.getParameter(gl.CULL_FACE_MODE) === gl.FRONT ? 'front' : 'back';
246
+ }
247
+
248
+ function recordDraw(
249
+ entryPoint: FrameCaptureEntryPoint,
250
+ mode: number,
251
+ count: number,
252
+ instanceCount: number | null,
253
+ ): void {
254
+ if (drawCalls.length >= maxDrawCalls) {
255
+ truncated++;
256
+ pendingAnnotation = null;
257
+ return;
258
+ }
259
+ const annotation = pendingAnnotation;
260
+ pendingAnnotation = null;
261
+ drawCalls.push({
262
+ index: drawCalls.length,
263
+ entryPoint,
264
+ mode: decodeMode(mode),
265
+ count,
266
+ instanceCount,
267
+ programLabel: labelForProgram(currentProgram),
268
+ target: currentTarget(),
269
+ viewport: [...currentViewport] as [number, number, number, number],
270
+ state: {
271
+ blend: Boolean(gl.getParameter(gl.BLEND)),
272
+ depthTest: Boolean(gl.getParameter(gl.DEPTH_TEST)),
273
+ depthWrite: Boolean(gl.getParameter(gl.DEPTH_WRITEMASK)),
274
+ cull: readCull(),
275
+ scissor: Boolean(gl.getParameter(gl.SCISSOR_TEST)),
276
+ },
277
+ annotation: annotation ?? null,
278
+ });
279
+ }
280
+
281
+ // Cast to an index signature to shadow methods without fighting the DOM lib
282
+ // types on every wrapped name.
283
+ const target = gl as unknown as Record<string, unknown>;
284
+
285
+ function patch(name: string, wrapper: (original: (...a: unknown[]) => unknown) => unknown): void {
286
+ const original = target[name] as (...a: unknown[]) => unknown;
287
+ originals.set(name, original);
288
+ if (Object.hasOwn(gl, name)) wasOwn.add(name);
289
+ target[name] = wrapper(original.bind(gl));
290
+ }
291
+
292
+ function restoreAll(): void {
293
+ for (const [name, original] of originals) {
294
+ if (wasOwn.has(name)) target[name] = original;
295
+ else delete target[name];
296
+ }
297
+ originals.clear();
298
+ wasOwn.clear();
299
+ }
300
+
301
+ function installPatches(): void {
302
+ // Seed shadow state from the live context so the first draw's viewport /
303
+ // framebuffer / program reflect reality even before any setter fires.
304
+ try {
305
+ const vp = gl.getParameter(gl.VIEWPORT) as ArrayLike<number> | null;
306
+ if (vp && vp.length >= 4) currentViewport = [vp[0]!, vp[1]!, vp[2]!, vp[3]!];
307
+ currentFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING) as WebGLFramebuffer | null;
308
+ currentProgram = gl.getParameter(gl.CURRENT_PROGRAM) as WebGLProgram | null;
309
+ } catch {
310
+ // Mock context without full getParameter coverage — start from defaults.
311
+ }
312
+
313
+ patch('useProgram', (original) => (program: unknown) => {
314
+ currentProgram = (program as WebGLProgram) ?? null;
315
+ return original(program);
316
+ });
317
+ patch('bindFramebuffer', (original) => (bindTarget: unknown, framebuffer: unknown) => {
318
+ // Track the DRAW framebuffer binding (FRAMEBUFFER and DRAW_FRAMEBUFFER
319
+ // both affect it; READ_FRAMEBUFFER does not).
320
+ if (bindTarget === gl.FRAMEBUFFER || bindTarget === gl.DRAW_FRAMEBUFFER) {
321
+ currentFramebuffer = (framebuffer as WebGLFramebuffer) ?? null;
322
+ }
323
+ return original(bindTarget, framebuffer);
324
+ });
325
+ patch('viewport', (original) => (x: unknown, y: unknown, w: unknown, h: unknown) => {
326
+ currentViewport = [x as number, y as number, w as number, h as number];
327
+ return original(x, y, w, h);
328
+ });
329
+
330
+ patch('drawArrays', (original) => (mode: unknown, first: unknown, count: unknown) => {
331
+ recordDraw('drawArrays', mode as number, count as number, null);
332
+ return original(mode, first, count);
333
+ });
334
+ patch(
335
+ 'drawElements',
336
+ (original) => (mode: unknown, count: unknown, type: unknown, offset: unknown) => {
337
+ recordDraw('drawElements', mode as number, count as number, null);
338
+ return original(mode, count, type, offset);
339
+ },
340
+ );
341
+ patch(
342
+ 'drawArraysInstanced',
343
+ (original) => (mode: unknown, first: unknown, count: unknown, instanceCount: unknown) => {
344
+ recordDraw('drawArraysInstanced', mode as number, count as number, instanceCount as number);
345
+ return original(mode, first, count, instanceCount);
346
+ },
347
+ );
348
+ patch(
349
+ 'drawElementsInstanced',
350
+ (original) =>
351
+ (mode: unknown, count: unknown, type: unknown, offset: unknown, instanceCount: unknown) => {
352
+ recordDraw(
353
+ 'drawElementsInstanced',
354
+ mode as number,
355
+ count as number,
356
+ instanceCount as number,
357
+ );
358
+ return original(mode, count, type, offset, instanceCount);
359
+ },
360
+ );
361
+ patch(
362
+ 'drawRangeElements',
363
+ (original) =>
364
+ (
365
+ mode: unknown,
366
+ start: unknown,
367
+ end: unknown,
368
+ count: unknown,
369
+ type: unknown,
370
+ offset: unknown,
371
+ ) => {
372
+ recordDraw('drawRangeElements', mode as number, count as number, null);
373
+ return original(mode, start, end, count, type, offset);
374
+ },
375
+ );
376
+ }
377
+
378
+ return {
379
+ arm() {
380
+ armed = true;
381
+ },
382
+ get armed() {
383
+ return armed;
384
+ },
385
+ beginPass() {
386
+ if (!armed || patched) return;
387
+ patched = true;
388
+ drawCalls = [];
389
+ truncated = 0;
390
+ notes = [];
391
+ pendingAnnotation = null;
392
+ installPatches();
393
+ },
394
+ endPass(): FrameCapture | null {
395
+ if (!patched) {
396
+ armed = false;
397
+ return null;
398
+ }
399
+ try {
400
+ if (truncated > 0) {
401
+ notes.push(
402
+ `Draw list capped at ${maxDrawCalls}; ${truncated} later draw(s) were not recorded.`,
403
+ );
404
+ }
405
+ const unattributed = drawCalls.reduce((n, d) => n + (d.annotation === null ? 1 : 0), 0);
406
+ return {
407
+ id: ++captureId,
408
+ capturedAt: nowFn(),
409
+ drawCalls: drawCalls.slice(),
410
+ totals: { drawCalls: drawCalls.length, unattributed, truncated },
411
+ notes: notes.slice(),
412
+ };
413
+ } finally {
414
+ restoreAll();
415
+ patched = false;
416
+ armed = false;
417
+ pendingAnnotation = null;
418
+ }
419
+ },
420
+ annotateNextDraw(annotation: DrawAnnotation) {
421
+ pendingAnnotation = annotation;
422
+ },
423
+ };
424
+ }
@@ -5,9 +5,10 @@ import type { PhysicsRefs, PhysicsRegistry } from '../physics/physics-registry';
5
5
  import { type AdapterSurface, isFirstPartyMounted, type WorldInstance } from '../runtime/game';
6
6
  import type { GameContext } from '../runtime/types';
7
7
  import type { Physics2DRefs, Physics2DRegistry } from '../world2d/physics2d-registry';
8
+ import type { World2DContext } from '../world2d/types';
8
9
  import {
10
+ type AnyGameComponentClass,
9
11
  GameComponent,
10
- type GameComponentClass,
11
12
  linkGameComponentHmrClasses,
12
13
  type NodeOf,
13
14
  } from './game-component';
@@ -24,6 +25,7 @@ import { logHmrSwapMiss } from './hmr-swap-report';
24
25
  */
25
26
  type AnyNode = NodeOf<AdapterSurface>;
26
27
  type AnyComponent = GameComponent<AdapterSurface>;
28
+ type ComponentRuntimeContext = GameContext | World2DContext;
27
29
 
28
30
  /**
29
31
  * Optional identity a caller of `attach()` can supply for a component instance
@@ -68,10 +70,19 @@ export interface RegistryAttachInfo {
68
70
  * instance ends up with a defined, correct `world` by the time
69
71
  * `createGameRuntime`/`registerThreeWorld` returns.
70
72
  */
71
- function resolveWorldInstance(ctx: GameContext): WorldInstance | undefined {
73
+ function resolveWorldInstance(
74
+ ctx: ComponentRuntimeContext,
75
+ self: unknown,
76
+ ): WorldInstance | undefined {
72
77
  if (!ctx.game) return undefined;
73
78
  for (const w of ctx.game.roots) {
74
79
  if (isFirstPartyMounted(w.mounted) && w.mounted.ctx === ctx) return w;
80
+ // A non-first-party world exposing ITS manager via the optional
81
+ // `MountedWorldBase.components` capability (e.g. `@engine/world3d-react`'s
82
+ // R3F mount) — matched by manager identity (`self` is the manager doing
83
+ // this attach), the same one-owner-per-world guarantee the ctx-identity
84
+ // probe above gives the first-party mount.
85
+ if (self !== undefined && w.mounted.components === self) return w;
75
86
  }
76
87
  return undefined;
77
88
  }
@@ -121,13 +132,20 @@ function describeNode(node: AnyNode): string {
121
132
  * just to satisfy this signature.
122
133
  */
123
134
  export function createComponentManager(
124
- ctx: GameContext,
135
+ ctx: ComponentRuntimeContext,
125
136
  physics?: PhysicsRegistry,
126
137
  opts?: { kind?: AdapterSurface; physics2d?: Physics2DRegistry },
127
138
  ) {
128
139
  const kind: AdapterSurface = opts?.kind ?? 'threejs';
129
140
  const physics2d = opts?.physics2d;
130
141
 
142
+ // The manager object this factory returns — captured after construction so
143
+ // `performAttach` can hand `resolveWorldInstance` its own identity (the
144
+ // `MountedWorldBase.components` capability match; see that function's doc).
145
+ // Safe closure-over-a-later-assignment: `performAttach` only ever runs
146
+ // after the return below.
147
+ let selfManager: unknown;
148
+
131
149
  const byPhase = new Map<SystemPhaseName, AnyComponent[]>();
132
150
  const byEntity = new Map<AnyNode, AnyComponent[]>();
133
151
 
@@ -332,7 +350,7 @@ export function createComponentManager(
332
350
  } catch (err) {
333
351
  console.error(`[component-manager] ${inst.constructor.name}.update() threw:`, err);
334
352
  } finally {
335
- ctx.game?.profiler.endComponent(inst.constructor.name);
353
+ ctx.game?.profiler.endComponent(inst.constructor.name, phase);
336
354
  }
337
355
  }
338
356
  } finally {
@@ -363,14 +381,14 @@ export function createComponentManager(
363
381
  function swapOneIfMatched(
364
382
  inst: AnyComponent,
365
383
  name: string,
366
- NewClass: GameComponentClass,
384
+ NewClass: AnyGameComponentClass,
367
385
  ): boolean {
368
386
  const trackedKey = registryKeyOf.get(inst);
369
387
  const isMatch = trackedKey !== undefined ? trackedKey === name : inst.constructor.name === name;
370
388
  if (!isMatch) return false;
371
389
  const oldPhase = (inst.constructor as typeof GameComponent).phase ?? 'gameLogic';
372
390
  const newPhase = (NewClass as unknown as typeof GameComponent).phase ?? 'gameLogic';
373
- linkGameComponentHmrClasses(inst.constructor as unknown as GameComponentClass, NewClass);
391
+ linkGameComponentHmrClasses(inst.constructor as unknown as AnyGameComponentClass, NewClass);
374
392
  Object.setPrototypeOf(inst, NewClass.prototype);
375
393
  migratePhase(inst, oldPhase, newPhase);
376
394
  reparseSchemaOnSwap(inst, NewClass, name);
@@ -412,7 +430,7 @@ export function createComponentManager(
412
430
  */
413
431
  function reparseSchemaOnSwap(
414
432
  inst: AnyComponent,
415
- NewClass: GameComponentClass,
433
+ NewClass: AnyGameComponentClass,
416
434
  key: string,
417
435
  ): void {
418
436
  const schema = NewClass.schema;
@@ -476,7 +494,7 @@ export function createComponentManager(
476
494
 
477
495
  function performAttach(node: AnyNode, instance: AnyComponent, registry?: RegistryAttachInfo) {
478
496
  instance.node = node;
479
- instance.world = resolveWorldInstance(ctx) as WorldInstance;
497
+ instance.world = resolveWorldInstance(ctx, selfManager) as WorldInstance;
480
498
  const refs = resolvePhysicsRefs(node);
481
499
  instance.rigidBody = refs?.body ?? null;
482
500
  instance.collider = refs?.collider ?? null;
@@ -533,7 +551,7 @@ export function createComponentManager(
533
551
  byEntity.delete(node);
534
552
  }
535
553
 
536
- return {
554
+ const manager = {
537
555
  /** Attach a component instance to an entity node (Object3D in a threejs
538
556
  * world, PIXI.Container in a pixijs world). Validates the attach rules
539
557
  * (§3: react-world throw, kind-mismatch throw) synchronously before any
@@ -722,7 +740,11 @@ export function createComponentManager(
722
740
  * can sum it without re-deriving it from a side-channel.
723
741
  */
724
742
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the GameComponent-subclass guard (§7.1-5) and the warnOnMiss option are both load-bearing early-exit branches ahead of the existing match loop — splitting them into separate helpers would obscure that hotSwap has exactly one guarded entry point
725
- hotSwap(name: string, NewClass: GameComponentClass, opts?: { warnOnMiss?: boolean }): number {
743
+ hotSwap(
744
+ name: string,
745
+ NewClass: AnyGameComponentClass,
746
+ opts?: { warnOnMiss?: boolean },
747
+ ): number {
726
748
  if (typeof NewClass !== 'function' || !(NewClass.prototype instanceof GameComponent)) {
727
749
  // biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors logHmrSwapMiss's own deliberate direct console.warn
728
750
  console.warn(
@@ -734,6 +756,15 @@ export function createComponentManager(
734
756
  );
735
757
  return 0;
736
758
  }
759
+ if (kind === 'react' || (NewClass.declaredKind !== 'any' && NewClass.declaredKind !== kind)) {
760
+ // biome-ignore lint/suspicious/noConsole: same guarded, greppable HMR refusal as the subclass check above
761
+ console.warn(
762
+ `[component-manager] hotSwap("${name}"): refused — ` +
763
+ `"${NewClass.name}" declares kind "${NewClass.declaredKind}" but this manager owns ` +
764
+ `"${kind}" entities, so no live instance was touched.`,
765
+ );
766
+ return 0;
767
+ }
737
768
  let matched = 0;
738
769
  for (const [, instances] of byEntity) {
739
770
  for (const inst of instances) {
@@ -776,6 +807,8 @@ export function createComponentManager(
776
807
  detachedThisTick.clear();
777
808
  },
778
809
  };
810
+ selfManager = manager;
811
+ return manager;
779
812
  }
780
813
 
781
814
  export type ComponentManager = ReturnType<typeof createComponentManager>;
@@ -6,6 +6,7 @@ import type { z } from 'zod';
6
6
  import type { SystemPhaseName } from '../core/types';
7
7
  import type { AdapterSurface, WorldInstance } from '../runtime/game';
8
8
  import type { GameContext } from '../runtime/types';
9
+ import type { World2DContext } from '../world2d/types';
9
10
 
10
11
  /**
11
12
  * Map a {@link AdapterSurface} to its native world node type (T7.2, D8 —
@@ -33,6 +34,11 @@ export type ColliderOf<K extends AdapterSurface> = K extends 'threejs'
33
34
  ? RAPIER2D.Collider
34
35
  : never;
35
36
 
37
+ /** Native lifecycle context for a component's declared surface. */
38
+ export type ComponentContextOf<K extends AdapterSurface> = K extends 'pixijs'
39
+ ? World2DContext
40
+ : GameContext;
41
+
36
42
  /**
37
43
  * Base class for game components attached to entities.
38
44
  *
@@ -209,23 +215,46 @@ export abstract class GameComponent<K extends AdapterSurface = 'threejs'> {
209
215
  collider: ColliderOf<K> | null = null;
210
216
 
211
217
  /** Called once after the entity is fully constructed (node, physics, etc.). */
212
- init?(ctx: GameContext): void | Promise<void>;
218
+ init?(ctx: ComponentContextOf<K>): void | Promise<void>;
213
219
 
214
220
  /** Called every fixed timestep. */
215
- abstract update(dt: number, ctx: GameContext): void;
221
+ abstract update(dt: number, ctx: ComponentContextOf<K>): void;
216
222
 
217
223
  /** Called when the entity is destroyed or the component is removed. */
218
- dispose?(ctx: GameContext): void;
224
+ dispose?(ctx: ComponentContextOf<K>): void;
219
225
 
220
226
  /** Called when a sensor overlap with `other` begins. */
221
- onTriggerEnter?(other: NodeOf<K>, ctx: GameContext): void;
227
+ onTriggerEnter?(other: NodeOf<K>, ctx: ComponentContextOf<K>): void;
222
228
 
223
229
  /** Called when a sensor overlap with `other` ends. */
224
- onTriggerExit?(other: NodeOf<K>, ctx: GameContext): void;
230
+ onTriggerExit?(other: NodeOf<K>, ctx: ComponentContextOf<K>): void;
225
231
  }
226
232
 
227
- /** Constructor type for GameComponent subclasses (carries static phase/schema). */
228
- export type GameComponentClass = (new () => GameComponent) & {
229
- phase?: SystemPhaseName;
230
- schema?: z.ZodObject<z.ZodRawShape>;
231
- };
233
+ /** Constructor type for one surface's GameComponent subclasses. The default
234
+ * remains Three so existing scene registries stay surface-narrowed. */
235
+ export type GameComponentClass<K extends AdapterSurface = 'threejs'> =
236
+ (new () => GameComponent<K>) & {
237
+ declaredKind: AdapterSurface | 'any';
238
+ phase?: SystemPhaseName;
239
+ schema?: z.ZodObject<z.ZodRawShape>;
240
+ };
241
+
242
+ /** Cross-surface constructor contract for tooling such as HMR and script
243
+ * discovery. React contributes no class because React nodes cannot host
244
+ * GameComponents. Runtime attach still enforces each class's declaredKind. */
245
+ export type AnyGameComponentClass = GameComponentClass<'threejs'> | GameComponentClass<'pixijs'>;
246
+
247
+ export function gameComponentClassMatchesSurface(
248
+ component: AnyGameComponentClass,
249
+ surface: 'threejs',
250
+ ): component is GameComponentClass<'threejs'>;
251
+ export function gameComponentClassMatchesSurface(
252
+ component: AnyGameComponentClass,
253
+ surface: 'pixijs',
254
+ ): component is GameComponentClass<'pixijs'>;
255
+ export function gameComponentClassMatchesSurface(
256
+ component: AnyGameComponentClass,
257
+ surface: 'threejs' | 'pixijs',
258
+ ): boolean {
259
+ return component.declaredKind === surface || component.declaredKind === 'any';
260
+ }