@aardworx/wombat.rendering 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/core/command.d.ts +7 -9
  2. package/dist/core/command.d.ts.map +1 -1
  3. package/dist/core/renderTask.d.ts +64 -1
  4. package/dist/core/renderTask.d.ts.map +1 -1
  5. package/dist/core/renderTask.js +3 -1
  6. package/dist/core/renderTask.js.map +1 -1
  7. package/dist/resources/preparedRenderObject.js +3 -3
  8. package/dist/resources/preparedRenderObject.js.map +1 -1
  9. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  10. package/dist/resources/uniformBuffer.js +17 -0
  11. package/dist/resources/uniformBuffer.js.map +1 -1
  12. package/dist/runtime/derivedUniforms/dispatch.d.ts +105 -0
  13. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -0
  14. package/dist/runtime/derivedUniforms/dispatch.js +355 -0
  15. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -0
  16. package/dist/runtime/derivedUniforms/index.d.ts +6 -0
  17. package/dist/runtime/derivedUniforms/index.d.ts.map +1 -0
  18. package/dist/runtime/derivedUniforms/index.js +26 -0
  19. package/dist/runtime/derivedUniforms/index.js.map +1 -0
  20. package/dist/runtime/derivedUniforms/recipes.d.ts +42 -0
  21. package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -0
  22. package/dist/runtime/derivedUniforms/recipes.js +134 -0
  23. package/dist/runtime/derivedUniforms/recipes.js.map +1 -0
  24. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +56 -0
  25. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -0
  26. package/dist/runtime/derivedUniforms/sceneIntegration.js +171 -0
  27. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -0
  28. package/dist/runtime/derivedUniforms/slotId.d.ts +15 -0
  29. package/dist/runtime/derivedUniforms/slotId.d.ts.map +1 -0
  30. package/dist/runtime/derivedUniforms/slotId.js +30 -0
  31. package/dist/runtime/derivedUniforms/slotId.js.map +1 -0
  32. package/dist/runtime/derivedUniforms/slots.d.ts +49 -0
  33. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -0
  34. package/dist/runtime/derivedUniforms/slots.js +123 -0
  35. package/dist/runtime/derivedUniforms/slots.js.map +1 -0
  36. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +13 -0
  37. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +1 -0
  38. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +218 -0
  39. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +1 -0
  40. package/dist/runtime/flattenTree.d.ts +5 -0
  41. package/dist/runtime/flattenTree.d.ts.map +1 -0
  42. package/dist/runtime/flattenTree.js +45 -0
  43. package/dist/runtime/flattenTree.js.map +1 -0
  44. package/dist/runtime/heapAdapter.d.ts +16 -0
  45. package/dist/runtime/heapAdapter.d.ts.map +1 -0
  46. package/dist/runtime/heapAdapter.js +263 -0
  47. package/dist/runtime/heapAdapter.js.map +1 -0
  48. package/dist/runtime/heapEffect.d.ts +235 -0
  49. package/dist/runtime/heapEffect.d.ts.map +1 -0
  50. package/dist/runtime/heapEffect.js +518 -0
  51. package/dist/runtime/heapEffect.js.map +1 -0
  52. package/dist/runtime/heapEffectIR.d.ts +34 -0
  53. package/dist/runtime/heapEffectIR.d.ts.map +1 -0
  54. package/dist/runtime/heapEffectIR.js +1161 -0
  55. package/dist/runtime/heapEffectIR.js.map +1 -0
  56. package/dist/runtime/heapEligibility.d.ts +9 -0
  57. package/dist/runtime/heapEligibility.d.ts.map +1 -0
  58. package/dist/runtime/heapEligibility.js +195 -0
  59. package/dist/runtime/heapEligibility.js.map +1 -0
  60. package/dist/runtime/heapScene.d.ts +240 -31
  61. package/dist/runtime/heapScene.d.ts.map +1 -1
  62. package/dist/runtime/heapScene.js +3334 -265
  63. package/dist/runtime/heapScene.js.map +1 -1
  64. package/dist/runtime/heapShaderFamily.d.ts +132 -0
  65. package/dist/runtime/heapShaderFamily.d.ts.map +1 -0
  66. package/dist/runtime/heapShaderFamily.js +866 -0
  67. package/dist/runtime/heapShaderFamily.js.map +1 -0
  68. package/dist/runtime/hybridScene.d.ts +133 -0
  69. package/dist/runtime/hybridScene.d.ts.map +1 -0
  70. package/dist/runtime/hybridScene.js +159 -0
  71. package/dist/runtime/hybridScene.js.map +1 -0
  72. package/dist/runtime/index.d.ts +8 -0
  73. package/dist/runtime/index.d.ts.map +1 -1
  74. package/dist/runtime/index.js +9 -0
  75. package/dist/runtime/index.js.map +1 -1
  76. package/dist/runtime/renderTask.d.ts +21 -4
  77. package/dist/runtime/renderTask.d.ts.map +1 -1
  78. package/dist/runtime/renderTask.js +127 -32
  79. package/dist/runtime/renderTask.js.map +1 -1
  80. package/dist/runtime/runtime.d.ts +33 -3
  81. package/dist/runtime/runtime.d.ts.map +1 -1
  82. package/dist/runtime/runtime.js +12 -3
  83. package/dist/runtime/runtime.js.map +1 -1
  84. package/dist/runtime/scenePass.d.ts +28 -3
  85. package/dist/runtime/scenePass.d.ts.map +1 -1
  86. package/dist/runtime/scenePass.js +37 -4
  87. package/dist/runtime/scenePass.js.map +1 -1
  88. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +35 -0
  89. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -0
  90. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +247 -0
  91. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -0
  92. package/dist/runtime/textureAtlas/atlasPool.d.ts +240 -0
  93. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -0
  94. package/dist/runtime/textureAtlas/atlasPool.js +798 -0
  95. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -0
  96. package/dist/runtime/textureAtlas/bvhTree2d.d.ts +33 -0
  97. package/dist/runtime/textureAtlas/bvhTree2d.d.ts.map +1 -0
  98. package/dist/runtime/textureAtlas/bvhTree2d.js +501 -0
  99. package/dist/runtime/textureAtlas/bvhTree2d.js.map +1 -0
  100. package/dist/runtime/textureAtlas/packer.d.ts +50 -0
  101. package/dist/runtime/textureAtlas/packer.d.ts.map +1 -0
  102. package/dist/runtime/textureAtlas/packer.js +390 -0
  103. package/dist/runtime/textureAtlas/packer.js.map +1 -0
  104. package/dist/window/loop.d.ts +14 -0
  105. package/dist/window/loop.d.ts.map +1 -1
  106. package/dist/window/loop.js +18 -5
  107. package/dist/window/loop.js.map +1 -1
  108. package/package.json +5 -4
  109. package/src/core/command.ts +9 -9
  110. package/src/core/renderTask.ts +55 -2
  111. package/src/resources/preparedRenderObject.ts +3 -3
  112. package/src/resources/uniformBuffer.ts +15 -0
  113. package/src/runtime/derivedUniforms/dispatch.ts +407 -0
  114. package/src/runtime/derivedUniforms/index.ts +52 -0
  115. package/src/runtime/derivedUniforms/recipes.ts +159 -0
  116. package/src/runtime/derivedUniforms/sceneIntegration.ts +239 -0
  117. package/src/runtime/derivedUniforms/slotId.ts +35 -0
  118. package/src/runtime/derivedUniforms/slots.ts +155 -0
  119. package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +220 -0
  120. package/src/runtime/flattenTree.ts +46 -0
  121. package/src/runtime/heapAdapter.ts +314 -0
  122. package/src/runtime/heapEffect.ts +719 -0
  123. package/src/runtime/heapEffectIR.ts +1211 -0
  124. package/src/runtime/heapEligibility.ts +189 -0
  125. package/src/runtime/heapScene.ts +3981 -349
  126. package/src/runtime/heapShaderFamily.ts +1102 -0
  127. package/src/runtime/hybridScene.ts +310 -0
  128. package/src/runtime/index.ts +46 -0
  129. package/src/runtime/renderTask.ts +167 -32
  130. package/src/runtime/runtime.ts +37 -4
  131. package/src/runtime/scenePass.ts +39 -4
  132. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +291 -0
  133. package/src/runtime/textureAtlas/atlasPool.ts +991 -0
  134. package/src/runtime/textureAtlas/bvhTree2d.ts +530 -0
  135. package/src/runtime/textureAtlas/packer.ts +429 -0
  136. package/src/window/loop.ts +32 -6
