@aardworx/wombat.rendering 0.19.25 → 0.20.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 (75) hide show
  1. package/dist/core/renderObject.d.ts +9 -0
  2. package/dist/core/renderObject.d.ts.map +1 -1
  3. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  4. package/dist/resources/preparedRenderObject.js +9 -3
  5. package/dist/resources/preparedRenderObject.js.map +1 -1
  6. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  7. package/dist/resources/uniformBuffer.js +3 -1
  8. package/dist/resources/uniformBuffer.js.map +1 -1
  9. package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -1
  10. package/dist/runtime/derivedUniforms/codegen.js +34 -13
  11. package/dist/runtime/derivedUniforms/codegen.js.map +1 -1
  12. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  13. package/dist/runtime/derivedUniforms/dispatch.js +6 -0
  14. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  15. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  16. package/dist/runtime/derivedUniforms/sceneIntegration.js +9 -4
  17. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  18. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  19. package/dist/runtime/heapAdapter.js +3 -0
  20. package/dist/runtime/heapAdapter.js.map +1 -1
  21. package/dist/runtime/heapEffect.d.ts +23 -1
  22. package/dist/runtime/heapEffect.d.ts.map +1 -1
  23. package/dist/runtime/heapEffect.js +17 -2
  24. package/dist/runtime/heapEffect.js.map +1 -1
  25. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  26. package/dist/runtime/heapEffectIR.js +33 -2
  27. package/dist/runtime/heapEffectIR.js.map +1 -1
  28. package/dist/runtime/heapScene.d.ts +9 -0
  29. package/dist/runtime/heapScene.d.ts.map +1 -1
  30. package/dist/runtime/heapScene.js +90 -1
  31. package/dist/runtime/heapScene.js.map +1 -1
  32. package/dist/runtime/heapShaderFamily.d.ts.map +1 -1
  33. package/dist/runtime/heapShaderFamily.js +19 -0
  34. package/dist/runtime/heapShaderFamily.js.map +1 -1
  35. package/dist/runtime/hybridScene.d.ts +6 -0
  36. package/dist/runtime/hybridScene.d.ts.map +1 -1
  37. package/dist/runtime/hybridScene.js +17 -0
  38. package/dist/runtime/hybridScene.js.map +1 -1
  39. package/dist/runtime/renderTask.d.ts +6 -0
  40. package/dist/runtime/renderTask.d.ts.map +1 -1
  41. package/dist/runtime/renderTask.js +3 -0
  42. package/dist/runtime/renderTask.js.map +1 -1
  43. package/dist/runtime/runtime.d.ts +15 -0
  44. package/dist/runtime/runtime.d.ts.map +1 -1
  45. package/dist/runtime/runtime.js +3 -0
  46. package/dist/runtime/runtime.js.map +1 -1
  47. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +19 -2
  48. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -1
  49. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +37 -6
  50. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -1
  51. package/dist/runtime/textureAtlas/atlasPool.d.ts +5 -0
  52. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  53. package/dist/runtime/textureAtlas/atlasPool.js +25 -3
  54. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  55. package/dist/window/loop.d.ts.map +1 -1
  56. package/dist/window/loop.js +31 -4
  57. package/dist/window/loop.js.map +1 -1
  58. package/package.json +3 -3
  59. package/src/core/renderObject.ts +9 -0
  60. package/src/resources/preparedRenderObject.ts +7 -3
  61. package/src/resources/uniformBuffer.ts +3 -1
  62. package/src/runtime/derivedUniforms/codegen.ts +34 -13
  63. package/src/runtime/derivedUniforms/dispatch.ts +6 -0
  64. package/src/runtime/derivedUniforms/sceneIntegration.ts +9 -4
  65. package/src/runtime/heapAdapter.ts +3 -0
  66. package/src/runtime/heapEffect.ts +30 -2
  67. package/src/runtime/heapEffectIR.ts +36 -2
  68. package/src/runtime/heapScene.ts +109 -1
  69. package/src/runtime/heapShaderFamily.ts +21 -0
  70. package/src/runtime/hybridScene.ts +23 -0
  71. package/src/runtime/renderTask.ts +9 -0
  72. package/src/runtime/runtime.ts +18 -0
  73. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +52 -3
  74. package/src/runtime/textureAtlas/atlasPool.ts +28 -2
  75. package/src/window/loop.ts +32 -4
