@aardworx/wombat.rendering 0.9.14 → 0.9.16
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 +62 -0
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -0
- package/dist/runtime/derivedModes/modeKeyCpu.js +225 -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/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 +346 -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 +251 -0
- package/src/runtime/derivedModes/partition.ts +206 -0
- package/src/runtime/derivedModes/slotTable.ts +153 -0
- 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 +397 -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
|
@@ -49,797 +49,13 @@ import { buildShaderFamily, compileShaderFamily, } from "./heapShaderFamily.js";
|
|
|
49
49
|
import { ATLAS_PAGE_FORMATS, atlasFormatIndex, } from "./textureAtlas/atlasPool.js";
|
|
50
50
|
import { ATLAS_ARRAY_SIZE, ATLAS_LINEAR_BINDING_BASE, ATLAS_SRGB_BINDING_BASE, ATLAS_SAMPLER_BINDING, } from "./heapEffect.js";
|
|
51
51
|
import { DerivedUniformsScene, registerRoDerivations, deregisterRoDerivations, isDerivedRule, STANDARD_DERIVED_RULES, STANDARD_TRAFO_LEAVES, inputsOf, } from "./derivedUniforms/index.js";
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const ALLOC_HEADER_BYTES = 8;
|
|
60
|
-
const ALLOC_HEADER_PAD_TO = 16; // data starts header_offset + 16
|
|
61
|
-
// Encoding-tag enum (low 16 bits of typeId).
|
|
62
|
-
const ENC_V3F_TIGHT = 1; // tightly-packed array of vec3<f32> (12 B/elt)
|
|
63
|
-
// Semantic-tag enum (high 16 bits of typeId). Optional metadata —
|
|
64
|
-
// the shader doesn't branch on this.
|
|
65
|
-
const SEM_POSITIONS = 1;
|
|
66
|
-
const SEM_NORMALS = 2;
|
|
67
|
-
const ALIGN16 = (n) => (n + 15) & ~15;
|
|
68
|
-
function packMat44(m, dst, off) {
|
|
69
|
-
// Zero-alloc flat copy (row-major) straight into the f32 staging
|
|
70
|
-
// buffer — `copyTo` does `dst.set(m._data, off)` which narrows f64→f32
|
|
71
|
-
// on store, no throwaway `number[]` per call.
|
|
72
|
-
m.copyTo(dst, off);
|
|
73
|
-
}
|
|
74
|
-
const PACKER_MAT4 = {
|
|
75
|
-
dataBytes: 64, typeId: 0,
|
|
76
|
-
pack: (val, dst, off) => {
|
|
77
|
-
// Accept Trafo3d (uses .forward) or M44d directly.
|
|
78
|
-
const m = val.forward !== undefined
|
|
79
|
-
? val.forward
|
|
80
|
-
: val;
|
|
81
|
-
packMat44(m, dst, off);
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
const PACKER_VEC4 = {
|
|
85
|
-
dataBytes: 16, typeId: 0,
|
|
86
|
-
pack: (val, dst, off) => {
|
|
87
|
-
const v = val;
|
|
88
|
-
dst[off + 0] = v.x;
|
|
89
|
-
dst[off + 1] = v.y;
|
|
90
|
-
dst[off + 2] = v.z;
|
|
91
|
-
dst[off + 3] = v.w;
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
const PACKER_VEC3 = {
|
|
95
|
-
dataBytes: 12, typeId: 0,
|
|
96
|
-
pack: (val, dst, off) => {
|
|
97
|
-
// V3f or V3d both expose .x/.y/.z; cast through a common shape.
|
|
98
|
-
const v = val;
|
|
99
|
-
dst[off + 0] = v.x;
|
|
100
|
-
dst[off + 1] = v.y;
|
|
101
|
-
dst[off + 2] = v.z;
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
const PACKER_VEC2 = {
|
|
105
|
-
dataBytes: 8, typeId: 0,
|
|
106
|
-
pack: (val, dst, off) => {
|
|
107
|
-
const v = val;
|
|
108
|
-
dst[off + 0] = v.x;
|
|
109
|
-
dst[off + 1] = v.y;
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
const PACKER_F32 = {
|
|
113
|
-
dataBytes: 4, typeId: 0,
|
|
114
|
-
pack: (val, dst, off) => { dst[off] = val; },
|
|
115
|
-
};
|
|
116
|
-
// Integer scalars / vectors. The arena is fronted by a `Float32Array`,
|
|
117
|
-
// so writing raw bits has to go through a same-buffer Uint32/Int32 view
|
|
118
|
-
// to avoid the lossy `i32 → f32` coercion you'd get from a direct
|
|
119
|
-
// `dst[off] = ...` assignment.
|
|
120
|
-
function makeIntPacker(ctor, dim) {
|
|
121
|
-
const bytes = dim * 4;
|
|
122
|
-
if (dim === 1) {
|
|
123
|
-
return {
|
|
124
|
-
dataBytes: bytes, typeId: 0,
|
|
125
|
-
pack: (val, dst, off) => {
|
|
126
|
-
new ctor(dst.buffer, dst.byteOffset + off * 4, 1)[0] = val;
|
|
127
|
-
},
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
// Vector: accept {x,y,z,w} components.
|
|
131
|
-
return {
|
|
132
|
-
dataBytes: bytes, typeId: 0,
|
|
133
|
-
pack: (val, dst, off) => {
|
|
134
|
-
const view = new ctor(dst.buffer, dst.byteOffset + off * 4, dim);
|
|
135
|
-
const v = val;
|
|
136
|
-
view[0] = v.x;
|
|
137
|
-
view[1] = v.y;
|
|
138
|
-
if (dim >= 3)
|
|
139
|
-
view[2] = v.z;
|
|
140
|
-
if (dim >= 4)
|
|
141
|
-
view[3] = v.w;
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
const PACKER_U32 = makeIntPacker(Uint32Array, 1);
|
|
146
|
-
const PACKER_UVEC2 = makeIntPacker(Uint32Array, 2);
|
|
147
|
-
const PACKER_UVEC3 = makeIntPacker(Uint32Array, 3);
|
|
148
|
-
const PACKER_UVEC4 = makeIntPacker(Uint32Array, 4);
|
|
149
|
-
const PACKER_I32 = makeIntPacker(Int32Array, 1);
|
|
150
|
-
const PACKER_IVEC2 = makeIntPacker(Int32Array, 2);
|
|
151
|
-
const PACKER_IVEC3 = makeIntPacker(Int32Array, 3);
|
|
152
|
-
const PACKER_IVEC4 = makeIntPacker(Int32Array, 4);
|
|
153
|
-
function packerForWgslType(wgslType) {
|
|
154
|
-
switch (wgslType) {
|
|
155
|
-
case "mat4x4<f32>": return PACKER_MAT4;
|
|
156
|
-
case "vec4<f32>": return PACKER_VEC4;
|
|
157
|
-
case "vec3<f32>": return PACKER_VEC3;
|
|
158
|
-
case "vec2<f32>": return PACKER_VEC2;
|
|
159
|
-
case "f32": return PACKER_F32;
|
|
160
|
-
case "u32": return PACKER_U32;
|
|
161
|
-
case "vec2<u32>": return PACKER_UVEC2;
|
|
162
|
-
case "vec3<u32>": return PACKER_UVEC3;
|
|
163
|
-
case "vec4<u32>": return PACKER_UVEC4;
|
|
164
|
-
case "i32": return PACKER_I32;
|
|
165
|
-
case "vec2<i32>": return PACKER_IVEC2;
|
|
166
|
-
case "vec3<i32>": return PACKER_IVEC3;
|
|
167
|
-
case "vec4<i32>": return PACKER_IVEC4;
|
|
168
|
-
default:
|
|
169
|
-
throw new Error(`heapScene: no JS-side packer for WGSL type '${wgslType}'`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
// Resizable buffer (pow2 grow + GPU-side copy on resize)
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
const MIN_BUFFER_BYTES = 64 * 1024;
|
|
176
|
-
const POW2 = (n) => {
|
|
177
|
-
let p = 1;
|
|
178
|
-
while (p < n)
|
|
179
|
-
p <<= 1;
|
|
180
|
-
return p;
|
|
181
|
-
};
|
|
182
|
-
/**
|
|
183
|
-
* A GPUBuffer that can grow to next power-of-two on demand. On grow,
|
|
184
|
-
* a fresh buffer is created at the new size, the live tail copied
|
|
185
|
-
* over via copyBufferToBuffer, and dependents (bind groups, mostly)
|
|
186
|
-
* are notified to rebuild via the `onResize` callback.
|
|
187
|
-
*
|
|
188
|
-
* `usedBytes` is the high-water mark — the runtime advances this as
|
|
189
|
-
* it allocates, and `ensureCapacity` grows when required. This
|
|
190
|
-
* separates allocation policy from grow policy.
|
|
191
|
-
*/
|
|
192
|
-
class GrowBuffer {
|
|
193
|
-
device;
|
|
194
|
-
label;
|
|
195
|
-
usage;
|
|
196
|
-
buf;
|
|
197
|
-
cap;
|
|
198
|
-
used = 0;
|
|
199
|
-
listeners = new Set();
|
|
200
|
-
constructor(device, label, usage, initialBytes) {
|
|
201
|
-
this.device = device;
|
|
202
|
-
this.label = label;
|
|
203
|
-
this.usage = usage;
|
|
204
|
-
this.cap = Math.max(MIN_BUFFER_BYTES, POW2(initialBytes));
|
|
205
|
-
this.buf = device.createBuffer({ size: this.cap, usage: usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label });
|
|
206
|
-
}
|
|
207
|
-
get buffer() { return this.buf; }
|
|
208
|
-
get capacity() { return this.cap; }
|
|
209
|
-
get usedBytes() { return this.used; }
|
|
210
|
-
setUsed(n) { this.used = n; }
|
|
211
|
-
onResize(cb) {
|
|
212
|
-
this.listeners.add(cb);
|
|
213
|
-
return { dispose: () => { this.listeners.delete(cb); } };
|
|
214
|
-
}
|
|
215
|
-
/** Ensure the buffer is at least `bytes` capacity. Grows by pow2 + copies live tail. */
|
|
216
|
-
ensureCapacity(bytes) {
|
|
217
|
-
if (bytes <= this.cap)
|
|
218
|
-
return;
|
|
219
|
-
const newCap = POW2(bytes);
|
|
220
|
-
const newBuf = this.device.createBuffer({
|
|
221
|
-
size: newCap, usage: this.usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label: this.label,
|
|
222
|
-
});
|
|
223
|
-
if (this.used > 0) {
|
|
224
|
-
const enc = this.device.createCommandEncoder({ label: `${this.label}: grow-copy` });
|
|
225
|
-
enc.copyBufferToBuffer(this.buf, 0, newBuf, 0, ALIGN16(this.used));
|
|
226
|
-
this.device.queue.submit([enc.finish()]);
|
|
227
|
-
}
|
|
228
|
-
this.buf.destroy();
|
|
229
|
-
this.buf = newBuf;
|
|
230
|
-
this.cap = newCap;
|
|
231
|
-
for (const cb of this.listeners)
|
|
232
|
-
cb();
|
|
233
|
-
}
|
|
234
|
-
destroy() { this.buf.destroy(); }
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Aval-keyed pool of arena allocations. One allocation per unique
|
|
238
|
-
* aval (object identity). Two draws referencing the same aval share
|
|
239
|
-
* the allocation; their DrawHeaders carry the same u32 ref. Holds
|
|
240
|
-
* uniforms (fixed-size scalars/vectors/matrices) AND attribute arrays
|
|
241
|
-
* (variable-size). The caller decides `dataBytes` + `length` per
|
|
242
|
-
* acquisition — the pool just keys on aval identity and refcounts.
|
|
243
|
-
*
|
|
244
|
-
* Sharing emerges from aval identity — no separate "frequency"
|
|
245
|
-
* declaration needed. A `cval` shared by all draws → 1 alloc.
|
|
246
|
-
* A static positions array shared across instanced draws → 1 alloc.
|
|
247
|
-
* Same code path either way.
|
|
248
|
-
*/
|
|
249
|
-
class UniformPool {
|
|
250
|
-
// Keyed by `aval<unknown>` *by reference* (a plain JS `Map`). These
|
|
251
|
-
// keys are overwhelmingly reactive `cval`s (per-object trafos,
|
|
252
|
-
// colours, …) and the hot path is `acquire`/`release` ~once per
|
|
253
|
-
// drawHeader field per RO. A content-keyed `HashTable` would buy
|
|
254
|
-
// nothing here — reactive avals never compare content-equal — and
|
|
255
|
-
// would cost: reactive avals have no `equals`/`getHashCode`, so a
|
|
256
|
-
// `HashTable` falls back to a WeakMap-counter identity hash per
|
|
257
|
-
// lookup, measurably slower than `Map`'s native hashing. (Constant-
|
|
258
|
-
// aval dedup matters where keys are *texture* avals — there the
|
|
259
|
-
// `AtlasPool` is content-keyed; constant avals there carry a cached
|
|
260
|
-
// hash and a fast `equals`.)
|
|
261
|
-
byAval = new Map();
|
|
262
|
-
has(aval) { return this.byAval.has(aval); }
|
|
263
|
-
entry(aval) { return this.byAval.get(aval); }
|
|
264
|
-
/**
|
|
265
|
-
* Acquire (or share) an allocation for `aval`. Caller passes the
|
|
266
|
-
* pre-read `value` (so the pool doesn't need a token) plus the
|
|
267
|
-
* (`dataBytes`, `typeId`, `length`, `pack`) describing how to lay
|
|
268
|
-
* it out. If a new allocation is made, the value is packed and
|
|
269
|
-
* uploaded immediately.
|
|
270
|
-
*/
|
|
271
|
-
acquire(device, arena, aval, value, dataBytes, typeId, length, pack) {
|
|
272
|
-
const existing = this.byAval.get(aval);
|
|
273
|
-
if (existing !== undefined) {
|
|
274
|
-
existing.refcount++;
|
|
275
|
-
return existing.ref;
|
|
276
|
-
}
|
|
277
|
-
const ref = arena.alloc(dataBytes);
|
|
278
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
279
|
-
const buf = new ArrayBuffer(allocBytes);
|
|
280
|
-
const u32 = new Uint32Array(buf);
|
|
281
|
-
const f32 = new Float32Array(buf);
|
|
282
|
-
u32[0] = typeId;
|
|
283
|
-
u32[1] = length;
|
|
284
|
-
// stride_bytes (offset 8): bytes per element. Lets the VS decode
|
|
285
|
-
// pick V3- vs V4-tight load expressions for vec4 attributes
|
|
286
|
-
// (and is informative for everything else).
|
|
287
|
-
u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
|
|
288
|
-
pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
|
|
289
|
-
arena.write(ref, new Uint8Array(buf));
|
|
290
|
-
void device;
|
|
291
|
-
this.byAval.set(aval, { ref, dataBytes, typeId, pack, refcount: 1 });
|
|
292
|
-
return ref;
|
|
293
|
-
}
|
|
294
|
-
/** Decrement refcount; if zero, free the arena allocation. */
|
|
295
|
-
release(arena, aval) {
|
|
296
|
-
const e = this.byAval.get(aval);
|
|
297
|
-
if (e === undefined)
|
|
298
|
-
return;
|
|
299
|
-
e.refcount--;
|
|
300
|
-
if (e.refcount > 0)
|
|
301
|
-
return;
|
|
302
|
-
arena.release(e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
|
|
303
|
-
this.byAval.delete(aval);
|
|
304
|
-
}
|
|
305
|
-
/** Re-pack one entry's data region into the arena's CPU shadow. */
|
|
306
|
-
repack(device, arena, aval, val) {
|
|
307
|
-
const e = this.byAval.get(aval);
|
|
308
|
-
if (e === undefined)
|
|
309
|
-
return;
|
|
310
|
-
const dst = new Float32Array(e.dataBytes / 4);
|
|
311
|
-
e.pack(val, dst, 0);
|
|
312
|
-
arena.write(e.ref + ALLOC_HEADER_PAD_TO, new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes));
|
|
313
|
-
void device;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Aval-keyed pool over the `IndexAllocator`. Two draws referencing
|
|
318
|
-
* the same `Uint32Array` (or aval thereof) share an index range —
|
|
319
|
-
* 19K instanced clones of the same mesh share one allocation, one
|
|
320
|
-
* upload. Index data is treated as immutable for the aval's
|
|
321
|
-
* lifetime: an aval mark won't repack (we'd have to free + re-alloc
|
|
322
|
-
* since size changes are likely). Use a fresh aval to swap meshes.
|
|
323
|
-
*
|
|
324
|
-
* **Value-equality dedup for constant avals (§5b):** when an
|
|
325
|
-
* incoming aval has `isConstant === true`, the pool also keys by
|
|
326
|
-
* the underlying `ArrayBuffer` tuple `(buffer, byteOffset,
|
|
327
|
-
* byteLength)`. Two distinct constant avals wrapping the same
|
|
328
|
-
* `Uint32Array` view (or two views over the same backing buffer
|
|
329
|
-
* with matching offsets) collapse to one allocation. Hashing
|
|
330
|
-
* kilobytes of indices on every acquire would be wasteful; the
|
|
331
|
-
* tuple key catches the realistic "one ArrayBuffer shared across
|
|
332
|
-
* many aval wrappers" pattern, which is the only one that matters
|
|
333
|
-
* for the heap path. Reactive (non-constant) avals fall through
|
|
334
|
-
* to identity-only — their content can change and the pool can't
|
|
335
|
-
* silently merge them.
|
|
336
|
-
*/
|
|
337
|
-
class IndexPool {
|
|
338
|
-
// Per-aval binding. `perAvalCount` tracks acquire/release balance
|
|
339
|
-
// for THIS aval; `entry` is the shared allocation (one entry can be
|
|
340
|
-
// referenced by many aliasing avals via §5b dedup).
|
|
341
|
-
byAval = new Map();
|
|
342
|
-
byValueKey = new Map();
|
|
343
|
-
// Stable per-ArrayBuffer numeric id for value-key composition.
|
|
344
|
-
// WeakMap-backed so buffers GC'd elsewhere drop their entry too.
|
|
345
|
-
bufferIds = new WeakMap();
|
|
346
|
-
nextBufferId = 1;
|
|
347
|
-
bufferIdOf(buf) {
|
|
348
|
-
let id = this.bufferIds.get(buf);
|
|
349
|
-
if (id === undefined) {
|
|
350
|
-
id = this.nextBufferId++;
|
|
351
|
-
this.bufferIds.set(buf, id);
|
|
352
|
-
}
|
|
353
|
-
return id;
|
|
354
|
-
}
|
|
355
|
-
acquire(device, indices, aval, arr) {
|
|
356
|
-
const bound = this.byAval.get(aval);
|
|
357
|
-
if (bound !== undefined) {
|
|
358
|
-
bound.perAvalCount++;
|
|
359
|
-
bound.entry.totalRefcount++;
|
|
360
|
-
return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
|
|
361
|
-
}
|
|
362
|
-
let valueKey;
|
|
363
|
-
if (aval.isConstant) {
|
|
364
|
-
valueKey = `${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
|
|
365
|
-
const shared = this.byValueKey.get(valueKey);
|
|
366
|
-
if (shared !== undefined) {
|
|
367
|
-
shared.totalRefcount++;
|
|
368
|
-
this.byAval.set(aval, { entry: shared, perAvalCount: 1 });
|
|
369
|
-
return { firstIndex: shared.firstIndex, count: shared.count };
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
const firstIndex = indices.alloc(arr.length);
|
|
373
|
-
indices.write(firstIndex * 4, new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
|
|
374
|
-
void device;
|
|
375
|
-
const entry = { firstIndex, count: arr.length, totalRefcount: 1, valueKey };
|
|
376
|
-
this.byAval.set(aval, { entry, perAvalCount: 1 });
|
|
377
|
-
if (valueKey !== undefined)
|
|
378
|
-
this.byValueKey.set(valueKey, entry);
|
|
379
|
-
return { firstIndex, count: arr.length };
|
|
380
|
-
}
|
|
381
|
-
release(indices, aval) {
|
|
382
|
-
const bound = this.byAval.get(aval);
|
|
383
|
-
if (bound === undefined)
|
|
384
|
-
return;
|
|
385
|
-
bound.perAvalCount--;
|
|
386
|
-
bound.entry.totalRefcount--;
|
|
387
|
-
if (bound.perAvalCount === 0)
|
|
388
|
-
this.byAval.delete(aval);
|
|
389
|
-
if (bound.entry.totalRefcount > 0)
|
|
390
|
-
return;
|
|
391
|
-
indices.release(bound.entry.firstIndex, bound.entry.count);
|
|
392
|
-
if (bound.entry.valueKey !== undefined) {
|
|
393
|
-
this.byValueKey.delete(bound.entry.valueKey);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
// ---------------------------------------------------------------------------
|
|
398
|
-
// DrawHeap (slot-indexed) and AttributeArena (byte-bump) allocators
|
|
399
|
-
// ---------------------------------------------------------------------------
|
|
400
|
-
/**
|
|
401
|
-
* Slot-indexed allocator over a GrowBuffer. `slotBytes` is set per-
|
|
402
|
-
* instance — each bucket sizes its DrawHeader from its effect's
|
|
403
|
-
* schema, so a bucket whose layout is e.g. 96 B / slot uses a
|
|
404
|
-
* DrawHeap with `slotBytes=96`.
|
|
405
|
-
*/
|
|
406
|
-
class DrawHeap {
|
|
407
|
-
buf;
|
|
408
|
-
slotBytes;
|
|
409
|
-
free = [];
|
|
410
|
-
nextSlot = 0;
|
|
411
|
-
constructor(buf, slotBytes) {
|
|
412
|
-
this.buf = buf;
|
|
413
|
-
this.slotBytes = slotBytes;
|
|
414
|
-
}
|
|
415
|
-
get buffer() { return this.buf.buffer; }
|
|
416
|
-
/** Bytes per slot — caller multiplies by slot index for byte offsets. */
|
|
417
|
-
get bytesPerSlot() { return this.slotBytes; }
|
|
418
|
-
/** High-water mark in bytes (used to size bind-group entry on rebuild). */
|
|
419
|
-
get usedBytes() { return this.nextSlot * this.slotBytes; }
|
|
420
|
-
alloc() {
|
|
421
|
-
const slot = this.free.length > 0 ? this.free.pop() : this.nextSlot++;
|
|
422
|
-
this.buf.ensureCapacity((slot + 1) * this.slotBytes);
|
|
423
|
-
this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
|
|
424
|
-
return slot;
|
|
425
|
-
}
|
|
426
|
-
release(slot) { this.free.push(slot); }
|
|
427
|
-
onResize(cb) { return this.buf.onResize(cb); }
|
|
428
|
-
destroy() { this.buf.destroy(); }
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* Byte-bump allocator over a GrowBuffer for variable-size attribute
|
|
432
|
-
* allocations. Each allocation gets a 16-byte aligned start (8-byte
|
|
433
|
-
* (typeId, length) header at the start, data 16 bytes in). Frees go
|
|
434
|
-
* onto a list keyed by size for simple first-fit reuse later — for
|
|
435
|
-
* now `release` just records the gap and the bump cursor never
|
|
436
|
-
* shrinks.
|
|
437
|
-
*/
|
|
438
|
-
class AttributeArena {
|
|
439
|
-
buf;
|
|
440
|
-
cursor = 0;
|
|
441
|
-
// (offset, size) free entries; first-fit reuse not yet implemented.
|
|
442
|
-
freeList = [];
|
|
443
|
-
// CPU shadow of the entire GPU buffer. Writes go here first; a
|
|
444
|
-
// single `device.queue.writeBuffer` per dirty contiguous range
|
|
445
|
-
// lifts them to the GPU at flush time. At the cost of doubling
|
|
446
|
-
// host memory we collapse N small writeBuffer calls (10K+ at
|
|
447
|
-
// initial population) to 1 per frame.
|
|
448
|
-
shadow;
|
|
449
|
-
dirtyMin = Infinity;
|
|
450
|
-
dirtyMax = 0;
|
|
451
|
-
constructor(buf) {
|
|
452
|
-
this.buf = buf;
|
|
453
|
-
this.shadow = new Uint8Array(buf.capacity);
|
|
454
|
-
buf.onResize(() => {
|
|
455
|
-
const grown = new Uint8Array(buf.capacity);
|
|
456
|
-
grown.set(this.shadow);
|
|
457
|
-
this.shadow = grown;
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
get buffer() { return this.buf.buffer; }
|
|
461
|
-
get capacity() { return this.buf.capacity; }
|
|
462
|
-
get usedBytes() { return this.cursor; }
|
|
463
|
-
/**
|
|
464
|
-
* Stage `data` to the shadow at byte offset `dst`. Tracks the
|
|
465
|
-
* dirty range so `flush(device)` can emit a single writeBuffer
|
|
466
|
-
* covering everything dirty since the last flush.
|
|
467
|
-
*/
|
|
468
|
-
write(dst, data) {
|
|
469
|
-
this.shadow.set(data, dst);
|
|
470
|
-
if (dst < this.dirtyMin)
|
|
471
|
-
this.dirtyMin = dst;
|
|
472
|
-
const end = dst + data.byteLength;
|
|
473
|
-
if (end > this.dirtyMax)
|
|
474
|
-
this.dirtyMax = end;
|
|
475
|
-
}
|
|
476
|
-
flush(device) {
|
|
477
|
-
if (this.dirtyMax <= this.dirtyMin)
|
|
478
|
-
return;
|
|
479
|
-
device.queue.writeBuffer(this.buf.buffer, this.dirtyMin, this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin, this.dirtyMax - this.dirtyMin);
|
|
480
|
-
this.dirtyMin = Infinity;
|
|
481
|
-
this.dirtyMax = 0;
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* Allocate space for one attribute. Returns the byte ref (offset
|
|
485
|
-
* to the header — data lives at ref + 16).
|
|
486
|
-
*/
|
|
487
|
-
alloc(dataBytes) {
|
|
488
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
489
|
-
// First-fit reuse from free list.
|
|
490
|
-
for (let i = 0; i < this.freeList.length; i++) {
|
|
491
|
-
const f = this.freeList[i];
|
|
492
|
-
if (f.size >= allocBytes) {
|
|
493
|
-
const ref = f.off;
|
|
494
|
-
if (f.size === allocBytes)
|
|
495
|
-
this.freeList.splice(i, 1);
|
|
496
|
-
else {
|
|
497
|
-
f.off += allocBytes;
|
|
498
|
-
f.size -= allocBytes;
|
|
499
|
-
}
|
|
500
|
-
return ref;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
const ref = this.cursor;
|
|
504
|
-
this.cursor += allocBytes;
|
|
505
|
-
this.buf.ensureCapacity(this.cursor);
|
|
506
|
-
this.buf.setUsed(this.cursor);
|
|
507
|
-
return ref;
|
|
508
|
-
}
|
|
509
|
-
release(ref, dataBytes) {
|
|
510
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
511
|
-
insertSortedFreeBlock(this.freeList, ref, allocBytes);
|
|
512
|
-
}
|
|
513
|
-
onResize(cb) { return this.buf.onResize(cb); }
|
|
514
|
-
destroy() { this.buf.destroy(); }
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Insert `{off, size}` into a free-list kept sorted by `off`, then
|
|
518
|
-
* coalesce with the two immediate neighbours.
|
|
519
|
-
*
|
|
520
|
-
* The list invariant — sorted, non-overlapping, never-adjacent — is
|
|
521
|
-
* preserved across allocs (which take from the front or split a
|
|
522
|
-
* block) and releases (this function). The previous implementation
|
|
523
|
-
* did `push + Array.prototype.sort + linear coalesce scan`, which is
|
|
524
|
-
* O(N log N) per release. Under a 500-RO bulk-remove the sort
|
|
525
|
-
* dominated `removeDraw` (~41 ms of self-time in the heap-demo-sg
|
|
526
|
-
* toggle profile). Binary-search insert + 2-neighbour merge collapses
|
|
527
|
-
* that to O(log N + N-shift), and is principled — the sort never
|
|
528
|
-
* actually mattered since we already had the sorted prefix as an
|
|
529
|
-
* invariant.
|
|
530
|
-
*/
|
|
531
|
-
function insertSortedFreeBlock(freeList, off, size) {
|
|
532
|
-
// Binary-search for the insertion index (first entry whose off > new).
|
|
533
|
-
let lo = 0, hi = freeList.length;
|
|
534
|
-
while (lo < hi) {
|
|
535
|
-
const mid = (lo + hi) >>> 1;
|
|
536
|
-
if (freeList[mid].off <= off)
|
|
537
|
-
lo = mid + 1;
|
|
538
|
-
else
|
|
539
|
-
hi = mid;
|
|
540
|
-
}
|
|
541
|
-
// lo is the index where the new entry would be inserted.
|
|
542
|
-
// Try merging with the predecessor first; if successful, the merged
|
|
543
|
-
// block may now be adjacent to its (former) successor too.
|
|
544
|
-
const prev = lo > 0 ? freeList[lo - 1] : undefined;
|
|
545
|
-
if (prev !== undefined && prev.off + prev.size === off) {
|
|
546
|
-
prev.size += size;
|
|
547
|
-
// Check forward-merge with what was freeList[lo].
|
|
548
|
-
const next = freeList[lo];
|
|
549
|
-
if (next !== undefined && prev.off + prev.size === next.off) {
|
|
550
|
-
prev.size += next.size;
|
|
551
|
-
freeList.splice(lo, 1);
|
|
552
|
-
}
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
const next = freeList[lo];
|
|
556
|
-
if (next !== undefined && off + size === next.off) {
|
|
557
|
-
next.off = off;
|
|
558
|
-
next.size += size;
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
freeList.splice(lo, 0, { off, size });
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* Element-bump allocator over an index GrowBuffer (units = u32). Each
|
|
565
|
-
* draw's index range is allocated as one block; on release the block
|
|
566
|
-
* is returned to a free list and can be reused first-fit.
|
|
567
|
-
*/
|
|
568
|
-
class IndexAllocator {
|
|
569
|
-
buf;
|
|
570
|
-
cursor = 0; // in u32s, not bytes
|
|
571
|
-
freeList = [];
|
|
572
|
-
// CPU shadow + dirty range, same shape as AttributeArena. Index
|
|
573
|
-
// uploads (one per drawn mesh's index buffer) get coalesced to a
|
|
574
|
-
// single writeBuffer per dirty range at flush time.
|
|
575
|
-
shadow;
|
|
576
|
-
dirtyMin = Infinity;
|
|
577
|
-
dirtyMax = 0;
|
|
578
|
-
constructor(buf) {
|
|
579
|
-
this.buf = buf;
|
|
580
|
-
this.shadow = new Uint8Array(buf.capacity);
|
|
581
|
-
buf.onResize(() => {
|
|
582
|
-
const grown = new Uint8Array(buf.capacity);
|
|
583
|
-
grown.set(this.shadow);
|
|
584
|
-
this.shadow = grown;
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
get buffer() { return this.buf.buffer; }
|
|
588
|
-
get usedElements() { return this.cursor; }
|
|
589
|
-
/** Stage `data` (bytes) at the given byte offset; tracks dirty range. */
|
|
590
|
-
write(dstByteOffset, data) {
|
|
591
|
-
this.shadow.set(data, dstByteOffset);
|
|
592
|
-
if (dstByteOffset < this.dirtyMin)
|
|
593
|
-
this.dirtyMin = dstByteOffset;
|
|
594
|
-
const end = dstByteOffset + data.byteLength;
|
|
595
|
-
if (end > this.dirtyMax)
|
|
596
|
-
this.dirtyMax = end;
|
|
597
|
-
}
|
|
598
|
-
flush(device) {
|
|
599
|
-
if (this.dirtyMax <= this.dirtyMin)
|
|
600
|
-
return;
|
|
601
|
-
device.queue.writeBuffer(this.buf.buffer, this.dirtyMin, this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin, this.dirtyMax - this.dirtyMin);
|
|
602
|
-
this.dirtyMin = Infinity;
|
|
603
|
-
this.dirtyMax = 0;
|
|
604
|
-
}
|
|
605
|
-
alloc(elements) {
|
|
606
|
-
for (let i = 0; i < this.freeList.length; i++) {
|
|
607
|
-
const f = this.freeList[i];
|
|
608
|
-
if (f.size >= elements) {
|
|
609
|
-
const off = f.off;
|
|
610
|
-
if (f.size === elements)
|
|
611
|
-
this.freeList.splice(i, 1);
|
|
612
|
-
else {
|
|
613
|
-
f.off += elements;
|
|
614
|
-
f.size -= elements;
|
|
615
|
-
}
|
|
616
|
-
return off;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
const off = this.cursor;
|
|
620
|
-
this.cursor += elements;
|
|
621
|
-
this.buf.ensureCapacity(this.cursor * 4);
|
|
622
|
-
this.buf.setUsed(this.cursor * 4);
|
|
623
|
-
return off;
|
|
624
|
-
}
|
|
625
|
-
release(off, elements) {
|
|
626
|
-
insertSortedFreeBlock(this.freeList, off, elements);
|
|
627
|
-
}
|
|
628
|
-
onResize(cb) { return this.buf.onResize(cb); }
|
|
629
|
-
destroy() { this.buf.destroy(); }
|
|
630
|
-
}
|
|
631
|
-
function buildArenaState(device, attrBytesHint, idxBytesHint, label, idxExtraUsage = 0) {
|
|
632
|
-
const attrs = new AttributeArena(new GrowBuffer(device, `${label}/attrs`, GPUBufferUsage.STORAGE, attrBytesHint));
|
|
633
|
-
const indices = new IndexAllocator(new GrowBuffer(device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage, idxBytesHint));
|
|
634
|
-
return { attrs, indices };
|
|
635
|
-
}
|
|
636
|
-
function arenaBytes(arena) {
|
|
637
|
-
return arena.attrs.usedBytes + arena.indices.usedElements * 4;
|
|
638
|
-
}
|
|
639
|
-
/** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */
|
|
640
|
-
function writeAttribute(device, buf, ref, typeId, length, data) {
|
|
641
|
-
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + data.byteLength);
|
|
642
|
-
const staging = new ArrayBuffer(allocBytes);
|
|
643
|
-
const u32 = new Uint32Array(staging);
|
|
644
|
-
const f32 = new Float32Array(staging);
|
|
645
|
-
u32[0] = typeId;
|
|
646
|
-
u32[1] = length;
|
|
647
|
-
f32.set(data, ALLOC_HEADER_PAD_TO / 4);
|
|
648
|
-
device.queue.writeBuffer(buf, ref, staging, 0, allocBytes);
|
|
649
|
-
}
|
|
650
|
-
function asAval(v) {
|
|
651
|
-
return (typeof v === "object" && v !== null && typeof v.getValue === "function")
|
|
652
|
-
? v
|
|
653
|
-
: AVal.constant(v);
|
|
654
|
-
}
|
|
655
|
-
/** Heuristic predicate — BufferView has `buffer: aval<IBuffer>` + elementType. */
|
|
656
|
-
function isBufferView(v) {
|
|
657
|
-
if (typeof v !== "object" || v === null)
|
|
658
|
-
return false;
|
|
659
|
-
const o = v;
|
|
660
|
-
return typeof o.buffer === "object" && o.buffer !== null
|
|
661
|
-
&& typeof o.buffer.getValue === "function"
|
|
662
|
-
&& typeof o.elementType === "object" && o.elementType !== null;
|
|
663
|
-
}
|
|
664
|
-
/**
|
|
665
|
-
* Float32 view over a host-side buffer source. Used by the BufferView
|
|
666
|
-
* packer to hand the pool a typed array it can `set()` from.
|
|
667
|
-
*/
|
|
668
|
-
function asFloat32(data) {
|
|
669
|
-
if (data instanceof Float32Array)
|
|
670
|
-
return data;
|
|
671
|
-
if (ArrayBuffer.isView(data)) {
|
|
672
|
-
return new Float32Array(data.buffer, data.byteOffset, data.byteLength / 4);
|
|
673
|
-
}
|
|
674
|
-
return new Float32Array(data); // ArrayBuffer
|
|
675
|
-
}
|
|
676
|
-
// ---------------------------------------------------------------------------
|
|
677
|
-
// Megacall GPU prefix-sum compute shader
|
|
678
|
-
// ---------------------------------------------------------------------------
|
|
679
|
-
const SCAN_TILE_SIZE = 512;
|
|
680
|
-
const SCAN_WG_SIZE = 256;
|
|
681
|
-
const SCAN_MAX_RECORDS = SCAN_TILE_SIZE * SCAN_TILE_SIZE; // numBlocks ≤ TILE_SIZE
|
|
682
|
-
const TILE_K = 64;
|
|
683
|
-
/** drawTable record width: (firstEmit, drawIdx, indexStart, indexCount, instanceCount). */
|
|
684
|
-
const RECORD_U32 = 5;
|
|
685
|
-
const RECORD_BYTES = RECORD_U32 * 4;
|
|
686
|
-
const HEAP_SCAN_WGSL = `
|
|
687
|
-
struct Params {
|
|
688
|
-
numRecords: u32,
|
|
689
|
-
numBlocks: u32,
|
|
690
|
-
_pad0: u32,
|
|
691
|
-
_pad1: u32,
|
|
692
|
-
};
|
|
693
|
-
|
|
694
|
-
struct Record {
|
|
695
|
-
firstEmit: u32,
|
|
696
|
-
drawIdx: u32,
|
|
697
|
-
indexStart: u32,
|
|
698
|
-
indexCount: u32,
|
|
699
|
-
instanceCount: u32,
|
|
700
|
-
};
|
|
701
|
-
|
|
702
|
-
@group(0) @binding(0) var<storage, read_write> drawTable: array<Record>;
|
|
703
|
-
@group(0) @binding(1) var<storage, read_write> blockSums: array<u32>;
|
|
704
|
-
@group(0) @binding(2) var<storage, read_write> blockOffsets: array<u32>;
|
|
705
|
-
@group(0) @binding(3) var<storage, read_write> indirect: array<u32>;
|
|
706
|
-
@group(0) @binding(4) var<uniform> params: Params;
|
|
707
|
-
@group(0) @binding(5) var<storage, read_write> firstDrawInTile: array<u32>;
|
|
708
|
-
|
|
709
|
-
const TILE_SIZE: u32 = 512u;
|
|
710
|
-
const WG_SIZE: u32 = 256u;
|
|
711
|
-
const TILE_K: u32 = 64u;
|
|
712
|
-
|
|
713
|
-
var<workgroup> sdata: array<u32, 512>;
|
|
714
|
-
|
|
715
|
-
fn blellochScan(tid: u32) {
|
|
716
|
-
var offset: u32 = 1u;
|
|
717
|
-
for (var d: u32 = TILE_SIZE >> 1u; d > 0u; d = d >> 1u) {
|
|
718
|
-
workgroupBarrier();
|
|
719
|
-
if (tid < d) {
|
|
720
|
-
let ai = offset * (2u * tid + 1u) - 1u;
|
|
721
|
-
let bi = offset * (2u * tid + 2u) - 1u;
|
|
722
|
-
sdata[bi] = sdata[bi] + sdata[ai];
|
|
723
|
-
}
|
|
724
|
-
offset = offset * 2u;
|
|
725
|
-
}
|
|
726
|
-
if (tid == 0u) { sdata[TILE_SIZE - 1u] = 0u; }
|
|
727
|
-
for (var d: u32 = 1u; d < TILE_SIZE; d = d * 2u) {
|
|
728
|
-
offset = offset >> 1u;
|
|
729
|
-
workgroupBarrier();
|
|
730
|
-
if (tid < d) {
|
|
731
|
-
let ai = offset * (2u * tid + 1u) - 1u;
|
|
732
|
-
let bi = offset * (2u * tid + 2u) - 1u;
|
|
733
|
-
let t = sdata[ai];
|
|
734
|
-
sdata[ai] = sdata[bi];
|
|
735
|
-
sdata[bi] = sdata[bi] + t;
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
workgroupBarrier();
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
@compute @workgroup_size(WG_SIZE)
|
|
742
|
-
fn scanTile(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
|
|
743
|
-
let tid = lid.x;
|
|
744
|
-
let blockOff = wgid.x * TILE_SIZE;
|
|
745
|
-
let n = params.numRecords;
|
|
746
|
-
let i0 = blockOff + tid;
|
|
747
|
-
let i1 = blockOff + tid + WG_SIZE;
|
|
748
|
-
var v0: u32 = 0u;
|
|
749
|
-
var v1: u32 = 0u;
|
|
750
|
-
if (i0 < n) { v0 = drawTable[i0].indexCount * drawTable[i0].instanceCount; }
|
|
751
|
-
if (i1 < n) { v1 = drawTable[i1].indexCount * drawTable[i1].instanceCount; }
|
|
752
|
-
sdata[tid] = v0;
|
|
753
|
-
sdata[tid + WG_SIZE] = v1;
|
|
754
|
-
workgroupBarrier();
|
|
755
|
-
blellochScan(tid);
|
|
756
|
-
if (i0 < n) { drawTable[i0].firstEmit = sdata[tid]; }
|
|
757
|
-
if (i1 < n) { drawTable[i1].firstEmit = sdata[tid + WG_SIZE]; }
|
|
758
|
-
if (tid == WG_SIZE - 1u) {
|
|
759
|
-
blockSums[wgid.x] = sdata[tid + WG_SIZE] + v1;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
@compute @workgroup_size(WG_SIZE)
|
|
764
|
-
fn scanBlocks(@builtin(local_invocation_id) lid: vec3<u32>) {
|
|
765
|
-
let tid = lid.x;
|
|
766
|
-
let n = params.numBlocks;
|
|
767
|
-
let i0 = tid;
|
|
768
|
-
let i1 = tid + WG_SIZE;
|
|
769
|
-
var v0: u32 = 0u;
|
|
770
|
-
var v1: u32 = 0u;
|
|
771
|
-
if (i0 < n) { v0 = blockSums[i0]; }
|
|
772
|
-
if (i1 < n) { v1 = blockSums[i1]; }
|
|
773
|
-
sdata[tid] = v0;
|
|
774
|
-
sdata[tid + WG_SIZE] = v1;
|
|
775
|
-
workgroupBarrier();
|
|
776
|
-
blellochScan(tid);
|
|
777
|
-
if (i0 < n) { blockOffsets[i0] = sdata[tid]; }
|
|
778
|
-
if (i1 < n) { blockOffsets[i1] = sdata[tid + WG_SIZE]; }
|
|
779
|
-
workgroupBarrier();
|
|
780
|
-
if (tid == 0u) {
|
|
781
|
-
if (n > 0u) {
|
|
782
|
-
let lastIdx = n - 1u;
|
|
783
|
-
let total = blockOffsets[lastIdx] + blockSums[lastIdx];
|
|
784
|
-
indirect[0] = total;
|
|
785
|
-
} else {
|
|
786
|
-
indirect[0] = 0u;
|
|
787
|
-
}
|
|
788
|
-
indirect[1] = 1u;
|
|
789
|
-
indirect[2] = 0u;
|
|
790
|
-
indirect[3] = 0u;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
@compute @workgroup_size(WG_SIZE)
|
|
795
|
-
fn addOffsets(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
|
|
796
|
-
let tid = lid.x;
|
|
797
|
-
let blockOff = wgid.x * TILE_SIZE;
|
|
798
|
-
let n = params.numRecords;
|
|
799
|
-
let off = blockOffsets[wgid.x];
|
|
800
|
-
let i0 = blockOff + tid;
|
|
801
|
-
let i1 = blockOff + tid + WG_SIZE;
|
|
802
|
-
if (i0 < n) { drawTable[i0].firstEmit = drawTable[i0].firstEmit + off; }
|
|
803
|
-
if (i1 < n) { drawTable[i1].firstEmit = drawTable[i1].firstEmit + off; }
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
@compute @workgroup_size(WG_SIZE)
|
|
807
|
-
fn buildTileIndex(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
808
|
-
let tileIdx = gid.x;
|
|
809
|
-
// totalEmit is computed by scanBlocks into indirect[0]; reading it
|
|
810
|
-
// from indirect avoids a separate uniform/storage round-trip.
|
|
811
|
-
let totalEmit = indirect[0];
|
|
812
|
-
let numTiles = (totalEmit + TILE_K - 1u) / TILE_K;
|
|
813
|
-
if (tileIdx > numTiles) { return; }
|
|
814
|
-
if (params.numRecords == 0u) {
|
|
815
|
-
if (tileIdx == 0u) { firstDrawInTile[0] = 0u; }
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
if (tileIdx == numTiles) {
|
|
819
|
-
// Sentinel for the open upper bound — the LAST VALID SLOT, not
|
|
820
|
-
// numRecords. The render VS uses
|
|
821
|
-
// hi = firstDrawInTile[_tileIdx + 1u]
|
|
822
|
-
// and the binary search treats hi as INCLUSIVE. If the sentinel
|
|
823
|
-
// were numRecords (one past last), the search would drag lo into
|
|
824
|
-
// the OOB slot for emits in the last tile, since drawTable reads
|
|
825
|
-
// past recordCount return 0 (binding size clamping) and 0 ≤ emit
|
|
826
|
-
// is always true. Visible symptom: the LAST few emits in the
|
|
827
|
-
// bucket land on slot=numRecords (drawIdx=0, indexCount=0 → /-by-
|
|
828
|
-
// zero) → degenerate / cross-RO triangle stitched to slot 0.
|
|
829
|
-
firstDrawInTile[tileIdx] = params.numRecords - 1u;
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
let tileStart = tileIdx * TILE_K;
|
|
833
|
-
var lo: u32 = 0u;
|
|
834
|
-
var hi: u32 = params.numRecords - 1u;
|
|
835
|
-
loop {
|
|
836
|
-
if (lo >= hi) { break; }
|
|
837
|
-
let mid = (lo + hi + 1u) >> 1u;
|
|
838
|
-
if (drawTable[mid].firstEmit <= tileStart) { lo = mid; } else { hi = mid - 1u; }
|
|
839
|
-
}
|
|
840
|
-
firstDrawInTile[tileIdx] = lo;
|
|
841
|
-
}
|
|
842
|
-
`;
|
|
52
|
+
import { packerForWgslType, PACKER_MAT4 } from "./heapScene/packers.js";
|
|
53
|
+
import { HEAP_SCAN_WGSL, SCAN_TILE_SIZE, SCAN_WG_SIZE, SCAN_MAX_RECORDS, TILE_K, RECORD_U32, RECORD_BYTES, } from "./heapScene/scanKernel.js";
|
|
54
|
+
import { GrowBuffer, MIN_BUFFER_BYTES, POW2, ALIGN16, } from "./heapScene/growBuffer.js";
|
|
55
|
+
import { UniformPool, IndexPool, DrawHeap, AttributeArena, IndexAllocator, insertSortedFreeBlock, buildArenaState, arenaBytes, writeAttribute, asAval, isBufferView, asFloat32, ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO, ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS, } from "./heapScene/pools.js";
|
|
56
|
+
import { encodeModeKey } from "./pipelineCache/index.js";
|
|
57
|
+
import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
|
|
58
|
+
import { addMarkingCallback } from "@aardworx/wombat.adaptive";
|
|
843
59
|
export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
844
60
|
const atlasPool = opts.atlasPool;
|
|
845
61
|
const colorAttachmentName = sig.colorNames[0];
|
|
@@ -872,6 +88,55 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
872
88
|
const drawIdToLocalSlot = [];
|
|
873
89
|
/** Per-draw index aval — for `indexPool.release` on removeDraw. */
|
|
874
90
|
const drawIdToIndexAval = [];
|
|
91
|
+
/**
|
|
92
|
+
* Original spec retained per drawId so we can re-add the RO into a
|
|
93
|
+
* new bucket when its PipelineState modeKey changes (reactive
|
|
94
|
+
* cullmode flip, etc.). Cheap to retain — specs are mostly aval
|
|
95
|
+
* references.
|
|
96
|
+
*/
|
|
97
|
+
const drawIdToSpec = [];
|
|
98
|
+
/** Per-draw ModeKeyTracker — recomputes the modeKey on demand. Does
|
|
99
|
+
* NOT install per-instance marking callbacks; subscriptions are
|
|
100
|
+
* scene-level (deduped by aval identity below). */
|
|
101
|
+
const drawIdToModeTracker = [];
|
|
102
|
+
/** drawIds whose PS marked since last update; drained by `update`. */
|
|
103
|
+
const dirtyModeKeyDrawIds = new Set();
|
|
104
|
+
/**
|
|
105
|
+
* Scene-level mode-aval subscription dedupe. One IDisposable per
|
|
106
|
+
* unique leaf aval, plus a Set of dependent drawIds. When the aval
|
|
107
|
+
* marks, all drawIds in the set become dirty in O(1) per RO instead
|
|
108
|
+
* of installing N separate addMarkingCallbacks. The big win for the
|
|
109
|
+
* shared-cullCval case (20k ROs subscribing to one cval → 1 callback
|
|
110
|
+
* instead of 20k).
|
|
111
|
+
*/
|
|
112
|
+
const modeAvalToDrawIds = new Map();
|
|
113
|
+
const modeAvalCallbacks = new Map();
|
|
114
|
+
function subscribeModeLeaf(av, drawId) {
|
|
115
|
+
let set = modeAvalToDrawIds.get(av);
|
|
116
|
+
if (set === undefined) {
|
|
117
|
+
set = new Set();
|
|
118
|
+
modeAvalToDrawIds.set(av, set);
|
|
119
|
+
modeAvalCallbacks.set(av, addMarkingCallback(av, () => {
|
|
120
|
+
for (const id of set)
|
|
121
|
+
dirtyModeKeyDrawIds.add(id);
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
set.add(drawId);
|
|
125
|
+
}
|
|
126
|
+
function unsubscribeModeLeaf(av, drawId) {
|
|
127
|
+
const set = modeAvalToDrawIds.get(av);
|
|
128
|
+
if (set === undefined)
|
|
129
|
+
return;
|
|
130
|
+
set.delete(drawId);
|
|
131
|
+
if (set.size === 0) {
|
|
132
|
+
modeAvalToDrawIds.delete(av);
|
|
133
|
+
const cb = modeAvalCallbacks.get(av);
|
|
134
|
+
if (cb !== undefined) {
|
|
135
|
+
cb.dispose();
|
|
136
|
+
modeAvalCallbacks.delete(av);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
875
140
|
let nextDrawId = 0;
|
|
876
141
|
/**
|
|
877
142
|
* Unwrap an aval to its inner value, or pass through a plain value.
|
|
@@ -1246,12 +511,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1246
511
|
label: `heapScene/${bucket.label}/scanBg`,
|
|
1247
512
|
layout: scanBgl,
|
|
1248
513
|
entries: [
|
|
1249
|
-
{ binding: 0, resource: { buffer: bucket.drawTableBuf.buffer } },
|
|
1250
|
-
{ binding: 1, resource: { buffer: bucket.blockSumsBuf.buffer } },
|
|
1251
|
-
{ binding: 2, resource: { buffer: bucket.blockOffsetsBuf.buffer } },
|
|
1252
|
-
{ binding: 3, resource: { buffer: bucket.indirectBuf } },
|
|
1253
|
-
{ binding: 4, resource: { buffer: bucket.paramsBuf } },
|
|
1254
|
-
{ binding: 5, resource: { buffer: bucket.firstDrawInTileBuf.buffer } },
|
|
514
|
+
{ binding: 0, resource: { buffer: bucket.slots[0].drawTableBuf.buffer } },
|
|
515
|
+
{ binding: 1, resource: { buffer: bucket.slots[0].blockSumsBuf.buffer } },
|
|
516
|
+
{ binding: 2, resource: { buffer: bucket.slots[0].blockOffsetsBuf.buffer } },
|
|
517
|
+
{ binding: 3, resource: { buffer: bucket.slots[0].indirectBuf } },
|
|
518
|
+
{ binding: 4, resource: { buffer: bucket.slots[0].paramsBuf } },
|
|
519
|
+
{ binding: 5, resource: { buffer: bucket.slots[0].firstDrawInTileBuf.buffer } },
|
|
1255
520
|
],
|
|
1256
521
|
});
|
|
1257
522
|
}
|
|
@@ -1458,31 +723,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1458
723
|
avalIds.set(av, vid);
|
|
1459
724
|
return `c${vid}`;
|
|
1460
725
|
};
|
|
1461
|
-
const psContentIds = new WeakMap();
|
|
1462
|
-
const psIdOf = (ps) => {
|
|
1463
|
-
if (ps === undefined)
|
|
1464
|
-
return "ps#default";
|
|
1465
|
-
const cached = psContentIds.get(ps);
|
|
1466
|
-
if (cached !== undefined)
|
|
1467
|
-
return cached;
|
|
1468
|
-
const r = ps.rasterizer;
|
|
1469
|
-
const parts = [
|
|
1470
|
-
avalIdOf(r.topology),
|
|
1471
|
-
avalIdOf(r.cullMode),
|
|
1472
|
-
avalIdOf(r.frontFace),
|
|
1473
|
-
avalIdOf(r.depthBias),
|
|
1474
|
-
ps.depth !== undefined
|
|
1475
|
-
? `d:${avalIdOf(ps.depth.write)}:${avalIdOf(ps.depth.compare)}:${avalIdOf(ps.depth.clamp)}`
|
|
1476
|
-
: "d:_",
|
|
1477
|
-
ps.stencil !== undefined ? "s:1" : "s:_",
|
|
1478
|
-
avalIdOf(ps.blends),
|
|
1479
|
-
avalIdOf(ps.alphaToCoverage),
|
|
1480
|
-
avalIdOf(ps.blendConstant),
|
|
1481
|
-
];
|
|
1482
|
-
const key = `ps#${parts.join("|")}`;
|
|
1483
|
-
psContentIds.set(ps, key);
|
|
1484
|
-
return key;
|
|
1485
|
-
};
|
|
1486
726
|
function resolvePipelineState(ps) {
|
|
1487
727
|
if (ps === undefined) {
|
|
1488
728
|
return {
|
|
@@ -1521,7 +761,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1521
761
|
{ binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
|
|
1522
762
|
];
|
|
1523
763
|
{
|
|
1524
|
-
if (bucket.drawTableBuf === undefined) {
|
|
764
|
+
if (bucket.slots[0].drawTableBuf === undefined) {
|
|
1525
765
|
throw new Error("heapScene: megacall bucket without drawTableBuf");
|
|
1526
766
|
}
|
|
1527
767
|
// Bind drawTable with size = recordCount * RECORD_BYTES so the VS
|
|
@@ -1529,9 +769,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1529
769
|
// the live record count — keeps stale tail entries out of the
|
|
1530
770
|
// binary search. Minimum one zero-record to satisfy WebGPU non-
|
|
1531
771
|
// zero size constraint when the bucket is empty.
|
|
1532
|
-
const dtBytes = Math.max(RECORD_BYTES, bucket.recordCount * RECORD_BYTES);
|
|
1533
|
-
entries.push({ binding: 4, resource: { buffer: bucket.drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: bucket.firstDrawInTileBuf.buffer } });
|
|
1534
|
-
bucket.renderBoundRecordCount = bucket.recordCount;
|
|
772
|
+
const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0].recordCount * RECORD_BYTES);
|
|
773
|
+
entries.push({ binding: 4, resource: { buffer: bucket.slots[0].drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: bucket.slots[0].firstDrawInTileBuf.buffer } });
|
|
774
|
+
bucket.slots[0].renderBoundRecordCount = bucket.slots[0].recordCount;
|
|
1535
775
|
}
|
|
1536
776
|
// Schema-driven texture + sampler entries. v1 user surface still
|
|
1537
777
|
// accepts a single (texture, sampler) pair via HeapTextureSet —
|
|
@@ -1656,8 +896,17 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1656
896
|
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
1657
897
|
}
|
|
1658
898
|
const fam = familyFor(effect);
|
|
1659
|
-
|
|
1660
|
-
|
|
899
|
+
// ─── PS keying — VALUE based, not identity based ───────────────
|
|
900
|
+
// psIdOf used to hash by aval identity. That meant 20k ROs each
|
|
901
|
+
// constructed with `cval("back")` for cullMode hashed to 20k
|
|
902
|
+
// distinct keys → 20k buckets, all rendering with bitwise-
|
|
903
|
+
// identical state. Now the key is the bitfield-encoded modeKey
|
|
904
|
+
// (the actual value-set in the descriptor), so identical-value
|
|
905
|
+
// cvals collapse to ONE bucket. See docs/derived-modes.md and
|
|
906
|
+
// tests/heap-multi-pipeline-bucket.test.ts.
|
|
907
|
+
const psDescriptor = snapshotDescriptor(pipelineState, sig);
|
|
908
|
+
const psModeKey = encodeModeKey(psDescriptor);
|
|
909
|
+
const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
|
|
1661
910
|
const existing = bucketByKey.get(bk);
|
|
1662
911
|
if (existing !== undefined)
|
|
1663
912
|
return existing;
|
|
@@ -1686,13 +935,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1686
935
|
// refs).
|
|
1687
936
|
const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
|
|
1688
937
|
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
938
|
+
const slot0 = {
|
|
939
|
+
pipeline,
|
|
940
|
+
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
941
|
+
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
942
|
+
totalEmitEstimate: 0,
|
|
943
|
+
scanDirty: false,
|
|
944
|
+
};
|
|
1689
945
|
const bucket = {
|
|
1690
946
|
// §6 family-merge: family buckets aren't keyed on a specific
|
|
1691
947
|
// texture set — atlas placements are addressed per-RO via
|
|
1692
948
|
// drawHeader fields (`pageRef` + `formatBits` + `origin` +
|
|
1693
949
|
// `size`), and the bucket's atlas-binding ladder is driven by
|
|
1694
950
|
// `atlasPool.pagesFor(format)`. Leave `textures` undefined.
|
|
1695
|
-
label: bk, textures: undefined, layout,
|
|
951
|
+
label: bk, textures: undefined, layout,
|
|
952
|
+
slots: [slot0],
|
|
1696
953
|
bindGroup: null,
|
|
1697
954
|
drawHeap,
|
|
1698
955
|
drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
|
|
@@ -1701,10 +958,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1701
958
|
localEntries: [], localToDrawId: [],
|
|
1702
959
|
localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
|
|
1703
960
|
drawSlots: new Set(), dirty: new Set(),
|
|
1704
|
-
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
1705
|
-
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
1706
|
-
totalEmitEstimate: 0,
|
|
1707
|
-
scanDirty: false,
|
|
1708
961
|
isAtlasBucket,
|
|
1709
962
|
localAtlasReleases: [],
|
|
1710
963
|
localAtlasTextures: [],
|
|
@@ -1728,30 +981,30 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1728
981
|
label: `heapScene/${bk}/params`, size: 16,
|
|
1729
982
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1730
983
|
});
|
|
1731
|
-
bucket.drawTableBuf = dtBuf;
|
|
1732
|
-
bucket.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
|
|
1733
|
-
bucket.blockSumsBuf = blockSumsBuf;
|
|
1734
|
-
bucket.blockOffsetsBuf = blockOffsetsBuf;
|
|
1735
|
-
bucket.firstDrawInTileBuf = firstDrawInTileBuf;
|
|
1736
|
-
bucket.indirectBuf = indirectBuf;
|
|
1737
|
-
bucket.paramsBuf = paramsBuf;
|
|
984
|
+
bucket.slots[0].drawTableBuf = dtBuf;
|
|
985
|
+
bucket.slots[0].drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
|
|
986
|
+
bucket.slots[0].blockSumsBuf = blockSumsBuf;
|
|
987
|
+
bucket.slots[0].blockOffsetsBuf = blockOffsetsBuf;
|
|
988
|
+
bucket.slots[0].firstDrawInTileBuf = firstDrawInTileBuf;
|
|
989
|
+
bucket.slots[0].indirectBuf = indirectBuf;
|
|
990
|
+
bucket.slots[0].paramsBuf = paramsBuf;
|
|
1738
991
|
const ensureScanBuffers = () => {
|
|
1739
|
-
const needBlocks = Math.max(1, Math.ceil(bucket.recordCount / SCAN_TILE_SIZE));
|
|
992
|
+
const needBlocks = Math.max(1, Math.ceil(bucket.slots[0].recordCount / SCAN_TILE_SIZE));
|
|
1740
993
|
blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1741
994
|
blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1742
995
|
};
|
|
1743
996
|
const rebuildScanBg = () => {
|
|
1744
|
-
bucket.scanBindGroup = buildScanBindGroup(bucket);
|
|
997
|
+
bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
|
|
1745
998
|
};
|
|
1746
999
|
dtBuf.onResize(() => {
|
|
1747
1000
|
const grown = new Uint32Array(dtBuf.capacity / 4);
|
|
1748
|
-
grown.set(bucket.drawTableShadow);
|
|
1749
|
-
bucket.drawTableShadow = grown;
|
|
1001
|
+
grown.set(bucket.slots[0].drawTableShadow);
|
|
1002
|
+
bucket.slots[0].drawTableShadow = grown;
|
|
1750
1003
|
ensureScanBuffers();
|
|
1751
1004
|
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1752
1005
|
rebuildScanBg();
|
|
1753
|
-
bucket.drawTableDirtyMin = 0;
|
|
1754
|
-
bucket.drawTableDirtyMax = bucket.recordCount * RECORD_BYTES;
|
|
1006
|
+
bucket.slots[0].drawTableDirtyMin = 0;
|
|
1007
|
+
bucket.slots[0].drawTableDirtyMax = bucket.slots[0].recordCount * RECORD_BYTES;
|
|
1755
1008
|
});
|
|
1756
1009
|
blockSumsBuf.onResize(rebuildScanBg);
|
|
1757
1010
|
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
@@ -1759,7 +1012,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1759
1012
|
rebuildScanBg();
|
|
1760
1013
|
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1761
1014
|
});
|
|
1762
|
-
bucket.scanBindGroup = buildScanBindGroup(bucket);
|
|
1015
|
+
bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
|
|
1763
1016
|
}
|
|
1764
1017
|
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1765
1018
|
drawHeap.onResize(() => {
|
|
@@ -2059,8 +1312,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2059
1312
|
if (end > bucket.headerDirtyMax)
|
|
2060
1313
|
bucket.headerDirtyMax = end;
|
|
2061
1314
|
{
|
|
2062
|
-
const dtBuf = bucket.drawTableBuf;
|
|
2063
|
-
const recIdx = bucket.recordCount;
|
|
1315
|
+
const dtBuf = bucket.slots[0].drawTableBuf;
|
|
1316
|
+
const recIdx = bucket.slots[0].recordCount;
|
|
2064
1317
|
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
2065
1318
|
throw new Error(`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
|
|
2066
1319
|
`extend the scan to multi-level if you need more`);
|
|
@@ -2070,30 +1323,43 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2070
1323
|
dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
|
|
2071
1324
|
// Grow scan-side buffers if recordCount crosses a tile boundary.
|
|
2072
1325
|
const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
|
|
2073
|
-
bucket.blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
2074
|
-
bucket.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
2075
|
-
const shadow = bucket.drawTableShadow;
|
|
1326
|
+
bucket.slots[0].blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1327
|
+
bucket.slots[0].blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1328
|
+
const shadow = bucket.slots[0].drawTableShadow;
|
|
2076
1329
|
// firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
|
|
2077
1330
|
shadow[recIdx * RECORD_U32 + 0] = 0;
|
|
2078
1331
|
shadow[recIdx * RECORD_U32 + 1] = localSlot;
|
|
2079
1332
|
shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
|
|
2080
1333
|
shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
|
|
2081
1334
|
shadow[recIdx * RECORD_U32 + 4] = instanceCount;
|
|
2082
|
-
bucket.recordCount = recIdx + 1;
|
|
2083
|
-
bucket.slotToRecord[localSlot] = recIdx;
|
|
2084
|
-
bucket.recordToSlot[recIdx] = localSlot;
|
|
2085
|
-
if (byteOff < bucket.drawTableDirtyMin)
|
|
2086
|
-
bucket.drawTableDirtyMin = byteOff;
|
|
2087
|
-
if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax)
|
|
2088
|
-
bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2089
|
-
bucket.totalEmitEstimate += idxAlloc.count * instanceCount;
|
|
2090
|
-
const newNumTiles = Math.max(1, Math.ceil(bucket.totalEmitEstimate / TILE_K));
|
|
2091
|
-
bucket.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
|
|
2092
|
-
bucket.scanDirty = true;
|
|
1335
|
+
bucket.slots[0].recordCount = recIdx + 1;
|
|
1336
|
+
bucket.slots[0].slotToRecord[localSlot] = recIdx;
|
|
1337
|
+
bucket.slots[0].recordToSlot[recIdx] = localSlot;
|
|
1338
|
+
if (byteOff < bucket.slots[0].drawTableDirtyMin)
|
|
1339
|
+
bucket.slots[0].drawTableDirtyMin = byteOff;
|
|
1340
|
+
if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
|
|
1341
|
+
bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1342
|
+
bucket.slots[0].totalEmitEstimate += idxAlloc.count * instanceCount;
|
|
1343
|
+
const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0].totalEmitEstimate / TILE_K));
|
|
1344
|
+
bucket.slots[0].firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
|
|
1345
|
+
bucket.slots[0].scanDirty = true;
|
|
2093
1346
|
}
|
|
2094
1347
|
drawIdToBucket[drawId] = bucket;
|
|
2095
1348
|
drawIdToLocalSlot[drawId] = localSlot;
|
|
2096
1349
|
drawIdToIndexAval[drawId] = indicesAval;
|
|
1350
|
+
// ─── Reactive rebucket: track PS-modeKey changes ────────────────
|
|
1351
|
+
// When any mode-axis aval marks (e.g. cullCval.value = 'front'),
|
|
1352
|
+
// schedule this RO for rebucket on the next update(). Today's
|
|
1353
|
+
// bucket key is the modeKey VALUE (Phase 5b), so changing the
|
|
1354
|
+
// value must move the RO to a different bucket. Without this
|
|
1355
|
+
// tracker the cval flip would be silently ignored.
|
|
1356
|
+
drawIdToSpec[drawId] = spec;
|
|
1357
|
+
// skipSubscribe: this tracker doesn't install its own callbacks.
|
|
1358
|
+
// The scene-level `modeAvalToDrawIds` does it once per unique aval
|
|
1359
|
+
// (the 20k-ROs-share-one-cullCval optimization).
|
|
1360
|
+
const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => { dirtyModeKeyDrawIds.add(drawId); }, { skipSubscribe: true });
|
|
1361
|
+
drawIdToModeTracker[drawId] = tracker;
|
|
1362
|
+
tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
|
|
2097
1363
|
// ─── §7 derived-uniforms registration ────────────────────────────
|
|
2098
1364
|
// A uniform binding on this RO is either a value (aval/constant) or a rule —
|
|
2099
1365
|
// collect the rules (explicit `derivedUniform(...)` values + standard trafo
|
|
@@ -2173,13 +1439,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2173
1439
|
const removedCount = removedEntry !== undefined
|
|
2174
1440
|
? removedEntry.indexCount * removedEntry.instanceCount
|
|
2175
1441
|
: 0;
|
|
2176
|
-
bucket.totalEmitEstimate = Math.max(0, bucket.totalEmitEstimate - removedCount);
|
|
1442
|
+
bucket.slots[0].totalEmitEstimate = Math.max(0, bucket.slots[0].totalEmitEstimate - removedCount);
|
|
2177
1443
|
// Swap-pop: move the last record into the freed slot, decrement
|
|
2178
1444
|
// recordCount. firstEmit is GPU-rewritten by the next scan, so
|
|
2179
1445
|
// we only fix (drawIdx, indexStart, indexCount, instanceCount).
|
|
2180
|
-
const recIdx = bucket.slotToRecord[localSlot];
|
|
2181
|
-
const lastRecIdx = bucket.recordCount - 1;
|
|
2182
|
-
const shadow = bucket.drawTableShadow;
|
|
1446
|
+
const recIdx = bucket.slots[0].slotToRecord[localSlot];
|
|
1447
|
+
const lastRecIdx = bucket.slots[0].recordCount - 1;
|
|
1448
|
+
const shadow = bucket.slots[0].drawTableShadow;
|
|
2183
1449
|
if (recIdx !== lastRecIdx) {
|
|
2184
1450
|
const dst = recIdx * RECORD_U32;
|
|
2185
1451
|
const src = lastRecIdx * RECORD_U32;
|
|
@@ -2188,19 +1454,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2188
1454
|
shadow[dst + 2] = shadow[src + 2];
|
|
2189
1455
|
shadow[dst + 3] = shadow[src + 3];
|
|
2190
1456
|
shadow[dst + 4] = shadow[src + 4];
|
|
2191
|
-
const movedSlot = bucket.recordToSlot[lastRecIdx];
|
|
2192
|
-
bucket.slotToRecord[movedSlot] = recIdx;
|
|
2193
|
-
bucket.recordToSlot[recIdx] = movedSlot;
|
|
1457
|
+
const movedSlot = bucket.slots[0].recordToSlot[lastRecIdx];
|
|
1458
|
+
bucket.slots[0].slotToRecord[movedSlot] = recIdx;
|
|
1459
|
+
bucket.slots[0].recordToSlot[recIdx] = movedSlot;
|
|
2194
1460
|
const byteOff = recIdx * RECORD_BYTES;
|
|
2195
|
-
if (byteOff < bucket.drawTableDirtyMin)
|
|
2196
|
-
bucket.drawTableDirtyMin = byteOff;
|
|
2197
|
-
if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax)
|
|
2198
|
-
bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1461
|
+
if (byteOff < bucket.slots[0].drawTableDirtyMin)
|
|
1462
|
+
bucket.slots[0].drawTableDirtyMin = byteOff;
|
|
1463
|
+
if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
|
|
1464
|
+
bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2199
1465
|
}
|
|
2200
|
-
bucket.slotToRecord[localSlot] = -1;
|
|
2201
|
-
bucket.recordToSlot[lastRecIdx] = -1;
|
|
2202
|
-
bucket.recordCount = lastRecIdx;
|
|
2203
|
-
bucket.scanDirty = true;
|
|
1466
|
+
bucket.slots[0].slotToRecord[localSlot] = -1;
|
|
1467
|
+
bucket.slots[0].recordToSlot[lastRecIdx] = -1;
|
|
1468
|
+
bucket.slots[0].recordCount = lastRecIdx;
|
|
1469
|
+
bucket.slots[0].scanDirty = true;
|
|
2204
1470
|
}
|
|
2205
1471
|
// Release pool entries — refcount drops; if zero, allocation freed.
|
|
2206
1472
|
const avals = bucket.localPerDrawAvals[localSlot];
|
|
@@ -2254,8 +1520,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2254
1520
|
drawIdToBucket[drawId] = undefined;
|
|
2255
1521
|
drawIdToLocalSlot[drawId] = undefined;
|
|
2256
1522
|
drawIdToIndexAval[drawId] = undefined;
|
|
1523
|
+
drawIdToSpec[drawId] = undefined;
|
|
1524
|
+
const oldTracker = drawIdToModeTracker[drawId];
|
|
1525
|
+
if (oldTracker !== undefined) {
|
|
1526
|
+
oldTracker.forEachLeaf((av) => unsubscribeModeLeaf(av, drawId));
|
|
1527
|
+
oldTracker.dispose();
|
|
1528
|
+
}
|
|
1529
|
+
drawIdToModeTracker[drawId] = undefined;
|
|
1530
|
+
dirtyModeKeyDrawIds.delete(drawId);
|
|
2257
1531
|
stats.totalDraws--;
|
|
2258
1532
|
stats.geometryBytes = arenaBytes(arena);
|
|
1533
|
+
// GC the bucket if it just emptied (common after reactive
|
|
1534
|
+
// PS rebucket leaves the source bucket with zero live ROs).
|
|
1535
|
+
gcBucket(bucket);
|
|
2259
1536
|
}
|
|
2260
1537
|
// ─── Aset reader (pull-driven on each frame) ──────────────────────
|
|
2261
1538
|
let asetReader;
|
|
@@ -2345,6 +1622,114 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2345
1622
|
let totalDirtyBytes = 0;
|
|
2346
1623
|
sceneObj.evaluateAlways(token, (tok) => {
|
|
2347
1624
|
drainAsetWith(tok);
|
|
1625
|
+
// 0. Reactive rebucket: any RO whose PipelineState modeKey
|
|
1626
|
+
// changed since last frame moves to its new bucket.
|
|
1627
|
+
// `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
|
|
1628
|
+
// onDirty callbacks (one per leaf aval per RO). Calling
|
|
1629
|
+
// `recompute()` here re-snapshots the descriptor and tells
|
|
1630
|
+
// us whether the modeKey actually changed (vs the aval just
|
|
1631
|
+
// being marked-but-equal). If the key changed, we remove +
|
|
1632
|
+
// re-add the RO via the retained spec; addDraw routes it to
|
|
1633
|
+
// the correct (new) bucket via the value-keyed lookup.
|
|
1634
|
+
if (dirtyModeKeyDrawIds.size > 0) {
|
|
1635
|
+
const dirty = [...dirtyModeKeyDrawIds];
|
|
1636
|
+
dirtyModeKeyDrawIds.clear();
|
|
1637
|
+
// Bucket-level fast path: group dirty drawIds by bucket. If
|
|
1638
|
+
// ALL of a bucket's ROs are dirty (typical SG case where one
|
|
1639
|
+
// shared aval drives every leaf — e.g. <Sg CullMode={cullC}>
|
|
1640
|
+
// wrapping 20k leaves), the whole bucket transitions together.
|
|
1641
|
+
// Rebuild the bucket's pipeline + rename in bucketByKey,
|
|
1642
|
+
// O(1) work instead of N remove+add cycles.
|
|
1643
|
+
const byBucket = new Map();
|
|
1644
|
+
for (const drawId of dirty) {
|
|
1645
|
+
const b = drawIdToBucket[drawId];
|
|
1646
|
+
if (b === undefined)
|
|
1647
|
+
continue; // RO removed since mark
|
|
1648
|
+
let arr = byBucket.get(b);
|
|
1649
|
+
if (arr === undefined) {
|
|
1650
|
+
arr = [];
|
|
1651
|
+
byBucket.set(b, arr);
|
|
1652
|
+
}
|
|
1653
|
+
arr.push(drawId);
|
|
1654
|
+
}
|
|
1655
|
+
for (const [bucket, ids] of byBucket) {
|
|
1656
|
+
// First recompute every dirty tracker so .modeKey is fresh.
|
|
1657
|
+
// Filter out ones whose value didn't actually change.
|
|
1658
|
+
const changed = [];
|
|
1659
|
+
for (const drawId of ids) {
|
|
1660
|
+
const tracker = drawIdToModeTracker[drawId];
|
|
1661
|
+
if (tracker === undefined)
|
|
1662
|
+
continue;
|
|
1663
|
+
const oldKey = tracker.modeKey;
|
|
1664
|
+
if (!tracker.recompute())
|
|
1665
|
+
continue;
|
|
1666
|
+
if (tracker.modeKey === oldKey)
|
|
1667
|
+
continue;
|
|
1668
|
+
changed.push(drawId);
|
|
1669
|
+
}
|
|
1670
|
+
if (changed.length === 0)
|
|
1671
|
+
continue;
|
|
1672
|
+
// Try the all-together fast path.
|
|
1673
|
+
const wholeBucket = changed.length === bucket.drawSlots.size;
|
|
1674
|
+
if (wholeBucket) {
|
|
1675
|
+
const repId = changed[0];
|
|
1676
|
+
const repTracker = drawIdToModeTracker[repId];
|
|
1677
|
+
const newKey = repTracker.modeKey;
|
|
1678
|
+
// Compute the new bucketByKey string; we need the family.
|
|
1679
|
+
const sample = drawIdToSpec[repId];
|
|
1680
|
+
const fam = familyFor(sample.effect);
|
|
1681
|
+
const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
|
|
1682
|
+
const existing = bucketByKey.get(newBk);
|
|
1683
|
+
if (existing === undefined) {
|
|
1684
|
+
// No conflict — rename + rebuild pipeline in place.
|
|
1685
|
+
// Find this bucket's current key by linear scan
|
|
1686
|
+
// (buckets ≤ ~100 typically, so this is cheap).
|
|
1687
|
+
let oldBk;
|
|
1688
|
+
for (const [k, v] of bucketByKey) {
|
|
1689
|
+
if (v === bucket) {
|
|
1690
|
+
oldBk = k;
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
if (oldBk !== undefined)
|
|
1695
|
+
bucketByKey.delete(oldBk);
|
|
1696
|
+
bucketByKey.set(newBk, bucket);
|
|
1697
|
+
// Build the new pipeline with the snapshotted PS.
|
|
1698
|
+
const desc = repTracker.descriptor;
|
|
1699
|
+
const { pipelineLayout } = getBgl(bucket.layout, bucket.isAtlasBucket);
|
|
1700
|
+
const newPipeline = device.createRenderPipeline({
|
|
1701
|
+
label: `heapScene/${newBk}/pipeline`,
|
|
1702
|
+
layout: pipelineLayout,
|
|
1703
|
+
vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
|
|
1704
|
+
fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
|
|
1705
|
+
primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
|
|
1706
|
+
...(depthFormat !== undefined && desc.depth !== undefined
|
|
1707
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
|
|
1708
|
+
: depthFormat !== undefined
|
|
1709
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" } }
|
|
1710
|
+
: {}),
|
|
1711
|
+
});
|
|
1712
|
+
// Swap pipeline reference on the bucket's single slot.
|
|
1713
|
+
// (Multi-slot Phase 5c would update one slot at a time.)
|
|
1714
|
+
bucket.slots[0].pipeline = newPipeline;
|
|
1715
|
+
continue; // done with this bucket
|
|
1716
|
+
}
|
|
1717
|
+
// Conflict: another bucket already owns newBk. Fall through
|
|
1718
|
+
// to per-RO rebucket below, which will merge into `existing`.
|
|
1719
|
+
}
|
|
1720
|
+
// Slow path — per-RO rebucket (used when partial transition
|
|
1721
|
+
// OR when a merge with an existing destination bucket is
|
|
1722
|
+
// required).
|
|
1723
|
+
for (const drawId of changed) {
|
|
1724
|
+
const spec = drawIdToSpec[drawId];
|
|
1725
|
+
if (spec === undefined)
|
|
1726
|
+
continue;
|
|
1727
|
+
removeDraw(drawId);
|
|
1728
|
+
const newId = addDrawImpl(spec, tok);
|
|
1729
|
+
void newId;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
2348
1733
|
// 1. Pool: re-pack any aval whose value changed since last frame.
|
|
2349
1734
|
// One writeBuffer per dirty aval, regardless of how many draws
|
|
2350
1735
|
// reference it — sharing pays off here.
|
|
@@ -2441,11 +1826,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2441
1826
|
bucket.headerDirtyMin = Infinity;
|
|
2442
1827
|
bucket.headerDirtyMax = 0;
|
|
2443
1828
|
}
|
|
2444
|
-
if (bucket.drawTableDirtyMax > bucket.drawTableDirtyMin) {
|
|
2445
|
-
const shadow = bucket.drawTableShadow;
|
|
2446
|
-
device.queue.writeBuffer(bucket.drawTableBuf.buffer, bucket.drawTableDirtyMin, shadow.buffer, shadow.byteOffset + bucket.drawTableDirtyMin, bucket.drawTableDirtyMax - bucket.drawTableDirtyMin);
|
|
2447
|
-
bucket.drawTableDirtyMin = Infinity;
|
|
2448
|
-
bucket.drawTableDirtyMax = 0;
|
|
1829
|
+
if (bucket.slots[0].drawTableDirtyMax > bucket.slots[0].drawTableDirtyMin) {
|
|
1830
|
+
const shadow = bucket.slots[0].drawTableShadow;
|
|
1831
|
+
device.queue.writeBuffer(bucket.slots[0].drawTableBuf.buffer, bucket.slots[0].drawTableDirtyMin, shadow.buffer, shadow.byteOffset + bucket.slots[0].drawTableDirtyMin, bucket.slots[0].drawTableDirtyMax - bucket.slots[0].drawTableDirtyMin);
|
|
1832
|
+
bucket.slots[0].drawTableDirtyMin = Infinity;
|
|
1833
|
+
bucket.slots[0].drawTableDirtyMax = 0;
|
|
2449
1834
|
}
|
|
2450
1835
|
}
|
|
2451
1836
|
}
|
|
@@ -2461,9 +1846,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2461
1846
|
pass.setBindGroup(0, b.bindGroup);
|
|
2462
1847
|
curBg = b.bindGroup;
|
|
2463
1848
|
}
|
|
2464
|
-
pass.setPipeline(b.pipeline);
|
|
2465
|
-
if (b.recordCount > 0)
|
|
2466
|
-
pass.drawIndirect(b.indirectBuf, 0);
|
|
1849
|
+
pass.setPipeline(b.slots[0].pipeline);
|
|
1850
|
+
if (b.slots[0].recordCount > 0)
|
|
1851
|
+
pass.drawIndirect(b.slots[0].indirectBuf, 0);
|
|
2467
1852
|
}
|
|
2468
1853
|
}
|
|
2469
1854
|
/**
|
|
@@ -2505,7 +1890,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2505
1890
|
}
|
|
2506
1891
|
let anyDirty = false;
|
|
2507
1892
|
for (const b of buckets) {
|
|
2508
|
-
if (b.scanDirty) {
|
|
1893
|
+
if (b.slots[0].scanDirty) {
|
|
2509
1894
|
anyDirty = true;
|
|
2510
1895
|
break;
|
|
2511
1896
|
}
|
|
@@ -2514,17 +1899,17 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2514
1899
|
return;
|
|
2515
1900
|
const pass = enc.beginComputePass({ label: "heapScene/scan" });
|
|
2516
1901
|
for (const b of buckets) {
|
|
2517
|
-
if (!b.scanDirty)
|
|
1902
|
+
if (!b.slots[0].scanDirty)
|
|
2518
1903
|
continue;
|
|
2519
1904
|
// Rebuild render bind group if recordCount changed — its
|
|
2520
1905
|
// drawTable binding is sized to recordCount * 16.
|
|
2521
|
-
if (b.renderBoundRecordCount !== b.recordCount) {
|
|
1906
|
+
if (b.slots[0].renderBoundRecordCount !== b.slots[0].recordCount) {
|
|
2522
1907
|
b.bindGroup = buildBucketBindGroup(b);
|
|
2523
1908
|
}
|
|
2524
|
-
const numRecords = b.recordCount;
|
|
1909
|
+
const numRecords = b.slots[0].recordCount;
|
|
2525
1910
|
const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
2526
|
-
device.queue.writeBuffer(b.paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
|
|
2527
|
-
pass.setBindGroup(0, b.scanBindGroup);
|
|
1911
|
+
device.queue.writeBuffer(b.slots[0].paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
|
|
1912
|
+
pass.setBindGroup(0, b.slots[0].scanBindGroup);
|
|
2528
1913
|
pass.setPipeline(scanPipeTile);
|
|
2529
1914
|
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2530
1915
|
pass.setPipeline(scanPipeBlocks);
|
|
@@ -2535,11 +1920,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2535
1920
|
// must cover the worst-case totalEmit. Each WG handles WG_SIZE
|
|
2536
1921
|
// tiles; +1 for the sentinel slot.
|
|
2537
1922
|
const SCAN_WG_SIZE = 256;
|
|
2538
|
-
const numTilesCap = Math.max(1, Math.ceil(b.totalEmitEstimate / TILE_K));
|
|
1923
|
+
const numTilesCap = Math.max(1, Math.ceil(b.slots[0].totalEmitEstimate / TILE_K));
|
|
2539
1924
|
const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
|
|
2540
1925
|
pass.setPipeline(scanPipeBuildTileIndex);
|
|
2541
1926
|
pass.dispatchWorkgroups(tileWgs, 1, 1);
|
|
2542
|
-
b.scanDirty = false;
|
|
1927
|
+
b.slots[0].scanDirty = false;
|
|
2543
1928
|
}
|
|
2544
1929
|
pass.end();
|
|
2545
1930
|
}
|
|
@@ -2572,29 +1957,53 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2572
1957
|
pass.end();
|
|
2573
1958
|
device.queue.submit([enc.finish()]);
|
|
2574
1959
|
}
|
|
1960
|
+
function destroyBucketResources(b) {
|
|
1961
|
+
b.drawHeap.destroy();
|
|
1962
|
+
for (const slot of b.slots) {
|
|
1963
|
+
slot.drawTableBuf?.destroy();
|
|
1964
|
+
slot.blockSumsBuf?.destroy();
|
|
1965
|
+
slot.blockOffsetsBuf?.destroy();
|
|
1966
|
+
slot.firstDrawInTileBuf?.destroy();
|
|
1967
|
+
slot.indirectBuf?.destroy();
|
|
1968
|
+
slot.paramsBuf?.destroy();
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Remove an empty bucket from the scene's bucket list + key index +
|
|
1973
|
+
* destroy its GPU resources. Called from removeDraw when an RO's
|
|
1974
|
+
* removal leaves the bucket with zero live slots — common after
|
|
1975
|
+
* reactive PS rebucket leaves the source bucket empty.
|
|
1976
|
+
*/
|
|
1977
|
+
function gcBucket(b) {
|
|
1978
|
+
if (b.drawSlots.size !== 0)
|
|
1979
|
+
return;
|
|
1980
|
+
const idx = buckets.indexOf(b);
|
|
1981
|
+
if (idx >= 0)
|
|
1982
|
+
buckets.splice(idx, 1);
|
|
1983
|
+
for (const [k, v] of bucketByKey) {
|
|
1984
|
+
if (v === b) {
|
|
1985
|
+
bucketByKey.delete(k);
|
|
1986
|
+
break;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
destroyBucketResources(b);
|
|
1990
|
+
}
|
|
2575
1991
|
function dispose() {
|
|
2576
1992
|
arena.attrs.destroy();
|
|
2577
1993
|
arena.indices.destroy();
|
|
2578
|
-
for (const b of buckets)
|
|
2579
|
-
b
|
|
2580
|
-
b.drawTableBuf?.destroy();
|
|
2581
|
-
b.blockSumsBuf?.destroy();
|
|
2582
|
-
b.blockOffsetsBuf?.destroy();
|
|
2583
|
-
b.firstDrawInTileBuf?.destroy();
|
|
2584
|
-
b.indirectBuf?.destroy();
|
|
2585
|
-
b.paramsBuf?.destroy();
|
|
2586
|
-
}
|
|
1994
|
+
for (const b of buckets)
|
|
1995
|
+
destroyBucketResources(b);
|
|
2587
1996
|
}
|
|
2588
1997
|
// Test-only escape hatch for inspecting megacall bucket state. Not
|
|
2589
1998
|
// part of the public API surface — keep cast at use-site.
|
|
2590
1999
|
const _debug = {
|
|
2591
2000
|
bucketsForTest() {
|
|
2592
2001
|
return buckets.map(b => ({
|
|
2593
|
-
indirectBuf: b.indirectBuf,
|
|
2594
|
-
drawTableBuf: b.drawTableBuf?.buffer,
|
|
2595
|
-
firstDrawInTileBuf: b.firstDrawInTileBuf?.buffer,
|
|
2596
|
-
totalEmitEstimate: b.totalEmitEstimate,
|
|
2597
|
-
recordCount: b.recordCount,
|
|
2002
|
+
indirectBuf: b.slots[0].indirectBuf,
|
|
2003
|
+
drawTableBuf: b.slots[0].drawTableBuf?.buffer,
|
|
2004
|
+
firstDrawInTileBuf: b.slots[0].firstDrawInTileBuf?.buffer,
|
|
2005
|
+
totalEmitEstimate: b.slots[0].totalEmitEstimate,
|
|
2006
|
+
recordCount: b.slots[0].recordCount,
|
|
2598
2007
|
layout: b.layout,
|
|
2599
2008
|
}));
|
|
2600
2009
|
},
|
|
@@ -2639,19 +2048,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2639
2048
|
const TILE_K_LOCAL = 64;
|
|
2640
2049
|
const dcs = [];
|
|
2641
2050
|
for (const b of buckets) {
|
|
2642
|
-
const dhSize = Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes);
|
|
2051
|
+
const dhSize = Math.min(b.drawHeap.buffer.size, b.slots[0].recordCount * b.layout.drawHeaderBytes);
|
|
2643
2052
|
if (dhSize === 0)
|
|
2644
2053
|
continue;
|
|
2645
2054
|
const dh = stage(b.drawHeap.buffer, dhSize);
|
|
2646
|
-
const dt = b.drawTableBuf !== undefined && b.recordCount > 0
|
|
2647
|
-
? stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES)
|
|
2055
|
+
const dt = b.slots[0].drawTableBuf !== undefined && b.slots[0].recordCount > 0
|
|
2056
|
+
? stage(b.slots[0].drawTableBuf.buffer, b.slots[0].recordCount * RECORD_BYTES)
|
|
2648
2057
|
: undefined;
|
|
2649
|
-
const totalEmit = b.totalEmitEstimate;
|
|
2058
|
+
const totalEmit = b.slots[0].totalEmitEstimate;
|
|
2650
2059
|
const numTiles = totalEmit > 0 ? Math.ceil(totalEmit / TILE_K_LOCAL) : 0;
|
|
2651
|
-
const fdt = b.firstDrawInTileBuf !== undefined && numTiles > 0
|
|
2652
|
-
? stage(b.firstDrawInTileBuf.buffer, Math.min(b.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
|
|
2060
|
+
const fdt = b.slots[0].firstDrawInTileBuf !== undefined && numTiles > 0
|
|
2061
|
+
? stage(b.slots[0].firstDrawInTileBuf.buffer, Math.min(b.slots[0].firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
|
|
2653
2062
|
: undefined;
|
|
2654
|
-
const ind = b.indirectBuf !== undefined ? stage(b.indirectBuf, 16) : undefined;
|
|
2063
|
+
const ind = b.slots[0].indirectBuf !== undefined ? stage(b.slots[0].indirectBuf, 16) : undefined;
|
|
2655
2064
|
dcs.push({
|
|
2656
2065
|
bucket: b, drawHeap: dh, numTiles,
|
|
2657
2066
|
...(dt !== undefined ? { drawTable: dt } : {}),
|
|
@@ -2684,7 +2093,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2684
2093
|
for (const dc of dcs) {
|
|
2685
2094
|
const u32 = new Uint32Array(dc.drawHeap.getMappedRange());
|
|
2686
2095
|
const stride = dc.bucket.layout.drawHeaderBytes;
|
|
2687
|
-
const recordCount = dc.bucket.recordCount;
|
|
2096
|
+
const recordCount = dc.bucket.slots[0].recordCount;
|
|
2688
2097
|
for (let slot = 0; slot < recordCount; slot++) {
|
|
2689
2098
|
const slotOff = slot * stride;
|
|
2690
2099
|
for (const f of dc.bucket.layout.drawHeaderFields) {
|
|
@@ -2836,7 +2245,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2836
2245
|
// ── 6. indirect[0] (totalEmit) must match CPU prefix sum.
|
|
2837
2246
|
if (dc.indirect !== undefined) {
|
|
2838
2247
|
const ind = new Uint32Array(dc.indirect.getMappedRange());
|
|
2839
|
-
const expectedTotal = dc.bucket.totalEmitEstimate;
|
|
2248
|
+
const expectedTotal = dc.bucket.slots[0].totalEmitEstimate;
|
|
2840
2249
|
const got = ind[0];
|
|
2841
2250
|
if (got !== expectedTotal) {
|
|
2842
2251
|
push(`bucket#${bucketIdx} indirect[0]=${got} ≠ expected totalEmit=${expectedTotal}`);
|
|
@@ -2926,11 +2335,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2926
2335
|
const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
|
|
2927
2336
|
const dcs = [];
|
|
2928
2337
|
for (const b of buckets) {
|
|
2929
|
-
if (b.recordCount === 0 || b.totalEmitEstimate === 0)
|
|
2338
|
+
if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
|
|
2930
2339
|
continue;
|
|
2931
|
-
const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes));
|
|
2932
|
-
const dt = stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES);
|
|
2933
|
-
dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.totalEmitEstimate });
|
|
2340
|
+
const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.slots[0].recordCount * b.layout.drawHeaderBytes));
|
|
2341
|
+
const dt = stage(b.slots[0].drawTableBuf.buffer, b.slots[0].recordCount * RECORD_BYTES);
|
|
2342
|
+
dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.slots[0].totalEmitEstimate });
|
|
2934
2343
|
}
|
|
2935
2344
|
device.queue.submit([enc.finish()]);
|
|
2936
2345
|
await arenaCopy.mapAsync(GPUMapMode.READ);
|
|
@@ -2954,7 +2363,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2954
2363
|
const dt = new Uint32Array(dc.drawTable.getMappedRange());
|
|
2955
2364
|
bucketDt.push(dt);
|
|
2956
2365
|
bucketHdr.push(new Uint32Array(dc.drawHeap.getMappedRange()));
|
|
2957
|
-
for (let r = 0; r < dc.bucket.recordCount; r++) {
|
|
2366
|
+
for (let r = 0; r < dc.bucket.slots[0].recordCount; r++) {
|
|
2958
2367
|
dc.firstEmit.push(dt[r * RECORD_U32 + 0]);
|
|
2959
2368
|
}
|
|
2960
2369
|
bucketCumEmit.push(totalEmitGlobal);
|
|
@@ -3003,7 +2412,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3003
2412
|
}
|
|
3004
2413
|
emitsChecked++;
|
|
3005
2414
|
// Binary search for slot.
|
|
3006
|
-
const recCount = dc.bucket.recordCount;
|
|
2415
|
+
const recCount = dc.bucket.slots[0].recordCount;
|
|
3007
2416
|
let lo = 0, hi = recCount - 1;
|
|
3008
2417
|
while (lo < hi) {
|
|
3009
2418
|
const mid = (lo + hi + 1) >>> 1;
|
|
@@ -3199,12 +2608,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3199
2608
|
? new Uint32Array(indicesCopy.getMappedRange())
|
|
3200
2609
|
: new Uint32Array(0);
|
|
3201
2610
|
for (const target of buckets) {
|
|
3202
|
-
if (target.recordCount === 0 || target.totalEmitEstimate === 0) {
|
|
2611
|
+
if (target.slots[0].recordCount === 0 || target.slots[0].totalEmitEstimate === 0) {
|
|
3203
2612
|
bucketIdx++;
|
|
3204
2613
|
continue;
|
|
3205
2614
|
}
|
|
3206
|
-
const recordCount = target.recordCount;
|
|
3207
|
-
const totalEmit = target.totalEmitEstimate;
|
|
2615
|
+
const recordCount = target.slots[0].recordCount;
|
|
2616
|
+
const totalEmit = target.slots[0].totalEmitEstimate;
|
|
3208
2617
|
const totalTris = Math.floor(totalEmit / 3);
|
|
3209
2618
|
const remainder = totalEmit % 3;
|
|
3210
2619
|
if (remainder !== 0) {
|
|
@@ -3221,7 +2630,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3221
2630
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3222
2631
|
});
|
|
3223
2632
|
const enc0 = device.createCommandEncoder({ label: "checkTriangleCoherence.dt" });
|
|
3224
|
-
enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
2633
|
+
enc0.copyBufferToBuffer(target.slots[0].drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
3225
2634
|
enc0.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, recordCount * target.layout.drawHeaderBytes);
|
|
3226
2635
|
device.queue.submit([enc0.finish()]);
|
|
3227
2636
|
await dtCopy.mapAsync(GPUMapMode.READ);
|
|
@@ -3270,7 +2679,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3270
2679
|
});
|
|
3271
2680
|
{
|
|
3272
2681
|
const enc1 = device.createCommandEncoder();
|
|
3273
|
-
enc1.copyBufferToBuffer(target.firstDrawInTileBuf.buffer, 0, fdtCopy, 0, fdtSize);
|
|
2682
|
+
enc1.copyBufferToBuffer(target.slots[0].firstDrawInTileBuf.buffer, 0, fdtCopy, 0, fdtSize);
|
|
3274
2683
|
device.queue.submit([enc1.finish()]);
|
|
3275
2684
|
await fdtCopy.mapAsync(GPUMapMode.READ);
|
|
3276
2685
|
}
|
|
@@ -3425,12 +2834,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3425
2834
|
// own bytes).
|
|
3426
2835
|
bucketIdx = 0;
|
|
3427
2836
|
for (const target of buckets) {
|
|
3428
|
-
if (target.recordCount === 0) {
|
|
2837
|
+
if (target.slots[0].recordCount === 0) {
|
|
3429
2838
|
bucketIdx++;
|
|
3430
2839
|
continue;
|
|
3431
2840
|
}
|
|
3432
2841
|
// Re-stage drawHeap for this bucket.
|
|
3433
|
-
const dhBytes = target.recordCount * target.layout.drawHeaderBytes;
|
|
2842
|
+
const dhBytes = target.slots[0].recordCount * target.layout.drawHeaderBytes;
|
|
3434
2843
|
const dhCopy = device.createBuffer({
|
|
3435
2844
|
size: dhBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3436
2845
|
});
|
|
@@ -3440,7 +2849,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3440
2849
|
await dhCopy.mapAsync(GPUMapMode.READ);
|
|
3441
2850
|
const dhU32 = new Uint32Array(dhCopy.getMappedRange());
|
|
3442
2851
|
const stride2 = target.layout.drawHeaderBytes;
|
|
3443
|
-
for (let slot = 0; slot < target.recordCount; slot++) {
|
|
2852
|
+
for (let slot = 0; slot < target.slots[0].recordCount; slot++) {
|
|
3444
2853
|
for (const f of target.layout.drawHeaderFields) {
|
|
3445
2854
|
if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
|
|
3446
2855
|
continue;
|
|
@@ -3500,11 +2909,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3500
2909
|
let gpuMismatches = 0;
|
|
3501
2910
|
const push = (s) => { if (issues.length < 30)
|
|
3502
2911
|
issues.push(s); };
|
|
3503
|
-
const target = buckets.find(b => b.recordCount > 0 && b.totalEmitEstimate > 0);
|
|
2912
|
+
const target = buckets.find(b => b.slots[0].recordCount > 0 && b.slots[0].totalEmitEstimate > 0);
|
|
3504
2913
|
if (target === undefined)
|
|
3505
2914
|
return { emitsChecked: 0, gpuMismatches: 0, issues };
|
|
3506
|
-
const recordCount = target.recordCount;
|
|
3507
|
-
const totalEmit = target.totalEmitEstimate;
|
|
2915
|
+
const recordCount = target.slots[0].recordCount;
|
|
2916
|
+
const totalEmit = target.slots[0].totalEmitEstimate;
|
|
3508
2917
|
const sampleCount = Math.min(samples, totalEmit);
|
|
3509
2918
|
const stride = Math.max(1, Math.floor(totalEmit / sampleCount));
|
|
3510
2919
|
// CPU's firstEmit is GPU-computed — must read it back from the
|
|
@@ -3517,7 +2926,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3517
2926
|
});
|
|
3518
2927
|
{
|
|
3519
2928
|
const enc0 = device.createCommandEncoder({ label: "probeBinarySearch.dtCopy" });
|
|
3520
|
-
enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
2929
|
+
enc0.copyBufferToBuffer(target.slots[0].drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
3521
2930
|
device.queue.submit([enc0.finish()]);
|
|
3522
2931
|
await dtCopy.mapAsync(GPUMapMode.READ);
|
|
3523
2932
|
}
|
|
@@ -3621,8 +3030,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3621
3030
|
const bg = device.createBindGroup({
|
|
3622
3031
|
layout: bgl,
|
|
3623
3032
|
entries: [
|
|
3624
|
-
{ binding: 0, resource: { buffer: target.drawTableBuf.buffer } },
|
|
3625
|
-
{ binding: 1, resource: { buffer: target.firstDrawInTileBuf.buffer } },
|
|
3033
|
+
{ binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
|
|
3034
|
+
{ binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
|
|
3626
3035
|
{ binding: 2, resource: { buffer: sampleBuf } },
|
|
3627
3036
|
{ binding: 3, resource: { buffer: arena.indices.buffer } },
|
|
3628
3037
|
{ binding: 4, resource: { buffer: outBuf } },
|