@vgai/engine 0.5.7 → 0.5.9

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 (62) hide show
  1. package/dist/adapter/ingest/contract-debug-adapter.d.ts +35 -0
  2. package/dist/adapter/ingest/contract-debug-adapter.d.ts.map +1 -0
  3. package/dist/adapter/ingest/contract-debug-adapter.js +90 -0
  4. package/dist/adapter/ingest/game-contract.d.ts +60 -1
  5. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  6. package/dist/adapter/ingest/game-contract.js +1 -1
  7. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  8. package/dist/adapter/setup-three-root-adapter.js +94 -2
  9. package/dist/core/game-scoped-slot.d.ts +14 -0
  10. package/dist/core/game-scoped-slot.d.ts.map +1 -0
  11. package/dist/core/game-scoped-slot.js +24 -0
  12. package/dist/core/seeded-random.d.ts.map +1 -1
  13. package/dist/core/seeded-random.js +3 -2
  14. package/dist/core/sim-clock.d.ts.map +1 -1
  15. package/dist/core/sim-clock.js +3 -2
  16. package/dist/dev/performance-profiler.d.ts +13 -7
  17. package/dist/dev/performance-profiler.d.ts.map +1 -1
  18. package/dist/dev/performance-profiler.js +31 -3
  19. package/dist/dev/register-render-vitals.d.ts +95 -0
  20. package/dist/dev/register-render-vitals.d.ts.map +1 -0
  21. package/dist/dev/register-render-vitals.js +182 -0
  22. package/dist/dev/render-census.d.ts +135 -0
  23. package/dist/dev/render-census.d.ts.map +1 -0
  24. package/dist/dev/render-census.js +257 -0
  25. package/dist/dev/render-vitals.d.ts +181 -0
  26. package/dist/dev/render-vitals.d.ts.map +1 -0
  27. package/dist/dev/render-vitals.js +232 -0
  28. package/dist/dev/static-batch-advisor.d.ts +106 -0
  29. package/dist/dev/static-batch-advisor.d.ts.map +1 -0
  30. package/dist/dev/static-batch-advisor.js +141 -0
  31. package/dist/render/render-batch-system.d.ts.map +1 -1
  32. package/dist/render/render-batch-system.js +7 -18
  33. package/dist/render/structural-signature.d.ts +148 -0
  34. package/dist/render/structural-signature.d.ts.map +1 -0
  35. package/dist/render/structural-signature.js +193 -0
  36. package/dist/runtime/debug-registry.d.ts +2 -2
  37. package/dist/runtime/debug-registry.d.ts.map +1 -1
  38. package/dist/runtime/debug-registry.js +4 -3
  39. package/dist/runtime/dev-layers.d.ts.map +1 -1
  40. package/dist/runtime/dev-layers.js +6 -0
  41. package/dist/runtime/game.js +2 -2
  42. package/dist/runtime/gameplay-rng-trap.d.ts.map +1 -1
  43. package/dist/runtime/gameplay-rng-trap.js +2 -1
  44. package/package.json +1 -1
  45. package/schemas/engine-capabilities.json +5 -5
  46. package/src/adapter/ingest/contract-debug-adapter.ts +110 -0
  47. package/src/adapter/ingest/game-contract.ts +63 -1
  48. package/src/adapter/setup-three-root-adapter.ts +94 -2
  49. package/src/core/game-scoped-slot.ts +28 -0
  50. package/src/core/seeded-random.ts +3 -2
  51. package/src/core/sim-clock.ts +3 -2
  52. package/src/dev/performance-profiler.ts +47 -12
  53. package/src/dev/register-render-vitals.ts +249 -0
  54. package/src/dev/render-census.ts +351 -0
  55. package/src/dev/render-vitals.ts +338 -0
  56. package/src/dev/static-batch-advisor.ts +186 -0
  57. package/src/render/render-batch-system.ts +16 -19
  58. package/src/render/structural-signature.ts +231 -0
  59. package/src/runtime/debug-registry.ts +4 -3
  60. package/src/runtime/dev-layers.ts +7 -1
  61. package/src/runtime/game.ts +2 -2
  62. package/src/runtime/gameplay-rng-trap.ts +4 -2
