@woosh/meep-engine 3.34.0 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +1 -1
  2. package/src/shade/device/timing/profile/GPUProfileSession.d.ts +25 -11
  3. package/src/shade/device/timing/profile/GPUProfileSession.d.ts.map +1 -1
  4. package/src/shade/device/timing/profile/GPUProfileSession.js +553 -474
  5. package/src/shade/playground/avboit/main.d.ts.map +1 -1
  6. package/src/shade/playground/avboit/main.js +888 -916
  7. package/src/shade/playground/particle_ecs/README.md +13 -7
  8. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts +5 -5
  9. package/src/shade/playground/particle_ecs/bonfire_effects.js +5 -5
  10. package/src/shade/playground/particle_ecs/effects/smoke.json +1859 -1859
  11. package/src/shade/playground/particle_ecs/effects/soot.json +1605 -1605
  12. package/src/shade/playground/particle_system/README.md +1 -2
  13. package/src/shade/playground/profile_hotkey.d.ts.map +1 -1
  14. package/src/shade/playground/profile_hotkey.js +4 -40
  15. package/src/shade/playground/skinned_blas_refit/main.d.ts.map +1 -1
  16. package/src/shade/playground/skinned_blas_refit/main.js +4 -39
  17. package/src/shade/playground/volumetrics_froxel/main.d.ts.map +1 -1
  18. package/src/shade/playground/volumetrics_froxel/main.js +4 -39
  19. package/src/shade/playground/volumetrics_froxel/measure.d.ts.map +1 -1
  20. package/src/shade/playground/volumetrics_froxel/measure.js +2 -4
  21. package/src/shade/renderer/Renderer.d.ts +1 -1
  22. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  23. package/src/shade/renderer/Renderer.js +3 -1
  24. package/src/shade/renderer/light/GPULightCollection.d.ts +9 -1
  25. package/src/shade/renderer/light/GPULightCollection.d.ts.map +1 -1
  26. package/src/shade/renderer/light/GPULightCollection.js +41 -0
  27. package/src/shade/renderer/light/environment/shader_project_environment_sh.d.ts +37 -0
  28. package/src/shade/renderer/light/environment/shader_project_environment_sh.d.ts.map +1 -0
  29. package/src/shade/renderer/light/environment/shader_project_environment_sh.js +105 -0
  30. package/src/shade/renderer/particles/DESIGN.md +65 -8
  31. package/src/shade/renderer/particles/GPUParticleSystem.d.ts +7 -1
  32. package/src/shade/renderer/particles/GPUParticleSystem.d.ts.map +1 -1
  33. package/src/shade/renderer/particles/GPUParticleSystem.js +1247 -1244
  34. package/src/shade/renderer/particles/graph_particles_avboit.d.ts +7 -1
  35. package/src/shade/renderer/particles/graph_particles_avboit.d.ts.map +1 -1
  36. package/src/shade/renderer/particles/graph_particles_avboit.js +9 -0
  37. package/src/shade/renderer/particles/shaders/chunk_particle_billboard_vertex.d.ts.map +1 -1
  38. package/src/shade/renderer/particles/shaders/chunk_particle_billboard_vertex.js +4 -2
  39. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +15 -9
  40. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  41. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +29 -11
  42. package/src/shade/renderer/particles/shaders/chunk_particle_lighting_model.d.ts +24 -0
  43. package/src/shade/renderer/particles/shaders/chunk_particle_lighting_model.d.ts.map +1 -0
  44. package/src/shade/renderer/particles/shaders/chunk_particle_lighting_model.js +45 -0
  45. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts +3 -0
  46. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  47. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +38 -0
  48. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.d.ts.map +1 -1
  49. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.js +190 -185
  50. package/src/shade/renderer/rasterize/native/avboit/AVBOITSideChannel.d.ts +8 -0
  51. package/src/shade/renderer/rasterize/native/avboit/AVBOITSideChannel.d.ts.map +1 -1
  52. package/src/shade/renderer/rasterize/native/avboit/AVBOITSideChannel.js +5 -0
  53. package/src/shade/renderer/rasterize/native/avboit/graph_rasterize_meshes_transparent_avboit.d.ts +8 -1
  54. package/src/shade/renderer/rasterize/native/avboit/graph_rasterize_meshes_transparent_avboit.d.ts.map +1 -1
  55. package/src/shade/renderer/rasterize/native/avboit/graph_rasterize_meshes_transparent_avboit.js +9 -0
  56. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts +13 -3
  57. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
  58. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +9 -1
  59. package/src/shade/renderer/volumetrics/chunk_volumetrics_irradiance_sh.d.ts +39 -0
  60. package/src/shade/renderer/volumetrics/chunk_volumetrics_irradiance_sh.d.ts.map +1 -0
  61. package/src/shade/renderer/volumetrics/chunk_volumetrics_irradiance_sh.js +107 -0
  62. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_froxel_centre_ws.d.ts +15 -0
  63. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_froxel_centre_ws.d.ts.map +1 -0
  64. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_froxel_centre_ws.js +44 -0
  65. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.d.ts +19 -0
  66. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.d.ts.map +1 -0
  67. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.js +48 -0
  68. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.d.ts +29 -0
  69. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.d.ts.map +1 -0
  70. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.js +90 -0
  71. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_brick4.d.ts +10 -0
  72. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_brick4.d.ts.map +1 -0
  73. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_brick4.js +55 -0
  74. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_ibl.d.ts +10 -0
  75. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_ibl.d.ts.map +1 -0
  76. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_ibl.js +47 -0
  77. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_lpv.d.ts +10 -0
  78. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_lpv.d.ts.map +1 -0
  79. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi_lpv.js +63 -0
  80. package/src/shade/renderer/volumetrics/gi/volumetrics_gi_resources.d.ts +17 -0
  81. package/src/shade/renderer/volumetrics/gi/volumetrics_gi_resources.d.ts.map +1 -0
  82. package/src/shade/renderer/volumetrics/gi/volumetrics_gi_resources.js +26 -0
  83. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +8 -1
  84. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  85. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +50 -2
  86. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  87. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +488 -436
  88. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.d.ts +1 -1
  89. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.d.ts.map +1 -1
  90. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +254 -63
