@aardworx/wombat.rendering 0.9.13 → 0.9.15
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/runtime/derivedModes/modeKeyCpu.d.ts +45 -0
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -0
- package/dist/runtime/derivedModes/modeKeyCpu.js +179 -0
- package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -0
- package/dist/runtime/derivedModes/partition.d.ts +36 -0
- package/dist/runtime/derivedModes/partition.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partition.js +170 -0
- package/dist/runtime/derivedModes/partition.js.map +1 -0
- package/dist/runtime/derivedModes/slotTable.d.ts +70 -0
- package/dist/runtime/derivedModes/slotTable.d.ts.map +1 -0
- package/dist/runtime/derivedModes/slotTable.js +130 -0
- package/dist/runtime/derivedModes/slotTable.js.map +1 -0
- package/dist/runtime/derivedUniforms/marker.d.ts +11 -3
- package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/marker.js +10 -5
- package/dist/runtime/derivedUniforms/marker.js.map +1 -1
- package/dist/runtime/heapScene/growBuffer.d.ts +33 -0
- package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -0
- package/dist/runtime/heapScene/growBuffer.js +76 -0
- package/dist/runtime/heapScene/growBuffer.js.map +1 -0
- package/dist/runtime/heapScene/packers.d.ts +25 -0
- package/dist/runtime/heapScene/packers.d.ts.map +1 -0
- package/dist/runtime/heapScene/packers.js +111 -0
- package/dist/runtime/heapScene/packers.js.map +1 -0
- package/dist/runtime/heapScene/pools.d.ts +181 -0
- package/dist/runtime/heapScene/pools.d.ts.map +1 -0
- package/dist/runtime/heapScene/pools.js +417 -0
- package/dist/runtime/heapScene/pools.js.map +1 -0
- package/dist/runtime/heapScene/scanKernel.d.ts +11 -0
- package/dist/runtime/heapScene/scanKernel.d.ts.map +1 -0
- package/dist/runtime/heapScene/scanKernel.js +181 -0
- package/dist/runtime/heapScene/scanKernel.js.map +1 -0
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +228 -937
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +5 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/pipelineCache/bitfield.d.ts +5 -0
- package/dist/runtime/pipelineCache/bitfield.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/bitfield.js +201 -0
- package/dist/runtime/pipelineCache/bitfield.js.map +1 -0
- package/dist/runtime/pipelineCache/cache.d.ts +36 -0
- package/dist/runtime/pipelineCache/cache.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/cache.js +108 -0
- package/dist/runtime/pipelineCache/cache.js.map +1 -0
- package/dist/runtime/pipelineCache/descriptor.d.ts +58 -0
- package/dist/runtime/pipelineCache/descriptor.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/descriptor.js +100 -0
- package/dist/runtime/pipelineCache/descriptor.js.map +1 -0
- package/dist/runtime/pipelineCache/index.d.ts +5 -0
- package/dist/runtime/pipelineCache/index.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/index.js +12 -0
- package/dist/runtime/pipelineCache/index.js.map +1 -0
- package/package.json +1 -1
- package/src/runtime/derivedModes/modeKeyCpu.ts +213 -0
- package/src/runtime/derivedModes/partition.ts +206 -0
- package/src/runtime/derivedModes/slotTable.ts +153 -0
- package/src/runtime/derivedUniforms/marker.ts +19 -6
- package/src/runtime/heapScene/growBuffer.ts +75 -0
- package/src/runtime/heapScene/packers.ts +127 -0
- package/src/runtime/heapScene/pools.ts +492 -0
- package/src/runtime/heapScene/scanKernel.ts +184 -0
- package/src/runtime/heapScene.ts +283 -1064
- package/src/runtime/index.ts +40 -0
- package/src/runtime/pipelineCache/bitfield.ts +225 -0
- package/src/runtime/pipelineCache/cache.ts +129 -0
- package/src/runtime/pipelineCache/descriptor.ts +150 -0
- package/src/runtime/pipelineCache/index.ts +37 -0
package/src/runtime/heapScene.ts
CHANGED
|
@@ -75,161 +75,28 @@ import {
|
|
|
75
75
|
isDerivedRule, STANDARD_DERIVED_RULES, STANDARD_TRAFO_LEAVES, inputsOf,
|
|
76
76
|
type RoRegistration, type DerivedRule,
|
|
77
77
|
} from "./derivedUniforms/index.js";
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
function packMat44(m: M44d, dst: Float32Array, off: number): void {
|
|
101
|
-
// Zero-alloc flat copy (row-major) straight into the f32 staging
|
|
102
|
-
// buffer — `copyTo` does `dst.set(m._data, off)` which narrows f64→f32
|
|
103
|
-
// on store, no throwaway `number[]` per call.
|
|
104
|
-
m.copyTo(dst, off);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ─── Layout-driven value packing ────────────────────────────────────
|
|
108
|
-
//
|
|
109
|
-
// Maps a schema uniform name + its value source to bytes in a staging
|
|
110
|
-
// buffer. The bridge between the spec's named JS-side fields (e.g.
|
|
111
|
-
// `spec.modelTrafo: Trafo3d`) and the schema's typed uniforms
|
|
112
|
-
// ("ModelTrafo" mat4, "ModelTrafoInv" mat4, …). Step 5 generalises
|
|
113
|
-
// this to a `spec.uniforms: { [name]: aval }` map; until then we
|
|
114
|
-
// hardcode the shape here.
|
|
115
|
-
|
|
116
|
-
// ─── Generic packer registry, keyed on WGSL type ────────────────────
|
|
117
|
-
//
|
|
118
|
-
// Each per-draw uniform comes from the spec as an aval whose JS value
|
|
119
|
-
// type is determined by what the user passes. The packer for a given
|
|
120
|
-
// WGSL type knows how to turn that JS value into bytes for the arena.
|
|
121
|
-
// Step 5: this replaces the per-name `perDrawBinding` switch — the
|
|
122
|
-
// spec just supplies `uniforms: { [name]: aval<...> }` and the
|
|
123
|
-
// runtime asks the registry "how do I pack a `mat4x4<f32>`".
|
|
124
|
-
|
|
125
|
-
/** A packer for one WGSL storage-buffer type. */
|
|
126
|
-
interface WgslPacker {
|
|
127
|
-
/** Tightly-packed size in bytes of one value (mat4 = 64, vec3 = 12, …). */
|
|
128
|
-
readonly dataBytes: number;
|
|
129
|
-
readonly typeId: number;
|
|
130
|
-
/**
|
|
131
|
-
* Pack `val` (the aval's `.getValue(tok)` result) into `dst` at
|
|
132
|
-
* float offset `off`. The packer is responsible for handling the
|
|
133
|
-
* value type — Trafo3d, M44d, V4f, V3d, V3f, number — coercing as
|
|
134
|
-
* needed. Throws on unsupported value shapes.
|
|
135
|
-
*/
|
|
136
|
-
readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const PACKER_MAT4: WgslPacker = {
|
|
140
|
-
dataBytes: 64, typeId: 0,
|
|
141
|
-
pack: (val, dst, off) => {
|
|
142
|
-
// Accept Trafo3d (uses .forward) or M44d directly.
|
|
143
|
-
const m = (val as { forward?: M44d }).forward !== undefined
|
|
144
|
-
? (val as { forward: M44d }).forward
|
|
145
|
-
: (val as M44d);
|
|
146
|
-
packMat44(m, dst, off);
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
const PACKER_VEC4: WgslPacker = {
|
|
150
|
-
dataBytes: 16, typeId: 0,
|
|
151
|
-
pack: (val, dst, off) => {
|
|
152
|
-
const v = val as V4f;
|
|
153
|
-
dst[off + 0] = v.x; dst[off + 1] = v.y;
|
|
154
|
-
dst[off + 2] = v.z; dst[off + 3] = v.w;
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
const PACKER_VEC3: WgslPacker = {
|
|
158
|
-
dataBytes: 12, typeId: 0,
|
|
159
|
-
pack: (val, dst, off) => {
|
|
160
|
-
// V3f or V3d both expose .x/.y/.z; cast through a common shape.
|
|
161
|
-
const v = val as { x: number; y: number; z: number };
|
|
162
|
-
dst[off + 0] = v.x; dst[off + 1] = v.y; dst[off + 2] = v.z;
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
const PACKER_VEC2: WgslPacker = {
|
|
166
|
-
dataBytes: 8, typeId: 0,
|
|
167
|
-
pack: (val, dst, off) => {
|
|
168
|
-
const v = val as { x: number; y: number };
|
|
169
|
-
dst[off + 0] = v.x; dst[off + 1] = v.y;
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
const PACKER_F32: WgslPacker = {
|
|
173
|
-
dataBytes: 4, typeId: 0,
|
|
174
|
-
pack: (val, dst, off) => { dst[off] = val as number; },
|
|
175
|
-
};
|
|
176
|
-
// Integer scalars / vectors. The arena is fronted by a `Float32Array`,
|
|
177
|
-
// so writing raw bits has to go through a same-buffer Uint32/Int32 view
|
|
178
|
-
// to avoid the lossy `i32 → f32` coercion you'd get from a direct
|
|
179
|
-
// `dst[off] = ...` assignment.
|
|
180
|
-
function makeIntPacker(
|
|
181
|
-
ctor: typeof Uint32Array | typeof Int32Array,
|
|
182
|
-
dim: 1 | 2 | 3 | 4,
|
|
183
|
-
): WgslPacker {
|
|
184
|
-
const bytes = dim * 4;
|
|
185
|
-
if (dim === 1) {
|
|
186
|
-
return {
|
|
187
|
-
dataBytes: bytes, typeId: 0,
|
|
188
|
-
pack: (val, dst, off) => {
|
|
189
|
-
new ctor(dst.buffer as ArrayBuffer, dst.byteOffset + off * 4, 1)[0] = val as number;
|
|
190
|
-
},
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
// Vector: accept {x,y,z,w} components.
|
|
194
|
-
return {
|
|
195
|
-
dataBytes: bytes, typeId: 0,
|
|
196
|
-
pack: (val, dst, off) => {
|
|
197
|
-
const view = new ctor(dst.buffer as ArrayBuffer, dst.byteOffset + off * 4, dim);
|
|
198
|
-
const v = val as { x: number; y: number; z?: number; w?: number };
|
|
199
|
-
view[0] = v.x; view[1] = v.y;
|
|
200
|
-
if (dim >= 3) view[2] = v.z!;
|
|
201
|
-
if (dim >= 4) view[3] = v.w!;
|
|
202
|
-
},
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
const PACKER_U32 = makeIntPacker(Uint32Array, 1);
|
|
206
|
-
const PACKER_UVEC2 = makeIntPacker(Uint32Array, 2);
|
|
207
|
-
const PACKER_UVEC3 = makeIntPacker(Uint32Array, 3);
|
|
208
|
-
const PACKER_UVEC4 = makeIntPacker(Uint32Array, 4);
|
|
209
|
-
const PACKER_I32 = makeIntPacker(Int32Array, 1);
|
|
210
|
-
const PACKER_IVEC2 = makeIntPacker(Int32Array, 2);
|
|
211
|
-
const PACKER_IVEC3 = makeIntPacker(Int32Array, 3);
|
|
212
|
-
const PACKER_IVEC4 = makeIntPacker(Int32Array, 4);
|
|
213
|
-
|
|
214
|
-
function packerForWgslType(wgslType: string): WgslPacker {
|
|
215
|
-
switch (wgslType) {
|
|
216
|
-
case "mat4x4<f32>": return PACKER_MAT4;
|
|
217
|
-
case "vec4<f32>": return PACKER_VEC4;
|
|
218
|
-
case "vec3<f32>": return PACKER_VEC3;
|
|
219
|
-
case "vec2<f32>": return PACKER_VEC2;
|
|
220
|
-
case "f32": return PACKER_F32;
|
|
221
|
-
case "u32": return PACKER_U32;
|
|
222
|
-
case "vec2<u32>": return PACKER_UVEC2;
|
|
223
|
-
case "vec3<u32>": return PACKER_UVEC3;
|
|
224
|
-
case "vec4<u32>": return PACKER_UVEC4;
|
|
225
|
-
case "i32": return PACKER_I32;
|
|
226
|
-
case "vec2<i32>": return PACKER_IVEC2;
|
|
227
|
-
case "vec3<i32>": return PACKER_IVEC3;
|
|
228
|
-
case "vec4<i32>": return PACKER_IVEC4;
|
|
229
|
-
default:
|
|
230
|
-
throw new Error(`heapScene: no JS-side packer for WGSL type '${wgslType}'`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
78
|
+
import { packerForWgslType, PACKER_MAT4, type WgslPacker } from "./heapScene/packers.js";
|
|
79
|
+
import {
|
|
80
|
+
HEAP_SCAN_WGSL, SCAN_TILE_SIZE, SCAN_WG_SIZE, SCAN_MAX_RECORDS,
|
|
81
|
+
TILE_K, RECORD_U32, RECORD_BYTES,
|
|
82
|
+
} from "./heapScene/scanKernel.js";
|
|
83
|
+
import {
|
|
84
|
+
GrowBuffer, MIN_BUFFER_BYTES, POW2, ALIGN16,
|
|
85
|
+
} from "./heapScene/growBuffer.js";
|
|
86
|
+
import {
|
|
87
|
+
UniformPool, IndexPool, DrawHeap,
|
|
88
|
+
AttributeArena, IndexAllocator, insertSortedFreeBlock,
|
|
89
|
+
buildArenaState, arenaBytes, writeAttribute,
|
|
90
|
+
asAval, isBufferView, asFloat32,
|
|
91
|
+
ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO,
|
|
92
|
+
ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS,
|
|
93
|
+
type ArenaState,
|
|
94
|
+
} from "./heapScene/pools.js";
|
|
95
|
+
import { encodeModeKey } from "./pipelineCache/index.js";
|
|
96
|
+
import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
|
|
97
|
+
|
|
98
|
+
// GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
|
|
99
|
+
// Packers (WGSL-type → JS-value → arena bytes) live in ./heapScene/packers.
|
|
233
100
|
|
|
234
101
|
// ---------------------------------------------------------------------------
|
|
235
102
|
// Shared WGSL prelude (struct + bindings + VS)
|
|
@@ -268,765 +135,65 @@ export interface HeapGeometry {
|
|
|
268
135
|
}
|
|
269
136
|
|
|
270
137
|
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
// Resizable buffer (pow2 grow + GPU-side copy on resize)
|
|
273
|
-
// ---------------------------------------------------------------------------
|
|
274
138
|
|
|
275
|
-
const MIN_BUFFER_BYTES = 64 * 1024;
|
|
276
|
-
const POW2 = (n: number): number => {
|
|
277
|
-
let p = 1; while (p < n) p <<= 1; return p;
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* A GPUBuffer that can grow to next power-of-two on demand. On grow,
|
|
282
|
-
* a fresh buffer is created at the new size, the live tail copied
|
|
283
|
-
* over via copyBufferToBuffer, and dependents (bind groups, mostly)
|
|
284
|
-
* are notified to rebuild via the `onResize` callback.
|
|
285
|
-
*
|
|
286
|
-
* `usedBytes` is the high-water mark — the runtime advances this as
|
|
287
|
-
* it allocates, and `ensureCapacity` grows when required. This
|
|
288
|
-
* separates allocation policy from grow policy.
|
|
289
|
-
*/
|
|
290
|
-
class GrowBuffer {
|
|
291
|
-
private buf: GPUBuffer;
|
|
292
|
-
private cap: number;
|
|
293
|
-
private used = 0;
|
|
294
|
-
private readonly listeners = new Set<() => void>();
|
|
295
|
-
constructor(
|
|
296
|
-
private readonly device: GPUDevice,
|
|
297
|
-
private readonly label: string,
|
|
298
|
-
private readonly usage: GPUBufferUsageFlags,
|
|
299
|
-
initialBytes: number,
|
|
300
|
-
) {
|
|
301
|
-
this.cap = Math.max(MIN_BUFFER_BYTES, POW2(initialBytes));
|
|
302
|
-
this.buf = device.createBuffer({ size: this.cap, usage: usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label });
|
|
303
|
-
}
|
|
304
|
-
get buffer(): GPUBuffer { return this.buf; }
|
|
305
|
-
get capacity(): number { return this.cap; }
|
|
306
|
-
get usedBytes(): number { return this.used; }
|
|
307
|
-
setUsed(n: number): void { this.used = n; }
|
|
308
|
-
onResize(cb: () => void): IDisposable {
|
|
309
|
-
this.listeners.add(cb);
|
|
310
|
-
return { dispose: () => { this.listeners.delete(cb); } };
|
|
311
|
-
}
|
|
312
|
-
/** Ensure the buffer is at least `bytes` capacity. Grows by pow2 + copies live tail. */
|
|
313
|
-
ensureCapacity(bytes: number): void {
|
|
314
|
-
if (bytes <= this.cap) return;
|
|
315
|
-
const newCap = POW2(bytes);
|
|
316
|
-
const newBuf = this.device.createBuffer({
|
|
317
|
-
size: newCap, usage: this.usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label: this.label,
|
|
318
|
-
});
|
|
319
|
-
if (this.used > 0) {
|
|
320
|
-
const enc = this.device.createCommandEncoder({ label: `${this.label}: grow-copy` });
|
|
321
|
-
enc.copyBufferToBuffer(this.buf, 0, newBuf, 0, ALIGN16(this.used));
|
|
322
|
-
this.device.queue.submit([enc.finish()]);
|
|
323
|
-
}
|
|
324
|
-
this.buf.destroy();
|
|
325
|
-
this.buf = newBuf;
|
|
326
|
-
this.cap = newCap;
|
|
327
|
-
for (const cb of this.listeners) cb();
|
|
328
|
-
}
|
|
329
|
-
destroy(): void { this.buf.destroy(); }
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
// UniformPool — aval-keyed refcounted allocations over the AttributeArena
|
|
334
139
|
// ---------------------------------------------------------------------------
|
|
335
|
-
|
|
336
|
-
interface PoolEntry {
|
|
337
|
-
/** Byte offset into the arena. Data starts at ref + ALLOC_HEADER_PAD_TO. */
|
|
338
|
-
readonly ref: number;
|
|
339
|
-
readonly dataBytes: number;
|
|
340
|
-
readonly typeId: number;
|
|
341
|
-
/** Packer used to refresh the data region on aval marks. */
|
|
342
|
-
readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
|
|
343
|
-
refcount: number;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Aval-keyed pool of arena allocations. One allocation per unique
|
|
348
|
-
* aval (object identity). Two draws referencing the same aval share
|
|
349
|
-
* the allocation; their DrawHeaders carry the same u32 ref. Holds
|
|
350
|
-
* uniforms (fixed-size scalars/vectors/matrices) AND attribute arrays
|
|
351
|
-
* (variable-size). The caller decides `dataBytes` + `length` per
|
|
352
|
-
* acquisition — the pool just keys on aval identity and refcounts.
|
|
353
|
-
*
|
|
354
|
-
* Sharing emerges from aval identity — no separate "frequency"
|
|
355
|
-
* declaration needed. A `cval` shared by all draws → 1 alloc.
|
|
356
|
-
* A static positions array shared across instanced draws → 1 alloc.
|
|
357
|
-
* Same code path either way.
|
|
358
|
-
*/
|
|
359
|
-
class UniformPool {
|
|
360
|
-
// Keyed by `aval<unknown>` *by reference* (a plain JS `Map`). These
|
|
361
|
-
// keys are overwhelmingly reactive `cval`s (per-object trafos,
|
|
362
|
-
// colours, …) and the hot path is `acquire`/`release` ~once per
|
|
363
|
-
// drawHeader field per RO. A content-keyed `HashTable` would buy
|
|
364
|
-
// nothing here — reactive avals never compare content-equal — and
|
|
365
|
-
// would cost: reactive avals have no `equals`/`getHashCode`, so a
|
|
366
|
-
// `HashTable` falls back to a WeakMap-counter identity hash per
|
|
367
|
-
// lookup, measurably slower than `Map`'s native hashing. (Constant-
|
|
368
|
-
// aval dedup matters where keys are *texture* avals — there the
|
|
369
|
-
// `AtlasPool` is content-keyed; constant avals there carry a cached
|
|
370
|
-
// hash and a fast `equals`.)
|
|
371
|
-
private readonly byAval = new Map<aval<unknown>, PoolEntry>();
|
|
372
|
-
|
|
373
|
-
has(aval: aval<unknown>): boolean { return this.byAval.has(aval); }
|
|
374
|
-
entry(aval: aval<unknown>): PoolEntry | undefined { return this.byAval.get(aval); }
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Acquire (or share) an allocation for `aval`. Caller passes the
|
|
378
|
-
* pre-read `value` (so the pool doesn't need a token) plus the
|
|
379
|
-
* (`dataBytes`, `typeId`, `length`, `pack`) describing how to lay
|
|
380
|
-
* it out. If a new allocation is made, the value is packed and
|
|
381
|
-
* uploaded immediately.
|
|
382
|
-
*/
|
|
383
|
-
acquire(
|
|
384
|
-
device: GPUDevice,
|
|
385
|
-
arena: AttributeArena,
|
|
386
|
-
aval: aval<unknown>,
|
|
387
|
-
value: unknown,
|
|
388
|
-
dataBytes: number,
|
|
389
|
-
typeId: number,
|
|
390
|
-
length: number,
|
|
391
|
-
pack: (val: unknown, dst: Float32Array, off: number) => void,
|
|
392
|
-
): number {
|
|
393
|
-
const existing = this.byAval.get(aval);
|
|
394
|
-
if (existing !== undefined) {
|
|
395
|
-
existing.refcount++;
|
|
396
|
-
return existing.ref;
|
|
397
|
-
}
|
|
398
|
-
const ref = arena.alloc(dataBytes);
|
|
399
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
400
|
-
const buf = new ArrayBuffer(allocBytes);
|
|
401
|
-
const u32 = new Uint32Array(buf);
|
|
402
|
-
const f32 = new Float32Array(buf);
|
|
403
|
-
u32[0] = typeId;
|
|
404
|
-
u32[1] = length;
|
|
405
|
-
// stride_bytes (offset 8): bytes per element. Lets the VS decode
|
|
406
|
-
// pick V3- vs V4-tight load expressions for vec4 attributes
|
|
407
|
-
// (and is informative for everything else).
|
|
408
|
-
u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
|
|
409
|
-
pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
|
|
410
|
-
arena.write(ref, new Uint8Array(buf));
|
|
411
|
-
void device;
|
|
412
|
-
this.byAval.set(aval, { ref, dataBytes, typeId, pack, refcount: 1 });
|
|
413
|
-
return ref;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/** Decrement refcount; if zero, free the arena allocation. */
|
|
417
|
-
release(arena: AttributeArena, aval: aval<unknown>): void {
|
|
418
|
-
const e = this.byAval.get(aval);
|
|
419
|
-
if (e === undefined) return;
|
|
420
|
-
e.refcount--;
|
|
421
|
-
if (e.refcount > 0) return;
|
|
422
|
-
arena.release(e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
|
|
423
|
-
this.byAval.delete(aval);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/** Re-pack one entry's data region into the arena's CPU shadow. */
|
|
427
|
-
repack(device: GPUDevice, arena: AttributeArena, aval: aval<unknown>, val: unknown): void {
|
|
428
|
-
const e = this.byAval.get(aval);
|
|
429
|
-
if (e === undefined) return;
|
|
430
|
-
const dst = new Float32Array(e.dataBytes / 4);
|
|
431
|
-
e.pack(val, dst, 0);
|
|
432
|
-
arena.write(
|
|
433
|
-
e.ref + ALLOC_HEADER_PAD_TO,
|
|
434
|
-
new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes),
|
|
435
|
-
);
|
|
436
|
-
void device;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Aval-keyed pool over the `IndexAllocator`. Two draws referencing
|
|
442
|
-
* the same `Uint32Array` (or aval thereof) share an index range —
|
|
443
|
-
* 19K instanced clones of the same mesh share one allocation, one
|
|
444
|
-
* upload. Index data is treated as immutable for the aval's
|
|
445
|
-
* lifetime: an aval mark won't repack (we'd have to free + re-alloc
|
|
446
|
-
* since size changes are likely). Use a fresh aval to swap meshes.
|
|
447
|
-
*
|
|
448
|
-
* **Value-equality dedup for constant avals (§5b):** when an
|
|
449
|
-
* incoming aval has `isConstant === true`, the pool also keys by
|
|
450
|
-
* the underlying `ArrayBuffer` tuple `(buffer, byteOffset,
|
|
451
|
-
* byteLength)`. Two distinct constant avals wrapping the same
|
|
452
|
-
* `Uint32Array` view (or two views over the same backing buffer
|
|
453
|
-
* with matching offsets) collapse to one allocation. Hashing
|
|
454
|
-
* kilobytes of indices on every acquire would be wasteful; the
|
|
455
|
-
* tuple key catches the realistic "one ArrayBuffer shared across
|
|
456
|
-
* many aval wrappers" pattern, which is the only one that matters
|
|
457
|
-
* for the heap path. Reactive (non-constant) avals fall through
|
|
458
|
-
* to identity-only — their content can change and the pool can't
|
|
459
|
-
* silently merge them.
|
|
460
|
-
*/
|
|
461
|
-
class IndexPool {
|
|
462
|
-
// Per-aval binding. `perAvalCount` tracks acquire/release balance
|
|
463
|
-
// for THIS aval; `entry` is the shared allocation (one entry can be
|
|
464
|
-
// referenced by many aliasing avals via §5b dedup).
|
|
465
|
-
private readonly byAval = new Map<
|
|
466
|
-
aval<Uint32Array>,
|
|
467
|
-
{ entry: IndexPoolEntry; perAvalCount: number }
|
|
468
|
-
>();
|
|
469
|
-
private readonly byValueKey = new Map<string, IndexPoolEntry>();
|
|
470
|
-
// Stable per-ArrayBuffer numeric id for value-key composition.
|
|
471
|
-
// WeakMap-backed so buffers GC'd elsewhere drop their entry too.
|
|
472
|
-
private readonly bufferIds = new WeakMap<ArrayBufferLike, number>();
|
|
473
|
-
private nextBufferId = 1;
|
|
474
|
-
private bufferIdOf(buf: ArrayBufferLike): number {
|
|
475
|
-
let id = this.bufferIds.get(buf);
|
|
476
|
-
if (id === undefined) {
|
|
477
|
-
id = this.nextBufferId++;
|
|
478
|
-
this.bufferIds.set(buf, id);
|
|
479
|
-
}
|
|
480
|
-
return id;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
acquire(
|
|
484
|
-
device: GPUDevice,
|
|
485
|
-
indices: IndexAllocator,
|
|
486
|
-
aval: aval<Uint32Array>,
|
|
487
|
-
arr: Uint32Array,
|
|
488
|
-
): { firstIndex: number; count: number } {
|
|
489
|
-
const bound = this.byAval.get(aval);
|
|
490
|
-
if (bound !== undefined) {
|
|
491
|
-
bound.perAvalCount++;
|
|
492
|
-
bound.entry.totalRefcount++;
|
|
493
|
-
return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
|
|
494
|
-
}
|
|
495
|
-
let valueKey: string | undefined;
|
|
496
|
-
if (aval.isConstant) {
|
|
497
|
-
valueKey = `${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
|
|
498
|
-
const shared = this.byValueKey.get(valueKey);
|
|
499
|
-
if (shared !== undefined) {
|
|
500
|
-
shared.totalRefcount++;
|
|
501
|
-
this.byAval.set(aval, { entry: shared, perAvalCount: 1 });
|
|
502
|
-
return { firstIndex: shared.firstIndex, count: shared.count };
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
const firstIndex = indices.alloc(arr.length);
|
|
506
|
-
indices.write(
|
|
507
|
-
firstIndex * 4,
|
|
508
|
-
new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength),
|
|
509
|
-
);
|
|
510
|
-
void device;
|
|
511
|
-
const entry: IndexPoolEntry = { firstIndex, count: arr.length, totalRefcount: 1, valueKey };
|
|
512
|
-
this.byAval.set(aval, { entry, perAvalCount: 1 });
|
|
513
|
-
if (valueKey !== undefined) this.byValueKey.set(valueKey, entry);
|
|
514
|
-
return { firstIndex, count: arr.length };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
release(indices: IndexAllocator, aval: aval<Uint32Array>): void {
|
|
518
|
-
const bound = this.byAval.get(aval);
|
|
519
|
-
if (bound === undefined) return;
|
|
520
|
-
bound.perAvalCount--;
|
|
521
|
-
bound.entry.totalRefcount--;
|
|
522
|
-
if (bound.perAvalCount === 0) this.byAval.delete(aval);
|
|
523
|
-
if (bound.entry.totalRefcount > 0) return;
|
|
524
|
-
indices.release(bound.entry.firstIndex, bound.entry.count);
|
|
525
|
-
if (bound.entry.valueKey !== undefined) {
|
|
526
|
-
this.byValueKey.delete(bound.entry.valueKey);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
interface IndexPoolEntry {
|
|
532
|
-
firstIndex: number;
|
|
533
|
-
count: number;
|
|
534
|
-
totalRefcount: number;
|
|
535
|
-
valueKey: string | undefined;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// ---------------------------------------------------------------------------
|
|
539
|
-
// DrawHeap (slot-indexed) and AttributeArena (byte-bump) allocators
|
|
540
|
-
// ---------------------------------------------------------------------------
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Slot-indexed allocator over a GrowBuffer. `slotBytes` is set per-
|
|
544
|
-
* instance — each bucket sizes its DrawHeader from its effect's
|
|
545
|
-
* schema, so a bucket whose layout is e.g. 96 B / slot uses a
|
|
546
|
-
* DrawHeap with `slotBytes=96`.
|
|
547
|
-
*/
|
|
548
|
-
class DrawHeap {
|
|
549
|
-
private free: number[] = [];
|
|
550
|
-
private nextSlot = 0;
|
|
551
|
-
constructor(private readonly buf: GrowBuffer, private readonly slotBytes: number) {}
|
|
552
|
-
get buffer(): GPUBuffer { return this.buf.buffer; }
|
|
553
|
-
/** Bytes per slot — caller multiplies by slot index for byte offsets. */
|
|
554
|
-
get bytesPerSlot(): number { return this.slotBytes; }
|
|
555
|
-
/** High-water mark in bytes (used to size bind-group entry on rebuild). */
|
|
556
|
-
get usedBytes(): number { return this.nextSlot * this.slotBytes; }
|
|
557
|
-
alloc(): number {
|
|
558
|
-
const slot = this.free.length > 0 ? this.free.pop()! : this.nextSlot++;
|
|
559
|
-
this.buf.ensureCapacity((slot + 1) * this.slotBytes);
|
|
560
|
-
this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
|
|
561
|
-
return slot;
|
|
562
|
-
}
|
|
563
|
-
release(slot: number): void { this.free.push(slot); }
|
|
564
|
-
onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
|
|
565
|
-
destroy(): void { this.buf.destroy(); }
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* Byte-bump allocator over a GrowBuffer for variable-size attribute
|
|
570
|
-
* allocations. Each allocation gets a 16-byte aligned start (8-byte
|
|
571
|
-
* (typeId, length) header at the start, data 16 bytes in). Frees go
|
|
572
|
-
* onto a list keyed by size for simple first-fit reuse later — for
|
|
573
|
-
* now `release` just records the gap and the bump cursor never
|
|
574
|
-
* shrinks.
|
|
575
|
-
*/
|
|
576
|
-
class AttributeArena {
|
|
577
|
-
private cursor = 0;
|
|
578
|
-
// (offset, size) free entries; first-fit reuse not yet implemented.
|
|
579
|
-
private freeList: { off: number; size: number }[] = [];
|
|
580
|
-
// CPU shadow of the entire GPU buffer. Writes go here first; a
|
|
581
|
-
// single `device.queue.writeBuffer` per dirty contiguous range
|
|
582
|
-
// lifts them to the GPU at flush time. At the cost of doubling
|
|
583
|
-
// host memory we collapse N small writeBuffer calls (10K+ at
|
|
584
|
-
// initial population) to 1 per frame.
|
|
585
|
-
private shadow: Uint8Array;
|
|
586
|
-
private dirtyMin = Infinity;
|
|
587
|
-
private dirtyMax = 0;
|
|
588
|
-
constructor(private readonly buf: GrowBuffer) {
|
|
589
|
-
this.shadow = new Uint8Array(buf.capacity);
|
|
590
|
-
buf.onResize(() => {
|
|
591
|
-
const grown = new Uint8Array(buf.capacity);
|
|
592
|
-
grown.set(this.shadow);
|
|
593
|
-
this.shadow = grown;
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
get buffer(): GPUBuffer { return this.buf.buffer; }
|
|
597
|
-
get capacity(): number { return this.buf.capacity; }
|
|
598
|
-
get usedBytes(): number { return this.cursor; }
|
|
599
|
-
/**
|
|
600
|
-
* Stage `data` to the shadow at byte offset `dst`. Tracks the
|
|
601
|
-
* dirty range so `flush(device)` can emit a single writeBuffer
|
|
602
|
-
* covering everything dirty since the last flush.
|
|
603
|
-
*/
|
|
604
|
-
write(dst: number, data: Uint8Array): void {
|
|
605
|
-
this.shadow.set(data, dst);
|
|
606
|
-
if (dst < this.dirtyMin) this.dirtyMin = dst;
|
|
607
|
-
const end = dst + data.byteLength;
|
|
608
|
-
if (end > this.dirtyMax) this.dirtyMax = end;
|
|
609
|
-
}
|
|
610
|
-
flush(device: GPUDevice): void {
|
|
611
|
-
if (this.dirtyMax <= this.dirtyMin) return;
|
|
612
|
-
device.queue.writeBuffer(
|
|
613
|
-
this.buf.buffer, this.dirtyMin,
|
|
614
|
-
this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin,
|
|
615
|
-
this.dirtyMax - this.dirtyMin,
|
|
616
|
-
);
|
|
617
|
-
this.dirtyMin = Infinity;
|
|
618
|
-
this.dirtyMax = 0;
|
|
619
|
-
}
|
|
620
|
-
/**
|
|
621
|
-
* Allocate space for one attribute. Returns the byte ref (offset
|
|
622
|
-
* to the header — data lives at ref + 16).
|
|
623
|
-
*/
|
|
624
|
-
alloc(dataBytes: number): number {
|
|
625
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
626
|
-
// First-fit reuse from free list.
|
|
627
|
-
for (let i = 0; i < this.freeList.length; i++) {
|
|
628
|
-
const f = this.freeList[i]!;
|
|
629
|
-
if (f.size >= allocBytes) {
|
|
630
|
-
const ref = f.off;
|
|
631
|
-
if (f.size === allocBytes) this.freeList.splice(i, 1);
|
|
632
|
-
else { f.off += allocBytes; f.size -= allocBytes; }
|
|
633
|
-
return ref;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
const ref = this.cursor;
|
|
637
|
-
this.cursor += allocBytes;
|
|
638
|
-
this.buf.ensureCapacity(this.cursor);
|
|
639
|
-
this.buf.setUsed(this.cursor);
|
|
640
|
-
return ref;
|
|
641
|
-
}
|
|
642
|
-
release(ref: number, dataBytes: number): void {
|
|
643
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
644
|
-
insertSortedFreeBlock(this.freeList, ref, allocBytes);
|
|
645
|
-
}
|
|
646
|
-
onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
|
|
647
|
-
destroy(): void { this.buf.destroy(); }
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Insert `{off, size}` into a free-list kept sorted by `off`, then
|
|
652
|
-
* coalesce with the two immediate neighbours.
|
|
653
|
-
*
|
|
654
|
-
* The list invariant — sorted, non-overlapping, never-adjacent — is
|
|
655
|
-
* preserved across allocs (which take from the front or split a
|
|
656
|
-
* block) and releases (this function). The previous implementation
|
|
657
|
-
* did `push + Array.prototype.sort + linear coalesce scan`, which is
|
|
658
|
-
* O(N log N) per release. Under a 500-RO bulk-remove the sort
|
|
659
|
-
* dominated `removeDraw` (~41 ms of self-time in the heap-demo-sg
|
|
660
|
-
* toggle profile). Binary-search insert + 2-neighbour merge collapses
|
|
661
|
-
* that to O(log N + N-shift), and is principled — the sort never
|
|
662
|
-
* actually mattered since we already had the sorted prefix as an
|
|
663
|
-
* invariant.
|
|
664
|
-
*/
|
|
665
|
-
function insertSortedFreeBlock(
|
|
666
|
-
freeList: { off: number; size: number }[],
|
|
667
|
-
off: number,
|
|
668
|
-
size: number,
|
|
669
|
-
): void {
|
|
670
|
-
// Binary-search for the insertion index (first entry whose off > new).
|
|
671
|
-
let lo = 0, hi = freeList.length;
|
|
672
|
-
while (lo < hi) {
|
|
673
|
-
const mid = (lo + hi) >>> 1;
|
|
674
|
-
if (freeList[mid]!.off <= off) lo = mid + 1;
|
|
675
|
-
else hi = mid;
|
|
676
|
-
}
|
|
677
|
-
// lo is the index where the new entry would be inserted.
|
|
678
|
-
// Try merging with the predecessor first; if successful, the merged
|
|
679
|
-
// block may now be adjacent to its (former) successor too.
|
|
680
|
-
const prev = lo > 0 ? freeList[lo - 1] : undefined;
|
|
681
|
-
if (prev !== undefined && prev.off + prev.size === off) {
|
|
682
|
-
prev.size += size;
|
|
683
|
-
// Check forward-merge with what was freeList[lo].
|
|
684
|
-
const next = freeList[lo];
|
|
685
|
-
if (next !== undefined && prev.off + prev.size === next.off) {
|
|
686
|
-
prev.size += next.size;
|
|
687
|
-
freeList.splice(lo, 1);
|
|
688
|
-
}
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
const next = freeList[lo];
|
|
692
|
-
if (next !== undefined && off + size === next.off) {
|
|
693
|
-
next.off = off;
|
|
694
|
-
next.size += size;
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
freeList.splice(lo, 0, { off, size });
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
/**
|
|
701
|
-
* Element-bump allocator over an index GrowBuffer (units = u32). Each
|
|
702
|
-
* draw's index range is allocated as one block; on release the block
|
|
703
|
-
* is returned to a free list and can be reused first-fit.
|
|
704
|
-
*/
|
|
705
|
-
class IndexAllocator {
|
|
706
|
-
private cursor = 0; // in u32s, not bytes
|
|
707
|
-
private freeList: { off: number; size: number }[] = [];
|
|
708
|
-
// CPU shadow + dirty range, same shape as AttributeArena. Index
|
|
709
|
-
// uploads (one per drawn mesh's index buffer) get coalesced to a
|
|
710
|
-
// single writeBuffer per dirty range at flush time.
|
|
711
|
-
private shadow: Uint8Array;
|
|
712
|
-
private dirtyMin = Infinity;
|
|
713
|
-
private dirtyMax = 0;
|
|
714
|
-
constructor(private readonly buf: GrowBuffer) {
|
|
715
|
-
this.shadow = new Uint8Array(buf.capacity);
|
|
716
|
-
buf.onResize(() => {
|
|
717
|
-
const grown = new Uint8Array(buf.capacity);
|
|
718
|
-
grown.set(this.shadow);
|
|
719
|
-
this.shadow = grown;
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
get buffer(): GPUBuffer { return this.buf.buffer; }
|
|
723
|
-
get usedElements(): number { return this.cursor; }
|
|
724
|
-
/** Stage `data` (bytes) at the given byte offset; tracks dirty range. */
|
|
725
|
-
write(dstByteOffset: number, data: Uint8Array): void {
|
|
726
|
-
this.shadow.set(data, dstByteOffset);
|
|
727
|
-
if (dstByteOffset < this.dirtyMin) this.dirtyMin = dstByteOffset;
|
|
728
|
-
const end = dstByteOffset + data.byteLength;
|
|
729
|
-
if (end > this.dirtyMax) this.dirtyMax = end;
|
|
730
|
-
}
|
|
731
|
-
flush(device: GPUDevice): void {
|
|
732
|
-
if (this.dirtyMax <= this.dirtyMin) return;
|
|
733
|
-
device.queue.writeBuffer(
|
|
734
|
-
this.buf.buffer, this.dirtyMin,
|
|
735
|
-
this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin,
|
|
736
|
-
this.dirtyMax - this.dirtyMin,
|
|
737
|
-
);
|
|
738
|
-
this.dirtyMin = Infinity;
|
|
739
|
-
this.dirtyMax = 0;
|
|
740
|
-
}
|
|
741
|
-
alloc(elements: number): number {
|
|
742
|
-
for (let i = 0; i < this.freeList.length; i++) {
|
|
743
|
-
const f = this.freeList[i]!;
|
|
744
|
-
if (f.size >= elements) {
|
|
745
|
-
const off = f.off;
|
|
746
|
-
if (f.size === elements) this.freeList.splice(i, 1);
|
|
747
|
-
else { f.off += elements; f.size -= elements; }
|
|
748
|
-
return off;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
const off = this.cursor;
|
|
752
|
-
this.cursor += elements;
|
|
753
|
-
this.buf.ensureCapacity(this.cursor * 4);
|
|
754
|
-
this.buf.setUsed(this.cursor * 4);
|
|
755
|
-
return off;
|
|
756
|
-
}
|
|
757
|
-
release(off: number, elements: number): void {
|
|
758
|
-
insertSortedFreeBlock(this.freeList, off, elements);
|
|
759
|
-
}
|
|
760
|
-
onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
|
|
761
|
-
destroy(): void { this.buf.destroy(); }
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// ---------------------------------------------------------------------------
|
|
765
|
-
// Static initial pack (uses the new allocators)
|
|
140
|
+
// Internal state
|
|
766
141
|
// ---------------------------------------------------------------------------
|
|
767
142
|
|
|
768
143
|
/**
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
* the UniformPool's refcount + the bucket's per-local-slot arrays.
|
|
144
|
+
* One pipeline-state bucket. Holds the pipeline + a list of global
|
|
145
|
+
* draw-slot indices to emit with it. The bind group it draws against
|
|
146
|
+
* is referenced by `bindGroup` (typically the shared no-textures one
|
|
147
|
+
* or one of the per-texture-set ones).
|
|
774
148
|
*/
|
|
775
|
-
interface ArenaState {
|
|
776
|
-
readonly attrs: AttributeArena;
|
|
777
|
-
readonly indices: IndexAllocator;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
function buildArenaState(
|
|
781
|
-
device: GPUDevice,
|
|
782
|
-
attrBytesHint: number,
|
|
783
|
-
idxBytesHint: number,
|
|
784
|
-
label: string,
|
|
785
|
-
idxExtraUsage: GPUBufferUsageFlags = 0,
|
|
786
|
-
): ArenaState {
|
|
787
|
-
const attrs = new AttributeArena(new GrowBuffer(
|
|
788
|
-
device, `${label}/attrs`, GPUBufferUsage.STORAGE,
|
|
789
|
-
attrBytesHint,
|
|
790
|
-
));
|
|
791
|
-
const indices = new IndexAllocator(new GrowBuffer(
|
|
792
|
-
device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage,
|
|
793
|
-
idxBytesHint,
|
|
794
|
-
));
|
|
795
|
-
return { attrs, indices };
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
function arenaBytes(arena: ArenaState): number {
|
|
799
|
-
return arena.attrs.usedBytes + arena.indices.usedElements * 4;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */
|
|
803
|
-
function writeAttribute(
|
|
804
|
-
device: GPUDevice, buf: GPUBuffer, ref: number, typeId: number, length: number, data: Float32Array,
|
|
805
|
-
): void {
|
|
806
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + data.byteLength);
|
|
807
|
-
const staging = new ArrayBuffer(allocBytes);
|
|
808
|
-
const u32 = new Uint32Array(staging);
|
|
809
|
-
const f32 = new Float32Array(staging);
|
|
810
|
-
u32[0] = typeId;
|
|
811
|
-
u32[1] = length;
|
|
812
|
-
f32.set(data, ALLOC_HEADER_PAD_TO / 4);
|
|
813
|
-
device.queue.writeBuffer(buf, ref, staging, 0, allocBytes);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function asAval<T>(v: aval<T> | T): aval<T> {
|
|
817
|
-
return (typeof v === "object" && v !== null && typeof (v as { getValue?: unknown }).getValue === "function")
|
|
818
|
-
? (v as aval<T>)
|
|
819
|
-
: AVal.constant(v as T);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/** Heuristic predicate — BufferView has `buffer: aval<IBuffer>` + elementType. */
|
|
823
|
-
function isBufferView(v: unknown): v is BufferView {
|
|
824
|
-
if (typeof v !== "object" || v === null) return false;
|
|
825
|
-
const o = v as { buffer?: unknown; elementType?: unknown };
|
|
826
|
-
return typeof o.buffer === "object" && o.buffer !== null
|
|
827
|
-
&& typeof (o.buffer as { getValue?: unknown }).getValue === "function"
|
|
828
|
-
&& typeof o.elementType === "object" && o.elementType !== null;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
149
|
/**
|
|
832
|
-
*
|
|
833
|
-
*
|
|
150
|
+
* Per-slot state inside a bucket. Today every bucket has exactly ONE
|
|
151
|
+
* slot — `bucket.slots.length === 1`. Multi-pipeline buckets (the
|
|
152
|
+
* derived-modes work, see docs/derived-modes.md) grow this to one
|
|
153
|
+
* slot per distinct pipeline state used by the bucket's ROs. Each
|
|
154
|
+
* slot owns its own drawTable + scan buffers + indirect + pipeline.
|
|
834
155
|
*/
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return new Float32Array(data.buffer, data.byteOffset, data.byteLength / 4);
|
|
839
|
-
}
|
|
840
|
-
return new Float32Array(data); // ArrayBuffer
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// ---------------------------------------------------------------------------
|
|
844
|
-
// Megacall GPU prefix-sum compute shader
|
|
845
|
-
// ---------------------------------------------------------------------------
|
|
846
|
-
|
|
847
|
-
const SCAN_TILE_SIZE = 512;
|
|
848
|
-
const SCAN_WG_SIZE = 256;
|
|
849
|
-
const SCAN_MAX_RECORDS = SCAN_TILE_SIZE * SCAN_TILE_SIZE; // numBlocks ≤ TILE_SIZE
|
|
850
|
-
|
|
851
|
-
const TILE_K = 64;
|
|
852
|
-
|
|
853
|
-
/** drawTable record width: (firstEmit, drawIdx, indexStart, indexCount, instanceCount). */
|
|
854
|
-
const RECORD_U32 = 5;
|
|
855
|
-
const RECORD_BYTES = RECORD_U32 * 4;
|
|
856
|
-
|
|
857
|
-
const HEAP_SCAN_WGSL = `
|
|
858
|
-
struct Params {
|
|
859
|
-
numRecords: u32,
|
|
860
|
-
numBlocks: u32,
|
|
861
|
-
_pad0: u32,
|
|
862
|
-
_pad1: u32,
|
|
863
|
-
};
|
|
864
|
-
|
|
865
|
-
struct Record {
|
|
866
|
-
firstEmit: u32,
|
|
867
|
-
drawIdx: u32,
|
|
868
|
-
indexStart: u32,
|
|
869
|
-
indexCount: u32,
|
|
870
|
-
instanceCount: u32,
|
|
871
|
-
};
|
|
872
|
-
|
|
873
|
-
@group(0) @binding(0) var<storage, read_write> drawTable: array<Record>;
|
|
874
|
-
@group(0) @binding(1) var<storage, read_write> blockSums: array<u32>;
|
|
875
|
-
@group(0) @binding(2) var<storage, read_write> blockOffsets: array<u32>;
|
|
876
|
-
@group(0) @binding(3) var<storage, read_write> indirect: array<u32>;
|
|
877
|
-
@group(0) @binding(4) var<uniform> params: Params;
|
|
878
|
-
@group(0) @binding(5) var<storage, read_write> firstDrawInTile: array<u32>;
|
|
879
|
-
|
|
880
|
-
const TILE_SIZE: u32 = 512u;
|
|
881
|
-
const WG_SIZE: u32 = 256u;
|
|
882
|
-
const TILE_K: u32 = 64u;
|
|
883
|
-
|
|
884
|
-
var<workgroup> sdata: array<u32, 512>;
|
|
885
|
-
|
|
886
|
-
fn blellochScan(tid: u32) {
|
|
887
|
-
var offset: u32 = 1u;
|
|
888
|
-
for (var d: u32 = TILE_SIZE >> 1u; d > 0u; d = d >> 1u) {
|
|
889
|
-
workgroupBarrier();
|
|
890
|
-
if (tid < d) {
|
|
891
|
-
let ai = offset * (2u * tid + 1u) - 1u;
|
|
892
|
-
let bi = offset * (2u * tid + 2u) - 1u;
|
|
893
|
-
sdata[bi] = sdata[bi] + sdata[ai];
|
|
894
|
-
}
|
|
895
|
-
offset = offset * 2u;
|
|
896
|
-
}
|
|
897
|
-
if (tid == 0u) { sdata[TILE_SIZE - 1u] = 0u; }
|
|
898
|
-
for (var d: u32 = 1u; d < TILE_SIZE; d = d * 2u) {
|
|
899
|
-
offset = offset >> 1u;
|
|
900
|
-
workgroupBarrier();
|
|
901
|
-
if (tid < d) {
|
|
902
|
-
let ai = offset * (2u * tid + 1u) - 1u;
|
|
903
|
-
let bi = offset * (2u * tid + 2u) - 1u;
|
|
904
|
-
let t = sdata[ai];
|
|
905
|
-
sdata[ai] = sdata[bi];
|
|
906
|
-
sdata[bi] = sdata[bi] + t;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
workgroupBarrier();
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
@compute @workgroup_size(WG_SIZE)
|
|
913
|
-
fn scanTile(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
|
|
914
|
-
let tid = lid.x;
|
|
915
|
-
let blockOff = wgid.x * TILE_SIZE;
|
|
916
|
-
let n = params.numRecords;
|
|
917
|
-
let i0 = blockOff + tid;
|
|
918
|
-
let i1 = blockOff + tid + WG_SIZE;
|
|
919
|
-
var v0: u32 = 0u;
|
|
920
|
-
var v1: u32 = 0u;
|
|
921
|
-
if (i0 < n) { v0 = drawTable[i0].indexCount * drawTable[i0].instanceCount; }
|
|
922
|
-
if (i1 < n) { v1 = drawTable[i1].indexCount * drawTable[i1].instanceCount; }
|
|
923
|
-
sdata[tid] = v0;
|
|
924
|
-
sdata[tid + WG_SIZE] = v1;
|
|
925
|
-
workgroupBarrier();
|
|
926
|
-
blellochScan(tid);
|
|
927
|
-
if (i0 < n) { drawTable[i0].firstEmit = sdata[tid]; }
|
|
928
|
-
if (i1 < n) { drawTable[i1].firstEmit = sdata[tid + WG_SIZE]; }
|
|
929
|
-
if (tid == WG_SIZE - 1u) {
|
|
930
|
-
blockSums[wgid.x] = sdata[tid + WG_SIZE] + v1;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
@compute @workgroup_size(WG_SIZE)
|
|
935
|
-
fn scanBlocks(@builtin(local_invocation_id) lid: vec3<u32>) {
|
|
936
|
-
let tid = lid.x;
|
|
937
|
-
let n = params.numBlocks;
|
|
938
|
-
let i0 = tid;
|
|
939
|
-
let i1 = tid + WG_SIZE;
|
|
940
|
-
var v0: u32 = 0u;
|
|
941
|
-
var v1: u32 = 0u;
|
|
942
|
-
if (i0 < n) { v0 = blockSums[i0]; }
|
|
943
|
-
if (i1 < n) { v1 = blockSums[i1]; }
|
|
944
|
-
sdata[tid] = v0;
|
|
945
|
-
sdata[tid + WG_SIZE] = v1;
|
|
946
|
-
workgroupBarrier();
|
|
947
|
-
blellochScan(tid);
|
|
948
|
-
if (i0 < n) { blockOffsets[i0] = sdata[tid]; }
|
|
949
|
-
if (i1 < n) { blockOffsets[i1] = sdata[tid + WG_SIZE]; }
|
|
950
|
-
workgroupBarrier();
|
|
951
|
-
if (tid == 0u) {
|
|
952
|
-
if (n > 0u) {
|
|
953
|
-
let lastIdx = n - 1u;
|
|
954
|
-
let total = blockOffsets[lastIdx] + blockSums[lastIdx];
|
|
955
|
-
indirect[0] = total;
|
|
956
|
-
} else {
|
|
957
|
-
indirect[0] = 0u;
|
|
958
|
-
}
|
|
959
|
-
indirect[1] = 1u;
|
|
960
|
-
indirect[2] = 0u;
|
|
961
|
-
indirect[3] = 0u;
|
|
962
|
-
}
|
|
963
|
-
}
|
|
156
|
+
interface BucketSlot {
|
|
157
|
+
/** The render pipeline this slot draws with. */
|
|
158
|
+
readonly pipeline: GPURenderPipeline;
|
|
964
159
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
160
|
+
// ─── drawTable / megacall state ────────────────────────────────────
|
|
161
|
+
drawTableBuf?: GrowBuffer;
|
|
162
|
+
drawTableShadow?: Uint32Array;
|
|
163
|
+
drawTableDirtyMin: number;
|
|
164
|
+
drawTableDirtyMax: number;
|
|
165
|
+
/** Number of live records routed to this slot (= drawTable length).
|
|
166
|
+
* GPU owns firstEmit / total. */
|
|
167
|
+
recordCount: number;
|
|
168
|
+
/** localSlot → recordIdx (or -1). */
|
|
169
|
+
slotToRecord: number[];
|
|
170
|
+
/** recordIdx → localSlot. */
|
|
171
|
+
recordToSlot: number[];
|
|
976
172
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
if (tileIdx == numTiles) {
|
|
990
|
-
// Sentinel for the open upper bound — the LAST VALID SLOT, not
|
|
991
|
-
// numRecords. The render VS uses
|
|
992
|
-
// hi = firstDrawInTile[_tileIdx + 1u]
|
|
993
|
-
// and the binary search treats hi as INCLUSIVE. If the sentinel
|
|
994
|
-
// were numRecords (one past last), the search would drag lo into
|
|
995
|
-
// the OOB slot for emits in the last tile, since drawTable reads
|
|
996
|
-
// past recordCount return 0 (binding size clamping) and 0 ≤ emit
|
|
997
|
-
// is always true. Visible symptom: the LAST few emits in the
|
|
998
|
-
// bucket land on slot=numRecords (drawIdx=0, indexCount=0 → /-by-
|
|
999
|
-
// zero) → degenerate / cross-RO triangle stitched to slot 0.
|
|
1000
|
-
firstDrawInTile[tileIdx] = params.numRecords - 1u;
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
let tileStart = tileIdx * TILE_K;
|
|
1004
|
-
var lo: u32 = 0u;
|
|
1005
|
-
var hi: u32 = params.numRecords - 1u;
|
|
1006
|
-
loop {
|
|
1007
|
-
if (lo >= hi) { break; }
|
|
1008
|
-
let mid = (lo + hi + 1u) >> 1u;
|
|
1009
|
-
if (drawTable[mid].firstEmit <= tileStart) { lo = mid; } else { hi = mid - 1u; }
|
|
1010
|
-
}
|
|
1011
|
-
firstDrawInTile[tileIdx] = lo;
|
|
173
|
+
// ─── Per-slot scan / indirect buffers ──────────────────────────────
|
|
174
|
+
blockSumsBuf?: GrowBuffer;
|
|
175
|
+
blockOffsetsBuf?: GrowBuffer;
|
|
176
|
+
firstDrawInTileBuf?: GrowBuffer;
|
|
177
|
+
/** CPU sum of indexCounts across live records — drives firstDrawInTileBuf sizing only. */
|
|
178
|
+
totalEmitEstimate: number;
|
|
179
|
+
indirectBuf?: GPUBuffer;
|
|
180
|
+
paramsBuf?: GPUBuffer;
|
|
181
|
+
scanBindGroup?: GPUBindGroup;
|
|
182
|
+
/** numRecords used to size the current render bindGroup; rebuild when it changes. */
|
|
183
|
+
renderBoundRecordCount?: number;
|
|
184
|
+
scanDirty: boolean;
|
|
1012
185
|
}
|
|
1013
|
-
`;
|
|
1014
|
-
|
|
1015
|
-
// ---------------------------------------------------------------------------
|
|
1016
|
-
// Internal state
|
|
1017
|
-
// ---------------------------------------------------------------------------
|
|
1018
186
|
|
|
1019
|
-
/**
|
|
1020
|
-
* One pipeline-state bucket. Holds the pipeline + a list of global
|
|
1021
|
-
* draw-slot indices to emit with it. The bind group it draws against
|
|
1022
|
-
* is referenced by `bindGroup` (typically the shared no-textures one
|
|
1023
|
-
* or one of the per-texture-set ones).
|
|
1024
|
-
*/
|
|
1025
187
|
interface Bucket {
|
|
1026
188
|
readonly label: string;
|
|
1027
189
|
readonly textures: HeapTextureSet | undefined;
|
|
1028
190
|
readonly layout: BucketLayout;
|
|
1029
|
-
|
|
191
|
+
/**
|
|
192
|
+
* One entry per distinct pipeline state. Phase 5a: always length 1
|
|
193
|
+
* (per-RO pipeline-state still keyed at the bucket level).
|
|
194
|
+
* Later phases of the derived-modes work let `slots.length > 1`.
|
|
195
|
+
*/
|
|
196
|
+
readonly slots: BucketSlot[];
|
|
1030
197
|
/** Repointed by `rebuildBindGroups` whenever any backing GrowBuffer reallocates. */
|
|
1031
198
|
bindGroup: GPUBindGroup;
|
|
1032
199
|
|
|
@@ -1082,30 +249,6 @@ interface Bucket {
|
|
|
1082
249
|
/** Local slots whose DrawHeader needs re-pack + writeBuffer next frame. */
|
|
1083
250
|
readonly dirty: Set<number>;
|
|
1084
251
|
|
|
1085
|
-
// ─── Megacall state ────────────────────────────────────────────────
|
|
1086
|
-
drawTableBuf?: GrowBuffer;
|
|
1087
|
-
drawTableShadow?: Uint32Array;
|
|
1088
|
-
drawTableDirtyMin: number;
|
|
1089
|
-
drawTableDirtyMax: number;
|
|
1090
|
-
/** Number of live records (= drawTable length). GPU owns firstEmit / total. */
|
|
1091
|
-
recordCount: number;
|
|
1092
|
-
/** localSlot → recordIdx (or -1). */
|
|
1093
|
-
slotToRecord: number[];
|
|
1094
|
-
/** recordIdx → localSlot. */
|
|
1095
|
-
recordToSlot: number[];
|
|
1096
|
-
/** Per-bucket buffers for the GPU prefix-sum pipeline. */
|
|
1097
|
-
blockSumsBuf?: GrowBuffer;
|
|
1098
|
-
blockOffsetsBuf?: GrowBuffer;
|
|
1099
|
-
firstDrawInTileBuf?: GrowBuffer;
|
|
1100
|
-
/** CPU sum of indexCounts across live records — drives firstDrawInTileBuf sizing only. */
|
|
1101
|
-
totalEmitEstimate: number;
|
|
1102
|
-
indirectBuf?: GPUBuffer;
|
|
1103
|
-
paramsBuf?: GPUBuffer;
|
|
1104
|
-
scanBindGroup?: GPUBindGroup;
|
|
1105
|
-
/** numRecords used to size the current render bindGroup; rebuild when it changes. */
|
|
1106
|
-
renderBoundRecordCount?: number;
|
|
1107
|
-
scanDirty: boolean;
|
|
1108
|
-
|
|
1109
252
|
// ─── Atlas-binding state (atlas-variant buckets only) ─────────────
|
|
1110
253
|
/**
|
|
1111
254
|
* True when this bucket holds at least one atlas-variant RO.
|
|
@@ -1428,6 +571,18 @@ export function buildHeapScene(
|
|
|
1428
571
|
const drawIdToLocalSlot: (number | undefined)[] = [];
|
|
1429
572
|
/** Per-draw index aval — for `indexPool.release` on removeDraw. */
|
|
1430
573
|
const drawIdToIndexAval: (aval<Uint32Array> | undefined)[] = [];
|
|
574
|
+
/**
|
|
575
|
+
* Original spec retained per drawId so we can re-add the RO into a
|
|
576
|
+
* new bucket when its PipelineState modeKey changes (reactive
|
|
577
|
+
* cullmode flip, etc.). Cheap to retain — specs are mostly aval
|
|
578
|
+
* references.
|
|
579
|
+
*/
|
|
580
|
+
const drawIdToSpec: (HeapDrawSpec | undefined)[] = [];
|
|
581
|
+
/** Per-draw ModeKeyTracker — subscribes to PS aval marks, recomputes
|
|
582
|
+
* the modeKey, schedules a rebucket if it changed. */
|
|
583
|
+
const drawIdToModeTracker: (ModeKeyTracker | undefined)[] = [];
|
|
584
|
+
/** drawIds whose PS marked since last update; drained by `update`. */
|
|
585
|
+
const dirtyModeKeyDrawIds = new Set<number>();
|
|
1431
586
|
let nextDrawId = 0;
|
|
1432
587
|
|
|
1433
588
|
/**
|
|
@@ -1853,12 +1008,12 @@ export function buildHeapScene(
|
|
|
1853
1008
|
label: `heapScene/${bucket.label}/scanBg`,
|
|
1854
1009
|
layout: scanBgl,
|
|
1855
1010
|
entries: [
|
|
1856
|
-
{ binding: 0, resource: { buffer: bucket.drawTableBuf!.buffer } },
|
|
1857
|
-
{ binding: 1, resource: { buffer: bucket.blockSumsBuf!.buffer } },
|
|
1858
|
-
{ binding: 2, resource: { buffer: bucket.blockOffsetsBuf!.buffer } },
|
|
1859
|
-
{ binding: 3, resource: { buffer: bucket.indirectBuf! } },
|
|
1860
|
-
{ binding: 4, resource: { buffer: bucket.paramsBuf! } },
|
|
1861
|
-
{ binding: 5, resource: { buffer: bucket.firstDrawInTileBuf!.buffer } },
|
|
1011
|
+
{ binding: 0, resource: { buffer: bucket.slots[0]!.drawTableBuf!.buffer } },
|
|
1012
|
+
{ binding: 1, resource: { buffer: bucket.slots[0]!.blockSumsBuf!.buffer } },
|
|
1013
|
+
{ binding: 2, resource: { buffer: bucket.slots[0]!.blockOffsetsBuf!.buffer } },
|
|
1014
|
+
{ binding: 3, resource: { buffer: bucket.slots[0]!.indirectBuf! } },
|
|
1015
|
+
{ binding: 4, resource: { buffer: bucket.slots[0]!.paramsBuf! } },
|
|
1016
|
+
{ binding: 5, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
|
|
1862
1017
|
],
|
|
1863
1018
|
});
|
|
1864
1019
|
}
|
|
@@ -2085,29 +1240,10 @@ export function buildHeapScene(
|
|
|
2085
1240
|
avalIds.set(av, vid);
|
|
2086
1241
|
return `c${vid}`;
|
|
2087
1242
|
};
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
if (cached !== undefined) return cached;
|
|
2093
|
-
const r = ps.rasterizer;
|
|
2094
|
-
const parts: string[] = [
|
|
2095
|
-
avalIdOf(r.topology),
|
|
2096
|
-
avalIdOf(r.cullMode),
|
|
2097
|
-
avalIdOf(r.frontFace),
|
|
2098
|
-
avalIdOf(r.depthBias),
|
|
2099
|
-
ps.depth !== undefined
|
|
2100
|
-
? `d:${avalIdOf(ps.depth.write)}:${avalIdOf(ps.depth.compare)}:${avalIdOf(ps.depth.clamp)}`
|
|
2101
|
-
: "d:_",
|
|
2102
|
-
ps.stencil !== undefined ? "s:1" : "s:_",
|
|
2103
|
-
avalIdOf(ps.blends),
|
|
2104
|
-
avalIdOf(ps.alphaToCoverage),
|
|
2105
|
-
avalIdOf(ps.blendConstant),
|
|
2106
|
-
];
|
|
2107
|
-
const key = `ps#${parts.join("|")}`;
|
|
2108
|
-
psContentIds.set(ps, key);
|
|
2109
|
-
return key;
|
|
2110
|
-
};
|
|
1243
|
+
// (psIdOf removed in Phase 5b — replaced by snapshotDescriptor +
|
|
1244
|
+
// encodeModeKey at the findOrCreateBucket call site, which keys on
|
|
1245
|
+
// the VALUE of the pipeline state rather than aval identity. The
|
|
1246
|
+
// 20k-cvals-with-same-value pathology now collapses correctly.)
|
|
2111
1247
|
|
|
2112
1248
|
/** Resolved (forced) snapshot of the user's PipelineState. */
|
|
2113
1249
|
interface ResolvedPipelineState {
|
|
@@ -2155,7 +1291,7 @@ export function buildHeapScene(
|
|
|
2155
1291
|
{ binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
|
|
2156
1292
|
];
|
|
2157
1293
|
{
|
|
2158
|
-
if (bucket.drawTableBuf === undefined) {
|
|
1294
|
+
if (bucket.slots[0]!.drawTableBuf === undefined) {
|
|
2159
1295
|
throw new Error("heapScene: megacall bucket without drawTableBuf");
|
|
2160
1296
|
}
|
|
2161
1297
|
// Bind drawTable with size = recordCount * RECORD_BYTES so the VS
|
|
@@ -2163,13 +1299,13 @@ export function buildHeapScene(
|
|
|
2163
1299
|
// the live record count — keeps stale tail entries out of the
|
|
2164
1300
|
// binary search. Minimum one zero-record to satisfy WebGPU non-
|
|
2165
1301
|
// zero size constraint when the bucket is empty.
|
|
2166
|
-
const dtBytes = Math.max(RECORD_BYTES, bucket.recordCount * RECORD_BYTES);
|
|
1302
|
+
const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0]!.recordCount * RECORD_BYTES);
|
|
2167
1303
|
entries.push(
|
|
2168
|
-
{ binding: 4, resource: { buffer: bucket.drawTableBuf.buffer, offset: 0, size: dtBytes } },
|
|
1304
|
+
{ binding: 4, resource: { buffer: bucket.slots[0]!.drawTableBuf.buffer, offset: 0, size: dtBytes } },
|
|
2169
1305
|
{ binding: 5, resource: { buffer: arena.indices.buffer } },
|
|
2170
|
-
{ binding: 6, resource: { buffer: bucket.firstDrawInTileBuf!.buffer } },
|
|
1306
|
+
{ binding: 6, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
|
|
2171
1307
|
);
|
|
2172
|
-
bucket.renderBoundRecordCount = bucket.recordCount;
|
|
1308
|
+
bucket.slots[0]!.renderBoundRecordCount = bucket.slots[0]!.recordCount;
|
|
2173
1309
|
}
|
|
2174
1310
|
// Schema-driven texture + sampler entries. v1 user surface still
|
|
2175
1311
|
// accepts a single (texture, sampler) pair via HeapTextureSet —
|
|
@@ -2307,8 +1443,17 @@ export function buildHeapScene(
|
|
|
2307
1443
|
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
2308
1444
|
}
|
|
2309
1445
|
const fam = familyFor(effect);
|
|
2310
|
-
|
|
2311
|
-
|
|
1446
|
+
// ─── PS keying — VALUE based, not identity based ───────────────
|
|
1447
|
+
// psIdOf used to hash by aval identity. That meant 20k ROs each
|
|
1448
|
+
// constructed with `cval("back")` for cullMode hashed to 20k
|
|
1449
|
+
// distinct keys → 20k buckets, all rendering with bitwise-
|
|
1450
|
+
// identical state. Now the key is the bitfield-encoded modeKey
|
|
1451
|
+
// (the actual value-set in the descriptor), so identical-value
|
|
1452
|
+
// cvals collapse to ONE bucket. See docs/derived-modes.md and
|
|
1453
|
+
// tests/heap-multi-pipeline-bucket.test.ts.
|
|
1454
|
+
const psDescriptor = snapshotDescriptor(pipelineState, sig);
|
|
1455
|
+
const psModeKey = encodeModeKey(psDescriptor);
|
|
1456
|
+
const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
|
|
2312
1457
|
const existing = bucketByKey.get(bk);
|
|
2313
1458
|
if (existing !== undefined) return existing;
|
|
2314
1459
|
const ps = resolvePipelineState(pipelineState);
|
|
@@ -2343,13 +1488,21 @@ export function buildHeapScene(
|
|
|
2343
1488
|
);
|
|
2344
1489
|
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
2345
1490
|
|
|
1491
|
+
const slot0: BucketSlot = {
|
|
1492
|
+
pipeline,
|
|
1493
|
+
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
1494
|
+
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
1495
|
+
totalEmitEstimate: 0,
|
|
1496
|
+
scanDirty: false,
|
|
1497
|
+
};
|
|
2346
1498
|
const bucket: Bucket = {
|
|
2347
1499
|
// §6 family-merge: family buckets aren't keyed on a specific
|
|
2348
1500
|
// texture set — atlas placements are addressed per-RO via
|
|
2349
1501
|
// drawHeader fields (`pageRef` + `formatBits` + `origin` +
|
|
2350
1502
|
// `size`), and the bucket's atlas-binding ladder is driven by
|
|
2351
1503
|
// `atlasPool.pagesFor(format)`. Leave `textures` undefined.
|
|
2352
|
-
label: bk, textures: undefined, layout,
|
|
1504
|
+
label: bk, textures: undefined, layout,
|
|
1505
|
+
slots: [slot0],
|
|
2353
1506
|
bindGroup: null as unknown as GPUBindGroup,
|
|
2354
1507
|
drawHeap,
|
|
2355
1508
|
drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
|
|
@@ -2358,10 +1511,6 @@ export function buildHeapScene(
|
|
|
2358
1511
|
localEntries: [], localToDrawId: [],
|
|
2359
1512
|
localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
|
|
2360
1513
|
drawSlots: new Set<number>(), dirty: new Set<number>(),
|
|
2361
|
-
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
2362
|
-
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
2363
|
-
totalEmitEstimate: 0,
|
|
2364
|
-
scanDirty: false,
|
|
2365
1514
|
isAtlasBucket,
|
|
2366
1515
|
localAtlasReleases: [],
|
|
2367
1516
|
localAtlasTextures: [],
|
|
@@ -2401,30 +1550,30 @@ export function buildHeapScene(
|
|
|
2401
1550
|
label: `heapScene/${bk}/params`, size: 16,
|
|
2402
1551
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2403
1552
|
});
|
|
2404
|
-
bucket.drawTableBuf = dtBuf;
|
|
2405
|
-
bucket.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
|
|
2406
|
-
bucket.blockSumsBuf = blockSumsBuf;
|
|
2407
|
-
bucket.blockOffsetsBuf = blockOffsetsBuf;
|
|
2408
|
-
bucket.firstDrawInTileBuf = firstDrawInTileBuf;
|
|
2409
|
-
bucket.indirectBuf = indirectBuf;
|
|
2410
|
-
bucket.paramsBuf = paramsBuf;
|
|
1553
|
+
bucket.slots[0]!.drawTableBuf = dtBuf;
|
|
1554
|
+
bucket.slots[0]!.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
|
|
1555
|
+
bucket.slots[0]!.blockSumsBuf = blockSumsBuf;
|
|
1556
|
+
bucket.slots[0]!.blockOffsetsBuf = blockOffsetsBuf;
|
|
1557
|
+
bucket.slots[0]!.firstDrawInTileBuf = firstDrawInTileBuf;
|
|
1558
|
+
bucket.slots[0]!.indirectBuf = indirectBuf;
|
|
1559
|
+
bucket.slots[0]!.paramsBuf = paramsBuf;
|
|
2411
1560
|
const ensureScanBuffers = (): void => {
|
|
2412
|
-
const needBlocks = Math.max(1, Math.ceil(bucket.recordCount / SCAN_TILE_SIZE));
|
|
1561
|
+
const needBlocks = Math.max(1, Math.ceil(bucket.slots[0]!.recordCount / SCAN_TILE_SIZE));
|
|
2413
1562
|
blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
2414
1563
|
blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
2415
1564
|
};
|
|
2416
1565
|
const rebuildScanBg = (): void => {
|
|
2417
|
-
bucket.scanBindGroup = buildScanBindGroup(bucket);
|
|
1566
|
+
bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
|
|
2418
1567
|
};
|
|
2419
1568
|
dtBuf.onResize(() => {
|
|
2420
1569
|
const grown = new Uint32Array(dtBuf.capacity / 4);
|
|
2421
|
-
grown.set(bucket.drawTableShadow!);
|
|
2422
|
-
bucket.drawTableShadow = grown;
|
|
1570
|
+
grown.set(bucket.slots[0]!.drawTableShadow!);
|
|
1571
|
+
bucket.slots[0]!.drawTableShadow = grown;
|
|
2423
1572
|
ensureScanBuffers();
|
|
2424
1573
|
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
2425
1574
|
rebuildScanBg();
|
|
2426
|
-
bucket.drawTableDirtyMin = 0;
|
|
2427
|
-
bucket.drawTableDirtyMax = bucket.recordCount * RECORD_BYTES;
|
|
1575
|
+
bucket.slots[0]!.drawTableDirtyMin = 0;
|
|
1576
|
+
bucket.slots[0]!.drawTableDirtyMax = bucket.slots[0]!.recordCount * RECORD_BYTES;
|
|
2428
1577
|
});
|
|
2429
1578
|
blockSumsBuf.onResize(rebuildScanBg);
|
|
2430
1579
|
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
@@ -2432,7 +1581,7 @@ export function buildHeapScene(
|
|
|
2432
1581
|
rebuildScanBg();
|
|
2433
1582
|
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
2434
1583
|
});
|
|
2435
|
-
bucket.scanBindGroup = buildScanBindGroup(bucket);
|
|
1584
|
+
bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
|
|
2436
1585
|
}
|
|
2437
1586
|
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
2438
1587
|
drawHeap.onResize(() => {
|
|
@@ -2754,8 +1903,8 @@ export function buildHeapScene(
|
|
|
2754
1903
|
if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
|
|
2755
1904
|
|
|
2756
1905
|
{
|
|
2757
|
-
const dtBuf = bucket.drawTableBuf!;
|
|
2758
|
-
const recIdx = bucket.recordCount;
|
|
1906
|
+
const dtBuf = bucket.slots[0]!.drawTableBuf!;
|
|
1907
|
+
const recIdx = bucket.slots[0]!.recordCount;
|
|
2759
1908
|
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
2760
1909
|
throw new Error(
|
|
2761
1910
|
`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
|
|
@@ -2767,30 +1916,42 @@ export function buildHeapScene(
|
|
|
2767
1916
|
dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
|
|
2768
1917
|
// Grow scan-side buffers if recordCount crosses a tile boundary.
|
|
2769
1918
|
const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
|
|
2770
|
-
bucket.blockSumsBuf!.ensureCapacity(needBlocks * 4);
|
|
2771
|
-
bucket.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
|
|
2772
|
-
const shadow = bucket.drawTableShadow!;
|
|
1919
|
+
bucket.slots[0]!.blockSumsBuf!.ensureCapacity(needBlocks * 4);
|
|
1920
|
+
bucket.slots[0]!.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
|
|
1921
|
+
const shadow = bucket.slots[0]!.drawTableShadow!;
|
|
2773
1922
|
// firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
|
|
2774
1923
|
shadow[recIdx * RECORD_U32 + 0] = 0;
|
|
2775
1924
|
shadow[recIdx * RECORD_U32 + 1] = localSlot;
|
|
2776
1925
|
shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
|
|
2777
1926
|
shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
|
|
2778
1927
|
shadow[recIdx * RECORD_U32 + 4] = instanceCount;
|
|
2779
|
-
bucket.recordCount = recIdx + 1;
|
|
2780
|
-
bucket.slotToRecord[localSlot] = recIdx;
|
|
2781
|
-
bucket.recordToSlot[recIdx] = localSlot;
|
|
2782
|
-
if (byteOff < bucket.drawTableDirtyMin) bucket.drawTableDirtyMin = byteOff;
|
|
2783
|
-
if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax) bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2784
|
-
bucket.totalEmitEstimate += idxAlloc.count * instanceCount;
|
|
2785
|
-
const newNumTiles = Math.max(1, Math.ceil(bucket.totalEmitEstimate / TILE_K));
|
|
2786
|
-
bucket.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
|
|
2787
|
-
bucket.scanDirty = true;
|
|
1928
|
+
bucket.slots[0]!.recordCount = recIdx + 1;
|
|
1929
|
+
bucket.slots[0]!.slotToRecord[localSlot] = recIdx;
|
|
1930
|
+
bucket.slots[0]!.recordToSlot[recIdx] = localSlot;
|
|
1931
|
+
if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
|
|
1932
|
+
if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1933
|
+
bucket.slots[0]!.totalEmitEstimate += idxAlloc.count * instanceCount;
|
|
1934
|
+
const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0]!.totalEmitEstimate / TILE_K));
|
|
1935
|
+
bucket.slots[0]!.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
|
|
1936
|
+
bucket.slots[0]!.scanDirty = true;
|
|
2788
1937
|
}
|
|
2789
1938
|
|
|
2790
1939
|
drawIdToBucket[drawId] = bucket;
|
|
2791
1940
|
drawIdToLocalSlot[drawId] = localSlot;
|
|
2792
1941
|
drawIdToIndexAval[drawId] = indicesAval;
|
|
2793
1942
|
|
|
1943
|
+
// ─── Reactive rebucket: track PS-modeKey changes ────────────────
|
|
1944
|
+
// When any mode-axis aval marks (e.g. cullCval.value = 'front'),
|
|
1945
|
+
// schedule this RO for rebucket on the next update(). Today's
|
|
1946
|
+
// bucket key is the modeKey VALUE (Phase 5b), so changing the
|
|
1947
|
+
// value must move the RO to a different bucket. Without this
|
|
1948
|
+
// tracker the cval flip would be silently ignored.
|
|
1949
|
+
drawIdToSpec[drawId] = spec;
|
|
1950
|
+
const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => {
|
|
1951
|
+
dirtyModeKeyDrawIds.add(drawId);
|
|
1952
|
+
});
|
|
1953
|
+
drawIdToModeTracker[drawId] = tracker;
|
|
1954
|
+
|
|
2794
1955
|
// ─── §7 derived-uniforms registration ────────────────────────────
|
|
2795
1956
|
// A uniform binding on this RO is either a value (aval/constant) or a rule —
|
|
2796
1957
|
// collect the rules (explicit `derivedUniform(...)` values + standard trafo
|
|
@@ -2865,13 +2026,13 @@ export function buildHeapScene(
|
|
|
2865
2026
|
const removedCount = removedEntry !== undefined
|
|
2866
2027
|
? removedEntry.indexCount * removedEntry.instanceCount
|
|
2867
2028
|
: 0;
|
|
2868
|
-
bucket.totalEmitEstimate = Math.max(0, bucket.totalEmitEstimate - removedCount);
|
|
2029
|
+
bucket.slots[0]!.totalEmitEstimate = Math.max(0, bucket.slots[0]!.totalEmitEstimate - removedCount);
|
|
2869
2030
|
// Swap-pop: move the last record into the freed slot, decrement
|
|
2870
2031
|
// recordCount. firstEmit is GPU-rewritten by the next scan, so
|
|
2871
2032
|
// we only fix (drawIdx, indexStart, indexCount, instanceCount).
|
|
2872
|
-
const recIdx = bucket.slotToRecord[localSlot]!;
|
|
2873
|
-
const lastRecIdx = bucket.recordCount - 1;
|
|
2874
|
-
const shadow = bucket.drawTableShadow!;
|
|
2033
|
+
const recIdx = bucket.slots[0]!.slotToRecord[localSlot]!;
|
|
2034
|
+
const lastRecIdx = bucket.slots[0]!.recordCount - 1;
|
|
2035
|
+
const shadow = bucket.slots[0]!.drawTableShadow!;
|
|
2875
2036
|
if (recIdx !== lastRecIdx) {
|
|
2876
2037
|
const dst = recIdx * RECORD_U32;
|
|
2877
2038
|
const src = lastRecIdx * RECORD_U32;
|
|
@@ -2880,17 +2041,17 @@ export function buildHeapScene(
|
|
|
2880
2041
|
shadow[dst + 2] = shadow[src + 2]!;
|
|
2881
2042
|
shadow[dst + 3] = shadow[src + 3]!;
|
|
2882
2043
|
shadow[dst + 4] = shadow[src + 4]!;
|
|
2883
|
-
const movedSlot = bucket.recordToSlot[lastRecIdx]!;
|
|
2884
|
-
bucket.slotToRecord[movedSlot] = recIdx;
|
|
2885
|
-
bucket.recordToSlot[recIdx] = movedSlot;
|
|
2044
|
+
const movedSlot = bucket.slots[0]!.recordToSlot[lastRecIdx]!;
|
|
2045
|
+
bucket.slots[0]!.slotToRecord[movedSlot] = recIdx;
|
|
2046
|
+
bucket.slots[0]!.recordToSlot[recIdx] = movedSlot;
|
|
2886
2047
|
const byteOff = recIdx * RECORD_BYTES;
|
|
2887
|
-
if (byteOff < bucket.drawTableDirtyMin) bucket.drawTableDirtyMin = byteOff;
|
|
2888
|
-
if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax) bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2048
|
+
if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
|
|
2049
|
+
if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2889
2050
|
}
|
|
2890
|
-
bucket.slotToRecord[localSlot] = -1;
|
|
2891
|
-
bucket.recordToSlot[lastRecIdx] = -1;
|
|
2892
|
-
bucket.recordCount = lastRecIdx;
|
|
2893
|
-
bucket.scanDirty = true;
|
|
2051
|
+
bucket.slots[0]!.slotToRecord[localSlot] = -1;
|
|
2052
|
+
bucket.slots[0]!.recordToSlot[lastRecIdx] = -1;
|
|
2053
|
+
bucket.slots[0]!.recordCount = lastRecIdx;
|
|
2054
|
+
bucket.slots[0]!.scanDirty = true;
|
|
2894
2055
|
}
|
|
2895
2056
|
|
|
2896
2057
|
// Release pool entries — refcount drops; if zero, allocation freed.
|
|
@@ -2943,9 +2104,18 @@ export function buildHeapScene(
|
|
|
2943
2104
|
drawIdToBucket[drawId] = undefined;
|
|
2944
2105
|
drawIdToLocalSlot[drawId] = undefined;
|
|
2945
2106
|
drawIdToIndexAval[drawId] = undefined;
|
|
2107
|
+
drawIdToSpec[drawId] = undefined;
|
|
2108
|
+
const oldTracker = drawIdToModeTracker[drawId];
|
|
2109
|
+
if (oldTracker !== undefined) oldTracker.dispose();
|
|
2110
|
+
drawIdToModeTracker[drawId] = undefined;
|
|
2111
|
+
dirtyModeKeyDrawIds.delete(drawId);
|
|
2946
2112
|
|
|
2947
2113
|
stats.totalDraws--;
|
|
2948
2114
|
stats.geometryBytes = arenaBytes(arena);
|
|
2115
|
+
|
|
2116
|
+
// GC the bucket if it just emptied (common after reactive
|
|
2117
|
+
// PS rebucket leaves the source bucket with zero live ROs).
|
|
2118
|
+
gcBucket(bucket);
|
|
2949
2119
|
}
|
|
2950
2120
|
|
|
2951
2121
|
// ─── Aset reader (pull-driven on each frame) ──────────────────────
|
|
@@ -3030,6 +2200,35 @@ export function buildHeapScene(
|
|
|
3030
2200
|
sceneObj.evaluateAlways(token, (tok) => {
|
|
3031
2201
|
drainAsetWith(tok);
|
|
3032
2202
|
|
|
2203
|
+
// 0. Reactive rebucket: any RO whose PipelineState modeKey
|
|
2204
|
+
// changed since last frame moves to its new bucket.
|
|
2205
|
+
// `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
|
|
2206
|
+
// onDirty callbacks (one per leaf aval per RO). Calling
|
|
2207
|
+
// `recompute()` here re-snapshots the descriptor and tells
|
|
2208
|
+
// us whether the modeKey actually changed (vs the aval just
|
|
2209
|
+
// being marked-but-equal). If the key changed, we remove +
|
|
2210
|
+
// re-add the RO via the retained spec; addDraw routes it to
|
|
2211
|
+
// the correct (new) bucket via the value-keyed lookup.
|
|
2212
|
+
if (dirtyModeKeyDrawIds.size > 0) {
|
|
2213
|
+
const dirty = [...dirtyModeKeyDrawIds];
|
|
2214
|
+
dirtyModeKeyDrawIds.clear();
|
|
2215
|
+
for (const drawId of dirty) {
|
|
2216
|
+
const tracker = drawIdToModeTracker[drawId];
|
|
2217
|
+
if (tracker === undefined) continue; // RO removed since mark
|
|
2218
|
+
const oldKey = tracker.modeKey;
|
|
2219
|
+
if (!tracker.recompute()) continue; // value unchanged
|
|
2220
|
+
if (tracker.modeKey === oldKey) continue; // belt + braces
|
|
2221
|
+
const spec = drawIdToSpec[drawId];
|
|
2222
|
+
if (spec === undefined) continue;
|
|
2223
|
+
removeDraw(drawId);
|
|
2224
|
+
const newId = addDrawImpl(spec, tok);
|
|
2225
|
+
// The drawId returned by addDraw is a fresh slot; we don't
|
|
2226
|
+
// attempt to preserve the caller's id mapping in v1. v2
|
|
2227
|
+
// could thread the original id through if needed.
|
|
2228
|
+
void newId;
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
3033
2232
|
// 1. Pool: re-pack any aval whose value changed since last frame.
|
|
3034
2233
|
// One writeBuffer per dirty aval, regardless of how many draws
|
|
3035
2234
|
// reference it — sharing pays off here.
|
|
@@ -3125,16 +2324,16 @@ export function buildHeapScene(
|
|
|
3125
2324
|
bucket.headerDirtyMin = Infinity;
|
|
3126
2325
|
bucket.headerDirtyMax = 0;
|
|
3127
2326
|
}
|
|
3128
|
-
if (bucket.drawTableDirtyMax > bucket.drawTableDirtyMin) {
|
|
3129
|
-
const shadow = bucket.drawTableShadow!;
|
|
2327
|
+
if (bucket.slots[0]!.drawTableDirtyMax > bucket.slots[0]!.drawTableDirtyMin) {
|
|
2328
|
+
const shadow = bucket.slots[0]!.drawTableShadow!;
|
|
3130
2329
|
device.queue.writeBuffer(
|
|
3131
|
-
bucket.drawTableBuf!.buffer, bucket.drawTableDirtyMin,
|
|
2330
|
+
bucket.slots[0]!.drawTableBuf!.buffer, bucket.slots[0]!.drawTableDirtyMin,
|
|
3132
2331
|
shadow.buffer,
|
|
3133
|
-
shadow.byteOffset + bucket.drawTableDirtyMin,
|
|
3134
|
-
bucket.drawTableDirtyMax - bucket.drawTableDirtyMin,
|
|
2332
|
+
shadow.byteOffset + bucket.slots[0]!.drawTableDirtyMin,
|
|
2333
|
+
bucket.slots[0]!.drawTableDirtyMax - bucket.slots[0]!.drawTableDirtyMin,
|
|
3135
2334
|
);
|
|
3136
|
-
bucket.drawTableDirtyMin = Infinity;
|
|
3137
|
-
bucket.drawTableDirtyMax = 0;
|
|
2335
|
+
bucket.slots[0]!.drawTableDirtyMin = Infinity;
|
|
2336
|
+
bucket.slots[0]!.drawTableDirtyMax = 0;
|
|
3138
2337
|
}
|
|
3139
2338
|
}
|
|
3140
2339
|
}
|
|
@@ -3148,8 +2347,8 @@ export function buildHeapScene(
|
|
|
3148
2347
|
let curBg: GPUBindGroup | null = null;
|
|
3149
2348
|
for (const b of buckets) {
|
|
3150
2349
|
if (b.bindGroup !== curBg) { pass.setBindGroup(0, b.bindGroup); curBg = b.bindGroup; }
|
|
3151
|
-
pass.setPipeline(b.pipeline);
|
|
3152
|
-
if (b.recordCount > 0) pass.drawIndirect(b.indirectBuf!, 0);
|
|
2350
|
+
pass.setPipeline(b.slots[0]!.pipeline);
|
|
2351
|
+
if (b.slots[0]!.recordCount > 0) pass.drawIndirect(b.slots[0]!.indirectBuf!, 0);
|
|
3153
2352
|
}
|
|
3154
2353
|
}
|
|
3155
2354
|
|
|
@@ -3190,23 +2389,23 @@ export function buildHeapScene(
|
|
|
3190
2389
|
stats.derivedRecords = derivedScene.records.recordCount;
|
|
3191
2390
|
}
|
|
3192
2391
|
let anyDirty = false;
|
|
3193
|
-
for (const b of buckets) { if (b.scanDirty) { anyDirty = true; break; } }
|
|
2392
|
+
for (const b of buckets) { if (b.slots[0]!.scanDirty) { anyDirty = true; break; } }
|
|
3194
2393
|
if (!anyDirty) return;
|
|
3195
2394
|
const pass = enc.beginComputePass({ label: "heapScene/scan" });
|
|
3196
2395
|
for (const b of buckets) {
|
|
3197
|
-
if (!b.scanDirty) continue;
|
|
2396
|
+
if (!b.slots[0]!.scanDirty) continue;
|
|
3198
2397
|
// Rebuild render bind group if recordCount changed — its
|
|
3199
2398
|
// drawTable binding is sized to recordCount * 16.
|
|
3200
|
-
if (b.renderBoundRecordCount !== b.recordCount) {
|
|
2399
|
+
if (b.slots[0]!.renderBoundRecordCount !== b.slots[0]!.recordCount) {
|
|
3201
2400
|
b.bindGroup = buildBucketBindGroup(b);
|
|
3202
2401
|
}
|
|
3203
|
-
const numRecords = b.recordCount;
|
|
2402
|
+
const numRecords = b.slots[0]!.recordCount;
|
|
3204
2403
|
const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
3205
2404
|
device.queue.writeBuffer(
|
|
3206
|
-
b.paramsBuf!, 0,
|
|
2405
|
+
b.slots[0]!.paramsBuf!, 0,
|
|
3207
2406
|
new Uint32Array([numRecords, numBlocks, 0, 0]),
|
|
3208
2407
|
);
|
|
3209
|
-
pass.setBindGroup(0, b.scanBindGroup!);
|
|
2408
|
+
pass.setBindGroup(0, b.slots[0]!.scanBindGroup!);
|
|
3210
2409
|
pass.setPipeline(scanPipeTile!);
|
|
3211
2410
|
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
3212
2411
|
pass.setPipeline(scanPipeBlocks!);
|
|
@@ -3217,11 +2416,11 @@ export function buildHeapScene(
|
|
|
3217
2416
|
// must cover the worst-case totalEmit. Each WG handles WG_SIZE
|
|
3218
2417
|
// tiles; +1 for the sentinel slot.
|
|
3219
2418
|
const SCAN_WG_SIZE = 256;
|
|
3220
|
-
const numTilesCap = Math.max(1, Math.ceil(b.totalEmitEstimate / TILE_K));
|
|
2419
|
+
const numTilesCap = Math.max(1, Math.ceil(b.slots[0]!.totalEmitEstimate / TILE_K));
|
|
3221
2420
|
const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
|
|
3222
2421
|
pass.setPipeline(scanPipeBuildTileIndex!);
|
|
3223
2422
|
pass.dispatchWorkgroups(tileWgs, 1, 1);
|
|
3224
|
-
b.scanDirty = false;
|
|
2423
|
+
b.slots[0]!.scanDirty = false;
|
|
3225
2424
|
}
|
|
3226
2425
|
pass.end();
|
|
3227
2426
|
}
|
|
@@ -3259,18 +2458,38 @@ export function buildHeapScene(
|
|
|
3259
2458
|
device.queue.submit([enc.finish()]);
|
|
3260
2459
|
}
|
|
3261
2460
|
|
|
2461
|
+
function destroyBucketResources(b: Bucket): void {
|
|
2462
|
+
b.drawHeap.destroy();
|
|
2463
|
+
for (const slot of b.slots) {
|
|
2464
|
+
slot.drawTableBuf?.destroy();
|
|
2465
|
+
slot.blockSumsBuf?.destroy();
|
|
2466
|
+
slot.blockOffsetsBuf?.destroy();
|
|
2467
|
+
slot.firstDrawInTileBuf?.destroy();
|
|
2468
|
+
slot.indirectBuf?.destroy();
|
|
2469
|
+
slot.paramsBuf?.destroy();
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* Remove an empty bucket from the scene's bucket list + key index +
|
|
2475
|
+
* destroy its GPU resources. Called from removeDraw when an RO's
|
|
2476
|
+
* removal leaves the bucket with zero live slots — common after
|
|
2477
|
+
* reactive PS rebucket leaves the source bucket empty.
|
|
2478
|
+
*/
|
|
2479
|
+
function gcBucket(b: Bucket): void {
|
|
2480
|
+
if (b.drawSlots.size !== 0) return;
|
|
2481
|
+
const idx = buckets.indexOf(b);
|
|
2482
|
+
if (idx >= 0) buckets.splice(idx, 1);
|
|
2483
|
+
for (const [k, v] of bucketByKey) {
|
|
2484
|
+
if (v === b) { bucketByKey.delete(k); break; }
|
|
2485
|
+
}
|
|
2486
|
+
destroyBucketResources(b);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
3262
2489
|
function dispose(): void {
|
|
3263
2490
|
arena.attrs.destroy();
|
|
3264
2491
|
arena.indices.destroy();
|
|
3265
|
-
for (const b of buckets)
|
|
3266
|
-
b.drawHeap.destroy();
|
|
3267
|
-
b.drawTableBuf?.destroy();
|
|
3268
|
-
b.blockSumsBuf?.destroy();
|
|
3269
|
-
b.blockOffsetsBuf?.destroy();
|
|
3270
|
-
b.firstDrawInTileBuf?.destroy();
|
|
3271
|
-
b.indirectBuf?.destroy();
|
|
3272
|
-
b.paramsBuf?.destroy();
|
|
3273
|
-
}
|
|
2492
|
+
for (const b of buckets) destroyBucketResources(b);
|
|
3274
2493
|
}
|
|
3275
2494
|
|
|
3276
2495
|
// Test-only escape hatch for inspecting megacall bucket state. Not
|
|
@@ -3285,11 +2504,11 @@ export function buildHeapScene(
|
|
|
3285
2504
|
layout: BucketLayout;
|
|
3286
2505
|
}[] {
|
|
3287
2506
|
return buckets.map(b => ({
|
|
3288
|
-
indirectBuf: b.indirectBuf,
|
|
3289
|
-
drawTableBuf: b.drawTableBuf?.buffer,
|
|
3290
|
-
firstDrawInTileBuf: b.firstDrawInTileBuf?.buffer,
|
|
3291
|
-
totalEmitEstimate: b.totalEmitEstimate,
|
|
3292
|
-
recordCount: b.recordCount,
|
|
2507
|
+
indirectBuf: b.slots[0]!.indirectBuf,
|
|
2508
|
+
drawTableBuf: b.slots[0]!.drawTableBuf?.buffer,
|
|
2509
|
+
firstDrawInTileBuf: b.slots[0]!.firstDrawInTileBuf?.buffer,
|
|
2510
|
+
totalEmitEstimate: b.slots[0]!.totalEmitEstimate,
|
|
2511
|
+
recordCount: b.slots[0]!.recordCount,
|
|
3293
2512
|
layout: b.layout,
|
|
3294
2513
|
}));
|
|
3295
2514
|
},
|
|
@@ -3358,18 +2577,18 @@ export function buildHeapScene(
|
|
|
3358
2577
|
const TILE_K_LOCAL = 64;
|
|
3359
2578
|
const dcs: DC[] = [];
|
|
3360
2579
|
for (const b of buckets) {
|
|
3361
|
-
const dhSize = Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes);
|
|
2580
|
+
const dhSize = Math.min(b.drawHeap.buffer.size, b.slots[0]!.recordCount * b.layout.drawHeaderBytes);
|
|
3362
2581
|
if (dhSize === 0) continue;
|
|
3363
2582
|
const dh = stage(b.drawHeap.buffer, dhSize);
|
|
3364
|
-
const dt = b.drawTableBuf !== undefined && b.recordCount > 0
|
|
3365
|
-
? stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES)
|
|
2583
|
+
const dt = b.slots[0]!.drawTableBuf !== undefined && b.slots[0]!.recordCount > 0
|
|
2584
|
+
? stage(b.slots[0]!.drawTableBuf.buffer, b.slots[0]!.recordCount * RECORD_BYTES)
|
|
3366
2585
|
: undefined;
|
|
3367
|
-
const totalEmit = b.totalEmitEstimate;
|
|
2586
|
+
const totalEmit = b.slots[0]!.totalEmitEstimate;
|
|
3368
2587
|
const numTiles = totalEmit > 0 ? Math.ceil(totalEmit / TILE_K_LOCAL) : 0;
|
|
3369
|
-
const fdt = b.firstDrawInTileBuf !== undefined && numTiles > 0
|
|
3370
|
-
? stage(b.firstDrawInTileBuf.buffer, Math.min(b.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
|
|
2588
|
+
const fdt = b.slots[0]!.firstDrawInTileBuf !== undefined && numTiles > 0
|
|
2589
|
+
? stage(b.slots[0]!.firstDrawInTileBuf.buffer, Math.min(b.slots[0]!.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
|
|
3371
2590
|
: undefined;
|
|
3372
|
-
const ind = b.indirectBuf !== undefined ? stage(b.indirectBuf, 16) : undefined;
|
|
2591
|
+
const ind = b.slots[0]!.indirectBuf !== undefined ? stage(b.slots[0]!.indirectBuf, 16) : undefined;
|
|
3373
2592
|
dcs.push({
|
|
3374
2593
|
bucket: b, drawHeap: dh, numTiles,
|
|
3375
2594
|
...(dt !== undefined ? { drawTable: dt } : {}),
|
|
@@ -3401,7 +2620,7 @@ export function buildHeapScene(
|
|
|
3401
2620
|
for (const dc of dcs) {
|
|
3402
2621
|
const u32 = new Uint32Array(dc.drawHeap.getMappedRange());
|
|
3403
2622
|
const stride = dc.bucket.layout.drawHeaderBytes;
|
|
3404
|
-
const recordCount = dc.bucket.recordCount;
|
|
2623
|
+
const recordCount = dc.bucket.slots[0]!.recordCount;
|
|
3405
2624
|
|
|
3406
2625
|
for (let slot = 0; slot < recordCount; slot++) {
|
|
3407
2626
|
const slotOff = slot * stride;
|
|
@@ -3557,7 +2776,7 @@ export function buildHeapScene(
|
|
|
3557
2776
|
// ── 6. indirect[0] (totalEmit) must match CPU prefix sum.
|
|
3558
2777
|
if (dc.indirect !== undefined) {
|
|
3559
2778
|
const ind = new Uint32Array(dc.indirect.getMappedRange());
|
|
3560
|
-
const expectedTotal = dc.bucket.totalEmitEstimate;
|
|
2779
|
+
const expectedTotal = dc.bucket.slots[0]!.totalEmitEstimate;
|
|
3561
2780
|
const got = ind[0]!;
|
|
3562
2781
|
if (got !== expectedTotal) {
|
|
3563
2782
|
push(`bucket#${bucketIdx} indirect[0]=${got} ≠ expected totalEmit=${expectedTotal}`);
|
|
@@ -3658,10 +2877,10 @@ export function buildHeapScene(
|
|
|
3658
2877
|
};
|
|
3659
2878
|
const dcs: DC[] = [];
|
|
3660
2879
|
for (const b of buckets) {
|
|
3661
|
-
if (b.recordCount === 0 || b.totalEmitEstimate === 0) continue;
|
|
3662
|
-
const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes));
|
|
3663
|
-
const dt = stage(b.drawTableBuf!.buffer, b.recordCount * RECORD_BYTES);
|
|
3664
|
-
dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.totalEmitEstimate });
|
|
2880
|
+
if (b.slots[0]!.recordCount === 0 || b.slots[0]!.totalEmitEstimate === 0) continue;
|
|
2881
|
+
const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.slots[0]!.recordCount * b.layout.drawHeaderBytes));
|
|
2882
|
+
const dt = stage(b.slots[0]!.drawTableBuf!.buffer, b.slots[0]!.recordCount * RECORD_BYTES);
|
|
2883
|
+
dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.slots[0]!.totalEmitEstimate });
|
|
3665
2884
|
}
|
|
3666
2885
|
device.queue.submit([enc.finish()]);
|
|
3667
2886
|
|
|
@@ -3687,7 +2906,7 @@ export function buildHeapScene(
|
|
|
3687
2906
|
const dt = new Uint32Array(dc.drawTable.getMappedRange());
|
|
3688
2907
|
bucketDt.push(dt);
|
|
3689
2908
|
bucketHdr.push(new Uint32Array(dc.drawHeap.getMappedRange()));
|
|
3690
|
-
for (let r = 0; r < dc.bucket.recordCount; r++) {
|
|
2909
|
+
for (let r = 0; r < dc.bucket.slots[0]!.recordCount; r++) {
|
|
3691
2910
|
dc.firstEmit.push(dt[r * RECORD_U32 + 0]!);
|
|
3692
2911
|
}
|
|
3693
2912
|
bucketCumEmit.push(totalEmitGlobal);
|
|
@@ -3745,7 +2964,7 @@ export function buildHeapScene(
|
|
|
3745
2964
|
emitsChecked++;
|
|
3746
2965
|
|
|
3747
2966
|
// Binary search for slot.
|
|
3748
|
-
const recCount = dc.bucket.recordCount;
|
|
2967
|
+
const recCount = dc.bucket.slots[0]!.recordCount;
|
|
3749
2968
|
let lo = 0, hi = recCount - 1;
|
|
3750
2969
|
while (lo < hi) {
|
|
3751
2970
|
const mid = (lo + hi + 1) >>> 1;
|
|
@@ -3942,9 +3161,9 @@ export function buildHeapScene(
|
|
|
3942
3161
|
: new Uint32Array(0);
|
|
3943
3162
|
|
|
3944
3163
|
for (const target of buckets) {
|
|
3945
|
-
if (target.recordCount === 0 || target.totalEmitEstimate === 0) { bucketIdx++; continue; }
|
|
3946
|
-
const recordCount = target.recordCount;
|
|
3947
|
-
const totalEmit = target.totalEmitEstimate;
|
|
3164
|
+
if (target.slots[0]!.recordCount === 0 || target.slots[0]!.totalEmitEstimate === 0) { bucketIdx++; continue; }
|
|
3165
|
+
const recordCount = target.slots[0]!.recordCount;
|
|
3166
|
+
const totalEmit = target.slots[0]!.totalEmitEstimate;
|
|
3948
3167
|
const totalTris = Math.floor(totalEmit / 3);
|
|
3949
3168
|
const remainder = totalEmit % 3;
|
|
3950
3169
|
if (remainder !== 0) {
|
|
@@ -3962,7 +3181,7 @@ export function buildHeapScene(
|
|
|
3962
3181
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3963
3182
|
});
|
|
3964
3183
|
const enc0 = device.createCommandEncoder({ label: "checkTriangleCoherence.dt" });
|
|
3965
|
-
enc0.copyBufferToBuffer(target.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
3184
|
+
enc0.copyBufferToBuffer(target.slots[0]!.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
3966
3185
|
enc0.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, recordCount * target.layout.drawHeaderBytes);
|
|
3967
3186
|
device.queue.submit([enc0.finish()]);
|
|
3968
3187
|
await dtCopy.mapAsync(GPUMapMode.READ);
|
|
@@ -4011,7 +3230,7 @@ export function buildHeapScene(
|
|
|
4011
3230
|
});
|
|
4012
3231
|
{
|
|
4013
3232
|
const enc1 = device.createCommandEncoder();
|
|
4014
|
-
enc1.copyBufferToBuffer(target.firstDrawInTileBuf!.buffer, 0, fdtCopy, 0, fdtSize);
|
|
3233
|
+
enc1.copyBufferToBuffer(target.slots[0]!.firstDrawInTileBuf!.buffer, 0, fdtCopy, 0, fdtSize);
|
|
4015
3234
|
device.queue.submit([enc1.finish()]);
|
|
4016
3235
|
await fdtCopy.mapAsync(GPUMapMode.READ);
|
|
4017
3236
|
}
|
|
@@ -4166,9 +3385,9 @@ export function buildHeapScene(
|
|
|
4166
3385
|
// own bytes).
|
|
4167
3386
|
bucketIdx = 0;
|
|
4168
3387
|
for (const target of buckets) {
|
|
4169
|
-
if (target.recordCount === 0) { bucketIdx++; continue; }
|
|
3388
|
+
if (target.slots[0]!.recordCount === 0) { bucketIdx++; continue; }
|
|
4170
3389
|
// Re-stage drawHeap for this bucket.
|
|
4171
|
-
const dhBytes = target.recordCount * target.layout.drawHeaderBytes;
|
|
3390
|
+
const dhBytes = target.slots[0]!.recordCount * target.layout.drawHeaderBytes;
|
|
4172
3391
|
const dhCopy = device.createBuffer({
|
|
4173
3392
|
size: dhBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
4174
3393
|
});
|
|
@@ -4178,7 +3397,7 @@ export function buildHeapScene(
|
|
|
4178
3397
|
await dhCopy.mapAsync(GPUMapMode.READ);
|
|
4179
3398
|
const dhU32 = new Uint32Array(dhCopy.getMappedRange());
|
|
4180
3399
|
const stride2 = target.layout.drawHeaderBytes;
|
|
4181
|
-
for (let slot = 0; slot < target.recordCount; slot++) {
|
|
3400
|
+
for (let slot = 0; slot < target.slots[0]!.recordCount; slot++) {
|
|
4182
3401
|
for (const f of target.layout.drawHeaderFields) {
|
|
4183
3402
|
if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
|
|
4184
3403
|
const ref = dhU32[(slot * stride2 + f.byteOffset) >>> 2];
|
|
@@ -4238,11 +3457,11 @@ export function buildHeapScene(
|
|
|
4238
3457
|
let gpuMismatches = 0;
|
|
4239
3458
|
const push = (s: string) => { if (issues.length < 30) issues.push(s); };
|
|
4240
3459
|
|
|
4241
|
-
const target = buckets.find(b => b.recordCount > 0 && b.totalEmitEstimate > 0);
|
|
3460
|
+
const target = buckets.find(b => b.slots[0]!.recordCount > 0 && b.slots[0]!.totalEmitEstimate > 0);
|
|
4242
3461
|
if (target === undefined) return { emitsChecked: 0, gpuMismatches: 0, issues };
|
|
4243
3462
|
|
|
4244
|
-
const recordCount = target.recordCount;
|
|
4245
|
-
const totalEmit = target.totalEmitEstimate;
|
|
3463
|
+
const recordCount = target.slots[0]!.recordCount;
|
|
3464
|
+
const totalEmit = target.slots[0]!.totalEmitEstimate;
|
|
4246
3465
|
const sampleCount = Math.min(samples, totalEmit);
|
|
4247
3466
|
const stride = Math.max(1, Math.floor(totalEmit / sampleCount));
|
|
4248
3467
|
|
|
@@ -4256,7 +3475,7 @@ export function buildHeapScene(
|
|
|
4256
3475
|
});
|
|
4257
3476
|
{
|
|
4258
3477
|
const enc0 = device.createCommandEncoder({ label: "probeBinarySearch.dtCopy" });
|
|
4259
|
-
enc0.copyBufferToBuffer(target.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
3478
|
+
enc0.copyBufferToBuffer(target.slots[0]!.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
4260
3479
|
device.queue.submit([enc0.finish()]);
|
|
4261
3480
|
await dtCopy.mapAsync(GPUMapMode.READ);
|
|
4262
3481
|
}
|
|
@@ -4360,8 +3579,8 @@ export function buildHeapScene(
|
|
|
4360
3579
|
const bg = device.createBindGroup({
|
|
4361
3580
|
layout: bgl,
|
|
4362
3581
|
entries: [
|
|
4363
|
-
{ binding: 0, resource: { buffer: target.drawTableBuf!.buffer } },
|
|
4364
|
-
{ binding: 1, resource: { buffer: target.firstDrawInTileBuf!.buffer } },
|
|
3582
|
+
{ binding: 0, resource: { buffer: target.slots[0]!.drawTableBuf!.buffer } },
|
|
3583
|
+
{ binding: 1, resource: { buffer: target.slots[0]!.firstDrawInTileBuf!.buffer } },
|
|
4365
3584
|
{ binding: 2, resource: { buffer: sampleBuf } },
|
|
4366
3585
|
{ binding: 3, resource: { buffer: arena.indices.buffer } },
|
|
4367
3586
|
{ binding: 4, resource: { buffer: outBuf } },
|