@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
@@ -9,7 +9,7 @@
9
9
  import { UIInputRouter } from "../input/ui-router";
10
10
  import type { RendererModule } from "../module/renderer-module";
11
11
  import type { RenderSurface, RenderSurfaceContext } from "../platform/render-surface";
12
- import { getHostCapabilities, getNativeHost } from "../platform/runtime";
12
+ import { getNativeHost } from "../platform/runtime";
13
13
  import { TelemetryCollector } from "../telemetry/collector";
14
14
  import type { GPUAdapterInfo as GPUAdapterInfoData } from "../telemetry/gpu-profiler";
15
15
  import { GPUProfiler, type FrameGraphData, type GPUInfo } from "../telemetry/gpu-profiler";
@@ -233,6 +233,9 @@ export class GameRenderer implements CanvasResizeHandler {
233
233
  uiInputRouter: UIInputRouter | null = null;
234
234
  /** Screen-space compositors (html-ui panels etc.) drawn in the UI pass. */
235
235
  private uiCompositors = new Set<import("../module/renderer-module").ScreenUiCompositor>();
236
+ /** uiCompositors as a pre-sorted array — rebuilt on add/remove so the
237
+ * frame loop doesn't spread+sort the set every frame. */
238
+ private uiCompositorOrder: import("../module/renderer-module").ScreenUiCompositor[] = [];
236
239
 
237
240
  // Render loop state
238
241
  private running = false;
@@ -272,13 +275,25 @@ export class GameRenderer implements CanvasResizeHandler {
272
275
 
273
276
  // Depth texture cache (used when the graph does not own depth)
274
277
  // LRU: Map insertion order = access order (delete + re-set on access).
275
- private depthTextures = new Map<string, GPUTexture>();
278
+ // Numeric key packs (w,h) — a `${w}x${h}` string key allocated a fresh
279
+ // string per lookup per viewport per frame.
280
+ private depthTextures = new Map<number, GPUTexture>();
276
281
  private depthTextureCacheSize: number;
277
282
 
278
283
  // Frame stats
279
284
  private frameDrawCalls = 0;
280
285
  private frameTriangles = 0;
281
286
 
287
+ // Per-frame submit interception: while non-null, queue.submit() calls
288
+ // (game callbacks, render-target providers, helpers) append their buffers
289
+ // here in call order instead of crossing into wgpu — the whole frame then
290
+ // lands in ONE queue.submit at end-of-frame. Games legitimately submit
291
+ // inside afterFrame callbacks (2D renderers, overlays), so folding the UI
292
+ // composite into the main batch requires capturing those submits rather
293
+ // than submitting early or reordering.
294
+ private frameSubmitSink: GPUCommandBuffer[] | null = null;
295
+ private submitInterceptorDevice: GPUDevice | null = null;
296
+
282
297
  // Callbacks
283
298
  private callbacks: FrameCallbacks = {};
284
299
 
@@ -354,6 +369,7 @@ export class GameRenderer implements CanvasResizeHandler {
354
369
  return false;
355
370
  }
356
371
  this.device = borrowedDevice ?? await this.requestDeviceFromAdapter(adapter!);
372
+ this.installQueueSubmitInterceptor(this.device);
357
373
  this.adapter = adapter;
358
374
  // The host configured the surface with its own usage set at creation —
359
375
  // reconfiguring here would tear down the swapchain and race the host's
@@ -426,8 +442,16 @@ export class GameRenderer implements CanvasResizeHandler {
426
442
  setViewportCount: (count) => this.setViewportCount(count),
427
443
  getUIInputRouter: () => this.uiInputRouter,
428
444
  registerUiCompositor: (c) => {
445
+ const isNew = !this.uiCompositors.has(c);
429
446
  this.uiCompositors.add(c);
430
- return () => this.uiCompositors.delete(c);
447
+ if (isNew) {
448
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
449
+ }
450
+ return () => {
451
+ if (this.uiCompositors.delete(c)) {
452
+ this.uiCompositorOrder = [...this.uiCompositors].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
453
+ }
454
+ };
431
455
  },
432
456
  });
433
457
 
@@ -543,6 +567,7 @@ export class GameRenderer implements CanvasResizeHandler {
543
567
  const device = await this.requestDeviceFromAdapter(adapter);
544
568
  device.lost.then((info) => this.handleDeviceLost(info));
545
569
  this.device = device;
570
+ this.installQueueSubmitInterceptor(device);
546
571
  this.adapter = adapter;
547
572
 
548
573
  installShaderValidationGuard(device);
@@ -605,17 +630,15 @@ export class GameRenderer implements CanvasResizeHandler {
605
630
  */
606
631
  setResolutionScale(scale: number): void {
607
632
  this.resolutionScale = Math.max(0.25, Math.min(1, scale));
608
- if (!getHostCapabilities().hasDom) {
609
- // On the native host the swapchain extent must equal the window's
610
- // real size — wgpu compares the configured size against the surface's
611
- // currentExtent at acquire time and returns Outdated forever when
612
- // they differ. Shrinking canvas.width/height would also corrupt
613
- // clientWidth (UI layout + input coords). Resolution scaling needs
614
- // an offscreen-render + upscale path to work here; for now it is a
615
- // no-op rather than a brick.
616
- if (this.resolutionScale < 1) {
617
- log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
618
- }
633
+ // On the native host the swapchain extent must equal the window's
634
+ // real size — wgpu compares the configured size against the surface's
635
+ // currentExtent at acquire time and returns Outdated forever when
636
+ // they differ. Shrinking canvas.width/height would also corrupt
637
+ // clientWidth (UI layout + input coords). Resolution scaling needs
638
+ // an offscreen-render + upscale path to work here; for now it is a
639
+ // no-op rather than a brick.
640
+ if (this.resolutionScale < 1) {
641
+ log.warn("game-renderer", "resolutionScale is not supported on the native surface (swapchain must match the window size) — ignoring");
619
642
  }
620
643
  if (this.lastCssW > 0) this.onResize(this.lastCssW, this.lastCssH, this.lastRawDpr);
621
644
  }
@@ -632,7 +655,7 @@ export class GameRenderer implements CanvasResizeHandler {
632
655
  // performance knob when fill rate is a concern. On native the
633
656
  // swapchain is window-sized (see setResolutionScale) so the scale is
634
657
  // never applied to the backing store.
635
- const cappedDpr = dpr * (getHostCapabilities().hasDom ? this.resolutionScale : 1);
658
+ const cappedDpr = dpr;
636
659
  this.dpr = cappedDpr;
637
660
  const w = Math.round(cssWidth * cappedDpr);
638
661
  const h = Math.round(cssHeight * cappedDpr);
@@ -798,15 +821,39 @@ export class GameRenderer implements CanvasResizeHandler {
798
821
  this.renderFrame();
799
822
  } catch (err) {
800
823
  log.error("game-renderer", `Render loop error: ${(err as Error).message}\n${(err as Error).stack}`);
801
- if (this.device?.lost) {
802
- this.device.lost.then((info: GPUDeviceLostInfo) => {
803
- this.handleDeviceLost(info);
804
- });
805
- }
824
+ // No per-error device.lost.then here: the device already has exactly
825
+ // one lost subscription (init + reinit paths), and on native each
826
+ // .lost access mints a fresh promise — per-error attaches accumulated
827
+ // duplicate watchers during an error storm.
806
828
  }
807
829
  this.scheduleRender();
808
830
  };
809
831
 
832
+ /**
833
+ * Redirect queue.submit() into frameSubmitSink while a frame is in
834
+ * flight. Submission order is preserved — buffers land in the sink in
835
+ * the order submit() was called — so mid-frame submits (uploads in
836
+ * beforeFrame, a game's afterFrame pass, endFrame hooks) still execute
837
+ * ahead of work recorded later, just inside the single end-of-frame
838
+ * queue.submit rather than as extra queue-lock + driver calls.
839
+ */
840
+ private installQueueSubmitInterceptor(device: GPUDevice): void {
841
+ if (this.submitInterceptorDevice === device) return;
842
+ this.submitInterceptorDevice = device;
843
+ const queue = device.queue as any;
844
+ if (queue.__ddSubmitIntercepted) return;
845
+ queue.__ddSubmitIntercepted = true;
846
+ const origSubmit: GPUQueue["submit"] = queue.submit.bind(queue);
847
+ queue.submit = (commandBuffers: Iterable<GPUCommandBuffer>) => {
848
+ const sink = this.frameSubmitSink;
849
+ if (!sink) {
850
+ origSubmit(commandBuffers);
851
+ return;
852
+ }
853
+ for (const cb of commandBuffers) sink.push(cb);
854
+ };
855
+ }
856
+
810
857
  private renderFrame(): void {
811
858
  const now = performance.now();
812
859
 
@@ -856,6 +903,15 @@ export class GameRenderer implements CanvasResizeHandler {
856
903
  // recreates the invalid resources.
857
904
  const gpuError = this.gpuProfiler?.hasUncapturedError() ?? false;
858
905
 
906
+ // Collect command buffers from all phases and submit once at the end
907
+ // of the frame — one queue.submit per frame instead of 2-4. The sink
908
+ // is armed BEFORE beginFrame so submit() calls from hooks and game
909
+ // callbacks (beforeFrame uploads, afterFrame render passes, the XR
910
+ // endFrame encoder) are captured in call order into the same batch
911
+ // rather than crossing into wgpu individually.
912
+ const frameCommandBuffers: GPUCommandBuffer[] = [];
913
+ this.frameSubmitSink = frameCommandBuffers;
914
+
859
915
  // XR render target provider beginFrame hook
860
916
  if (this.renderTargetProvider && !gpuError) {
861
917
  this.renderTargetProvider.beginFrame();
@@ -869,9 +925,6 @@ export class GameRenderer implements CanvasResizeHandler {
869
925
  offscreen.ensureTargets(this.canvas.width, this.canvas.height);
870
926
  }
871
927
 
872
- // Collect command buffers from all phases and submit once at the end
873
- // (reduces CPU→GPU sync points from 3+ per frame to 1).
874
- const frameCommandBuffers: GPUCommandBuffer[] = [];
875
928
  if (!gpuError) {
876
929
  // 2D mode with viewportCount=0: clear the canvas if a clearColor is
877
930
  // configured, then let the afterFrame callback do custom rendering.
@@ -922,21 +975,6 @@ export class GameRenderer implements CanvasResizeHandler {
922
975
  frameCommandBuffers.push(...passMailbox.drain("preSubmit"));
923
976
  }
924
977
 
925
- // Submit all command buffers for this frame in a single queue.submit() call
926
- if (frameCommandBuffers.length > 0) {
927
- this.device!.queue.submit(frameCommandBuffers);
928
- }
929
-
930
- // Read GPU timer results asynchronously (1-frame latency).
931
- // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
932
- // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
933
- // submitted command buffer (the copy-to-readBuffer was encoded above).
934
- if (this.gpuProfiler) {
935
- this.gpuProfiler.readGpuTimers().then(() => {
936
- // Results available for next frame
937
- }).catch(() => {});
938
- }
939
-
940
978
  // Record telemetry
941
979
  if (this.telemetryCollector) {
942
980
  this.telemetryCollector.recordFrame(dt * 1000);
@@ -975,43 +1013,72 @@ export class GameRenderer implements CanvasResizeHandler {
975
1013
  this.frameTriangles = 0;
976
1014
  }
977
1015
 
978
- // XR render target provider endFrame hook (skip on GPU error)
979
- if (!gpuError && this.renderTargetProvider && this.device) {
980
- this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
981
- }
1016
+ try {
1017
+ // XR render target provider endFrame hook (skip on GPU error). Its
1018
+ // submits are captured by frameSubmitSink and join the batch.
1019
+ if (!gpuError && this.renderTargetProvider && this.device) {
1020
+ this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
1021
+ }
982
1022
 
983
- // After frame callback
984
- this.callbacks.afterFrame?.(dt, this.elapsedTime);
985
- this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
986
-
987
- // Composite registered screen-space UI (html-ui panels) on top of the
988
- // final image (skip on GPU error to avoid cascade). This must run AFTER
989
- // afterFrame — 2D games (viewportCount=0) do their custom rendering in
990
- // the afterFrame callback, so drawing UI earlier would put it underneath
991
- // their clear pass.
992
- if (!gpuError && this.device && this.context) {
993
- const compositors = [...this.uiCompositors]
994
- .filter((c) => c.hasContent())
995
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
996
- const uiCanvasView = compositors.length > 0
997
- ? this.getSurfaceTexture()?.createView()
998
- : undefined;
999
- if (uiCanvasView) {
1000
- const uiEncoder = this.device.createCommandEncoder();
1001
- const uiPass = uiEncoder.beginRenderPass({
1002
- colorAttachments: [{
1003
- view: uiCanvasView,
1004
- clearValue: { r: 0, g: 0, b: 0, a: 0 },
1005
- loadOp: "load" as GPULoadOp,
1006
- storeOp: "store" as GPUStoreOp,
1007
- }],
1008
- });
1009
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1010
- uiPass.end();
1011
- // "preUi" slot — worker passes ordered ahead of the UI-composite
1012
- // submit so their output is visible to the compositor this frame.
1013
- this.device.queue.submit([...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish()]);
1023
+ // After frame callback — game submits here append to the batch in
1024
+ // order, still ahead of the UI composite below.
1025
+ this.callbacks.afterFrame?.(dt, this.elapsedTime);
1026
+ this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
1027
+
1028
+ // Composite registered screen-space UI (html-ui panels) on top of the
1029
+ // final image (skip on GPU error to avoid cascade). This must run AFTER
1030
+ // afterFrame — 2D games (viewportCount=0) do their custom rendering in
1031
+ // the afterFrame callback, so drawing UI earlier would put it underneath
1032
+ // their clear pass. The texture comes from peek: a frame the game
1033
+ // skipped must not be started just to draw UI on a blank swapchain.
1034
+ if (!gpuError && this.device && this.context) {
1035
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1036
+ const uiCanvasView = compositors.length > 0
1037
+ ? this.getFrameSurfaceTexture()?.createView()
1038
+ : undefined;
1039
+ if (uiCanvasView) {
1040
+ const uiEncoder = this.device.createCommandEncoder();
1041
+ const uiPass = uiEncoder.beginRenderPass({
1042
+ colorAttachments: [{
1043
+ view: uiCanvasView,
1044
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
1045
+ loadOp: "load" as GPULoadOp,
1046
+ storeOp: "store" as GPUStoreOp,
1047
+ }],
1048
+ });
1049
+ // Panel rects are CSS px — pass the CSS surface size so
1050
+ // rect/surface maps correctly on HiDPI displays (canvas.width is
1051
+ // physical px; identical where scaleFactor=1).
1052
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
1053
+ uiPass.end();
1054
+ // "preUi" slot — worker passes ordered ahead of the UI-composite
1055
+ // buffer inside the end-of-frame submit.
1056
+ frameCommandBuffers.push(...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish());
1057
+ }
1014
1058
  }
1059
+ } finally {
1060
+ // End of frame: disarm the submit sink and flush everything recorded
1061
+ // this frame — engine passes, captured callback submits, mailbox
1062
+ // passes, the UI composite — in a single queue.submit(). Runs even on
1063
+ // a callback throw so already-recorded work still lands.
1064
+ this.frameSubmitSink = null;
1065
+ if (frameCommandBuffers.length > 0 && this.device) {
1066
+ try {
1067
+ this.device.queue.submit(frameCommandBuffers);
1068
+ } catch (err) {
1069
+ log.error("game-renderer", `queue.submit failed: ${(err as Error).message ?? err}`);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ // Read GPU timer results asynchronously (1-frame latency).
1075
+ // Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
1076
+ // the read buffer, and a mapped/mapping-pending buffer cannot be used in a
1077
+ // submitted command buffer (the copy-to-readBuffer was encoded above).
1078
+ if (this.gpuProfiler) {
1079
+ this.gpuProfiler.readGpuTimers().then(() => {
1080
+ // Results available for next frame
1081
+ }).catch(() => {});
1015
1082
  }
1016
1083
 
1017
1084
  // Present the surface (native wgpu requires explicit presentation;
@@ -1043,6 +1110,25 @@ export class GameRenderer implements CanvasResizeHandler {
1043
1110
  }
1044
1111
  }
1045
1112
 
1113
+ /**
1114
+ * The surface texture already acquired for the in-flight frame, or null
1115
+ * when nothing has acquired one yet. End-of-frame overlays (the UI
1116
+ * composite) use this so they join a frame rather than start one: on
1117
+ * hosts with frame retention (native), acquiring here would fabricate a
1118
+ * frame the game deliberately skipped (dirty-tracking) and present the
1119
+ * overlay over a blank swapchain image — a full-screen flash. Falls
1120
+ * back to acquiring on contexts without peek (DOM semantics:
1121
+ * getCurrentTexture is idempotent within a frame and present is
1122
+ * implicit, so there is nothing to defeat).
1123
+ */
1124
+ private getFrameSurfaceTexture(): GPUTexture | null {
1125
+ const peek = (this.context as { peekCurrentTexture?: () => GPUTexture | null } | null)?.peekCurrentTexture;
1126
+ if (typeof peek === "function") {
1127
+ try { return peek.call(this.context) ?? null; } catch { return null; }
1128
+ }
1129
+ return this.getSurfaceTexture();
1130
+ }
1131
+
1046
1132
  private renderViewport(viewportIdx: number, dt: number, offscreen: OffscreenMode | null): GPUCommandBuffer | null {
1047
1133
  if (!this.device || !this.context) return null;
1048
1134
 
@@ -1220,7 +1306,7 @@ export class GameRenderer implements CanvasResizeHandler {
1220
1306
 
1221
1307
  private createDepthTexture(w: number, h: number): GPUTextureView {
1222
1308
  if (!this.device) throw new Error("No device");
1223
- const key = `${w}x${h}`;
1309
+ const key = w * 65536 + h;
1224
1310
  let tex = this.depthTextures.get(key);
1225
1311
  if (tex) {
1226
1312
  // LRU: move to most-recently-used by re-inserting.
@@ -1314,11 +1400,9 @@ export class GameRenderer implements CanvasResizeHandler {
1314
1400
  * this helper only runs the compositors.
1315
1401
  */
1316
1402
  protected renderScreenUiCompositors(): void {
1317
- const compositors = [...this.uiCompositors]
1318
- .filter((c) => c.hasContent())
1319
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1403
+ const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
1320
1404
  if (compositors.length === 0 || !this.device || !this.context) return;
1321
- const canvasView = this.getSurfaceTexture()?.createView();
1405
+ const canvasView = this.getFrameSurfaceTexture()?.createView();
1322
1406
  if (!canvasView) return;
1323
1407
  const uiEncoder = this.device.createCommandEncoder();
1324
1408
  const uiPass = uiEncoder.beginRenderPass({
@@ -1329,7 +1413,8 @@ export class GameRenderer implements CanvasResizeHandler {
1329
1413
  storeOp: "store" as GPUStoreOp,
1330
1414
  }],
1331
1415
  });
1332
- compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
1416
+ // Panel rects are CSS px — see the main UI composite block above.
1417
+ compositors.forEach((c) => { c.render(uiPass, this.canvas.clientWidth, this.canvas.clientHeight); });
1333
1418
  uiPass.end();
1334
1419
  this.device.queue.submit([uiEncoder.finish()]);
1335
1420
  }
@@ -111,8 +111,6 @@ export type { FrameGraphBuilder } from "./render-pass";
111
111
  export { RendererInputBusImpl } from "./renderer-input-bus";
112
112
  export { RendererModuleHost } from "./renderer-module-host";
113
113
  export type { RendererModuleHostCallbacks } from "./renderer-module-host";
114
- export { SabCanvasOverlay } from "./sab-canvas-overlay";
115
- export type { SabCanvasOverlayOptions } from "./sab-canvas-overlay";
116
114
  export { clearShaderValidationDedup, createValidatedShaderModule, createValidatedShaderModuleAsync, installShaderValidationGuard } from "./shader-validator";
117
115
  export { CSM_SHADER_CHUNK, POINT_SHADOW_SHADER_CHUNK, SPOT_SHADOW_SHADER_CHUNK } from "./shaders/shadow-chunks";
118
116
  export { SkyboxRenderer } from "./skybox";
@@ -79,9 +79,9 @@ export class InputManager {
79
79
  }
80
80
  }) as EventListener);
81
81
 
82
- add(document, "pointerlockchange", (() => {
82
+ add(this.canvas, "pointerlockchange", (() => {
83
83
  const wasLocked = this.pointerLocked;
84
- this.pointerLocked = (document.pointerLockElement as unknown) === this.canvas;
84
+ this.pointerLocked = this.canvas.pointerLocked === true;
85
85
  if (this.pointerLocked) {
86
86
  this.pointerLockRetryCount = 0;
87
87
  if (this.pointerLockRetryTimer) {
@@ -97,7 +97,7 @@ export class InputManager {
97
97
  }
98
98
  }) as EventListener);
99
99
 
100
- add(document, "pointerlockerror", (() => {
100
+ add(this.canvas, "pointerlockerror", (() => {
101
101
  // No action needed — tryLockPointer's fallback timer will retry.
102
102
  }) as EventListener);
103
103
  }
@@ -180,10 +180,10 @@ export class InputManager {
180
180
  }
181
181
  }) as EventListener, { passive: false });
182
182
 
183
- // Use document-level mouseleave: a sibling canvas stacked above (e.g.
184
- // sandjongg's tile canvas) would otherwise trigger the canvas's own
185
- // mouseleave and break UI hover/pressed state.
186
- add(document, "mouseleave", (() => {
183
+ // Window-level mouseleave: a second interactive canvas stacked above
184
+ // (e.g. sandjongg's tile canvas) would otherwise trigger the canvas's
185
+ // own mouseleave and break UI hover/pressed state.
186
+ add(window, "mouseleave", (() => {
187
187
  this.uiInputRouter?.handlePointerLeave();
188
188
  }) as EventListener);
189
189
 
@@ -116,8 +116,8 @@ export class RendererModuleHost {
116
116
  }
117
117
 
118
118
  /**
119
- * Inject the DevTools API so renderer plugins can self-register debug
120
- * panels via `ctx.devtools.registerPanel(...)` during their `register()`.
119
+ * Inject the DevTools API so renderer plugins can self-register data
120
+ * feeds via `ctx.devtools.registerDataFeed(...)` during their `register()`.
121
121
  */
122
122
  setDevToolsAPI(api: ModuleDevToolsAPI): void {
123
123
  this._devtools = api;
@@ -635,8 +635,6 @@ export class RendererModuleHost {
635
635
 
636
636
  // No-op DevTools API stub — used when setDevToolsAPI() hasn't been called.
637
637
  const NoopRendererDevToolsAPI: ModuleDevToolsAPI = {
638
- registerPanel: () => {},
639
- registerOverlayToggle: () => {},
640
638
  registerDataFeed: () => {},
641
639
  registerCommand: () => {},
642
640
  registerSABStat: () => {},
@@ -1,5 +1,5 @@
1
- import type { ChannelLayout, GridLayerLayout, HeaderViews } from "./types";
2
1
  import { describeHeaderValidation } from "./errors";
2
+ import type { ChannelLayout, GridLayerLayout, HeaderViews } from "./types";
3
3
 
4
4
  function createHeaderViews(sab: SharedArrayBuffer, headerSize: number): HeaderViews {
5
5
  const u32Length = headerSize / 4;
@@ -66,6 +66,32 @@ export class GridReader {
66
66
  return this.getSequence() !== lastSeen;
67
67
  }
68
68
 
69
+ /**
70
+ * True while the writer holds an open write window (sequence is odd).
71
+ * Readers can use this to cheaply defer sync work past a publish.
72
+ */
73
+ isWriteInProgress(): boolean {
74
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
75
+ }
76
+
77
+ /**
78
+ * Run `fn` against a consistent snapshot of the channel. The sequence
79
+ * word is a seqlock: odd while the writer is publishing, even when the
80
+ * data is stable. The read is retried while the word is odd or changes
81
+ * mid-read; after maxRetries it returns whatever the last attempt read
82
+ * rather than starving the reader (writers publish every tick).
83
+ */
84
+ readConsistent<T>(fn: () => T, maxRetries = 64): T {
85
+ for (let i = 0; i < maxRetries; i++) {
86
+ const s1 = Atomics.load(this.seqArr, 0);
87
+ if ((s1 & 1) === 0) {
88
+ const out = fn();
89
+ if (Atomics.load(this.seqArr, 0) === s1) return out;
90
+ }
91
+ }
92
+ return fn();
93
+ }
94
+
69
95
  snapshot(): Record<string, unknown> {
70
96
  const data: Record<string, unknown> = {};
71
97
  data["sequence"] = this.getSequence();
@@ -88,6 +114,8 @@ export class GridWriter {
88
114
  layers: Record<string, Float32Array | Int32Array | Uint32Array | Float64Array>;
89
115
  private seqArr: Int32Array;
90
116
  private layout: ChannelLayout;
117
+ /** True while a beginWrite() window is open (sequence is odd). */
118
+ private writeOpen = false;
91
119
 
92
120
  constructor(sab: SharedArrayBuffer, layout: ChannelLayout) {
93
121
  this.layout = layout;
@@ -107,10 +135,35 @@ export class GridWriter {
107
135
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
108
136
  }
109
137
 
110
- bumpSequence(): void {
138
+ /**
139
+ * Open a write window — marks the sequence word odd so readers know a
140
+ * publish is in progress. Idempotent so mutating paths can call it
141
+ * unconditionally.
142
+ */
143
+ beginWrite(): void {
144
+ if (this.writeOpen) return;
145
+ this.writeOpen = true;
146
+ Atomics.add(this.seqArr, 0, 1);
147
+ }
148
+
149
+ /** Close the write window opened by beginWrite() — sequence back to even. */
150
+ endWrite(): void {
151
+ if (!this.writeOpen) return;
152
+ this.writeOpen = false;
111
153
  Atomics.add(this.seqArr, 0, 1);
112
154
  }
113
155
 
156
+ /**
157
+ * Publish a commit. Without an open write window it steps +2 (even→even)
158
+ * so the sequence always lands on a stable value while still advancing
159
+ * once per publish; inside a window it closes it (+1, odd→even).
160
+ */
161
+ bumpSequence(): void {
162
+ const step = this.writeOpen ? 1 : 2;
163
+ this.writeOpen = false;
164
+ Atomics.add(this.seqArr, 0, step);
165
+ }
166
+
114
167
  getSequence(): number {
115
168
  return Atomics.load(this.seqArr, 0);
116
169
  }
@@ -67,6 +67,32 @@ export class RecordReader {
67
67
  return this.getSequence() !== lastSeen;
68
68
  }
69
69
 
70
+ /**
71
+ * True while the writer holds an open write window (sequence is odd).
72
+ * Readers can use this to cheaply defer sync work past a publish.
73
+ */
74
+ isWriteInProgress(): boolean {
75
+ return (Atomics.load(this.seqArr, 0) & 1) !== 0;
76
+ }
77
+
78
+ /**
79
+ * Run `fn` against a consistent snapshot of the channel. The sequence
80
+ * word is a seqlock: odd while the writer is publishing, even when the
81
+ * data is stable. The read is retried while the word is odd or changes
82
+ * mid-read; after maxRetries it returns whatever the last attempt read
83
+ * rather than starving the reader (writers publish every tick).
84
+ */
85
+ readConsistent<T>(fn: () => T, maxRetries = 64): T {
86
+ for (let i = 0; i < maxRetries; i++) {
87
+ const s1 = Atomics.load(this.seqArr, 0);
88
+ if ((s1 & 1) === 0) {
89
+ const out = fn();
90
+ if (Atomics.load(this.seqArr, 0) === s1) return out;
91
+ }
92
+ }
93
+ return fn();
94
+ }
95
+
70
96
  snapshot(): Record<string, unknown> {
71
97
  const data: Record<string, unknown> = {};
72
98
  if (this.layout.fields) {
@@ -93,6 +119,8 @@ export class RecordWriter {
93
119
  fields: Record<string, Float32Array | Int32Array | Uint32Array | Float64Array>;
94
120
  private seqArr: Int32Array;
95
121
  private layout: ChannelLayout;
122
+ /** True while a beginWrite() window is open (sequence is odd). */
123
+ private writeOpen = false;
96
124
 
97
125
  constructor(sab: SharedArrayBuffer, layout: ChannelLayout) {
98
126
  this.layout = layout;
@@ -112,18 +140,33 @@ export class RecordWriter {
112
140
  this.header.u32[this.layout.header.versionIndex] = this.layout.version;
113
141
  }
114
142
 
143
+ /**
144
+ * Open a write window — marks the sequence word odd so readers know a
145
+ * publish is in progress. Idempotent so mutating paths can call it
146
+ * unconditionally.
147
+ */
115
148
  beginWrite(): void {
116
- const seq = Atomics.load(this.seqArr, 0);
117
- Atomics.store(this.seqArr, 0, seq + 1);
149
+ if (this.writeOpen) return;
150
+ this.writeOpen = true;
151
+ Atomics.add(this.seqArr, 0, 1);
118
152
  }
119
153
 
154
+ /** Close the write window opened by beginWrite() — sequence back to even. */
120
155
  endWrite(): void {
121
- // Sequence is incremented in beginWrite() — endWrite() is a no-op
122
- // to avoid double incrementing.
156
+ if (!this.writeOpen) return;
157
+ this.writeOpen = false;
158
+ Atomics.add(this.seqArr, 0, 1);
123
159
  }
124
160
 
161
+ /**
162
+ * Publish a commit. Without an open write window it steps +2 (even→even)
163
+ * so the sequence always lands on a stable value while still advancing
164
+ * once per publish; inside a window it closes it (+1, odd→even).
165
+ */
125
166
  bumpSequence(): void {
126
- Atomics.add(this.seqArr, 0, 1);
167
+ const step = this.writeOpen ? 1 : 2;
168
+ this.writeOpen = false;
169
+ Atomics.add(this.seqArr, 0, step);
127
170
  }
128
171
 
129
172
  getSequence(): number {