@@ -75,6 +75,10 @@ export interface HeapEffectSchema {
75
75
  readonly fragmentOutputs: readonly { readonly name: string; readonly location: number; readonly wgslType: string }[];
76
76
  readonly textures: readonly HeapTextureBinding[];
77
77
  readonly samplers: readonly HeapSamplerBinding[];
78
+ /** User-declared storage buffers (the OIT node pool & co) — bound at
79
+ * bucket level from scene-shared avals (`HeapDrawSpec.storageBuffers`).
80
+ * Absent/empty for ordinary effects. */
81
+ readonly storageBuffers?: readonly { readonly name: string; readonly access: "read" | "read_write" }[];
78
82
  }
79
83
 
80
84
  export interface CompiledHeapEffect {
@@ -104,7 +108,7 @@ export interface FragmentOutputLayout {
104
108
  // and on quota; every failure simply degrades to "recompute".
105
109
 
106
110
  /** Cache-generation stamp for the persistent (localStorage) tier. */
107
- export const HEAP_PERSIST_VERSION = "h4"; // h4: inline PickId drawHeader read (was h3: packed decode arms)
111
+ export const HEAP_PERSIST_VERSION = "h7"; // h7: OIT resolve early-exit
108
112
 
109
113
  /**
110
114
  * Uniforms whose drawHeader word IS the u32 value (no arena
@@ -275,7 +279,11 @@ function buildSchema(iface: ProgramInterface): HeapEffectSchema {
275
279
  name: s.name, wgslType: isComparison(s.type) ? "sampler_comparison" : "sampler",
276
280
  ...(s.state !== undefined ? { state: s.state } : {}),
277
281
  }));
278
- return { attributes, uniforms, varyings, fragmentOutputs, textures, samplers };
282
+ const storageBuffers = iface.storageBuffers.map(sb => ({ name: sb.name, access: sb.access }));
283
+ return {
284
+ attributes, uniforms, varyings, fragmentOutputs, textures, samplers,
285
+ ...(storageBuffers.length > 0 ? { storageBuffers } : {}),
286
+ };
279
287
  }
280
288
 
281
289
  function irTypeToWgsl(t: IrType): string {
@@ -389,6 +397,15 @@ export interface BucketLayout {
389
397
  * `atlasSample(...)` helper for these names only.
390
398
  */
391
399
  readonly atlasTextureBindings: ReadonlySet<string>;
400
+ /**
401
+ * User storage-buffer bindings (allocated from
402
+ * HEAP_USER_STORAGE_BINDING_BASE, above the atlas range). Bound at
403
+ * bucket level from `HeapDrawSpec.storageBuffers` — every RO in the
404
+ * bucket shares the same buffers (they are scene-level injections
405
+ * like the OIT node pool). FRAGMENT visibility only (WebGPU forbids
406
+ * writable storage in vertex stages).
407
+ */
408
+ readonly storageBindings: readonly { readonly name: string; readonly access: "read" | "read_write"; readonly binding: number }[];
392
409
  }
393
410
 
394
411
  /**
@@ -412,6 +429,8 @@ export const ATLAS_LINEAR_BINDING_BASE = 11;
412
429
  export const ATLAS_SRGB_BINDING_BASE = ATLAS_LINEAR_BINDING_BASE + ATLAS_ARRAY_SIZE;
413
430
  /** Atlas sampler binding (= linear base + 2N). */
414
431
  export const ATLAS_SAMPLER_BINDING = ATLAS_LINEAR_BINDING_BASE + 2 * ATLAS_ARRAY_SIZE;
432
+ /** First user storage-buffer binding (above the whole atlas range). */
433
+ export const HEAP_USER_STORAGE_BINDING_BASE = ATLAS_SAMPLER_BINDING + 1;
415
434
 
416
435
  export function buildBucketLayout(
417
436
  schema: HeapEffectSchema,
@@ -536,10 +555,18 @@ export function buildBucketLayout(
536
555
  isAtlasBucket,
537
556
  );
538
557
 
558
+ // User storage buffers: deterministic order (by name) starting above
559
+ // the atlas binding range.
560
+ let nextStorageBinding = HEAP_USER_STORAGE_BINDING_BASE;
561
+ const storageBindings = [...(schema.storageBuffers ?? [])]
562
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
563
+ .map(sb => ({ name: sb.name, access: sb.access, binding: nextStorageBinding++ }));
564
+
539
565
  const base = {
540
566
  drawHeaderFields, drawHeaderBytes, preludeWgsl, strideU32,
541
567
  perInstanceUniforms, perInstanceAttributes,
542
568
  textureBindings, samplerBindings, atlasTextureBindings,
569
+ storageBindings,
543
570
  };
544
571
  return { id: bucketLayoutId(base), ...base };
545
572
  }
@@ -567,6 +594,7 @@ export function bucketLayoutId(l: Omit<BucketLayout, "id">): string {
567
594
  parts.push("PIA:" + [...l.perInstanceAttributes].sort().join(","));
568
595
  for (const t of l.textureBindings) parts.push(`T:${t.name}|${t.wgslType}|${t.binding}`);
569
596
  for (const s of l.samplerBindings) parts.push(`S:${s.name}|${s.wgslType}|${s.binding}`);
597
+ for (const sb of l.storageBindings) parts.push(`SB:${sb.name}|${sb.access}|${sb.binding}`);
570
598
  parts.push("ATB:" + [...l.atlasTextureBindings].sort().join(","));
571
599
  // The prelude is derived from the above, but fold its hash in too —
572
600
  // cheap insurance against any generator path the field list misses.
@@ -20,7 +20,8 @@
20
20
  import { compileModule, stage as makeStage, effect as makeEffect } from "@aardworx/wombat.shader";
21
21
  import { isInlineHeaderField } from "./heapEffect.js";
22
22
  import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
23
- import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns, uniformsToInputs } from "@aardworx/wombat.shader/passes";
23
+ import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns, uniformsToInputs, resolveHoles } from "@aardworx/wombat.shader/passes";
24
+ import type { HoleValue } from "@aardworx/wombat.shader/passes";
24
25
  import { synthesizeHeapDecoderModule } from "./heapDecoder.js";
