@aardworx/wombat.rendering 0.19.23 → 0.19.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/elementType.d.ts +2 -0
- package/dist/core/elementType.d.ts.map +1 -1
- package/dist/core/elementType.js +10 -0
- package/dist/core/elementType.js.map +1 -1
- package/dist/core/provider.d.ts.map +1 -1
- package/dist/core/provider.js +65 -32
- package/dist/core/provider.js.map +1 -1
- package/dist/core/renderObject.d.ts +9 -0
- package/dist/core/renderObject.d.ts.map +1 -1
- package/dist/resources/preparedRenderObject.d.ts.map +1 -1
- package/dist/resources/preparedRenderObject.js +19 -2
- package/dist/resources/preparedRenderObject.js.map +1 -1
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts +2 -12
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
- package/dist/runtime/derivedModes/modeKeyCpu.js +30 -13
- package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts +11 -0
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -1
- package/dist/runtime/derivedModes/partitionDispatcher.js +31 -0
- package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts +5 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.js +28 -13
- package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
- package/dist/runtime/derivedUniforms/records.d.ts +15 -0
- package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/records.js +81 -14
- package/dist/runtime/derivedUniforms/records.js.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.js +9 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
- package/dist/runtime/flattenTree.d.ts.map +1 -1
- package/dist/runtime/flattenTree.js +46 -2
- package/dist/runtime/flattenTree.js.map +1 -1
- package/dist/runtime/heapAdapter.d.ts +3 -1
- package/dist/runtime/heapAdapter.d.ts.map +1 -1
- package/dist/runtime/heapAdapter.js +42 -4
- package/dist/runtime/heapAdapter.js.map +1 -1
- package/dist/runtime/heapDecoder.d.ts.map +1 -1
- package/dist/runtime/heapDecoder.js +8 -3
- package/dist/runtime/heapDecoder.js.map +1 -1
- package/dist/runtime/heapEffect.d.ts +14 -1
- package/dist/runtime/heapEffect.d.ts.map +1 -1
- package/dist/runtime/heapEffect.js +13 -1
- package/dist/runtime/heapEffect.js.map +1 -1
- package/dist/runtime/heapEffectIR.d.ts.map +1 -1
- package/dist/runtime/heapEffectIR.js +18 -9
- package/dist/runtime/heapEffectIR.js.map +1 -1
- package/dist/runtime/heapEligibility.d.ts.map +1 -1
- package/dist/runtime/heapEligibility.js +64 -12
- package/dist/runtime/heapEligibility.js.map +1 -1
- package/dist/runtime/heapIrBuilders.d.ts +8 -0
- package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
- package/dist/runtime/heapIrBuilders.js +47 -2
- package/dist/runtime/heapIrBuilders.js.map +1 -1
- package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
- package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
- package/dist/runtime/heapScene/chunkedArena.js +28 -91
- package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
- package/dist/runtime/heapScene/freelist.d.ts +1 -1
- package/dist/runtime/heapScene/freelist.js +3 -3
- package/dist/runtime/heapScene/freelist.js.map +1 -1
- package/dist/runtime/heapScene/pools.d.ts +103 -59
- package/dist/runtime/heapScene/pools.d.ts.map +1 -1
- package/dist/runtime/heapScene/pools.js +335 -194
- package/dist/runtime/heapScene/pools.js.map +1 -1
- package/dist/runtime/heapScene.d.ts +116 -1
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +581 -125
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/hybridScene.d.ts.map +1 -1
- package/dist/runtime/hybridScene.js +19 -3
- package/dist/runtime/hybridScene.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/elementType.ts +10 -0
- package/src/core/provider.ts +56 -28
- package/src/core/renderObject.ts +9 -0
- package/src/resources/preparedRenderObject.ts +22 -3
- package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
- package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
- package/src/runtime/derivedUniforms/dispatch.ts +26 -10
- package/src/runtime/derivedUniforms/records.ts +70 -15
- package/src/runtime/derivedUniforms/sceneIntegration.ts +10 -0
- package/src/runtime/flattenTree.ts +47 -2
- package/src/runtime/heapAdapter.ts +40 -5
- package/src/runtime/heapDecoder.ts +8 -3
- package/src/runtime/heapEffect.ts +15 -1
- package/src/runtime/heapEffectIR.ts +18 -9
- package/src/runtime/heapEligibility.ts +39 -12
- package/src/runtime/heapIrBuilders.ts +50 -2
- package/src/runtime/heapScene/chunkedArena.ts +29 -94
- package/src/runtime/heapScene/freelist.ts +3 -3
- package/src/runtime/heapScene/pools.ts +340 -218
- package/src/runtime/heapScene.ts +717 -141
- package/src/runtime/hybridScene.ts +20 -3
- package/src/runtime/index.ts +5 -0
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// classes here are tightly interleaved:
|
|
4
4
|
//
|
|
5
5
|
// AttributeArena — byte-bump over a STORAGE GrowBuffer
|
|
6
|
-
//
|
|
6
|
+
// (index arrays are AttributeArena regions too — VS storage-decodes them)
|
|
7
7
|
// DrawHeap — slot-indexed allocator over a STORAGE GrowBuffer
|
|
8
8
|
// (one slot per RO drawHeader)
|
|
9
9
|
// UniformPool — aval-keyed refcounted alloc on top of AttributeArena
|
|
10
|
-
// IndexPool — aval-keyed refcounted alloc on top of
|
|
10
|
+
// IndexPool — aval-keyed refcounted alloc on top of AttributeArena,
|
|
11
11
|
// with value-tuple dedup for constant avals (§5b).
|
|
12
12
|
//
|
|
13
13
|
// Plus the per-allocation header constants used by all of them and
|
|
@@ -21,8 +21,7 @@ import type { BufferView } from "../../core/bufferView.js";
|
|
|
21
21
|
import type { HostBufferSource } from "../../core/buffer.js";
|
|
22
22
|
import { GrowBuffer, ALIGN16, DEFAULT_MAX_BUFFER_BYTES } from "./growBuffer.js";
|
|
23
23
|
import { Freelist } from "./freelist.js";
|
|
24
|
-
import {
|
|
25
|
-
import { ChunkedAttributeArena, ChunkedIndexAllocator } from "./chunkedArena.js";
|
|
24
|
+
import { ChunkedAttributeArena } from "./chunkedArena.js";
|
|
26
25
|
|
|
27
26
|
// ─── Debug toggles ─────────────────────────────────────────────────────
|
|
28
27
|
|
|
@@ -43,8 +42,17 @@ const DEBUG_ALLOC_OVERLAP: boolean =
|
|
|
43
42
|
export const ALLOC_HEADER_BYTES = 8;
|
|
44
43
|
export const ALLOC_HEADER_PAD_TO = 16;
|
|
45
44
|
|
|
46
|
-
/**
|
|
45
|
+
/** Default fragmentation-waste floor (bytes) below which `AttributeArena`
|
|
46
|
+
* declines to compact — relocating a few KB isn't worth a GPU copy +
|
|
47
|
+
* ref re-seat. Matches Aardvark's 4 MiB compaction floor. */
|
|
48
|
+
export const COMPACTION_WASTE_FLOOR_BYTES = 4 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
/** Encoding-tag enum (low 16 bits of typeId). The generated VS decode
|
|
51
|
+
* branches on this per field (2-arm select; header word 0 shares the
|
|
52
|
+
* cache line with the broadcast length in word 1). */
|
|
47
53
|
export const ENC_V3F_TIGHT = 1; // tightly-packed array of vec3<f32> (12 B/elt)
|
|
54
|
+
export const ENC_OCT32 = 2; // oct-packed unit vectors, 2×unorm16 in one u32 (4 B/elt)
|
|
55
|
+
export const ENC_C4B = 3; // RGBA8-unorm colors, one u32/elt → unpack4x8unorm (4 B/elt)
|
|
48
56
|
|
|
49
57
|
/** Semantic-tag enum (high 16 bits of typeId). Optional metadata —
|
|
50
58
|
* the shader doesn't branch on this. */
|
|
@@ -61,7 +69,10 @@ export const SEM_NORMALS = 2;
|
|
|
61
69
|
*/
|
|
62
70
|
interface PoolEntry {
|
|
63
71
|
readonly chunkIdx: number;
|
|
64
|
-
|
|
72
|
+
/** Byte offset within the chunk's GPUBuffer. Mutated by
|
|
73
|
+
* `UniformPool.remapRefs` when a waste-triggered arena compaction
|
|
74
|
+
* relocates the allocation. */
|
|
75
|
+
ref: number;
|
|
65
76
|
readonly dataBytes: number;
|
|
66
77
|
readonly typeId: number;
|
|
67
78
|
readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
|
|
@@ -77,34 +88,107 @@ interface PoolEntry {
|
|
|
77
88
|
* an aval still share the underlying allocation.
|
|
78
89
|
*/
|
|
79
90
|
export class UniformPool {
|
|
80
|
-
|
|
91
|
+
/** Value is a single `PoolEntry` (the overwhelmingly common case —
|
|
92
|
+
* one aval, one chunk) or a `Map<chunkIdx, PoolEntry>` once the
|
|
93
|
+
* same aval is allocated in a second chunk. The flat form saves a
|
|
94
|
+
* whole inner `Map` per aval, which at heap scale (several avals
|
|
95
|
+
* per draw) is megabytes of JS heap. */
|
|
96
|
+
private readonly byAval = new Map<aval<unknown>, PoolEntry | Map<number, PoolEntry>>();
|
|
97
|
+
|
|
98
|
+
private getEntry(av: aval<unknown>, chunkIdx: number): PoolEntry | undefined {
|
|
99
|
+
const v = this.byAval.get(av);
|
|
100
|
+
if (v === undefined) return undefined;
|
|
101
|
+
if (v instanceof Map) return v.get(chunkIdx);
|
|
102
|
+
return v.chunkIdx === chunkIdx ? v : undefined;
|
|
103
|
+
}
|
|
104
|
+
private setEntry(av: aval<unknown>, e: PoolEntry): void {
|
|
105
|
+
const v = this.byAval.get(av);
|
|
106
|
+
if (v === undefined) { this.byAval.set(av, e); return; }
|
|
107
|
+
if (v instanceof Map) { v.set(e.chunkIdx, e); return; }
|
|
108
|
+
const m = new Map<number, PoolEntry>();
|
|
109
|
+
m.set(v.chunkIdx, v);
|
|
110
|
+
m.set(e.chunkIdx, e);
|
|
111
|
+
this.byAval.set(av, m);
|
|
112
|
+
}
|
|
113
|
+
private deleteEntry(av: aval<unknown>, chunkIdx: number): void {
|
|
114
|
+
const v = this.byAval.get(av);
|
|
115
|
+
if (v === undefined) return;
|
|
116
|
+
if (v instanceof Map) {
|
|
117
|
+
v.delete(chunkIdx);
|
|
118
|
+
if (v.size === 1) {
|
|
119
|
+
this.byAval.set(av, v.values().next().value as PoolEntry);
|
|
120
|
+
} else if (v.size === 0) this.byAval.delete(av);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (v.chunkIdx === chunkIdx) this.byAval.delete(av);
|
|
124
|
+
}
|
|
125
|
+
private *entriesOf(av: aval<unknown>): IterableIterator<PoolEntry> {
|
|
126
|
+
const v = this.byAval.get(av);
|
|
127
|
+
if (v === undefined) return;
|
|
128
|
+
if (v instanceof Map) yield* v.values();
|
|
129
|
+
else yield v;
|
|
130
|
+
}
|
|
81
131
|
|
|
82
132
|
has(av: aval<unknown>, chunkIdx: number): boolean {
|
|
83
|
-
return this.
|
|
133
|
+
return this.getEntry(av, chunkIdx) !== undefined;
|
|
84
134
|
}
|
|
85
135
|
/** True when the pool holds at least one entry for `av` in any
|
|
86
136
|
* chunk. Used by the scene's `inputChanged` dispatch to decide
|
|
87
137
|
* whether a marking aval is one of our tracked allocations. */
|
|
88
138
|
hasAny(av: aval<unknown>): boolean {
|
|
89
|
-
|
|
139
|
+
const v = this.byAval.get(av);
|
|
140
|
+
return v !== undefined && (!(v instanceof Map) || v.size > 0);
|
|
90
141
|
}
|
|
91
142
|
entry(av: aval<unknown>, chunkIdx: number): PoolEntry | undefined {
|
|
92
|
-
return this.
|
|
143
|
+
return this.getEntry(av, chunkIdx);
|
|
93
144
|
}
|
|
94
145
|
/** First chunk this aval is allocated in, if any. Used by `addRO`
|
|
95
146
|
* to prefer co-locating a new RO with its already-allocated
|
|
96
147
|
* shared inputs (avoids unnecessary duplication). */
|
|
97
148
|
firstChunkContaining(av: aval<unknown>): number | undefined {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
149
|
+
for (const e of this.entriesOf(av)) return e.chunkIdx;
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Unkeyed allocation for slots the GPU overwrites every dirty frame
|
|
155
|
+
* (§7 derived-uniform outputs). No aval, no `byAval` entry, no
|
|
156
|
+
* sharing — the caller keeps the ref in its drawHeader cell (which
|
|
157
|
+
* the compaction remap re-seats) and frees it with `releaseRaw`.
|
|
158
|
+
* Avoids a placeholder `AVal.constant` + pool entry per draw.
|
|
159
|
+
*/
|
|
160
|
+
allocRaw(
|
|
161
|
+
arena: ChunkedAttributeArena,
|
|
162
|
+
chunkIdx: number,
|
|
163
|
+
dataBytes: number,
|
|
164
|
+
typeId: number,
|
|
165
|
+
length: number,
|
|
166
|
+
): number {
|
|
167
|
+
const r = arena.alloc(dataBytes, chunkIdx);
|
|
168
|
+
if (r.chunkIdx !== chunkIdx) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`UniformPool.allocRaw: allocator spilled from chunk ${chunkIdx} to chunk ${r.chunkIdx} ` +
|
|
171
|
+
`(${dataBytes} bytes). Caller's bucket is bound to chunk ${chunkIdx}.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
175
|
+
const buf = new ArrayBuffer(allocBytes);
|
|
176
|
+
const u32 = new Uint32Array(buf);
|
|
177
|
+
u32[0] = typeId;
|
|
178
|
+
u32[1] = length;
|
|
179
|
+
u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
|
|
180
|
+
arena.write(chunkIdx, r.off, new Uint8Array(buf));
|
|
181
|
+
return r.off;
|
|
182
|
+
}
|
|
183
|
+
releaseRaw(arena: ChunkedAttributeArena, chunkIdx: number, ref: number, dataBytes: number): void {
|
|
184
|
+
arena.release(chunkIdx, ref, dataBytes);
|
|
101
185
|
}
|
|
102
186
|
|
|
103
187
|
/**
|
|
104
188
|
* Acquire (or share) an allocation for `aval` in `chunkIdx`.
|
|
105
189
|
* Returns the byte offset within that chunk's GPUBuffer. If a
|
|
106
190
|
* fresh allocation is made, the value is packed + uploaded
|
|
107
|
-
* immediately
|
|
191
|
+
* immediately to the chunk's GPU buffer (mirror-less).
|
|
108
192
|
*/
|
|
109
193
|
acquire(
|
|
110
194
|
device: GPUDevice,
|
|
@@ -117,8 +201,7 @@ export class UniformPool {
|
|
|
117
201
|
length: number,
|
|
118
202
|
pack: (val: unknown, dst: Float32Array, off: number) => void,
|
|
119
203
|
): number {
|
|
120
|
-
|
|
121
|
-
const existing = byChunk?.get(chunkIdx);
|
|
204
|
+
const existing = this.getEntry(av, chunkIdx);
|
|
122
205
|
if (existing !== undefined) {
|
|
123
206
|
existing.refcount++;
|
|
124
207
|
return existing.ref;
|
|
@@ -152,19 +235,14 @@ export class UniformPool {
|
|
|
152
235
|
pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
|
|
153
236
|
arena.write(finalChunk, r.off, new Uint8Array(buf));
|
|
154
237
|
void device;
|
|
155
|
-
|
|
156
|
-
byChunk = new Map();
|
|
157
|
-
this.byAval.set(av, byChunk);
|
|
158
|
-
}
|
|
159
|
-
byChunk.set(finalChunk, {
|
|
238
|
+
this.setEntry(av, {
|
|
160
239
|
chunkIdx: finalChunk, ref: r.off, dataBytes, typeId, pack, refcount: 1,
|
|
161
240
|
});
|
|
162
241
|
return r.off;
|
|
163
242
|
}
|
|
164
243
|
|
|
165
244
|
release(arena: ChunkedAttributeArena, av: aval<unknown>, chunkIdx: number): void {
|
|
166
|
-
const
|
|
167
|
-
const e = byChunk?.get(chunkIdx);
|
|
245
|
+
const e = this.getEntry(av, chunkIdx);
|
|
168
246
|
if (e === undefined) return;
|
|
169
247
|
e.refcount--;
|
|
170
248
|
if (e.refcount > 0) return;
|
|
@@ -179,8 +257,20 @@ export class UniformPool {
|
|
|
179
257
|
// 16 B inside the previous alloc → overlapping allocations →
|
|
180
258
|
// garbage `typeId`/`length` in the attribute header.
|
|
181
259
|
arena.release(e.chunkIdx, e.ref, e.dataBytes);
|
|
182
|
-
|
|
183
|
-
|
|
260
|
+
this.deleteEntry(av, chunkIdx);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Re-seat every entry's `ref` in `chunkIdx` after a waste-triggered
|
|
264
|
+
* arena compaction relocated allocations. `remap` maps OLD→NEW byte
|
|
265
|
+
* offset. Entries whose ref didn't move are left as-is. O(entries). */
|
|
266
|
+
remapRefs(chunkIdx: number, remap: ReadonlyMap<number, number>): void {
|
|
267
|
+
if (remap.size === 0) return;
|
|
268
|
+
for (const v of this.byAval.values()) {
|
|
269
|
+
const e = v instanceof Map ? v.get(chunkIdx) : (v.chunkIdx === chunkIdx ? v : undefined);
|
|
270
|
+
if (e === undefined) continue;
|
|
271
|
+
const nn = remap.get(e.ref);
|
|
272
|
+
if (nn !== undefined) e.ref = nn;
|
|
273
|
+
}
|
|
184
274
|
}
|
|
185
275
|
|
|
186
276
|
/** Re-pack one entry's data region into every chunk that holds
|
|
@@ -193,10 +283,8 @@ export class UniformPool {
|
|
|
193
283
|
av: aval<unknown>,
|
|
194
284
|
val: unknown,
|
|
195
285
|
): void {
|
|
196
|
-
const byChunk = this.byAval.get(av);
|
|
197
|
-
if (byChunk === undefined) return;
|
|
198
286
|
let dst: Float32Array | undefined;
|
|
199
|
-
for (const e of
|
|
287
|
+
for (const e of this.entriesOf(av)) {
|
|
200
288
|
if (dst === undefined || dst.length !== e.dataBytes / 4) {
|
|
201
289
|
dst = new Float32Array(e.dataBytes / 4);
|
|
202
290
|
}
|
|
@@ -212,10 +300,8 @@ export class UniformPool {
|
|
|
212
300
|
/** Total bytes touched by `repack` for this aval — sum across
|
|
213
301
|
* chunks. Used by the dirty-bytes diagnostics. */
|
|
214
302
|
totalDataBytes(av: aval<unknown>): number {
|
|
215
|
-
const byChunk = this.byAval.get(av);
|
|
216
|
-
if (byChunk === undefined) return 0;
|
|
217
303
|
let s = 0;
|
|
218
|
-
for (const e of
|
|
304
|
+
for (const e of this.entriesOf(av)) s += e.dataBytes;
|
|
219
305
|
return s;
|
|
220
306
|
}
|
|
221
307
|
}
|
|
@@ -223,7 +309,7 @@ export class UniformPool {
|
|
|
223
309
|
// ─── IndexPool ─────────────────────────────────────────────────────────
|
|
224
310
|
|
|
225
311
|
/**
|
|
226
|
-
* Aval-keyed pool over the
|
|
312
|
+
* Aval-keyed pool over the (attribute) arena. Two draws referencing
|
|
227
313
|
* the same `Uint32Array` (or aval thereof) share an index range —
|
|
228
314
|
* 19K instanced clones of the same mesh share one allocation, one
|
|
229
315
|
* upload.
|
|
@@ -236,11 +322,40 @@ export class UniformPool {
|
|
|
236
322
|
*/
|
|
237
323
|
export class IndexPool {
|
|
238
324
|
/** Per-aval, per-chunk bookkeeping. Same aval acquired in two
|
|
239
|
-
* chunks gets two entries (accepted §3 duplication).
|
|
325
|
+
* chunks gets two entries (accepted §3 duplication). Value is a
|
|
326
|
+
* single binding (common case) or a Map<chunkIdx, binding> once
|
|
327
|
+
* the aval spans chunks — same flat-form saving as UniformPool. */
|
|
240
328
|
private readonly byAval = new Map<
|
|
241
329
|
aval<Uint32Array>,
|
|
242
|
-
Map<number,
|
|
330
|
+
IndexBinding | Map<number, IndexBinding>
|
|
243
331
|
>();
|
|
332
|
+
|
|
333
|
+
private getBinding(av: aval<Uint32Array>, chunkIdx: number): IndexBinding | undefined {
|
|
334
|
+
const v = this.byAval.get(av);
|
|
335
|
+
if (v === undefined) return undefined;
|
|
336
|
+
if (v instanceof Map) return v.get(chunkIdx);
|
|
337
|
+
return v.entry.chunkIdx === chunkIdx ? v : undefined;
|
|
338
|
+
}
|
|
339
|
+
private setBinding(av: aval<Uint32Array>, b: IndexBinding): void {
|
|
340
|
+
const v = this.byAval.get(av);
|
|
341
|
+
if (v === undefined) { this.byAval.set(av, b); return; }
|
|
342
|
+
if (v instanceof Map) { v.set(b.entry.chunkIdx, b); return; }
|
|
343
|
+
const m = new Map<number, IndexBinding>();
|
|
344
|
+
m.set(v.entry.chunkIdx, v);
|
|
345
|
+
m.set(b.entry.chunkIdx, b);
|
|
346
|
+
this.byAval.set(av, m);
|
|
347
|
+
}
|
|
348
|
+
private deleteBinding(av: aval<Uint32Array>, chunkIdx: number): void {
|
|
349
|
+
const v = this.byAval.get(av);
|
|
350
|
+
if (v === undefined) return;
|
|
351
|
+
if (v instanceof Map) {
|
|
352
|
+
v.delete(chunkIdx);
|
|
353
|
+
if (v.size === 1) this.byAval.set(av, v.values().next().value as IndexBinding);
|
|
354
|
+
else if (v.size === 0) this.byAval.delete(av);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (v.entry.chunkIdx === chunkIdx) this.byAval.delete(av);
|
|
358
|
+
}
|
|
244
359
|
/** Per-chunk constant-aval value-dedup (§5b in-chunk). */
|
|
245
360
|
private readonly byValueKey = new Map<string, IndexPoolEntry>();
|
|
246
361
|
private readonly bufferIds = new WeakMap<ArrayBufferLike, number>();
|
|
@@ -255,20 +370,20 @@ export class IndexPool {
|
|
|
255
370
|
}
|
|
256
371
|
|
|
257
372
|
firstChunkContaining(av: aval<Uint32Array>): number | undefined {
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
260
|
-
return
|
|
373
|
+
const v = this.byAval.get(av);
|
|
374
|
+
if (v === undefined) return undefined;
|
|
375
|
+
if (v instanceof Map) return v.size > 0 ? (v.keys().next().value as number) : undefined;
|
|
376
|
+
return v.entry.chunkIdx;
|
|
261
377
|
}
|
|
262
378
|
|
|
263
379
|
acquire(
|
|
264
380
|
device: GPUDevice,
|
|
265
|
-
|
|
381
|
+
arena: ChunkedAttributeArena,
|
|
266
382
|
av: aval<Uint32Array>,
|
|
267
383
|
chunkIdx: number,
|
|
268
384
|
arr: Uint32Array,
|
|
269
385
|
): { chunkIdx: number; firstIndex: number; count: number } {
|
|
270
|
-
|
|
271
|
-
const bound = byChunk?.get(chunkIdx);
|
|
386
|
+
const bound = this.getBinding(av, chunkIdx);
|
|
272
387
|
if (bound !== undefined) {
|
|
273
388
|
bound.perAvalCount++;
|
|
274
389
|
bound.entry.totalRefcount++;
|
|
@@ -278,59 +393,92 @@ export class IndexPool {
|
|
|
278
393
|
if (av.isConstant) {
|
|
279
394
|
// Value-dedup is scoped per chunk — two constant avals in the
|
|
280
395
|
// same chunk collapse to one allocation; two constant avals in
|
|
281
|
-
// DIFFERENT chunks each get their own (
|
|
282
|
-
//
|
|
396
|
+
// DIFFERENT chunks each get their own (the index range lives in
|
|
397
|
+
// the chunk's GPUBuffer).
|
|
283
398
|
valueKey = `${chunkIdx}:${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
|
|
284
399
|
const shared = this.byValueKey.get(valueKey);
|
|
285
400
|
if (shared !== undefined) {
|
|
286
401
|
shared.totalRefcount++;
|
|
287
|
-
|
|
288
|
-
byChunk = new Map();
|
|
289
|
-
this.byAval.set(av, byChunk);
|
|
290
|
-
}
|
|
291
|
-
byChunk.set(chunkIdx, { entry: shared, perAvalCount: 1 });
|
|
402
|
+
this.setBinding(av, { entry: shared, perAvalCount: 1 });
|
|
292
403
|
return { chunkIdx: shared.chunkIdx, firstIndex: shared.firstIndex, count: shared.count };
|
|
293
404
|
}
|
|
294
405
|
}
|
|
295
|
-
|
|
296
|
-
indices
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
406
|
+
// Index data lives in the SAME arena as attributes/uniforms (the heap VS
|
|
407
|
+
// storage-decodes indices — `indexStorage[_indexStart + i]` — so they need
|
|
408
|
+
// no separate index buffer). Allocate a normal header+data region; the
|
|
409
|
+
// raw u32 indices sit at `ref + 16`, so the element offset the drawTable
|
|
410
|
+
// stores is `(ref + 16) >> 2`. The header is unread by index gathers but
|
|
411
|
+
// kept for a uniform region layout (and so compaction treats it like any
|
|
412
|
+
// other region).
|
|
413
|
+
const byteLen = arr.byteLength;
|
|
414
|
+
const r = arena.alloc(byteLen, chunkIdx);
|
|
415
|
+
const dataOff = r.off + ALLOC_HEADER_PAD_TO;
|
|
416
|
+
const hdr = new Uint32Array([0, arr.length, 4, 0]); // (typeId, length, strideBytes, pad)
|
|
417
|
+
arena.write(r.chunkIdx, r.off, new Uint8Array(hdr.buffer));
|
|
418
|
+
arena.write(r.chunkIdx, dataOff, new Uint8Array(arr.buffer, arr.byteOffset, byteLen));
|
|
300
419
|
void device;
|
|
420
|
+
const firstIndex = dataOff >>> 2;
|
|
301
421
|
const entry: IndexPoolEntry = {
|
|
302
|
-
chunkIdx: r.chunkIdx,
|
|
422
|
+
chunkIdx: r.chunkIdx, ref: r.off, firstIndex, count: arr.length,
|
|
303
423
|
totalRefcount: 1, valueKey,
|
|
304
424
|
};
|
|
305
|
-
|
|
306
|
-
byChunk = new Map();
|
|
307
|
-
this.byAval.set(av, byChunk);
|
|
308
|
-
}
|
|
309
|
-
byChunk.set(r.chunkIdx, { entry, perAvalCount: 1 });
|
|
425
|
+
this.setBinding(av, { entry, perAvalCount: 1 });
|
|
310
426
|
if (valueKey !== undefined) this.byValueKey.set(valueKey, entry);
|
|
311
|
-
return { chunkIdx: r.chunkIdx, firstIndex
|
|
427
|
+
return { chunkIdx: r.chunkIdx, firstIndex, count: arr.length };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The allocation's byte header offset (`ref`) of `av`'s entry in `chunkIdx`,
|
|
431
|
+
* or undefined if not allocated there. The heap compaction pass looks the
|
|
432
|
+
* region up in the arena remap by this ref to compute the draw's new
|
|
433
|
+
* `_indexStart`. */
|
|
434
|
+
baseFor(av: aval<Uint32Array>, chunkIdx: number): number | undefined {
|
|
435
|
+
return this.getBinding(av, chunkIdx)?.entry.ref;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Re-seat every entry after a compaction relocated its region. `remap` maps
|
|
439
|
+
* OLD→NEW byte header offset (`ref`); `firstIndex` is recomputed from the
|
|
440
|
+
* new ref. Entries are shared by value-dedup, so each is visited once. */
|
|
441
|
+
remapRefs(chunkIdx: number, remap: ReadonlyMap<number, number>): void {
|
|
442
|
+
if (remap.size === 0) return;
|
|
443
|
+
const seen = new Set<IndexPoolEntry>();
|
|
444
|
+
for (const v of this.byAval.values()) {
|
|
445
|
+
const bound = v instanceof Map ? v.get(chunkIdx) : (v.entry.chunkIdx === chunkIdx ? v : undefined);
|
|
446
|
+
if (bound === undefined || seen.has(bound.entry)) continue;
|
|
447
|
+
seen.add(bound.entry);
|
|
448
|
+
const nn = remap.get(bound.entry.ref);
|
|
449
|
+
if (nn !== undefined) {
|
|
450
|
+
bound.entry.ref = nn;
|
|
451
|
+
bound.entry.firstIndex = (nn + ALLOC_HEADER_PAD_TO) >>> 2;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
312
454
|
}
|
|
313
455
|
|
|
314
|
-
release(
|
|
315
|
-
const
|
|
316
|
-
const bound = byChunk?.get(chunkIdx);
|
|
456
|
+
release(arena: ChunkedAttributeArena, av: aval<Uint32Array>, chunkIdx: number): void {
|
|
457
|
+
const bound = this.getBinding(av, chunkIdx);
|
|
317
458
|
if (bound === undefined) return;
|
|
318
459
|
bound.perAvalCount--;
|
|
319
460
|
bound.entry.totalRefcount--;
|
|
320
|
-
if (bound.perAvalCount === 0)
|
|
321
|
-
byChunk!.delete(chunkIdx);
|
|
322
|
-
if (byChunk!.size === 0) this.byAval.delete(av);
|
|
323
|
-
}
|
|
461
|
+
if (bound.perAvalCount === 0) this.deleteBinding(av, chunkIdx);
|
|
324
462
|
if (bound.entry.totalRefcount > 0) return;
|
|
325
|
-
|
|
463
|
+
arena.release(bound.entry.chunkIdx, bound.entry.ref, bound.entry.count * 4);
|
|
326
464
|
if (bound.entry.valueKey !== undefined) {
|
|
327
465
|
this.byValueKey.delete(bound.entry.valueKey);
|
|
328
466
|
}
|
|
329
467
|
}
|
|
330
468
|
}
|
|
331
469
|
|
|
470
|
+
interface IndexBinding {
|
|
471
|
+
entry: IndexPoolEntry;
|
|
472
|
+
perAvalCount: number;
|
|
473
|
+
}
|
|
474
|
+
|
|
332
475
|
interface IndexPoolEntry {
|
|
333
476
|
chunkIdx: number;
|
|
477
|
+
/** Byte header offset of the region in the (attribute) arena — the unit the
|
|
478
|
+
* compaction remap is keyed by. */
|
|
479
|
+
ref: number;
|
|
480
|
+
/** Element (u32) offset to the index DATA (`(ref + 16) >> 2`) — the value
|
|
481
|
+
* the drawTable's `_indexStart` stores. */
|
|
334
482
|
firstIndex: number;
|
|
335
483
|
count: number;
|
|
336
484
|
totalRefcount: number;
|
|
@@ -387,52 +535,40 @@ export class DrawHeap {
|
|
|
387
535
|
*/
|
|
388
536
|
export class AttributeArena {
|
|
389
537
|
private cursor = 0;
|
|
538
|
+
/** Sum of `allocBytes` over all live allocations. `cursor - liveBytes`
|
|
539
|
+
* is the fragmentation waste below the high-water mark — what
|
|
540
|
+
* `compact()` reclaims. */
|
|
541
|
+
private liveBytes = 0;
|
|
390
542
|
private readonly freelist = new Freelist();
|
|
391
543
|
/** DEBUG: tracks every live allocation by start byte → size. Set
|
|
392
544
|
* on alloc, cleared on release. Used by `assertNoOverlap()` to
|
|
393
545
|
* detect allocator returning overlapping ranges. Enabled in
|
|
394
546
|
* development; cheap (one Map insert/delete per alloc). */
|
|
395
547
|
private readonly liveAllocs = new Map<number, number>();
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
});
|
|
409
|
-
}
|
|
548
|
+
// MIRROR-LESS: no CPU shadow of the arena. Writes go straight to
|
|
549
|
+
// `queue.writeBuffer` (queue-ordered with submits, so they serialize
|
|
550
|
+
// correctly against GrowBuffer's grow-copy and the compaction bounce —
|
|
551
|
+
// both of which preserve contents GPU-side). This halves the heap's
|
|
552
|
+
// host-memory footprint: the source-of-truth for values is the user's
|
|
553
|
+
// avals (they must stay alive — changeability is the point), and the
|
|
554
|
+
// GPU buffer is the render copy; a third full-size CPU mirror bought
|
|
555
|
+
// only dirty-range batching + debug readback comparisons.
|
|
556
|
+
constructor(
|
|
557
|
+
private readonly device: GPUDevice,
|
|
558
|
+
private readonly buf: GrowBuffer,
|
|
559
|
+
) {}
|
|
410
560
|
get buffer(): GPUBuffer { return this.buf.buffer; }
|
|
411
561
|
get capacity(): number { return this.buf.capacity; }
|
|
412
562
|
get usedBytes(): number { return this.cursor; }
|
|
413
|
-
/** Read 4 u32s from the CPU shadow starting at byte `off`. Used by
|
|
414
|
-
* the validator to compare against the GPU's read-back values:
|
|
415
|
-
* if CPU shadow is correct but GPU shows garbage → the corruption
|
|
416
|
-
* came from a GPU-side write (a compute kernel writing to a wrong
|
|
417
|
-
* byte). If both differ from what pool.acquire wrote → CPU-side
|
|
418
|
-
* write path is the culprit. */
|
|
419
|
-
peekShadowU32(off: number, count: number): Uint32Array {
|
|
420
|
-
return new Uint32Array(this.shadow.buffer, this.shadow.byteOffset + off, count);
|
|
421
|
-
}
|
|
422
563
|
write(dst: number, data: Uint8Array): void {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
if (this.dirty.isEmpty) return;
|
|
428
|
-
this.dirty.drain((start, end) => {
|
|
429
|
-
device.queue.writeBuffer(
|
|
430
|
-
this.buf.buffer, start,
|
|
431
|
-
this.shadow.buffer, this.shadow.byteOffset + start,
|
|
432
|
-
end - start,
|
|
433
|
-
);
|
|
434
|
-
});
|
|
564
|
+
// Immediate upload; Chrome's queue staging does the batching. The
|
|
565
|
+
// write targets the CURRENT buffer handle — a later grow copies it
|
|
566
|
+
// forward (queue-ordered), so ordering stays correct.
|
|
567
|
+
this.device.queue.writeBuffer(this.buf.buffer, dst, data as BufferSource);
|
|
435
568
|
}
|
|
569
|
+
/** No-op — kept for call-site compatibility (mirror-less arena uploads
|
|
570
|
+
* in `write`). */
|
|
571
|
+
flush(_device: GPUDevice): void {}
|
|
436
572
|
/**
|
|
437
573
|
* Allocate space for one attribute. Returns the byte ref (offset
|
|
438
574
|
* to the header — data lives at ref + 16).
|
|
@@ -446,6 +582,7 @@ export class AttributeArena {
|
|
|
446
582
|
const reused = this.freelist.alloc(allocBytes);
|
|
447
583
|
if (reused !== undefined) {
|
|
448
584
|
this.recordAlloc(reused, allocBytes, "freelist");
|
|
585
|
+
this.liveBytes += allocBytes;
|
|
449
586
|
return reused;
|
|
450
587
|
}
|
|
451
588
|
const next = this.cursor + allocBytes;
|
|
@@ -455,6 +592,7 @@ export class AttributeArena {
|
|
|
455
592
|
this.buf.setUsed(next);
|
|
456
593
|
const ref = next - allocBytes;
|
|
457
594
|
this.recordAlloc(ref, allocBytes, "bump");
|
|
595
|
+
this.liveBytes += allocBytes;
|
|
458
596
|
return ref;
|
|
459
597
|
}
|
|
460
598
|
alloc(dataBytes: number): number {
|
|
@@ -484,6 +622,7 @@ export class AttributeArena {
|
|
|
484
622
|
);
|
|
485
623
|
}
|
|
486
624
|
this.liveAllocs.delete(ref);
|
|
625
|
+
this.liveBytes -= allocBytes;
|
|
487
626
|
this.freelist.release(ref, allocBytes);
|
|
488
627
|
// §5: shrink the bump cursor back if release exposed a free
|
|
489
628
|
// tail touching it (cascading — `freelist.release` may have
|
|
@@ -514,111 +653,92 @@ export class AttributeArena {
|
|
|
514
653
|
}
|
|
515
654
|
this.liveAllocs.set(off, size);
|
|
516
655
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
656
|
+
/** Bytes the bump cursor can still grow into before hitting the chunk cap
|
|
657
|
+
* (ignores freelist holes — a conservative lower bound on free space, used
|
|
658
|
+
* by group placement to decide whether a draw's group fits here). */
|
|
659
|
+
get bumpHeadroom(): number { return this.buf.maxCapacity - this.cursor; }
|
|
660
|
+
/** Live bytes (sum of all live allocations, header+pad included). */
|
|
661
|
+
get liveByteCount(): number { return this.liveBytes; }
|
|
662
|
+
/** Fragmentation waste below the high-water mark: free bytes the tail-
|
|
663
|
+
* reclaim couldn't recover because they sit between live allocations. */
|
|
664
|
+
get wasteBytes(): number { return this.cursor - this.liveBytes; }
|
|
522
665
|
|
|
523
|
-
/**
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
this.dirty.add(dstByteOffset, dstByteOffset + data.byteLength);
|
|
548
|
-
}
|
|
549
|
-
flush(device: GPUDevice): void {
|
|
550
|
-
if (this.dirty.isEmpty) return;
|
|
551
|
-
this.dirty.drain((start, end) => {
|
|
552
|
-
device.queue.writeBuffer(
|
|
553
|
-
this.buf.buffer, start,
|
|
554
|
-
this.shadow.buffer, this.shadow.byteOffset + start,
|
|
555
|
-
end - start,
|
|
556
|
-
);
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
/** Non-throwing alloc — returns `undefined` when the request
|
|
560
|
-
* would exceed the underlying GrowBuffer's `maxCapacity` cap.
|
|
561
|
-
* Used by `ChunkedIndexAllocator` to probe before spilling. */
|
|
562
|
-
tryAlloc(elements: number): number | undefined {
|
|
563
|
-
const reused = this.freelist.alloc(elements);
|
|
564
|
-
if (reused !== undefined) {
|
|
565
|
-
this.recordAlloc(reused, elements, "freelist");
|
|
566
|
-
return reused;
|
|
666
|
+
/**
|
|
667
|
+
* Waste-triggered compaction. Relocates live allocations to the front of
|
|
668
|
+
* the buffer, eliminating the high-water ratchet that exact-size freelist
|
|
669
|
+
* reuse alone can't fix (a drifting allocation-size distribution never
|
|
670
|
+
* reuses its holes and grows toward the high-water forever).
|
|
671
|
+
*
|
|
672
|
+
* The byte move happens GPU-side via `copyBufferToBuffer` through a scratch
|
|
673
|
+
* buffer — no CPU→GPU re-upload — and is mirrored into the CPU shadow so it
|
|
674
|
+
* stays authoritative for future partial writes. Same-buffer copies can't
|
|
675
|
+
* overlap, hence the scratch round-trip.
|
|
676
|
+
*
|
|
677
|
+
* Returns a map of OLD→NEW byte offset for every allocation that moved; the
|
|
678
|
+
* caller must re-seat every cached ref (drawHeader cells, uniform-pool
|
|
679
|
+
* entries, derived-uniform handles, partition master refs). Returns an
|
|
680
|
+
* empty map when it declines to compact (waste below `wasteFloorBytes` or
|
|
681
|
+
* less than half the extent wasted).
|
|
682
|
+
*
|
|
683
|
+
* Precondition: the arena's pending shadow writes must already be flushed
|
|
684
|
+
* to the GPU buffer (the GPU copy reads the live buffer). O(live).
|
|
685
|
+
*/
|
|
686
|
+
compact(device: GPUDevice, wasteFloorBytes: number, force = false): Map<number, number> {
|
|
687
|
+
const waste = this.cursor - this.liveBytes;
|
|
688
|
+
if (!force && (this.liveBytes * 2 >= this.cursor || waste < wasteFloorBytes)) {
|
|
689
|
+
return new Map();
|
|
567
690
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
);
|
|
586
|
-
}
|
|
691
|
+
// Live allocations sorted by current offset.
|
|
692
|
+
const live = [...this.liveAllocs.entries()].sort((a, b) => a[0] - b[0]);
|
|
693
|
+
// Assign packed offsets (bump from 0); build the OLD→NEW remap and the
|
|
694
|
+
// source-contiguous runs to copy. A run is a maximal set of live blocks
|
|
695
|
+
// adjacent in the source; under the bump it lands dest-contiguous too, so
|
|
696
|
+
// the compacted prefix [0, newExtent) is fully covered by the runs.
|
|
697
|
+
const remap = new Map<number, number>();
|
|
698
|
+
const runs: { srcStart: number; dstStart: number; len: number }[] = [];
|
|
699
|
+
let w = 0;
|
|
700
|
+
let cur: { srcStart: number; dstStart: number; len: number } | undefined;
|
|
701
|
+
for (const [off, size] of live) {
|
|
702
|
+
if (w !== off) remap.set(off, w);
|
|
703
|
+
if (cur !== undefined && off === cur.srcStart + cur.len) {
|
|
704
|
+
cur.len += size;
|
|
705
|
+
} else {
|
|
706
|
+
cur = { srcStart: off, dstStart: w, len: size };
|
|
707
|
+
runs.push(cur);
|
|
587
708
|
}
|
|
709
|
+
w += size;
|
|
588
710
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
`IndexAllocator.release: off=${off} (elements=${elements}) was not in ` +
|
|
603
|
-
`liveAllocs — double-free or release for an alloc that never happened. ` +
|
|
604
|
-
`Live alloc count=${this.liveAllocs.size}.`,
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
if (tracked !== elements) {
|
|
608
|
-
throw new Error(
|
|
609
|
-
`IndexAllocator.release: size mismatch at off=${off} — recorded=${tracked} ` +
|
|
610
|
-
`but release elements=${elements}. Will corrupt the freelist.`,
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
this.liveAllocs.delete(off);
|
|
614
|
-
this.freelist.release(off, elements);
|
|
615
|
-
while (true) {
|
|
616
|
-
const top = this.freelist.takeBlockEndingAt(this.cursor);
|
|
617
|
-
if (top === undefined) break;
|
|
618
|
-
this.cursor = top.off;
|
|
619
|
-
this.buf.setUsed(this.cursor * 4);
|
|
711
|
+
const newExtent = w;
|
|
712
|
+
if (remap.size === 0) return remap; // already tight
|
|
713
|
+
|
|
714
|
+
// GPU move: arena → scratch (compacted), then scratch → arena[0,newExtent).
|
|
715
|
+
// Both copies are between distinct buffers, so no overlap constraint.
|
|
716
|
+
const scratch = device.createBuffer({
|
|
717
|
+
label: "AttributeArena/compact-scratch",
|
|
718
|
+
size: newExtent,
|
|
719
|
+
usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
|
|
720
|
+
});
|
|
721
|
+
const enc = device.createCommandEncoder({ label: "AttributeArena/compact" });
|
|
722
|
+
for (const r of runs) {
|
|
723
|
+
enc.copyBufferToBuffer(this.buf.buffer, r.srcStart, scratch, r.dstStart, r.len);
|
|
620
724
|
}
|
|
725
|
+
enc.copyBufferToBuffer(scratch, 0, this.buf.buffer, 0, newExtent);
|
|
726
|
+
device.queue.submit([enc.finish()]);
|
|
727
|
+
scratch.destroy();
|
|
728
|
+
|
|
729
|
+
// Rebuild allocator bookkeeping: live allocs at their new offsets, no free
|
|
730
|
+
// blocks (everything is now contiguous), cursor at the new high-water.
|
|
731
|
+
// (Mirror-less: the GPU bounce above IS the move — nothing else to sync.)
|
|
732
|
+
const moved: Array<[number, number]> = [];
|
|
733
|
+
for (const [off, size] of live) moved.push([remap.get(off) ?? off, size]);
|
|
734
|
+
this.liveAllocs.clear();
|
|
735
|
+
for (const [o, s] of moved) this.liveAllocs.set(o, s);
|
|
736
|
+
this.freelist.clear();
|
|
737
|
+
this.cursor = newExtent;
|
|
738
|
+
this.buf.setUsed(newExtent);
|
|
739
|
+
return remap;
|
|
621
740
|
}
|
|
741
|
+
|
|
622
742
|
onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
|
|
623
743
|
destroy(): void { this.buf.destroy(); }
|
|
624
744
|
}
|
|
@@ -626,17 +746,18 @@ export class IndexAllocator {
|
|
|
626
746
|
// ─── ArenaState + helpers ──────────────────────────────────────────────
|
|
627
747
|
|
|
628
748
|
/**
|
|
629
|
-
* Global arena state:
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
* time by which chunk's bind group is active.
|
|
749
|
+
* Global arena state: ONE chunked arena holds all heap data (per-draw
|
|
750
|
+
* uniforms, attributes, and index arrays) as multi-typed views over the same
|
|
751
|
+
* buffers. Each chunk owns a separate GPUBuffer + Freelist. Refs that
|
|
752
|
+
* downstream code stores are byte offsets within a chunk; the chunk identity
|
|
753
|
+
* is carried alongside (via `bucket.chunkIdx`) and made implicit at draw time
|
|
754
|
+
* by which chunk's bind group is active.
|
|
636
755
|
*/
|
|
637
756
|
export interface ArenaState {
|
|
638
|
-
|
|
639
|
-
|
|
757
|
+
/** The single arena holding ALL heap data — per-draw uniforms, vertex /
|
|
758
|
+
* instance attributes, AND index arrays (the heap VS storage-decodes
|
|
759
|
+
* indices, so they need no separate index buffer). */
|
|
760
|
+
readonly attrs: ChunkedAttributeArena;
|
|
640
761
|
}
|
|
641
762
|
|
|
642
763
|
export function buildArenaState(
|
|
@@ -655,19 +776,20 @@ export function buildArenaState(
|
|
|
655
776
|
// exercise multi-chunk routing).
|
|
656
777
|
const adapterCap = device.limits.maxStorageBufferBindingSize;
|
|
657
778
|
const cap = maxChunkBytes ?? Math.min(adapterCap, DEFAULT_MAX_BUFFER_BYTES);
|
|
779
|
+
void idxBytesHint; void idxExtraUsage; // legacy params (separate index buffer removed)
|
|
780
|
+
// Clamp the pre-size hint to the per-chunk cap: GrowBuffer RAISES its max
|
|
781
|
+
// to fit a larger initial size, which would mint an oversized page (and
|
|
782
|
+
// §7 derived-record handles cap page offsets at 2^29 B = 512 MB). A
|
|
783
|
+
// multi-page scene simply pre-sizes each page at the cap as it opens.
|
|
658
784
|
const attrs = new ChunkedAttributeArena(
|
|
659
|
-
device, `${label}/
|
|
660
|
-
attrBytesHint, cap,
|
|
661
|
-
);
|
|
662
|
-
const indices = new ChunkedIndexAllocator(
|
|
663
|
-
device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage,
|
|
664
|
-
idxBytesHint, cap,
|
|
785
|
+
device, `${label}/arena`, GPUBufferUsage.STORAGE,
|
|
786
|
+
Math.min(attrBytesHint, cap), cap,
|
|
665
787
|
);
|
|
666
|
-
return { attrs
|
|
788
|
+
return { attrs };
|
|
667
789
|
}
|
|
668
790
|
|
|
669
791
|
export function arenaBytes(arena: ArenaState): number {
|
|
670
|
-
return arena.attrs.totalUsedBytes()
|
|
792
|
+
return arena.attrs.totalUsedBytes();
|
|
671
793
|
}
|
|
672
794
|
|
|
673
795
|
/** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */
|