@aardworx/wombat.rendering 0.8.0 → 0.9.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.
Files changed (136) hide show
  1. package/dist/core/command.d.ts +7 -9
  2. package/dist/core/command.d.ts.map +1 -1
  3. package/dist/core/renderTask.d.ts +64 -1
  4. package/dist/core/renderTask.d.ts.map +1 -1
  5. package/dist/core/renderTask.js +3 -1
  6. package/dist/core/renderTask.js.map +1 -1
  7. package/dist/resources/preparedRenderObject.js +3 -3
  8. package/dist/resources/preparedRenderObject.js.map +1 -1
  9. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  10. package/dist/resources/uniformBuffer.js +17 -0
  11. package/dist/resources/uniformBuffer.js.map +1 -1
  12. package/dist/runtime/derivedUniforms/dispatch.d.ts +105 -0
  13. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -0
  14. package/dist/runtime/derivedUniforms/dispatch.js +355 -0
  15. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -0
  16. package/dist/runtime/derivedUniforms/index.d.ts +6 -0
  17. package/dist/runtime/derivedUniforms/index.d.ts.map +1 -0
  18. package/dist/runtime/derivedUniforms/index.js +26 -0
  19. package/dist/runtime/derivedUniforms/index.js.map +1 -0
  20. package/dist/runtime/derivedUniforms/recipes.d.ts +42 -0
  21. package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -0
  22. package/dist/runtime/derivedUniforms/recipes.js +134 -0
  23. package/dist/runtime/derivedUniforms/recipes.js.map +1 -0
  24. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +56 -0
  25. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -0
  26. package/dist/runtime/derivedUniforms/sceneIntegration.js +171 -0
  27. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -0
  28. package/dist/runtime/derivedUniforms/slotId.d.ts +15 -0
  29. package/dist/runtime/derivedUniforms/slotId.d.ts.map +1 -0
  30. package/dist/runtime/derivedUniforms/slotId.js +30 -0
  31. package/dist/runtime/derivedUniforms/slotId.js.map +1 -0
  32. package/dist/runtime/derivedUniforms/slots.d.ts +49 -0
  33. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -0
  34. package/dist/runtime/derivedUniforms/slots.js +123 -0
  35. package/dist/runtime/derivedUniforms/slots.js.map +1 -0
  36. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +13 -0
  37. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +1 -0
  38. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +218 -0
  39. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +1 -0
  40. package/dist/runtime/flattenTree.d.ts +5 -0
  41. package/dist/runtime/flattenTree.d.ts.map +1 -0
  42. package/dist/runtime/flattenTree.js +45 -0
  43. package/dist/runtime/flattenTree.js.map +1 -0
  44. package/dist/runtime/heapAdapter.d.ts +16 -0
  45. package/dist/runtime/heapAdapter.d.ts.map +1 -0
  46. package/dist/runtime/heapAdapter.js +263 -0
  47. package/dist/runtime/heapAdapter.js.map +1 -0
  48. package/dist/runtime/heapEffect.d.ts +235 -0
  49. package/dist/runtime/heapEffect.d.ts.map +1 -0
  50. package/dist/runtime/heapEffect.js +518 -0
  51. package/dist/runtime/heapEffect.js.map +1 -0
  52. package/dist/runtime/heapEffectIR.d.ts +34 -0
  53. package/dist/runtime/heapEffectIR.d.ts.map +1 -0
  54. package/dist/runtime/heapEffectIR.js +1161 -0
  55. package/dist/runtime/heapEffectIR.js.map +1 -0
  56. package/dist/runtime/heapEligibility.d.ts +9 -0
  57. package/dist/runtime/heapEligibility.d.ts.map +1 -0
  58. package/dist/runtime/heapEligibility.js +195 -0
  59. package/dist/runtime/heapEligibility.js.map +1 -0
  60. package/dist/runtime/heapScene.d.ts +240 -31
  61. package/dist/runtime/heapScene.d.ts.map +1 -1
  62. package/dist/runtime/heapScene.js +3334 -265
  63. package/dist/runtime/heapScene.js.map +1 -1
  64. package/dist/runtime/heapShaderFamily.d.ts +132 -0
  65. package/dist/runtime/heapShaderFamily.d.ts.map +1 -0
  66. package/dist/runtime/heapShaderFamily.js +866 -0
  67. package/dist/runtime/heapShaderFamily.js.map +1 -0
  68. package/dist/runtime/hybridScene.d.ts +133 -0
  69. package/dist/runtime/hybridScene.d.ts.map +1 -0
  70. package/dist/runtime/hybridScene.js +159 -0
  71. package/dist/runtime/hybridScene.js.map +1 -0
  72. package/dist/runtime/index.d.ts +8 -0
  73. package/dist/runtime/index.d.ts.map +1 -1
  74. package/dist/runtime/index.js +9 -0
  75. package/dist/runtime/index.js.map +1 -1
  76. package/dist/runtime/renderTask.d.ts +21 -4
  77. package/dist/runtime/renderTask.d.ts.map +1 -1
  78. package/dist/runtime/renderTask.js +127 -32
  79. package/dist/runtime/renderTask.js.map +1 -1
  80. package/dist/runtime/runtime.d.ts +33 -3
  81. package/dist/runtime/runtime.d.ts.map +1 -1
  82. package/dist/runtime/runtime.js +12 -3
  83. package/dist/runtime/runtime.js.map +1 -1
  84. package/dist/runtime/scenePass.d.ts +28 -3
  85. package/dist/runtime/scenePass.d.ts.map +1 -1
  86. package/dist/runtime/scenePass.js +37 -4
  87. package/dist/runtime/scenePass.js.map +1 -1
  88. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +35 -0
  89. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -0
  90. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +247 -0
  91. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -0
  92. package/dist/runtime/textureAtlas/atlasPool.d.ts +240 -0
  93. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -0
  94. package/dist/runtime/textureAtlas/atlasPool.js +798 -0
  95. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -0
  96. package/dist/runtime/textureAtlas/bvhTree2d.d.ts +33 -0
  97. package/dist/runtime/textureAtlas/bvhTree2d.d.ts.map +1 -0
  98. package/dist/runtime/textureAtlas/bvhTree2d.js +501 -0
  99. package/dist/runtime/textureAtlas/bvhTree2d.js.map +1 -0
  100. package/dist/runtime/textureAtlas/packer.d.ts +50 -0
  101. package/dist/runtime/textureAtlas/packer.d.ts.map +1 -0
  102. package/dist/runtime/textureAtlas/packer.js +390 -0
  103. package/dist/runtime/textureAtlas/packer.js.map +1 -0
  104. package/dist/window/loop.d.ts +14 -0
  105. package/dist/window/loop.d.ts.map +1 -1
  106. package/dist/window/loop.js +18 -5
  107. package/dist/window/loop.js.map +1 -1
  108. package/package.json +5 -4
  109. package/src/core/command.ts +9 -9
  110. package/src/core/renderTask.ts +55 -2
  111. package/src/resources/preparedRenderObject.ts +3 -3
  112. package/src/resources/uniformBuffer.ts +15 -0
  113. package/src/runtime/derivedUniforms/dispatch.ts +407 -0
  114. package/src/runtime/derivedUniforms/index.ts +52 -0
  115. package/src/runtime/derivedUniforms/recipes.ts +159 -0
  116. package/src/runtime/derivedUniforms/sceneIntegration.ts +239 -0
  117. package/src/runtime/derivedUniforms/slotId.ts +35 -0
  118. package/src/runtime/derivedUniforms/slots.ts +155 -0
  119. package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +220 -0
  120. package/src/runtime/flattenTree.ts +46 -0
  121. package/src/runtime/heapAdapter.ts +314 -0
  122. package/src/runtime/heapEffect.ts +719 -0
  123. package/src/runtime/heapEffectIR.ts +1211 -0
  124. package/src/runtime/heapEligibility.ts +189 -0
  125. package/src/runtime/heapScene.ts +3981 -349
  126. package/src/runtime/heapShaderFamily.ts +1102 -0
  127. package/src/runtime/hybridScene.ts +310 -0
  128. package/src/runtime/index.ts +46 -0
  129. package/src/runtime/renderTask.ts +167 -32
  130. package/src/runtime/runtime.ts +37 -4
  131. package/src/runtime/scenePass.ts +39 -4
  132. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +291 -0
  133. package/src/runtime/textureAtlas/atlasPool.ts +991 -0
  134. package/src/runtime/textureAtlas/bvhTree2d.ts +530 -0
  135. package/src/runtime/textureAtlas/packer.ts +429 -0
  136. package/src/window/loop.ts +32 -6
