@aardworx/wombat.rendering 0.1.1 → 0.3.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,
@@ -15,9 +22,11 @@ import {
15
22
  import {
16
23
  type AdaptiveToken,
17
24
  type aval,
18
- type HashMap,
25
+ HashMap,
26
+ AVal,
19
27
  cval,
20
28
  } from "@aardworx/wombat.adaptive";
29
+ import type { BufferView } from "../core/bufferView.js";
21
30
  import type { Type } from "@aardworx/wombat.shader/ir";
22
31
  import { prepareAdaptiveBuffer } from "./adaptiveBuffer.js";
23
32
  import { prepareAdaptiveTexture } from "./adaptiveTexture.js";
@@ -56,8 +65,6 @@ interface VertexBindingInfo {
56
65
  }
57
66
 
58
67
  function vertexBindingsFor(iface: ProgramInterface): VertexBindingInfo[] {
59
- // `iface.attributes` is already filtered to non-builtin vertex inputs
60
- // by wombat.shader's interface builder.
61
68
  return iface.attributes.map(a => ({
62
69
  name: a.name,
63
70
  slot: a.location,
@@ -66,19 +73,127 @@ function vertexBindingsFor(iface: ProgramInterface): VertexBindingInfo[] {
66
73
  }));
67
74
  }
68
75
 
69
- function colorTargetsFor(
76
+ // ---------------------------------------------------------------------------
77
+ // Plain pipeline-state snapshot — what we evaluate avals into per-frame.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ interface BlendComponentSnap {
81
+ readonly operation: GPUBlendOperation;
82
+ readonly srcFactor: GPUBlendFactor;
83
+ readonly dstFactor: GPUBlendFactor;
84
+ }
85
+ interface BlendSnap {
86
+ readonly color: BlendComponentSnap;
87
+ readonly alpha: BlendComponentSnap;
88
+ readonly writeMask: number;
89
+ }
90
+ interface StencilFaceSnap {
91
+ readonly compare: GPUCompareFunction;
92
+ readonly failOp: GPUStencilOperation;
93
+ readonly depthFailOp: GPUStencilOperation;
94
+ readonly passOp: GPUStencilOperation;
95
+ }
96
+ interface StencilSnap {
97
+ readonly enabled: boolean;
98
+ readonly readMask: number;
99
+ readonly writeMask: number;
100
+ readonly front: StencilFaceSnap;
101
+ readonly back: StencilFaceSnap;
102
+ }
103
+ interface PipelineSnap {
104
+ readonly topology: GPUPrimitiveTopology;
105
+ readonly cullMode: import("../core/index.js").CullMode;
106
+ readonly frontFace: import("../core/index.js").FrontFace;
107
+ readonly depthBias: { readonly constant: number; readonly slopeScale: number; readonly clamp: number } | undefined;
108
+ readonly depthWrite: boolean | undefined;
109
+ readonly depthCompare: GPUCompareFunction | undefined;
110
+ readonly depthClamp: boolean | undefined;
111
+ readonly stencil: StencilSnap | undefined;
112
+ readonly blends: ReadonlyArray<readonly [string, BlendSnap]> | undefined;
113
+ readonly alphaToCoverage: boolean;
114
+ }
115
+
116
+ function snapshotPipeline(
117
+ ps: import("../core/index.js").PipelineState,
118
+ token: AdaptiveToken,
119
+ ): PipelineSnap {
120
+ const r = ps.rasterizer;
121
+ const depthBias = r.depthBias !== undefined ? r.depthBias.getValue(token) : undefined;
122
+ let stencil: StencilSnap | undefined;
123
+ if (ps.stencil !== undefined) {
124
+ const s = ps.stencil;
125
+ stencil = {
126
+ enabled: s.enabled.getValue(token),
127
+ readMask: s.readMask.getValue(token),
128
+ writeMask: s.writeMask.getValue(token),
129
+ front: {
130
+ compare: s.front.compare.getValue(token),
131
+ failOp: s.front.failOp.getValue(token),
132
+ depthFailOp: s.front.depthFailOp.getValue(token),
133
+ passOp: s.front.passOp.getValue(token),
134
+ },
135
+ back: {
136
+ compare: s.back.compare.getValue(token),
137
+ failOp: s.back.failOp.getValue(token),
138
+ depthFailOp: s.back.depthFailOp.getValue(token),
139
+ passOp: s.back.passOp.getValue(token),
140
+ },
141
+ };
142
+ }
143
+ let blends: ReadonlyArray<readonly [string, BlendSnap]> | undefined;
144
+ if (ps.blends !== undefined) {
145
+ const m = ps.blends.getValue(token);
146
+ const arr: [string, BlendSnap][] = [];
147
+ for (const [k, b] of m) {
148
+ arr.push([k, {
149
+ color: {
150
+ operation: b.color.operation.getValue(token),
151
+ srcFactor: b.color.srcFactor.getValue(token),
152
+ dstFactor: b.color.dstFactor.getValue(token),
153
+ },
154
+ alpha: {
155
+ operation: b.alpha.operation.getValue(token),
156
+ srcFactor: b.alpha.srcFactor.getValue(token),
157
+ dstFactor: b.alpha.dstFactor.getValue(token),
158
+ },
159
+ writeMask: b.writeMask.getValue(token),
160
+ }]);
161
+ }
162
+ arr.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
163
+ blends = arr;
164
+ }
165
+ return {
166
+ topology: r.topology.getValue(token),
167
+ cullMode: r.cullMode.getValue(token),
168
+ frontFace: r.frontFace.getValue(token),
169
+ depthBias,
170
+ depthWrite: ps.depth?.write.getValue(token),
171
+ depthCompare: ps.depth?.compare.getValue(token),
172
+ depthClamp: ps.depth?.clamp?.getValue(token),
173
+ stencil,
174
+ blends,
175
+ alphaToCoverage: ps.alphaToCoverage?.getValue(token) ?? false,
176
+ };
177
+ }
178
+
179
+ function pipelineKeyOf(snap: PipelineSnap): string {
180
+ return JSON.stringify(snap);
181
+ }
182
+
183
+ function colorTargetsForSnap(
70
184
  iface: ProgramInterface,
71
185
  sig: FramebufferSignature,
72
- blends: import("@aardworx/wombat.adaptive").HashMap<string, import("../core/index.js").BlendState> | undefined,
186
+ blends: ReadonlyArray<readonly [string, BlendSnap]> | undefined,
73
187
  ): GPUColorTargetState[] {
74
188
  const out: GPUColorTargetState[] = [];
189
+ const blendMap = blends !== undefined ? new Map<string, BlendSnap>(blends as readonly (readonly [string, BlendSnap])[]) : undefined;
75
190
  for (const o of iface.fragmentOutputs) {
76
191
  const fmt = sig.colors.tryFind(o.name);
77
192
  if (fmt === undefined) {
78
193
  throw new Error(`prepareRenderObject: fragment output "${o.name}" has no matching signature attachment`);
79
194
  }
80
195
  const target: GPUColorTargetState = { format: fmt };
81
- const blend = blends?.tryFind(o.name);
196
+ const blend = blendMap?.get(o.name);
82
197
  if (blend !== undefined) {
83
198
  target.blend = {
84
199
  color: { operation: blend.color.operation, srcFactor: blend.color.srcFactor, dstFactor: blend.color.dstFactor },
@@ -91,27 +206,27 @@ function colorTargetsFor(
91
206
  return out;
92
207
  }
93
208
 
94
- function depthStencilStateFor(
209
+ function depthStencilStateForSnap(
95
210
  sig: FramebufferSignature,
96
- ps: import("../core/index.js").PipelineState,
211
+ snap: PipelineSnap,
97
212
  ): GPUDepthStencilState | undefined {
98
213
  if (sig.depthStencil === undefined) return undefined;
99
- if (ps.depth === undefined && ps.stencil === undefined) return undefined;
214
+ if (snap.depthCompare === undefined && snap.depthWrite === undefined && snap.stencil === undefined) return undefined;
100
215
  const out: GPUDepthStencilState = {
101
216
  format: sig.depthStencil.format,
102
- depthWriteEnabled: ps.depth?.write ?? false,
103
- depthCompare: ps.depth?.compare ?? "always",
217
+ depthWriteEnabled: snap.depthWrite ?? false,
218
+ depthCompare: snap.depthCompare ?? "always",
104
219
  };
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;
220
+ if (snap.stencil !== undefined && snap.stencil.enabled) {
221
+ out.stencilFront = snap.stencil.front;
222
+ out.stencilBack = snap.stencil.back;
223
+ out.stencilReadMask = snap.stencil.readMask;
224
+ out.stencilWriteMask = snap.stencil.writeMask;
110
225
  }
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;
226
+ if (snap.depthBias !== undefined) {
227
+ out.depthBias = snap.depthBias.constant;
228
+ out.depthBiasSlopeScale = snap.depthBias.slopeScale;
229
+ out.depthBiasClamp = snap.depthBias.clamp;
115
230
  }
116
231
  return out;
117
232
  }
@@ -124,9 +239,6 @@ function sampleTypeFor(type: Type): GPUTextureSampleType {
124
239
  if (s.kind === "Int") return s.signed ? "sint" : "uint";
125
240
  return "float";
126
241
  }
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
242
  return "float";
131
243
  }
132
244
 
@@ -168,8 +280,7 @@ function buildGroups(device: GPUDevice, descs: readonly EntryDesc[][]): GroupDes
168
280
  return out;
169
281
  }
170
282
 
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.
283
+ // Per-handle identity counter for the bind-group cache key.
173
284
  const handleIds = new WeakMap<object, number>();
174
285
  let nextHandleId = 1;
175
286
  function handleId(h: object): number {
@@ -182,14 +293,75 @@ function handleId(h: object): number {
182
293
  // PreparedRenderObject
183
294
  // ---------------------------------------------------------------------------
184
295
 
296
+ interface PipelineBuildContext {
297
+ readonly device: GPUDevice;
298
+ readonly iface: ProgramInterface;
299
+ readonly signature: FramebufferSignature;
300
+ readonly vertexLayouts: readonly GPUVertexBufferLayout[];
301
+ readonly bindGroupLayouts: readonly GPUBindGroupLayout[];
302
+ readonly vsSource: string;
303
+ readonly fsSource: string;
304
+ readonly vsEntry: string;
305
+ readonly fsEntry: string;
306
+ readonly vsSourceMap: import("@aardworx/wombat.shader/ir").SourceMap | null | undefined;
307
+ readonly fsSourceMap: import("@aardworx/wombat.shader/ir").SourceMap | null | undefined;
308
+ readonly label?: string | undefined;
309
+ readonly effectId?: string | undefined;
310
+ }
311
+
312
+ function buildPipelineForSnap(
313
+ ctx: PipelineBuildContext,
314
+ snap: PipelineSnap,
315
+ ): GPURenderPipeline {
316
+ const colorTargets = colorTargetsForSnap(ctx.iface, ctx.signature, snap.blends);
317
+ const ds = depthStencilStateForSnap(ctx.signature, snap);
318
+ const multisample: GPUMultisampleState | undefined =
319
+ ctx.signature.sampleCount > 1 || snap.alphaToCoverage
320
+ ? {
321
+ count: ctx.signature.sampleCount,
322
+ ...(snap.alphaToCoverage ? { alphaToCoverageEnabled: true } : {}),
323
+ }
324
+ : undefined;
325
+ const desc: CompileRenderPipelineDescription = {
326
+ ...(ctx.label !== undefined ? { label: ctx.label } : {}),
327
+ ...(ctx.effectId !== undefined ? { effectId: ctx.effectId } : {}),
328
+ vertexShaderSource: ctx.vsSource,
329
+ fragmentShaderSource: ctx.fsSource,
330
+ ...(ctx.vsSourceMap ? { vertexSourceMap: ctx.vsSourceMap } : {}),
331
+ ...(ctx.fsSourceMap ? { fragmentSourceMap: ctx.fsSourceMap } : {}),
332
+ vertexEntryPoint: ctx.vsEntry,
333
+ fragmentEntryPoint: ctx.fsEntry,
334
+ vertexBufferLayouts: ctx.vertexLayouts,
335
+ bindGroupLayouts: ctx.bindGroupLayouts,
336
+ colorTargets,
337
+ primitive: {
338
+ topology: snap.topology,
339
+ ...(snap.cullMode !== "none" ? { cullMode: snap.cullMode } : {}),
340
+ frontFace: snap.frontFace,
341
+ ...(snap.depthClamp === true ? { unclippedDepth: true } : {}),
342
+ },
343
+ ...(ds !== undefined ? { depthStencil: ds } : {}),
344
+ ...(multisample !== undefined ? { multisample } : {}),
345
+ };
346
+ return compileRenderPipeline(ctx.device, desc);
347
+ }
348
+
185
349
  export class PreparedRenderObject {
186
- readonly pipeline: GPURenderPipeline;
350
+ /**
351
+ * Most recently resolved pipeline. Set by `update(token)`. Reading
352
+ * this before `update` was ever called yields a sentinel object
353
+ * suitable only for identity comparison; the actual pipeline is
354
+ * resolved on the first `update`/`record` call.
355
+ */
356
+ pipeline: GPURenderPipeline;
187
357
  readonly groups: readonly GroupDesc[];
188
358
 
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.
359
+ // Per-PreparedRO pipeline cache keyed by snapshot string.
360
+ private readonly _pipelineCache = new Map<string, GPURenderPipeline>();
361
+ private _pipelineCtx: PipelineBuildContext;
362
+ private _pipelineState: import("../core/index.js").PipelineState;
363
+
364
+ // Per-group bind group cache.
193
365
  private readonly _bindGroupCache: { key: string | null; group: GPUBindGroup | null }[];
194
366
 
195
367
  constructor(
@@ -201,13 +373,21 @@ export class PreparedRenderObject {
201
373
  private readonly indices: aval<import("../core/index.js").BufferView> | undefined,
202
374
  groups: readonly GroupDesc[],
203
375
  private readonly drawCall: aval<import("../core/index.js").DrawCall>,
204
- pipeline: GPURenderPipeline,
376
+ pipelineCtx: PipelineBuildContext,
377
+ pipelineState: import("../core/index.js").PipelineState,
378
+ private readonly validity: aval<boolean>,
205
379
  ) {
206
- this.pipeline = pipeline;
207
380
  this.groups = groups;
381
+ this._pipelineCtx = pipelineCtx;
382
+ this._pipelineState = pipelineState;
208
383
  this._bindGroupCache = groups.map(() => ({ key: null, group: null }));
384
+ // Sentinel pipeline — replaced on the first `update(token)`.
385
+ this.pipeline = SENTINEL_PIPELINE;
209
386
  }
210
387
 
388
+ /** Internal — exposed for tests. The PipelineState the RO is reading. */
389
+ get pipelineState(): import("../core/index.js").PipelineState { return this._pipelineState; }
390
+
211
391
  acquire(): void {
212
392
  for (const r of this.vertexBuffers.values()) r.acquire();
213
393
  if (this.indexBuffer !== undefined) this.indexBuffer.acquire();
@@ -220,15 +400,59 @@ export class PreparedRenderObject {
220
400
  for (const g of this.groups) for (const e of g.entries) e.resource.release();
221
401
  }
222
402
 
403
+ /**
404
+ * Resolve the pipeline for the current values of the
405
+ * pipeline-influencing avals. Updates `this.pipeline` so that the
406
+ * surrounding sort / record path can read it without forcing.
407
+ */
408
+ update(token: AdaptiveToken): void {
409
+ // Pull validity into the upstream walker's dependency set so a
410
+ // key-set change on the reactive vertex-attribute aval propagates
411
+ // even when the shape happens not to influence the pipeline cache.
412
+ this.validity.getValue(token);
413
+ const snap = snapshotPipeline(this._pipelineState, token);
414
+ const key = pipelineKeyOf(snap);
415
+ let pipeline = this._pipelineCache.get(key);
416
+ if (pipeline === undefined) {
417
+ pipeline = buildPipelineForSnap(this._pipelineCtx, snap);
418
+ this._pipelineCache.set(key, pipeline);
419
+ }
420
+ this.pipeline = pipeline;
421
+ }
422
+
423
+ private _warnedMissing = false;
424
+
223
425
  record(pass: GPURenderPassEncoder, token: AdaptiveToken): void {
426
+ // Validity gate: when the reactive attribute set fails to cover
427
+ // the shader's required-input names, warn-once and skip the draw.
428
+ // Reading validity here keeps the dependency on the outer attr
429
+ // avals chained into the consumer's tracking.
430
+ if (!this.validity.getValue(token)) {
431
+ if (!this._warnedMissing) {
432
+ console.warn("[wombat.rendering] PreparedRenderObject.record: vertex-attribute set is missing one or more shader-required names; skipping draw.");
433
+ this._warnedMissing = true;
434
+ }
435
+ return;
436
+ }
437
+ // Resolve current pipeline. If `update` was already called by the
438
+ // walker this is a no-op cache hit; otherwise we still pick the
439
+ // right pipeline for the token-evaluated values.
440
+ this.update(token);
224
441
  pass.setPipeline(this.pipeline);
225
442
 
443
+ // Per-frame state — does NOT influence the pipeline cache key.
444
+ const ps = this._pipelineState;
445
+ if (ps.blendConstant !== undefined) {
446
+ const c = ps.blendConstant.getValue(token);
447
+ pass.setBlendConstant(c);
448
+ }
449
+ if (ps.stencil !== undefined) {
450
+ const ref = ps.stencil.reference.getValue(token);
451
+ pass.setStencilReference(ref);
452
+ }
453
+
226
454
  for (let gi = 0; gi < this.groups.length; gi++) {
227
455
  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
456
  let cacheKey = "";
233
457
  const resolved: { binding: number; resource: GPUBindingResource }[] = [];
234
458
  for (const e of g.entries) {
@@ -243,10 +467,6 @@ export class PreparedRenderObject {
243
467
  case "tex": {
244
468
  const tex = e.resource.getValue(token);
245
469
  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
470
  resolved.push({ binding: e.binding, resource: tex.createView() });
251
471
  break;
252
472
  }
@@ -316,21 +536,47 @@ export function prepareRenderObject(
316
536
  const iface = effect.interface;
317
537
  const vertexBindings = vertexBindingsFor(iface);
318
538
 
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).
539
+ // Construction-boundary read of the outer attribute avals. We use
540
+ // these initial maps ONLY to determine per-binding step-mode
541
+ // (vertex vs instance) that is structural and baked into the
542
+ // GPU pipeline's vertex-buffer layout, so it cannot be reactive
543
+ // without re-preparation. The buffers themselves AND the rest of
544
+ // the map (its key set) remain fully reactive on the live path.
545
+ const vertexAttributesAval = obj.vertexAttributes;
546
+ const instanceAttributesAval = obj.instanceAttributes
547
+ ?? (AVal.constant(emptyAttrMap()) as aval<HashMap<string, aval<BufferView>>>);
548
+ const initialVertex = vertexAttributesAval.force();
549
+ const initialInstance = obj.instanceAttributes !== undefined
550
+ ? obj.instanceAttributes.force()
551
+ : emptyAttrMap();
552
+
322
553
  const vertexBuffers = new Map<string, AdaptiveResource<GPUBuffer>>();
323
554
  const vertexLayouts: GPUVertexBufferLayout[] = [];
324
555
  for (const vb of vertexBindings) {
325
- const perVertex = obj.vertexAttributes.tryFind(vb.name);
326
- const perInstance = obj.instanceAttributes?.tryFind(vb.name);
327
- const av = perVertex ?? perInstance;
328
- if (av === undefined) throw new Error(`prepareRenderObject: missing vertex attribute "${vb.name}"`);
329
- const stepMode: GPUVertexStepMode = perInstance !== undefined ? "instance" : "vertex";
556
+ const inVertex = initialVertex.tryFind(vb.name) !== undefined;
557
+ const inInstance = initialInstance.tryFind(vb.name) !== undefined;
558
+ if (!inVertex && !inInstance) {
559
+ throw new Error(`prepareRenderObject: missing vertex attribute "${vb.name}"`);
560
+ }
561
+ const stepMode: GPUVertexStepMode = inInstance && !inVertex ? "instance" : "vertex";
330
562
  const stride = vb.byteSize !== undefined && vb.byteSize > 0
331
563
  ? vb.byteSize
332
564
  : vertexFormatStride(vb.format);
333
- const bufAval = av.map(view => view.buffer);
565
+
566
+ // Reactive lookup: chain (outer map aval) → (inner BufferView aval)
567
+ // → buffer. AVal.bind preserves the dependency on the outer map
568
+ // so a key-set change re-runs the lookup without losing tracking.
569
+ // Missing-name on a frame is signalled via a sentinel that record()
570
+ // detects and skips the draw with a warn-once.
571
+ const bufAval = AVal.bind(vertexAttributesAval, vMap => {
572
+ const found = vMap.tryFind(vb.name);
573
+ if (found !== undefined) return found.map(view => view.buffer);
574
+ return AVal.bind(instanceAttributesAval, iMap => {
575
+ const f2 = iMap.tryFind(vb.name);
576
+ if (f2 !== undefined) return f2.map(view => view.buffer);
577
+ return AVal.constant(MISSING_BUFFER);
578
+ });
579
+ });
334
580
  const res = prepareAdaptiveBuffer(device, bufAval, {
335
581
  usage: BufferUsage.VERTEX,
336
582
  ...(opts.label !== undefined ? { label: `${opts.label}.${vb.name}` } : {}),
@@ -343,6 +589,19 @@ export function prepareRenderObject(
343
589
  };
344
590
  }
345
591
 
592
+ // Validity aval: depends on the outer attribute avals so any key-set
593
+ // change re-runs the check. Used by record() to skip draws when the
594
+ // shader's required-input set isn't satisfied (warn-once).
595
+ const requiredNames = vertexBindings.map(b => b.name);
596
+ const validityAval: aval<boolean> = AVal.zip(vertexAttributesAval, instanceAttributesAval).map(
597
+ (vMap, iMap) => {
598
+ for (const n of requiredNames) {
599
+ if (vMap.tryFind(n) === undefined && iMap.tryFind(n) === undefined) return false;
600
+ }
601
+ return true;
602
+ },
603
+ );
604
+
346
605
  // Index buffer — `indexFormat` from BufferView wins; defaults to uint32.
347
606
  let indexBuffer: AdaptiveResource<GPUBuffer> | undefined;
348
607
  let indexFormat: GPUIndexFormat | undefined;
@@ -352,12 +611,14 @@ export function prepareRenderObject(
352
611
  usage: BufferUsage.INDEX,
353
612
  ...(opts.label !== undefined ? { label: `${opts.label}.indices` } : {}),
354
613
  });
614
+ // Construction-time read to discover the index format. The
615
+ // BufferView aval is expected to settle on a stable format —
616
+ // changing index format would require a fresh PreparedRO.
355
617
  const initial = obj.indices.force();
356
618
  indexFormat = initial.indexFormat
357
619
  ?? (initial.format === "uint16" ? "uint16" : "uint32");
358
620
  }
359
621
 
360
- // Per-group entry collection. Use `slot` (real shape) instead of `binding`.
361
622
  const slotOf = (e: { group: number; slot: number }) => e.slot;
362
623
  const groupOf = (e: { group: number }) => e.group;
363
624
  const maxGroup = Math.max(
@@ -371,9 +632,6 @@ export function prepareRenderObject(
371
632
  for (let g = 0; g <= maxGroup; g++) perGroup.push([]);
372
633
 
373
634
  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
635
  const merged = mergeUniformInputs(obj.uniforms, effect.avalBindings, ub);
378
636
  const block = ubAsBlock(ub);
379
637
  const res = prepareUniformBuffer(device, block, merged, {
@@ -412,42 +670,30 @@ export function prepareRenderObject(
412
670
 
413
671
  const groups = buildGroups(device, perGroup);
414
672
 
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");
673
+ // Pipeline — set up the build context and produce the initial
674
+ // GPURenderPipeline. We snapshot via AdaptiveToken.top here; that's
675
+ // a one-time construction-boundary read, NOT inside an adaptive
676
+ // computation. Subsequent recompiles happen inside `update(token)`.
677
+ const vsStage = effect.stages.find(st => st.stage === "vertex");
678
+ const fsStage = effect.stages.find(st => st.stage === "fragment");
419
679
  if (vsStage === undefined) throw new Error("prepareRenderObject: CompiledEffect has no vertex stage");
420
680
  if (fsStage === undefined) throw new Error("prepareRenderObject: CompiledEffect has no fragment stage");
421
681
 
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 = {
682
+ const pipelineCtx: PipelineBuildContext = {
683
+ device,
684
+ iface,
685
+ signature,
686
+ vertexLayouts,
687
+ bindGroupLayouts: groups.map(g => g.layout),
688
+ vsSource: vsStage.source,
689
+ fsSource: fsStage.source,
690
+ vsEntry: vsStage.entryName,
691
+ fsEntry: fsStage.entryName,
692
+ vsSourceMap: vsStage.sourceMap,
693
+ fsSourceMap: fsStage.sourceMap,
431
694
  ...(opts.label !== undefined ? { label: opts.label } : {}),
432
695
  ...(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
696
  };
450
- const pipeline = compileRenderPipeline(device, pipelineDesc);
451
697
 
452
698
  return new PreparedRenderObject(
453
699
  device,
@@ -455,10 +701,38 @@ export function prepareRenderObject(
455
701
  indexBuffer, indexFormat, obj.indices,
456
702
  groups,
457
703
  obj.drawCall,
458
- pipeline,
704
+ pipelineCtx,
705
+ obj.pipelineState,
706
+ validityAval,
459
707
  );
460
708
  }
461
709
 
710
+ /**
711
+ * Sentinel host-buffer returned by the per-attribute lookup aval when
712
+ * the named attribute is absent from a frame's outer attribute map.
713
+ * The PreparedRO's validity aval flips false in the same situation;
714
+ * record() observes the validity flip and skips the draw before any
715
+ * sentinel buffer is ever bound.
716
+ */
717
+ const MISSING_BUFFER: import("../core/index.js").IBuffer = {
718
+ kind: "host",
719
+ data: new Uint8Array(4),
720
+ sizeBytes: 4,
721
+ };
722
+
723
+ function emptyAttrMap(): HashMap<string, aval<BufferView>> {
724
+ return HashMap.empty<string, aval<BufferView>>();
725
+ }
726
+
727
+ /**
728
+ * Sentinel pipeline placeholder. PreparedRenderObject's `pipeline`
729
+ * field is non-null for ergonomic typing; before the first
730
+ * `update(token)` it points at this sentinel which is only used as
731
+ * a sort-rank identity (never bound to a render pass — `record`
732
+ * always calls `update` first).
733
+ */
734
+ const SENTINEL_PIPELINE = { __sentinel: "PreparedRenderObject.pipeline" } as unknown as GPURenderPipeline;
735
+
462
736
  // ---------------------------------------------------------------------------
463
737
  // Uniform input merging — `obj.uniforms` wins; `avalBindings` is fallback.
464
738
  // ---------------------------------------------------------------------------
@@ -475,8 +749,6 @@ function mergeUniformInputs(
475
749
  const getter = bindings[f.name];
476
750
  if (getter === undefined) continue;
477
751
  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
752
  if (isAval(v)) merged = merged.add(f.name, v as aval<unknown>);
481
753
  else merged = merged.add(f.name, cval(v));
482
754
  }
@@ -487,12 +759,6 @@ function isAval(v: unknown): boolean {
487
759
  return typeof v === "object" && v !== null && typeof (v as { getValue?: unknown }).getValue === "function";
488
760
  }
489
761
 
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
762
  function ubAsBlock(ub: import("../core/index.js").UniformBlockInfo): import("../core/index.js").UniformBlockInfo {
497
763
  return ub;
498
764
  }
@@ -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
  }