@downdraft/engine 0.2.0 → 0.3.1

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 (138) hide show
  1. package/app/src/renderer/bootstrap.ts +0 -6
  2. package/app/src/renderer/feature-log.ts +1 -9
  3. package/app/src/renderer/mcp-harness.ts +8 -11
  4. package/app/src/renderer/standard-automation-tools.ts +8 -145
  5. package/core/src/index.ts +2 -2
  6. package/core/src/module/host.ts +3 -6
  7. package/core/src/module/index.ts +1 -1
  8. package/core/src/module/module.ts +4 -47
  9. package/core/src/physics/interface.ts +5 -0
  10. package/core/src/platform/render-surface.ts +13 -0
  11. package/core/src/platform/rpc.ts +4 -1
  12. package/core/src/render/canvas-resize-watcher.ts +1 -10
  13. package/core/src/render/game-renderer.ts +164 -79
  14. package/core/src/render/index.ts +0 -2
  15. package/core/src/render/input-manager.ts +7 -7
  16. package/core/src/render/renderer-module-host.ts +2 -4
  17. package/core/src/sab/grid-buffer.ts +55 -2
  18. package/core/src/sab/record-buffer.ts +48 -5
  19. package/core/src/sab/sim-channel.ts +181 -17
  20. package/core/src/sab/slot-buffer.ts +55 -1
  21. package/core/src/sab/types.ts +11 -0
  22. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  23. package/core/src/ui-font-scale.ts +5 -38
  24. package/core/src/util/index.ts +1 -0
  25. package/core/src/util/session-timer.ts +23 -0
  26. package/core/src/worker/base-worker-host.ts +2 -1
  27. package/core/src/worker/buffer-sync.ts +20 -13
  28. package/core/src/worker/rpc.ts +119 -5
  29. package/core/src/worker/sim-worker-base.ts +5 -2
  30. package/core/src/worker/task-pool.ts +4 -0
  31. package/dist/.dd-manifest.json +893 -908
  32. package/dist/app/src/renderer/bootstrap.js +0 -4
  33. package/dist/app/src/renderer/feature-log.js +1 -10
  34. package/dist/app/src/renderer/mcp-harness.js +8 -11
  35. package/dist/app/src/renderer/standard-automation-tools.js +6 -138
  36. package/dist/core/src/index.js +2 -2
  37. package/dist/core/src/module/host.js +3 -6
  38. package/dist/core/src/platform/rpc.js +2 -1
  39. package/dist/core/src/render/canvas-resize-watcher.js +1 -10
  40. package/dist/core/src/render/game-renderer.js +166 -74
  41. package/dist/core/src/render/index.js +0 -1
  42. package/dist/core/src/render/input-manager.js +7 -7
  43. package/dist/core/src/render/renderer-module-host.js +2 -4
  44. package/dist/core/src/sab/grid-buffer.js +53 -1
  45. package/dist/core/src/sab/record-buffer.js +49 -5
  46. package/dist/core/src/sab/sim-channel.js +173 -17
  47. package/dist/core/src/sab/slot-buffer.js +54 -1
  48. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  49. package/dist/core/src/ui-font-scale.js +5 -38
  50. package/dist/core/src/util/index.js +1 -0
  51. package/dist/core/src/util/session-timer.js +22 -0
  52. package/dist/core/src/worker/base-worker-host.js +2 -1
  53. package/dist/core/src/worker/buffer-sync.js +16 -9
  54. package/dist/core/src/worker/rpc.js +105 -8
  55. package/dist/core/src/worker/sim-worker-base.js +5 -2
  56. package/dist/core/src/worker/task-pool.js +5 -0
  57. package/dist/libraries/blitz-ui/src/native-module.js +8 -1
  58. package/dist/libraries/devtools/src/backend.js +35 -9
  59. package/dist/libraries/devtools/src/collectors.js +73 -22
  60. package/dist/libraries/devtools/src/native-providers.js +0 -2
  61. package/dist/libraries/entities/src/model-renderer.js +87 -27
  62. package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
  63. package/dist/libraries/networking/src/index.js +1 -0
  64. package/dist/libraries/networking/src/ws-server.js +387 -0
  65. package/dist/libraries/physics-rapier/src/api.js +15 -0
  66. package/dist/libraries/physics-rapier/src/backend.js +15 -0
  67. package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
  68. package/dist/libraries/postfx/src/post-process-stack.js +1 -3
  69. package/dist/libraries/sand/src/materials.js +2 -32
  70. package/dist/libraries/sand/src/sand-world.js +0 -3
  71. package/dist/modules/devtools/src/api.js +8 -23
  72. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  73. package/dist/modules/devtools/src/blitz/host.js +124 -30
  74. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  75. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  76. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  77. package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
  78. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  79. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  80. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  81. package/dist/modules/devtools/src/data-bridge.js +0 -11
  82. package/dist/modules/devtools/src/index.js +0 -6
  83. package/dist/modules/devtools/src/init.js +8 -42
  84. package/dist/modules/devtools/src/scene-inspector.js +1 -4
  85. package/dist/modules/devtools/src/worker-sync.js +3 -11
  86. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  87. package/dist/modules/html-ui/src/host.js +6 -3
  88. package/dist/test/src/vision-test.js +1 -3
  89. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  90. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  91. package/libraries/blitz-ui/src/native-module.ts +8 -1
  92. package/libraries/devtools/src/backend.ts +38 -12
  93. package/libraries/devtools/src/collectors.ts +75 -22
  94. package/libraries/devtools/src/native-providers.ts +0 -2
  95. package/libraries/entities/src/model-renderer.ts +88 -27
  96. package/libraries/entities/src/shaders/model.wgsl +13 -4
  97. package/libraries/networking/src/index.ts +4 -0
  98. package/libraries/networking/src/ws-server.ts +397 -0
  99. package/libraries/physics-rapier/src/api.ts +15 -0
  100. package/libraries/physics-rapier/src/backend.ts +15 -0
  101. package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
  102. package/libraries/postfx/src/post-process-stack.ts +1 -2
  103. package/libraries/sand/src/materials.ts +2 -32
  104. package/libraries/sand/src/sand-world.ts +0 -4
  105. package/modules/devtools/README.md +1 -3
  106. package/modules/devtools/src/api.ts +10 -34
  107. package/modules/devtools/src/blitz/fonts.ts +78 -0
  108. package/modules/devtools/src/blitz/host.ts +107 -31
  109. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  110. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  111. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  112. package/modules/devtools/src/blitz/panels/performance.ts +24 -12
  113. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  114. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  115. package/modules/devtools/src/blitz/theme.ts +26 -4
  116. package/modules/devtools/src/data-bridge.ts +0 -16
  117. package/modules/devtools/src/index.ts +1 -11
  118. package/modules/devtools/src/init.ts +8 -51
  119. package/modules/devtools/src/scene-inspector.ts +2 -6
  120. package/modules/devtools/src/types.ts +5 -90
  121. package/modules/devtools/src/worker-sync.ts +3 -13
  122. package/modules/html-ui/src/doc-backend.ts +67 -4
  123. package/modules/html-ui/src/host.ts +11 -3
  124. package/modules/html-ui/src/protocol.ts +1 -1
  125. package/package.json +4 -5
  126. package/test/src/vision-test.ts +2 -4
  127. package/core/src/render/sab-canvas-overlay.ts +0 -122
  128. package/dist/core/src/render/sab-canvas-overlay.js +0 -83
  129. package/dist/modules/devtools/src/debug-overlay.js +0 -181
  130. package/dist/modules/devtools/src/doctor-panel.js +0 -163
  131. package/dist/modules/devtools/src/label-overlay.js +0 -129
  132. package/dist/modules/devtools/src/material-stats-panel.js +0 -171
  133. package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
  134. package/modules/devtools/src/debug-overlay.ts +0 -238
  135. package/modules/devtools/src/doctor-panel.ts +0 -182
  136. package/modules/devtools/src/label-overlay.ts +0 -154
  137. package/modules/devtools/src/material-stats-panel.ts +0 -188
  138. package/modules/devtools/src/sim-stats-panel.ts +0 -272
