@aardworx/wombat.rendering 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/core/command.d.ts +7 -9
  2. package/dist/core/command.d.ts.map +1 -1
  3. package/dist/core/renderTask.d.ts +64 -1
  4. package/dist/core/renderTask.d.ts.map +1 -1
  5. package/dist/core/renderTask.js +3 -1
  6. package/dist/core/renderTask.js.map +1 -1
  7. package/dist/resources/preparedRenderObject.js +3 -3
  8. package/dist/resources/preparedRenderObject.js.map +1 -1
  9. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  10. package/dist/resources/uniformBuffer.js +17 -0
  11. package/dist/resources/uniformBuffer.js.map +1 -1
  12. package/dist/runtime/derivedUniforms/dispatch.d.ts +105 -0
  13. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -0
  14. package/dist/runtime/derivedUniforms/dispatch.js +355 -0
  15. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -0
  16. package/dist/runtime/derivedUniforms/index.d.ts +6 -0
  17. package/dist/runtime/derivedUniforms/index.d.ts.map +1 -0
  18. package/dist/runtime/derivedUniforms/index.js +26 -0
  19. package/dist/runtime/derivedUniforms/index.js.map +1 -0
  20. package/dist/runtime/derivedUniforms/recipes.d.ts +42 -0
  21. package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -0
  22. package/dist/runtime/derivedUniforms/recipes.js +134 -0
  23. package/dist/runtime/derivedUniforms/recipes.js.map +1 -0
  24. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +56 -0
  25. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -0
  26. package/dist/runtime/derivedUniforms/sceneIntegration.js +171 -0
  27. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -0
  28. package/dist/runtime/derivedUniforms/slotId.d.ts +15 -0
  29. package/dist/runtime/derivedUniforms/slotId.d.ts.map +1 -0
  30. package/dist/runtime/derivedUniforms/slotId.js +30 -0
  31. package/dist/runtime/derivedUniforms/slotId.js.map +1 -0
  32. package/dist/runtime/derivedUniforms/slots.d.ts +49 -0
  33. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -0
  34. package/dist/runtime/derivedUniforms/slots.js +123 -0
  35. package/dist/runtime/derivedUniforms/slots.js.map +1 -0
  36. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +13 -0
  37. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +1 -0
  38. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +218 -0
  39. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +1 -0
  40. package/dist/runtime/flattenTree.d.ts +5 -0
  41. package/dist/runtime/flattenTree.d.ts.map +1 -0
  42. package/dist/runtime/flattenTree.js +45 -0
  43. package/dist/runtime/flattenTree.js.map +1 -0
  44. package/dist/runtime/heapAdapter.d.ts +16 -0
  45. package/dist/runtime/heapAdapter.d.ts.map +1 -0
  46. package/dist/runtime/heapAdapter.js +263 -0
  47. package/dist/runtime/heapAdapter.js.map +1 -0
  48. package/dist/runtime/heapEffect.d.ts +235 -0
  49. package/dist/runtime/heapEffect.d.ts.map +1 -0
  50. package/dist/runtime/heapEffect.js +518 -0
  51. package/dist/runtime/heapEffect.js.map +1 -0
  52. package/dist/runtime/heapEffectIR.d.ts +34 -0
  53. package/dist/runtime/heapEffectIR.d.ts.map +1 -0
  54. package/dist/runtime/heapEffectIR.js +1161 -0
  55. package/dist/runtime/heapEffectIR.js.map +1 -0
  56. package/dist/runtime/heapEligibility.d.ts +9 -0
  57. package/dist/runtime/heapEligibility.d.ts.map +1 -0
  58. package/dist/runtime/heapEligibility.js +195 -0
  59. package/dist/runtime/heapEligibility.js.map +1 -0
  60. package/dist/runtime/heapScene.d.ts +240 -31
  61. package/dist/runtime/heapScene.d.ts.map +1 -1
  62. package/dist/runtime/heapScene.js +3334 -265
  63. package/dist/runtime/heapScene.js.map +1 -1
  64. package/dist/runtime/heapShaderFamily.d.ts +132 -0
  65. package/dist/runtime/heapShaderFamily.d.ts.map +1 -0
  66. package/dist/runtime/heapShaderFamily.js +866 -0
  67. package/dist/runtime/heapShaderFamily.js.map +1 -0
  68. package/dist/runtime/hybridScene.d.ts +133 -0
  69. package/dist/runtime/hybridScene.d.ts.map +1 -0
  70. package/dist/runtime/hybridScene.js +159 -0
  71. package/dist/runtime/hybridScene.js.map +1 -0
  72. package/dist/runtime/index.d.ts +8 -0
  73. package/dist/runtime/index.d.ts.map +1 -1
  74. package/dist/runtime/index.js +9 -0
  75. package/dist/runtime/index.js.map +1 -1
  76. package/dist/runtime/renderTask.d.ts +21 -4
  77. package/dist/runtime/renderTask.d.ts.map +1 -1
  78. package/dist/runtime/renderTask.js +127 -32
  79. package/dist/runtime/renderTask.js.map +1 -1
  80. package/dist/runtime/runtime.d.ts +33 -3
  81. package/dist/runtime/runtime.d.ts.map +1 -1
  82. package/dist/runtime/runtime.js +12 -3
  83. package/dist/runtime/runtime.js.map +1 -1
  84. package/dist/runtime/scenePass.d.ts +28 -3
  85. package/dist/runtime/scenePass.d.ts.map +1 -1
  86. package/dist/runtime/scenePass.js +37 -4
  87. package/dist/runtime/scenePass.js.map +1 -1
  88. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +35 -0
  89. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -0
  90. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +247 -0
  91. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -0
  92. package/dist/runtime/textureAtlas/atlasPool.d.ts +240 -0
  93. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -0
  94. package/dist/runtime/textureAtlas/atlasPool.js +798 -0
  95. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -0
  96. package/dist/runtime/textureAtlas/bvhTree2d.d.ts +33 -0
  97. package/dist/runtime/textureAtlas/bvhTree2d.d.ts.map +1 -0
  98. package/dist/runtime/textureAtlas/bvhTree2d.js +501 -0
  99. package/dist/runtime/textureAtlas/bvhTree2d.js.map +1 -0
  100. package/dist/runtime/textureAtlas/packer.d.ts +50 -0
  101. package/dist/runtime/textureAtlas/packer.d.ts.map +1 -0
  102. package/dist/runtime/textureAtlas/packer.js +390 -0
  103. package/dist/runtime/textureAtlas/packer.js.map +1 -0
  104. package/dist/window/loop.d.ts +14 -0
  105. package/dist/window/loop.d.ts.map +1 -1
  106. package/dist/window/loop.js +18 -5
  107. package/dist/window/loop.js.map +1 -1
  108. package/package.json +5 -4
  109. package/src/core/command.ts +9 -9
  110. package/src/core/renderTask.ts +55 -2
  111. package/src/resources/preparedRenderObject.ts +3 -3
  112. package/src/resources/uniformBuffer.ts +15 -0
  113. package/src/runtime/derivedUniforms/dispatch.ts +407 -0
  114. package/src/runtime/derivedUniforms/index.ts +52 -0
  115. package/src/runtime/derivedUniforms/recipes.ts +159 -0
  116. package/src/runtime/derivedUniforms/sceneIntegration.ts +239 -0
  117. package/src/runtime/derivedUniforms/slotId.ts +35 -0
  118. package/src/runtime/derivedUniforms/slots.ts +155 -0
  119. package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +220 -0
  120. package/src/runtime/flattenTree.ts +46 -0
  121. package/src/runtime/heapAdapter.ts +314 -0
  122. package/src/runtime/heapEffect.ts +719 -0
  123. package/src/runtime/heapEffectIR.ts +1211 -0
  124. package/src/runtime/heapEligibility.ts +189 -0
  125. package/src/runtime/heapScene.ts +3981 -349
  126. package/src/runtime/heapShaderFamily.ts +1102 -0
  127. package/src/runtime/hybridScene.ts +310 -0
  128. package/src/runtime/index.ts +46 -0
  129. package/src/runtime/renderTask.ts +167 -32
  130. package/src/runtime/runtime.ts +37 -4
  131. package/src/runtime/scenePass.ts +39 -4
  132. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +291 -0
  133. package/src/runtime/textureAtlas/atlasPool.ts +991 -0
  134. package/src/runtime/textureAtlas/bvhTree2d.ts +530 -0
  135. package/src/runtime/textureAtlas/packer.ts +429 -0
  136. package/src/window/loop.ts +32 -6
