@aardworx/wombat.rendering 0.8.1 → 0.9.1
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/command.d.ts +7 -9
- package/dist/core/command.d.ts.map +1 -1
- package/dist/core/renderTask.d.ts +64 -1
- package/dist/core/renderTask.d.ts.map +1 -1
- package/dist/core/renderTask.js +3 -1
- package/dist/core/renderTask.js.map +1 -1
- package/dist/resources/preparedRenderObject.js +3 -3
- package/dist/resources/preparedRenderObject.js.map +1 -1
- package/dist/resources/uniformBuffer.d.ts.map +1 -1
- package/dist/resources/uniformBuffer.js +17 -0
- package/dist/resources/uniformBuffer.js.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts +105 -0
- package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/dispatch.js +355 -0
- package/dist/runtime/derivedUniforms/dispatch.js.map +1 -0
- package/dist/runtime/derivedUniforms/index.d.ts +6 -0
- package/dist/runtime/derivedUniforms/index.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/index.js +26 -0
- package/dist/runtime/derivedUniforms/index.js.map +1 -0
- package/dist/runtime/derivedUniforms/recipes.d.ts +42 -0
- package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/recipes.js +134 -0
- package/dist/runtime/derivedUniforms/recipes.js.map +1 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +56 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.js +171 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -0
- package/dist/runtime/derivedUniforms/slotId.d.ts +15 -0
- package/dist/runtime/derivedUniforms/slotId.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/slotId.js +30 -0
- package/dist/runtime/derivedUniforms/slotId.js.map +1 -0
- package/dist/runtime/derivedUniforms/slots.d.ts +49 -0
- package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/slots.js +123 -0
- package/dist/runtime/derivedUniforms/slots.js.map +1 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +13 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +218 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +1 -0
- package/dist/runtime/flattenTree.d.ts +5 -0
- package/dist/runtime/flattenTree.d.ts.map +1 -0
- package/dist/runtime/flattenTree.js +45 -0
- package/dist/runtime/flattenTree.js.map +1 -0
- package/dist/runtime/heapAdapter.d.ts +16 -0
- package/dist/runtime/heapAdapter.d.ts.map +1 -0
- package/dist/runtime/heapAdapter.js +263 -0
- package/dist/runtime/heapAdapter.js.map +1 -0
- package/dist/runtime/heapEffect.d.ts +235 -0
- package/dist/runtime/heapEffect.d.ts.map +1 -0
- package/dist/runtime/heapEffect.js +529 -0
- package/dist/runtime/heapEffect.js.map +1 -0
- package/dist/runtime/heapEffectIR.d.ts +34 -0
- package/dist/runtime/heapEffectIR.d.ts.map +1 -0
- package/dist/runtime/heapEffectIR.js +1161 -0
- package/dist/runtime/heapEffectIR.js.map +1 -0
- package/dist/runtime/heapEligibility.d.ts +9 -0
- package/dist/runtime/heapEligibility.d.ts.map +1 -0
- package/dist/runtime/heapEligibility.js +195 -0
- package/dist/runtime/heapEligibility.js.map +1 -0
- package/dist/runtime/heapScene.d.ts +240 -31
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +3334 -265
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/heapShaderFamily.d.ts +132 -0
- package/dist/runtime/heapShaderFamily.d.ts.map +1 -0
- package/dist/runtime/heapShaderFamily.js +866 -0
- package/dist/runtime/heapShaderFamily.js.map +1 -0
- package/dist/runtime/hybridScene.d.ts +133 -0
- package/dist/runtime/hybridScene.d.ts.map +1 -0
- package/dist/runtime/hybridScene.js +159 -0
- package/dist/runtime/hybridScene.js.map +1 -0
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +9 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/renderTask.d.ts +21 -4
- package/dist/runtime/renderTask.d.ts.map +1 -1
- package/dist/runtime/renderTask.js +127 -32
- package/dist/runtime/renderTask.js.map +1 -1
- package/dist/runtime/runtime.d.ts +33 -3
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +12 -3
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/scenePass.d.ts +28 -3
- package/dist/runtime/scenePass.d.ts.map +1 -1
- package/dist/runtime/scenePass.js +37 -4
- package/dist/runtime/scenePass.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +35 -0
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +247 -0
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts +240 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/atlasPool.js +798 -0
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -0
- package/dist/runtime/textureAtlas/bvhTree2d.d.ts +33 -0
- package/dist/runtime/textureAtlas/bvhTree2d.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/bvhTree2d.js +501 -0
- package/dist/runtime/textureAtlas/bvhTree2d.js.map +1 -0
- package/dist/runtime/textureAtlas/packer.d.ts +50 -0
- package/dist/runtime/textureAtlas/packer.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/packer.js +390 -0
- package/dist/runtime/textureAtlas/packer.js.map +1 -0
- package/dist/window/loop.d.ts +14 -0
- package/dist/window/loop.d.ts.map +1 -1
- package/dist/window/loop.js +18 -5
- package/dist/window/loop.js.map +1 -1
- package/package.json +6 -5
- package/src/core/command.ts +9 -9
- package/src/core/renderTask.ts +55 -2
- package/src/resources/preparedRenderObject.ts +3 -3
- package/src/resources/uniformBuffer.ts +15 -0
- package/src/runtime/derivedUniforms/dispatch.ts +407 -0
- package/src/runtime/derivedUniforms/index.ts +52 -0
- package/src/runtime/derivedUniforms/recipes.ts +159 -0
- package/src/runtime/derivedUniforms/sceneIntegration.ts +239 -0
- package/src/runtime/derivedUniforms/slotId.ts +35 -0
- package/src/runtime/derivedUniforms/slots.ts +155 -0
- package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +220 -0
- package/src/runtime/flattenTree.ts +46 -0
- package/src/runtime/heapAdapter.ts +314 -0
- package/src/runtime/heapEffect.ts +728 -0
- package/src/runtime/heapEffectIR.ts +1211 -0
- package/src/runtime/heapEligibility.ts +189 -0
- package/src/runtime/heapScene.ts +3981 -349
- package/src/runtime/heapShaderFamily.ts +1102 -0
- package/src/runtime/hybridScene.ts +310 -0
- package/src/runtime/index.ts +45 -1
- package/src/runtime/renderTask.ts +167 -32
- package/src/runtime/runtime.ts +37 -4
- package/src/runtime/scenePass.ts +39 -4
- package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +291 -0
- package/src/runtime/textureAtlas/atlasPool.ts +991 -0
- package/src/runtime/textureAtlas/bvhTree2d.ts +530 -0
- package/src/runtime/textureAtlas/packer.ts +429 -0
- package/src/window/loop.ts +32 -6
- package/dist/runtime/heapShader.d.ts +0 -12
- package/dist/runtime/heapShader.d.ts.map +0 -1
- package/dist/runtime/heapShader.js +0 -157
- package/dist/runtime/heapShader.js.map +0 -1
- package/src/runtime/heapShader.ts +0 -185
|
@@ -41,15 +41,30 @@
|
|
|
41
41
|
// per groupKey via `BuildHeapSceneOptions.texturesByGroupKey`. The
|
|
42
42
|
// package adds the texture (binding 4) + sampler (binding 5) to that
|
|
43
43
|
// group's bind-group layout; the user's FS WGSL declares them.
|
|
44
|
-
import { Trafo3d, V3d, V4f } from "@aardworx/wombat.base";
|
|
45
|
-
import { AVal,
|
|
44
|
+
import { Trafo3d, V3d, V4f, M44d } from "@aardworx/wombat.base";
|
|
45
|
+
import { AVal, AdaptiveObject, AdaptiveToken } from "@aardworx/wombat.adaptive";
|
|
46
|
+
import { buildBucketLayout, compileHeapEffect, } from "./heapEffect.js";
|
|
47
|
+
import { compileHeapEffectIR } from "./heapEffectIR.js";
|
|
48
|
+
import { buildShaderFamily, compileShaderFamily, } from "./heapShaderFamily.js";
|
|
49
|
+
import { ATLAS_PAGE_FORMATS, atlasFormatIndex, } from "./textureAtlas/atlasPool.js";
|
|
50
|
+
import { ATLAS_ARRAY_SIZE, ATLAS_LINEAR_BINDING_BASE, ATLAS_SRGB_BINDING_BASE, ATLAS_SAMPLER_BINDING, } from "./heapEffect.js";
|
|
51
|
+
import { DerivedUniformsScene, registerRoDerivations, deregisterRoDerivations, isDerivedUniformName, } from "./derivedUniforms/index.js";
|
|
46
52
|
// ---------------------------------------------------------------------------
|
|
47
|
-
//
|
|
53
|
+
// Per-allocation arena layout
|
|
48
54
|
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
// Per-allocation header: (u32 typeId, u32 length). typeId is
|
|
56
|
+
// (semantic << 16) | encoding. The data region follows the header
|
|
57
|
+
// aligned up to 16 bytes (so positions/normals/etc. line up for
|
|
58
|
+
// future vec4 reads).
|
|
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;
|
|
53
68
|
function packMat44(m, dst, off) {
|
|
54
69
|
// M44d._data is the row-major Float64Array; not part of the public
|
|
55
70
|
// type surface. toArray() round-trips through a fresh number[] which
|
|
@@ -58,297 +73,3351 @@ function packMat44(m, dst, off) {
|
|
|
58
73
|
for (let i = 0; i < 16; i++)
|
|
59
74
|
dst[off + i] = r[i];
|
|
60
75
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
dst
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
dst
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const PACKER_MAT4 = {
|
|
77
|
+
dataBytes: 64, typeId: 0,
|
|
78
|
+
pack: (val, dst, off) => {
|
|
79
|
+
// Accept Trafo3d (uses .forward) or M44d directly.
|
|
80
|
+
const m = val.forward !== undefined
|
|
81
|
+
? val.forward
|
|
82
|
+
: val;
|
|
83
|
+
packMat44(m, dst, off);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const PACKER_VEC4 = {
|
|
87
|
+
dataBytes: 16, typeId: 0,
|
|
88
|
+
pack: (val, dst, off) => {
|
|
89
|
+
const v = val;
|
|
90
|
+
dst[off + 0] = v.x;
|
|
91
|
+
dst[off + 1] = v.y;
|
|
92
|
+
dst[off + 2] = v.z;
|
|
93
|
+
dst[off + 3] = v.w;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const PACKER_VEC3 = {
|
|
97
|
+
dataBytes: 12, typeId: 0,
|
|
98
|
+
pack: (val, dst, off) => {
|
|
99
|
+
// V3f or V3d both expose .x/.y/.z; cast through a common shape.
|
|
100
|
+
const v = val;
|
|
101
|
+
dst[off + 0] = v.x;
|
|
102
|
+
dst[off + 1] = v.y;
|
|
103
|
+
dst[off + 2] = v.z;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const PACKER_VEC2 = {
|
|
107
|
+
dataBytes: 8, typeId: 0,
|
|
108
|
+
pack: (val, dst, off) => {
|
|
109
|
+
const v = val;
|
|
110
|
+
dst[off + 0] = v.x;
|
|
111
|
+
dst[off + 1] = v.y;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const PACKER_F32 = {
|
|
115
|
+
dataBytes: 4, typeId: 0,
|
|
116
|
+
pack: (val, dst, off) => { dst[off] = val; },
|
|
117
|
+
};
|
|
118
|
+
function packerForWgslType(wgslType) {
|
|
119
|
+
switch (wgslType) {
|
|
120
|
+
case "mat4x4<f32>": return PACKER_MAT4;
|
|
121
|
+
case "vec4<f32>": return PACKER_VEC4;
|
|
122
|
+
case "vec3<f32>": return PACKER_VEC3;
|
|
123
|
+
case "vec2<f32>": return PACKER_VEC2;
|
|
124
|
+
case "f32": return PACKER_F32;
|
|
125
|
+
default:
|
|
126
|
+
throw new Error(`heapScene: no JS-side packer for WGSL type '${wgslType}'`);
|
|
127
|
+
}
|
|
77
128
|
}
|
|
78
129
|
// ---------------------------------------------------------------------------
|
|
79
|
-
//
|
|
130
|
+
// Resizable buffer (pow2 grow + GPU-side copy on resize)
|
|
80
131
|
// ---------------------------------------------------------------------------
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
modelTrafo: mat4x4<f32>,
|
|
88
|
-
modelTrafoInv: mat4x4<f32>,
|
|
89
|
-
color: vec4<f32>,
|
|
90
|
-
vertexBase: u32,
|
|
91
|
-
_pad0: u32, _pad1: u32, _pad2: u32,
|
|
132
|
+
const MIN_BUFFER_BYTES = 64 * 1024;
|
|
133
|
+
const POW2 = (n) => {
|
|
134
|
+
let p = 1;
|
|
135
|
+
while (p < n)
|
|
136
|
+
p <<= 1;
|
|
137
|
+
return p;
|
|
92
138
|
};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
139
|
+
/**
|
|
140
|
+
* A GPUBuffer that can grow to next power-of-two on demand. On grow,
|
|
141
|
+
* a fresh buffer is created at the new size, the live tail copied
|
|
142
|
+
* over via copyBufferToBuffer, and dependents (bind groups, mostly)
|
|
143
|
+
* are notified to rebuild via the `onResize` callback.
|
|
144
|
+
*
|
|
145
|
+
* `usedBytes` is the high-water mark — the runtime advances this as
|
|
146
|
+
* it allocates, and `ensureCapacity` grows when required. This
|
|
147
|
+
* separates allocation policy from grow policy.
|
|
148
|
+
*/
|
|
149
|
+
class GrowBuffer {
|
|
150
|
+
device;
|
|
151
|
+
label;
|
|
152
|
+
usage;
|
|
153
|
+
buf;
|
|
154
|
+
cap;
|
|
155
|
+
used = 0;
|
|
156
|
+
listeners = new Set();
|
|
157
|
+
constructor(device, label, usage, initialBytes) {
|
|
158
|
+
this.device = device;
|
|
159
|
+
this.label = label;
|
|
160
|
+
this.usage = usage;
|
|
161
|
+
this.cap = Math.max(MIN_BUFFER_BYTES, POW2(initialBytes));
|
|
162
|
+
this.buf = device.createBuffer({ size: this.cap, usage: usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label });
|
|
163
|
+
}
|
|
164
|
+
get buffer() { return this.buf; }
|
|
165
|
+
get capacity() { return this.cap; }
|
|
166
|
+
get usedBytes() { return this.used; }
|
|
167
|
+
setUsed(n) { this.used = n; }
|
|
168
|
+
onResize(cb) {
|
|
169
|
+
this.listeners.add(cb);
|
|
170
|
+
return { dispose: () => { this.listeners.delete(cb); } };
|
|
171
|
+
}
|
|
172
|
+
/** Ensure the buffer is at least `bytes` capacity. Grows by pow2 + copies live tail. */
|
|
173
|
+
ensureCapacity(bytes) {
|
|
174
|
+
if (bytes <= this.cap)
|
|
175
|
+
return;
|
|
176
|
+
const newCap = POW2(bytes);
|
|
177
|
+
const newBuf = this.device.createBuffer({
|
|
178
|
+
size: newCap, usage: this.usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label: this.label,
|
|
179
|
+
});
|
|
180
|
+
if (this.used > 0) {
|
|
181
|
+
const enc = this.device.createCommandEncoder({ label: `${this.label}: grow-copy` });
|
|
182
|
+
enc.copyBufferToBuffer(this.buf, 0, newBuf, 0, ALIGN16(this.used));
|
|
183
|
+
this.device.queue.submit([enc.finish()]);
|
|
184
|
+
}
|
|
185
|
+
this.buf.destroy();
|
|
186
|
+
this.buf = newBuf;
|
|
187
|
+
this.cap = newCap;
|
|
188
|
+
for (const cb of this.listeners)
|
|
189
|
+
cb();
|
|
190
|
+
}
|
|
191
|
+
destroy() { this.buf.destroy(); }
|
|
108
192
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Aval-keyed pool of arena allocations. One allocation per unique
|
|
195
|
+
* aval (object identity). Two draws referencing the same aval share
|
|
196
|
+
* the allocation; their DrawHeaders carry the same u32 ref. Holds
|
|
197
|
+
* uniforms (fixed-size scalars/vectors/matrices) AND attribute arrays
|
|
198
|
+
* (variable-size). The caller decides `dataBytes` + `length` per
|
|
199
|
+
* acquisition — the pool just keys on aval identity and refcounts.
|
|
200
|
+
*
|
|
201
|
+
* Sharing emerges from aval identity — no separate "frequency"
|
|
202
|
+
* declaration needed. A `cval` shared by all draws → 1 alloc.
|
|
203
|
+
* A static positions array shared across instanced draws → 1 alloc.
|
|
204
|
+
* Same code path either way.
|
|
205
|
+
*/
|
|
206
|
+
class UniformPool {
|
|
207
|
+
byAval = new Map();
|
|
208
|
+
has(aval) { return this.byAval.has(aval); }
|
|
209
|
+
entry(aval) { return this.byAval.get(aval); }
|
|
210
|
+
/**
|
|
211
|
+
* Acquire (or share) an allocation for `aval`. Caller passes the
|
|
212
|
+
* pre-read `value` (so the pool doesn't need a token) plus the
|
|
213
|
+
* (`dataBytes`, `typeId`, `length`, `pack`) describing how to lay
|
|
214
|
+
* it out. If a new allocation is made, the value is packed and
|
|
215
|
+
* uploaded immediately.
|
|
216
|
+
*/
|
|
217
|
+
acquire(device, arena, aval, value, dataBytes, typeId, length, pack) {
|
|
218
|
+
const existing = this.byAval.get(aval);
|
|
219
|
+
if (existing !== undefined) {
|
|
220
|
+
existing.refcount++;
|
|
221
|
+
return existing.ref;
|
|
222
|
+
}
|
|
223
|
+
const ref = arena.alloc(dataBytes);
|
|
224
|
+
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
225
|
+
const buf = new ArrayBuffer(allocBytes);
|
|
226
|
+
const u32 = new Uint32Array(buf);
|
|
227
|
+
const f32 = new Float32Array(buf);
|
|
228
|
+
u32[0] = typeId;
|
|
229
|
+
u32[1] = length;
|
|
230
|
+
// stride_bytes (offset 8): bytes per element. Lets the VS decode
|
|
231
|
+
// pick V3- vs V4-tight load expressions for vec4 attributes
|
|
232
|
+
// (and is informative for everything else).
|
|
233
|
+
u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
|
|
234
|
+
pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
|
|
235
|
+
arena.write(ref, new Uint8Array(buf));
|
|
236
|
+
void device;
|
|
237
|
+
this.byAval.set(aval, { ref, dataBytes, typeId, pack, refcount: 1 });
|
|
238
|
+
return ref;
|
|
239
|
+
}
|
|
240
|
+
/** Decrement refcount; if zero, free the arena allocation. */
|
|
241
|
+
release(arena, aval) {
|
|
242
|
+
const e = this.byAval.get(aval);
|
|
243
|
+
if (e === undefined)
|
|
244
|
+
return;
|
|
245
|
+
e.refcount--;
|
|
246
|
+
if (e.refcount > 0)
|
|
247
|
+
return;
|
|
248
|
+
arena.release(e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
|
|
249
|
+
this.byAval.delete(aval);
|
|
250
|
+
}
|
|
251
|
+
/** Re-pack one entry's data region into the arena's CPU shadow. */
|
|
252
|
+
repack(device, arena, aval, val) {
|
|
253
|
+
const e = this.byAval.get(aval);
|
|
254
|
+
if (e === undefined)
|
|
255
|
+
return;
|
|
256
|
+
const dst = new Float32Array(e.dataBytes / 4);
|
|
257
|
+
e.pack(val, dst, 0);
|
|
258
|
+
arena.write(e.ref + ALLOC_HEADER_PAD_TO, new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes));
|
|
259
|
+
void device;
|
|
260
|
+
}
|
|
128
261
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Aval-keyed pool over the `IndexAllocator`. Two draws referencing
|
|
264
|
+
* the same `Uint32Array` (or aval thereof) share an index range —
|
|
265
|
+
* 19K instanced clones of the same mesh share one allocation, one
|
|
266
|
+
* upload. Index data is treated as immutable for the aval's
|
|
267
|
+
* lifetime: an aval mark won't repack (we'd have to free + re-alloc
|
|
268
|
+
* since size changes are likely). Use a fresh aval to swap meshes.
|
|
269
|
+
*
|
|
270
|
+
* **Value-equality dedup for constant avals (§5b):** when an
|
|
271
|
+
* incoming aval has `isConstant === true`, the pool also keys by
|
|
272
|
+
* the underlying `ArrayBuffer` tuple `(buffer, byteOffset,
|
|
273
|
+
* byteLength)`. Two distinct constant avals wrapping the same
|
|
274
|
+
* `Uint32Array` view (or two views over the same backing buffer
|
|
275
|
+
* with matching offsets) collapse to one allocation. Hashing
|
|
276
|
+
* kilobytes of indices on every acquire would be wasteful; the
|
|
277
|
+
* tuple key catches the realistic "one ArrayBuffer shared across
|
|
278
|
+
* many aval wrappers" pattern, which is the only one that matters
|
|
279
|
+
* for the heap path. Reactive (non-constant) avals fall through
|
|
280
|
+
* to identity-only — their content can change and the pool can't
|
|
281
|
+
* silently merge them.
|
|
282
|
+
*/
|
|
283
|
+
class IndexPool {
|
|
284
|
+
// Per-aval binding. `perAvalCount` tracks acquire/release balance
|
|
285
|
+
// for THIS aval; `entry` is the shared allocation (one entry can be
|
|
286
|
+
// referenced by many aliasing avals via §5b dedup).
|
|
287
|
+
byAval = new Map();
|
|
288
|
+
byValueKey = new Map();
|
|
289
|
+
// Stable per-ArrayBuffer numeric id for value-key composition.
|
|
290
|
+
// WeakMap-backed so buffers GC'd elsewhere drop their entry too.
|
|
291
|
+
bufferIds = new WeakMap();
|
|
292
|
+
nextBufferId = 1;
|
|
293
|
+
bufferIdOf(buf) {
|
|
294
|
+
let id = this.bufferIds.get(buf);
|
|
295
|
+
if (id === undefined) {
|
|
296
|
+
id = this.nextBufferId++;
|
|
297
|
+
this.bufferIds.set(buf, id);
|
|
298
|
+
}
|
|
299
|
+
return id;
|
|
300
|
+
}
|
|
301
|
+
acquire(device, indices, aval, arr) {
|
|
302
|
+
const bound = this.byAval.get(aval);
|
|
303
|
+
if (bound !== undefined) {
|
|
304
|
+
bound.perAvalCount++;
|
|
305
|
+
bound.entry.totalRefcount++;
|
|
306
|
+
return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
|
|
307
|
+
}
|
|
308
|
+
let valueKey;
|
|
309
|
+
if (aval.isConstant) {
|
|
310
|
+
valueKey = `${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
|
|
311
|
+
const shared = this.byValueKey.get(valueKey);
|
|
312
|
+
if (shared !== undefined) {
|
|
313
|
+
shared.totalRefcount++;
|
|
314
|
+
this.byAval.set(aval, { entry: shared, perAvalCount: 1 });
|
|
315
|
+
return { firstIndex: shared.firstIndex, count: shared.count };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const firstIndex = indices.alloc(arr.length);
|
|
319
|
+
indices.write(firstIndex * 4, new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
|
|
320
|
+
void device;
|
|
321
|
+
const entry = { firstIndex, count: arr.length, totalRefcount: 1, valueKey };
|
|
322
|
+
this.byAval.set(aval, { entry, perAvalCount: 1 });
|
|
323
|
+
if (valueKey !== undefined)
|
|
324
|
+
this.byValueKey.set(valueKey, entry);
|
|
325
|
+
return { firstIndex, count: arr.length };
|
|
326
|
+
}
|
|
327
|
+
release(indices, aval) {
|
|
328
|
+
const bound = this.byAval.get(aval);
|
|
329
|
+
if (bound === undefined)
|
|
330
|
+
return;
|
|
331
|
+
bound.perAvalCount--;
|
|
332
|
+
bound.entry.totalRefcount--;
|
|
333
|
+
if (bound.perAvalCount === 0)
|
|
334
|
+
this.byAval.delete(aval);
|
|
335
|
+
if (bound.entry.totalRefcount > 0)
|
|
336
|
+
return;
|
|
337
|
+
indices.release(bound.entry.firstIndex, bound.entry.count);
|
|
338
|
+
if (bound.entry.valueKey !== undefined) {
|
|
339
|
+
this.byValueKey.delete(bound.entry.valueKey);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// DrawHeap (slot-indexed) and AttributeArena (byte-bump) allocators
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
/**
|
|
347
|
+
* Slot-indexed allocator over a GrowBuffer. `slotBytes` is set per-
|
|
348
|
+
* instance — each bucket sizes its DrawHeader from its effect's
|
|
349
|
+
* schema, so a bucket whose layout is e.g. 96 B / slot uses a
|
|
350
|
+
* DrawHeap with `slotBytes=96`.
|
|
351
|
+
*/
|
|
352
|
+
class DrawHeap {
|
|
353
|
+
buf;
|
|
354
|
+
slotBytes;
|
|
355
|
+
free = [];
|
|
356
|
+
nextSlot = 0;
|
|
357
|
+
constructor(buf, slotBytes) {
|
|
358
|
+
this.buf = buf;
|
|
359
|
+
this.slotBytes = slotBytes;
|
|
360
|
+
}
|
|
361
|
+
get buffer() { return this.buf.buffer; }
|
|
362
|
+
/** Bytes per slot — caller multiplies by slot index for byte offsets. */
|
|
363
|
+
get bytesPerSlot() { return this.slotBytes; }
|
|
364
|
+
/** High-water mark in bytes (used to size bind-group entry on rebuild). */
|
|
365
|
+
get usedBytes() { return this.nextSlot * this.slotBytes; }
|
|
366
|
+
alloc() {
|
|
367
|
+
const slot = this.free.length > 0 ? this.free.pop() : this.nextSlot++;
|
|
368
|
+
this.buf.ensureCapacity((slot + 1) * this.slotBytes);
|
|
369
|
+
this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
|
|
370
|
+
return slot;
|
|
371
|
+
}
|
|
372
|
+
release(slot) { this.free.push(slot); }
|
|
373
|
+
onResize(cb) { return this.buf.onResize(cb); }
|
|
374
|
+
destroy() { this.buf.destroy(); }
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Byte-bump allocator over a GrowBuffer for variable-size attribute
|
|
378
|
+
* allocations. Each allocation gets a 16-byte aligned start (8-byte
|
|
379
|
+
* (typeId, length) header at the start, data 16 bytes in). Frees go
|
|
380
|
+
* onto a list keyed by size for simple first-fit reuse later — for
|
|
381
|
+
* now `release` just records the gap and the bump cursor never
|
|
382
|
+
* shrinks.
|
|
383
|
+
*/
|
|
384
|
+
class AttributeArena {
|
|
385
|
+
buf;
|
|
386
|
+
cursor = 0;
|
|
387
|
+
// (offset, size) free entries; first-fit reuse not yet implemented.
|
|
388
|
+
freeList = [];
|
|
389
|
+
// CPU shadow of the entire GPU buffer. Writes go here first; a
|
|
390
|
+
// single `device.queue.writeBuffer` per dirty contiguous range
|
|
391
|
+
// lifts them to the GPU at flush time. At the cost of doubling
|
|
392
|
+
// host memory we collapse N small writeBuffer calls (10K+ at
|
|
393
|
+
// initial population) to 1 per frame.
|
|
394
|
+
shadow;
|
|
395
|
+
dirtyMin = Infinity;
|
|
396
|
+
dirtyMax = 0;
|
|
397
|
+
constructor(buf) {
|
|
398
|
+
this.buf = buf;
|
|
399
|
+
this.shadow = new Uint8Array(buf.capacity);
|
|
400
|
+
buf.onResize(() => {
|
|
401
|
+
const grown = new Uint8Array(buf.capacity);
|
|
402
|
+
grown.set(this.shadow);
|
|
403
|
+
this.shadow = grown;
|
|
155
404
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
return
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
405
|
+
}
|
|
406
|
+
get buffer() { return this.buf.buffer; }
|
|
407
|
+
get capacity() { return this.buf.capacity; }
|
|
408
|
+
get usedBytes() { return this.cursor; }
|
|
409
|
+
/**
|
|
410
|
+
* Stage `data` to the shadow at byte offset `dst`. Tracks the
|
|
411
|
+
* dirty range so `flush(device)` can emit a single writeBuffer
|
|
412
|
+
* covering everything dirty since the last flush.
|
|
413
|
+
*/
|
|
414
|
+
write(dst, data) {
|
|
415
|
+
this.shadow.set(data, dst);
|
|
416
|
+
if (dst < this.dirtyMin)
|
|
417
|
+
this.dirtyMin = dst;
|
|
418
|
+
const end = dst + data.byteLength;
|
|
419
|
+
if (end > this.dirtyMax)
|
|
420
|
+
this.dirtyMax = end;
|
|
421
|
+
}
|
|
422
|
+
flush(device) {
|
|
423
|
+
if (this.dirtyMax <= this.dirtyMin)
|
|
424
|
+
return;
|
|
425
|
+
device.queue.writeBuffer(this.buf.buffer, this.dirtyMin, this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin, this.dirtyMax - this.dirtyMin);
|
|
426
|
+
this.dirtyMin = Infinity;
|
|
427
|
+
this.dirtyMax = 0;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Allocate space for one attribute. Returns the byte ref (offset
|
|
431
|
+
* to the header — data lives at ref + 16).
|
|
432
|
+
*/
|
|
433
|
+
alloc(dataBytes) {
|
|
434
|
+
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
435
|
+
// First-fit reuse from free list.
|
|
436
|
+
for (let i = 0; i < this.freeList.length; i++) {
|
|
437
|
+
const f = this.freeList[i];
|
|
438
|
+
if (f.size >= allocBytes) {
|
|
439
|
+
const ref = f.off;
|
|
440
|
+
if (f.size === allocBytes)
|
|
441
|
+
this.freeList.splice(i, 1);
|
|
442
|
+
else {
|
|
443
|
+
f.off += allocBytes;
|
|
444
|
+
f.size -= allocBytes;
|
|
445
|
+
}
|
|
446
|
+
return ref;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const ref = this.cursor;
|
|
450
|
+
this.cursor += allocBytes;
|
|
451
|
+
this.buf.ensureCapacity(this.cursor);
|
|
452
|
+
this.buf.setUsed(this.cursor);
|
|
453
|
+
return ref;
|
|
454
|
+
}
|
|
455
|
+
release(ref, dataBytes) {
|
|
456
|
+
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
457
|
+
this.freeList.push({ off: ref, size: allocBytes });
|
|
458
|
+
// Coalesce adjacent free entries for cleanliness.
|
|
459
|
+
this.freeList.sort((a, b) => a.off - b.off);
|
|
460
|
+
for (let i = 0; i < this.freeList.length - 1;) {
|
|
461
|
+
const a = this.freeList[i], b = this.freeList[i + 1];
|
|
462
|
+
if (a.off + a.size === b.off) {
|
|
463
|
+
a.size += b.size;
|
|
464
|
+
this.freeList.splice(i + 1, 1);
|
|
465
|
+
}
|
|
466
|
+
else
|
|
467
|
+
i++;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
onResize(cb) { return this.buf.onResize(cb); }
|
|
471
|
+
destroy() { this.buf.destroy(); }
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Element-bump allocator over an index GrowBuffer (units = u32). Each
|
|
475
|
+
* draw's index range is allocated as one block; on release the block
|
|
476
|
+
* is returned to a free list and can be reused first-fit.
|
|
477
|
+
*/
|
|
478
|
+
class IndexAllocator {
|
|
479
|
+
buf;
|
|
480
|
+
cursor = 0; // in u32s, not bytes
|
|
481
|
+
freeList = [];
|
|
482
|
+
// CPU shadow + dirty range, same shape as AttributeArena. Index
|
|
483
|
+
// uploads (one per drawn mesh's index buffer) get coalesced to a
|
|
484
|
+
// single writeBuffer per dirty range at flush time.
|
|
485
|
+
shadow;
|
|
486
|
+
dirtyMin = Infinity;
|
|
487
|
+
dirtyMax = 0;
|
|
488
|
+
constructor(buf) {
|
|
489
|
+
this.buf = buf;
|
|
490
|
+
this.shadow = new Uint8Array(buf.capacity);
|
|
491
|
+
buf.onResize(() => {
|
|
492
|
+
const grown = new Uint8Array(buf.capacity);
|
|
493
|
+
grown.set(this.shadow);
|
|
494
|
+
this.shadow = grown;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
get buffer() { return this.buf.buffer; }
|
|
498
|
+
get usedElements() { return this.cursor; }
|
|
499
|
+
/** Stage `data` (bytes) at the given byte offset; tracks dirty range. */
|
|
500
|
+
write(dstByteOffset, data) {
|
|
501
|
+
this.shadow.set(data, dstByteOffset);
|
|
502
|
+
if (dstByteOffset < this.dirtyMin)
|
|
503
|
+
this.dirtyMin = dstByteOffset;
|
|
504
|
+
const end = dstByteOffset + data.byteLength;
|
|
505
|
+
if (end > this.dirtyMax)
|
|
506
|
+
this.dirtyMax = end;
|
|
507
|
+
}
|
|
508
|
+
flush(device) {
|
|
509
|
+
if (this.dirtyMax <= this.dirtyMin)
|
|
510
|
+
return;
|
|
511
|
+
device.queue.writeBuffer(this.buf.buffer, this.dirtyMin, this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin, this.dirtyMax - this.dirtyMin);
|
|
512
|
+
this.dirtyMin = Infinity;
|
|
513
|
+
this.dirtyMax = 0;
|
|
514
|
+
}
|
|
515
|
+
alloc(elements) {
|
|
516
|
+
for (let i = 0; i < this.freeList.length; i++) {
|
|
517
|
+
const f = this.freeList[i];
|
|
518
|
+
if (f.size >= elements) {
|
|
519
|
+
const off = f.off;
|
|
520
|
+
if (f.size === elements)
|
|
521
|
+
this.freeList.splice(i, 1);
|
|
522
|
+
else {
|
|
523
|
+
f.off += elements;
|
|
524
|
+
f.size -= elements;
|
|
525
|
+
}
|
|
526
|
+
return off;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const off = this.cursor;
|
|
530
|
+
this.cursor += elements;
|
|
531
|
+
this.buf.ensureCapacity(this.cursor * 4);
|
|
532
|
+
this.buf.setUsed(this.cursor * 4);
|
|
533
|
+
return off;
|
|
534
|
+
}
|
|
535
|
+
release(off, elements) {
|
|
536
|
+
this.freeList.push({ off, size: elements });
|
|
537
|
+
this.freeList.sort((a, b) => a.off - b.off);
|
|
538
|
+
for (let i = 0; i < this.freeList.length - 1;) {
|
|
539
|
+
const a = this.freeList[i], b = this.freeList[i + 1];
|
|
540
|
+
if (a.off + a.size === b.off) {
|
|
541
|
+
a.size += b.size;
|
|
542
|
+
this.freeList.splice(i + 1, 1);
|
|
543
|
+
}
|
|
544
|
+
else
|
|
545
|
+
i++;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
onResize(cb) { return this.buf.onResize(cb); }
|
|
549
|
+
destroy() { this.buf.destroy(); }
|
|
550
|
+
}
|
|
551
|
+
function buildArenaState(device, attrBytesHint, idxBytesHint, label, idxExtraUsage = 0) {
|
|
552
|
+
const attrs = new AttributeArena(new GrowBuffer(device, `${label}/attrs`, GPUBufferUsage.STORAGE, attrBytesHint));
|
|
553
|
+
const indices = new IndexAllocator(new GrowBuffer(device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage, idxBytesHint));
|
|
554
|
+
return { attrs, indices };
|
|
555
|
+
}
|
|
556
|
+
function arenaBytes(arena) {
|
|
557
|
+
return arena.attrs.usedBytes + arena.indices.usedElements * 4;
|
|
558
|
+
}
|
|
559
|
+
/** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */
|
|
560
|
+
function writeAttribute(device, buf, ref, typeId, length, data) {
|
|
561
|
+
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + data.byteLength);
|
|
562
|
+
const staging = new ArrayBuffer(allocBytes);
|
|
563
|
+
const u32 = new Uint32Array(staging);
|
|
564
|
+
const f32 = new Float32Array(staging);
|
|
565
|
+
u32[0] = typeId;
|
|
566
|
+
u32[1] = length;
|
|
567
|
+
f32.set(data, ALLOC_HEADER_PAD_TO / 4);
|
|
568
|
+
device.queue.writeBuffer(buf, ref, staging, 0, allocBytes);
|
|
166
569
|
}
|
|
167
|
-
function alignUp(n, a) { return (n + a - 1) & ~(a - 1); }
|
|
168
570
|
function asAval(v) {
|
|
169
571
|
return (typeof v === "object" && v !== null && typeof v.getValue === "function")
|
|
170
572
|
? v
|
|
171
573
|
: AVal.constant(v);
|
|
172
574
|
}
|
|
173
|
-
|
|
174
|
-
|
|
575
|
+
/** Heuristic predicate — BufferView has `buffer: aval<IBuffer>` + elementType. */
|
|
576
|
+
function isBufferView(v) {
|
|
577
|
+
if (typeof v !== "object" || v === null)
|
|
578
|
+
return false;
|
|
579
|
+
const o = v;
|
|
580
|
+
return typeof o.buffer === "object" && o.buffer !== null
|
|
581
|
+
&& typeof o.buffer.getValue === "function"
|
|
582
|
+
&& typeof o.elementType === "object" && o.elementType !== null;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Float32 view over a host-side buffer source. Used by the BufferView
|
|
586
|
+
* packer to hand the pool a typed array it can `set()` from.
|
|
587
|
+
*/
|
|
588
|
+
function asFloat32(data) {
|
|
589
|
+
if (data instanceof Float32Array)
|
|
590
|
+
return data;
|
|
591
|
+
if (ArrayBuffer.isView(data)) {
|
|
592
|
+
return new Float32Array(data.buffer, data.byteOffset, data.byteLength / 4);
|
|
593
|
+
}
|
|
594
|
+
return new Float32Array(data); // ArrayBuffer
|
|
595
|
+
}
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
// Megacall GPU prefix-sum compute shader
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
const SCAN_TILE_SIZE = 512;
|
|
600
|
+
const SCAN_WG_SIZE = 256;
|
|
601
|
+
const SCAN_MAX_RECORDS = SCAN_TILE_SIZE * SCAN_TILE_SIZE; // numBlocks ≤ TILE_SIZE
|
|
602
|
+
const TILE_K = 64;
|
|
603
|
+
/** drawTable record width: (firstEmit, drawIdx, indexStart, indexCount, instanceCount). */
|
|
604
|
+
const RECORD_U32 = 5;
|
|
605
|
+
const RECORD_BYTES = RECORD_U32 * 4;
|
|
606
|
+
const HEAP_SCAN_WGSL = `
|
|
607
|
+
struct Params {
|
|
608
|
+
numRecords: u32,
|
|
609
|
+
numBlocks: u32,
|
|
610
|
+
_pad0: u32,
|
|
611
|
+
_pad1: u32,
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
struct Record {
|
|
615
|
+
firstEmit: u32,
|
|
616
|
+
drawIdx: u32,
|
|
617
|
+
indexStart: u32,
|
|
618
|
+
indexCount: u32,
|
|
619
|
+
instanceCount: u32,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
@group(0) @binding(0) var<storage, read_write> drawTable: array<Record>;
|
|
623
|
+
@group(0) @binding(1) var<storage, read_write> blockSums: array<u32>;
|
|
624
|
+
@group(0) @binding(2) var<storage, read_write> blockOffsets: array<u32>;
|
|
625
|
+
@group(0) @binding(3) var<storage, read_write> indirect: array<u32>;
|
|
626
|
+
@group(0) @binding(4) var<uniform> params: Params;
|
|
627
|
+
@group(0) @binding(5) var<storage, read_write> firstDrawInTile: array<u32>;
|
|
628
|
+
|
|
629
|
+
const TILE_SIZE: u32 = 512u;
|
|
630
|
+
const WG_SIZE: u32 = 256u;
|
|
631
|
+
const TILE_K: u32 = 64u;
|
|
632
|
+
|
|
633
|
+
var<workgroup> sdata: array<u32, 512>;
|
|
634
|
+
|
|
635
|
+
fn blellochScan(tid: u32) {
|
|
636
|
+
var offset: u32 = 1u;
|
|
637
|
+
for (var d: u32 = TILE_SIZE >> 1u; d > 0u; d = d >> 1u) {
|
|
638
|
+
workgroupBarrier();
|
|
639
|
+
if (tid < d) {
|
|
640
|
+
let ai = offset * (2u * tid + 1u) - 1u;
|
|
641
|
+
let bi = offset * (2u * tid + 2u) - 1u;
|
|
642
|
+
sdata[bi] = sdata[bi] + sdata[ai];
|
|
643
|
+
}
|
|
644
|
+
offset = offset * 2u;
|
|
645
|
+
}
|
|
646
|
+
if (tid == 0u) { sdata[TILE_SIZE - 1u] = 0u; }
|
|
647
|
+
for (var d: u32 = 1u; d < TILE_SIZE; d = d * 2u) {
|
|
648
|
+
offset = offset >> 1u;
|
|
649
|
+
workgroupBarrier();
|
|
650
|
+
if (tid < d) {
|
|
651
|
+
let ai = offset * (2u * tid + 1u) - 1u;
|
|
652
|
+
let bi = offset * (2u * tid + 2u) - 1u;
|
|
653
|
+
let t = sdata[ai];
|
|
654
|
+
sdata[ai] = sdata[bi];
|
|
655
|
+
sdata[bi] = sdata[bi] + t;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
workgroupBarrier();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
@compute @workgroup_size(WG_SIZE)
|
|
662
|
+
fn scanTile(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
|
|
663
|
+
let tid = lid.x;
|
|
664
|
+
let blockOff = wgid.x * TILE_SIZE;
|
|
665
|
+
let n = params.numRecords;
|
|
666
|
+
let i0 = blockOff + tid;
|
|
667
|
+
let i1 = blockOff + tid + WG_SIZE;
|
|
668
|
+
var v0: u32 = 0u;
|
|
669
|
+
var v1: u32 = 0u;
|
|
670
|
+
if (i0 < n) { v0 = drawTable[i0].indexCount * drawTable[i0].instanceCount; }
|
|
671
|
+
if (i1 < n) { v1 = drawTable[i1].indexCount * drawTable[i1].instanceCount; }
|
|
672
|
+
sdata[tid] = v0;
|
|
673
|
+
sdata[tid + WG_SIZE] = v1;
|
|
674
|
+
workgroupBarrier();
|
|
675
|
+
blellochScan(tid);
|
|
676
|
+
if (i0 < n) { drawTable[i0].firstEmit = sdata[tid]; }
|
|
677
|
+
if (i1 < n) { drawTable[i1].firstEmit = sdata[tid + WG_SIZE]; }
|
|
678
|
+
if (tid == WG_SIZE - 1u) {
|
|
679
|
+
blockSums[wgid.x] = sdata[tid + WG_SIZE] + v1;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
@compute @workgroup_size(WG_SIZE)
|
|
684
|
+
fn scanBlocks(@builtin(local_invocation_id) lid: vec3<u32>) {
|
|
685
|
+
let tid = lid.x;
|
|
686
|
+
let n = params.numBlocks;
|
|
687
|
+
let i0 = tid;
|
|
688
|
+
let i1 = tid + WG_SIZE;
|
|
689
|
+
var v0: u32 = 0u;
|
|
690
|
+
var v1: u32 = 0u;
|
|
691
|
+
if (i0 < n) { v0 = blockSums[i0]; }
|
|
692
|
+
if (i1 < n) { v1 = blockSums[i1]; }
|
|
693
|
+
sdata[tid] = v0;
|
|
694
|
+
sdata[tid + WG_SIZE] = v1;
|
|
695
|
+
workgroupBarrier();
|
|
696
|
+
blellochScan(tid);
|
|
697
|
+
if (i0 < n) { blockOffsets[i0] = sdata[tid]; }
|
|
698
|
+
if (i1 < n) { blockOffsets[i1] = sdata[tid + WG_SIZE]; }
|
|
699
|
+
workgroupBarrier();
|
|
700
|
+
if (tid == 0u) {
|
|
701
|
+
if (n > 0u) {
|
|
702
|
+
let lastIdx = n - 1u;
|
|
703
|
+
let total = blockOffsets[lastIdx] + blockSums[lastIdx];
|
|
704
|
+
indirect[0] = total;
|
|
705
|
+
} else {
|
|
706
|
+
indirect[0] = 0u;
|
|
707
|
+
}
|
|
708
|
+
indirect[1] = 1u;
|
|
709
|
+
indirect[2] = 0u;
|
|
710
|
+
indirect[3] = 0u;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
@compute @workgroup_size(WG_SIZE)
|
|
715
|
+
fn addOffsets(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
|
|
716
|
+
let tid = lid.x;
|
|
717
|
+
let blockOff = wgid.x * TILE_SIZE;
|
|
718
|
+
let n = params.numRecords;
|
|
719
|
+
let off = blockOffsets[wgid.x];
|
|
720
|
+
let i0 = blockOff + tid;
|
|
721
|
+
let i1 = blockOff + tid + WG_SIZE;
|
|
722
|
+
if (i0 < n) { drawTable[i0].firstEmit = drawTable[i0].firstEmit + off; }
|
|
723
|
+
if (i1 < n) { drawTable[i1].firstEmit = drawTable[i1].firstEmit + off; }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
@compute @workgroup_size(WG_SIZE)
|
|
727
|
+
fn buildTileIndex(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
728
|
+
let tileIdx = gid.x;
|
|
729
|
+
// totalEmit is computed by scanBlocks into indirect[0]; reading it
|
|
730
|
+
// from indirect avoids a separate uniform/storage round-trip.
|
|
731
|
+
let totalEmit = indirect[0];
|
|
732
|
+
let numTiles = (totalEmit + TILE_K - 1u) / TILE_K;
|
|
733
|
+
if (tileIdx > numTiles) { return; }
|
|
734
|
+
if (params.numRecords == 0u) {
|
|
735
|
+
if (tileIdx == 0u) { firstDrawInTile[0] = 0u; }
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (tileIdx == numTiles) {
|
|
739
|
+
// Sentinel for the open upper bound — the LAST VALID SLOT, not
|
|
740
|
+
// numRecords. The render VS uses
|
|
741
|
+
// hi = firstDrawInTile[_tileIdx + 1u]
|
|
742
|
+
// and the binary search treats hi as INCLUSIVE. If the sentinel
|
|
743
|
+
// were numRecords (one past last), the search would drag lo into
|
|
744
|
+
// the OOB slot for emits in the last tile, since drawTable reads
|
|
745
|
+
// past recordCount return 0 (binding size clamping) and 0 ≤ emit
|
|
746
|
+
// is always true. Visible symptom: the LAST few emits in the
|
|
747
|
+
// bucket land on slot=numRecords (drawIdx=0, indexCount=0 → /-by-
|
|
748
|
+
// zero) → degenerate / cross-RO triangle stitched to slot 0.
|
|
749
|
+
firstDrawInTile[tileIdx] = params.numRecords - 1u;
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
let tileStart = tileIdx * TILE_K;
|
|
753
|
+
var lo: u32 = 0u;
|
|
754
|
+
var hi: u32 = params.numRecords - 1u;
|
|
755
|
+
loop {
|
|
756
|
+
if (lo >= hi) { break; }
|
|
757
|
+
let mid = (lo + hi + 1u) >> 1u;
|
|
758
|
+
if (drawTable[mid].firstEmit <= tileStart) { lo = mid; } else { hi = mid - 1u; }
|
|
759
|
+
}
|
|
760
|
+
firstDrawInTile[tileIdx] = lo;
|
|
761
|
+
}
|
|
762
|
+
`;
|
|
763
|
+
export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
764
|
+
const atlasPool = opts.atlasPool;
|
|
175
765
|
const colorAttachmentName = sig.colorNames[0];
|
|
176
766
|
if (colorAttachmentName === undefined) {
|
|
177
767
|
throw new Error("buildHeapScene: framebuffer signature has no color attachment");
|
|
178
768
|
}
|
|
179
769
|
const colorFormat = sig.colors.tryFind(colorAttachmentName);
|
|
180
770
|
const depthFormat = sig.depthStencil?.format;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
771
|
+
// ─── Global arena (uniform/attribute data + index buffer) ────────
|
|
772
|
+
// Initial capacities are just hints; both buffers pow2-grow on
|
|
773
|
+
// demand. Skip per-draw enumeration since aval-keyed sharing makes
|
|
774
|
+
// the actual allocated size hard to predict (10K instanced draws
|
|
775
|
+
// sharing the same Positions array → 1 alloc, not 10K).
|
|
776
|
+
const arena = buildArenaState(device, 64 * 1024, 16 * 1024, "heapScene", GPUBufferUsage.STORAGE);
|
|
777
|
+
// ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
|
|
778
|
+
const drawIdToBucket = [];
|
|
779
|
+
const drawIdToLocalSlot = [];
|
|
780
|
+
/** Per-draw index aval — for `indexPool.release` on removeDraw. */
|
|
781
|
+
const drawIdToIndexAval = [];
|
|
782
|
+
let nextDrawId = 0;
|
|
783
|
+
/**
|
|
784
|
+
* Unwrap an aval to its inner value, or pass through a plain value.
|
|
785
|
+
* Boundary helper at scene-build time — no adaptive context to read
|
|
786
|
+
* through, so a plain force is the right call.
|
|
787
|
+
*/
|
|
788
|
+
function readPlain(v) {
|
|
789
|
+
if (typeof v === "object" && v !== null && typeof v.force === "function") {
|
|
790
|
+
return v.force( /* allow-force */);
|
|
791
|
+
}
|
|
792
|
+
return v;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Pick a pool placement (dataBytes/typeId/length/pack) for a
|
|
796
|
+
* schema-driven DrawHeader field given the JS value. Drives both
|
|
797
|
+
* fixed-size uniform reads (mat4, vec4, …) and variable-size
|
|
798
|
+
* attribute arrays — the latter measured from the value itself.
|
|
799
|
+
*/
|
|
800
|
+
function poolPlacementFor(f, value) {
|
|
801
|
+
if (f.kind === "uniform-ref") {
|
|
802
|
+
const p = packerForWgslType(f.uniformWgslType ?? "");
|
|
803
|
+
return { dataBytes: p.dataBytes, typeId: p.typeId, length: 1, pack: p.pack };
|
|
804
|
+
}
|
|
805
|
+
// attribute-ref: variable-size array; we copy verbatim into the
|
|
806
|
+
// arena. The current encoding is V3F_TIGHT (3 f32s per element).
|
|
807
|
+
const arr = value;
|
|
808
|
+
const eltBytes = f.attributeWgslType === "vec3<f32>" ? 12 :
|
|
809
|
+
f.attributeWgslType === "vec4<f32>" ? 12 /* v3 stored, v4 assembled in shader */ :
|
|
810
|
+
f.attributeWgslType === "vec2<f32>" ? 8 :
|
|
811
|
+
4;
|
|
812
|
+
const length = arr.byteLength / eltBytes;
|
|
813
|
+
return {
|
|
814
|
+
dataBytes: arr.byteLength,
|
|
815
|
+
typeId: ENC_V3F_TIGHT,
|
|
816
|
+
length,
|
|
817
|
+
pack: (val, dst, off) => { dst.set(val, off); },
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Pool placement for a `BufferView`-backed attribute. The pool key
|
|
822
|
+
* is the BufferView's `aval<IBuffer>` (so `buffer`-identity drives
|
|
823
|
+
* sharing). The packer reads the `IBuffer`'s host data — native
|
|
824
|
+
* GPUBuffer-backed views are rejected (they belong on the legacy
|
|
825
|
+
* fallback path; see `isHeapEligible`).
|
|
826
|
+
*
|
|
827
|
+
* Validates: `stride === elementType.byteSize` (tight) and
|
|
828
|
+
* `offset === 0`. Both can be relaxed once the gather path
|
|
829
|
+
* supports strided/sub-range copies.
|
|
830
|
+
*/
|
|
831
|
+
function bufferViewPlacement(f, bv) {
|
|
832
|
+
const srcEltBytes = bv.elementType.byteSize;
|
|
833
|
+
const offset = bv.offset ?? 0;
|
|
834
|
+
if (offset !== 0) {
|
|
835
|
+
throw new Error(`heapScene: BufferView offset ${offset} > 0 not yet supported`);
|
|
836
|
+
}
|
|
837
|
+
// In-arena element bytes: the storage layout the shader will read
|
|
838
|
+
// from. vec4 is always 16 (read via `heapV4f[idx]`), vec3 is 12
|
|
839
|
+
// (read as 3 f32s), vec2 is 8, scalar is 4. V3f source for a vec4
|
|
840
|
+
// schema is expanded to V4f at upload with .w = 1.0 (Positions
|
|
841
|
+
// convention) — the shader reads heapV4f[idx] either way.
|
|
842
|
+
const outEltBytes = f.attributeWgslType === "vec3<f32>" ? 12 :
|
|
843
|
+
f.attributeWgslType === "vec4<f32>" ? 16 :
|
|
844
|
+
f.attributeWgslType === "vec2<f32>" ? 8 :
|
|
845
|
+
4;
|
|
846
|
+
const allowedSrc = f.attributeWgslType === "vec4<f32>"
|
|
847
|
+
? [12, 16] // V3f-with-w=1 expansion OR genuine V4f source
|
|
848
|
+
: [outEltBytes];
|
|
849
|
+
if (!allowedSrc.includes(srcEltBytes)) {
|
|
850
|
+
throw new Error(`heapScene: BufferView elementType byteSize ${srcEltBytes} doesn't match ` +
|
|
851
|
+
`schema attribute ${f.attributeWgslType ?? "?"} (expected ${allowedSrc.join(" or ")})`);
|
|
852
|
+
}
|
|
853
|
+
// Stride convention:
|
|
854
|
+
// stride == srcEltBytes (tight per-vertex) → length = byteLength / srcEltBytes
|
|
855
|
+
// stride == 0 OR singleValue !== undefined → broadcast, length = 1
|
|
856
|
+
// anything else (interleaved) → unsupported
|
|
857
|
+
// The shader uses cyclic addressing (`vid % length`), so a
|
|
858
|
+
// length-1 broadcast just maps every vertex to element 0.
|
|
859
|
+
const stride = bv.stride ?? srcEltBytes;
|
|
860
|
+
const isBroadcast = bv.singleValue !== undefined || stride === 0;
|
|
861
|
+
if (!isBroadcast && stride !== srcEltBytes) {
|
|
862
|
+
throw new Error(`heapScene: BufferView stride ${stride} not tight (${srcEltBytes}) and not a broadcast`);
|
|
863
|
+
}
|
|
864
|
+
const ib = bv.buffer.force( /* allow-force */);
|
|
865
|
+
if (ib.kind !== "host") {
|
|
866
|
+
throw new Error(`heapScene: BufferView wraps a native GPUBuffer (kind=${ib.kind}); ` +
|
|
867
|
+
`route this RO via the legacy path (see isHeapEligible).`);
|
|
868
|
+
}
|
|
869
|
+
// Store source-as-is; the alloc header's stride field tells the VS
|
|
870
|
+
// how many floats per element. No host-side expansion — the VS
|
|
871
|
+
// decode for vec4 uses `select` to fill `.w = 1.0` when the source
|
|
872
|
+
// is V3-tight.
|
|
873
|
+
const length = isBroadcast ? 1 : ib.sizeBytes / srcEltBytes;
|
|
874
|
+
const dataBytes = srcEltBytes * length;
|
|
875
|
+
void outEltBytes; // (not used: storage matches source exactly)
|
|
876
|
+
return {
|
|
877
|
+
dataBytes,
|
|
878
|
+
typeId: ENC_V3F_TIGHT,
|
|
879
|
+
length,
|
|
880
|
+
pack: (val, dst, off) => {
|
|
881
|
+
const ibuf = val;
|
|
882
|
+
if (ibuf.kind !== "host") {
|
|
883
|
+
throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
|
|
884
|
+
}
|
|
885
|
+
const src = asFloat32(ibuf.data);
|
|
886
|
+
const limitFloats = dataBytes / 4;
|
|
887
|
+
dst.set(src.subarray(0, limitFloats), off);
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Pool placement for a per-instance uniform: the value is an array
|
|
893
|
+
* (length = `instanceCount`) of WGSL-typed elements packed tightly.
|
|
894
|
+
* The shader reads element `iidx` via `instanceLoadExpr`.
|
|
895
|
+
*/
|
|
896
|
+
function perInstancePlacementFor(f, value, instanceCount) {
|
|
897
|
+
const elt = packerForWgslType(f.uniformWgslType ?? "");
|
|
898
|
+
const dataBytes = elt.dataBytes * instanceCount;
|
|
899
|
+
const stride = elt.dataBytes / 4;
|
|
900
|
+
return {
|
|
901
|
+
dataBytes,
|
|
902
|
+
typeId: 0,
|
|
903
|
+
length: instanceCount,
|
|
904
|
+
pack: (val, dst, off) => {
|
|
905
|
+
const arr = val;
|
|
906
|
+
if (arr.length < instanceCount) {
|
|
907
|
+
throw new Error(`heapScene: per-instance value has length ${arr.length} < instanceCount ${instanceCount}`);
|
|
908
|
+
}
|
|
909
|
+
for (let i = 0; i < instanceCount; i++)
|
|
910
|
+
elt.pack(arr[i], dst, off + i * stride);
|
|
911
|
+
},
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
// ─── Pools — aval-keyed refcounted allocations ────────────────────
|
|
915
|
+
const pool = new UniformPool();
|
|
916
|
+
const indexPool = new IndexPool();
|
|
917
|
+
// ─── Adaptive routing: aval marks → repack the pool entry ─────────
|
|
918
|
+
// With pool-managed per-draw uniforms, value changes don't touch
|
|
919
|
+
// any DrawHeader (refs stay constant) — only the arena allocation's
|
|
920
|
+
// data needs re-uploading. inputChanged(o) routes to allocDirty
|
|
921
|
+
// when `o` is a known pool aval; the frame loop drains it.
|
|
922
|
+
const allocDirty = new Set();
|
|
923
|
+
// Atlas texture-aval reactivity. When a `cval<ITexture>` driving an
|
|
924
|
+
// atlas-routed RO swaps its inner ITexture, the heap path needs to
|
|
925
|
+
// (a) release the old sub-rect, (b) acquire a new one, and
|
|
926
|
+
// (c) rewrite the drawHeader fields of every RO referencing the aval.
|
|
927
|
+
// Mirrors `allocDirty` shape but for atlas placements rather than
|
|
928
|
+
// arena uniforms. Each aval can be referenced by multiple ROs (across
|
|
929
|
+
// buckets) — `atlasAvalRefs` tracks all (bucket, localSlot) pairs so
|
|
930
|
+
// one swap rewrites N drawHeaders in one drain.
|
|
931
|
+
const atlasAvalDirty = new Set();
|
|
932
|
+
const atlasAvalRefs = new Map();
|
|
933
|
+
/**
|
|
934
|
+
* Per-draw bucket dirty (rare in steady state — only fires when
|
|
935
|
+
* something forces a header rewrite, e.g. a drawHeap GrowBuffer
|
|
936
|
+
* resize remarks every live local slot). Value-only changes go
|
|
937
|
+
* through `allocDirty` instead.
|
|
938
|
+
*/
|
|
939
|
+
// (Bucket.dirty is on each bucket; this comment is a placeholder.)
|
|
940
|
+
class HeapSceneObj extends AdaptiveObject {
|
|
941
|
+
inputChanged(_t, o) {
|
|
942
|
+
// Pool avals are stored as `aval<unknown>`; the IAdaptiveObject
|
|
943
|
+
// identity matches.
|
|
944
|
+
if (pool.has(o)) {
|
|
945
|
+
allocDirty.add(o);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
// §7 derived-uniforms constituent (Trafo3d aval). Routed BEFORE
|
|
949
|
+
// the atlas check because trafo avals never overlap with texture
|
|
950
|
+
// avals — early-return is just to avoid the second Map lookup.
|
|
951
|
+
if (derivedScene !== undefined && derivedScene.routeInputChanged(o)) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const av = o;
|
|
955
|
+
if (atlasAvalRefs.has(av)) {
|
|
956
|
+
atlasAvalDirty.add(av);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const sceneObj = new HeapSceneObj();
|
|
961
|
+
// ─── §7 derived-uniforms (opt-in) ─────────────────────────────────
|
|
962
|
+
// Constructed lazily after sceneObj so the SubscribeFn can reference
|
|
963
|
+
// both. `derivedScene` is undefined when the option is off; nothing
|
|
964
|
+
// else in this file should run when it is.
|
|
965
|
+
const enableDerivedUniforms = opts.enableDerivedUniforms === true;
|
|
966
|
+
const derivedScene = enableDerivedUniforms
|
|
967
|
+
? new DerivedUniformsScene(device, arena.attrs.buffer, {
|
|
968
|
+
// Larger initial capacity reduces grow-during-first-frame churn.
|
|
969
|
+
initialConstituentSlots: 4096,
|
|
970
|
+
initialRecordCapacity: 8192,
|
|
971
|
+
})
|
|
972
|
+
: undefined;
|
|
973
|
+
/** Per-RO §7 registration handles, keyed by global drawId.
|
|
974
|
+
* Drained on removeDraw to release slots + records. */
|
|
975
|
+
const derivedByDrawId = new Map();
|
|
976
|
+
// ─── Atlas bindings ───────────────────────────────────────────────
|
|
977
|
+
//
|
|
978
|
+
// A bucket whose textures live in the atlas binds N consecutive
|
|
979
|
+
// `texture_2d<f32>` slots per format (linear + srgb), plus a shared
|
|
980
|
+
// atlas sampler. Each AtlasPage is its own independent GPUTexture;
|
|
981
|
+
// unfilled slots get a 1×1 placeholder so the BGL is fully populated.
|
|
982
|
+
// When a fresh page joins via `AtlasPool.onPageAdded`, we rebuild
|
|
983
|
+
// affected bind groups (the new page's slot now points at a real
|
|
984
|
+
// texture instead of the placeholder).
|
|
985
|
+
//
|
|
986
|
+
// Replaces both the failed `binding_array<texture_2d<f32>, N>` design
|
|
987
|
+
// (needs experimental bindless) and the texture_2d_array fallback
|
|
988
|
+
// (needs GPU-side copy on layer-count grow). N independent textures
|
|
989
|
+
// + an N-way `switch pageRef` in the shader is core WebGPU 1.0 and
|
|
990
|
+
// makes page-add a pure allocation.
|
|
991
|
+
// Shared atlas sampler — shader does mip filtering manually, so
|
|
992
|
+
// hardware mipmapFilter stays "nearest". Plan §2.
|
|
993
|
+
const atlasSampler = device.createSampler({
|
|
994
|
+
label: "heapScene/atlasSampler",
|
|
995
|
+
magFilter: "linear", minFilter: "linear", mipmapFilter: "nearest",
|
|
249
996
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
997
|
+
// Placeholder textures (one per format) used for atlas binding slots
|
|
998
|
+
// that don't yet point at an allocated AtlasPage. WebGPU rejects
|
|
999
|
+
// bind-group entries with `undefined` resources, so every slot in
|
|
1000
|
+
// the N-wide ladder must hold a valid view at all times.
|
|
1001
|
+
let atlasPlaceholders;
|
|
1002
|
+
function getAtlasPlaceholder(format) {
|
|
1003
|
+
if (atlasPlaceholders === undefined) {
|
|
1004
|
+
atlasPlaceholders = new Map();
|
|
1005
|
+
}
|
|
1006
|
+
const cached = atlasPlaceholders.get(format);
|
|
1007
|
+
if (cached !== undefined)
|
|
1008
|
+
return cached;
|
|
1009
|
+
const tex = device.createTexture({
|
|
1010
|
+
label: `heapScene/atlasPlaceholder/${format}`,
|
|
1011
|
+
size: { width: 1, height: 1, depthOrArrayLayers: 1 },
|
|
1012
|
+
format,
|
|
1013
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
1014
|
+
});
|
|
1015
|
+
const view = tex.createView();
|
|
1016
|
+
atlasPlaceholders.set(format, view);
|
|
1017
|
+
return view;
|
|
1018
|
+
}
|
|
1019
|
+
const bglCache = new Map();
|
|
1020
|
+
function getBgl(layout, withAtlasArrays) {
|
|
1021
|
+
const key = `t${layout.textureBindings.length}|s${layout.samplerBindings.length}|a${withAtlasArrays ? 1 : 0}`;
|
|
1022
|
+
let e = bglCache.get(key);
|
|
1023
|
+
if (e !== undefined)
|
|
1024
|
+
return e;
|
|
1025
|
+
// Heap data buffers are read by both stages: FS uniform-via-varying
|
|
1026
|
+
// threading reads `heapF32` / `heapV4f` to decode uniforms inside
|
|
1027
|
+
// the fragment shader. Visibility = VERTEX | FRAGMENT for all four.
|
|
1028
|
+
const heapVis = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
|
|
1029
|
+
const entries = [
|
|
1030
|
+
{ binding: 0, visibility: heapVis, buffer: { type: "read-only-storage" } },
|
|
1031
|
+
{ binding: 1, visibility: heapVis, buffer: { type: "read-only-storage" } },
|
|
1032
|
+
{ binding: 2, visibility: heapVis, buffer: { type: "read-only-storage" } },
|
|
1033
|
+
{ binding: 3, visibility: heapVis, buffer: { type: "read-only-storage" } },
|
|
1034
|
+
];
|
|
1035
|
+
entries.push({ binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, { binding: 5, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }, { binding: 6, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } });
|
|
1036
|
+
for (const t of layout.textureBindings) {
|
|
1037
|
+
entries.push({
|
|
1038
|
+
binding: t.binding, visibility: GPUShaderStage.FRAGMENT,
|
|
1039
|
+
texture: { sampleType: "float" },
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
for (const s of layout.samplerBindings) {
|
|
1043
|
+
entries.push({
|
|
1044
|
+
binding: s.binding, visibility: GPUShaderStage.FRAGMENT,
|
|
1045
|
+
sampler: { type: "filtering" },
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
if (withAtlasArrays) {
|
|
1049
|
+
// 2N + 1 entries: N linear textures, N srgb textures, 1 sampler.
|
|
1050
|
+
for (let i = 0; i < ATLAS_ARRAY_SIZE; i++) {
|
|
1051
|
+
entries.push({
|
|
1052
|
+
binding: ATLAS_LINEAR_BINDING_BASE + i, visibility: GPUShaderStage.FRAGMENT,
|
|
1053
|
+
texture: { sampleType: "float" },
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
for (let i = 0; i < ATLAS_ARRAY_SIZE; i++) {
|
|
1057
|
+
entries.push({
|
|
1058
|
+
binding: ATLAS_SRGB_BINDING_BASE + i, visibility: GPUShaderStage.FRAGMENT,
|
|
1059
|
+
texture: { sampleType: "float" },
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
entries.push({
|
|
1063
|
+
binding: ATLAS_SAMPLER_BINDING, visibility: GPUShaderStage.FRAGMENT,
|
|
1064
|
+
sampler: { type: "filtering" },
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
const bgl = device.createBindGroupLayout({ label: `heapScene/bgl/${key}`, entries });
|
|
1068
|
+
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bgl] });
|
|
1069
|
+
e = { bgl, pipelineLayout };
|
|
1070
|
+
bglCache.set(key, e);
|
|
1071
|
+
return e;
|
|
1072
|
+
}
|
|
1073
|
+
// ─── Megacall compute (prefix-sum) pipelines ──────────────────────
|
|
1074
|
+
// Built once per scene (not per bucket — pipelines are identical;
|
|
1075
|
+
// only the bind-group differs). All three pipelines share one
|
|
1076
|
+
// bind-group layout with 5 bindings (drawTable, blockSums,
|
|
1077
|
+
// blockOffsets, indirect, params).
|
|
1078
|
+
let scanBgl;
|
|
1079
|
+
let scanPipeTile;
|
|
1080
|
+
let scanPipeBlocks;
|
|
1081
|
+
let scanPipeAdd;
|
|
1082
|
+
let scanPipeBuildTileIndex;
|
|
1083
|
+
{
|
|
1084
|
+
scanBgl = device.createBindGroupLayout({
|
|
1085
|
+
label: "heapScene/scanBgl",
|
|
1086
|
+
entries: [
|
|
1087
|
+
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
1088
|
+
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
1089
|
+
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
1090
|
+
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
1091
|
+
{ binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
|
|
1092
|
+
{ binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
1093
|
+
],
|
|
1094
|
+
});
|
|
1095
|
+
const scanLayout = device.createPipelineLayout({ bindGroupLayouts: [scanBgl] });
|
|
1096
|
+
const scanModule = device.createShaderModule({ code: HEAP_SCAN_WGSL, label: "heapScene/scan" });
|
|
1097
|
+
scanPipeTile = device.createComputePipeline({
|
|
1098
|
+
label: "heapScene/scan/tile", layout: scanLayout,
|
|
1099
|
+
compute: { module: scanModule, entryPoint: "scanTile" },
|
|
1100
|
+
});
|
|
1101
|
+
scanPipeBlocks = device.createComputePipeline({
|
|
1102
|
+
label: "heapScene/scan/blocks", layout: scanLayout,
|
|
1103
|
+
compute: { module: scanModule, entryPoint: "scanBlocks" },
|
|
1104
|
+
});
|
|
1105
|
+
scanPipeAdd = device.createComputePipeline({
|
|
1106
|
+
label: "heapScene/scan/add", layout: scanLayout,
|
|
1107
|
+
compute: { module: scanModule, entryPoint: "addOffsets" },
|
|
1108
|
+
});
|
|
1109
|
+
scanPipeBuildTileIndex = device.createComputePipeline({
|
|
1110
|
+
label: "heapScene/scan/buildTileIndex", layout: scanLayout,
|
|
1111
|
+
compute: { module: scanModule, entryPoint: "buildTileIndex" },
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
function buildScanBindGroup(bucket) {
|
|
1115
|
+
if (scanBgl === undefined)
|
|
1116
|
+
throw new Error("heapScene: scan BGL missing");
|
|
1117
|
+
return device.createBindGroup({
|
|
1118
|
+
label: `heapScene/${bucket.label}/scanBg`,
|
|
1119
|
+
layout: scanBgl,
|
|
1120
|
+
entries: [
|
|
1121
|
+
{ binding: 0, resource: { buffer: bucket.drawTableBuf.buffer } },
|
|
1122
|
+
{ binding: 1, resource: { buffer: bucket.blockSumsBuf.buffer } },
|
|
1123
|
+
{ binding: 2, resource: { buffer: bucket.blockOffsetsBuf.buffer } },
|
|
1124
|
+
{ binding: 3, resource: { buffer: bucket.indirectBuf } },
|
|
1125
|
+
{ binding: 4, resource: { buffer: bucket.paramsBuf } },
|
|
1126
|
+
{ binding: 5, resource: { buffer: bucket.firstDrawInTileBuf.buffer } },
|
|
1127
|
+
],
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
// ─── Buckets ──────────────────────────────────────────────────────
|
|
1131
|
+
const buckets = [];
|
|
1132
|
+
const bucketByKey = new Map();
|
|
1133
|
+
const familyByEffect = new Map();
|
|
1134
|
+
let familyBuilt = false;
|
|
1135
|
+
const enableFamilyMerge = opts.enableFamilyMerge === true;
|
|
1136
|
+
function compileFamilyFor(effects, perInstanceByEffect) {
|
|
1137
|
+
const schema = buildShaderFamily(effects, opts.fragmentOutputLayout, undefined, {
|
|
1138
|
+
atlasizeAllTextures: atlasPool !== undefined,
|
|
1139
|
+
perEffectPerInstance: perInstanceByEffect,
|
|
1140
|
+
});
|
|
1141
|
+
const compiled = compileShaderFamily(schema, opts.fragmentOutputLayout);
|
|
1142
|
+
const vsModule = device.createShaderModule({ code: compiled.vs, label: `heapScene/family/${schema.id}/vs` });
|
|
1143
|
+
const fsModule = device.createShaderModule({ code: compiled.fs, label: `heapScene/family/${schema.id}/fs` });
|
|
1144
|
+
const fieldsForEffect = new Map();
|
|
1145
|
+
for (const e of schema.effects) {
|
|
1146
|
+
const s = schema.perEffectSchema.get(e);
|
|
1147
|
+
const fields = new Set();
|
|
1148
|
+
for (const a of s.attributes)
|
|
1149
|
+
fields.add(a.name);
|
|
1150
|
+
for (const u of s.uniforms)
|
|
1151
|
+
fields.add(u.name);
|
|
1152
|
+
for (const t of s.textures)
|
|
1153
|
+
fields.add(t.name);
|
|
1154
|
+
fieldsForEffect.set(e, fields);
|
|
1155
|
+
}
|
|
1156
|
+
return { schema, vsModule, fsModule, fieldsForEffect };
|
|
1157
|
+
}
|
|
1158
|
+
function buildFamilyFromSpecs(specs) {
|
|
1159
|
+
if (familyBuilt)
|
|
1160
|
+
return;
|
|
1161
|
+
if (specs.length === 0) {
|
|
1162
|
+
throw new Error("heapScene: cannot build shader family from an empty spec set");
|
|
1163
|
+
}
|
|
1164
|
+
// Deduplicate by Effect identity preserving order; derive per-effect
|
|
1165
|
+
// per-instance info from the spec's `instanceAttributes` map. The
|
|
1166
|
+
// IR substitution needs `perInstanceAttributes` set on the family
|
|
1167
|
+
// layout to address per-instance attribute reads via `instId`
|
|
1168
|
+
// instead of `vertex_index` — without this, instanced effects
|
|
1169
|
+
// produce broken geometry.
|
|
1170
|
+
const seen = new Set();
|
|
1171
|
+
const unique = [];
|
|
1172
|
+
const perInstanceByEffect = new Map();
|
|
1173
|
+
for (const spec of specs) {
|
|
1174
|
+
const e = spec.effect;
|
|
1175
|
+
let entry = perInstanceByEffect.get(e);
|
|
1176
|
+
if (entry === undefined) {
|
|
1177
|
+
entry = { attributes: new Set(), uniforms: new Set() };
|
|
1178
|
+
perInstanceByEffect.set(e, entry);
|
|
1179
|
+
}
|
|
1180
|
+
if (spec.instanceAttributes !== undefined) {
|
|
1181
|
+
for (const name of Object.keys(spec.instanceAttributes))
|
|
1182
|
+
entry.attributes.add(name);
|
|
1183
|
+
}
|
|
1184
|
+
if (!seen.has(e)) {
|
|
1185
|
+
seen.add(e);
|
|
1186
|
+
unique.push(e);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (enableFamilyMerge) {
|
|
1190
|
+
const merged = compileFamilyFor(unique, perInstanceByEffect);
|
|
1191
|
+
for (const e of unique)
|
|
1192
|
+
familyByEffect.set(e, merged);
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
// Default: one family per effect — no shared layoutId switch.
|
|
1196
|
+
// Per-effect bucketing is at-or-better than merged on tested
|
|
1197
|
+
// workloads; merge stays opt-in pending trace-based v2.
|
|
1198
|
+
for (const e of unique) {
|
|
1199
|
+
const perI = perInstanceByEffect.get(e);
|
|
1200
|
+
const singleMap = new Map();
|
|
1201
|
+
if (perI !== undefined)
|
|
1202
|
+
singleMap.set(e, perI);
|
|
1203
|
+
familyByEffect.set(e, compileFamilyFor([e], singleMap));
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
familyBuilt = true;
|
|
1207
|
+
}
|
|
1208
|
+
function familyFor(effect) {
|
|
1209
|
+
const f = familyByEffect.get(effect);
|
|
1210
|
+
if (f === undefined) {
|
|
1211
|
+
const known = [...familyByEffect.keys()].map(e => e.id).join(",");
|
|
1212
|
+
throw new Error(`heapScene: family is frozen; effect ${effect.id} not in {${known}}; ` +
|
|
1213
|
+
`reactive family rebuild is v2`);
|
|
1214
|
+
}
|
|
1215
|
+
return f;
|
|
1216
|
+
}
|
|
1217
|
+
function ensureFamilyKnowsEffect(effect) {
|
|
1218
|
+
if (!familyBuilt) {
|
|
1219
|
+
throw new Error("heapScene: ensureFamilyKnowsEffect called before family build");
|
|
1220
|
+
}
|
|
1221
|
+
familyFor(effect); // throws if unknown
|
|
1222
|
+
}
|
|
1223
|
+
// ─── id-of helpers ────────────────────────────────────────────────
|
|
1224
|
+
const idOf = (s) => `effect#${s.id}`;
|
|
1225
|
+
const textureIds = new WeakMap();
|
|
1226
|
+
let texCounter = 0;
|
|
1227
|
+
const texIdOf = (t) => {
|
|
1228
|
+
if (t === undefined)
|
|
1229
|
+
return "tex#none";
|
|
1230
|
+
let id = textureIds.get(t);
|
|
1231
|
+
if (id === undefined) {
|
|
1232
|
+
id = `tex#${texCounter++}`;
|
|
1233
|
+
textureIds.set(t, id);
|
|
1234
|
+
}
|
|
1235
|
+
return id;
|
|
253
1236
|
};
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
1237
|
+
const psIds = new WeakMap();
|
|
1238
|
+
let psCounter = 0;
|
|
1239
|
+
const psIdOf = (ps) => {
|
|
1240
|
+
if (ps === undefined)
|
|
1241
|
+
return "ps#default";
|
|
1242
|
+
let id = psIds.get(ps);
|
|
1243
|
+
if (id === undefined) {
|
|
1244
|
+
id = `ps#${psCounter++}`;
|
|
1245
|
+
psIds.set(ps, id);
|
|
1246
|
+
}
|
|
1247
|
+
return id;
|
|
1248
|
+
};
|
|
1249
|
+
function resolvePipelineState(ps) {
|
|
1250
|
+
if (ps === undefined) {
|
|
1251
|
+
return {
|
|
1252
|
+
topology: "triangle-list", cullMode: "back", frontFace: "ccw",
|
|
1253
|
+
depth: { write: true, compare: "less" },
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
const r = ps.rasterizer;
|
|
1257
|
+
const out = {
|
|
1258
|
+
topology: r.topology.force( /* allow-force */),
|
|
1259
|
+
cullMode: r.cullMode.force( /* allow-force */),
|
|
1260
|
+
frontFace: r.frontFace.force( /* allow-force */),
|
|
1261
|
+
...(ps.depth !== undefined ? {
|
|
1262
|
+
depth: {
|
|
1263
|
+
write: ps.depth.write.force( /* allow-force */),
|
|
1264
|
+
compare: ps.depth.compare.force( /* allow-force */),
|
|
1265
|
+
},
|
|
1266
|
+
} : {}),
|
|
1267
|
+
};
|
|
1268
|
+
return out;
|
|
1269
|
+
}
|
|
1270
|
+
// ─── Per-bucket bind-group construction ───────────────────────────
|
|
1271
|
+
// The bind-group LAYOUT (binding types) is shared across all
|
|
1272
|
+
// pipelines via noTexBgl/texBgl; the bind-group itself binds this
|
|
1273
|
+
// bucket's drawHeap (binding 1) + its globals UBO (binding 0) +
|
|
1274
|
+
// the global arena's heapF32 view (binding 2) + textures if any.
|
|
1275
|
+
function buildBucketBindGroup(bucket) {
|
|
1276
|
+
// heapU32 / heapF32 / heapV4f are different typed views of the
|
|
1277
|
+
// SAME global arena GPUBuffer (emscripten-style aliasing). The
|
|
1278
|
+
// WGSL prelude declares one binding per view; the shader picks
|
|
1279
|
+
// whichever matches its read.
|
|
1280
|
+
const entries = [
|
|
1281
|
+
{ binding: 0, resource: { buffer: arena.attrs.buffer } }, // heapU32
|
|
1282
|
+
{ binding: 1, resource: { buffer: bucket.drawHeap.buffer } }, // headersU32
|
|
1283
|
+
{ binding: 2, resource: { buffer: arena.attrs.buffer } }, // heapF32
|
|
1284
|
+
{ binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
|
|
1285
|
+
];
|
|
1286
|
+
{
|
|
1287
|
+
if (bucket.drawTableBuf === undefined) {
|
|
1288
|
+
throw new Error("heapScene: megacall bucket without drawTableBuf");
|
|
1289
|
+
}
|
|
1290
|
+
// Bind drawTable with size = recordCount * RECORD_BYTES so the VS
|
|
1291
|
+
// prelude's `arrayLength(&drawTable)/RECORD_U32` returns exactly
|
|
1292
|
+
// the live record count — keeps stale tail entries out of the
|
|
1293
|
+
// binary search. Minimum one zero-record to satisfy WebGPU non-
|
|
1294
|
+
// zero size constraint when the bucket is empty.
|
|
1295
|
+
const dtBytes = Math.max(RECORD_BYTES, bucket.recordCount * RECORD_BYTES);
|
|
1296
|
+
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 } });
|
|
1297
|
+
bucket.renderBoundRecordCount = bucket.recordCount;
|
|
1298
|
+
}
|
|
1299
|
+
// Schema-driven texture + sampler entries. v1 user surface still
|
|
1300
|
+
// accepts a single (texture, sampler) pair via HeapTextureSet —
|
|
1301
|
+
// we map it onto the schema's discovered names by position. If
|
|
1302
|
+
// the schema has more than one of either, the user must extend
|
|
1303
|
+
// the spec (a future, multi-binding API).
|
|
1304
|
+
const texLayout = bucket.layout.textureBindings;
|
|
1305
|
+
const smpLayout = bucket.layout.samplerBindings;
|
|
1306
|
+
if (texLayout.length > 0 || smpLayout.length > 0) {
|
|
1307
|
+
if (bucket.textures === undefined) {
|
|
1308
|
+
throw new Error(`heapScene: bucket needs ${texLayout.length} texture(s) + ${smpLayout.length} sampler(s) ` +
|
|
1309
|
+
`but spec.textures is undefined`);
|
|
1310
|
+
}
|
|
1311
|
+
if (texLayout.length > 1 || smpLayout.length > 1) {
|
|
1312
|
+
throw new Error(`heapScene: only single texture/sampler per bucket supported in v1 ` +
|
|
1313
|
+
`(schema declares ${texLayout.length}/${smpLayout.length})`);
|
|
1314
|
+
}
|
|
1315
|
+
const ts = bucket.textures;
|
|
1316
|
+
if (ts.kind === "standalone") {
|
|
1317
|
+
if (texLayout.length === 1) {
|
|
1318
|
+
if (ts.texture.kind !== "gpu") {
|
|
1319
|
+
throw new Error("heapScene: standalone texture must be ITexture.kind === 'gpu' " +
|
|
1320
|
+
"(adapter is responsible for materialising host textures)");
|
|
1321
|
+
}
|
|
1322
|
+
entries.push({
|
|
1323
|
+
binding: texLayout[0].binding,
|
|
1324
|
+
resource: ts.textureView ?? ts.texture.texture.createView(),
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
if (smpLayout.length === 1) {
|
|
1328
|
+
if (ts.sampler.kind !== "gpu") {
|
|
1329
|
+
throw new Error("heapScene: standalone sampler must be ISampler.kind === 'gpu' " +
|
|
1330
|
+
"(adapter is responsible for materialising descriptor samplers)");
|
|
1331
|
+
}
|
|
1332
|
+
entries.push({
|
|
1333
|
+
binding: smpLayout[0].binding,
|
|
1334
|
+
resource: ts.sampler.sampler,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
// Atlas-variant: textureBindings/samplerBindings were stripped at
|
|
1339
|
+
// buildBucketLayout time (the schema's user-named texture entries
|
|
1340
|
+
// are served via the binding_array path instead). Nothing to push
|
|
1341
|
+
// here — the atlas slots come from the per-format page tracking
|
|
1342
|
+
// below.
|
|
1343
|
+
}
|
|
1344
|
+
if (bucket.isAtlasBucket) {
|
|
1345
|
+
if (atlasPool === undefined) {
|
|
1346
|
+
throw new Error("heapScene: atlas-variant bucket needs `atlasPool` in BuildHeapSceneOptions");
|
|
1347
|
+
}
|
|
1348
|
+
// Bind N consecutive `texture_2d<f32>` slots per format. Slot
|
|
1349
|
+
// i for format F holds the GPUTexture view of `pagesFor(F)[i]`
|
|
1350
|
+
// when present, or a 1×1 placeholder otherwise. drawHeader's
|
|
1351
|
+
// `pageRef` field is exactly that slot index; the shader's
|
|
1352
|
+
// `switch pageRef` ladder picks the matching binding.
|
|
1353
|
+
for (const format of ATLAS_PAGE_FORMATS) {
|
|
1354
|
+
const base = format === "rgba8unorm-srgb"
|
|
1355
|
+
? ATLAS_SRGB_BINDING_BASE : ATLAS_LINEAR_BINDING_BASE;
|
|
1356
|
+
const pages = atlasPool.pagesFor(format);
|
|
1357
|
+
for (let i = 0; i < ATLAS_ARRAY_SIZE; i++) {
|
|
1358
|
+
const page = pages[i];
|
|
1359
|
+
entries.push({
|
|
1360
|
+
binding: base + i,
|
|
1361
|
+
resource: page !== undefined
|
|
1362
|
+
? page.texture.createView()
|
|
1363
|
+
: getAtlasPlaceholder(format),
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
entries.push({ binding: ATLAS_SAMPLER_BINDING, resource: atlasSampler });
|
|
1368
|
+
}
|
|
1369
|
+
return device.createBindGroup({
|
|
1370
|
+
label: `heapScene/${bucket.label}/bg`,
|
|
1371
|
+
layout: getBgl(bucket.layout, bucket.isAtlasBucket).bgl,
|
|
1372
|
+
entries,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
// Atlas pages are owned by the AtlasPool; heapScene wires each
|
|
1376
|
+
// page's GPUTexture into a fixed slot in the BGL ladder via
|
|
1377
|
+
// `atlasPool.pagesFor(format)` when building bind groups. Buckets
|
|
1378
|
+
// carry strong refcount handles (`localAtlasReleases`) that drive
|
|
1379
|
+
// `pool.release` on removeDraw.
|
|
1380
|
+
// When the global arena reallocates, every bucket's bind group
|
|
1381
|
+
// needs rebuilding (its binding 2 buffer reference is stale).
|
|
1382
|
+
arena.attrs.onResize(() => {
|
|
1383
|
+
for (const b of buckets)
|
|
1384
|
+
b.bindGroup = buildBucketBindGroup(b);
|
|
1385
|
+
// §7 dispatcher targets arena.attrs.buffer; rebind on grow.
|
|
1386
|
+
if (derivedScene !== undefined)
|
|
1387
|
+
derivedScene.rebindMainHeap(arena.attrs.buffer);
|
|
1388
|
+
});
|
|
1389
|
+
// Same when the atlas pool allocates a fresh page — its slot in the
|
|
1390
|
+
// BGL ladder transitions from placeholder to the real GPUTexture.
|
|
1391
|
+
// Every atlas bucket rebuilds (cheap; just N+N+1 entries each).
|
|
1392
|
+
if (atlasPool !== undefined) {
|
|
1393
|
+
for (const f of ATLAS_PAGE_FORMATS) {
|
|
1394
|
+
atlasPool.onPageAdded(f, () => {
|
|
1395
|
+
for (const b of buckets) {
|
|
1396
|
+
if (b.isAtlasBucket)
|
|
1397
|
+
b.bindGroup = buildBucketBindGroup(b);
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// indexStorage is bound at slot 5 from arena.indices, so a grow there
|
|
1403
|
+
// also invalidates every bucket's bind group.
|
|
1404
|
+
{
|
|
1405
|
+
arena.indices.onResize(() => {
|
|
1406
|
+
for (const b of buckets)
|
|
1407
|
+
b.bindGroup = buildBucketBindGroup(b);
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
// ─── findOrCreateBucket ───────────────────────────────────────────
|
|
1411
|
+
// Slice 3c: the bucket key collapses to (familyId, pipelineState).
|
|
1412
|
+
// Every member effect in the family shares one bucket per
|
|
1413
|
+
// pipelineState; layoutId dispatch in the family VS/FS picks the
|
|
1414
|
+
// right per-effect helper at draw time. Atlas-binding shape follows
|
|
1415
|
+
// from `family.drawHeaderUnion.atlasTextureBindings.size > 0`.
|
|
1416
|
+
void texIdOf; // retained for future per-bucket diagnostics
|
|
1417
|
+
function findOrCreateBucket(effect, _textures, pipelineState) {
|
|
1418
|
+
if (!familyBuilt) {
|
|
1419
|
+
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
1420
|
+
}
|
|
1421
|
+
const fam = familyFor(effect);
|
|
1422
|
+
const psKey = psIdOf(pipelineState);
|
|
1423
|
+
const bk = `family#${fam.schema.id}|${psKey}`;
|
|
1424
|
+
const existing = bucketByKey.get(bk);
|
|
1425
|
+
if (existing !== undefined)
|
|
1426
|
+
return existing;
|
|
1427
|
+
const ps = resolvePipelineState(pipelineState);
|
|
1428
|
+
const layout = fam.schema.drawHeaderUnion;
|
|
1429
|
+
const isAtlasBucket = layout.atlasTextureBindings.size > 0;
|
|
1430
|
+
const vsModule = fam.vsModule;
|
|
1431
|
+
const fsModule = fam.fsModule;
|
|
1432
|
+
const vsEntry = "family_vs_main";
|
|
1433
|
+
const fsEntry = "family_fs_main";
|
|
1434
|
+
const { pipelineLayout } = getBgl(layout, isAtlasBucket);
|
|
1435
|
+
const pipeline = device.createRenderPipeline({
|
|
1436
|
+
label: `heapScene/${bk}/pipeline`,
|
|
1437
|
+
layout: pipelineLayout,
|
|
1438
|
+
vertex: { module: vsModule, entryPoint: vsEntry, buffers: [] },
|
|
1439
|
+
fragment: { module: fsModule, entryPoint: fsEntry, targets: [{ format: colorFormat }] },
|
|
1440
|
+
primitive: { topology: ps.topology, cullMode: ps.cullMode, frontFace: ps.frontFace },
|
|
1441
|
+
...(depthFormat !== undefined && ps.depth !== undefined
|
|
1442
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: ps.depth.write, depthCompare: ps.depth.compare } }
|
|
1443
|
+
: depthFormat !== undefined
|
|
1444
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" } }
|
|
1445
|
+
: {}),
|
|
1446
|
+
});
|
|
1447
|
+
// Per-bucket DrawHeader buffer (every uniform / attribute is a u32
|
|
1448
|
+
// ref into the global arena; the per-bucket buffer just holds the
|
|
1449
|
+
// refs).
|
|
1450
|
+
const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
|
|
1451
|
+
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
1452
|
+
const bucket = {
|
|
1453
|
+
// §6 family-merge: family buckets aren't keyed on a specific
|
|
1454
|
+
// texture set — atlas placements are addressed per-RO via
|
|
1455
|
+
// drawHeader fields (`pageRef` + `formatBits` + `origin` +
|
|
1456
|
+
// `size`), and the bucket's atlas-binding ladder is driven by
|
|
1457
|
+
// `atlasPool.pagesFor(format)`. Leave `textures` undefined.
|
|
1458
|
+
label: bk, textures: undefined, layout, pipeline,
|
|
1459
|
+
bindGroup: null,
|
|
1460
|
+
drawHeap,
|
|
1461
|
+
drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
|
|
1462
|
+
headerDirtyMin: Infinity, headerDirtyMax: 0,
|
|
1463
|
+
localPosRefs: [], localNorRefs: [],
|
|
1464
|
+
localEntries: [], localToDrawId: [],
|
|
1465
|
+
localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
|
|
1466
|
+
drawSlots: [], dirty: new Set(),
|
|
1467
|
+
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
1468
|
+
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
1469
|
+
totalEmitEstimate: 0,
|
|
1470
|
+
scanDirty: false,
|
|
1471
|
+
isAtlasBucket,
|
|
1472
|
+
localAtlasReleases: [],
|
|
1473
|
+
localAtlasTextures: [],
|
|
1474
|
+
};
|
|
1475
|
+
{
|
|
1476
|
+
const dtBuf = new GrowBuffer(device, `heapScene/${bk}/drawTable`, GPUBufferUsage.STORAGE, 1024);
|
|
1477
|
+
const blockSumsBuf = new GrowBuffer(device, `heapScene/${bk}/blockSums`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
|
|
1478
|
+
const blockOffsetsBuf = new GrowBuffer(device, `heapScene/${bk}/blockOffsets`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
|
|
1479
|
+
// 32 u32 = 128 bytes is the floor; pow2-grown by ensureCapacity
|
|
1480
|
+
// as totalEmitEstimate grows.
|
|
1481
|
+
const firstDrawInTileBuf = new GrowBuffer(device, `heapScene/${bk}/firstDrawInTile`, GPUBufferUsage.STORAGE, 128);
|
|
1482
|
+
const indirectBuf = device.createBuffer({
|
|
1483
|
+
label: `heapScene/${bk}/indirect`, size: 16,
|
|
1484
|
+
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
1485
|
+
});
|
|
1486
|
+
// Initialize to (0, 1, 0, 0) so an unscanned/empty bucket draws
|
|
1487
|
+
// nothing safely.
|
|
1488
|
+
device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
|
|
1489
|
+
const paramsBuf = device.createBuffer({
|
|
1490
|
+
label: `heapScene/${bk}/params`, size: 16,
|
|
1491
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1492
|
+
});
|
|
1493
|
+
bucket.drawTableBuf = dtBuf;
|
|
1494
|
+
bucket.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
|
|
1495
|
+
bucket.blockSumsBuf = blockSumsBuf;
|
|
1496
|
+
bucket.blockOffsetsBuf = blockOffsetsBuf;
|
|
1497
|
+
bucket.firstDrawInTileBuf = firstDrawInTileBuf;
|
|
1498
|
+
bucket.indirectBuf = indirectBuf;
|
|
1499
|
+
bucket.paramsBuf = paramsBuf;
|
|
1500
|
+
const ensureScanBuffers = () => {
|
|
1501
|
+
const needBlocks = Math.max(1, Math.ceil(bucket.recordCount / SCAN_TILE_SIZE));
|
|
1502
|
+
blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1503
|
+
blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1504
|
+
};
|
|
1505
|
+
const rebuildScanBg = () => {
|
|
1506
|
+
bucket.scanBindGroup = buildScanBindGroup(bucket);
|
|
1507
|
+
};
|
|
1508
|
+
dtBuf.onResize(() => {
|
|
1509
|
+
const grown = new Uint32Array(dtBuf.capacity / 4);
|
|
1510
|
+
grown.set(bucket.drawTableShadow);
|
|
1511
|
+
bucket.drawTableShadow = grown;
|
|
1512
|
+
ensureScanBuffers();
|
|
1513
|
+
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1514
|
+
rebuildScanBg();
|
|
1515
|
+
bucket.drawTableDirtyMin = 0;
|
|
1516
|
+
bucket.drawTableDirtyMax = bucket.recordCount * RECORD_BYTES;
|
|
1517
|
+
});
|
|
1518
|
+
blockSumsBuf.onResize(rebuildScanBg);
|
|
1519
|
+
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
1520
|
+
firstDrawInTileBuf.onResize(() => {
|
|
1521
|
+
rebuildScanBg();
|
|
1522
|
+
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1523
|
+
});
|
|
1524
|
+
bucket.scanBindGroup = buildScanBindGroup(bucket);
|
|
1525
|
+
}
|
|
1526
|
+
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1527
|
+
drawHeap.onResize(() => {
|
|
1528
|
+
bucket.drawHeaderStaging = new Float32Array(drawHeapBuf.capacity / 4);
|
|
1529
|
+
// GPU-side data was copied by the GrowBuffer's resize, but our
|
|
1530
|
+
// CPU mirror is fresh — mark all live local slots dirty so
|
|
1531
|
+
// their headers get re-packed and re-uploaded next frame.
|
|
1532
|
+
for (const s of bucket.drawSlots)
|
|
1533
|
+
bucket.dirty.add(s);
|
|
1534
|
+
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1535
|
+
// §7's main heap is arena.attrs.buffer (not drawHeap) — the
|
|
1536
|
+
// arena.attrs.onResize handler at the scene level handles its
|
|
1537
|
+
// rebind. drawHeap resize doesn't touch §7.
|
|
1538
|
+
});
|
|
1539
|
+
// §7 has one scene-wide records buffer + dispatcher (all buckets
|
|
1540
|
+
// target arena.attrs.buffer), so nothing per-bucket to set up here.
|
|
1541
|
+
buckets.push(bucket);
|
|
1542
|
+
bucketByKey.set(bk, bucket);
|
|
1543
|
+
return bucket;
|
|
1544
|
+
}
|
|
1545
|
+
// ─── Per-bucket header writer (refs only, post step 3) ────────────
|
|
1546
|
+
// The DrawHeader is now a flat list of u32 refs into the arena —
|
|
1547
|
+
// values themselves live in arena allocations the pool manages.
|
|
1548
|
+
// This function packs the refs into the bucket's staging mirror;
|
|
1549
|
+
// callers writeBuffer the result to the GPU.
|
|
1550
|
+
function packBucketHeader(bucket, localSlot, perDrawRefs, layoutId) {
|
|
1551
|
+
const dst = bucket.drawHeaderStaging;
|
|
1552
|
+
const u32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length);
|
|
1553
|
+
const baseFloat = (localSlot * bucket.layout.drawHeaderBytes) / 4;
|
|
1554
|
+
for (const f of bucket.layout.drawHeaderFields) {
|
|
1555
|
+
// texture-ref fields carry inline values (pageRef/formatBits as u32,
|
|
1556
|
+
// origin/size as vec2<f32>) and are filled by packAtlasTextureFields.
|
|
1557
|
+
if (f.kind === "texture-ref")
|
|
1558
|
+
continue;
|
|
1559
|
+
const fOff = baseFloat + f.byteOffset / 4;
|
|
1560
|
+
// §6 family-merge slice 3c: write the layoutId selector inline.
|
|
1561
|
+
// Field kind stays `uniform-ref` so the IR's load expression
|
|
1562
|
+
// (`headersU32[base + offset]`) reads it as a u32 directly,
|
|
1563
|
+
// exactly matching the inline integer we store here.
|
|
1564
|
+
if (f.name === "__layoutId") {
|
|
1565
|
+
u32[fOff] = layoutId >>> 0;
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
const ref = perDrawRefs.get(f.name);
|
|
1569
|
+
if (ref === undefined) {
|
|
1570
|
+
// Family-merge: a slot's effect doesn't populate every field of
|
|
1571
|
+
// the union; leave the unused slots zero — the layoutId switch
|
|
1572
|
+
// ensures they're never read by the wrong effect's helper.
|
|
1573
|
+
u32[fOff] = 0;
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
u32[fOff] = ref;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
// ─── Atlas drawHeader packing + page-set tracking ─────────────────
|
|
1580
|
+
//
|
|
1581
|
+
// Sampler-state extraction. Atlas-variant ROs serve their texture
|
|
1582
|
+
// through the binding_array path, so the user's per-draw ISampler
|
|
1583
|
+
// becomes "sampler state bits" packed into formatBits. The plan
|
|
1584
|
+
// calls this `force()`-ing the sampler — for `kind: "desc"` the
|
|
1585
|
+
// descriptor is right there; for `kind: "gpu"` we don't have one
|
|
1586
|
+
// and fall back to a sensible default (linear/linear/clamp/clamp/
|
|
1587
|
+
// nearest), matching the shared atlasSampler.
|
|
1588
|
+
function samplerStateBits(s) {
|
|
1589
|
+
let mag = "linear";
|
|
1590
|
+
let min = "linear";
|
|
1591
|
+
let mip = "nearest";
|
|
1592
|
+
let aU = "clamp-to-edge";
|
|
1593
|
+
let aV = "clamp-to-edge";
|
|
1594
|
+
if (s.kind === "desc") {
|
|
1595
|
+
mag = s.descriptor.magFilter ?? mag;
|
|
1596
|
+
min = s.descriptor.minFilter ?? min;
|
|
1597
|
+
mip = s.descriptor.mipmapFilter ?? mip;
|
|
1598
|
+
aU = s.descriptor.addressModeU ?? aU;
|
|
1599
|
+
aV = s.descriptor.addressModeV ?? aV;
|
|
1600
|
+
}
|
|
1601
|
+
const addrCode = (m) => m === "repeat" ? 1 : m === "mirror-repeat" ? 2 : 0;
|
|
1602
|
+
const filterCode = (m) => m === "linear" ? 1 : 0;
|
|
1603
|
+
return ((addrCode(aU) & 0x3) << 4 |
|
|
1604
|
+
(addrCode(aV) & 0x3) << 6 |
|
|
1605
|
+
(filterCode(mag) & 0x3) << 8 |
|
|
1606
|
+
(filterCode(min) & 0x3) << 10 |
|
|
1607
|
+
// mipmapFilter not separately encoded — atlas does software mip,
|
|
1608
|
+
// hardware mipmapFilter is fixed to "nearest" on atlasSampler.
|
|
1609
|
+
(mip === "linear" ? 1 : 0) << 12);
|
|
1610
|
+
}
|
|
1611
|
+
function packAtlasTextureFields(bucket, localSlot, textures) {
|
|
1612
|
+
const f32 = bucket.drawHeaderStaging;
|
|
1613
|
+
const u32 = new Uint32Array(f32.buffer, f32.byteOffset, f32.length);
|
|
1614
|
+
const baseBytes = localSlot * bucket.layout.drawHeaderBytes;
|
|
1615
|
+
const fmt = textures.page.format;
|
|
1616
|
+
const slotIdx = ensureAtlasPageSlot(bucket, fmt, textures.pageId, textures.page);
|
|
1617
|
+
const fmtIdx = atlasFormatIndex(fmt);
|
|
1618
|
+
const numMips = Math.max(1, Math.min(7, textures.numMips));
|
|
1619
|
+
const formatBits = (fmtIdx & 0x1) |
|
|
1620
|
+
((numMips & 0x7) << 1) |
|
|
1621
|
+
samplerStateBits(textures.sampler);
|
|
1622
|
+
for (const f of bucket.layout.drawHeaderFields) {
|
|
1623
|
+
if (f.kind !== "texture-ref")
|
|
1624
|
+
continue;
|
|
1625
|
+
const off = baseBytes + f.byteOffset;
|
|
1626
|
+
const offU32 = off / 4;
|
|
1627
|
+
const offF32 = off / 4;
|
|
1628
|
+
switch (f.textureSub) {
|
|
1629
|
+
case "pageRef":
|
|
1630
|
+
u32[offU32] = slotIdx >>> 0;
|
|
1631
|
+
break;
|
|
1632
|
+
case "formatBits":
|
|
1633
|
+
u32[offU32] = formatBits >>> 0;
|
|
1634
|
+
break;
|
|
1635
|
+
case "origin":
|
|
1636
|
+
f32[offF32] = textures.origin.x;
|
|
1637
|
+
f32[offF32 + 1] = textures.origin.y;
|
|
1638
|
+
break;
|
|
1639
|
+
case "size":
|
|
1640
|
+
f32[offF32] = textures.size.x;
|
|
1641
|
+
f32[offF32 + 1] = textures.size.y;
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
// pageId IS the slot index in the format's BGL binding sequence.
|
|
1647
|
+
// No per-bucket slot mapping is needed — the shader's
|
|
1648
|
+
// `switch pageRef` ladder picks `atlasLinear<pageRef>` /
|
|
1649
|
+
// `atlasSrgb<pageRef>` directly. Kept as a function for callsite-
|
|
1650
|
+
// symmetry with the pre-pivot code.
|
|
1651
|
+
function ensureAtlasPageSlot(_bucket, _format, pageId, _page) {
|
|
1652
|
+
return pageId;
|
|
283
1653
|
}
|
|
1654
|
+
// ─── Stats (declared early so addDraw/removeDraw can mutate it) ───
|
|
284
1655
|
const stats = {
|
|
285
|
-
groups:
|
|
286
|
-
totalDraws:
|
|
287
|
-
globalsBytes: GLOBALS_BYTES * groups.length,
|
|
1656
|
+
groups: 0,
|
|
1657
|
+
totalDraws: 0,
|
|
288
1658
|
drawBytes: 0,
|
|
289
|
-
geometryBytes:
|
|
1659
|
+
geometryBytes: 0,
|
|
1660
|
+
derivedPullMs: 0,
|
|
1661
|
+
derivedUploadMs: 0,
|
|
1662
|
+
derivedEncodeMs: 0,
|
|
1663
|
+
derivedRecords: 0,
|
|
290
1664
|
};
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
1665
|
+
Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
|
|
1666
|
+
// ─── addDraw / removeDraw ─────────────────────────────────────────
|
|
1667
|
+
function addDraw(spec) {
|
|
1668
|
+
const drawId = nextDrawId++;
|
|
1669
|
+
// Family-merge (slice 3c): build the family lazily from this
|
|
1670
|
+
// single spec when no batched lazy-build occurred earlier (e.g.
|
|
1671
|
+
// direct addDraw at runtime). The aset / array initial-population
|
|
1672
|
+
// paths build from the full effect set up front.
|
|
1673
|
+
if (!familyBuilt) {
|
|
1674
|
+
buildFamilyFromSpecs([spec]);
|
|
1675
|
+
}
|
|
1676
|
+
else {
|
|
1677
|
+
ensureFamilyKnowsEffect(spec.effect);
|
|
1678
|
+
}
|
|
1679
|
+
const perInstanceUniforms = new Set();
|
|
1680
|
+
const perInstanceAttributes = spec.instanceAttributes !== undefined
|
|
1681
|
+
? new Set(Object.keys(spec.instanceAttributes))
|
|
1682
|
+
: new Set();
|
|
1683
|
+
const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState);
|
|
1684
|
+
const fam = familyFor(spec.effect);
|
|
1685
|
+
const effectFields = fam.fieldsForEffect.get(spec.effect);
|
|
1686
|
+
// Indices live in their own INDEX-usage buffer (WebGPU constraint).
|
|
1687
|
+
// Aval-keyed: 19K instanced clones of the same mesh share one
|
|
1688
|
+
// index allocation + one upload.
|
|
1689
|
+
const indicesAval = asAval(spec.indices);
|
|
1690
|
+
const indicesArr = readPlain(spec.indices);
|
|
1691
|
+
const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, indicesArr);
|
|
1692
|
+
const localSlot = bucket.drawHeap.alloc();
|
|
1693
|
+
// Per-RO instancing: read `spec.instanceCount` (defaults to 1).
|
|
1694
|
+
const instanceCount = spec.instanceCount !== undefined
|
|
1695
|
+
? readPlain(spec.instanceCount)
|
|
1696
|
+
: 1;
|
|
1697
|
+
// Walk the bucket's schema-driven DrawHeader fields. Per-instance
|
|
1698
|
+
// attributes pull from `spec.instanceAttributes` and pack into an
|
|
1699
|
+
// array allocation; everything else pulls from `spec.inputs` and
|
|
1700
|
+
// packs as a single value. Both go through the same pool — sharing
|
|
1701
|
+
// emerges from aval identity either way.
|
|
1702
|
+
const perDrawAvals = [];
|
|
1703
|
+
const perDrawRefs = new Map();
|
|
1704
|
+
sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
|
|
1705
|
+
for (const f of bucket.layout.drawHeaderFields) {
|
|
1706
|
+
// Atlas-variant texture bindings carry inline values rather than
|
|
1707
|
+
// pool refs; packAtlasTextureFields fills them after this loop.
|
|
1708
|
+
if (f.kind === "texture-ref")
|
|
1709
|
+
continue;
|
|
1710
|
+
// Family-merge: the union drawHeader includes fields from every
|
|
1711
|
+
// member effect. Skip fields this effect doesn't declare — its
|
|
1712
|
+
// layoutId branch never reads them, so the slot stays zero.
|
|
1713
|
+
if (f.name === "__layoutId")
|
|
1714
|
+
continue;
|
|
1715
|
+
if (!effectFields.has(f.name))
|
|
1716
|
+
continue;
|
|
1717
|
+
// §7: derived-uniform fields are produced by the compute
|
|
1718
|
+
// pre-pass. We still allocate an arena slot here so the
|
|
1719
|
+
// drawHeader gets a valid ref written by packBucketHeader; §7
|
|
1720
|
+
// overwrites the arena data each frame the inputs are dirty.
|
|
1721
|
+
if (derivedScene !== undefined && isDerivedUniformName(f.name)) {
|
|
1722
|
+
const dummyAval = AVal.constant(M44d.zero);
|
|
1723
|
+
const ref = pool.acquire(device, arena.attrs, dummyAval, M44d.zero, PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1, PACKER_MAT4.pack);
|
|
1724
|
+
perDrawRefs.set(f.name, ref);
|
|
1725
|
+
perDrawAvals.push(dummyAval);
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
const isPerInstanceUniformField = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
|
|
1729
|
+
const isPerInstanceAttrField = f.kind === "attribute-ref" && perInstanceAttributes.has(f.name);
|
|
1730
|
+
const provided = isPerInstanceAttrField
|
|
1731
|
+
? spec.instanceAttributes[f.name]
|
|
1732
|
+
: spec.inputs[f.name];
|
|
1733
|
+
if (provided === undefined) {
|
|
1734
|
+
const where = isPerInstanceAttrField
|
|
1735
|
+
? "spec.instanceAttributes"
|
|
1736
|
+
: "spec.inputs";
|
|
1737
|
+
throw new Error(`heapScene: ${where} missing required entry '${f.name}' ` +
|
|
1738
|
+
`(effect declares ${f.kind === "uniform-ref"
|
|
1739
|
+
? `uniform ${f.uniformWgslType ?? "unknown"}`
|
|
1740
|
+
: `attribute ${f.attributeWgslType ?? "unknown"}`})`);
|
|
1741
|
+
}
|
|
1742
|
+
// Attribute-ref + BufferView: pivot the pool key onto the
|
|
1743
|
+
// BufferView's `aval<IBuffer>` so identity-based sharing works
|
|
1744
|
+
// naturally across draws (two ROs with the same `buffer` aval
|
|
1745
|
+
// share the arena allocation). The packer extracts host data.
|
|
1746
|
+
// Both per-vertex and per-instance attribute BufferViews flow
|
|
1747
|
+
// through the same arena pool (the heap path doesn't need a
|
|
1748
|
+
// separate "instance arena").
|
|
1749
|
+
let av;
|
|
1750
|
+
let value;
|
|
1751
|
+
let placement;
|
|
1752
|
+
if (f.kind === "attribute-ref" && !isPerInstanceUniformField && isBufferView(provided)) {
|
|
1753
|
+
const bv = provided;
|
|
1754
|
+
placement = bufferViewPlacement(f, bv);
|
|
1755
|
+
av = bv.buffer;
|
|
1756
|
+
value = bv.buffer.getValue(tok);
|
|
1757
|
+
}
|
|
1758
|
+
else {
|
|
1759
|
+
av = asAval(provided);
|
|
1760
|
+
value = av.getValue(tok);
|
|
1761
|
+
placement = isPerInstanceUniformField
|
|
1762
|
+
? perInstancePlacementFor(f, value, instanceCount)
|
|
1763
|
+
: poolPlacementFor(f, value);
|
|
1764
|
+
}
|
|
1765
|
+
const ref = pool.acquire(device, arena.attrs, av, value, placement.dataBytes, placement.typeId, placement.length, placement.pack);
|
|
1766
|
+
perDrawRefs.set(f.name, ref);
|
|
1767
|
+
perDrawAvals.push(av);
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
|
|
1771
|
+
bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
|
|
1772
|
+
bucket.localEntries[localSlot] = {
|
|
1773
|
+
indexCount: idxAlloc.count, firstIndex: idxAlloc.firstIndex, instanceCount,
|
|
1774
|
+
};
|
|
1775
|
+
bucket.localToDrawId[localSlot] = drawId;
|
|
1776
|
+
bucket.drawSlots.push(localSlot);
|
|
1777
|
+
bucket.localPerDrawAvals[localSlot] = perDrawAvals;
|
|
1778
|
+
bucket.localPerDrawRefs[localSlot] = perDrawRefs;
|
|
1779
|
+
const layoutId = fam.schema.layoutIdOf.get(spec.effect);
|
|
1780
|
+
bucket.localLayoutIds[localSlot] = layoutId;
|
|
1781
|
+
packBucketHeader(bucket, localSlot, perDrawRefs, layoutId);
|
|
1782
|
+
if (bucket.isAtlasBucket && spec.textures !== undefined && spec.textures.kind === "atlas") {
|
|
1783
|
+
packAtlasTextureFields(bucket, localSlot, spec.textures);
|
|
1784
|
+
bucket.localAtlasReleases[localSlot] = spec.textures.release;
|
|
1785
|
+
bucket.localAtlasTextures[localSlot] = spec.textures;
|
|
1786
|
+
// Reactivity wire-up: subscribe to `sourceAval` (so sceneObj.inputChanged
|
|
1787
|
+
// sees marks) and record the (bucket, slot) pair for the drain loop.
|
|
1788
|
+
const sourceAval = spec.textures.sourceAval;
|
|
1789
|
+
const repackFn = spec.textures.repack;
|
|
1790
|
+
if (sourceAval !== undefined && repackFn !== undefined) {
|
|
1791
|
+
sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
|
|
1792
|
+
// touch — registers sceneObj as an output of sourceAval.
|
|
1793
|
+
sourceAval.getValue(tok);
|
|
1794
|
+
});
|
|
1795
|
+
let arr = atlasAvalRefs.get(sourceAval);
|
|
1796
|
+
if (arr === undefined) {
|
|
1797
|
+
arr = [];
|
|
1798
|
+
atlasAvalRefs.set(sourceAval, arr);
|
|
1799
|
+
}
|
|
1800
|
+
arr.push({ bucket, localSlot, repack: repackFn, sampler: spec.textures.sampler });
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
const byteOff = localSlot * bucket.layout.drawHeaderBytes;
|
|
1804
|
+
if (byteOff < bucket.headerDirtyMin)
|
|
1805
|
+
bucket.headerDirtyMin = byteOff;
|
|
1806
|
+
const end = byteOff + bucket.layout.drawHeaderBytes;
|
|
1807
|
+
if (end > bucket.headerDirtyMax)
|
|
1808
|
+
bucket.headerDirtyMax = end;
|
|
1809
|
+
{
|
|
1810
|
+
const dtBuf = bucket.drawTableBuf;
|
|
1811
|
+
const recIdx = bucket.recordCount;
|
|
1812
|
+
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
1813
|
+
throw new Error(`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
|
|
1814
|
+
`extend the scan to multi-level if you need more`);
|
|
1815
|
+
}
|
|
1816
|
+
const byteOff = recIdx * RECORD_BYTES;
|
|
1817
|
+
dtBuf.ensureCapacity(byteOff + RECORD_BYTES);
|
|
1818
|
+
dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
|
|
1819
|
+
// Grow scan-side buffers if recordCount crosses a tile boundary.
|
|
1820
|
+
const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
|
|
1821
|
+
bucket.blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1822
|
+
bucket.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1823
|
+
const shadow = bucket.drawTableShadow;
|
|
1824
|
+
// firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
|
|
1825
|
+
shadow[recIdx * RECORD_U32 + 0] = 0;
|
|
1826
|
+
shadow[recIdx * RECORD_U32 + 1] = localSlot;
|
|
1827
|
+
shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
|
|
1828
|
+
shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
|
|
1829
|
+
shadow[recIdx * RECORD_U32 + 4] = instanceCount;
|
|
1830
|
+
bucket.recordCount = recIdx + 1;
|
|
1831
|
+
bucket.slotToRecord[localSlot] = recIdx;
|
|
1832
|
+
bucket.recordToSlot[recIdx] = localSlot;
|
|
1833
|
+
if (byteOff < bucket.drawTableDirtyMin)
|
|
1834
|
+
bucket.drawTableDirtyMin = byteOff;
|
|
1835
|
+
if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax)
|
|
1836
|
+
bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1837
|
+
bucket.totalEmitEstimate += idxAlloc.count * instanceCount;
|
|
1838
|
+
const newNumTiles = Math.max(1, Math.ceil(bucket.totalEmitEstimate / TILE_K));
|
|
1839
|
+
bucket.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
|
|
1840
|
+
bucket.scanDirty = true;
|
|
1841
|
+
}
|
|
1842
|
+
drawIdToBucket[drawId] = bucket;
|
|
1843
|
+
drawIdToLocalSlot[drawId] = localSlot;
|
|
1844
|
+
drawIdToIndexAval[drawId] = indicesAval;
|
|
1845
|
+
// ─── §7 derived-uniforms registration ────────────────────────────
|
|
1846
|
+
// Collect the derived names this effect actually uses (i.e. fields
|
|
1847
|
+
// declared in the drawHeader for this effect AND in the §7 set).
|
|
1848
|
+
// For each, the kernel writes into byte offset
|
|
1849
|
+
// localSlot * drawHeaderBytes + field.byteOffset
|
|
1850
|
+
// i.e. this RO's drawHeader region in the bucket's drawHeap.
|
|
1851
|
+
if (derivedScene !== undefined) {
|
|
1852
|
+
const requiredNames = [];
|
|
1853
|
+
const arenaByteOffsetByName = new Map();
|
|
1854
|
+
for (const f of bucket.layout.drawHeaderFields) {
|
|
1855
|
+
if (f.name === "__layoutId")
|
|
1856
|
+
continue;
|
|
1857
|
+
if (!effectFields.has(f.name))
|
|
1858
|
+
continue;
|
|
1859
|
+
if (!isDerivedUniformName(f.name))
|
|
1860
|
+
continue;
|
|
1861
|
+
const ref = perDrawRefs.get(f.name);
|
|
1862
|
+
if (ref === undefined)
|
|
1863
|
+
continue;
|
|
1864
|
+
requiredNames.push(f.name);
|
|
1865
|
+
arenaByteOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
|
|
1866
|
+
}
|
|
1867
|
+
if (requiredNames.length > 0) {
|
|
1868
|
+
const reg = registerRoDerivations(derivedScene, {
|
|
1869
|
+
trafos: {
|
|
1870
|
+
modelTrafo: spec.inputs["ModelTrafo"],
|
|
1871
|
+
viewTrafo: spec.inputs["ViewTrafo"],
|
|
1872
|
+
projTrafo: spec.inputs["ProjTrafo"],
|
|
325
1873
|
},
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
for (let i = 0; i < g.packed.entries.length; i++) {
|
|
332
|
-
const e = g.packed.entries[i];
|
|
333
|
-
pass.drawIndexed(e.indexCount, 1, e.firstIndex, 0, i);
|
|
1874
|
+
requiredNames,
|
|
1875
|
+
byteOffsetByName: arenaByteOffsetByName,
|
|
1876
|
+
drawHeaderBaseByte: 0,
|
|
1877
|
+
});
|
|
1878
|
+
derivedByDrawId.set(drawId, reg);
|
|
334
1879
|
}
|
|
335
|
-
pass.end();
|
|
336
|
-
firstPass = false;
|
|
337
1880
|
}
|
|
1881
|
+
stats.totalDraws++;
|
|
1882
|
+
stats.geometryBytes = arenaBytes(arena);
|
|
1883
|
+
return drawId;
|
|
1884
|
+
}
|
|
1885
|
+
function removeDraw(drawId) {
|
|
1886
|
+
const bucket = drawIdToBucket[drawId];
|
|
1887
|
+
const localSlot = drawIdToLocalSlot[drawId];
|
|
1888
|
+
if (bucket === undefined || localSlot === undefined)
|
|
1889
|
+
return;
|
|
1890
|
+
// §7: deregister this RO's derivation records and release slots.
|
|
1891
|
+
if (derivedScene !== undefined) {
|
|
1892
|
+
const reg = derivedByDrawId.get(drawId);
|
|
1893
|
+
if (reg !== undefined) {
|
|
1894
|
+
deregisterRoDerivations(derivedScene, reg);
|
|
1895
|
+
derivedByDrawId.delete(drawId);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
{
|
|
1899
|
+
const removedEntry = bucket.localEntries[localSlot];
|
|
1900
|
+
const removedCount = removedEntry !== undefined
|
|
1901
|
+
? removedEntry.indexCount * removedEntry.instanceCount
|
|
1902
|
+
: 0;
|
|
1903
|
+
bucket.totalEmitEstimate = Math.max(0, bucket.totalEmitEstimate - removedCount);
|
|
1904
|
+
// Swap-pop: move the last record into the freed slot, decrement
|
|
1905
|
+
// recordCount. firstEmit is GPU-rewritten by the next scan, so
|
|
1906
|
+
// we only fix (drawIdx, indexStart, indexCount, instanceCount).
|
|
1907
|
+
const recIdx = bucket.slotToRecord[localSlot];
|
|
1908
|
+
const lastRecIdx = bucket.recordCount - 1;
|
|
1909
|
+
const shadow = bucket.drawTableShadow;
|
|
1910
|
+
if (recIdx !== lastRecIdx) {
|
|
1911
|
+
const dst = recIdx * RECORD_U32;
|
|
1912
|
+
const src = lastRecIdx * RECORD_U32;
|
|
1913
|
+
shadow[dst + 0] = 0;
|
|
1914
|
+
shadow[dst + 1] = shadow[src + 1];
|
|
1915
|
+
shadow[dst + 2] = shadow[src + 2];
|
|
1916
|
+
shadow[dst + 3] = shadow[src + 3];
|
|
1917
|
+
shadow[dst + 4] = shadow[src + 4];
|
|
1918
|
+
const movedSlot = bucket.recordToSlot[lastRecIdx];
|
|
1919
|
+
bucket.slotToRecord[movedSlot] = recIdx;
|
|
1920
|
+
bucket.recordToSlot[recIdx] = movedSlot;
|
|
1921
|
+
const byteOff = recIdx * RECORD_BYTES;
|
|
1922
|
+
if (byteOff < bucket.drawTableDirtyMin)
|
|
1923
|
+
bucket.drawTableDirtyMin = byteOff;
|
|
1924
|
+
if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax)
|
|
1925
|
+
bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1926
|
+
}
|
|
1927
|
+
bucket.slotToRecord[localSlot] = -1;
|
|
1928
|
+
bucket.recordToSlot[lastRecIdx] = -1;
|
|
1929
|
+
bucket.recordCount = lastRecIdx;
|
|
1930
|
+
bucket.scanDirty = true;
|
|
1931
|
+
}
|
|
1932
|
+
// Release pool entries — refcount drops; if zero, allocation freed.
|
|
1933
|
+
const avals = bucket.localPerDrawAvals[localSlot];
|
|
1934
|
+
if (avals !== undefined)
|
|
1935
|
+
for (const av of avals)
|
|
1936
|
+
pool.release(arena.attrs, av);
|
|
1937
|
+
const idxAval = drawIdToIndexAval[drawId];
|
|
1938
|
+
if (idxAval !== undefined)
|
|
1939
|
+
indexPool.release(arena.indices, idxAval);
|
|
1940
|
+
const atlasRel = bucket.localAtlasReleases[localSlot];
|
|
1941
|
+
if (atlasRel !== undefined)
|
|
1942
|
+
atlasRel();
|
|
1943
|
+
// Drop atlas-aval ref (if any). When the last ref is dropped we
|
|
1944
|
+
// also remove the aval from the dirty set — a marking after the
|
|
1945
|
+
// last RO is gone is a no-op.
|
|
1946
|
+
const atlasTex = bucket.localAtlasTextures[localSlot];
|
|
1947
|
+
if (atlasTex !== undefined) {
|
|
1948
|
+
const sourceAval = atlasTex.sourceAval;
|
|
1949
|
+
if (sourceAval !== undefined) {
|
|
1950
|
+
const arr = atlasAvalRefs.get(sourceAval);
|
|
1951
|
+
if (arr !== undefined) {
|
|
1952
|
+
const i = arr.findIndex(r => r.bucket === bucket && r.localSlot === localSlot);
|
|
1953
|
+
if (i >= 0)
|
|
1954
|
+
arr.splice(i, 1);
|
|
1955
|
+
if (arr.length === 0) {
|
|
1956
|
+
atlasAvalRefs.delete(sourceAval);
|
|
1957
|
+
atlasAvalDirty.delete(sourceAval);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
bucket.localAtlasReleases[localSlot] = undefined;
|
|
1963
|
+
bucket.localAtlasTextures[localSlot] = undefined;
|
|
1964
|
+
bucket.localPerDrawAvals[localSlot] = undefined;
|
|
1965
|
+
bucket.localPerDrawRefs[localSlot] = undefined;
|
|
1966
|
+
bucket.localLayoutIds[localSlot] = undefined;
|
|
1967
|
+
bucket.localPosRefs[localSlot] = undefined;
|
|
1968
|
+
bucket.localNorRefs[localSlot] = undefined;
|
|
1969
|
+
bucket.localEntries[localSlot] = undefined;
|
|
1970
|
+
bucket.localToDrawId[localSlot] = undefined;
|
|
1971
|
+
const idx = bucket.drawSlots.indexOf(localSlot);
|
|
1972
|
+
if (idx >= 0)
|
|
1973
|
+
bucket.drawSlots.splice(idx, 1);
|
|
1974
|
+
bucket.dirty.delete(localSlot);
|
|
1975
|
+
bucket.drawHeap.release(localSlot);
|
|
1976
|
+
drawIdToBucket[drawId] = undefined;
|
|
1977
|
+
drawIdToLocalSlot[drawId] = undefined;
|
|
1978
|
+
drawIdToIndexAval[drawId] = undefined;
|
|
1979
|
+
stats.totalDraws--;
|
|
1980
|
+
stats.geometryBytes = arenaBytes(arena);
|
|
1981
|
+
}
|
|
1982
|
+
// ─── Aset reader (pull-driven on each frame) ──────────────────────
|
|
1983
|
+
let asetReader;
|
|
1984
|
+
const specToDrawId = new Map();
|
|
1985
|
+
function drainAsetWith(tok) {
|
|
1986
|
+
if (asetReader === undefined)
|
|
1987
|
+
return;
|
|
1988
|
+
const delta = asetReader.getChanges(tok);
|
|
1989
|
+
delta.iter((op) => {
|
|
1990
|
+
if (op.count > 0) {
|
|
1991
|
+
const id = addDraw(op.value);
|
|
1992
|
+
specToDrawId.set(op.value, id);
|
|
1993
|
+
}
|
|
1994
|
+
else {
|
|
1995
|
+
const id = specToDrawId.get(op.value);
|
|
1996
|
+
if (id !== undefined) {
|
|
1997
|
+
removeDraw(id);
|
|
1998
|
+
specToDrawId.delete(op.value);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
// ─── Initial population ───────────────────────────────────────────
|
|
2004
|
+
if (Array.isArray(initialDraws)) {
|
|
2005
|
+
// Pre-build the family from all initial effects so the first
|
|
2006
|
+
// bucket is built against the union right away. Skipped when the
|
|
2007
|
+
// array is empty (family will build lazily when an addDraw fires).
|
|
2008
|
+
const arr = initialDraws;
|
|
2009
|
+
if (arr.length > 0)
|
|
2010
|
+
buildFamilyFromSpecs(arr);
|
|
2011
|
+
for (const d of arr)
|
|
2012
|
+
addDraw(d);
|
|
2013
|
+
}
|
|
2014
|
+
else {
|
|
2015
|
+
asetReader = initialDraws.getReader();
|
|
2016
|
+
sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
|
|
2017
|
+
// First drain: snapshot all add-ops, build the family from their
|
|
2018
|
+
// effects up front, then run addDraw. Subsequent drains either
|
|
2019
|
+
// reuse the frozen family (matching effects) or throw on a new
|
|
2020
|
+
// unseen effect (per the v1 frozen-family contract).
|
|
2021
|
+
if (!familyBuilt) {
|
|
2022
|
+
const reader = asetReader;
|
|
2023
|
+
const delta = reader.getChanges(tok);
|
|
2024
|
+
const adds = [];
|
|
2025
|
+
const remaining = [];
|
|
2026
|
+
delta.iter((op) => {
|
|
2027
|
+
remaining.push(op);
|
|
2028
|
+
if (op.count > 0)
|
|
2029
|
+
adds.push(op.value);
|
|
2030
|
+
});
|
|
2031
|
+
if (adds.length > 0)
|
|
2032
|
+
buildFamilyFromSpecs(adds);
|
|
2033
|
+
for (const op of remaining) {
|
|
2034
|
+
if (op.count > 0) {
|
|
2035
|
+
const id = addDraw(op.value);
|
|
2036
|
+
specToDrawId.set(op.value, id);
|
|
2037
|
+
}
|
|
2038
|
+
else {
|
|
2039
|
+
const id = specToDrawId.get(op.value);
|
|
2040
|
+
if (id !== undefined) {
|
|
2041
|
+
removeDraw(id);
|
|
2042
|
+
specToDrawId.delete(op.value);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
else {
|
|
2048
|
+
drainAsetWith(tok);
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
// ─── update / encodeIntoPass / frame / dispose ───────────────────
|
|
2053
|
+
/**
|
|
2054
|
+
* CPU-side data refresh: drain pending aset deltas, repack any
|
|
2055
|
+
* pool-tracked aval that marked since last frame, re-write any
|
|
2056
|
+
* bucket headers that the drawHeap grew under. After this returns,
|
|
2057
|
+
* the GPU state mirrors the current adaptive snapshot — caller is
|
|
2058
|
+
* free to encode draws.
|
|
2059
|
+
*/
|
|
2060
|
+
function update(token) {
|
|
2061
|
+
let totalDirtyBytes = 0;
|
|
2062
|
+
sceneObj.evaluateAlways(token, (tok) => {
|
|
2063
|
+
drainAsetWith(tok);
|
|
2064
|
+
// 1. Pool: re-pack any aval whose value changed since last frame.
|
|
2065
|
+
// One writeBuffer per dirty aval, regardless of how many draws
|
|
2066
|
+
// reference it — sharing pays off here.
|
|
2067
|
+
if (allocDirty.size > 0) {
|
|
2068
|
+
for (const av of allocDirty) {
|
|
2069
|
+
pool.repack(device, arena.attrs, av, av.getValue(tok));
|
|
2070
|
+
const e = pool.entry(av);
|
|
2071
|
+
if (e !== undefined)
|
|
2072
|
+
totalDirtyBytes += e.dataBytes;
|
|
2073
|
+
}
|
|
2074
|
+
allocDirty.clear();
|
|
2075
|
+
}
|
|
2076
|
+
// 1b. Atlas-texture aval reactivity: an `aval<ITexture>` that
|
|
2077
|
+
// drives an atlas placement was marked. Repack the pool entry
|
|
2078
|
+
// and rewrite the drawHeader fields of every (bucket, slot)
|
|
2079
|
+
// referencing this aval. The replaced HeapTextureSet's
|
|
2080
|
+
// `release` closure points at the OLD pool ref — that's
|
|
2081
|
+
// fine, it still resolves correctly under the pool's
|
|
2082
|
+
// ref-by-id lookup; we replace it with a closure over the
|
|
2083
|
+
// new ref so removeDraw frees the right one.
|
|
2084
|
+
if (atlasAvalDirty.size > 0) {
|
|
2085
|
+
for (const av of atlasAvalDirty) {
|
|
2086
|
+
const refs = atlasAvalRefs.get(av);
|
|
2087
|
+
if (refs === undefined || refs.length === 0)
|
|
2088
|
+
continue;
|
|
2089
|
+
const newTex = av.getValue(tok);
|
|
2090
|
+
const acq = refs[0].repack(newTex);
|
|
2091
|
+
for (const r of refs) {
|
|
2092
|
+
const newTextures = {
|
|
2093
|
+
kind: "atlas",
|
|
2094
|
+
format: acq.page.format,
|
|
2095
|
+
pageId: acq.pageId,
|
|
2096
|
+
origin: acq.origin,
|
|
2097
|
+
size: acq.size,
|
|
2098
|
+
numMips: acq.numMips,
|
|
2099
|
+
sampler: r.sampler,
|
|
2100
|
+
page: acq.page,
|
|
2101
|
+
poolRef: acq.ref,
|
|
2102
|
+
release: r.bucket.localAtlasTextures[r.localSlot].release,
|
|
2103
|
+
sourceAval: av,
|
|
2104
|
+
repack: r.repack,
|
|
2105
|
+
};
|
|
2106
|
+
r.bucket.localAtlasTextures[r.localSlot] = newTextures;
|
|
2107
|
+
packAtlasTextureFields(r.bucket, r.localSlot, newTextures);
|
|
2108
|
+
const byteOff = r.localSlot * r.bucket.layout.drawHeaderBytes;
|
|
2109
|
+
if (byteOff < r.bucket.headerDirtyMin)
|
|
2110
|
+
r.bucket.headerDirtyMin = byteOff;
|
|
2111
|
+
const end = byteOff + r.bucket.layout.drawHeaderBytes;
|
|
2112
|
+
if (end > r.bucket.headerDirtyMax)
|
|
2113
|
+
r.bucket.headerDirtyMax = end;
|
|
2114
|
+
totalDirtyBytes += r.bucket.layout.drawHeaderBytes;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
atlasAvalDirty.clear();
|
|
2118
|
+
}
|
|
2119
|
+
// 2. Per-bucket: (rare) header re-pack — only fires when the
|
|
2120
|
+
// bucket's drawHeap GrowBuffer reallocated and we need to
|
|
2121
|
+
// re-write all live slots into the new staging mirror.
|
|
2122
|
+
for (const bucket of buckets) {
|
|
2123
|
+
if (bucket.dirty.size === 0)
|
|
2124
|
+
continue;
|
|
2125
|
+
for (const localSlot of bucket.dirty) {
|
|
2126
|
+
const refs = bucket.localPerDrawRefs[localSlot];
|
|
2127
|
+
if (refs === undefined)
|
|
2128
|
+
continue;
|
|
2129
|
+
packBucketHeader(bucket, localSlot, refs, bucket.localLayoutIds[localSlot] ?? 0);
|
|
2130
|
+
if (bucket.isAtlasBucket) {
|
|
2131
|
+
const ts = bucket.localAtlasTextures[localSlot];
|
|
2132
|
+
if (ts !== undefined)
|
|
2133
|
+
packAtlasTextureFields(bucket, localSlot, ts);
|
|
2134
|
+
}
|
|
2135
|
+
const byteOff = localSlot * bucket.layout.drawHeaderBytes;
|
|
2136
|
+
if (byteOff < bucket.headerDirtyMin)
|
|
2137
|
+
bucket.headerDirtyMin = byteOff;
|
|
2138
|
+
const end = byteOff + bucket.layout.drawHeaderBytes;
|
|
2139
|
+
if (end > bucket.headerDirtyMax)
|
|
2140
|
+
bucket.headerDirtyMax = end;
|
|
2141
|
+
totalDirtyBytes += bucket.layout.drawHeaderBytes;
|
|
2142
|
+
}
|
|
2143
|
+
bucket.dirty.clear();
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
338
2146
|
stats.drawBytes = totalDirtyBytes;
|
|
2147
|
+
// ─── Flush staged uploads (one writeBuffer per dirty range) ──────
|
|
2148
|
+
// Replaces N small writeBuffer calls (per addDraw, per dirty alloc,
|
|
2149
|
+
// per dirty header slot) with at most one per logical buffer. At
|
|
2150
|
+
// initial population for 10K ROs this collapses ~30K calls into
|
|
2151
|
+
// ~5 (arena, indices, plus one per bucket).
|
|
2152
|
+
arena.attrs.flush(device);
|
|
2153
|
+
arena.indices.flush(device);
|
|
2154
|
+
for (const bucket of buckets) {
|
|
2155
|
+
if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
|
|
2156
|
+
device.queue.writeBuffer(bucket.drawHeap.buffer, bucket.headerDirtyMin, bucket.drawHeaderStaging.buffer, bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin, bucket.headerDirtyMax - bucket.headerDirtyMin);
|
|
2157
|
+
bucket.headerDirtyMin = Infinity;
|
|
2158
|
+
bucket.headerDirtyMax = 0;
|
|
2159
|
+
}
|
|
2160
|
+
if (bucket.drawTableDirtyMax > bucket.drawTableDirtyMin) {
|
|
2161
|
+
const shadow = bucket.drawTableShadow;
|
|
2162
|
+
device.queue.writeBuffer(bucket.drawTableBuf.buffer, bucket.drawTableDirtyMin, shadow.buffer, shadow.byteOffset + bucket.drawTableDirtyMin, bucket.drawTableDirtyMax - bucket.drawTableDirtyMin);
|
|
2163
|
+
bucket.drawTableDirtyMin = Infinity;
|
|
2164
|
+
bucket.drawTableDirtyMax = 0;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Encode all live bucket draws into an existing render pass. No
|
|
2170
|
+
* begin/end — caller owns the pass. Used by both the convenience
|
|
2171
|
+
* `frame()` below and (eventually) the hybrid render task.
|
|
2172
|
+
*/
|
|
2173
|
+
function encodeIntoPass(pass) {
|
|
2174
|
+
let curBg = null;
|
|
2175
|
+
for (const b of buckets) {
|
|
2176
|
+
if (b.bindGroup !== curBg) {
|
|
2177
|
+
pass.setBindGroup(0, b.bindGroup);
|
|
2178
|
+
curBg = b.bindGroup;
|
|
2179
|
+
}
|
|
2180
|
+
pass.setPipeline(b.pipeline);
|
|
2181
|
+
if (b.recordCount > 0)
|
|
2182
|
+
pass.drawIndirect(b.indirectBuf, 0);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Compute prep — must be called BEFORE `beginRenderPass`. For each
|
|
2187
|
+
* megacall bucket whose drawTable changed since the last frame: write
|
|
2188
|
+
* fresh `params`, rebuild the render bind group if recordCount
|
|
2189
|
+
* changed (drawTable view tightened), and dispatch the three-pass
|
|
2190
|
+
* Blelloch scan that fills `firstEmit` and the indirect args.
|
|
2191
|
+
*
|
|
2192
|
+
* Single compute pass over all dirty buckets — pipelines are shared,
|
|
2193
|
+
* we just swap the bind group + dispatch shape per bucket.
|
|
2194
|
+
*/
|
|
2195
|
+
function encodeComputePrep(enc, token) {
|
|
2196
|
+
// §7: derived-uniforms dispatch first — writes into per-bucket
|
|
2197
|
+
// drawHeap regions before the scan reads anything. One dispatcher
|
|
2198
|
+
// per bucket; constituents are shared so dirty state propagates
|
|
2199
|
+
// correctly. O(changed) per dispatcher.
|
|
2200
|
+
if (derivedScene !== undefined) {
|
|
2201
|
+
const _t0 = performance.now();
|
|
2202
|
+
// pullDirty's getValue(t) needs an evaluateAlways scope to
|
|
2203
|
+
// re-establish our subscription on the firing avals (getValue
|
|
2204
|
+
// adds the evaluating object to the aval's outputs). Without
|
|
2205
|
+
// this scope, view changes never propagate again — the scene
|
|
2206
|
+
// freezes after one frame.
|
|
2207
|
+
let dirty;
|
|
2208
|
+
sceneObj.evaluateAlways(token, (t) => {
|
|
2209
|
+
dirty = derivedScene.constituents.pullDirty(t);
|
|
2210
|
+
});
|
|
2211
|
+
const _t1 = performance.now();
|
|
2212
|
+
derivedScene.uploadDirty(dirty);
|
|
2213
|
+
const _t2 = performance.now();
|
|
2214
|
+
if (dirty.size > 0)
|
|
2215
|
+
derivedScene.dispatcher.encode(enc);
|
|
2216
|
+
const _t3 = performance.now();
|
|
2217
|
+
stats.derivedPullMs = _t1 - _t0;
|
|
2218
|
+
stats.derivedUploadMs = _t2 - _t1;
|
|
2219
|
+
stats.derivedEncodeMs = _t3 - _t2;
|
|
2220
|
+
stats.derivedRecords = derivedScene.dispatcher.records.recordCount;
|
|
2221
|
+
}
|
|
2222
|
+
let anyDirty = false;
|
|
2223
|
+
for (const b of buckets) {
|
|
2224
|
+
if (b.scanDirty) {
|
|
2225
|
+
anyDirty = true;
|
|
2226
|
+
break;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
if (!anyDirty)
|
|
2230
|
+
return;
|
|
2231
|
+
const pass = enc.beginComputePass({ label: "heapScene/scan" });
|
|
2232
|
+
for (const b of buckets) {
|
|
2233
|
+
if (!b.scanDirty)
|
|
2234
|
+
continue;
|
|
2235
|
+
// Rebuild render bind group if recordCount changed — its
|
|
2236
|
+
// drawTable binding is sized to recordCount * 16.
|
|
2237
|
+
if (b.renderBoundRecordCount !== b.recordCount) {
|
|
2238
|
+
b.bindGroup = buildBucketBindGroup(b);
|
|
2239
|
+
}
|
|
2240
|
+
const numRecords = b.recordCount;
|
|
2241
|
+
const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
2242
|
+
device.queue.writeBuffer(b.paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
|
|
2243
|
+
pass.setBindGroup(0, b.scanBindGroup);
|
|
2244
|
+
pass.setPipeline(scanPipeTile);
|
|
2245
|
+
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2246
|
+
pass.setPipeline(scanPipeBlocks);
|
|
2247
|
+
pass.dispatchWorkgroups(1, 1, 1);
|
|
2248
|
+
pass.setPipeline(scanPipeAdd);
|
|
2249
|
+
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2250
|
+
// numTiles is computed on GPU from indirect[0]; CPU dispatch
|
|
2251
|
+
// must cover the worst-case totalEmit. Each WG handles WG_SIZE
|
|
2252
|
+
// tiles; +1 for the sentinel slot.
|
|
2253
|
+
const SCAN_WG_SIZE = 256;
|
|
2254
|
+
const numTilesCap = Math.max(1, Math.ceil(b.totalEmitEstimate / TILE_K));
|
|
2255
|
+
const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
|
|
2256
|
+
pass.setPipeline(scanPipeBuildTileIndex);
|
|
2257
|
+
pass.dispatchWorkgroups(tileWgs, 1, 1);
|
|
2258
|
+
b.scanDirty = false;
|
|
2259
|
+
}
|
|
2260
|
+
pass.end();
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Convenience: open a command encoder + render pass against
|
|
2264
|
+
* `framebuffer`, drive update + encodeIntoPass, end pass, submit.
|
|
2265
|
+
* For hybrid composition use `update` + `encodeIntoPass` directly.
|
|
2266
|
+
*/
|
|
2267
|
+
function frame(framebuffer, token) {
|
|
2268
|
+
update(token);
|
|
2269
|
+
const colorView = framebuffer.colors.tryFind(colorAttachmentName);
|
|
2270
|
+
const depthView = framebuffer.depthStencil;
|
|
2271
|
+
const enc = device.createCommandEncoder({ label: "heapScene: encoder" });
|
|
2272
|
+
encodeComputePrep(enc, token);
|
|
2273
|
+
const pass = enc.beginRenderPass({
|
|
2274
|
+
colorAttachments: [{
|
|
2275
|
+
view: colorView,
|
|
2276
|
+
clearValue: { r: 0.07, g: 0.07, b: 0.08, a: 1.0 },
|
|
2277
|
+
loadOp: "clear", storeOp: "store",
|
|
2278
|
+
}],
|
|
2279
|
+
...(depthView !== undefined && depthFormat !== undefined ? {
|
|
2280
|
+
depthStencilAttachment: {
|
|
2281
|
+
view: depthView,
|
|
2282
|
+
depthClearValue: 1.0,
|
|
2283
|
+
depthLoadOp: "clear", depthStoreOp: "store",
|
|
2284
|
+
},
|
|
2285
|
+
} : {}),
|
|
2286
|
+
});
|
|
2287
|
+
encodeIntoPass(pass);
|
|
2288
|
+
pass.end();
|
|
339
2289
|
device.queue.submit([enc.finish()]);
|
|
340
2290
|
}
|
|
341
2291
|
function dispose() {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
2292
|
+
arena.attrs.destroy();
|
|
2293
|
+
arena.indices.destroy();
|
|
2294
|
+
for (const b of buckets) {
|
|
2295
|
+
b.drawHeap.destroy();
|
|
2296
|
+
b.drawTableBuf?.destroy();
|
|
2297
|
+
b.blockSumsBuf?.destroy();
|
|
2298
|
+
b.blockOffsetsBuf?.destroy();
|
|
2299
|
+
b.firstDrawInTileBuf?.destroy();
|
|
2300
|
+
b.indirectBuf?.destroy();
|
|
2301
|
+
b.paramsBuf?.destroy();
|
|
350
2302
|
}
|
|
351
2303
|
}
|
|
352
|
-
|
|
2304
|
+
// Test-only escape hatch for inspecting megacall bucket state. Not
|
|
2305
|
+
// part of the public API surface — keep cast at use-site.
|
|
2306
|
+
const _debug = {
|
|
2307
|
+
bucketsForTest() {
|
|
2308
|
+
return buckets.map(b => ({
|
|
2309
|
+
indirectBuf: b.indirectBuf,
|
|
2310
|
+
drawTableBuf: b.drawTableBuf?.buffer,
|
|
2311
|
+
firstDrawInTileBuf: b.firstDrawInTileBuf?.buffer,
|
|
2312
|
+
totalEmitEstimate: b.totalEmitEstimate,
|
|
2313
|
+
recordCount: b.recordCount,
|
|
2314
|
+
layout: b.layout,
|
|
2315
|
+
}));
|
|
2316
|
+
},
|
|
2317
|
+
/** §7 debug: bucket drawHeap GPU buffers. */
|
|
2318
|
+
drawHeapBufsForTest() {
|
|
2319
|
+
return buckets.map(b => b.drawHeap.buffer);
|
|
2320
|
+
},
|
|
2321
|
+
/** Download arena + drawHeaps + drawTables + indices and validate
|
|
2322
|
+
* the heap renderer's invariants:
|
|
2323
|
+
* 1. drawHeader refs land inside arena.
|
|
2324
|
+
* 2. drawTable rows (firstEmit, drawIdx, indexStart, indexCount,
|
|
2325
|
+
* instanceCount) reference valid slots / indices / instances.
|
|
2326
|
+
* 3. firstEmit is a correct prefix-sum of `indexCount *
|
|
2327
|
+
* instanceCount` over drawTable rows in record-index order.
|
|
2328
|
+
* 4. Index range [indexStart, indexStart+indexCount) lies within
|
|
2329
|
+
* arena.indices.buffer.
|
|
2330
|
+
* Returns a list of issue strings + counters. Async, safe to
|
|
2331
|
+
* call any frame.
|
|
2332
|
+
*/
|
|
2333
|
+
async validateHeap() {
|
|
2334
|
+
const issues = [];
|
|
2335
|
+
let okRefs = 0, badRefs = 0;
|
|
2336
|
+
let drawTableRows = 0, drawTableErrs = 0, prefixSumErrs = 0;
|
|
2337
|
+
let attrAllocsChecked = 0, attrAllocsBad = 0;
|
|
2338
|
+
let tilesChecked = 0, tilesBad = 0;
|
|
2339
|
+
let vidChecks = 0, vidBad = 0;
|
|
2340
|
+
const push = (s) => { if (issues.length < 60)
|
|
2341
|
+
issues.push(s); };
|
|
2342
|
+
const arenaSize = arena.attrs.buffer.size;
|
|
2343
|
+
const indicesSize = arena.indices.buffer.size;
|
|
2344
|
+
const enc = device.createCommandEncoder({ label: "validateHeap" });
|
|
2345
|
+
const stage = (src, size) => {
|
|
2346
|
+
const c = device.createBuffer({
|
|
2347
|
+
size: size,
|
|
2348
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2349
|
+
});
|
|
2350
|
+
enc.copyBufferToBuffer(src, 0, c, 0, size);
|
|
2351
|
+
return c;
|
|
2352
|
+
};
|
|
2353
|
+
const arenaCopy = stage(arena.attrs.buffer, arenaSize);
|
|
2354
|
+
const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
|
|
2355
|
+
const TILE_K_LOCAL = 64;
|
|
2356
|
+
const dcs = [];
|
|
2357
|
+
for (const b of buckets) {
|
|
2358
|
+
const dhSize = Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes);
|
|
2359
|
+
if (dhSize === 0)
|
|
2360
|
+
continue;
|
|
2361
|
+
const dh = stage(b.drawHeap.buffer, dhSize);
|
|
2362
|
+
const dt = b.drawTableBuf !== undefined && b.recordCount > 0
|
|
2363
|
+
? stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES)
|
|
2364
|
+
: undefined;
|
|
2365
|
+
const totalEmit = b.totalEmitEstimate;
|
|
2366
|
+
const numTiles = totalEmit > 0 ? Math.ceil(totalEmit / TILE_K_LOCAL) : 0;
|
|
2367
|
+
const fdt = b.firstDrawInTileBuf !== undefined && numTiles > 0
|
|
2368
|
+
? stage(b.firstDrawInTileBuf.buffer, Math.min(b.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
|
|
2369
|
+
: undefined;
|
|
2370
|
+
const ind = b.indirectBuf !== undefined ? stage(b.indirectBuf, 16) : undefined;
|
|
2371
|
+
dcs.push({
|
|
2372
|
+
bucket: b, drawHeap: dh, numTiles,
|
|
2373
|
+
...(dt !== undefined ? { drawTable: dt } : {}),
|
|
2374
|
+
...(fdt !== undefined ? { firstDrawInTile: fdt } : {}),
|
|
2375
|
+
...(ind !== undefined ? { indirect: ind } : {}),
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
device.queue.submit([enc.finish()]);
|
|
2379
|
+
await arenaCopy.mapAsync(GPUMapMode.READ);
|
|
2380
|
+
if (indicesCopy !== undefined)
|
|
2381
|
+
await indicesCopy.mapAsync(GPUMapMode.READ);
|
|
2382
|
+
for (const dc of dcs) {
|
|
2383
|
+
await dc.drawHeap.mapAsync(GPUMapMode.READ);
|
|
2384
|
+
if (dc.drawTable !== undefined)
|
|
2385
|
+
await dc.drawTable.mapAsync(GPUMapMode.READ);
|
|
2386
|
+
if (dc.firstDrawInTile !== undefined)
|
|
2387
|
+
await dc.firstDrawInTile.mapAsync(GPUMapMode.READ);
|
|
2388
|
+
if (dc.indirect !== undefined)
|
|
2389
|
+
await dc.indirect.mapAsync(GPUMapMode.READ);
|
|
2390
|
+
}
|
|
2391
|
+
// ── 1+4. drawHeader refs inside arena, drawTable indices in range,
|
|
2392
|
+
// + attribute alloc-header sanity (typeId / length / data).
|
|
2393
|
+
const arenaU32 = new Uint32Array(arenaCopy.getMappedRange());
|
|
2394
|
+
const arenaF32 = new Float32Array(arenaU32.buffer, arenaU32.byteOffset, arenaU32.length);
|
|
2395
|
+
// Track unique attribute-alloc refs we've already inspected so we
|
|
2396
|
+
// don't re-validate the same shared alloc 20K times.
|
|
2397
|
+
const attrAllocsSeen = new Set();
|
|
2398
|
+
const KNOWN_TYPE_IDS = new Set([0, 1, 2, 3]);
|
|
2399
|
+
let bucketIdx = 0;
|
|
2400
|
+
for (const dc of dcs) {
|
|
2401
|
+
const u32 = new Uint32Array(dc.drawHeap.getMappedRange());
|
|
2402
|
+
const stride = dc.bucket.layout.drawHeaderBytes;
|
|
2403
|
+
const recordCount = dc.bucket.recordCount;
|
|
2404
|
+
for (let slot = 0; slot < recordCount; slot++) {
|
|
2405
|
+
const slotOff = slot * stride;
|
|
2406
|
+
for (const f of dc.bucket.layout.drawHeaderFields) {
|
|
2407
|
+
if (f.name === "__layoutId")
|
|
2408
|
+
continue;
|
|
2409
|
+
if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
|
|
2410
|
+
continue;
|
|
2411
|
+
const ref = u32[(slotOff + f.byteOffset) >>> 2];
|
|
2412
|
+
if (ref === undefined || ref === 0)
|
|
2413
|
+
continue;
|
|
2414
|
+
const payloadStart = ref + ALLOC_HEADER_PAD_TO;
|
|
2415
|
+
if (payloadStart >= arenaSize) {
|
|
2416
|
+
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' ` +
|
|
2417
|
+
`ref=0x${ref.toString(16)} payload=0x${payloadStart.toString(16)} >= arena=0x${arenaSize.toString(16)}`);
|
|
2418
|
+
badRefs++;
|
|
2419
|
+
continue;
|
|
2420
|
+
}
|
|
2421
|
+
okRefs++;
|
|
2422
|
+
// For attribute-ref fields, decode the alloc header at
|
|
2423
|
+
// [ref, ref+16): typeId @ +0, length @ +4. Then sanity-
|
|
2424
|
+
// check the data range covers what the shader will index.
|
|
2425
|
+
if (f.kind !== "attribute-ref")
|
|
2426
|
+
continue;
|
|
2427
|
+
if (attrAllocsSeen.has(ref))
|
|
2428
|
+
continue;
|
|
2429
|
+
attrAllocsSeen.add(ref);
|
|
2430
|
+
attrAllocsChecked++;
|
|
2431
|
+
const refU32 = ref >>> 2;
|
|
2432
|
+
const typeId = arenaU32[refU32];
|
|
2433
|
+
const length = arenaU32[refU32 + 1];
|
|
2434
|
+
if (!KNOWN_TYPE_IDS.has(typeId)) {
|
|
2435
|
+
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown`);
|
|
2436
|
+
attrAllocsBad++;
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
if (length === 0) {
|
|
2440
|
+
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} length=0`);
|
|
2441
|
+
attrAllocsBad++;
|
|
2442
|
+
continue;
|
|
2443
|
+
}
|
|
2444
|
+
const eltFloats = f.attributeWgslType === "vec3<f32>" ? 3 :
|
|
2445
|
+
f.attributeWgslType === "vec4<f32>" ? 4 :
|
|
2446
|
+
f.attributeWgslType === "vec2<f32>" ? 2 : 1;
|
|
2447
|
+
const dataStartF32 = (ref + ALLOC_HEADER_PAD_TO) >>> 2;
|
|
2448
|
+
const dataEndF32 = dataStartF32 + length * eltFloats;
|
|
2449
|
+
if (dataEndF32 * 4 > arenaSize) {
|
|
2450
|
+
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} ` +
|
|
2451
|
+
`data extends to f32[${dataEndF32}] = ${dataEndF32 * 4}B > arena=${arenaSize}B`);
|
|
2452
|
+
attrAllocsBad++;
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
// Check finite-ness on the data we're about to read.
|
|
2456
|
+
let nonFinite = 0;
|
|
2457
|
+
for (let i = dataStartF32; i < dataEndF32; i++) {
|
|
2458
|
+
const v = arenaF32[i];
|
|
2459
|
+
if (!Number.isFinite(v))
|
|
2460
|
+
nonFinite++;
|
|
2461
|
+
}
|
|
2462
|
+
if (nonFinite > 0) {
|
|
2463
|
+
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} ` +
|
|
2464
|
+
`${nonFinite} non-finite floats in data range`);
|
|
2465
|
+
attrAllocsBad++;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
// ── 2. drawTable validation
|
|
2470
|
+
if (dc.drawTable !== undefined) {
|
|
2471
|
+
const dt = new Uint32Array(dc.drawTable.getMappedRange());
|
|
2472
|
+
let runningSum = 0;
|
|
2473
|
+
for (let r = 0; r < recordCount; r++) {
|
|
2474
|
+
const off = r * RECORD_U32;
|
|
2475
|
+
const firstEmit = dt[off + 0];
|
|
2476
|
+
const drawIdx = dt[off + 1];
|
|
2477
|
+
const indexStart = dt[off + 2];
|
|
2478
|
+
const indexCount = dt[off + 3];
|
|
2479
|
+
const instanceCount = dt[off + 4];
|
|
2480
|
+
drawTableRows++;
|
|
2481
|
+
// 2a. drawIdx must be a valid local slot in this bucket
|
|
2482
|
+
if (drawIdx >= dc.bucket.layout.drawHeaderBytes * 0 + recordCount + 8 /* loose: should be < total addDraws */) {
|
|
2483
|
+
// can't easily check upper without an externally known max — skip
|
|
2484
|
+
}
|
|
2485
|
+
// 2b. instanceCount > 0
|
|
2486
|
+
if (instanceCount === 0) {
|
|
2487
|
+
push(`bucket#${bucketIdx} drawTable[${r}] instanceCount=0`);
|
|
2488
|
+
drawTableErrs++;
|
|
2489
|
+
}
|
|
2490
|
+
// 2c. indexCount > 0 (or the record is dead — but live records should have count)
|
|
2491
|
+
if (indexCount === 0) {
|
|
2492
|
+
push(`bucket#${bucketIdx} drawTable[${r}] indexCount=0`);
|
|
2493
|
+
drawTableErrs++;
|
|
2494
|
+
}
|
|
2495
|
+
// 2d. indices range inside arena.indices
|
|
2496
|
+
const indexBytes = (indexStart + indexCount) * 4;
|
|
2497
|
+
if (indexBytes > indicesSize) {
|
|
2498
|
+
push(`bucket#${bucketIdx} drawTable[${r}] index range ` +
|
|
2499
|
+
`[${indexStart}, ${indexStart + indexCount}) bytes=${indexBytes} > indicesSize=${indicesSize}`);
|
|
2500
|
+
drawTableErrs++;
|
|
2501
|
+
}
|
|
2502
|
+
// 3. prefix sum
|
|
2503
|
+
if (firstEmit !== runningSum) {
|
|
2504
|
+
push(`bucket#${bucketIdx} drawTable[${r}] firstEmit=${firstEmit} ≠ expected=${runningSum} ` +
|
|
2505
|
+
`(indexCount=${indexCount} instanceCount=${instanceCount})`);
|
|
2506
|
+
prefixSumErrs++;
|
|
2507
|
+
}
|
|
2508
|
+
runningSum += indexCount * instanceCount;
|
|
2509
|
+
}
|
|
2510
|
+
// ── 5. firstDrawInTile validation: per-tile CPU binary
|
|
2511
|
+
// search using the drawTable.firstEmit values must
|
|
2512
|
+
// match the GPU-computed values in firstDrawInTile.
|
|
2513
|
+
// firstEmit was just verified vs CPU prefix-sum, so
|
|
2514
|
+
// we use the GPU-stored values as the trusted truth.
|
|
2515
|
+
if (dc.firstDrawInTile !== undefined && dc.numTiles > 0) {
|
|
2516
|
+
const fdt = new Uint32Array(dc.firstDrawInTile.getMappedRange());
|
|
2517
|
+
const firstEmits = [];
|
|
2518
|
+
for (let r = 0; r < recordCount; r++) {
|
|
2519
|
+
firstEmits.push(dt[r * RECORD_U32 + 0]);
|
|
2520
|
+
}
|
|
2521
|
+
for (let t = 0; t < dc.numTiles; t++) {
|
|
2522
|
+
const tileStart = t * TILE_K_LOCAL;
|
|
2523
|
+
let lo = 0, hi = recordCount - 1;
|
|
2524
|
+
while (lo < hi) {
|
|
2525
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
2526
|
+
if (firstEmits[mid] <= tileStart)
|
|
2527
|
+
lo = mid;
|
|
2528
|
+
else
|
|
2529
|
+
hi = mid - 1;
|
|
2530
|
+
}
|
|
2531
|
+
const got = fdt[t];
|
|
2532
|
+
tilesChecked++;
|
|
2533
|
+
if (got !== lo) {
|
|
2534
|
+
push(`bucket#${bucketIdx} firstDrawInTile[${t}] (tileStart=${tileStart}) ` +
|
|
2535
|
+
`got=${got} expected=${lo}`);
|
|
2536
|
+
tilesBad++;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
const sentinel = fdt[dc.numTiles];
|
|
2540
|
+
tilesChecked++;
|
|
2541
|
+
if (sentinel !== recordCount) {
|
|
2542
|
+
push(`bucket#${bucketIdx} firstDrawInTile[${dc.numTiles}] sentinel ` +
|
|
2543
|
+
`got=${sentinel} expected=${recordCount}`);
|
|
2544
|
+
tilesBad++;
|
|
2545
|
+
}
|
|
2546
|
+
dc.firstDrawInTile.unmap();
|
|
2547
|
+
dc.firstDrawInTile.destroy();
|
|
2548
|
+
}
|
|
2549
|
+
dc.drawTable.unmap();
|
|
2550
|
+
dc.drawTable.destroy();
|
|
2551
|
+
}
|
|
2552
|
+
// ── 6. indirect[0] (totalEmit) must match CPU prefix sum.
|
|
2553
|
+
if (dc.indirect !== undefined) {
|
|
2554
|
+
const ind = new Uint32Array(dc.indirect.getMappedRange());
|
|
2555
|
+
const expectedTotal = dc.bucket.totalEmitEstimate;
|
|
2556
|
+
const got = ind[0];
|
|
2557
|
+
if (got !== expectedTotal) {
|
|
2558
|
+
push(`bucket#${bucketIdx} indirect[0]=${got} ≠ expected totalEmit=${expectedTotal}`);
|
|
2559
|
+
drawTableErrs++;
|
|
2560
|
+
}
|
|
2561
|
+
// [1]=instanceCount must be 1 (megacall pattern).
|
|
2562
|
+
if (ind[1] !== 1) {
|
|
2563
|
+
push(`bucket#${bucketIdx} indirect[1]=${ind[1]} ≠ 1`);
|
|
2564
|
+
drawTableErrs++;
|
|
2565
|
+
}
|
|
2566
|
+
dc.indirect.unmap();
|
|
2567
|
+
dc.indirect.destroy();
|
|
2568
|
+
}
|
|
2569
|
+
dc.drawHeap.unmap();
|
|
2570
|
+
dc.drawHeap.destroy();
|
|
2571
|
+
bucketIdx++;
|
|
2572
|
+
}
|
|
2573
|
+
// ── 7. Content fingerprints over scene-deterministic regions.
|
|
2574
|
+
// Hashing the FULL arena would change per frame because
|
|
2575
|
+
// view/proj uniforms are repacked each tick — not useful
|
|
2576
|
+
// for cross-device comparison. Restrict to what's stable
|
|
2577
|
+
// across the whole scene lifetime: indices buffer (vertex
|
|
2578
|
+
// indices, written once at addDraw and never touched).
|
|
2579
|
+
// fnv1a is fast and good enough.
|
|
2580
|
+
const fnv1a = (u32) => {
|
|
2581
|
+
let h = 0x811c9dc5 >>> 0;
|
|
2582
|
+
for (let i = 0; i < u32.length; i++) {
|
|
2583
|
+
const v = u32[i];
|
|
2584
|
+
h = (h ^ (v & 0xff)) >>> 0;
|
|
2585
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
2586
|
+
h = (h ^ ((v >>> 8) & 0xff)) >>> 0;
|
|
2587
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
2588
|
+
h = (h ^ ((v >>> 16) & 0xff)) >>> 0;
|
|
2589
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
2590
|
+
h = (h ^ ((v >>> 24) & 0xff)) >>> 0;
|
|
2591
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
2592
|
+
}
|
|
2593
|
+
return h.toString(16).padStart(8, "0");
|
|
2594
|
+
};
|
|
2595
|
+
const indicesHash = indicesCopy !== undefined
|
|
2596
|
+
? fnv1a(new Uint32Array(indicesCopy.getMappedRange(), 0, indicesSize >>> 2))
|
|
2597
|
+
: "—";
|
|
2598
|
+
arenaCopy.unmap();
|
|
2599
|
+
arenaCopy.destroy();
|
|
2600
|
+
if (indicesCopy !== undefined) {
|
|
2601
|
+
indicesCopy.unmap();
|
|
2602
|
+
indicesCopy.destroy();
|
|
2603
|
+
}
|
|
2604
|
+
return {
|
|
2605
|
+
arenaBytes: arenaSize,
|
|
2606
|
+
issues,
|
|
2607
|
+
okRefs, badRefs,
|
|
2608
|
+
drawTableRows, drawTableErrs, prefixSumErrs,
|
|
2609
|
+
attrAllocsChecked, attrAllocsBad,
|
|
2610
|
+
tilesChecked, tilesBad,
|
|
2611
|
+
vidChecks, vidBad,
|
|
2612
|
+
indicesHash,
|
|
2613
|
+
};
|
|
2614
|
+
},
|
|
2615
|
+
/** Per-emit CPU draw simulator. Samples N emits across all
|
|
2616
|
+
* buckets; for each, performs the same binary search the render
|
|
2617
|
+
* kernel does, recovers (slot, _local, instId, vid), and verifies
|
|
2618
|
+
* every storage-buffer read address the vertex shader would
|
|
2619
|
+
* perform lands inside its bound buffer.
|
|
2620
|
+
*
|
|
2621
|
+
* Returns counts + first few sites of any OOB read. The shader's
|
|
2622
|
+
* cyclic addressing on per-vertex attrs and direct (non-cyclic)
|
|
2623
|
+
* addressing on per-instance attrs are both modelled. */
|
|
2624
|
+
async simulateDraws(samples = 50_000) {
|
|
2625
|
+
const issues = [];
|
|
2626
|
+
let oob = 0;
|
|
2627
|
+
let emitsChecked = 0;
|
|
2628
|
+
const push = (s) => { if (issues.length < 30)
|
|
2629
|
+
issues.push(s); };
|
|
2630
|
+
const arenaSize = arena.attrs.buffer.size;
|
|
2631
|
+
const indicesSize = arena.indices.buffer.size;
|
|
2632
|
+
// Stage all buffers we'll need.
|
|
2633
|
+
const enc = device.createCommandEncoder({ label: "simulateDraws" });
|
|
2634
|
+
const stage = (src, size) => {
|
|
2635
|
+
const c = device.createBuffer({
|
|
2636
|
+
size, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2637
|
+
});
|
|
2638
|
+
enc.copyBufferToBuffer(src, 0, c, 0, size);
|
|
2639
|
+
return c;
|
|
2640
|
+
};
|
|
2641
|
+
const arenaCopy = stage(arena.attrs.buffer, arenaSize);
|
|
2642
|
+
const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
|
|
2643
|
+
const dcs = [];
|
|
2644
|
+
for (const b of buckets) {
|
|
2645
|
+
if (b.recordCount === 0 || b.totalEmitEstimate === 0)
|
|
2646
|
+
continue;
|
|
2647
|
+
const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes));
|
|
2648
|
+
const dt = stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES);
|
|
2649
|
+
dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.totalEmitEstimate });
|
|
2650
|
+
}
|
|
2651
|
+
device.queue.submit([enc.finish()]);
|
|
2652
|
+
await arenaCopy.mapAsync(GPUMapMode.READ);
|
|
2653
|
+
if (indicesCopy !== undefined)
|
|
2654
|
+
await indicesCopy.mapAsync(GPUMapMode.READ);
|
|
2655
|
+
for (const dc of dcs) {
|
|
2656
|
+
await dc.drawHeap.mapAsync(GPUMapMode.READ);
|
|
2657
|
+
await dc.drawTable.mapAsync(GPUMapMode.READ);
|
|
2658
|
+
}
|
|
2659
|
+
const arenaU32 = new Uint32Array(arenaCopy.getMappedRange());
|
|
2660
|
+
const arenaF32 = new Float32Array(arenaU32.buffer, arenaU32.byteOffset, arenaU32.length);
|
|
2661
|
+
const indicesU32 = indicesCopy !== undefined
|
|
2662
|
+
? new Uint32Array(indicesCopy.getMappedRange())
|
|
2663
|
+
: undefined;
|
|
2664
|
+
// Compute per-bucket cumulative emit ranges + cache mapped views.
|
|
2665
|
+
const bucketCumEmit = [];
|
|
2666
|
+
const bucketDt = [];
|
|
2667
|
+
const bucketHdr = [];
|
|
2668
|
+
let totalEmitGlobal = 0;
|
|
2669
|
+
for (const dc of dcs) {
|
|
2670
|
+
const dt = new Uint32Array(dc.drawTable.getMappedRange());
|
|
2671
|
+
bucketDt.push(dt);
|
|
2672
|
+
bucketHdr.push(new Uint32Array(dc.drawHeap.getMappedRange()));
|
|
2673
|
+
for (let r = 0; r < dc.bucket.recordCount; r++) {
|
|
2674
|
+
dc.firstEmit.push(dt[r * RECORD_U32 + 0]);
|
|
2675
|
+
}
|
|
2676
|
+
bucketCumEmit.push(totalEmitGlobal);
|
|
2677
|
+
totalEmitGlobal += dc.totalEmit;
|
|
2678
|
+
}
|
|
2679
|
+
const fieldMap = new Map();
|
|
2680
|
+
for (const dc of dcs) {
|
|
2681
|
+
const fs = [];
|
|
2682
|
+
for (const f of dc.bucket.layout.drawHeaderFields) {
|
|
2683
|
+
if (f.name === "__layoutId")
|
|
2684
|
+
continue;
|
|
2685
|
+
if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
|
|
2686
|
+
continue;
|
|
2687
|
+
const wt = f.kind === "uniform-ref" ? f.uniformWgslType : f.attributeWgslType;
|
|
2688
|
+
const eltF = wt === "vec3<f32>" ? 3 :
|
|
2689
|
+
wt === "vec4<f32>" ? 4 :
|
|
2690
|
+
wt === "vec2<f32>" ? 2 :
|
|
2691
|
+
wt === "mat4x4<f32>" ? 16 : 1;
|
|
2692
|
+
const isInstance = f.kind === "attribute-ref" && dc.bucket.layout.perInstanceAttributes.has(f.name);
|
|
2693
|
+
fs.push({
|
|
2694
|
+
name: f.name,
|
|
2695
|
+
byteOffset: f.byteOffset,
|
|
2696
|
+
kind: f.kind,
|
|
2697
|
+
eltFloats: eltF,
|
|
2698
|
+
isInstance,
|
|
2699
|
+
uniSizeFloats: eltF,
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
fieldMap.set(dc.bucket, fs);
|
|
2703
|
+
}
|
|
2704
|
+
// Sample emits uniformly.
|
|
2705
|
+
const sampleCount = Math.min(samples, totalEmitGlobal);
|
|
2706
|
+
const stride = Math.max(1, Math.floor(totalEmitGlobal / sampleCount));
|
|
2707
|
+
for (let s = 0; s < totalEmitGlobal && emitsChecked < sampleCount; s += stride) {
|
|
2708
|
+
// Find which bucket this global emit lands in.
|
|
2709
|
+
let dc = dcs[0];
|
|
2710
|
+
let dcIdx = 0;
|
|
2711
|
+
let emit = s;
|
|
2712
|
+
for (let i = 0; i < dcs.length; i++) {
|
|
2713
|
+
if (s < bucketCumEmit[i] + dcs[i].totalEmit) {
|
|
2714
|
+
dc = dcs[i];
|
|
2715
|
+
dcIdx = i;
|
|
2716
|
+
emit = s - bucketCumEmit[i];
|
|
2717
|
+
break;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
emitsChecked++;
|
|
2721
|
+
// Binary search for slot.
|
|
2722
|
+
const recCount = dc.bucket.recordCount;
|
|
2723
|
+
let lo = 0, hi = recCount - 1;
|
|
2724
|
+
while (lo < hi) {
|
|
2725
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
2726
|
+
if (dc.firstEmit[mid] <= emit)
|
|
2727
|
+
lo = mid;
|
|
2728
|
+
else
|
|
2729
|
+
hi = mid - 1;
|
|
2730
|
+
}
|
|
2731
|
+
const slot = lo;
|
|
2732
|
+
const dt = bucketDt[dcIdx];
|
|
2733
|
+
const off = slot * RECORD_U32;
|
|
2734
|
+
const firstEmit = dt[off + 0];
|
|
2735
|
+
const drawIdx = dt[off + 1];
|
|
2736
|
+
const indexStart = dt[off + 2];
|
|
2737
|
+
const indexCount = dt[off + 3];
|
|
2738
|
+
const instanceCount = dt[off + 4];
|
|
2739
|
+
// Recover _local, instId, vid.
|
|
2740
|
+
const _local = emit - firstEmit;
|
|
2741
|
+
const instId = Math.floor(_local / indexCount);
|
|
2742
|
+
const idxIdx = indexStart + (_local % indexCount);
|
|
2743
|
+
if (idxIdx * 4 >= indicesSize) {
|
|
2744
|
+
push(`emit=${s} bucket=${dcIdx} slot=${slot} idxIdx=${idxIdx} OOB indices`);
|
|
2745
|
+
oob++;
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
if (instId >= instanceCount) {
|
|
2749
|
+
push(`emit=${s} slot=${slot} instId=${instId} ≥ instanceCount=${instanceCount}`);
|
|
2750
|
+
oob++;
|
|
2751
|
+
continue;
|
|
2752
|
+
}
|
|
2753
|
+
const vid = indicesU32[idxIdx];
|
|
2754
|
+
// Walk drawHeader fields for this drawIdx.
|
|
2755
|
+
const stride2 = dc.bucket.layout.drawHeaderBytes;
|
|
2756
|
+
const headerU32 = bucketHdr[dcIdx];
|
|
2757
|
+
const headerOff = drawIdx * stride2;
|
|
2758
|
+
const fs = fieldMap.get(dc.bucket);
|
|
2759
|
+
for (const f of fs) {
|
|
2760
|
+
const ref = headerU32[(headerOff + f.byteOffset) >>> 2];
|
|
2761
|
+
if (ref === undefined || ref === 0)
|
|
2762
|
+
continue;
|
|
2763
|
+
const allocBase = ref + ALLOC_HEADER_PAD_TO;
|
|
2764
|
+
const length = arenaU32[(ref >>> 2) + 1];
|
|
2765
|
+
if (length === 0) {
|
|
2766
|
+
push(`emit=${s} field='${f.name}' alloc length=0`);
|
|
2767
|
+
oob++;
|
|
2768
|
+
continue;
|
|
2769
|
+
}
|
|
2770
|
+
let elemIdx;
|
|
2771
|
+
if (f.kind === "uniform-ref") {
|
|
2772
|
+
elemIdx = 0;
|
|
2773
|
+
}
|
|
2774
|
+
else if (f.isInstance) {
|
|
2775
|
+
// direct: iidx * eltFloats. (`cyclic=false` codepath I added.)
|
|
2776
|
+
elemIdx = instId;
|
|
2777
|
+
}
|
|
2778
|
+
else {
|
|
2779
|
+
// cyclic: vid % length.
|
|
2780
|
+
elemIdx = vid % length;
|
|
2781
|
+
}
|
|
2782
|
+
const byteOffset = allocBase + elemIdx * (f.eltFloats * 4);
|
|
2783
|
+
const endByte = byteOffset + f.eltFloats * 4;
|
|
2784
|
+
if (endByte > arenaSize) {
|
|
2785
|
+
push(`emit=${s} bucket=${dcIdx} slot=${slot} drawIdx=${drawIdx} ` +
|
|
2786
|
+
`field='${f.name}' kind=${f.kind} isInstance=${f.isInstance} ` +
|
|
2787
|
+
`vid=${vid} instId=${instId} length=${length} elemIdx=${elemIdx} ` +
|
|
2788
|
+
`byteOffset=${byteOffset} endByte=${endByte} > arenaSize=${arenaSize}`);
|
|
2789
|
+
oob++;
|
|
2790
|
+
continue;
|
|
2791
|
+
}
|
|
2792
|
+
// Check finiteness of the data we're reading.
|
|
2793
|
+
let nonFinite = 0;
|
|
2794
|
+
const baseF = byteOffset >>> 2;
|
|
2795
|
+
for (let i = 0; i < f.eltFloats; i++) {
|
|
2796
|
+
const v = arenaF32[baseF + i];
|
|
2797
|
+
if (!Number.isFinite(v))
|
|
2798
|
+
nonFinite++;
|
|
2799
|
+
}
|
|
2800
|
+
if (nonFinite > 0) {
|
|
2801
|
+
push(`emit=${s} field='${f.name}' kind=${f.kind} isInstance=${f.isInstance} ` +
|
|
2802
|
+
`vid=${vid} instId=${instId} elemIdx=${elemIdx} ` +
|
|
2803
|
+
`nonFinite=${nonFinite}/${f.eltFloats}`);
|
|
2804
|
+
oob++;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
// ── Bounding-box check: read ModelTrafo + Positions, transform
|
|
2808
|
+
// the vertex, verify it lands near the RO's origin (the
|
|
2809
|
+
// modelTrafo's translation column). Distance > MAX × geometry
|
|
2810
|
+
// extent flags overlapping-write corruption: if two ROs'
|
|
2811
|
+
// arena allocs got cross-wired, the transformed position
|
|
2812
|
+
// will land in a different RO's region, far from this RO's
|
|
2813
|
+
// expected origin.
|
|
2814
|
+
const fsMap = new Map(fs.map(x => [x.name, x]));
|
|
2815
|
+
const modelF = fsMap.get("ModelTrafo");
|
|
2816
|
+
const positionsF = fsMap.get("Positions");
|
|
2817
|
+
if (modelF !== undefined && positionsF !== undefined) {
|
|
2818
|
+
const modelRef = headerU32[(headerOff + modelF.byteOffset) >>> 2];
|
|
2819
|
+
const posRef = headerU32[(headerOff + positionsF.byteOffset) >>> 2];
|
|
2820
|
+
if (modelRef !== undefined && modelRef !== 0 && posRef !== undefined && posRef !== 0) {
|
|
2821
|
+
const mBase = (modelRef + ALLOC_HEADER_PAD_TO) >>> 2;
|
|
2822
|
+
const posLen = arenaU32[(posRef >>> 2) + 1];
|
|
2823
|
+
// Aardvark M44d is row-major; CPU mirror packs row-major.
|
|
2824
|
+
// M[r][c] = arenaF32[mBase + r*4 + c]. Multiply M·v with
|
|
2825
|
+
// v = (px, py, pz, 1).
|
|
2826
|
+
const m = new Float32Array(16);
|
|
2827
|
+
for (let i = 0; i < 16; i++)
|
|
2828
|
+
m[i] = arenaF32[mBase + i];
|
|
2829
|
+
// RO origin = M·(0,0,0,1) = column 3 (translation).
|
|
2830
|
+
const ox = m[0 * 4 + 3];
|
|
2831
|
+
const oy = m[1 * 4 + 3];
|
|
2832
|
+
const oz = m[2 * 4 + 3];
|
|
2833
|
+
// Read vertex (cyclic vid%length).
|
|
2834
|
+
const vidWrap = vid % posLen;
|
|
2835
|
+
const pBase = (posRef + ALLOC_HEADER_PAD_TO) >>> 2;
|
|
2836
|
+
const px = arenaF32[pBase + vidWrap * 3 + 0];
|
|
2837
|
+
const py = arenaF32[pBase + vidWrap * 3 + 1];
|
|
2838
|
+
const pz = arenaF32[pBase + vidWrap * 3 + 2];
|
|
2839
|
+
// World = M·vec4(px,py,pz,1).
|
|
2840
|
+
const wx = m[0 * 4 + 0] * px + m[0 * 4 + 1] * py + m[0 * 4 + 2] * pz + m[0 * 4 + 3];
|
|
2841
|
+
const wy = m[1 * 4 + 0] * px + m[1 * 4 + 1] * py + m[1 * 4 + 2] * pz + m[1 * 4 + 3];
|
|
2842
|
+
const wz = m[2 * 4 + 0] * px + m[2 * 4 + 1] * py + m[2 * 4 + 2] * pz + m[2 * 4 + 3];
|
|
2843
|
+
const dx = wx - ox, dy = wy - oy, dz = wz - oz;
|
|
2844
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
2845
|
+
// Conservative bound: scale ≤ ~3 in any axis × max-vertex-mag ≤ ~1.5
|
|
2846
|
+
// → expected dist ≤ ~5. Anything > 50 is corruption.
|
|
2847
|
+
if (!Number.isFinite(wx + wy + wz) || dist > 50) {
|
|
2848
|
+
push(`emit=${s} bucket=${dcIdx} slot=${slot} drawIdx=${drawIdx} vid=${vid} ` +
|
|
2849
|
+
`pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)}) ` +
|
|
2850
|
+
`world=(${wx.toFixed(2)},${wy.toFixed(2)},${wz.toFixed(2)}) ` +
|
|
2851
|
+
`origin=(${ox.toFixed(2)},${oy.toFixed(2)},${oz.toFixed(2)}) ` +
|
|
2852
|
+
`distFromOrigin=${dist.toFixed(2)}`);
|
|
2853
|
+
oob++;
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
// Cleanup
|
|
2859
|
+
arenaCopy.unmap();
|
|
2860
|
+
arenaCopy.destroy();
|
|
2861
|
+
if (indicesCopy !== undefined) {
|
|
2862
|
+
indicesCopy.unmap();
|
|
2863
|
+
indicesCopy.destroy();
|
|
2864
|
+
}
|
|
2865
|
+
for (const dc of dcs) {
|
|
2866
|
+
dc.drawHeap.unmap();
|
|
2867
|
+
dc.drawHeap.destroy();
|
|
2868
|
+
dc.drawTable.unmap();
|
|
2869
|
+
dc.drawTable.destroy();
|
|
2870
|
+
}
|
|
2871
|
+
return { emitsChecked, oob, issues };
|
|
2872
|
+
},
|
|
2873
|
+
/** Triangle-level coherence: walks N triangle bases (3 consecutive
|
|
2874
|
+
* emits each) and verifies all three vertices of each triangle
|
|
2875
|
+
* resolve to the SAME slot. A mismatch means the rasterizer would
|
|
2876
|
+
* stitch a triangle across two ROs — the cross-RO stretched-band
|
|
2877
|
+
* bug. Vertex-level OOB / BB checks miss this because they sample
|
|
2878
|
+
* individual emits in isolation. */
|
|
2879
|
+
async checkTriangleCoherence(samples = 50_000) {
|
|
2880
|
+
const issues = [];
|
|
2881
|
+
let crossSlot = 0;
|
|
2882
|
+
const push = (s) => { if (issues.length < 30)
|
|
2883
|
+
issues.push(s); };
|
|
2884
|
+
let trianglesChecked = 0;
|
|
2885
|
+
let bucketIdx = 0;
|
|
2886
|
+
// Cross-bucket paranoia: collect every (ref, allocBytes, owner)
|
|
2887
|
+
// claimed by any drawHeader of any bucket. After all buckets are
|
|
2888
|
+
// walked, sort and verify no two distinct refs overlap in arena.
|
|
2889
|
+
// Distinct refs that overlap = arena allocator handed the same
|
|
2890
|
+
// bytes to two avals → two ROs reading each other's data → the
|
|
2891
|
+
// cross-RO triangle symptom.
|
|
2892
|
+
const allocClaims = [];
|
|
2893
|
+
// Stage arena + arena.indices once (shared across buckets).
|
|
2894
|
+
const arenaCopy = device.createBuffer({
|
|
2895
|
+
size: arena.attrs.buffer.size,
|
|
2896
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2897
|
+
});
|
|
2898
|
+
const indicesCopy = arena.indices.buffer.size > 0 ? device.createBuffer({
|
|
2899
|
+
size: arena.indices.buffer.size,
|
|
2900
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2901
|
+
}) : undefined;
|
|
2902
|
+
{
|
|
2903
|
+
const enc = device.createCommandEncoder({ label: "checkTriangleCoherence.arena" });
|
|
2904
|
+
enc.copyBufferToBuffer(arena.attrs.buffer, 0, arenaCopy, 0, arena.attrs.buffer.size);
|
|
2905
|
+
if (indicesCopy !== undefined)
|
|
2906
|
+
enc.copyBufferToBuffer(arena.indices.buffer, 0, indicesCopy, 0, arena.indices.buffer.size);
|
|
2907
|
+
device.queue.submit([enc.finish()]);
|
|
2908
|
+
}
|
|
2909
|
+
await arenaCopy.mapAsync(GPUMapMode.READ);
|
|
2910
|
+
if (indicesCopy !== undefined)
|
|
2911
|
+
await indicesCopy.mapAsync(GPUMapMode.READ);
|
|
2912
|
+
const arenaU32 = new Uint32Array(arenaCopy.getMappedRange());
|
|
2913
|
+
const arenaF32 = new Float32Array(arenaU32.buffer, arenaU32.byteOffset, arenaU32.length);
|
|
2914
|
+
const indicesU32 = indicesCopy !== undefined
|
|
2915
|
+
? new Uint32Array(indicesCopy.getMappedRange())
|
|
2916
|
+
: new Uint32Array(0);
|
|
2917
|
+
for (const target of buckets) {
|
|
2918
|
+
if (target.recordCount === 0 || target.totalEmitEstimate === 0) {
|
|
2919
|
+
bucketIdx++;
|
|
2920
|
+
continue;
|
|
2921
|
+
}
|
|
2922
|
+
const recordCount = target.recordCount;
|
|
2923
|
+
const totalEmit = target.totalEmitEstimate;
|
|
2924
|
+
const totalTris = Math.floor(totalEmit / 3);
|
|
2925
|
+
const remainder = totalEmit % 3;
|
|
2926
|
+
if (remainder !== 0) {
|
|
2927
|
+
push(`bucket#${bucketIdx} totalEmit=${totalEmit} not multiple of 3 (remainder ${remainder}) — partial triangle from indirect-draw`);
|
|
2928
|
+
crossSlot++;
|
|
2929
|
+
}
|
|
2930
|
+
// drawTable + drawHeap readback (per-bucket).
|
|
2931
|
+
const dtCopy = device.createBuffer({
|
|
2932
|
+
size: recordCount * RECORD_BYTES,
|
|
2933
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2934
|
+
});
|
|
2935
|
+
const dhCopy = device.createBuffer({
|
|
2936
|
+
size: recordCount * target.layout.drawHeaderBytes,
|
|
2937
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2938
|
+
});
|
|
2939
|
+
const enc0 = device.createCommandEncoder({ label: "checkTriangleCoherence.dt" });
|
|
2940
|
+
enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
2941
|
+
enc0.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, recordCount * target.layout.drawHeaderBytes);
|
|
2942
|
+
device.queue.submit([enc0.finish()]);
|
|
2943
|
+
await dtCopy.mapAsync(GPUMapMode.READ);
|
|
2944
|
+
await dhCopy.mapAsync(GPUMapMode.READ);
|
|
2945
|
+
const dtU32 = new Uint32Array(dtCopy.getMappedRange());
|
|
2946
|
+
const dhU32 = new Uint32Array(dhCopy.getMappedRange());
|
|
2947
|
+
const firstEmits = [];
|
|
2948
|
+
const drawIdxs = [];
|
|
2949
|
+
const indexStarts = [];
|
|
2950
|
+
const indexCounts = [];
|
|
2951
|
+
const instCounts = [];
|
|
2952
|
+
for (let r = 0; r < recordCount; r++) {
|
|
2953
|
+
firstEmits.push(dtU32[r * RECORD_U32 + 0]);
|
|
2954
|
+
drawIdxs.push(dtU32[r * RECORD_U32 + 1]);
|
|
2955
|
+
indexStarts.push(dtU32[r * RECORD_U32 + 2]);
|
|
2956
|
+
indexCounts.push(dtU32[r * RECORD_U32 + 3]);
|
|
2957
|
+
instCounts.push(dtU32[r * RECORD_U32 + 4]);
|
|
2958
|
+
}
|
|
2959
|
+
// Resolve drawHeader field offsets for ModelTrafo + Positions.
|
|
2960
|
+
let mtOff = -1, posOff = -1;
|
|
2961
|
+
for (const f of target.layout.drawHeaderFields) {
|
|
2962
|
+
if (f.name === "ModelTrafo")
|
|
2963
|
+
mtOff = f.byteOffset;
|
|
2964
|
+
if (f.name === "Positions")
|
|
2965
|
+
posOff = f.byteOffset;
|
|
2966
|
+
}
|
|
2967
|
+
const stride2 = target.layout.drawHeaderBytes;
|
|
2968
|
+
// Per-record correctness: indexCount * instanceCount must be
|
|
2969
|
+
// a multiple of 3, otherwise THIS record's last triangle would
|
|
2970
|
+
// lack a third vertex and the next record's first emit would
|
|
2971
|
+
// be pulled into it by the rasterizer.
|
|
2972
|
+
for (let r = 0; r < recordCount; r++) {
|
|
2973
|
+
const sub = indexCounts[r] * instCounts[r];
|
|
2974
|
+
if (sub % 3 !== 0) {
|
|
2975
|
+
push(`bucket#${bucketIdx} record[${r}] indexCount=${indexCounts[r]} instanceCount=${instCounts[r]} ` +
|
|
2976
|
+
`→ sub-emit=${sub} not multiple of 3 (CROSS-RO TRIANGLE!)`);
|
|
2977
|
+
crossSlot++;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
// Read firstDrawInTile so we can bound the search EXACTLY the
|
|
2981
|
+
// same way the VS does — `lo = fdt[tileIdx]; hi = fdt[tileIdx+1]`.
|
|
2982
|
+
const numTilesLocal = Math.ceil(totalEmit / 64);
|
|
2983
|
+
const fdtSize = (numTilesLocal + 1) * 4;
|
|
2984
|
+
const fdtCopy = device.createBuffer({
|
|
2985
|
+
size: fdtSize, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2986
|
+
});
|
|
2987
|
+
{
|
|
2988
|
+
const enc1 = device.createCommandEncoder();
|
|
2989
|
+
enc1.copyBufferToBuffer(target.firstDrawInTileBuf.buffer, 0, fdtCopy, 0, fdtSize);
|
|
2990
|
+
device.queue.submit([enc1.finish()]);
|
|
2991
|
+
await fdtCopy.mapAsync(GPUMapMode.READ);
|
|
2992
|
+
}
|
|
2993
|
+
const fdt = new Uint32Array(fdtCopy.getMappedRange().slice(0));
|
|
2994
|
+
fdtCopy.unmap();
|
|
2995
|
+
fdtCopy.destroy();
|
|
2996
|
+
const slotFor = (emit) => {
|
|
2997
|
+
// Match the VS preamble exactly: bound by firstDrawInTile.
|
|
2998
|
+
const tileIdx = emit >>> 6;
|
|
2999
|
+
let lo = fdt[tileIdx];
|
|
3000
|
+
let hi = fdt[tileIdx + 1];
|
|
3001
|
+
while (lo < hi) {
|
|
3002
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
3003
|
+
if (firstEmits[mid] <= emit)
|
|
3004
|
+
lo = mid;
|
|
3005
|
+
else
|
|
3006
|
+
hi = mid - 1;
|
|
3007
|
+
}
|
|
3008
|
+
return lo;
|
|
3009
|
+
};
|
|
3010
|
+
// Exhaustive — walk EVERY triangle. For each:
|
|
3011
|
+
// 1. Verify all 3 emits resolve to the same slot AND the
|
|
3012
|
+
// same instId (cross-RO or cross-instance triangle).
|
|
3013
|
+
// 2. Read drawHeader[drawIdx] for ModelTrafo + Positions.
|
|
3014
|
+
// 3. Look up the 3 vid values via indexStorage.
|
|
3015
|
+
// 4. Apply ModelTrafo to each vertex, verify world position
|
|
3016
|
+
// lies within a sane radius of the trafo's translation.
|
|
3017
|
+
let here = 0;
|
|
3018
|
+
for (let t = 0; t < totalTris; t++) {
|
|
3019
|
+
const e0 = t * 3;
|
|
3020
|
+
const e1 = e0 + 1;
|
|
3021
|
+
const e2 = e0 + 2;
|
|
3022
|
+
const s0 = slotFor(e0);
|
|
3023
|
+
const s1 = slotFor(e1);
|
|
3024
|
+
const s2 = slotFor(e2);
|
|
3025
|
+
here++;
|
|
3026
|
+
if (s0 !== s1 || s1 !== s2) {
|
|
3027
|
+
crossSlot++;
|
|
3028
|
+
push(`bucket#${bucketIdx} tri=${t} emits=(${e0},${e1},${e2}) slots=(${s0},${s1},${s2}) ` +
|
|
3029
|
+
`firstEmits[${s0}]=${firstEmits[s0]} [${s1}]=${firstEmits[s1]} [${s2}]=${firstEmits[s2]}`);
|
|
3030
|
+
continue;
|
|
3031
|
+
}
|
|
3032
|
+
// Paranoid re-derive: drawIdx + ModelTrafoRef per-emit. If
|
|
3033
|
+
// any of these read paths gives a different value across
|
|
3034
|
+
// the three emits of one triangle, the triangle is using
|
|
3035
|
+
// multiple model trafos.
|
|
3036
|
+
const drawIdxA = drawIdxs[s0];
|
|
3037
|
+
const drawIdxB = drawIdxs[s1];
|
|
3038
|
+
const drawIdxC = drawIdxs[s2];
|
|
3039
|
+
if (drawIdxA !== drawIdxB || drawIdxB !== drawIdxC) {
|
|
3040
|
+
crossSlot++;
|
|
3041
|
+
push(`bucket#${bucketIdx} tri=${t} drawIdx mismatch across emits: ${drawIdxA},${drawIdxB},${drawIdxC}`);
|
|
3042
|
+
continue;
|
|
3043
|
+
}
|
|
3044
|
+
if (mtOff >= 0) {
|
|
3045
|
+
const mtRefA = dhU32[(drawIdxA * stride2 + mtOff) >>> 2];
|
|
3046
|
+
const mtRefB = dhU32[(drawIdxB * stride2 + mtOff) >>> 2];
|
|
3047
|
+
const mtRefC = dhU32[(drawIdxC * stride2 + mtOff) >>> 2];
|
|
3048
|
+
if (mtRefA !== mtRefB || mtRefB !== mtRefC) {
|
|
3049
|
+
crossSlot++;
|
|
3050
|
+
push(`bucket#${bucketIdx} tri=${t} ModelTrafoRef mismatch: ${mtRefA.toString(16)},${mtRefB.toString(16)},${mtRefC.toString(16)}`);
|
|
3051
|
+
continue;
|
|
3052
|
+
}
|
|
3053
|
+
// Triple-read mat4 bytes and assert identical (in case
|
|
3054
|
+
// the per-vertex shader gets different bytes from
|
|
3055
|
+
// arena due to some aliasing/coherency anomaly the CPU
|
|
3056
|
+
// can model only as identical reads).
|
|
3057
|
+
if (mtRefA !== 0) {
|
|
3058
|
+
const mb = (mtRefA + ALLOC_HEADER_PAD_TO) >>> 2;
|
|
3059
|
+
for (let k = 0; k < 16; k++) {
|
|
3060
|
+
const a = arenaF32[mb + k];
|
|
3061
|
+
if (!Number.isFinite(a)) {
|
|
3062
|
+
crossSlot++;
|
|
3063
|
+
push(`bucket#${bucketIdx} tri=${t} ModelTrafo[${k}] non-finite=${a}`);
|
|
3064
|
+
break;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
const slot = s0;
|
|
3070
|
+
const indexCount = indexCounts[slot];
|
|
3071
|
+
const indexStart = indexStarts[slot];
|
|
3072
|
+
const drawIdx = drawIdxs[slot];
|
|
3073
|
+
const fe = firstEmits[slot];
|
|
3074
|
+
const localBase = e0 - fe;
|
|
3075
|
+
const inst0 = Math.floor(localBase / indexCount);
|
|
3076
|
+
const inst1 = Math.floor((localBase + 1) / indexCount);
|
|
3077
|
+
const inst2 = Math.floor((localBase + 2) / indexCount);
|
|
3078
|
+
if (inst0 !== inst1 || inst1 !== inst2) {
|
|
3079
|
+
crossSlot++;
|
|
3080
|
+
push(`bucket#${bucketIdx} tri=${t} slot=${slot} cross-instance: ` +
|
|
3081
|
+
`instIds=(${inst0},${inst1},${inst2}) localBase=${localBase} indexCount=${indexCount}`);
|
|
3082
|
+
continue;
|
|
3083
|
+
}
|
|
3084
|
+
if (mtOff < 0 || posOff < 0)
|
|
3085
|
+
continue; // bucket lacks one of the fields
|
|
3086
|
+
const mtRef = dhU32[(drawIdx * stride2 + mtOff) >>> 2];
|
|
3087
|
+
const posRef = dhU32[(drawIdx * stride2 + posOff) >>> 2];
|
|
3088
|
+
if (mtRef === 0 || posRef === 0)
|
|
3089
|
+
continue;
|
|
3090
|
+
const mBase = (mtRef + ALLOC_HEADER_PAD_TO) >>> 2;
|
|
3091
|
+
const posLen = arenaU32[(posRef >>> 2) + 1];
|
|
3092
|
+
const pBase = (posRef + ALLOC_HEADER_PAD_TO) >>> 2;
|
|
3093
|
+
// Aardvark M44d row-major: M[r][c] = arenaF32[mBase + r*4+c].
|
|
3094
|
+
// Translation column = (M[0][3], M[1][3], M[2][3]).
|
|
3095
|
+
const ox = arenaF32[mBase + 0 * 4 + 3];
|
|
3096
|
+
const oy = arenaF32[mBase + 1 * 4 + 3];
|
|
3097
|
+
const oz = arenaF32[mBase + 2 * 4 + 3];
|
|
3098
|
+
// Three vid lookups via indexStorage[indexStart + (local % indexCount)].
|
|
3099
|
+
const v0 = indicesU32[indexStart + (localBase % indexCount)] % posLen;
|
|
3100
|
+
const v1 = indicesU32[indexStart + ((localBase + 1) % indexCount)] % posLen;
|
|
3101
|
+
const v2 = indicesU32[indexStart + ((localBase + 2) % indexCount)] % posLen;
|
|
3102
|
+
let triBad = false;
|
|
3103
|
+
for (const v of [v0, v1, v2]) {
|
|
3104
|
+
const px = arenaF32[pBase + v * 3 + 0];
|
|
3105
|
+
const py = arenaF32[pBase + v * 3 + 1];
|
|
3106
|
+
const pz = arenaF32[pBase + v * 3 + 2];
|
|
3107
|
+
const wx = arenaF32[mBase + 0 * 4 + 0] * px + arenaF32[mBase + 0 * 4 + 1] * py + arenaF32[mBase + 0 * 4 + 2] * pz + arenaF32[mBase + 0 * 4 + 3];
|
|
3108
|
+
const wy = arenaF32[mBase + 1 * 4 + 0] * px + arenaF32[mBase + 1 * 4 + 1] * py + arenaF32[mBase + 1 * 4 + 2] * pz + arenaF32[mBase + 1 * 4 + 3];
|
|
3109
|
+
const wz = arenaF32[mBase + 2 * 4 + 0] * px + arenaF32[mBase + 2 * 4 + 1] * py + arenaF32[mBase + 2 * 4 + 2] * pz + arenaF32[mBase + 2 * 4 + 3];
|
|
3110
|
+
const dx = wx - ox, dy = wy - oy, dz = wz - oz;
|
|
3111
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
3112
|
+
if (!Number.isFinite(wx + wy + wz) || dist > 50) {
|
|
3113
|
+
if (!triBad) {
|
|
3114
|
+
push(`bucket#${bucketIdx} tri=${t} slot=${slot} drawIdx=${drawIdx} ` +
|
|
3115
|
+
`vid=${v} pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)}) ` +
|
|
3116
|
+
`world=(${wx.toFixed(2)},${wy.toFixed(2)},${wz.toFixed(2)}) ` +
|
|
3117
|
+
`origin=(${ox.toFixed(2)},${oy.toFixed(2)},${oz.toFixed(2)}) dist=${dist.toFixed(2)}`);
|
|
3118
|
+
}
|
|
3119
|
+
triBad = true;
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
if (triBad)
|
|
3123
|
+
crossSlot++;
|
|
3124
|
+
}
|
|
3125
|
+
trianglesChecked += here;
|
|
3126
|
+
dtCopy.unmap();
|
|
3127
|
+
dtCopy.destroy();
|
|
3128
|
+
dhCopy.unmap();
|
|
3129
|
+
dhCopy.destroy();
|
|
3130
|
+
bucketIdx++;
|
|
3131
|
+
}
|
|
3132
|
+
// ── Cross-bucket arena allocation overlap detection. ──────────
|
|
3133
|
+
// Walk every drawHeader of every bucket, collect (ref, size,
|
|
3134
|
+
// owner) for every uniform-ref + attribute-ref field. Sort by
|
|
3135
|
+
// ref. Verify: two consecutive entries with DIFFERENT ref do
|
|
3136
|
+
// NOT have overlapping byte ranges. Distinct refs overlapping
|
|
3137
|
+
// = the arena allocator handed the same bytes to two distinct
|
|
3138
|
+
// allocations → two ROs read each other's data → cross-RO
|
|
3139
|
+
// triangle. Bug is invisible to per-triangle checks because
|
|
3140
|
+
// each individual triangle reads "valid" bytes (just not its
|
|
3141
|
+
// own bytes).
|
|
3142
|
+
bucketIdx = 0;
|
|
3143
|
+
for (const target of buckets) {
|
|
3144
|
+
if (target.recordCount === 0) {
|
|
3145
|
+
bucketIdx++;
|
|
3146
|
+
continue;
|
|
3147
|
+
}
|
|
3148
|
+
// Re-stage drawHeap for this bucket.
|
|
3149
|
+
const dhBytes = target.recordCount * target.layout.drawHeaderBytes;
|
|
3150
|
+
const dhCopy = device.createBuffer({
|
|
3151
|
+
size: dhBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3152
|
+
});
|
|
3153
|
+
const enc1 = device.createCommandEncoder();
|
|
3154
|
+
enc1.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, dhBytes);
|
|
3155
|
+
device.queue.submit([enc1.finish()]);
|
|
3156
|
+
await dhCopy.mapAsync(GPUMapMode.READ);
|
|
3157
|
+
const dhU32 = new Uint32Array(dhCopy.getMappedRange());
|
|
3158
|
+
const stride2 = target.layout.drawHeaderBytes;
|
|
3159
|
+
for (let slot = 0; slot < target.recordCount; slot++) {
|
|
3160
|
+
for (const f of target.layout.drawHeaderFields) {
|
|
3161
|
+
if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
|
|
3162
|
+
continue;
|
|
3163
|
+
const ref = dhU32[(slot * stride2 + f.byteOffset) >>> 2];
|
|
3164
|
+
if (ref === undefined || ref === 0)
|
|
3165
|
+
continue;
|
|
3166
|
+
// Read alloc length (u32 at ref/4 + 1) → derive bytes.
|
|
3167
|
+
const len = arenaU32[(ref >>> 2) + 1];
|
|
3168
|
+
const stride = arenaU32[(ref >>> 2) + 2]; // stride bytes
|
|
3169
|
+
const dataBytes = stride > 0 ? stride * len : 64; // fallback for uniform-ref
|
|
3170
|
+
const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
|
|
3171
|
+
allocClaims.push({ ref, bytes: allocBytes, owner: `b${bucketIdx}.slot${slot}.${f.name}` });
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
dhCopy.unmap();
|
|
3175
|
+
dhCopy.destroy();
|
|
3176
|
+
bucketIdx++;
|
|
3177
|
+
}
|
|
3178
|
+
// Sort + walk for overlap. Group by ref first — same ref is OK
|
|
3179
|
+
// (legit dedup). Different refs whose ranges intersect = bug.
|
|
3180
|
+
allocClaims.sort((a, b) => a.ref - b.ref);
|
|
3181
|
+
let prevRef = -1, prevEnd = -1, prevOwner = "";
|
|
3182
|
+
for (const c of allocClaims) {
|
|
3183
|
+
if (c.ref === prevRef)
|
|
3184
|
+
continue; // shared (legit)
|
|
3185
|
+
if (prevEnd > c.ref) {
|
|
3186
|
+
crossSlot++;
|
|
3187
|
+
push(`arena overlap: ${prevOwner} [0x${prevRef.toString(16)}, 0x${prevEnd.toString(16)}) ` +
|
|
3188
|
+
`vs ${c.owner} [0x${c.ref.toString(16)}, 0x${(c.ref + c.bytes).toString(16)})`);
|
|
3189
|
+
}
|
|
3190
|
+
prevRef = c.ref;
|
|
3191
|
+
prevEnd = c.ref + c.bytes;
|
|
3192
|
+
prevOwner = c.owner;
|
|
3193
|
+
}
|
|
3194
|
+
arenaCopy.unmap();
|
|
3195
|
+
arenaCopy.destroy();
|
|
3196
|
+
if (indicesCopy !== undefined) {
|
|
3197
|
+
indicesCopy.unmap();
|
|
3198
|
+
indicesCopy.destroy();
|
|
3199
|
+
}
|
|
3200
|
+
void samples;
|
|
3201
|
+
return { trianglesChecked, crossSlot, issues };
|
|
3202
|
+
},
|
|
3203
|
+
/** GPU-side binary-search probe: dispatches a tiny compute kernel
|
|
3204
|
+
* that, for each sampled global emit-index, runs the SAME binary
|
|
3205
|
+
* search the render VS does (using firstDrawInTile + drawTable)
|
|
3206
|
+
* and writes the resulting slot into a debug buffer. Returns the
|
|
3207
|
+
* list of (emit, gpuSlot, cpuSlot) where the GPU disagrees with
|
|
3208
|
+
* the CPU. Cross-device comparison: if Chromium reports 0
|
|
3209
|
+
* disagreements but iOS reports any, we've localized the bug to
|
|
3210
|
+
* Apple's WGSL-lowered binary-search loop.
|
|
3211
|
+
*
|
|
3212
|
+
* Bucket selection: validates the FIRST non-empty bucket only
|
|
3213
|
+
* (most diagnostic, simpler kernel; expand later if needed). */
|
|
3214
|
+
async probeBinarySearch(samples = 50_000) {
|
|
3215
|
+
const issues = [];
|
|
3216
|
+
let gpuMismatches = 0;
|
|
3217
|
+
const push = (s) => { if (issues.length < 30)
|
|
3218
|
+
issues.push(s); };
|
|
3219
|
+
const target = buckets.find(b => b.recordCount > 0 && b.totalEmitEstimate > 0);
|
|
3220
|
+
if (target === undefined)
|
|
3221
|
+
return { emitsChecked: 0, gpuMismatches: 0, issues };
|
|
3222
|
+
const recordCount = target.recordCount;
|
|
3223
|
+
const totalEmit = target.totalEmitEstimate;
|
|
3224
|
+
const sampleCount = Math.min(samples, totalEmit);
|
|
3225
|
+
const stride = Math.max(1, Math.floor(totalEmit / sampleCount));
|
|
3226
|
+
// CPU's firstEmit is GPU-computed — must read it back from the
|
|
3227
|
+
// drawTable GPU buffer (the CPU shadow's firstEmit field is
|
|
3228
|
+
// always 0, written by the scan kernel only). Otherwise the
|
|
3229
|
+
// CPU binary search would compare against zeros.
|
|
3230
|
+
const dtCopy = device.createBuffer({
|
|
3231
|
+
size: recordCount * RECORD_BYTES,
|
|
3232
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3233
|
+
});
|
|
3234
|
+
{
|
|
3235
|
+
const enc0 = device.createCommandEncoder({ label: "probeBinarySearch.dtCopy" });
|
|
3236
|
+
enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
|
|
3237
|
+
device.queue.submit([enc0.finish()]);
|
|
3238
|
+
await dtCopy.mapAsync(GPUMapMode.READ);
|
|
3239
|
+
}
|
|
3240
|
+
// Snapshot ALL drawTable u32s into a regular array — we'll need
|
|
3241
|
+
// them after unmap to compute the per-emit CPU expectation.
|
|
3242
|
+
const dtSnapshot = new Uint32Array(dtCopy.getMappedRange().slice(0));
|
|
3243
|
+
const cpuFirstEmit = [];
|
|
3244
|
+
for (let r = 0; r < recordCount; r++)
|
|
3245
|
+
cpuFirstEmit.push(dtSnapshot[r * RECORD_U32 + 0]);
|
|
3246
|
+
dtCopy.unmap();
|
|
3247
|
+
dtCopy.destroy();
|
|
3248
|
+
// Build sample list of emit indices on CPU.
|
|
3249
|
+
const sampleIdxArr = new Uint32Array(sampleCount);
|
|
3250
|
+
for (let i = 0; i < sampleCount; i++)
|
|
3251
|
+
sampleIdxArr[i] = Math.min(i * stride, totalEmit - 1);
|
|
3252
|
+
// CPU expected slots.
|
|
3253
|
+
const cpuSlots = new Uint32Array(sampleCount);
|
|
3254
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
3255
|
+
const emit = sampleIdxArr[i];
|
|
3256
|
+
let lo = 0, hi = recordCount - 1;
|
|
3257
|
+
while (lo < hi) {
|
|
3258
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
3259
|
+
if (cpuFirstEmit[mid] <= emit)
|
|
3260
|
+
lo = mid;
|
|
3261
|
+
else
|
|
3262
|
+
hi = mid - 1;
|
|
3263
|
+
}
|
|
3264
|
+
cpuSlots[i] = lo;
|
|
3265
|
+
}
|
|
3266
|
+
// GPU kernel — mirrors the render VS preamble's search exactly.
|
|
3267
|
+
// Writes (slot, drawIdx, indexStart, indexCount, instanceCount,
|
|
3268
|
+
// _local, instId, vid) per emit so we can verify EVERY field
|
|
3269
|
+
// the VS computes, not just the slot. 8 u32 per emit.
|
|
3270
|
+
const wgsl = /* wgsl */ `
|
|
3271
|
+
@group(0) @binding(0) var<storage, read> drawTable: array<u32>;
|
|
3272
|
+
@group(0) @binding(1) var<storage, read> firstDrawInTile: array<u32>;
|
|
3273
|
+
@group(0) @binding(2) var<storage, read> sampleEmits: array<u32>;
|
|
3274
|
+
@group(0) @binding(3) var<storage, read> indexStorage: array<u32>;
|
|
3275
|
+
@group(0) @binding(4) var<storage, read_write> outRows: array<u32>;
|
|
3276
|
+
@group(0) @binding(5) var<uniform> P: vec4<u32>;
|
|
3277
|
+
|
|
3278
|
+
@compute @workgroup_size(64)
|
|
3279
|
+
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
3280
|
+
let i = gid.x;
|
|
3281
|
+
if (i >= P.x) { return; }
|
|
3282
|
+
let emitIdx = sampleEmits[i];
|
|
3283
|
+
let _tileIdx = emitIdx >> 6u;
|
|
3284
|
+
var lo: u32 = firstDrawInTile[_tileIdx];
|
|
3285
|
+
var hi: u32 = firstDrawInTile[_tileIdx + 1u];
|
|
3286
|
+
loop {
|
|
3287
|
+
if (lo >= hi) { break; }
|
|
3288
|
+
let _mid = (lo + hi + 1u) >> 1u;
|
|
3289
|
+
if (drawTable[_mid * 5u] <= emitIdx) { lo = _mid; } else { hi = _mid - 1u; }
|
|
3290
|
+
}
|
|
3291
|
+
let _slot = lo;
|
|
3292
|
+
let _firstEmit = drawTable[_slot * 5u + 0u];
|
|
3293
|
+
let _drawIdx = drawTable[_slot * 5u + 1u];
|
|
3294
|
+
let _indexStart = drawTable[_slot * 5u + 2u];
|
|
3295
|
+
let _indexCount = drawTable[_slot * 5u + 3u];
|
|
3296
|
+
let _instCount = drawTable[_slot * 5u + 4u];
|
|
3297
|
+
let _local = emitIdx - _firstEmit;
|
|
3298
|
+
let _instId = _local / _indexCount;
|
|
3299
|
+
let _vid = indexStorage[_indexStart + (_local % _indexCount)];
|
|
3300
|
+
let base = i * 8u;
|
|
3301
|
+
outRows[base + 0u] = _slot;
|
|
3302
|
+
outRows[base + 1u] = _drawIdx;
|
|
3303
|
+
outRows[base + 2u] = _indexStart;
|
|
3304
|
+
outRows[base + 3u] = _indexCount;
|
|
3305
|
+
outRows[base + 4u] = _instCount;
|
|
3306
|
+
outRows[base + 5u] = _local;
|
|
3307
|
+
outRows[base + 6u] = _instId;
|
|
3308
|
+
outRows[base + 7u] = _vid;
|
|
3309
|
+
}
|
|
3310
|
+
`;
|
|
3311
|
+
const module = device.createShaderModule({ code: wgsl });
|
|
3312
|
+
const bgl = device.createBindGroupLayout({
|
|
3313
|
+
entries: [
|
|
3314
|
+
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
|
|
3315
|
+
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
|
|
3316
|
+
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
|
|
3317
|
+
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
|
|
3318
|
+
{ binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
3319
|
+
{ binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
|
|
3320
|
+
],
|
|
3321
|
+
});
|
|
3322
|
+
const pipeline = device.createComputePipeline({
|
|
3323
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [bgl] }),
|
|
3324
|
+
compute: { module, entryPoint: "main" },
|
|
3325
|
+
});
|
|
3326
|
+
const sampleBuf = device.createBuffer({
|
|
3327
|
+
size: sampleCount * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
3328
|
+
});
|
|
3329
|
+
device.queue.writeBuffer(sampleBuf, 0, sampleIdxArr.buffer, sampleIdxArr.byteOffset, sampleIdxArr.byteLength);
|
|
3330
|
+
const outBuf = device.createBuffer({
|
|
3331
|
+
size: sampleCount * 32, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
|
3332
|
+
});
|
|
3333
|
+
const paramBuf = device.createBuffer({
|
|
3334
|
+
size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
3335
|
+
});
|
|
3336
|
+
device.queue.writeBuffer(paramBuf, 0, new Uint32Array([sampleCount, 0, 0, 0]));
|
|
3337
|
+
const bg = device.createBindGroup({
|
|
3338
|
+
layout: bgl,
|
|
3339
|
+
entries: [
|
|
3340
|
+
{ binding: 0, resource: { buffer: target.drawTableBuf.buffer } },
|
|
3341
|
+
{ binding: 1, resource: { buffer: target.firstDrawInTileBuf.buffer } },
|
|
3342
|
+
{ binding: 2, resource: { buffer: sampleBuf } },
|
|
3343
|
+
{ binding: 3, resource: { buffer: arena.indices.buffer } },
|
|
3344
|
+
{ binding: 4, resource: { buffer: outBuf } },
|
|
3345
|
+
{ binding: 5, resource: { buffer: paramBuf } },
|
|
3346
|
+
],
|
|
3347
|
+
});
|
|
3348
|
+
const enc = device.createCommandEncoder({ label: "probeBinarySearch" });
|
|
3349
|
+
const pass = enc.beginComputePass();
|
|
3350
|
+
pass.setPipeline(pipeline);
|
|
3351
|
+
pass.setBindGroup(0, bg);
|
|
3352
|
+
pass.dispatchWorkgroups(Math.ceil(sampleCount / 64));
|
|
3353
|
+
pass.end();
|
|
3354
|
+
const readback = device.createBuffer({
|
|
3355
|
+
size: sampleCount * 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3356
|
+
});
|
|
3357
|
+
enc.copyBufferToBuffer(outBuf, 0, readback, 0, sampleCount * 32);
|
|
3358
|
+
device.queue.submit([enc.finish()]);
|
|
3359
|
+
await readback.mapAsync(GPUMapMode.READ);
|
|
3360
|
+
const gpuRows = new Uint32Array(readback.getMappedRange().slice(0));
|
|
3361
|
+
readback.unmap();
|
|
3362
|
+
readback.destroy();
|
|
3363
|
+
sampleBuf.destroy();
|
|
3364
|
+
outBuf.destroy();
|
|
3365
|
+
paramBuf.destroy();
|
|
3366
|
+
// Read drawTable + indices for CPU expectation.
|
|
3367
|
+
const indicesCopy2 = device.createBuffer({
|
|
3368
|
+
size: arena.indices.buffer.size,
|
|
3369
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3370
|
+
});
|
|
3371
|
+
const enc2 = device.createCommandEncoder();
|
|
3372
|
+
enc2.copyBufferToBuffer(arena.indices.buffer, 0, indicesCopy2, 0, arena.indices.buffer.size);
|
|
3373
|
+
device.queue.submit([enc2.finish()]);
|
|
3374
|
+
await indicesCopy2.mapAsync(GPUMapMode.READ);
|
|
3375
|
+
const indicesU32 = new Uint32Array(indicesCopy2.getMappedRange().slice(0));
|
|
3376
|
+
indicesCopy2.unmap();
|
|
3377
|
+
indicesCopy2.destroy();
|
|
3378
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
3379
|
+
const emit = sampleIdxArr[i];
|
|
3380
|
+
const cpuSlot = cpuSlots[i];
|
|
3381
|
+
const cpuFirstEmit_ = dtSnapshot[cpuSlot * RECORD_U32 + 0];
|
|
3382
|
+
const cpuDrawIdx = dtSnapshot[cpuSlot * RECORD_U32 + 1];
|
|
3383
|
+
const cpuIndexStart = dtSnapshot[cpuSlot * RECORD_U32 + 2];
|
|
3384
|
+
const cpuIndexCount = dtSnapshot[cpuSlot * RECORD_U32 + 3];
|
|
3385
|
+
const cpuInstCount = dtSnapshot[cpuSlot * RECORD_U32 + 4];
|
|
3386
|
+
const cpuLocal = emit - cpuFirstEmit_;
|
|
3387
|
+
const cpuInstId = Math.floor(cpuLocal / cpuIndexCount);
|
|
3388
|
+
const cpuVid = indicesU32[cpuIndexStart + (cpuLocal % cpuIndexCount)];
|
|
3389
|
+
const base = i * 8;
|
|
3390
|
+
const got = {
|
|
3391
|
+
slot: gpuRows[base + 0], drawIdx: gpuRows[base + 1],
|
|
3392
|
+
indexStart: gpuRows[base + 2], indexCount: gpuRows[base + 3],
|
|
3393
|
+
instCount: gpuRows[base + 4], local: gpuRows[base + 5],
|
|
3394
|
+
instId: gpuRows[base + 6], vid: gpuRows[base + 7],
|
|
3395
|
+
};
|
|
3396
|
+
const exp = {
|
|
3397
|
+
slot: cpuSlot, drawIdx: cpuDrawIdx,
|
|
3398
|
+
indexStart: cpuIndexStart, indexCount: cpuIndexCount,
|
|
3399
|
+
instCount: cpuInstCount, local: cpuLocal,
|
|
3400
|
+
instId: cpuInstId, vid: cpuVid,
|
|
3401
|
+
};
|
|
3402
|
+
if (got.slot !== exp.slot ||
|
|
3403
|
+
got.drawIdx !== exp.drawIdx ||
|
|
3404
|
+
got.indexStart !== exp.indexStart ||
|
|
3405
|
+
got.indexCount !== exp.indexCount ||
|
|
3406
|
+
got.instCount !== exp.instCount ||
|
|
3407
|
+
got.local !== exp.local ||
|
|
3408
|
+
got.instId !== exp.instId ||
|
|
3409
|
+
got.vid !== exp.vid) {
|
|
3410
|
+
gpuMismatches++;
|
|
3411
|
+
push(`emit=${emit} GPU vs CPU: ` +
|
|
3412
|
+
`slot=${got.slot}/${exp.slot} drawIdx=${got.drawIdx}/${exp.drawIdx} ` +
|
|
3413
|
+
`idxStart=${got.indexStart}/${exp.indexStart} idxCount=${got.indexCount}/${exp.indexCount} ` +
|
|
3414
|
+
`instCount=${got.instCount}/${exp.instCount} local=${got.local}/${exp.local} ` +
|
|
3415
|
+
`instId=${got.instId}/${exp.instId} vid=${got.vid}/${exp.vid}`);
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
return { emitsChecked: sampleCount, gpuMismatches, issues };
|
|
3419
|
+
},
|
|
3420
|
+
};
|
|
3421
|
+
return { frame, update, encodeIntoPass, encodeComputePrep, addDraw, removeDraw, stats, dispose, _debug };
|
|
353
3422
|
}
|
|
354
3423
|
//# sourceMappingURL=heapScene.js.map
|