@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.
- package/dist/runtime/derivedModes/gpuDispatcher.d.ts +2 -4
- package/dist/runtime/derivedModes/gpuDispatcher.d.ts.map +1 -1
- package/dist/runtime/derivedModes/gpuDispatcher.js +19 -7
- package/dist/runtime/derivedModes/gpuDispatcher.js.map +1 -1
- package/dist/runtime/derivedModes/rule.d.ts +9 -3
- package/dist/runtime/derivedModes/rule.d.ts.map +1 -1
- package/dist/runtime/derivedModes/rule.js.map +1 -1
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +106 -82
- package/dist/runtime/heapScene.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/derivedModes/gpuDispatcher.ts +29 -10
- package/src/runtime/derivedModes/rule.ts +14 -3
- package/src/runtime/heapScene.ts +119 -78
|
@@ -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
|
|
33
|
-
|
|
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(
|
|
77
|
-
|
|
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)
|
|
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,
|
|
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
|
-
/**
|
|
71
|
-
|
|
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:
|
|
155
|
+
declared:
|
|
156
|
+
| CullMode
|
|
157
|
+
| import("@aardworx/wombat.adaptive").aval<CullMode>
|
|
158
|
+
= "back",
|
|
148
159
|
): DerivedModeRule<"cull"> {
|
|
149
160
|
return {
|
|
150
161
|
__derivedModeRule: true,
|
package/src/runtime/heapScene.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
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
|
-
|
|
2450
|
-
// to per-RO rebucket below, which will merge into `existing`.
|
|
2450
|
+
if (oldBk === newBk) continue; // shouldn't happen but safe
|
|
2451
2451
|
}
|
|
2452
|
-
//
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
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
|
|
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
|
|
2673
|
+
let mismatches = 0;
|
|
2616
2674
|
for (let i = 0; i < numROs; i++) {
|
|
2617
2675
|
const cur = out[i] ?? 0xFFFFFFFF;
|
|
2618
|
-
|
|
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
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
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
|