@aardworx/wombat.rendering 0.8.1 → 0.9.1

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