@aardworx/wombat.rendering 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/command.d.ts +7 -9
- package/dist/core/command.d.ts.map +1 -1
- package/dist/core/renderTask.d.ts +64 -1
- package/dist/core/renderTask.d.ts.map +1 -1
- package/dist/core/renderTask.js +3 -1
- package/dist/core/renderTask.js.map +1 -1
- package/dist/resources/preparedRenderObject.js +3 -3
- package/dist/resources/preparedRenderObject.js.map +1 -1
- package/dist/resources/uniformBuffer.d.ts.map +1 -1
- package/dist/resources/uniformBuffer.js +17 -0
- package/dist/resources/uniformBuffer.js.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts +105 -0
- package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/dispatch.js +355 -0
- package/dist/runtime/derivedUniforms/dispatch.js.map +1 -0
- package/dist/runtime/derivedUniforms/index.d.ts +6 -0
- package/dist/runtime/derivedUniforms/index.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/index.js +26 -0
- package/dist/runtime/derivedUniforms/index.js.map +1 -0
- package/dist/runtime/derivedUniforms/recipes.d.ts +42 -0
- package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/recipes.js +134 -0
- package/dist/runtime/derivedUniforms/recipes.js.map +1 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +56 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.js +171 -0
- package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -0
- package/dist/runtime/derivedUniforms/slotId.d.ts +15 -0
- package/dist/runtime/derivedUniforms/slotId.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/slotId.js +30 -0
- package/dist/runtime/derivedUniforms/slotId.js.map +1 -0
- package/dist/runtime/derivedUniforms/slots.d.ts +49 -0
- package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/slots.js +123 -0
- package/dist/runtime/derivedUniforms/slots.js.map +1 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +13 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +1 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +218 -0
- package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +1 -0
- package/dist/runtime/flattenTree.d.ts +5 -0
- package/dist/runtime/flattenTree.d.ts.map +1 -0
- package/dist/runtime/flattenTree.js +45 -0
- package/dist/runtime/flattenTree.js.map +1 -0
- package/dist/runtime/heapAdapter.d.ts +16 -0
- package/dist/runtime/heapAdapter.d.ts.map +1 -0
- package/dist/runtime/heapAdapter.js +263 -0
- package/dist/runtime/heapAdapter.js.map +1 -0
- package/dist/runtime/heapEffect.d.ts +235 -0
- package/dist/runtime/heapEffect.d.ts.map +1 -0
- package/dist/runtime/heapEffect.js +529 -0
- package/dist/runtime/heapEffect.js.map +1 -0
- package/dist/runtime/heapEffectIR.d.ts +34 -0
- package/dist/runtime/heapEffectIR.d.ts.map +1 -0
- package/dist/runtime/heapEffectIR.js +1161 -0
- package/dist/runtime/heapEffectIR.js.map +1 -0
- package/dist/runtime/heapEligibility.d.ts +9 -0
- package/dist/runtime/heapEligibility.d.ts.map +1 -0
- package/dist/runtime/heapEligibility.js +195 -0
- package/dist/runtime/heapEligibility.js.map +1 -0
- package/dist/runtime/heapScene.d.ts +240 -31
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +3334 -265
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/heapShaderFamily.d.ts +132 -0
- package/dist/runtime/heapShaderFamily.d.ts.map +1 -0
- package/dist/runtime/heapShaderFamily.js +866 -0
- package/dist/runtime/heapShaderFamily.js.map +1 -0
- package/dist/runtime/hybridScene.d.ts +133 -0
- package/dist/runtime/hybridScene.d.ts.map +1 -0
- package/dist/runtime/hybridScene.js +159 -0
- package/dist/runtime/hybridScene.js.map +1 -0
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +9 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/renderTask.d.ts +21 -4
- package/dist/runtime/renderTask.d.ts.map +1 -1
- package/dist/runtime/renderTask.js +127 -32
- package/dist/runtime/renderTask.js.map +1 -1
- package/dist/runtime/runtime.d.ts +33 -3
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +12 -3
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/scenePass.d.ts +28 -3
- package/dist/runtime/scenePass.d.ts.map +1 -1
- package/dist/runtime/scenePass.js +37 -4
- package/dist/runtime/scenePass.js.map +1 -1
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +35 -0
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +247 -0
- package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts +240 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/atlasPool.js +798 -0
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -0
- package/dist/runtime/textureAtlas/bvhTree2d.d.ts +33 -0
- package/dist/runtime/textureAtlas/bvhTree2d.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/bvhTree2d.js +501 -0
- package/dist/runtime/textureAtlas/bvhTree2d.js.map +1 -0
- package/dist/runtime/textureAtlas/packer.d.ts +50 -0
- package/dist/runtime/textureAtlas/packer.d.ts.map +1 -0
- package/dist/runtime/textureAtlas/packer.js +390 -0
- package/dist/runtime/textureAtlas/packer.js.map +1 -0
- package/dist/window/loop.d.ts +14 -0
- package/dist/window/loop.d.ts.map +1 -1
- package/dist/window/loop.js +18 -5
- package/dist/window/loop.js.map +1 -1
- package/package.json +6 -5
- package/src/core/command.ts +9 -9
- package/src/core/renderTask.ts +55 -2
- package/src/resources/preparedRenderObject.ts +3 -3
- package/src/resources/uniformBuffer.ts +15 -0
- package/src/runtime/derivedUniforms/dispatch.ts +407 -0
- package/src/runtime/derivedUniforms/index.ts +52 -0
- package/src/runtime/derivedUniforms/recipes.ts +159 -0
- package/src/runtime/derivedUniforms/sceneIntegration.ts +239 -0
- package/src/runtime/derivedUniforms/slotId.ts +35 -0
- package/src/runtime/derivedUniforms/slots.ts +155 -0
- package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +220 -0
- package/src/runtime/flattenTree.ts +46 -0
- package/src/runtime/heapAdapter.ts +314 -0
- package/src/runtime/heapEffect.ts +728 -0
- package/src/runtime/heapEffectIR.ts +1211 -0
- package/src/runtime/heapEligibility.ts +189 -0
- package/src/runtime/heapScene.ts +3981 -349
- package/src/runtime/heapShaderFamily.ts +1102 -0
- package/src/runtime/hybridScene.ts +310 -0
- package/src/runtime/index.ts +45 -1
- package/src/runtime/renderTask.ts +167 -32
- package/src/runtime/runtime.ts +37 -4
- package/src/runtime/scenePass.ts +39 -4
- package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +291 -0
- package/src/runtime/textureAtlas/atlasPool.ts +991 -0
- package/src/runtime/textureAtlas/bvhTree2d.ts +530 -0
- package/src/runtime/textureAtlas/packer.ts +429 -0
- package/src/window/loop.ts +32 -6
- package/dist/runtime/heapShader.d.ts +0 -12
- package/dist/runtime/heapShader.d.ts.map +0 -1
- package/dist/runtime/heapShader.js +0 -157
- package/dist/runtime/heapShader.js.map +0 -1
- package/src/runtime/heapShader.ts +0 -185
package/src/runtime/heapScene.ts
CHANGED
|
@@ -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
|
|
46
|
-
import {
|
|
47
|
-
import type {
|
|
48
|
-
import
|
|
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
|
-
//
|
|
80
|
+
// Per-allocation arena layout
|
|
52
81
|
// ---------------------------------------------------------------------------
|
|
53
82
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
//
|
|
752
|
+
// Megacall GPU prefix-sum compute shader
|
|
215
753
|
// ---------------------------------------------------------------------------
|
|
216
754
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
761
|
+
/** drawTable record width: (firstEmit, drawIdx, indexStart, indexCount, instanceCount). */
|
|
762
|
+
const RECORD_U32 = 5;
|
|
763
|
+
const RECORD_BYTES = RECORD_U32 * 4;
|
|
253
764
|
|
|
254
|
-
|
|
765
|
+
const HEAP_SCAN_WGSL = `
|
|
766
|
+
struct Params {
|
|
767
|
+
numRecords: u32,
|
|
768
|
+
numBlocks: u32,
|
|
769
|
+
_pad0: u32,
|
|
770
|
+
_pad1: u32,
|
|
771
|
+
};
|
|
255
772
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
773
|
+
struct Record {
|
|
774
|
+
firstEmit: u32,
|
|
775
|
+
drawIdx: u32,
|
|
776
|
+
indexStart: u32,
|
|
777
|
+
indexCount: u32,
|
|
778
|
+
instanceCount: u32,
|
|
779
|
+
};
|
|
262
780
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
817
|
+
workgroupBarrier();
|
|
818
|
+
}
|
|
278
819
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
];
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
readonly
|
|
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
|
-
|
|
356
|
-
readonly globalsBytes: number;
|
|
1099
|
+
totalDraws: number;
|
|
357
1100
|
drawBytes: number;
|
|
358
|
-
|
|
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
|
-
/**
|
|
363
|
-
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
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
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
opts: BuildHeapSceneOptions,
|
|
1286
|
+
sig: import("../core/framebufferSignature.js").FramebufferSignature,
|
|
1287
|
+
initialDraws: readonly HeapDrawSpec[] | aset<HeapDrawSpec>,
|
|
1288
|
+
opts: BuildHeapSceneOptions = {},
|
|
401
1289
|
): HeapScene {
|
|
402
|
-
const
|
|
403
|
-
const
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
1324
|
+
return v as T;
|
|
415
1325
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
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
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
}
|