@aardworx/wombat.rendering 0.19.24 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/elementType.d.ts +2 -0
- package/dist/core/elementType.d.ts.map +1 -1
- package/dist/core/elementType.js +10 -0
- package/dist/core/elementType.js.map +1 -1
- package/dist/core/provider.d.ts.map +1 -1
- package/dist/core/provider.js +65 -32
- package/dist/core/provider.js.map +1 -1
- package/dist/core/renderObject.d.ts +18 -0
- package/dist/core/renderObject.d.ts.map +1 -1
- package/dist/resources/preparedRenderObject.d.ts.map +1 -1
- package/dist/resources/preparedRenderObject.js +28 -5
- package/dist/resources/preparedRenderObject.js.map +1 -1
- package/dist/resources/uniformBuffer.d.ts.map +1 -1
- package/dist/resources/uniformBuffer.js +3 -1
- package/dist/resources/uniformBuffer.js.map +1 -1
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts +2 -12
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
- package/dist/runtime/derivedModes/modeKeyCpu.js +30 -13
- package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts +11 -0
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -1
- package/dist/runtime/derivedModes/partitionDispatcher.js +31 -0
- package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -1
- package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/codegen.js +34 -13
- package/dist/runtime/derivedUniforms/codegen.js.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.js +6 -0
- package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
- package/dist/runtime/derivedUniforms/records.d.ts +15 -0
- package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/records.js +81 -14
- package/dist/runtime/derivedUniforms/records.js.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.js +18 -4
- package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
- package/dist/runtime/flattenTree.d.ts.map +1 -1
- package/dist/runtime/flattenTree.js +46 -2
- package/dist/runtime/flattenTree.js.map +1 -1
- package/dist/runtime/heapAdapter.d.ts +3 -1
- package/dist/runtime/heapAdapter.d.ts.map +1 -1
- package/dist/runtime/heapAdapter.js +45 -4
- package/dist/runtime/heapAdapter.js.map +1 -1
- package/dist/runtime/heapDecoder.d.ts.map +1 -1
- package/dist/runtime/heapDecoder.js +8 -3
- package/dist/runtime/heapDecoder.js.map +1 -1
- package/dist/runtime/heapEffect.d.ts +36 -1
- package/dist/runtime/heapEffect.d.ts.map +1 -1
- package/dist/runtime/heapEffect.js +29 -2
- package/dist/runtime/heapEffect.js.map +1 -1
- package/dist/runtime/heapEffectIR.d.ts.map +1 -1
- package/dist/runtime/heapEffectIR.js +51 -11
- package/dist/runtime/heapEffectIR.js.map +1 -1
- package/dist/runtime/heapEligibility.d.ts.map +1 -1
- package/dist/runtime/heapEligibility.js +64 -12
- package/dist/runtime/heapEligibility.js.map +1 -1
- package/dist/runtime/heapIrBuilders.d.ts +8 -0
- package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
- package/dist/runtime/heapIrBuilders.js +47 -2
- package/dist/runtime/heapIrBuilders.js.map +1 -1
- package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
- package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
- package/dist/runtime/heapScene/chunkedArena.js +28 -91
- package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
- package/dist/runtime/heapScene/freelist.d.ts +1 -1
- package/dist/runtime/heapScene/freelist.js +3 -3
- package/dist/runtime/heapScene/freelist.js.map +1 -1
- package/dist/runtime/heapScene/pools.d.ts +103 -59
- package/dist/runtime/heapScene/pools.d.ts.map +1 -1
- package/dist/runtime/heapScene/pools.js +335 -194
- package/dist/runtime/heapScene/pools.js.map +1 -1
- package/dist/runtime/heapScene.d.ts +125 -1
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +671 -126
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/heapShaderFamily.d.ts.map +1 -1
- package/dist/runtime/heapShaderFamily.js +19 -0
- package/dist/runtime/heapShaderFamily.js.map +1 -1
- package/dist/runtime/hybridScene.d.ts +6 -0
- package/dist/runtime/hybridScene.d.ts.map +1 -1
- package/dist/runtime/hybridScene.js +36 -3
- package/dist/runtime/hybridScene.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/renderTask.d.ts +6 -0
- package/dist/runtime/renderTask.d.ts.map +1 -1
- package/dist/runtime/renderTask.js +3 -0
- package/dist/runtime/renderTask.js.map +1 -1
- package/dist/runtime/runtime.d.ts +15 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +3 -0
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +19 -2
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +37 -6
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.d.ts +5 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.js +25 -3
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
- package/dist/window/loop.d.ts.map +1 -1
- package/dist/window/loop.js +31 -4
- package/dist/window/loop.js.map +1 -1
- package/package.json +3 -3
- package/src/core/elementType.ts +10 -0
- package/src/core/provider.ts +56 -28
- package/src/core/renderObject.ts +18 -0
- package/src/resources/preparedRenderObject.ts +29 -6
- package/src/resources/uniformBuffer.ts +3 -1
- package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
- package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
- package/src/runtime/derivedUniforms/codegen.ts +34 -13
- package/src/runtime/derivedUniforms/dispatch.ts +6 -0
- package/src/runtime/derivedUniforms/records.ts +70 -15
- package/src/runtime/derivedUniforms/sceneIntegration.ts +19 -4
- package/src/runtime/flattenTree.ts +47 -2
- package/src/runtime/heapAdapter.ts +43 -5
- package/src/runtime/heapDecoder.ts +8 -3
- package/src/runtime/heapEffect.ts +44 -2
- package/src/runtime/heapEffectIR.ts +54 -11
- package/src/runtime/heapEligibility.ts +39 -12
- package/src/runtime/heapIrBuilders.ts +50 -2
- package/src/runtime/heapScene/chunkedArena.ts +29 -94
- package/src/runtime/heapScene/freelist.ts +3 -3
- package/src/runtime/heapScene/pools.ts +340 -218
- package/src/runtime/heapScene.ts +826 -142
- package/src/runtime/heapShaderFamily.ts +21 -0
- package/src/runtime/hybridScene.ts +43 -3
- package/src/runtime/index.ts +5 -0
- package/src/runtime/renderTask.ts +9 -0
- package/src/runtime/runtime.ts +18 -0
- package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +52 -3
- package/src/runtime/textureAtlas/atlasPool.ts +28 -2
- package/src/window/loop.ts +32 -4
package/src/runtime/heapScene.ts
CHANGED
|
@@ -51,7 +51,7 @@ import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
|
|
|
51
51
|
import type { PipelineState } from "../core/pipelineState.js";
|
|
52
52
|
import type { BufferView } from "../core/bufferView.js";
|
|
53
53
|
import type { IBuffer, HostBufferSource } from "../core/buffer.js";
|
|
54
|
-
import {
|
|
54
|
+
import { INLINE_HEADER_UNIFORMS,
|
|
55
55
|
buildBucketLayout, compileHeapEffect,
|
|
56
56
|
type BucketLayout, type FragmentOutputLayout,
|
|
57
57
|
type HeapEffectSchema,
|
|
@@ -85,11 +85,11 @@ import {
|
|
|
85
85
|
} from "./heapScene/growBuffer.js";
|
|
86
86
|
import {
|
|
87
87
|
UniformPool, IndexPool, DrawHeap,
|
|
88
|
-
AttributeArena, IndexAllocator,
|
|
89
88
|
buildArenaState, arenaBytes, writeAttribute,
|
|
90
89
|
asAval, isBufferView, asFloat32,
|
|
91
90
|
ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO,
|
|
92
|
-
ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS,
|
|
91
|
+
ENC_V3F_TIGHT, ENC_OCT32, ENC_C4B, SEM_POSITIONS, SEM_NORMALS,
|
|
92
|
+
COMPACTION_WASTE_FLOOR_BYTES,
|
|
93
93
|
type ArenaState,
|
|
94
94
|
} from "./heapScene/pools.js";
|
|
95
95
|
import { encodeModeKey, type PipelineStateDescriptor } from "./pipelineCache/index.js";
|
|
@@ -110,6 +110,49 @@ import {
|
|
|
110
110
|
type Expr, type Stmt,
|
|
111
111
|
} from "@aardworx/wombat.shader/ir";
|
|
112
112
|
|
|
113
|
+
const NOOP = (): void => {};
|
|
114
|
+
|
|
115
|
+
// ─── Shared pool packers ──────────────────────────────────────────────
|
|
116
|
+
// Static functions where possible: a fresh closure per pool entry was
|
|
117
|
+
// measurable ballast at heap scale (3 closures/leaf). Only the
|
|
118
|
+
// broadcast prefix variant needs a capture (the copy limit differs
|
|
119
|
+
// from the source buffer's size).
|
|
120
|
+
type PoolPackFn = (val: unknown, dst: Float32Array, off: number) => void;
|
|
121
|
+
const packPackedAttr: PoolPackFn = (val, dst, off) => {
|
|
122
|
+
const src = (val as HeapPackedAttribute).data;
|
|
123
|
+
new Uint32Array(dst.buffer, dst.byteOffset + off * 4, src.length).set(src);
|
|
124
|
+
};
|
|
125
|
+
const packF32Verbatim: PoolPackFn = (val, dst, off) => {
|
|
126
|
+
dst.set(val as Float32Array, off);
|
|
127
|
+
};
|
|
128
|
+
const packHostU32Bits: PoolPackFn = (val, dst, off) => {
|
|
129
|
+
const ibuf = val as IBuffer;
|
|
130
|
+
if (ibuf.kind !== "host") {
|
|
131
|
+
throw new Error("heapScene: packed BufferView aval flipped to native GPUBuffer");
|
|
132
|
+
}
|
|
133
|
+
const d = ibuf.data as ArrayBufferView;
|
|
134
|
+
// reinterpret the u32 payload as f32 bits for the arena write
|
|
135
|
+
dst.set(new Float32Array(d.buffer, d.byteOffset, d.byteLength >>> 2), off);
|
|
136
|
+
};
|
|
137
|
+
const packHostF32: PoolPackFn = (val, dst, off) => {
|
|
138
|
+
const ibuf = val as IBuffer;
|
|
139
|
+
if (ibuf.kind !== "host") {
|
|
140
|
+
throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
|
|
141
|
+
}
|
|
142
|
+
dst.set(asFloat32(ibuf.data), off);
|
|
143
|
+
};
|
|
144
|
+
/** Broadcast prefix copy — the only variant that genuinely captures. */
|
|
145
|
+
function packHostPrefix(limitFloats: number): PoolPackFn {
|
|
146
|
+
return (val, dst, off) => {
|
|
147
|
+
const ibuf = val as IBuffer;
|
|
148
|
+
if (ibuf.kind !== "host") {
|
|
149
|
+
throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
|
|
150
|
+
}
|
|
151
|
+
const src = asFloat32(ibuf.data);
|
|
152
|
+
dst.set(src.subarray(0, limitFloats), off);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
113
156
|
// GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
|
|
114
157
|
// Packers (WGSL-type → JS-value → arena bytes) live in ./heapScene/packers.
|
|
115
158
|
|
|
@@ -148,6 +191,41 @@ import {
|
|
|
148
191
|
* Must match the literal in heapEffect.ts's `megacallSearchPrelude`. */
|
|
149
192
|
export const HEAP_NONINDEXED = 0xffffffff;
|
|
150
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Packed attribute source — stored in the arena at 4 B/element (one u32)
|
|
196
|
+
* instead of the 12 B tight-f32 expansion, decoded by the VS's
|
|
197
|
+
* typeId-selected arm. Use for memory-bound scenes (phones): both the
|
|
198
|
+
* host-side aval payload AND the GPU arena shrink 3×.
|
|
199
|
+
*
|
|
200
|
+
* - `oct32(...)` — unit vectors (normals): 2×unorm16 octahedral, for
|
|
201
|
+
* `vec3<f32>` fields. Matches build_vienna.py's `oct_pack`.
|
|
202
|
+
* - `c4b(...)` — RGBA8-unorm colors, for `vec4<f32>` fields
|
|
203
|
+
* (`unpack4x8unorm` in the shader; alpha rides along).
|
|
204
|
+
*/
|
|
205
|
+
export interface HeapPackedAttribute {
|
|
206
|
+
readonly kind: "heap-packed";
|
|
207
|
+
readonly enc: "oct32" | "c4b";
|
|
208
|
+
readonly data: Uint32Array;
|
|
209
|
+
}
|
|
210
|
+
export function oct32(data: Int32Array | Uint32Array): HeapPackedAttribute {
|
|
211
|
+
const u = data instanceof Uint32Array ? data : new Uint32Array(data.buffer, data.byteOffset, data.length);
|
|
212
|
+
return { kind: "heap-packed", enc: "oct32", data: u };
|
|
213
|
+
}
|
|
214
|
+
export function c4b(data: Uint8Array | Uint32Array): HeapPackedAttribute {
|
|
215
|
+
if (data instanceof Uint8Array) {
|
|
216
|
+
if (data.byteLength % 4 !== 0 || data.byteOffset % 4 !== 0) {
|
|
217
|
+
throw new Error("heapScene.c4b: RGBA8 data must be 4-byte sized and aligned");
|
|
218
|
+
}
|
|
219
|
+
return { kind: "heap-packed", enc: "c4b", data: new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4) };
|
|
220
|
+
}
|
|
221
|
+
return { kind: "heap-packed", enc: "c4b", data };
|
|
222
|
+
}
|
|
223
|
+
export function isHeapPackedAttribute(v: unknown): v is HeapPackedAttribute {
|
|
224
|
+
return typeof v === "object" && v !== null
|
|
225
|
+
&& (v as { kind?: unknown }).kind === "heap-packed"
|
|
226
|
+
&& (v as { data?: unknown }).data instanceof Uint32Array;
|
|
227
|
+
}
|
|
228
|
+
|
|
151
229
|
export interface HeapGeometry {
|
|
152
230
|
readonly positions: Float32Array;
|
|
153
231
|
readonly normals: Float32Array;
|
|
@@ -272,6 +350,14 @@ interface Bucket {
|
|
|
272
350
|
readonly slots: BucketSlot[];
|
|
273
351
|
/** Repointed by `rebuildBindGroups` whenever any backing GrowBuffer reallocates. */
|
|
274
352
|
bindGroup: GPUBindGroup;
|
|
353
|
+
/** Scene-shared user storage buffers (layout.storageBindings), set on the
|
|
354
|
+
* first addDraw that supplies them. */
|
|
355
|
+
storageBuffers: ReadonlyMap<string, aval<IBuffer>> | undefined;
|
|
356
|
+
/** Set when a storage aval marks (e.g. the OIT head buffer reallocating on
|
|
357
|
+
* resize) — update() rebuilds this bucket's slot bind groups. */
|
|
358
|
+
storageDirty: boolean;
|
|
359
|
+
/** Disposers for the storage avals' marking subscriptions. */
|
|
360
|
+
storageSubs: (() => void)[];
|
|
275
361
|
|
|
276
362
|
// Per-bucket buffers
|
|
277
363
|
readonly drawHeap: DrawHeap;
|
|
@@ -299,13 +385,24 @@ interface Bucket {
|
|
|
299
385
|
* keyed by aval identity.
|
|
300
386
|
*/
|
|
301
387
|
readonly localPerDrawAvals: (aval<unknown>[] | undefined)[];
|
|
388
|
+
/** Field indices (into `layout.drawHeaderFields`) whose drawHeader
|
|
389
|
+
* cells hold RAW arena refs (§7 output slots, no pool entry). */
|
|
390
|
+
readonly localPerDrawRawFieldIdx: (number[] | undefined)[];
|
|
302
391
|
/**
|
|
303
|
-
* Per-draw uniform refs
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
392
|
+
* Per-draw uniform refs for this slot, indexed by the bucket
|
|
393
|
+
* layout's drawHeaderFields ORDER (−1 = field not populated by
|
|
394
|
+
* this slot's effect). A flat Int32Array instead of a
|
|
395
|
+
* Map<string, number> — at heap scale (hundreds of thousands of
|
|
396
|
+
* draws) the per-draw Map was one of the largest JS-memory items.
|
|
397
|
+
* Stable during the slot's lifetime; used to re-pack the
|
|
398
|
+
* DrawHeader after the drawHeap GrowBuffer reallocates.
|
|
307
399
|
*/
|
|
308
|
-
readonly localPerDrawRefs: (
|
|
400
|
+
readonly localPerDrawRefs: (Int32Array | undefined)[];
|
|
401
|
+
/** drawHeaderFields name → array index (built once per bucket). */
|
|
402
|
+
readonly fieldIdx: Map<string, number>;
|
|
403
|
+
/** fieldIdx.get("Positions") / ("Normals"), −1 when absent. */
|
|
404
|
+
readonly posFieldIdx: number;
|
|
405
|
+
readonly norFieldIdx: number;
|
|
309
406
|
/**
|
|
310
407
|
* Per-local-slot layoutId for the §6 family-merge selector. Stable
|
|
311
408
|
* during the slot's lifetime; written into the drawHeader's
|
|
@@ -412,6 +509,12 @@ export interface HeapDrawSpec {
|
|
|
412
509
|
* share a pipeline + bucket.
|
|
413
510
|
*/
|
|
414
511
|
readonly effect: Effect;
|
|
512
|
+
/**
|
|
513
|
+
* Optional picking id (u32 < 2^24), written INLINE into the
|
|
514
|
+
* drawHeader's `PickId` field (see `INLINE_HEADER_UNIFORMS`) — no
|
|
515
|
+
* pool entry, no per-draw aval.
|
|
516
|
+
*/
|
|
517
|
+
readonly pickId?: number;
|
|
415
518
|
/**
|
|
416
519
|
* Per-name inputs covering both vertex attributes (e.g. `Positions`,
|
|
417
520
|
* `Normals`) and uniforms (e.g. `ModelTrafo`, `Color`, `ViewProjTrafo`)
|
|
@@ -484,6 +587,14 @@ export interface HeapDrawSpec {
|
|
|
484
587
|
* alongside the effect id.
|
|
485
588
|
*/
|
|
486
589
|
readonly textures?: HeapTextureSet;
|
|
590
|
+
/**
|
|
591
|
+
* Scene-shared storage buffers for effects that declare
|
|
592
|
+
* `var<storage>` bindings (e.g. the OIT node pool injected by the
|
|
593
|
+
* transparency task). Bound ONCE at bucket level — every RO in a
|
|
594
|
+
* bucket must supply the SAME avals (they are scene-level by
|
|
595
|
+
* construction). Buffers must be GPU-backed IBuffers.
|
|
596
|
+
*/
|
|
597
|
+
readonly storageBuffers?: ReadonlyMap<string, aval<IBuffer>>;
|
|
487
598
|
/**
|
|
488
599
|
* Optional pipeline state (rasterizer / depth / blend). Two specs
|
|
489
600
|
* sharing the SAME `PipelineState` object share a bucket; distinct
|
|
@@ -552,6 +663,10 @@ export interface HeapSceneStats {
|
|
|
552
663
|
totalDraws: number;
|
|
553
664
|
drawBytes: number;
|
|
554
665
|
geometryBytes: number;
|
|
666
|
+
/** Cumulative count of waste-triggered arena compactions performed
|
|
667
|
+
* (one per chunk per `update` that compacted). Stays 0 when the
|
|
668
|
+
* arena never ratchets past the waste floor. */
|
|
669
|
+
compactions: number;
|
|
555
670
|
/** §7 derived-uniforms per-frame breakdown (last frame). */
|
|
556
671
|
derivedPullMs: number;
|
|
557
672
|
derivedUploadMs: number;
|
|
@@ -752,6 +867,127 @@ export interface BuildHeapSceneOptions {
|
|
|
752
867
|
* chunks duplicate their pool entries (accepted trade-off).
|
|
753
868
|
*/
|
|
754
869
|
readonly maxChunkBytes?: number;
|
|
870
|
+
/**
|
|
871
|
+
* Waste-triggered attribute-arena compaction. When enabled (default),
|
|
872
|
+
* `update()` relocates live arena allocations to the front of each chunk
|
|
873
|
+
* once fragmentation waste crosses `compactionWasteFloorBytes` and at least
|
|
874
|
+
* half the extent is wasted — preventing the high-water ratchet that
|
|
875
|
+
* exact-size freelist reuse can't fix under a drifting allocation-size
|
|
876
|
+
* distribution. Pass `false` to disable.
|
|
877
|
+
*/
|
|
878
|
+
readonly enableCompaction?: boolean;
|
|
879
|
+
/**
|
|
880
|
+
* Override the fragmentation-waste floor (bytes) below which compaction is
|
|
881
|
+
* skipped. Default `COMPACTION_WASTE_FLOOR_BYTES` (4 MiB). Tests pass a
|
|
882
|
+
* small value to exercise the path without realistic memory pressure.
|
|
883
|
+
*/
|
|
884
|
+
readonly compactionWasteFloorBytes?: number;
|
|
885
|
+
/**
|
|
886
|
+
* Pre-size the arena chunks (bytes). When the caller can estimate the
|
|
887
|
+
* scene's total arena footprint up front (e.g. from an asset manifest),
|
|
888
|
+
* passing it here allocates each chunk at its final size immediately —
|
|
889
|
+
* eliminating the pow2-grow copies during ingest, whose old+new-buffer
|
|
890
|
+
* transient is what OOM-kills memory-tight targets (phones). Clamped to
|
|
891
|
+
* the per-chunk cap; harmless to overshoot.
|
|
892
|
+
*/
|
|
893
|
+
readonly initialArenaBytes?: number;
|
|
894
|
+
/**
|
|
895
|
+
* Release constant attribute payloads after staging (aardvark's
|
|
896
|
+
* "StageOnce"). When a `HeapDrawSpec` attribute is supplied as a PLAIN
|
|
897
|
+
* value (raw typed array / packed marker — not a real aval), the heap
|
|
898
|
+
* wraps it in a constant holder; a constant is provably never re-read
|
|
899
|
+
* after its arena write (repack fires only on `inputChanged`, which
|
|
900
|
+
* constants never raise; compaction and growth move bytes GPU-side).
|
|
901
|
+
* With this flag the holder drops its payload right after staging, so
|
|
902
|
+
* the GPU arena becomes the ONLY copy of constant geometry — halving a
|
|
903
|
+
* static scene's memory. Real avals (cvals etc.) are never touched:
|
|
904
|
+
* changeable data stays alive by definition. Default false.
|
|
905
|
+
*/
|
|
906
|
+
readonly releaseConstantAttributes?: boolean;
|
|
907
|
+
/**
|
|
908
|
+
* Share a `HeapStorage` (arena + dedup pools) across multiple heap scenes.
|
|
909
|
+
* When omitted, the scene creates a private store it owns and disposes.
|
|
910
|
+
* When supplied (via `createHeapStorage`), two or more scenes — e.g. the
|
|
911
|
+
* same geometry rendered by a shadow pass and a color pass with different
|
|
912
|
+
* effects — share one deduped/refcounted store, so common geometry and
|
|
913
|
+
* uniforms are stored ONCE. The caller owns the store's lifetime.
|
|
914
|
+
*/
|
|
915
|
+
readonly storage?: HeapStorage;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The data-placement layer behind a heap scene: the chunked arena (uniform /
|
|
920
|
+
* attribute data + the index buffer) plus the aval-keyed dedup pools. Owns
|
|
921
|
+
* the GPU buffers; one or more `buildHeapScene` calls draw against it. Create
|
|
922
|
+
* with `createHeapStorage` and pass via `BuildHeapSceneOptions.storage` to
|
|
923
|
+
* share geometry/uniforms across heaps (shadow + color pass, picking, …).
|
|
924
|
+
*/
|
|
925
|
+
/** A heap scene's hook into a shared store. When a compaction relocates arena
|
|
926
|
+
* regions, the store calls every registered consumer's `applyRemap` so each
|
|
927
|
+
* scene re-seats its OWN refs (drawHeader cells, derived records, partition
|
|
928
|
+
* master, index `_indexStart`). `remap` maps OLD→NEW byte header offset. */
|
|
929
|
+
export interface HeapStorageConsumer {
|
|
930
|
+
applyRemap(chunkIdx: number, remap: ReadonlyMap<number, number>): void;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export interface HeapStorage {
|
|
934
|
+
readonly arena: ArenaState;
|
|
935
|
+
readonly pool: UniformPool;
|
|
936
|
+
readonly indexPool: IndexPool;
|
|
937
|
+
/** Register a scene as a consumer (so compaction re-seats its refs). */
|
|
938
|
+
register(consumer: HeapStorageConsumer): IDisposable;
|
|
939
|
+
/**
|
|
940
|
+
* Waste-triggered compaction of the shared arena. Relocates live regions,
|
|
941
|
+
* re-seats the storage-level dedup pools, and notifies every registered
|
|
942
|
+
* consumer to re-seat its scene-level refs. Returns the number of chunks
|
|
943
|
+
* compacted. Must run with pending writes flushable (it flushes first).
|
|
944
|
+
*/
|
|
945
|
+
compact(device: GPUDevice, wasteFloorBytes: number, force: boolean): number;
|
|
946
|
+
/** Destroy the arena's GPU buffers. Only call once no scene references it. */
|
|
947
|
+
dispose(): void;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
export interface HeapStorageOptions {
|
|
951
|
+
/** Per-arena-chunk size cap in bytes. See `BuildHeapSceneOptions.maxChunkBytes`. */
|
|
952
|
+
readonly maxChunkBytes?: number;
|
|
953
|
+
/** Pre-size chunks (bytes) — see `BuildHeapSceneOptions.initialArenaBytes`. */
|
|
954
|
+
readonly initialArenaBytes?: number;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Create a standalone `HeapStorage`. Pass it to `buildHeapScene` via
|
|
958
|
+
* `opts.storage` to share one arena across multiple heaps. */
|
|
959
|
+
export function createHeapStorage(device: GPUDevice, opts: HeapStorageOptions = {}): HeapStorage {
|
|
960
|
+
void device;
|
|
961
|
+
// Initial capacities are hints; the buffers pow2-grow on demand.
|
|
962
|
+
const arena = buildArenaState(
|
|
963
|
+
device, Math.max(64 * 1024, opts.initialArenaBytes ?? 0), 16 * 1024, "heapStorage",
|
|
964
|
+
GPUBufferUsage.STORAGE, opts.maxChunkBytes,
|
|
965
|
+
);
|
|
966
|
+
const pool = new UniformPool();
|
|
967
|
+
const indexPool = new IndexPool();
|
|
968
|
+
const consumers = new Set<HeapStorageConsumer>();
|
|
969
|
+
return {
|
|
970
|
+
arena, pool, indexPool,
|
|
971
|
+
register(c: HeapStorageConsumer): IDisposable {
|
|
972
|
+
consumers.add(c);
|
|
973
|
+
return { dispose: () => { consumers.delete(c); } };
|
|
974
|
+
},
|
|
975
|
+
compact(dev: GPUDevice, floor: number, force: boolean): number {
|
|
976
|
+
arena.attrs.flush(dev); // GPU buffer current for the copy
|
|
977
|
+
const remaps = arena.attrs.compact(dev, floor, force);
|
|
978
|
+
for (const { chunkIdx, remap } of remaps) {
|
|
979
|
+
// Storage-level dedup pools first (uniform refs); then each scene
|
|
980
|
+
// re-seats its drawHeaders / derive / partition / index `_indexStart`
|
|
981
|
+
// (the index re-seat reads `indexPool.baseFor` at the OLD ref, so the
|
|
982
|
+
// index pool is remapped LAST, after all consumers).
|
|
983
|
+
pool.remapRefs(chunkIdx, remap);
|
|
984
|
+
for (const c of consumers) c.applyRemap(chunkIdx, remap);
|
|
985
|
+
indexPool.remapRefs(chunkIdx, remap);
|
|
986
|
+
}
|
|
987
|
+
return remaps.length;
|
|
988
|
+
},
|
|
989
|
+
dispose(): void { arena.attrs.destroy(); },
|
|
990
|
+
};
|
|
755
991
|
}
|
|
756
992
|
|
|
757
993
|
export function buildHeapScene(
|
|
@@ -781,16 +1017,21 @@ export function buildHeapScene(
|
|
|
781
1017
|
}
|
|
782
1018
|
const depthFormat = sig.depthStencil?.format;
|
|
783
1019
|
|
|
784
|
-
// ───
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1020
|
+
// ─── Storage (arena + dedup pools) — private or shared ───────────
|
|
1021
|
+
// The chunked arena (uniform/attribute data + index buffer) and the
|
|
1022
|
+
// aval-keyed dedup pools live in a `HeapStorage`. Default: a private
|
|
1023
|
+
// store this scene owns. `opts.storage`: a shared store (the same
|
|
1024
|
+
// geometry/uniforms rendered by multiple heaps — e.g. shadow + color
|
|
1025
|
+
// pass — are deduped once). The local `arena`/`pool`/`indexPool`
|
|
1026
|
+
// bindings below keep every downstream reference unchanged.
|
|
1027
|
+
const ownsStorage = opts.storage === undefined;
|
|
1028
|
+
const storage = opts.storage ?? createHeapStorage(device, {
|
|
1029
|
+
...(opts.maxChunkBytes !== undefined ? { maxChunkBytes: opts.maxChunkBytes } : {}),
|
|
1030
|
+
...(opts.initialArenaBytes !== undefined ? { initialArenaBytes: opts.initialArenaBytes } : {}),
|
|
1031
|
+
});
|
|
1032
|
+
const arena = storage.arena;
|
|
1033
|
+
const pool = storage.pool;
|
|
1034
|
+
const indexPool = storage.indexPool;
|
|
794
1035
|
|
|
795
1036
|
// ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
|
|
796
1037
|
const drawIdToBucket: (Bucket | undefined)[] = [];
|
|
@@ -959,6 +1200,25 @@ export function buildHeapScene(
|
|
|
959
1200
|
const p = packerForWgslType(f.uniformWgslType ?? "");
|
|
960
1201
|
return { dataBytes: p.dataBytes, typeId: p.typeId, length: 1, pack: p.pack };
|
|
961
1202
|
}
|
|
1203
|
+
// Packed attribute encodings (oct32 unit vectors / C4b colors): one
|
|
1204
|
+
// u32 per element, decoded by the VS's typeId-selected arm. 3× smaller
|
|
1205
|
+
// in the arena AND host-side (the caller keeps the packed source).
|
|
1206
|
+
if (isHeapPackedAttribute(value)) {
|
|
1207
|
+
const pa = value;
|
|
1208
|
+
const wantType = pa.enc === "oct32" ? "vec3<f32>" : "vec4<f32>";
|
|
1209
|
+
if (f.attributeWgslType !== wantType) {
|
|
1210
|
+
throw new Error(
|
|
1211
|
+
`heapScene: ${pa.enc} attribute supplied for '${f.attributeWgslType}' field — ` +
|
|
1212
|
+
`oct32 requires vec3<f32>, c4b requires vec4<f32>`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
return {
|
|
1216
|
+
dataBytes: pa.data.byteLength,
|
|
1217
|
+
typeId: pa.enc === "oct32" ? ENC_OCT32 : ENC_C4B,
|
|
1218
|
+
length: pa.data.length,
|
|
1219
|
+
pack: packPackedAttr,
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
962
1222
|
// attribute-ref: variable-size array; we copy verbatim into the
|
|
963
1223
|
// arena. The current encoding is V3F_TIGHT (3 f32s per element).
|
|
964
1224
|
const arr = value as Float32Array;
|
|
@@ -972,7 +1232,7 @@ export function buildHeapScene(
|
|
|
972
1232
|
dataBytes: arr.byteLength,
|
|
973
1233
|
typeId: ENC_V3F_TIGHT,
|
|
974
1234
|
length,
|
|
975
|
-
pack:
|
|
1235
|
+
pack: packF32Verbatim,
|
|
976
1236
|
};
|
|
977
1237
|
}
|
|
978
1238
|
|
|
@@ -997,6 +1257,39 @@ export function buildHeapScene(
|
|
|
997
1257
|
throw new Error(`heapScene: BufferView offset ${offset} > 0 not yet supported`);
|
|
998
1258
|
}
|
|
999
1259
|
|
|
1260
|
+
// Packed encodings by ELEMENT TYPE (the BufferView analogue of the
|
|
1261
|
+
// oct32()/c4b() HeapDrawSpec markers): one u32 per element in the
|
|
1262
|
+
// arena, decoded by the VS typeId arm. Dedup stays aval-keyed like
|
|
1263
|
+
// any other BufferView attribute.
|
|
1264
|
+
const etName = bv.elementType.name;
|
|
1265
|
+
if (etName === "oct32" || etName === "c4b") {
|
|
1266
|
+
const want = etName === "oct32" ? "vec3<f32>" : "vec4<f32>";
|
|
1267
|
+
if (f.attributeWgslType !== want) {
|
|
1268
|
+
throw new Error(
|
|
1269
|
+
`heapScene: ${etName} BufferView requires a ${want} schema attribute (got ${f.attributeWgslType ?? "?"})`,
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
const strideP = bv.stride ?? 4;
|
|
1273
|
+
const isBroadcastP = bv.singleValue !== undefined || strideP === 0;
|
|
1274
|
+
if (!isBroadcastP && strideP !== 4) {
|
|
1275
|
+
throw new Error(`heapScene: ${etName} BufferView stride ${strideP} not tight (4) and not a broadcast`);
|
|
1276
|
+
}
|
|
1277
|
+
const ibP = bv.buffer.force(/* allow-force */);
|
|
1278
|
+
if (ibP.kind !== "host") {
|
|
1279
|
+
throw new Error(`heapScene: ${etName} BufferView wraps a native GPUBuffer; not supported in heap path`);
|
|
1280
|
+
}
|
|
1281
|
+
const lengthP = isBroadcastP ? 1 : ibP.sizeBytes / 4;
|
|
1282
|
+
const dataBytesP = 4 * lengthP;
|
|
1283
|
+
return {
|
|
1284
|
+
dataBytes: dataBytesP,
|
|
1285
|
+
typeId: etName === "oct32" ? ENC_OCT32 : ENC_C4B,
|
|
1286
|
+
length: lengthP,
|
|
1287
|
+
// Tight case: the copy limit equals the source payload, so the
|
|
1288
|
+
// static packer (which derives it from the buffer) is exact.
|
|
1289
|
+
pack: isBroadcastP ? packHostPrefix(dataBytesP / 4) : packHostU32Bits,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1000
1293
|
// In-arena element bytes: the storage layout the shader will read
|
|
1001
1294
|
// from. vec4 is always 16 (read via `heapV4f[idx]`), vec3 is 12
|
|
1002
1295
|
// (read as 3 f32s), vec2 is 8, scalar is 4. V3f source for a vec4
|
|
@@ -1050,15 +1343,7 @@ export function buildHeapScene(
|
|
|
1050
1343
|
dataBytes,
|
|
1051
1344
|
typeId: ENC_V3F_TIGHT,
|
|
1052
1345
|
length,
|
|
1053
|
-
pack: (
|
|
1054
|
-
const ibuf = val as IBuffer;
|
|
1055
|
-
if (ibuf.kind !== "host") {
|
|
1056
|
-
throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
|
|
1057
|
-
}
|
|
1058
|
-
const src = asFloat32(ibuf.data);
|
|
1059
|
-
const limitFloats = dataBytes / 4;
|
|
1060
|
-
dst.set(src.subarray(0, limitFloats), off);
|
|
1061
|
-
},
|
|
1346
|
+
pack: isBroadcast ? packHostPrefix(dataBytes / 4) : packHostF32,
|
|
1062
1347
|
};
|
|
1063
1348
|
}
|
|
1064
1349
|
|
|
@@ -1091,9 +1376,7 @@ export function buildHeapScene(
|
|
|
1091
1376
|
};
|
|
1092
1377
|
}
|
|
1093
1378
|
|
|
1094
|
-
//
|
|
1095
|
-
const pool = new UniformPool();
|
|
1096
|
-
const indexPool = new IndexPool();
|
|
1379
|
+
// (`pool` / `indexPool` are owned by `storage`, bound above.)
|
|
1097
1380
|
|
|
1098
1381
|
// ─── Adaptive routing: aval marks → repack the pool entry ─────────
|
|
1099
1382
|
// With pool-managed per-draw uniforms, value changes don't touch
|
|
@@ -1157,6 +1440,9 @@ export function buildHeapScene(
|
|
|
1157
1440
|
// both. `derivedScene` is undefined when the option is off; nothing
|
|
1158
1441
|
// else in this file should run when it is. On by default — pass
|
|
1159
1442
|
// `enableDerivedUniforms: false` to opt out.
|
|
1443
|
+
const enableCompaction = opts.enableCompaction !== false;
|
|
1444
|
+
const releaseConstantAttributes = opts.releaseConstantAttributes === true;
|
|
1445
|
+
const compactionWasteFloor = opts.compactionWasteFloorBytes ?? COMPACTION_WASTE_FLOOR_BYTES;
|
|
1160
1446
|
const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
|
|
1161
1447
|
const derivedScene: DerivedUniformsScene | undefined = enableDerivedUniforms
|
|
1162
1448
|
? new DerivedUniformsScene(device, {
|
|
@@ -1246,6 +1532,19 @@ export function buildHeapScene(
|
|
|
1246
1532
|
// that don't yet point at an allocated AtlasPage. WebGPU rejects
|
|
1247
1533
|
// bind-group entries with `undefined` resources, so every slot in
|
|
1248
1534
|
// the N-wide ladder must hold a valid view at all times.
|
|
1535
|
+
// 4-byte placeholder storage buffer — binds user-storage slots on the
|
|
1536
|
+
// seed bind group built before the first addDraw delivers the real
|
|
1537
|
+
// buffers (rebuilt immediately after).
|
|
1538
|
+
let storagePlaceholder: GPUBuffer | undefined;
|
|
1539
|
+
function getStoragePlaceholder(): GPUBuffer {
|
|
1540
|
+
if (storagePlaceholder === undefined) {
|
|
1541
|
+
storagePlaceholder = device.createBuffer({
|
|
1542
|
+
label: "heapScene/storagePlaceholder", size: 4, usage: GPUBufferUsage.STORAGE,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
return storagePlaceholder;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1249
1548
|
let atlasPlaceholders: Map<AtlasPageFormat, GPUTextureView> | undefined;
|
|
1250
1549
|
function getAtlasPlaceholder(format: AtlasPageFormat): GPUTextureView {
|
|
1251
1550
|
if (atlasPlaceholders === undefined) {
|
|
@@ -1276,6 +1575,7 @@ export function buildHeapScene(
|
|
|
1276
1575
|
const key =
|
|
1277
1576
|
`t${layout.textureBindings.map(t => t.wgslType).join(",")}` +
|
|
1278
1577
|
`|s${layout.samplerBindings.map(s => s.wgslType).join(",")}` +
|
|
1578
|
+
`|sb${layout.storageBindings.map(b => `${b.binding}${b.access === "read_write" ? "w" : "r"}`).join(",")}` +
|
|
1279
1579
|
`|a${withAtlasArrays ? 1 : 0}`;
|
|
1280
1580
|
let e = bglCache.get(key);
|
|
1281
1581
|
if (e !== undefined) return e;
|
|
@@ -1306,6 +1606,14 @@ export function buildHeapScene(
|
|
|
1306
1606
|
sampler: { type: s.wgslType === "sampler_comparison" ? "comparison" : "filtering" },
|
|
1307
1607
|
});
|
|
1308
1608
|
}
|
|
1609
|
+
for (const sb of layout.storageBindings) {
|
|
1610
|
+
// FRAGMENT only: WebGPU forbids writable storage in vertex stages,
|
|
1611
|
+
// and the OIT writers are fragment-side anyway.
|
|
1612
|
+
entries.push({
|
|
1613
|
+
binding: sb.binding, visibility: GPUShaderStage.FRAGMENT,
|
|
1614
|
+
buffer: { type: sb.access === "read_write" ? "storage" : "read-only-storage" },
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1309
1617
|
if (withAtlasArrays) {
|
|
1310
1618
|
// 2N + 1 entries: N linear textures, N srgb textures, 1 sampler.
|
|
1311
1619
|
for (let i = 0; i < ATLAS_ARRAY_SIZE; i++) {
|
|
@@ -1418,11 +1726,31 @@ export function buildHeapScene(
|
|
|
1418
1726
|
const cached = pipelineByModeKey.get(cacheKey);
|
|
1419
1727
|
if (cached !== undefined) return cached;
|
|
1420
1728
|
const { pipelineLayout } = getBgl(layout, isAtlasBucket);
|
|
1729
|
+
// Per-descriptor color targets: blend + write mask come from the RO's
|
|
1730
|
+
// PipelineState (`desc.attachments`, ordered by signature colorNames —
|
|
1731
|
+
// the same values the modeKey encodes). Using the bare scene-level
|
|
1732
|
+
// `colorTargets` here silently dropped blends/write-masks from every
|
|
1733
|
+
// heap pipeline: transparent heap leaves painted opaque, and the OIT
|
|
1734
|
+
// pick passes' write-mask-0 "Colors" leaked color onto the composite.
|
|
1735
|
+
const targets: GPUColorTargetState[] = colorTargets.map((t, i) => {
|
|
1736
|
+
const a = desc.attachments[i];
|
|
1737
|
+
if (a === undefined) return { format: t.format };
|
|
1738
|
+
return {
|
|
1739
|
+
format: t.format,
|
|
1740
|
+
writeMask: a.writeMask,
|
|
1741
|
+
...(a.enabled
|
|
1742
|
+
? { blend: {
|
|
1743
|
+
color: { operation: a.color.operation, srcFactor: a.color.srcFactor, dstFactor: a.color.dstFactor },
|
|
1744
|
+
alpha: { operation: a.alpha.operation, srcFactor: a.alpha.srcFactor, dstFactor: a.alpha.dstFactor },
|
|
1745
|
+
} }
|
|
1746
|
+
: {}),
|
|
1747
|
+
};
|
|
1748
|
+
});
|
|
1421
1749
|
const pipeline = device.createRenderPipeline({
|
|
1422
1750
|
label: `heapScene/cached/${cacheKey}`,
|
|
1423
1751
|
layout: pipelineLayout,
|
|
1424
1752
|
vertex: { module: fam.vsModule, entryPoint: fam.vsEntryName, buffers: [] },
|
|
1425
|
-
fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets
|
|
1753
|
+
fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets },
|
|
1426
1754
|
primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
|
|
1427
1755
|
...(depthFormat !== undefined && desc.depth !== undefined
|
|
1428
1756
|
? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
|
|
@@ -1436,8 +1764,9 @@ export function buildHeapScene(
|
|
|
1436
1764
|
|
|
1437
1765
|
// ─── Family state (§6 family-merge, slice 3c) ─────────────────────
|
|
1438
1766
|
// Built lazily on the first addDraw batch from the union of all
|
|
1439
|
-
// effects in that batch
|
|
1440
|
-
//
|
|
1767
|
+
// effects in that batch. A NEW effect appearing later is registered
|
|
1768
|
+
// on the fly (ensureFamilyForSpec) — families are independent per
|
|
1769
|
+
// effect (merge disabled), so the scene can mix effects added over time.
|
|
1441
1770
|
//
|
|
1442
1771
|
// Bucket key collapses to (familyId, pipelineState): every effect
|
|
1443
1772
|
// in the family shares one bucket per pipelineState. The family
|
|
@@ -1581,6 +1910,25 @@ export function buildHeapScene(
|
|
|
1581
1910
|
familyFor(effect); // throws if unknown
|
|
1582
1911
|
}
|
|
1583
1912
|
|
|
1913
|
+
/**
|
|
1914
|
+
* Reactive family rebuild: register a new effect's family when it first
|
|
1915
|
+
* appears after the initial freeze. Family-merge is disabled, so every
|
|
1916
|
+
* Effect already gets its OWN independent single-member family (its own
|
|
1917
|
+
* shader modules + buckets); a late effect just needs its family compiled
|
|
1918
|
+
* and added — no rebuild of existing families. Idempotent.
|
|
1919
|
+
*/
|
|
1920
|
+
function ensureFamilyForSpec(spec: HeapDrawSpec): void {
|
|
1921
|
+
if (!familyBuilt) { buildFamilyFromSpecs([spec]); return; }
|
|
1922
|
+
if (familyByEffectId.has(spec.effect.id)) return;
|
|
1923
|
+
const entry = { attributes: new Set<string>(), uniforms: new Set<string>() };
|
|
1924
|
+
if (spec.instanceAttributes !== undefined) {
|
|
1925
|
+
for (const name of Object.keys(spec.instanceAttributes)) entry.attributes.add(name);
|
|
1926
|
+
}
|
|
1927
|
+
const singleMap = new Map<Effect, { attributes: Set<string>; uniforms: Set<string> }>();
|
|
1928
|
+
singleMap.set(spec.effect, entry);
|
|
1929
|
+
familyByEffectId.set(spec.effect.id, compileFamilyFor([spec.effect], singleMap));
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1584
1932
|
// ─── id-of helpers ────────────────────────────────────────────────
|
|
1585
1933
|
const idOf = (s: Effect): string => `effect#${s.id}`;
|
|
1586
1934
|
const textureIds = new WeakMap<HeapTextureSet, string>();
|
|
@@ -1716,7 +2064,9 @@ export function buildHeapScene(
|
|
|
1716
2064
|
const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
|
|
1717
2065
|
entries.push(
|
|
1718
2066
|
{ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
|
|
1719
|
-
|
|
2067
|
+
// Index data lives in the attr arena now (the VS storage-decodes it);
|
|
2068
|
+
// bind the same buffer as the gather views.
|
|
2069
|
+
{ binding: 5, resource: { buffer: attrsBuf } },
|
|
1720
2070
|
{ binding: 6, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
|
|
1721
2071
|
);
|
|
1722
2072
|
s.renderBoundRecordCount = s.recordCount;
|
|
@@ -1774,6 +2124,23 @@ export function buildHeapScene(
|
|
|
1774
2124
|
// here — the atlas slots come from the per-format page tracking
|
|
1775
2125
|
// below.
|
|
1776
2126
|
}
|
|
2127
|
+
for (const sb of bucket.layout.storageBindings) {
|
|
2128
|
+
const av = bucket.storageBuffers?.get(sb.name);
|
|
2129
|
+
if (av === undefined) {
|
|
2130
|
+
// Buffers arrive with the first addDraw; the seed slot built at
|
|
2131
|
+
// bucket creation is rebuilt right after they land. Bind a
|
|
2132
|
+
// 4-byte placeholder so the seed bind group validates.
|
|
2133
|
+
entries.push({ binding: sb.binding, resource: { buffer: getStoragePlaceholder() } });
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
const ib = av.force(/* allow-force */);
|
|
2137
|
+
if (ib.kind !== "gpu") {
|
|
2138
|
+
throw new Error(
|
|
2139
|
+
`heapScene: user storage buffer '${sb.name}' must be a GPU-backed IBuffer`,
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
entries.push({ binding: sb.binding, resource: { buffer: ib.buffer } });
|
|
2143
|
+
}
|
|
1777
2144
|
if (bucket.isAtlasBucket) {
|
|
1778
2145
|
if (atlasPool === undefined) {
|
|
1779
2146
|
throw new Error(
|
|
@@ -1849,15 +2216,6 @@ export function buildHeapScene(
|
|
|
1849
2216
|
atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
|
|
1850
2217
|
}
|
|
1851
2218
|
}
|
|
1852
|
-
arena.indices.onAnyResize((resizedChunkIdx) => {
|
|
1853
|
-
for (const b of buckets) {
|
|
1854
|
-
if (b.chunkIdx !== resizedChunkIdx) continue;
|
|
1855
|
-
for (let i = 0; i < b.slots.length; i++) {
|
|
1856
|
-
b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
});
|
|
1860
|
-
|
|
1861
2219
|
// ─── findOrCreateBucket ───────────────────────────────────────────
|
|
1862
2220
|
// Slice 3c: the bucket key collapses to (familyId, pipelineState).
|
|
1863
2221
|
// Every member effect in the family shares one bucket per
|
|
@@ -2416,7 +2774,7 @@ export function buildHeapScene(
|
|
|
2416
2774
|
firstIndex: number;
|
|
2417
2775
|
indexCount: number;
|
|
2418
2776
|
instanceCount: number;
|
|
2419
|
-
perDrawRefs:
|
|
2777
|
+
perDrawRefs: Int32Array;
|
|
2420
2778
|
descriptor: PipelineStateDescriptor;
|
|
2421
2779
|
/** Filled in below after synthetic combos are registered. */
|
|
2422
2780
|
targetComboId: number;
|
|
@@ -2596,7 +2954,11 @@ export function buildHeapScene(
|
|
|
2596
2954
|
if (cpuMigration !== undefined) {
|
|
2597
2955
|
let totalEmit = 0;
|
|
2598
2956
|
for (const m of cpuMigration) {
|
|
2599
|
-
const uRefs = bucket.uniformOrder!.map(n =>
|
|
2957
|
+
const uRefs = bucket.uniformOrder!.map(n => {
|
|
2958
|
+
const i = bucket.fieldIdx.get(n);
|
|
2959
|
+
const r = i === undefined ? -1 : m.perDrawRefs[i]!;
|
|
2960
|
+
return r < 0 ? 0 : r;
|
|
2961
|
+
});
|
|
2600
2962
|
const recIdx = partition.appendRecord(
|
|
2601
2963
|
m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs,
|
|
2602
2964
|
);
|
|
@@ -2717,8 +3079,11 @@ export function buildHeapScene(
|
|
|
2717
3079
|
);
|
|
2718
3080
|
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
2719
3081
|
|
|
3082
|
+
const fieldIdx = new Map<string, number>();
|
|
3083
|
+
layout.drawHeaderFields.forEach((f, i) => fieldIdx.set(f.name, i));
|
|
2720
3084
|
const bucket: Bucket = {
|
|
2721
3085
|
label: bk, textures: undefined, layout, chunkIdx,
|
|
3086
|
+
storageBuffers: undefined, storageDirty: false, storageSubs: [],
|
|
2722
3087
|
slots: [], // populated lazily by ensureSlot per modeKey
|
|
2723
3088
|
bindGroup: null as unknown as GPUBindGroup, // legacy field; slot.bindGroup is the real one
|
|
2724
3089
|
drawHeap,
|
|
@@ -2726,7 +3091,10 @@ export function buildHeapScene(
|
|
|
2726
3091
|
headerDirtyMin: Infinity, headerDirtyMax: 0,
|
|
2727
3092
|
localPosRefs: [], localNorRefs: [],
|
|
2728
3093
|
localEntries: [], localToDrawId: [],
|
|
2729
|
-
localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
|
|
3094
|
+
localPerDrawAvals: [], localPerDrawRawFieldIdx: [], localPerDrawRefs: [], localLayoutIds: [],
|
|
3095
|
+
fieldIdx,
|
|
3096
|
+
posFieldIdx: fieldIdx.get("Positions") ?? -1,
|
|
3097
|
+
norFieldIdx: fieldIdx.get("Normals") ?? -1,
|
|
2730
3098
|
drawSlots: new Set<number>(), dirty: new Set<number>(),
|
|
2731
3099
|
isAtlasBucket,
|
|
2732
3100
|
localAtlasReleases: [],
|
|
@@ -2796,13 +3164,15 @@ export function buildHeapScene(
|
|
|
2796
3164
|
// callers writeBuffer the result to the GPU.
|
|
2797
3165
|
function packBucketHeader(
|
|
2798
3166
|
bucket: Bucket, localSlot: number,
|
|
2799
|
-
perDrawRefs:
|
|
3167
|
+
perDrawRefs: Int32Array,
|
|
2800
3168
|
layoutId: number,
|
|
2801
3169
|
): void {
|
|
2802
3170
|
const dst = bucket.drawHeaderStaging;
|
|
2803
3171
|
const u32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length);
|
|
2804
3172
|
const baseFloat = (localSlot * bucket.layout.drawHeaderBytes) / 4;
|
|
2805
|
-
|
|
3173
|
+
const fields = bucket.layout.drawHeaderFields;
|
|
3174
|
+
for (let i = 0; i < fields.length; i++) {
|
|
3175
|
+
const f = fields[i]!;
|
|
2806
3176
|
// texture-ref fields carry inline values (pageRef/formatBits as u32,
|
|
2807
3177
|
// origin/size as vec2<f32>) and are filled by packAtlasTextureFields.
|
|
2808
3178
|
if (f.kind === "texture-ref") continue;
|
|
@@ -2815,15 +3185,11 @@ export function buildHeapScene(
|
|
|
2815
3185
|
u32[fOff] = layoutId >>> 0;
|
|
2816
3186
|
continue;
|
|
2817
3187
|
}
|
|
2818
|
-
const ref = perDrawRefs
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
u32[fOff] = 0;
|
|
2824
|
-
continue;
|
|
2825
|
-
}
|
|
2826
|
-
u32[fOff] = ref;
|
|
3188
|
+
const ref = perDrawRefs[i]!;
|
|
3189
|
+
// Family-merge: a slot's effect doesn't populate every field of
|
|
3190
|
+
// the union (−1); leave the unused slots zero — the layoutId
|
|
3191
|
+
// switch ensures they're never read by the wrong effect's helper.
|
|
3192
|
+
u32[fOff] = ref < 0 ? 0 : ref;
|
|
2827
3193
|
}
|
|
2828
3194
|
}
|
|
2829
3195
|
|
|
@@ -2916,6 +3282,7 @@ export function buildHeapScene(
|
|
|
2916
3282
|
totalDraws: 0,
|
|
2917
3283
|
drawBytes: 0,
|
|
2918
3284
|
geometryBytes: 0,
|
|
3285
|
+
compactions: 0,
|
|
2919
3286
|
derivedPullMs: 0,
|
|
2920
3287
|
derivedUploadMs: 0,
|
|
2921
3288
|
derivedEncodeMs: 0,
|
|
@@ -2958,11 +3325,10 @@ export function buildHeapScene(
|
|
|
2958
3325
|
// single spec when no batched lazy-build occurred earlier (e.g.
|
|
2959
3326
|
// direct addDraw at runtime). The aset / array initial-population
|
|
2960
3327
|
// paths build from the full effect set up front.
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
}
|
|
3328
|
+
// Build the family lazily from this spec (first addDraw), or register a
|
|
3329
|
+
// newly-seen effect's family on the fly (reactive rebuild) — supports a
|
|
3330
|
+
// scene mixing multiple effects added over time (tiles + markers + …).
|
|
3331
|
+
ensureFamilyForSpec(spec);
|
|
2966
3332
|
const perInstanceUniforms = new Set<string>();
|
|
2967
3333
|
const perInstanceAttributes = spec.instanceAttributes !== undefined
|
|
2968
3334
|
? new Set(Object.keys(spec.instanceAttributes))
|
|
@@ -2984,16 +3350,47 @@ export function buildHeapScene(
|
|
|
2984
3350
|
token: outerTok,
|
|
2985
3351
|
});
|
|
2986
3352
|
}
|
|
2987
|
-
//
|
|
2988
|
-
//
|
|
2989
|
-
//
|
|
2990
|
-
//
|
|
2991
|
-
//
|
|
2992
|
-
//
|
|
2993
|
-
//
|
|
2994
|
-
//
|
|
2995
|
-
//
|
|
2996
|
-
|
|
3353
|
+
// ─── Group-fit page placement (§3) ───────────────────────────────
|
|
3354
|
+
// A draw's whole group — uniforms + attributes + INDEX data (all one
|
|
3355
|
+
// arena now) — must land on ONE chunk/page (the draw binds one buffer).
|
|
3356
|
+
// Estimate the group's arena bytes (exact per-field via the same
|
|
3357
|
+
// placement helpers, ignoring dedup ⇒ an upper bound on the NEW bytes a
|
|
3358
|
+
// chunk must absorb), then place the group on a chunk with room — prefer
|
|
3359
|
+
// one already holding a shared input, else the newest, else open a fresh
|
|
3360
|
+
// page. This replaces the old cross-chunk spill → throw, so a scene can
|
|
3361
|
+
// grow past one buffer's cap (WebGPU's storage-buffer binding limit).
|
|
3362
|
+
const fam = familyFor(spec.effect);
|
|
3363
|
+
const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
|
|
3364
|
+
const layout = fam.schema.drawHeaderUnion;
|
|
3365
|
+
const hasIndices = spec.indices !== undefined;
|
|
3366
|
+
const indicesAval = hasIndices ? (asAval(spec.indices!) as aval<Uint32Array>) : undefined;
|
|
3367
|
+
const indexArr = hasIndices ? (readPlain(spec.indices!) as Uint32Array) : undefined;
|
|
3368
|
+
const instanceCount = spec.instanceCount !== undefined
|
|
3369
|
+
? (readPlain(spec.instanceCount) as number) : 1;
|
|
3370
|
+
|
|
3371
|
+
let estBytes = indexArr !== undefined ? ALIGN16(ALLOC_HEADER_PAD_TO + indexArr.byteLength) : 0;
|
|
3372
|
+
for (const f of layout.drawHeaderFields) {
|
|
3373
|
+
if (f.kind === "texture-ref" || f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name) || !effectFields.has(f.name)) continue;
|
|
3374
|
+
if (ruleForUniform(spec, f.name) !== undefined) {
|
|
3375
|
+
estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + PACKER_MAT4.dataBytes); // §7 derived dummy mat4
|
|
3376
|
+
continue;
|
|
3377
|
+
}
|
|
3378
|
+
const perInstUniform = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
|
|
3379
|
+
const perInstAttr = f.kind === "attribute-ref" && perInstanceAttributes.has(f.name);
|
|
3380
|
+
const provided = perInstAttr ? spec.instanceAttributes![f.name] : spec.inputs[f.name];
|
|
3381
|
+
if (provided === undefined) continue; // the acquire loop throws with a precise message
|
|
3382
|
+
let dataBytes: number;
|
|
3383
|
+
if (f.kind === "attribute-ref" && !perInstUniform && isBufferView(provided)) {
|
|
3384
|
+
dataBytes = bufferViewPlacement(f, provided).dataBytes;
|
|
3385
|
+
} else {
|
|
3386
|
+
const value = asAval(provided as aval<unknown> | unknown).getValue(outerTok);
|
|
3387
|
+
dataBytes = (perInstUniform
|
|
3388
|
+
? perInstancePlacementFor(f, value, instanceCount)
|
|
3389
|
+
: poolPlacementFor(f, value)).dataBytes;
|
|
3390
|
+
}
|
|
3391
|
+
estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
3392
|
+
}
|
|
3393
|
+
|
|
2997
3394
|
let chunkIdx: number;
|
|
2998
3395
|
{
|
|
2999
3396
|
let preferred: number | undefined;
|
|
@@ -3005,9 +3402,36 @@ export function buildHeapScene(
|
|
|
3005
3402
|
if (where !== undefined) { preferred = where; break; }
|
|
3006
3403
|
}
|
|
3007
3404
|
}
|
|
3008
|
-
|
|
3405
|
+
const fits = (c: number): boolean => arena.attrs.bumpHeadroom(c) >= estBytes;
|
|
3406
|
+
const newest = arena.attrs.chunkCount - 1;
|
|
3407
|
+
chunkIdx =
|
|
3408
|
+
preferred !== undefined && fits(preferred) ? preferred
|
|
3409
|
+
: fits(newest) ? newest
|
|
3410
|
+
: arena.attrs.openPage();
|
|
3009
3411
|
}
|
|
3010
3412
|
const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
|
|
3413
|
+
// User storage buffers (layout.storageBindings): bucket-level, set once.
|
|
3414
|
+
if (bucket.layout.storageBindings.length > 0 && bucket.storageBuffers === undefined) {
|
|
3415
|
+
if (spec.storageBuffers === undefined) {
|
|
3416
|
+
throw new Error(
|
|
3417
|
+
`heapScene: bucket '${bucket.label}' needs storage buffers ` +
|
|
3418
|
+
`(${bucket.layout.storageBindings.map(b => b.name).join(", ")}) but spec.storageBuffers is undefined`,
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
bucket.storageBuffers = spec.storageBuffers;
|
|
3422
|
+
// A storage aval marking (e.g. the OIT head buffer reallocating on a
|
|
3423
|
+
// canvas resize) invalidates every slot bind group of this bucket.
|
|
3424
|
+
for (const av of spec.storageBuffers.values()) {
|
|
3425
|
+
const disposer = (av as unknown as { addMarkingCallback?: (cb: () => void) => () => void })
|
|
3426
|
+
.addMarkingCallback?.(() => { bucket.storageDirty = true; });
|
|
3427
|
+
if (disposer !== undefined) bucket.storageSubs.push(disposer);
|
|
3428
|
+
}
|
|
3429
|
+
// Slots seeded before the buffers were known carry incomplete bind
|
|
3430
|
+
// groups — rebuild them now.
|
|
3431
|
+
for (let i = 0; i < bucket.slots.length; i++) {
|
|
3432
|
+
bucket.slots[i]!.bindGroup = buildBucketBindGroup(bucket, i);
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3011
3435
|
// Phase 5c.3 — every RO carrying a derived-mode rule is registered
|
|
3012
3436
|
// on the bucket (lazy-promoting it to GPU-routed on the first
|
|
3013
3437
|
// call). ROs may carry DIFFERENT rules even when they share the
|
|
@@ -3038,19 +3462,17 @@ export function buildHeapScene(
|
|
|
3038
3462
|
// tables every frame from the master.
|
|
3039
3463
|
const roSlot = bucket.gpuRouted ? bucket.slots[0]! : ensureSlot(bucket, roDescriptor);
|
|
3040
3464
|
const roSlotIdx = bucket.slots.indexOf(roSlot);
|
|
3041
|
-
const fam = familyFor(spec.effect);
|
|
3042
|
-
const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
|
|
3043
3465
|
|
|
3044
|
-
// Indices
|
|
3045
|
-
// Aval-keyed: 19K instanced clones of the same mesh share
|
|
3046
|
-
// index allocation + one upload. NON-INDEXED draws (spec.indices
|
|
3047
|
-
// omitted) skip the
|
|
3048
|
-
//
|
|
3049
|
-
//
|
|
3050
|
-
|
|
3051
|
-
|
|
3466
|
+
// Indices ride the same arena as attributes/uniforms (the VS storage-
|
|
3467
|
+
// decodes them). Aval-keyed: 19K instanced clones of the same mesh share
|
|
3468
|
+
// one index allocation + one upload. NON-INDEXED draws (spec.indices
|
|
3469
|
+
// omitted) skip the pool: the drawTable record stores firstIndex =
|
|
3470
|
+
// HEAP_NONINDEXED (sentinel) and indexCount = vertexCount, and the
|
|
3471
|
+
// megacall prelude uses the local vertex index directly. `indicesAval` /
|
|
3472
|
+
// `indexArr` were read above for the group estimate; the group landed
|
|
3473
|
+
// wholly on `bucket.chunkIdx`, so this allocation can't spill.
|
|
3052
3474
|
const idxAlloc = hasIndices
|
|
3053
|
-
? indexPool.acquire(device, arena.
|
|
3475
|
+
? indexPool.acquire(device, arena.attrs, indicesAval!, bucket.chunkIdx, indexArr!)
|
|
3054
3476
|
: undefined;
|
|
3055
3477
|
// Emit count per instance + the firstIndex field written to the record.
|
|
3056
3478
|
// Slice: emit only [firstIndex, firstIndex+indexCount) of the (shared,
|
|
@@ -3060,10 +3482,6 @@ export function buildHeapScene(
|
|
|
3060
3482
|
const firstIndexField = hasIndices ? (idxAlloc!.firstIndex + (spec.firstIndex ?? 0)) : HEAP_NONINDEXED;
|
|
3061
3483
|
|
|
3062
3484
|
const localSlot = bucket.drawHeap.alloc();
|
|
3063
|
-
// Per-RO instancing: read `spec.instanceCount` (defaults to 1).
|
|
3064
|
-
const instanceCount = spec.instanceCount !== undefined
|
|
3065
|
-
? readPlain(spec.instanceCount) as number
|
|
3066
|
-
: 1;
|
|
3067
3485
|
|
|
3068
3486
|
// Walk the bucket's schema-driven DrawHeader fields. Per-instance
|
|
3069
3487
|
// attributes pull from `spec.instanceAttributes` and pack into an
|
|
@@ -3071,7 +3489,18 @@ export function buildHeapScene(
|
|
|
3071
3489
|
// packs as a single value. Both go through the same pool — sharing
|
|
3072
3490
|
// emerges from aval identity either way.
|
|
3073
3491
|
const perDrawAvals: aval<unknown>[] = [];
|
|
3074
|
-
|
|
3492
|
+
let perDrawRawFieldIdx: number[] | undefined;
|
|
3493
|
+
const perDrawRefs = new Int32Array(bucket.layout.drawHeaderFields.length).fill(-1);
|
|
3494
|
+
const setRef = (name: string, ref: number): void => {
|
|
3495
|
+
const i = bucket.fieldIdx.get(name);
|
|
3496
|
+
if (i !== undefined) perDrawRefs[i] = ref;
|
|
3497
|
+
};
|
|
3498
|
+
const getRef = (name: string): number | undefined => {
|
|
3499
|
+
const i = bucket.fieldIdx.get(name);
|
|
3500
|
+
if (i === undefined) return undefined;
|
|
3501
|
+
const r = perDrawRefs[i]!;
|
|
3502
|
+
return r < 0 ? undefined : r;
|
|
3503
|
+
};
|
|
3075
3504
|
{
|
|
3076
3505
|
const tok = outerTok;
|
|
3077
3506
|
for (const f of bucket.layout.drawHeaderFields) {
|
|
@@ -3083,18 +3512,45 @@ export function buildHeapScene(
|
|
|
3083
3512
|
// layoutId branch never reads them, so the slot stays zero.
|
|
3084
3513
|
if (f.name === "__layoutId") continue;
|
|
3085
3514
|
if (!effectFields.has(f.name)) continue;
|
|
3515
|
+
// Inline header uniforms (PickId): the header word IS the u32
|
|
3516
|
+
// value — write it via perDrawRefs (packBucketHeader copies
|
|
3517
|
+
// refs verbatim), skip the pool entirely. A provider-supplied
|
|
3518
|
+
// PickId aval still works below as the (slow) fallback.
|
|
3519
|
+
if (INLINE_HEADER_UNIFORMS.has(f.name)) {
|
|
3520
|
+
const iv = f.name === "PickId" ? spec.pickId : undefined;
|
|
3521
|
+
if (iv !== undefined) {
|
|
3522
|
+
setRef(f.name, iv >>> 0);
|
|
3523
|
+
continue;
|
|
3524
|
+
}
|
|
3525
|
+
const provInline = spec.inputs[f.name];
|
|
3526
|
+
if (typeof provInline === "number") {
|
|
3527
|
+
setRef(f.name, provInline >>> 0);
|
|
3528
|
+
continue;
|
|
3529
|
+
}
|
|
3530
|
+
if (provInline === undefined) {
|
|
3531
|
+
// No id at all (picking disabled but the effect still
|
|
3532
|
+
// reads the uniform): 0 = "no pick", a reserved id.
|
|
3533
|
+
setRef(f.name, 0);
|
|
3534
|
+
continue;
|
|
3535
|
+
}
|
|
3536
|
+
// fall through: aval-provided → legacy pooled path
|
|
3537
|
+
}
|
|
3086
3538
|
// §7: derived-uniform fields are produced by the compute
|
|
3087
3539
|
// pre-pass. We still allocate an arena slot here so the
|
|
3088
3540
|
// drawHeader gets a valid ref written by packBucketHeader; §7
|
|
3089
3541
|
// overwrites the arena data each frame the inputs are dirty.
|
|
3090
3542
|
if (ruleForUniform(spec, f.name) !== undefined) {
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3543
|
+
// Raw (aval-less) slot: §7's compute pre-pass overwrites the
|
|
3544
|
+
// data every dirty frame, so a keyed pool entry (placeholder
|
|
3545
|
+
// ConstantVal + Map per draw) bought nothing. The ref lives
|
|
3546
|
+
// in the drawHeader cell (compaction re-seats it there);
|
|
3547
|
+
// removeDraw frees it via `perDrawRawFieldIdx`.
|
|
3548
|
+
const ref = pool.allocRaw(
|
|
3549
|
+
arena.attrs, bucket.chunkIdx,
|
|
3550
|
+
PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1,
|
|
3095
3551
|
);
|
|
3096
|
-
|
|
3097
|
-
|
|
3552
|
+
setRef(f.name, ref);
|
|
3553
|
+
(perDrawRawFieldIdx ??= []).push(bucket.fieldIdx.get(f.name)!);
|
|
3098
3554
|
continue;
|
|
3099
3555
|
}
|
|
3100
3556
|
const isPerInstanceUniformField =
|
|
@@ -3126,11 +3582,37 @@ export function buildHeapScene(
|
|
|
3126
3582
|
let av: aval<unknown>;
|
|
3127
3583
|
let value: unknown;
|
|
3128
3584
|
let placement: ReturnType<typeof poolPlacementFor>;
|
|
3585
|
+
let releasable: { _v: unknown } | undefined;
|
|
3129
3586
|
if (f.kind === "attribute-ref" && !isPerInstanceUniformField && isBufferView(provided)) {
|
|
3130
3587
|
const bv = provided;
|
|
3131
3588
|
placement = bufferViewPlacement(f, bv);
|
|
3132
3589
|
av = bv.buffer as aval<unknown>;
|
|
3133
3590
|
value = bv.buffer.getValue(tok);
|
|
3591
|
+
} else if (
|
|
3592
|
+
releaseConstantAttributes
|
|
3593
|
+
&& f.kind === "attribute-ref"
|
|
3594
|
+
&& !isPerInstanceUniformField
|
|
3595
|
+
&& !(typeof provided === "object" && provided !== null
|
|
3596
|
+
&& typeof (provided as { getValue?: unknown }).getValue === "function")
|
|
3597
|
+
) {
|
|
3598
|
+
// Plain value (raw array / packed marker) → CONSTANT by definition.
|
|
3599
|
+
// Wrap in a droppable holder: after this field's arena write the GPU
|
|
3600
|
+
// copy is the only copy the heap ever reads again, so the payload is
|
|
3601
|
+
// released (real avals never take this branch — changeable data
|
|
3602
|
+
// stays alive by contract).
|
|
3603
|
+
const holder = {
|
|
3604
|
+
_v: provided as unknown,
|
|
3605
|
+
getValue(): unknown {
|
|
3606
|
+
if (holder._v === null) {
|
|
3607
|
+
throw new Error("heapScene: constant attribute payload was released after staging (releaseConstantAttributes)");
|
|
3608
|
+
}
|
|
3609
|
+
return holder._v;
|
|
3610
|
+
},
|
|
3611
|
+
};
|
|
3612
|
+
releasable = holder;
|
|
3613
|
+
av = holder as unknown as aval<unknown>;
|
|
3614
|
+
value = provided;
|
|
3615
|
+
placement = poolPlacementFor(f, value);
|
|
3134
3616
|
} else {
|
|
3135
3617
|
av = asAval(provided as aval<unknown> | unknown);
|
|
3136
3618
|
value = av.getValue(tok);
|
|
@@ -3142,19 +3624,21 @@ export function buildHeapScene(
|
|
|
3142
3624
|
device, arena.attrs, av, bucket.chunkIdx, value,
|
|
3143
3625
|
placement.dataBytes, placement.typeId, placement.length, placement.pack,
|
|
3144
3626
|
);
|
|
3145
|
-
|
|
3627
|
+
if (releasable !== undefined) releasable._v = null; // staged — drop the CPU copy
|
|
3628
|
+
setRef(f.name, ref);
|
|
3146
3629
|
perDrawAvals.push(av);
|
|
3147
3630
|
}
|
|
3148
3631
|
}
|
|
3149
3632
|
|
|
3150
|
-
bucket.localPosRefs[localSlot] =
|
|
3151
|
-
bucket.localNorRefs[localSlot] =
|
|
3633
|
+
bucket.localPosRefs[localSlot] = getRef("Positions");
|
|
3634
|
+
bucket.localNorRefs[localSlot] = getRef("Normals");
|
|
3152
3635
|
bucket.localEntries[localSlot] = {
|
|
3153
3636
|
indexCount: emitCount, firstIndex: firstIndexField, instanceCount,
|
|
3154
3637
|
};
|
|
3155
3638
|
bucket.localToDrawId[localSlot] = drawId;
|
|
3156
3639
|
bucket.drawSlots.add(localSlot);
|
|
3157
3640
|
bucket.localPerDrawAvals[localSlot] = perDrawAvals;
|
|
3641
|
+
bucket.localPerDrawRawFieldIdx[localSlot] = perDrawRawFieldIdx;
|
|
3158
3642
|
bucket.localPerDrawRefs[localSlot] = perDrawRefs;
|
|
3159
3643
|
const layoutId = fam.schema.layoutIdOf.get(spec.effect)!;
|
|
3160
3644
|
bucket.localLayoutIds[localSlot] = layoutId;
|
|
@@ -3264,7 +3748,7 @@ export function buildHeapScene(
|
|
|
3264
3748
|
// it) get 0 — the kernel only reads refs through combo fns that
|
|
3265
3749
|
// gate on `r.comboId`, so an unread 0 is harmless.
|
|
3266
3750
|
const uniformRefs: number[] = (bucket.uniformOrder ?? []).map(
|
|
3267
|
-
name =>
|
|
3751
|
+
name => getRef(name) ?? 0,
|
|
3268
3752
|
);
|
|
3269
3753
|
partition.appendRecord(localSlot, firstIndexField, emitCount, instanceCount, roComboId, uniformRefs);
|
|
3270
3754
|
bucket.drawIdToRecord!.set(drawId, recIdx);
|
|
@@ -3369,7 +3853,10 @@ export function buildHeapScene(
|
|
|
3369
3853
|
}
|
|
3370
3854
|
const tracker = new ModeKeyTracker(
|
|
3371
3855
|
spec.pipelineState, sig,
|
|
3372
|
-
|
|
3856
|
+
// skipSubscribe means the tracker never invokes onDirty itself
|
|
3857
|
+
// (scene-level modeAvalToDrawIds dispatches instead) — a shared
|
|
3858
|
+
// noop avoids one closure per draw.
|
|
3859
|
+
NOOP,
|
|
3373
3860
|
{
|
|
3374
3861
|
skipSubscribe: true,
|
|
3375
3862
|
...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
|
|
@@ -3404,11 +3891,11 @@ export function buildHeapScene(
|
|
|
3404
3891
|
const ruleSubset = new Map<string, DerivedRule>();
|
|
3405
3892
|
const outOffsetByName = new Map<string, number>();
|
|
3406
3893
|
for (const f of bucket.layout.drawHeaderFields) {
|
|
3407
|
-
if (f.name === "__layoutId") continue;
|
|
3894
|
+
if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
|
|
3408
3895
|
if (!effectFields.has(f.name)) continue;
|
|
3409
3896
|
const rule = ruleForUniform(spec, f.name);
|
|
3410
3897
|
if (rule === undefined) continue;
|
|
3411
|
-
const ref =
|
|
3898
|
+
const ref = getRef(f.name);
|
|
3412
3899
|
if (ref === undefined) continue;
|
|
3413
3900
|
ruleSubset.set(f.name, rule);
|
|
3414
3901
|
outOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
|
|
@@ -3434,7 +3921,7 @@ export function buildHeapScene(
|
|
|
3434
3921
|
trafoAvals,
|
|
3435
3922
|
...(spec.modelChain !== undefined ? { modelChain: spec.modelChain } : {}),
|
|
3436
3923
|
hostUniformOffset: (n) => {
|
|
3437
|
-
const r =
|
|
3924
|
+
const r = getRef(n);
|
|
3438
3925
|
return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
|
|
3439
3926
|
},
|
|
3440
3927
|
outputOffset: (n) => outOffsetByName.get(n),
|
|
@@ -3545,8 +4032,21 @@ export function buildHeapScene(
|
|
|
3545
4032
|
// Release pool entries — refcount drops; if zero, allocation freed.
|
|
3546
4033
|
const avals = bucket.localPerDrawAvals[localSlot];
|
|
3547
4034
|
if (avals !== undefined) for (const av of avals) pool.release(arena.attrs, av, bucket.chunkIdx);
|
|
4035
|
+
// Raw §7 slots: the live ref is the drawHeader cell (compaction
|
|
4036
|
+
// keeps it current); free it directly.
|
|
4037
|
+
const rawIdxs = bucket.localPerDrawRawFieldIdx[localSlot];
|
|
4038
|
+
if (rawIdxs !== undefined) {
|
|
4039
|
+
const rawRefs = bucket.localPerDrawRefs[localSlot];
|
|
4040
|
+
if (rawRefs !== undefined) {
|
|
4041
|
+
for (const fi of rawIdxs) {
|
|
4042
|
+
const ref = rawRefs[fi]!;
|
|
4043
|
+
if (ref >= 0) pool.releaseRaw(arena.attrs, bucket.chunkIdx, ref, PACKER_MAT4.dataBytes);
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
bucket.localPerDrawRawFieldIdx[localSlot] = undefined;
|
|
4047
|
+
}
|
|
3548
4048
|
const idxAval = drawIdToIndexAval[drawId];
|
|
3549
|
-
if (idxAval !== undefined) indexPool.release(arena.
|
|
4049
|
+
if (idxAval !== undefined) indexPool.release(arena.attrs, idxAval, bucket.chunkIdx);
|
|
3550
4050
|
const atlasRel = bucket.localAtlasReleases[localSlot];
|
|
3551
4051
|
if (atlasRel !== undefined) atlasRel();
|
|
3552
4052
|
// Drop atlas-aval ref (if any). When the last ref is dropped we
|
|
@@ -3616,14 +4116,16 @@ export function buildHeapScene(
|
|
|
3616
4116
|
function drainAsetWith(tok: AdaptiveToken): void {
|
|
3617
4117
|
if (asetReader === undefined) return;
|
|
3618
4118
|
const delta = asetReader.getChanges(tok);
|
|
4119
|
+
// Process removals before adds. A remove+add in the same
|
|
4120
|
+
// transaction must free its slot / arena region / atlas tile
|
|
4121
|
+
// before the add allocates, so the freelist reuses the just-freed
|
|
4122
|
+
// block instead of growing the high-water toward
|
|
4123
|
+
// live + removed-this-tick. (Matches Aardvark's removals-first
|
|
4124
|
+
// delta discipline.)
|
|
4125
|
+
const adds: HeapDrawSpec[] = [];
|
|
3619
4126
|
delta.iter((op: { value: HeapDrawSpec; count: number }) => {
|
|
3620
4127
|
if (op.count > 0) {
|
|
3621
|
-
|
|
3622
|
-
// already sceneObj) — collapses N nested evaluateAlways into
|
|
3623
|
-
// one for a bulk add. Saves a per-RO setUnsafeEvaluationDepth
|
|
3624
|
-
// + outputs.add(sceneObj) round trip.
|
|
3625
|
-
const id = addDrawImpl(op.value, tok);
|
|
3626
|
-
specToDrawId.set(op.value, id);
|
|
4128
|
+
adds.push(op.value);
|
|
3627
4129
|
} else {
|
|
3628
4130
|
const id = specToDrawId.get(op.value);
|
|
3629
4131
|
if (id !== undefined) {
|
|
@@ -3632,6 +4134,13 @@ export function buildHeapScene(
|
|
|
3632
4134
|
}
|
|
3633
4135
|
}
|
|
3634
4136
|
});
|
|
4137
|
+
// Re-use the outer evaluateAlways scope's token (caller is already
|
|
4138
|
+
// sceneObj) — collapses N nested evaluateAlways into one for a bulk
|
|
4139
|
+
// add. Saves a per-RO setUnsafeEvaluationDepth + outputs.add round trip.
|
|
4140
|
+
for (const value of adds) {
|
|
4141
|
+
const id = addDrawImpl(value, tok);
|
|
4142
|
+
specToDrawId.set(value, id);
|
|
4143
|
+
}
|
|
3635
4144
|
}
|
|
3636
4145
|
|
|
3637
4146
|
// ─── Initial population ───────────────────────────────────────────
|
|
@@ -3648,9 +4157,9 @@ export function buildHeapScene(
|
|
|
3648
4157
|
asetReader = (initialDraws as aset<HeapDrawSpec>).getReader();
|
|
3649
4158
|
sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
|
|
3650
4159
|
// First drain: snapshot all add-ops, build the family from their
|
|
3651
|
-
// effects up front, then run addDraw. Subsequent drains
|
|
3652
|
-
//
|
|
3653
|
-
//
|
|
4160
|
+
// effects up front, then run addDraw. Subsequent drains reuse the
|
|
4161
|
+
// family, or register a newly-seen effect's family on the fly
|
|
4162
|
+
// (ensureFamilyForSpec, via addDrawImpl).
|
|
3654
4163
|
if (!familyBuilt) {
|
|
3655
4164
|
const reader = asetReader!;
|
|
3656
4165
|
const delta = reader.getChanges(tok);
|
|
@@ -3679,6 +4188,142 @@ export function buildHeapScene(
|
|
|
3679
4188
|
});
|
|
3680
4189
|
}
|
|
3681
4190
|
|
|
4191
|
+
// ─── Heap compaction (attribute arena + index buffer) ────────────
|
|
4192
|
+
/**
|
|
4193
|
+
* Compact every arena/index chunk whose fragmentation waste crosses the
|
|
4194
|
+
* floor (or unconditionally when `force`), then re-seat EVERY cached ref so
|
|
4195
|
+
* the relocated allocations stay addressable. Attribute arena:
|
|
4196
|
+
* • uniform-pool entries (`pool.remapRefs`)
|
|
4197
|
+
* • §7 derived-uniform record handles (`derivedScene.remapHostHeap`)
|
|
4198
|
+
* • modes partition master refs (`partitionScene.remapUniformRefs`)
|
|
4199
|
+
* • drawHeader cells + the Pos/Nor caches (re-`packBucketHeader`)
|
|
4200
|
+
* Index buffer (ref = `allocBase + slice`, held in the drawTable / partition
|
|
4201
|
+
* master + IndexPool entry):
|
|
4202
|
+
* • per-draw `indexStart` in the CPU drawTable or partition master
|
|
4203
|
+
* • `localEntries[slot].firstIndex` + IndexPool entry base
|
|
4204
|
+
* Must run with the buffers current (we flush first) and before the
|
|
4205
|
+
* per-bucket header / drawTable flush so the re-seated records upload.
|
|
4206
|
+
*/
|
|
4207
|
+
// Per-scene ref re-seat after the shared store relocates regions. Registered
|
|
4208
|
+
// with `storage`; the store calls it (for THIS scene + any others sharing the
|
|
4209
|
+
// store) inside `storage.compact`. Re-seats derived records, partition master
|
|
4210
|
+
// refs, drawHeader cells, and index `_indexStart` — everything this scene
|
|
4211
|
+
// caches that points into the arena. (The storage-level uniform/index pools
|
|
4212
|
+
// are re-seated by the store itself, around this call.)
|
|
4213
|
+
function applyRemap(chunkIdx: number, remap: ReadonlyMap<number, number>): void {
|
|
4214
|
+
if (derivedScene !== undefined) {
|
|
4215
|
+
// §7 handles store DATA offsets (ref + header pad), not header refs.
|
|
4216
|
+
const dataRemap = new Map<number, number>();
|
|
4217
|
+
for (const [o, n] of remap) {
|
|
4218
|
+
dataRemap.set(o + ALLOC_HEADER_PAD_TO, n + ALLOC_HEADER_PAD_TO);
|
|
4219
|
+
}
|
|
4220
|
+
derivedScene.remapHostHeap(chunkIdx, dataRemap);
|
|
4221
|
+
}
|
|
4222
|
+
{
|
|
4223
|
+
for (const bucket of buckets) {
|
|
4224
|
+
if (bucket.chunkIdx !== chunkIdx) continue;
|
|
4225
|
+
if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
|
|
4226
|
+
if (bucket.partitionScene.remapUniformRefs(remap) > 0) {
|
|
4227
|
+
bucket.partitionDirty = true;
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
4230
|
+
for (const localSlot of bucket.drawSlots) {
|
|
4231
|
+
// (a) Index region re-seat. Index data is an attr-arena region now,
|
|
4232
|
+
// so it relocates in THIS pass. Its draw stores `_indexStart` (an
|
|
4233
|
+
// element offset = allocBase + slice) in the drawTable / partition
|
|
4234
|
+
// master; shift it by the region's element delta. Independent of the
|
|
4235
|
+
// uniform re-seat below (a slot's indices may move while its uniforms
|
|
4236
|
+
// don't), so it runs before the `changed` gate.
|
|
4237
|
+
const drawId = bucket.localToDrawId[localSlot];
|
|
4238
|
+
if (drawId !== undefined) {
|
|
4239
|
+
const iav = drawIdToIndexAval[drawId];
|
|
4240
|
+
const oldRef = iav !== undefined ? indexPool.baseFor(iav, chunkIdx) : undefined;
|
|
4241
|
+
const newRef = oldRef !== undefined ? remap.get(oldRef) : undefined;
|
|
4242
|
+
const entry = bucket.localEntries[localSlot];
|
|
4243
|
+
if (oldRef !== undefined && newRef !== undefined && entry !== undefined) {
|
|
4244
|
+
const newStart = entry.firstIndex + ((newRef - oldRef) >> 2);
|
|
4245
|
+
entry.firstIndex = newStart;
|
|
4246
|
+
if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
|
|
4247
|
+
const recIdx = bucket.drawIdToRecord?.get(drawId);
|
|
4248
|
+
if (recIdx !== undefined) {
|
|
4249
|
+
bucket.partitionScene.setRecordIndexStart(recIdx, newStart);
|
|
4250
|
+
bucket.partitionDirty = true;
|
|
4251
|
+
}
|
|
4252
|
+
} else {
|
|
4253
|
+
const slotIdx = drawIdToSlotIdx[drawId];
|
|
4254
|
+
const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
|
|
4255
|
+
const recIdx = slot?.slotToRecord[localSlot];
|
|
4256
|
+
if (slot !== undefined && recIdx !== undefined && recIdx >= 0) {
|
|
4257
|
+
slot.drawTableShadow![recIdx * RECORD_U32 + 2] = newStart >>> 0;
|
|
4258
|
+
const off = recIdx * RECORD_BYTES;
|
|
4259
|
+
if (off < slot.drawTableDirtyMin) slot.drawTableDirtyMin = off;
|
|
4260
|
+
if (off + RECORD_BYTES > slot.drawTableDirtyMax) slot.drawTableDirtyMax = off + RECORD_BYTES;
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
// (b) Uniform/attr drawHeader cells (+ Pos/Nor caches).
|
|
4266
|
+
const refs = bucket.localPerDrawRefs[localSlot];
|
|
4267
|
+
if (refs === undefined) continue;
|
|
4268
|
+
let changed = false;
|
|
4269
|
+
for (let i = 0; i < refs.length; i++) {
|
|
4270
|
+
const ref = refs[i]!;
|
|
4271
|
+
if (ref < 0) continue;
|
|
4272
|
+
const fld = bucket.layout.drawHeaderFields[i]!;
|
|
4273
|
+
// Inline header values (PickId/__layoutId) are NOT arena
|
|
4274
|
+
// refs — a numeric collision with an old offset must not
|
|
4275
|
+
// remap them.
|
|
4276
|
+
if (fld.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(fld.name)) continue;
|
|
4277
|
+
const nn = remap.get(ref);
|
|
4278
|
+
if (nn !== undefined) { refs[i] = nn; changed = true; }
|
|
4279
|
+
}
|
|
4280
|
+
if (!changed) continue;
|
|
4281
|
+
const pr = bucket.posFieldIdx >= 0 ? refs[bucket.posFieldIdx]! : -1;
|
|
4282
|
+
const nr = bucket.norFieldIdx >= 0 ? refs[bucket.norFieldIdx]! : -1;
|
|
4283
|
+
bucket.localPosRefs[localSlot] = pr < 0 ? undefined : pr;
|
|
4284
|
+
bucket.localNorRefs[localSlot] = nr < 0 ? undefined : nr;
|
|
4285
|
+
packBucketHeader(bucket, localSlot, refs, bucket.localLayoutIds[localSlot] ?? 0);
|
|
4286
|
+
if (bucket.isAtlasBucket) {
|
|
4287
|
+
const ts = bucket.localAtlasTextures[localSlot];
|
|
4288
|
+
if (ts !== undefined) packAtlasTextureFields(bucket, localSlot, ts);
|
|
4289
|
+
}
|
|
4290
|
+
const byteOff = localSlot * bucket.layout.drawHeaderBytes;
|
|
4291
|
+
if (byteOff < bucket.headerDirtyMin) bucket.headerDirtyMin = byteOff;
|
|
4292
|
+
const end = byteOff + bucket.layout.drawHeaderBytes;
|
|
4293
|
+
if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
// Register this scene as a consumer of the (possibly shared) store, so a
|
|
4300
|
+
// compaction — triggered by THIS scene or any other sharing the store —
|
|
4301
|
+
// re-seats this scene's refs. Disposed in `dispose()`.
|
|
4302
|
+
const storageReg = storage.register({ applyRemap });
|
|
4303
|
+
|
|
4304
|
+
// ─── Compaction (delegates to the shared store) ──────────────────
|
|
4305
|
+
function runCompaction(force: boolean): void {
|
|
4306
|
+
const compacted = storage.compact(device, compactionWasteFloor, force);
|
|
4307
|
+
stats.compactions += compacted;
|
|
4308
|
+
// update() flushes the re-seated drawHeaders via its own per-bucket loop
|
|
4309
|
+
// right after this returns; the forced (test) path runs outside update(),
|
|
4310
|
+
// so it flushes this scene's dirtied headers itself.
|
|
4311
|
+
if (force) {
|
|
4312
|
+
for (const bucket of buckets) {
|
|
4313
|
+
if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
|
|
4314
|
+
device.queue.writeBuffer(
|
|
4315
|
+
bucket.drawHeap.buffer, bucket.headerDirtyMin,
|
|
4316
|
+
bucket.drawHeaderStaging.buffer,
|
|
4317
|
+
bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin,
|
|
4318
|
+
bucket.headerDirtyMax - bucket.headerDirtyMin,
|
|
4319
|
+
);
|
|
4320
|
+
bucket.headerDirtyMin = Infinity;
|
|
4321
|
+
bucket.headerDirtyMax = 0;
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
|
|
3682
4327
|
// ─── update / encodeIntoPass / frame / dispose ───────────────────
|
|
3683
4328
|
/**
|
|
3684
4329
|
* CPU-side data refresh: drain pending aset deltas, repack any
|
|
@@ -3692,6 +4337,16 @@ export function buildHeapScene(
|
|
|
3692
4337
|
sceneObj.evaluateAlways(token, (tok) => {
|
|
3693
4338
|
drainAsetWith(tok);
|
|
3694
4339
|
|
|
4340
|
+
// User storage buffers that marked (e.g. the OIT head buffer
|
|
4341
|
+
// reallocating on a resize) invalidate the bucket's bind groups.
|
|
4342
|
+
for (const b of buckets) {
|
|
4343
|
+
if (!b.storageDirty) continue;
|
|
4344
|
+
b.storageDirty = false;
|
|
4345
|
+
for (let i = 0; i < b.slots.length; i++) {
|
|
4346
|
+
b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
|
|
3695
4350
|
// 0. Reactive rebucket: any RO whose PipelineState modeKey
|
|
3696
4351
|
// changed since last frame moves to its new bucket.
|
|
3697
4352
|
// `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
|
|
@@ -3908,7 +4563,17 @@ export function buildHeapScene(
|
|
|
3908
4563
|
// initial population for 10K ROs this collapses ~30K calls into
|
|
3909
4564
|
// ~5 (arena, indices, plus one per bucket).
|
|
3910
4565
|
arena.attrs.flush(device);
|
|
3911
|
-
|
|
4566
|
+
|
|
4567
|
+
// ─── Waste-triggered attribute-arena compaction ─────────────────
|
|
4568
|
+
// Relocate live allocations to the front of each chunk once
|
|
4569
|
+
// fragmentation waste crosses the floor — exact-size freelist reuse
|
|
4570
|
+
// can't reclaim a drifting size distribution, so the arena would
|
|
4571
|
+
// otherwise ratchet to its high-water forever. Runs AFTER the arena
|
|
4572
|
+
// flush (GPU buffer current) and BEFORE the header flush (so the
|
|
4573
|
+
// re-seated drawHeaders upload below). Indices live in a separate
|
|
4574
|
+
// buffer and are not compacted here.
|
|
4575
|
+
if (enableCompaction) runCompaction(false);
|
|
4576
|
+
|
|
3912
4577
|
for (const bucket of buckets) {
|
|
3913
4578
|
if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
|
|
3914
4579
|
device.queue.writeBuffer(
|
|
@@ -4112,14 +4777,42 @@ export function buildHeapScene(
|
|
|
4112
4777
|
}
|
|
4113
4778
|
|
|
4114
4779
|
function dispose(): void {
|
|
4115
|
-
|
|
4116
|
-
|
|
4780
|
+
// Stop receiving compaction remaps for the shared store.
|
|
4781
|
+
storageReg.dispose();
|
|
4782
|
+
// Shared storage: release this scene's allocations so the shared pool's
|
|
4783
|
+
// refcounts stay correct (another scene may still hold the same regions),
|
|
4784
|
+
// but don't destroy the buffers — the storage owner does that. Private
|
|
4785
|
+
// storage: the whole arena is torn down wholesale, so per-draw release is
|
|
4786
|
+
// unnecessary.
|
|
4787
|
+
if (!ownsStorage) {
|
|
4788
|
+
for (let drawId = 0; drawId < drawIdToBucket.length; drawId++) {
|
|
4789
|
+
if (drawIdToBucket[drawId] !== undefined) removeDraw(drawId);
|
|
4790
|
+
}
|
|
4791
|
+
}
|
|
4117
4792
|
for (const b of buckets) destroyBucketResources(b);
|
|
4793
|
+
if (ownsStorage) storage.dispose();
|
|
4118
4794
|
}
|
|
4119
4795
|
|
|
4120
4796
|
// Test-only escape hatch for inspecting megacall bucket state. Not
|
|
4121
4797
|
// part of the public API surface — keep cast at use-site.
|
|
4122
4798
|
const _debug = {
|
|
4799
|
+
/** Force a full heap compaction pass (attribute arena + index buffer)
|
|
4800
|
+
* regardless of the waste heuristics (bypasses the floor + 50%-live
|
|
4801
|
+
* gate), re-seat all refs, flush. Returns residual waste afterwards in
|
|
4802
|
+
* bytes (attr bytes + index elements×4; 0 when fully packed). Test-only —
|
|
4803
|
+
* drives relocation deterministically without engineering megabytes of
|
|
4804
|
+
* fragmentation. Callers should `update()` afterward to flush drawTables /
|
|
4805
|
+
* re-dispatch the partition (the render path does this). */
|
|
4806
|
+
forceCompact(): number {
|
|
4807
|
+
runCompaction(true);
|
|
4808
|
+
return arena.attrs.totalWasteBytes();
|
|
4809
|
+
},
|
|
4810
|
+
/** Current arena fragmentation waste (bytes) — covers uniforms,
|
|
4811
|
+
* attributes, and index regions (all one arena now). */
|
|
4812
|
+
attrWasteBytes(): number { return arena.attrs.totalWasteBytes(); },
|
|
4813
|
+
/** Number of arena chunks (pages) currently open. >1 means group
|
|
4814
|
+
* placement rolled past one buffer cap. */
|
|
4815
|
+
pageCount(): number { return arena.attrs.chunkCount; },
|
|
4123
4816
|
bucketsForTest(): readonly {
|
|
4124
4817
|
indirectBuf: GPUBuffer | undefined;
|
|
4125
4818
|
drawTableBuf: GPUBuffer | undefined;
|
|
@@ -4180,14 +4873,14 @@ export function buildHeapScene(
|
|
|
4180
4873
|
// multi-chunk-aware validator needs per-bucket-chunkIdx
|
|
4181
4874
|
// staging which is a bigger refactor; assert for now so
|
|
4182
4875
|
// multi-chunk callers don't get false-passes.
|
|
4183
|
-
if (arena.attrs.chunkCount > 1
|
|
4876
|
+
if (arena.attrs.chunkCount > 1) {
|
|
4184
4877
|
throw new Error(
|
|
4185
4878
|
"heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
|
|
4186
4879
|
"Run with the default chunk cap (no second chunk opened) for now.",
|
|
4187
4880
|
);
|
|
4188
4881
|
}
|
|
4189
4882
|
const arenaSize = arena.attrs.chunk(0).buffer.size;
|
|
4190
|
-
const indicesSize = arena
|
|
4883
|
+
const indicesSize = arenaSize; // indices live in the attr arena now
|
|
4191
4884
|
|
|
4192
4885
|
const enc = device.createCommandEncoder({ label: "validateHeap" });
|
|
4193
4886
|
const stage = (src: GPUBuffer, size: number): GPUBuffer => {
|
|
@@ -4200,7 +4893,7 @@ export function buildHeapScene(
|
|
|
4200
4893
|
};
|
|
4201
4894
|
|
|
4202
4895
|
const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
|
|
4203
|
-
const indicesCopy =
|
|
4896
|
+
const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
|
|
4204
4897
|
type DC = {
|
|
4205
4898
|
bucket: typeof buckets[number];
|
|
4206
4899
|
drawHeap: GPUBuffer;
|
|
@@ -4249,7 +4942,7 @@ export function buildHeapScene(
|
|
|
4249
4942
|
// Track unique attribute-alloc refs we've already inspected so we
|
|
4250
4943
|
// don't re-validate the same shared alloc 20K times.
|
|
4251
4944
|
const attrAllocsSeen = new Set<number>();
|
|
4252
|
-
|
|
4945
|
+
const KNOWN_TYPE_IDS = new Set<number>([0, 1, 2, 3]);
|
|
4253
4946
|
|
|
4254
4947
|
let bucketIdx = 0;
|
|
4255
4948
|
for (const dc of dcs) {
|
|
@@ -4260,7 +4953,7 @@ export function buildHeapScene(
|
|
|
4260
4953
|
for (let slot = 0; slot < recordCount; slot++) {
|
|
4261
4954
|
const slotOff = slot * stride;
|
|
4262
4955
|
for (const f of dc.bucket.layout.drawHeaderFields) {
|
|
4263
|
-
if (f.name === "__layoutId") continue;
|
|
4956
|
+
if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
|
|
4264
4957
|
if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
|
|
4265
4958
|
const ref = u32[(slotOff + f.byteOffset) >>> 2];
|
|
4266
4959
|
if (ref === undefined || ref === 0) continue;
|
|
@@ -4294,18 +4987,9 @@ export function buildHeapScene(
|
|
|
4294
4987
|
? `prev=[${arenaU32[refU32 - 4]},${arenaU32[refU32 - 3]},${arenaU32[refU32 - 2]},${arenaU32[refU32 - 1]}] `
|
|
4295
4988
|
: "";
|
|
4296
4989
|
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]}]`;
|
|
4297
|
-
//
|
|
4298
|
-
//
|
|
4299
|
-
|
|
4300
|
-
const shadowChunk = arena.attrs.chunk(0);
|
|
4301
|
-
const shadowU32 = shadowChunk.peekShadowU32(ref, 8);
|
|
4302
|
-
const shadowStr = `shadow=[${shadowU32[0]},${shadowU32[1]},${shadowU32[2]},${shadowU32[3]},${shadowU32[4]},${shadowU32[5]},${shadowU32[6]},${shadowU32[7]}]`;
|
|
4303
|
-
const cpuMatches = shadowU32[0] === arenaU32[refU32]
|
|
4304
|
-
&& shadowU32[1] === arenaU32[refU32 + 1]
|
|
4305
|
-
&& shadowU32[2] === arenaU32[refU32 + 2]
|
|
4306
|
-
&& shadowU32[3] === arenaU32[refU32 + 3];
|
|
4307
|
-
const verdict = cpuMatches ? "GPU=CPU (likely CPU-write bug)" : "GPU≠CPU (likely GPU-write corruption)";
|
|
4308
|
-
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${verdict} ${before}${here} ${shadowStr}`);
|
|
4990
|
+
// (Mirror-less arena: no CPU shadow to cross-check against —
|
|
4991
|
+
// the GPU read-back is the only copy.)
|
|
4992
|
+
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${before}${here}`);
|
|
4309
4993
|
attrAllocsBad++;
|
|
4310
4994
|
continue;
|
|
4311
4995
|
}
|
|
@@ -4516,11 +5200,11 @@ export function buildHeapScene(
|
|
|
4516
5200
|
const push = (s: string) => { if (issues.length < 30) issues.push(s); };
|
|
4517
5201
|
|
|
4518
5202
|
// §3 v1: chunk-0-only like validateHeap.
|
|
4519
|
-
if (arena.attrs.chunkCount > 1
|
|
5203
|
+
if (arena.attrs.chunkCount > 1) {
|
|
4520
5204
|
throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
|
|
4521
5205
|
}
|
|
4522
5206
|
const arenaSize = arena.attrs.chunk(0).buffer.size;
|
|
4523
|
-
const indicesSize = arena
|
|
5207
|
+
const indicesSize = arenaSize; // indices live in the attr arena now
|
|
4524
5208
|
|
|
4525
5209
|
// Stage all buffers we'll need.
|
|
4526
5210
|
const enc = device.createCommandEncoder({ label: "simulateDraws" });
|
|
@@ -4532,7 +5216,7 @@ export function buildHeapScene(
|
|
|
4532
5216
|
return c;
|
|
4533
5217
|
};
|
|
4534
5218
|
const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
|
|
4535
|
-
const indicesCopy =
|
|
5219
|
+
const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
|
|
4536
5220
|
type DC = {
|
|
4537
5221
|
bucket: typeof buckets[number];
|
|
4538
5222
|
drawHeap: GPUBuffer;
|
|
@@ -4592,7 +5276,7 @@ export function buildHeapScene(
|
|
|
4592
5276
|
for (const dc of dcs) {
|
|
4593
5277
|
const fs: Field[] = [];
|
|
4594
5278
|
for (const f of dc.bucket.layout.drawHeaderFields) {
|
|
4595
|
-
if (f.name === "__layoutId") continue;
|
|
5279
|
+
if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
|
|
4596
5280
|
if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
|
|
4597
5281
|
const wt = f.kind === "uniform-ref" ? f.uniformWgslType : f.attributeWgslType;
|
|
4598
5282
|
const eltF =
|
|
@@ -4803,11 +5487,11 @@ export function buildHeapScene(
|
|
|
4803
5487
|
const allocClaims: { ref: number; bytes: number; owner: string }[] = [];
|
|
4804
5488
|
|
|
4805
5489
|
// §3 v1: chunk-0-only (see validateHeap).
|
|
4806
|
-
if (arena.attrs.chunkCount > 1
|
|
5490
|
+
if (arena.attrs.chunkCount > 1) {
|
|
4807
5491
|
throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
|
|
4808
5492
|
}
|
|
4809
5493
|
const arenaBuf0 = arena.attrs.chunk(0).buffer;
|
|
4810
|
-
const indicesBuf0 = arena.
|
|
5494
|
+
const indicesBuf0 = arena.attrs.chunk(0).buffer;
|
|
4811
5495
|
// Stage arena + arena.indices once (shared across buckets).
|
|
4812
5496
|
const arenaCopy = device.createBuffer({
|
|
4813
5497
|
size: arenaBuf0.size,
|
|
@@ -5255,7 +5939,7 @@ export function buildHeapScene(
|
|
|
5255
5939
|
{ binding: 0, resource: { buffer: target.slots[0]!.drawTableBuf!.buffer } },
|
|
5256
5940
|
{ binding: 1, resource: { buffer: target.slots[0]!.firstDrawInTileBuf!.buffer } },
|
|
5257
5941
|
{ binding: 2, resource: { buffer: sampleBuf } },
|
|
5258
|
-
{ binding: 3, resource: { buffer: arena.
|
|
5942
|
+
{ binding: 3, resource: { buffer: arena.attrs.chunk(target.chunkIdx).buffer } },
|
|
5259
5943
|
{ binding: 4, resource: { buffer: outBuf } },
|
|
5260
5944
|
{ binding: 5, resource: { buffer: paramBuf } },
|
|
5261
5945
|
],
|
|
@@ -5278,7 +5962,7 @@ export function buildHeapScene(
|
|
|
5278
5962
|
sampleBuf.destroy(); outBuf.destroy(); paramBuf.destroy();
|
|
5279
5963
|
|
|
5280
5964
|
// Read drawTable + indices for CPU expectation.
|
|
5281
|
-
const idxBuf = arena.
|
|
5965
|
+
const idxBuf = arena.attrs.chunk(target.chunkIdx).buffer;
|
|
5282
5966
|
const indicesCopy2 = device.createBuffer({
|
|
5283
5967
|
size: idxBuf.size,
|
|
5284
5968
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|