@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.
- package/package.json +67 -0
- package/src/commands/clear.ts +66 -0
- package/src/commands/index.ts +11 -0
- package/src/commands/render.ts +106 -0
- package/src/core/acquire.ts +22 -0
- package/src/core/adaptiveResource.ts +88 -0
- package/src/core/buffer.ts +45 -0
- package/src/core/bufferView.ts +26 -0
- package/src/core/clear.ts +16 -0
- package/src/core/command.ts +50 -0
- package/src/core/drawCall.ts +22 -0
- package/src/core/framebuffer.ts +37 -0
- package/src/core/framebufferSignature.ts +19 -0
- package/src/core/index.ts +116 -0
- package/src/core/pipelineState.ts +64 -0
- package/src/core/renderContext.ts +48 -0
- package/src/core/renderObject.ts +57 -0
- package/src/core/renderTask.ts +17 -0
- package/src/core/renderTree.ts +29 -0
- package/src/core/sampler.ts +12 -0
- package/src/core/shader.ts +28 -0
- package/src/core/texture.ts +63 -0
- package/src/index.ts +18 -0
- package/src/resources/adaptiveBuffer.ts +135 -0
- package/src/resources/adaptiveSampler.ts +82 -0
- package/src/resources/adaptiveTexture.ts +269 -0
- package/src/resources/framebuffer.ts +163 -0
- package/src/resources/framebufferSignature.ts +36 -0
- package/src/resources/index.ts +82 -0
- package/src/resources/mipGen.ts +148 -0
- package/src/resources/preparedComputeShader.ts +426 -0
- package/src/resources/preparedRenderObject.ts +498 -0
- package/src/resources/renderPipeline.ts +148 -0
- package/src/resources/shaderDiagnostics.ts +52 -0
- package/src/resources/sourceMapDecoder.ts +133 -0
- package/src/resources/uniformBuffer.ts +170 -0
- package/src/resources/webgpuFlags.ts +43 -0
- package/src/runtime/copy.ts +13 -0
- package/src/runtime/index.ts +24 -0
- package/src/runtime/renderTask.ts +137 -0
- package/src/runtime/renderTo.ts +154 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/runtime/scenePass.ts +273 -0
- package/src/window/canvas.ts +252 -0
- package/src/window/index.ts +14 -0
- 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
|
+
}
|