@@ -0,0 +1,186 @@
1
+ /**
2
+ * THE ROUTING from a draw-call reading to the one-line fix.
3
+ *
4
+ * `dev/render-vitals.ts` can already tell a game it is submitting 4,000 draws
5
+ * and `dev/render-census.ts` can already say which subtree they are in. Both
6
+ * require someone to ASK, and both require that someone to already know that
7
+ * static batching exists, is possible here, and is spelled `<Frozen>`. An
8
+ * agent building a game does not know any of that, so the measurement has to
9
+ * do the routing itself — the same idiom as the dev-menu's unconfigured-section
10
+ * warning: the reading names the exact edit.
11
+ *
12
+ * ── THE DECISION IS PURE, THE SCHEDULE IS NOT ───────────────────────────────
13
+ * {@link decideStaticBatchAdvisory} takes a draw-call count and two scan
14
+ * reports and answers with an advisory or `null`. It reads no clock, no
15
+ * scene, no console. `dev/register-render-vitals.ts` owns the impure half —
16
+ * when to scan, and warning once — because that is where the profiler
17
+ * subscription already lives.
18
+ *
19
+ * ── COST ────────────────────────────────────────────────────────────────────
20
+ * The scan is TWO walks of the scene graph, once, after the frame rate has
21
+ * settled ({@link ADVISOR_SETTLE_FRAMES}). Never per frame: a walk of 4,000
22
+ * nodes every frame is itself the kind of cost this advisory exists to
23
+ * remove, and the answer does not change from one frame to the next in a world
24
+ * whose scenery is mount-static — which is the only world the advice applies
25
+ * to anyway.
26
+ *
27
+ * ── WHY IT ASKS INSTEAD OF ACTING ───────────────────────────────────────────
28
+ * "These 2,600 meshes are the same draw" is measurable. "These 2,600 meshes
29
+ * never move" is NOT — nothing in a scene graph distinguishes scenery from a
30
+ * thing that will move on the next input. Inferring it and batching anyway is
31
+ * how a batcher freezes a door half-open. So the advisory names the subtree
32
+ * and the wrapper, and the author (who knows) places it.
33
+ */
34
+
35
+ import type { CensusReport, StructuralBatchReport } from './render-census';
36
+
37
+ /**
38
+ * Draw calls below which no advisory fires, however batchable the scene.
39
+ *
40
+ * The field measurement this capability came out of: ~4,000 draws cost ~11 ms
41
+ * of CPU submission per frame — about 2.75 µs each on a desktop browser. At
42
+ * 500 draws that is ~1.4 ms, roughly 8% of a 60 Hz frame: the first point
43
+ * where halving it is a visible win rather than noise a profiler cannot
44
+ * separate from jitter. Below it, an advisory would be a nag pointing at
45
+ * something that is not costing anything, and a nag that is usually wrong is
46
+ * one nobody reads when it is right.
47
+ */
48
+ export const ADVISOR_DRAW_CALL_THRESHOLD = 500;
49
+
50
+ /**
51
+ * The share of the draw calls that must be collapsible before the advice is
52
+ * worth an edit. Half: below that, the wrapper leaves most of the cost exactly
53
+ * where it was, and "you could remove a third of a third" is not a payoff
54
+ * anyone should restructure a scene for.
55
+ */
56
+ export const ADVISOR_COLLAPSIBLE_SHARE = 0.5;
57
+
58
+ /**
59
+ * A subtree must hold at least this share of the collapsible meshes to be
60
+ * named as THE address. Under it the advisory says "across the scene" — an
61
+ * invented address is worse than none, because the reader wraps the wrong
62
+ * group and measures no change.
63
+ */
64
+ export const ADVISOR_SUBTREE_SHARE = 0.4;
65
+
66
+ /**
67
+ * Presented frames to wait before scanning — ~2 s at 60 Hz. Long enough for
68
+ * asset loads and the first setup pass to finish populating the graph (a scan
69
+ * at frame one measures an empty world and stays silent forever), short enough
70
+ * that the line lands while the author is still looking at the boot.
71
+ */
72
+ export const ADVISOR_SETTLE_FRAMES = 120;
73
+
74
+ /** The finding, as a caller can present it however it likes. */
75
+ export interface StaticBatchAdvisory {
76
+ /** The reading that triggered it. */
77
+ readonly drawCalls: number;
78
+ /** Meshes sitting in a structural family of two or more. */
79
+ readonly collapsible: number;
80
+ /** The subtree holding most of them, or `null` when they are spread out. */
81
+ readonly subtree: string | null;
82
+ /** The exact edit, ready to paste — `<Frozen name="Terminal">`. */
83
+ readonly fix: string;
84
+ /** The one-line console message: payoff, address, edit, install. */
85
+ readonly message: string;
86
+ }
87
+
88
+ export interface StaticBatchAdvisoryInput {
89
+ /** `render.vitals`' own reading. `null` before the first presented frame. */
90
+ readonly drawCalls: number | null;
91
+ /** The one-level census, used only to sanity-check the address against a
92
+ * subtree that genuinely exists in the graph. */
93
+ readonly census: CensusReport;
94
+ /** The STRUCTURAL scan — see `render-census.ts` for why the identity scan
95
+ * cannot answer this. */
96
+ readonly structural: StructuralBatchReport;
97
+ }
98
+
99
+ /** `2600` → `2,600`. Digits a person reads at a glance, in the one place the
100
+ * message is built, so every number in it is grouped the same way. */
101
+ function grouped(value: number): string {
102
+ return value.toLocaleString('en-US');
103
+ }
104
+
105
+ /**
106
+ * Decide whether this frame's cost is worth an advisory, and what it should
107
+ * say. Pure — every input is an argument, and the same arguments always
108
+ * produce the same message.
109
+ */
110
+ export function decideStaticBatchAdvisory(
111
+ input: StaticBatchAdvisoryInput,
112
+ ): StaticBatchAdvisory | null {
113
+ const { drawCalls, census, structural } = input;
114
+ if (drawCalls === null || drawCalls < ADVISOR_DRAW_CALL_THRESHOLD) return null;
115
+
116
+ const { collapsible } = structural;
117
+ if (collapsible < drawCalls * ADVISOR_COLLAPSIBLE_SHARE) return null;
118
+
119
+ // The address, when one subtree genuinely dominates. `bySubtree` is already
120
+ // sorted, and a name that no census row confirms is not offered: it would
121
+ // send the reader looking for a node the other render.* commands cannot
122
+ // find either.
123
+ const addressable = new Set(census.subtrees.map((row) => row.name));
124
+ const leader = structural.bySubtree[0];
125
+ const subtree =
126
+ leader &&
127
+ leader.collapsible >= collapsible * ADVISOR_SUBTREE_SHARE &&
128
+ addressable.has(leader.name)
129
+ ? leader.name
130
+ : null;
131
+
132
+ const fix = subtree === null ? '<Frozen>' : `<Frozen name="${subtree}">`;
133
+ const where = subtree === null ? 'spread across the scene' : `mostly under "${subtree}"`;
134
+ const message =
135
+ `[static-batch] ~${grouped(collapsible)} of ${grouped(drawCalls)} draw calls are the same ` +
136
+ `handful of draws repeated (${grouped(structural.familyCount)} structural families), ` +
137
+ `${where}. If that scenery is mount-static — nothing under it moves, re-colours or ` +
138
+ `unmounts after mount — one wrapper collapses it to a few draws: wrap it in ` +
139
+ `${fix}…</Frozen> (vgai add static-batch). Reactive scenery goes outside the wrapper, ` +
140
+ `and a subtree that must stay unbatched declares it: userData={{ staticBatch: false }}.`;
141
+
142
+ return { drawCalls, collapsible, subtree, fix, message };
143
+ }
144
+
145
+ /**
146
+ * Once per PAGE, not once per module evaluation — a hot reload re-runs this
147
+ * module, and an advisory that reappears on every save is one that gets muted
148
+ * along with everything else on the console. Same mechanism, and the same
149
+ * reason, as the dev menu's unconfigured-section warning.
150
+ */
151
+ const WARNED_KEY = '__vgaiStaticBatchAdvised';
152
+
153
+ function alreadyWarned(): boolean {
154
+ return (globalThis as unknown as Record<string, boolean | undefined>)[WARNED_KEY] === true;
155
+ }
156
+
157
+ /**
158
+ * The console IS this advisory's channel: `vgai status` reports console
159
+ * warnings, which is where a building agent already looks. An in-editor
160
+ * banner would be one nobody opens, and a provider would be one nobody reads
161
+ * without already knowing to ask.
162
+ */
163
+ function warnOnConsole(message: string): void {
164
+ // biome-ignore lint/suspicious/noConsole: this function's entire job — see above.
165
+ console.warn(message);
166
+ }
167
+
168
+ /**
169
+ * Emit `advisory` on the console, at most once per page. Answers whether it
170
+ * warned, so a caller can stop scanning.
171
+ */
172
+ export function warnStaticBatchAdvisory(
173
+ advisory: StaticBatchAdvisory,
174
+ /** Injectable so the decision is testable without a console. */
175
+ warn: (message: string) => void = warnOnConsole,
176
+ ): boolean {
177
+ if (alreadyWarned()) return false;
178
+ (globalThis as unknown as Record<string, boolean>)[WARNED_KEY] = true;
179
+ warn(advisory.message);
180
+ return true;
181
+ }
182
+
183
+ /** Test-only: forget that the advisory was ever emitted. */
184
+ export function __resetStaticBatchAdvisoryForTest(): void {
185
+ (globalThis as unknown as Record<string, boolean | undefined>)[WARNED_KEY] = undefined;
186
+ }
@@ -1,6 +1,7 @@
1
1
  import * as THREE from 'three';
