@aardworx/wombat.rendering 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/runtime/derivedUniforms/codegen.d.ts +8 -0
  2. package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -0
  3. package/dist/runtime/derivedUniforms/codegen.js +449 -0
  4. package/dist/runtime/derivedUniforms/codegen.js.map +1 -0
  5. package/dist/runtime/derivedUniforms/dispatch.d.ts +14 -93
  6. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  7. package/dist/runtime/derivedUniforms/dispatch.js +123 -299
  8. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  9. package/dist/runtime/derivedUniforms/flatten.d.ts +25 -0
  10. package/dist/runtime/derivedUniforms/flatten.d.ts.map +1 -0
  11. package/dist/runtime/derivedUniforms/flatten.js +71 -0
  12. package/dist/runtime/derivedUniforms/flatten.js.map +1 -0
  13. package/dist/runtime/derivedUniforms/index.d.ts +10 -5
  14. package/dist/runtime/derivedUniforms/index.d.ts.map +1 -1
  15. package/dist/runtime/derivedUniforms/index.js +24 -19
  16. package/dist/runtime/derivedUniforms/index.js.map +1 -1
  17. package/dist/runtime/derivedUniforms/marker.d.ts +32 -0
  18. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -0
  19. package/dist/runtime/derivedUniforms/marker.js +84 -0
  20. package/dist/runtime/derivedUniforms/marker.js.map +1 -0
  21. package/dist/runtime/derivedUniforms/recipes.d.ts +6 -41
  22. package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -1
  23. package/dist/runtime/derivedUniforms/recipes.js +53 -129
  24. package/dist/runtime/derivedUniforms/recipes.js.map +1 -1
  25. package/dist/runtime/derivedUniforms/records.d.ts +40 -0
  26. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -0
  27. package/dist/runtime/derivedUniforms/records.js +151 -0
  28. package/dist/runtime/derivedUniforms/records.js.map +1 -0
  29. package/dist/runtime/derivedUniforms/registry.d.ts +33 -0
  30. package/dist/runtime/derivedUniforms/registry.d.ts.map +1 -0
  31. package/dist/runtime/derivedUniforms/registry.js +58 -0
  32. package/dist/runtime/derivedUniforms/registry.js.map +1 -0
  33. package/dist/runtime/derivedUniforms/rule.d.ts +26 -0
  34. package/dist/runtime/derivedUniforms/rule.d.ts.map +1 -0
  35. package/dist/runtime/derivedUniforms/rule.js +35 -0
  36. package/dist/runtime/derivedUniforms/rule.js.map +1 -0
  37. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +34 -33
  38. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  39. package/dist/runtime/derivedUniforms/sceneIntegration.js +80 -90
  40. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  41. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  42. package/dist/runtime/heapAdapter.js +9 -6
  43. package/dist/runtime/heapAdapter.js.map +1 -1
  44. package/dist/runtime/heapScene.d.ts.map +1 -1
  45. package/dist/runtime/heapScene.js +72 -27
  46. package/dist/runtime/heapScene.js.map +1 -1
  47. package/dist/runtime/hybridScene.js +1 -1
  48. package/dist/runtime/hybridScene.js.map +1 -1
  49. package/dist/runtime/index.d.ts +1 -0
  50. package/dist/runtime/index.d.ts.map +1 -1
  51. package/dist/runtime/index.js +2 -0
  52. package/dist/runtime/index.js.map +1 -1
  53. package/dist/runtime/renderTask.js +1 -1
  54. package/dist/runtime/renderTask.js.map +1 -1
  55. package/dist/runtime/runtime.js +1 -1
  56. package/dist/runtime/runtime.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/runtime/derivedUniforms/codegen.ts +477 -0
  59. package/src/runtime/derivedUniforms/dispatch.ts +135 -341
  60. package/src/runtime/derivedUniforms/flatten.ts +89 -0
  61. package/src/runtime/derivedUniforms/index.ts +50 -34
  62. package/src/runtime/derivedUniforms/marker.ts +91 -0
  63. package/src/runtime/derivedUniforms/recipes.ts +63 -155
  64. package/src/runtime/derivedUniforms/records.ts +158 -0
  65. package/src/runtime/derivedUniforms/registry.ts +77 -0
  66. package/src/runtime/derivedUniforms/rule.ts +59 -0
  67. package/src/runtime/derivedUniforms/sceneIntegration.ts +115 -149
  68. package/src/runtime/heapAdapter.ts +9 -4
  69. package/src/runtime/heapScene.ts +73 -29
  70. package/src/runtime/hybridScene.ts +1 -1
  71. package/src/runtime/index.ts +8 -0
  72. package/src/runtime/renderTask.ts +1 -1
  73. package/src/runtime/runtime.ts +1 -1
  74. package/dist/runtime/derivedUniforms/slotId.d.ts +0 -15
  75. package/dist/runtime/derivedUniforms/slotId.d.ts.map +0 -1
  76. package/dist/runtime/derivedUniforms/slotId.js +0 -30
  77. package/dist/runtime/derivedUniforms/slotId.js.map +0 -1
  78. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +0 -13
  79. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +0 -1
  80. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +0 -218
  81. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +0 -1
  82. package/src/runtime/derivedUniforms/slotId.ts +0 -35
  83. package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +0 -220
