@aardworx/wombat.rendering 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/core/buffer.d.ts.map +1 -1
  2. package/dist/core/buffer.js +65 -5
  3. package/dist/core/buffer.js.map +1 -1
  4. package/dist/core/index.d.ts +2 -0
  5. package/dist/core/index.d.ts.map +1 -1
  6. package/dist/core/index.js +1 -0
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/provider.d.ts +38 -0
  9. package/dist/core/provider.d.ts.map +1 -0
  10. package/dist/core/provider.js +104 -0
  11. package/dist/core/provider.js.map +1 -0
  12. package/dist/core/renderObject.d.ts +17 -10
  13. package/dist/core/renderObject.d.ts.map +1 -1
  14. package/dist/core/sampler.d.ts.map +1 -1
  15. package/dist/core/sampler.js +71 -2
  16. package/dist/core/sampler.js.map +1 -1
  17. package/dist/core/texture.d.ts +9 -0
  18. package/dist/core/texture.d.ts.map +1 -1
  19. package/dist/core/texture.js +0 -0
  20. package/dist/core/texture.js.map +1 -1
  21. package/dist/resources/adaptiveTexture.d.ts.map +1 -1
  22. package/dist/resources/adaptiveTexture.js +7 -0
  23. package/dist/resources/adaptiveTexture.js.map +1 -1
  24. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  25. package/dist/resources/preparedRenderObject.js +20 -18
  26. package/dist/resources/preparedRenderObject.js.map +1 -1
  27. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  28. package/dist/resources/uniformBuffer.js +23 -0
  29. package/dist/resources/uniformBuffer.js.map +1 -1
  30. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -1
  31. package/dist/runtime/derivedUniforms/slots.js +4 -0
  32. package/dist/runtime/derivedUniforms/slots.js.map +1 -1
  33. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  34. package/dist/runtime/heapAdapter.js +72 -29
  35. package/dist/runtime/heapAdapter.js.map +1 -1
  36. package/dist/runtime/heapDecoder.d.ts +52 -0
  37. package/dist/runtime/heapDecoder.d.ts.map +1 -0
  38. package/dist/runtime/heapDecoder.js +363 -0
  39. package/dist/runtime/heapDecoder.js.map +1 -0
  40. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  41. package/dist/runtime/heapEffectIR.js +300 -3
  42. package/dist/runtime/heapEffectIR.js.map +1 -1
  43. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  44. package/dist/runtime/heapEligibility.js +42 -5
  45. package/dist/runtime/heapEligibility.js.map +1 -1
  46. package/dist/runtime/heapIrBuilders.d.ts +64 -0
  47. package/dist/runtime/heapIrBuilders.d.ts.map +1 -0
  48. package/dist/runtime/heapIrBuilders.js +311 -0
  49. package/dist/runtime/heapIrBuilders.js.map +1 -0
  50. package/dist/runtime/heapScene.d.ts.map +1 -1
  51. package/dist/runtime/heapScene.js +329 -112
  52. package/dist/runtime/heapScene.js.map +1 -1
  53. package/dist/runtime/textureAtlas/atlasPool.d.ts +67 -4
  54. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  55. package/dist/runtime/textureAtlas/atlasPool.js +152 -18
  56. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/core/buffer.ts +58 -6
  59. package/src/core/index.ts +11 -0
  60. package/src/core/provider.ts +143 -0
  61. package/src/core/renderObject.ts +17 -10
  62. package/src/core/sampler.ts +64 -2
  63. package/src/core/texture.ts +0 -0
  64. package/src/resources/adaptiveTexture.ts +7 -0
  65. package/src/resources/preparedRenderObject.ts +21 -16
  66. package/src/resources/uniformBuffer.ts +21 -0
  67. package/src/runtime/derivedUniforms/slots.ts +4 -0
  68. package/src/runtime/heapAdapter.ts +63 -29
  69. package/src/runtime/heapDecoder.ts +446 -0
  70. package/src/runtime/heapEffectIR.ts +314 -3
  71. package/src/runtime/heapEligibility.ts +34 -5
  72. package/src/runtime/heapIrBuilders.ts +365 -0
  73. package/src/runtime/heapScene.ts +338 -96
  74. package/src/runtime/textureAtlas/atlasPool.ts +152 -16
