@aardworx/wombat.rendering 0.19.24 → 0.20.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 (137) hide show
  1. package/dist/core/elementType.d.ts +2 -0
  2. package/dist/core/elementType.d.ts.map +1 -1
  3. package/dist/core/elementType.js +10 -0
  4. package/dist/core/elementType.js.map +1 -1
  5. package/dist/core/provider.d.ts.map +1 -1
  6. package/dist/core/provider.js +65 -32
  7. package/dist/core/provider.js.map +1 -1
  8. package/dist/core/renderObject.d.ts +18 -0
  9. package/dist/core/renderObject.d.ts.map +1 -1
  10. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  11. package/dist/resources/preparedRenderObject.js +28 -5
  12. package/dist/resources/preparedRenderObject.js.map +1 -1
  13. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  14. package/dist/resources/uniformBuffer.js +3 -1
  15. package/dist/resources/uniformBuffer.js.map +1 -1
  16. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +2 -12
  17. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  18. package/dist/runtime/derivedModes/modeKeyCpu.js +30 -13
  19. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  20. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +11 -0
  21. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -1
  22. package/dist/runtime/derivedModes/partitionDispatcher.js +31 -0
  23. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -1
  24. package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -1
  25. package/dist/runtime/derivedUniforms/codegen.js +34 -13
  26. package/dist/runtime/derivedUniforms/codegen.js.map +1 -1
  27. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  28. package/dist/runtime/derivedUniforms/dispatch.js +6 -0
  29. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  30. package/dist/runtime/derivedUniforms/records.d.ts +15 -0
  31. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
  32. package/dist/runtime/derivedUniforms/records.js +81 -14
  33. package/dist/runtime/derivedUniforms/records.js.map +1 -1
  34. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
  35. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  36. package/dist/runtime/derivedUniforms/sceneIntegration.js +18 -4
  37. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  38. package/dist/runtime/flattenTree.d.ts.map +1 -1
  39. package/dist/runtime/flattenTree.js +46 -2
  40. package/dist/runtime/flattenTree.js.map +1 -1
  41. package/dist/runtime/heapAdapter.d.ts +3 -1
  42. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  43. package/dist/runtime/heapAdapter.js +45 -4
  44. package/dist/runtime/heapAdapter.js.map +1 -1
  45. package/dist/runtime/heapDecoder.d.ts.map +1 -1
  46. package/dist/runtime/heapDecoder.js +8 -3
  47. package/dist/runtime/heapDecoder.js.map +1 -1
  48. package/dist/runtime/heapEffect.d.ts +36 -1
  49. package/dist/runtime/heapEffect.d.ts.map +1 -1
  50. package/dist/runtime/heapEffect.js +29 -2
  51. package/dist/runtime/heapEffect.js.map +1 -1
  52. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  53. package/dist/runtime/heapEffectIR.js +51 -11
  54. package/dist/runtime/heapEffectIR.js.map +1 -1
  55. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  56. package/dist/runtime/heapEligibility.js +64 -12
  57. package/dist/runtime/heapEligibility.js.map +1 -1
  58. package/dist/runtime/heapIrBuilders.d.ts +8 -0
  59. package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
  60. package/dist/runtime/heapIrBuilders.js +47 -2
  61. package/dist/runtime/heapIrBuilders.js.map +1 -1
  62. package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
  63. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
  64. package/dist/runtime/heapScene/chunkedArena.js +28 -91
  65. package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
  66. package/dist/runtime/heapScene/freelist.d.ts +1 -1
  67. package/dist/runtime/heapScene/freelist.js +3 -3
  68. package/dist/runtime/heapScene/freelist.js.map +1 -1
  69. package/dist/runtime/heapScene/pools.d.ts +103 -59
  70. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  71. package/dist/runtime/heapScene/pools.js +335 -194
  72. package/dist/runtime/heapScene/pools.js.map +1 -1
  73. package/dist/runtime/heapScene.d.ts +125 -1
  74. package/dist/runtime/heapScene.d.ts.map +1 -1
  75. package/dist/runtime/heapScene.js +671 -126
  76. package/dist/runtime/heapScene.js.map +1 -1
  77. package/dist/runtime/heapShaderFamily.d.ts.map +1 -1
  78. package/dist/runtime/heapShaderFamily.js +19 -0
  79. package/dist/runtime/heapShaderFamily.js.map +1 -1
  80. package/dist/runtime/hybridScene.d.ts +6 -0
  81. package/dist/runtime/hybridScene.d.ts.map +1 -1
  82. package/dist/runtime/hybridScene.js +36 -3
  83. package/dist/runtime/hybridScene.js.map +1 -1
  84. package/dist/runtime/index.d.ts +1 -1
  85. package/dist/runtime/index.d.ts.map +1 -1
  86. package/dist/runtime/index.js +1 -1
  87. package/dist/runtime/index.js.map +1 -1
  88. package/dist/runtime/renderTask.d.ts +6 -0
  89. package/dist/runtime/renderTask.d.ts.map +1 -1
  90. package/dist/runtime/renderTask.js +3 -0
  91. package/dist/runtime/renderTask.js.map +1 -1
  92. package/dist/runtime/runtime.d.ts +15 -0
  93. package/dist/runtime/runtime.d.ts.map +1 -1
  94. package/dist/runtime/runtime.js +3 -0
  95. package/dist/runtime/runtime.js.map +1 -1
  96. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +19 -2
  97. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -1
  98. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +37 -6
  99. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -1
  100. package/dist/runtime/textureAtlas/atlasPool.d.ts +5 -0
  101. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  102. package/dist/runtime/textureAtlas/atlasPool.js +25 -3
  103. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  104. package/dist/window/loop.d.ts.map +1 -1
  105. package/dist/window/loop.js +31 -4
  106. package/dist/window/loop.js.map +1 -1
  107. package/package.json +3 -3
  108. package/src/core/elementType.ts +10 -0
  109. package/src/core/provider.ts +56 -28
  110. package/src/core/renderObject.ts +18 -0
  111. package/src/resources/preparedRenderObject.ts +29 -6
  112. package/src/resources/uniformBuffer.ts +3 -1
  113. package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
  114. package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
  115. package/src/runtime/derivedUniforms/codegen.ts +34 -13
  116. package/src/runtime/derivedUniforms/dispatch.ts +6 -0
  117. package/src/runtime/derivedUniforms/records.ts +70 -15
  118. package/src/runtime/derivedUniforms/sceneIntegration.ts +19 -4
  119. package/src/runtime/flattenTree.ts +47 -2
  120. package/src/runtime/heapAdapter.ts +43 -5
  121. package/src/runtime/heapDecoder.ts +8 -3
  122. package/src/runtime/heapEffect.ts +44 -2
  123. package/src/runtime/heapEffectIR.ts +54 -11
  124. package/src/runtime/heapEligibility.ts +39 -12
  125. package/src/runtime/heapIrBuilders.ts +50 -2
  126. package/src/runtime/heapScene/chunkedArena.ts +29 -94
  127. package/src/runtime/heapScene/freelist.ts +3 -3
  128. package/src/runtime/heapScene/pools.ts +340 -218
  129. package/src/runtime/heapScene.ts +826 -142
  130. package/src/runtime/heapShaderFamily.ts +21 -0
  131. package/src/runtime/hybridScene.ts +43 -3
  132. package/src/runtime/index.ts +5 -0
  133. package/src/runtime/renderTask.ts +9 -0
  134. package/src/runtime/runtime.ts +18 -0
  135. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +52 -3
  136. package/src/runtime/textureAtlas/atlasPool.ts +28 -2
  137. package/src/window/loop.ts +32 -4