@@ -1,407 +1,201 @@
1
- // §7 single-dispatch leaf dispatcher.
1
+ // §7 v2 — uber-kernel dispatcher (GPU plumbing).
2
2
  //
3
- // Each bucket holds a static-ish records buffer (only edited on RO
4
- // add/remove). Per frame the caller:
5
- // 1. Drains dirty constituent avals and uploads the changed byte
6
- // range to the shared constituents buffer.
7
- // 2. Builds a dirty bitmask (one bit per constituent slot) and
8
- // uploads it to the shared dirty buffer.
9
- // 3. For each bucket, calls `dispatcher.encode(enc)` — one
10
- // compute pass with one dispatch per bucket. The kernel
11
- // early-returns on records whose inputs are all clean.
12
- //
13
- // No worklist building, no atomicAdd compaction, no per-frame record
14
- // encoding. The records buffer changes only when ROs come and go.
15
-
16
- import {
17
- ConstituentSlots, DF32_MAT4_BYTES,
18
- type DerivationRecord, type SlotIndex,
19
- } from "./slots.js";
20
- import {
21
- RECORD_STRIDE_BYTES, RECORD_STRIDE_U32, RECORD_FIELD,
22
- DERIVED_UBER_KERNEL_WGSL,
23
- } from "./uberKernel.wgsl.js";
3
+ // Owns: the GPU mirror of the CPU records buffer, a tiny `Count` uniform, and a
4
+ // recompilable compute pipeline. The kernel WGSL is regenerated whenever the rule
5
+ // registry gains a new rule (`registry.version`) or the record stride grows
6
+ // (`records.layoutVersion`). The records GPU buffer is re-uploaded whenever the CPU
7
+ // records buffer mutates (`records.generation`) records change only on RO add/remove,
8
+ // never per frame. One `dispatchWorkgroups` per frame, no levels, no barriers.
9
+ // See docs/derived-uniforms-extensible.md § "Dispatcher".
10
+
11
+ import type { DerivedUniformRegistry } from "./registry.js";
12
+ import type { RecordsBuffer } from "./records.js";
13
+ import { buildUberKernel } from "./codegen.js";
14
+ import { DF32_MAT4_BYTES, type SlotIndex } from "./slots.js";
15
+
16
+ /** Upload the changed-constituent value range to the constituents GPU buffer. */
17
+ export function uploadConstituentsRange(
18
+ device: GPUDevice,
19
+ buf: GPUBuffer,
20
+ mirror: Float32Array,
21
+ dirty: ReadonlySet<SlotIndex>,
22
+ ): void {
23
+ let minSlot = Infinity;
24
+ let maxSlot = -1;
25
+ for (const s of dirty) {
26
+ if (s < minSlot) minSlot = s;
27
+ if (s > maxSlot) maxSlot = s;
28
+ }
29
+ if (maxSlot < 0) return;
30
+ const startByte = minSlot * DF32_MAT4_BYTES;
31
+ const endByte = (maxSlot + 1) * DF32_MAT4_BYTES;
32
+ device.queue.writeBuffer(buf, startByte, mirror.buffer, mirror.byteOffset + startByte, endByte - startByte);
33
+ }
24
34
 