25
26
  import {
26
27
  type Module, type Expr, type Stmt, type Type, type ValueDef,
@@ -1020,6 +1021,22 @@ function compileHeapEffectIRViaDecoder(
1020
1021
  // from the carrier varyings the decoder wrote).
1021
1022
  combined = rewriteFsAtlasTexturesViaCarrier(combined, layout);
1022
1023
 
1024
+ // 6b. USER storage buffers (the OIT node pool & co): pin each to the
1025
+ // slot the bucket's BGL committed to. `keep: true` makes the emitter
1026
+ // use it verbatim (and stops `reduceUniforms` dropping an unreferenced
1027
+ // one). Without this the emitter auto-assigns sequentially and collides
1028
+ // with the heap's own reserved bindings.
1029
+ if (layout.storageBindings.length > 0) {
1030
+ const userStorageSlot = new Map(layout.storageBindings.map(b => [b.name, b.binding]));
1031
+ combined = {
1032
+ ...combined,
1033
+ values: combined.values.map(v =>
1034
+ v.kind === "StorageBuffer" && userStorageSlot.has(v.name)
1035
+ ? { ...v, binding: { group: 0, slot: userStorageSlot.get(v.name)! }, keep: true }
1036
+ : v),
1037
+ };
1038
+ }
1039
+
1023
1040
  // 7. Compile.
1024
1041
  const compiled = compileModule(combined, compileOptions);
1025
1042
  if ((globalThis as { __HEAP_DEBUG_FULL__?: boolean }).__HEAP_DEBUG_FULL__) {
@@ -1566,7 +1583,24 @@ function mergeStages(eff: Effect): Module {
1566
1583
  // Combine all stages' templates into one module by concatenating
1567
1584
  // their values + types arrays. The compile pipeline's composeStages
1568
1585
  // pass will fuse same-stage entries afterwards.
1586
+ //
1587
+ // Closure holes are resolved here (per stage, with that stage's
1588
+ // getters) — Effect.compile does this on the classic path, but the
1589
+ // heap pipeline compiles the raw templates directly, and an
1590
+ // unresolved `ReadInput("Closure", …)` emits as a bare identifier
1591
+ // ("unresolved value" at pipeline compile). Constant captures like
1592
+ // an OIT writer's node-pool capacity are the norm in composed
1593
+ // effects.
1569
1594
  const types = eff.stages.flatMap(s => [...s.template.types]);
1570
- const values = eff.stages.flatMap(s => [...s.template.values]);
1595
+ const values = eff.stages.flatMap(s => {
1596
+ let hasHoles = false;
1597
+ const sampled: Record<string, HoleValue> = {};
1598
+ for (const [name, getter] of Object.entries(s.holes ?? {})) {
1599
+ sampled[name] = getter();
1600
+ hasHoles = true;
1601
+ }
1602
+ const filled = hasHoles ? resolveHoles(s.template, sampled) : s.template;
1603
+ return [...filled.values];
1604
+ });
1571
1605
  return { types, values };
1572
1606
  }
@@ -350,6 +350,14 @@ interface Bucket {
350
350
  readonly slots: BucketSlot[];
351
351
  /** Repointed by `rebuildBindGroups` whenever any backing GrowBuffer reallocates. */
352
352
  bindGroup: GPUBindGroup;
353
+ /** Scene-shared user storage buffers (layout.storageBindings), set on the
354
+ * first addDraw that supplies them. */
355
+ storageBuffers: ReadonlyMap<string, aval<IBuffer>> | undefined;
356
+ /** Set when a storage aval marks (e.g. the OIT head buffer reallocating on
357
+ * resize) — update() rebuilds this bucket's slot bind groups. */
358
+ storageDirty: boolean;
359
+ /** Disposers for the storage avals' marking subscriptions. */
360
+ storageSubs: (() => void)[];
353
361
 
354
362
  // Per-bucket buffers
355
363
  readonly drawHeap: DrawHeap;
@@ -579,6 +587,14 @@ export interface HeapDrawSpec {
579
587
  * alongside the effect id.
580
588
  */
581
589
  readonly textures?: HeapTextureSet;
590
+ /**
591
+ * Scene-shared storage buffers for effects that declare
592
+ * `var<storage>` bindings (e.g. the OIT node pool injected by the
593
+ * transparency task). Bound ONCE at bucket level — every RO in a
594
+ * bucket must supply the SAME avals (they are scene-level by
595
+ * construction). Buffers must be GPU-backed IBuffers.
596
+ */
597
+ readonly storageBuffers?: ReadonlyMap<string, aval<IBuffer>>;
582
598
  /**
583
599
  * Optional pipeline state (rasterizer / depth / blend). Two specs
584
600
  * sharing the SAME `PipelineState` object share a bucket; distinct
@@ -1516,6 +1532,19 @@ export function buildHeapScene(
1516
1532
  // that don't yet point at an allocated AtlasPage. WebGPU rejects
1517
1533
  // bind-group entries with `undefined` resources, so every slot in
1518
1534
  // the N-wide ladder must hold a valid view at all times.
1535
+ // 4-byte placeholder storage buffer — binds user-storage slots on the
1536
+ // seed bind group built before the first addDraw delivers the real
1537
+ // buffers (rebuilt immediately after).
1538
+ let storagePlaceholder: GPUBuffer | undefined;
1539
+ function getStoragePlaceholder(): GPUBuffer {
1540
+ if (storagePlaceholder === undefined) {
1541
+ storagePlaceholder = device.createBuffer({
1542
+ label: "heapScene/storagePlaceholder", size: 4, usage: GPUBufferUsage.STORAGE,
1543
+ });
1544
+ }
1545
+ return storagePlaceholder;
1546
+ }
1547
+
1519
1548
  let atlasPlaceholders: Map<AtlasPageFormat, GPUTextureView> | undefined;
1520
1549
  function getAtlasPlaceholder(format: AtlasPageFormat): GPUTextureView {
1521
1550
  if (atlasPlaceholders === undefined) {
@@ -1546,6 +1575,7 @@ export function buildHeapScene(
1546
1575
  const key =
1547
1576
  `t${layout.textureBindings.map(t => t.wgslType).join(",")}` +
1548
1577
  `|s${layout.samplerBindings.map(s => s.wgslType).join(",")}` +
1578
+ `|sb${layout.storageBindings.map(b => `${b.binding}${b.access === "read_write" ? "w" : "r"}`).join(",")}` +
1549
1579
  `|a${withAtlasArrays ? 1 : 0}`;
1550
1580
  let e = bglCache.get(key);
1551
1581
  if (e !== undefined) return e;
@@ -1576,6 +1606,14 @@ export function buildHeapScene(
1576
1606
  sampler: { type: s.wgslType === "sampler_comparison" ? "comparison" : "filtering" },
1577
1607
  });
1578
1608
  }
1609
+ for (const sb of layout.storageBindings) {
1610
+ // FRAGMENT only: WebGPU forbids writable storage in vertex stages,
1611
+ // and the OIT writers are fragment-side anyway.
1612
+ entries.push({
1613
+ binding: sb.binding, visibility: GPUShaderStage.FRAGMENT,
1614
+ buffer: { type: sb.access === "read_write" ? "storage" : "read-only-storage" },
1615
+ });
1616
+ }
1579
1617
  if (withAtlasArrays) {
1580
1618
  // 2N + 1 entries: N linear textures, N srgb textures, 1 sampler.
1581
1619
  for (let i = 0; i < ATLAS_ARRAY_SIZE; i++) {
@@ -1688,11 +1726,31 @@ export function buildHeapScene(
1688
1726
  const cached = pipelineByModeKey.get(cacheKey);
1689
1727
  if (cached !== undefined) return cached;
1690
1728
  const { pipelineLayout } = getBgl(layout, isAtlasBucket);
1729
+ // Per-descriptor color targets: blend + write mask come from the RO's
1730
+ // PipelineState (`desc.attachments`, ordered by signature colorNames —
1731
+ // the same values the modeKey encodes). Using the bare scene-level
1732
+ // `colorTargets` here silently dropped blends/write-masks from every
1733
+ // heap pipeline: transparent heap leaves painted opaque, and the OIT
1734
+ // pick passes' write-mask-0 "Colors" leaked color onto the composite.
1735
+ const targets: GPUColorTargetState[] = colorTargets.map((t, i) => {
1736
+ const a = desc.attachments[i];
1737
+ if (a === undefined) return { format: t.format };
1738
+ return {
1739
+ format: t.format,
1740
+ writeMask: a.writeMask,
1741
+ ...(a.enabled
1742
+ ? { blend: {
1743
+ color: { operation: a.color.operation, srcFactor: a.color.srcFactor, dstFactor: a.color.dstFactor },
1744
+ alpha: { operation: a.alpha.operation, srcFactor: a.alpha.srcFactor, dstFactor: a.alpha.dstFactor },
1745
+ } }
1746
+ : {}),
1747
+ };
1748
+ });
1691
1749
  const pipeline = device.createRenderPipeline({
1692
1750
  label: `heapScene/cached/${cacheKey}`,
1693
1751
  layout: pipelineLayout,
1694
1752
  vertex: { module: fam.vsModule, entryPoint: fam.vsEntryName, buffers: [] },
1695
- fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets: colorTargets },
1753
+ fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets },
1696
1754
  primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
1697
1755
  ...(depthFormat !== undefined && desc.depth !== undefined
1698
1756
  ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
@@ -2066,6 +2124,23 @@ export function buildHeapScene(
2066
2124
  // here — the atlas slots come from the per-format page tracking
2067
2125
  // below.
2068
2126
  }
2127
+ for (const sb of bucket.layout.storageBindings) {
2128
+ const av = bucket.storageBuffers?.get(sb.name);
2129
+ if (av === undefined) {
2130
+ // Buffers arrive with the first addDraw; the seed slot built at
2131
+ // bucket creation is rebuilt right after they land. Bind a
2132
+ // 4-byte placeholder so the seed bind group validates.
2133
+ entries.push({ binding: sb.binding, resource: { buffer: getStoragePlaceholder() } });
2134
+ continue;
2135
+ }
2136
+ const ib = av.force(/* allow-force */);
2137
+ if (ib.kind !== "gpu") {
2138
+ throw new Error(
2139
+ `heapScene: user storage buffer '${sb.name}' must be a GPU-backed IBuffer`,
2140
+ );
2141
+ }
2142
+ entries.push({ binding: sb.binding, resource: { buffer: ib.buffer } });
2143
+ }
2069
2144
  if (bucket.isAtlasBucket) {
2070
2145
  if (atlasPool === undefined) {
2071
2146
  throw new Error(
@@ -3008,6 +3083,7 @@ export function buildHeapScene(
3008
3083
  layout.drawHeaderFields.forEach((f, i) => fieldIdx.set(f.name, i));
3009
3084
  const bucket: Bucket = {
3010
3085
  label: bk, textures: undefined, layout, chunkIdx,
3086
+ storageBuffers: undefined, storageDirty: false, storageSubs: [],
3011
3087
  slots: [], // populated lazily by ensureSlot per modeKey
3012
3088
  bindGroup: null as unknown as GPUBindGroup, // legacy field; slot.bindGroup is the real one
3013
3089
  drawHeap,
@@ -3334,6 +3410,28 @@ export function buildHeapScene(
3334
3410
  : arena.attrs.openPage();
3335
3411
  }
3336
3412
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
3413
+ // User storage buffers (layout.storageBindings): bucket-level, set once.
3414
+ if (bucket.layout.storageBindings.length > 0 && bucket.storageBuffers === undefined) {
3415
+ if (spec.storageBuffers === undefined) {
3416
+ throw new Error(
3417
+ `heapScene: bucket '${bucket.label}' needs storage buffers ` +
3418
+ `(${bucket.layout.storageBindings.map(b => b.name).join(", ")}) but spec.storageBuffers is undefined`,
3419
+ );
3420
+ }
3421
+ bucket.storageBuffers = spec.storageBuffers;
3422
+ // A storage aval marking (e.g. the OIT head buffer reallocating on a
3423
+ // canvas resize) invalidates every slot bind group of this bucket.
3424
+ for (const av of spec.storageBuffers.values()) {
3425
+ const disposer = (av as unknown as { addMarkingCallback?: (cb: () => void) => () => void })
3426
+ .addMarkingCallback?.(() => { bucket.storageDirty = true; });
3427
+ if (disposer !== undefined) bucket.storageSubs.push(disposer);
3428
+ }
3429
+ // Slots seeded before the buffers were known carry incomplete bind
3430
+ // groups — rebuild them now.
3431
+ for (let i = 0; i < bucket.slots.length; i++) {
3432
+ bucket.slots[i]!.bindGroup = buildBucketBindGroup(bucket, i);
3433
+ }
3434
+ }
3337
3435
  // Phase 5c.3 — every RO carrying a derived-mode rule is registered
3338
3436
  // on the bucket (lazy-promoting it to GPU-routed on the first
3339
3437
  // call). ROs may carry DIFFERENT rules even when they share the
@@ -4239,6 +4337,16 @@ export function buildHeapScene(
4239
4337
  sceneObj.evaluateAlways(token, (tok) => {
4240
4338
  drainAsetWith(tok);
4241
4339
 
4340
+ // User storage buffers that marked (e.g. the OIT head buffer
4341
+ // reallocating on a resize) invalidate the bucket's bind groups.
4342
+ for (const b of buckets) {
4343
+ if (!b.storageDirty) continue;
4344
+ b.storageDirty = false;
4345
+ for (let i = 0; i < b.slots.length; i++) {
4346
+ b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
4347
+ }
4348
+ }
4349
+
4242
4350
  // 0. Reactive rebucket: any RO whose PipelineState modeKey
4243
4351
  // changed since last frame moves to its new bucket.
4244
4352
  // `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
@@ -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 {
@@ -259,6 +265,9 @@ export function compileHybridScene(
259
265
  ...(opts.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
260
266
  ...(opts.enableDerivedUniforms === false ? { enableDerivedUniforms: false } : {}),
261
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 } : {}),
262
271
  });
263
272
 
264
273
  // ─── Legacy subset → RenderTree → ScenePass ──────────────────────
@@ -269,10 +278,24 @@ export function compileHybridScene(
269
278
  const legacyTree: RenderTree =
270
279
  RenderTree.unorderedFromSet(legacyAset.map(ro => RenderTree.leaf(ro)));
271
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);
272
290
 
273
291
  return {
274
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();
275
297
  heapScene.update(token);
298
+ atlasPool.flushUploadBatch();
276
299
  scenePass.update(token);
277
300
  },
278
301
  encodeComputePrep(enc: GPUCommandEncoder, token: AdaptiveToken): void {
@@ -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) —