@@ -0,0 +1,446 @@
1
+ // heapDecoder — synthesise a vertex stage that loads every input/uniform
2
+ // the user effect reads from the heap and surfaces them on the inter-
3
+ // stage carrier.
4
+ //
5
+ // Composition model:
6
+ //
7
+ // effect(decoderEffect, ...userEffect.stages)
8
+ //
9
+ // `composeStages` fuses the decoder VS with the user VS into a single
10
+ // merged entry. The decoder writes `Position`, `ModelTrafo`, etc.; the
11
+ // user VS reads those names from `Input` scope (after `uniformsToInputs`
12
+ // renames its `Uniform`-scope reads to `Input`). Stage fusion's
13
+ // state-struct path threads the values through helpers naturally — no
14
+ // in-place IR rewriting, no `extractFusedEntry` wrapper-init gap, no
15
+ // post-emit string mangling.
16
+ //
17
+ // The decoder is an IR Module: storage-buffer bindings for heap arena +
18
+ // megacall lookup buffers, plus a single vertex Entry. Body shape:
19
+ //
20
+ // @vertex fn heap_decode(@builtin(vertex_index) emitIdx: u32) -> Out {
21
+ // // megacall binary search → heap_drawIdx / instId / vid
22
+ // ...
23
+ // out.<name> = loadAttributeByRef(...); // attribute-ref fields
24
+ // out.<name> = loadUniformByRef(...); // uniform-ref fields
25
+ // return out;
26
+ // }
27
+ //
28
+ // gl_Position is NOT written here — the user VS owns it, and the fused
29
+ // entry's last writer wins (linkCrossStage semantics).
30
+ //
31
+ // Texture-ref drawHeader fields ARE surfaced through the carrier as
32
+ // flat-interpolated varyings — four per atlas-routed texture binding
33
+ // (`<name>__atlasPageRef: u32`, `<name>__atlasFormatBits: u32`,
34
+ // `<name>__atlasOrigin: vec2<f32>`, `<name>__atlasSize: vec2<f32>`).
35
+ // The matching FS-side rewrite turns each `textureSample(t, smp, uv)`
36
+ // into `atlasSample(<t>__atlasPageRef, …, uv)` reading from the
37
+ // carrier — see `rewriteFsAtlasTexturesViaCarrier` below.
38
+
39
+ import {
40
+ stage as makeStage,
41
+ type Effect,
42
+ } from "@aardworx/wombat.shader";
43
+ import {
44
+ type Module, type Expr, type Stmt, type Type, type ValueDef,
45
+ type EntryDef, type EntryParameter, type Var,
46
+ Tu32, Tf32,
47
+ } from "@aardworx/wombat.shader/ir";
48
+ import type { BucketLayout, DrawHeaderField } from "./heapEffect.js";
49
+ import {
50
+ Tvec2, Tvec3, Tvec4, Tmat4, TarrU32,
51
+ constU32, add, mul, div, item, newVec, readScope,
52
+ loadHeaderRef, loadUniformByRef, loadAttributeByRef, loadInstanceByRef,
53
+ buildMegacallPrelude,
54
+ heapArenaStorageDecls, megacallLookupStorageDecls,
55
+ headersU32,
56
+ } from "./heapIrBuilders.js";
57
+
58
+ // Build an Expr that returns the u32 value of a specific inline u32
59
+ // slot in the drawHeader: `headersU32[drawIdx * stride + offset_u32]`.
60
+ function readHeaderU32(drawIdx: Expr, byteOffset: number, strideU32: number): Expr {
61
+ if ((byteOffset % 4) !== 0) {
62
+ throw new Error(`readHeaderU32: byte offset ${byteOffset} not 4-aligned`);
63
+ }
64
+ const fieldU32 = byteOffset / 4;
65
+ const base = mul(drawIdx, constU32(strideU32), Tu32);
66
+ const idx = fieldU32 === 0 ? base : add(base, constU32(fieldU32), Tu32);
67
+ return item(headersU32, idx, Tu32);
68
+ }
69
+
70
+ // Read two contiguous u32 slots, reinterpret as f32, return as vec2<f32>.
71
+ // Used for the inline `origin: vec2<f32>` and `size: vec2<f32>` atlas
72
+ // drawHeader entries — the runtime packs them via Float32Array view
73
+ // over the same backing buffer, so on the GPU side the bits at u32
74
+ // offset are valid f32 values; we just need to address them and
75
+ // bitcast.
76
+ function readHeaderVec2F(drawIdx: Expr, byteOffset: number, strideU32: number): Expr {
77
+ if ((byteOffset % 4) !== 0) {
78
+ throw new Error(`readHeaderVec2F: byte offset ${byteOffset} not 4-aligned`);
79
+ }
80
+ const fieldU32 = byteOffset / 4;
81
+ const base = mul(drawIdx, constU32(strideU32), Tu32);
82
+ const idx0 = fieldU32 === 0 ? base : add(base, constU32(fieldU32), Tu32);
83
+ const idx1 = add(base, constU32(fieldU32 + 1), Tu32);
84
+ // headersU32 is u32; we need f32. Use the same trick the runtime does:
85
+ // expose a parallel `headersF32` view over the same buffer? That
86
+ // doesn't exist yet — for now bitcast via a CallIntrinsic on `bitcast<f32>`.
87
+ const bitcast = (e: Expr): Expr => ({
88
+ kind: "CallIntrinsic",
89
+ op: {
90
+ name: "bitcast<f32>",
91
+ returnTypeOf: () => Tf32,
92
+ pure: true,
93
+ emit: { wgsl: "bitcast<f32>", glsl: "uintBitsToFloat" },
94
+ },
95
+ args: [e],
96
+ type: Tf32,
97
+ } as Expr);
98
+ return newVec(
99
+ [bitcast(item(headersU32, idx0, Tu32)), bitcast(item(headersU32, idx1, Tu32))],
100
+ Tvec2,
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Decoder-side carrier name for an atlas texture sub-field. Mirrors the
106
+ * existing `_h_<name><Sub>` naming used by `atlasVaryingNames` in
107
+ * heapEffect.ts so the FS-side `textureSample → atlasSample` rewrite
108
+ * can keep reading them by the same names.
109
+ */
110
+ export function atlasCarrierName(textureBindingName: string, sub: "pageRef" | "formatBits" | "origin" | "size"): string {
111
+ const cap = sub.charAt(0).toUpperCase() + sub.slice(1);
112
+ return `_h_${textureBindingName}${cap}`;
113
+ }
114
+
115
+ // ─── wgsl-type-string → IR Type ──────────────────────────────────────
116
+
117
+ /**
118
+ * Convert the WGSL type-string carried on `DrawHeaderField.{uniformWgslType,
119
+ * attributeWgslType}` back into an IR `Type`. The string set is closed:
120
+ * it's whatever `irTypeToWgsl` in heapEffect.ts emits when populating
121
+ * the bucket schema, so the reverse map is finite and exact.
122
+ */
123
+ function wgslTypeToIrType(wgsl: string): Type {
124
+ switch (wgsl) {
125
+ case "f32": return Tf32;
126
+ case "u32": return Tu32;
127
+ case "i32": return { kind: "Int", signed: true, width: 32 } as Type;
128
+ case "vec2<f32>": return Tvec2;
129
+ case "vec3<f32>": return Tvec3;
130
+ case "vec4<f32>": return Tvec4;
131
+ case "mat4x4<f32>": return Tmat4;
132
+ default:
133
+ throw new Error(`heapDecoder: cannot translate WGSL type '${wgsl}' to IR Type`);
134
+ }
135
+ }
136
+
137
+ // ─── Decoder synthesis ───────────────────────────────────────────────
138
+
139
+ /**
140
+ * Decoder emission shape.
141
+ *
142
+ * - `"standalone"`: the decoder owns the megacall binary search.
143
+ * `@builtin(vertex_index)` is its sole entry input, and the body's
144
+ * prelude computes `heap_drawIdx` / `instId` / `vid` as local
145
+ * `let`s. Used by the canonical heap path.
146
+ *
147
+ * - `"family-member"`: the family wrapper VS already ran the
148
+ * megacall search and dispatches into per-effect helpers passing
149
+ * `heap_drawIdx`, `instId`, `vid` as plain `u32` args. The decoder
150
+ * consumes those as `EntryParameter`s (non-builtin) and skips the
151
+ * prelude entirely. After `extractFusedEntry` collapses the
152
+ * decoder + user stages, the per-effect helper exposed to the
153
+ * wrapper has exactly three `u32` parameters and one output struct
154
+ * return.
155
+ */
156
+ export type HeapDecoderMode = "standalone" | "family-member";
157
+
158
+ /**
159
+ * Build a fresh wombat.shader Effect containing exactly one vertex
160
+ * stage — the heap-decoder. Compose it with the user effect's stages
161
+ * via `effect(decoder, ...userEffect.stages)`; let composeStages do
162
+ * the rest.
163
+ *
164
+ * Required input: the bucket layout. Reading the user's iface to
165
+ * derive it happens upstream (heapScene already builds the layout from
166
+ * `effect.compile(...).interface`).
167
+ *
168
+ * Each `attribute-ref` field in the layout produces a per-vertex (or
169
+ * per-instance, depending on `layout.perInstanceAttributes`) load. Each
170
+ * `uniform-ref` field produces a per-draw broadcast load (or per-
171
+ * instance from `layout.perInstanceUniforms`). Atlas texture-ref
172
+ * fields surface as four flat varyings per binding so the FS-side
173
+ * atlas-sample rewrite can read them from the carrier.
174
+ */
175
+ export function synthesizeHeapDecoderEffect(layout: BucketLayout, mode: HeapDecoderMode = "standalone"): Effect {
176
+ return makeStage(synthesizeHeapDecoderModule(layout, mode));
177
+ }
178
+
179
+ /**
180
+ * Like `synthesizeHeapDecoderEffect` but returns the raw Module so
181
+ * callers can splice it into an existing module-merge pipeline.
182
+ */
183
+ export function synthesizeHeapDecoderModule(layout: BucketLayout, mode: HeapDecoderMode = "standalone"): Module {
184
+ // Storage bindings: arena always required; megacall lookup buffers
185
+ // only when the decoder itself owns the binary search (standalone
186
+ // mode). Family-member mode receives the resolved triple from the
187
+ // wrapper VS via plain u32 fn parameters, so it doesn't need the
188
+ // lookup tables in its own module-scope binding set.
189
+ const storageDecls: ValueDef[] = mode === "standalone"
190
+ ? [...heapArenaStorageDecls(), ...megacallLookupStorageDecls()]
191
+ : [...heapArenaStorageDecls()];
192
+
193
+ const inputs: EntryParameter[] = [];
194
+ const outputs: EntryParameter[] = [];
195
+ const stmts: Stmt[] = [];
196
+
197
+ // Locals (or input params) that downstream WriteOutput stmts pull
198
+ // from. Populated below depending on `mode`.
199
+ let drawIdxExpr: Expr;
200
+ let instIdExpr: Expr;
201
+ let vidExpr: Expr;
202
+
203
+ if (mode === "standalone") {
204
+ // Single vertex entry input: the megacall builtin. WGSL emits
205
+ // builtin reads as the *semantic* name; `name === semantic`
206
+ // keeps the @vertex param identifier matching the body.
207
+ inputs.push({
208
+ name: "vertex_index",
209
+ type: Tu32,
210
+ semantic: "vertex_index",
211
+ decorations: [{ kind: "Builtin", value: "vertex_index" }],
212
+ });
213
+ // Read via ReadInput("Builtin", ...): extractFusedEntry detects
214
+ // builtin reads through this scope; a bare `Var` reference would
215
+ // slip past its bodyReads analysis and the fused @vertex
216
+ // wouldn't surface the builtin at all.
217
+ const emitIdxExpr: Expr = { kind: "ReadInput", scope: "Builtin", name: "vertex_index", type: Tu32 };
218
+ // Megacall search → locals heap_drawIdx, instId, vid.
219
+ const { stmts: megacallStmts, locals } = buildMegacallPrelude(emitIdxExpr);
220
+ stmts.push(...megacallStmts);
221
+ drawIdxExpr = { kind: "Var", var: locals.heapDrawIdx, type: Tu32 };
222
+ instIdExpr = { kind: "Var", var: locals.instId, type: Tu32 };
223
+ vidExpr = { kind: "Var", var: locals.vid, type: Tu32 };
224
+ } else {
225
+ // Family-member mode: receive the megacall outputs as plain u32
226
+ // params from the wrapper. Declare them as EntryParameters
227
+ // (location-decorated so they survive composeStages' attribute
228
+ // classification), then read them via ReadInput("Input", ...)
229
+ // inside the body. After composition the fused entry surfaces
230
+ // them as inputs that the wrapper passes by name.
231
+ const declParam = (name: string, location: number): EntryParameter => ({
232
+ name,
233
+ type: Tu32,
234
+ semantic: name,
235
+ decorations: [
236
+ { kind: "Location", value: location },
237
+ // u32 inter-stage IO must be flat-interpolated.
238
+ { kind: "Interpolation", mode: "flat" },
239
+ ],
240
+ });
241
+ inputs.push(declParam("heap_drawIdx", 0));
242
+ inputs.push(declParam("instId", 1));
243
+ inputs.push(declParam("vid", 2));
244
+ drawIdxExpr = { kind: "ReadInput", scope: "Input", name: "heap_drawIdx", type: Tu32 };
245
+ instIdExpr = { kind: "ReadInput", scope: "Input", name: "instId", type: Tu32 };
246
+ vidExpr = { kind: "ReadInput", scope: "Input", name: "vid", type: Tu32 };
247
+ }
248
+
249
+ // Walk the bucket layout's drawHeader fields. The order doesn't
250
+ // matter for correctness — every surfaced name becomes a unique
251
+ // output and the carrier carries them all.
252
+ let nextLocation = 0;
253
+ // Texture-ref drawHeader fields are grouped by `textureBindingName` —
254
+ // each atlas-routed texture binding produces four contiguous entries
255
+ // (pageRef / formatBits / origin / size). We emit them as four
256
+ // distinct carrier outputs `<name>__atlasPageRef`, etc., so the
257
+ // FS-side `textureSample(t, …)` rewrite can read them by name.
258
+ for (const f of layout.drawHeaderFields) {
259
+ if (f.kind === "uniform-ref") {
260
+ addUniformOutput(f, layout, drawIdxExpr, instIdExpr, outputs, stmts, nextLocation++);
261
+ continue;
262
+ }
263
+ if (f.kind === "attribute-ref") {
264
+ addAttributeOutput(f, layout, drawIdxExpr, instIdExpr, vidExpr, outputs, stmts, nextLocation++);
265
+ continue;
266
+ }
267
+ if (f.kind === "texture-ref") {
268
+ addTextureSubOutput(f, layout, drawIdxExpr, outputs, stmts, nextLocation++);
269
+ continue;
270
+ }
271
+ }
272
+
273
+ const entry: EntryDef = {
274
+ name: "heap_decode_vs",
275
+ stage: "vertex",
276
+ inputs,
277
+ outputs,
278
+ arguments: [],
279
+ returnType: { kind: "Void" } as Type,
280
+ body: { kind: "Sequential", body: stmts },
281
+ decorations: [],
282
+ };
283
+
284
+ return {
285
+ types: [],
286
+ values: [...storageDecls, { kind: "Entry", entry }],
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Emit a single `WriteOutput(name, loadUniformByRef(...))` for a
292
+ * `uniform-ref` field. Surfaces a per-name output the user VS will
293
+ * read as `ReadInput("Input", name)` post-uniformsToInputs.
294
+ */
295
+ function addUniformOutput(
296
+ f: DrawHeaderField,
297
+ layout: BucketLayout,
298
+ drawIdxExpr: Expr,
299
+ instIdExpr: Expr,
300
+ outputs: EntryParameter[],
301
+ stmts: Stmt[],
302
+ location: number,
303
+ ): void {
304
+ const wgslType = f.uniformWgslType;
305
+ if (wgslType === undefined) {
306
+ throw new Error(`heapDecoder: uniform-ref field '${f.name}' has no uniformWgslType`);
307
+ }
308
+ const irType = wgslTypeToIrType(wgslType);
309
+ const refExpr = loadHeaderRef(drawIdxExpr, f.byteOffset, layout.strideU32);
310
+ const value = layout.perInstanceUniforms.has(f.name)
311
+ ? loadInstanceByRef(refExpr, instIdExpr, wgslType)
312
+ : loadUniformByRef(refExpr, wgslType);
313
+
314
+ outputs.push({
315
+ name: f.name,
316
+ type: irType,
317
+ semantic: f.name,
318
+ decorations: [
319
+ { kind: "Location", value: location },
320
+ // Per-draw broadcast — uniform across all vertices of one draw.
321
+ // `flat` interpolation skips the rasterizer's perspective-correct
322
+ // interpolation; required for u32 carriers anyway, harmless for
323
+ // float carriers that are constant across the draw.
324
+ { kind: "Interpolation", mode: "flat" },
325
+ ],
326
+ });
327
+ stmts.push({
328
+ kind: "WriteOutput",
329
+ name: f.name,
330
+ value: { kind: "Expr", value },
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Emit a `WriteOutput` for a single atlas texture-ref sub-field.
336
+ * Atlas drawHeader entries carry the value INLINE (not as a heap ref).
337
+ * Layout (matches `buildBucketLayout` in heapEffect.ts):
338
+ * pageRef : u32 — 4 bytes
339
+ * formatBits : u32 — 4 bytes
340
+ * (padding to 8-aligned)
341
+ * origin : vec2<f32> — 8 bytes
342
+ * size : vec2<f32> — 8 bytes
343
+ *
344
+ * The carrier output name follows `<textureBindingName>__atlas<Sub>`.
345
+ */
346
+ function addTextureSubOutput(
347
+ f: DrawHeaderField,
348
+ layout: BucketLayout,
349
+ drawIdxExpr: Expr,
350
+ outputs: EntryParameter[],
351
+ stmts: Stmt[],
352
+ location: number,
353
+ ): void {
354
+ const sub = f.textureSub;
355
+ const tex = f.textureBindingName;
356
+ if (sub === undefined || tex === undefined) {
357
+ throw new Error(`heapDecoder: texture-ref field '${f.name}' missing textureSub/textureBindingName`);
358
+ }
359
+ const carrierName = atlasCarrierName(tex, sub);
360
+ let value: Expr;
361
+ let irType: Type;
362
+ switch (sub) {
363
+ case "pageRef":
364
+ case "formatBits": {
365
+ irType = Tu32;
366
+ value = readHeaderU32(drawIdxExpr, f.byteOffset, layout.strideU32);
367
+ break;
368
+ }
369
+ case "origin":
370
+ case "size": {
371
+ irType = Tvec2;
372
+ value = readHeaderVec2F(drawIdxExpr, f.byteOffset, layout.strideU32);
373
+ break;
374
+ }
375
+ }
376
+ outputs.push({
377
+ name: carrierName,
378
+ type: irType,
379
+ semantic: carrierName,
380
+ decorations: [
381
+ { kind: "Location", value: location },
382
+ // Atlas params are constant across all fragments of a primitive
383
+ // — flat interpolation skips the rasterizer's per-pixel math and
384
+ // is mandatory for u32 carriers.
385
+ { kind: "Interpolation", mode: "flat" },
386
+ ],
387
+ });
388
+ stmts.push({
389
+ kind: "WriteOutput",
390
+ name: carrierName,
391
+ value: { kind: "Expr", value },
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Emit a single `WriteOutput(name, loadAttributeByRef(...))` for an
397
+ * `attribute-ref` field. Uses per-vertex `vid` for plain attributes
398
+ * and per-instance `instId` for instance-attributes.
399
+ */
400
+ function addAttributeOutput(
401
+ f: DrawHeaderField,
402
+ layout: BucketLayout,
403
+ drawIdxExpr: Expr,
404
+ instIdExpr: Expr,
405
+ vidExpr: Expr,
406
+ outputs: EntryParameter[],
407
+ stmts: Stmt[],
408
+ location: number,
409
+ ): void {
410
+ const wgslType = f.attributeWgslType;
411
+ if (wgslType === undefined) {
412
+ throw new Error(`heapDecoder: attribute-ref field '${f.name}' has no attributeWgslType`);
413
+ }
414
+ const irType = wgslTypeToIrType(wgslType);
415
+ const refExpr = loadHeaderRef(drawIdxExpr, f.byteOffset, layout.strideU32);
416
+ const idx = layout.perInstanceAttributes.has(f.name) ? instIdExpr : vidExpr;
417
+ const value = loadAttributeByRef(refExpr, idx, wgslType);
418
+
419
+ // Per-vertex attributes naturally vary across a primitive — perspective-
420
+ // correct interpolation is the right default. Per-instance attributes
421
+ // are constant across a primitive's vertices but in this fused-stage
422
+ // model they still flow through the carrier, so flat is the safe
423
+ // policy (no semantic difference for genuinely-constant values).
424
+ // Integer-typed inter-stage IO MUST be `flat` (WGSL constraint).
425
+ const isInteger =
426
+ irType.kind === "Int" || irType.kind === "Bool"
427
+ || (irType.kind === "Vector"
428
+ && (irType.element.kind === "Int" || irType.element.kind === "Bool"));
429
+ const interp: "smooth" | "flat" =
430
+ layout.perInstanceAttributes.has(f.name) || isInteger ? "flat" : "smooth";
431
+
432
+ outputs.push({
433
+ name: f.name,
434
+ type: irType,
435
+ semantic: f.name,
436
+ decorations: [
437
+ { kind: "Location", value: location },
438
+ { kind: "Interpolation", mode: interp },
439
+ ],
440
+ });
441
+ stmts.push({
442
+ kind: "WriteOutput",
443
+ name: f.name,
444
+ value: { kind: "Expr", value },
445
+ });
446
+ }