@vgai/engine 0.5.8 → 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 (43) 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/dev/performance-profiler.d.ts +13 -7
  10. package/dist/dev/performance-profiler.d.ts.map +1 -1
  11. package/dist/dev/performance-profiler.js +31 -3
  12. package/dist/dev/register-render-vitals.d.ts +95 -0
  13. package/dist/dev/register-render-vitals.d.ts.map +1 -0
  14. package/dist/dev/register-render-vitals.js +182 -0
  15. package/dist/dev/render-census.d.ts +135 -0
  16. package/dist/dev/render-census.d.ts.map +1 -0
  17. package/dist/dev/render-census.js +257 -0
  18. package/dist/dev/render-vitals.d.ts +181 -0
  19. package/dist/dev/render-vitals.d.ts.map +1 -0
  20. package/dist/dev/render-vitals.js +232 -0
  21. package/dist/dev/static-batch-advisor.d.ts +106 -0
  22. package/dist/dev/static-batch-advisor.d.ts.map +1 -0
  23. package/dist/dev/static-batch-advisor.js +141 -0
  24. package/dist/render/render-batch-system.d.ts.map +1 -1
  25. package/dist/render/render-batch-system.js +7 -18
  26. package/dist/render/structural-signature.d.ts +148 -0
  27. package/dist/render/structural-signature.d.ts.map +1 -0
  28. package/dist/render/structural-signature.js +193 -0
  29. package/dist/runtime/dev-layers.d.ts.map +1 -1
  30. package/dist/runtime/dev-layers.js +6 -0
  31. package/package.json +1 -1
  32. package/schemas/engine-capabilities.json +5 -5
  33. package/src/adapter/ingest/contract-debug-adapter.ts +110 -0
  34. package/src/adapter/ingest/game-contract.ts +63 -1
  35. package/src/adapter/setup-three-root-adapter.ts +94 -2
  36. package/src/dev/performance-profiler.ts +47 -12
  37. package/src/dev/register-render-vitals.ts +249 -0
  38. package/src/dev/render-census.ts +351 -0
  39. package/src/dev/render-vitals.ts +338 -0
  40. package/src/dev/static-batch-advisor.ts +186 -0
  41. package/src/render/render-batch-system.ts +16 -19
  42. package/src/render/structural-signature.ts +231 -0
  43. package/src/runtime/dev-layers.ts +7 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * THE DOOR for live render vitals: one provider and four commands on the
