@aardworx/wombat.rendering 0.18.1 → 0.19.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 (33) hide show
  1. package/dist/core/renderObject.d.ts +16 -0
  2. package/dist/core/renderObject.d.ts.map +1 -1
  3. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  4. package/dist/runtime/heapAdapter.js +24 -0
  5. package/dist/runtime/heapAdapter.js.map +1 -1
  6. package/dist/runtime/heapScene/freelist.d.ts +5 -0
  7. package/dist/runtime/heapScene/freelist.d.ts.map +1 -1
  8. package/dist/runtime/heapScene/freelist.js +28 -0
  9. package/dist/runtime/heapScene/freelist.js.map +1 -1
  10. package/dist/runtime/heapScene/pools.d.ts +19 -0
  11. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  12. package/dist/runtime/heapScene/pools.js +115 -12
  13. package/dist/runtime/heapScene/pools.js.map +1 -1
  14. package/dist/runtime/heapScene.d.ts +21 -1
  15. package/dist/runtime/heapScene.d.ts.map +1 -1
  16. package/dist/runtime/heapScene.js +237 -10
  17. package/dist/runtime/heapScene.js.map +1 -1
  18. package/dist/runtime/hybridScene.d.ts +8 -0
  19. package/dist/runtime/hybridScene.d.ts.map +1 -1
  20. package/dist/runtime/hybridScene.js +15 -0
  21. package/dist/runtime/hybridScene.js.map +1 -1
  22. package/dist/runtime/textureAtlas/atlasPool.d.ts +12 -0
  23. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  24. package/dist/runtime/textureAtlas/atlasPool.js +20 -0
  25. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/core/renderObject.ts +16 -0
  28. package/src/runtime/heapAdapter.ts +24 -0
  29. package/src/runtime/heapScene/freelist.ts +35 -0
  30. package/src/runtime/heapScene/pools.ts +131 -12
  31. package/src/runtime/heapScene.ts +257 -11
  32. package/src/runtime/hybridScene.ts +23 -0
  33. package/src/runtime/textureAtlas/atlasPool.ts +19 -0
@@ -112,9 +112,23 @@ export class UniformPool {
112
112
  return existing.ref;
113
113
  }
114
114
  const r = arena.alloc(dataBytes, chunkIdx);
115
- // The alloc may have spilled into a different chunk if `chunkIdx`
116
- // was full. Honour wherever it landed addRO's chunk-routing
117
- // commits the RO to the spill chunk too.
115
+ // `arena.alloc` may spill to a different chunk if `chunkIdx`'s
116
+ // GrowBuffer is at its maxCapacity. Callers (heapScene.addRO)
117
+ // bind a bucket's bind groups to a single chunk's buffer — a
118
+ // silent spill makes the bucket's drawHeader refs point into
119
+ // the wrong chunk's buffer (garbage reads / typeId corruption).
120
+ //
121
+ // Hard-fail with diagnostic info instead of returning quietly.
122
+ // The right long-term fix is for addRO to pre-probe the chunk,
123
+ // open a new bucket bound to the spill chunk, and re-route; but
124
+ // until that lands, a clear error beats unexplained artefacts.
125
+ if (r.chunkIdx !== chunkIdx) {
126
+ throw new Error(
127
+ `UniformPool.acquire: allocator spilled from chunk ${chunkIdx} to chunk ${r.chunkIdx} ` +
128
+ `(${dataBytes} bytes). Caller's bucket is bound to chunk ${chunkIdx}; this would silently ` +
129
+ `corrupt the drawHeader→arena reads. Open a new bucket bound to chunk ${r.chunkIdx} instead.`,
130
+ );
131
+ }
118
132
  const finalChunk = r.chunkIdx;
119
133
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
120
134
  const buf = new ArrayBuffer(allocBytes);