@@ -63,10 +63,28 @@ export interface RenderObject {
63
63
  readonly samplers: HashMap<string, aval<ISampler>>;
64
64
  /** name → storage buffer source. */
65
65
  readonly storageBuffers?: HashMap<string, aval<IBuffer>>;
66
+ /**
67
+ * Scene-level INJECTED storage buffers (e.g. the OIT node pool the
68
+ * transparency task threads into every transparent leaf). Unlike
69
+ * `storageBuffers`, these do NOT disqualify the RO from the heap
70
+ * path — the heap binds them once per bucket (they are shared by
71
+ * construction). The classic path treats them exactly like
72
+ * `storageBuffers` (leaf-own entries win on name conflict).
73
+ */
74
+ readonly injectedStorage?: HashMap<string, aval<IBuffer>>;
66
75
 
67
76
  /** Index buffer for indexed draws. */
68
77
  readonly indices?: BufferView;
69
78
  readonly drawCall: aval<DrawCall>;
79
+ /**
80
+ * Optional picking id (u32 < 2^24). First-class instead of a
81
+ * `PickId` uniform: one unique `AVal.constant` per RO would drag a
82
+ * pool entry + per-RO provider identity + adaptive bookkeeping in —
83
+ * measured ~6.5 KB of JS heap PER RO at scale. The heap path writes
84
+ * this INLINE into the drawHeader (like `__layoutId`); the classic
85
+ * path synthesizes the `PickId` uniform from it.
86
+ */
87
+ readonly pickId?: number;
70
88
  /**
71
89
  * Optional gate: when `active` ticks to `false`, the runtime must
72
90
  * skip drawing this RO this frame **without** releasing pool
@@ -12,7 +12,7 @@
12
12
  // PreparedRenderObject so flipping a non-pipeline-affecting field
13
13
  // (`stencilReference`, `blendConstant`) does NOT trigger a recompile.
14
14
 
15
- import {
15
+ import { type IUniformProvider,
16
16
  AdaptiveResource,
17
17
  AttributeProvider,
18
18
  BufferView,
@@ -340,7 +340,8 @@ function buildPipelineForSnap(
340
340
  }
341
341
  : undefined;
342
342
  const desc: CompileRenderPipelineDescription = {
343
- ...(ctx.label !== undefined ? { label: ctx.label } : {}),
343
+ // default label = the effect identity attributable GPU debugging
344
+ label: ctx.label ?? `classic/${ctx.effectId ?? "anon"}`,
344
345
  ...(ctx.effectId !== undefined ? { effectId: ctx.effectId } : {}),
345
346
  vertexShaderSource: ctx.vsSource,
346
347
  fragmentShaderSource: ctx.fsSource,
@@ -736,8 +737,16 @@ export function prepareRenderObject(
736
737
  const perGroup: EntryDesc[][] = [];
737
738
  for (let g = 0; g <= maxGroup; g++) perGroup.push([]);
738
739
 
740
+ // First-class `RenderObject.pickId` → synthesized `PickId` uniform on
741
+ // the classic path (the heap path writes it inline into the
742
+ // drawHeader instead — see INLINE_HEADER_UNIFORMS). Classic scenes
743
+ // are small, so a per-RO constant aval is fine here.
744
+ const uniformSource: IUniformProvider = obj.pickId !== undefined
745
+ ? withPickId(obj.pickId, asUniformProvider(obj.uniforms))
746
+ : asUniformProvider(obj.uniforms);
747
+
739
748
  for (const ub of iface.uniformBlocks) {
740
- const merged = mergeUniformInputs(asUniformProvider(obj.uniforms), effect.avalBindings, ub);
749
+ const merged = mergeUniformInputs(uniformSource, effect.avalBindings, ub);
741
750
  const block = ubAsBlock(ub);
742
751
  const res = prepareUniformBuffer(device, block, merged, {
743
752
  ...(opts.label !== undefined ? { label: `${opts.label}.${ub.name}` } : {}),
@@ -768,7 +777,7 @@ export function prepareRenderObject(
768
777
  }
769
778
 
770
779
  for (const sb of iface.storageBuffers) {
771
- const av = obj.storageBuffers?.tryFind(sb.name);
780
+ const av = obj.storageBuffers?.tryFind(sb.name) ?? obj.injectedStorage?.tryFind(sb.name);
772
781
  if (av === undefined) throw new Error(`prepareRenderObject: missing storage buffer "${sb.name}"`);
773
782
  const usage = BufferUsage.STORAGE
774
783
  | (sb.access === "read_write" ? BufferUsage.COPY_DST : 0);
@@ -811,7 +820,9 @@ export function prepareRenderObject(
811
820
  // the RO's provider (resolving nested derived-uniform rules too).
812
821
  let modeUniformReader: ((name: string, token: AdaptiveToken) => unknown) | undefined;
813
822
  if (obj.modeRules !== undefined) {
814
- const uniProv = asUniformProvider(obj.uniforms);
823
+ const uniProv = obj.pickId !== undefined
824
+ ? withPickId(obj.pickId, asUniformProvider(obj.uniforms))
825
+ : asUniformProvider(obj.uniforms);
815
826
  modeUniformReader = (name: string, token: AdaptiveToken): unknown => {
816
827
  const v = uniProv.tryGet(name);
817
828
  if (v === undefined) return undefined;
@@ -844,6 +855,15 @@ export function prepareRenderObject(
844
855
  const SENTINEL_PIPELINE = { __sentinel: "PreparedRenderObject.pipeline" } as unknown as GPURenderPipeline;
845
856
 
846
857
  // ---------------------------------------------------------------------------
858
+ /** Overlay provider: `PickId` first (constant), then `base`. */
859
+ function withPickId(pickId: number, base: IUniformProvider): IUniformProvider {
860
+ const pick = AVal.constant<number>(pickId);
861
+ return {
862
+ tryGet(name: string) { return name === "PickId" ? (pick as aval<unknown>) : base.tryGet(name); },
863
+ *names() { yield "PickId"; yield* base.names(); },
864
+ };
865
+ }
866
+
847
867
  // Uniform input merging — `obj.uniforms` wins; `avalBindings` is fallback.
848
868
  // ---------------------------------------------------------------------------
849
869
 
@@ -867,7 +887,10 @@ function mergeUniformInputs(
867
887
  // here just as the heap path derives it on the GPU, so the legacy path
868
888
  // serves the same declared uniforms.
869
889
  const recipe = STANDARD_DERIVED_RULES.get(f.name);
870
- if (recipe !== undefined) merged = merged.add(f.name, ruleToAval(recipe, user, bindings));
890
+ if (recipe !== undefined) { merged = merged.add(f.name, ruleToAval(recipe, user, bindings)); continue; }
891
+ // An unresolved field stays ZERO in the uniform buffer — silent garbage
892
+ // transforms/values downstream. Say so once per (block, field).
893
+ console.warn(`prepareRenderObject: uniform block field "${f.name}" resolves to undefined (buffer stays zero)`);
871
894
  }
872
895
  return merged;
873
896
  }
