@aardworx/wombat.rendering 0.1.1 → 0.2.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.
@@ -4,6 +4,13 @@
4
4
  // Reads the wombat.shader `ProgramInterface` for everything needed
5
5
  // to lay out vertex buffers, bind groups, color targets, and draw
6
6
  // state.
7
+ //
8
+ // Pipeline state is fully reactive: the pipeline-influencing avals
9
+ // (rasterizer / depth / stencil ops / blends / alphaToCoverage) are
10
+ // snapshotted at token-evaluation time into a cache key; the
11
+ // resulting `GPURenderPipeline` is cached per-key in the
12
+ // PreparedRenderObject so flipping a non-pipeline-affecting field
13
+ // (`stencilReference`, `blendConstant`) does NOT trigger a recompile.
7
14
 
8
15
  import {
9
16
  AdaptiveResource,
@@ -56,8 +63,6 @@ interface VertexBindingInfo {
56
63
  }
57
64
 
58
65
  function vertexBindingsFor(iface: ProgramInterface): VertexBindingInfo[] {
59
- // `iface.attributes` is already filtered to non-builtin vertex inputs
60
- // by wombat.shader's interface builder.
61
66
  return iface.attributes.map(a => ({
62
67
  name: a.name,
63
68
  slot: a.location,
@@ -66,19 +71,127 @@ function vertexBindingsFor(iface: ProgramInterface): VertexBindingInfo[] {
66
71
  }));
67
72
  }
68
73
 
69
- function colorTargetsFor(
74
+ // ---------------------------------------------------------------------------
75
+ // Plain pipeline-state snapshot — what we evaluate avals into per-frame.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ interface BlendComponentSnap {
79
+ readonly operation: GPUBlendOperation;
80
+ readonly srcFactor: GPUBlendFactor;
81
+ readonly dstFactor: GPUBlendFactor;
82
+ }
83
+ interface BlendSnap {
84
+ readonly color: BlendComponentSnap;
85
+ readonly alpha: BlendComponentSnap;
86
+ readonly writeMask: number;
87
+ }
88
+ interface StencilFaceSnap {
89
+ readonly compare: GPUCompareFunction;
90
+ readonly failOp: GPUStencilOperation;
91
+ readonly depthFailOp: GPUStencilOperation;
92
+ readonly passOp: GPUStencilOperation;
93
+ }
94
+ interface StencilSnap {
95
+ readonly enabled: boolean;
96
+ readonly readMask: number;
97
+ readonly writeMask: number;
98
+ readonly front: StencilFaceSnap;
99
+ readonly back: StencilFaceSnap;
100
+ }
101
+ interface PipelineSnap {
102
+ readonly topology: GPUPrimitiveTopology;
103
+ readonly cullMode: import("../core/index.js").CullMode;
104
+ readonly frontFace: import("../core/index.js").FrontFace;
105
+ readonly depthBias: { readonly constant: number; readonly slopeScale: number; readonly clamp: number } | undefined;
106
+ readonly depthWrite: boolean | undefined;
107
+ readonly depthCompare: GPUCompareFunction | undefined;
108
+ readonly depthClamp: boolean | undefined;
109
+ readonly stencil: StencilSnap | undefined;
110
+ readonly blends: ReadonlyArray<readonly [string, BlendSnap]> | undefined;
111
+ readonly alphaToCoverage: boolean;
112
+ }
113
+
114
+ function snapshotPipeline(
115
+ ps: import("../core/index.js").PipelineState,
116
+ token: AdaptiveToken,
117
+ ): PipelineSnap {
118
+ const r = ps.rasterizer;
119
+ const depthBias = r.depthBias !== undefined ? r.depthBias.getValue(token) : undefined;
120
+ let stencil: StencilSnap | undefined;
121
+ if (ps.stencil !== undefined) {
122
+ const s = ps.stencil;
123
+ stencil = {
124
+ enabled: s.enabled.getValue(token),
125
+ readMask: s.readMask.getValue(token),
126
+ writeMask: s.writeMask.getValue(token),
127
+ front: {
128
+ compare: s.front.compare.getValue(token),
129
+ failOp: s.front.failOp.getValue(token),
130
+ depthFailOp: s.front.depthFailOp.getValue(token),
131
+ passOp: s.front.passOp.getValue(token),
132
+ },
133
+ back: {
134
+ compare: s.back.compare.getValue(token),
135
+ failOp: s.back.failOp.getValue(token),
136
+ depthFailOp: s.back.depthFailOp.getValue(token),
137
+ passOp: s.back.passOp.getValue(token),
138
+ },
139
+ };
140
+ }
141
+ let blends: ReadonlyArray<readonly [string, BlendSnap]> | undefined;
142
+ if (ps.blends !== undefined) {
143
+ const m = ps.blends.getValue(token);
144
+ const arr: [string, BlendSnap][] = [];
145
+ for (const [k, b] of m) {
146
+ arr.push([k, {
147
+ color: {
148
+ operation: b.color.operation.getValue(token),
149
+ srcFactor: b.color.srcFactor.getValue(token),
150
+ dstFactor: b.color.dstFactor.getValue(token),
151
+ },
152
+ alpha: {
153
+ operation: b.alpha.operation.getValue(token),
154
+ srcFactor: b.alpha.srcFactor.getValue(token),
155
+ dstFactor: b.alpha.dstFactor.getValue(token),
156
+ },
157
+ writeMask: b.writeMask.getValue(token),
158
+ }]);
159
+ }
160
+ arr.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
161
+ blends = arr;
162
+ }
163
+ return {
164
+ topology: r.topology.getValue(token),
165
+ cullMode: r.cullMode.getValue(token),
166
+ frontFace: r.frontFace.getValue(token),
167
+ depthBias,
168
+ depthWrite: ps.depth?.write.getValue(token),
169
+ depthCompare: ps.depth?.compare.getValue(token),
170
+ depthClamp: ps.depth?.clamp?.getValue(token),
171
+ stencil,
172
+ blends,
173
+ alphaToCoverage: ps.alphaToCoverage?.getValue(token) ?? false,
174
+ };
175
+ }
176
+
177
+ function pipelineKeyOf(snap: PipelineSnap): string {
178
+ return JSON.stringify(snap);
179
+ }
180
+
181
+ function colorTargetsForSnap(
70
182
  iface: ProgramInterface,
71
183
  sig: FramebufferSignature,
72
- blends: import("@aardworx/wombat.adaptive").HashMap<string, import("../core/index.js").BlendState> | undefined,
184
+ blends: ReadonlyArray<readonly [string, BlendSnap]> | undefined,
73
185
  ): GPUColorTargetState[] {
74
186
  const out: GPUColorTargetState[] = [];
187
+ const blendMap = blends !== undefined ? new Map<string, BlendSnap>(blends as readonly (readonly [string, BlendSnap])[]) : undefined;
75
188
  for (const o of iface.fragmentOutputs) {
76
189
  const fmt = sig.colors.tryFind(o.name);
77
190
  if (fmt === undefined) {
78
191
  throw new Error(`prepareRenderObject: fragment output "${o.name}" has no matching signature attachment`);
79
192
  }
80
193
  const target: GPUColorTargetState = { format: fmt };
81
- const blend = blends?.tryFind(o.name);
194
+ const blend = blendMap?.get(o.name);
82
195
  if (blend !== undefined) {
83
196
  target.blend = {
84
197
  color: { operation: blend.color.operation, srcFactor: blend.color.srcFactor, dstFactor: blend.color.dstFactor },
@@ -91,27 +204,27 @@ function colorTargetsFor(
91
204
  return out;
92
205
  }
93
206
 
94
- function depthStencilStateFor(
207
+ function depthStencilStateForSnap(
95
208
  sig: FramebufferSignature,
96
- ps: import("../core/index.js").PipelineState,
209
+ snap: PipelineSnap,
97
210
  ): GPUDepthStencilState | undefined {
98
211
  if (sig.depthStencil === undefined) return undefined;
99
- if (ps.depth === undefined && ps.stencil === undefined) return undefined;
212
+ if (snap.depthCompare === undefined && snap.depthWrite === undefined && snap.stencil === undefined) return undefined;
100
213
  const out: GPUDepthStencilState = {
101
214
  format: sig.depthStencil.format,
102
- depthWriteEnabled: ps.depth?.write ?? false,
103
- depthCompare: ps.depth?.compare ?? "always",
215
+ depthWriteEnabled: snap.depthWrite ?? false,
216
+ depthCompare: snap.depthCompare ?? "always",
104
217
  };
105
- if (ps.stencil !== undefined) {
106
- out.stencilFront = ps.stencil.front;
107
- out.stencilBack = ps.stencil.back;
108
- out.stencilReadMask = ps.stencil.readMask;
109
- out.stencilWriteMask = ps.stencil.writeMask;
218
+ if (snap.stencil !== undefined && snap.stencil.enabled) {
219
+ out.stencilFront = snap.stencil.front;
220
+ out.stencilBack = snap.stencil.back;
221
+ out.stencilReadMask = snap.stencil.readMask;
222
+ out.stencilWriteMask = snap.stencil.writeMask;
110
223
  }
111
- if (ps.rasterizer.depthBias !== undefined) {
112
- out.depthBias = ps.rasterizer.depthBias.constant;
113
- out.depthBiasSlopeScale = ps.rasterizer.depthBias.slopeScale;
114
- out.depthBiasClamp = ps.rasterizer.depthBias.clamp;
224
+ if (snap.depthBias !== undefined) {
225
+ out.depthBias = snap.depthBias.constant;
226
+ out.depthBiasSlopeScale = snap.depthBias.slopeScale;
227
+ out.depthBiasClamp = snap.depthBias.clamp;
115
228
  }
116
229
  return out;
117
230
  }
@@ -124,9 +237,6 @@ function sampleTypeFor(type: Type): GPUTextureSampleType {
124
237
  if (s.kind === "Int") return s.signed ? "sint" : "uint";
125
238
  return "float";
126
239
  }
127
- // Storage textures don't have a sampleType in the binding-layout
128
- // sense; this code path is only for sampled textures. If we see
129
- // anything else default to filterable float.
130
240
  return "float";
131
241
  }
132
242
 
@@ -168,8 +278,7 @@ function buildGroups(device: GPUDevice, descs: readonly EntryDesc[][]): GroupDes
168
278
  return out;
169
279
  }
170
280
 
171
- // Per-handle identity counter for the bind-group cache key. WebGPU
172
- // handles have no built-in id; we attach one via a WeakMap.
281
+ // Per-handle identity counter for the bind-group cache key.
173
282
  const handleIds = new WeakMap<object, number>();
174
283
  let nextHandleId = 1;
175
284
  function handleId(h: object): number {
@@ -182,14 +291,75 @@ function handleId(h: object): number {
182
291
  // PreparedRenderObject
183
292
  // ---------------------------------------------------------------------------
184
293
 
294
+ interface PipelineBuildContext {
295
+ readonly device: GPUDevice;
296
+ readonly iface: ProgramInterface;
297
+ readonly signature: FramebufferSignature;
298
+ readonly vertexLayouts: readonly GPUVertexBufferLayout[];
299
+ readonly bindGroupLayouts: readonly GPUBindGroupLayout[];
300
+ readonly vsSource: string;
301
+ readonly fsSource: string;
302
+ readonly vsEntry: string;
303
+ readonly fsEntry: string;
304
+ readonly vsSourceMap: import("@aardworx/wombat.shader/ir").SourceMap | null | undefined;
305
+ readonly fsSourceMap: import("@aardworx/wombat.shader/ir").SourceMap | null | undefined;
306
+ readonly label?: string | undefined;
307
+ readonly effectId?: string | undefined;
308
+ }
309
+
310
+ function buildPipelineForSnap(
311
+ ctx: PipelineBuildContext,
312
+ snap: PipelineSnap,
313
+ ): GPURenderPipeline {
314
+ const colorTargets = colorTargetsForSnap(ctx.iface, ctx.signature, snap.blends);
315
+ const ds = depthStencilStateForSnap(ctx.signature, snap);
316
+ const multisample: GPUMultisampleState | undefined =
317
+ ctx.signature.sampleCount > 1 || snap.alphaToCoverage
318
+ ? {
319
+ count: ctx.signature.sampleCount,
320
+ ...(snap.alphaToCoverage ? { alphaToCoverageEnabled: true } : {}),
321
+ }
322
+ : undefined;
323
+ const desc: CompileRenderPipelineDescription = {
324
+ ...(ctx.label !== undefined ? { label: ctx.label } : {}),
325
+ ...(ctx.effectId !== undefined ? { effectId: ctx.effectId } : {}),
326
+ vertexShaderSource: ctx.vsSource,
327
+ fragmentShaderSource: ctx.fsSource,
328
+ ...(ctx.vsSourceMap ? { vertexSourceMap: ctx.vsSourceMap } : {}),
329
+ ...(ctx.fsSourceMap ? { fragmentSourceMap: ctx.fsSourceMap } : {}),
330
+ vertexEntryPoint: ctx.vsEntry,
331
+ fragmentEntryPoint: ctx.fsEntry,
332
+ vertexBufferLayouts: ctx.vertexLayouts,
333
+ bindGroupLayouts: ctx.bindGroupLayouts,
334
+ colorTargets,
335
+ primitive: {
336
+ topology: snap.topology,
337
+ ...(snap.cullMode !== "none" ? { cullMode: snap.cullMode } : {}),
338
+ frontFace: snap.frontFace,
339
+ ...(snap.depthClamp === true ? { unclippedDepth: true } : {}),
340
+ },
341
+ ...(ds !== undefined ? { depthStencil: ds } : {}),
342
+ ...(multisample !== undefined ? { multisample } : {}),
343
+ };
344
+ return compileRenderPipeline(ctx.device, desc);
345
+ }
346
+
185
347
  export class PreparedRenderObject {
186
- readonly pipeline: GPURenderPipeline;
348
+ /**
349
+ * Most recently resolved pipeline. Set by `update(token)`. Reading
350
+ * this before `update` was ever called yields a sentinel object
351
+ * suitable only for identity comparison; the actual pipeline is
352
+ * resolved on the first `update`/`record` call.
353
+ */
354
+ pipeline: GPURenderPipeline;
187
355
  readonly groups: readonly GroupDesc[];
188
356
 
189
- // Per-group bind group cache. Keyed on the concatenated identity
190
- // of the underlying GPU handles (`buffer:N`, `tex:N`, etc.); a
191
- // cache hit means none of the resources reallocated since the
192
- // last record(), so the GPUBindGroup is reusable.
357
+ // Per-PreparedRO pipeline cache keyed by snapshot string.
358
+ private readonly _pipelineCache = new Map<string, GPURenderPipeline>();
359
+ private _pipelineCtx: PipelineBuildContext;
360
+ private _pipelineState: import("../core/index.js").PipelineState;
361
+
362
+ // Per-group bind group cache.
193
363
  private readonly _bindGroupCache: { key: string | null; group: GPUBindGroup | null }[];
194
364
 
195
365
  constructor(
@@ -201,13 +371,20 @@ export class PreparedRenderObject {
201
371
  private readonly indices: aval<import("../core/index.js").BufferView> | undefined,
202
372
  groups: readonly GroupDesc[],
203
373
  private readonly drawCall: aval<import("../core/index.js").DrawCall>,
204
- pipeline: GPURenderPipeline,
374
+ pipelineCtx: PipelineBuildContext,
375
+ pipelineState: import("../core/index.js").PipelineState,
205
376
  ) {
206
- this.pipeline = pipeline;
207
377
  this.groups = groups;
378
+ this._pipelineCtx = pipelineCtx;
379
+ this._pipelineState = pipelineState;
208
380
  this._bindGroupCache = groups.map(() => ({ key: null, group: null }));
381
+ // Sentinel pipeline — replaced on the first `update(token)`.
382
+ this.pipeline = SENTINEL_PIPELINE;
209
383
  }
210
384
 
385
+ /** Internal — exposed for tests. The PipelineState the RO is reading. */
386
+ get pipelineState(): import("../core/index.js").PipelineState { return this._pipelineState; }
387
+
211
388
  acquire(): void {
212
389
  for (const r of this.vertexBuffers.values()) r.acquire();
213
390
  if (this.indexBuffer !== undefined) this.indexBuffer.acquire();
@@ -220,15 +397,42 @@ export class PreparedRenderObject {
220
397
  for (const g of this.groups) for (const e of g.entries) e.resource.release();
221
398
  }
222
399
 
400
+ /**
401
+ * Resolve the pipeline for the current values of the
402
+ * pipeline-influencing avals. Updates `this.pipeline` so that the
403
+ * surrounding sort / record path can read it without forcing.
404
+ */
405
+ update(token: AdaptiveToken): void {
406
+ const snap = snapshotPipeline(this._pipelineState, token);
407
+ const key = pipelineKeyOf(snap);
408
+ let pipeline = this._pipelineCache.get(key);
409
+ if (pipeline === undefined) {
410
+ pipeline = buildPipelineForSnap(this._pipelineCtx, snap);
411
+ this._pipelineCache.set(key, pipeline);
412
+ }
413
+ this.pipeline = pipeline;
414
+ }
415
+
223
416
  record(pass: GPURenderPassEncoder, token: AdaptiveToken): void {
417
+ // Resolve current pipeline. If `update` was already called by the
418
+ // walker this is a no-op cache hit; otherwise we still pick the
419
+ // right pipeline for the token-evaluated values.
420
+ this.update(token);
224
421
  pass.setPipeline(this.pipeline);
225
422
 
423
+ // Per-frame state — does NOT influence the pipeline cache key.
424
+ const ps = this._pipelineState;
425
+ if (ps.blendConstant !== undefined) {
426
+ const c = ps.blendConstant.getValue(token);
427
+ pass.setBlendConstant(c);
428
+ }
429
+ if (ps.stencil !== undefined) {
430
+ const ref = ps.stencil.reference.getValue(token);
431
+ pass.setStencilReference(ref);
432
+ }
433
+
226
434
  for (let gi = 0; gi < this.groups.length; gi++) {
227
435
  const g = this.groups[gi]!;
228
- // Build a cache key from the resolved GPU handle identities;
229
- // resources may be re-allocated by their AdaptiveResource on
230
- // capacity growth or shape change, so we need to key on the
231
- // CURRENT handles, not on the AdaptiveResource references.
232
436
  let cacheKey = "";
233
437
  const resolved: { binding: number; resource: GPUBindingResource }[] = [];
234
438
  for (const e of g.entries) {
@@ -243,10 +447,6 @@ export class PreparedRenderObject {
243
447
  case "tex": {
244
448
  const tex = e.resource.getValue(token);
245
449
  cacheKey += `${e.binding}:t${handleId(tex)};`;
246
- // We create a new view per record because views are cheap
247
- // and texture identity already keys the cache. (A
248
- // real-perf-tuned implementation would also cache the
249
- // view per texture.)
250
450
  resolved.push({ binding: e.binding, resource: tex.createView() });
251
451
  break;
252
452
  }
@@ -316,9 +516,6 @@ export function prepareRenderObject(
316
516
  const iface = effect.interface;
317
517
  const vertexBindings = vertexBindingsFor(iface);
318
518
 
319
- // Vertex + instance buffers — same shape; only stepMode differs.
320
- // Each shader-required attribute is looked up in obj.vertexAttributes
321
- // first (per-vertex) then obj.instanceAttributes (per-instance).
322
519
  const vertexBuffers = new Map<string, AdaptiveResource<GPUBuffer>>();
323
520
  const vertexLayouts: GPUVertexBufferLayout[] = [];
324
521
  for (const vb of vertexBindings) {
@@ -352,12 +549,14 @@ export function prepareRenderObject(
352
549
  usage: BufferUsage.INDEX,
353
550
  ...(opts.label !== undefined ? { label: `${opts.label}.indices` } : {}),
354
551
  });
552
+ // Construction-time read to discover the index format. The
553
+ // BufferView aval is expected to settle on a stable format —
554
+ // changing index format would require a fresh PreparedRO.
355
555
  const initial = obj.indices.force();
356
556
  indexFormat = initial.indexFormat
357
557
  ?? (initial.format === "uint16" ? "uint16" : "uint32");
358
558
  }
359
559
 
360
- // Per-group entry collection. Use `slot` (real shape) instead of `binding`.
361
560
  const slotOf = (e: { group: number; slot: number }) => e.slot;
362
561
  const groupOf = (e: { group: number }) => e.group;
363
562
  const maxGroup = Math.max(
@@ -371,9 +570,6 @@ export function prepareRenderObject(
371
570
  for (let g = 0; g <= maxGroup; g++) perGroup.push([]);
372
571
 
373
572
  for (const ub of iface.uniformBlocks) {
374
- // Merge user-supplied uniforms (`obj.uniforms`) with effect-bound
375
- // avals (`compiledEffect.avalBindings`). User entries win on name
376
- // conflict — the rendering caller is the source of truth.
377
573
  const merged = mergeUniformInputs(obj.uniforms, effect.avalBindings, ub);
378
574
  const block = ubAsBlock(ub);
379
575
  const res = prepareUniformBuffer(device, block, merged, {
@@ -412,42 +608,30 @@ export function prepareRenderObject(
412
608
 
413
609
  const groups = buildGroups(device, perGroup);
414
610
 
415
- // Pipeline.
416
- const colorTargets = colorTargetsFor(iface, signature, obj.pipelineState.blends);
417
- const vsStage = effect.stages.find(s => s.stage === "vertex");
418
- const fsStage = effect.stages.find(s => s.stage === "fragment");
611
+ // Pipeline — set up the build context and produce the initial
612
+ // GPURenderPipeline. We snapshot via AdaptiveToken.top here; that's
613
+ // a one-time construction-boundary read, NOT inside an adaptive
614
+ // computation. Subsequent recompiles happen inside `update(token)`.
615
+ const vsStage = effect.stages.find(st => st.stage === "vertex");
616
+ const fsStage = effect.stages.find(st => st.stage === "fragment");
419
617
  if (vsStage === undefined) throw new Error("prepareRenderObject: CompiledEffect has no vertex stage");
420
618
  if (fsStage === undefined) throw new Error("prepareRenderObject: CompiledEffect has no fragment stage");
421
619
 
422
- const ds = depthStencilStateFor(signature, obj.pipelineState);
423
- const multisample: GPUMultisampleState | undefined =
424
- signature.sampleCount > 1 || obj.pipelineState.alphaToCoverage === true
425
- ? {
426
- count: signature.sampleCount,
427
- ...(obj.pipelineState.alphaToCoverage === true ? { alphaToCoverageEnabled: true } : {}),
428
- }
429
- : undefined;
430
- const pipelineDesc: CompileRenderPipelineDescription = {
620
+ const pipelineCtx: PipelineBuildContext = {
621
+ device,
622
+ iface,
623
+ signature,
624
+ vertexLayouts,
625
+ bindGroupLayouts: groups.map(g => g.layout),
626
+ vsSource: vsStage.source,
627
+ fsSource: fsStage.source,
628
+ vsEntry: vsStage.entryName,
629
+ fsEntry: fsStage.entryName,
630
+ vsSourceMap: vsStage.sourceMap,
631
+ fsSourceMap: fsStage.sourceMap,
431
632
  ...(opts.label !== undefined ? { label: opts.label } : {}),
432
633
  ...(opts.effectId !== undefined ? { effectId: opts.effectId } : {}),
433
- vertexShaderSource: vsStage.source,
434
- fragmentShaderSource: fsStage.source,
435
- ...(vsStage.sourceMap ? { vertexSourceMap: vsStage.sourceMap } : {}),
436
- ...(fsStage.sourceMap ? { fragmentSourceMap: fsStage.sourceMap } : {}),
437
- vertexEntryPoint: vsStage.entryName,
438
- fragmentEntryPoint: fsStage.entryName,
439
- vertexBufferLayouts: vertexLayouts,
440
- bindGroupLayouts: groups.map(g => g.layout),
441
- colorTargets,
442
- primitive: {
443
- topology: obj.pipelineState.rasterizer.topology,
444
- ...(obj.pipelineState.rasterizer.cullMode !== "none" ? { cullMode: obj.pipelineState.rasterizer.cullMode } : {}),
445
- frontFace: obj.pipelineState.rasterizer.frontFace,
446
- },
447
- ...(ds !== undefined ? { depthStencil: ds } : {}),
448
- ...(multisample !== undefined ? { multisample } : {}),
449
634
  };
450
- const pipeline = compileRenderPipeline(device, pipelineDesc);
451
635
 
452
636
  return new PreparedRenderObject(
453
637
  device,
@@ -455,10 +639,20 @@ export function prepareRenderObject(
455
639
  indexBuffer, indexFormat, obj.indices,
456
640
  groups,
457
641
  obj.drawCall,
458
- pipeline,
642
+ pipelineCtx,
643
+ obj.pipelineState,
459
644
  );
460
645
  }
461
646
 
647
+ /**
648
+ * Sentinel pipeline placeholder. PreparedRenderObject's `pipeline`
649
+ * field is non-null for ergonomic typing; before the first
650
+ * `update(token)` it points at this sentinel which is only used as
651
+ * a sort-rank identity (never bound to a render pass — `record`
652
+ * always calls `update` first).
653
+ */
654
+ const SENTINEL_PIPELINE = { __sentinel: "PreparedRenderObject.pipeline" } as unknown as GPURenderPipeline;
655
+
462
656
  // ---------------------------------------------------------------------------
463
657
  // Uniform input merging — `obj.uniforms` wins; `avalBindings` is fallback.
464
658
  // ---------------------------------------------------------------------------
@@ -475,8 +669,6 @@ function mergeUniformInputs(
475
669
  const getter = bindings[f.name];
476
670
  if (getter === undefined) continue;
477
671
  const v = getter();
478
- // wombat.shader's avalBindings store either an `aval<T>` or a
479
- // raw value; wrap the raw case as a constant aval.
480
672
  if (isAval(v)) merged = merged.add(f.name, v as aval<unknown>);
481
673
  else merged = merged.add(f.name, cval(v));
482
674
  }
@@ -487,12 +679,6 @@ function isAval(v: unknown): boolean {
487
679
  return typeof v === "object" && v !== null && typeof (v as { getValue?: unknown }).getValue === "function";
488
680
  }
489
681
 
490
- /**
491
- * Adapter — wombat.shader's `UniformBlockInfo` and our internal
492
- * UBO packer expect the same field shape (`offset`, `size`, etc.),
493
- * so this is just a structural pass-through. Keeps the call sites
494
- * type-clean.
495
- */
496
682
  function ubAsBlock(ub: import("../core/index.js").UniformBlockInfo): import("../core/index.js").UniformBlockInfo {
497
683
  return ub;
498
684
  }
@@ -79,7 +79,12 @@ class LeafWalker extends NodeWalker {
79
79
  this.prepared.acquire();
80
80
  ctx.stats.prepareCount++;
81
81
  }
82
- update(): void {}
82
+ update(token: AdaptiveToken): void {
83
+ // Resolve the current pipeline so the upstream sort comparator
84
+ // sees a stable identity for this frame's pipeline-influencing
85
+ // aval values.
86
+ this.prepared.update(token);
87
+ }
83
88
  emit(out: PreparedRenderObject[]): void { out.push(this.prepared); }
84
89
  dispose(): void { this.prepared.release(); }
85
90
  }