@aardworx/wombat.rendering 0.1.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 (46) hide show
  1. package/package.json +67 -0
  2. package/src/commands/clear.ts +66 -0
  3. package/src/commands/index.ts +11 -0
  4. package/src/commands/render.ts +106 -0
  5. package/src/core/acquire.ts +22 -0
  6. package/src/core/adaptiveResource.ts +88 -0
  7. package/src/core/buffer.ts +45 -0
  8. package/src/core/bufferView.ts +26 -0
  9. package/src/core/clear.ts +16 -0
  10. package/src/core/command.ts +50 -0
  11. package/src/core/drawCall.ts +22 -0
  12. package/src/core/framebuffer.ts +37 -0
  13. package/src/core/framebufferSignature.ts +19 -0
  14. package/src/core/index.ts +116 -0
  15. package/src/core/pipelineState.ts +64 -0
  16. package/src/core/renderContext.ts +48 -0
  17. package/src/core/renderObject.ts +57 -0
  18. package/src/core/renderTask.ts +17 -0
  19. package/src/core/renderTree.ts +29 -0
  20. package/src/core/sampler.ts +12 -0
  21. package/src/core/shader.ts +28 -0
  22. package/src/core/texture.ts +63 -0
  23. package/src/index.ts +18 -0
  24. package/src/resources/adaptiveBuffer.ts +135 -0
  25. package/src/resources/adaptiveSampler.ts +82 -0
  26. package/src/resources/adaptiveTexture.ts +269 -0
  27. package/src/resources/framebuffer.ts +163 -0
  28. package/src/resources/framebufferSignature.ts +36 -0
  29. package/src/resources/index.ts +82 -0
  30. package/src/resources/mipGen.ts +148 -0
  31. package/src/resources/preparedComputeShader.ts +426 -0
  32. package/src/resources/preparedRenderObject.ts +498 -0
  33. package/src/resources/renderPipeline.ts +148 -0
  34. package/src/resources/shaderDiagnostics.ts +52 -0
  35. package/src/resources/sourceMapDecoder.ts +133 -0
  36. package/src/resources/uniformBuffer.ts +170 -0
  37. package/src/resources/webgpuFlags.ts +43 -0
  38. package/src/runtime/copy.ts +13 -0
  39. package/src/runtime/index.ts +24 -0
  40. package/src/runtime/renderTask.ts +137 -0
  41. package/src/runtime/renderTo.ts +154 -0
  42. package/src/runtime/runtime.ts +113 -0
  43. package/src/runtime/scenePass.ts +273 -0
  44. package/src/window/canvas.ts +252 -0
  45. package/src/window/index.ts +14 -0
  46. package/src/window/loop.ts +62 -0