3
+ * game's own debug registry (`runtime/debug-registry.ts`), so a running game
4
+ * explains its own frame cost through the SAME seam every other reading and
5
+ * verb uses — `game.state('render.vitals')` / `game.command('render.census')`
6
+ * through `vgai eval`, and `game.providers()`/`game.commands()` to discover
7
+ * them without being told they exist.
8
+ *
9
+ * Engine-owned and first-party, seeded by the three adapter
10
+ * (`adapter/setup-three-root-adapter.ts`) at the same spot it seeds
11
+ * `systemAdapters.renderDebug` — a game writes nothing to get these, and there
12
+ * is no capability to install. The measurement half is `dev/render-vitals.ts`
13
+ * (derived entirely from profiler frames); the address-book half is
14
+ * `dev/render-census.ts` (pure walks over the live scene). This module is only
15
+ * the wiring between them and the registry.
16
+ *
17
+ * ── THE READING AND THE EXPERIMENTS ─────────────────────────────────────────
18
+ * A number alone does not route an investigation, so each command is the
19
+ * experiment that turns one reading into an address:
20
+ * - `render.census` — which subtree owns the meshes and triangles, with a
21
+ * drill-down argument;
22
+ * - `render.families` — which meshes are identical enough to instance;
23
+ * - `render.toggle` — hide a subtree and watch the stat move (the only way
24
+ * to ATTRIBUTE cost rather than infer it);
25
+ * - `render.dpr` — change the pixel count, which separates GPU fill cost
26
+ * from vsync idle: render CPU that does not move when
27
+ * the drawing buffer halves was never fill-bound.
28
+ *
29
+ * ── GATING ──────────────────────────────────────────────────────────────────
30
+ * The caller gates. `setup-three-root-adapter.ts` calls this only under
31
+ * `devLayersEnabled()` (`runtime/dev-layers.ts` — the ONE owner of "is this a
32
+ * dev context"), so a ship build registers nothing and pays nothing.
33
+ *
34
+ * ── RESOURCE OWNERSHIP ──────────────────────────────────────────────────────
35
+ * OWNER: {@link registerRenderVitals}'s caller. It allocates one
36
+ * {@link RenderVitals} fold (which owns one `profiler.subscribe`), a SECOND,
37
+ * self-ending `profiler.subscribe` for the static-batch advisory, and the
38
+ * registrations, whose provenance is the caller's `worldId`. SHARERS: none.
39
+ * TEARDOWN: the returned `dispose()` — the ONE path that ends both
40
+ * subscriptions. The REGISTRATIONS are ended by the registry's own
41
+ * `strip(worldId)`, which the adapter already runs on stop and warm restart;
42
+ * this module never strips, because a blind strip here would take the game's
43
+ * registrations with it.
44
+ */
45
+ import * as THREE from 'three';
46
+ import { z } from 'zod';
47
+ import { findByName, meshFamilies, sceneCensus, structuralBatchScan } from './render-census';
48
+ import { createRenderVitals } from './render-vitals';
49
+ import { ADVISOR_SETTLE_FRAMES, decideStaticBatchAdvisory, warnStaticBatchAdvisory, } from './static-batch-advisor';
50
+ /** The one provider name — spelled here, referenced everywhere else. */
51
+ export const RENDER_VITALS_PROVIDER = 'render.vitals';
52
+ export const RENDER_CENSUS_COMMAND = 'render.census';
53
+ export const RENDER_FAMILIES_COMMAND = 'render.families';
54
+ export const RENDER_TOGGLE_COMMAND = 'render.toggle';
55
+ export const RENDER_DPR_COMMAND = 'render.dpr';
56
+ const NO_RENDERER = 'render.dpr: this world has no WebGL renderer (headless mount), so there is no pixel ratio ' +
57
+ 'to change. The other render.* commands still work — they read the scene graph.';
58
+ /**
59
+ * Seed the vitals door for one world. Returns `null` when another live world
60
+ * already owns the door: the names are fixed and discoverable on purpose, and
61
+ * the registry (correctly) throws on a cross-world name collision, so the
62
+ * FIRST three root to mount owns them. A second three root's draws still show
63
+ * up in the readings — the profiler is game-scoped, so every world's
64
+ * submission folds into the same frame — but the census commands address the
65
+ * owning root's scene.
66
+ */
67
+ export function registerRenderVitals(deps) {
68
+ const { registry, worldId, profiler, scene, renderer } = deps;
69
+ const taken = registry.adapter
70
+ .providers()
71
+ .some((provider) => provider.name === RENDER_VITALS_PROVIDER);
72
+ if (taken)
73
+ return null;
74
+ const vitals = createRenderVitals(profiler);
75
+ const debug = registry.forRoot(worldId);
76
+ debug.registerStateProvider(RENDER_VITALS_PROVIDER, () => vitals.read());
77
+ debug.registerCommand(RENDER_CENSUS_COMMAND, {
78
+ description: 'Visible meshes and triangles per subtree — the address book behind the draw-call ' +
79
+ 'reading. Pass a subtree name (as listed by a previous census) to drill into it.',
80
+ args: z.tuple([z.string().optional()]),
81
+ locus: 'client',
82
+ }, (subtree) => {
83
+ if (subtree === undefined || subtree === '')
84
+ return sceneCensus(scene);
85
+ const node = findByName(scene, subtree);
86
+ if (!node) {
87
+ throw new Error(`render.census: no node named "${subtree}" in this world. Run render.census with no ` +
88
+ 'argument to list the addressable subtrees.');
89
+ }
90
+ return sceneCensus(node, subtree);
91
+ });
92
+ debug.registerCommand(RENDER_FAMILIES_COMMAND, {
93
+ description: 'Meshes grouped by shared (geometry, material) identity, largest group first — the ' +
94
+ 'instancing shortlist for a draw-call diet. `structural` is the same scan keyed by ' +
95
+ 'VALUE instead of object identity: what would collapse under a <Frozen> wrapper ' +
96
+ '(vgai add static-batch) even when every mesh carries its own inline material.',
97
+ args: z.tuple([]),
98
+ locus: 'client',
99
+ },
100
+ // Two readings, deliberately: the identity families (what can be instanced
101
+ // as the scene stands) and the STRUCTURAL scan (what would collapse if the
102
+ // same draws were batched by value). A world writing inline materials —
103
+ // every fresh scaffold — has families of one and a large structural win,
104
+ // and reporting only the first would say "nothing to do" about the exact
105
+ // scene the `static-batch` capability exists for.
106
+ () => ({ ...meshFamilies(scene), structural: structuralBatchScan(scene) }));
107
+ debug.registerCommand(RENDER_TOGGLE_COMMAND, {
108
+ description: 'Show/hide a named subtree (as listed by render.census) and read render.vitals again — ' +
109
+ 'the difference is what that subtree costs.',
110
+ args: z.tuple([z.string()]),
111
+ locus: 'client',
112
+ }, (name) => {
113
+ const node = findByName(scene, name);
114
+ if (!node) {
115
+ throw new Error(`render.toggle: no node named "${name}" in this world. Run render.census to list the ` +
116
+ 'addressable subtrees.');
117
+ }
118
+ node.visible = !node.visible;
119
+ return { name, visible: node.visible };
120
+ });
121
+ debug.registerCommand(RENDER_DPR_COMMAND, {
122
+ description: 'Set the renderer pixel ratio (retina default is ~2) to probe GPU fill cost — render CPU ' +
123
+ 'that does not move when the drawing buffer shrinks was never fill-bound.',
124
+ args: z.tuple([z.number().positive().max(8)]),
125
+ locus: 'client',
126
+ }, (ratio) => {
127
+ if (!renderer)
128
+ throw new Error(NO_RENDERER);
129
+ const size = renderer.getSize(new THREE.Vector2());
130
+ renderer.setPixelRatio(ratio);
131
+ // `updateStyle: false` — the CSS size is the layout's, not ours to
132
+ // change; only the drawing buffer moves, which is the whole experiment.
133
+ renderer.setSize(size.x, size.y, false);
134
+ return {
135
+ pixelRatio: renderer.getPixelRatio(),
136
+ drawingBuffer: { width: renderer.domElement.width, height: renderer.domElement.height },
137
+ };
138
+ });
139
+ // --- The static-batch advisory (issue #1503) -------------------------------
140
+ // A reading nobody asks for is a reading nobody gets, so the measurement
141
+ // routes itself: once the frame rate has settled, scan ONCE, and if this
142
+ // world is paying for draws it does not have to, name the exact wrapper on
143
+ // the console (`dev/static-batch-advisor.ts` owns the decision and the
144
+ // wording; this owns only WHEN).
145
+ //
146
+ // It rides the profiler subscription rather than a timer because the trigger
147
+ // is presented frames, not wall time: a tab that never presents (hidden,
148
+ // headless) should never scan, and a slow boot should not be measured before
149
+ // its assets are in the graph. The subscription ends itself the moment it
150
+ // fires or the advisory turns out to be `null`.
151
+ let stopAdvisor = null;
152
+ let advised = false;
153
+ const advisorTick = () => {
154
+ if (advised)
155
+ return;
156
+ if (vitals.read().presentedFrames < ADVISOR_SETTLE_FRAMES)
157
+ return;
158
+ advised = true;
159
+ stopAdvisor?.();
160
+ stopAdvisor = null;
161
+ const advisory = decideStaticBatchAdvisory({
162
+ drawCalls: vitals.read().drawCalls,
163
+ census: sceneCensus(scene),
164
+ structural: structuralBatchScan(scene),
165
+ });
166
+ if (advisory)
167
+ warnStaticBatchAdvisory(advisory);
168
+ };
169
+ stopAdvisor = profiler.subscribe(advisorTick);
170
+ let disposed = false;
171
+ return {
172
+ vitals,
173
+ dispose() {
174
+ if (disposed)
175
+ return;
176
+ disposed = true;
177
+ stopAdvisor?.();
178
+ stopAdvisor = null;
179
+ vitals.dispose();
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * THE ADDRESS BOOK behind the render vitals (`dev/render-vitals.ts`): which
3
+ * subtree owns the draw calls, and which meshes are identical enough to
4
+ * instance. A draw-call count with no address is a symptom you cannot act on.
5
+ *
6
+ * Pure functions over a live `THREE.Object3D` — no renderer, no profiler, no
7
+ * game. They are what the `render.census` / `render.families` debug commands
8
+ * run; keeping them here (rather than inline in the adapter) is what lets a
9
+ * headless test build a scene by hand and check the arithmetic.
10
+ *
11
+ * ── WHAT "VISIBLE" MEANS HERE, AND WHY IT IS A WALK ─────────────────────────
12
+ * Visibility in three is HIERARCHICAL: a hidden parent hides its whole
13
+ * subtree, and `Object3D.traverse` cannot prune. So these walk manually and
14
+ * stop descending at an invisible node. That is not a detail — `render.toggle`
15
+ * works by hiding a PARENT, and a census that kept counting the children under
16
+ * it would report that nothing changed.
17
+ *
18
+ * ── TWO FAMILY SCANS, AND WHY BOTH ──────────────────────────────────────────
19
+ * {@link meshFamilies} keys on object IDENTITY: meshes sharing one geometry
20
+ * instance and one material instance (`uuid`) — exactly the precondition for
21
+ * collapsing them into a bare `InstancedMesh` as they stand. It deliberately
22
+ * does not compare material PARAMETERS.
23
+ *
24
+ * {@link structuralBatchScan} keys on STRUCTURE (`render/structural-
25
+ * signature.ts`): meshes that are the same DRAW, however many distinct
26
+ * material objects describe it. A project that writes a material per mesh —
27
+ * which is what inline `<meshStandardMaterial>` in a TSX world does, i.e. what
28
+ * every fresh scaffold does — reads as all families-of-one under the first
29
+ * scan and as one big collapsible family under the second. Both readings are
30
+ * true; they answer different questions, and only the second one routes an
31
+ * author to the fix (`dev/static-batch-advisor.ts`).
32
+ *
33
+ * Nothing here assumes a project interns its materials or that its world is
34
+ * static — the STATIC half is a claim only the author can make, which is why
35
+ * the advisory asks rather than acts.
36
+ */
37
+ import type * as THREE from 'three';
38
+ /** How many rows a census/family report carries. A shortlist is actionable;
39
+ * a full dump through a JSON relay is a wall of text nobody reads. */
40
+ export declare const CENSUS_ROW_LIMIT = 40;
41
+ export declare const FAMILY_ROW_LIMIT = 30;
42
+ /** One addressable subtree's share of the frame. */
43
+ export interface CensusRow {
44
+ /** The node's own name, or a stable placeholder — this is the string
45
+ * `render.census`/`render.toggle` take as their drill-down argument, so an
46
+ * unnamed node is honestly reported as unaddressable rather than given an
47
+ * invented name that would not resolve. */
48
+ readonly name: string;
49
+ readonly meshes: number;
50
+ readonly triangles: number;
51
+ }
52
+ export interface CensusReport {
53
+ /** The subtree this census covers (`'(scene)'` for the whole world). */
54
+ readonly root: string;
55
+ readonly totalMeshes: number;
56
+ readonly totalTriangles: number;
57
+ /** How many rows exist in total, so a truncated report says so out loud
58
+ * instead of reading as complete. */
59
+ readonly subtreeCount: number;
60
+ /** Heaviest first, capped at {@link CENSUS_ROW_LIMIT}. */
61
+ readonly subtrees: readonly CensusRow[];
62
+ }
63
+ /**
64
+ * Tally visible meshes and triangles per direct child of `root`. Pure.
65
+ *
66
+ * One level, deliberately: the report is a set of addresses to drill into
67
+ * with the same command, and a recursive dump is not something you can act on
68
+ * one step at a time. `root` itself contributes its own meshes under its own
69
+ * name when it has any.
70
+ */
71
+ export declare function sceneCensus(root: THREE.Object3D, rootLabel?: string): CensusReport;
72
+ /** First node named `name` in `root`'s subtree, or `null`. Depth-first, so the
73
+ * shallowest match under an ordinary hierarchy wins. */
74
+ export declare function findByName(root: THREE.Object3D, name: string): THREE.Object3D | null;
75
+ /** One (geometry, material) identity and how many meshes share it. */
76
+ export interface FamilyRow {
77
+ /** How many meshes share this identity — the instancing win, minus one. */
78
+ readonly meshes: number;
79
+ /** Triangles ONE member submits. */
80
+ readonly triangles: number;
81
+ /** A representative address (`parent/child`), so the row is findable. */
82
+ readonly example: string;
83
+ }
84
+ export interface FamilyReport {
85
+ /** Visible, non-instanced meshes considered. `InstancedMesh`es are excluded:
86
+ * they are already the thing this report recommends becoming. */
87
+ readonly plainMeshes: number;
88
+ readonly familyCount: number;
89
+ /** Largest first, capped at {@link FAMILY_ROW_LIMIT}. */
90
+ readonly top: readonly FamilyRow[];
91
+ /** How many of {@link plainMeshes} the reported rows cover — the honest
92
+ * headroom figure, so a long tail is not mistaken for a short one. */
93
+ readonly meshesInTop: number;
94
+ }
95
+ /** Group visible non-instanced meshes by (geometry, material) identity. Pure.
96
+ * See the module note for what identity does and does not assume. */
97
+ export declare function meshFamilies(root: THREE.Object3D): FamilyReport;
98
+ /** One top-level subtree's share of the collapsible meshes — the ADDRESS the
99
+ * advisory names, so the fix is a wrapper someone can actually place. */
100
+ export interface StructuralSubtreeRow {
101
+ readonly name: string;
102
+ readonly collapsible: number;
103
+ }
104
+ /** What {@link structuralBatchScan} answers with. A {@link FamilyReport} whose
105
+ * families are keyed by STRUCTURE, plus the two figures the advisory needs. */
106
+ export interface StructuralBatchReport extends FamilyReport {
107
+ /** Meshes sitting in a family of two or more — what would collapse. The
108
+ * saving is a little less than this (each family leaves one product
109
+ * behind), which is why the advisory says "~". */
110
+ readonly collapsible: number;
111
+ /** Meshes the batcher would REFUSE, by reason — the honest other half. A
112
+ * scene of 4,000 transparent quads has no draw-call diet available, and a
113
+ * scan that reported only the upside would send an author to a wrapper
114
+ * that declines every member. */
115
+ readonly skipped: Readonly<Record<string, number>>;
116
+ /** Collapsible meshes per direct child of the scanned root, largest first. */
117
+ readonly bySubtree: readonly StructuralSubtreeRow[];
118
+ }
119
+ /**
120
+ * Group visible meshes by STRUCTURAL identity — the same key the batchers use
121
+ * (`render/structural-signature.ts`), so this measures what `<Frozen>` would
122
+ * actually do rather than what a uuid comparison can see.
123
+ *
124
+ * This is the companion to {@link meshFamilies}, not a replacement, and the
125
+ * difference between the two is the finding: `meshFamilies` asks "which meshes
126
+ * SHARE a geometry and material object", which is what a bare `InstancedMesh`
127
+ * needs; this asks "which meshes are the same draw", which is what a merging
128
+ * batcher needs. On a freshly scaffolded world writing inline materials the
129
+ * first reports families of one and the second reports the win — see the uuid
130
+ * trap in `render/structural-signature.ts`.
131
+ *
132
+ * Pure. One walk, plus a tally.
133
+ */
134
+ export declare function structuralBatchScan(root: THREE.Object3D): StructuralBatchReport;
135
+ //# sourceMappingURL=render-census.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-census.d.ts","sourceRoot":"","sources":["../../src/dev/render-census.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAGpC;uEACuE;AACvE,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAyCnC,oDAAoD;AACpD,MAAM,WAAW,SAAS;IACxB;;;gDAG4C;IAC5C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,YAAY;IAC3B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC;0CACsC;IACtC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,SAAS,SAAS,EAAE,CAAC;CACzC;AAID;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,EAAE,SAAS,SAAY,GAAG,YAAY,CA2CrF;AAED;yDACyD;AACzD,wBAAgB,UAAU,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC,QAAQ,GAAG,IAAI,CAOpF;AAED,sEAAsE;AACtE,MAAM,WAAW,SAAS;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,oCAAoC;IACpC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B;sEACkE;IAClE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,yDAAyD;IACzD,QAAQ,CAAC,GAAG,EAAE,SAAS,SAAS,EAAE,CAAC;IACnC;2EACuE;IACvE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;sEACsE;AACtE,wBAAgB,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,YAAY,CAkC/D;AAED;0EAC0E;AAC1E,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;gFACgF;AAChF,MAAM,WAAW,qBAAsB,SAAQ,YAAY;IACzD;;uDAEmD;IACnD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;sCAGkC;IAClC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,8EAA8E;IAC9E,QAAQ,CAAC,SAAS,EAAE,SAAS,oBAAoB,EAAE,CAAC;CACrD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,qBAAqB,CA+E/E"}
@@ -0,0 +1,257 @@
1
+ /**
2
+ * THE ADDRESS BOOK behind the render vitals (`dev/render-vitals.ts`): which
3
+ * subtree owns the draw calls, and which meshes are identical enough to
4
+ * instance. A draw-call count with no address is a symptom you cannot act on.
5
+ *
6
+ * Pure functions over a live `THREE.Object3D` — no renderer, no profiler, no
7
+ * game. They are what the `render.census` / `render.families` debug commands
8
+ * run; keeping them here (rather than inline in the adapter) is what lets a
9
+ * headless test build a scene by hand and check the arithmetic.
10
+ *
11
+ * ── WHAT "VISIBLE" MEANS HERE, AND WHY IT IS A WALK ─────────────────────────
12
+ * Visibility in three is HIERARCHICAL: a hidden parent hides its whole
13
+ * subtree, and `Object3D.traverse` cannot prune. So these walk manually and
14
+ * stop descending at an invisible node. That is not a detail — `render.toggle`
15
+ * works by hiding a PARENT, and a census that kept counting the children under
16
+ * it would report that nothing changed.
17
+ *
18
+ * ── TWO FAMILY SCANS, AND WHY BOTH ──────────────────────────────────────────
19
+ * {@link meshFamilies} keys on object IDENTITY: meshes sharing one geometry
20
+ * instance and one material instance (`uuid`) — exactly the precondition for
21
+ * collapsing them into a bare `InstancedMesh` as they stand. It deliberately
22
+ * does not compare material PARAMETERS.
23
+ *
24
+ * {@link structuralBatchScan} keys on STRUCTURE (`render/structural-
25
+ * signature.ts`): meshes that are the same DRAW, however many distinct
26
+ * material objects describe it. A project that writes a material per mesh —
27
+ * which is what inline `<meshStandardMaterial>` in a TSX world does, i.e. what
28
+ * every fresh scaffold does — reads as all families-of-one under the first
29
+ * scan and as one big collapsible family under the second. Both readings are
30
+ * true; they answer different questions, and only the second one routes an
31
+ * author to the fix (`dev/static-batch-advisor.ts`).
32
+ *
33
+ * Nothing here assumes a project interns its materials or that its world is
34
+ * static — the STATIC half is a claim only the author can make, which is why
35
+ * the advisory asks rather than acts.
36
+ */
37
+ import { materialMergeSignature, staticBatchSkipReason } from '../render/structural-signature';
38
+ /** How many rows a census/family report carries. A shortlist is actionable;
39
+ * a full dump through a JSON relay is a wall of text nobody reads. */
40
+ export const CENSUS_ROW_LIMIT = 40;
41
+ export const FAMILY_ROW_LIMIT = 30;
42
+ function asMesh(node) {
43
+ const mesh = node;
44
+ return mesh.isMesh || mesh.isSkinnedMesh ? mesh : null;
45
+ }
46
+ /** Triangles one mesh submits: its geometry's own count times its instance
47
+ * count. Non-indexed geometry falls back to the position attribute; a
48
+ * geometry with neither contributes 0 rather than a guess. */
49
+ function triangleCount(mesh) {
50
+ const geometry = mesh.geometry;
51
+ if (!geometry)
52
+ return 0;
53
+ const index = geometry.getIndex?.() ?? null;
54
+ const position = geometry.getAttribute?.('position') ?? null;
55
+ const vertices = index ? index.count : (position?.count ?? 0);
56
+ const instances = mesh.isInstancedMesh ? (mesh.count ?? 1) : 1;
57
+ return Math.round(vertices / 3) * instances;
58
+ }
59
+ /** Walk `node` and its visible descendants, calling `visit` for every visible
60
+ * mesh. Stops at an invisible node — see the module note. */
61
+ function walkVisibleMeshes(node, visit) {
62
+ if (!node.visible)
63
+ return;
64
+ const mesh = asMesh(node);
65
+ if (mesh)
66
+ visit(mesh);
67
+ for (const child of node.children)
68
+ walkVisibleMeshes(child, visit);
69
+ }
70
+ const UNNAMED = '(unnamed)';
71
+ /**
72
+ * Tally visible meshes and triangles per direct child of `root`. Pure.
73
+ *
74
+ * One level, deliberately: the report is a set of addresses to drill into
75
+ * with the same command, and a recursive dump is not something you can act on
76
+ * one step at a time. `root` itself contributes its own meshes under its own
77
+ * name when it has any.
78
+ */
79
+ export function sceneCensus(root, rootLabel = '(scene)') {
80
+ const rows = new Map();
81
+ let totalMeshes = 0;
82
+ let totalTriangles = 0;
83
+ if (!root.visible) {
84
+ return { root: rootLabel, totalMeshes: 0, totalTriangles: 0, subtreeCount: 0, subtrees: [] };
85
+ }
86
+ const tally = (key, node) => {
87
+ walkVisibleMeshes(node, (mesh) => {
88
+ const triangles = triangleCount(mesh);
89
+ const row = rows.get(key) ?? { meshes: 0, triangles: 0 };
90
+ row.meshes += 1;
91
+ row.triangles += triangles;
92
+ rows.set(key, row);
93
+ totalMeshes += 1;
94
+ totalTriangles += triangles;
95
+ });
96
+ };
97
+ // `root`'s own mesh, if it is one, is charged to `root` — not silently
98
+ // dropped because the grouping key is "direct child".
99
+ const selfMesh = asMesh(root);
100
+ if (selfMesh) {
101
+ const triangles = triangleCount(selfMesh);
102
+ rows.set(root.name || rootLabel, { meshes: 1, triangles });
103
+ totalMeshes += 1;
104
+ totalTriangles += triangles;
105
+ }
106
+ for (const child of root.children)
107
+ tally(child.name || UNNAMED, child);
108
+ const sorted = [...rows.entries()]
109
+ .map(([name, row]) => ({ name, meshes: row.meshes, triangles: row.triangles }))
110
+ .sort((a, b) => b.triangles - a.triangles || b.meshes - a.meshes);
111
+ return {
112
+ root: rootLabel,
113
+ totalMeshes,
114
+ totalTriangles,
115
+ subtreeCount: sorted.length,
116
+ subtrees: sorted.slice(0, CENSUS_ROW_LIMIT),
117
+ };
118
+ }
119
+ /** First node named `name` in `root`'s subtree, or `null`. Depth-first, so the
120
+ * shallowest match under an ordinary hierarchy wins. */
121
+ export function findByName(root, name) {
122
+ if (root.name === name)
123
+ return root;
124
+ for (const child of root.children) {
125
+ const hit = findByName(child, name);
126
+ if (hit)
127
+ return hit;
128
+ }
129
+ return null;
130
+ }
131
+ /** Group visible non-instanced meshes by (geometry, material) identity. Pure.
132
+ * See the module note for what identity does and does not assume. */
133
+ export function meshFamilies(root) {
134
+ const families = new Map();
135
+ let plainMeshes = 0;
136
+ walkVisibleMeshes(root, (mesh) => {
137
+ if (mesh.isInstancedMesh)
138
+ return;
139
+ const geometry = mesh.geometry;
140
+ if (!geometry)
141
+ return;
142
+ const material = mesh.material;
143
+ const materialKey = Array.isArray(material)
144
+ ? material.map((entry) => entry.uuid).join('+')
145
+ : (material?.uuid ?? 'none');
146
+ const key = `${geometry.uuid}/${materialKey}`;
147
+ plainMeshes += 1;
148
+ const existing = families.get(key);
149
+ if (existing) {
150
+ existing.meshes += 1;
151
+ return;
152
+ }
153
+ families.set(key, {
154
+ meshes: 1,
155
+ triangles: triangleCount(mesh),
156
+ example: `${mesh.parent?.name || UNNAMED}/${mesh.name || UNNAMED}`,
157
+ });
158
+ });
159
+ const sorted = [...families.values()].sort((a, b) => b.meshes - a.meshes);
160
+ const top = sorted.slice(0, FAMILY_ROW_LIMIT);
161
+ return {
162
+ plainMeshes,
163
+ familyCount: sorted.length,
164
+ top,
165
+ meshesInTop: top.reduce((total, row) => total + row.meshes, 0),
166
+ };
167
+ }
168
+ /**
169
+ * Group visible meshes by STRUCTURAL identity — the same key the batchers use
170
+ * (`render/structural-signature.ts`), so this measures what `<Frozen>` would
171
+ * actually do rather than what a uuid comparison can see.
172
+ *
173
+ * This is the companion to {@link meshFamilies}, not a replacement, and the
174
+ * difference between the two is the finding: `meshFamilies` asks "which meshes
175
+ * SHARE a geometry and material object", which is what a bare `InstancedMesh`
176
+ * needs; this asks "which meshes are the same draw", which is what a merging
177
+ * batcher needs. On a freshly scaffolded world writing inline materials the
178
+ * first reports families of one and the second reports the win — see the uuid
179
+ * trap in `render/structural-signature.ts`.
180
+ *
181
+ * Pure. One walk, plus a tally.
182
+ */
183
+ export function structuralBatchScan(root) {
184
+ const families = new Map();
185
+ const skipped = {};
186
+ // Which subtree each candidate sits under, kept per mesh so attribution can
187
+ // wait until the families are known (a family of one is not collapsible, and
188
+ // charging its member to a subtree would inflate that subtree's row).
189
+ const members = [];
190
+ let plainMeshes = 0;
191
+ /** One node, no recursion. Answers `false` when its subtree must be pruned. */
192
+ const visit = (node, subtree) => {
193
+ if (!node.visible)
194
+ return false;
195
+ const reason = staticBatchSkipReason(node);
196
+ if (reason === 'opted-out') {
197
+ skipped['opted-out'] = (skipped['opted-out'] ?? 0) + 1;
198
+ return false; // an opt-out covers its whole subtree
199
+ }
200
+ if (reason === null) {
201
+ const mesh = node;
202
+ const material = mesh.material;
203
+ const key = `${materialMergeSignature(material)}|${node.castShadow ? 1 : 0}${node.receiveShadow ? 1 : 0}`;
204
+ plainMeshes += 1;
205
+ members.push({ key, subtree });
206
+ const existing = families.get(key);
207
+ if (existing)
208
+ existing.meshes += 1;
209
+ else {
210
+ families.set(key, {
211
+ meshes: 1,
212
+ triangles: triangleCount(mesh),
213
+ example: `${node.parent?.name || UNNAMED}/${node.name || UNNAMED}`,
214
+ });
215
+ }
216
+ }
217
+ else if (reason !== 'not-a-mesh') {
218
+ skipped[reason] = (skipped[reason] ?? 0) + 1;
219
+ }
220
+ return true;
221
+ };
222
+ const scan = (node, subtree) => {
223
+ if (!visit(node, subtree))
224
+ return;
225
+ for (const child of node.children)
226
+ scan(child, subtree);
227
+ };
228
+ // `root`'s own mesh, if it is one, is charged to `root` — the same rule
229
+ // `sceneCensus` uses, so the two reports address the same graph.
230
+ if (visit(root, root.name || UNNAMED)) {
231
+ for (const child of root.children)
232
+ scan(child, child.name || UNNAMED);
233
+ }
234
+ const collapsibleKeys = new Set([...families.entries()].filter(([, family]) => family.meshes >= 2).map(([key]) => key));
235
+ const perSubtree = new Map();
236
+ let collapsible = 0;
237
+ for (const member of members) {
238
+ if (!collapsibleKeys.has(member.key))
239
+ continue;
240
+ collapsible += 1;
241
+ perSubtree.set(member.subtree, (perSubtree.get(member.subtree) ?? 0) + 1);
242
+ }
243
+ const sorted = [...families.values()].sort((a, b) => b.meshes - a.meshes);
244
+ const top = sorted.slice(0, FAMILY_ROW_LIMIT);
245
+ return {
246
+ plainMeshes,
247
+ familyCount: sorted.length,
248
+ top,
249
+ meshesInTop: top.reduce((total, row) => total + row.meshes, 0),
250
+ collapsible,
251
+ skipped,
252
+ bySubtree: [...perSubtree.entries()]
253
+ .map(([name, count]) => ({ name, collapsible: count }))
254
+ .sort((a, b) => b.collapsible - a.collapsible)
255
+ .slice(0, CENSUS_ROW_LIMIT),
256
+ };
257
+ }