2
2
  import { getUserData } from '../ecs/user-data';
3
3
  import type { ResolvedRenderSettings } from './render-settings';
4
+ import { staticBatchSignature } from './structural-signature';
4
5
 
5
6
  /**
6
7
  * Runtime STATIC render-batching (the framework-level transparent perf delivery).
@@ -24,7 +25,15 @@ import type { ResolvedRenderSettings } from './render-settings';
24
25
  * kept in-graph hidden + dirty-synced each frame. A bucket may split into both.
25
26
  *
26
27
  * Scope / limitations (v1):
27
- * - Runs once per scene load (build() is called after loadScene). Entities spawned
28
+ * - NOT WIRED: nothing in the engine calls build() the rendering-scale
29
+ * example's render lab is the only driver (via the hosted-module map), and
30
+ * no adapter batches automatically. That is settled, not pending: a game's
31
+ * production answer is the `static-batch` capability's `<Frozen>`, a
32
+ * DECLARED mount-static subtree in the game's own TSX, and `dev/static-
33
+ * batch-advisor.ts` is what routes an author to it by measurement. Full-
34
+ * auto inference over a scene nobody declared static is what this class
35
+ * would need to become, and it was rejected (issue #1503).
36
+ * - build() is a one-shot scan of the scene handed to it. Entities spawned
28
37
  * AFTER build do not join a batch; call build() again to re-scan. Removing a batched
29
38
  * source leaves a stale instance until rebuild. (Static scenery is load-time stable.)
30
39
  * - The batch culls as ONE unit (no per-instance frustum culling yet — Tier-2).
@@ -39,24 +48,12 @@ interface Group {
39
48
 
40
49
  // Structural signature — groups meshes that are VALUE-identical, not object-identical.
41
50
  // The engine scene-loader instantiates a fresh geometry+material per entity (no dedup),
42
- // so keying on .uuid would never batch a real scene. Keying on geometry type+parameters
43
- // and material type+key-props groups identical authored primitives (e.g. 10k boxes of
44
- // the same size+color one batch). Non-primitive geometry (GLTF, no `.parameters`)
45
- // falls back to uuid it simply won't group, which is the safe default.
46
- const geoKey = (g: THREE.BufferGeometry): string => {
47
- const params = (g as unknown as { parameters?: object }).parameters;
48
- return params ? `${g.type}:${JSON.stringify(params)}` : `uuid:${g.uuid}`;
49
- };
50
- const matKey = (m: THREE.Material): string => {
51
- const s = m as THREE.MeshStandardMaterial;
52
- const col = s.color?.getHexString?.() ?? '';
53
- const map = s.map?.uuid ?? '';
54
- return `${m.type}:${col}:${s.roughness ?? ''}:${s.metalness ?? ''}:${map}:${m.side}:${m.transparent}:${m.vertexColors}`;
55
- };
56
- const SIG = (m: THREE.Mesh): string =>
57
- // include shadow flags: meshes with different cast/receive must not share a batch
58
- // (the InstancedMesh carries one flag for the whole group).
59
- `${geoKey(m.geometry as THREE.BufferGeometry)}|${matKey(m.material as THREE.Material)}|${m.castShadow ? 1 : 0}${m.receiveShadow ? 1 : 0}`;
51
+ // so keying on .uuid would never batch a real scene; a TSX/R3F world writing inline
52
+ // `<meshStandardMaterial>` elements has exactly the same property. That key lives in
53
+ // `render/structural-signature.ts` ONE owner, shared with the `static-batch`
54
+ // capability's `<Frozen>`, so the two cannot drift into disagreeing about what
55
+ // "the same draw" means. `staticBatchSignature` is this file's original key, moved.
56
+ const SIG = staticBatchSignature;
60
57
 
61
58
  /** Match Three.js renderer visibility: a hidden ancestor hides the whole subtree. */