@@ -0,0 +1,498 @@
1
+ // prepareRenderObject — turn a `RenderObject` + `CompiledEffect`
2
+ // + `FramebufferSignature` into a runnable `PreparedRenderObject`.
3
+ //
4
+ // Reads the wombat.shader `ProgramInterface` for everything needed
5
+ // to lay out vertex buffers, bind groups, color targets, and draw
6
+ // state.
7
+
8
+ import {
9
+ AdaptiveResource,
10
+ type CompiledEffect,
11
+ type FramebufferSignature,
12
+ type ProgramInterface,
13
+ type RenderObject,
14
+ } from "../core/index.js";
15
+ import {
16
+ type AdaptiveToken,
17
+ type aval,
18
+ type HashMap,
19
+ cval,
20
+ } from "@aardworx/wombat.adaptive";
21
+ import type { Type } from "@aardworx/wombat.shader/ir";
22
+ import { prepareAdaptiveBuffer } from "./adaptiveBuffer.js";
23
+ import { prepareAdaptiveTexture } from "./adaptiveTexture.js";
24
+ import { prepareAdaptiveSampler } from "./adaptiveSampler.js";
25
+ import { prepareUniformBuffer } from "./uniformBuffer.js";
26
+ import { compileRenderPipeline, type CompileRenderPipelineDescription } from "./renderPipeline.js";
27
+ import { BufferUsage, ShaderStage } from "./webgpuFlags.js";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function vertexFormatStride(fmt: GPUVertexFormat): number {
34
+ switch (fmt) {
35
+ case "float32": return 4;
36
+ case "float32x2": return 8;
37
+ case "float32x3": return 12;
38
+ case "float32x4": return 16;
39
+ case "uint32": case "sint32": return 4;
40
+ case "uint32x2": case "sint32x2": return 8;
41
+ case "uint32x3": case "sint32x3": return 12;
42
+ case "uint32x4": case "sint32x4": return 16;
43
+ case "uint16x2": case "sint16x2": case "float16x2": return 4;
44
+ case "uint16x4": case "sint16x4": case "float16x4": return 8;
45
+ case "uint8x2": case "sint8x2": case "unorm8x2": case "snorm8x2": return 2;
46
+ case "uint8x4": case "sint8x4": case "unorm8x4": case "snorm8x4": return 4;
47
+ default: throw new Error(`vertexFormatStride: unsupported format ${fmt}`);
48
+ }
49
+ }
50
+
51
+ interface VertexBindingInfo {
52
+ readonly name: string;
53
+ readonly slot: number;
54
+ readonly format: GPUVertexFormat;
55
+ readonly byteSize: number;
56
+ }
57
+
58
+ function vertexBindingsFor(iface: ProgramInterface): VertexBindingInfo[] {
59
+ // `iface.attributes` is already filtered to non-builtin vertex inputs
60
+ // by wombat.shader's interface builder.
61
+ return iface.attributes.map(a => ({
62
+ name: a.name,
63
+ slot: a.location,
64
+ format: a.format as GPUVertexFormat,
65
+ byteSize: a.byteSize,
66
+ }));
67
+ }
68
+
69
+ function colorTargetsFor(
70
+ iface: ProgramInterface,
71
+ sig: FramebufferSignature,
72
+ blends: import("@aardworx/wombat.adaptive").HashMap<string, import("../core/index.js").BlendState> | undefined,
73
+ ): GPUColorTargetState[] {
74
+ const out: GPUColorTargetState[] = [];
75
+ for (const o of iface.fragmentOutputs) {
76
+ const fmt = sig.colors.tryFind(o.name);
77
+ if (fmt === undefined) {
78
+ throw new Error(`prepareRenderObject: fragment output "${o.name}" has no matching signature attachment`);
79
+ }
80
+ const target: GPUColorTargetState = { format: fmt };
81
+ const blend = blends?.tryFind(o.name);
82
+ if (blend !== undefined) {
83
+ target.blend = {
84
+ color: { operation: blend.color.operation, srcFactor: blend.color.srcFactor, dstFactor: blend.color.dstFactor },
85
+ alpha: { operation: blend.alpha.operation, srcFactor: blend.alpha.srcFactor, dstFactor: blend.alpha.dstFactor },
86
+ };
87
+ target.writeMask = blend.writeMask;
88
+ }
89
+ out[o.location] = target;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function depthStencilStateFor(
95
+ sig: FramebufferSignature,
96
+ ps: import("../core/index.js").PipelineState,
97
+ ): GPUDepthStencilState | undefined {
98
+ if (sig.depthStencil === undefined) return undefined;
99
+ if (ps.depth === undefined && ps.stencil === undefined) return undefined;
100
+ const out: GPUDepthStencilState = {
101
+ format: sig.depthStencil.format,
102
+ depthWriteEnabled: ps.depth?.write ?? false,
103
+ depthCompare: ps.depth?.compare ?? "always",
104
+ };
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;
110
+ }
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;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /** Map a wombat.shader IR Type for a sampled texture to WebGPU's GPUTextureSampleType. */
120
+ function sampleTypeFor(type: Type): GPUTextureSampleType {
121
+ if (type.kind === "Texture") {
122
+ if (type.comparison === true) return "depth";
123
+ const s = type.sampled;
124
+ if (s.kind === "Int") return s.signed ? "sint" : "uint";
125
+ return "float";
126
+ }
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
+ return "float";
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Bind-group entry descriptions
135
+ // ---------------------------------------------------------------------------
136
+
137
+ type EntryDesc =
138
+ | { kind: "ubuf"; binding: number; resource: AdaptiveResource<GPUBuffer> }
139
+ | { kind: "sbuf"; binding: number; resource: AdaptiveResource<GPUBuffer>; access: "read" | "read_write" }
140
+ | { kind: "tex"; binding: number; resource: AdaptiveResource<GPUTexture>; sampleType: GPUTextureSampleType }
141
+ | { kind: "sampler"; binding: number; resource: AdaptiveResource<GPUSampler> };
142
+
143
+ interface GroupDesc {
144
+ readonly group: number;
145
+ readonly layout: GPUBindGroupLayout;
146
+ readonly entries: readonly EntryDesc[];
147
+ }
148
+
149
+ function buildGroups(device: GPUDevice, descs: readonly EntryDesc[][]): GroupDesc[] {
150
+ const out: GroupDesc[] = [];
151
+ for (let g = 0; g < descs.length; g++) {
152
+ const entries = (descs[g] ?? []).slice().sort((a, b) => a.binding - b.binding);
153
+ const layoutEntries: GPUBindGroupLayoutEntry[] = entries.map(e => {
154
+ const visibility = ShaderStage.VERTEX | ShaderStage.FRAGMENT;
155
+ switch (e.kind) {
156
+ case "ubuf": return { binding: e.binding, visibility, buffer: { type: "uniform" } };
157
+ case "sbuf": return {
158
+ binding: e.binding, visibility,
159
+ buffer: { type: e.access === "read_write" ? "storage" : "read-only-storage" },
160
+ };
161
+ case "tex": return { binding: e.binding, visibility, texture: { sampleType: e.sampleType } };
162
+ case "sampler": return { binding: e.binding, visibility, sampler: { type: "filtering" } };
163
+ }
164
+ });
165
+ const layout = device.createBindGroupLayout({ entries: layoutEntries });
166
+ out.push({ group: g, layout, entries });
167
+ }
168
+ return out;
169
+ }
170
+
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.
173
+ const handleIds = new WeakMap<object, number>();
174
+ let nextHandleId = 1;
175
+ function handleId(h: object): number {
176
+ let id = handleIds.get(h);
177
+ if (id === undefined) { id = nextHandleId++; handleIds.set(h, id); }
178
+ return id;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // PreparedRenderObject
183
+ // ---------------------------------------------------------------------------
184
+
185
+ export class PreparedRenderObject {
186
+ readonly pipeline: GPURenderPipeline;
187
+ readonly groups: readonly GroupDesc[];
188
+
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.
193
+ private readonly _bindGroupCache: { key: string | null; group: GPUBindGroup | null }[];
194
+
195
+ constructor(
196
+ private readonly device: GPUDevice,
197
+ private readonly vertexBindings: readonly VertexBindingInfo[],
198
+ private readonly vertexBuffers: ReadonlyMap<string, AdaptiveResource<GPUBuffer>>,
199
+ private readonly indexBuffer: AdaptiveResource<GPUBuffer> | undefined,
200
+ private readonly indexFormat: GPUIndexFormat | undefined,
201
+ private readonly indices: aval<import("../core/index.js").BufferView> | undefined,
202
+ groups: readonly GroupDesc[],
203
+ private readonly drawCall: aval<import("../core/index.js").DrawCall>,
204
+ pipeline: GPURenderPipeline,
205
+ ) {
206
+ this.pipeline = pipeline;
207
+ this.groups = groups;
208
+ this._bindGroupCache = groups.map(() => ({ key: null, group: null }));
209
+ }
210
+
211
+ acquire(): void {
212
+ for (const r of this.vertexBuffers.values()) r.acquire();
213
+ if (this.indexBuffer !== undefined) this.indexBuffer.acquire();
214
+ for (const g of this.groups) for (const e of g.entries) e.resource.acquire();
215
+ }
216
+
217
+ release(): void {
218
+ for (const r of this.vertexBuffers.values()) r.release();
219
+ if (this.indexBuffer !== undefined) this.indexBuffer.release();
220
+ for (const g of this.groups) for (const e of g.entries) e.resource.release();
221
+ }
222
+
223
+ record(pass: GPURenderPassEncoder, token: AdaptiveToken): void {
224
+ pass.setPipeline(this.pipeline);
225
+
226
+ for (let gi = 0; gi < this.groups.length; gi++) {
227
+ 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
+ let cacheKey = "";
233
+ const resolved: { binding: number; resource: GPUBindingResource }[] = [];
234
+ for (const e of g.entries) {
235
+ switch (e.kind) {
236
+ case "ubuf":
237
+ case "sbuf": {
238
+ const buf = e.resource.getValue(token);
239
+ cacheKey += `${e.binding}:b${handleId(buf)};`;
240
+ resolved.push({ binding: e.binding, resource: { buffer: buf } });
241
+ break;
242
+ }
243
+ case "tex": {
244
+ const tex = e.resource.getValue(token);
245
+ 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
+ resolved.push({ binding: e.binding, resource: tex.createView() });
251
+ break;
252
+ }
253
+ case "sampler": {
254
+ const samp = e.resource.getValue(token);
255
+ cacheKey += `${e.binding}:s${handleId(samp)};`;
256
+ resolved.push({ binding: e.binding, resource: samp });
257
+ break;
258
+ }
259
+ }
260
+ }
261
+ const slot = this._bindGroupCache[gi]!;
262
+ let bg: GPUBindGroup;
263
+ if (slot.key === cacheKey && slot.group !== null) {
264
+ bg = slot.group;
265
+ } else {
266
+ bg = this.device.createBindGroup({ layout: g.layout, entries: resolved });
267
+ slot.key = cacheKey;
268
+ slot.group = bg;
269
+ }
270
+ pass.setBindGroup(g.group, bg);
271
+ }
272
+
273
+ for (const vb of this.vertexBindings) {
274
+ const res = this.vertexBuffers.get(vb.name);
275
+ if (res === undefined) throw new Error(`PreparedRenderObject.record: missing vertex buffer for "${vb.name}"`);
276
+ pass.setVertexBuffer(vb.slot, res.getValue(token));
277
+ }
278
+
279
+ if (this.indexBuffer !== undefined && this.indices !== undefined && this.indexFormat !== undefined) {
280
+ const buf = this.indexBuffer.getValue(token);
281
+ const view = this.indices.getValue(token);
282
+ pass.setIndexBuffer(buf, this.indexFormat, view.offset, view.count * (this.indexFormat === "uint16" ? 2 : 4));
283
+ }
284
+
285
+ const dc = this.drawCall.getValue(token);
286
+ if (dc.kind === "indexed") {
287
+ pass.drawIndexed(dc.indexCount, dc.instanceCount, dc.firstIndex, dc.baseVertex, dc.firstInstance);
288
+ } else {
289
+ pass.draw(dc.vertexCount, dc.instanceCount, dc.firstVertex, dc.firstInstance);
290
+ }
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // prepareRenderObject
296
+ // ---------------------------------------------------------------------------
297
+
298
+ export interface PrepareRenderObjectOptions {
299
+ readonly label?: string;
300
+ /**
301
+ * Stable identity of the upstream effect — wombat.shader's
302
+ * `Effect.id`. Threaded into the pipeline cache key so that
303
+ * pipelines compiled from the same effect against the same
304
+ * signature dedupe correctly.
305
+ */
306
+ readonly effectId?: string;
307
+ }
308
+
309
+ export function prepareRenderObject(
310
+ device: GPUDevice,
311
+ obj: RenderObject,
312
+ effect: CompiledEffect,
313
+ signature: FramebufferSignature,
314
+ opts: PrepareRenderObjectOptions = {},
315
+ ): PreparedRenderObject {
316
+ const iface = effect.interface;
317
+ const vertexBindings = vertexBindingsFor(iface);
318
+
319
+ // Vertex + instance buffers — same shape; only stepMode differs.
320
+ // Each shader-required attribute is looked up in obj.vertexAttributes
321
+ // first (per-vertex) then obj.instanceAttributes (per-instance).
322
+ const vertexBuffers = new Map<string, AdaptiveResource<GPUBuffer>>();
323
+ const vertexLayouts: GPUVertexBufferLayout[] = [];
324
+ 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";
330
+ const stride = vb.byteSize !== undefined && vb.byteSize > 0
331
+ ? vb.byteSize
332
+ : vertexFormatStride(vb.format);
333
+ const bufAval = av.map(view => view.buffer);
334
+ const res = prepareAdaptiveBuffer(device, bufAval, {
335
+ usage: BufferUsage.VERTEX,
336
+ ...(opts.label !== undefined ? { label: `${opts.label}.${vb.name}` } : {}),
337
+ });
338
+ vertexBuffers.set(vb.name, res);
339
+ vertexLayouts[vb.slot] = {
340
+ arrayStride: stride,
341
+ stepMode,
342
+ attributes: [{ shaderLocation: vb.slot, offset: 0, format: vb.format }],
343
+ };
344
+ }
345
+
346
+ // Index buffer — `indexFormat` from BufferView wins; defaults to uint32.
347
+ let indexBuffer: AdaptiveResource<GPUBuffer> | undefined;
348
+ let indexFormat: GPUIndexFormat | undefined;
349
+ if (obj.indices !== undefined) {
350
+ const bufAval = obj.indices.map(v => v.buffer);
351
+ indexBuffer = prepareAdaptiveBuffer(device, bufAval, {
352
+ usage: BufferUsage.INDEX,
353
+ ...(opts.label !== undefined ? { label: `${opts.label}.indices` } : {}),
354
+ });
355
+ const initial = obj.indices.force();
356
+ indexFormat = initial.indexFormat
357
+ ?? (initial.format === "uint16" ? "uint16" : "uint32");
358
+ }
359
+
360
+ // Per-group entry collection. Use `slot` (real shape) instead of `binding`.
361
+ const slotOf = (e: { group: number; slot: number }) => e.slot;
362
+ const groupOf = (e: { group: number }) => e.group;
363
+ const maxGroup = Math.max(
364
+ -1,
365
+ ...iface.uniformBlocks.map(groupOf),
366
+ ...iface.samplers.map(groupOf),
367
+ ...iface.textures.map(groupOf),
368
+ ...iface.storageBuffers.map(groupOf),
369
+ );
370
+ const perGroup: EntryDesc[][] = [];
371
+ for (let g = 0; g <= maxGroup; g++) perGroup.push([]);
372
+
373
+ 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
+ const merged = mergeUniformInputs(obj.uniforms, effect.avalBindings, ub);
378
+ const block = ubAsBlock(ub);
379
+ const res = prepareUniformBuffer(device, block, merged, {
380
+ ...(opts.label !== undefined ? { label: `${opts.label}.${ub.name}` } : {}),
381
+ });
382
+ perGroup[ub.group]!.push({ kind: "ubuf", binding: slotOf(ub), resource: res });
383
+ }
384
+
385
+ for (const t of iface.textures) {
386
+ const av = obj.textures.tryFind(t.name);
387
+ if (av === undefined) throw new Error(`prepareRenderObject: missing texture "${t.name}"`);
388
+ const res = prepareAdaptiveTexture(device, av, {
389
+ ...(opts.label !== undefined ? { label: `${opts.label}.${t.name}` } : {}),
390
+ });
391
+ perGroup[t.group]!.push({ kind: "tex", binding: slotOf(t), resource: res, sampleType: sampleTypeFor(t.type) });
392
+ }
393
+
394
+ for (const s of iface.samplers) {
395
+ const av = obj.samplers.tryFind(s.name);
396
+ if (av === undefined) throw new Error(`prepareRenderObject: missing sampler "${s.name}"`);
397
+ const res = prepareAdaptiveSampler(device, av);
398
+ perGroup[s.group]!.push({ kind: "sampler", binding: slotOf(s), resource: res });
399
+ }
400
+
401
+ for (const sb of iface.storageBuffers) {
402
+ const av = obj.storageBuffers?.tryFind(sb.name);
403
+ if (av === undefined) throw new Error(`prepareRenderObject: missing storage buffer "${sb.name}"`);
404
+ const usage = BufferUsage.STORAGE
405
+ | (sb.access === "read_write" ? BufferUsage.COPY_DST : 0);
406
+ const res = prepareAdaptiveBuffer(device, av, {
407
+ usage,
408
+ ...(opts.label !== undefined ? { label: `${opts.label}.${sb.name}` } : {}),
409
+ });
410
+ perGroup[sb.group]!.push({ kind: "sbuf", binding: slotOf(sb), resource: res, access: sb.access });
411
+ }
412
+
413
+ const groups = buildGroups(device, perGroup);
414
+
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");
419
+ if (vsStage === undefined) throw new Error("prepareRenderObject: CompiledEffect has no vertex stage");
420
+ if (fsStage === undefined) throw new Error("prepareRenderObject: CompiledEffect has no fragment stage");
421
+
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 = {
431
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
432
+ ...(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
+ };
450
+ const pipeline = compileRenderPipeline(device, pipelineDesc);
451
+
452
+ return new PreparedRenderObject(
453
+ device,
454
+ vertexBindings, vertexBuffers,
455
+ indexBuffer, indexFormat, obj.indices,
456
+ groups,
457
+ obj.drawCall,
458
+ pipeline,
459
+ );
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Uniform input merging — `obj.uniforms` wins; `avalBindings` is fallback.
464
+ // ---------------------------------------------------------------------------
465
+
466
+ function mergeUniformInputs(
467
+ user: HashMap<string, aval<unknown>>,
468
+ bindings: Readonly<Record<string, () => unknown>> | undefined,
469
+ block: import("../core/index.js").UniformBlockInfo,
470
+ ): HashMap<string, aval<unknown>> {
471
+ let merged = user;
472
+ if (bindings === undefined) return merged;
473
+ for (const f of block.fields) {
474
+ if (merged.tryFind(f.name) !== undefined) continue;
475
+ const getter = bindings[f.name];
476
+ if (getter === undefined) continue;
477
+ 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
+ if (isAval(v)) merged = merged.add(f.name, v as aval<unknown>);
481
+ else merged = merged.add(f.name, cval(v));
482
+ }
483
+ return merged;
484
+ }
485
+
486
+ function isAval(v: unknown): boolean {
487
+ return typeof v === "object" && v !== null && typeof (v as { getValue?: unknown }).getValue === "function";
488
+ }
489
+
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
+ function ubAsBlock(ub: import("../core/index.js").UniformBlockInfo): import("../core/index.js").UniformBlockInfo {
497
+ return ub;
498
+ }
@@ -0,0 +1,148 @@
1
+ // compileRenderPipeline — produce a `GPURenderPipeline` from a
2
+ // description and memoize per-device. Pipeline state is treated
3
+ // as static (not adaptive); when the user wants pipeline switching
4
+ // at runtime they should construct multiple PreparedRenderObjects
5
+ // and pick between them at the RenderTree level.
6
+ //
7
+ // The descriptor is intentionally low-level: all the hard work of
8
+ // turning a `CompiledEffect` + `ProgramInterface` + `PipelineState`
9
+ // + `FramebufferSignature` into a GPUVertexBufferLayout[] /
10
+ // GPUBindGroupLayout[] / GPUColorTargetState[] etc. lives in
11
+ // `prepareRenderObject`. This function is just the caching layer
12
+ // over `device.createRenderPipeline`.
13
+
14
+ export interface CompileRenderPipelineDescription {
15
+ readonly label?: string;
16
+ /**
17
+ * Stable identity of the source effect (wombat.shader's
18
+ * build-time `Effect.id`). Used as the strong key for the
19
+ * pipeline cache — two `compileRenderPipeline` calls with the
20
+ * same `effectId` + the rest of the descriptor share a
21
+ * `GPURenderPipeline`. Falls back to FNV-hashing the shader
22
+ * source when omitted (only useful for hand-built sources).
23
+ */
24
+ readonly effectId?: string;
25
+ readonly vertexShaderSource: string;
26
+ readonly fragmentShaderSource: string;
27
+ /** Optional source map for the vertex stage. Used to enrich compile-error logs. */
28
+ readonly vertexSourceMap?: import("@aardworx/wombat.shader/ir").SourceMap | null;
29
+ readonly fragmentSourceMap?: import("@aardworx/wombat.shader/ir").SourceMap | null;
30
+ readonly vertexEntryPoint: string;
31
+ readonly fragmentEntryPoint: string;
32
+ readonly vertexBufferLayouts: readonly GPUVertexBufferLayout[];
33
+ readonly bindGroupLayouts: readonly GPUBindGroupLayout[];
34
+ readonly colorTargets: readonly GPUColorTargetState[];
35
+ readonly depthStencil?: GPUDepthStencilState;
36
+ readonly primitive: GPUPrimitiveState;
37
+ readonly multisample?: GPUMultisampleState;
38
+ }
39
+
40
+ import { installShaderDiagnostics } from "./shaderDiagnostics.js";
41
+
42
+ interface DeviceCache {
43
+ modules: Map<string, GPUShaderModule>;
44
+ pipelines: Map<string, GPURenderPipeline>;
45
+ }
46
+
47
+ const caches = new WeakMap<GPUDevice, DeviceCache>();
48
+ function cacheFor(device: GPUDevice): DeviceCache {
49
+ let c = caches.get(device);
50
+ if (c === undefined) {
51
+ c = { modules: new Map(), pipelines: new Map() };
52
+ caches.set(device, c);
53
+ }
54
+ return c;
55
+ }
56
+
57
+ function moduleFor(
58
+ device: GPUDevice,
59
+ source: string,
60
+ label?: string,
61
+ sourceMap?: import("@aardworx/wombat.shader/ir").SourceMap | null,
62
+ ): GPUShaderModule {
63
+ const cache = cacheFor(device);
64
+ let m = cache.modules.get(source);
65
+ if (m === undefined) {
66
+ m = device.createShaderModule({ code: source, ...(label !== undefined ? { label } : {}) });
67
+ cache.modules.set(source, m);
68
+ installShaderDiagnostics(m, source, {
69
+ ...(label !== undefined ? { label } : {}),
70
+ ...(sourceMap !== undefined && sourceMap !== null ? { sourceMap } : {}),
71
+ });
72
+ }
73
+ return m;
74
+ }
75
+
76
+ export function compileRenderPipeline(
77
+ device: GPUDevice,
78
+ desc: CompileRenderPipelineDescription,
79
+ ): GPURenderPipeline {
80
+ const cache = cacheFor(device);
81
+ const k = pipelineKey(desc);
82
+ let p = cache.pipelines.get(k);
83
+ if (p !== undefined) return p;
84
+
85
+ const vsModule = moduleFor(device, desc.vertexShaderSource, desc.label ? `${desc.label}.vs` : undefined, desc.vertexSourceMap);
86
+ const fsModule = desc.vertexShaderSource === desc.fragmentShaderSource
87
+ ? vsModule
88
+ : moduleFor(device, desc.fragmentShaderSource, desc.label ? `${desc.label}.fs` : undefined, desc.fragmentSourceMap);
89
+
90
+ const layout = device.createPipelineLayout({
91
+ bindGroupLayouts: desc.bindGroupLayouts as GPUBindGroupLayout[],
92
+ ...(desc.label !== undefined ? { label: `${desc.label}.layout` } : {}),
93
+ });
94
+
95
+ const pdesc: GPURenderPipelineDescriptor = {
96
+ layout,
97
+ vertex: {
98
+ module: vsModule,
99
+ entryPoint: desc.vertexEntryPoint,
100
+ buffers: desc.vertexBufferLayouts as GPUVertexBufferLayout[],
101
+ },
102
+ fragment: {
103
+ module: fsModule,
104
+ entryPoint: desc.fragmentEntryPoint,
105
+ targets: desc.colorTargets as GPUColorTargetState[],
106
+ },
107
+ primitive: desc.primitive,
108
+ ...(desc.depthStencil !== undefined ? { depthStencil: desc.depthStencil } : {}),
109
+ ...(desc.multisample !== undefined ? { multisample: desc.multisample } : {}),
110
+ ...(desc.label !== undefined ? { label: desc.label } : {}),
111
+ };
112
+ p = device.createRenderPipeline(pdesc);
113
+ cache.pipelines.set(k, p);
114
+ return p;
115
+ }
116
+
117
+ function pipelineKey(d: CompileRenderPipelineDescription): string {
118
+ // Strong identity component: prefer `effectId` (wombat.shader's
119
+ // build-time stable hash); fall back to FNV-hashing the shader
120
+ // source for hand-built effects. The rest of the descriptor is
121
+ // small enough to JSON-stringify.
122
+ const ident = d.effectId !== undefined
123
+ ? d.effectId
124
+ : `${hashString(d.vertexShaderSource)}/${hashString(d.fragmentShaderSource)}`;
125
+ const slim = {
126
+ id: ident,
127
+ vEntry: d.vertexEntryPoint,
128
+ fEntry: d.fragmentEntryPoint,
129
+ vb: d.vertexBufferLayouts,
130
+ bgl: d.bindGroupLayouts.length,
131
+ ct: d.colorTargets,
132
+ ds: d.depthStencil,
133
+ pr: d.primitive,
134
+ ms: d.multisample,
135
+ };
136
+ return JSON.stringify(slim);
137
+ }
138
+
139
+ function hashString(s: string): number {
140
+ // FNV-1a 32-bit; collision risk is negligible for our purposes
141
+ // and the cost is one call per uncached pipeline.
142
+ let h = 0x811c9dc5;
143
+ for (let i = 0; i < s.length; i++) {
144
+ h ^= s.charCodeAt(i);
145
+ h = Math.imul(h, 0x01000193);
146
+ }
147
+ return h >>> 0;
148
+ }