@threenative/core 0.3.1 → 0.3.3

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.
@@ -1,10 +1,10 @@
1
1
  import { JsonValue, JsonPrimitive } from '@threenative/playtest/protocol';
2
- import { b as IGamePluginHooks } from './game-XGrTzapq.js';
3
- import './assets-kyoF7JlJ.js';
2
+ import { b as IGamePluginHooks } from './game-D_6r-k4Y.js';
3
+ import './assets-CYKk2WTu.js';
4
4
  import 'three';
5
- import './renderer-C6hqZpoG.js';
5
+ import './renderer-Cy4qeBOA.js';
6
6
  import 'three/webgpu';
7
- import './canvas-layer-BLVijiUJ.js';
7
+ import './canvas-layer-C1SnMoJ-.js';
8
8
  import 'zustand/vanilla';
9
9
 
10
10
  /**
package/dist/playtest.js CHANGED
@@ -1,27 +1,48 @@
1
1
  import { assertJsonSafe, PLAYTEST_PROTOCOL_LIMITS } from '@threenative/playtest/protocol';
2
2
  import { installThreePlaytestBridge } from '@threenative/playtest/three';
3
- import { Object3D } from 'three';
3
+ import { Sphere, Vector3, Object3D } from 'three';
4
4
 
5
5
  // src/playtest.ts
6
- var buses = /* @__PURE__ */ new Set();
6
+ var AUDIO_STATE = /* @__PURE__ */ Symbol.for("threenative.audio.runtime");
7
+ function audioState() {
8
+ const host = globalThis;
9
+ const existing = host[AUDIO_STATE];
10
+ if (existing !== void 0) return existing;
11
+ const created = { buses: /* @__PURE__ */ new Set(), cueCounts: /* @__PURE__ */ new Map(), cueLog: [] };
12
+ host[AUDIO_STATE] = created;
13
+ return created;
14
+ }
7
15
  function audioRuntimeSnapshot() {
8
16
  let queued = 0;
9
17
  let voices = 0;
10
18
  let pooled = 0;
11
19
  let paused = 0;
12
20
  const unsupported = /* @__PURE__ */ new Set();
13
- for (const bus of buses) {
21
+ const state = audioState();
22
+ for (const bus of state.buses) {
14
23
  queued += bus.queued;
15
24
  voices += bus.voices;
16
25
  pooled += bus.pooled;
17
26
  paused += bus.pausedVoices;
18
27
  for (const option of bus.unsupported) unsupported.add(option);
19
28
  }
20
- return { paused, pooled, queued, unsupported: [...unsupported].sort(), voices };
29
+ return {
30
+ cues: Object.fromEntries(state.cueCounts),
31
+ paused,
32
+ pooled,
33
+ queued,
34
+ recentCues: state.cueLog.map((entry) => ({ ...entry })),
35
+ unsupported: [...unsupported].sort(),
36
+ voices
37
+ };
21
38
  }
39
+ var GEOMETRY_CAPTURE_CAPABILITY = "runtime.geometry";
40
+ new Sphere();
41
+ new Vector3();
42
+ new Vector3();
22
43
 
23
44
  // src/version.ts
24
- var CORE_VERSION = "0.3.1";
45
+ var CORE_VERSION = "0.3.3";
25
46
 
26
47
  // src/pipeline-census.ts
27
48
  var PIPELINE_CENSUS_CAPABILITY = "runtime.pipelineCensus";
@@ -53,6 +74,7 @@ function playtest(options = {}) {
53
74
  let attached;
54
75
  let startSceneEntered;
55
76
  let disposePipelineCensus;
77
+ let disposeGeometryCapture;
56
78
  let contactHistory = [];
57
79
  const watcher = createTransitionWatcher();
58
80
  let readTick;
@@ -122,14 +144,24 @@ function playtest(options = {}) {
122
144
  sample: () => ({ pipelineCensus: pipelineCensus() })
123
145
  });
124
146
  }
147
+ const geometryCapture = runtime?.geometryCapture;
148
+ if (runtime !== void 0 && geometryCapture !== void 0) {
149
+ disposeGeometryCapture = runtime.observations.contribute({
150
+ capabilities: [GEOMETRY_CAPTURE_CAPABILITY],
151
+ // Nothing is armed, walked or hooked unless this request asked for a capture: the
152
+ // capability says the runtime can answer, not that every sample pays for one.
153
+ sample: async (request) => request.geometry === void 0 ? {} : { geometry: await geometryCapture(request.geometry) }
154
+ });
155
+ }
125
156
  installRuntimeChannels(installation.bridge, runtime);