@@ -99,7 +99,9 @@ class UniformBufferResource extends AdaptiveResource<GPUBuffer> {
99
99
  const desc: GPUBufferDescriptor = {
100
100
  size: this._scratch.bytes.byteLength,
101
101
  usage: BufferUsage.UNIFORM | BufferUsage.COPY_DST,
102
- ...(this.label !== undefined ? { label: this.label } : {}),
102
+ // default label: the block's field names makes GPU debugging
103
+ // (writeBuffer interception, buffer inspectors) attributable.
104
+ label: this.label ?? `ubo{${this.layout.fields.map((f) => f.name).join(",")}}`,
103
105
  };
104
106
  this._gpu = this.device.createBuffer(desc);
105
107
  }
@@ -165,18 +165,32 @@ function stripFormatFor(topology: GPUPrimitiveTopology): GPUIndexFormat | undefi
165
165
  * — `addMarkingCallback` is a no-op for an aval that never marks, so
166
166
  * no special-casing required.
167
167
  */
168
+ /** Canonical descriptor per modeKey. The modeKey is a COMPLETE encoding
169
+ * of the descriptor (that's what makes it the bucket key), so two
170
+ * trackers with equal keys can share one frozen snapshot object —
171
+ * at heap scale most draws share a handful of pipeline states, and a
172
+ * per-draw descriptor (+ its attachments array) was measurable ballast.
173
+ * The map is bounded by the number of DISTINCT pipeline states. */
174
+ const descriptorInterning = new Map<bigint, PipelineStateDescriptor>();
175
+ function internDescriptor(key: bigint, d: PipelineStateDescriptor): PipelineStateDescriptor {
176
+ const hit = descriptorInterning.get(key);
177
+ if (hit !== undefined) return hit;
178
+ descriptorInterning.set(key, d);
179
+ return d;
180
+ }
181
+
168
182
  export class ModeKeyTracker implements IDisposable {
169
183
  readonly ps: PipelineState | undefined;
170
184
  readonly signature: FramebufferSignature;
171
185
  private readonly onDirty: () => void;
172
- private readonly subs: IDisposable[] = [];
186
+ private subs: IDisposable[] | undefined;
173
187
  private cachedDescriptor: PipelineStateDescriptor;
174
188
  private cachedModeKey: bigint;
175
189
  private readonly modeRules: RoModeRules | undefined;
176
190
  private readonly uniformAvals: ReadonlyMap<string, aval<unknown>> | undefined;
177
191
  /** Avals discovered while evaluating rules; merged into the leaf
178
192
  * set returned by `forEachLeaf` so the heap scene subscribes once. */
179
- private readonly ruleDeps = new Set<aval<unknown>>();
193
+ private ruleDeps: Set<aval<unknown>> | undefined;
180
194
 
181
195
  constructor(
182
196
  ps: PipelineState | undefined,
@@ -201,8 +215,9 @@ export class ModeKeyTracker implements IDisposable {
201
215
  this.onDirty = onDirty;
202
216
  if (options.modeRules !== undefined) this.modeRules = options.modeRules;
203
217
  if (options.uniformAvals !== undefined) this.uniformAvals = options.uniformAvals;
204
- this.cachedDescriptor = this.snapshot();
205
- this.cachedModeKey = encodeModeKey(this.cachedDescriptor);
218
+ const d = this.snapshot();
219
+ this.cachedModeKey = encodeModeKey(d);
220
+ this.cachedDescriptor = internDescriptor(this.cachedModeKey, d);
206
221
  if (options.skipSubscribe !== true) this.subscribeAll();
207
222
  }
208
223
 
@@ -210,7 +225,7 @@ export class ModeKeyTracker implements IDisposable {
210
225
  return snapshotDescriptor(this.ps, this.signature, {
211
226
  ...(this.modeRules !== undefined ? { modeRules: this.modeRules } : {}),
212
227
  ...(this.uniformAvals !== undefined ? { uniformAvals: this.uniformAvals } : {}),
213
- recordDep: (av) => { this.ruleDeps.add(av); },
228
+ recordDep: (av) => { (this.ruleDeps ??= new Set()).add(av); },
214
229
  });
215
230
  }
216
231
 
@@ -257,7 +272,7 @@ export class ModeKeyTracker implements IDisposable {
257
272
  }
258
273
  }
259
274
  }
