@downdraft/engine 0.2.0 → 0.3.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.
- package/core/src/index.ts +2 -2
- package/core/src/platform/rpc.ts +4 -1
- package/core/src/render/game-renderer.ts +127 -64
- package/core/src/sab/grid-buffer.ts +55 -2
- package/core/src/sab/record-buffer.ts +48 -5
- package/core/src/sab/sim-channel.ts +181 -17
- package/core/src/sab/slot-buffer.ts +55 -1
- package/core/src/sab/types.ts +11 -0
- package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
- package/core/src/util/index.ts +1 -0
- package/core/src/util/session-timer.ts +23 -0
- package/core/src/worker/base-worker-host.ts +2 -1
- package/core/src/worker/buffer-sync.ts +20 -13
- package/core/src/worker/rpc.ts +54 -5
- package/core/src/worker/sim-worker-base.ts +5 -2
- package/core/src/worker/task-pool.ts +4 -0
- package/dist/.dd-manifest.json +861 -851
- package/dist/core/src/index.js +2 -2
- package/dist/core/src/platform/rpc.js +2 -1
- package/dist/core/src/render/game-renderer.js +125 -59
- package/dist/core/src/sab/grid-buffer.js +53 -1
- package/dist/core/src/sab/record-buffer.js +49 -5
- package/dist/core/src/sab/sim-channel.js +173 -17
- package/dist/core/src/sab/slot-buffer.js +54 -1
- package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
- package/dist/core/src/util/index.js +1 -0
- package/dist/core/src/util/session-timer.js +22 -0
- package/dist/core/src/worker/base-worker-host.js +2 -1
- package/dist/core/src/worker/buffer-sync.js +16 -9
- package/dist/core/src/worker/rpc.js +46 -8
- package/dist/core/src/worker/sim-worker-base.js +5 -2
- package/dist/core/src/worker/task-pool.js +5 -0
- package/dist/libraries/devtools/src/backend.js +35 -9
- package/dist/libraries/devtools/src/collectors.js +73 -22
- package/dist/modules/devtools/src/blitz/fonts.js +75 -0
- package/dist/modules/devtools/src/blitz/host.js +124 -30
- package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
- package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
- package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
- package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
- package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
- package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
- package/dist/modules/devtools/src/blitz/theme.js +26 -4
- package/dist/modules/html-ui/src/doc-backend.js +52 -3
- package/dist/modules/html-ui/src/host.js +6 -3
- package/libraries/blitz-ui/native/Cargo.toml +6 -0
- package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
- package/libraries/devtools/src/backend.ts +38 -12
- package/libraries/devtools/src/collectors.ts +75 -22
- package/modules/devtools/src/blitz/fonts.ts +78 -0
- package/modules/devtools/src/blitz/host.ts +107 -31
- package/modules/devtools/src/blitz/panels/console.ts +20 -8
- package/modules/devtools/src/blitz/panels/elements.ts +212 -31
- package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
- package/modules/devtools/src/blitz/panels/performance.ts +24 -12
- package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
- package/modules/devtools/src/blitz/panels/types.ts +2 -1
- package/modules/devtools/src/blitz/theme.ts +26 -4
- package/modules/html-ui/src/doc-backend.ts +67 -4
- package/modules/html-ui/src/host.ts +11 -3
- package/modules/html-ui/src/protocol.ts +1 -1
- package/package.json +2 -2
package/dist/core/src/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export { createMultiInputChannel, DEFAULT_KEY_BITFIELD_COUNT, DEFAULT_MAX_PLAYER
|
|
|
45
45
|
// Game Input SAB Channel
|
|
46
46
|
export { INP, INP_FLAG, INP_HDR, INPUT_MAGIC, INPUT_VERSION, InputBufferReader, InputBufferWriter, InputChannel, KEY, MAX_INPUT_PLAYERS } from "./sab/game-input.js";
|
|
47
47
|
// Sim SAB Channel
|
|
48
|
-
export { allocateInputBuffer, allocateSimBuffer, ENT, MAX_ENTITIES, MAX_PLAYERS, PLR, PLR_FLAG, SIM_ENTITY_SLOT_SIZE, SIM_HDR, SIM_MAGIC, SIM_PLAYER_SLOT_SIZE, SIM_VERSION, SimBufferReader, SimBufferWriter, SimChannel } from "./sab/sim-channel.js";
|
|
48
|
+
export { allocateInputBuffer, allocateSimBuffer, ENT, MAX_ENTITIES, MAX_PLAYERS, PLR, PLR_FLAG, PREV, SIM_ENTITY_SLOT_SIZE, SIM_HDR, SIM_MAGIC, SIM_PLAYER_SLOT_SIZE, SIM_VERSION, SimBufferReader, SimBufferWriter, SimChannel } from "./sab/sim-channel.js";
|
|
49
49
|
// SAB Framework
|
|
50
50
|
export { defineChannel, defineManifest } from "./sab/define.js";
|
|
51
51
|
export { isDebug, resetWarnings, ValidationError as SABValidationError, setDebug, warnOnce } from "./sab/errors.js";
|
|
@@ -57,7 +57,7 @@ export { BufferSyncHost, BufferSyncWorker, isBufferSyncMessage } from "./worker/
|
|
|
57
57
|
export { CrashRecoveryManager, DEFAULT_RECOVERY_CONFIG } from "./worker/crash-recovery.js";
|
|
58
58
|
export { EntitySimWorkerHost } from "./worker/entity-sim-worker-host.js";
|
|
59
59
|
export { exposeProfilingApi, InstrumentedWorkerHost } from "./worker/instrumented-worker-host.js";
|
|
60
|
-
export { expose, exposeEvents, getWorkerHost, wrap } from "./worker/rpc.js";
|
|
60
|
+
export { expose, exposeBatchedEvents, exposeEvents, getWorkerHost, wrap } from "./worker/rpc.js";
|
|
61
61
|
export { createSimWorker } from "./worker/sim-worker-base.js";
|
|
62
62
|
export { RawInputRegionWriter, SimWorkerHost } from "./worker/sim-worker-host.js";
|
|
63
63
|
export { createTaskWorker, PortChannel, TaskPool } from "./worker/task-pool.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { untrackedTimeout } from "../util/session-timer.js";
|
|
1
2
|
export class RPC {
|
|
2
3
|
handlers = new Map();
|
|
3
4
|
pending = new Map();
|
|
@@ -20,7 +21,7 @@ export class RPC {
|
|
|
20
21
|
}
|
|
21
22
|
const id = this.messageIdCounter = (this.messageIdCounter + 1) % 0xFFFFFFFF;
|
|
22
23
|
return new Promise((resolve, reject) => {
|
|
23
|
-
const timer =
|
|
24
|
+
const timer = untrackedTimeout(() => {
|
|
24
25
|
if (this.pending.has(id)) {
|
|
25
26
|
this.pending.delete(id);
|
|
26
27
|
reject(new Error(`[RPC] Call to "${channel}" timed out after ${timeoutMs}ms`));
|
|
@@ -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
|
-
|
|
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();
|
|
@@ -563,14 +587,40 @@ export class GameRenderer {
|
|
|
563
587
|
}
|
|
564
588
|
catch (err) {
|
|
565
589
|
log.error("game-renderer", `Render loop error: ${err.message}\n${err.stack}`);
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
590
|
+
// No per-error device.lost.then here: the device already has exactly
|
|
591
|
+
// one lost subscription (init + reinit paths), and on native each
|
|
592
|
+
// .lost access mints a fresh promise — per-error attaches accumulated
|
|
593
|
+
// duplicate watchers during an error storm.
|
|
571
594
|
}
|
|
572
595
|
this.scheduleRender();
|
|
573
596
|
};
|
|
597
|
+
/**
|
|
598
|
+
* Redirect queue.submit() into frameSubmitSink while a frame is in
|
|
599
|
+
* flight. Submission order is preserved — buffers land in the sink in
|
|
600
|
+
* the order submit() was called — so mid-frame submits (uploads in
|
|
601
|
+
* beforeFrame, a game's afterFrame pass, endFrame hooks) still execute
|
|
602
|
+
* ahead of work recorded later, just inside the single end-of-frame
|
|
603
|
+
* queue.submit rather than as extra queue-lock + driver calls.
|
|
604
|
+
*/
|
|
605
|
+
installQueueSubmitInterceptor(device) {
|
|
606
|
+
if (this.submitInterceptorDevice === device)
|
|
607
|
+
return;
|
|
608
|
+
this.submitInterceptorDevice = device;
|
|
609
|
+
const queue = device.queue;
|
|
610
|
+
if (queue.__ddSubmitIntercepted)
|
|
611
|
+
return;
|
|
612
|
+
queue.__ddSubmitIntercepted = true;
|
|
613
|
+
const origSubmit = queue.submit.bind(queue);
|
|
614
|
+
queue.submit = (commandBuffers) => {
|
|
615
|
+
const sink = this.frameSubmitSink;
|
|
616
|
+
if (!sink) {
|
|
617
|
+
origSubmit(commandBuffers);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
for (const cb of commandBuffers)
|
|
621
|
+
sink.push(cb);
|
|
622
|
+
};
|
|
623
|
+
}
|
|
574
624
|
renderFrame() {
|
|
575
625
|
const now = performance.now();
|
|
576
626
|
// Frame rate limiter: phase accumulator (skip during XR — XR drives its own cadence)
|
|
@@ -615,6 +665,14 @@ export class GameRenderer {
|
|
|
615
665
|
// simulation stays responsive. The flag is cleared by recovery code that
|
|
616
666
|
// recreates the invalid resources.
|
|
617
667
|
const gpuError = this.gpuProfiler?.hasUncapturedError() ?? false;
|
|
668
|
+
// Collect command buffers from all phases and submit once at the end
|
|
669
|
+
// of the frame — one queue.submit per frame instead of 2-4. The sink
|
|
670
|
+
// is armed BEFORE beginFrame so submit() calls from hooks and game
|
|
671
|
+
// callbacks (beforeFrame uploads, afterFrame render passes, the XR
|
|
672
|
+
// endFrame encoder) are captured in call order into the same batch
|
|
673
|
+
// rather than crossing into wgpu individually.
|
|
674
|
+
const frameCommandBuffers = [];
|
|
675
|
+
this.frameSubmitSink = frameCommandBuffers;
|
|
618
676
|
// XR render target provider beginFrame hook
|
|
619
677
|
if (this.renderTargetProvider && !gpuError) {
|
|
620
678
|
this.renderTargetProvider.beginFrame();
|
|
@@ -625,9 +683,6 @@ export class GameRenderer {
|
|
|
625
683
|
if (offscreen && useOffscreen && offscreen.ensureTargets && !gpuError) {
|
|
626
684
|
offscreen.ensureTargets(this.canvas.width, this.canvas.height);
|
|
627
685
|
}
|
|
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
686
|
if (!gpuError) {
|
|
632
687
|
// 2D mode with viewportCount=0: clear the canvas if a clearColor is
|
|
633
688
|
// configured, then let the afterFrame callback do custom rendering.
|
|
@@ -674,19 +729,6 @@ export class GameRenderer {
|
|
|
674
729
|
if (passMailbox) {
|
|
675
730
|
frameCommandBuffers.push(...passMailbox.drain("preSubmit"));
|
|
676
731
|
}
|
|
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
732
|
// Record telemetry
|
|
691
733
|
if (this.telemetryCollector) {
|
|
692
734
|
this.telemetryCollector.recordFrame(dt * 1000);
|
|
@@ -721,42 +763,68 @@ export class GameRenderer {
|
|
|
721
763
|
this.frameDrawCalls = 0;
|
|
722
764
|
this.frameTriangles = 0;
|
|
723
765
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
766
|
+
try {
|
|
767
|
+
// XR render target provider endFrame hook (skip on GPU error). Its
|
|
768
|
+
// submits are captured by frameSubmitSink and join the batch.
|
|
769
|
+
if (!gpuError && this.renderTargetProvider && this.device) {
|
|
770
|
+
this.renderTargetProvider.endFrame(this.device.createCommandEncoder());
|
|
771
|
+
}
|
|
772
|
+
// After frame callback — game submits here append to the batch in
|
|
773
|
+
// order, still ahead of the UI composite below.
|
|
774
|
+
this.callbacks.afterFrame?.(dt, this.elapsedTime);
|
|
775
|
+
this.rendererModuleHost?.dispatchFrame("afterFrame", dt, this.elapsedTime);
|
|
776
|
+
// Composite registered screen-space UI (html-ui panels) on top of the
|
|
777
|
+
// final image (skip on GPU error to avoid cascade). This must run AFTER
|
|
778
|
+
// afterFrame — 2D games (viewportCount=0) do their custom rendering in
|
|
779
|
+
// the afterFrame callback, so drawing UI earlier would put it underneath
|
|
780
|
+
// their clear pass.
|
|
781
|
+
if (!gpuError && this.device && this.context) {
|
|
782
|
+
const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
|
|
783
|
+
const uiCanvasView = compositors.length > 0
|
|
784
|
+
? this.getSurfaceTexture()?.createView()
|
|
785
|
+
: undefined;
|
|
786
|
+
if (uiCanvasView) {
|
|
787
|
+
const uiEncoder = this.device.createCommandEncoder();
|
|
788
|
+
const uiPass = uiEncoder.beginRenderPass({
|
|
789
|
+
colorAttachments: [{
|
|
790
|
+
view: uiCanvasView,
|
|
791
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
792
|
+
loadOp: "load",
|
|
793
|
+
storeOp: "store",
|
|
794
|
+
}],
|
|
795
|
+
});
|
|
796
|
+
compositors.forEach((c) => { c.render(uiPass, this.canvas.width, this.canvas.height); });
|
|
797
|
+
uiPass.end();
|
|
798
|
+
// "preUi" slot — worker passes ordered ahead of the UI-composite
|
|
799
|
+
// buffer inside the end-of-frame submit.
|
|
800
|
+
frameCommandBuffers.push(...(passMailbox?.drain("preUi") ?? []), uiEncoder.finish());
|
|
801
|
+
}
|
|
802
|
+
}
|
|
727
803
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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()]);
|
|
804
|
+
finally {
|
|
805
|
+
// End of frame: disarm the submit sink and flush everything recorded
|
|
806
|
+
// this frame — engine passes, captured callback submits, mailbox
|
|
807
|
+
// passes, the UI composite — in a single queue.submit(). Runs even on
|
|
808
|
+
// a callback throw so already-recorded work still lands.
|
|
809
|
+
this.frameSubmitSink = null;
|
|
810
|
+
if (frameCommandBuffers.length > 0 && this.device) {
|
|
811
|
+
try {
|
|
812
|
+
this.device.queue.submit(frameCommandBuffers);
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
log.error("game-renderer", `queue.submit failed: ${err.message ?? err}`);
|
|
816
|
+
}
|
|
758
817
|
}
|
|
759
818
|
}
|
|
819
|
+
// Read GPU timer results asynchronously (1-frame latency).
|
|
820
|
+
// Must be called AFTER queue.submit() — readGpuTimers() calls mapAsync on
|
|
821
|
+
// the read buffer, and a mapped/mapping-pending buffer cannot be used in a
|
|
822
|
+
// submitted command buffer (the copy-to-readBuffer was encoded above).
|
|
823
|
+
if (this.gpuProfiler) {
|
|
824
|
+
this.gpuProfiler.readGpuTimers().then(() => {
|
|
825
|
+
// Results available for next frame
|
|
826
|
+
}).catch(() => { });
|
|
827
|
+
}
|
|
760
828
|
// Present the surface (native wgpu requires explicit presentation;
|
|
761
829
|
// in browsers this is automatic at the end of the frame). Must be last —
|
|
762
830
|
// all submissions targeting the surface texture happen above.
|
|
@@ -951,7 +1019,7 @@ export class GameRenderer {
|
|
|
951
1019
|
createDepthTexture(w, h) {
|
|
952
1020
|
if (!this.device)
|
|
953
1021
|
throw new Error("No device");
|
|
954
|
-
const key =
|
|
1022
|
+
const key = w * 65536 + h;
|
|
955
1023
|
let tex = this.depthTextures.get(key);
|
|
956
1024
|
if (tex) {
|
|
957
1025
|
// LRU: move to most-recently-used by re-inserting.
|
|
@@ -1030,9 +1098,7 @@ export class GameRenderer {
|
|
|
1030
1098
|
* this helper only runs the compositors.
|
|
1031
1099
|
*/
|
|
1032
1100
|
renderScreenUiCompositors() {
|
|
1033
|
-
const compositors =
|
|
1034
|
-
.filter((c) => c.hasContent())
|
|
1035
|
-
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
1101
|
+
const compositors = this.uiCompositorOrder.filter((c) => c.hasContent());
|
|
1036
1102
|
if (compositors.length === 0 || !this.device || !this.context)
|
|
1037
1103
|
return;
|
|
1038
1104
|
const canvasView = this.getSurfaceTexture()?.createView();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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);
|