@aardworx/wombat.rendering 0.21.12 → 0.21.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aardworx/wombat.rendering",
3
- "version": "0.21.12",
3
+ "version": "0.21.14",
4
4
  "description": "WebGPU rendering layer for the Wombat TypeScript stack — RenderObject/RenderTask/runtime + window glue, port of Aardvark.Rendering's lower layers on top of WebGPU and wombat.shader.",
5
5
  "license": "MIT",
6
6
  "author": "krauthaufen",
@@ -621,11 +621,22 @@ export function renderObjectToHeapSpec(
621
621
  pipelineState: ro.pipelineState,
622
622
  inputs,
623
623
  ...(instanceAttributes !== undefined ? { instanceAttributes } : {}),
624
- // 1 (not > 1): instanceCount 0 must flow through — the scan
625
- // kernel emits indexCount × 0 = nothing, matching what the legacy
626
- // path draws for zero instances. Omitting it would default to 1
627
- // and pack a garbage instance from empty per-instance arrays.
628
- ...(dc.instanceCount !== 1 ? { instanceCount: dc.instanceCount } : {}),
624
+ // Count binding, in preference order:
625
+ // 1. The scene layer's shape-invariant drawCall marker carries
626
+ // the LIVE count aval (`__sgHeapSafeDraw.count`) bind it so
627
+ // count ticks flow to the drawTable in place (epoch morphing,
628
+ // point add/remove) with no per-RO wrapper aval.
629
+ // 2. Snapshot ≠ 1 (not > 1): instanceCount 0 must flow through —
630
+ // the scan kernel emits indexCount × 0 = nothing, matching
631
+ // what the legacy path draws for zero instances. Omitting it
632
+ // would default to 1 and pack a garbage instance.
633
+ ...((): { instanceCount?: aval<number> | number } => {
634
+ const marker = (ro.drawCall as {
635
+ __sgHeapSafeDraw?: { kind: string; count?: aval<number> };
636
+ }).__sgHeapSafeDraw;
637
+ if (marker?.count !== undefined) return { instanceCount: marker.count };
638
+ return dc.instanceCount !== 1 ? { instanceCount: dc.instanceCount } : {};
639
+ })(),
629
640
  ...geom,
630
641
  ...(textures !== undefined ? { textures } : {}),
631
642
  ...(ro.injectedStorage !== undefined && ro.injectedStorage.count > 0
@@ -67,16 +67,42 @@ export const SEM_NORMALS = 2;
67
67
  * in which chunk's bind group is active at draw time (shaders
68
68
  * never decode it).
69
69
  */
70
+ /** Fresh placement for a changed value — everything `repack` needs to
71
+ * decide whether the allocation still fits and how to re-encode. */
72
+ export interface PoolPlacement {
73
+ readonly dataBytes: number;
74
+ readonly typeId: number;
75
+ readonly length: number;
76
+ readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
77
+ }
78
+
70
79
  interface PoolEntry {
71
80
  readonly chunkIdx: number;
72
81
  /** Byte offset within the chunk's GPUBuffer. Mutated by
73
82
  * `UniformPool.remapRefs` when a waste-triggered arena compaction
74
- * relocates the allocation. */
83
+ * relocates the allocation, and by the sized-repack realloc path. */
75
84
  ref: number;
76
- readonly dataBytes: number;
77
- readonly typeId: number;
78
- readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
85
+ /** Raw data bytes of the CURRENT allocation. Mutated by the
86
+ * sized-repack realloc path. */
87
+ dataBytes: number;
88
+ typeId: number;
89
+ pack: (val: unknown, dst: Float32Array, off: number) => void;
79
90
  refcount: number;
91
+ /** Present for size-variable payloads (attribute buffers): re-derive
92
+ * the placement from the CURRENT value so `repack` can realloc when
93
+ * the payload grew or shrank (geometry morphing — epoch switches,
94
+ * point add/remove). Absent = fixed-size (plain uniforms). */
95
+ replan?: (val: unknown) => PoolPlacement;
96
+ }
97
+
98
+ /** A pool allocation that moved during a sized repack — every
99
+ * drawHeader field referencing (aval, chunkIdx, oldRef) must be
100
+ * rewritten to newRef by the caller. */
101
+ export interface MovedRef {
102
+ readonly av: aval<unknown>;
103
+ readonly chunkIdx: number;
104
+ readonly oldRef: number;
105
+ readonly newRef: number;
80
106
  }
81
107
 
82
108
  /**
@@ -200,10 +226,12 @@ export class UniformPool {
200
226
  typeId: number,
201
227
  length: number,
202
228
  pack: (val: unknown, dst: Float32Array, off: number) => void,
229
+ replan?: (val: unknown) => PoolPlacement,
203
230
  ): number {
204
231
  const existing = this.getEntry(av, chunkIdx);
205
232
  if (existing !== undefined) {
206
233
  existing.refcount++;
234
+ if (replan !== undefined && existing.replan === undefined) existing.replan = replan;
207
235
  return existing.ref;
208
236
  }
209
237
  const r = arena.alloc(dataBytes, chunkIdx);
@@ -237,6 +265,7 @@ export class UniformPool {
237
265
  void device;
238
266
  this.setEntry(av, {
239
267
  chunkIdx: finalChunk, ref: r.off, dataBytes, typeId, pack, refcount: 1,
268
+ ...(replan !== undefined ? { replan } : {}),
240
269
  });
241
270
  return r.off;
242
271
  }
@@ -282,9 +311,44 @@ export class UniformPool {
282
311
  arena: ChunkedAttributeArena,
283
312
  av: aval<unknown>,
284
313
  val: unknown,
285
- ): void {
314
+ ): MovedRef[] | undefined {
286
315
  let dst: Float32Array | undefined;
316
+ let moved: MovedRef[] | undefined;
287
317
  for (const e of this.entriesOf(av)) {
318
+ if (e.replan !== undefined) {
319
+ // Size-variable payload: re-derive the placement from the new
320
+ // value. Same size → refresh the encoder and fall through to
321
+ // the in-place write; different size → realloc within the
322
+ // same chunk, rewrite header + data, and report the move so
323
+ // the caller re-seats every drawHeader field.
324
+ const p = e.replan(val);
325
+ if (p.dataBytes !== e.dataBytes) {
326
+ arena.release(e.chunkIdx, e.ref, e.dataBytes);
327
+ const r = arena.alloc(p.dataBytes, e.chunkIdx);
328
+ if (r.chunkIdx !== e.chunkIdx) {
329
+ throw new Error(
330
+ `UniformPool.repack: realloc spilled from chunk ${e.chunkIdx} to ${r.chunkIdx} ` +
331
+ `(${p.dataBytes} bytes) — the referencing bucket is bound to chunk ${e.chunkIdx}.`,
332
+ );
333
+ }
334
+ const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + p.dataBytes);
335
+ const buf = new ArrayBuffer(allocBytes);
336
+ const u32 = new Uint32Array(buf);
337
+ const f32 = new Float32Array(buf);
338
+ u32[0] = p.typeId;
339
+ u32[1] = p.length;
340
+ u32[2] = p.length > 0 ? Math.floor(p.dataBytes / p.length) : 0;
341
+ p.pack(val, f32, ALLOC_HEADER_PAD_TO / 4);
342
+ arena.write(e.chunkIdx, r.off, new Uint8Array(buf));
343
+ (moved ??= []).push({ av, chunkIdx: e.chunkIdx, oldRef: e.ref, newRef: r.off });
344
+ e.ref = r.off;
345
+ e.dataBytes = p.dataBytes;
346
+ e.typeId = p.typeId;
347
+ e.pack = p.pack;
348
+ continue;
349
+ }
350
+ e.pack = p.pack;
351
+ }
288
352
  if (dst === undefined || dst.length !== e.dataBytes / 4) {
289
353
  dst = new Float32Array(e.dataBytes / 4);
290
354
  }
@@ -296,6 +360,7 @@ export class UniformPool {
296
360
  );
297
361
  }
298
362
  void device;
363
+ return moved;
299
364
  }
300
365
  /** Total bytes touched by `repack` for this aval — sum across
301
366
  * chunks. Used by the dirty-bytes diagnostics. */
@@ -91,6 +91,7 @@ import {
91
91
  ENC_V3F_TIGHT, ENC_OCT32, ENC_C4B, SEM_POSITIONS, SEM_NORMALS,
92
92
  COMPACTION_WASTE_FLOOR_BYTES,
93
93
  type ArenaState,
94
+ type PoolPlacement,
94
95
  } from "./heapScene/pools.js";
95
96
  import { encodeModeKey, type PipelineStateDescriptor } from "./pipelineCache/index.js";
96
97
  import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
@@ -385,6 +386,12 @@ interface Bucket {
385
386
  * keyed by aval identity.
386
387
  */
387
388
  readonly localPerDrawAvals: (aval<unknown>[] | undefined)[];
389
+ /** drawHeader field index per pooled aval — PARALLEL to
390
+ * `localPerDrawAvals[slot]`. Lets the sized-repack drain rewrite
391
+ * exactly the fields whose aval's allocation moved (matching by
392
+ * aval identity, never by numeric offset — a released offset can
393
+ * be re-issued to a different aval within the same drain). */
394
+ readonly localPerDrawAvalFieldIdx: (number[] | undefined)[];
388
395
  /** Field indices (into `layout.drawHeaderFields`) whose drawHeader
389
396
  * cells hold RAW arena refs (§7 output slots, no pool entry). */
390
397
  readonly localPerDrawRawFieldIdx: (number[] | undefined)[];
@@ -1082,6 +1089,7 @@ export function buildHeapScene(
1082
1089
  const activeDirty = new Set<number>();
1083
1090
 
1084
1091
  function subscribeActive(av: aval<boolean>, drawId: number): void {
1092
+ if (av.isConstant) return; // fixed visibility — nothing will ever tick
1085
1093
  let set = activeAvalToDrawIds.get(av);
1086
1094
  if (set === undefined) {
1087
1095
  set = new Set<number>();
@@ -1150,7 +1158,87 @@ export function buildHeapScene(
1150
1158
  }
1151
1159
  }
1152
1160
 
1161
+ // ─── HeapDrawSpec.instanceCount as an aval ────────────────────────
1162
+ // The geometry-morphing / collection-count path (epoch switches,
1163
+ // point add/remove): a count tick is ONE drawTable write (record
1164
+ // word 4) + emit re-estimate + re-scan — no add/remove churn, no
1165
+ // arena activity. Instance ATTRIBUTE payloads ride the sized-repack
1166
+ // path independently; per-instance UNIFORM arrays (packed per
1167
+ // count) are rejected with an adaptive count at addDraw.
1168
+ const drawIdToCountAval = new Map<number, aval<number>>();
1169
+ const drawIdToCountCallback = new Map<number, IDisposable>();
1170
+ const countDirty = new Set<number>();
1171
+
1172
+ function subscribeCount(av: aval<number>, drawId: number): void {
1173
+ if (av.isConstant) return; // fixed count — nothing will ever tick
1174
+ drawIdToCountAval.set(drawId, av);
1175
+ drawIdToCountCallback.set(
1176
+ drawId,
1177
+ addMarkingCallback(av, () => { countDirty.add(drawId); }),
1178
+ );
1179
+ }
1180
+
1181
+ function unsubscribeCount(drawId: number): void {
1182
+ const cb = drawIdToCountCallback.get(drawId);
1183
+ if (cb === undefined) return;
1184
+ cb.dispose();
1185
+ drawIdToCountCallback.delete(drawId);
1186
+ drawIdToCountAval.delete(drawId);
1187
+ countDirty.delete(drawId);
1188
+ }
1189
+
1190
+ /** Update the drawTable record's instanceCount in place — the
1191
+ * word-4 sibling of `setEffectiveIndexCount`. */
1192
+ function setEffectiveInstanceCount(drawId: number, newCount: number): void {
1193
+ const bucket = drawIdToBucket[drawId];
1194
+ if (bucket === undefined) return;
1195
+ if (bucket.gpuRouted) {
1196
+ const partition = bucket.partitionScene!;
1197
+ const recIdx = bucket.drawIdToRecord!.get(drawId);
1198
+ if (recIdx === undefined) return;
1199
+ const ru32 = partition.recordU32;
1200
+ const indexCount = partition.masterShadow[recIdx * ru32 + 3]!;
1201
+ const oldCount = partition.masterShadow[recIdx * ru32 + 4]!;
1202
+ partition.masterShadow[recIdx * ru32 + 4] = newCount >>> 0;
1203
+ const delta = indexCount * (newCount - oldCount);
1204
+ bucket.partitionDirty = true;
1205
+ for (const sl of bucket.slots) {
1206
+ sl.totalEmitEstimate = Math.max(0, sl.totalEmitEstimate + delta);
1207
+ sl.scanDirty = true;
1208
+ }
1209
+ } else {
1210
+ const slotIdx = drawIdToSlotIdx[drawId];
1211
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
1212
+ if (slot === undefined) return;
1213
+ const localSlot = drawIdToLocalSlot[drawId];
1214
+ if (localSlot === undefined) return;
1215
+ const recIdx = slot.slotToRecord[localSlot];
1216
+ if (recIdx === undefined || recIdx < 0) return;
1217
+ const shadow = slot.drawTableShadow!;
1218
+ const indexCount = shadow[recIdx * RECORD_U32 + 3]!;
1219
+ const oldCount = shadow[recIdx * RECORD_U32 + 4]!;
1220
+ shadow[recIdx * RECORD_U32 + 4] = newCount >>> 0;
1221
+ const byteOff = recIdx * RECORD_BYTES + 4 * 4;
1222
+ if (byteOff < slot.drawTableDirtyMin) slot.drawTableDirtyMin = byteOff;
1223
+ if (byteOff + 4 > slot.drawTableDirtyMax) slot.drawTableDirtyMax = byteOff + 4;
1224
+ const delta = indexCount * (newCount - oldCount);
1225
+ slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate + delta);
1226
+ slot.scanDirty = true;
1227
+ }
1228
+ const localSlot = drawIdToLocalSlot[drawId];
1229
+ if (localSlot !== undefined) {
1230
+ const e = bucket.localEntries[localSlot];
1231
+ if (e !== undefined) e.instanceCount = newCount;
1232
+ }
1233
+ }
1234
+
1153
1235
  function subscribeModeLeaf(av: aval<unknown>, drawId: number): void {
1236
+ // A CONSTANT leaf can never mark — subscribing would allocate a
1237
+ // MultiCallbackObject + callback Map + disposable + a draw-id Set
1238
+ // PER AVAL for nothing. At collection scale the per-row uniform
1239
+ // wrappers are overwhelmingly constants; this guard was worth
1240
+ // ~4-5 KB per row-pass (measured, WBOIT 2k).
1241
+ if (av.isConstant) return;
1154
1242
  let set = modeAvalToDrawIds.get(av);
1155
1243
  if (set === undefined) {
1156
1244
  set = new Set<number>();
@@ -3102,7 +3190,7 @@ export function buildHeapScene(
3102
3190
  headerDirtyMin: Infinity, headerDirtyMax: 0,
3103
3191
  localPosRefs: [], localNorRefs: [],
3104
3192
  localEntries: [], localToDrawId: [],
3105
- localPerDrawAvals: [], localPerDrawRawFieldIdx: [], localPerDrawRefs: [], localLayoutIds: [],
3193
+ localPerDrawAvals: [], localPerDrawAvalFieldIdx: [], localPerDrawRawFieldIdx: [], localPerDrawRefs: [], localLayoutIds: [],
3106
3194
  fieldIdx,
3107
3195
  posFieldIdx: fieldIdx.get("Positions") ?? -1,
3108
3196
  norFieldIdx: fieldIdx.get("Normals") ?? -1,
@@ -3376,8 +3464,24 @@ export function buildHeapScene(
3376
3464
  const hasIndices = spec.indices !== undefined;
3377
3465
  const indicesAval = hasIndices ? (asAval(spec.indices!) as aval<Uint32Array>) : undefined;
3378
3466
  const indexArr = hasIndices ? (readPlain(spec.indices!) as Uint32Array) : undefined;
3467
+ const icAval =
3468
+ spec.instanceCount !== undefined && typeof spec.instanceCount === "object"
3469
+ && typeof (spec.instanceCount as { getValue?: unknown }).getValue === "function"
3470
+ ? (spec.instanceCount as aval<number>)
3471
+ : undefined;
3379
3472
  const instanceCount = spec.instanceCount !== undefined
3380
3473
  ? (readPlain(spec.instanceCount) as number) : 1;
3474
+ if (icAval !== undefined && perInstanceUniforms.size > 0) {
3475
+ // Per-instance UNIFORM arrays are packed `instanceCount` deep at
3476
+ // addDraw — a changing count would need a repack protocol those
3477
+ // packers don't have. Attribute-backed instancing (BufferViews /
3478
+ // raw arrays) is unaffected: the payload rides the sized-repack
3479
+ // path and the count merely selects a prefix.
3480
+ throw new Error(
3481
+ "heapScene: adaptive instanceCount is not supported together with per-instance UNIFORM values " +
3482
+ `(fields: ${[...perInstanceUniforms].join(", ")}) — use instance attributes instead`,
3483
+ );
3484
+ }
3381
3485
 
3382
3486
  let estBytes = indexArr !== undefined ? ALIGN16(ALLOC_HEADER_PAD_TO + indexArr.byteLength) : 0;
3383
3487
  for (const f of layout.drawHeaderFields) {
@@ -3500,6 +3604,7 @@ export function buildHeapScene(
3500
3604
  // packs as a single value. Both go through the same pool — sharing
3501
3605
  // emerges from aval identity either way.
3502
3606
  const perDrawAvals: aval<unknown>[] = [];
3607
+ const perDrawAvalFieldIdx: number[] = [];
3503
3608
  let perDrawRawFieldIdx: number[] | undefined;
3504
3609
  const perDrawRefs = new Int32Array(bucket.layout.drawHeaderFields.length).fill(-1);
3505
3610
  const setRef = (name: string, ref: number): void => {
@@ -3594,9 +3699,13 @@ export function buildHeapScene(
3594
3699
  let value: unknown;
3595
3700
  let placement: ReturnType<typeof poolPlacementFor>;
3596
3701
  let releasable: { _v: unknown } | undefined;
3702
+ let replanFn: ((v: unknown) => PoolPlacement) | undefined;
3597
3703
  if (f.kind === "attribute-ref" && !isPerInstanceUniformField && isBufferView(provided)) {
3598
3704
  const bv = provided;
3599
3705
  placement = bufferViewPlacement(f, bv);
3706
+ // Size-variable payload: geometry morphing re-derives the
3707
+ // placement from the view's CURRENT buffer value.
3708
+ replanFn = () => bufferViewPlacement(f, bv);
3600
3709
  av = bv.buffer as aval<unknown>;
3601
3710
  value = bv.buffer.getValue(tok);
3602
3711
  } else if (
@@ -3630,14 +3739,19 @@ export function buildHeapScene(
3630
3739
  placement = isPerInstanceUniformField
3631
3740
  ? perInstancePlacementFor(f, value, instanceCount)
3632
3741
  : poolPlacementFor(f, value);
3742
+ if (f.kind === "attribute-ref" && !isPerInstanceUniformField) {
3743
+ replanFn = (v) => poolPlacementFor(f, v);
3744
+ }
3633
3745
  }
3634
3746
  const ref = pool.acquire(
3635
3747
  device, arena.attrs, av, bucket.chunkIdx, value,
3636
3748
  placement.dataBytes, placement.typeId, placement.length, placement.pack,
3749
+ replanFn,
3637
3750
  );
3638
3751
  if (releasable !== undefined) releasable._v = null; // staged — drop the CPU copy
3639
3752
  setRef(f.name, ref);
3640
3753
  perDrawAvals.push(av);
3754
+ perDrawAvalFieldIdx.push(bucket.fieldIdx.get(f.name)!);
3641
3755
  }
3642
3756
  }
3643
3757
 
@@ -3649,6 +3763,7 @@ export function buildHeapScene(
3649
3763
  bucket.localToDrawId[localSlot] = drawId;
3650
3764
  bucket.drawSlots.add(localSlot);
3651
3765
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
3766
+ bucket.localPerDrawAvalFieldIdx[localSlot] = perDrawAvalFieldIdx;
3652
3767
  bucket.localPerDrawRawFieldIdx[localSlot] = perDrawRawFieldIdx;
3653
3768
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
3654
3769
  const layoutId = fam.schema.layoutIdOf.get(spec.effect)!;
@@ -3850,6 +3965,9 @@ export function buildHeapScene(
3850
3965
  setEffectiveIndexCount(drawId, 0);
3851
3966
  }
3852
3967
  }
3968
+ if (icAval !== undefined) {
3969
+ subscribeCount(icAval, drawId);
3970
+ }
3853
3971
 
3854
3972
  // ─── Reactive rebucket: track PS-modeKey changes ────────────────
3855
3973
  // When any mode-axis aval marks (e.g. cullCval.value = 'front'),
@@ -3966,6 +4084,7 @@ export function buildHeapScene(
3966
4084
  // Tear down `active` subscription (if any) so its callback won't
3967
4085
  // fire after the underlying drawTable record is gone.
3968
4086
  unsubscribeActive(drawId);
4087
+ unsubscribeCount(drawId);
3969
4088
  // §7: deregister this RO's derivation records and release slots.
3970
4089
  if (derivedScene !== undefined) {
3971
4090
  const reg = derivedByDrawId.get(drawId);
@@ -4100,6 +4219,7 @@ export function buildHeapScene(
4100
4219
  bucket.localAtlasArrIdx[localSlot] = undefined;
4101
4220
 
4102
4221
  bucket.localPerDrawAvals[localSlot] = undefined;
4222
+ bucket.localPerDrawAvalFieldIdx[localSlot] = undefined;
4103
4223
  bucket.localPerDrawRefs[localSlot] = undefined;
4104
4224
  bucket.localLayoutIds[localSlot] = undefined;
4105
4225
  bucket.localPosRefs[localSlot] = undefined;
@@ -4490,11 +4610,63 @@ export function buildHeapScene(
4490
4610
  // One writeBuffer per dirty aval, regardless of how many draws
4491
4611
  // reference it — sharing pays off here.
4492
4612
  if (allocDirty.size > 0) {
4613
+ // aval → (chunkIdx → newRef) for allocations the sized repack
4614
+ // moved (payload grew/shrank → realloc'd within the chunk).
4615
+ let movedByAval: Map<aval<unknown>, Map<number, number>> | undefined;
4493
4616
  for (const av of allocDirty) {
4494
- pool.repack(device, arena.attrs, av, av.getValue(tok));
4617
+ const moved = pool.repack(device, arena.attrs, av, av.getValue(tok));
4495
4618
  totalDirtyBytes += pool.totalDataBytes(av);
4619
+ if (moved !== undefined) {
4620
+ for (const m of moved) {
4621
+ let byChunk = (movedByAval ??= new Map()).get(m.av);
4622
+ if (byChunk === undefined) { byChunk = new Map(); movedByAval.set(m.av, byChunk); }
4623
+ byChunk.set(m.chunkIdx, m.newRef);
4624
+ }
4625
+ }
4496
4626
  }
4497
4627
  allocDirty.clear();
4628
+ if (movedByAval !== undefined) {
4629
+ // Re-seat every drawHeader field whose aval's allocation
4630
+ // moved. Matched by AVAL IDENTITY via the parallel
4631
+ // `localPerDrawAvalFieldIdx` — never by numeric offset (a
4632
+ // released offset can be re-issued to a DIFFERENT aval in
4633
+ // this same drain). Mirrors the compaction remap loop.
4634
+ for (const bucket of buckets) {
4635
+ for (const localSlot of bucket.drawSlots) {
4636
+ const avals = bucket.localPerDrawAvals[localSlot];
4637
+ const fidx = bucket.localPerDrawAvalFieldIdx[localSlot];
4638
+ const refs = bucket.localPerDrawRefs[localSlot];
4639
+ if (avals === undefined || fidx === undefined || refs === undefined) continue;
4640
+ let changed = false;
4641
+ for (let i = 0; i < avals.length; i++) {
4642
+ const byChunk = movedByAval.get(avals[i]!);
4643
+ if (byChunk === undefined) continue;
4644
+ const nn = byChunk.get(bucket.chunkIdx);
4645
+ if (nn === undefined) continue;
4646
+ if (bucket.gpuRouted) {
4647
+ throw new Error(
4648
+ "heapScene: payload realloc under a GPU-routed (derived-modes) bucket is not supported yet",
4649
+ );
4650
+ }
4651
+ if (refs[fidx[i]!]! !== nn) { refs[fidx[i]!] = nn; changed = true; }
4652
+ }
4653
+ if (!changed) continue;
4654
+ const pr = bucket.posFieldIdx >= 0 ? refs[bucket.posFieldIdx]! : -1;
4655
+ const nr = bucket.norFieldIdx >= 0 ? refs[bucket.norFieldIdx]! : -1;
4656
+ bucket.localPosRefs[localSlot] = pr < 0 ? undefined : pr;
4657
+ bucket.localNorRefs[localSlot] = nr < 0 ? undefined : nr;
4658
+ packBucketHeader(bucket, localSlot, refs, bucket.localLayoutIds[localSlot] ?? 0);
4659
+ if (bucket.isAtlasBucket) {
4660
+ const ts = bucket.localAtlasTextures[localSlot];
4661
+ if (ts !== undefined) packAtlasTextureFields(bucket, localSlot, ts);
4662
+ }
4663
+ const byteOff = localSlot * bucket.layout.drawHeaderBytes;
4664
+ if (byteOff < bucket.headerDirtyMin) bucket.headerDirtyMin = byteOff;
4665
+ const end = byteOff + bucket.layout.drawHeaderBytes;
4666
+ if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
4667
+ }
4668
+ }
4669
+ }
4498
4670
  }
4499
4671
 
4500
4672
  // 1c. HeapDrawSpec.active drain — flip drawTable.indexCount
@@ -4513,6 +4685,18 @@ export function buildHeapScene(
4513
4685
  activeDirty.clear();
4514
4686
  }
4515
4687
 
4688
+ // 1d. HeapDrawSpec.instanceCount aval drain — write the new
4689
+ // count into drawTable word 4 (emit = indexCount × count;
4690
+ // 0 draws nothing). One record write + re-scan per tick.
4691
+ if (countDirty.size > 0) {
4692
+ for (const did of countDirty) {
4693
+ const av = drawIdToCountAval.get(did);
4694
+ if (av === undefined) continue;
4695
+ setEffectiveInstanceCount(did, Math.max(0, av.getValue(tok) | 0));
4696
+ }
4697
+ countDirty.clear();
4698
+ }
4699
+
4516
4700
  // 1b. Atlas-texture aval reactivity: an `aval<ITexture>` that
4517
4701
  // drives an atlas placement was marked. Repack the pool entry
4518
4702
  // and rewrite the drawHeader fields of every (bucket, slot)