@aardworx/wombat.rendering 0.19.24 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/core/elementType.d.ts +2 -0
  2. package/dist/core/elementType.d.ts.map +1 -1
  3. package/dist/core/elementType.js +10 -0
  4. package/dist/core/elementType.js.map +1 -1
  5. package/dist/core/provider.d.ts.map +1 -1
  6. package/dist/core/provider.js +65 -32
  7. package/dist/core/provider.js.map +1 -1
  8. package/dist/core/renderObject.d.ts +18 -0
  9. package/dist/core/renderObject.d.ts.map +1 -1
  10. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  11. package/dist/resources/preparedRenderObject.js +28 -5
  12. package/dist/resources/preparedRenderObject.js.map +1 -1
  13. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  14. package/dist/resources/uniformBuffer.js +3 -1
  15. package/dist/resources/uniformBuffer.js.map +1 -1
  16. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +2 -12
  17. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  18. package/dist/runtime/derivedModes/modeKeyCpu.js +30 -13
  19. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  20. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +11 -0
  21. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -1
  22. package/dist/runtime/derivedModes/partitionDispatcher.js +31 -0
  23. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -1
  24. package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -1
  25. package/dist/runtime/derivedUniforms/codegen.js +34 -13
  26. package/dist/runtime/derivedUniforms/codegen.js.map +1 -1
  27. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  28. package/dist/runtime/derivedUniforms/dispatch.js +6 -0
  29. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  30. package/dist/runtime/derivedUniforms/records.d.ts +15 -0
  31. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
  32. package/dist/runtime/derivedUniforms/records.js +81 -14
  33. package/dist/runtime/derivedUniforms/records.js.map +1 -1
  34. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
  35. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  36. package/dist/runtime/derivedUniforms/sceneIntegration.js +18 -4
  37. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  38. package/dist/runtime/flattenTree.d.ts.map +1 -1
  39. package/dist/runtime/flattenTree.js +46 -2
  40. package/dist/runtime/flattenTree.js.map +1 -1
  41. package/dist/runtime/heapAdapter.d.ts +3 -1
  42. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  43. package/dist/runtime/heapAdapter.js +45 -4
  44. package/dist/runtime/heapAdapter.js.map +1 -1
  45. package/dist/runtime/heapDecoder.d.ts.map +1 -1
  46. package/dist/runtime/heapDecoder.js +8 -3
  47. package/dist/runtime/heapDecoder.js.map +1 -1
  48. package/dist/runtime/heapEffect.d.ts +36 -1
  49. package/dist/runtime/heapEffect.d.ts.map +1 -1
  50. package/dist/runtime/heapEffect.js +29 -2
  51. package/dist/runtime/heapEffect.js.map +1 -1
  52. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  53. package/dist/runtime/heapEffectIR.js +51 -11
  54. package/dist/runtime/heapEffectIR.js.map +1 -1
  55. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  56. package/dist/runtime/heapEligibility.js +64 -12
  57. package/dist/runtime/heapEligibility.js.map +1 -1
  58. package/dist/runtime/heapIrBuilders.d.ts +8 -0
  59. package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
  60. package/dist/runtime/heapIrBuilders.js +47 -2
  61. package/dist/runtime/heapIrBuilders.js.map +1 -1
  62. package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
  63. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
  64. package/dist/runtime/heapScene/chunkedArena.js +28 -91
  65. package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
  66. package/dist/runtime/heapScene/freelist.d.ts +1 -1
  67. package/dist/runtime/heapScene/freelist.js +3 -3
  68. package/dist/runtime/heapScene/freelist.js.map +1 -1
  69. package/dist/runtime/heapScene/pools.d.ts +103 -59
  70. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  71. package/dist/runtime/heapScene/pools.js +335 -194
  72. package/dist/runtime/heapScene/pools.js.map +1 -1
  73. package/dist/runtime/heapScene.d.ts +125 -1
  74. package/dist/runtime/heapScene.d.ts.map +1 -1
  75. package/dist/runtime/heapScene.js +671 -126
  76. package/dist/runtime/heapScene.js.map +1 -1
  77. package/dist/runtime/heapShaderFamily.d.ts.map +1 -1
  78. package/dist/runtime/heapShaderFamily.js +19 -0
  79. package/dist/runtime/heapShaderFamily.js.map +1 -1
  80. package/dist/runtime/hybridScene.d.ts +6 -0
  81. package/dist/runtime/hybridScene.d.ts.map +1 -1
  82. package/dist/runtime/hybridScene.js +36 -3
  83. package/dist/runtime/hybridScene.js.map +1 -1
  84. package/dist/runtime/index.d.ts +1 -1
  85. package/dist/runtime/index.d.ts.map +1 -1
  86. package/dist/runtime/index.js +1 -1
  87. package/dist/runtime/index.js.map +1 -1
  88. package/dist/runtime/renderTask.d.ts +6 -0
  89. package/dist/runtime/renderTask.d.ts.map +1 -1
  90. package/dist/runtime/renderTask.js +3 -0
  91. package/dist/runtime/renderTask.js.map +1 -1
  92. package/dist/runtime/runtime.d.ts +15 -0
  93. package/dist/runtime/runtime.d.ts.map +1 -1
  94. package/dist/runtime/runtime.js +3 -0
  95. package/dist/runtime/runtime.js.map +1 -1
  96. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +19 -2
  97. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -1
  98. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +37 -6
  99. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -1
  100. package/dist/runtime/textureAtlas/atlasPool.d.ts +5 -0
  101. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  102. package/dist/runtime/textureAtlas/atlasPool.js +25 -3
  103. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  104. package/dist/window/loop.d.ts.map +1 -1
  105. package/dist/window/loop.js +31 -4
  106. package/dist/window/loop.js.map +1 -1
  107. package/package.json +3 -3
  108. package/src/core/elementType.ts +10 -0
  109. package/src/core/provider.ts +56 -28
  110. package/src/core/renderObject.ts +18 -0
  111. package/src/resources/preparedRenderObject.ts +29 -6
  112. package/src/resources/uniformBuffer.ts +3 -1
  113. package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
  114. package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
  115. package/src/runtime/derivedUniforms/codegen.ts +34 -13
  116. package/src/runtime/derivedUniforms/dispatch.ts +6 -0
  117. package/src/runtime/derivedUniforms/records.ts +70 -15
  118. package/src/runtime/derivedUniforms/sceneIntegration.ts +19 -4
  119. package/src/runtime/flattenTree.ts +47 -2
  120. package/src/runtime/heapAdapter.ts +43 -5
  121. package/src/runtime/heapDecoder.ts +8 -3
  122. package/src/runtime/heapEffect.ts +44 -2
  123. package/src/runtime/heapEffectIR.ts +54 -11
  124. package/src/runtime/heapEligibility.ts +39 -12
  125. package/src/runtime/heapIrBuilders.ts +50 -2
  126. package/src/runtime/heapScene/chunkedArena.ts +29 -94
  127. package/src/runtime/heapScene/freelist.ts +3 -3
  128. package/src/runtime/heapScene/pools.ts +340 -218
  129. package/src/runtime/heapScene.ts +826 -142
  130. package/src/runtime/heapShaderFamily.ts +21 -0
  131. package/src/runtime/hybridScene.ts +43 -3
  132. package/src/runtime/index.ts +5 -0
  133. package/src/runtime/renderTask.ts +9 -0
  134. package/src/runtime/runtime.ts +18 -0
  135. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +52 -3
  136. package/src/runtime/textureAtlas/atlasPool.ts +28 -2
  137. package/src/window/loop.ts +32 -4