@@ -1,16 +1,69 @@
1
1
  // RenderTask — the compiled, runnable form of an alist<Command>.
2
2
  // The runtime returns one of these from `compile`; the user calls
3
- // `run(token)` to encode + submit a frame.
3
+ // `run(framebuffer, token)` to encode + submit a frame against the
4
+ // supplied framebuffer instance. The framebuffer's signature must
5
+ // match the one the task was compiled against.
4
6
 
5
7
  import type { AdaptiveToken } from "@aardworx/wombat.adaptive";
8
+ import type { FramebufferSignature } from "./framebufferSignature.js";
9
+ import type { IFramebuffer } from "./framebuffer.js";
6
10
 
7
11
  export interface IRenderTask {
12
+ /**
13
+ * The signature this task was compiled against. All pipelines
14
+ * baked in were specialised against this signature; framebuffers
15
+ * passed to `run`/`encode` must match.
16
+ */
17
+ readonly signature: FramebufferSignature;
18
+
8
19
  /**
9
20
  * Encode all commands into the GPU queue using the current
10
21
  * adaptive state read via `token`. Returns once submission has
11
22
  * been queued (does not wait for GPU completion).
12
23
  */
13
- run(token: AdaptiveToken): void;
24
+ run(framebuffer: IFramebuffer, token: AdaptiveToken): void;
25
+
26
+ /**
27
+ * Total GPU buckets the heap path emits across this task's
28
+ * compiled scenes. With §6 family-merge in v1 this collapses to
29
+ * one per pipelineState per Render command. Useful for status
30
+ * text / dev overlays.
31
+ */
32
+ heapBucketCount(): number;
33
+
34
+ /** Per-frame breakdown of §7 derived-uniforms work (CPU, last frame). */
35
+ heapDerivedTimings(): {
36
+ pullMs: number; uploadMs: number; encodeMs: number; records: number;
37
+ };
38
+
39
+ /** Diagnostic: walks heap drawHeaders, drawTables, prefix sums,
40
+ * attribute alloc headers; checks refs/indices/instance counts and
41
+ * data finiteness. Useful for hunting OOB-corruption bugs. */
42
+ validateHeap(): Promise<{
43
+ arenaBytes: number; issues: string[];
44
+ okRefs: number; badRefs: number;
45
+ drawTableRows: number; drawTableErrs: number; prefixSumErrs: number;
46
+ attrAllocsChecked: number; attrAllocsBad: number;
47
+ tilesChecked: number; tilesBad: number;
48
+ vidChecks: number; vidBad: number;
49
+ indicesHash: string;
50
+ }>;
51
+ /** CPU draw simulator. Walks N sampled emits, runs the binary-search
52
+ * + index/instance recovery, and verifies every storage read lands
53
+ * inside the bound buffer. */
54
+ simulateDraws(samples?: number): Promise<{
55
+ emitsChecked: number; oob: number; issues: string[];
56
+ }>;
57
+ /** GPU-side binary-search probe — runs the same search the render VS
58
+ * does and compares its result with the CPU's. */
59
+ probeBinarySearch(samples?: number): Promise<{
60
+ emitsChecked: number; gpuMismatches: number; issues: string[];
61
+ }>;
62
+ /** Triangle-level coherence — verifies each triangle's 3 emits land
63
+ * in the same slot (no cross-RO triangles). */
64
+ checkTriangleCoherence(samples?: number): Promise<{
65
+ trianglesChecked: number; crossSlot: number; issues: string[];
66
+ }>;
14
67
 
15
68
  /** Tear down resources owned by the task. Idempotent. */
16
69
  dispose(): void;
@@ -548,9 +548,9 @@ export function prepareRenderObject(
548
548
  // tight one-element buffer and asks every vertex/instance to read
549
549
  // it. WebGPU reads the same element when arrayStride is 0
550
550
  // (regardless of stepMode), so override the default tight stride
551
- // here. Without this the second vertex would read past the end of
552
- // the 16-byte buffer and pick up zeros — colorAval would tint
553
- // only the first vertex of a draw.
551
+ // here. Without this the second vertex would read past the end
552
+ // of the 16-byte buffer and pick up zeros — colorAval would tint
553
+ // only the first vertex of each draw.
554
554
  const isBroadcast = view.singleValue !== undefined;
555
555
  const stride = isBroadcast ? 0 : BufferView.strideOf(view);
556
556
  const offset = BufferView.offsetOf(view);
@@ -135,6 +135,16 @@ export function writeField(
135
135
  view.f32.set(value, offset >> 2);
136
136
  return;
137
137
  }
138
+ if (value instanceof Float64Array) {
139
+ // Downcast to f32 in-place. Aardvark's V/M types in the F#-on-JS
140
+ // path use Float64Array internally (V4d / M44d / Trafo3d) but
141
+ // GPU uniforms are always f32 — converting here saves every
142
+ // F# user from sprinkling V4f / V3f conversions across their
143
+ // Sg.Uniform values.
144
+ const dst = view.f32.subarray(offset >> 2, (offset >> 2) + value.length);
145
+ for (let i = 0; i < value.length; i++) dst[i] = value[i]!;
146
+ return;
147
+ }
138
148
  if (value instanceof Int32Array) {
139
149
  view.i32.set(value, offset >> 2);
140
150
  return;
@@ -146,6 +156,11 @@ export function writeField(
146
156
  if (value !== null && typeof value === "object" && "_data" in (value as object)) {
147
157
  const data = (value as { _data: unknown })._data;
148
158
  if (data instanceof Float32Array) { view.f32.set(data, offset >> 2); return; }
159
+ if (data instanceof Float64Array) {
160
+ const dst = view.f32.subarray(offset >> 2, (offset >> 2) + data.length);
161
+ for (let i = 0; i < data.length; i++) dst[i] = data[i]!;
162
+ return;
163
+ }
149
164
  if (data instanceof Int32Array) { view.i32.set(data, offset >> 2); return; }
150
165
  if (data instanceof Uint32Array) { view.u32.set(data, offset >> 2); return; }
151
166
  }
@@ -0,0 +1,407 @@
1
+ // §7 single-dispatch leaf dispatcher.
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";
24
+
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. */
28
+ export interface DerivedUniformsResources {
29
+ readonly constituentsBuf: () => GPUBuffer;
30
+ readonly mainHeapBuf: () => GPUBuffer;
31
+ }
32
+
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;
49
+
50
+ private gpu: GPUBuffer | undefined;
51
+ private gpuCap = 0; // record slots in `gpu`
52
+
53
+ constructor(device: GPUDevice, initialCapacity = 64) {
54
+ 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;
92
+ }
93
+
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({
103
+ label: "derivedUniforms.records",
104
+ size: this.cap * RECORD_STRIDE_BYTES,
105
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
106
+ });
107
+ this.gpuCap = this.cap;
108
+ // Whole CPU mirror needs re-upload.
109
+ this.dirtyMinByte = 0;
110
+ this.dirtyMaxByte = this.count * RECORD_STRIDE_BYTES;
111
+ }
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;
125
+ }
126
+ }
127
+
128
+ 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({
132
+ label: "derivedUniforms.records.stub",
133
+ size: RECORD_STRIDE_BYTES,
134
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
135
+ });
136
+ this.gpuCap = 1;
137
+ }
138
+ return this.gpu;
139
+ }
140
+
141
+ 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;
173
+ }
174
+ }
175
+
176
+ /** One compute pipeline shared across buckets — created once per scene. */
177
+ export class DerivedUniformsPipeline {
178
+ readonly pipeline: GPUComputePipeline;
179
+ readonly bgl: GPUBindGroupLayout;
180
+
181
+ 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] }),
193
+ compute: { module, entryPoint: "main" },
194
+ });
195
+ }
196
+ }
197
+
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
+ export class DerivedUniformsDispatcher {
202
+ private readonly device: GPUDevice;
203
+ private readonly pipe: DerivedUniformsPipeline;
204
+ private readonly resources: DerivedUniformsResources;
205
+ readonly records: RecordsBuffer;
206
+
207
+ private cachedBg: GPUBindGroup | undefined;
208
+ private bgKeyConsts: GPUBuffer | undefined;
209
+ private bgKeyHeap: GPUBuffer | undefined;
210
+ private bgKeyRecs: GPUBuffer | undefined;
211
+
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;
226
+ 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
+ });
233
+ }
234
+
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;
259
+ const pass = enc.beginComputePass({ label: "derivedUniforms.uber" });
260
+ pass.setPipeline(this.pipe.pipeline);
261
+ const ran = this.dispatchInto(pass);
262
+ pass.end();
263
+ return ran;
264
+ }
265
+
266
+ dispose(): void {
267
+ this.records.dispose();
268
+ this.countBuf.destroy();
269
+ }
270
+
271
+ private bindGroup(): GPUBindGroup {
272
+ 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
+ ) {
280
+ return this.cachedBg;
281
+ }
282
+ this.cachedBg = this.device.createBindGroup({
283
+ layout: this.pipe.bgl,
284
+ entries: [
285
+ { binding: 0, resource: { buffer: consts } },
286
+ { binding: 1, resource: { buffer: heap } },
287
+ { binding: 2, resource: { buffer: recs } },
288
+ { binding: 3, resource: { buffer: this.countBuf } },
289
+ ],
290
+ });
291
+ this.bgKeyConsts = consts; this.bgKeyHeap = heap;
292
+ this.bgKeyRecs = recs;
293
+ return this.cachedBg;
294
+ }
295
+ }
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,52 @@
1
+ // §7 derived-uniforms module — public surface.
2
+ //
3
+ // Caller responsibilities (typically heapScene.ts):
4
+ //
5
+ // 1. Construct one DerivedUniformsScene per heap scene, one
6
+ // DerivedUniformsBucket per render bucket. The scene owns the
7
+ // shared constituents storage buffer + dirty bitmask + pipeline.
8
+ // 2. In the parent scene-object's `inputChanged(t, o)` router, call
9
+ // `scene.routeInputChanged(o)`. Returns true iff o matched.
10
+ // 3. In each RO's add path: `registerRoDerivations(scene, bucket, req)`
11
+ // allocates constituent slots and pushes records into the bucket's
12
+ // records buffer. In remove path: `deregisterRoDerivations(...)`.
13
+ // 4. In the per-frame compute prep, inside the parent sceneObj's
14
+ // `evaluateAlways(token, ...)` scope:
15
+ // const dirty = scene.constituents.pullDirty(token);
16
+ // scene.uploadDirty(dirty);
17
+ // for each bucket: bucket.dispatcher.encode(enc);
18
+ // All steps are O(changed); on clean frames they no-op.
19
+ //
20
+ // Identifying which uniform names are §7-owned: DERIVED_UNIFORM_NAMES.
21
+
22
+ export {
23
+ RecipeId,
24
+ ALL_RECIPES, DERIVED_UNIFORM_NAMES,
25
+ recipeIdByName, recipeName, recipeInputs, recipeInputCount, recipeOutput,
26
+ type ConstituentRef, type RecipeOutput,
27
+ } from "./recipes.js";
28
+
29
+ export {
30
+ ConstituentSlots, DF32_MAT4_BYTES,
31
+ type SlotIndex, type PairedSlots, type DerivationRecord, type SubscribeFn,
32
+ } from "./slots.js";
33
+
34
+ export {
35
+ DerivedUniformsDispatcher, DerivedUniformsPipeline,
36
+ RecordsBuffer, uploadConstituentsRange,
37
+ type DerivedUniformsResources,
38
+ } from "./dispatch.js";
39
+
40
+ export {
41
+ DerivedUniformsScene,
42
+ registerRoDerivations,
43
+ deregisterRoDerivations,
44
+ isDerivedUniformName,
45
+ type RoTrafoInputs,
46
+ type RoDerivedRequest,
47
+ type RoRegistration,
48
+ } from "./sceneIntegration.js";
49
+
50
+ export {
51
+ DERIVED_UBER_KERNEL_WGSL,
52
+ } from "./uberKernel.wgsl.js";