@vgai/engine 0.5.8 → 0.5.10
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 +105 -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/dev-layers.d.ts.map +1 -1
- package/dist/runtime/dev-layers.js +6 -0
- 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 +105 -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/dev-layers.ts +7 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
-
* -
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
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 {
|