@aardworx/wombat.rendering 0.9.23 → 0.9.25

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.
@@ -23,14 +23,17 @@
23
23
 
24
24
  import { GPU_FLIP_CULL_BY_DET_WGSL, CULL_TO_U32 } from "./gpuKernel.js";
25
25
  import type { CullMode } from "../pipelineCache/index.js";
26
+ import type { aval } from "@aardworx/wombat.adaptive";
27
+ import { AdaptiveToken } from "@aardworx/wombat.adaptive";
26
28
 
27
29
  const NO_RULE = 0xFFFFFFFF;
28
30
 
29
31
  interface RoEntry {
30
32
  /** Byte offset of the RO's ModelTrafo mat4 in the arena. */
31
33
  modelRef: number;
32
- /** SG-declared cull mode, encoded as u32 (0=none/1=front/2=back). */
33
- declaredU32: number;
34
+ /** SG-declared cull mode either a plain value or an aval whose
35
+ * current value the dispatcher reads each frame. */
36
+ declared: CullMode | aval<CullMode>;
34
37
  }
35
38
 
36
39
  const WG_SIZE = 64;
@@ -55,9 +58,6 @@ export class GpuDerivedModesScene {
55
58
  private capacity = 0;
56
59
  /** Last-known arena buffer, used to detect bind-group invalidation. */
57
60
  private arenaBuf: GPUBuffer | null = null;
58
- /** Last-known declared (for kernel uniform). Common case: all RO
59
- * rules share the same declared (= the SG scope's value). */
60
- private declared: number = CULL_TO_U32.back;
61
61
  /** True while the staging buffer's mapAsync is in flight. We must
62
62
  * not enqueue a new copyBufferToBuffer into it until it's been
63
63
  * unmapped (mapAsync resolved + unmap called). */
@@ -73,11 +73,14 @@ export class GpuDerivedModesScene {
73
73
 
74
74
  get registered(): number { return this.liveCount; }
75
75
 
76
- registerRo(drawId: number, modelRef: number, declared: CullMode): void {
77
- this.entries[drawId] = { modelRef, declaredU32: CULL_TO_U32[declared] };
76
+ registerRo(
77
+ drawId: number,
78
+ modelRef: number,
79
+ declared: CullMode | aval<CullMode>,
80
+ ): void {
81
+ this.entries[drawId] = { modelRef, declared };
78
82
  this.liveCount++;
79
83
  this.dirty = true;
80
- this.declared = CULL_TO_U32[declared];
81
84
  }
82
85
 
83
86
  deregisterRo(drawId: number): void {
@@ -144,12 +147,28 @@ export class GpuDerivedModesScene {
144
147
  /** Upload current per-RO input refs + params; called when dirty. */
145
148
  private uploadInputs(numROs: number): void {
146
149
  const refs = new Uint32Array(this.capacity).fill(NO_RULE);
150
+ let declaredU32: number = CULL_TO_U32.back;
147
151
  for (let i = 0; i < numROs; i++) {
148
152
  const e = this.entries[i];
149
- if (e !== undefined) refs[i] = e.modelRef;
153
+ if (e !== undefined) {
154
+ refs[i] = e.modelRef;
155
+ // Resolve the declared each dispatch. v1 supports one shared
156
+ // declared per kernel (single uniform in the WGSL). Last
157
+ // registered entry's declared wins. For the typical SG shape
158
+ // where a single `<Sg CullMode={cv}>` wraps every leaf, every
159
+ // entry's declared is the same aval — so picking the last is
160
+ // equivalent to picking any. If the user genuinely binds
161
+ // distinct declareds across the scene, an axis-domain
162
+ // analyzer would be the correct generalization.
163
+ const d = e.declared;
164
+ const v = typeof d === "string"
165
+ ? d
166
+ : (d as aval<CullMode>).getValue(AdaptiveToken.top);
167
+ declaredU32 = CULL_TO_U32[v];
168
+ }
150
169
  }
151
170
  this.device.queue.writeBuffer(this.inputBuf!, 0, refs.buffer, refs.byteOffset, refs.byteLength);
152
- const params = new Uint32Array([numROs, this.declared, 0, 0]);
171
+ const params = new Uint32Array([numROs, declaredU32, 0, 0]);
153
172
  this.device.queue.writeBuffer(this.paramsBuf!, 0, params.buffer, params.byteOffset, params.byteLength);
154
173
  }
155
174
 
@@ -67,8 +67,16 @@ export interface GpuRuleSpec<A extends ModeAxis = ModeAxis> {
67
67
  readonly kernel: "flipCullByDeterminant";
68
68
  /** Name of the input uniform the kernel reads (e.g. "ModelTrafo"). */
69
69
  readonly inputUniform: string;
70
- /** Declared (SG-scope) value the kernel mutates by. */
71
- readonly declared: ModeValue<A>;
70
+ /**
71
+ * Declared (SG-scope) value the kernel mutates by. Either a plain
72
+ * mode value (baked at scene-build) or an `aval` for reactive
73
+ * declared — the dispatcher re-uploads the kernel uniform on each
74
+ * dispatch from the aval's current value, so flipping it via a UI
75
+ * cval re-runs the rule with the new declared.
76
+ */
77
+ readonly declared:
78
+ | ModeValue<A>
79
+ | import("@aardworx/wombat.adaptive").aval<ModeValue<A>>;
72
80
  }
73
81
 
74
82
  export interface DerivedModeRule<A extends ModeAxis = ModeAxis> {
@@ -144,7 +152,10 @@ export function flipCull(c: CullMode): CullMode {
144
152
  */
145
153
  export function gpuFlipCullByDeterminant(
146
154
  inputUniform: string,
147
- declared: CullMode = "back",
155
+ declared:
156
+ | CullMode
157
+ | import("@aardworx/wombat.adaptive").aval<CullMode>
158
+ = "back",
148
159
  ): DerivedModeRule<"cull"> {
149
160
  return {
150
161
  __derivedModeRule: true,
@@ -637,6 +637,12 @@ export function buildHeapScene(
637
637
  modeAvalToDrawIds.set(av, set);
638
638
  modeAvalCallbacks.set(av, addMarkingCallback(av, () => {
639
639
  for (const id of set!) dirtyModeKeyDrawIds.add(id);
640
+ // Mark the GPU mode-rules dispatcher dirty too — at least one
641
+ // of those drawIds may carry a GPU rule whose input changed.
642
+ // The dispatch path checks consumeDirty() before encoding, so
643
+ // an unrelated aval mark (e.g. Color change on hover) leaves
644
+ // the kernel quiet.
645
+ gpuModesScene?.markDirty();
640
646
  }));
641
647
  }
642
648
  set.add(drawId);
@@ -2097,10 +2103,14 @@ export function buildHeapScene(
2097
2103
  if (inputRef !== undefined) {
2098
2104
  if (gpuModesScene === undefined) gpuModesScene = new GpuDerivedModesScene(device);
2099
2105
  gpuModesScene.registerRo(drawId, inputRef, gpuRule.declared);
2106
+ // If declared is reactive (e.g. a shared cullModeC cval),
2107
+ // subscribe so flipping it marks the dispatcher dirty.
2108
+ // Plain values: no subscription needed.
2109
+ if (typeof gpuRule.declared !== "string") {
2110
+ subscribeModeLeaf(gpuRule.declared as aval<unknown>, drawId);
2111
+ }
2100
2112
  if (drawId < 3 || drawId % 20 === 0) {
2101
- // Sparse logging so we can confirm registration without
2102
- // spamming for big scenes.
2103
- console.debug(`[heapScene] GPU rule registered drawId=${drawId} input='${gpuRule.inputUniform}' ref=${inputRef} declared=${gpuRule.declared}`);
2113
+ console.debug(`[heapScene] GPU rule registered drawId=${drawId} input='${gpuRule.inputUniform}' ref=${inputRef} declared=${typeof gpuRule.declared === "string" ? gpuRule.declared : "<aval>"}`);
2104
2114
  }
2105
2115
  } else {
2106
2116
  console.warn(`[heapScene] GPU rule input '${gpuRule.inputUniform}' not in spec.inputs for drawId=${drawId}; rule disabled`);
@@ -2391,9 +2401,25 @@ export function buildHeapScene(
2391
2401
  arr.push(drawId);
2392
2402
  }
2393
2403
 
2404
+ // Two-phase rebucket: collect all proposed renames first, then
2405
+ // commit them atomically. Avoids the "two buckets swap names"
2406
+ // conflict (e.g. cullModeC flip with both 'back' and 'front'
2407
+ // buckets present — A wants 'front', B wants 'back', they
2408
+ // collide). After phase 1 every fast-path bucket is removed
2409
+ // from bucketByKey, freeing both keys for phase 2's atomic
2410
+ // re-insertion. Slow per-RO rebuckets handle anything that
2411
+ // still conflicts (rare).
2412
+ type Rename = {
2413
+ bucket: Bucket;
2414
+ repId: number;
2415
+ newKey: bigint;
2416
+ newBk: string;
2417
+ oldBk: string;
2418
+ };
2419
+ const renames: Rename[] = [];
2420
+ const slowChanged: number[] = []; // ROs going through per-RO
2421
+
2394
2422
  for (const [bucket, ids] of byBucket) {
2395
- // First recompute every dirty tracker so .modeKey is fresh.
2396
- // Filter out ones whose value didn't actually change.
2397
2423
  const changed: number[] = [];
2398
2424
  for (const drawId of ids) {
2399
2425
  const tracker = drawIdToModeTracker[drawId];
@@ -2405,61 +2431,73 @@ export function buildHeapScene(
2405
2431
  }
2406
2432
  if (changed.length === 0) continue;
2407
2433
 
2408
- // Try the all-together fast path.
2409
2434
  const wholeBucket = changed.length === bucket.drawSlots.size;
2410
2435
  if (wholeBucket) {
2411
2436
  const repId = changed[0]!;
2412
2437
  const repTracker = drawIdToModeTracker[repId]!;
2413
- const newKey = repTracker.modeKey;
2414
- // Compute the new bucketByKey string; we need the family.
2415
2438
  const sample = drawIdToSpec[repId]!;
2416
2439
  const fam = familyFor(sample.effect);
2440
+ const newKey = repTracker.modeKey;
2417
2441
  const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
2418
- const existing = bucketByKey.get(newBk);
2419
- if (existing === undefined) {
2420
- // No conflict rename + rebuild pipeline in place.
2421
- // Find this bucket's current key by linear scan
2422
- // (buckets ~100 typically, so this is cheap).
2423
- let oldBk: string | undefined;
2424
- for (const [k, v] of bucketByKey) {
2425
- if (v === bucket) { oldBk = k; break; }
2426
- }
2427
- if (oldBk !== undefined) bucketByKey.delete(oldBk);
2428
- bucketByKey.set(newBk, bucket);
2429
- // Build the new pipeline with the snapshotted PS.
2430
- const desc = repTracker.descriptor;
2431
- const { pipelineLayout } = getBgl(bucket.layout, bucket.isAtlasBucket);
2432
- const newPipeline = device.createRenderPipeline({
2433
- label: `heapScene/${newBk}/pipeline`,
2434
- layout: pipelineLayout,
2435
- vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
2436
- fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
2437
- primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
2438
- ...(depthFormat !== undefined && desc.depth !== undefined
2439
- ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
2440
- : depthFormat !== undefined
2441
- ? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" as GPUCompareFunction } }
2442
- : {}),
2443
- });
2444
- // Swap pipeline reference on the bucket's single slot.
2445
- // (Multi-slot Phase 5c would update one slot at a time.)
2446
- (bucket.slots[0] as { pipeline: GPURenderPipeline }).pipeline = newPipeline;
2447
- continue; // done with this bucket
2442
+ let oldBk: string | undefined;
2443
+ for (const [k, v] of bucketByKey) {
2444
+ if (v === bucket) { oldBk = k; break; }
2445
+ }
2446
+ if (oldBk !== undefined && oldBk !== newBk) {
2447
+ renames.push({ bucket, repId, newKey, newBk, oldBk });
2448
+ continue;
2448
2449
  }
2449
- // Conflict: another bucket already owns newBk. Fall through
2450
- // to per-RO rebucket below, which will merge into `existing`.
2450
+ if (oldBk === newBk) continue; // shouldn't happen but safe
2451
2451
  }
2452
- // Slow path per-RO rebucket (used when partial transition
2453
- // OR when a merge with an existing destination bucket is
2454
- // required).
2455
- for (const drawId of changed) {
2456
- const spec = drawIdToSpec[drawId];
2457
- if (spec === undefined) continue;
2458
- removeDraw(drawId);
2459
- const newId = addDrawImpl(spec, tok);
2460
- void newId;
2452
+ // Partial transition: per-RO slow path.
2453
+ for (const c of changed) slowChanged.push(c);
2454
+ }
2455
+
2456
+ if (renames.length > 0) {
2457
+ // Phase 1: remove all old keys. After this, the renames' new
2458
+ // keys are free unless they collide with a NON-rename
2459
+ // bucket's current key.
2460
+ for (const r of renames) bucketByKey.delete(r.oldBk);
2461
+ // Phase 2: assign new keys, building new pipelines. If a
2462
+ // collision still exists after phase 1 (against a stale
2463
+ // bucket that's NOT being renamed), restore old key + fall
2464
+ // back to per-RO for that bucket's records.
2465
+ for (const r of renames) {
2466
+ if (bucketByKey.has(r.newBk)) {
2467
+ // Collision with a stale bucket. Restore + flag for slow.
2468
+ bucketByKey.set(r.oldBk, r.bucket);
2469
+ for (const id of r.bucket.drawSlots) slowChanged.push(id);
2470
+ continue;
2471
+ }
2472
+ bucketByKey.set(r.newBk, r.bucket);
2473
+ const tracker = drawIdToModeTracker[r.repId]!;
2474
+ const sample = drawIdToSpec[r.repId]!;
2475
+ const desc = tracker.descriptor;
2476
+ const { pipelineLayout } = getBgl(r.bucket.layout, r.bucket.isAtlasBucket);
2477
+ const newPipeline = device.createRenderPipeline({
2478
+ label: `heapScene/${r.newBk}/pipeline`,
2479
+ layout: pipelineLayout,
2480
+ vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
2481
+ fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
2482
+ primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
2483
+ ...(depthFormat !== undefined && desc.depth !== undefined
2484
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
2485
+ : depthFormat !== undefined
2486
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" as GPUCompareFunction } }
2487
+ : {}),
2488
+ });
2489
+ (r.bucket.slots[0] as { pipeline: GPURenderPipeline }).pipeline = newPipeline;
2461
2490
  }
2462
2491
  }
2492
+
2493
+ // Slow per-RO rebucket for the leftover cases.
2494
+ for (const drawId of slowChanged) {
2495
+ const spec = drawIdToSpec[drawId];
2496
+ if (spec === undefined) continue;
2497
+ removeDraw(drawId);
2498
+ const newId = addDrawImpl(spec, tok);
2499
+ void newId;
2500
+ }
2463
2501
  }
2464
2502
 
2465
2503
  // 1. Pool: re-pack any aval whose value changed since last frame.
@@ -2601,7 +2639,13 @@ export function buildHeapScene(
2601
2639
  // Resolved on the next frame's update() — one-frame lag for the
2602
2640
  // first appearance of a state transition. CPU fallback runs at
2603
2641
  // addDraw time so the initial bucket assignment is still correct.
2604
- if (gpuModesScene !== undefined && gpuModesScene.registered > 0) {
2642
+ if (gpuModesScene !== undefined
2643
+ && gpuModesScene.registered > 0
2644
+ && gpuModesScene.consumeDirty()) {
2645
+ // Dirty-gate: skip the kernel + readback when no rule input
2646
+ // changed since the last dispatch. Unrelated avals (Color on
2647
+ // hover, etc.) don't trigger dirty, so steady state has zero
2648
+ // GPU mode-rule work per frame.
2605
2649
  const numROs = nextDrawId; // upper bound; sparse entries are skipped via 0xFFFFFFFF sentinel
2606
2650
  gpuModesScene.dispatch(arena.attrs.buffer, numROs, enc);
2607
2651
  // mapAsync the staging buffer AFTER the caller's submit. The
@@ -2610,39 +2654,36 @@ export function buildHeapScene(
2610
2654
  // submitted the enc. Without this deferral, mapAsync fires
2611
2655
  // before submit and WebGPU rejects the next dispatch's
2612
2656
  // copy-to-staging with "buffer used in submit while mapped".
2657
+ // v1.5: the readback used to PATCH spec.modeRules.cull with the
2658
+ // GPU output as a constant rule, then mark the drawId dirty so
2659
+ // the next update() rebucketed via the patched value. That had
2660
+ // a fatal bug: per-RO rebucket (the slow-path fallback) creates
2661
+ // a NEW tracker that reads the patched (constant) rule —
2662
+ // permanently breaking the rule's reactivity. Second cullModeC
2663
+ // flip → trackers re-evaluate → constant rule returns the same
2664
+ // value → no rebucket. So patching is gone.
2665
+ //
2666
+ // The CPU fallback already routes ROs correctly: its evaluate
2667
+ // is the same algorithm as the kernel (sign(det(upper3x3))), so
2668
+ // CPU and GPU agree. The readback now runs purely as a
2669
+ // verification signal — disagreements indicate a CPU/GPU eval
2670
+ // drift bug. Phase 5c.2 (no-readback design) drops it entirely.
2613
2671
  Promise.resolve().then(() => gpuModesScene!.finish(numROs)).then(() => {
2614
2672
  const out = gpuModesScene!.lastOutput;
2615
- let changes = 0;
2673
+ let mismatches = 0;
2616
2674
  for (let i = 0; i < numROs; i++) {
2617
2675
  const cur = out[i] ?? 0xFFFFFFFF;
2618
- const prev = gpuModesLastKey.get(i);
2619
- if (cur === 0xFFFFFFFF) continue; // no rule on this RO
2620
- if (prev === cur) continue;
2621
- changes++;
2622
- gpuModesLastKey.set(i, cur);
2623
- // Project the kernel's u32 enum back to a CullMode value,
2624
- // store on the tracker's modeRules so the next snapshot
2625
- // picks it up, then schedule a rebucket.
2676
+ if (cur === 0xFFFFFFFF) continue;
2626
2677
  const tracker = drawIdToModeTracker[i];
2627
- const spec = drawIdToSpec[i];
2628
- if (tracker === undefined || spec === undefined) continue;
2629
- const cull = U32_TO_CULL[cur];
2630
- if (cull === undefined) continue;
2631
- // Inject the GPU result by patching the spec's CPU rule to
2632
- // return a constant. Slightly hacky but contained: the next
2633
- // recompute() will read this and route to the right bucket.
2634
- const patched: typeof spec.modeRules = {
2635
- ...spec.modeRules,
2636
- cull: {
2637
- __derivedModeRule: true, axis: "cull",
2638
- evaluate: () => cull,
2639
- },
2640
- };
2641
- (spec as { modeRules?: typeof spec.modeRules }).modeRules = patched;
2642
- dirtyModeKeyDrawIds.add(i);
2643
- }
2644
- if (changes > 0) {
2645
- console.debug(`[heapScene] GPU rule readback: ${changes}/${numROs} ROs changed modeKey`);
2678
+ if (tracker === undefined) continue;
2679
+ // Decode the tracker's current cullMode (CPU result) into the
2680
+ // same u32 enum the kernel emits and compare. Drift logs once.
2681
+ const cpuCull = tracker.descriptor.cullMode;
2682
+ const cpuU32 = cpuCull === "none" ? 0 : cpuCull === "front" ? 1 : 2;
2683
+ if (cpuU32 !== cur && mismatches < 3) {
2684
+ mismatches++;
2685
+ console.warn(`[heapScene] GPU/CPU mode drift drawId=${i} cpu=${cpuCull}(${cpuU32}) gpu=${U32_TO_CULL[cur]}(${cur})`);
2686
+ }
2646
2687
  }
2647
2688
  }).catch((e) => {
2648
2689
  // mapAsync can reject if the device was lost or the scene was