@@ -142,7 +156,17 @@ export class UniformPool {
142
156
  if (e === undefined) return;
143
157
  e.refcount--;
144
158
  if (e.refcount > 0) return;
145
- arena.release(e.chunkIdx, e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
159
+ // BUG FIX: arena.release's third arg is `dataBytes` (raw), and the
160
+ // chunk's AttributeArena.release internally adds the header padding
161
+ // via `ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes)`. We MUST pass the
162
+ // raw data size here — passing pre-aligned `allocBytes` (the old
163
+ // code) double-pads, freeing 16 extra bytes per release. The freed
164
+ // surplus then gets coalesced with the neighbouring scalar-uniform
165
+ // allocs (any 32 B u32/f32) into a single combined block, and the
166
+ // next attribute alloc handed out from that pool ends up STARTING
167
+ // 16 B inside the previous alloc → overlapping allocations →
168
+ // garbage `typeId`/`length` in the attribute header.
169
+ arena.release(e.chunkIdx, e.ref, e.dataBytes);
146
170
  byChunk!.delete(chunkIdx);
147
171
  if (byChunk!.size === 0) this.byAval.delete(av);
148
172
  }
@@ -312,6 +336,8 @@ interface IndexPoolEntry {
312
336
  export class DrawHeap {
313
337
  private free: number[] = [];
314
338
  private nextSlot = 0;
339
+ /** DEBUG: tracks slot live/free state. true = live (in use). */
340
+ private readonly live = new Set<number>();
315
341
  constructor(private readonly buf: GrowBuffer, private readonly slotBytes: number) {}
316
342
  get buffer(): GPUBuffer { return this.buf.buffer; }
317
343
  /** Bytes per slot — caller multiplies by slot index for byte offsets. */
@@ -320,11 +346,21 @@ export class DrawHeap {
320
346
  get usedBytes(): number { return this.nextSlot * this.slotBytes; }
321
347
  alloc(): number {
322
348
  const slot = this.free.length > 0 ? this.free.pop()! : this.nextSlot++;
349
+ if (this.live.has(slot)) {
350
+ throw new Error(`DrawHeap.alloc: returned already-live slot ${slot}`);
351
+ }
352
+ this.live.add(slot);
323
353
  this.buf.ensureCapacity((slot + 1) * this.slotBytes);
324
354
  this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
325
355
  return slot;
326
356
  }
327
- release(slot: number): void { this.free.push(slot); }
357
+ release(slot: number): void {
358
+ if (!this.live.has(slot)) {
359
+ throw new Error(`DrawHeap.release: slot ${slot} was not live — double-release or release-of-never-allocated.`);
360
+ }
361
+ this.live.delete(slot);
362
+ this.free.push(slot);
363
+ }
328
364
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
329
365
  destroy(): void { this.buf.destroy(); }
330
366
  }
@@ -340,6 +376,11 @@ export class DrawHeap {
340
376
  export class AttributeArena {
341
377
  private cursor = 0;
342
378
  private readonly freelist = new Freelist();
379
+ /** DEBUG: tracks every live allocation by start byte → size. Set
380
+ * on alloc, cleared on release. Used by `assertNoOverlap()` to
381
+ * detect allocator returning overlapping ranges. Enabled in
382
+ * development; cheap (one Map insert/delete per alloc). */
383
+ private readonly liveAllocs = new Map<number, number>();
343
384
  /** CPU shadow of the entire GPU buffer; writes go here first then
344
385
  * flush() emits one writeBuffer per dirty contiguous range. */
345
386
  private shadow: Uint8Array;
@@ -356,6 +397,15 @@ export class AttributeArena {
356
397
  get buffer(): GPUBuffer { return this.buf.buffer; }
357
398
  get capacity(): number { return this.buf.capacity; }
358
399
  get usedBytes(): number { return this.cursor; }
400
+ /** Read 4 u32s from the CPU shadow starting at byte `off`. Used by
401
+ * the validator to compare against the GPU's read-back values:
402
+ * if CPU shadow is correct but GPU shows garbage → the corruption
403
+ * came from a GPU-side write (a compute kernel writing to a wrong
404
+ * byte). If both differ from what pool.acquire wrote → CPU-side
405
+ * write path is the culprit. */
406
+ peekShadowU32(off: number, count: number): Uint32Array {
407
+ return new Uint32Array(this.shadow.buffer, this.shadow.byteOffset + off, count);
408
+ }
359
409
  write(dst: number, data: Uint8Array): void {
360
410
  this.shadow.set(data, dst);
361
411
  if (dst < this.dirtyMin) this.dirtyMin = dst;
@@ -383,13 +433,18 @@ export class AttributeArena {
383
433
  tryAlloc(dataBytes: number): number | undefined {
384
434
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
385
435
  const reused = this.freelist.alloc(allocBytes);
386
- if (reused !== undefined) return reused;
436
+ if (reused !== undefined) {
437
+ this.recordAlloc(reused, allocBytes, "freelist");
438
+ return reused;
439
+ }
387
440
  const next = this.cursor + allocBytes;
388
441
  if (next > this.buf.maxCapacity) return undefined;
389
442
  this.cursor = next;
390
443
  this.buf.ensureCapacity(next);
391
444
  this.buf.setUsed(next);
392
- return next - allocBytes;
445
+ const ref = next - allocBytes;
446
+ this.recordAlloc(ref, allocBytes, "bump");
447
+ return ref;
393
448
  }
394
449
  alloc(dataBytes: number): number {
395
450
  const r = this.tryAlloc(dataBytes);
@@ -402,6 +457,22 @@ export class AttributeArena {
402
457
  }
403
458
  release(ref: number, dataBytes: number): void {
404
459
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
460
+ const tracked = this.liveAllocs.get(ref);
461
+ if (tracked === undefined) {
462
+ throw new Error(
463
+ `AttributeArena.release: ref=${ref} (size=${allocBytes}) was not in liveAllocs — ` +
464
+ `double-free or release for an alloc that never happened. ` +
465
+ `Live alloc count=${this.liveAllocs.size}.`,
466
+ );
467
+ }
468
+ if (tracked !== allocBytes) {
469
+ throw new Error(
470
+ `AttributeArena.release: size mismatch at ref=${ref} — recorded=${tracked} but ` +
471
+ `release args→ allocBytes=${allocBytes} (dataBytes=${dataBytes}). ` +
472
+ `Caller is releasing the wrong size; this WILL corrupt the freelist via over- or under-shrinking.`,
473
+ );
474
+ }
475
+ this.liveAllocs.delete(ref);
405
476
  this.freelist.release(ref, allocBytes);
406
477
  // §5: shrink the bump cursor back if release exposed a free
407
478
  // tail touching it (cascading — `freelist.release` may have
@@ -414,6 +485,23 @@ export class AttributeArena {
414
485
  this.buf.setUsed(this.cursor);
415
486
  }
416
487
  }
488
+ /** Throws if the just-returned alloc overlaps any live allocation. */
489
+ private recordAlloc(off: number, size: number, source: string): void {
490
+ // Check against every existing live alloc — O(N). Cheap while N
491
+ // is small (per-tile demos run with ~hundreds of allocs). Wire
492
+ // off behind an env-flag if it ever becomes a hot path.
493
+ for (const [liveOff, liveSize] of this.liveAllocs) {
494
+ // [a, a+sa) vs [b, b+sb) overlap iff a < b+sb && b < a+sa
495
+ if (off < liveOff + liveSize && liveOff < off + size) {
496
+ throw new Error(
497
+ `AttributeArena.tryAlloc(${source}): returned [${off},${off + size}) ` +
498
+ `(size=${size}) overlaps live alloc at [${liveOff},${liveOff + liveSize}) ` +
499
+ `(size=${liveSize}). Allocator is handing out shared memory!`,
500
+ );
501
+ }
502
+ }
503
+ this.liveAllocs.set(off, size);
504
+ }
417
505
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
418
506
  destroy(): void { this.buf.destroy(); }
419
507
  }
@@ -428,6 +516,8 @@ export class AttributeArena {
428
516
  export class IndexAllocator {
429
517
  private cursor = 0; // in u32s, not bytes
430
518
  private readonly freelist = new Freelist();
519
+ /** DEBUG: live index allocations tracked by start element. */
520
+ private readonly liveAllocs = new Map<number, number>();
431
521
  private shadow: Uint8Array;
432
522
  private dirtyMin = Infinity;
433
523
  private dirtyMax = 0;
@@ -462,13 +552,30 @@ export class IndexAllocator {
462
552
  * Used by `ChunkedIndexAllocator` to probe before spilling. */
463
553
  tryAlloc(elements: number): number | undefined {
464
554
  const reused = this.freelist.alloc(elements);
465
- if (reused !== undefined) return reused;
555
+ if (reused !== undefined) {
556
+ this.recordAlloc(reused, elements, "freelist");
557
+ return reused;
558
+ }
466
559
  const nextElts = this.cursor + elements;
467
560
  if (nextElts * 4 > this.buf.maxCapacity) return undefined;
468
561
  this.cursor = nextElts;
469
562
  this.buf.ensureCapacity(nextElts * 4);
470
563
  this.buf.setUsed(nextElts * 4);
471
- return nextElts - elements;
564
+ const off = nextElts - elements;
565
+ this.recordAlloc(off, elements, "bump");
566
+ return off;
567
+ }
568
+ private recordAlloc(off: number, size: number, source: string): void {
569
+ for (const [liveOff, liveSize] of this.liveAllocs) {
570
+ if (off < liveOff + liveSize && liveOff < off + size) {
571
+ throw new Error(
572
+ `IndexAllocator.tryAlloc(${source}): returned [${off},${off + size}) ` +
573
+ `(size=${size}) overlaps live alloc at [${liveOff},${liveOff + liveSize}) ` +
574
+ `(size=${liveSize}). Index allocator is handing out shared memory!`,
575
+ );
576
+ }
577
+ }
578
+ this.liveAllocs.set(off, size);
472
579
  }
473
580
  alloc(elements: number): number {
474
581
  const r = this.tryAlloc(elements);
@@ -478,10 +585,22 @@ export class IndexAllocator {
478
585
  return r;
479
586
  }
480
587
  release(off: number, elements: number): void {
588
+ const tracked = this.liveAllocs.get(off);
589
+ if (tracked === undefined) {
590
+ throw new Error(
591
+ `IndexAllocator.release: off=${off} (elements=${elements}) was not in ` +
592
+ `liveAllocs — double-free or release for an alloc that never happened. ` +
593
+ `Live alloc count=${this.liveAllocs.size}.`,
594
+ );
595
+ }
596
+ if (tracked !== elements) {
597
+ throw new Error(
598
+ `IndexAllocator.release: size mismatch at off=${off} — recorded=${tracked} ` +
599
+ `but release elements=${elements}. Will corrupt the freelist.`,
600
+ );
601
+ }
602
+ this.liveAllocs.delete(off);
481
603
  this.freelist.release(off, elements);
482
- // §5: cursor-shrink mirror of AttributeArena.release. Cursor
483
- // is in u32 elements here, and setUsed on the GrowBuffer
484
- // expects bytes — multiply by 4.
485
604
  while (true) {
486
605
  const top = this.freelist.takeBlockEndingAt(this.cursor);
487
606
  if (top === undefined) break;
@@ -43,7 +43,7 @@
43
43
  // group's bind-group layout; the user's FS WGSL declares them.
44
44
 
45
45
  import { Trafo3d, V3d, V4f, M44d, type V2f } from "@aardworx/wombat.base";
46
- import type { ITexture } from "../core/texture.js";
46
+ import type { HostTextureSource, ITexture } from "../core/texture.js";
47
47
  import type { ISampler } from "../core/sampler.js";
48
48
  import { AVal, AdaptiveObject, AdaptiveToken, HashTable } from "@aardworx/wombat.adaptive";
49
49
  import type { aval, aset, IAdaptiveObject, IDisposable, IHashSetReader } from "@aardworx/wombat.adaptive";
@@ -438,6 +438,18 @@ export interface HeapDrawSpec {
438
438
  * computes `indexCount * instanceCount` per record.
439
439
  */
440
440
  readonly instanceCount?: aval<number> | number;
441
+ /**
442
+ * Optional visibility gate. When ticks to `false`, this RO emits
443
+ * 0 vertices that frame — its drawTable record gets effective
444
+ * `indexCount = 0`, the scan kernel's prefix sum skips past it,
445
+ * and no fragment shader invocations fire. Pool allocations,
446
+ * arena uploads, and bucket membership all stay intact, so the
447
+ * `true → false → true` round-trip is a pair of small drawTable
448
+ * writes plus a scan-pass re-issue — no `addDraw`/`removeDraw`
449
+ * churn. Designed for `<Sg Active={cval}>` driven by LOD walkers
450
+ * that flip many tiles per camera move.
451
+ */
452
+ readonly active?: aval<boolean>;
441
453
  /**
442
454
  * Index buffer for this draw. Indices live in their own `INDEX`-
443
455
  * usage `GPUBuffer` (WebGPU forces this), separate from the arena.
@@ -633,6 +645,14 @@ export type HeapTextureSet =
633
645
  * reference.
634
646
  */
635
647
  readonly repack?: (newTex: ITexture) => import("./textureAtlas/atlasPool.js").AtlasAcquisition;
648
+ /**
649
+ * Host source captured at adapter time, used by the heap-side
650
+ * re-acquire path when the pool entry was evicted while this spec
651
+ * sat idle (heap remove → re-add). Without it, `AtlasPool.acquire`
652
+ * allocates a fresh sub-rect with no `source` and skips `upload()`,
653
+ * leaving the drawHeader pointing at uninitialized atlas pixels.
654
+ */
655
+ readonly host?: HostTextureSource;
636
656
  };
637
657
 
638
658
  /**
@@ -774,6 +794,90 @@ export function buildHeapScene(
774
794
  const modeAvalToDrawIds = new Map<aval<unknown>, Set<number>>();
775
795
  const modeAvalCallbacks = new Map<aval<unknown>, IDisposable>();
776
796
 
797
+ // ─── HeapDrawSpec.active subscription ────────────────────────────────
798
+ // Per-drawId state for the "skip without re-allocating" path. When a
799
+ // tile (or any RO) flips visibility many times per second (e.g. an
800
+ // LOD walker culling/uncrossing the frustum), pool churn would be
801
+ // prohibitive. Instead we keep the RO in the bucket and flip its
802
+ // drawTable record's `indexCount` field between the original count
803
+ // (visible) and 0 (hidden). The scan kernel's prefix sum naturally
804
+ // skips a record with 0 emit count, so this costs one drawTable
805
+ // write + a re-scan dispatch — no arena/freelist activity.
806
+ const drawIdToActiveAval = new Map<number, aval<boolean>>();
807
+ const drawIdToOrigIndexCount = new Map<number, number>();
808
+ const drawIdToActiveCallback = new Map<number, IDisposable>();
809
+ const activeAvalToDrawIds = new Map<aval<boolean>, Set<number>>();
810
+ const activeDirty = new Set<number>();
811
+
812
+ function subscribeActive(av: aval<boolean>, drawId: number): void {
813
+ let set = activeAvalToDrawIds.get(av);
814
+ if (set === undefined) {
815
+ set = new Set<number>();
816
+ activeAvalToDrawIds.set(av, set);
817
+ }
818
+ set.add(drawId);
819
+ const cb = addMarkingCallback(av, () => { activeDirty.add(drawId); });
820
+ drawIdToActiveCallback.set(drawId, cb);
821
+ }
822
+
823
+ function unsubscribeActive(drawId: number): void {
824
+ const av = drawIdToActiveAval.get(drawId);
825
+ if (av === undefined) return;
826
+ const cb = drawIdToActiveCallback.get(drawId);
827
+ cb?.dispose();
828
+ drawIdToActiveCallback.delete(drawId);
829
+ const set = activeAvalToDrawIds.get(av);
830
+ if (set !== undefined) {
831
+ set.delete(drawId);
832
+ if (set.size === 0) activeAvalToDrawIds.delete(av);
833
+ }
834
+ drawIdToActiveAval.delete(drawId);
835
+ drawIdToOrigIndexCount.delete(drawId);
836
+ activeDirty.delete(drawId);
837
+ }
838
+
839
+ /** Update the drawTable record's effective indexCount in place
840
+ * (GPU-routed: masterShadow + partition re-dispatch; legacy:
841
+ * drawTableShadow + scan re-dispatch). Common helper used by the
842
+ * active-aval drain in `update()`. */
843
+ function setEffectiveIndexCount(drawId: number, newIndexCount: number): void {
844
+ const bucket = drawIdToBucket[drawId];
845
+ if (bucket === undefined) return;
846
+ if (bucket.gpuRouted) {
847
+ const partition = bucket.partitionScene!;
848
+ const recIdx = bucket.drawIdToRecord!.get(drawId);
849
+ if (recIdx === undefined) return;
850
+ const ru32 = partition.recordU32;
851
+ const oldIndexCount = partition.masterShadow[recIdx * ru32 + 3]!;
852
+ const instanceCount = partition.masterShadow[recIdx * ru32 + 4]!;
853
+ partition.masterShadow[recIdx * ru32 + 3] = newIndexCount >>> 0;
854
+ const delta = (newIndexCount - oldIndexCount) * instanceCount;
855
+ bucket.partitionDirty = true;
856
+ for (const s of bucket.slots) {
857
+ s.totalEmitEstimate = Math.max(0, s.totalEmitEstimate + delta);
858
+ s.scanDirty = true;
859
+ }
860
+ } else {
861
+ const slotIdx = drawIdToSlotIdx[drawId];
862
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
863
+ if (slot === undefined) return;
864
+ const localSlot = drawIdToLocalSlot[drawId];
865
+ if (localSlot === undefined) return;
866
+ const recIdx = slot.slotToRecord[localSlot];
867
+ if (recIdx === undefined || recIdx < 0) return;
868
+ const shadow = slot.drawTableShadow!;
869
+ const oldIndexCount = shadow[recIdx * RECORD_U32 + 3]!;
870
+ const instanceCount = shadow[recIdx * RECORD_U32 + 4]!;
871
+ shadow[recIdx * RECORD_U32 + 3] = newIndexCount >>> 0;
872
+ const byteOff = recIdx * RECORD_BYTES + 3 * 4;
873
+ if (byteOff < slot.drawTableDirtyMin) slot.drawTableDirtyMin = byteOff;
874
+ if (byteOff + 4 > slot.drawTableDirtyMax) slot.drawTableDirtyMax = byteOff + 4;
875
+ const delta = (newIndexCount - oldIndexCount) * instanceCount;
876
+ slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate + delta);
877
+ slot.scanDirty = true;
878
+ }
879
+ }
880
+
777
881
  function subscribeModeLeaf(av: aval<unknown>, drawId: number): void {
778
882
  let set = modeAvalToDrawIds.get(av);
779
883
  if (set === undefined) {
@@ -2785,6 +2889,11 @@ export function buildHeapScene(
2785
2889
  // inside a single outer evaluateAlways and invoke addDrawImpl
2786
2890
  // directly with their token — collapsing 1000× nested
2787
2891
  // evaluateAlways into 1× outer.
2892
+ // DEBUG counters.
2893
+ let __addDrawCalls = 0;
2894
+ let __removeDrawCalls = 0;
2895
+ (sceneObj as unknown as { __addDrawCalls: () => number }).__addDrawCalls = () => __addDrawCalls;
2896
+ (sceneObj as unknown as { __removeDrawCalls: () => number }).__removeDrawCalls = () => __removeDrawCalls;
2788
2897
  function addDraw(spec: HeapDrawSpec): number {
2789
2898
  let id = -1;
2790
2899
  sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
@@ -2793,6 +2902,7 @@ export function buildHeapScene(
2793
2902
  return id;
2794
2903
  }
2795
2904
  function addDrawImpl(spec: HeapDrawSpec, outerTok: AdaptiveToken): number {
2905
+ __addDrawCalls++;
2796
2906
  const drawId = nextDrawId++;
2797
2907
  // Family-merge (slice 3c): build the family lazily from this
2798
2908
  // single spec when no batched lazy-build occurred earlier (e.g.
@@ -2991,6 +3101,65 @@ export function buildHeapScene(
2991
3101
  packBucketHeader(bucket, localSlot, perDrawRefs, layoutId);
2992
3102
  if (bucket.isAtlasBucket && spec.textures !== undefined && spec.textures.kind === "atlas") {
2993
3103
  packAtlasTextureFields(bucket, localSlot, spec.textures);
3104
+ // Pair this addDraw with one atlas refcount bump. The spec's
3105
+ // release closure (stored below, fired in removeDraw) decrements
3106
+ // exactly once per add/remove cycle; without this incRef the
3107
+ // refcount underflows when the same cached spec is re-introduced
3108
+ // across multiple aset add/remove cycles (e.g., a tile flipping
3109
+ // in and out of the heap-eligible subset).
3110
+ //
3111
+ // Falls back to a full re-acquire if the entry was already
3112
+ // evicted (incRef returns false): in that case the spec's stored
3113
+ // (pageId, origin, size) are stale and the new acquire's values
3114
+ // overwrite them before packAtlasTextureFields is re-called.
3115
+ if (atlasPool !== undefined) {
3116
+ const ok = atlasPool.incRef(spec.textures.poolRef);
3117
+ if (!ok) {
3118
+ // Entry was evicted while the cached spec was idle in heap-
3119
+ // remove state. Acquire a fresh sub-rect, redirect the spec
3120
+ // (and the per-aval cell behind its release closure) to the
3121
+ // new ref, and re-emit the drawHeader fields.
3122
+ // Pass the captured `host` as `source` so the pool's
3123
+ // `finalize` re-runs `upload()` for the fresh sub-rect. Without
3124
+ // this, AtlasPool.acquire allocates the rect and skips the
3125
+ // upload (the `source.host !== undefined` branch in finalize),
3126
+ // so the drawHeader points at uninitialized atlas pixels —
3127
+ // exactly the "black rectangle holes" symptom on tiles that
3128
+ // cycled out and back in across a long-enough gap to be
3129
+ // evicted.
3130
+ const reW = spec.textures.size.x;
3131
+ const reH = spec.textures.size.y;
3132
+ const host = spec.textures.host;
3133
+ const acq = atlasPool.acquire(
3134
+ spec.textures.page.format,
3135
+ spec.textures.sourceAval as aval<ITexture>,
3136
+ reW, reH,
3137
+ {
3138
+ wantsMips: spec.textures.numMips > 1,
3139
+ ...(host !== undefined ? { source: { width: reW, height: reH, host } } : {}),
3140
+ },
3141
+ );
3142
+ (spec.textures as { pageId: number }).pageId = acq.pageId;
3143
+ (spec.textures as { origin: V2f }).origin = acq.origin;
3144
+ (spec.textures as { size: V2f }).size = acq.size;
3145
+ (spec.textures as { numMips: number }).numMips = acq.numMips;
3146
+ (spec.textures as { page: AtlasPage }).page = acq.page;
3147
+ (spec.textures as { poolRef: number }).poolRef = acq.ref;
3148
+ // The release closure captures a per-aval cell whose `ref`
3149
+ // must track the LATEST acquired sub-rect; otherwise the
3150
+ // closure releases a long-evicted ref (no-op leak).
3151
+ if (spec.textures.repack !== undefined) {
3152
+ // repack updates the per-aval cell to the new ref as a
3153
+ // side effect of its own atlas-mark drain path. Re-using
3154
+ // it here would free the entry we just acquired; instead
3155
+ // mirror the same effect with a tiny helper attached to
3156
+ // the spec at adapter time (`__retarget`).
3157
+ }
3158
+ const retarget = (spec.textures as unknown as { __retarget?: (ref: number) => void }).__retarget;
3159
+ if (retarget !== undefined) retarget(acq.ref);
3160
+ packAtlasTextureFields(bucket, localSlot, spec.textures);
3161
+ }
3162
+ }
2994
3163
  bucket.localAtlasReleases[localSlot] = spec.textures.release;
2995
3164
  bucket.localAtlasTextures[localSlot] = spec.textures;
2996
3165
  // Reactivity wire-up: subscribe to `sourceAval` (so sceneObj.inputChanged
@@ -3099,6 +3268,23 @@ export function buildHeapScene(
3099
3268
  drawIdToSlotIdx[drawId] = roSlotIdx;
3100
3269
  drawIdToIndexAval[drawId] = indicesAval;
3101
3270
 
3271
+ // ─── HeapDrawSpec.active wire-up ──────────────────────────────────
3272
+ // Track the visibility aval (if any) and apply its initial value
3273
+ // immediately. After this point, ticks on the aval are routed via
3274
+ // `subscribeActive` → `activeDirty` and drained in `update()`.
3275
+ if (spec.active !== undefined) {
3276
+ drawIdToActiveAval.set(drawId, spec.active);
3277
+ drawIdToOrigIndexCount.set(drawId, idxAlloc.count);
3278
+ const initial = spec.active.getValue(outerTok);
3279
+ subscribeActive(spec.active, drawId);
3280
+ if (initial === false) {
3281
+ // Apply the off state synchronously so the very first frame
3282
+ // already skips this RO (no flash of un-gated geometry while
3283
+ // we wait for the next update tick).
3284
+ setEffectiveIndexCount(drawId, 0);
3285
+ }
3286
+ }
3287
+
3102
3288
  // ─── Reactive rebucket: track PS-modeKey changes ────────────────
3103
3289
  // When any mode-axis aval marks (e.g. cullCval.value = 'front'),
3104
3290
  // schedule this RO for rebucket on the next update(). Today's
@@ -3203,9 +3389,13 @@ export function buildHeapScene(
3203
3389
  }
3204
3390
 
3205
3391
  function removeDraw(drawId: number): void {
3392
+ __removeDrawCalls++;
3206
3393
  const bucket = drawIdToBucket[drawId];
3207
3394
  const localSlot = drawIdToLocalSlot[drawId];
3208
3395
  if (bucket === undefined || localSlot === undefined) return;
3396
+ // Tear down `active` subscription (if any) so its callback won't
3397
+ // fire after the underlying drawTable record is gone.
3398
+ unsubscribeActive(drawId);
3209
3399
  // §7: deregister this RO's derivation records and release slots.
3210
3400
  if (derivedScene !== undefined) {
3211
3401
  const reg = derivedByDrawId.get(drawId);
@@ -3218,9 +3408,15 @@ export function buildHeapScene(
3218
3408
  const partition = bucket.partitionScene!;
3219
3409
  const recIdx = bucket.drawIdToRecord!.get(drawId);
3220
3410
  const removedEntry = bucket.localEntries[localSlot];
3221
- const removedCount = removedEntry !== undefined
3222
- ? removedEntry.indexCount * removedEntry.instanceCount
3223
- : 0;
3411
+ // Read the EFFECTIVE indexCount from masterShadow (already 0 for
3412
+ // an inactive draw via setEffectiveIndexCount). Using the original
3413
+ // count from localEntries would double-subtract for inactive draws
3414
+ // (whose contribution was already removed when active flipped false).
3415
+ const effectiveIndexCount = recIdx !== undefined
3416
+ ? partition.masterShadow[recIdx * partition.recordU32 + 3]!
3417
+ : (removedEntry?.indexCount ?? 0);
3418
+ const instanceCount = removedEntry?.instanceCount ?? 0;
3419
+ const removedCount = effectiveIndexCount * instanceCount;
3224
3420
  if (recIdx !== undefined) {
3225
3421
  const moved = partition.removeRecord(recIdx);
3226
3422
  if (moved >= 0) {
@@ -3247,9 +3443,15 @@ export function buildHeapScene(
3247
3443
  const slotIdx = drawIdToSlotIdx[drawId];
3248
3444
  const slot = slotIdx !== undefined ? bucket.slots[slotIdx]! : bucket.slots[0]!;
3249
3445
  const removedEntry = bucket.localEntries[localSlot];
3250
- const removedCount = removedEntry !== undefined
3251
- ? removedEntry.indexCount * removedEntry.instanceCount
3252
- : 0;
3446
+ const localSlotRec = slot.slotToRecord[localSlot];
3447
+ // Effective (post-active) indexCount, read from shadow so that
3448
+ // inactive draws contribute 0 here (their contribution was
3449
+ // already removed when active flipped false).
3450
+ const effectiveIndexCount = localSlotRec !== undefined && localSlotRec >= 0
3451
+ ? slot.drawTableShadow![localSlotRec * RECORD_U32 + 3]!
3452
+ : (removedEntry?.indexCount ?? 0);
3453
+ const instanceCount = removedEntry?.instanceCount ?? 0;
3454
+ const removedCount = effectiveIndexCount * instanceCount;
3253
3455
  slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate - removedCount);
3254
3456
  // Swap-pop: move the last record into the freed slot, decrement
3255
3457
  // recordCount. firstEmit is GPU-rewritten by the next scan, so
@@ -3545,6 +3747,22 @@ export function buildHeapScene(
3545
3747
  allocDirty.clear();
3546
3748
  }
3547
3749
 
3750
+ // 1c. HeapDrawSpec.active drain — flip drawTable.indexCount
3751
+ // between origIndexCount (visible) and 0 (hidden) for any RO
3752
+ // whose active aval ticked since last frame. Set is small in
3753
+ // steady state (only the ROs whose visibility actually
3754
+ // changed). No pool / arena / freelist activity.
3755
+ if (activeDirty.size > 0) {
3756
+ for (const did of activeDirty) {
3757
+ const av = drawIdToActiveAval.get(did);
3758
+ if (av === undefined) continue;
3759
+ const a = av.getValue(tok);
3760
+ const orig = drawIdToOrigIndexCount.get(did) ?? 0;
3761
+ setEffectiveIndexCount(did, a ? orig : 0);
3762
+ }
3763
+ activeDirty.clear();
3764
+ }
3765
+
3548
3766
  // 1b. Atlas-texture aval reactivity: an `aval<ITexture>` that
3549
3767
  // drives an atlas placement was marked. Repack the pool entry
3550
3768
  // and rewrite the drawHeader fields of every (bucket, slot)
@@ -3994,7 +4212,26 @@ export function buildHeapScene(
3994
4212
  const length = arenaU32[refU32 + 1]!;
3995
4213
 
3996
4214
  if (!KNOWN_TYPE_IDS.has(typeId)) {
3997
- push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown`);
4215
+ // Dump the first 8 u32s of the alloc to help diagnose
4216
+ // what wrote garbage onto the header. Also peek the
4217
+ // FOUR u32s BEFORE the alloc to see if a neighbour
4218
+ // overran into us.
4219
+ const before = refU32 >= 4
4220
+ ? `prev=[${arenaU32[refU32 - 4]},${arenaU32[refU32 - 3]},${arenaU32[refU32 - 2]},${arenaU32[refU32 - 1]}] `
4221
+ : "";
4222
+ const here = `here=[${arenaU32[refU32]},${arenaU32[refU32 + 1]},${arenaU32[refU32 + 2]},${arenaU32[refU32 + 3]},${arenaU32[refU32 + 4]},${arenaU32[refU32 + 5]},${arenaU32[refU32 + 6]},${arenaU32[refU32 + 7]}]`;
4223
+ // Compare CPU shadow vs GPU read-back. Differ → GPU
4224
+ // write corrupted; match → bug is in CPU-side write path
4225
+ // OR the alloc never went through pool.acquire.
4226
+ const shadowChunk = arena.attrs.chunk(0);
4227
+ const shadowU32 = shadowChunk.peekShadowU32(ref, 8);
4228
+ const shadowStr = `shadow=[${shadowU32[0]},${shadowU32[1]},${shadowU32[2]},${shadowU32[3]},${shadowU32[4]},${shadowU32[5]},${shadowU32[6]},${shadowU32[7]}]`;
4229
+ const cpuMatches = shadowU32[0] === arenaU32[refU32]
4230
+ && shadowU32[1] === arenaU32[refU32 + 1]
4231
+ && shadowU32[2] === arenaU32[refU32 + 2]
4232
+ && shadowU32[3] === arenaU32[refU32 + 3];
4233
+ const verdict = cpuMatches ? "GPU=CPU (likely CPU-write bug)" : "GPU≠CPU (likely GPU-write corruption)";
4234
+ push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${verdict} ${before}${here} ${shadowStr}`);
3998
4235
  attrAllocsBad++;
3999
4236
  continue;
4000
4237
  }
@@ -4102,11 +4339,18 @@ export function buildHeapScene(
4102
4339
  tilesBad++;
4103
4340
  }
4104
4341
  }
4342
+ // The scan kernel intentionally writes `numRecords - 1` as the
4343
+ // sentinel (not `numRecords`) — the render VS's binary search
4344
+ // uses `hi = firstDrawInTile[_tileIdx + 1u]` as an *inclusive*
4345
+ // upper bound, so the sentinel must point at the last valid
4346
+ // record slot. See scanKernel.ts `buildTileIndex` for the
4347
+ // full rationale.
4348
+ const expectedSentinel = recordCount === 0 ? 0 : recordCount - 1;
4105
4349
  const sentinel = fdt[dc.numTiles]!;
4106
4350
  tilesChecked++;
4107
- if (sentinel !== recordCount) {
4351
+ if (sentinel !== expectedSentinel) {
4108
4352
  push(`bucket#${bucketIdx} firstDrawInTile[${dc.numTiles}] sentinel ` +
4109
- `got=${sentinel} expected=${recordCount}`);
4353
+ `got=${sentinel} expected=${expectedSentinel}`);
4110
4354
  tilesBad++;
4111
4355
  }
4112
4356
  dc.firstDrawInTile.unmap();
@@ -5018,5 +5262,7 @@ export function buildHeapScene(
5018
5262
  },
5019
5263
  };
5020
5264
 
5021
- return { frame, update, encodeIntoPass, encodeComputePrep, addDraw, removeDraw, stats, dispose, _debug } as HeapScene;
5265
+ const __addDrawCallsGetter = (): number => __addDrawCalls;
5266
+ const __removeDrawCallsGetter = (): number => __removeDrawCalls;
5267
+ return { frame, update, encodeIntoPass, encodeComputePrep, addDraw, removeDraw, stats, dispose, _debug, __addDrawCalls: __addDrawCallsGetter, __removeDrawCalls: __removeDrawCallsGetter } as HeapScene;
5022
5268
  }
@@ -124,6 +124,14 @@ export interface HybridScene {
124
124
  * pipelineState. Useful for status / dev-overlay text.
125
125
  */
126
126
  heapBucketCount(): number;
127
+ /** DEBUG: outstanding live ROs in the heap path. */
128
+ heapTotalDraws(): number;
129
+ /** DEBUG: cumulative addDraw invocations on the heap scene. */
130
+ __addDrawCalls(): number;
131
+ /** DEBUG: cumulative removeDraw invocations on the heap scene. */
132
+ __removeDrawCalls(): number;
133
+ /** DEBUG: current legacy-path RO count. */
134
+ __legacyCount(): number;
127
135
  /** Per-frame breakdown of §7 derived-uniforms work (CPU). */
128
136
  heapDerivedTimings(): {
129
137
  pullMs: number; uploadMs: number; encodeMs: number; records: number;
@@ -211,6 +219,7 @@ export function compileHybridScene(
211
219
  // comments) — the pool is wired here so the next PR can add Tier-S
212
220
  // classification without touching this file.
213
221
  const atlasPool = new AtlasPool(device);
222
+ (globalThis as { __atlasDebug?: AtlasPool }).__atlasDebug = atlasPool;
214
223
 
215
224
  // ─── Heap subset → HeapDrawSpec aset ─────────────────────────────
216
225
  // Memoize the adapter: aset removal must identify the SAME spec
@@ -268,6 +277,20 @@ export function compileHybridScene(
268
277
  heapBucketCount(): number {
269
278
  return heapScene.stats.groups;
270
279
  },
280
+ heapTotalDraws(): number {
281
+ return heapScene.stats.totalDraws;
282
+ },
283
+ __addDrawCalls(): number {
284
+ const fn = (heapScene as unknown as { __addDrawCalls?: () => number }).__addDrawCalls;
285
+ return fn ? fn() : -1;
286
+ },
287
+ __removeDrawCalls(): number {
288
+ const fn = (heapScene as unknown as { __removeDrawCalls?: () => number }).__removeDrawCalls;
289
+ return fn ? fn() : -1;
290
+ },
291
+ __legacyCount(): number {
292
+ return scenePass.collect().length;
293
+ },
271
294
  heapDerivedTimings() {
272
295
  const s = heapScene.stats;
273
296
  return {