126
- if (globalThis[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true)
127
- runtime?.enableRuntimeDiagnostics?.();
157
+ if (runnerAnnounced()) runtime?.enableRuntimeDiagnostics?.();
128
158
  dispose = installation.dispose;
129
159
  attached = holdUntilAttached(installation.bridge, options, () => startSceneEntered);
130
160
  const cleanup = () => {
131
161
  disposePipelineCensus?.();
132
162
  disposePipelineCensus = void 0;
163
+ disposeGeometryCapture?.();
164
+ disposeGeometryCapture = void 0;
133
165
  dispose?.();
134
166
  dispose = void 0;
135
167
  attached = void 0;
@@ -157,6 +189,9 @@ var PLAYTEST_ATTACH_TIMEOUT_MS = 3e4;
157
189
  var PLAYTEST_RUNNER_EXPECTED_GLOBAL = "__THREENATIVE_PLAYTEST_RUNNER_EXPECTED__";
158
190
  function shouldHoldUntilAttached(options) {
159
191
  if (options.holdUntilAttached !== void 0) return options.holdUntilAttached;
192
+ return runnerAnnounced();
193
+ }
194
+ function runnerAnnounced() {
160
195
  const host = globalThis;
161
196
  return host[PLAYTEST_RUNNER_EXPECTED_GLOBAL] === true || host.TN_PLAYTEST_ENDPOINT !== void 0;
162
197
  }
@@ -227,10 +262,10 @@ function addRuntimeCapabilities(description, contributions) {
227
262
  }
228
263
  return { ...description, capabilities };
229
264
  }
230
- function addRuntimeObservations(snapshot, request, contributions) {
265
+ async function addRuntimeObservations(snapshot, request, contributions) {
231
266
  const result = { ...snapshot };
232
267
  for (const contribution of contributions) {
233
- const slice = contribution.sample(request);
268
+ const slice = await contribution.sample(request);
234
269
  if (typeof slice !== "object" || slice === null || Array.isArray(slice)) {
235
270
  throw new TypeError("A runtime observation contribution must return a top-level object.");
236
271
  }
package/dist/react.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ReactNode } from 'react';
2
- import { C as CanvasLayer } from './canvas-layer-BLVijiUJ.js';
2
+ import { C as CanvasLayer } from './canvas-layer-C1SnMoJ-.js';
3
3
  import 'three';
4
- import './renderer-C6hqZpoG.js';
4
+ import './renderer-Cy4qeBOA.js';
5
5
  import 'three/webgpu';
6
6
 
7
7
  /**
@@ -1,6 +1,90 @@
1
1
  import { Object3D, Matrix4, Camera } from 'three';
2
2
  import { MRTNode, Node } from 'three/webgpu';
3
3
 
4
+ /**
5
+ * Host-boundary counters, default-off, on the frame budget's own record.
6
+ *
7
+ * Two of the three hypotheses about the render phase's unattributed time are about crossings and
8
+ * bytes — "the V8↔host boundary dominates" and "a buffer write per object per pass dominates" —
9
+ * and neither can be answered by a JS CPU profile, because the time is spent on the other side of a
10
+ * call the profiler attributes to a single frame. This counts the calls instead.
11
+ *
12
+ * What it counts, precisely, because a counter that overstates itself is worse than none:
13
+ *
14
+ * - `hostCalls` — every WebGPU method invoked on the device's command encoders and on its queue.
15
+ * On a native host each of those is one crossing; in a browser it is one API call. It is not
16
+ * every crossing a frame makes: `mapAsync`, `getCurrentTexture` and the presentation path are
17
+ * issued by three and by the host outside these objects, and they are named here rather than
18
+ * folded in so the number can be checked against a host that counts its own.
19
+ * - `gpuBytes` — the byte count handed to `queue.writeBuffer`, exactly. Texture uploads are not
20
+ * included: a `writeTexture` size is a function of the destination's format, and estimating it
21
+ * from `data.byteLength` would be a number nobody could reconcile.
22
+ * - `jsAllocBytes` — the change in `performance.memory.usedJSHeapSize`, where the platform has it.
23
+ * Absent on a host without `performance.memory`, never zero: an unmeasurable allocation rate and
24
+ * a frame that allocated nothing are different facts.
25
+ *
26
+ * Wrapping every method of a command encoder is not free — it is one extra JS call per command, and
27
+ * this is why the counters ride the same opt-in flag as the spans rather than being always on.
28
+ */
29
+ /** One frame's boundary counts. Every field is absent when the platform cannot report it. */
30
+ interface IFrameCounters {
31
+ readonly gpuBytes?: number;
32
+ readonly hostCalls?: number;
33
+ readonly jsAllocBytes?: number;
34
+ }
35
+ /** The slice of a WebGPU device this reads. Structural, so a test can stand in a fake. */
36
+ interface ICounterDevice {
37
+ createCommandEncoder?(): unknown;
38
+ createRenderBundleEncoder?(): unknown;
39
+ queue?: unknown;
40
+ }
41
+ /** The WebGPU device behind a raw three renderer, when the backend exposes one. */
42
+ declare function counterDeviceOf(raw: unknown): unknown;
43
+ /**
44
+ * Counts the frame's boundary crossings and bytes, and reads what the platform will say about
45
+ * allocation. Install once per renderer; `read` returns the counts since the previous `read`.
46
+ */
47
+ declare class FrameCounters {
48
+ #private;
49
+ private constructor();
50
+ /** Installs the counters on a device, or answers `undefined` when it has nothing to count. */
51
+ static install(device: unknown): FrameCounters | undefined;
52
+ /** The counts since the previous read. */
53
+ read(): IFrameCounters;
54
+ /** Removes every wrapper. A game that disposes its renderer must not leave the counters behind. */
55
+ uninstall(): void;
56
+ }
57
+
58
+ /**
59
+ * Per-pass draw and triangle attribution, on by default, for every platform.
60
+ *
61
+ * The frame budget already owned the milliseconds; this owns the submissions. Three's
62
+ * `renderer.info.reset()` runs once per frame before the world render, and a nested shadow or
63
+ * reflection `renderer.render(...)` shares that same `info`, so `info.render` reports main plus
64
+ * every nested pass combined. The aggregate cannot tell a 316-draw shadow lane from a 1,418-draw
65
+ * colour pass, and every optimisation attempt had to hand-roll the split. This is the split: each
66
+ * `render()` is one entry on a stack, and its own submissions are the counter delta across its
67
+ * call minus the deltas of the nested calls it made, so the innermost active render call owns
68
+ * exactly what it submitted.
69
+ *
70
+ * Three names its own shadow pass by temporarily renaming the scene to `Shadow Map [ ... ]`
71
+ * (`renderShadow`), and a reflection pass names its scene with `Reflector` in it. Those are
72
+ * borrowed, not invented: the classifier reads three's vocabulary and falls back to `nested`.
73
+ *
74
+ * Measurement, not policy: installing this alters no draw. `install` answers `undefined` on a
75
+ * renderer whose `info.render` cannot be read, and a consumer must then report the pass split as
76
+ * absent rather than zero.
77
+ */
78
+ /** The named kinds of a render pass. Closed on purpose: a consumer bounds a known kind. */
79
+ declare const FRAME_PASS_KINDS: readonly ["main", "shadow", "reflection", "nested"];
80
+ type FramePassKind = (typeof FRAME_PASS_KINDS)[number];
81
+ /** One render call's own submissions, attributed to its innermost active render call. */
82
+ interface IRenderPassSample {
83
+ readonly draws: number;
84
+ readonly kind: FramePassKind;
85
+ readonly triangles: number;
86
+ }
87
+
4
88
  /**
5
89
  * Per-presented-frame cost attribution, on by default, for every platform.
6
90
  *
@@ -23,6 +107,7 @@ import { MRTNode, Node } from 'three/webgpu';
23
107
  * and a phase that was never measured reports zero samples so a consumer asserting on it fails
24
108
  * instead of skipping.
25
109
  */
110
+
26
111
  /** Marker printed once per report window. */
27
112
  declare const FRAME_BUDGET_MARKER = "TN_FRAME_BUDGET";
28
113
  /** Marker printed the moment a gap between presented frames exceeds `hitchMs`. */
@@ -30,9 +115,14 @@ declare const FRAME_HITCH_MARKER = "TN_FRAME_HITCH";
30
115
  /**
31
116
  * The named parts of one presented frame. They partition the frame: `hostGap` is the time before
32
117
  * the callback (present wait plus whatever the host did between callbacks), and `update`,
33
- * `render`, `overlay` and `residual` sum to the callback's own duration.
118
+ * `render`, `overlay`, `ui` and `residual` sum to the callback's own duration.
119
+ *
120
+ * `overlay` and `ui` are two different draws that happen to sit next to each other. `overlay` is
121
+ * the three.js HUD pass; `ui` is the native UI layer's composite of the page's pixels into the
122
+ * game's own frame — one upload and one quad — which is why it is a phase of its own and not part
123
+ * of `overlay`.
34
124
  */
35
- declare const FRAME_BUDGET_PHASES: readonly ["hostGap", "update", "render", "overlay", "residual"];
125
+ declare const FRAME_BUDGET_PHASES: readonly ["hostGap", "update", "render", "overlay", "ui", "residual"];
36
126
  type FrameBudgetPhase = (typeof FRAME_BUDGET_PHASES)[number];
37
127
  /** One frame's cost, split by phase. Every field is milliseconds. */
38
128
  interface IFramePhaseSample {
@@ -40,6 +130,7 @@ interface IFramePhaseSample {
40
130
  readonly update: number;
41
131
  readonly render: number;
42
132
  readonly overlay: number;
133
+ readonly ui: number;
43
134
  readonly residual: number;
44
135
  }
45
136
  /**
@@ -82,16 +173,58 @@ interface IFrameBudgetSummary {
82
173
  readonly p99: number;
83
174
  readonly max: number;
84
175
  }
176
+ /**
177
+ * One render-pass kind's submissions across a window, so a change that trades triangles for CPU is
178
+ * visible in the same report as the milliseconds it traded for.
179
+ */
180
+ interface IFrameBudgetPassSummary {
181
+ readonly draws: IFrameBudgetSummary;
182
+ /** Frames in the window that submitted a pass of this kind. */
183
+ readonly frames: number;
184
+ readonly triangles: IFrameBudgetSummary;
185
+ }
186
+ /**
187
+ * One reported window of the frame meter.
188
+ *
189
+ * **The cadence here is the loop's, not necessarily the display's.** `endFrame` is called once per
190
+ * frame the game's loop runs, and on the web that is one `requestAnimationFrame` per vblank, so the
191
+ * interval is a presented frame's. On a native host the loop's dispatch is what drives it — the host
192
+ * says so itself: "the JavaScript budget reads the same interval as presentedDelta"
193
+ * (`runtime-native/src/runtime.cpp`, `executeAnimationFrameCallbacks`). Its presentation cap paces
194
+ * the **present**, never the loop, so a loop that outruns 60 Hz iterates many times per present and
195
+ * this window's `fps` is inflated by exactly that ratio. Measured on midway's native launch:
196
+ * `fps 2631.58` beside the host's own `TN_PRESENTS_TICK:{"frames":1740,"presents":133,"capHz":60}`.
197
+ * The display's rate is the host's series; a reader that has both must not print this one as though
198
+ * it were a player's — `threenative-playtest perf` refuses to (`TN_PERF_VIRTUAL_DISPLAY`).
199
+ */
85
200
  interface IFrameBudgetWindow {
86
201
  /** 1 for the first reported window, incrementing thereafter. */
87
202
  readonly window: number;
88
- /** Presented frames counted in this window, hitches excluded. */
203
+ /** Loop frames counted in this window, hitches excluded — one per present on the web. */
89
204
  readonly frames: number;
90
205
  /** Frames excluded from the window because their present gap exceeded `hitchMs`. */
91
206
  readonly hitches: number;
92
- /** Derived from the mean presented interval: the number a player would read off a counter. */
207
+ /**
208
+ * Derived from the mean interval between loop frames. The number a player would read off a
209
+ * counter only where the loop's cadence is the display's, as it is on the web via rAF.
210
+ */
93
211
  readonly fps: number;
94
- /** Interval between presented frames — the honest frame period. */
212
+ /**
213
+ * Frames that reached the display in this window, when the host reports them.
214
+ *
215
+ * Absent on the web, where rAF *is* the display's cadence and a second number would be the same
216
+ * one. On a native host the presentation cap lets the loop dispatch many times per present, so
217
+ * this is the count a player saw and `fps` is not. **Zero is a reading**: a window of loop frames
218
+ * can be shorter than one present period, and the display genuinely showed nothing in it.
219
+ */
220
+ readonly presents?: number;
221
+ /**
222
+ * Presents per second over this window's own duration. Absent when the window counted none —
223
+ * a rate needs at least one present, and a window too short to contain one carries no rate to
224
+ * assess rather than a zero.
225
+ */
226
+ readonly presentedFps?: number;
227
+ /** Interval between loop frames — the honest frame period where the loop presents every frame. */
95
228
  readonly presented: IFrameBudgetSummary;
96
229
  /** Duration of the frame callback itself, entry to exit. */
97
230
  readonly frame: IFrameBudgetSummary;
@@ -100,6 +233,14 @@ interface IFrameBudgetWindow {
100
233
  readonly phases: Readonly<Record<FrameBudgetPhase, IFrameBudgetSummary>>;
101
234
  /** Each phase's mean as a fraction of the mean presented interval. */
102
235
  readonly shares: Readonly<Record<FrameBudgetPhase, number>>;
236
+ /**
237
+ * Draw calls and triangles submitted per render pass, when a pass recorder was installed.
238
+ *
239
+ * Absent rather than defaulted: a renderer whose submissions nothing measured and a frame that
240
+ * submitted nothing are different facts, and a zero would merge them. A kind no frame submitted
241
+ * is absent; `frames` says how many frames did.
242
+ */
243
+ readonly passes?: Readonly<Partial<Record<FramePassKind, IFrameBudgetPassSummary>>>;
103
244
  /**
104
245
  * The resolution and sampling this window's frames were drawn at, when the loop reported one.
105
246
  * Absent rather than defaulted: a consumer asserting on it must fail loudly instead of reading
@@ -107,14 +248,44 @@ interface IFrameBudgetWindow {
107
248
  */
108
249
  readonly surface?: IFrameSurfaceState;
109
250
  /**
110
- * GPU milliseconds for a frame in this window, from `timestamp-query`, when the adapter has it.
251
+ * GPU milliseconds per resolved frame in this window, from `timestamp-query`, summarised like a
252
+ * phase — mean/p50/p95/p99/max over the frames the device actually reported.
253
+ *
254
+ * A single instantaneous `info.render.timestamp` read is lagged by up to `gpuAgeFrames` and
255
+ * spread 3.5x between consecutive reads of one steady frame, so it is not the frame's GPU cost
256
+ * and is not what this reports. Absent rather than zero when no frame resolved a reading: an
257
+ * adapter without timestamps and a frame that genuinely cost no GPU time are different facts,
258
+ * and a zero would merge them.
259
+ */
260
+ readonly gpu?: IFrameBudgetSummary;
261
+ /**
262
+ * Frames in the window whose GPU reading had not advanced since the previous frame, or was
263
+ * absent. `gpu.samples + gpuStale` is the frames the device was asked about; a window where
264
+ * every frame is stale reports `gpu` absent rather than the last reading looking current.
265
+ */
266
+ readonly gpuStale: number;
267
+ /**
268
+ * The window mean of `gpu`, when present. The scalar the resolution scaler reads.
111
269
  *
112
- * Absent rather than zero when there is nothing to report: an adapter without timestamps and a
113
- * frame that genuinely cost no GPU time are different facts, and a zero would merge them.
270
+ * It is the same number as `gpu.mean`; a single field keeps the scaler and the perf report on
271
+ * one series rather than a second instantaneous read.
114
272
  */
115
273
  readonly gpuMs?: number;
116
- /** Age of the resolved GPU timestamp in Three.js frame IDs; absent means unobservable. */
274
+ /** Age of the most recent resolved GPU timestamp in Three.js frame IDs; absent means unobservable. */
117
275
  readonly gpuAgeFrames?: number;
276
+ /**
277
+ * The frame's boundary counts, when something counted them.
278
+ *
279
+ * Each series is absent when nothing measured it, and a series present with zero samples is not
280
+ * possible: an uncounted frame and a frame that crossed the boundary zero times are different
281
+ * facts, and a fabricated zero merges them. `hostCalls` and `gpuBytes` come from
282
+ * `FrameCounters`; `jsAllocBytes` needs a platform that publishes `performance.memory`.
283
+ */
284
+ readonly counters?: {
285
+ readonly hostCalls?: IFrameBudgetSummary;
286
+ readonly gpuBytes?: IFrameBudgetSummary;
287
+ readonly jsAllocBytes?: IFrameBudgetSummary;
288
+ };
118
289
  }
119
290
  interface IFrameBudgetOptions {
120
291
  /** Presented frames per report window. Default 300. */
@@ -138,8 +309,14 @@ interface IFrameBudgetOptions {
138
309
  * loop, which is the only place that knows both the renderer and the window boundary.
139
310
  */
140
311
  readonly readSurface?: () => IFrameSurfaceState;
141
- /** Reads the last resolved GPU frame time, called once per reported window. */
142
- readonly readGpuMs?: () => number | undefined;
312
+ /**
313
+ * Frames the display has presented so far, when the platform can say.
314
+ *
315
+ * Defaults to the native host's `__tnPresentedCount`, whose presence is the whole signal: the
316
+ * web has no such seam and needs none. A counter that goes backwards or non-finite is ignored
317
+ * for the window rather than reported as a negative rate.
318
+ */
319
+ readonly readPresentCount?: () => number | undefined;
143
320
  /** Reads the successful GPU query frame age, not the age of the last resolve attempt. */
144
321
  readonly readGpuAgeFrames?: () => number | undefined;
145
322
  }
@@ -147,7 +324,7 @@ interface IFrameBudgetOptions {
147
324
  * Accumulates one frame at a time and reports windowed attribution.
148
325
  *
149
326
  * The caller is the frame loop; the sequence per frame is
150
- * `beginFrame` → `markSimulationEnd` → (`addRender` / `addOverlay`) → `endFrame`.
327
+ * `beginFrame` → `markSimulationEnd` → (`addRender` / `addOverlay` / `addUi`) → `endFrame`.
151
328
  * Calling them out of order throws rather than producing a plausible-looking split.
152
329
  */
153
330
  declare class FrameBudget {
@@ -165,6 +342,34 @@ declare class FrameBudget {
165
342
  markSimulationEnd(nowMs: number, substeps: number): void;
166
343
  addRender(ms: number): void;
167
344
  addOverlay(ms: number): void;
345
+ addUi(ms: number): void;
346
+ /**
347
+ * Records one presented frame's GPU duration, from a resolved `timestamp-query`.
348
+ *
349
+ * `ms` is `undefined` when the device reported no reading for the frame. `frame` is the
350
+ * Three.js frame id the duration belongs to — `gpuFrameSample`/`gpuFrameAge` on the renderer.
351
+ * A reading whose `frame` has not advanced since the previous frame is the previous frame's
352
+ * resolve still in flight, so it is counted as stale and not pushed again; that repetition was
353
+ * what made one lagged sample read as the current frame's cost. Without a `frame` a reading is
354
+ * always taken as fresh, since there is nothing to tell repeats from a genuine re-measurement.
355
+ *
356
+ * Once per frame. A window with no reading at all reports `gpu` absent, never a zero.
357
+ */
358
+ addGpuMs(ms: number | undefined, frame?: number): void;
359
+ /**
360
+ * Records the frame's host-boundary counts, from `FrameCounters` or any other source.
361
+ *
362
+ * At most one call per frame; a second replaces the first rather than summing, because the
363
+ * counter's own reader already returns the frame's totals and summing two reads of one frame
364
+ * would double it. A field the platform cannot report is left out of the series entirely.
365
+ */
366
+ addCounters(counters: IFrameCounters): void;
367
+ /**
368
+ * Records the frame's per-pass submissions, from `RenderPassBudget` or any other source. At most
369
+ * one entry per kind per frame is meaningful; a second of the same kind is summed by the caller.
370
+ * An unknown kind throws rather than being dropped, the same fail-closed rule as a phase.
371
+ */
372
+ addRenderPasses(passes: readonly IRenderPassSample[]): void;
168
373
  /**
169
374
  * Closes the frame and returns its phase split, or `undefined` when the frame was a hitch and
170
375
  * therefore excluded — a 27-second startup stall is not a frame time and must not enter a
@@ -179,6 +384,16 @@ declare class FrameBudget {
179
384
  endFrame(nowMs: number, wantSample?: boolean): IFramePhaseSample | undefined;
180
385
  /** Reads the window in progress without disturbing it. */
181
386
  window(): IFrameBudgetWindow;
387
+ /**
388
+ * The render phase of the frame that just closed, or `undefined` when that frame was a hitch and
389
+ * therefore not counted.
390
+ *
391
+ * It exists because the phase split object is optional — `endFrame` builds one only when a
392
+ * consumer asked for per-frame samples, which shipping games do not — and a reader that needs
393
+ * the number must not be forced to turn that allocation on to get it. Reading a measurement and
394
+ * collecting a sample are different requests.
395
+ */
396
+ get lastRenderMs(): number | undefined;
182
397
  }
183
398
 
184
399
  /**
@@ -704,6 +919,19 @@ interface IRendererLike {
704
919
  gpuFrameMs(): number | undefined;
705
920
  /** Age in Three.js frame IDs of the resolved render timestamp; absent when unobservable. */
706
921
  gpuFrameAge?(): number | undefined;
922
+ /**
923
+ * The last resolved GPU frame's duration and the Three.js frame id it belongs to, or
924
+ * `undefined` when no resolved reading is available.
925
+ *
926
+ * `gpuFrameMs` is that duration alone; the frame id is what tells a reading still in flight
927
+ * from the current frame's cost, so a caller building a per-frame series never measures one
928
+ * resolve twice. Optional like `gpuFrameAge`, for stubs that implement only the drawing
929
+ * contract. `createRenderer` always provides it.
930
+ */
931
+ gpuFrameSample?(): {
932
+ readonly frame: number;
933
+ readonly ms: number;
934
+ } | undefined;
707
935
  /** Starts a resolve of the GPU timestamps for the frames drawn since the last call. */
708
936
  resolveGpuFrame(): void;
709
937
  /**
@@ -717,6 +945,11 @@ interface IRendererLike {
717
945
  * session because nothing in the measurement could say which one produced it.
718
946
  */
719
947
  surface(): IFrameSurfaceState;
948
+ /**
949
+ * The drawing buffer height on its own, for a caller that wants one number every frame and no
950
+ * record. Optional so a platform or test double can keep exposing only `surface()`.
951
+ */
952
+ surfaceDrawingBufferHeight?(): number;
720
953
  dispose(): void;
721
954
  }
722
955
  interface IRendererPlatformSource {
@@ -736,6 +969,7 @@ interface IRendererOptions {
736
969
  /** Requests multisample antialiasing from the renderer. Defaults to true. */
737
970
  antialias?: boolean;
738
971
  canvas?: HTMLCanvasElement;
972
+ gpuTimestampFrameInterval?: number;
739
973
  preferWebGPU?: boolean;
740
974
  /** CSS-pixel multiplier for the drawing buffer. The default is intentional DPR 1. */
741
975
  resolutionScale?: number;
@@ -767,4 +1001,4 @@ interface IRendererOptions {
767
1001
  }>) => unknown;
768
1002
  }
769
1003
 
770
- export { readRenderChainObservation as $, type IVelocityRenderPass as A, PIPELINE_CENSUS_VERSION as B, PipelineCensus as C, DEFAULT_PIPELINE_CENSUS_LIMIT as D, type PipelineCensusMode as E, FRAME_BUDGET_MARKER as F, type PipelineCensusStatus as G, RENDER_CHAIN_STAGE_ORDER as H, type IRendererLike as I, RENDER_CHAIN_TIERS as J, RenderChain as K, type RenderChainSource as L, type RenderChainStageId as M, type RenderChainStageName as N, type RenderChainTier as O, PIPELINE_CENSUS_CAPABILITY as P, type RenderChainTierRequest as Q, RENDER_CHAIN_MARKER as R, type RenderChainVelocitySource as S, VELOCITY_PREVIOUS_BONE_MATRICES as T, VELOCITY_PREVIOUS_INSTANCE_MATRICES as U, VELOCITY_OUTPUT_NAME as V, VELOCITY_PREVIOUS_WORLD_MATRIX as W, VelocityTracker as X, createPipelineCensus as Y, ensureVelocityOutput as Z, prewarm as _, FRAME_BUDGET_PHASES as a, readRenderChainReport as a0, readVelocityPreviousBoneMatrices as a1, readVelocityPreviousMatrices as a2, readVelocityPreviousWorldMatrix as a3, velocityTexture as a4, withVelocityContext as a5, type IRendererOptions as a6, FRAME_HITCH_MARKER as b, FrameBudget as c, type FrameBudgetPhase as d, type IFrameBudgetOptions as e, type IFrameBudgetSummary as f, type IFrameBudgetWindow as g, type IFramePhaseSample as h, type IPipelineCensus as i, type IPipelineCensusCounts as j, type IPipelineCensusEvent as k, type IPipelineCensusOptions as l, type IPipelineProvenance as m, type IPipelineShaderObservation as n, type IRenderChainApplied as o, type IRenderChainBudgetWindow as p, type IRenderChainDroppedStage as q, type IRenderChainOptions as r, type IRenderChainRenderer as s, type IRenderChainRequest as t, type IRenderChainStage as u, type IRenderChainStageContext as v, type IRenderChainVelocityMeasurement as w, type IRenderChainVelocityReport as x, type IRenderChainVelocityRequest as y, type IRenderChainVelocityResult as z };
1004
+ export { VELOCITY_PREVIOUS_INSTANCE_MATRICES as $, type IRenderChainStageContext as A, type IRenderChainVelocityMeasurement as B, type IRenderChainVelocityReport as C, DEFAULT_PIPELINE_CENSUS_LIMIT as D, type IRenderChainVelocityRequest as E, type FramePassKind as F, type IRenderChainVelocityResult as G, type IRenderPassSample as H, type IRendererLike as I, type IVelocityRenderPass as J, PIPELINE_CENSUS_VERSION as K, PipelineCensus as L, type PipelineCensusMode as M, type PipelineCensusStatus as N, RENDER_CHAIN_STAGE_ORDER as O, PIPELINE_CENSUS_CAPABILITY as P, RENDER_CHAIN_TIERS as Q, RENDER_CHAIN_MARKER as R, RenderChain as S, type RenderChainSource as T, type RenderChainStageId as U, type RenderChainStageName as V, type RenderChainTier as W, type RenderChainTierRequest as X, type RenderChainVelocitySource as Y, VELOCITY_OUTPUT_NAME as Z, VELOCITY_PREVIOUS_BONE_MATRICES as _, type IFrameBudgetWindow as a, VELOCITY_PREVIOUS_WORLD_MATRIX as a0, VelocityTracker as a1, counterDeviceOf as a2, createPipelineCensus as a3, ensureVelocityOutput as a4, prewarm as a5, readRenderChainObservation as a6, readRenderChainReport as a7, readVelocityPreviousBoneMatrices as a8, readVelocityPreviousMatrices as a9, readVelocityPreviousWorldMatrix as aa, velocityTexture as ab, withVelocityContext as ac, type IRendererOptions as ad, FRAME_BUDGET_MARKER as b, FRAME_BUDGET_PHASES as c, FRAME_HITCH_MARKER as d, FrameBudget as e, type FrameBudgetPhase as f, FrameCounters as g, type ICounterDevice as h, type IFrameBudgetOptions as i, type IFrameBudgetPassSummary as j, type IFrameBudgetSummary as k, type IFrameCounters as l, type IFramePhaseSample as m, type IPipelineCensus as n, type IPipelineCensusCounts as o, type IPipelineCensusEvent as p, type IPipelineCensusOptions as q, type IPipelineProvenance as r, type IPipelineShaderObservation as s, type IRenderChainApplied as t, type IRenderChainBudgetWindow as u, type IRenderChainDroppedStage as v, type IRenderChainOptions as w, type IRenderChainRenderer as x, type IRenderChainRequest as y, type IRenderChainStage as z };
@@ -57,6 +57,13 @@ declare const HIT_REGIONS_MESSAGE = "tn:hit-regions";
57
57
  declare const GAME_STATE_MESSAGE = "tn:state";
58
58
  /** The message the UI end sends when the player acts on a control. */
59
59
  declare const UI_INTENT_MESSAGE = "tn:intent";
60
+ /**
61
+ * The frame rate the game end publishes when it was launched in dev mode.
62
+ *
63
+ * Separate from the state stream because it is not game state: a HUD that showed it would be
64
+ * showing the engine's own measurement, and a game that never asked for dev mode never sends it.
65
+ */
66
+ declare const UI_DEV_METRICS_MESSAGE = "tn:dev-metrics";
60
67
  /**
61
68
  * The intent the UI layer sends once, when its tree has rendered and its rectangles are published.
62
69
  *
@@ -118,16 +125,15 @@ declare function connectUiBridge(options: IConnectOptions): IUiBridge;
118
125
  * the mirror behaves identically on both, because on web the mirror is fed by the same
119
126
  * publication through an in-process channel.
120
127
  *
121
- * Publications are **coalesced**: many store writes inside one turn produce one frame. React
122
- * must never re-render on the game loop, and neither may the bridge carry a frame per tick.
128
+ * Publications are **coalesced**: many simulation writes produce one state message per rendered
129
+ * frame. React consumes that snapshot in the UI realm, independently of the game's render work.
123
130
  */
124
131
  /**
125
132
  * The minimum a store must offer to be published.
126
133
  *
127
134
  * `getPublishedState` is optional and preferred when present: ThreeNative's game store keeps a
128
- * live `getState()` that moves every tick and a `getPublishedState()` that moves at most ten
129
- * times a second. The UI wants the throttled one — the live one would put a bridge frame on
130
- * every tick, which is the thing React must never do.
135
+ * live `getState()` that moves every tick and a stable `getPublishedState()` snapshot published
136
+ * once per rendered frame, unless the game selected a slower `stateFlushMs` interval.
131
137
  */
132
138
  interface IPublishableStore<T> {
133
139
  getState(): T;
@@ -162,7 +168,7 @@ interface IPublishOptions {
162
168
  * directly would be a second source of truth that only diverges on the platform where the two
163
169
  * are actually separate processes.
164
170
  * @situation publish game state to a HUD in another realm
165
- * @situation keep a web and native UI mirror on the same throttled state stream
171
+ * @situation keep a web and native UI mirror on the same coalesced state stream
166
172
  * @example const publisher = publishUiState(bridge, store);
167
173
  */
168
174
  declare function publishUiState<T>(bridge: IUiBridge, store: IPublishableStore<T>, options?: IPublishOptions): IUiStatePublisher;
@@ -303,4 +309,4 @@ interface IScopeLike extends IEventTargetLike {
303
309
  */
304
310
  declare function publishHitRegions(options: IRegistryOptions): IHitRegionRegistry;
305
311
 
306
- export { GAME_STATE_MESSAGE, HIT_REGIONS_MESSAGE, type IHitRegion, type IHitRegionRegistry, INTERACTIVE_ATTRIBUTE, type IPublishableStore, type IUiBridge, type IUiMessage, type IUiStateMirror, type IUiStatePublisher, UI_BRIDGE_GLOBALS, UI_INTENT_MESSAGE, UI_READY_INTENT, type UiBridgeEnd, type UiBridgeTransport, connectUiBridge, onUiIntent, publishHitRegions, publishUiState, sendUiIntent, subscribeUiState };
312
+ export { GAME_STATE_MESSAGE, HIT_REGIONS_MESSAGE, type IHitRegion, type IHitRegionRegistry, INTERACTIVE_ATTRIBUTE, type IPublishableStore, type IUiBridge, type IUiMessage, type IUiStateMirror, type IUiStatePublisher, UI_BRIDGE_GLOBALS, UI_DEV_METRICS_MESSAGE, UI_INTENT_MESSAGE, UI_READY_INTENT, type UiBridgeEnd, type UiBridgeTransport, connectUiBridge, onUiIntent, publishHitRegions, publishUiState, sendUiIntent, subscribeUiState };
package/dist/ui-layer.js CHANGED
@@ -2,6 +2,7 @@
2
2
  var HIT_REGIONS_MESSAGE = "tn:hit-regions";
3
3
  var GAME_STATE_MESSAGE = "tn:state";
4
4
  var UI_INTENT_MESSAGE = "tn:intent";
5
+ var UI_DEV_METRICS_MESSAGE = "tn:dev-metrics";
5
6
  var UI_READY_INTENT = "tn:ready";
6
7
  var UI_BRIDGE_GLOBALS = {
7
8
  /** UI end, inbound: the host calls this with one JSON string. */
@@ -422,4 +423,4 @@ function isDevelopment() {
422
423
  return import.meta.env?.DEV === true;
423
424
  }
424
425
 
425
- export { GAME_STATE_MESSAGE, HIT_REGIONS_MESSAGE, INTERACTIVE_ATTRIBUTE, UI_BRIDGE_GLOBALS, UI_INTENT_MESSAGE, UI_READY_INTENT, connectUiBridge, onUiIntent, publishHitRegions, publishUiState, sendUiIntent, subscribeUiState };
426
+ export { GAME_STATE_MESSAGE, HIT_REGIONS_MESSAGE, INTERACTIVE_ATTRIBUTE, UI_BRIDGE_GLOBALS, UI_DEV_METRICS_MESSAGE, UI_INTENT_MESSAGE, UI_READY_INTENT, connectUiBridge, onUiIntent, publishHitRegions, publishUiState, sendUiIntent, subscribeUiState };
package/dist/world.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Object3D, Mesh, LOD, Vector3, Group, BufferGeometry } from 'three';
2
- import { I as IComputeDriven, a as IGPUReadbackSample } from './gpu-readback-D2iRvoe9.js';
3
- import { I as IRendererLike } from './renderer-C6hqZpoG.js';
4
- import { I as IAssetLoader } from './assets-kyoF7JlJ.js';
2
+ import { I as IComputeDriven, a as IGPUReadbackSample } from './gpu-readback-CMklJs6r.js';
3
+ import { I as IRendererLike } from './renderer-Cy4qeBOA.js';
4
+ import { I as IAssetLoader } from './assets-CYKk2WTu.js';
5
5
  import 'three/webgpu';
6
6
 
7
7
  interface IWorldErosionOptions {
@@ -424,7 +424,7 @@ async function runRecipe(name, request, options = {}) {
424
424
 
425
425
  // src/index.ts
426
426
  var SERVER_NAME = "threenative-blender-mcp";
427
- var SERVER_VERSION = "0.1.1";
427
+ var SERVER_VERSION = "0.1.3";
428
428
  var AUTHORING_INSTRUCTIONS = "Call blender_status before anything else: it answers whether this machine can convert models at all, and when it cannot it names the install command rather than failing. Blender is never installed for the user \u2014 ask first, then let them run the command. An .fbx, .blend, .obj or .dae placed in a game's assets directory is converted by the build itself; these tools are for inspecting a source before committing it and for operations the build does not perform. For anything no named tool covers, read a shipped recipe with blender_recipes and adapt it, then run it with blender_run_python.";
429
429
  var TOOL_DEFINITIONS = [
430
430
  {