260
- for (const av of this.ruleDeps) visit(av);
275
+ if (this.ruleDeps !== undefined) for (const av of this.ruleDeps) visit(av);
261
276
  }
262
277
 
263
278
  get descriptor(): PipelineStateDescriptor { return this.cachedDescriptor; }
@@ -274,18 +289,18 @@ export class ModeKeyTracker implements IDisposable {
274
289
  * subscription-dedupe layer picks them up.
275
290
  */
276
291
  recompute(): boolean {
277
- this.ruleDeps.clear();
292
+ this.ruleDeps?.clear();
278
293
  const next = this.snapshot();
279
294
  const nextKey = encodeModeKey(next);
280
295
  if (nextKey === this.cachedModeKey) return false;
281
- this.cachedDescriptor = next;
296
+ this.cachedDescriptor = internDescriptor(nextKey, next);
282
297
  this.cachedModeKey = nextKey;
283
298
  return true;
284
299
  }
285
300
 
286
301
  dispose(): void {
287
- for (const s of this.subs) s.dispose();
288
- this.subs.length = 0;
302
+ if (this.subs !== undefined) for (const s of this.subs) s.dispose();
303
+ this.subs = undefined;
289
304
  }
290
305
 
291
306
  private subscribeAll(): void {
@@ -318,7 +333,7 @@ export class ModeKeyTracker implements IDisposable {
318
333
  }
319
334
 
320
335
  private sub(a: aval<unknown>): void {
321
- this.subs.push(addMarkingCallback(a, this.onDirty));
336
+ (this.subs ??= []).push(addMarkingCallback(a, this.onDirty));
322
337
  }
323
338
 
324
339
  private subBlendState(b: PsBlendState): void {
@@ -248,6 +248,13 @@ export class GpuPartitionScene {
248
248
  this.masterShadow[recordIdx * this.recordU32 + 5] = comboId >>> 0;
249
249
  }
250
250
 
251
+ /** Update an existing record's `indexStart` (field 2 = allocBase + slice).
252
+ * Used by index-buffer compaction; caller flags the bucket partitionDirty. */
253
+ setRecordIndexStart(recordIdx: number, indexStart: number): void {
254
+ if (recordIdx < 0 || recordIdx >= this.numRecords) return;
255
+ this.masterShadow[recordIdx * this.recordU32 + 2] = indexStart >>> 0;
256
+ }
257
+
251
258
  /** Update an existing record's uniform ref by uniform index. */
252
259
  setRecordUniformRef(recordIdx: number, uniformIdx: number, ref: number): void {
253
260
  if (recordIdx < 0 || recordIdx >= this.numRecords) return;
@@ -255,6 +262,29 @@ export class GpuPartitionScene {
255
262
  this.masterShadow[recordIdx * this.recordU32 + PARTITION_RECORD_PREFIX_U32 + uniformIdx] = ref >>> 0;
256
263
  }
257
264
 
265
+ /**
266
+ * Re-seat every record's baked arena uniform refs after a main-heap
267
+ * compaction relocated their allocations. `remap` maps OLD→NEW arena byte
268
+ * offset (alloc header offset, same space the refs are stored in). Returns
269
+ * the number of ref words rewritten; the caller must flag the bucket
270
+ * `partitionDirty` so the master re-uploads. O(records × numUniforms).
271
+ */
272
+ remapUniformRefs(remap: ReadonlyMap<number, number>): number {
273
+ if (remap.size === 0 || this._numUniforms === 0) return 0;
274
+ const ru32 = this.recordU32;
275
+ let changed = 0;
276
+ for (let i = 0; i < this.numRecords; i++) {
277
+ const o = i * ru32 + PARTITION_RECORD_PREFIX_U32;
278
+ for (let k = 0; k < this._numUniforms; k++) {
279
+ const nn = remap.get(this.masterShadow[o + k]!);
280
+ if (nn === undefined) continue;
281
+ this.masterShadow[o + k] = nn >>> 0;
282
+ changed++;
283
+ }
284
+ }
285
+ return changed;
286
+ }
287
+
258
288
  removeRecord(recordIdx: number): number {
259
289
  if (recordIdx < 0 || recordIdx >= this.numRecords) return -1;
260
290
  const last = this.numRecords - 1;
@@ -88,28 +88,49 @@ function classify(entry: RuleEntry): Shape {
88
88
 
89
89
  // ─── WGSL fragments ───────────────────────────────────────────────────
90
90
 
91
+ // Error-free transforms survive ONLY if the compiler cannot see their
92
+ // algebra: WebKit's WGSL→MSL path compiles with fast-math, which folds
93
+ // (a+b)-a → b (two_sum dies) and distributes Dekker's split products into
94
+ // exact cancellation (two_prod dies) — df32 silently degrades to f32, the
95
+ // planet-scale 0.25m wobble on iOS. Desktop drivers fold the naked forms
96
+ // too. Defense: the EFT-critical constants 1/-1/0 come from the `Count`
97
+ // uniform, whose contents no shader compiler can constant-fold; validated
98
+ // per-primitive on Apple M-series + NVIDIA/Vulkan (df32-probe, variant J).
91
99
  const DF32_LIB = /* wgsl */ `
92
- fn split12(a: f32) -> vec2<f32> {
93
- let hi = bitcast<f32>(bitcast<u32>(a) & 0xFFFFE000u);
94
- return vec2<f32>(hi, a - hi);
95
- }
100
+ // launder: value-preserving, but an opaque integer op the optimizer
101
+ // cannot trace through (Count.zeroBits == 0 at runtime).
102
+ fn ln_(x: f32) -> f32 { return bitcast<f32>(bitcast<u32>(x) ^ Count.zeroBits); }
96
103
  fn two_sum(a: f32, b: f32) -> vec2<f32> {
97
104
  let s = a + b;
98
- let bb = fma(1.0, s, -a);
99
- let t1 = fma(1.0, s, -bb);
100
- let t2 = fma(1.0, a, -t1);
101
- let t3 = fma(1.0, b, -bb);
105
+ let bb = fma(Count.one, s, -a);
106
+ let t1 = fma(Count.one, s, -bb);
107
+ let t2 = fma(Count.one, a, -t1);
108
+ let t3 = fma(Count.one, b, -bb);
102
109
  return vec2<f32>(s, t2 + t3);
103
110
  }
104
111
  fn quick_two_sum(a: f32, b: f32) -> vec2<f32> {
105
112
  let s = a + b;
106
- let t = fma(1.0, s, -a);
107
- return vec2<f32>(s, fma(1.0, b, -t));
113
+ let t = fma(Count.one, s, -a);
114
+ return vec2<f32>(s, fma(Count.one, b, -t));
115
+ }
116
+ // Veltkamp split with LAUNDERED halves. The laundering is load-bearing on
117
+ // two fronts: Apple's fast-math distributes Dekker's partial products into
118
+ // exact symbolic cancellation (err ≡ a*b - p ≡ 0) unless the halves are
119
+ // opaque; and an fma-based two_prod (err = fma(a,b,-p)) is NOT an option —
120
+ // Qualcomm Adreno and Intel Xe lower fma() to an unfused multiply-add,
121
+ // which makes that error term exactly zero. Laundered Dekker needs no fma
122
+ // at all, so it is exact on every backend probed (NVIDIA/Vulkan, Apple
123
+ // M-series/Metal, Adreno/Android, Intel Xe/D3D).
124
+ fn lsplit12(a: f32) -> vec2<f32> {
125
+ // 12/12 mantissa split (11 explicit + implicit high bits): every partial
126
+ // product below is <= 24 bits, i.e. exactly representable in f32.
127
+ let hi = bitcast<f32>(bitcast<u32>(a) & 0xFFFFF000u);
128
+ return vec2<f32>(ln_(hi), ln_(a - hi));
108
129
  }
109
130
  fn two_prod(a: f32, b: f32) -> vec2<f32> {
110
131
  let p = a * b;
111
- let A = split12(a);
112
- let B = split12(b);
132
+ let A = lsplit12(a);
133
+ let B = lsplit12(b);
113
134
  let err = ((A.x * B.x - p) + A.x * B.y + A.y * B.x) + A.y * B.y;
114
135
  return vec2<f32>(p, err);
115
136
  }
@@ -165,7 +186,7 @@ function bindings(strideU32: number): string {
165
186
  @group(0) @binding(0) var<storage, read_write> Constituents: array<vec2<f32>>;
166
187
  @group(0) @binding(1) var<storage, read_write> MainHeap: array<f32>;
167
188
  @group(0) @binding(2) var<storage, read> RecordData: array<u32>;
168
- struct CountUniform { count: u32 }
189
+ struct CountUniform { count: u32, zeroBits: u32, one: f32, negOne: f32 }
169
190
  @group(0) @binding(3) var<uniform> Count: CountUniform;
170
191
 
171
192
  const RECORD_STRIDE: u32 = ${strideU32 >>> 0}u;
@@ -85,6 +85,12 @@ class RecordsGpu {
85
85
  size: 16, // uniform min binding size
86
86
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
87
87
  });
88
+ // Fields 1..3 are DF32_LIB's fast-math guards (see codegen.ts): zeroBits,
89
+ // one, negOne — runtime values the shader compiler cannot constant-fold.
90
+ const init = new ArrayBuffer(16);
91
+ new Uint32Array(init, 0, 2).set([0, 0]); // count, zeroBits
92
+ new Float32Array(init, 8, 2).set([1.0, -1.0]); // one, negOne
93
+ device.queue.writeBuffer(this.countBuf, 0, init);
88
94
  }
89
95
 
90
96
  /** Re-upload the packed record data if it changed since last sync, and refresh `Count`. */
@@ -48,7 +48,42 @@ const MIN_STRIDE_U32 = 5; // rule_id, out_slot, in0, in1, in2 — covers the 13
48
48
  export class RecordsBuffer {
49
49
  private buf: Uint32Array;
50
50
  private owners: (RecordOwner | undefined)[] = [];
51
- private readonly byOwner = new Map<RecordOwner, Set<number>>();
51
+ /** Record indices per owner. A single number for the common one-
52
+ * record-per-owner case, promoted to a small array on the second —
53
+ * saves a whole `Set` per RO at heap scale. */
54
+ private readonly byOwner = new Map<RecordOwner, number | number[]>();
55
+
56
+ private ownerAdd(owner: RecordOwner, idx: number): void {
57
+ const v = this.byOwner.get(owner);
58
+ if (v === undefined) this.byOwner.set(owner, idx);
59
+ else if (typeof v === "number") this.byOwner.set(owner, [v, idx]);
60
+ else v.push(idx);
61
+ }
62
+ private ownerDelete(owner: RecordOwner, idx: number): void {
63
+ const v = this.byOwner.get(owner);
64
+ if (v === undefined) return;
65
+ if (typeof v === "number") {
66
+ if (v === idx) this.byOwner.delete(owner);
67
+ return;
68
+ }
69
+ const i = v.indexOf(idx);
70
+ if (i >= 0) {
71
+ v[i] = v[v.length - 1]!;
72
+ v.pop();
73
+ if (v.length === 1) this.byOwner.set(owner, v[0]!);
74
+ else if (v.length === 0) this.byOwner.delete(owner);
75
+ }
76
+ }
77
+ private ownerReplace(owner: RecordOwner, oldIdx: number, newIdx: number): void {
78
+ const v = this.byOwner.get(owner);
79
+ if (v === undefined) return;
80
+ if (typeof v === "number") {
81
+ if (v === oldIdx) this.byOwner.set(owner, newIdx);
82
+ return;
83
+ }
84
+ const i = v.indexOf(oldIdx);
85
+ if (i >= 0) v[i] = newIdx;
86
+ }
52
87
  private strideU32 = MIN_STRIDE_U32;
53
88
  private count = 0;
54
89
  /** Bumps whenever the byte layout changes shape (stride growth) so the dispatcher recompiles. */
@@ -86,44 +121,64 @@ export class RecordsBuffer {
86
121
  for (let i = 0; i < inSlots.length; i++) this.buf[base + 2 + i] = inSlots[i]! >>> 0;
87
122
  for (let i = base + 2 + inSlots.length; i < base + this.strideU32; i++) this.buf[i] = 0;
88
123
  this.owners[idx] = owner;
89
- let set = this.byOwner.get(owner);
90
- if (set === undefined) {
91
- set = new Set();
92
- this.byOwner.set(owner, set);
93
- }
94
- set.add(idx);
124
+ this.ownerAdd(owner, idx);
95
125
  this.generation++;
96
126
  return idx;
97
127
  }
98
128
 
99
129
  /** Remove every record owned by `owner` (swap-remove against the tail). */
100
130
  removeAllForOwner(owner: RecordOwner): void {
101
- const set = this.byOwner.get(owner);
102
- if (set === undefined || set.size === 0) {
103
- this.byOwner.delete(owner);
131
+ const v = this.byOwner.get(owner);
132
+ if (v === undefined) {
104
133
  return;
105
134
  }
106
135
  // Process highest indices first so a swap can never move a not-yet-removed
107
136
  // record of this owner that we still hold a stale index for.
108
- const indices = [...set].sort((a, b) => b - a);
137
+ const indices = typeof v === "number" ? [v] : [...v].sort((a, b) => b - a);
109
138
  for (const idx of indices) this.removeAt(idx);
110
139
  this.byOwner.delete(owner);
111
140
  this.generation++;
112
141
  }
113
142
 
143
+ /**
144
+ * Re-seat `HostHeap` handles after a main-heap compaction relocated their
145
+ * backing allocations. `remap` maps an OLD handle payload (the data byte
146
+ * offset in the main heap) to its NEW one. `Constituent` handles point into
147
+ * the separate df32 slot buffer (never compacted) and are left untouched.
148
+ * Returns the number of handle words rewritten; bumps `generation` so the
149
+ * dispatcher re-uploads. O(records × stride).
150
+ */
151
+ remapHostHeap(remap: ReadonlyMap<number, number>): number {
152
+ if (remap.size === 0 || this.count === 0) return 0;
153
+ const s = this.strideU32;
154
+ let changed = 0;
155
+ for (let i = 0; i < this.count; i++) {
156
+ const base = i * s;
157
+ // out_slot at base+1, in_slots at base+2 .. base+s-1.
158
+ for (let w = base + 1; w < base + s; w++) {
159
+ const h = this.buf[w]!;
160
+ if (handleTag(h) !== SlotTag.HostHeap) continue;
161
+ const nn = remap.get(handlePayload(h));
162
+ if (nn === undefined) continue;
163
+ this.buf[w] = makeHandle(SlotTag.HostHeap, nn);
164
+ changed++;
165
+ }
166
+ }
167
+ if (changed > 0) this.generation++;
168
+ return changed;
169
+ }
170
+
114
171
  private removeAt(idx: number): void {
115
172
  const last = this.count - 1;
116
173
  // The record currently at `idx` is the one being removed.
117
- this.byOwner.get(this.owners[idx]!)?.delete(idx);
174
+ this.ownerDelete(this.owners[idx]!, idx);
118
175
  if (idx !== last) {
119
176
  // Move the tail record's words into the hole, and re-home it.
120
177
  const dst = idx * this.strideU32;
121
178
  const src = last * this.strideU32;
122
179
  this.buf.copyWithin(dst, src, src + this.strideU32);
123
180
  const lastOwner = this.owners[last]!;
124
- const lset = this.byOwner.get(lastOwner)!;
125
- lset.delete(last);
126
- lset.add(idx);
181
+ this.ownerReplace(lastOwner, last, idx);
127
182
  this.owners[idx] = lastOwner;
128
183
  }
129
184
  this.owners[last] = undefined;
@@ -109,6 +109,16 @@ export class DerivedUniformsScene {
109
109
  return r;
110
110
  }
111
111
 
112
+ /**
113
+ * Re-seat the per-chunk derived-uniform records' `HostHeap` handles
114
+ * (derived outputs and host-uniform inputs) after the main heap compacted
115
+ * `chunkIdx`. `remap` maps OLD→NEW data byte offsets. Returns words changed.
116
+ */
117
+ remapHostHeap(chunkIdx: number, remap: ReadonlyMap<number, number>): number {
118
+ const r = this.recordsByChunk.get(chunkIdx);
119
+ return r === undefined ? 0 : r.remapHostHeap(remap);
120
+ }
121
+
112
122
  /** Total record count summed across every chunk's records buffer.
113
123
  * Used by the scene's stats / diagnostics. */
114
124
  get totalRecordCount(): number {
@@ -264,14 +274,19 @@ export class TrafoTree {
264
274
  this.byKey.set(key, node);
265
275
  const h = (s: number): number => makeHandle(SlotTag.Constituent, s);
266
276
  const recs = this.scene.chainRecordsFor(level);
267
- // node.world.fwd = link.fwd · parent.world.fwd (just link at the root end).
268
- // node.world.inv = parent.world.inv · link.inv (reversed; just link at root).
277
+ // node.world.fwd = parent.world.fwd · link.fwd (just link at the root end):
278
+ // deeper links sit on the RIGHT so they hit the column vector FIRST —
279
+ // p' = root · … · leaf · p, the scene-graph inner-first contract. (The
280
+ // previous `link · parent` order applied ancestors first, which put nested
281
+ // child trafos in WORLD space — invisible for the ubiquitous single-link
282
+ // chains, wrong for e.g. a translate under an adaptive billboard trafo.)
283
+ // node.world.inv = link.inv · parent.world.inv (reversed accordingly).
269
284
  if (parent === undefined) {
270
285
  recs.add(node, CHAIN_RULE_ID, h(modelPair.fwd), [1, h(link.fwd)]);
271
286
  recs.add(node, CHAIN_RULE_ID, h(modelPair.inv), [1, h(link.inv)]);
272
287
  } else {
273
- recs.add(node, CHAIN_RULE_ID, h(modelPair.fwd), [2, h(link.fwd), h(parent.modelPair.fwd)]);
274
- recs.add(node, CHAIN_RULE_ID, h(modelPair.inv), [2, h(parent.modelPair.inv), h(link.inv)]);
288
+ recs.add(node, CHAIN_RULE_ID, h(modelPair.fwd), [2, h(parent.modelPair.fwd), h(link.fwd)]);
289
+ recs.add(node, CHAIN_RULE_ID, h(modelPair.inv), [2, h(link.inv), h(parent.modelPair.inv)]);
275
290
  }
276
291
  return node;
277
292
  }
@@ -19,6 +19,49 @@ import { AListBridges } from "@aardworx/wombat.adaptive";
19
19
  import type { RenderObject } from "../core/renderObject.js";
20
20
  import type { RenderTree } from "../core/renderTree.js";
21
21
 
22
+ /** True when `tree` contains no reactive nodes — its RenderObjects can
23
+ * be enumerated as a plain iterable (no inner aset / reader needed). */
24
+ function isStaticTree(tree: RenderTree): boolean {
25
+ switch (tree.kind) {
26
+ case "Empty":
27
+ case "Leaf":
28
+ return true;
29
+ case "Ordered":
30
+ case "Unordered":
31
+ return tree.children.every(isStaticTree);
32
+ default:
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Enumerate a static subtree's RenderObjects (pre: `isStaticTree`). */
38
+ function* staticObjects(tree: RenderTree): Iterable<RenderObject> {
39
+ switch (tree.kind) {
40
+ case "Empty":
41
+ return;
42
+ case "Leaf":
43
+ yield tree.object;
44
+ return;
45
+ case "Ordered":
46
+ case "Unordered":
47
+ for (const c of tree.children) yield* staticObjects(c);
48
+ return;
49
+ default:
50
+ throw new Error(`staticObjects: reactive node '${tree.kind}'`);
51
+ }
52
+ }
53
+
54
+ /** Flatten a reactive container of subtrees. The static children go
55
+ * through `collectSeq` (plain iterables — ONE reader for the whole
56
+ * set, instead of a ConstantAset + reader + subscription PER LEAF,
57
+ * which at heap scale was megabytes of ballast); reactive children
58
+ * keep the full `collect`. */
59
+ function flattenChildSet(children: aset<RenderTree>): aset<RenderObject> {
60
+ const staticPart = children.filter(isStaticTree).collectSeq(staticObjects);
61
+ const reactivePart = children.filter(t => !isStaticTree(t)).collect(flattenRenderTree);
62
+ return ASet.union(staticPart, reactivePart);
63
+ }
64
+
22
65
  export function flattenRenderTree(tree: RenderTree): aset<RenderObject> {
23
66
  switch (tree.kind) {
24
67
  case "Empty":
@@ -27,6 +70,8 @@ export function flattenRenderTree(tree: RenderTree): aset<RenderObject> {
27
70
  return ASet.single(tree.object);
28
71
  case "Ordered":
29
72
  case "Unordered": {
73
+ // Fully-static subtree → one constant aset over a plain array.
74
+ if (isStaticTree(tree)) return ASet.ofArray([...staticObjects(tree)]);
30
75
  // N-ary union via `unionMany(aset<aset<T>>)`. The naïve
31
76
  // `children.reduce(ASet.union)` builds a 2N-deep nested-union
32
77
  // tree; the reader's traversal recurses through it and blows
@@ -39,8 +84,8 @@ export function flattenRenderTree(tree: RenderTree): aset<RenderObject> {
39
84
  case "Adaptive":
40
85
  return ASet.bind(flattenRenderTree, tree.tree);
41
86
  case "OrderedFromList":
42
- return ASet.collect(flattenRenderTree, AListBridges.toASet(tree.children));
87
+ return flattenChildSet(AListBridges.toASet(tree.children));
43
88
  case "UnorderedFromSet":
44
- return ASet.collect(flattenRenderTree, tree.children);
89
+ return flattenChildSet(tree.children);
45
90
  }
46
91
  }