62
59
  function isEffectivelyVisible(obj: THREE.Object3D): boolean {
@@ -0,0 +1,231 @@
1
+ /**
2
+ * STRUCTURAL IDENTITY AND ELIGIBILITY for static render batching — the one
3
+ * place that answers "may this draw be collapsed at all?" and "are these two
4
+ * meshes the same thing drawn twice?".
5
+ *
6
+ * Three consumers, which is the whole reason it is a module rather than three
7
+ * private helpers: `render/render-batch-system.ts` (the engine's own
8
+ * lab-driven instancer), `dev/render-census.ts`'s structural scan (what the
9
+ * advisor and `render.families` measure), and the `static-batch` capability's
10
+ * `<Frozen>` (what a game actually installs). A batcher and the advisor that
11
+ * routes people to it MUST agree about what is batchable, or the advisor
12
+ * promises a win the wrapper then declines to take.
13
+ *
14
+ * ── THE UUID TRAP, WHICH IS WHY THIS MODULE EXISTS ──────────────────────────
15
+ * The obvious key for "can these two meshes be drawn as one" is object
16
+ * identity: same `geometry.uuid`, same `material.uuid`. It is also the key
17
+ * that silently batches NOTHING in the shape that matters most. A freshly
18
+ * scaffolded TSX/R3F world writes its materials INLINE —
19
+ *
20
+ * {crates.map((c) => (
21
+ * <mesh key={c.id} position={c.at}>
22
+ * <boxGeometry args={[1, 1, 1]} />
23
+ * <meshStandardMaterial color="#8a6a44" />
24
+ * </mesh>
25
+ * ))}
26
+ *
27
+ * — and every one of those `<meshStandardMaterial>` elements constructs its
28
+ * OWN `THREE.MeshStandardMaterial`. Five hundred crates are five hundred
29
+ * distinct uuids describing one identical appearance. A uuid-keyed grouper
30
+ * reports five hundred families of one, finds nothing to do, and is indistin-
31
+ * guishable from a correct batcher over an unbatchable scene. The engine's own
32
+ * scene construction has the same property, which is why
33
+ * `render/render-batch-system.ts` has keyed on VALUE since it was written.
34
+ *
35
+ * So the key here is the STRUCTURE: geometry `type` + its construction
36
+ * `parameters`, material `type` + the props that decide what the draw looks
37
+ * like. Two independently constructed `BoxGeometry(1,1,1)` +
38
+ * `MeshStandardMaterial({color:'#8a6a44'})` pairs are the same draw, and this
39
+ * module says so.
40
+ *
41
+ * ── WHERE VALUE CANNOT ANSWER, IDENTITY IS THE SAFE FALLBACK ────────────────
42
+ * Twice below, a structural comparison would be a guess, and the answer is
43
+ * `uuid:` — a key nothing else can equal, so the members simply do not group.
44
+ * Not grouping costs a draw call; grouping two things that only LOOK alike
45
+ * renders the wrong picture.
46
+ * - geometry with no `.parameters` (a loaded GLTF mesh, a hand-built
47
+ * `BufferGeometry`): its vertices are its identity and comparing them is
48
+ * not a signature, it is a diff.
49
+ * - a shader material ({@link materialMergeSignature} only): its appearance
50
+ * lives in shader source and uniforms that no fixed prop list can read.
51
+ *
52
+ * ── TWO KEYS, DELIBERATELY ──────────────────────────────────────────────────
53
+ * {@link staticBatchSignature} is `RenderBatchSystem`'s key, extracted here
54
+ * unchanged so there is one owner of the answer rather than two that drift.
55
+ * {@link materialMergeSignature} is STRICTER, and it is what the `static-batch`
56
+ * capability's `<Frozen>` groups by: that path hands ONE material instance to
57
+ * a merged/instanced product, so its members must be interchangeable, not
58
+ * merely similar. The looser key predates it and is kept exactly as it was —
59
+ * see that constant's own note.
60
+ */
61
+
62
+ import type * as THREE from 'three';
63
+
64
+ /**
65
+ * The `userData` key a node opts OUT of static batching with — the one
66
+ * declaration that beats every measurement:
67
+ *
68
+ * <group name="Beacons" userData={{ staticBatch: false }}>
69
+ *
70
+ * Set on a node, it covers that node's whole subtree (a walker stops there).
71
+ * It is deliberately `userData` and not a component prop: the thing being
72
+ * excluded is a three node, and every authoring lane — TSX, a loaded GLTF, a
73
+ * hand-built graph — can set `userData` on one.
74
+ */
75
+ export const STATIC_BATCH_OPT_OUT_KEY = 'staticBatch';
76
+
77
+ /**
78
+ * Why one node may not be collapsed into a batch. `null` from
79
+ * {@link staticBatchSkipReason} means it may.
80
+ *
81
+ * Each of these is a case where a batched draw would render something DIFFERENT
82
+ * from the originals, not merely a case that is awkward to implement:
83
+ * - `instanced` / `batched` — already one draw; swallowing it would flatten
84
+ * per-instance transforms the batch does not carry.
85
+ * - `skinned` — its vertices are posed by a bone matrix palette every frame;
86
+ * baking one pose freezes the character mid-stride.
87
+ * - `morph-targets` — same, driven by influences instead of bones.
88
+ * - `multi-material` — the geometry's `groups` select a material per range;
89
+ * a batch carries one material.
90
+ * - `transparent` — blending is order-dependent and three sorts TRANSPARENT
91
+ * OBJECTS, not triangles. Collapsing them fixes their relative order to
92
+ * whatever the merge happened to write.
93
+ * - `opted-out` — see {@link STATIC_BATCH_OPT_OUT_KEY}.
94
+ */
95
+ export type StaticBatchSkipReason =
96
+ | 'not-a-mesh'
97
+ | 'instanced'
98
+ | 'batched'
99
+ | 'skinned'
100
+ | 'no-geometry'
101
+ | 'multi-material'
102
+ | 'morph-targets'
103
+ | 'transparent'
104
+ | 'opted-out';
105
+
106
+ interface MeshKinds {
107
+ isMesh?: boolean;
108
+ isInstancedMesh?: boolean;
109
+ isBatchedMesh?: boolean;
110
+ isSkinnedMesh?: boolean;
111
+ geometry?: THREE.BufferGeometry;
112
+ material?: THREE.Material | THREE.Material[];
113
+ }
114
+
115
+ /**
116
+ * Why `node` may not join a static batch, or `null` when it may. Pure, cheap,
117
+ * and the SINGLE owner of that answer — see the module note for why the
118
+ * batcher and the advisor cannot each keep their own copy.
119
+ *
120
+ * Checks the node itself only. Subtree exclusion (an opted-out ancestor) is
121
+ * the caller's walk, because the walkers that need this already prune.
122
+ */
123
+ export function staticBatchSkipReason(node: THREE.Object3D): StaticBatchSkipReason | null {
124
+ if (node.userData?.[STATIC_BATCH_OPT_OUT_KEY] === false) return 'opted-out';
125
+ const mesh = node as THREE.Object3D & MeshKinds;
126
+ if (mesh.isInstancedMesh) return 'instanced';
127
+ if (mesh.isBatchedMesh) return 'batched';
128
+ if (mesh.isSkinnedMesh) return 'skinned';
129
+ if (!mesh.isMesh) return 'not-a-mesh';
130
+ if (!mesh.geometry || !mesh.material) return 'no-geometry';
131
+ if (Array.isArray(mesh.material)) return 'multi-material';
132
+ if (Object.keys(mesh.geometry.morphAttributes).length > 0) return 'morph-targets';
133
+ if (mesh.material.transparent) return 'transparent';
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Geometry identity: construction `type` + `parameters` for the parametric
139
+ * primitives, object identity for everything else. See the module note.
140
+ */
141
+ export function geometrySignature(geometry: THREE.BufferGeometry): string {
142
+ const params = (geometry as unknown as { parameters?: object }).parameters;
143
+ return params ? `${geometry.type}:${JSON.stringify(params)}` : `uuid:${geometry.uuid}`;
144
+ }
145
+
146
+ /**
147
+ * Material identity as `RenderBatchSystem` has always computed it: type plus
148
+ * the standard-material props that change the draw.
149
+ *
150
+ * FROZEN ON PURPOSE. This is one half of {@link staticBatchSignature}, which
151
+ * is a live batcher's grouping key; widening or narrowing it silently
152
+ * regroups that batcher's scenes. New discrimination goes in
153
+ * {@link materialMergeSignature}, which is free to be stricter because
154
+ * stricter only ever means "batches less".
155
+ */
156
+ export function materialSignature(material: THREE.Material): string {
157
+ const standard = material as THREE.MeshStandardMaterial;
158
+ const color = standard.color?.getHexString?.() ?? '';
159
+ const map = standard.map?.uuid ?? '';
160
+ return `${material.type}:${color}:${standard.roughness ?? ''}:${standard.metalness ?? ''}:${map}:${material.side}:${material.transparent}:${material.vertexColors}`;
161
+ }
162
+
163
+ /**
164
+ * One mesh's batch key: geometry, material, and the shadow flags.
165
+ *
166
+ * The shadow flags are part of the key because a batched product carries ONE
167
+ * `castShadow`/`receiveShadow` pair for every member it swallowed — mixing a
168
+ * caster and a non-caster into one draw changes the picture.
169
+ */
170
+ export function staticBatchSignature(mesh: THREE.Mesh): string {
171
+ const geometry = mesh.geometry as THREE.BufferGeometry;
172
+ const material = mesh.material as THREE.Material;
173
+ return `${geometrySignature(geometry)}|${materialSignature(material)}|${mesh.castShadow ? 1 : 0}${mesh.receiveShadow ? 1 : 0}`;
174
+ }
175
+
176
+ /**
177
+ * Which vertex attributes a geometry carries, and whether it is indexed —
178
+ * the compatibility precondition for `mergeGeometries`, which refuses a batch
179
+ * whose members disagree.
180
+ *
181
+ * Item size is in the key too: two geometries can both have `uv` and disagree
182
+ * about whether it is 2- or 3-component, which merges into silent garbage
183
+ * rather than a refusal.
184
+ */
185
+ export function geometryLayoutSignature(geometry: THREE.BufferGeometry): string {
186
+ const attributes = Object.keys(geometry.attributes)
187
+ .sort()
188
+ .map((name) => `${name}:${geometry.attributes[name]?.itemSize ?? '?'}`)
189
+ .join(',');
190
+ return `${attributes}${geometry.getIndex() ? '|i' : ''}`;
191
+ }
192
+
193
+ /**
194
+ * Material identity for a path that will SHARE one material instance between
195
+ * every member it collapses — stricter than {@link materialSignature}.
196
+ *
197
+ * Everything the looser key reads, plus the props that decide the picture
198
+ * without touching colour/roughness/metalness: opacity and the depth/blend
199
+ * state, emissive, the remaining standard maps, and the flags that change the
200
+ * compiled program. A shader material answers with its uuid instead (see the
201
+ * module note): its appearance is source and uniforms, and there is no honest
202
+ * fixed-prop reading of it.
203
+ */
204
+ export function materialMergeSignature(material: THREE.Material): string {
205
+ const shader = material as THREE.ShaderMaterial & { isRawShaderMaterial?: boolean };
206
+ if (shader.isShaderMaterial || shader.isRawShaderMaterial) return `uuid:${material.uuid}`;
207
+
208
+ const rich = material as THREE.MeshPhysicalMaterial;
209
+ const maps = [rich.normalMap, rich.aoMap, rich.emissiveMap, rich.roughnessMap, rich.metalnessMap]
210
+ .map((map) => map?.uuid ?? '')
211
+ .join(',');
212
+ // A material with a patched `onBeforeCompile` declares its variant through
213
+ // this hook (that is what three itself keys its program cache on), so a
214
+ // non-empty value discriminates here too.
215
+ const program = material.customProgramCacheKey?.() ?? '';
216
+ return [
217
+ materialSignature(material),
218
+ material.opacity,
219
+ material.depthWrite,
220
+ material.depthTest,
221
+ material.alphaTest,
222
+ material.blending,
223
+ material.toneMapped,
224
+ material.visible,
225
+ rich.emissive?.getHexString?.() ?? '',
226
+ rich.flatShading ?? '',
227
+ rich.wireframe ?? '',
228
+ maps,
229
+ program,
230
+ ].join('|');
231
+ }
@@ -4,8 +4,8 @@
4
4
  * SAME registry via {@link DebugRegistry.forRoot}, so a name a game registers is
5
5
  * visible (and name-collision-checked) across every world, not just the one that
6
6
  * registered it. `createGame` (`runtime/game.ts`) constructs the registry
7
- * alongside the state bridge and files it in the `Game -> DebugRegistry` WeakMap
8
- * this module owns; later consumers (the react `useDebugProvider`/
7
+ * alongside the state bridge and files it in a non-enumerable game-scoped slot;
8
+ * later consumers (the react `useDebugProvider`/
9
9
  * `useDebugCommand` hooks, editor panels) reach it via {@link getDebugRegistry}
10
10
  * rather than threading it through every call site.
11
11
  *
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { z } from 'zod';
24
24
  import type { DebugAdapter, DebugCommandInfo, TickStampedEvent } from '../adapter/system-adapter';
25
+ import { createGameScopedSlot } from '../core/game-scoped-slot';
25
26
  import type { GameLoopLiveness } from '../core/types';
26
27
  import type { Game } from './game';
27
28
  import type { DebugCommandArgs, DebugCtxSurface, DebugRoomHandle } from './types';
@@ -865,7 +866,7 @@ export function createDebugRegistry(opts: {
865
866
  };
866
867
  }
867
868
 
868
- const registryByGame = new WeakMap<Game, DebugRegistry>();
869
+ const registryByGame = createGameScopedSlot<DebugRegistry>('debug-registry');
869
870
 
870
871
  /** Called once by `createGame`, right after both the registry and the Game
871
872
  * shell object exist, to file the association {@link getDebugRegistry} reads. */
@@ -32,7 +32,13 @@
32
32
  */
33
33
  export function devLayersEnabled(override?: boolean | undefined): boolean {
34
34
  if (override !== undefined) return override;
35
- const env = import.meta.env as
35
+ // `import.meta` is cast whole, not just its `.env`: this module is reachable
36
+ // from programs whose tsconfig does not pull in `vite/client` (the session
37
+ // client's, for one, which reaches the three adapter transitively), and there
38
+ // `ImportMeta` has no declared `env` at all. The cast keeps the single owner
39
+ // of the dev answer importable from ANY program rather than forcing every
40
+ // downstream tsconfig to adopt Vite's ambient types.
41
+ const env = (import.meta as unknown as { env?: unknown }).env as
36
42
  | { DEV?: boolean | undefined; VITE_VGAI_DEV_LAYERS?: string | undefined }
37
43
  | undefined;
38
44
  if (env?.DEV === true) return true;
@@ -862,7 +862,7 @@ export function createGame(opts: {
862
862
  // same `advanced` guard as the two bumps above, so a paused/frozen frame
863
863
  // fires no timers). Every world's `ctx.clock` is THIS instance, reached the
864
864
  // same way `ctx.random`/`ctx.debug` reach their game-scoped singletons —
865
- // `getSimClock(host.game)` over the WeakMap filed below.
865
+ // `getSimClock(host.game)` over the game-scoped slot filed below.
866
866
  //
867
867
  // The disposer is supplied HERE rather than inside the clock because
868
868
  // `core/sim-clock.ts` deliberately knows nothing about Rapier or shared
@@ -1568,7 +1568,7 @@ export function createGame(opts: {
1568
1568
  },
1569
1569
  };
1570
1570
 
1571
- // Filed AFTER the shell exists (the WeakMap keys on the Game object
1571
+ // Filed AFTER the shell exists (the slot lives on the Game object
1572
1572
  // itself) so `getDebugRegistry(game)` — the react hooks' and any later
1573
1573
  // consumer's reach-in — works from the moment `createGame` returns.
1574
1574
  registerDebugRegistry(gameInternal, debugRegistry);
@@ -20,6 +20,8 @@
20
20
  * contract (the design doc is explicit about this — "warn, never throw").
21
21
  */
22
22
 
23
+ import { createGameScopedSlot } from '../core/game-scoped-slot';
24
+
23
25
  export interface GameplayRngTrap {
24
26
  /** Wrap `Math.random` for the duration of one frame — call at frame start. */
25
27
  enable(): void;
@@ -100,7 +102,7 @@ export function createGameplayRngTrap(
100
102
  }
101
103
 
102
104
  // ---------------------------------------------------------------------------
103
- // Game-scoped registry — same WeakMap-on-owner-object pattern as
105
+ // Game-scoped registry — same slot-on-owner-object pattern as
104
106
  // `core/seeded-random.ts`'s `registerSeededRandom`/`getSeededRandom` (and
105
107
  // `debug-registry.ts`'s `registerDebugRegistry`/`getDebugRegistry`): the
106
108
  // trap's enable/disable calls live inside `game.ts`'s `runFrameImpl`, gated
@@ -115,7 +117,7 @@ export interface GameplayRngTrapControl {
115
117
  readonly enabled: boolean;
116
118
  }
117
119
 
118
- const controlByOwner = new WeakMap<object, GameplayRngTrapControl>();
120
+ const controlByOwner = createGameScopedSlot<GameplayRngTrapControl>('gameplay-rng-trap');
119
121
 
120
122
  /** Called once by `createGame`, alongside `registerSeededRandom`/
121
123
  * `registerDebugRegistry`. */