@aardworx/wombat.rendering 0.9.33 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/renderObject.d.ts +16 -0
- package/dist/core/renderObject.d.ts.map +1 -1
- package/dist/runtime/derivedModes/kernelCodegen.d.ts +90 -0
- package/dist/runtime/derivedModes/kernelCodegen.d.ts.map +1 -0
- package/dist/runtime/derivedModes/kernelCodegen.js +623 -0
- package/dist/runtime/derivedModes/kernelCodegen.js.map +1 -0
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
- package/dist/runtime/derivedModes/modeKeyCpu.js +23 -67
- package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts +79 -0
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partitionDispatcher.js +303 -0
- package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -0
- package/dist/runtime/derivedModes/partitionKernel.d.ts +12 -0
- package/dist/runtime/derivedModes/partitionKernel.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partitionKernel.js +127 -0
- package/dist/runtime/derivedModes/partitionKernel.js.map +1 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.d.ts +4 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.js +24 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.js.map +1 -0
- package/dist/runtime/derivedModes/rule.d.ts +38 -86
- package/dist/runtime/derivedModes/rule.d.ts.map +1 -1
- package/dist/runtime/derivedModes/rule.js +34 -74
- package/dist/runtime/derivedModes/rule.js.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts +10 -8
- package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.js +31 -18
- package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
- package/dist/runtime/derivedUniforms/marker.d.ts +90 -11
- package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/marker.js +161 -15
- package/dist/runtime/derivedUniforms/marker.js.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +25 -6
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.js +61 -20
- package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
- package/dist/runtime/heapAdapter.d.ts.map +1 -1
- package/dist/runtime/heapAdapter.js +17 -0
- package/dist/runtime/heapAdapter.js.map +1 -1
- package/dist/runtime/heapEligibility.d.ts.map +1 -1
- package/dist/runtime/heapEligibility.js +22 -2
- package/dist/runtime/heapEligibility.js.map +1 -1
- package/dist/runtime/heapScene/chunkedArena.d.ts +76 -0
- package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -0
- package/dist/runtime/heapScene/chunkedArena.js +223 -0
- package/dist/runtime/heapScene/chunkedArena.js.map +1 -0
- package/dist/runtime/heapScene/freelist.d.ts +55 -0
- package/dist/runtime/heapScene/freelist.d.ts.map +1 -0
- package/dist/runtime/heapScene/freelist.js +188 -0
- package/dist/runtime/heapScene/freelist.js.map +1 -0
- package/dist/runtime/heapScene/growBuffer.d.ts +23 -2
- package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -1
- package/dist/runtime/heapScene/growBuffer.js +35 -3
- package/dist/runtime/heapScene/growBuffer.js.map +1 -1
- package/dist/runtime/heapScene/pools.d.ts +84 -42
- package/dist/runtime/heapScene/pools.d.ts.map +1 -1
- package/dist/runtime/heapScene/pools.js +309 -134
- package/dist/runtime/heapScene/pools.js.map +1 -1
- package/dist/runtime/heapScene.d.ts +24 -0
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +1158 -92
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/hybridScene.d.ts +15 -0
- package/dist/runtime/hybridScene.d.ts.map +1 -1
- package/dist/runtime/hybridScene.js +16 -0
- package/dist/runtime/hybridScene.js.map +1 -1
- package/dist/runtime/index.d.ts +4 -3
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +4 -3
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/renderTask.d.ts +5 -0
- package/dist/runtime/renderTask.d.ts.map +1 -1
- package/dist/runtime/renderTask.js +1 -0
- package/dist/runtime/renderTask.js.map +1 -1
- package/dist/runtime/runtime.d.ts +6 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +1 -0
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.d.ts +12 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.js +20 -0
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
- package/package.json +1 -1
- package/src/core/renderObject.ts +16 -0
- package/src/runtime/derivedModes/kernelCodegen.ts +673 -0
- package/src/runtime/derivedModes/modeKeyCpu.ts +22 -60
- package/src/runtime/derivedModes/partitionDispatcher.ts +349 -0
- package/src/runtime/derivedModes/partitionKernelLayout.ts +25 -0
- package/src/runtime/derivedModes/rule.ts +66 -145
- package/src/runtime/derivedUniforms/dispatch.ts +48 -21
- package/src/runtime/derivedUniforms/marker.ts +210 -16
- package/src/runtime/derivedUniforms/sceneIntegration.ts +67 -20
- package/src/runtime/heapAdapter.ts +17 -0
- package/src/runtime/heapEligibility.ts +22 -1
- package/src/runtime/heapScene/chunkedArena.ts +238 -0
- package/src/runtime/heapScene/freelist.ts +196 -0
- package/src/runtime/heapScene/growBuffer.ts +39 -2
- package/src/runtime/heapScene/pools.ts +350 -143
- package/src/runtime/heapScene.ts +1350 -88
- package/src/runtime/hybridScene.ts +31 -0
- package/src/runtime/index.ts +12 -9
- package/src/runtime/renderTask.ts +6 -0
- package/src/runtime/runtime.ts +7 -0
- package/src/runtime/textureAtlas/atlasPool.ts +19 -0
- package/src/runtime/derivedModes/gpuDispatcher.ts +0 -226
- package/src/runtime/derivedModes/gpuKernel.ts +0 -98
|
@@ -19,7 +19,9 @@ import { AVal } from "@aardworx/wombat.adaptive";
|
|
|
19
19
|
import type { aval, IDisposable } from "@aardworx/wombat.adaptive";
|
|
20
20
|
import type { BufferView } from "../../core/bufferView.js";
|
|
21
21
|
import type { HostBufferSource } from "../../core/buffer.js";
|
|
22
|
-
import { GrowBuffer, ALIGN16 } from "./growBuffer.js";
|
|
22
|
+
import { GrowBuffer, ALIGN16, DEFAULT_MAX_BUFFER_BYTES } from "./growBuffer.js";
|
|
23
|
+
import { Freelist } from "./freelist.js";
|
|
24
|
+
import { ChunkedAttributeArena, ChunkedIndexAllocator } from "./chunkedArena.js";
|
|
23
25
|
|
|
24
26
|
// ─── Per-allocation header layout ──────────────────────────────────────
|
|
25
27
|
|
|
@@ -39,100 +41,171 @@ export const SEM_NORMALS = 2;
|
|
|
39
41
|
|
|
40
42
|
// ─── UniformPool ───────────────────────────────────────────────────────
|
|
41
43
|
|
|
44
|
+
/**
|
|
45
|
+
* One pool entry tracks a single (aval, chunkIdx) pair. Refs are
|
|
46
|
+
* byte offsets within the chunk's GPUBuffer; chunkIdx is implicit
|
|
47
|
+
* in which chunk's bind group is active at draw time (shaders
|
|
48
|
+
* never decode it).
|
|
49
|
+
*/
|
|
42
50
|
interface PoolEntry {
|
|
43
|
-
|
|
51
|
+
readonly chunkIdx: number;
|
|
44
52
|
readonly ref: number;
|
|
45
53
|
readonly dataBytes: number;
|
|
46
54
|
readonly typeId: number;
|
|
47
|
-
/** Packer used to refresh the data region on aval marks. */
|
|
48
55
|
readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
|
|
49
56
|
refcount: number;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
|
-
*
|
|
54
|
-
* aval
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* (
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* Sharing emerges from aval identity — no separate "frequency"
|
|
61
|
-
* declaration needed. A `cval` shared by all draws → 1 alloc.
|
|
62
|
-
* A static positions array shared across instanced draws → 1 alloc.
|
|
63
|
-
* Same code path either way.
|
|
60
|
+
* `(aval, chunkIdx)`-keyed pool of chunked-arena allocations. An
|
|
61
|
+
* aval that's referenced from multiple chunks (e.g. two ROs sharing
|
|
62
|
+
* a uniform but landing in different chunks) gets one entry per
|
|
63
|
+
* chunk — accepted duplication cost of the multi-draw-call
|
|
64
|
+
* per-chunk render path (§3). Within a single chunk, ROs sharing
|
|
65
|
+
* an aval still share the underlying allocation.
|
|
64
66
|
*/
|
|
65
67
|
export class UniformPool {
|
|
66
|
-
|
|
67
|
-
// keys are overwhelmingly reactive `cval`s (per-object trafos,
|
|
68
|
-
// colours, …) and the hot path is `acquire`/`release` ~once per
|
|
69
|
-
// drawHeader field per RO.
|
|
70
|
-
private readonly byAval = new Map<aval<unknown>, PoolEntry>();
|
|
68
|
+
private readonly byAval = new Map<aval<unknown>, Map<number, PoolEntry>>();
|
|
71
69
|
|
|
72
|
-
has(av: aval<unknown
|
|
73
|
-
|
|
70
|
+
has(av: aval<unknown>, chunkIdx: number): boolean {
|
|
71
|
+
return this.byAval.get(av)?.has(chunkIdx) ?? false;
|
|
72
|
+
}
|
|
73
|
+
/** True when the pool holds at least one entry for `av` in any
|
|
74
|
+
* chunk. Used by the scene's `inputChanged` dispatch to decide
|
|
75
|
+
* whether a marking aval is one of our tracked allocations. */
|
|
76
|
+
hasAny(av: aval<unknown>): boolean {
|
|
77
|
+
return (this.byAval.get(av)?.size ?? 0) > 0;
|
|
78
|
+
}
|
|
79
|
+
entry(av: aval<unknown>, chunkIdx: number): PoolEntry | undefined {
|
|
80
|
+
return this.byAval.get(av)?.get(chunkIdx);
|
|
81
|
+
}
|
|
82
|
+
/** First chunk this aval is allocated in, if any. Used by `addRO`
|
|
83
|
+
* to prefer co-locating a new RO with its already-allocated
|
|
84
|
+
* shared inputs (avoids unnecessary duplication). */
|
|
85
|
+
firstChunkContaining(av: aval<unknown>): number | undefined {
|
|
86
|
+
const byChunk = this.byAval.get(av);
|
|
87
|
+
if (byChunk === undefined || byChunk.size === 0) return undefined;
|
|
88
|
+
return byChunk.keys().next().value as number;
|
|
89
|
+
}
|
|
74
90
|
|
|
75
91
|
/**
|
|
76
|
-
* Acquire (or share) an allocation for `aval
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* uploaded immediately.
|
|
92
|
+
* Acquire (or share) an allocation for `aval` in `chunkIdx`.
|
|
93
|
+
* Returns the byte offset within that chunk's GPUBuffer. If a
|
|
94
|
+
* fresh allocation is made, the value is packed + uploaded
|
|
95
|
+
* immediately into the chunk's CPU shadow.
|
|
81
96
|
*/
|
|
82
97
|
acquire(
|
|
83
98
|
device: GPUDevice,
|
|
84
|
-
arena:
|
|
99
|
+
arena: ChunkedAttributeArena,
|
|
85
100
|
av: aval<unknown>,
|
|
101
|
+
chunkIdx: number,
|
|
86
102
|
value: unknown,
|
|
87
103
|
dataBytes: number,
|
|
88
104
|
typeId: number,
|
|
89
105
|
length: number,
|
|
90
106
|
pack: (val: unknown, dst: Float32Array, off: number) => void,
|
|
91
107
|
): number {
|
|
92
|
-
|
|
108
|
+
let byChunk = this.byAval.get(av);
|
|
109
|
+
const existing = byChunk?.get(chunkIdx);
|
|
93
110
|
if (existing !== undefined) {
|
|
94
111
|
existing.refcount++;
|
|
95
112
|
return existing.ref;
|
|
96
113
|
}
|
|
97
|
-
const
|
|
114
|
+
const r = arena.alloc(dataBytes, chunkIdx);
|
|
115
|
+
// `arena.alloc` may spill to a different chunk if `chunkIdx`'s
|
|
116
|
+
// GrowBuffer is at its maxCapacity. Callers (heapScene.addRO)
|
|
117
|
+
// bind a bucket's bind groups to a single chunk's buffer — a
|
|
118
|
+
// silent spill makes the bucket's drawHeader refs point into
|
|
119
|
+
// the wrong chunk's buffer (garbage reads / typeId corruption).
|
|
120
|
+
//
|
|
121
|
+
// Hard-fail with diagnostic info instead of returning quietly.
|
|
122
|
+
// The right long-term fix is for addRO to pre-probe the chunk,
|
|
123
|
+
// open a new bucket bound to the spill chunk, and re-route; but
|
|
124
|
+
// until that lands, a clear error beats unexplained artefacts.
|
|
125
|
+
if (r.chunkIdx !== chunkIdx) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`UniformPool.acquire: allocator spilled from chunk ${chunkIdx} to chunk ${r.chunkIdx} ` +
|
|
128
|
+
`(${dataBytes} bytes). Caller's bucket is bound to chunk ${chunkIdx}; this would silently ` +
|
|
129
|
+
`corrupt the drawHeader→arena reads. Open a new bucket bound to chunk ${r.chunkIdx} instead.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const finalChunk = r.chunkIdx;
|
|
98
133
|
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
99
134
|
const buf = new ArrayBuffer(allocBytes);
|
|
100
135
|
const u32 = new Uint32Array(buf);
|
|
101
136
|
const f32 = new Float32Array(buf);
|
|
102
137
|
u32[0] = typeId;
|
|
103
138
|
u32[1] = length;
|
|
104
|
-
// stride_bytes (offset 8): bytes per element. Lets the VS decode
|
|
105
|
-
// pick V3- vs V4-tight load expressions for vec4 attributes.
|
|
106
139
|
u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
|
|
107
140
|
pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
|
|
108
|
-
arena.write(
|
|
141
|
+
arena.write(finalChunk, r.off, new Uint8Array(buf));
|
|
109
142
|
void device;
|
|
110
|
-
|
|
111
|
-
|
|
143
|
+
if (byChunk === undefined) {
|
|
144
|
+
byChunk = new Map();
|
|
145
|
+
this.byAval.set(av, byChunk);
|
|
146
|
+
}
|
|
147
|
+
byChunk.set(finalChunk, {
|
|
148
|
+
chunkIdx: finalChunk, ref: r.off, dataBytes, typeId, pack, refcount: 1,
|
|
149
|
+
});
|
|
150
|
+
return r.off;
|
|
112
151
|
}
|
|
113
152
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const e =
|
|
153
|
+
release(arena: ChunkedAttributeArena, av: aval<unknown>, chunkIdx: number): void {
|
|
154
|
+
const byChunk = this.byAval.get(av);
|
|
155
|
+
const e = byChunk?.get(chunkIdx);
|
|
117
156
|
if (e === undefined) return;
|
|
118
157
|
e.refcount--;
|
|
119
158
|
if (e.refcount > 0) return;
|
|
120
|
-
arena.release
|
|
121
|
-
|
|
159
|
+
// BUG FIX: arena.release's third arg is `dataBytes` (raw), and the
|
|
160
|
+
// chunk's AttributeArena.release internally adds the header padding
|
|
161
|
+
// via `ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes)`. We MUST pass the
|
|
162
|
+
// raw data size here — passing pre-aligned `allocBytes` (the old
|
|
163
|
+
// code) double-pads, freeing 16 extra bytes per release. The freed
|
|
164
|
+
// surplus then gets coalesced with the neighbouring scalar-uniform
|
|
165
|
+
// allocs (any 32 B u32/f32) into a single combined block, and the
|
|
166
|
+
// next attribute alloc handed out from that pool ends up STARTING
|
|
167
|
+
// 16 B inside the previous alloc → overlapping allocations →
|
|
168
|
+
// garbage `typeId`/`length` in the attribute header.
|
|
169
|
+
arena.release(e.chunkIdx, e.ref, e.dataBytes);
|
|
170
|
+
byChunk!.delete(chunkIdx);
|
|
171
|
+
if (byChunk!.size === 0) this.byAval.delete(av);
|
|
122
172
|
}
|
|
123
173
|
|
|
124
|
-
/** Re-pack one entry's data region into
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
arena
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
174
|
+
/** Re-pack one entry's data region into every chunk that holds
|
|
175
|
+
* this aval. When an aval is shared across N chunks (each chunk
|
|
176
|
+
* has its own duplicate alloc), every duplicate needs the
|
|
177
|
+
* refresh so all the chunks' ROs see the new value. */
|
|
178
|
+
repack(
|
|
179
|
+
device: GPUDevice,
|
|
180
|
+
arena: ChunkedAttributeArena,
|
|
181
|
+
av: aval<unknown>,
|
|
182
|
+
val: unknown,
|
|
183
|
+
): void {
|
|
184
|
+
const byChunk = this.byAval.get(av);
|
|
185
|
+
if (byChunk === undefined) return;
|
|
186
|
+
let dst: Float32Array | undefined;
|
|
187
|
+
for (const e of byChunk.values()) {
|
|
188
|
+
if (dst === undefined || dst.length !== e.dataBytes / 4) {
|
|
189
|
+
dst = new Float32Array(e.dataBytes / 4);
|
|
190
|
+
}
|
|
191
|
+
e.pack(val, dst, 0);
|
|
192
|
+
arena.write(
|
|
193
|
+
e.chunkIdx,
|
|
194
|
+
e.ref + ALLOC_HEADER_PAD_TO,
|
|
195
|
+
new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
134
198
|
void device;
|
|
135
199
|
}
|
|
200
|
+
/** Total bytes touched by `repack` for this aval — sum across
|
|
201
|
+
* chunks. Used by the dirty-bytes diagnostics. */
|
|
202
|
+
totalDataBytes(av: aval<unknown>): number {
|
|
203
|
+
const byChunk = this.byAval.get(av);
|
|
204
|
+
if (byChunk === undefined) return 0;
|
|
205
|
+
let s = 0;
|
|
206
|
+
for (const e of byChunk.values()) s += e.dataBytes;
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
136
209
|
}
|
|
137
210
|
|
|
138
211
|
// ─── IndexPool ─────────────────────────────────────────────────────────
|
|
@@ -150,10 +223,13 @@ export class UniformPool {
|
|
|
150
223
|
* `Uint32Array` view collapse to one allocation.
|
|
151
224
|
*/
|
|
152
225
|
export class IndexPool {
|
|
226
|
+
/** Per-aval, per-chunk bookkeeping. Same aval acquired in two
|
|
227
|
+
* chunks gets two entries (accepted §3 duplication). */
|
|
153
228
|
private readonly byAval = new Map<
|
|
154
229
|
aval<Uint32Array>,
|
|
155
|
-
{ entry: IndexPoolEntry; perAvalCount: number }
|
|
230
|
+
Map<number, { entry: IndexPoolEntry; perAvalCount: number }>
|
|
156
231
|
>();
|
|
232
|
+
/** Per-chunk constant-aval value-dedup (§5b in-chunk). */
|
|
157
233
|
private readonly byValueKey = new Map<string, IndexPoolEntry>();
|
|
158
234
|
private readonly bufferIds = new WeakMap<ArrayBufferLike, number>();
|
|
159
235
|
private nextBufferId = 1;
|
|
@@ -166,48 +242,75 @@ export class IndexPool {
|
|
|
166
242
|
return id;
|
|
167
243
|
}
|
|
168
244
|
|
|
245
|
+
firstChunkContaining(av: aval<Uint32Array>): number | undefined {
|
|
246
|
+
const byChunk = this.byAval.get(av);
|
|
247
|
+
if (byChunk === undefined || byChunk.size === 0) return undefined;
|
|
248
|
+
return byChunk.keys().next().value as number;
|
|
249
|
+
}
|
|
250
|
+
|
|
169
251
|
acquire(
|
|
170
252
|
device: GPUDevice,
|
|
171
|
-
indices:
|
|
253
|
+
indices: ChunkedIndexAllocator,
|
|
172
254
|
av: aval<Uint32Array>,
|
|
255
|
+
chunkIdx: number,
|
|
173
256
|
arr: Uint32Array,
|
|
174
|
-
): { firstIndex: number; count: number } {
|
|
175
|
-
|
|
257
|
+
): { chunkIdx: number; firstIndex: number; count: number } {
|
|
258
|
+
let byChunk = this.byAval.get(av);
|
|
259
|
+
const bound = byChunk?.get(chunkIdx);
|
|
176
260
|
if (bound !== undefined) {
|
|
177
261
|
bound.perAvalCount++;
|
|
178
262
|
bound.entry.totalRefcount++;
|
|
179
|
-
return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
|
|
263
|
+
return { chunkIdx: bound.entry.chunkIdx, firstIndex: bound.entry.firstIndex, count: bound.entry.count };
|
|
180
264
|
}
|
|
181
265
|
let valueKey: string | undefined;
|
|
182
266
|
if (av.isConstant) {
|
|
183
|
-
|
|
267
|
+
// Value-dedup is scoped per chunk — two constant avals in the
|
|
268
|
+
// same chunk collapse to one allocation; two constant avals in
|
|
269
|
+
// DIFFERENT chunks each get their own (we can't share across
|
|
270
|
+
// chunks since the index range lives in the chunk's GPUBuffer).
|
|
271
|
+
valueKey = `${chunkIdx}:${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
|
|
184
272
|
const shared = this.byValueKey.get(valueKey);
|
|
185
273
|
if (shared !== undefined) {
|
|
186
274
|
shared.totalRefcount++;
|
|
187
|
-
|
|
188
|
-
|
|
275
|
+
if (byChunk === undefined) {
|
|
276
|
+
byChunk = new Map();
|
|
277
|
+
this.byAval.set(av, byChunk);
|
|
278
|
+
}
|
|
279
|
+
byChunk.set(chunkIdx, { entry: shared, perAvalCount: 1 });
|
|
280
|
+
return { chunkIdx: shared.chunkIdx, firstIndex: shared.firstIndex, count: shared.count };
|
|
189
281
|
}
|
|
190
282
|
}
|
|
191
|
-
const
|
|
283
|
+
const r = indices.alloc(arr.length, chunkIdx);
|
|
192
284
|
indices.write(
|
|
193
|
-
|
|
285
|
+
r.chunkIdx, r.off * 4,
|
|
194
286
|
new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength),
|
|
195
287
|
);
|
|
196
288
|
void device;
|
|
197
|
-
const entry: IndexPoolEntry = {
|
|
198
|
-
|
|
289
|
+
const entry: IndexPoolEntry = {
|
|
290
|
+
chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length,
|
|
291
|
+
totalRefcount: 1, valueKey,
|
|
292
|
+
};
|
|
293
|
+
if (byChunk === undefined) {
|
|
294
|
+
byChunk = new Map();
|
|
295
|
+
this.byAval.set(av, byChunk);
|
|
296
|
+
}
|
|
297
|
+
byChunk.set(r.chunkIdx, { entry, perAvalCount: 1 });
|
|
199
298
|
if (valueKey !== undefined) this.byValueKey.set(valueKey, entry);
|
|
200
|
-
return { firstIndex, count: arr.length };
|
|
299
|
+
return { chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length };
|
|
201
300
|
}
|
|
202
301
|
|
|
203
|
-
release(indices:
|
|
204
|
-
const
|
|
302
|
+
release(indices: ChunkedIndexAllocator, av: aval<Uint32Array>, chunkIdx: number): void {
|
|
303
|
+
const byChunk = this.byAval.get(av);
|
|
304
|
+
const bound = byChunk?.get(chunkIdx);
|
|
205
305
|
if (bound === undefined) return;
|
|
206
306
|
bound.perAvalCount--;
|
|
207
307
|
bound.entry.totalRefcount--;
|
|
208
|
-
if (bound.perAvalCount === 0)
|
|
308
|
+
if (bound.perAvalCount === 0) {
|
|
309
|
+
byChunk!.delete(chunkIdx);
|
|
310
|
+
if (byChunk!.size === 0) this.byAval.delete(av);
|
|
311
|
+
}
|
|
209
312
|
if (bound.entry.totalRefcount > 0) return;
|
|
210
|
-
indices.release(bound.entry.firstIndex, bound.entry.count);
|
|
313
|
+
indices.release(bound.entry.chunkIdx, bound.entry.firstIndex, bound.entry.count);
|
|
211
314
|
if (bound.entry.valueKey !== undefined) {
|
|
212
315
|
this.byValueKey.delete(bound.entry.valueKey);
|
|
213
316
|
}
|
|
@@ -215,6 +318,7 @@ export class IndexPool {
|
|
|
215
318
|
}
|
|
216
319
|
|
|
217
320
|
interface IndexPoolEntry {
|
|
321
|
+
chunkIdx: number;
|
|
218
322
|
firstIndex: number;
|
|
219
323
|
count: number;
|
|
220
324
|
totalRefcount: number;
|
|
@@ -232,6 +336,8 @@ interface IndexPoolEntry {
|
|
|
232
336
|
export class DrawHeap {
|
|
233
337
|
private free: number[] = [];
|
|
234
338
|
private nextSlot = 0;
|
|
339
|
+
/** DEBUG: tracks slot live/free state. true = live (in use). */
|
|
340
|
+
private readonly live = new Set<number>();
|
|
235
341
|
constructor(private readonly buf: GrowBuffer, private readonly slotBytes: number) {}
|
|
236
342
|
get buffer(): GPUBuffer { return this.buf.buffer; }
|
|
237
343
|
/** Bytes per slot — caller multiplies by slot index for byte offsets. */
|
|
@@ -240,11 +346,21 @@ export class DrawHeap {
|
|
|
240
346
|
get usedBytes(): number { return this.nextSlot * this.slotBytes; }
|
|
241
347
|
alloc(): number {
|
|
242
348
|
const slot = this.free.length > 0 ? this.free.pop()! : this.nextSlot++;
|
|
349
|
+
if (this.live.has(slot)) {
|
|
350
|
+
throw new Error(`DrawHeap.alloc: returned already-live slot ${slot}`);
|
|
351
|
+
}
|
|
352
|
+
this.live.add(slot);
|
|
243
353
|
this.buf.ensureCapacity((slot + 1) * this.slotBytes);
|
|
244
354
|
this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
|
|
245
355
|
return slot;
|
|
246
356
|
}
|
|
247
|
-
release(slot: number): void {
|
|
357
|
+
release(slot: number): void {
|
|
358
|
+
if (!this.live.has(slot)) {
|
|
359
|
+
throw new Error(`DrawHeap.release: slot ${slot} was not live — double-release or release-of-never-allocated.`);
|
|
360
|
+
}
|
|
361
|
+
this.live.delete(slot);
|
|
362
|
+
this.free.push(slot);
|
|
363
|
+
}
|
|
248
364
|
onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
|
|
249
365
|
destroy(): void { this.buf.destroy(); }
|
|
250
366
|
}
|
|
@@ -259,7 +375,12 @@ export class DrawHeap {
|
|
|
259
375
|
*/
|
|
260
376
|
export class AttributeArena {
|
|
261
377
|
private cursor = 0;
|
|
262
|
-
private
|
|
378
|
+
private readonly freelist = new Freelist();
|
|
379
|
+
/** DEBUG: tracks every live allocation by start byte → size. Set
|
|
380
|
+
* on alloc, cleared on release. Used by `assertNoOverlap()` to
|
|
381
|
+
* detect allocator returning overlapping ranges. Enabled in
|
|
382
|
+
* development; cheap (one Map insert/delete per alloc). */
|
|
383
|
+
private readonly liveAllocs = new Map<number, number>();
|
|
263
384
|
/** CPU shadow of the entire GPU buffer; writes go here first then
|
|
264
385
|
* flush() emits one writeBuffer per dirty contiguous range. */
|
|
265
386
|
private shadow: Uint8Array;
|
|
@@ -276,6 +397,15 @@ export class AttributeArena {
|
|
|
276
397
|
get buffer(): GPUBuffer { return this.buf.buffer; }
|
|
277
398
|
get capacity(): number { return this.buf.capacity; }
|
|
278
399
|
get usedBytes(): number { return this.cursor; }
|
|
400
|
+
/** Read 4 u32s from the CPU shadow starting at byte `off`. Used by
|
|
401
|
+
* the validator to compare against the GPU's read-back values:
|
|
402
|
+
* if CPU shadow is correct but GPU shows garbage → the corruption
|
|
403
|
+
* came from a GPU-side write (a compute kernel writing to a wrong
|
|
404
|
+
* byte). If both differ from what pool.acquire wrote → CPU-side
|
|
405
|
+
* write path is the culprit. */
|
|
406
|
+
peekShadowU32(off: number, count: number): Uint32Array {
|
|
407
|
+
return new Uint32Array(this.shadow.buffer, this.shadow.byteOffset + off, count);
|
|
408
|
+
}
|
|
279
409
|
write(dst: number, data: Uint8Array): void {
|
|
280
410
|
this.shadow.set(data, dst);
|
|
281
411
|
if (dst < this.dirtyMin) this.dirtyMin = dst;
|
|
@@ -296,64 +426,84 @@ export class AttributeArena {
|
|
|
296
426
|
* Allocate space for one attribute. Returns the byte ref (offset
|
|
297
427
|
* to the header — data lives at ref + 16).
|
|
298
428
|
*/
|
|
299
|
-
alloc
|
|
429
|
+
/** Non-throwing alloc — returns `undefined` when the request
|
|
430
|
+
* would exceed the underlying GrowBuffer's `maxCapacity` cap.
|
|
431
|
+
* Used by `ChunkedAttributeArena` to probe before spilling to
|
|
432
|
+
* the next chunk. */
|
|
433
|
+
tryAlloc(dataBytes: number): number | undefined {
|
|
300
434
|
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const ref = f.off;
|
|
306
|
-
if (f.size === allocBytes) this.freeList.splice(i, 1);
|
|
307
|
-
else { f.off += allocBytes; f.size -= allocBytes; }
|
|
308
|
-
return ref;
|
|
309
|
-
}
|
|
435
|
+
const reused = this.freelist.alloc(allocBytes);
|
|
436
|
+
if (reused !== undefined) {
|
|
437
|
+
this.recordAlloc(reused, allocBytes, "freelist");
|
|
438
|
+
return reused;
|
|
310
439
|
}
|
|
311
|
-
const
|
|
312
|
-
this.
|
|
313
|
-
this.
|
|
314
|
-
this.buf.
|
|
440
|
+
const next = this.cursor + allocBytes;
|
|
441
|
+
if (next > this.buf.maxCapacity) return undefined;
|
|
442
|
+
this.cursor = next;
|
|
443
|
+
this.buf.ensureCapacity(next);
|
|
444
|
+
this.buf.setUsed(next);
|
|
445
|
+
const ref = next - allocBytes;
|
|
446
|
+
this.recordAlloc(ref, allocBytes, "bump");
|
|
315
447
|
return ref;
|
|
316
448
|
}
|
|
449
|
+
alloc(dataBytes: number): number {
|
|
450
|
+
const r = this.tryAlloc(dataBytes);
|
|
451
|
+
if (r === undefined) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
`AttributeArena: allocation of ${dataBytes}B (with header alignment) exceeds chunk cap`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
return r;
|
|
457
|
+
}
|
|
317
458
|
release(ref: number, dataBytes: number): void {
|
|
318
459
|
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
prev.size += next.size;
|
|
346
|
-
freeList.splice(lo, 1);
|
|
460
|
+
const tracked = this.liveAllocs.get(ref);
|
|
461
|
+
if (tracked === undefined) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`AttributeArena.release: ref=${ref} (size=${allocBytes}) was not in liveAllocs — ` +
|
|
464
|
+
`double-free or release for an alloc that never happened. ` +
|
|
465
|
+
`Live alloc count=${this.liveAllocs.size}.`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (tracked !== allocBytes) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`AttributeArena.release: size mismatch at ref=${ref} — recorded=${tracked} but ` +
|
|
471
|
+
`release args→ allocBytes=${allocBytes} (dataBytes=${dataBytes}). ` +
|
|
472
|
+
`Caller is releasing the wrong size; this WILL corrupt the freelist via over- or under-shrinking.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
this.liveAllocs.delete(ref);
|
|
476
|
+
this.freelist.release(ref, allocBytes);
|
|
477
|
+
// §5: shrink the bump cursor back if release exposed a free
|
|
478
|
+
// tail touching it (cascading — `freelist.release` may have
|
|
479
|
+
// coalesced into a single block ending at `cursor`). Reclaims
|
|
480
|
+
// the high-watermark over time in long-lived high-churn scenes.
|
|
481
|
+
while (true) {
|
|
482
|
+
const top = this.freelist.takeBlockEndingAt(this.cursor);
|
|
483
|
+
if (top === undefined) break;
|
|
484
|
+
this.cursor = top.off;
|
|
485
|
+
this.buf.setUsed(this.cursor);
|
|
347
486
|
}
|
|
348
|
-
return;
|
|
349
487
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
488
|
+
/** Throws if the just-returned alloc overlaps any live allocation. */
|
|
489
|
+
private recordAlloc(off: number, size: number, source: string): void {
|
|
490
|
+
// Check against every existing live alloc — O(N). Cheap while N
|
|
491
|
+
// is small (per-tile demos run with ~hundreds of allocs). Wire
|
|
492
|
+
// off behind an env-flag if it ever becomes a hot path.
|
|
493
|
+
for (const [liveOff, liveSize] of this.liveAllocs) {
|
|
494
|
+
// [a, a+sa) vs [b, b+sb) overlap iff a < b+sb && b < a+sa
|
|
495
|
+
if (off < liveOff + liveSize && liveOff < off + size) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`AttributeArena.tryAlloc(${source}): returned [${off},${off + size}) ` +
|
|
498
|
+
`(size=${size}) overlaps live alloc at [${liveOff},${liveOff + liveSize}) ` +
|
|
499
|
+
`(size=${liveSize}). Allocator is handing out shared memory!`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
this.liveAllocs.set(off, size);
|
|
355
504
|
}
|
|
356
|
-
|
|
505
|
+
onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
|
|
506
|
+
destroy(): void { this.buf.destroy(); }
|
|
357
507
|
}
|
|
358
508
|
|
|
359
509
|
// ─── IndexAllocator (element-bump) ─────────────────────────────────────
|
|
@@ -361,11 +511,13 @@ export function insertSortedFreeBlock(
|
|
|
361
511
|
/**
|
|
362
512
|
* Element-bump allocator over an index GrowBuffer (units = u32). Each
|
|
363
513
|
* draw's index range is allocated as one block; on release the block
|
|
364
|
-
* is returned to a
|
|
514
|
+
* is returned to a Freelist and can be reused best-fit.
|
|
365
515
|
*/
|
|
366
516
|
export class IndexAllocator {
|
|
367
517
|
private cursor = 0; // in u32s, not bytes
|
|
368
|
-
private
|
|
518
|
+
private readonly freelist = new Freelist();
|
|
519
|
+
/** DEBUG: live index allocations tracked by start element. */
|
|
520
|
+
private readonly liveAllocs = new Map<number, number>();
|
|
369
521
|
private shadow: Uint8Array;
|
|
370
522
|
private dirtyMin = Infinity;
|
|
371
523
|
private dirtyMax = 0;
|
|
@@ -395,24 +547,66 @@ export class IndexAllocator {
|
|
|
395
547
|
this.dirtyMin = Infinity;
|
|
396
548
|
this.dirtyMax = 0;
|
|
397
549
|
}
|
|
398
|
-
alloc
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
550
|
+
/** Non-throwing alloc — returns `undefined` when the request
|
|
551
|
+
* would exceed the underlying GrowBuffer's `maxCapacity` cap.
|
|
552
|
+
* Used by `ChunkedIndexAllocator` to probe before spilling. */
|
|
553
|
+
tryAlloc(elements: number): number | undefined {
|
|
554
|
+
const reused = this.freelist.alloc(elements);
|
|
555
|
+
if (reused !== undefined) {
|
|
556
|
+
this.recordAlloc(reused, elements, "freelist");
|
|
557
|
+
return reused;
|
|
407
558
|
}
|
|
408
|
-
const
|
|
409
|
-
this.
|
|
410
|
-
this.
|
|
411
|
-
this.buf.
|
|
559
|
+
const nextElts = this.cursor + elements;
|
|
560
|
+
if (nextElts * 4 > this.buf.maxCapacity) return undefined;
|
|
561
|
+
this.cursor = nextElts;
|
|
562
|
+
this.buf.ensureCapacity(nextElts * 4);
|
|
563
|
+
this.buf.setUsed(nextElts * 4);
|
|
564
|
+
const off = nextElts - elements;
|
|
565
|
+
this.recordAlloc(off, elements, "bump");
|
|
412
566
|
return off;
|
|
413
567
|
}
|
|
568
|
+
private recordAlloc(off: number, size: number, source: string): void {
|
|
569
|
+
for (const [liveOff, liveSize] of this.liveAllocs) {
|
|
570
|
+
if (off < liveOff + liveSize && liveOff < off + size) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
`IndexAllocator.tryAlloc(${source}): returned [${off},${off + size}) ` +
|
|
573
|
+
`(size=${size}) overlaps live alloc at [${liveOff},${liveOff + liveSize}) ` +
|
|
574
|
+
`(size=${liveSize}). Index allocator is handing out shared memory!`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
this.liveAllocs.set(off, size);
|
|
579
|
+
}
|
|
580
|
+
alloc(elements: number): number {
|
|
581
|
+
const r = this.tryAlloc(elements);
|
|
582
|
+
if (r === undefined) {
|
|
583
|
+
throw new Error(`IndexAllocator: allocation of ${elements} elements exceeds chunk cap`);
|
|
584
|
+
}
|
|
585
|
+
return r;
|
|
586
|
+
}
|
|
414
587
|
release(off: number, elements: number): void {
|
|
415
|
-
|
|
588
|
+
const tracked = this.liveAllocs.get(off);
|
|
589
|
+
if (tracked === undefined) {
|
|
590
|
+
throw new Error(
|
|
591
|
+
`IndexAllocator.release: off=${off} (elements=${elements}) was not in ` +
|
|
592
|
+
`liveAllocs — double-free or release for an alloc that never happened. ` +
|
|
593
|
+
`Live alloc count=${this.liveAllocs.size}.`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (tracked !== elements) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`IndexAllocator.release: size mismatch at off=${off} — recorded=${tracked} ` +
|
|
599
|
+
`but release elements=${elements}. Will corrupt the freelist.`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
this.liveAllocs.delete(off);
|
|
603
|
+
this.freelist.release(off, elements);
|
|
604
|
+
while (true) {
|
|
605
|
+
const top = this.freelist.takeBlockEndingAt(this.cursor);
|
|
606
|
+
if (top === undefined) break;
|
|
607
|
+
this.cursor = top.off;
|
|
608
|
+
this.buf.setUsed(this.cursor * 4);
|
|
609
|
+
}
|
|
416
610
|
}
|
|
417
611
|
onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
|
|
418
612
|
destroy(): void { this.buf.destroy(); }
|
|
@@ -423,11 +617,15 @@ export class IndexAllocator {
|
|
|
423
617
|
/**
|
|
424
618
|
* Global arena state: attribute / uniform data lives in `attrs`
|
|
425
619
|
* (multi-typed-view storage); indices live in `indices` (separate
|
|
426
|
-
* INDEX-usage buffer).
|
|
620
|
+
* INDEX-usage buffer). Both are chunked: each chunk owns a separate
|
|
621
|
+
* GPUBuffer + Freelist. Refs that downstream code stores are byte
|
|
622
|
+
* offsets within a chunk; the chunk identity is carried alongside
|
|
623
|
+
* (via bucket.chunkIdx in heapScene) and made implicit at draw
|
|
624
|
+
* time by which chunk's bind group is active.
|
|
427
625
|
*/
|
|
428
626
|
export interface ArenaState {
|
|
429
|
-
readonly attrs:
|
|
430
|
-
readonly indices:
|
|
627
|
+
readonly attrs: ChunkedAttributeArena;
|
|
628
|
+
readonly indices: ChunkedIndexAllocator;
|
|
431
629
|
}
|
|
432
630
|
|
|
433
631
|
export function buildArenaState(
|
|
@@ -436,20 +634,29 @@ export function buildArenaState(
|
|
|
436
634
|
idxBytesHint: number,
|
|
437
635
|
label: string,
|
|
438
636
|
idxExtraUsage: GPUBufferUsageFlags = 0,
|
|
637
|
+
maxChunkBytes: number | undefined = undefined,
|
|
439
638
|
): ArenaState {
|
|
440
|
-
|
|
639
|
+
// §3: cap chunk size at min(adapter's maxStorageBufferBindingSize,
|
|
640
|
+
// GrowBuffer's DEFAULT_MAX_BUFFER_BYTES). Lets chunks grow as far
|
|
641
|
+
// as the hardware lets us (typically 2 GiB+ on desktop, ≥ 256 MB
|
|
642
|
+
// on mobile/integrated) while keeping a sensible internal ceiling.
|
|
643
|
+
// Caller can override (the heap-demo uses a tiny 4 MB cap to
|
|
644
|
+
// exercise multi-chunk routing).
|
|
645
|
+
const adapterCap = device.limits.maxStorageBufferBindingSize;
|
|
646
|
+
const cap = maxChunkBytes ?? Math.min(adapterCap, DEFAULT_MAX_BUFFER_BYTES);
|
|
647
|
+
const attrs = new ChunkedAttributeArena(
|
|
441
648
|
device, `${label}/attrs`, GPUBufferUsage.STORAGE,
|
|
442
|
-
attrBytesHint,
|
|
443
|
-
)
|
|
444
|
-
const indices = new
|
|
649
|
+
attrBytesHint, cap,
|
|
650
|
+
);
|
|
651
|
+
const indices = new ChunkedIndexAllocator(
|
|
445
652
|
device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage,
|
|
446
|
-
idxBytesHint,
|
|
447
|
-
)
|
|
653
|
+
idxBytesHint, cap,
|
|
654
|
+
);
|
|
448
655
|
return { attrs, indices };
|
|
449
656
|
}
|
|
450
657
|
|
|
451
658
|
export function arenaBytes(arena: ArenaState): number {
|
|
452
|
-
return arena.attrs.
|
|
659
|
+
return arena.attrs.totalUsedBytes() + arena.indices.totalUsedElements() * 4;
|
|
453
660
|
}
|
|
454
661
|
|
|
455
662
|
/** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */
|