@@ -6,7 +6,7 @@
6
6
  // logic through callback hooks.
7
7
  // ============================================================================
8
8
  import { UIInputRouter } from "../input/ui-router.js";
9
- import { getHostCapabilities, getNativeHost } from "../platform/runtime.js";
9
+ import { getNativeHost } from "../platform/runtime.js";
10
10
  import { TelemetryCollector } from "../telemetry/collector.js";
11
11
  import { GPUProfiler } from "../telemetry/gpu-profiler.js";
12
12
  import { GPUResourceTracker } from "../telemetry/gpu-resource-tracker.js";
@@ -42,6 +42,9 @@ export class GameRenderer {
42
42
  uiInputRouter = null;
43
43
  /** Screen-space compositors (html-ui panels etc.) drawn in the UI pass. */
44
44
  uiCompositors = new Set();
45
+ /** uiCompositors as a pre-sorted array — rebuilt on add/remove so the
46
+ * frame loop doesn't spread+sort the set every frame. */
47
+ uiCompositorOrder = [];
45
48
  // Render loop state
46
49
  running = false;
47
50
  deviceLost = false;
@@ -75,11 +78,22 @@ export class GameRenderer {
75
78
  frameCtx = null;
76
79
  // Depth texture cache (used when the graph does not own depth)
77
80
  // LRU: Map insertion order = access order (delete + re-set on access).
81
+ // Numeric key packs (w,h) — a `${w}x${h}` string key allocated a fresh
82
+ // string per lookup per viewport per frame.
78
83
  depthTextures = new Map();
79
84
  depthTextureCacheSize;
80
85
  // Frame stats
81
86
  frameDrawCalls = 0;
82
87
  frameTriangles = 0;
88
+ // Per-frame submit interception: while non-null, queue.submit() calls
89
+ // (game callbacks, render-target providers, helpers) append their buffers
90
+ // here in call order instead of crossing into wgpu — the whole frame then
91
+ // lands in ONE queue.submit at end-of-frame. Games legitimately submit
92
+ // inside afterFrame callbacks (2D renderers, overlays), so folding the UI
93
+ // composite into the main batch requires capturing those submits rather
94
+ // than submitting early or reordering.
95
+ frameSubmitSink = null;
96
+ submitInterceptorDevice = null;
83
97
  // Callbacks
84
98
  callbacks = {};
85
99
  // Offscreen mode (set by game for postprocessing)
@@ -150,6 +164,7 @@ export class GameRenderer {
150
164
  return false;
151
165
  }
152
166
  this.device = borrowedDevice ?? await this.requestDeviceFromAdapter(adapter);
167
+ this.installQueueSubmitInterceptor(this.device);
153
168
  this.adapter = adapter;
154
169
  // The host configured the surface with its own usage set at creation —
155
170
  // reconfiguring here would tear down the swapchain and race the host's
@@ -214,8 +229,16 @@ export class GameRenderer {
214
229
  setViewportCount: (count) => this.setViewportCount(count),
215
230
  getUIInputRouter: () => this.uiInputRouter,
216
231
  registerUiCompositor: (c) => {
232
+ const isNew = !this.uiCompositors.has(c);
217
233
  this.uiCompositors.add(c);
218
- return () => this.uiCompositors.delete(c);
234
+ if (isNew) {
235
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
236
+ }
237
+ return () => {
238
+ if (this.uiCompositors.delete(c)) {
239
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
240
+ }
241
+ };
219
242
  },
220
243
  });
221
244
  // Initial viewport layout
@@ -328,6 +351,7 @@ export class GameRenderer {
328
351
  const device = await this.requestDeviceFromAdapter(adapter);
329
352
  device.lost.then((info) => this.handleDeviceLost(info));
330
353
  this.device = device;
354
+ this.installQueueSubmitInterceptor(device);
331
355
  this.adapter = adapter;
332
356
  installShaderValidationGuard(device);
333
357
  this.gpuResourceTracker = new GPUResourceTracker();
@@ -381,17 +405,15 @@ export class GameRenderer {
381
405
  */
382
406
  setResolutionScale(scale) {
383
407
  this.resolutionScale = Math.max(0.25, Math.min(1, scale));
384
- if (!getHostCapabilities().hasDom) {
385
- // On the native host the swapchain extent must equal the window's
386
- // real size — wgpu compares the configured size against the surface's
387
- // currentExtent at acquire time and returns Outdated forever when
388
- // they differ. Shrinking canvas.width/height would also corrupt
389
- // clientWidth (UI layout + input coords). Resolution scaling needs
390
- // an offscreen-render + upscale path to work here; for now it is a
391
- // no-op rather than a brick.
392
- if (this.resolutionScale < 1) {
393
- log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
394
- }
408
+ // On the native host the swapchain extent must equal the window's
409
+ // real size — wgpu compares the configured size against the surface's
410
+ // currentExtent at acquire time and returns Outdated forever when
411
+ // they differ. Shrinking canvas.width/height would also corrupt
412
+ // clientWidth (UI layout + input coords). Resolution scaling needs
413
+ // an offscreen-render + upscale path to work here; for now it is a
414
+ // no-op rather than a brick.
415
+ if (this.resolutionScale < 1) {
416
+ log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
395
417
  }
396
418
  if (this.lastCssW > 0)
397
419
  this.onResize(this.lastCssW, this.lastCssH, this.lastRawDpr);
@@ -407,7 +429,7 @@ export class GameRenderer {
407
429
  // performance knob when fill rate is a concern. On native the
408
430
  // swapchain is window-sized (see setResolutionScale) so the scale is
409
431
  // never applied to the backing store.
410
- const cappedDpr = dpr * (getHostCapabilities().hasDom ? this.resolutionScale : 1);
432
+ const cappedDpr = dpr;
411
433
  this.dpr = cappedDpr;
412
434
  const w = Math.round(cssWidth * cappedDpr);
413
435
  const h = Math.round(cssHeight * cappedDpr);
@@ -563,14 +585,40 @@ export class GameRenderer {
563
585
  }
564
586
  catch (err) {
565
587
  log.error("game-renderer", `Render loop error: ${err.message}\n${err.stack}`);
566
- if (this.device?.lost) {
567
- this.device.lost.then((info) => {
568
- this.handleDeviceLost(info);
569
- });
570
- }
588
+ // No per-error device.lost.then here: the device already has exactly
589
+ // one lost subscription (init + reinit paths), and on native each
590
+ // .lost access mints a fresh promise — per-error attaches accumulated
591
+ // duplicate watchers during an error storm.
571
592
  }
572
593
  this.scheduleRender();
573
594
  };
595
+ /**
596
+ * Redirect queue.submit() into frameSubmitSink while a frame is in
597
+ * flight. Submission order is preserved — buffers land in the sink in
598
+ * the order submit() was called — so mid-frame submits (uploads in
599
+ * beforeFrame, a game's afterFrame pass, endFrame hooks) still execute
600
+ * ahead of work recorded later, just inside the single end-of-frame
601
+ * queue.submit rather than as extra queue-lock + driver calls.
602
+ */
603
+ installQueueSubmitInterceptor(device) {
604
+ if (this.submitInterceptorDevice === device)
605
+ return;
606
+ this.submitInterceptorDevice = device;
607
+ const queue = device.queue;
608
+ if (queue.__ddSubmitIntercepted)
609
+ return;
610
+ queue.__ddSubmitIntercepted = true;
611
+ const origSubmit = queue.submit.bind(queue);
612
+ queue.submit = (commandBuffers) => {
613
+ const sink = this.frameSubmitSink;
614
+ if (!sink) {
615
+ origSubmit(commandBuffers);
616
+ return;
617
+ }
618
+ for (const cb of commandBuffers)
619
+ sink.push(cb);
620
+ };
621
+ }
574
622
  renderFrame() {
575
623
  const now = performance.now();
576
624
  // Frame rate limiter: phase accumulator (skip during XR — XR drives its own cadence)
@@ -615,6 +663,14 @@ export class GameRenderer {
615
663
  // simulation stays responsive. The flag is cleared by recovery code that
616
664
  // recreates the invalid resources.
617
665
  const gpuError = this.gpuProfiler?.hasUncapturedError() ?? false;
666
+ // Collect command buffers from all phases and submit once at the end
667
+ // of the frame — one queue.submit per frame instead of 2-4. The sink
668
+ // is armed BEFORE beginFrame so submit() calls from hooks and game
669
+ // callbacks (beforeFrame uploads, afterFrame render passes, the XR
670
+ // endFrame encoder) are captured in call order into the same batch
671
+ // rather than crossing into wgpu individually.
672
+ const frameCommandBuffers = [];
673
+ this.frameSubmitSink = frameCommandBuffers;
618
674
  // XR render target provider beginFrame hook
619
675
  if (this.renderTargetProvider && !gpuError) {
620
676
  this.renderTargetProvider.beginFrame();
@@ -625,9 +681,6 @@ export class GameRenderer {
625
681
  if (offscreen && useOffscreen && offscreen.ensureTargets && !gpuError) {
626
682
  offscreen.ensureTargets(this.canvas.width, this.canvas.height);
627
683
  }
628
- // Collect command buffers from all phases and submit once at the end
629
- // (reduces CPU→GPU sync points from 3+ per frame to 1).
630
- const frameCommandBuffers = [];
631
684
  if (!gpuError) {
632
685
  // 2D mode with viewportCount=0: clear the canvas if a clearColor is
633
686
  // configured, then let the afterFrame callback do custom rendering.
@@ -674,19 +727,6 @@ export class GameRenderer {
674
727
  if (passMailbox) {
675
728
  frameCommandBuffers.push(...passMailbox.drain("preSubmit"));
676
729
  }
677
- // Submit all command buffers for this frame in a single queue.submit() call
678
- if (frameCommandBuffers.length > 0) {
679
- this.device.queue.submit(frameCommandBuffers);
680
- }
681
- // Read GPU timer results asynchronously (1-frame latency).
682
- // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
683
- // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
684
- // submitted command buffer (the copy-to-readBuffer was encoded above).
685
- if (this.gpuProfiler) {
686
- this.gpuProfiler.readGpuTimers().then(() => {
687
- // Results available for next frame
688
- }).catch(() => { });
689
- }
690
730
  // Record telemetry
691
731
  if (this.telemetryCollector) {
692
732
  this.telemetryCollector.recordFrame(dt * 1000);
@@ -721,42 +761,72 @@ export class GameRenderer {
721
761
  this.frameDrawCalls = 0;
722
762
  this.frameTriangles = 0;
723
763
  }
724
- // XR render target provider endFrame hook (skip on GPU error)
725
- if (!gpuError && this.renderTargetProvider && this.device) {
726
- this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
764
+ try {
765
+ // XR render target provider endFrame hook (skip on GPU error). Its
766
+ // submits are captured by frameSubmitSink and join the batch.
767
+ if (!gpuError && this.renderTargetProvider && this.device) {
768
+ this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
769
+ }
770
+ // After frame callback — game submits here append to the batch in
771
+ // order, still ahead of the UI composite below.
772
+ this.callbacks.afterFrame?.(dt, this.elapsedTime);
773
+ this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
774
+ // Composite registered screen-space UI (html-ui panels) on top of the
775
+ // final image (skip on GPU error to avoid cascade). This must run AFTER
776
+ // afterFrame — 2D games (viewportCount=0) do their custom rendering in
777
+ // the afterFrame callback, so drawing UI earlier would put it underneath
778
+ // their clear pass. The texture comes from peek: a frame the game
779
+ // skipped must not be started just to draw UI on a blank swapchain.
780
+ if (!gpuError && this.device && this.context) {
781
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
782
+ const uiCanvasView = compositors.length > 0
783
+ ? this.getFrameSurfaceTexture()?.createView()
784
+ : undefined;
785
+ if (uiCanvasView) {
786
+ const uiEncoder = this.device.createCommandEncoder();
787
+ const uiPass = uiEncoder.beginRenderPass({
788
+ colorAttachments: [{
789
+ view: uiCanvasView,
790
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
791
+ loadOp: "load",
792
+ storeOp: "store",
793
+ }],
794
+ });
795
+ // Panel rects are CSS px — pass the CSS surface size so
796
+ // rect/surface maps correctly on HiDPI displays (canvas.width is
797
+ // physical px; identical where scaleFactor=1).
798
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
799
+ uiPass.end();
800
+ // "preUi" slot — worker passes ordered ahead of the UI-composite
801
+ // buffer inside the end-of-frame submit.
802
+ frameCommandBuffers.push(...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish());
803
+ }
804
+ }
727
805
  }
728
- // After frame callback
729
- this.callbacks.afterFrame?.(dt, this.elapsedTime);
730
- this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
731
- // Composite registered screen-space UI (html-ui panels) on top of the
732
- // final image (skip on GPU error to avoid cascade). This must run AFTER
733
- // afterFrame — 2D games (viewportCount=0) do their custom rendering in
734
- // the afterFrame callback, so drawing UI earlier would put it underneath
735
- // their clear pass.
736
- if (!gpuError && this.device && this.context) {
737
- const compositors = [...this.uiCompositors]
738
- .filter((c) => c.hasContent())
739
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
740
- const uiCanvasView = compositors.length > 0
741
- ? this.getSurfaceTexture()?.createView()
742
- : undefined;
743
- if (uiCanvasView) {
744
- const uiEncoder = this.device.createCommandEncoder();
745
- const uiPass = uiEncoder.beginRenderPass({
746
- colorAttachments: [{
747
- view: uiCanvasView,
748
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
749
- loadOp: "load",
750
- storeOp: "store",
751
- }],
752
- });
753
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
754
- uiPass.end();
755
- // "preUi" slot — worker passes ordered ahead of the UI-composite
756
- // submit so their output is visible to the compositor this frame.
757
- this.device.queue.submit([...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish()]);
806
+ finally {
807
+ // End of frame: disarm the submit sink and flush everything recorded
808
+ // this frame — engine passes, captured callback submits, mailbox
809
+ // passes, the UI composite — in a single queue.submit(). Runs even on
810
+ // a callback throw so already-recorded work still lands.
811
+ this.frameSubmitSink = null;
812
+ if (frameCommandBuffers.length > 0 && this.device) {
813
+ try {
814
+ this.device.queue.submit(frameCommandBuffers);
815
+ }
816
+ catch (err) {
817
+ log.error("game-renderer", `queue.submit failed: ${err.message ?? err}`);
818
+ }
758
819
  }
759
820
  }
821
+ // Read GPU timer results asynchronously (1-frame latency).
822
+ // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
823
+ // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
824
+ // submitted command buffer (the copy-to-readBuffer was encoded above).
825
+ if (this.gpuProfiler) {
826
+ this.gpuProfiler.readGpuTimers().then(() => {
827
+ // Results available for next frame
828
+ }).catch(() => { });
829
+ }
760
830
  // Present the surface (native wgpu requires explicit presentation;
761
831
  // in browsers this is automatic at the end of the frame). Must be last —
762
832
  // all submissions targeting the surface texture happen above.
@@ -785,6 +855,29 @@ export class GameRenderer {
785
855
  return null;
786
856
  }
787
857
  }
858
+ /**
859
+ * The surface texture already acquired for the in-flight frame, or null
860
+ * when nothing has acquired one yet. End-of-frame overlays (the UI
861
+ * composite) use this so they join a frame rather than start one: on
862
+ * hosts with frame retention (native), acquiring here would fabricate a
863
+ * frame the game deliberately skipped (dirty-tracking) and present the
864
+ * overlay over a blank swapchain image — a full-screen flash. Falls
865
+ * back to acquiring on contexts without peek (DOM semantics:
866
+ * getCurrentTexture is idempotent within a frame and present is
867
+ * implicit, so there is nothing to defeat).
868
+ */
869
+ getFrameSurfaceTexture() {
870
+ const peek = this.context?.peekCurrentTexture;
871
+ if (typeof peek === "function") {
872
+ try {
873
+ return peek.call(this.context) ?? null;
874
+ }
875
+ catch {
876
+ return null;
877
+ }
878
+ }
879
+ return this.getSurfaceTexture();
880
+ }
788
881
  renderViewport(viewportIdx, dt, offscreen) {
789
882
  if (!this.device || !this.context)
790
883
  return null;
@@ -951,7 +1044,7 @@ export class GameRenderer {
951
1044
  createDepthTexture(w, h) {
952
1045
  if (!this.device)
953
1046
  throw new Error("No device");
954
- const key = `${w}x${h}`;
1047
+ const key = w * 65536 + h;
955
1048
  let tex = this.depthTextures.get(key);
956
1049
  if (tex) {
957
1050
  // LRU: move to most-recently-used by re-inserting.
@@ -1030,12 +1123,10 @@ export class GameRenderer {
1030
1123
  * this helper only runs the compositors.
1031
1124
  */
1032
1125
  renderScreenUiCompositors() {
1033
- const compositors = [...this.uiCompositors]
1034
- .filter((c) => c.hasContent())
1035
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1126
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1036
1127
  if (compositors.length === 0 || !this.device || !this.context)
1037
1128
  return;
1038
- const canvasView = this.getSurfaceTexture()?.createView();
1129
+ const canvasView = this.getFrameSurfaceTexture()?.createView();
1039
1130
  if (!canvasView)
1040
1131
  return;
1041
1132
  const uiEncoder = this.device.createCommandEncoder();
@@ -1047,7 +1138,8 @@ export class GameRenderer {
1047
1138
  storeOp: "store",
1048
1139
  }],
1049
1140
  });
1050
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1141
+ // Panel rects are CSS px — see the main UI composite block above.
1142
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
1051
1143
  uiPass.end();
1052
1144
  this.device.queue.submit([uiEncoder.finish()]);
1053
1145
  }
@@ -57,7 +57,6 @@ export { PipelineCache } from "./pipeline.js";
57
57
  export { RenderPass } from "./render-pass.js";
58
58
  export { RendererInputBusImpl } from "./renderer-input-bus.js";
59
59
  export { RendererModuleHost } from "./renderer-module-host.js";
60
- export { SabCanvasOverlay } from "./sab-canvas-overlay.js";
61
60
  export { clearShaderValidationDedup, createValidatedShaderModule, createValidatedShaderModuleAsync, installShaderValidationGuard } from "./shader-validator.js";
62
61
  export { CSM_SHADER_CHUNK, POINT_SHADOW_SHADER_CHUNK, SPOT_SHADOW_SHADER_CHUNK } from "./shaders/shadow-chunks.js";
63
62
  export { SkyboxRenderer } from "./skybox.js";
@@ -58,9 +58,9 @@ export class InputManager {
58
58
  this.tryLockPointer();
59
59
  }
60
60
  }));
61
- add(document, "pointerlockchange", (() => {
61
+ add(this.canvas, "pointerlockchange", (() => {
62
62
  const wasLocked = this.pointerLocked;
63
- this.pointerLocked = document.pointerLockElement === this.canvas;
63
+ this.pointerLocked = this.canvas.pointerLocked === true;
64
64
  if (this.pointerLocked) {
65
65
  this.pointerLockRetryCount = 0;
66
66
  if (this.pointerLockRetryTimer) {
@@ -76,7 +76,7 @@ export class InputManager {
76
76
  this.mouseDelta.dy = 0;
77
77
  }
78
78
  }));
79
- add(document, "pointerlockerror", (() => {
79
+ add(this.canvas, "pointerlockerror", (() => {
80
80
  // No action needed — tryLockPointer's fallback timer will retry.
81
81
  }));
82
82
  }
@@ -150,10 +150,10 @@ export class InputManager {
150
150
  e.preventDefault();
151
151
  }
152
152
  }), { passive: false });
153
- // Use document-level mouseleave: a sibling canvas stacked above (e.g.
154
- // sandjongg's tile canvas) would otherwise trigger the canvas's own
155
- // mouseleave and break UI hover/pressed state.
156
- add(document, "mouseleave", (() => {
153
+ // Window-level mouseleave: a second interactive canvas stacked above
154
+ // (e.g. sandjongg's tile canvas) would otherwise trigger the canvas's
155
+ // own mouseleave and break UI hover/pressed state.
156
+ add(window, "mouseleave", (() => {
157
157
  this.uiInputRouter?.handlePointerLeave();
158
158
  }));
159
159
  add(this.canvas, "contextmenu", ((e) => {
@@ -50,8 +50,8 @@ export class RendererModuleHost {
50
50
  this.inputBus = new RendererInputBusImpl(canvas);
51
51
  }
52
52
  /**
53
- * Inject the DevTools API so renderer plugins can self-register debug
54
- * panels via `ctx.devtools.registerPanel(...)` during their `register()`.
53
+ * Inject the DevTools API so renderer plugins can self-register data
54
+ * feeds via `ctx.devtools.registerDataFeed(...)` during their `register()`.
55
55
  */
56
56
  setDevToolsAPI(api) {
57
57
  this._devtools = api;
@@ -531,8 +531,6 @@ export class RendererModuleHost {
531
531
  }
532
532
  // No-op DevTools API stub — used when setDevToolsAPI() hasn't been called.
533
533
  const NoopRendererDevToolsAPI = {
534
- registerPanel: () => { },
535
- registerOverlayToggle: () => { },
536
534
  registerDataFeed: () => { },
537
535
  registerCommand: () => { },
538
536
  registerSABStat: () => { },
@@ -46,6 +46,31 @@ export class GridReader {
46
46
  hasChanged(lastSeen) {
47
47
  return this.getSequence() !== lastSeen;
48
48
  }
49
+ /**
50
+ * True while the writer holds an open write window (sequence is odd).
51
+ * Readers can use this to cheaply defer sync work past a publish.
52
+ */
53
+ isWriteInProgress() {
54
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
55
+ }
56
+ /**
57
+ * Run `fn` against a consistent snapshot of the channel. The sequence
58
+ * word is a seqlock: odd while the writer is publishing, even when the
59
+ * data is stable. The read is retried while the word is odd or changes
60
+ * mid-read; after maxRetries it returns whatever the last attempt read
61
+ * rather than starving the reader (writers publish every tick).
62
+ */
63
+ readConsistent(fn, maxRetries = 64) {
64
+ for (let i = 0; i < maxRetries; i++) {
65
+ const s1 = Atomics.load(this.seqArr, 0);
66
+ if ((s1 & 1) === 0) {
67
+ const out = fn();
68
+ if (Atomics.load(this.seqArr, 0) === s1)
69
+ return out;
70
+ }
71
+ }
72
+ return fn();
73
+ }
49
74
  snapshot() {
50
75
  const data = {};
51
76
  data["sequence"] = this.getSequence();
@@ -67,6 +92,8 @@ export class GridWriter {
67
92
  layers;
68
93
  seqArr;
69
94
  layout;
95
+ /** True while a beginWrite() window is open (sequence is odd). */
96
+ writeOpen = false;
70
97
  constructor(sab, layout) {
71
98
  this.layout = layout;
72
99
  this.header = createHeaderViews(sab, layout.header.size);
@@ -82,9 +109,34 @@ export class GridWriter {
82
109
  this.header.u32[this.layout.header.magicIndex] = this.layout.magic;
83
110
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
84
111
  }
85
- bumpSequence() {
112
+ /**
113
+ * Open a write window — marks the sequence word odd so readers know a
114
+ * publish is in progress. Idempotent so mutating paths can call it
115
+ * unconditionally.
116
+ */
117
+ beginWrite() {
118
+ if (this.writeOpen)
119
+ return;
120
+ this.writeOpen = true;
86
121
  Atomics.add(this.seqArr, 0, 1);
87
122
  }
123
+ /** Close the write window opened by beginWrite() — sequence back to even. */
124
+ endWrite() {
125
+ if (!this.writeOpen)
126
+ return;
127
+ this.writeOpen = false;
128
+ Atomics.add(this.seqArr, 0, 1);
129
+ }
130
+ /**
131
+ * Publish a commit. Without an open write window it steps +2 (even→even)
132
+ * so the sequence always lands on a stable value while still advancing
133
+ * once per publish; inside a window it closes it (+1, odd→even).
134
+ */
135
+ bumpSequence() {
136
+ const step = this.writeOpen ? 1 : 2;
137
+ this.writeOpen = false;
138
+ Atomics.add(this.seqArr, 0, step);
139
+ }
88
140
  getSequence() {
89
141
  return Atomics.load(this.seqArr, 0);
90
142
  }
@@ -47,6 +47,31 @@ export class RecordReader {
47
47
  hasChanged(lastSeen) {
48
48
  return this.getSequence() !== lastSeen;
49
49
  }
50
+ /**
51
+ * True while the writer holds an open write window (sequence is odd).
52
+ * Readers can use this to cheaply defer sync work past a publish.
53
+ */
54
+ isWriteInProgress() {
55
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
56
+ }
57
+ /**
58
+ * Run `fn` against a consistent snapshot of the channel. The sequence
59
+ * word is a seqlock: odd while the writer is publishing, even when the
60
+ * data is stable. The read is retried while the word is odd or changes
61
+ * mid-read; after maxRetries it returns whatever the last attempt read
62
+ * rather than starving the reader (writers publish every tick).
63
+ */
64
+ readConsistent(fn, maxRetries = 64) {
65
+ for (let i = 0; i < maxRetries; i++) {
66
+ const s1 = Atomics.load(this.seqArr, 0);
67
+ if ((s1 & 1) === 0) {
68
+ const out = fn();
69
+ if (Atomics.load(this.seqArr, 0) === s1)
70
+ return out;
71
+ }
72
+ }
73
+ return fn();
74
+ }
50
75
  snapshot() {
51
76
  const data = {};
52
77
  if (this.layout.fields) {
@@ -73,6 +98,8 @@ export class RecordWriter {
73
98
  fields;
74
99
  seqArr;
75
100
  layout;
101
+ /** True while a beginWrite() window is open (sequence is odd). */
102
+ writeOpen = false;
76
103
  constructor(sab, layout) {
77
104
  this.layout = layout;
78
105
  this.header = createHeaderViews(sab, layout.header.size);
@@ -88,16 +115,33 @@ export class RecordWriter {
88
115
  this.header.u32[this.layout.header.magicIndex] = this.layout.magic;
89
116
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
90
117
  }
118
+ /**
119
+ * Open a write window — marks the sequence word odd so readers know a
120
+ * publish is in progress. Idempotent so mutating paths can call it
121
+ * unconditionally.
122
+ */
91
123
  beginWrite() {
92
- const seq = Atomics.load(this.seqArr, 0);
93
- Atomics.store(this.seqArr, 0, seq + 1);
124
+ if (this.writeOpen)
125
+ return;
126
+ this.writeOpen = true;
127
+ Atomics.add(this.seqArr, 0, 1);
94
128
  }
129
+ /** Close the write window opened by beginWrite() — sequence back to even. */
95
130
  endWrite() {
96
- // Sequence is incremented in beginWrite() — endWrite() is a no-op
97
- // to avoid double incrementing.
131
+ if (!this.writeOpen)
132
+ return;
133
+ this.writeOpen = false;
134
+ Atomics.add(this.seqArr, 0, 1);
98
135
  }
136
+ /**
137
+ * Publish a commit. Without an open write window it steps +2 (even→even)
138
+ * so the sequence always lands on a stable value while still advancing
139
+ * once per publish; inside a window it closes it (+1, odd→even).
140
+ */
99
141
  bumpSequence() {
100
- Atomics.add(this.seqArr, 0, 1);
142
+ const step = this.writeOpen ? 1 : 2;
143
+ this.writeOpen = false;
144
+ Atomics.add(this.seqArr, 0, step);
101
145
  }
102
146
  getSequence() {
103
147
  return Atomics.load(this.seqArr, 0);