@@ -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, addMarkingCallback } from "@aardworx/wombat.adaptive";
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
- // Layouts
53
+ // Per-allocation arena layout
48
54
  // ---------------------------------------------------------------------------
49
- const GLOBALS_BYTES = 64 + 16; // mat4 + vec4
50
- const GLOBALS_FLOATS = GLOBALS_BYTES / 4;
51
- const DRAW_HEADER_BYTES = 64 * 2 + 16 + 16; // 2 mat4 + vec4 + (u32 + 3*pad)
52
- const DRAW_HEADER_FLOATS = DRAW_HEADER_BYTES / 4;
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
- function packGlobals(viewProj, lightLocation, dst) {
62
- packMat44(viewProj.forward, dst, 0);
63
- dst[16] = lightLocation.x;
64
- dst[17] = lightLocation.y;
65
- dst[18] = lightLocation.z;
66
- dst[19] = 0;
67
- }
68
- function packDrawHeader(modelTrafo, color, vertexBase, dst, drawIndex) {
69
- const off = drawIndex * DRAW_HEADER_FLOATS;
70
- packMat44(modelTrafo.forward, dst, off + 0);
71
- packMat44(modelTrafo.backward, dst, off + 16);
72
- dst[off + 32] = color.x;
73
- dst[off + 33] = color.y;
74
- dst[off + 34] = color.z;
75
- dst[off + 35] = color.w;
76
- new Uint32Array(dst.buffer, dst.byteOffset, dst.length)[off + 36] = vertexBase;
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
- // Shared WGSL prelude (struct + bindings + VS)
130
+ // Resizable buffer (pow2 grow + GPU-side copy on resize)
80
131
  // ---------------------------------------------------------------------------
81
- const SHADER_PRELUDE = /* wgsl */ `
82
- struct Globals {
83
- viewProj: mat4x4<f32>,
84
- lightLocation: vec4<f32>,
85
- };
86
- struct DrawHeader {
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
- @group(0) @binding(0) var<uniform> globals: Globals;
94
- @group(0) @binding(1) var<storage, read> draws: array<DrawHeader>;
95
- @group(0) @binding(2) var<storage, read> positions: array<f32>;
96
- @group(0) @binding(3) var<storage, read> normals: array<f32>;
97
-
98
- struct VsOut {
99
- @builtin(position) clipPos: vec4<f32>,
100
- @location(0) worldPos: vec3<f32>,
101
- @location(1) normal: vec3<f32>,
102
- @location(2) color: vec4<f32>,
103
- @location(3) lightLoc: vec3<f32>,
104
- };
105
-
106
- fn fetchVec3(buf: ptr<storage, array<f32>, read>, base: u32) -> vec3<f32> {
107
- return vec3<f32>((*buf)[base + 0u], (*buf)[base + 1u], (*buf)[base + 2u]);
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
- @vertex
111
- fn vs(@builtin(vertex_index) vid: u32, @builtin(instance_index) drawIdx: u32) -> VsOut {
112
- let d = draws[drawIdx];
113
- let base = (d.vertexBase + vid) * 3u;
114
- let pos = fetchVec3(&positions, base);
115
- let nor = fetchVec3(&normals, base);
116
- // wombat.shader convention: matrices uploaded row-major; WGSL reads
117
- // them as col-major = transposed. v * M is the row-vec dual of
118
- // M.mul(v) (= M*v math).
119
- let wp = vec4<f32>(pos, 1.0) * d.modelTrafo;
120
- let n = (vec4<f32>(nor, 0.0) * d.modelTrafo).xyz;
121
- var out: VsOut;
122
- out.clipPos = wp * globals.viewProj;
123
- out.worldPos = wp.xyz;
124
- out.normal = n;
125
- out.color = d.color;
126
- out.lightLoc = globals.lightLocation.xyz;
127
- return out;
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
- function packGeometry(device, geos, label) {
131
- let totalVerts = 0, totalIndices = 0;
132
- for (const g of geos) {
133
- totalVerts += g.positions.length / 3;
134
- totalIndices += g.indices.length;
135
- }
136
- const positions = new Float32Array(totalVerts * 3);
137
- const normals = new Float32Array(totalVerts * 3);
138
- const indices = new Uint32Array(totalIndices);
139
- const entries = [];
140
- let vOff = 0, iOff = 0;
141
- for (const g of geos) {
142
- const vCount = g.positions.length / 3;
143
- positions.set(g.positions, vOff * 3);
144
- normals.set(g.normals, vOff * 3);
145
- indices.set(g.indices, iOff);
146
- entries.push({ vertexBase: vOff, indexCount: g.indices.length, firstIndex: iOff });
147
- vOff += vCount;
148
- iOff += g.indices.length;
149
- }
150
- const mk = (data, usage, lbl) => {
151
- const buf = device.createBuffer({
152
- size: alignUp(data.byteLength, 4),
153
- usage: usage | GPUBufferUsage.COPY_DST,
154
- label: lbl,
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
- device.queue.writeBuffer(buf, 0, data.buffer, data.byteOffset, data.byteLength);
157
- return buf;
158
- };
159
- return {
160
- positionsBuf: mk(positions, GPUBufferUsage.STORAGE, `${label}/pos`),
161
- normalsBuf: mk(normals, GPUBufferUsage.STORAGE, `${label}/nor`),
162
- indexBuf: mk(indices, GPUBufferUsage.INDEX, `${label}/idx`),
163
- entries,
164
- bytes: positions.byteLength + normals.byteLength + indices.byteLength,
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
- function buildGroup(device, attach, key, fragmentWgsl, textureSet, draws, modelTrafos, colors) {
174
- const sig = attach.signature;
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
- const memberIndices = draws.map(d => d.sourceIndex);
182
- const myTrafos = memberIndices.map(i => modelTrafos[i]);
183
- const myColors = memberIndices.map(i => colors[i]);
184
- const packed = packGeometry(device, draws.map(d => d.spec.geo), `heapScene/${key}`);
185
- const globals = device.createBuffer({
186
- size: alignUp(GLOBALS_BYTES, 16),
187
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
188
- label: `heapScene/${key}/globals`,
189
- });
190
- const globalsStaging = new Float32Array(GLOBALS_FLOATS);
191
- const heapBytes = DRAW_HEADER_BYTES * draws.length;
192
- const drawHeap = device.createBuffer({
193
- size: alignUp(heapBytes, 16),
194
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
195
- label: `heapScene/${key}/draws`,
196
- });
197
- const drawStaging = new Float32Array(DRAW_HEADER_FLOATS * draws.length);
198
- for (let i = 0; i < draws.length; i++) {
199
- packDrawHeader(myTrafos[i].force( /* allow-force */), myColors[i].force( /* allow-force */), packed.entries[i].vertexBase, drawStaging, i);
200
- }
201
- device.queue.writeBuffer(drawHeap, 0, drawStaging.buffer, drawStaging.byteOffset, heapBytes);
202
- const dirty = new Set();
203
- const subs = [];
204
- for (let i = 0; i < draws.length; i++) {
205
- subs.push(addMarkingCallback(myTrafos[i], () => dirty.add(i)));
206
- subs.push(addMarkingCallback(myColors[i], () => dirty.add(i)));
207
- }
208
- const module = device.createShaderModule({
209
- code: SHADER_PRELUDE + fragmentWgsl,
210
- label: `heapScene/${key}/shader`,
211
- });
212
- const bindLayoutEntries = [
213
- { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
214
- { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
215
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
216
- { binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
217
- ];
218
- if (textureSet !== undefined) {
219
- bindLayoutEntries.push({ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }, { binding: 5, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } });
220
- }
221
- const bindGroupLayout = device.createBindGroupLayout({
222
- label: `heapScene/${key}/bgl`,
223
- entries: bindLayoutEntries,
224
- });
225
- const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
226
- const pipeline = device.createRenderPipeline({
227
- label: `heapScene/${key}/pipeline`,
228
- layout: pipelineLayout,
229
- vertex: { module, entryPoint: "vs", buffers: [] },
230
- fragment: { module, entryPoint: "fs", targets: [{ format: colorFormat }] },
231
- primitive: { topology: "triangle-list", cullMode: "back", frontFace: "ccw" },
232
- ...(depthFormat !== undefined
233
- ? { depthStencil: { format: depthFormat, depthWriteEnabled: true, depthCompare: "less" } }
234
- : {}),
235
- });
236
- const bindEntries = [
237
- { binding: 0, resource: { buffer: globals } },
238
- { binding: 1, resource: { buffer: drawHeap } },
239
- { binding: 2, resource: { buffer: packed.positionsBuf } },
240
- { binding: 3, resource: { buffer: packed.normalsBuf } },
241
- ];
242
- if (textureSet !== undefined) {
243
- bindEntries.push({ binding: 4, resource: textureSet.textureView ?? textureSet.texture.createView() }, { binding: 5, resource: textureSet.sampler });
244
- }
245
- const bindGroup = device.createBindGroup({
246
- label: `heapScene/${key}/bg`,
247
- layout: bindGroupLayout,
248
- entries: bindEntries,
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
- return {
251
- key, pipeline, bindGroup, globals, globalsStaging, drawHeap, drawStaging,
252
- packed, trafos: myTrafos, colors: myColors, dirty, subs,
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
- * Build a multi-group heap-backed scene renderer. Buckets `draws` by
257
- * `groupKey`, builds one pipeline + bind group + heap per bucket,
258
- * subscribes per-RO avals so dirty marks propagate to per-slot
259
- * `writeBuffer`s on the next `frame()`.
260
- */
261
- export function buildHeapScene(device, attach, draws, opts) {
262
- const modelTrafos = draws.map(d => asAval(d.modelTrafo));
263
- const colors = draws.map(d => asAval(d.color));
264
- // Bucket by group key.
265
- const buckets = new Map();
266
- for (let i = 0; i < draws.length; i++) {
267
- const d = draws[i];
268
- let bucket = buckets.get(d.groupKey);
269
- if (bucket === undefined) {
270
- bucket = [];
271
- buckets.set(d.groupKey, bucket);
272
- }
273
- bucket.push({ spec: d, sourceIndex: i });
274
- }
275
- const groups = [];
276
- for (const [key, members] of buckets) {
277
- const fs = opts.fragmentShaders.get(key);
278
- if (fs === undefined) {
279
- throw new Error(`buildHeapScene: no fragment shader supplied for groupKey '${key}'`);
280
- }
281
- const textureSet = opts.texturesByGroupKey?.get(key);
282
- groups.push(buildGroup(device, attach, key, fs, textureSet, members, modelTrafos, colors));
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: groups.length,
286
- totalDraws: draws.length,
287
- globalsBytes: GLOBALS_BYTES * groups.length,
1656
+ groups: 0,
1657
+ totalDraws: 0,
288
1658
  drawBytes: 0,
289
- geometryBytes: groups.reduce((acc, g) => acc + g.packed.bytes, 0),
1659
+ geometryBytes: 0,
1660
+ derivedPullMs: 0,
1661
+ derivedUploadMs: 0,
1662
+ derivedEncodeMs: 0,
1663
+ derivedRecords: 0,
290
1664
  };
291
- function frame(viewProj, lightLocation) {
292
- const fb = attach.framebuffer.force( /* allow-force */);
293
- const colorAttachmentName = attach.signature.colorNames[0];
294
- const colorView = fb.colors.tryFind(colorAttachmentName);
295
- const depthFormat = attach.signature.depthStencil?.format;
296
- const depthView = fb.depthStencil;
297
- let totalDirtyBytes = 0;
298
- const enc = device.createCommandEncoder({ label: "heapScene: encoder" });
299
- let firstPass = true;
300
- for (const g of groups) {
301
- packGlobals(viewProj, lightLocation, g.globalsStaging);
302
- device.queue.writeBuffer(g.globals, 0, g.globalsStaging.buffer, g.globalsStaging.byteOffset, GLOBALS_BYTES);
303
- if (g.dirty.size > 0) {
304
- for (const i of g.dirty) {
305
- packDrawHeader(g.trafos[i].force( /* allow-force */), g.colors[i].force( /* allow-force */), g.packed.entries[i].vertexBase, g.drawStaging, i);
306
- const byteOff = i * DRAW_HEADER_BYTES;
307
- device.queue.writeBuffer(g.drawHeap, byteOff, g.drawStaging.buffer, g.drawStaging.byteOffset + byteOff, DRAW_HEADER_BYTES);
308
- totalDirtyBytes += DRAW_HEADER_BYTES;
309
- }
310
- g.dirty.clear();
311
- }
312
- const pass = enc.beginRenderPass({
313
- colorAttachments: [{
314
- view: colorView,
315
- clearValue: { r: 0.07, g: 0.07, b: 0.08, a: 1.0 },
316
- loadOp: firstPass ? "clear" : "load",
317
- storeOp: "store",
318
- }],
319
- ...(depthView !== undefined && depthFormat !== undefined ? {
320
- depthStencilAttachment: {
321
- view: depthView,
322
- depthClearValue: 1.0,
323
- depthLoadOp: firstPass ? "clear" : "load",
324
- depthStoreOp: "store",
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
- pass.setPipeline(g.pipeline);
329
- pass.setBindGroup(0, g.bindGroup);
330
- pass.setIndexBuffer(g.packed.indexBuf, "uint32");
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
- for (const g of groups) {
343
- for (const s of g.subs)
344
- s.dispose();
345
- g.globals.destroy();
346
- g.drawHeap.destroy();
347
- g.packed.positionsBuf.destroy();
348
- g.packed.normalsBuf.destroy();
349
- g.packed.indexBuf.destroy();
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
- return { frame, stats, dispose };
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