@@ -0,0 +1,1102 @@
1
+ // heapShaderFamily — analysis pass for §6 family-merge.
2
+ //
3
+ // Given a set of `Effect`s, build a `ShaderFamilySchema` that describes
4
+ // the family-wide layout the rest of §6 will plumb into the heap path:
5
+ //
6
+ // - deterministic `id` over the sorted-by-effect.id member set
7
+ // - `layoutIdOf` mapping each effect to a 0..N-1 layoutId
8
+ // - per-effect varying slot maps (anonymous Varying0..N packing)
9
+ // - unified `drawHeaderUnion` (union of every effect's BucketLayout)
10
+ // - per-effect `HeapEffectSchema` for downstream use
11
+ //
12
+ // This file is a pure analysis pass. It does NOT touch heapScene, does
13
+ // NOT compile WGSL, does NOT rewrite IR. It only computes the
14
+ // load-bearing descriptor that subsequent slices of §6 will consume.
15
+
16
+ import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
17
+ import { combineHashes } from "@aardworx/wombat.shader/ir";
18
+ import {
19
+ compileHeapEffect,
20
+ buildBucketLayout,
21
+ megacallSearchPrelude,
22
+ type HeapEffectSchema,
23
+ type HeapVarying,
24
+ type BucketLayout,
25
+ type DrawHeaderField,
26
+ type FragmentOutputLayout,
27
+ } from "./heapEffect.js";
28
+ import { compileHeapEffectIR } from "./heapEffectIR.js";
29
+
30
+ /**
31
+ * One varying's place in the family-wide anonymous-slot layout. Each
32
+ * slot is a `vec4<f32>` interpolant; varyings smaller than vec4 share
33
+ * a slot via `(offset, size)`. `offset + size <= 4`.
34
+ */
35
+ export interface FamilySlot {
36
+ /** Index into `Varying0..N` slots (0-based). */
37
+ readonly slot: number;
38
+ /** 0..3 — float offset within the slot's vec4. */
39
+ readonly offset: number;
40
+ /** 1..4 — float count consumed. */
41
+ readonly size: number;
42
+ }
43
+
44
+ /**
45
+ * User-supplied slot assigner. Receives the effect, the varying name,
46
+ * and its WGSL type string (e.g. `"vec3<f32>"`); returns a `FamilySlot`
47
+ * if the caller wants to override the default greedy packer for this
48
+ * varying. Returning `undefined` falls back to the default.
49
+ */
50
+ export type FamilySlotAssigner = (
51
+ effect: Effect,
52
+ name: string,
53
+ wgslType: string,
54
+ ) => FamilySlot | undefined;
55
+
56
+ /**
57
+ * One heap-injected (VS→FS threaded) field's place in a family-wide
58
+ * `vec4<u32>` slot. Heap-injected refs are flat-interpolated u32 /
59
+ * vec2<f32> values added by the IR pass (`rewriteFsUniforms` for FS-
60
+ * read uniforms, atlas routing for textureSample). The family wrapper
61
+ * packs them into anonymous `vec4<u32>` slots, bitcasting f32 fields
62
+ * through u32 storage.
63
+ */
64
+ export interface HeapFamilySlot {
65
+ /** Index into `HeapVarying0..N` slots (0-based). */
66
+ readonly slot: number;
67
+ /** 0..3 — u32 offset within the slot's vec4<u32>. */
68
+ readonly offset: number;
69
+ /** 1..4 — u32 component count consumed (= float-count for vec2/u32 alike). */
70
+ readonly size: number;
71
+ /**
72
+ * WGSL type of the field on the per-effect helper struct. Used to
73
+ * choose pack/unpack expressions (bitcast for f32, identity for u32).
74
+ */
75
+ readonly wgslType: string;
76
+ }
77
+
78
+ export interface ShaderFamilySchema {
79
+ /** Stable hash over the sorted-by-id effect set. Bucket-key axis. */
80
+ readonly id: string;
81
+ /** Member effects in deterministic order (sorted by `effect.id`). */
82
+ readonly effects: readonly Effect[];
83
+ /** layoutId assignment, 0..N-1, parallel to `effects`. */
84
+ readonly layoutIdOf: ReadonlyMap<Effect, number>;
85
+ /** Total vec4<f32> slots in family VsOut (max across effects). */
86
+ readonly varyingSlots: number;
87
+ /** Per-effect slot positions for each named, non-builtin varying. */
88
+ readonly perEffectSlotMap: ReadonlyMap<Effect, ReadonlyMap<string, FamilySlot>>;
89
+ /**
90
+ * Total `vec4<u32>` slots for heap-injected flat-interpolated
91
+ * threading refs (max across effects). Populated by
92
+ * `compileShaderFamily` from the IR-emitted per-effect VS output
93
+ * structs; `0` until `compileShaderFamily` runs.
94
+ */
95
+ readonly heapVaryingSlots: number;
96
+ /**
97
+ * Per-effect slot positions for heap-injected refs (`_h_...` fields
98
+ * synthesised by the IR rewrite). Populated by `compileShaderFamily`;
99
+ * empty until then.
100
+ */
101
+ readonly perEffectHeapSlotMap: ReadonlyMap<Effect, ReadonlyMap<string, HeapFamilySlot>>;
102
+ /**
103
+ * Unified BucketLayout: union of all effects' drawHeader fields,
104
+ * plus a `layoutId: u32` field appended at the end. Same-name fields
105
+ * collapse to one entry across effects (after type-equality check).
106
+ */
107
+ readonly drawHeaderUnion: BucketLayout;
108
+ /** Per-effect schema as compiled (diagnostics + downstream consumers). */
109
+ readonly perEffectSchema: ReadonlyMap<Effect, HeapEffectSchema>;
110
+ }
111
+
112
+ /**
113
+ * Build a family schema from a (possibly unordered) set of effects.
114
+ *
115
+ * Throws on:
116
+ * - same-name varying with mismatched WGSL type across effects
117
+ * - same-name drawHeader field with mismatched WGSL type across effects
118
+ *
119
+ * The error message identifies both effects + the offending field so
120
+ * the user can disambiguate via `effect.rename({varyings: ...})`.
121
+ */
122
+ export interface BuildShaderFamilyOptions {
123
+ /**
124
+ * Route every texture binding declared by any member effect through
125
+ * the atlas drawHeader path (pageRef / formatBits / origin / size
126
+ * inline fields) instead of standalone texture bindings. Set by
127
+ * heapScene's family-merge path so the union bucket has uniform
128
+ * texture-binding shape regardless of which member effects are
129
+ * textured. Defaults to false (analysis-only callers don't need it).
130
+ */
131
+ readonly atlasizeAllTextures?: boolean;
132
+ /**
133
+ * Per-effect per-instance attribute / uniform sets. Required for any
134
+ * effect that uses per-instance attributes (e.g. `InstanceOffset` on
135
+ * `instancedSurface`); without this, the IR substitution falls
136
+ * through to per-vertex `vertex_index` addressing for those reads
137
+ * and instances produce malformed triangles. heapScene's family
138
+ * lazy-build derives this from the first batch's
139
+ * `spec.instanceAttributes` per effect. Optional for analysis-only
140
+ * callers (no per-instance attrs in their effects).
141
+ */
142
+ readonly perEffectPerInstance?: ReadonlyMap<Effect, {
143
+ readonly attributes: ReadonlySet<string>;
144
+ readonly uniforms?: ReadonlySet<string>;
145
+ }>;
146
+ }
147
+
148
+ export function buildShaderFamily(
149
+ effects: readonly Effect[],
150
+ fragmentOutputLayout?: FragmentOutputLayout,
151
+ slotAssigner?: FamilySlotAssigner,
152
+ options: BuildShaderFamilyOptions = {},
153
+ ): ShaderFamilySchema {
154
+ // 1. Deterministic ordering. Sort by `effect.id` (lexicographic).
155
+ // `Array.prototype.sort` is mutating, so copy first.
156
+ const sortedEffects = [...effects].sort((a, b) =>
157
+ a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
158
+ );
159
+
160
+ const layoutIdOf = new Map<Effect, number>();
161
+ for (let i = 0; i < sortedEffects.length; i++) {
162
+ layoutIdOf.set(sortedEffects[i]!, i);
163
+ }
164
+
165
+ // 2. Compile every effect's schema. Don't run `compileHeapEffectIR`
166
+ // here — that does heap-shape rewriting; we just want the schema.
167
+ const perEffectSchema = new Map<Effect, HeapEffectSchema>();
168
+ for (const e of sortedEffects) {
169
+ const compiled = compileHeapEffect(e, fragmentOutputLayout);
170
+ perEffectSchema.set(e, compiled.schema);
171
+ }
172
+
173
+ // 3. Family hash: combine the (sorted) member ids deterministically.
174
+ const id = combineHashes(...sortedEffects.map(e => e.id));
175
+
176
+ // 4. Cross-effect varying type-equality check. Same-name varying ⇒
177
+ // same WGSL type (and same builtin-or-not flavor).
178
+ checkVaryingTypeAgreement(sortedEffects, perEffectSchema);
179
+
180
+ // 5. Per-effect varying slot packing.
181
+ const perEffectSlotMap = new Map<Effect, ReadonlyMap<string, FamilySlot>>();
182
+ let varyingSlots = 0;
183
+ for (const e of sortedEffects) {
184
+ const schema = perEffectSchema.get(e)!;
185
+ const slotMap = packVaryings(e, schema.varyings, slotAssigner);
186
+ perEffectSlotMap.set(e, slotMap);
187
+ let maxSlot = 0;
188
+ for (const s of slotMap.values()) {
189
+ if (s.slot + 1 > maxSlot) maxSlot = s.slot + 1;
190
+ }
191
+ if (maxSlot > varyingSlots) varyingSlots = maxSlot;
192
+ }
193
+
194
+ // 6. drawHeader union across all effects, then append layoutId.
195
+ const drawHeaderUnion = unionDrawHeaders(
196
+ sortedEffects, perEffectSchema, options.atlasizeAllTextures === true,
197
+ options.perEffectPerInstance,
198
+ );
199
+
200
+ return {
201
+ id,
202
+ effects: sortedEffects,
203
+ layoutIdOf,
204
+ varyingSlots,
205
+ perEffectSlotMap,
206
+ // `heapVaryingSlots` and `perEffectHeapSlotMap` are populated by
207
+ // `compileShaderFamily` from IR-emitted per-effect VS output
208
+ // structs (the names of heap-injected `_h_...` fields are not
209
+ // known until IR rewrite runs). Empty here so the schema is
210
+ // useable for analysis-only callers.
211
+ heapVaryingSlots: 0,
212
+ perEffectHeapSlotMap: new Map(),
213
+ drawHeaderUnion,
214
+ perEffectSchema,
215
+ };
216
+ }
217
+
218
+ // ─── Varying packing ────────────────────────────────────────────────
219
+
220
+ /** Float-count of a WGSL varying type (vec4=4, vec3=3, vec2=2, scalar=1). */
221
+ function wgslFloatCount(wgslType: string): number {
222
+ // Cover the realistic varying-typed surface: f32, vec2/3/4<f32>,
223
+ // and the scalar-int variants (u32, i32 — used for flat-interpolated
224
+ // atlas threading and discrete payloads). Matrices are not legal as
225
+ // an inter-stage type per WGSL, so we don't need to handle them.
226
+ if (wgslType === "f32" || wgslType === "u32" || wgslType === "i32" || wgslType === "bool") return 1;
227
+ const m = /^vec([234])<\s*[fui]32\s*>$/.exec(wgslType);
228
+ if (m !== null) return Number(m[1]);
229
+ throw new Error(
230
+ `buildShaderFamily: cannot determine slot size for varying type '${wgslType}'`,
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Greedy packing: walk the effect's non-builtin varyings in order;
236
+ * place each in the first slot that has enough remaining float-room.
237
+ * Open a fresh slot when the current one can't fit. Per-effect — each
238
+ * effect's slot space starts at slot 0; the family's `varyingSlots` is
239
+ * the max across effects.
240
+ */
241
+ function packVaryings(
242
+ effect: Effect,
243
+ varyings: readonly HeapVarying[],
244
+ slotAssigner: FamilySlotAssigner | undefined,
245
+ ): ReadonlyMap<string, FamilySlot> {
246
+ const result = new Map<string, FamilySlot>();
247
+ const slotFill: number[] = []; // floats consumed in each slot so far
248
+
249
+ for (const v of varyings) {
250
+ if (v.builtin !== undefined) continue; // gl_Position etc. — not in family VsOut
251
+ const size = wgslFloatCount(v.wgslType);
252
+
253
+ // Caller override path.
254
+ if (slotAssigner !== undefined) {
255
+ const override = slotAssigner(effect, v.name, v.wgslType);
256
+ if (override !== undefined) {
257
+ if (override.size !== size) {
258
+ throw new Error(
259
+ `buildShaderFamily: slotAssigner returned size ${override.size} for varying ` +
260
+ `'${v.name}: ${v.wgslType}' (expected ${size})`,
261
+ );
262
+ }
263
+ if (override.offset < 0 || override.offset + override.size > 4) {
264
+ throw new Error(
265
+ `buildShaderFamily: slotAssigner returned out-of-range slot ` +
266
+ `(offset=${override.offset}, size=${override.size}) for varying '${v.name}'`,
267
+ );
268
+ }
269
+ result.set(v.name, override);
270
+ // Track the slot fill for callers that mix override + default.
271
+ while (slotFill.length <= override.slot) slotFill.push(0);
272
+ const used = slotFill[override.slot]!;
273
+ const top = override.offset + override.size;
274
+ if (top > used) slotFill[override.slot] = top;
275
+ continue;
276
+ }
277
+ }
278
+
279
+ // Default greedy: first-fit by remaining room.
280
+ let placed = false;
281
+ for (let i = 0; i < slotFill.length; i++) {
282
+ const used = slotFill[i]!;
283
+ if (used + size <= 4) {
284
+ result.set(v.name, { slot: i, offset: used, size });
285
+ slotFill[i] = used + size;
286
+ placed = true;
287
+ break;
288
+ }
289
+ }
290
+ if (!placed) {
291
+ const slot = slotFill.length;
292
+ slotFill.push(size);
293
+ result.set(v.name, { slot, offset: 0, size });
294
+ }
295
+ }
296
+ return result;
297
+ }
298
+
299
+ // ─── DrawHeader union ───────────────────────────────────────────────
300
+
301
+ /**
302
+ * Union the per-effect BucketLayouts into a single drawHeader. Same-
303
+ * name field across effects ⇒ same WGSL type (else throws). The
304
+ * result has every unique field laid out in encounter order, then
305
+ * `layoutId: u32` appended at a fresh aligned offset.
306
+ *
307
+ * `perInstanceUniforms` and `perInstanceAttributes` unions across all
308
+ * effects (a uniform/attribute that's per-instance in any effect is
309
+ * per-instance in the family). Texture/sampler bindings union by name
310
+ * with the same type-agreement check; conflicting types throw.
311
+ */
312
+ function unionDrawHeaders(
313
+ sortedEffects: readonly Effect[],
314
+ perEffectSchema: ReadonlyMap<Effect, HeapEffectSchema>,
315
+ atlasizeAllTextures: boolean,
316
+ perEffectPerInstance: ReadonlyMap<Effect, {
317
+ readonly attributes: ReadonlySet<string>;
318
+ readonly uniforms?: ReadonlySet<string>;
319
+ }> | undefined,
320
+ ): BucketLayout {
321
+ // Build each effect's BucketLayout with default opts. The v1 PoC's
322
+ // family-build call site (a future slice) will pass the real
323
+ // perInstance / atlas opts; here we just want the schema-driven
324
+ // union.
325
+ const perEffectLayout = new Map<Effect, BucketLayout>();
326
+ for (const e of sortedEffects) {
327
+ const schema = perEffectSchema.get(e)!;
328
+ const atlasNames = atlasizeAllTextures
329
+ ? new Set(schema.textures.map(t => t.name))
330
+ : new Set<string>();
331
+ const perI = perEffectPerInstance?.get(e);
332
+ perEffectLayout.set(e, buildBucketLayout(schema, false, {
333
+ atlasTextureBindings: atlasNames,
334
+ ...(perI?.attributes !== undefined ? { perInstanceAttributes: perI.attributes } : {}),
335
+ ...(perI?.uniforms !== undefined ? { perInstanceUniforms: perI.uniforms } : {}),
336
+ }));
337
+ }
338
+
339
+ // Union drawHeaderFields by name. First-seen layout wins for the
340
+ // field's metadata; we just verify type-agreement on subsequent
341
+ // appearances. We DON'T trust the per-effect byteOffsets — those are
342
+ // recomputed for the unified layout below.
343
+ const fieldByName = new Map<string, { field: DrawHeaderField; ownerId: string }>();
344
+ for (const e of sortedEffects) {
345
+ const layout = perEffectLayout.get(e)!;
346
+ for (const f of layout.drawHeaderFields) {
347
+ const existing = fieldByName.get(f.name);
348
+ if (existing === undefined) {
349
+ fieldByName.set(f.name, { field: f, ownerId: e.id });
350
+ } else {
351
+ // Compare the underlying logical type, not the ref-slot type
352
+ // (which is always "u32" for uniform-ref / attribute-ref).
353
+ const existingLogical = logicalFieldType(existing.field);
354
+ const incomingLogical = logicalFieldType(f);
355
+ if (existingLogical !== incomingLogical) {
356
+ throw new Error(
357
+ `buildShaderFamily: drawHeader field '${f.name}' has conflicting WGSL type ` +
358
+ `across effects: '${existingLogical}' (effect ${existing.ownerId}) vs ` +
359
+ `'${incomingLogical}' (effect ${e.id}). Disambiguate via effect.rename(...).`,
360
+ );
361
+ }
362
+ if (existing.field.kind !== f.kind) {
363
+ throw new Error(
364
+ `buildShaderFamily: drawHeader field '${f.name}' has conflicting kind across ` +
365
+ `effects: '${existing.field.kind}' (effect ${existing.ownerId}) vs ` +
366
+ `'${f.kind}' (effect ${e.id}).`,
367
+ );
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ // Re-lay out the unioned fields with fresh byte offsets. Order is
374
+ // deterministic: encounter order over the sorted effects + their
375
+ // own field order.
376
+ const ordered: DrawHeaderField[] = [];
377
+ const seen = new Set<string>();
378
+ for (const e of sortedEffects) {
379
+ const layout = perEffectLayout.get(e)!;
380
+ for (const f of layout.drawHeaderFields) {
381
+ if (seen.has(f.name)) continue;
382
+ seen.add(f.name);
383
+ ordered.push(f);
384
+ }
385
+ }
386
+
387
+ const fields: DrawHeaderField[] = [];
388
+ let off = 0;
389
+ for (const f of ordered) {
390
+ off = roundUp(off, Math.min(f.byteSize, 16));
391
+ const next: DrawHeaderField = {
392
+ ...f,
393
+ byteOffset: off,
394
+ };
395
+ fields.push(next);
396
+ off += f.byteSize;
397
+ }
398
+ // Append the family-wide layoutId selector. u32 → 4-byte align.
399
+ off = roundUp(off, 4);
400
+ const layoutIdField: DrawHeaderField = {
401
+ name: "__layoutId",
402
+ wgslName: "layoutId",
403
+ wgslType: "u32",
404
+ byteOffset: off,
405
+ byteSize: 4,
406
+ kind: "uniform-ref",
407
+ // Logical type for the heap-load IR builder. Without this,
408
+ // `compileHeapEffectIR` would fail on the family layout because
409
+ // `loadUniformByRef` is dispatched on `uniformWgslType`.
410
+ uniformWgslType: "u32",
411
+ };
412
+ fields.push(layoutIdField);
413
+ off += 4;
414
+
415
+ const drawHeaderBytes = roundUp(off, 16);
416
+ const strideU32 = drawHeaderBytes / 4;
417
+
418
+ // Union perInstance sets and texture/sampler bindings.
419
+ const perInstanceUniforms = new Set<string>();
420
+ const perInstanceAttributes = new Set<string>();
421
+ for (const e of sortedEffects) {
422
+ const layout = perEffectLayout.get(e)!;
423
+ for (const n of layout.perInstanceUniforms) perInstanceUniforms.add(n);
424
+ for (const n of layout.perInstanceAttributes) perInstanceAttributes.add(n);
425
+ }
426
+
427
+ type TexBinding = { name: string; wgslType: string; binding: number };
428
+ const textureByName = new Map<string, TexBinding>();
429
+ const samplerByName = new Map<string, TexBinding>();
430
+ const atlasUnion = new Set<string>();
431
+ for (const e of sortedEffects) {
432
+ const layout = perEffectLayout.get(e)!;
433
+ for (const t of layout.textureBindings) {
434
+ const existing = textureByName.get(t.name);
435
+ if (existing === undefined) textureByName.set(t.name, { ...t });
436
+ else if (existing.wgslType !== t.wgslType) {
437
+ throw new Error(
438
+ `buildShaderFamily: texture binding '${t.name}' has conflicting WGSL type ` +
439
+ `across effects: '${existing.wgslType}' vs '${t.wgslType}'.`,
440
+ );
441
+ }
442
+ }
443
+ for (const s of layout.samplerBindings) {
444
+ const existing = samplerByName.get(s.name);
445
+ if (existing === undefined) samplerByName.set(s.name, { ...s });
446
+ else if (existing.wgslType !== s.wgslType) {
447
+ throw new Error(
448
+ `buildShaderFamily: sampler binding '${s.name}' has conflicting WGSL type ` +
449
+ `across effects: '${existing.wgslType}' vs '${s.wgslType}'.`,
450
+ );
451
+ }
452
+ }
453
+ for (const n of layout.atlasTextureBindings) atlasUnion.add(n);
454
+ }
455
+
456
+ // Renumber bindings deterministically — first the unioned textures,
457
+ // then samplers, in encounter order over the sorted effects.
458
+ const orderedTextures: TexBinding[] = [];
459
+ const seenTex = new Set<string>();
460
+ for (const e of sortedEffects) {
461
+ const layout = perEffectLayout.get(e)!;
462
+ for (const t of layout.textureBindings) {
463
+ if (seenTex.has(t.name)) continue;
464
+ seenTex.add(t.name);
465
+ orderedTextures.push(textureByName.get(t.name)!);
466
+ }
467
+ }
468
+ const orderedSamplers: TexBinding[] = [];
469
+ const seenSmp = new Set<string>();
470
+ for (const e of sortedEffects) {
471
+ const layout = perEffectLayout.get(e)!;
472
+ for (const s of layout.samplerBindings) {
473
+ if (seenSmp.has(s.name)) continue;
474
+ seenSmp.add(s.name);
475
+ orderedSamplers.push(samplerByName.get(s.name)!);
476
+ }
477
+ }
478
+ // We don't reassign binding numbers here — the v1 PoC slice that
479
+ // wires this into a real WGSL prelude will do that. For the
480
+ // descriptor-only purpose of this slice, the per-effect-allocated
481
+ // numbers are retained as-is on each entry.
482
+
483
+ return {
484
+ drawHeaderFields: fields,
485
+ drawHeaderBytes,
486
+ preludeWgsl: "", // Family-level prelude is a future slice; not produced here.
487
+ strideU32,
488
+ perInstanceUniforms,
489
+ perInstanceAttributes,
490
+ textureBindings: orderedTextures,
491
+ samplerBindings: orderedSamplers,
492
+ atlasTextureBindings: atlasUnion,
493
+ };
494
+ }
495
+
496
+ function checkVaryingTypeAgreement(
497
+ sortedEffects: readonly Effect[],
498
+ perEffectSchema: ReadonlyMap<Effect, HeapEffectSchema>,
499
+ ): void {
500
+ const seen = new Map<string, { wgslType: string; isBuiltin: boolean; ownerId: string }>();
501
+ for (const e of sortedEffects) {
502
+ const schema = perEffectSchema.get(e)!;
503
+ for (const v of schema.varyings) {
504
+ const existing = seen.get(v.name);
505
+ const isBuiltin = v.builtin !== undefined;
506
+ if (existing === undefined) {
507
+ seen.set(v.name, { wgslType: v.wgslType, isBuiltin, ownerId: e.id });
508
+ } else {
509
+ if (existing.wgslType !== v.wgslType || existing.isBuiltin !== isBuiltin) {
510
+ throw new Error(
511
+ `buildShaderFamily: varying '${v.name}' has conflicting type across effects: ` +
512
+ `'${existing.wgslType}'${existing.isBuiltin ? " (builtin)" : ""} ` +
513
+ `(effect ${existing.ownerId}) vs '${v.wgslType}'${isBuiltin ? " (builtin)" : ""} ` +
514
+ `(effect ${e.id}). Disambiguate via effect.rename({varyings: ...}).`,
515
+ );
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ function roundUp(value: number, mult: number): number {
523
+ return Math.ceil(value / mult) * mult;
524
+ }
525
+
526
+ /**
527
+ * Logical (semantic) WGSL type of a drawHeader field. For uniform-ref
528
+ * and attribute-ref, the field's `wgslType` is always `"u32"` (the
529
+ * pool ref); the real semantic type lives in
530
+ * `uniformWgslType` / `attributeWgslType`. For texture-ref and any
531
+ * other inline kinds, fall back to `wgslType`.
532
+ */
533
+ function logicalFieldType(f: DrawHeaderField): string {
534
+ if (f.kind === "uniform-ref" && f.uniformWgslType !== undefined) return f.uniformWgslType;
535
+ if (f.kind === "attribute-ref" && f.attributeWgslType !== undefined) return f.attributeWgslType;
536
+ return f.wgslType;
537
+ }
538
+
539
+ // ─── Family WGSL synthesis (slice 3b) ───────────────────────────────
540
+ //
541
+ // Take an analysis-only `ShaderFamilySchema` and produce a single VS+FS
542
+ // pair where the merged `@vertex fn family_vs_main` / `@fragment fn
543
+ // family_fs_main` switch on `layoutId` to dispatch to a per-effect
544
+ // helper. Per-effect helpers come from `compileHeapEffectIR(...,
545
+ // "family-member")` after we rename their entries / structs to make
546
+ // them collision-free at module scope.
547
+ //
548
+ // LIMITATIONS (v1):
549
+ // - Per-effect heap-arena bindings (`@group(0) @binding(0..3)`) and
550
+ // any user uniform bindings must agree across effects (same name ⇒
551
+ // same `@group/@binding` and same WGSL type). Dedup checks this
552
+ // and throws on conflict.
553
+ // - Each varying gets its own dedicated `@location(N) vec4<f32>` slot
554
+ // in the family `FamilyVsOut`, regardless of its real width — sub-
555
+ // vec4 packing across effects is a v2 problem. The varying's
556
+ // `FamilySlot.slot` from the schema is used directly as the
557
+ // family location index.
558
+
559
+ export interface CompiledShaderFamily {
560
+ readonly vs: string;
561
+ readonly fs: string;
562
+ readonly fragmentOutputLayout?: FragmentOutputLayout | undefined;
563
+ }
564
+
565
+ /**
566
+ * Synthesise the merged VS+FS pair for a `ShaderFamilySchema`.
567
+ *
568
+ * The output is one self-contained WGSL module per stage. The VS
569
+ * carries the megacall search prelude + all bindings (heap arena 0..3
570
+ * plus drawTable / indexStorage / firstDrawInTile at 4..6 — same shape
571
+ * as the standalone path); the FS carries no megacall bindings (it
572
+ * receives the layoutId via `@interpolate(flat)`).
573
+ *
574
+ * Slice 3b is a pure analysis pass — it does NOT wire into heapScene.
575
+ * Slice 3c will adopt this output behind the existing per-effect
576
+ * pipeline.
577
+ */
578
+ export function compileShaderFamily(
579
+ family: ShaderFamilySchema,
580
+ fragmentOutputLayout?: FragmentOutputLayout,
581
+ ): CompiledShaderFamily {
582
+ const opts: CompileOptions = fragmentOutputLayout !== undefined
583
+ ? { target: "wgsl", fragmentOutputLayout }
584
+ : { target: "wgsl" };
585
+
586
+ // 1. Per-effect rewrite + compile in family-member mode.
587
+ // Each effect's entries get renamed to `family_vs_${K}` /
588
+ // `family_fs_${K}` so they don't collide at module scope when
589
+ // we concatenate them. The auto-generated VsOut / FsIn / FsOut
590
+ // structs derive from the entry names, so they're disambiguated
591
+ // automatically (e.g. `Family_vs_0Output`).
592
+ const perEffectVs: { layoutId: number; vs: string; fsHelperName: string; vsHelperName: string; outStruct: string; fsInStruct: string; fsOutStruct: string }[] = [];
593
+ const perEffectFs: string[] = [];
594
+ for (const e of family.effects) {
595
+ const k = family.layoutIdOf.get(e)!;
596
+ // Composed effects (multiple same-stage Entries that compose into
597
+ // one fused entry) need each source Entry renamed to a UNIQUE
598
+ // target — `renameEntries` rejects collisions, and `composeStages`
599
+ // joins source names into the fused entry's name. After IR compile
600
+ // we read the actual emitted @vertex / @fragment name from the
601
+ // WGSL and use that as the helper name.
602
+ const vsEntries = findStageEntryNames(e, "vertex");
603
+ const fsEntries = findStageEntryNames(e, "fragment");
604
+ const vsPrefix = `family_vs_${k}`;
605
+ const fsPrefix = `family_fs_${k}`;
606
+ const entryMap = new Map<string, string>();
607
+ if (vsEntries.length === 1) {
608
+ entryMap.set(vsEntries[0]!, vsPrefix);
609
+ } else {
610
+ for (let i = 0; i < vsEntries.length; i++) {
611
+ entryMap.set(vsEntries[i]!, `${vsPrefix}_p${i}`);
612
+ }
613
+ }
614
+ if (fsEntries.length === 1) {
615
+ entryMap.set(fsEntries[0]!, fsPrefix);
616
+ } else {
617
+ for (let i = 0; i < fsEntries.length; i++) {
618
+ entryMap.set(fsEntries[i]!, `${fsPrefix}_p${i}`);
619
+ }
620
+ }
621
+ const renamed = e.rename({ entries: entryMap });
622
+ const ir = compileHeapEffectIR(renamed, family.drawHeaderUnion, opts, "family-member");
623
+ const vsHelper = readEntryName(ir.vs, "vertex") ?? vsPrefix;
624
+ const fsHelper = readEntryName(ir.fs, "fragment") ?? fsPrefix;
625
+ // Auto-generated struct names = `${capitalise(entryName)}Output` /
626
+ // `${capitalise(entryName)}Input` (see wombat.shader/wgsl/emit).
627
+ const outStruct = capitalise(vsHelper) + "Output";
628
+ const fsInStruct = capitalise(fsHelper) + "Input";
629
+ const fsOutStruct = capitalise(fsHelper) + "Output";
630
+ perEffectVs.push({ layoutId: k, vs: ir.vs, fsHelperName: fsHelper, vsHelperName: vsHelper, outStruct, fsInStruct, fsOutStruct });
631
+ perEffectFs.push(ir.fs);
632
+ }
633
+
634
+ // 2. Strip `@vertex`/`@fragment` decorations from the per-effect
635
+ // helpers (they're regular fns now) and split each output into a
636
+ // set of top-level decls.
637
+ const vsParts = perEffectVs.map(p => splitWgslTopLevel(stripStageDecoration(p.vs)));
638
+ const fsParts = perEffectFs.map(s => splitWgslTopLevel(stripStageDecoration(s)));
639
+
640
+ // 3. Dedup module-scope decls (storage/uniform bindings, atlas
641
+ // helpers, structs etc). The per-effect helper functions stay —
642
+ // they're already uniquely named.
643
+ const vsDecls = dedupModuleDecls(vsParts.flat(), "VS");
644
+ const fsDecls = dedupModuleDecls(fsParts.flat(), "FS");
645
+
646
+ // 4. Compute drawHeader stride + layoutId offset for the wrapper.
647
+ const familyStrideU32 = family.drawHeaderUnion.strideU32;
648
+ const layoutIdField = family.drawHeaderUnion.drawHeaderFields.find(f => f.name === "__layoutId");
649
+ if (layoutIdField === undefined) {
650
+ throw new Error("compileShaderFamily: drawHeaderUnion missing '__layoutId' field");
651
+ }
652
+ const layoutIdOffsetU32 = layoutIdField.byteOffset / 4;
653
+
654
+ // 4b. Family-merge direct-heap-read path: per-effect FS reads the heap
655
+ // headers directly using the wrapper-supplied `heap_drawIdx` /
656
+ // `instId` u32 fn parameters. The carrier on the cross-stage
657
+ // boundary is a single `heap_drawIdx_out: u32` (and
658
+ // `instId_out: u32`) flat varying — no per-uniform `_h_*Ref`
659
+ // varyings, no `vec4<u32>` packing. Keep the schema fields
660
+ // populated for backward compatibility (zero / empty).
661
+ (family as { -readonly [K in keyof ShaderFamilySchema]: ShaderFamilySchema[K] }).heapVaryingSlots = 0;
662
+ (family as { -readonly [K in keyof ShaderFamilySchema]: ShaderFamilySchema[K] }).perEffectHeapSlotMap = new Map();
663
+
664
+ // 5. Synthesize the wrapper VS and FS.
665
+ const vs = synthesizeFamilyVs(family, perEffectVs, vsDecls, familyStrideU32, layoutIdOffsetU32);
666
+ const fs = synthesizeFamilyFs(family, perEffectVs, fsDecls);
667
+
668
+ return {
669
+ vs,
670
+ fs,
671
+ fragmentOutputLayout,
672
+ };
673
+ }
674
+
675
+ // ─── Helpers (slice 3b) ─────────────────────────────────────────────
676
+
677
+ function capitalise(s: string): string {
678
+ return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
679
+ }
680
+
681
+ /**
682
+ * Find the name of the `@vertex` / `@fragment` fn in a WGSL source.
683
+ * Returns undefined when no such decl is present.
684
+ */
685
+ function readEntryName(src: string, stage: "vertex" | "fragment"): string | undefined {
686
+ const re = new RegExp(`@${stage}\\s+fn\\s+(\\w+)\\b`);
687
+ const m = re.exec(src);
688
+ return m === null ? undefined : m[1];
689
+ }
690
+
691
+ /**
692
+ * Find stage entry names for the given stage by scanning the effect's
693
+ * stage templates for `Entry` ValueDefs. Returns a list (typically
694
+ * length 1, but composed effects may have more).
695
+ */
696
+ function findStageEntryNames(effect: Effect, stage: "vertex" | "fragment"): string[] {
697
+ const names: string[] = [];
698
+ for (const s of effect.stages) {
699
+ for (const v of s.template.values) {
700
+ if (v.kind !== "Entry") continue;
701
+ if (v.entry.stage !== stage) continue;
702
+ names.push(v.entry.name);
703
+ }
704
+ }
705
+ return names;
706
+ }
707
+
708
+ /**
709
+ * Strip the `@vertex` / `@fragment` decoration from each stage entry
710
+ * function. The decoration is always immediately followed by `fn`
711
+ * (possibly across whitespace/newlines).
712
+ */
713
+ function stripStageDecoration(src: string): string {
714
+ return src.replace(/@(?:vertex|fragment|compute)\s+(?=fn\s)/g, "");
715
+ }
716
+
717
+ /**
718
+ * Split a WGSL source into top-level declarations. We walk the source
719
+ * tracking brace depth to find statement boundaries:
720
+ * - `@…` decorators + `var …;` / `const …;` / `alias …;` (one
721
+ * statement up to the terminating `;` at depth 0).
722
+ * - `struct Name { … };` (block, brace-balanced, optional trailing
723
+ * `;`).
724
+ * - `fn Name(…) { … }` (block, brace-balanced).
725
+ * Comments / blank lines between decls are dropped — we only retain
726
+ * the declaration text.
727
+ */
728
+ function splitWgslTopLevel(src: string): string[] {
729
+ const decls: string[] = [];
730
+ let i = 0;
731
+ const n = src.length;
732
+ while (i < n) {
733
+ // Skip whitespace and line comments.
734
+ while (i < n && /\s/.test(src[i]!)) i++;
735
+ if (i < n && src[i] === "/" && src[i + 1] === "/") {
736
+ while (i < n && src[i] !== "\n") i++;
737
+ continue;
738
+ }
739
+ if (i >= n) break;
740
+ const start = i;
741
+ // Find the end of this declaration. Walk forward, tracking braces.
742
+ // A decl ends either at `;` (depth 0) for non-block decls, or at
743
+ // the matching `}` (with optional trailing `;`) for block decls.
744
+ let depth = 0;
745
+ let sawBrace = false;
746
+ while (i < n) {
747
+ const c = src[i]!;
748
+ if (c === "{") { depth++; sawBrace = true; i++; continue; }
749
+ if (c === "}") {
750
+ depth--;
751
+ i++;
752
+ if (depth === 0 && sawBrace) {
753
+ // Optional trailing semicolon.
754
+ while (i < n && /\s/.test(src[i]!)) i++;
755
+ if (i < n && src[i] === ";") i++;
756
+ break;
757
+ }
758
+ continue;
759
+ }
760
+ if (c === ";" && depth === 0 && !sawBrace) {
761
+ i++;
762
+ break;
763
+ }
764
+ i++;
765
+ }
766
+ const decl = src.slice(start, i).trim();
767
+ if (decl.length > 0) decls.push(decl);
768
+ }
769
+ return decls;
770
+ }
771
+
772
+ /**
773
+ * Classify a top-level decl by kind + identifier (for dedup keying).
774
+ * Returns `{ key, kind }`. Decls with the same `key` must have the
775
+ * same text (else we throw — the family has structurally incompatible
776
+ * per-effect outputs).
777
+ */
778
+ function classifyDecl(decl: string): { key: string; kind: "binding" | "struct" | "fn" | "var" | "alias" | "other" } {
779
+ // Binding: `@group(N) @binding(M) var<…> name : type;`
780
+ let m = /^\s*@group\(\s*\d+\s*\)\s*@binding\(\s*\d+\s*\)\s*var(?:<[^>]*>)?\s+(\w+)\s*:/.exec(decl);
781
+ if (m !== null) return { key: `binding:${m[1]}`, kind: "binding" };
782
+ // Struct
783
+ m = /^\s*struct\s+(\w+)\s*\{/.exec(decl);
784
+ if (m !== null) return { key: `struct:${m[1]}`, kind: "struct" };
785
+ // Fn
786
+ m = /^\s*fn\s+(\w+)\s*\(/.exec(decl);
787
+ if (m !== null) return { key: `fn:${m[1]}`, kind: "fn" };
788
+ // var<private>/var
789
+ m = /^\s*var(?:<[^>]*>)?\s+(\w+)\s*:/.exec(decl);
790
+ if (m !== null) return { key: `var:${m[1]}`, kind: "var" };
791
+ // alias
792
+ m = /^\s*alias\s+(\w+)\s*=/.exec(decl);
793
+ if (m !== null) return { key: `alias:${m[1]}`, kind: "alias" };
794
+ return { key: `other:${decl.slice(0, 32)}`, kind: "other" };
795
+ }
796
+
797
+ /**
798
+ * Dedup a flat list of top-level decls. Same-key decls must have the
799
+ * same canonical text (whitespace-normalised); throws on conflict.
800
+ * Per-effect helper fns (named `family_vs_${K}` / `family_fs_${K}`)
801
+ * are unique by construction so they pass through.
802
+ */
803
+ function dedupModuleDecls(decls: readonly string[], stageLabel: string): string[] {
804
+ const seen = new Map<string, string>();
805
+ const ordered: string[] = [];
806
+ for (const d of decls) {
807
+ const { key } = classifyDecl(d);
808
+ const norm = d.replace(/\s+/g, " ").trim();
809
+ const existing = seen.get(key);
810
+ if (existing === undefined) {
811
+ seen.set(key, norm);
812
+ ordered.push(d);
813
+ } else if (existing !== norm) {
814
+ throw new Error(
815
+ `compileShaderFamily: ${stageLabel} declaration '${key}' conflicts across effects:\n` +
816
+ ` A: ${existing}\n` +
817
+ ` B: ${norm}\n` +
818
+ `Disambiguate via Effect.rename(...) or align bindings before merging.`,
819
+ );
820
+ }
821
+ }
822
+ return ordered;
823
+ }
824
+
825
+ /**
826
+ * Build the family `FamilyVsOut` struct text. One `vec4<f32>` per
827
+ * user-varying slot, then three flat-interpolated u32 carriers:
828
+ * `heap_drawIdx_out`, `instId_out`, and `layoutIdOut`. The FS reads
829
+ * the heap headers directly via these u32 indices — no per-uniform
830
+ * ref varyings.
831
+ */
832
+ function familyVsOutStruct(slots: number): string {
833
+ const lines: string[] = [];
834
+ lines.push("struct FamilyVsOut {");
835
+ lines.push(" @builtin(position) gl_Position: vec4<f32>,");
836
+ for (let i = 0; i < slots; i++) {
837
+ lines.push(` @location(${i}) Varying${i}: vec4<f32>,`);
838
+ }
839
+ lines.push(` @interpolate(flat) @location(${slots}) heap_drawIdx_out: u32,`);
840
+ lines.push(` @interpolate(flat) @location(${slots + 1}) instId_out: u32,`);
841
+ lines.push(` @interpolate(flat) @location(${slots + 2}) layoutIdOut: u32,`);
842
+ lines.push("};");
843
+ return lines.join("\n");
844
+ }
845
+
846
+ function familyFsInStruct(slots: number): string {
847
+ const lines: string[] = [];
848
+ lines.push("struct FamilyFsIn {");
849
+ for (let i = 0; i < slots; i++) {
850
+ lines.push(` @location(${i}) Varying${i}: vec4<f32>,`);
851
+ }
852
+ lines.push(` @interpolate(flat) @location(${slots}) heap_drawIdx_in: u32,`);
853
+ lines.push(` @interpolate(flat) @location(${slots + 1}) instId_in: u32,`);
854
+ lines.push(` @interpolate(flat) @location(${slots + 2}) layoutIdIn: u32,`);
855
+ lines.push("};");
856
+ return lines.join("\n");
857
+ }
858
+
859
+ /**
860
+ * Component-suffix for an offset+size into a vec4 slot. `(0,3)` →
861
+ * `.xyz`, `(0,4)` → `` (whole vec4), `(1,2)` → `.yz`, etc. v1: each
862
+ * varying takes the prefix `(0, size)` of a dedicated slot, but the
863
+ * helper accepts arbitrary offsets so future packing works.
864
+ */
865
+ function vec4Swizzle(offset: number, size: number): string {
866
+ if (offset === 0 && size === 4) return "";
867
+ const comps = ["x", "y", "z", "w"];
868
+ return "." + comps.slice(offset, offset + size).join("");
869
+ }
870
+
871
+ /**
872
+ * Per-varying read/write expression. For vec3/vec2/scalar, the source
873
+ * value is a vec3/vec2/f32; we need to read/write the appropriate
874
+ * `.xyz` / `.xy` / `.x` slice of the slot's `vec4<f32>`.
875
+ */
876
+ function slotComponentExpr(slotName: string, offset: number, size: number): string {
877
+ return slotName + vec4Swizzle(offset, size);
878
+ }
879
+
880
+ /**
881
+ * Build a dispatch case for the VS wrapper. Calls `family_vs_${K}` and
882
+ * packs its named-varying outputs into the family's anonymous slots.
883
+ */
884
+ function buildVsCase(
885
+ k: number,
886
+ vsHelperName: string,
887
+ outStruct: string,
888
+ effect: Effect,
889
+ family: ShaderFamilySchema,
890
+ ): string {
891
+ const slotMap = family.perEffectSlotMap.get(effect)!;
892
+ const lines: string[] = [];
893
+ lines.push(` case ${k}u: {`);
894
+ lines.push(` let r: ${outStruct} = ${vsHelperName}(heap_drawIdx, instId, vid);`);
895
+ // The @builtin(position) field on the per-effect VsOut struct is
896
+ // whatever the user named it (`gl_Position`, `position`, `clipPos`,
897
+ // …). Discover from the schema rather than hardcoding `gl_Position`
898
+ // — assuming the legacy spelling silently breaks any effect that
899
+ // uses a different identifier.
900
+ const schema = family.perEffectSchema.get(effect)!;
901
+ const positionVarying = schema.varyings.find(v => v.builtin === "position");
902
+ const positionField = positionVarying?.name ?? "gl_Position";
903
+ lines.push(` out.gl_Position = r.${positionField};`);
904
+ for (const v of schema.varyings) {
905
+ if (v.builtin !== undefined) continue;
906
+ const slot = slotMap.get(v.name);
907
+ if (slot === undefined) continue;
908
+ // WGSL spec: multi-component swizzles (`.xyz`/`.xy`) are r-values,
909
+ // not assignable l-values. Tint accepts swizzle assignment as an
910
+ // extension; WebKit/Safari does not. Reconstruct the whole vec4
911
+ // from components so the expression is spec-compliant everywhere.
912
+ const slotName = `out.Varying${slot.slot}`;
913
+ lines.push(` ${slotName} = ${packVec4Components(`r.${v.name}`, slot.offset, slot.size)};`);
914
+ }
915
+ lines.push(` }`);
916
+ return lines.join("\n");
917
+ }
918
+
919
+ /**
920
+ * Build a `vec4<f32>(c0, c1, c2, c3)` literal where the source value
921
+ * occupies `[offset..offset+size)` and the rest is `0.0`. The source
922
+ * is a scalar (size=1), vec2 (size=2), vec3 (size=3), or vec4 (size=4).
923
+ * Avoids multi-component swizzle l-values which Safari rejects.
924
+ */
925
+ function packVec4Components(rhsExpr: string, offset: number, size: number): string {
926
+ const comps: string[] = ["0.0", "0.0", "0.0", "0.0"];
927
+ const swiz = ["x", "y", "z", "w"];
928
+ for (let i = 0; i < size; i++) {
929
+ comps[offset + i] = size === 1 ? rhsExpr : `${rhsExpr}.${swiz[i]}`;
930
+ }
931
+ return `vec4<f32>(${comps.join(", ")})`;
932
+ }
933
+
934
+ function buildFsCase(
935
+ k: number,
936
+ fsHelperName: string,
937
+ fsInStruct: string,
938
+ fsOutStruct: string,
939
+ effect: Effect,
940
+ family: ShaderFamilySchema,
941
+ ): string {
942
+ const slotMap = family.perEffectSlotMap.get(effect)!;
943
+ const schema = family.perEffectSchema.get(effect)!;
944
+ const lines: string[] = [];
945
+ lines.push(` case ${k}u: {`);
946
+ lines.push(` var fin: ${fsInStruct};`);
947
+ for (const v of schema.varyings) {
948
+ if (v.builtin !== undefined) continue;
949
+ const slot = slotMap.get(v.name);
950
+ if (slot === undefined) continue;
951
+ const slotName = `in.Varying${slot.slot}`;
952
+ const rhs = slotComponentExpr(slotName, slot.offset, slot.size);
953
+ lines.push(` fin.${v.name} = ${rhs};`);
954
+ }
955
+ lines.push(` let r: ${fsOutStruct} = ${fsHelperName}(fin, heap_drawIdx, instId);`);
956
+ // Copy r's fields into the family FsOut. v1 assumes per-effect FS
957
+ // output structs all carry the same fields (driven by
958
+ // `fragmentOutputLayout`); we just map by name. Fragment outputs
959
+ // appear on the schema as `fragmentOutputs`.
960
+ for (const fo of schema.fragmentOutputs) {
961
+ lines.push(` fout.${fo.name} = r.${fo.name};`);
962
+ }
963
+ lines.push(` }`);
964
+ return lines.join("\n");
965
+ }
966
+
967
+ function familyFsOutStruct(family: ShaderFamilySchema): string {
968
+ // Union all per-effect fragmentOutputs by name. Mismatched types ⇒
969
+ // throw. v1: simple, no MRT-fan-out.
970
+ type FO = { name: string; location: number; wgslType: string };
971
+ const byName = new Map<string, FO>();
972
+ for (const e of family.effects) {
973
+ const sc = family.perEffectSchema.get(e)!;
974
+ for (const fo of sc.fragmentOutputs) {
975
+ const existing = byName.get(fo.name);
976
+ if (existing === undefined) {
977
+ byName.set(fo.name, { name: fo.name, location: fo.location, wgslType: fo.wgslType });
978
+ } else if (existing.wgslType !== fo.wgslType || existing.location !== fo.location) {
979
+ throw new Error(
980
+ `compileShaderFamily: fragmentOutput '${fo.name}' conflicts across effects: ` +
981
+ `'${existing.wgslType}@${existing.location}' vs '${fo.wgslType}@${fo.location}'`,
982
+ );
983
+ }
984
+ }
985
+ }
986
+ const ordered = [...byName.values()].sort((a, b) => a.location - b.location);
987
+ const lines: string[] = [];
988
+ lines.push("struct FamilyFsOut {");
989
+ for (const fo of ordered) {
990
+ lines.push(` @location(${fo.location}) ${fo.name}: ${fo.wgslType},`);
991
+ }
992
+ lines.push("};");
993
+ return lines.join("\n");
994
+ }
995
+
996
+ function synthesizeFamilyVs(
997
+ family: ShaderFamilySchema,
998
+ perEffect: readonly { layoutId: number; vsHelperName: string; outStruct: string }[],
999
+ dedupedDecls: readonly string[],
1000
+ familyStrideU32: number,
1001
+ layoutIdOffsetU32: number,
1002
+ ): string {
1003
+ const slots = family.varyingSlots;
1004
+
1005
+ const lines: string[] = [];
1006
+ lines.push("// Family-merged vertex shader (direct-heap-read FS path).");
1007
+ // Heap arena + megacall storage-buffer bindings declared explicitly
1008
+ // by the wrapper. The wrapper itself references `headersU32` for
1009
+ // the layoutId lookup, so these MUST be present even when no
1010
+ // per-effect helper happens to reference them (in which case DCE
1011
+ // in the helper compile would otherwise drop them). Hand-declared
1012
+ // strings; per-effect helpers' duplicate decls of the same names
1013
+ // are filtered out of dedupedDecls below.
1014
+ lines.push("@group(0) @binding(0) var<storage, read> heapU32: array<u32>;");
1015
+ lines.push("@group(0) @binding(1) var<storage, read> headersU32: array<u32>;");
1016
+ lines.push("@group(0) @binding(2) var<storage, read> heapF32: array<f32>;");
1017
+ lines.push("@group(0) @binding(3) var<storage, read> heapV4f: array<vec4<f32>>;");
1018
+ lines.push("@group(0) @binding(4) var<storage, read> drawTable: array<u32>;");
1019
+ lines.push("@group(0) @binding(5) var<storage, read> indexStorage: array<u32>;");
1020
+ lines.push("@group(0) @binding(6) var<storage, read> firstDrawInTile: array<u32>;");
1021
+ lines.push("");
1022
+ // Deduped module-scope decls (user uniforms, per-effect structs,
1023
+ // per-effect helper fns). Filter the heap-arena bindings out since
1024
+ // we hand-declare them above; otherwise they'd appear twice.
1025
+ const heapArenaNames = new Set(["heapU32", "headersU32", "heapF32", "heapV4f"]);
1026
+ for (const d of dedupedDecls) {
1027
+ const m = /^\s*@group\(\s*\d+\s*\)\s*@binding\(\s*\d+\s*\)\s*var(?:<[^>]*>)?\s+(\w+)\s*:/.exec(d);
1028
+ if (m !== null && heapArenaNames.has(m[1]!)) continue;
1029
+ lines.push(d);
1030
+ lines.push("");
1031
+ }
1032
+ // Family VsOut struct.
1033
+ lines.push(familyVsOutStruct(slots));
1034
+ lines.push("");
1035
+ // Wrapper @vertex.
1036
+ lines.push("@vertex fn family_vs_main(@builtin(vertex_index) emitIdx: u32) -> FamilyVsOut {");
1037
+ // Megacall search prelude — defines heap_drawIdx, instId, vid as
1038
+ // locals in this function's scope.
1039
+ lines.push(megacallSearchPrelude());
1040
+ // layoutId from drawHeader.
1041
+ lines.push(` let layoutId: u32 = headersU32[(heap_drawIdx * ${familyStrideU32}u) + ${layoutIdOffsetU32}u];`);
1042
+ lines.push(" var out: FamilyVsOut;");
1043
+ // Initialise all slots to zero so the WGSL out-of-init-store rule
1044
+ // is satisfied across switch arms that don't write every slot.
1045
+ lines.push(" out.gl_Position = vec4<f32>(0.0);");
1046
+ for (let i = 0; i < slots; i++) {
1047
+ lines.push(` out.Varying${i} = vec4<f32>(0.0);`);
1048
+ }
1049
+ lines.push(" out.heap_drawIdx_out = heap_drawIdx;");
1050
+ lines.push(" out.instId_out = instId;");
1051
+ lines.push(" out.layoutIdOut = layoutId;");
1052
+ lines.push(" switch (layoutId) {");
1053
+ for (const p of perEffect) {
1054
+ const e = family.effects[p.layoutId]!;
1055
+ lines.push(buildVsCase(p.layoutId, p.vsHelperName, p.outStruct, e, family));
1056
+ }
1057
+ lines.push(" default: { }");
1058
+ lines.push(" }");
1059
+ lines.push(" return out;");
1060
+ lines.push("}");
1061
+ return lines.join("\n");
1062
+ }
1063
+
1064
+ function synthesizeFamilyFs(
1065
+ family: ShaderFamilySchema,
1066
+ perEffect: readonly { layoutId: number; fsHelperName: string; fsInStruct: string; fsOutStruct: string }[],
1067
+ dedupedDecls: readonly string[],
1068
+ ): string {
1069
+ const slots = family.varyingSlots;
1070
+ const lines: string[] = [];
1071
+ // Suppress Tint's conservative derivative-uniformity analysis: layoutIdIn is
1072
+ // @interpolate(flat) and uniform per primitive in practice, but Tint treats
1073
+ // fragment-in params as non-uniform, rejecting dpdx/dpdy reachable from the
1074
+ // switch. v1 concession; v2 may hoist derivatives via parameter-threading.
1075
+ lines.push("diagnostic(off, derivative_uniformity);");
1076
+ lines.push("");
1077
+ lines.push("// Family-merged fragment shader (direct-heap-read FS path).");
1078
+ for (const d of dedupedDecls) {
1079
+ lines.push(d);
1080
+ lines.push("");
1081
+ }
1082
+ lines.push(familyFsInStruct(slots));
1083
+ lines.push("");
1084
+ lines.push(familyFsOutStruct(family));
1085
+ lines.push("");
1086
+ lines.push("@fragment fn family_fs_main(in: FamilyFsIn) -> FamilyFsOut {");
1087
+ lines.push(" var fout: FamilyFsOut;");
1088
+ // Hoist heap_drawIdx / instId out of the FsIn carrier into locals so
1089
+ // each per-effect dispatch case can pass them by name to the helper.
1090
+ lines.push(" let heap_drawIdx: u32 = in.heap_drawIdx_in;");
1091
+ lines.push(" let instId: u32 = in.instId_in;");
1092
+ lines.push(" switch (in.layoutIdIn) {");
1093
+ for (const p of perEffect) {
1094
+ const e = family.effects[p.layoutId]!;
1095
+ lines.push(buildFsCase(p.layoutId, p.fsHelperName, p.fsInStruct, p.fsOutStruct, e, family));
1096
+ }
1097
+ lines.push(" default: { }");
1098
+ lines.push(" }");
1099
+ lines.push(" return fout;");
1100
+ lines.push("}");
1101
+ return lines.join("\n");
1102
+ }