@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,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(
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
218
|
+
init?(ctx: ComponentContextOf<K>): void | Promise<void>;
|
|
213
219
|
|
|
214
220
|
/** Called every fixed timestep. */
|
|
215
|
-
abstract update(dt: number, ctx:
|
|
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:
|
|
224
|
+
dispose?(ctx: ComponentContextOf<K>): void;
|
|
219
225
|
|
|
220
226
|
/** Called when a sensor overlap with `other` begins. */
|
|
221
|
-
onTriggerEnter?(other: NodeOf<K>, ctx:
|
|
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:
|
|
230
|
+
onTriggerExit?(other: NodeOf<K>, ctx: ComponentContextOf<K>): void;
|
|
225
231
|
}
|
|
226
232
|
|
|
227
|
-
/** Constructor type for GameComponent subclasses
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|