@@ -1,474 +1,553 @@
1
- import { assert } from "../../../../core/assert.js";
2
- import { Signal } from "../../../../core/events/signal/Signal.js";
3
- import { GPUFrameRecorder } from "./GPUFrameRecorder.js";
4
- import { GPUProfileLevel, gpu_profile_level_name } from "./GPUProfileLevel.js";
5
- import { GPUProfileMeta } from "./GPUProfileMeta.js";
6
- import { SGPT_RECORD_TYPE } from "./SGPT_RECORD_TYPE.js";
7
- import { SGPTWriteStream } from "./SGPTWriteStream.js";
8
- import { sgpt_write_frame } from "./sgpt_frame_codec.js";
9
- import { sgpt_write_meta } from "./sgpt_meta_codec.js";
10
- import {
11
- sgpt_intern_topology_symbols,
12
- sgpt_topology_hash,
13
- sgpt_write_topology
14
- } from "./sgpt_topology_codec.js";
15
-
16
- /**
17
- * @typedef {import("./GPUProfileFrame.js").GPUProfileFrame} GPUProfileFrame
18
- */
19
-
20
- /**
21
- * Default point at which an uncapped session starts saying how much it has accumulated. Does not
22
- * stop anything — see {@link GPUProfileSession#byte_budget}.
23
- *
24
- * @type {number}
25
- */
26
- const DEFAULT_BYTE_BUDGET = 512 * 1024 * 1024;
27
-
28
- /**
29
- * A GPU profile recording, from `start` to `stop`.
30
- *
31
- * **The engine never constructs one of these.** The caller builds it and hands it over:
32
- *
33
- * ```js
34
- * const session = new GPUProfileSession({ level: GPUProfileLevel.TIMING });
35
- *
36
- * renderer.profile_session = session;
37
- *
38
- * await session.start(graphics);
39
- * // ... frames run, for as long as you like ...
40
- * const bytes = await session.stop();
41
- *
42
- * renderer.profile_session = null;
43
- * ```
44
- *
45
- * That ownership is the point rather than a style choice. **Nothing in the engine imports this file
46
- * or anything it reaches** — the write stream, the format codecs, the topology extractor — so an
47
- * application that never mentions the profiler does not carry any of it. The feature ships without
48
- * a build flag, a strip-plugin interaction, or a dead branch anyone has to keep honest.
49
- *
50
- * Nothing under this directory is linked unconditionally. The pass encoders count dispatches into a
51
- * {@link GPUProfileWork} when handed one, but they hold it as a bag of counters and never name the
52
- * type; the workgroup size they record comes off {@link ComputePipelineDescriptor}, which reads it
53
- * as its code is set. What remains in the engine is a nullable field and the null checks around it.
54
- *
55
- * Recording is an **open-ended forward stream**: `frame_limit` defaults to `Infinity` and
56
- * everything captured is kept. There is no ring buffer. Uncapped means a per-frame cost is also a
57
- * per-second one, so the session reports what it is accumulating rather than letting an
58
- * application find out when the tab dies.
59
- *
60
- * @author Alex Goldring
61
- * @copyright Company Named Limited (c) 2026
62
- */
63
- export class GPUProfileSession {
64
-
65
- /**
66
- * @type {number}
67
- */
68
- #level = GPUProfileLevel.TIMING;
69
-
70
- /**
71
- * @type {number}
72
- */
73
- #frame_limit = Infinity;
74
-
75
- /**
76
- * @type {string}
77
- */
78
- #note = "";
79
-
80
- /**
81
- * @type {SGPTWriteStream|null}
82
- */
83
- #stream = null;
84
-
85
- /**
86
- * @type {GPUProfileMeta}
87
- */
88
- #meta = new GPUProfileMeta();
89
-
90
- /**
91
- * @type {number}
92
- */
93
- #frames_recorded = 0;
94
-
95
- /**
96
- * @type {boolean}
97
- */
98
- #running = false;
99
-
100
- /**
101
- * @type {boolean}
102
- */
103
- #stopped = false;
104
-
105
- /**
106
- * @type {boolean}
107
- */
108
- #budget_warned = false;
109
-
110
- /**
111
- * Content hash of every topology already written, to the id it was given.
112
- *
113
- * The whole of the deduplication. A Shade frame graph is nearly identical frame to frame, so
114
- * this map stays tiny and hundreds of frames share one entry — the difference between a capture
115
- * of tens of megabytes and one of a couple.
116
- *
117
- * @type {Map<number, number>}
118
- */
119
- #topology_ids = new Map();
120
-
121
- /**
122
- * @type {number}
123
- */
124
- #topology_count = 0;
125
-
126
- /**
127
- * How many distinct frame graph structures this capture has needed.
128
- *
129
- * Worth watching: if this climbs with the frame count, the graph is changing shape every frame
130
- * and the size estimates for a long capture do not hold.
131
- *
132
- * @returns {number}
133
- */
134
- get topology_count() {
135
- return this.#topology_count;
136
- }
137
-
138
- /**
139
- * Bytes at which the session warns — once — that it is getting large. It does **not** stop:
140
- * full uncapped history is the behaviour that was asked for, and silently truncating a capture
141
- * is worse than a large one.
142
- *
143
- * @type {number}
144
- */
145
- byte_budget = DEFAULT_BYTE_BUDGET;
146
-
147
- /**
148
- * Fires per frame with the running byte total.
149
- *
150
- * @readonly
151
- * @type {Signal<number>}
152
- */
153
- onBytesWritten = new Signal();
154
-
155
- /**
156
- * Fires once, with the finished capture, however the session ended — `stop()` or the frame
157
- * limit running out. Both paths land here so a caller can await one thing.
158
- *
159
- * @readonly
160
- * @type {Signal<ArrayBuffer>}
161
- */
162
- onComplete = new Signal();
163
-
164
- /**
165
- * @param {object} [options]
166
- * @param {number} [options.level] one of {@link GPUProfileLevel}
167
- * @param {number} [options.frame_limit] frames to record before stopping on its own. `Infinity`
168
- * by default — a session runs until `stop()`.
169
- * @param {string} [options.note] whatever you want the capture to say about itself
170
- */
171
- constructor({
172
- level = GPUProfileLevel.TIMING,
173
- frame_limit = Infinity,
174
- note = ""
175
- } = {}) {
176
-
177
- assert.isInteger(level, 'level');
178
- assert.isString(note, 'note');
179
- assert.ok(
180
- frame_limit === Infinity || (Number.isInteger(frame_limit) && frame_limit > 0),
181
- `frame_limit must be a positive integer or Infinity, was ${frame_limit}`
182
- );
183
-
184
- this.#level = level;
185
- this.#frame_limit = frame_limit;
186
- this.#note = note;
187
- }
188
-
189
- /**
190
- * @returns {number} one of {@link GPUProfileLevel}
191
- */
192
- get level() {
193
- return this.#level;
194
- }
195
-
196
- /**
197
- * @returns {boolean}
198
- */
199
- get is_running() {
200
- return this.#running;
201
- }
202
-
203
- /**
204
- * @returns {number}
205
- */
206
- get frames_recorded() {
207
- return this.#frames_recorded;
208
- }
209
-
210
- /**
211
- * @returns {number}
212
- */
213
- get bytes_written() {
214
- return this.#stream === null ? 0 : this.#stream.bytes_written;
215
- }
216
-
217
- /**
218
- * The metadata this capture will carry. Populate before {@link start}; it is written into the
219
- * stream at that point and not read again.
220
- *
221
- * @returns {GPUProfileMeta}
222
- */
223
- get meta() {
224
- return this.#meta;
225
- }
226
-
227
- /**
228
- * Begin recording.
229
- *
230
- * {@link meta} is written into the stream here and not read again, so populate it first.
231
- *
232
- * @returns {void}
233
- */
234
- start() {
235
- assert.notOk(this.#running, 'session already started');
236
- assert.notOk(this.#stopped, 'a stopped session cannot be restarted; construct a new one');
237
-
238
- const meta = this.#meta;
239
-
240
- meta.level = this.#level;
241
- meta.note = this.#note;
242
-
243
- if (meta.wall_clock_start_ms === 0) {
244
- meta.wall_clock_start_ms = Date.now();
245
- }
246
-
247
- const stream = new SGPTWriteStream();
248
-
249
- stream.write_record(SGPT_RECORD_TYPE.META, sgpt_write_meta, meta);
250
-
251
- this.#stream = stream;
252
- this.#running = true;
253
- }
254
-
255
- /**
256
- * Open a recorder for a frame about to be encoded, or null when nothing should be recorded.
257
- *
258
- * **This is why the renderer imports nothing from the profiler.** It holds a nullable session
259
- * and calls methods on it; the recorder type never appears in its module graph, so the whole
260
- * profiler stays unreachable from any engine entry point and drops out of a bundle that does
261
- * not ask for it. A `new GPUFrameRecorder(...)` in the renderer would have quietly undone that.
262
- *
263
- * @param {number} frame_index the renderer's own frame counter
264
- * @param {number} cpu_begin_ms
265
- * @returns {GPUFrameRecorder|null}
266
- */
267
- begin_frame(frame_index, cpu_begin_ms) {
268
- if (!this.#running) {
269
- return null;
270
- }
271
-
272
- const recorder = new GPUFrameRecorder(frame_index, cpu_begin_ms);
273
-
274
- if (this.#level >= GPUProfileLevel.STRUCTURE) {
275
- recorder.enable_structure_capture();
276
- }
277
-
278
- if (this.#level >= GPUProfileLevel.WORKLOAD) {
279
- recorder.enable_work_capture();
280
- }
281
-
282
- return recorder;
283
- }
284
-
285
- /**
286
- * Close a recorder opened by {@link begin_frame} and commit what it collected.
287
- *
288
- * @param {GPUFrameRecorder} recorder
289
- * @param {number} cpu_submit_ms
290
- * @returns {void}
291
- */
292
- end_frame(recorder, cpu_submit_ms) {
293
- assert.defined(recorder, 'recorder');
294
-
295
- this.record_frame(recorder.close(cpu_submit_ms));
296
- }
297
-
298
- /**
299
- * Write a topology if this shape has not been seen, and return the id either way.
300
- *
301
- * @param {GPUProfileTopology} topology
302
- * @returns {number}
303
- */
304
- #write_topology(topology) {
305
- const hash = sgpt_topology_hash(topology);
306
-
307
- const existing = this.#topology_ids.get(hash);
308
-
309
- if (existing !== undefined) {
310
- return existing;
311
- }
312
-
313
- const id = this.#topology_count++;
314
-
315
- topology.id = id;
316
-
317
- this.#topology_ids.set(hash, id);
318
-
319
- const stream = this.#stream;
320
-
321
- // Ahead of the record, for the same reason frame span labels are — a `SYMS` block must
322
- // precede whatever references it, or a capture truncated in between resolves no names.
323
- sgpt_intern_topology_symbols(topology, value => stream.intern(value));
324
-
325
- stream.write_record(
326
- SGPT_RECORD_TYPE.TOPO,
327
- (buffer, t) => sgpt_write_topology(buffer, t, value => stream.intern(value)),
328
- topology
329
- );
330
-
331
- return id;
332
- }
333
-
334
- /**
335
- * Commit one frame.
336
- *
337
- * Ignored when the session is not running, so a frame that lands after `stop()` — the readback
338
- * for a frame already submitted, which is the ordinary case — is dropped rather than throwing
339
- * inside somebody's render loop.
340
- *
341
- * @param {GPUProfileFrame} frame
342
- * @returns {void}
343
- */
344
- record_frame(frame) {
345
- assert.defined(frame, 'frame');
346
-
347
- if (!this.#running) {
348
- return;
349
- }
350
-
351
- const stream = this.#stream;
352
-
353
- // Reserve before writing, so the buffer's growth — an allocation and a copy — happens here
354
- // at the boundary rather than part-way through encoding a frame.
355
- stream.reserve_frame();
356
-
357
- if (frame.topology !== null && frame.topology !== undefined) {
358
- frame.topology_id = this.#write_topology(frame.topology);
359
- }
360
-
361
- /*
362
- Intern every name this frame uses *before* the record is opened.
363
-
364
- `write_record` flushes pending symbols ahead of the record it is about to write, and
365
- interning from inside the payload writer is too late for that — the symbols would land in
366
- the *next* flush, behind the record referencing them. A reader of a capture truncated in
367
- between then resolves none of them, which is the failure this format exists to avoid.
368
-
369
- Doing it here rather than inside the stream keeps the stream ignorant of the frame model.
370
- */
371
- for (const span of frame.spans) {
372
- stream.intern(span.label);
373
-
374
- if (span.work !== null && span.work !== undefined) {
375
- stream.intern(span.work.pipeline);
376
- }
377
- }
378
-
379
- stream.write_record(
380
- SGPT_RECORD_TYPE.FRAM,
381
- (buffer, f) => sgpt_write_frame(buffer, f, value => stream.intern(value)),
382
- frame
383
- );
384
-
385
- this.#frames_recorded++;
386
-
387
- const bytes = stream.bytes_written;
388
-
389
- if (this.onBytesWritten.hasHandlers()) {
390
- this.onBytesWritten.send1(bytes);
391
- }
392
-
393
- if (!this.#budget_warned && bytes >= this.byte_budget) {
394
- this.#budget_warned = true;
395
-
396
- const per_frame = Math.round(bytes / this.#frames_recorded);
397
-
398
- console.warn(
399
- `GPUProfileSession: ${(bytes / (1024 * 1024)).toFixed(0)} MiB after `
400
- + `${this.#frames_recorded} frames at level ${gpu_profile_level_name(this.#level)} `
401
- + `(~${per_frame} bytes/frame). Recording continues — call stop() when you have enough.`
402
- );
403
- }
404
-
405
- if (this.#frames_recorded >= this.#frame_limit) {
406
- this.stop();
407
- }
408
- }
409
-
410
- /**
411
- * The capture as it stands, without ending the session.
412
- *
413
- * Reads back as a capture that was cut short — no directory, not marked closed — because that
414
- * is precisely what it is. Recording continues afterwards.
415
- *
416
- * @returns {ArrayBuffer}
417
- */
418
- snapshot() {
419
- assert.ok(this.#running, 'session is not running');
420
-
421
- return this.#stream.snapshot();
422
- }
423
-
424
- /**
425
- * End the recording and hand back the capture.
426
- *
427
- * Calling it twice returns the same bytes rather than complaining; a session that hit its
428
- * frame limit has already stopped itself, and the caller awaiting `stop()` should still get
429
- * the capture.
430
- *
431
- * @returns {ArrayBuffer}
432
- */
433
- stop() {
434
- if (this.#stopped) {
435
- return this.#result;
436
- }
437
-
438
- assert.ok(this.#running, 'session was never started');
439
-
440
- this.#running = false;
441
- this.#stopped = true;
442
-
443
- this.#result = this.#stream.finish();
444
-
445
- this.onComplete.send1(this.#result);
446
-
447
- return this.#result;
448
- }
449
-
450
- /**
451
- * @type {ArrayBuffer|null}
452
- */
453
- #result = null;
454
-
455
- /**
456
- * Resolves with the capture when the session ends, whichever way it ends.
457
- *
458
- * @returns {Promise<ArrayBuffer>}
459
- */
460
- get done() {
461
- if (this.#stopped) {
462
- return Promise.resolve(this.#result);
463
- }
464
-
465
- return new Promise(resolve => {
466
- this.onComplete.addOne(resolve);
467
-
468
- if (this.#stopped) {
469
- // guard against the session ending between the check above and the subscription
470
- resolve(this.#result);
471
- }
472
- });
473
- }
474
- }
1
+ import { assert } from "../../../../core/assert.js";
2
+ import { Signal } from "../../../../core/events/signal/Signal.js";
3
+ import { GPUFrameRecorder } from "./GPUFrameRecorder.js";
4
+ import { GPUProfileLevel, gpu_profile_level_name } from "./GPUProfileLevel.js";
5
+ import { GPUProfileMeta } from "./GPUProfileMeta.js";
6
+ import { SGPT_RECORD_TYPE } from "./SGPT_RECORD_TYPE.js";
7
+ import { SGPTWriteStream } from "./SGPTWriteStream.js";
8
+ import { sgpt_write_frame } from "./sgpt_frame_codec.js";
9
+ import { sgpt_write_meta } from "./sgpt_meta_codec.js";
10
+ import {
11
+ sgpt_intern_topology_symbols,
12
+ sgpt_topology_hash,
13
+ sgpt_write_topology
14
+ } from "./sgpt_topology_codec.js";
15
+
16
+ /**
17
+ * @typedef {import("./GPUProfileFrame.js").GPUProfileFrame} GPUProfileFrame
18
+ */
19
+
20
+ /**
21
+ * Default point at which an uncapped session starts saying how much it has accumulated. Does not
22
+ * stop anything — see {@link GPUProfileSession#byte_budget}.
23
+ *
24
+ * @type {number}
25
+ */
26
+ const DEFAULT_BYTE_BUDGET = 512 * 1024 * 1024;
27
+
28
+ /**
29
+ * A GPU profile recording, from `start` to `stop`.
30
+ *
31
+ * **The engine never constructs one of these.** The caller builds it and hands it over:
32
+ *
33
+ * ```js
34
+ * const session = new GPUProfileSession({ level: GPUProfileLevel.TIMING });
35
+ *
36
+ * renderer.profile_session = session;
37
+ *
38
+ * await session.start(graphics);
39
+ * // ... frames run, for as long as you like ...
40
+ * const bytes = await session.stop();
41
+ *
42
+ * renderer.profile_session = null;
43
+ * ```
44
+ *
45
+ * That ownership is the point rather than a style choice. **Nothing in the engine imports this file
46
+ * or anything it reaches** — the write stream, the format codecs, the topology extractor — so an
47
+ * application that never mentions the profiler does not carry any of it. The feature ships without
48
+ * a build flag, a strip-plugin interaction, or a dead branch anyone has to keep honest.
49
+ *
50
+ * Nothing under this directory is linked unconditionally. The pass encoders count dispatches into a
51
+ * {@link GPUProfileWork} when handed one, but they hold it as a bag of counters and never name the
52
+ * type; the workgroup size they record comes off {@link ComputePipelineDescriptor}, which reads it
53
+ * as its code is set. What remains in the engine is a nullable field and the null checks around it.
54
+ *
55
+ * Recording is an **open-ended forward stream**: `frame_limit` defaults to `Infinity` and
56
+ * everything captured is kept. There is no ring buffer. Uncapped means a per-frame cost is also a
57
+ * per-second one, so the session reports what it is accumulating rather than letting an
58
+ * application find out when the tab dies.
59
+ *
60
+ * @author Alex Goldring
61
+ * @copyright Company Named Limited (c) 2026
62
+ */
63
+ export class GPUProfileSession {
64
+
65
+ /**
66
+ * @type {number}
67
+ */
68
+ #level = GPUProfileLevel.TIMING;
69
+
70
+ /**
71
+ * @type {number}
72
+ */
73
+ #frame_limit = Infinity;
74
+
75
+ /**
76
+ * @type {string}
77
+ */
78
+ #note = "";
79
+
80
+ /**
81
+ * @type {SGPTWriteStream|null}
82
+ */
83
+ #stream = null;
84
+
85
+ /**
86
+ * @type {GPUProfileMeta}
87
+ */
88
+ #meta = new GPUProfileMeta();
89
+
90
+ /**
91
+ * @type {number}
92
+ */
93
+ #frames_recorded = 0;
94
+
95
+ /**
96
+ * The frames of recorders {@link begin_frame} has handed out that have not come back through
97
+ * {@link record_frame} yet — the ones whose timings are still on their way. {@link stop} waits
98
+ * for this to empty.
99
+ *
100
+ * @type {Set<GPUProfileFrame>}
101
+ */
102
+ #outstanding = new Set();
103
+
104
+ /**
105
+ * @type {boolean}
106
+ */
107
+ #running = false;
108
+
109
+ /**
110
+ * Whether `stop()` has been called, or the frame limit reached. The capture is finished once
111
+ * this is set and nothing is outstanding.
112
+ *
113
+ * @type {boolean}
114
+ */
115
+ #stopped = false;
116
+
117
+ /**
118
+ * @type {boolean}
119
+ */
120
+ #budget_warned = false;
121
+
122
+ /**
123
+ * Content hash of every topology already written, to the id it was given.
124
+ *
125
+ * The whole of the deduplication. A Shade frame graph is nearly identical frame to frame, so
126
+ * this map stays tiny and hundreds of frames share one entry — the difference between a capture
127
+ * of tens of megabytes and one of a couple.
128
+ *
129
+ * @type {Map<number, number>}
130
+ */
131
+ #topology_ids = new Map();
132
+
133
+ /**
134
+ * @type {number}
135
+ */
136
+ #topology_count = 0;
137
+
138
+ /**
139
+ * How many distinct frame graph structures this capture has needed.
140
+ *
141
+ * Worth watching: if this climbs with the frame count, the graph is changing shape every frame
142
+ * and the size estimates for a long capture do not hold.
143
+ *
144
+ * @returns {number}
145
+ */
146
+ get topology_count() {
147
+ return this.#topology_count;
148
+ }
149
+
150
+ /**
151
+ * Bytes at which the session warns — once — that it is getting large. It does **not** stop:
152
+ * full uncapped history is the behaviour that was asked for, and silently truncating a capture
153
+ * is worse than a large one.
154
+ *
155
+ * @type {number}
156
+ */
157
+ byte_budget = DEFAULT_BYTE_BUDGET;
158
+
159
+ /**
160
+ * Fires per frame with the running byte total.
161
+ *
162
+ * @readonly
163
+ * @type {Signal<number>}
164
+ */
165
+ onBytesWritten = new Signal();
166
+
167
+ /**
168
+ * Fires once, with the finished capture, however the session ended — `stop()` or the frame
169
+ * limit running out — once the frames begun before that have landed. Both paths land here so a
170
+ * caller can await one thing.
171
+ *
172
+ * @readonly
173
+ * @type {Signal<ArrayBuffer>}
174
+ */
175
+ onComplete = new Signal();
176
+
177
+ /**
178
+ * @param {object} [options]
179
+ * @param {number} [options.level] one of {@link GPUProfileLevel}
180
+ * @param {number} [options.frame_limit] frames to record before stopping on its own. `Infinity`
181
+ * by default — a session runs until `stop()`.
182
+ * @param {string} [options.note] whatever you want the capture to say about itself
183
+ */
184
+ constructor({
185
+ level = GPUProfileLevel.TIMING,
186
+ frame_limit = Infinity,
187
+ note = ""
188
+ } = {}) {
189
+
190
+ assert.isInteger(level, 'level');
191
+ assert.isString(note, 'note');
192
+ assert.ok(
193
+ frame_limit === Infinity || (Number.isInteger(frame_limit) && frame_limit > 0),
194
+ `frame_limit must be a positive integer or Infinity, was ${frame_limit}`
195
+ );
196
+
197
+ this.#level = level;
198
+ this.#frame_limit = frame_limit;
199
+ this.#note = note;
200
+ }
201
+
202
+ /**
203
+ * @returns {number} one of {@link GPUProfileLevel}
204
+ */
205
+ get level() {
206
+ return this.#level;
207
+ }
208
+
209
+ /**
210
+ * Whether new frames are being taken: from `start()` until `stop()` or the frame limit. A session
211
+ * that is no longer running can still be waiting on frames begun before that; {@link done} says
212
+ * when it has them.
213
+ *
214
+ * @returns {boolean}
215
+ */
216
+ get is_running() {
217
+ return this.#running;
218
+ }
219
+
220
+ /**
221
+ * @returns {number}
222
+ */
223
+ get frames_recorded() {
224
+ return this.#frames_recorded;
225
+ }
226
+
227
+ /**
228
+ * @returns {number}
229
+ */
230
+ get bytes_written() {
231
+ return this.#stream === null ? 0 : this.#stream.bytes_written;
232
+ }
233
+
234
+ /**
235
+ * The metadata this capture will carry. Populate before {@link start}; it is written into the
236
+ * stream at that point and not read again.
237
+ *
238
+ * @returns {GPUProfileMeta}
239
+ */
240
+ get meta() {
241
+ return this.#meta;
242
+ }
243
+
244
+ /**
245
+ * Begin recording.
246
+ *
247
+ * {@link meta} is written into the stream here and not read again, so populate it first.
248
+ *
249
+ * @returns {void}
250
+ */
251
+ start() {
252
+ assert.notOk(this.#running, 'session already started');
253
+ assert.notOk(this.#stopped, 'a stopped session cannot be restarted; construct a new one');
254
+
255
+ const meta = this.#meta;
256
+
257
+ meta.level = this.#level;
258
+ meta.note = this.#note;
259
+
260
+ if (meta.wall_clock_start_ms === 0) {
261
+ meta.wall_clock_start_ms = Date.now();
262
+ }
263
+
264
+ const stream = new SGPTWriteStream();
265
+
266
+ stream.write_record(SGPT_RECORD_TYPE.META, sgpt_write_meta, meta);
267
+
268
+ this.#stream = stream;
269
+ this.#running = true;
270
+ }
271
+
272
+ /**
273
+ * Open a recorder for a frame about to be encoded, or null when nothing should be recorded.
274
+ *
275
+ * **This is why the renderer imports nothing from the profiler.** It holds a nullable session
276
+ * and calls methods on it; the recorder type never appears in its module graph, so the whole
277
+ * profiler stays unreachable from any engine entry point and drops out of a bundle that does
278
+ * not ask for it. A `new GPUFrameRecorder(...)` in the renderer would have quietly undone that.
279
+ *
280
+ * @param {number} frame_index the renderer's own frame counter
281
+ * @param {number} cpu_begin_ms
282
+ * @returns {GPUFrameRecorder|null}
283
+ */
284
+ begin_frame(frame_index, cpu_begin_ms) {
285
+ if (!this.#running) {
286
+ return null;
287
+ }
288
+
289
+ const recorder = new GPUFrameRecorder(frame_index, cpu_begin_ms);
290
+
291
+ if (this.#level >= GPUProfileLevel.STRUCTURE) {
292
+ recorder.enable_structure_capture();
293
+ }
294
+
295
+ if (this.#level >= GPUProfileLevel.WORKLOAD) {
296
+ recorder.enable_work_capture();
297
+ }
298
+
299
+ this.#outstanding.add(recorder.frame);
300
+
301
+ return recorder;
302
+ }
303
+
304
+ /**
305
+ * Close a recorder opened by {@link begin_frame} and commit what it collected — after `stop()`
306
+ * too, which is waiting for exactly this.
307
+ *
308
+ * @param {GPUFrameRecorder} recorder
309
+ * @param {number} cpu_submit_ms
310
+ * @returns {void}
311
+ */
312
+ end_frame(recorder, cpu_submit_ms) {
313
+ assert.defined(recorder, 'recorder');
314
+
315
+ let frame;
316
+
317
+ try {
318
+ frame = recorder.close(cpu_submit_ms);
319
+ } catch (error) {
320
+ // `stop()` is waiting on this frame, and a capture missing it beats a caller hung for good
321
+ this.#settle(recorder.frame);
322
+
323
+ throw error;
324
+ }
325
+
326
+ this.record_frame(frame);
327
+ }
328
+
329
+ /**
330
+ * A frame is back, whether or not it was written. Finishes the capture if `stop()` was waiting
331
+ * for it and it was the last.
332
+ *
333
+ * @param {GPUProfileFrame} frame
334
+ * @returns {void}
335
+ */
336
+ #settle(frame) {
337
+ if (
338
+ this.#outstanding.delete(frame)
339
+ && this.#stopped
340
+ && this.#outstanding.size === 0
341
+ && this.#result === null
342
+ ) {
343
+ this.#finish();
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Write a topology if this shape has not been seen, and return the id either way.
349
+ *
350
+ * @param {GPUProfileTopology} topology
351
+ * @returns {number}
352
+ */
353
+ #write_topology(topology) {
354
+ const hash = sgpt_topology_hash(topology);
355
+
356
+ const existing = this.#topology_ids.get(hash);
357
+
358
+ if (existing !== undefined) {
359
+ return existing;
360
+ }
361
+
362
+ const id = this.#topology_count++;
363
+
364
+ topology.id = id;
365
+
366
+ this.#topology_ids.set(hash, id);
367
+
368
+ const stream = this.#stream;
369
+
370
+ // Ahead of the record, for the same reason frame span labels are — a `SYMS` block must
371
+ // precede whatever references it, or a capture truncated in between resolves no names.
372
+ sgpt_intern_topology_symbols(topology, value => stream.intern(value));
373
+
374
+ stream.write_record(
375
+ SGPT_RECORD_TYPE.TOPO,
376
+ (buffer, t) => sgpt_write_topology(buffer, t, value => stream.intern(value)),
377
+ topology
378
+ );
379
+
380
+ return id;
381
+ }
382
+
383
+ /**
384
+ * Commit one frame.
385
+ *
386
+ * Taken for as long as the stream is open: from `start()` until the capture is finished, which
387
+ * `stop()` holds off until every frame already begun has landed. Ignored before `start()`, once
388
+ * the capture is finished, and past the frame limit — frames begun before the limit was reached
389
+ * still land after it — rather than throwing inside somebody's render loop.
390
+ *
391
+ * @param {GPUProfileFrame} frame
392
+ * @returns {void}
393
+ */
394
+ record_frame(frame) {
395
+ assert.defined(frame, 'frame');
396
+
397
+ try {
398
+ this.#write_frame(frame);
399
+ } finally {
400
+ // written, refused or thrown, it is no longer on its way
401
+ this.#settle(frame);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * @param {GPUProfileFrame} frame
407
+ * @returns {void}
408
+ */
409
+ #write_frame(frame) {
410
+ if (this.#stream === null || this.#result !== null || this.#frames_recorded >= this.#frame_limit) {
411
+ return;
412
+ }
413
+
414
+ const stream = this.#stream;
415
+
416
+ // Reserve before writing, so the buffer's growth — an allocation and a copy — happens here
417
+ // at the boundary rather than part-way through encoding a frame.
418
+ stream.reserve_frame();
419
+
420
+ if (frame.topology !== null && frame.topology !== undefined) {
421
+ frame.topology_id = this.#write_topology(frame.topology);
422
+ }
423
+
424
+ /*
425
+ Intern every name this frame uses *before* the record is opened.
426
+
427
+ `write_record` flushes pending symbols ahead of the record it is about to write, and
428
+ interning from inside the payload writer is too late for that — the symbols would land in
429
+ the *next* flush, behind the record referencing them. A reader of a capture truncated in
430
+ between then resolves none of them, which is the failure this format exists to avoid.
431
+
432
+ Doing it here rather than inside the stream keeps the stream ignorant of the frame model.
433
+ */
434
+ for (const span of frame.spans) {
435
+ stream.intern(span.label);
436
+
437
+ if (span.work !== null && span.work !== undefined) {
438
+ stream.intern(span.work.pipeline);
439
+ }
440
+ }
441
+
442
+ stream.write_record(
443
+ SGPT_RECORD_TYPE.FRAM,
444
+ (buffer, f) => sgpt_write_frame(buffer, f, value => stream.intern(value)),
445
+ frame
446
+ );
447
+
448
+ this.#frames_recorded++;
449
+
450
+ const bytes = stream.bytes_written;
451
+
452
+ if (this.onBytesWritten.hasHandlers()) {
453
+ this.onBytesWritten.send1(bytes);
454
+ }
455
+
456
+ if (!this.#budget_warned && bytes >= this.byte_budget) {
457
+ this.#budget_warned = true;
458
+
459
+ const per_frame = Math.round(bytes / this.#frames_recorded);
460
+
461
+ console.warn(
462
+ `GPUProfileSession: ${(bytes / (1024 * 1024)).toFixed(0)} MiB after `
463
+ + `${this.#frames_recorded} frames at level ${gpu_profile_level_name(this.#level)} `
464
+ + `(~${per_frame} bytes/frame). Recording continues — call stop() when you have enough.`
465
+ );
466
+ }
467
+
468
+ if (this.#frames_recorded >= this.#frame_limit) {
469
+ this.stop();
470
+ }
471
+ }
472
+
473
+ /**
474
+ * The capture as it stands, without ending the session.
475
+ *
476
+ * Reads back as a capture that was cut short — no directory, not marked closed — because that
477
+ * is precisely what it is. Recording continues afterwards.
478
+ *
479
+ * @returns {ArrayBuffer}
480
+ */
481
+ snapshot() {
482
+ assert.ok(this.#running, 'session is not running');
483
+
484
+ return this.#stream.snapshot();
485
+ }
486
+
487
+ /**
488
+ * End the recording, and resolve with the capture once every frame already begun has landed.
489
+ *
490
+ * No frame is begun after this — {@link begin_frame} hands out nothing — but the ones that were
491
+ * are kept. A frame is committed when its timestamp readback resolves, a `mapAsync` behind its
492
+ * submit, so when `stop()` is called the last frame or two are always still on their way; behind
493
+ * a first-use pipeline compile, which holds the queue for seconds, every frame of a short capture
494
+ * can be. Finishing the stream on the spot threw all of those away without a word.
495
+ *
496
+ * Calling it again resolves with the same capture rather than complaining; a session that hit
497
+ * its frame limit has already stopped itself, and the caller awaiting `stop()` should still get
498
+ * the capture.
499
+ *
500
+ * @returns {Promise<ArrayBuffer>}
501
+ */
502
+ stop() {
503
+ if (!this.#stopped) {
504
+ assert.ok(this.#running, 'session was never started');
505
+
506
+ this.#running = false;
507
+ this.#stopped = true;
508
+
509
+ if (this.#outstanding.size === 0) {
510
+ this.#finish();
511
+ }
512
+ }
513
+
514
+ return this.done;
515
+ }
516
+
517
+ /**
518
+ * Close the stream and hand the capture to whoever is waiting for it.
519
+ *
520
+ * @returns {void}
521
+ */
522
+ #finish() {
523
+ this.#result = this.#stream.finish();
524
+
525
+ this.onComplete.send1(this.#result);
526
+ }
527
+
528
+ /**
529
+ * @type {ArrayBuffer|null}
530
+ */
531
+ #result = null;
532
+
533
+ /**
534
+ * Resolves with the capture when the session ends, whichever way it ends — after the frames
535
+ * begun before the end have landed.
536
+ *
537
+ * @returns {Promise<ArrayBuffer>}
538
+ */
539
+ get done() {
540
+ if (this.#result !== null) {
541
+ return Promise.resolve(this.#result);
542
+ }
543
+
544
+ return new Promise(resolve => {
545
+ this.onComplete.addOne(resolve);
546
+
547
+ if (this.#result !== null) {
548
+ // guard against the session ending between the check above and the subscription
549
+ resolve(this.#result);
550
+ }
551
+ });
552
+ }
553
+ }