@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.
- package/dist/core/renderObject.d.ts +16 -0
- package/dist/core/renderObject.d.ts.map +1 -1
- package/dist/runtime/heapAdapter.d.ts.map +1 -1
- package/dist/runtime/heapAdapter.js +24 -0
- package/dist/runtime/heapAdapter.js.map +1 -1
- package/dist/runtime/heapScene/freelist.d.ts +5 -0
- package/dist/runtime/heapScene/freelist.d.ts.map +1 -1
- package/dist/runtime/heapScene/freelist.js +28 -0
- package/dist/runtime/heapScene/freelist.js.map +1 -1
- package/dist/runtime/heapScene/pools.d.ts +19 -0
- package/dist/runtime/heapScene/pools.d.ts.map +1 -1
- package/dist/runtime/heapScene/pools.js +115 -12
- package/dist/runtime/heapScene/pools.js.map +1 -1
- package/dist/runtime/heapScene.d.ts +21 -1
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +237 -10
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/hybridScene.d.ts +8 -0
- package/dist/runtime/hybridScene.d.ts.map +1 -1
- package/dist/runtime/hybridScene.js +15 -0
- package/dist/runtime/hybridScene.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.d.ts +12 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.js +20 -0
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
- package/package.json +2 -2
- package/src/core/renderObject.ts +16 -0
- package/src/runtime/heapAdapter.ts +24 -0
- package/src/runtime/heapScene/freelist.ts +35 -0
- package/src/runtime/heapScene/pools.ts +131 -12
- package/src/runtime/heapScene.ts +257 -11
- package/src/runtime/hybridScene.ts +23 -0
- 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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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;
|
package/src/runtime/heapScene.ts
CHANGED
|
@@ -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
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
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
|
|
3251
|
-
|
|
3252
|
-
|
|
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
|
-
|
|
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 !==
|
|
4351
|
+
if (sentinel !== expectedSentinel) {
|
|
4108
4352
|
push(`bucket#${bucketIdx} firstDrawInTile[${dc.numTiles}] sentinel ` +
|
|
4109
|
-
`got=${sentinel} expected=${
|
|
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
|
-
|
|
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 {
|