25
- /** Live getters for the GPU resources the dispatcher needs. The scene
26
- * may replace these buffers in-place when capacity grows; the
27
- * dispatcher rebinds each frame. */
35
+ /** Live getters for the GPU buffers the kernel binds (scene may grow/replace them). */
28
36
  export interface DerivedUniformsResources {
37
+ /** `array<vec2<f32>>` — df32 trafo halves (the constituents heap). */
29
38
  readonly constituentsBuf: () => GPUBuffer;
30
- readonly mainHeapBuf: () => GPUBuffer;
39
+ /** `array<f32>` — the main heap (drawHeaders); written by the kernel. */
40
+ readonly mainHeapBuf: () => GPUBuffer;
31
41
  }
32
42
 
33
- /** Per-bucket records buffer. Records are added/removed when ROs
34
- * enter/leave the bucket. Uses swap-remove to keep the buffer dense.
35
- * CPU mirror is kept up-to-date; the dirty range is uploaded once
36
- * per `flush()`. */
37
- export class RecordsBuffer {
38
- private readonly device: GPUDevice;
39
- private cpu: Uint32Array;
40
- private cap = 0; // record slots (capacity, in records)
41
- private count = 0; // live records
42
- private nextId = 0;
43
- private readonly slotById = new Map<number, number>();
44
- private readonly idBySlot: number[] = [];
45
-
46
- /** Dirty CPU-mirror byte range that must be uploaded next flush. */
47
- private dirtyMinByte = Infinity;
48
- private dirtyMaxByte = -1;
43
+ function bglEntries(): GPUBindGroupLayoutEntry[] {
44
+ return [
45
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
46
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
47
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
48
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
49
+ ];
50
+ }
49
51
 
50
- private gpu: GPUBuffer | undefined;
51
- private gpuCap = 0; // record slots in `gpu`
52
+ /** GPU mirror of a CPU `RecordsBuffer` + the `Count` uniform. */
53
+ class RecordsGpu {
54
+ private readonly device: GPUDevice;
55
+ private buf: GPUBuffer | undefined;
56
+ private bufCapBytes = 0;
57
+ private uploadedGen = -1;
58
+ readonly countBuf: GPUBuffer;
59
+ private readonly countCpu = new Uint32Array(1);
60
+ private uploadedCount = -1;
52
61
 
53
- constructor(device: GPUDevice, initialCapacity = 64) {
62
+ constructor(device: GPUDevice) {
54
63
  this.device = device;
55
- this.cap = initialCapacity;
56
- this.cpu = new Uint32Array(this.cap * RECORD_STRIDE_U32);
57
- }
58
-
59
- /** Add a record. Returns a stable handle for later removal. */
60
- add(rec: DerivationRecord): number {
61
- if (this.count === this.cap) this.growCpu();
62
- const slot = this.count++;
63
- const id = this.nextId++;
64
- this.slotById.set(id, slot);
65
- this.idBySlot[slot] = id;
66
- this.writeAt(slot, rec);
67
- return id;
68
- }
69
-
70
- /** Swap-remove. The last record moves into the freed slot. */
71
- remove(id: number): void {
72
- const slot = this.slotById.get(id);
73
- if (slot === undefined) {
74
- throw new Error("RecordsBuffer.remove: unknown id");
75
- }
76
- const last = this.count - 1;
77
- if (slot !== last) {
78
- // Move last → slot.
79
- const srcOff = last * RECORD_STRIDE_U32;
80
- const dstOff = slot * RECORD_STRIDE_U32;
81
- for (let i = 0; i < RECORD_STRIDE_U32; i++) {
82
- this.cpu[dstOff + i] = this.cpu[srcOff + i]!;
83
- }
84
- const movedId = this.idBySlot[last]!;
85
- this.slotById.set(movedId, slot);
86
- this.idBySlot[slot] = movedId;
87
- this.markDirty(slot, slot + 1);
88
- }
89
- this.slotById.delete(id);
90
- this.idBySlot.length = last;
91
- this.count = last;
64
+ this.countBuf = device.createBuffer({
65
+ label: "derivedUniforms.recordCount",
66
+ size: 16, // uniform min binding size
67
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
68
+ });
92
69
  }
93
70
 
94
- /** Live record count. */
95
- get recordCount(): number { return this.count; }
96
-
97
- /** Ensure GPU buffer matches cap and upload dirty CPU range. Call
98
- * before `encode`. */
99
- flush(): void {
100
- if (this.gpu === undefined || this.gpuCap < this.cap) {
101
- if (this.gpu !== undefined) this.gpu.destroy();
102
- this.gpu = this.device.createBuffer({
71
+ /** Re-upload the packed record data if it changed since last sync, and refresh `Count`. */
72
+ sync(records: RecordsBuffer): void {
73
+ const data = records.data;
74
+ const needBytes = Math.max(16, data.byteLength);
75
+ if (this.buf === undefined || this.bufCapBytes < needBytes) {
76
+ if (this.buf !== undefined) this.buf.destroy();
77
+ let cap = Math.max(16, this.bufCapBytes || 16);
78
+ while (cap < needBytes) cap *= 2;
79
+ this.buf = this.device.createBuffer({
103
80
  label: "derivedUniforms.records",
104
- size: this.cap * RECORD_STRIDE_BYTES,
81
+ size: cap,
105
82
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
106
83
  });
107
- this.gpuCap = this.cap;
108
- // Whole CPU mirror needs re-upload.
109
- this.dirtyMinByte = 0;
110
- this.dirtyMaxByte = this.count * RECORD_STRIDE_BYTES;
84
+ this.bufCapBytes = cap;
85
+ this.uploadedGen = -1; // force re-upload into the fresh buffer
86
+ }
87
+ if (records.generation !== this.uploadedGen && data.byteLength > 0) {
88
+ this.device.queue.writeBuffer(this.buf!, 0, data.buffer, data.byteOffset, data.byteLength);
89
+ this.uploadedGen = records.generation;
111
90
  }
112
- if (this.dirtyMaxByte > this.dirtyMinByte) {
113
- const start = this.dirtyMinByte;
114
- const end = Math.min(this.dirtyMaxByte, this.count * RECORD_STRIDE_BYTES);
115
- if (end > start) {
116
- this.device.queue.writeBuffer(
117
- this.gpu!, start,
118
- this.cpu.buffer,
119
- this.cpu.byteOffset + start,
120
- end - start,
121
- );
122
- }
123
- this.dirtyMinByte = Infinity;
124
- this.dirtyMaxByte = -1;
91
+ if (records.recordCount !== this.uploadedCount) {
92
+ this.countCpu[0] = records.recordCount;
93
+ this.device.queue.writeBuffer(this.countBuf, 0, this.countCpu.buffer, this.countCpu.byteOffset, 4);
94
+ this.uploadedCount = records.recordCount;
125
95
  }
126
96
  }
127
97
 
128
98
  buffer(): GPUBuffer {
129
- if (this.gpu === undefined) {
130
- // Empty bucket — make a tiny stub so binding always has something.
131
- this.gpu = this.device.createBuffer({
99
+ if (this.buf === undefined) {
100
+ this.buf = this.device.createBuffer({
132
101
  label: "derivedUniforms.records.stub",
133
- size: RECORD_STRIDE_BYTES,
102
+ size: 16,
134
103
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
135
104
  });
136
- this.gpuCap = 1;
105
+ this.bufCapBytes = 16;
137
106
  }
138
- return this.gpu;
107
+ return this.buf;
139
108
  }
140
109
 
141
110
  dispose(): void {
142
- if (this.gpu !== undefined) {
143
- this.gpu.destroy();
144
- this.gpu = undefined;
145
- }
146
- }
147
-
148
- // ── Internals ─────────────────────────────────────────────────────
149
-
150
- private writeAt(slot: number, rec: DerivationRecord): void {
151
- const off = slot * RECORD_STRIDE_U32;
152
- this.cpu[off + RECORD_FIELD.recipe_id] = rec.recipe;
153
- this.cpu[off + RECORD_FIELD.in0] = rec.in0;
154
- this.cpu[off + RECORD_FIELD.in1] = rec.in1;
155
- this.cpu[off + RECORD_FIELD.in2] = rec.in2;
156
- this.cpu[off + RECORD_FIELD.out_byte] = rec.outByte;
157
- this.markDirty(slot, slot + 1);
158
- }
159
-
160
- private markDirty(startSlot: number, endSlot: number): void {
161
- const startByte = startSlot * RECORD_STRIDE_BYTES;
162
- const endByte = endSlot * RECORD_STRIDE_BYTES;
163
- if (startByte < this.dirtyMinByte) this.dirtyMinByte = startByte;
164
- if (endByte > this.dirtyMaxByte) this.dirtyMaxByte = endByte;
165
- }
166
-
167
- private growCpu(): void {
168
- const newCap = this.cap * 2;
169
- const grown = new Uint32Array(newCap * RECORD_STRIDE_U32);
170
- grown.set(this.cpu);
171
- this.cpu = grown;
172
- this.cap = newCap;
111
+ this.buf?.destroy();
112
+ this.buf = undefined;
113
+ this.countBuf.destroy();
173
114
  }
174
115
  }
175
116
 
176
- /** One compute pipeline shared across buckets created once per scene. */
177
- export class DerivedUniformsPipeline {
178
- readonly pipeline: GPUComputePipeline;
179
- readonly bgl: GPUBindGroupLayout;
117
+ /** Recompilable uber-kernel pipeline. Rebuilt on `(registry.version, strideU32)` change. */
118
+ class UberPipeline {
119
+ private readonly device: GPUDevice;
120
+ readonly bgl: GPUBindGroupLayout;
121
+ private pipe: GPUComputePipeline | undefined;
122
+ private builtVersion = -1;
123
+ private builtStride = -1;
180
124
 
181
125
  constructor(device: GPUDevice) {
182
- const module = device.createShaderModule({ code: DERIVED_UBER_KERNEL_WGSL });
183
- this.bgl = device.createBindGroupLayout({
184
- entries: [
185
- { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
186
- { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
187
- { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
188
- { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
189
- ],
190
- });
191
- this.pipeline = device.createComputePipeline({
192
- layout: device.createPipelineLayout({ bindGroupLayouts: [this.bgl] }),
126
+ this.device = device;
127
+ this.bgl = device.createBindGroupLayout({ entries: bglEntries() });
128
+ }
129
+
130
+ pipeline(registry: DerivedUniformRegistry, strideU32: number): GPUComputePipeline | undefined {
131
+ if (registry.size === 0) return undefined;
132
+ if (this.pipe !== undefined && this.builtVersion === registry.version && this.builtStride === strideU32) {
133
+ return this.pipe;
134
+ }
135
+ const { wgsl } = buildUberKernel(registry, strideU32);
136
+ const module = this.device.createShaderModule({ code: wgsl, label: "derivedUniforms.uber" });
137
+ this.pipe = this.device.createComputePipeline({
138
+ label: "derivedUniforms.uber",
139
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bgl] }),
193
140
  compute: { module, entryPoint: "main" },
194
141
  });
142
+ this.builtVersion = registry.version;
143
+ this.builtStride = strideU32;
144
+ return this.pipe;
195
145
  }
196
146
  }
197
147
 
198
- /** Per-bucket dispatcher. One bind group cached, rebuilt only when a
199
- * bound buffer reference changes (constituents or main heap grew, or
200
- * records buffer was re-created). */
201
148
  export class DerivedUniformsDispatcher {
202
149
  private readonly device: GPUDevice;
203
- private readonly pipe: DerivedUniformsPipeline;
204
150
  private readonly resources: DerivedUniformsResources;
205
- readonly records: RecordsBuffer;
206
-
151
+ private readonly gpu: RecordsGpu;
152
+ private readonly pipe: UberPipeline;
207
153
  private cachedBg: GPUBindGroup | undefined;
208
- private bgKeyConsts: GPUBuffer | undefined;
209
- private bgKeyHeap: GPUBuffer | undefined;
210
- private bgKeyRecs: GPUBuffer | undefined;
154
+ private bgKey: [GPUBuffer | undefined, GPUBuffer | undefined, GPUBuffer | undefined] = [undefined, undefined, undefined];
211
155
 
212
- /** Tiny uniform holding the live record count — gates threads past
213
- * the live range from reading stale swap-removed records. */
214
- private readonly countBuf: GPUBuffer;
215
- private readonly countCpu = new Uint32Array(1);
216
- private lastCountUploaded = -1;
217
-
218
- constructor(
219
- device: GPUDevice,
220
- pipe: DerivedUniformsPipeline,
221
- resources: DerivedUniformsResources,
222
- initialRecordCapacity = 64,
223
- ) {
224
- this.device = device;
225
- this.pipe = pipe;
156
+ constructor(device: GPUDevice, resources: DerivedUniformsResources) {
157
+ this.device = device;
226
158
  this.resources = resources;
227
- this.records = new RecordsBuffer(device, initialRecordCapacity);
228
- this.countBuf = device.createBuffer({
229
- label: "derivedUniforms.recordCount",
230
- size: 16, // uniform min size
231
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
232
- });
159
+ this.gpu = new RecordsGpu(device);
160
+ this.pipe = new UberPipeline(device);
233
161
  }
234
162
 
235
- /** Dispatch into an open compute pass. Caller is responsible for
236
- * beginning/ending the pass and setting the pipeline once across
237
- * all dispatchers (the pipeline is shared). */
238
- dispatchInto(pass: GPUComputePassEncoder): boolean {
239
- const count = this.records.recordCount;
240
- if (count === 0) return false;
241
- this.records.flush();
242
- if (count !== this.lastCountUploaded) {
243
- this.countCpu[0] = count;
244
- this.device.queue.writeBuffer(
245
- this.countBuf, 0, this.countCpu.buffer, this.countCpu.byteOffset, 4,
246
- );
247
- this.lastCountUploaded = count;
248
- }
249
- pass.setBindGroup(0, this.bindGroup());
250
- pass.dispatchWorkgroups(Math.ceil(count / 64));
251
- return true;
252
- }
253
-
254
- /** Convenience: open/close a pass and dispatch. Use only when this
255
- * is the sole dispatcher in the pass; otherwise use `dispatchInto`
256
- * inside a caller-managed pass. */
257
- encode(enc: GPUCommandEncoder): boolean {
258
- if (this.records.recordCount === 0) return false;
163
+ /** Open a compute pass and run the uber-kernel. No-ops if there are no records / no rules. */
164
+ encode(enc: GPUCommandEncoder, registry: DerivedUniformRegistry, records: RecordsBuffer): boolean {
165
+ if (records.recordCount === 0) return false;
166
+ const pipeline = this.pipe.pipeline(registry, records.strideWords);
167
+ if (pipeline === undefined) return false;
168
+ this.gpu.sync(records);
259
169
  const pass = enc.beginComputePass({ label: "derivedUniforms.uber" });
260
- pass.setPipeline(this.pipe.pipeline);
261
- const ran = this.dispatchInto(pass);
170
+ pass.setPipeline(pipeline);
171
+ pass.setBindGroup(0, this.bindGroup());
172
+ pass.dispatchWorkgroups(Math.ceil(records.recordCount / 64));
262
173
  pass.end();
263
- return ran;
174
+ return true;
264
175
  }
265
176
 
266
177
  dispose(): void {
267
- this.records.dispose();
268
- this.countBuf.destroy();
178
+ this.gpu.dispose();
269
179
  }
270
180
 
271
181
  private bindGroup(): GPUBindGroup {
272
182
  const consts = this.resources.constituentsBuf();
273
- const heap = this.resources.mainHeapBuf();
274
- const recs = this.records.buffer();
275
- if (
276
- this.cachedBg !== undefined &&
277
- this.bgKeyConsts === consts && this.bgKeyHeap === heap &&
278
- this.bgKeyRecs === recs
279
- ) {
183
+ const heap = this.resources.mainHeapBuf();
184
+ const recs = this.gpu.buffer();
185
+ if (this.cachedBg !== undefined && this.bgKey[0] === consts && this.bgKey[1] === heap && this.bgKey[2] === recs) {
280
186
  return this.cachedBg;
281
187
  }
282
188
  this.cachedBg = this.device.createBindGroup({
189
+ label: "derivedUniforms.uber",
283
190
  layout: this.pipe.bgl,
284
191
  entries: [
285
192
  { binding: 0, resource: { buffer: consts } },
286
193
  { binding: 1, resource: { buffer: heap } },
287
194
  { binding: 2, resource: { buffer: recs } },
288
- { binding: 3, resource: { buffer: this.countBuf } },
195
+ { binding: 3, resource: { buffer: this.gpu.countBuf } },
289
196
  ],
290
197
  });
291
- this.bgKeyConsts = consts; this.bgKeyHeap = heap;
292
- this.bgKeyRecs = recs;
198
+ this.bgKey = [consts, heap, recs];
293
199
  return this.cachedBg;
294
200
  }
295
201
  }
296
-
297
- /** Scene-side helper: O(changed) maintenance of a GPU dirty bitmask.
298
- *
299
- * Steady state (clean frame): zero work. The CPU mirror reflects the
300
- * GPU buffer, so we only ever touch bits that actually flip.
301
- *
302
- * Each `upload(dirty)` call:
303
- * 1. Clears the bits set last frame (O(prev_dirty)).
304
- * 2. Sets the bits dirty this frame (O(dirty)).
305
- * 3. Writes only the affected u32 range to the GPU (one writeBuffer).
306
- * If both sets are empty, returns immediately. */
307
- export class DirtyMaskUploader {
308
- private readonly device: GPUDevice;
309
- private cpu: Uint32Array;
310
- private gpu: GPUBuffer | undefined;
311
- private gpuCapU32 = 0;
312
- /** Bits set on the previous upload — must be cleared next time. */
313
- private prevDirty: SlotIndex[] = [];
314
-
315
- constructor(device: GPUDevice, initialU32 = 8) {
316
- this.device = device;
317
- this.cpu = new Uint32Array(Math.max(1, initialU32));
318
- }
319
-
320
- /** Resize CPU + GPU mirrors so they cover `slotCount` bits. */
321
- ensureCapacity(slotCount: number): void {
322
- const need = Math.max(1, Math.ceil(slotCount / 32));
323
- if (need > this.cpu.length) {
324
- let cap = Math.max(this.cpu.length, 1);
325
- while (cap < need) cap *= 2;
326
- const grown = new Uint32Array(cap);
327
- grown.set(this.cpu);
328
- this.cpu = grown;
329
- }
330
- if (this.gpu === undefined || this.gpuCapU32 < need) {
331
- if (this.gpu !== undefined) this.gpu.destroy();
332
- const cap = this.cpu.length;
333
- this.gpu = this.device.createBuffer({
334
- label: "derivedUniforms.dirtyMask",
335
- size: cap * 4,
336
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
337
- });
338
- this.gpuCapU32 = cap;
339
- // GPU buffer is fresh-zero; CPU mirror should match. Wipe any
340
- // prevDirty (those bits live in the destroyed buffer).
341
- this.prevDirty.length = 0;
342
- }
343
- }
344
-
345
- /** Apply the new dirty set. O(prev_dirty + dirty), no O(slotCount). */
346
- upload(dirty: ReadonlySet<SlotIndex>): void {
347
- let minU32 = Infinity;
348
- let maxU32 = -Infinity;
349
- for (const s of this.prevDirty) {
350
- const i = s >>> 5; const b = s & 31;
351
- this.cpu[i] = (this.cpu[i]! & ~(1 << b)) >>> 0;
352
- if (i < minU32) minU32 = i;
353
- if (i > maxU32) maxU32 = i;
354
- }
355
- this.prevDirty.length = 0;
356
- for (const s of dirty) {
357
- const i = s >>> 5; const b = s & 31;
358
- this.cpu[i] = (this.cpu[i]! | (1 << b)) >>> 0;
359
- if (i < minU32) minU32 = i;
360
- if (i > maxU32) maxU32 = i;
361
- this.prevDirty.push(s);
362
- }
363
- if (maxU32 < 0 || maxU32 === -Infinity) return;
364
- const startByte = minU32 * 4;
365
- const endByte = (maxU32 + 1) * 4;
366
- this.device.queue.writeBuffer(
367
- this.gpu!, startByte,
368
- this.cpu.buffer, this.cpu.byteOffset + startByte,
369
- endByte - startByte,
370
- );
371
- }
372
-
373
- buffer(): GPUBuffer {
374
- if (this.gpu === undefined) this.ensureCapacity(1);
375
- return this.gpu!;
376
- }
377
-
378
- dispose(): void {
379
- if (this.gpu !== undefined) {
380
- this.gpu.destroy();
381
- this.gpu = undefined;
382
- }
383
- }
384
- }
385
-
386
- /** Upload the dirty constituent value range to the constituents GPU
387
- * buffer. Helper used by the scene each frame after draining. */
388
- export function uploadConstituentsRange(
389
- device: GPUDevice,
390
- buf: GPUBuffer,
391
- mirror: Float32Array,
392
- dirty: ReadonlySet<SlotIndex>,
393
- ): void {
394
- let minSlot = Infinity, maxSlot = -1;
395
- for (const s of dirty) {
396
- if (s < minSlot) minSlot = s;
397
- if (s > maxSlot) maxSlot = s;
398
- }
399
- if (maxSlot < 0) return;
400
- const startByte = minSlot * DF32_MAT4_BYTES;
401
- const endByte = (maxSlot + 1) * DF32_MAT4_BYTES;
402
- device.queue.writeBuffer(
403
- buf, startByte,
404
- mirror.buffer, mirror.byteOffset + startByte,
405
- endByte - startByte,
406
- );
407
- }
@@ -0,0 +1,89 @@
1
+ // §7 v2 — chain flattening.
2
+ //
3
+ // A rule whose IR mentions another derived-uniform name has that producer's
4
+ // IR substituted in place (recursively) until the rule reads only non-derived
5
+ // inputs (constituents / host uniforms). After flattening every
6
+ // record is independent ⇒ one compute dispatch, no levels, no barriers.
7
+ // See docs/derived-uniforms-extensible.md § "Chain flattening".
8
+
9
+ import type { Expr, Type } from "@aardworx/wombat.shader/ir";
10
+ import { visitExprChildren, stableStringify } from "@aardworx/wombat.shader/ir";
11
+ import { mapExpr } from "@aardworx/wombat.shader/passes";
12
+ import type { DerivedRule } from "./rule.js";
13
+
14
+ export interface RuleInput {
15
+ readonly name: string;
16
+ readonly type: Type;
17
+ /** `true` ⇒ the leaf appeared as `Inverse(ReadInput(name))` — for a constituent
18
+ * trafo this selects the stored backward half (free); the forward half otherwise. */
19
+ readonly inverse: boolean;
20
+ }
21
+
22
+ /**
23
+ * The rule's input leaves in **first-appearance order** (DFS pre-order of `ir`),
24
+ * deduplicated by `(name, inverse)`. A leaf is either `ReadInput("Uniform", name)`
25
+ * or `Inverse(ReadInput("Uniform", name))`. Order matters: it is the order the
26
+ * generated kernel arm consumes its arguments (e.g. the multiplication order of a
27
+ * matmul chain). Throws if a name is used at two structurally-different types.
28
+ */
29
+ export function inputsOf(ir: Expr): RuleInput[] {
30
+ const out: RuleInput[] = [];
31
+ const seen = new Set<string>(); // key = `${inverse ? "i" : "v"}:${name}`
32
+ const typeByName = new Map<string, Type>();
33
+ const record = (name: string, type: Type, inverse: boolean): void => {
34
+ const prev = typeByName.get(name);
35
+ if (prev !== undefined && !typesEqual(prev, type)) {
36
+ throw new Error(`derived rule: uniform '${name}' used at conflicting types`);
37
+ }
38
+ typeByName.set(name, type);
39
+ const key = `${inverse ? "i" : "v"}:${name}`;
40
+ if (!seen.has(key)) {
41
+ seen.add(key);
42
+ out.push({ name, type, inverse });
43
+ }
44
+ };
45
+ const walk = (e: Expr): void => {
46
+ if (e.kind === "ReadInput" && e.scope === "Uniform") {
47
+ record(e.name, e.type, false);
48
+ return;
49
+ }
50
+ if (e.kind === "Inverse" && e.value.kind === "ReadInput" && e.value.scope === "Uniform") {
51
+ record(e.value.name, e.value.type, true);
52
+ return; // do not also count the inner ReadInput as a separate forward leaf
53
+ }
54
+ visitExprChildren(e, walk);
55
+ };
56
+ walk(ir);
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Substitute every derived-uniform leaf in `ir` with its (recursively
62
+ * flattened) producer. `name` is the uniform name `ir` is being registered
63
+ * under; it is pushed on the expansion stack so a self-reference — or any
64
+ * cycle through other rules — is caught.
65
+ */
66
+ export function flatten(
67
+ name: string,
68
+ ir: Expr,
69
+ derived: ReadonlyMap<string, DerivedRule>,
70
+ _stack: readonly string[] = [],
71
+ ): Expr {
72
+ if (_stack.includes(name)) {
73
+ throw new Error(`derived uniform cycle: ${[..._stack, name].join(" → ")}`);
74
+ }
75
+ const stack = [..._stack, name];
76
+ return mapExpr(ir, (e) => {
77
+ if (e.kind === "ReadInput" && e.scope === "Uniform") {
78
+ const producer = derived.get(e.name);
79
+ if (producer !== undefined) {
80
+ return flatten(e.name, producer.ir, derived, stack);
81
+ }
82
+ }
83
+ return e;
84
+ });
85
+ }
86
+
87
+ function typesEqual(a: Type, b: Type): boolean {
88
+ return a === b || stableStringify(a) === stableStringify(b);
89
+ }