@forgeax/engine-render-graph 0.1.2
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/LICENSE +202 -0
- package/README.md +184 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/graph-pass-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/graph-pass-kind-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/observation-lifetime-state-owner.test-d.d.ts +2 -0
- package/dist/__tests__/observation-lifetime-state-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/observation.unit.test.d.ts +2 -0
- package/dist/__tests__/observation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-alias-source.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph-alias-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-builder.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph-builder.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-capability-owner.test-d.d.ts +2 -0
- package/dist/__tests__/render-graph-capability-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-graph-errors.test-d.d.ts +2 -0
- package/dist/__tests__/render-graph-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-graph-regressions.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph-regressions.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts +2 -0
- package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resource-declaration-collision.test.d.ts +2 -0
- package/dist/__tests__/resource-declaration-collision.test.d.ts.map +1 -0
- package/dist/__tests__/resource-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/resource-kind-owner.test-d.d.ts.map +1 -0
- package/dist/builder.d.ts +42 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/compiled-graph.d.ts +23 -0
- package/dist/compiled-graph.d.ts.map +1 -0
- package/dist/errors.d.ts +148 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/graph.d.ts +404 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +2256 -0
- package/dist/index.mjs.map +1 -0
- package/dist/kernel-internal.d.ts +84 -0
- package/dist/kernel-internal.d.ts.map +1 -0
- package/dist/observation.d.ts +38 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/pass-registry.d.ts +12 -0
- package/dist/pass-registry.d.ts.map +1 -0
- package/dist/pipeline/__tests__/color-value-domain.test.d.ts +2 -0
- package/dist/pipeline/__tests__/color-value-domain.test.d.ts.map +1 -0
- package/dist/pipeline/color-value-domain.d.ts +35 -0
- package/dist/pipeline/color-value-domain.d.ts.map +1 -0
- package/dist/resource-registry.d.ts +61 -0
- package/dist/resource-registry.d.ts.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/graph-pass-kind-owner.test-d.ts +35 -0
- package/src/__tests__/observation-lifetime-state-owner.test-d.ts +39 -0
- package/src/__tests__/observation.unit.test.ts +103 -0
- package/src/__tests__/render-graph-alias-source.unit.test.ts +118 -0
- package/src/__tests__/render-graph-builder.unit.test.ts +664 -0
- package/src/__tests__/render-graph-capability-owner.test-d.ts +32 -0
- package/src/__tests__/render-graph-errors.test-d.ts +169 -0
- package/src/__tests__/render-graph-regressions.unit.test.ts +126 -0
- package/src/__tests__/render-graph-rhi-null.integration.test.ts +86 -0
- package/src/__tests__/render-graph.unit.test.ts +2549 -0
- package/src/__tests__/resource-declaration-collision.test.ts +151 -0
- package/src/__tests__/resource-kind-owner.test-d.ts +38 -0
- package/src/builder.ts +1009 -0
- package/src/compiled-graph.ts +383 -0
- package/src/errors.ts +205 -0
- package/src/graph.ts +1270 -0
- package/src/index.ts +97 -0
- package/src/kernel-internal.ts +148 -0
- package/src/observation.ts +134 -0
- package/src/pass-registry.ts +43 -0
- package/src/pipeline/__tests__/color-value-domain.test.ts +43 -0
- package/src/pipeline/color-value-domain.ts +139 -0
- package/src/resource-registry.ts +173 -0
- package/src/types.ts +217 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Buffer,
|
|
3
|
+
ComputePassDescriptor,
|
|
4
|
+
RhiCommandEncoder,
|
|
5
|
+
RhiComputePassEncoder,
|
|
6
|
+
RhiDevice,
|
|
7
|
+
Texture,
|
|
8
|
+
TextureView,
|
|
9
|
+
} from '@forgeax/engine-rhi';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { RenderGraphBuilder } from '../builder.js';
|
|
12
|
+
import { ok } from '../errors.js';
|
|
13
|
+
import type { RenderGraphFrame } from '../types.js';
|
|
14
|
+
|
|
15
|
+
interface Frame extends RenderGraphFrame {
|
|
16
|
+
readonly imported: Buffer;
|
|
17
|
+
readonly importedTexture?: Texture;
|
|
18
|
+
readonly importedView?: TextureView;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function caps(overrides: Partial<RhiDevice['caps']> = {}): RhiDevice['caps'] {
|
|
22
|
+
return {
|
|
23
|
+
backendKind: 'null',
|
|
24
|
+
compute: true,
|
|
25
|
+
storageBuffer: true,
|
|
26
|
+
storageTexture: true,
|
|
27
|
+
timestampQuery: false,
|
|
28
|
+
timestampPeriodNanoseconds: null,
|
|
29
|
+
indirectDrawing: true,
|
|
30
|
+
textureCompressionBc: false,
|
|
31
|
+
textureCompressionEtc2: false,
|
|
32
|
+
textureCompressionAstc: false,
|
|
33
|
+
multiDrawIndirect: false,
|
|
34
|
+
pushConstants: false,
|
|
35
|
+
textureBindingArray: false,
|
|
36
|
+
samplerAliasing: true,
|
|
37
|
+
firstInstanceIndirect: false,
|
|
38
|
+
rgba16floatRenderable: true,
|
|
39
|
+
rg11b10ufloatRenderable: true,
|
|
40
|
+
float32Filterable: true,
|
|
41
|
+
maxColorAttachments: 8,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mockDevice(overrides: Partial<RhiDevice> = {}): RhiDevice {
|
|
47
|
+
let next = 1;
|
|
48
|
+
return {
|
|
49
|
+
caps: caps(),
|
|
50
|
+
limits: {} as never,
|
|
51
|
+
features: new Set() as never,
|
|
52
|
+
queue: {
|
|
53
|
+
onSubmittedWorkDone: async () => undefined,
|
|
54
|
+
} as never,
|
|
55
|
+
createBuffer: () => ({ ok: true, value: { id: next++ } as unknown as Buffer }),
|
|
56
|
+
createTexture: () => ({ ok: true, value: { id: next++ } as unknown as Texture }),
|
|
57
|
+
createTextureView: () => ({ ok: true, value: { id: next++ } as unknown as TextureView }),
|
|
58
|
+
destroyBuffer: () => ({ ok: true, value: undefined }),
|
|
59
|
+
destroyTexture: () => ({ ok: true, value: undefined }),
|
|
60
|
+
...overrides,
|
|
61
|
+
} as unknown as RhiDevice;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mockEncoder(events: string[] = []): RhiCommandEncoder {
|
|
65
|
+
return {
|
|
66
|
+
beginComputePass: ({ label, timestampWrites }: ComputePassDescriptor = {}) => {
|
|
67
|
+
if (timestampWrites !== undefined) events.push('timestamp-writes');
|
|
68
|
+
events.push(`begin-compute:${label}`);
|
|
69
|
+
return {
|
|
70
|
+
setPipeline: () => undefined,
|
|
71
|
+
setBindGroup: () => undefined,
|
|
72
|
+
dispatchWorkgroups: () => events.push('dispatch'),
|
|
73
|
+
dispatchWorkgroupsIndirect: () => events.push('dispatch-indirect'),
|
|
74
|
+
end: () => events.push('end-compute'),
|
|
75
|
+
} as unknown as RhiComputePassEncoder;
|
|
76
|
+
},
|
|
77
|
+
beginRenderPass: () => {
|
|
78
|
+
events.push('begin-raster');
|
|
79
|
+
return { end: () => events.push('end-raster') } as never;
|
|
80
|
+
},
|
|
81
|
+
clearBuffer: () => events.push('clear-buffer'),
|
|
82
|
+
} as unknown as RhiCommandEncoder;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function value<T>(result: { readonly ok: true; readonly value: T } | { readonly ok: false }): T {
|
|
86
|
+
if (!result.ok) throw new Error('expected ok result');
|
|
87
|
+
return result.value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('RenderGraphBuilder resource and temporal contracts', () => {
|
|
91
|
+
it('rejects duplicate resource labels and duplicate pass names at declaration time', () => {
|
|
92
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
93
|
+
expect(graph.createBuffer('data', { size: 16 }).ok).toBe(true);
|
|
94
|
+
const duplicateResource = graph.createBuffer('data', { size: 16 });
|
|
95
|
+
expect(duplicateResource.ok).toBe(false);
|
|
96
|
+
if (!duplicateResource.ok)
|
|
97
|
+
expect(duplicateResource.error.code).toBe('duplicate-resource-label');
|
|
98
|
+
|
|
99
|
+
expect(graph.addCopyPass('copy', { accesses: [], encode: () => undefined }).ok).toBe(true);
|
|
100
|
+
const duplicatePass = graph.addCopyPass('copy', { accesses: [], encode: () => undefined });
|
|
101
|
+
expect(duplicatePass.ok).toBe(false);
|
|
102
|
+
if (!duplicatePass.ok) expect(duplicatePass.error.code).toBe('duplicate-pass-name');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('seals the builder after compile and keeps the compiled snapshot immutable', () => {
|
|
106
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
107
|
+
const compiled = graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } });
|
|
108
|
+
expect(compiled.ok).toBe(true);
|
|
109
|
+
const mutation = graph.createBuffer('late', { size: 16 });
|
|
110
|
+
expect(mutation.ok).toBe(false);
|
|
111
|
+
if (!mutation.ok) expect(mutation.error.code).toBe('builder-sealed');
|
|
112
|
+
if (compiled.ok) expect(compiled.value.inspect().passes).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('accepts imported first-read and rejects graph-created first-read', () => {
|
|
116
|
+
const importedGraph = new RenderGraphBuilder<Frame>();
|
|
117
|
+
const imported = value(
|
|
118
|
+
importedGraph.importBuffer(
|
|
119
|
+
'external',
|
|
120
|
+
{ size: 16, usage: 0x0040 },
|
|
121
|
+
(frame) => frame.imported,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
importedGraph.addComputePass('consume', {
|
|
125
|
+
accesses: [{ resource: imported, usage: 'uniform-read' }],
|
|
126
|
+
encode: () => undefined,
|
|
127
|
+
});
|
|
128
|
+
expect(
|
|
129
|
+
importedGraph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }).ok,
|
|
130
|
+
).toBe(true);
|
|
131
|
+
|
|
132
|
+
const createdGraph = new RenderGraphBuilder<Frame>();
|
|
133
|
+
const created = value(createdGraph.createBuffer('created', { size: 16 }));
|
|
134
|
+
createdGraph.addComputePass('consume', {
|
|
135
|
+
accesses: [{ resource: created, usage: 'uniform-read' }],
|
|
136
|
+
encode: () => undefined,
|
|
137
|
+
});
|
|
138
|
+
const refused = createdGraph.compile({
|
|
139
|
+
device: mockDevice(),
|
|
140
|
+
surfaceSize: { width: 1, height: 1 },
|
|
141
|
+
});
|
|
142
|
+
expect(refused.ok).toBe(false);
|
|
143
|
+
if (!refused.ok) expect(refused.error.code).toBe('uninitialized-read');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('resolves a host-owned view for an imported texture without creating a frame view', () => {
|
|
147
|
+
let createViewCount = 0;
|
|
148
|
+
const device = mockDevice({
|
|
149
|
+
createTextureView: () => {
|
|
150
|
+
createViewCount += 1;
|
|
151
|
+
return ok({ id: 9 } as unknown as TextureView);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
155
|
+
const texture = value(
|
|
156
|
+
graph.importTexture(
|
|
157
|
+
'surface',
|
|
158
|
+
{ format: 'rgba8unorm', size: 'surface', usage: 0x10 },
|
|
159
|
+
(frame) => frame.importedTexture as Texture,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const view = value(
|
|
163
|
+
graph.importView(
|
|
164
|
+
texture,
|
|
165
|
+
{ label: 'surface.view' },
|
|
166
|
+
(frame) => frame.importedView as TextureView,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
graph.addRasterPass('present', {
|
|
170
|
+
accesses: [{ resource: view, usage: 'color-attachment' }],
|
|
171
|
+
colorAttachments: [{ view, loadOp: 'clear', storeOp: 'store' }],
|
|
172
|
+
encode: () => undefined,
|
|
173
|
+
});
|
|
174
|
+
const compiled = value(graph.compile({ device, surfaceSize: { width: 1, height: 1 } }));
|
|
175
|
+
const events: string[] = [];
|
|
176
|
+
const frame = {
|
|
177
|
+
encoder: mockEncoder(events),
|
|
178
|
+
imported: {} as Buffer,
|
|
179
|
+
importedTexture: {} as Texture,
|
|
180
|
+
importedView: { id: 4 } as unknown as TextureView,
|
|
181
|
+
};
|
|
182
|
+
const executed: string[] = [];
|
|
183
|
+
expect(
|
|
184
|
+
compiled.execute(frame, (pass, encode) => {
|
|
185
|
+
executed.push(`${pass.executionIndex}:${pass.kind}:${pass.name}`);
|
|
186
|
+
encode();
|
|
187
|
+
}).ok,
|
|
188
|
+
).toBe(true);
|
|
189
|
+
expect(createViewCount).toBe(0);
|
|
190
|
+
expect(executed).toEqual(['0:raster:present']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('resolves a frame-owned attachment clear value at execution time', () => {
|
|
194
|
+
const graph = new RenderGraphBuilder<Frame & { readonly clear: number }>();
|
|
195
|
+
const texture = value(
|
|
196
|
+
graph.importTexture(
|
|
197
|
+
'surface',
|
|
198
|
+
{ format: 'rgba8unorm', size: 'surface', usage: 0x10 },
|
|
199
|
+
(frame) => frame.importedTexture as Texture,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
const view = value(
|
|
203
|
+
graph.importView(
|
|
204
|
+
texture,
|
|
205
|
+
{ label: 'surface.view' },
|
|
206
|
+
(frame) => frame.importedView as TextureView,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
graph.addRasterPass('present', {
|
|
210
|
+
accesses: [{ resource: view, usage: 'color-attachment' }],
|
|
211
|
+
colorAttachments: [
|
|
212
|
+
{
|
|
213
|
+
view,
|
|
214
|
+
loadOp: 'clear',
|
|
215
|
+
storeOp: 'store',
|
|
216
|
+
clearValue: (frame) => ({ r: frame.clear, g: 0, b: 0, a: 1 }),
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
encode: () => undefined,
|
|
220
|
+
});
|
|
221
|
+
const compiled = value(
|
|
222
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
223
|
+
);
|
|
224
|
+
let clearValue: GPUColor | undefined;
|
|
225
|
+
const encoder = {
|
|
226
|
+
...mockEncoder(),
|
|
227
|
+
beginRenderPass: (descriptor: GPURenderPassDescriptor) => {
|
|
228
|
+
clearValue = descriptor.colorAttachments[0]?.clearValue;
|
|
229
|
+
return { end: () => undefined } as never;
|
|
230
|
+
},
|
|
231
|
+
} as unknown as RhiCommandEncoder;
|
|
232
|
+
expect(
|
|
233
|
+
compiled.execute({
|
|
234
|
+
encoder,
|
|
235
|
+
imported: {} as Buffer,
|
|
236
|
+
importedTexture: {} as Texture,
|
|
237
|
+
importedView: {} as TextureView,
|
|
238
|
+
clear: 0.25,
|
|
239
|
+
}).ok,
|
|
240
|
+
).toBe(true);
|
|
241
|
+
expect(clearValue).toEqual({ r: 0.25, g: 0, b: 0, a: 1 });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('rejects importView for a graph-created texture', () => {
|
|
245
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
246
|
+
const texture = value(
|
|
247
|
+
graph.createTexture('created', { format: 'rgba8unorm', size: 'surface' }),
|
|
248
|
+
);
|
|
249
|
+
const imported = graph.importView(
|
|
250
|
+
texture,
|
|
251
|
+
{ label: 'created.view' },
|
|
252
|
+
(frame) => frame.importedView as TextureView,
|
|
253
|
+
);
|
|
254
|
+
expect(imported.ok).toBe(false);
|
|
255
|
+
if (!imported.ok) expect(imported.error.code).toBe('resource-descriptor-invalid');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('derives exact declaration-order RAW, WAR, and WAW dependencies', () => {
|
|
259
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
260
|
+
const x = value(graph.createBuffer('x', { size: 16 }));
|
|
261
|
+
const y = value(graph.createBuffer('y', { size: 16 }));
|
|
262
|
+
graph.addCopyPass('seed-x', {
|
|
263
|
+
accesses: [{ resource: x, usage: 'copy-dst' }],
|
|
264
|
+
encode: () => undefined,
|
|
265
|
+
});
|
|
266
|
+
graph.addCopyPass('consume-x', {
|
|
267
|
+
accesses: [
|
|
268
|
+
{ resource: x, usage: 'copy-src' },
|
|
269
|
+
{ resource: y, usage: 'copy-dst' },
|
|
270
|
+
],
|
|
271
|
+
encode: () => undefined,
|
|
272
|
+
});
|
|
273
|
+
graph.addCopyPass('rewrite-x', {
|
|
274
|
+
accesses: [
|
|
275
|
+
{ resource: y, usage: 'copy-src' },
|
|
276
|
+
{ resource: x, usage: 'copy-dst' },
|
|
277
|
+
],
|
|
278
|
+
encode: () => undefined,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const compiled = graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } });
|
|
282
|
+
expect(compiled.ok).toBe(true);
|
|
283
|
+
if (!compiled.ok) return;
|
|
284
|
+
expect(compiled.value.inspect().passes).toMatchObject([
|
|
285
|
+
{ name: 'seed-x', dependencies: [] },
|
|
286
|
+
{ name: 'consume-x', dependencies: ['seed-x'] },
|
|
287
|
+
{ name: 'rewrite-x', dependencies: ['seed-x', 'consume-x'] },
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('derives physical usages and refuses imported usage mismatch', () => {
|
|
292
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
293
|
+
const data = value(
|
|
294
|
+
graph.importBuffer('data', { size: 16, usage: 0x0080 }, (frame) => frame.imported),
|
|
295
|
+
);
|
|
296
|
+
graph.addComputePass('consume', {
|
|
297
|
+
accesses: [{ resource: data, usage: 'uniform-read' }],
|
|
298
|
+
encode: () => undefined,
|
|
299
|
+
});
|
|
300
|
+
const result = graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } });
|
|
301
|
+
expect(result.ok).toBe(false);
|
|
302
|
+
if (!result.ok) expect(result.error.code).toBe('import-usage-mismatch');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('tracks texture subresources so HZB mip read/write chains are legal', () => {
|
|
306
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
307
|
+
const hzb = value(
|
|
308
|
+
graph.createTexture('hzb', {
|
|
309
|
+
format: 'r32float',
|
|
310
|
+
size: { width: 16, height: 16 },
|
|
311
|
+
mipLevelCount: 2,
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
const mip0 = value(graph.view(hzb, { label: 'hzb-mip-0', baseMipLevel: 0, mipLevelCount: 1 }));
|
|
315
|
+
const mip1 = value(graph.view(hzb, { label: 'hzb-mip-1', baseMipLevel: 1, mipLevelCount: 1 }));
|
|
316
|
+
graph.addComputePass('seed-mip-0', {
|
|
317
|
+
accesses: [{ resource: mip0, usage: 'storage-write' }],
|
|
318
|
+
encode: () => undefined,
|
|
319
|
+
});
|
|
320
|
+
graph.addComputePass('build-mip-1', {
|
|
321
|
+
accesses: [
|
|
322
|
+
{ resource: mip0, usage: 'sampled-read' },
|
|
323
|
+
{ resource: mip1, usage: 'storage-write' },
|
|
324
|
+
],
|
|
325
|
+
encode: () => undefined,
|
|
326
|
+
});
|
|
327
|
+
expect(graph.compile({ device: mockDevice(), surfaceSize: { width: 16, height: 16 } }).ok).toBe(
|
|
328
|
+
true,
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('rejects handles from another builder before compilation', () => {
|
|
333
|
+
const owner = new RenderGraphBuilder<Frame>();
|
|
334
|
+
const foreign = value(owner.createBuffer('foreign', { size: 16 }));
|
|
335
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
336
|
+
const result = graph.addComputePass('consume', {
|
|
337
|
+
accesses: [{ resource: foreign, usage: 'storage-read' }],
|
|
338
|
+
encode: () => undefined,
|
|
339
|
+
});
|
|
340
|
+
expect(result.ok).toBe(false);
|
|
341
|
+
if (!result.ok) expect(result.error.code).toBe('foreign-resource-handle');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('rejects incompatible overlapping usages inside one pass', () => {
|
|
345
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
346
|
+
const data = value(
|
|
347
|
+
graph.importBuffer('data', { size: 16, usage: 0x0080 }, (frame) => frame.imported),
|
|
348
|
+
);
|
|
349
|
+
graph.addComputePass('conflict', {
|
|
350
|
+
accesses: [
|
|
351
|
+
{ resource: data, usage: 'storage-read' },
|
|
352
|
+
{ resource: data, usage: 'storage-read-write' },
|
|
353
|
+
],
|
|
354
|
+
encode: () => undefined,
|
|
355
|
+
});
|
|
356
|
+
const result = graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } });
|
|
357
|
+
expect(result.ok).toBe(false);
|
|
358
|
+
if (!result.ok) expect(result.error.code).toBe('access-conflict');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('treats attachment load as a read while clear is a first write', () => {
|
|
362
|
+
const clearedGraph = new RenderGraphBuilder<Frame>();
|
|
363
|
+
const clearedTexture = value(
|
|
364
|
+
clearedGraph.createTexture('cleared', { format: 'rgba8unorm', size: 'surface' }),
|
|
365
|
+
);
|
|
366
|
+
const clearedView = value(clearedGraph.view(clearedTexture));
|
|
367
|
+
clearedGraph.addRasterPass('clear', {
|
|
368
|
+
accesses: [{ resource: clearedView, usage: 'color-attachment' }],
|
|
369
|
+
colorAttachments: [{ view: clearedView, loadOp: 'clear', storeOp: 'store' }],
|
|
370
|
+
encode: () => undefined,
|
|
371
|
+
});
|
|
372
|
+
expect(
|
|
373
|
+
clearedGraph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }).ok,
|
|
374
|
+
).toBe(true);
|
|
375
|
+
|
|
376
|
+
const loadedGraph = new RenderGraphBuilder<Frame>();
|
|
377
|
+
const loadedTexture = value(
|
|
378
|
+
loadedGraph.createTexture('loaded', { format: 'rgba8unorm', size: 'surface' }),
|
|
379
|
+
);
|
|
380
|
+
const loadedView = value(loadedGraph.view(loadedTexture));
|
|
381
|
+
loadedGraph.addRasterPass('load', {
|
|
382
|
+
accesses: [{ resource: loadedView, usage: 'color-attachment' }],
|
|
383
|
+
colorAttachments: [{ view: loadedView, loadOp: 'load', storeOp: 'store' }],
|
|
384
|
+
encode: () => undefined,
|
|
385
|
+
});
|
|
386
|
+
const result = loadedGraph.compile({
|
|
387
|
+
device: mockDevice(),
|
|
388
|
+
surfaceSize: { width: 1, height: 1 },
|
|
389
|
+
});
|
|
390
|
+
expect(result.ok).toBe(false);
|
|
391
|
+
if (!result.ok) expect(result.error.code).toBe('uninitialized-read');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('RenderGraphBuilder capability, execution, and lifecycle contracts', () => {
|
|
396
|
+
it('derives storage usage for a write-only buffer producer', () => {
|
|
397
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
398
|
+
const buffer = value(graph.createBuffer('produced', { size: 16 }));
|
|
399
|
+
graph.addComputePass('produce', {
|
|
400
|
+
accesses: [{ resource: buffer, usage: 'storage-write' }],
|
|
401
|
+
encode: () => undefined,
|
|
402
|
+
});
|
|
403
|
+
const compiled = value(
|
|
404
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
405
|
+
);
|
|
406
|
+
expect(compiled.inspect().resources).toContainEqual(
|
|
407
|
+
expect.objectContaining({ label: 'produced', derivedUsage: 0x0080 }),
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('derives compute and storage capabilities from pass/access declarations', () => {
|
|
412
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
413
|
+
const buffer = value(
|
|
414
|
+
graph.importBuffer('data', { size: 16, usage: 0x0080 }, (frame) => frame.imported),
|
|
415
|
+
);
|
|
416
|
+
graph.addComputePass('compute', {
|
|
417
|
+
accesses: [{ resource: buffer, usage: 'storage-read' }],
|
|
418
|
+
encode: () => undefined,
|
|
419
|
+
});
|
|
420
|
+
const device = mockDevice({ caps: caps({ compute: false }) });
|
|
421
|
+
const refused = graph.compile({ device, surfaceSize: { width: 1, height: 1 } });
|
|
422
|
+
expect(refused.ok).toBe(false);
|
|
423
|
+
if (!refused.ok) {
|
|
424
|
+
expect(refused.error.code).toBe('capability-missing');
|
|
425
|
+
expect(refused.error.detail).toMatchObject({ capability: 'compute', passName: 'compute' });
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('opens, dispatches, and ends one graph-owned compute pass', () => {
|
|
430
|
+
const events: string[] = [];
|
|
431
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
432
|
+
const buffer = value(
|
|
433
|
+
graph.importBuffer('data', { size: 16, usage: 0x0080 }, (frame) => frame.imported),
|
|
434
|
+
);
|
|
435
|
+
graph.addComputePass('membership', {
|
|
436
|
+
accesses: [{ resource: buffer, usage: 'storage-read' }],
|
|
437
|
+
begin: () => ({
|
|
438
|
+
timestampWrites: {
|
|
439
|
+
querySet: {} as never,
|
|
440
|
+
beginningOfPassWriteIndex: 0,
|
|
441
|
+
endOfPassWriteIndex: 1,
|
|
442
|
+
},
|
|
443
|
+
}),
|
|
444
|
+
encode: ({ pass }) => pass.dispatchWorkgroups(1),
|
|
445
|
+
after: () => events.push('after-compute'),
|
|
446
|
+
});
|
|
447
|
+
const compiled = value(
|
|
448
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
449
|
+
);
|
|
450
|
+
const executed = compiled.execute({
|
|
451
|
+
imported: {} as Buffer,
|
|
452
|
+
encoder: mockEncoder(events),
|
|
453
|
+
});
|
|
454
|
+
expect(executed.ok).toBe(true);
|
|
455
|
+
expect(events).toEqual([
|
|
456
|
+
'timestamp-writes',
|
|
457
|
+
'begin-compute:membership',
|
|
458
|
+
'dispatch',
|
|
459
|
+
'end-compute',
|
|
460
|
+
'after-compute',
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('reports a graph-owned compute pass begin failure to the pass owner', () => {
|
|
465
|
+
const events: string[] = [];
|
|
466
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
467
|
+
graph.addComputePass('timed-compute', {
|
|
468
|
+
accesses: [],
|
|
469
|
+
begin: () => ({}),
|
|
470
|
+
onBeginError: (_frame, cause) => events.push(`begin-error:${String(cause)}`),
|
|
471
|
+
encode: () => events.push('encode'),
|
|
472
|
+
});
|
|
473
|
+
const compiled = value(
|
|
474
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
475
|
+
);
|
|
476
|
+
const result = compiled.execute({
|
|
477
|
+
imported: {} as Buffer,
|
|
478
|
+
encoder: {
|
|
479
|
+
beginComputePass: () => {
|
|
480
|
+
throw new Error('timestamp writes unavailable');
|
|
481
|
+
},
|
|
482
|
+
} as unknown as RhiCommandEncoder,
|
|
483
|
+
});
|
|
484
|
+
expect(result.ok).toBe(false);
|
|
485
|
+
if (!result.ok) expect(result.error.code).toBe('pass-encode-failed');
|
|
486
|
+
expect(events).toEqual(['begin-error:Error: timestamp writes unavailable']);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('skips a frame-disabled pass before opening its child encoder', () => {
|
|
490
|
+
const events: string[] = [];
|
|
491
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
492
|
+
graph.addComputePass('cached-producer', {
|
|
493
|
+
accesses: [],
|
|
494
|
+
executeIf: () => false,
|
|
495
|
+
encode: () => events.push('encode'),
|
|
496
|
+
});
|
|
497
|
+
const compiled = value(
|
|
498
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
499
|
+
);
|
|
500
|
+
const executed = compiled.execute({ imported: {} as Buffer, encoder: mockEncoder(events) });
|
|
501
|
+
expect(executed.ok).toBe(true);
|
|
502
|
+
expect(events).toEqual([]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('ends the compute child pass and returns structured failure when encode throws', () => {
|
|
506
|
+
const events: string[] = [];
|
|
507
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
508
|
+
graph.addComputePass('broken', {
|
|
509
|
+
accesses: [],
|
|
510
|
+
encode: () => {
|
|
511
|
+
throw new Error('boom');
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
const compiled = value(
|
|
515
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
516
|
+
);
|
|
517
|
+
const result = compiled.execute({ imported: {} as Buffer, encoder: mockEncoder(events) });
|
|
518
|
+
expect(result.ok).toBe(false);
|
|
519
|
+
if (!result.ok) expect(result.error.code).toBe('pass-encode-failed');
|
|
520
|
+
expect(events).toEqual(['begin-compute:broken', 'end-compute']);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('limits frame-time resolution to the exact pass declaration', () => {
|
|
524
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
525
|
+
const declared = value(
|
|
526
|
+
graph.importBuffer('declared', { size: 16, usage: 0x0040 }, (frame) => frame.imported),
|
|
527
|
+
);
|
|
528
|
+
const hidden = value(
|
|
529
|
+
graph.importBuffer('hidden', { size: 16, usage: 0x0040 }, (frame) => frame.imported),
|
|
530
|
+
);
|
|
531
|
+
let resolutionCode: string | undefined;
|
|
532
|
+
graph.addComputePass('resolve', {
|
|
533
|
+
accesses: [{ resource: declared, usage: 'uniform-read' }],
|
|
534
|
+
encode: ({ resources }) => {
|
|
535
|
+
const resolved = resources.buffer(hidden);
|
|
536
|
+
if (!resolved.ok) resolutionCode = resolved.error.code;
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
const compiled = value(
|
|
540
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
541
|
+
);
|
|
542
|
+
expect(compiled.execute({ imported: {} as Buffer, encoder: mockEncoder() }).ok).toBe(true);
|
|
543
|
+
expect(resolutionCode).toBe('resource-not-declared-by-pass');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('does not resolve imported resources that no pass uses', () => {
|
|
547
|
+
let resolutions = 0;
|
|
548
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
549
|
+
graph.importBuffer('unused-buffer', { size: 16, usage: 0x0040 }, () => {
|
|
550
|
+
resolutions++;
|
|
551
|
+
throw new Error('unused buffer resolved');
|
|
552
|
+
});
|
|
553
|
+
const texture = value(
|
|
554
|
+
graph.importTexture(
|
|
555
|
+
'unused-texture',
|
|
556
|
+
{ format: 'rgba8unorm', size: 'surface', usage: 0x04 },
|
|
557
|
+
() => {
|
|
558
|
+
resolutions++;
|
|
559
|
+
throw new Error('unused texture resolved');
|
|
560
|
+
},
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
graph.view(texture, { label: 'unused-view' });
|
|
564
|
+
const compiled = value(
|
|
565
|
+
graph.compile({ device: mockDevice(), surfaceSize: { width: 1, height: 1 } }),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
expect(compiled.execute({ imported: {} as Buffer, encoder: mockEncoder() }).ok).toBe(true);
|
|
569
|
+
expect(resolutions).toBe(0);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('rolls back earlier graph-owned allocations when a later allocation fails', () => {
|
|
573
|
+
const destroyed: Buffer[] = [];
|
|
574
|
+
let allocations = 0;
|
|
575
|
+
const device = mockDevice({
|
|
576
|
+
createBuffer: () => {
|
|
577
|
+
allocations++;
|
|
578
|
+
if (allocations === 1) return ok({ id: 1 } as unknown as Buffer);
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
error: {
|
|
582
|
+
code: 'webgpu-runtime-error',
|
|
583
|
+
},
|
|
584
|
+
} as never;
|
|
585
|
+
},
|
|
586
|
+
destroyBuffer: (buffer) => {
|
|
587
|
+
destroyed.push(buffer);
|
|
588
|
+
return ok(undefined);
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
592
|
+
const first = value(graph.createBuffer('first', { size: 16 }));
|
|
593
|
+
const second = value(graph.createBuffer('second', { size: 16 }));
|
|
594
|
+
graph.addCopyPass('seed', {
|
|
595
|
+
accesses: [
|
|
596
|
+
{ resource: first, usage: 'copy-dst' },
|
|
597
|
+
{ resource: second, usage: 'copy-dst' },
|
|
598
|
+
],
|
|
599
|
+
encode: () => undefined,
|
|
600
|
+
});
|
|
601
|
+
const result = graph.compile({ device, surfaceSize: { width: 1, height: 1 } });
|
|
602
|
+
expect(result.ok).toBe(false);
|
|
603
|
+
if (!result.ok) expect(result.error.code).toBe('resource-allocation-failed');
|
|
604
|
+
expect(destroyed).toHaveLength(1);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('destroys graph-created resources once and never destroys imported resources', async () => {
|
|
608
|
+
const destroyedBuffers: Buffer[] = [];
|
|
609
|
+
const device = mockDevice({
|
|
610
|
+
destroyBuffer: (buffer) => {
|
|
611
|
+
destroyedBuffers.push(buffer);
|
|
612
|
+
return ok(undefined);
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
616
|
+
const created = value(graph.createBuffer('created', { size: 16 }));
|
|
617
|
+
const imported = value(
|
|
618
|
+
graph.importBuffer('imported', { size: 16, usage: 0x0040 }, (frame) => frame.imported),
|
|
619
|
+
);
|
|
620
|
+
graph.addCopyPass('seed', {
|
|
621
|
+
accesses: [{ resource: created, usage: 'copy-dst' }],
|
|
622
|
+
encode: () => undefined,
|
|
623
|
+
});
|
|
624
|
+
graph.addComputePass('consume', {
|
|
625
|
+
accesses: [
|
|
626
|
+
{ resource: created, usage: 'uniform-read' },
|
|
627
|
+
{ resource: imported, usage: 'uniform-read' },
|
|
628
|
+
],
|
|
629
|
+
encode: () => undefined,
|
|
630
|
+
});
|
|
631
|
+
const compiled = value(graph.compile({ device, surfaceSize: { width: 1, height: 1 } }));
|
|
632
|
+
expect((await compiled.retire()).ok).toBe(true);
|
|
633
|
+
expect((await compiled.retire()).ok).toBe(true);
|
|
634
|
+
expect(destroyedBuffers).toHaveLength(1);
|
|
635
|
+
expect(compiled.execute({ imported: {} as Buffer, encoder: mockEncoder() }).ok).toBe(false);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('keeps a failed retirement result stable across repeated calls', async () => {
|
|
639
|
+
const graph = new RenderGraphBuilder<Frame>();
|
|
640
|
+
const created = value(graph.createBuffer('created', { size: 16 }));
|
|
641
|
+
graph.addCopyPass('seed', {
|
|
642
|
+
accesses: [{ resource: created, usage: 'copy-dst' }],
|
|
643
|
+
encode: () => undefined,
|
|
644
|
+
});
|
|
645
|
+
const compiled = value(
|
|
646
|
+
graph.compile({
|
|
647
|
+
device: mockDevice({
|
|
648
|
+
queue: {
|
|
649
|
+
onSubmittedWorkDone: async () => {
|
|
650
|
+
throw new Error('device lost');
|
|
651
|
+
},
|
|
652
|
+
} as never,
|
|
653
|
+
}),
|
|
654
|
+
surfaceSize: { width: 1, height: 1 },
|
|
655
|
+
}),
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const first = await compiled.retire();
|
|
659
|
+
const second = await compiled.retire();
|
|
660
|
+
expect(first.ok).toBe(false);
|
|
661
|
+
expect(second).toBe(first);
|
|
662
|
+
if (!second.ok) expect(second.error.code).toBe('resource-retire-failed');
|
|
663
|
+
});
|
|
664
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
5
|
+
import type { CapabilityDetail } from '../errors.js';
|
|
6
|
+
|
|
7
|
+
const builderSource = readFileSync(new URL('../builder.ts', import.meta.url), 'utf8');
|
|
8
|
+
|
|
9
|
+
type Capability = CapabilityDetail['capability'];
|
|
10
|
+
type ExpectedCapability = 'compute' | 'storage-buffer' | 'storage-texture' | 'indirect';
|
|
11
|
+
|
|
12
|
+
describe('RenderGraph capability owner', () => {
|
|
13
|
+
it('keeps the exact capability membership with bilateral equality', () => {
|
|
14
|
+
expectTypeOf<Capability>().toEqualTypeOf<ExpectedCapability>();
|
|
15
|
+
expectTypeOf<ExpectedCapability>().toEqualTypeOf<Capability>();
|
|
16
|
+
|
|
17
|
+
const acceptsCapability = (capability: Capability): Capability => capability;
|
|
18
|
+
acceptsCapability('compute');
|
|
19
|
+
acceptsCapability('storage-buffer');
|
|
20
|
+
acceptsCapability('storage-texture');
|
|
21
|
+
acceptsCapability('indirect');
|
|
22
|
+
// @ts-expect-error unknown capabilities remain outside the closed union.
|
|
23
|
+
acceptsCapability('capability-not-real');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('derives the builder parameter from the structured error owner', () => {
|
|
27
|
+
expect(builderSource).toContain("capability: CapabilityDetail['capability'],");
|
|
28
|
+
expect(builderSource).not.toContain(
|
|
29
|
+
"capability: 'compute' | 'storage-buffer' | 'storage-texture' | 'indirect',",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|