@@ -481,6 +481,26 @@ function unionDrawHeaders(
481
481
  // descriptor-only purpose of this slice, the per-effect-allocated
482
482
  // numbers are retained as-is on each entry.
483
483
 
484
+ // Union user storage-buffer bindings by name (type/access agreement),
485
+ // keeping the per-effect-allocated binding numbers (all effects allocate
486
+ // from the same fixed base in the same sorted-by-name order, so shared
487
+ // names agree; family-merge is disabled in practice anyway).
488
+ const storageByName = new Map<string, { name: string; access: "read" | "read_write"; binding: number }>();
489
+ for (const e of sortedEffects) {
490
+ const layout = perEffectLayout.get(e)!;
491
+ for (const sb of layout.storageBindings) {
492
+ const existing = storageByName.get(sb.name);
493
+ if (existing === undefined) storageByName.set(sb.name, { ...sb });
494
+ else if (existing.access !== sb.access) {
495
+ throw new Error(
496
+ `buildShaderFamily: storage buffer '${sb.name}' has conflicting access ` +
497
+ `across effects: '${existing.access}' vs '${sb.access}'.`,
498
+ );
499
+ }
500
+ }
501
+ }
502
+ const storageBindings = [...storageByName.values()].sort((a, b) => a.binding - b.binding);
503
+
484
504
  const base = {
485
505
  drawHeaderFields: fields,
486
506
  drawHeaderBytes,
@@ -491,6 +511,7 @@ function unionDrawHeaders(
491
511
  textureBindings: orderedTextures,
492
512
  samplerBindings: orderedSamplers,
493
513
  atlasTextureBindings: atlasUnion,
514
+ storageBindings,
494
515
  };
495
516
  return { id: bucketLayoutId(base), ...base };
496
517
  }
@@ -92,6 +92,12 @@ export interface CompileHybridSceneOptions {
92
92
  * massive workloads.
93
93
  */
94
94
  readonly maxChunkBytes?: number;
95
+ /** Pre-size heap arena chunks (bytes) — pass-through to `BuildHeapSceneOptions.initialArenaBytes`. */
96
+ readonly initialArenaBytes?: number;
97
+ /** Heap arena compaction toggle — pass-through to `BuildHeapSceneOptions.enableCompaction`. */
98
+ readonly enableCompaction?: boolean;
99
+ /** Compaction waste floor (bytes) — pass-through to `BuildHeapSceneOptions.compactionWasteFloorBytes`. */
100
+ readonly compactionWasteFloorBytes?: number;
95
101
  }
96
102
 
97
103
  export interface HybridScene {
@@ -197,19 +203,33 @@ export function compileHybridScene(
197
203
  // toggle — flipping that off forces every RO to legacy.
198
204
  const heapEnabled = opts.heapEnabled ?? AVal.constant(true);
199
205
  const eligCache = new WeakMap<RenderObject, aval<boolean>>();
206
+ const notEligCache = new WeakMap<RenderObject, aval<boolean>>();
200
207
  const elig = (ro: RenderObject): aval<boolean> => {
201
208
  let av = eligCache.get(ro);
202
209
  if (av === undefined) {
203
210
  const perRO = isHeapEligible(ro);
204
- av = AVal.custom(t => heapEnabled.getValue(t) && perRO.getValue(t));
211
+ // Constant collapse (static RO + constant heapEnabled): a plain
212
+ // constant instead of a custom aval + two subscriptions per RO.
213
+ av = heapEnabled.isConstant && perRO.isConstant
214
+ ? AVal.constant(heapEnabled.force(/* allow-force */) && perRO.force(/* allow-force */))
215
+ : AVal.custom(t => heapEnabled.getValue(t) && perRO.getValue(t));
205
216
  eligCache.set(ro, av);
206
217
  }
207
218
  return av;
208
219
  };
220
+ const notElig = (ro: RenderObject): aval<boolean> => {
221
+ let av = notEligCache.get(ro);
222
+ if (av === undefined) {
223
+ const e = elig(ro);
224
+ av = e.isConstant ? AVal.constant(!e.force(/* allow-force */)) : e.map(b => !b);
225
+ notEligCache.set(ro, av);
226
+ }
227
+ return av;
228
+ };
209
229
 
210
230
  const flat = flattenRenderTree(tree);
211
231
  const heapAset = flat.filterA(ro => elig(ro));
212
- const legacyAset = flat.filterA(ro => elig(ro).map(b => !b));
232
+ const legacyAset = flat.filterA(ro => notElig(ro));
213
233
 
214
234
  // ─── Atlas pool ──────────────────────────────────────────────────
215
235
  // Per-scene Tier-S atlas pool. Owned by this hybrid scene; disposed
@@ -231,7 +251,9 @@ export function compileHybridScene(
231
251
  const heapSpecAset = heapAset.map((ro: RenderObject) => {
232
252
  let spec = specCache.get(ro);
233
253
  if (spec === undefined) {
234
- spec = renderObjectToHeapSpec(ro, AdaptiveToken.top, atlasPool);
254
+ spec = renderObjectToHeapSpec(ro, AdaptiveToken.top, atlasPool, {
255
+ enableDerivedUniforms: opts.enableDerivedUniforms !== false,
256
+ });
235
257
  specCache.set(ro, spec);
236
258
  }
237
259
  return spec;
@@ -243,6 +265,9 @@ export function compileHybridScene(
243
265
  ...(opts.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
244
266
  ...(opts.enableDerivedUniforms === false ? { enableDerivedUniforms: false } : {}),
245
267
  ...(opts.maxChunkBytes !== undefined ? { maxChunkBytes: opts.maxChunkBytes } : {}),
268
+ ...(opts.initialArenaBytes !== undefined ? { initialArenaBytes: opts.initialArenaBytes } : {}),
269
+ ...(opts.enableCompaction !== undefined ? { enableCompaction: opts.enableCompaction } : {}),
270
+ ...(opts.compactionWasteFloorBytes !== undefined ? { compactionWasteFloorBytes: opts.compactionWasteFloorBytes } : {}),
246
271
  });
247
272
 
248
273
  // ─── Legacy subset → RenderTree → ScenePass ──────────────────────
@@ -253,10 +278,24 @@ export function compileHybridScene(
253
278
  const legacyTree: RenderTree =
254
279
  RenderTree.unorderedFromSet(legacyAset.map(ro => RenderTree.leaf(ro)));
255
280
  const scenePass = new ScenePass(device, signature, legacyTree, compileEffect);
281
+ // DEBUG registry: per-hybrid-scene live draw counts (heap vs legacy) so
282
+ // multi-task pipelines (OIT) can be inspected from the console.
283
+ const dbgEntry = {
284
+ sig: signature.colorNames.join("+"),
285
+ heap: () => heapScene.stats.totalDraws,
286
+ legacy: () => scenePass.collect().length,
287
+ };
288
+ const dbgArr = ((globalThis as any).__hybridScenes ??= []) as (typeof dbgEntry)[];
289
+ dbgArr.push(dbgEntry);
256
290
 
257
291
  return {
258
292
  update(token: AdaptiveToken): void {
293
+ // Batch all of this frame's atlas uploads (tile ingest during
294
+ // heapScene.update → pool.acquire) into ONE queue submission —
295
+ // per-tile submits stall multi-frame when several tiles land at once.
296
+ atlasPool.beginUploadBatch();
259
297
  heapScene.update(token);
298
+ atlasPool.flushUploadBatch();
260
299
  scenePass.update(token);
261
300
  },
262
301
  encodeComputePrep(enc: GPUCommandEncoder, token: AdaptiveToken): void {
@@ -291,6 +330,7 @@ export function compileHybridScene(
291
330
  __legacyCount(): number {
292
331
  return scenePass.collect().length;
293
332
  },
333
+
294
334
  heapDerivedTimings() {
295
335
  const s = heapScene.stats;
296
336
  return {
@@ -25,7 +25,12 @@ export {
25
25
 
26
26
  export {
27
27
  buildHeapScene,
28
+ createHeapStorage,
29
+ oct32, c4b,
30
+ type HeapPackedAttribute,
28
31
  type HeapScene,
32
+ type HeapStorage,
33
+ type HeapStorageOptions,
29
34
  type HeapSceneStats,
30
35
  type HeapDrawSpec,
31
36
  type HeapGeometry,
@@ -59,6 +59,12 @@ export interface RuntimeContext {
59
59
  * compiled against this context. Default unset → adapter limit.
60
60
  */
61
61
  readonly maxChunkBytes?: number;
62
+ /** Pre-size heap arena chunks (bytes) — see `BuildHeapSceneOptions.initialArenaBytes`. */
63
+ readonly initialArenaBytes?: number;
64
+ /** Heap arena compaction toggle — see `BuildHeapSceneOptions.enableCompaction`. */
65
+ readonly enableCompaction?: boolean;
66
+ /** Compaction waste floor (bytes) — see `BuildHeapSceneOptions.compactionWasteFloorBytes`. */
67
+ readonly compactionWasteFloorBytes?: number;
62
68
  }
63
69
 
64
70
  class RenderTask implements IRenderTask {
@@ -262,6 +268,9 @@ class RenderTask implements IRenderTask {
262
268
  ...(this.ctx.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
263
269
  ...(this.ctx.enableDerivedUniforms === false ? { enableDerivedUniforms: false } : {}),
264
270
  ...(this.ctx.maxChunkBytes !== undefined ? { maxChunkBytes: this.ctx.maxChunkBytes } : {}),
271
+ ...(this.ctx.initialArenaBytes !== undefined ? { initialArenaBytes: this.ctx.initialArenaBytes } : {}),
272
+ ...(this.ctx.enableCompaction !== undefined ? { enableCompaction: this.ctx.enableCompaction } : {}),
273
+ ...(this.ctx.compactionWasteFloorBytes !== undefined ? { compactionWasteFloorBytes: this.ctx.compactionWasteFloorBytes } : {}),
265
274
  });
266
275
  this._scenes.set(cmd, s);
267
276
  }
@@ -47,6 +47,21 @@ export interface RuntimeOptions {
47
47
  * from a demo / test to exercise multi-chunk routing.
48
48
  */
49
49
  readonly maxChunkBytes?: number;
50
+ /**
51
+ * Pre-size heap arena chunks (bytes) — threaded into
52
+ * `BuildHeapSceneOptions.initialArenaBytes`. Streaming apps should pass
53
+ * their expected working-set size to avoid pow2 grow-copies (full-arena
54
+ * GPU copy + bind-group rebuilds) mid-navigation.
55
+ */
56
+ readonly initialArenaBytes?: number;
57
+ /**
58
+ * Heap arena compaction toggle — threaded into
59
+ * `BuildHeapSceneOptions.enableCompaction`. Streaming apps with steady
60
+ * churn may prefer `false` to guarantee no mid-frame compaction bounce.
61
+ */
62
+ readonly enableCompaction?: boolean;
63
+ /** Waste floor (bytes) before compaction triggers — see `BuildHeapSceneOptions`. */
64
+ readonly compactionWasteFloorBytes?: number;
50
65
  }
51
66
 
52
67
  /**
@@ -84,6 +99,9 @@ export class Runtime {
84
99
  ...(opts.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
85
100
  ...(opts.enableDerivedUniforms === false ? { enableDerivedUniforms: false } : {}),
86
101
  ...(opts.maxChunkBytes !== undefined ? { maxChunkBytes: opts.maxChunkBytes } : {}),
102
+ ...(opts.initialArenaBytes !== undefined ? { initialArenaBytes: opts.initialArenaBytes } : {}),
103
+ ...(opts.enableCompaction !== undefined ? { enableCompaction: opts.enableCompaction } : {}),
104
+ ...(opts.compactionWasteFloorBytes !== undefined ? { compactionWasteFloorBytes: opts.compactionWasteFloorBytes } : {}),
87
105
  };
88
106
  // `device.lost` is a real-WebGPU promise; mock devices may not
89
107
  // expose it. Treat as "never lost" in that case.
@@ -185,6 +185,44 @@ export interface MipSlot {
185
185
  readonly size: { w: number; h: number };
186
186
  }
187
187
 
188
+ /**
189
+ * Shared-encoder batch for atlas uploads: many tiles' mip/gutter kernels
190
+ * encode into ONE command encoder and submit ONCE — per-tile submits (2 per
191
+ * tile: the implicit copyExternalImageToTexture + a dedicated kernel submit)
192
+ * were measured as multi-frame stalls when several tiles land per frame on
193
+ * mobile GPUs. Resources pushed to `trash`/`trashTextures` are destroyed
194
+ * once the single submission completes.
195
+ */
196
+ export interface MipGutterBatch {
197
+ readonly enc: GPUCommandEncoder;
198
+ readonly trash: GPUBuffer[];
199
+ readonly trashTextures: GPUTexture[];
200
+ /** encoded job count (empty batches skip the submit entirely) */
201
+ jobs: number;
202
+ }
203
+
204
+ export function beginMipGutterBatch(device: GPUDevice): MipGutterBatch {
205
+ return {
206
+ enc: device.createCommandEncoder({ label: "atlas/mipGutterBatch" }),
207
+ trash: [], trashTextures: [], jobs: 0,
208
+ };
209
+ }
210
+
211
+ export function submitMipGutterBatch(device: GPUDevice, batch: MipGutterBatch): void {
212
+ if (batch.jobs === 0) {
213
+ // nothing encoded — still finish the encoder to release it
214
+ batch.enc.finish();
215
+ for (const t of batch.trashTextures) t.destroy();
216
+ for (const b of batch.trash) b.destroy();
217
+ return;
218
+ }
219
+ device.queue.submit([batch.enc.finish()]);
220
+ void device.queue.onSubmittedWorkDone().then(() => {
221
+ for (const t of batch.trashTextures) t.destroy();
222
+ for (const b of batch.trash) b.destroy();
223
+ });
224
+ }
225
+
188
226
  /** How mip-0's interior pixels get into the scratch buffer. */
189
227
  type Mip0Source =
190
228
  // The caller already filled the buffer's mip-0 region at creation
@@ -216,12 +254,15 @@ function runMipGutter(
216
254
  srcW: number, srcH: number,
217
255
  mips: readonly MipSlot[],
218
256
  mip0Src: Mip0Source,
257
+ batch?: MipGutterBatch,
219
258
  ): void {
220
259
  const k = getKernel(device);
221
260
  const bufStrideU32 = rowBytes / 4;
222
261
  const mip0 = mips[0]!;
223
262
 
224
- const enc = device.createCommandEncoder({ label: "atlas/mipGutterKernel" });
263
+ const enc = batch !== undefined
264
+ ? batch.enc
265
+ : device.createCommandEncoder({ label: "atlas/mipGutterKernel" });
225
266
 
226
267
  const ubos: GPUBuffer[] = [];
227
268
  const makeUbo = (
@@ -312,6 +353,12 @@ function runMipGutter(
312
353
  { width: boundsW, height: boundsH, depthOrArrayLayers: 1 },
313
354
  );
314
355
 
356
+ if (batch !== undefined) {
357
+ batch.jobs++;
358
+ batch.trash.push(scratch, ...ubos);
359
+ return; // caller submits the shared encoder once per frame
360
+ }
361
+
315
362
  device.queue.submit([enc.finish()]);
316
363
 
317
364
  // Lifetime: destroy scratch + per-pass UBOs once submitted work is
@@ -348,6 +395,7 @@ export function buildMipsAndGutterOnGpu(
348
395
  srcPixels: Uint8Array,
349
396
  srcW: number, srcH: number,
350
397
  mips: readonly MipSlot[],
398
+ batch?: MipGutterBatch,
351
399
  ): void {
352
400
  if (mips.length === 0) return;
353
401
  const { rowBytes, bufSize } = scratchGeom(boundsW, boundsH);
@@ -382,7 +430,7 @@ export function buildMipsAndGutterOnGpu(
382
430
 
383
431
  runMipGutter(
384
432
  device, page, subRectX, subRectY, boundsW, boundsH,
385
- rowBytes, scratch, srcW, srcH, mips, { kind: "prefilled" },
433
+ rowBytes, scratch, srcW, srcH, mips, { kind: "prefilled" }, batch,
386
434
  );
387
435
  }
388
436
 
@@ -408,6 +456,7 @@ export function buildMipsAndGutterFromTexture(
408
456
  srcTex: GPUTexture,
409
457
  srcW: number, srcH: number,
410
458
  mips: readonly MipSlot[],
459
+ batch?: MipGutterBatch,
411
460
  ): void {
412
461
  if (mips.length === 0) return;
413
462
  const { rowBytes, bufSize } = scratchGeom(boundsW, boundsH);
@@ -418,6 +467,6 @@ export function buildMipsAndGutterFromTexture(
418
467
  });
419
468
  runMipGutter(
420
469
  device, page, subRectX, subRectY, boundsW, boundsH,
421
- rowBytes, scratch, srcW, srcH, mips, { kind: "texture", tex: srcTex },
470
+ rowBytes, scratch, srcW, srcH, mips, { kind: "texture", tex: srcTex }, batch,
422
471
  );
423
472
  }
@@ -33,7 +33,10 @@ import { V2f, V2i } from "@aardworx/wombat.base";
33
33
  import { type aval, HashTable } from "@aardworx/wombat.adaptive";
34
34
  import type { ITexture, HostTextureSource } from "../../core/texture.js";
35
35
  import { TexturePacking } from "./packer.js";
36
- import { buildMipsAndGutterOnGpu, buildMipsAndGutterFromTexture, type MipSlot } from "./atlasMipGutterKernel.js";
36
+ import {
37
+ buildMipsAndGutterOnGpu, buildMipsAndGutterFromTexture, type MipSlot,
38
+ beginMipGutterBatch, submitMipGutterBatch, type MipGutterBatch,
39
+ } from "./atlasMipGutterKernel.js";
37
40
 
38
41
  export const ATLAS_PAGE_SIZE = 4096;
39
42
  /** Tier S/M source-side dimension cap. */
@@ -675,6 +678,27 @@ export class AtlasPool {
675
678
  this.lru.clear();
676
679
  }
677
680
 
681
+ // ── shared-encoder upload batching ─────────────────────────────────
682
+ // While a batch is open, per-tile kernel work encodes into ONE encoder
683
+ // and `flushUploadBatch` submits once — per-tile submits were measured
684
+ // as multi-frame stalls when several tiles land per frame (mobile GPUs,
685
+ // large photogrammetry textures). Callers that never open a batch keep
686
+ // the old immediate-submit behavior.
687
+ private uploadBatch: MipGutterBatch | null = null;
688
+
689
+ /** Open (or keep) a shared upload batch. Pair with `flushUploadBatch`. */
690
+ beginUploadBatch(): void {
691
+ if (this.uploadBatch === null) this.uploadBatch = beginMipGutterBatch(this.device);
692
+ }
693
+
694
+ /** Submit all batched uploads in one queue submission. */
695
+ flushUploadBatch(): void {
696
+ if (this.uploadBatch !== null) {
697
+ submitMipGutterBatch(this.device, this.uploadBatch);
698
+ this.uploadBatch = null;
699
+ }
700
+ }
701
+
678
702
  private finalize(
679
703
  aval: aval<ITexture>,
680
704
  format: AtlasPageFormat,
@@ -804,8 +828,10 @@ export class AtlasPool {
804
828
  this.device, page.texture,
805
829
  boundsX, boundsY, reservedW, reservedH,
806
830
  staging, w, h, slots,
831
+ this.uploadBatch ?? undefined,
807
832
  );
808
- void this.device.queue.onSubmittedWorkDone().then(() => staging.destroy());
833
+ if (this.uploadBatch !== null) this.uploadBatch.trashTextures.push(staging);
834
+ else void this.device.queue.onSubmittedWorkDone().then(() => staging.destroy());
809
835
  return;
810
836
  } catch {
811
837
  // Source not in a copyable state (e.g. a not-ready video) —
@@ -131,6 +131,8 @@ export function runFrame(
131
131
  let lastTime = performance.now();
132
132
  let rafId = 0;
133
133
  let pending = false;
134
+ let consecutiveErrors = 0;
135
+ const MAX_CONSECUTIVE_ERRORS = 30;
134
136
  const UNCAPPED = isUncapped();
135
137
 
136
138
  // Wrap the frame callback in an AVal so the adaptive system tracks
@@ -143,10 +145,34 @@ export function runFrame(
143
145
  attachment.markFrame();
144
146
  try {
145
147
  frame(token, { frame: frameNo, timestampMs: now, deltaMs: delta });
148
+ consecutiveErrors = 0;
146
149
  } catch (e) {
147
- stopped = true;
148
- console.error("runFrame: error in frame callback", e);
149
- throw e;
150
+ // A throwing frame used to kill the loop for good. That is far too harsh
151
+ // for a transient fault (a resource disposed mid-frame, a one-off device
152
+ // hiccup): the page then looks alive — DOM, input and workers all keep
153
+ // running — while nothing renders again and any animation driven from the
154
+ // frame callback freezes. Retry instead, and only give up if the fault
155
+ // repeats every frame (a genuinely broken scene, which would otherwise
156
+ // spin the error path forever).
157
+ consecutiveErrors++;
158
+ console.error(
159
+ `runFrame: error in frame callback (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS})`, e,
160
+ );
161
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
162
+ stopped = true;
163
+ console.error("runFrame: giving up after repeated frame errors — render loop stopped");
164
+ throw e;
165
+ }
166
+ // Keep the loop breathing: schedule the next frame ourselves, since a
167
+ // frame that died early may not have read the inputs it would be marked
168
+ // by. Swallow the error rather than rethrowing — an exception escaping
169
+ // the aval eval leaves the wrapper's dirty state ambiguous, and we have
170
+ // already reported it.
171
+ queueMicrotask(() => {
172
+ if (stopped || pending) return;
173
+ pending = true;
174
+ rafId = schedule(tick);
175
+ });
150
176
  }
151
177
  frameNo++;
152
178
  return frameNo;
@@ -188,8 +214,10 @@ export function runFrame(
188
214
  // frame.
189
215
  if (opts.pacer !== undefined) {
190
216
  opts.pacer().then(finalizeFrame, (e) => {
191
- stopped = true;
217
+ // A rejected pacer (GPU hiccup) must not brick the loop either — the
218
+ // frame is done as far as we're concerned; carry on.
192
219
  console.error("runFrame: pacer rejected", e);
220
+ finalizeFrame();
193
221
  });
194
222
  } else {
195
223
  finalizeFrame();