@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.
- package/dist/adapter/ingest/contract-debug-adapter.d.ts +35 -0
- package/dist/adapter/ingest/contract-debug-adapter.d.ts.map +1 -0
- package/dist/adapter/ingest/contract-debug-adapter.js +90 -0
- package/dist/adapter/ingest/game-contract.d.ts +60 -1
- package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
- package/dist/adapter/ingest/game-contract.js +1 -1
- package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
- package/dist/adapter/setup-three-root-adapter.js +94 -2
- package/dist/core/game-scoped-slot.d.ts +14 -0
- package/dist/core/game-scoped-slot.d.ts.map +1 -0
- package/dist/core/game-scoped-slot.js +24 -0
- package/dist/core/seeded-random.d.ts.map +1 -1
- package/dist/core/seeded-random.js +3 -2
- package/dist/core/sim-clock.d.ts.map +1 -1
- package/dist/core/sim-clock.js +3 -2
- package/dist/dev/performance-profiler.d.ts +13 -7
- package/dist/dev/performance-profiler.d.ts.map +1 -1
- package/dist/dev/performance-profiler.js +31 -3
- package/dist/dev/register-render-vitals.d.ts +95 -0
- package/dist/dev/register-render-vitals.d.ts.map +1 -0
- package/dist/dev/register-render-vitals.js +182 -0
- package/dist/dev/render-census.d.ts +135 -0
- package/dist/dev/render-census.d.ts.map +1 -0
- package/dist/dev/render-census.js +257 -0
- package/dist/dev/render-vitals.d.ts +181 -0
- package/dist/dev/render-vitals.d.ts.map +1 -0
- package/dist/dev/render-vitals.js +232 -0
- package/dist/dev/static-batch-advisor.d.ts +106 -0
- package/dist/dev/static-batch-advisor.d.ts.map +1 -0
- package/dist/dev/static-batch-advisor.js +141 -0
- package/dist/render/render-batch-system.d.ts.map +1 -1
- package/dist/render/render-batch-system.js +7 -18
- package/dist/render/structural-signature.d.ts +148 -0
- package/dist/render/structural-signature.d.ts.map +1 -0
- package/dist/render/structural-signature.js +193 -0
- package/dist/runtime/debug-registry.d.ts +2 -2
- package/dist/runtime/debug-registry.d.ts.map +1 -1
- package/dist/runtime/debug-registry.js +4 -3
- package/dist/runtime/dev-layers.d.ts.map +1 -1
- package/dist/runtime/dev-layers.js +6 -0
- package/dist/runtime/game.js +2 -2
- package/dist/runtime/gameplay-rng-trap.d.ts.map +1 -1
- package/dist/runtime/gameplay-rng-trap.js +2 -1
- package/package.json +1 -1
- package/schemas/engine-capabilities.json +5 -5
- package/src/adapter/ingest/contract-debug-adapter.ts +110 -0
- package/src/adapter/ingest/game-contract.ts +63 -1
- package/src/adapter/setup-three-root-adapter.ts +94 -2
- package/src/core/game-scoped-slot.ts +28 -0
- package/src/core/seeded-random.ts +3 -2
- package/src/core/sim-clock.ts +3 -2
- package/src/dev/performance-profiler.ts +47 -12
- package/src/dev/register-render-vitals.ts +249 -0
- package/src/dev/render-census.ts +351 -0
- package/src/dev/render-vitals.ts +338 -0
- package/src/dev/static-batch-advisor.ts +186 -0
- package/src/render/render-batch-system.ts +16 -19
- package/src/render/structural-signature.ts +231 -0
- package/src/runtime/debug-registry.ts +4 -3
- package/src/runtime/dev-layers.ts +7 -1
- package/src/runtime/game.ts +2 -2
- package/src/runtime/gameplay-rng-trap.ts +4 -2
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
|
|
38
|
+
import type * as THREE from 'three';
|
|
39
|
+
import { materialMergeSignature, staticBatchSkipReason } from '../render/structural-signature';
|
|
40
|
+
|
|
41
|
+
/** How many rows a census/family report carries. A shortlist is actionable;
|
|
42
|
+
* a full dump through a JSON relay is a wall of text nobody reads. */
|
|
43
|
+
export const CENSUS_ROW_LIMIT = 40;
|
|
44
|
+
export const FAMILY_ROW_LIMIT = 30;
|
|
45
|
+
|
|
46
|
+
interface MeshLike {
|
|
47
|
+
isMesh?: boolean;
|
|
48
|
+
isSkinnedMesh?: boolean;
|
|
49
|
+
isInstancedMesh?: boolean;
|
|
50
|
+
count?: number;
|
|
51
|
+
geometry?: THREE.BufferGeometry;
|
|
52
|
+
material?: THREE.Material | THREE.Material[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function asMesh(node: THREE.Object3D): (MeshLike & THREE.Object3D) | null {
|
|
56
|
+
const mesh = node as THREE.Object3D & MeshLike;
|
|
57
|
+
return mesh.isMesh || mesh.isSkinnedMesh ? mesh : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Triangles one mesh submits: its geometry's own count times its instance
|
|
61
|
+
* count. Non-indexed geometry falls back to the position attribute; a
|
|
62
|
+
* geometry with neither contributes 0 rather than a guess. */
|
|
63
|
+
function triangleCount(mesh: MeshLike): number {
|
|
64
|
+
const geometry = mesh.geometry;
|
|
65
|
+
if (!geometry) return 0;
|
|
66
|
+
const index = geometry.getIndex?.() ?? null;
|
|
67
|
+
const position = geometry.getAttribute?.('position') ?? null;
|
|
68
|
+
const vertices = index ? index.count : (position?.count ?? 0);
|
|
69
|
+
const instances = mesh.isInstancedMesh ? (mesh.count ?? 1) : 1;
|
|
70
|
+
return Math.round(vertices / 3) * instances;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Walk `node` and its visible descendants, calling `visit` for every visible
|
|
74
|
+
* mesh. Stops at an invisible node — see the module note. */
|
|
75
|
+
function walkVisibleMeshes(
|
|
76
|
+
node: THREE.Object3D,
|
|
77
|
+
visit: (mesh: MeshLike & THREE.Object3D) => void,
|
|
78
|
+
): void {
|
|
79
|
+
if (!node.visible) return;
|
|
80
|
+
const mesh = asMesh(node);
|
|
81
|
+
if (mesh) visit(mesh);
|
|
82
|
+
for (const child of node.children) walkVisibleMeshes(child, visit);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One addressable subtree's share of the frame. */
|
|
86
|
+
export interface CensusRow {
|
|
87
|
+
/** The node's own name, or a stable placeholder — this is the string
|
|
88
|
+
* `render.census`/`render.toggle` take as their drill-down argument, so an
|
|
89
|
+
* unnamed node is honestly reported as unaddressable rather than given an
|
|
90
|
+
* invented name that would not resolve. */
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly meshes: number;
|
|
93
|
+
readonly triangles: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CensusReport {
|
|
97
|
+
/** The subtree this census covers (`'(scene)'` for the whole world). */
|
|
98
|
+
readonly root: string;
|
|
99
|
+
readonly totalMeshes: number;
|
|
100
|
+
readonly totalTriangles: number;
|
|
101
|
+
/** How many rows exist in total, so a truncated report says so out loud
|
|
102
|
+
* instead of reading as complete. */
|
|
103
|
+
readonly subtreeCount: number;
|
|
104
|
+
/** Heaviest first, capped at {@link CENSUS_ROW_LIMIT}. */
|
|
105
|
+
readonly subtrees: readonly CensusRow[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const UNNAMED = '(unnamed)';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Tally visible meshes and triangles per direct child of `root`. Pure.
|
|
112
|
+
*
|
|
113
|
+
* One level, deliberately: the report is a set of addresses to drill into
|
|
114
|
+
* with the same command, and a recursive dump is not something you can act on
|
|
115
|
+
* one step at a time. `root` itself contributes its own meshes under its own
|
|
116
|
+
* name when it has any.
|
|
117
|
+
*/
|
|
118
|
+
export function sceneCensus(root: THREE.Object3D, rootLabel = '(scene)'): CensusReport {
|
|
119
|
+
const rows = new Map<string, { meshes: number; triangles: number }>();
|
|
120
|
+
let totalMeshes = 0;
|
|
121
|
+
let totalTriangles = 0;
|
|
122
|
+
|
|
123
|
+
if (!root.visible) {
|
|
124
|
+
return { root: rootLabel, totalMeshes: 0, totalTriangles: 0, subtreeCount: 0, subtrees: [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const tally = (key: string, node: THREE.Object3D): void => {
|
|
128
|
+
walkVisibleMeshes(node, (mesh) => {
|
|
129
|
+
const triangles = triangleCount(mesh);
|
|
130
|
+
const row = rows.get(key) ?? { meshes: 0, triangles: 0 };
|
|
131
|
+
row.meshes += 1;
|
|
132
|
+
row.triangles += triangles;
|
|
133
|
+
rows.set(key, row);
|
|
134
|
+
totalMeshes += 1;
|
|
135
|
+
totalTriangles += triangles;
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// `root`'s own mesh, if it is one, is charged to `root` — not silently
|
|
140
|
+
// dropped because the grouping key is "direct child".
|
|
141
|
+
const selfMesh = asMesh(root);
|
|
142
|
+
if (selfMesh) {
|
|
143
|
+
const triangles = triangleCount(selfMesh);
|
|
144
|
+
rows.set(root.name || rootLabel, { meshes: 1, triangles });
|
|
145
|
+
totalMeshes += 1;
|
|
146
|
+
totalTriangles += triangles;
|
|
147
|
+
}
|
|
148
|
+
for (const child of root.children) tally(child.name || UNNAMED, child);
|
|
149
|
+
|
|
150
|
+
const sorted = [...rows.entries()]
|
|
151
|
+
.map(([name, row]) => ({ name, meshes: row.meshes, triangles: row.triangles }))
|
|
152
|
+
.sort((a, b) => b.triangles - a.triangles || b.meshes - a.meshes);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
root: rootLabel,
|
|
156
|
+
totalMeshes,
|
|
157
|
+
totalTriangles,
|
|
158
|
+
subtreeCount: sorted.length,
|
|
159
|
+
subtrees: sorted.slice(0, CENSUS_ROW_LIMIT),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** First node named `name` in `root`'s subtree, or `null`. Depth-first, so the
|
|
164
|
+
* shallowest match under an ordinary hierarchy wins. */
|
|
165
|
+
export function findByName(root: THREE.Object3D, name: string): THREE.Object3D | null {
|
|
166
|
+
if (root.name === name) return root;
|
|
167
|
+
for (const child of root.children) {
|
|
168
|
+
const hit = findByName(child, name);
|
|
169
|
+
if (hit) return hit;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One (geometry, material) identity and how many meshes share it. */
|
|
175
|
+
export interface FamilyRow {
|
|
176
|
+
/** How many meshes share this identity — the instancing win, minus one. */
|
|
177
|
+
readonly meshes: number;
|
|
178
|
+
/** Triangles ONE member submits. */
|
|
179
|
+
readonly triangles: number;
|
|
180
|
+
/** A representative address (`parent/child`), so the row is findable. */
|
|
181
|
+
readonly example: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface FamilyReport {
|
|
185
|
+
/** Visible, non-instanced meshes considered. `InstancedMesh`es are excluded:
|
|
186
|
+
* they are already the thing this report recommends becoming. */
|
|
187
|
+
readonly plainMeshes: number;
|
|
188
|
+
readonly familyCount: number;
|
|
189
|
+
/** Largest first, capped at {@link FAMILY_ROW_LIMIT}. */
|
|
190
|
+
readonly top: readonly FamilyRow[];
|
|
191
|
+
/** How many of {@link plainMeshes} the reported rows cover — the honest
|
|
192
|
+
* headroom figure, so a long tail is not mistaken for a short one. */
|
|
193
|
+
readonly meshesInTop: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Group visible non-instanced meshes by (geometry, material) identity. Pure.
|
|
197
|
+
* See the module note for what identity does and does not assume. */
|
|
198
|
+
export function meshFamilies(root: THREE.Object3D): FamilyReport {
|
|
199
|
+
const families = new Map<string, { meshes: number; triangles: number; example: string }>();
|
|
200
|
+
let plainMeshes = 0;
|
|
201
|
+
|
|
202
|
+
walkVisibleMeshes(root, (mesh) => {
|
|
203
|
+
if (mesh.isInstancedMesh) return;
|
|
204
|
+
const geometry = mesh.geometry;
|
|
205
|
+
if (!geometry) return;
|
|
206
|
+
const material = mesh.material;
|
|
207
|
+
const materialKey = Array.isArray(material)
|
|
208
|
+
? material.map((entry) => entry.uuid).join('+')
|
|
209
|
+
: (material?.uuid ?? 'none');
|
|
210
|
+
const key = `${geometry.uuid}/${materialKey}`;
|
|
211
|
+
plainMeshes += 1;
|
|
212
|
+
const existing = families.get(key);
|
|
213
|
+
if (existing) {
|
|
214
|
+
existing.meshes += 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
families.set(key, {
|
|
218
|
+
meshes: 1,
|
|
219
|
+
triangles: triangleCount(mesh),
|
|
220
|
+
example: `${mesh.parent?.name || UNNAMED}/${mesh.name || UNNAMED}`,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const sorted = [...families.values()].sort((a, b) => b.meshes - a.meshes);
|
|
225
|
+
const top = sorted.slice(0, FAMILY_ROW_LIMIT);
|
|
226
|
+
return {
|
|
227
|
+
plainMeshes,
|
|
228
|
+
familyCount: sorted.length,
|
|
229
|
+
top,
|
|
230
|
+
meshesInTop: top.reduce((total, row) => total + row.meshes, 0),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One top-level subtree's share of the collapsible meshes — the ADDRESS the
|
|
235
|
+
* advisory names, so the fix is a wrapper someone can actually place. */
|
|
236
|
+
export interface StructuralSubtreeRow {
|
|
237
|
+
readonly name: string;
|
|
238
|
+
readonly collapsible: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What {@link structuralBatchScan} answers with. A {@link FamilyReport} whose
|
|
242
|
+
* families are keyed by STRUCTURE, plus the two figures the advisory needs. */
|
|
243
|
+
export interface StructuralBatchReport extends FamilyReport {
|
|
244
|
+
/** Meshes sitting in a family of two or more — what would collapse. The
|
|
245
|
+
* saving is a little less than this (each family leaves one product
|
|
246
|
+
* behind), which is why the advisory says "~". */
|
|
247
|
+
readonly collapsible: number;
|
|
248
|
+
/** Meshes the batcher would REFUSE, by reason — the honest other half. A
|
|
249
|
+
* scene of 4,000 transparent quads has no draw-call diet available, and a
|
|
250
|
+
* scan that reported only the upside would send an author to a wrapper
|
|
251
|
+
* that declines every member. */
|
|
252
|
+
readonly skipped: Readonly<Record<string, number>>;
|
|
253
|
+
/** Collapsible meshes per direct child of the scanned root, largest first. */
|
|
254
|
+
readonly bySubtree: readonly StructuralSubtreeRow[];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Group visible meshes by STRUCTURAL identity — the same key the batchers use
|
|
259
|
+
* (`render/structural-signature.ts`), so this measures what `<Frozen>` would
|
|
260
|
+
* actually do rather than what a uuid comparison can see.
|
|
261
|
+
*
|
|
262
|
+
* This is the companion to {@link meshFamilies}, not a replacement, and the
|
|
263
|
+
* difference between the two is the finding: `meshFamilies` asks "which meshes
|
|
264
|
+
* SHARE a geometry and material object", which is what a bare `InstancedMesh`
|
|
265
|
+
* needs; this asks "which meshes are the same draw", which is what a merging
|
|
266
|
+
* batcher needs. On a freshly scaffolded world writing inline materials the
|
|
267
|
+
* first reports families of one and the second reports the win — see the uuid
|
|
268
|
+
* trap in `render/structural-signature.ts`.
|
|
269
|
+
*
|
|
270
|
+
* Pure. One walk, plus a tally.
|
|
271
|
+
*/
|
|
272
|
+
export function structuralBatchScan(root: THREE.Object3D): StructuralBatchReport {
|
|
273
|
+
interface Family {
|
|
274
|
+
meshes: number;
|
|
275
|
+
triangles: number;
|
|
276
|
+
example: string;
|
|
277
|
+
}
|
|
278
|
+
const families = new Map<string, Family>();
|
|
279
|
+
const skipped: Record<string, number> = {};
|
|
280
|
+
// Which subtree each candidate sits under, kept per mesh so attribution can
|
|
281
|
+
// wait until the families are known (a family of one is not collapsible, and
|
|
282
|
+
// charging its member to a subtree would inflate that subtree's row).
|
|
283
|
+
const members: { key: string; subtree: string }[] = [];
|
|
284
|
+
let plainMeshes = 0;
|
|
285
|
+
|
|
286
|
+
/** One node, no recursion. Answers `false` when its subtree must be pruned. */
|
|
287
|
+
const visit = (node: THREE.Object3D, subtree: string): boolean => {
|
|
288
|
+
if (!node.visible) return false;
|
|
289
|
+
const reason = staticBatchSkipReason(node);
|
|
290
|
+
if (reason === 'opted-out') {
|
|
291
|
+
skipped['opted-out'] = (skipped['opted-out'] ?? 0) + 1;
|
|
292
|
+
return false; // an opt-out covers its whole subtree
|
|
293
|
+
}
|
|
294
|
+
if (reason === null) {
|
|
295
|
+
const mesh = node as THREE.Object3D & MeshLike;
|
|
296
|
+
const material = mesh.material as THREE.Material;
|
|
297
|
+
const key = `${materialMergeSignature(material)}|${node.castShadow ? 1 : 0}${node.receiveShadow ? 1 : 0}`;
|
|
298
|
+
plainMeshes += 1;
|
|
299
|
+
members.push({ key, subtree });
|
|
300
|
+
const existing = families.get(key);
|
|
301
|
+
if (existing) existing.meshes += 1;
|
|
302
|
+
else {
|
|
303
|
+
families.set(key, {
|
|
304
|
+
meshes: 1,
|
|
305
|
+
triangles: triangleCount(mesh),
|
|
306
|
+
example: `${node.parent?.name || UNNAMED}/${node.name || UNNAMED}`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
} else if (reason !== 'not-a-mesh') {
|
|
310
|
+
skipped[reason] = (skipped[reason] ?? 0) + 1;
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const scan = (node: THREE.Object3D, subtree: string): void => {
|
|
316
|
+
if (!visit(node, subtree)) return;
|
|
317
|
+
for (const child of node.children) scan(child, subtree);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// `root`'s own mesh, if it is one, is charged to `root` — the same rule
|
|
321
|
+
// `sceneCensus` uses, so the two reports address the same graph.
|
|
322
|
+
if (visit(root, root.name || UNNAMED)) {
|
|
323
|
+
for (const child of root.children) scan(child, child.name || UNNAMED);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const collapsibleKeys = new Set(
|
|
327
|
+
[...families.entries()].filter(([, family]) => family.meshes >= 2).map(([key]) => key),
|
|
328
|
+
);
|
|
329
|
+
const perSubtree = new Map<string, number>();
|
|
330
|
+
let collapsible = 0;
|
|
331
|
+
for (const member of members) {
|
|
332
|
+
if (!collapsibleKeys.has(member.key)) continue;
|
|
333
|
+
collapsible += 1;
|
|
334
|
+
perSubtree.set(member.subtree, (perSubtree.get(member.subtree) ?? 0) + 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const sorted = [...families.values()].sort((a, b) => b.meshes - a.meshes);
|
|
338
|
+
const top = sorted.slice(0, FAMILY_ROW_LIMIT);
|
|
339
|
+
return {
|
|
340
|
+
plainMeshes,
|
|
341
|
+
familyCount: sorted.length,
|
|
342
|
+
top,
|
|
343
|
+
meshesInTop: top.reduce((total, row) => total + row.meshes, 0),
|
|
344
|
+
collapsible,
|
|
345
|
+
skipped,
|
|
346
|
+
bySubtree: [...perSubtree.entries()]
|
|
347
|
+
.map(([name, count]) => ({ name, collapsible: count }))
|
|
348
|
+
.sort((a, b) => b.collapsible - a.collapsible)
|
|
349
|
+
.slice(0, CENSUS_ROW_LIMIT),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE RENDER VITALS — the frame explaining its own cost, derived ENTIRELY
|
|
3
|
+
* from `PerformanceProfiler` frames (`dev/performance-profiler.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Nothing in a viewport tells you why it is slow, and a frame-time number on
|
|
6
|
+
* its own tells you only THAT it is. These readings answer the next question:
|
|
7
|
+
* where did the milliseconds go, and which subtree owns them
|
|
8
|
+
* (`dev/render-census.ts` is the address book half).
|
|
9
|
+
*
|
|
10
|
+
* ── THE MEASUREMENT SOURCE, STATED ONCE ─────────────────────────────────────
|
|
11
|
+
* There is no side ledger here. Every number below is folded out of profiler
|
|
12
|
+
* frames — the timing from phase spans, the counters from `reportRender`. The
|
|
13
|
+
* CPU submission cost is the profiler phase {@link RENDER_SUBMIT_PHASE}, which
|
|
14
|
+
* the three adapter brackets around its actual draw; the profiler's phase
|
|
15
|
+
* bookkeeping is a stack precisely so that bracket can sit INSIDE the frame's
|
|
16
|
+
* enclosing `render` phase without truncating it.
|
|
17
|
+
*
|
|
18
|
+
* ── TWO FRAME FLAVOURS, ONE FOLD ────────────────────────────────────────────
|
|
19
|
+
* The real runtime runs sim and presentation on different callbacks
|
|
20
|
+
* (`create-runtime.ts`: `update` → `runFrame({skipRenderPhases})` per fixed
|
|
21
|
+
* substep, `render` → `runRenderFrame` once per display frame), so a profiler
|
|
22
|
+
* "frame" is EITHER a sim substep OR a presentation pass. A capture/offline
|
|
23
|
+
* host drives one `runFrame` that is both. {@link foldProfilerFrame} handles
|
|
24
|
+
* all three the same way, and never has to know which host it is under:
|
|
25
|
+
*
|
|
26
|
+
* - a frame carrying a {@link RENDER_SUBMIT_PHASE} span IS a presentation —
|
|
27
|
+
* that phase only exists when a draw actually happened;
|
|
28
|
+
* - sim CPU accumulates across every frame since the last presentation, so
|
|
29
|
+
* the substeps a display frame consumed are attributed to it;
|
|
30
|
+
* - renderer COUNTERS ride whichever frame `reportRender` landed on (the
|
|
31
|
+
* adapter reports from its `endFrame` hook, which the substep pass drives),
|
|
32
|
+
* so the latest report wins and the counters describe the last completed
|
|
33
|
+
* draw. That is the same one-frame-warm reading `renderer.info` gives any
|
|
34
|
+
* caller — three resets it when the next draw starts.
|
|
35
|
+
*
|
|
36
|
+
* ── WHY THE WORST FRAME IS NOT AN EMA ───────────────────────────────────────
|
|
37
|
+
* A decaying average keeps a restart spike on the readout for ~30 s, long
|
|
38
|
+
* after it stopped being true. The rolling two-window max
|
|
39
|
+
* ({@link WORST_WINDOW_FRAMES}) holds a hitch for one to two windows and then
|
|
40
|
+
* genuinely forgets it, so the number always describes recent history.
|
|
41
|
+
*
|
|
42
|
+
* ── RESOURCE OWNERSHIP ──────────────────────────────────────────────────────
|
|
43
|
+
* OWNER: the caller of {@link createRenderVitals}, which allocates one state
|
|
44
|
+
* object and one `profiler.subscribe` registration. SHARER: none — the state
|
|
45
|
+
* is private to that call. TEARDOWN: the returned `dispose()`, the ONE path
|
|
46
|
+
* that ends the subscription (`setup-three-root-adapter.ts` calls it from
|
|
47
|
+
* `disposeGame`).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import type { PerformanceFrame, PerformanceProfiler } from './performance-profiler';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The profiler phase name the three adapter brackets its CPU render
|
|
54
|
+
* submission with — spelled HERE and nowhere else, so the producer
|
|
55
|
+
* (`setup-three-root-adapter.ts`) and the consumer ({@link foldProfilerFrame})
|
|
56
|
+
* cannot drift apart. Dotted, so it reads as a decomposition of the enclosing
|
|
57
|
+
* `render` phase rather than a ninth peer of `SystemPhase`.
|
|
58
|
+
*/
|
|
59
|
+
export const RENDER_SUBMIT_PHASE = 'render.submit';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The phases whose cost is SIMULATION. Deliberately not "everything that is
|
|
63
|
+
* not render": `preRender` (LOD selection, particle stepping, culling prep) is
|
|
64
|
+
* neither the simulation nor the submission, so it lands in the hitch's
|
|
65
|
+
* `other` bucket where an investigation can see it. `render` itself is
|
|
66
|
+
* excluded because {@link RENDER_SUBMIT_PHASE} nests inside it — summing both
|
|
67
|
+
* would double-count the draw.
|
|
68
|
+
*/
|
|
69
|
+
export const SIM_PHASES: readonly string[] = [
|
|
70
|
+
'input',
|
|
71
|
+
'prePhysics',
|
|
72
|
+
'physics',
|
|
73
|
+
'postPhysics',
|
|
74
|
+
'gameLogic',
|
|
75
|
+
'animation',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Display frames per worst-frame window — ~5 s at 60 Hz, so the reported
|
|
79
|
+
* worst (the max of the current and previous window) survives 5–10 s. */
|
|
80
|
+
export const WORST_WINDOW_FRAMES = 300;
|
|
81
|
+
|
|
82
|
+
/** A display frame at or above this is worth decomposing. ~3 dropped frames at
|
|
83
|
+
* 60 Hz: below it, ordinary jitter would rewrite the reading constantly. */
|
|
84
|
+
export const HITCH_THRESHOLD_MS = 50;
|
|
85
|
+
|
|
86
|
+
/** Smoothing for the frame-time reading — a readable number, not a blur. */
|
|
87
|
+
export const FRAME_TIME_EMA_ALPHA = 0.05;
|
|
88
|
+
|
|
89
|
+
/** Smoothing for the render-CPU reading. Faster than frame time: submission
|
|
90
|
+
* cost is what a draw-call diet moves, and it should visibly move. */
|
|
91
|
+
export const RENDER_CPU_EMA_ALPHA = 0.1;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A hitch, split into the three buckets that route an investigation. `other`
|
|
95
|
+
* is the diagnostic payload, not a rounding remainder: when it dominates, the
|
|
96
|
+
* thief is neither the renderer nor the simulation — it is GC, asset decode,
|
|
97
|
+
* a layout-thrashing DOM overlay, or the browser itself — and every minute
|
|
98
|
+
* spent on draw calls would have been wasted.
|
|
99
|
+
*/
|
|
100
|
+
export interface HitchDecomposition {
|
|
101
|
+
/** The display frame's wall-clock cost. */
|
|
102
|
+
readonly totalMs: number;
|
|
103
|
+
/** CPU spent submitting draws ({@link RENDER_SUBMIT_PHASE}). */
|
|
104
|
+
readonly renderMs: number;
|
|
105
|
+
/** CPU spent in {@link SIM_PHASES}, summed over the substeps this frame
|
|
106
|
+
* consumed. */
|
|
107
|
+
readonly simMs: number;
|
|
108
|
+
/** Everything else in the wall clock — see this interface's own note. May be
|
|
109
|
+
* negative if a measurement straddles the frame boundary; reported as
|
|
110
|
+
* measured rather than clamped to a tidier lie. */
|
|
111
|
+
readonly otherMs: number;
|
|
112
|
+
/** The one-line reading: `"NNms = render X + sim Y + other Z"`. */
|
|
113
|
+
readonly text: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Split one display frame's wall clock into render / sim / other. Pure. */
|
|
117
|
+
export function decomposeHitch(
|
|
118
|
+
totalMs: number,
|
|
119
|
+
renderMs: number,
|
|
120
|
+
simMs: number,
|
|
121
|
+
): HitchDecomposition {
|
|
122
|
+
const otherMs = totalMs - renderMs - simMs;
|
|
123
|
+
return {
|
|
124
|
+
totalMs,
|
|
125
|
+
renderMs,
|
|
126
|
+
simMs,
|
|
127
|
+
otherMs,
|
|
128
|
+
text:
|
|
129
|
+
`${totalMs.toFixed(0)}ms = render ${renderMs.toFixed(0)} + ` +
|
|
130
|
+
`sim ${simMs.toFixed(0)} + other ${otherMs.toFixed(0)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What `render.vitals` reads. Every field is `null` until the fold has
|
|
135
|
+
* actually measured it — a stopped, headless or never-drawn game reports
|
|
136
|
+
* emptiness rather than a fabricated `0 ms / 0 draws`. */
|
|
137
|
+
export interface RenderVitalsReading {
|
|
138
|
+
/** Smoothed wall-clock ms per display frame. */
|
|
139
|
+
readonly frameTimeMs: number | null;
|
|
140
|
+
/** Frames per second implied by {@link frameTimeMs}. */
|
|
141
|
+
readonly fps: number | null;
|
|
142
|
+
/** Draw calls in the last reported draw. */
|
|
143
|
+
readonly drawCalls: number | null;
|
|
144
|
+
/** Triangles in the last reported draw. */
|
|
145
|
+
readonly triangles: number | null;
|
|
146
|
+
/** Smoothed CPU ms spent submitting draws — invisible in frame time once
|
|
147
|
+
* vsync caps the loop, and exactly what fewer draw calls would improve. */
|
|
148
|
+
readonly renderCpuMs: number | null;
|
|
149
|
+
/** Top-level `renderer.render()` submissions the last presentation took. */
|
|
150
|
+
readonly renderPasses: number | null;
|
|
151
|
+
/** Worst display frame in the last one-to-two windows. */
|
|
152
|
+
readonly worstFrameMs: number | null;
|
|
153
|
+
/** The most recent frame over {@link HITCH_THRESHOLD_MS}, decomposed. */
|
|
154
|
+
readonly lastHitch: HitchDecomposition | null;
|
|
155
|
+
/** How many display frames the fold has seen. `0` reads as "nothing has
|
|
156
|
+
* presented yet", which is why every reading above is `null`. */
|
|
157
|
+
readonly presentedFrames: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The fold's accumulator. Mutable by design (one allocation for the life of a
|
|
161
|
+
* mount, written once per profiler frame); read through
|
|
162
|
+
* {@link readRenderVitals}. */
|
|
163
|
+
export interface RenderVitalsState {
|
|
164
|
+
/** Highest profiler frame id already folded — the dedupe that makes the fold
|
|
165
|
+
* safe to drive from `profiler.subscribe`, which also fires for
|
|
166
|
+
* enabled/recording changes that publish no new frame. */
|
|
167
|
+
lastFrameId: number;
|
|
168
|
+
/** `timestamp` of the last presentation, or `null` before the first (there
|
|
169
|
+
* is no interval to measure from one sample). */
|
|
170
|
+
lastPresentAt: number | null;
|
|
171
|
+
/** Sim CPU accumulated since the last presentation. */
|
|
172
|
+
pendingSimMs: number;
|
|
173
|
+
frameTimeMs: number | null;
|
|
174
|
+
renderCpuMs: number | null;
|
|
175
|
+
drawCalls: number | null;
|
|
176
|
+
triangles: number | null;
|
|
177
|
+
renderPasses: number | null;
|
|
178
|
+
/** Running max of the CURRENT worst-frame window. */
|
|
179
|
+
worstCurrentMs: number;
|
|
180
|
+
/** The PREVIOUS window's max, kept so the reading does not drop to zero the
|
|
181
|
+
* instant a window rolls over. */
|
|
182
|
+
worstPreviousMs: number;
|
|
183
|
+
/** Display frames counted into the current window. */
|
|
184
|
+
worstWindowFrames: number;
|
|
185
|
+
lastHitch: HitchDecomposition | null;
|
|
186
|
+
presentedFrames: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createRenderVitalsState(): RenderVitalsState {
|
|
190
|
+
return {
|
|
191
|
+
lastFrameId: 0,
|
|
192
|
+
lastPresentAt: null,
|
|
193
|
+
pendingSimMs: 0,
|
|
194
|
+
frameTimeMs: null,
|
|
195
|
+
renderCpuMs: null,
|
|
196
|
+
drawCalls: null,
|
|
197
|
+
triangles: null,
|
|
198
|
+
renderPasses: null,
|
|
199
|
+
worstCurrentMs: 0,
|
|
200
|
+
worstPreviousMs: 0,
|
|
201
|
+
worstWindowFrames: 0,
|
|
202
|
+
lastHitch: null,
|
|
203
|
+
presentedFrames: 0,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Sum of one frame's phase timings whose names are in `names`. */
|
|
208
|
+
function sumPhases(frame: PerformanceFrame, names: readonly string[]): number {
|
|
209
|
+
let total = 0;
|
|
210
|
+
for (const timing of frame.phases) if (names.includes(timing.name)) total += timing.ms;
|
|
211
|
+
return total;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** This frame's CPU render submission, or `null` when it was not a
|
|
215
|
+
* presentation (no draw happened, so no bracket was recorded). */
|
|
216
|
+
function submissionMs(frame: PerformanceFrame): number | null {
|
|
217
|
+
for (const timing of frame.phases) if (timing.name === RENDER_SUBMIT_PHASE) return timing.ms;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ema(previous: number | null, sample: number, alpha: number): number {
|
|
222
|
+
return previous === null ? sample : previous * (1 - alpha) + sample * alpha;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Fold ONE profiler frame into the accumulator. Pure with respect to time and
|
|
227
|
+
* randomness — every input is on the frame — which is what makes the whole
|
|
228
|
+
* derivation testable from synthetic frames with no renderer, no clock and no
|
|
229
|
+
* game.
|
|
230
|
+
*
|
|
231
|
+
* Returns `true` when the frame was a presentation (the readings moved).
|
|
232
|
+
*/
|
|
233
|
+
export function foldProfilerFrame(state: RenderVitalsState, frame: PerformanceFrame): boolean {
|
|
234
|
+
// Re-publishes and the ring's own re-reads must not double-count. Frame ids
|
|
235
|
+
// are monotonic for the profiler's whole life, including across `clear()`.
|
|
236
|
+
if (frame.id <= state.lastFrameId) return false;
|
|
237
|
+
state.lastFrameId = frame.id;
|
|
238
|
+
|
|
239
|
+
// Counters ride whichever frame `reportRender` landed on — see the module
|
|
240
|
+
// note. `renderPasses !== null` is the marker that a reporter spoke at all;
|
|
241
|
+
// a frame nobody reported on leaves the previous reading standing rather
|
|
242
|
+
// than blanking it to a zero that would read as "nothing is drawn".
|
|
243
|
+
if (frame.render.renderPasses !== null) {
|
|
244
|
+
state.drawCalls = frame.render.drawCalls;
|
|
245
|
+
state.triangles = frame.render.triangles;
|
|
246
|
+
state.renderPasses = frame.render.renderPasses;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
state.pendingSimMs += sumPhases(frame, SIM_PHASES);
|
|
250
|
+
|
|
251
|
+
const renderMs = submissionMs(frame);
|
|
252
|
+
if (renderMs === null) return false; // a sim substep: its cost is now pending
|
|
253
|
+
|
|
254
|
+
const simMs = state.pendingSimMs;
|
|
255
|
+
state.pendingSimMs = 0;
|
|
256
|
+
state.presentedFrames += 1;
|
|
257
|
+
state.renderCpuMs = ema(state.renderCpuMs, renderMs, RENDER_CPU_EMA_ALPHA);
|
|
258
|
+
|
|
259
|
+
const previousAt = state.lastPresentAt;
|
|
260
|
+
state.lastPresentAt = frame.timestamp;
|
|
261
|
+
// The FIRST presentation has no interval behind it. Everything that needs a
|
|
262
|
+
// dt (frame time, worst frame, the hitch) waits for the second one; the
|
|
263
|
+
// alternative is to invent an interval from the profiler's own start, which
|
|
264
|
+
// would report the mount cost as a frame time forever.
|
|
265
|
+
if (previousAt === null) return true;
|
|
266
|
+
|
|
267
|
+
const dt = frame.timestamp - previousAt;
|
|
268
|
+
state.frameTimeMs = ema(state.frameTimeMs, dt, FRAME_TIME_EMA_ALPHA);
|
|
269
|
+
|
|
270
|
+
if (dt > state.worstCurrentMs) state.worstCurrentMs = dt;
|
|
271
|
+
state.worstWindowFrames += 1;
|
|
272
|
+
if (state.worstWindowFrames >= WORST_WINDOW_FRAMES) {
|
|
273
|
+
state.worstPreviousMs = state.worstCurrentMs;
|
|
274
|
+
state.worstCurrentMs = 0;
|
|
275
|
+
state.worstWindowFrames = 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Blame the hitch with THIS frame's own measurements: `dt` spans the work
|
|
279
|
+
// between the two presentations, which is exactly the submission just
|
|
280
|
+
// measured plus the substeps that ran in between.
|
|
281
|
+
if (dt >= HITCH_THRESHOLD_MS) state.lastHitch = decomposeHitch(dt, renderMs, simMs);
|
|
282
|
+
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function round(value: number | null, places: number): number | null {
|
|
287
|
+
if (value === null) return null;
|
|
288
|
+
const factor = 10 ** places;
|
|
289
|
+
return Math.round(value * factor) / factor;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The provider body — pure over the accumulator. */
|
|
293
|
+
export function readRenderVitals(state: RenderVitalsState): RenderVitalsReading {
|
|
294
|
+
const frameTimeMs = round(state.frameTimeMs, 2);
|
|
295
|
+
const worst = Math.max(state.worstCurrentMs, state.worstPreviousMs);
|
|
296
|
+
return {
|
|
297
|
+
frameTimeMs,
|
|
298
|
+
fps: frameTimeMs !== null && frameTimeMs > 0 ? round(1000 / frameTimeMs, 1) : null,
|
|
299
|
+
drawCalls: state.drawCalls,
|
|
300
|
+
triangles: state.triangles,
|
|
301
|
+
renderCpuMs: round(state.renderCpuMs, 2),
|
|
302
|
+
renderPasses: state.renderPasses,
|
|
303
|
+
worstFrameMs: state.frameTimeMs === null ? null : round(worst, 2),
|
|
304
|
+
lastHitch: state.lastHitch,
|
|
305
|
+
presentedFrames: state.presentedFrames,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** What {@link createRenderVitals} hands back. */
|
|
310
|
+
export interface RenderVitals {
|
|
311
|
+
/** The `render.vitals` provider body. */
|
|
312
|
+
read(): RenderVitalsReading;
|
|
313
|
+
/** See the module's ownership note — the ONE path that ends the
|
|
314
|
+
* subscription. Idempotent. */
|
|
315
|
+
dispose(): void;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Attach a fold to a live profiler. `subscribe` fires once per published
|
|
320
|
+
* frame (and on enabled/recording changes, which the id dedupe absorbs), so
|
|
321
|
+
* nothing here polls and nothing here owns a timer.
|
|
322
|
+
*/
|
|
323
|
+
export function createRenderVitals(profiler: PerformanceProfiler): RenderVitals {
|
|
324
|
+
const state = createRenderVitalsState();
|
|
325
|
+
const unsubscribe = profiler.subscribe(() => {
|
|
326
|
+
const frame = profiler.getSnapshot().frames.at(-1);
|
|
327
|
+
if (frame) foldProfilerFrame(state, frame);
|
|
328
|
+
});
|
|
329
|
+
let disposed = false;
|
|
330
|
+
return {
|
|
331
|
+
read: () => readRenderVitals(state),
|
|
332
|
+
dispose() {
|
|
333
|
+
if (disposed) return;
|
|
334
|
+
disposed = true;
|
|
335
|
+
unsubscribe();
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|