@forgeax/engine-render-graph 0.0.0-dev.8d955ade1c79
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__/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-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/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 +403 -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 +62 -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__/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 +673 -0
- package/src/__tests__/render-graph-errors.test-d.ts +207 -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 +2551 -0
- package/src/__tests__/resource-declaration-collision.test.ts +151 -0
- package/src/builder.ts +1009 -0
- package/src/compiled-graph.ts +383 -0
- package/src/errors.ts +205 -0
- package/src/graph.ts +1269 -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 +174 -0
- package/src/types.ts +216 -0
|
@@ -0,0 +1,2551 @@
|
|
|
1
|
+
// Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
|
|
2
|
+
// biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
|
|
3
|
+
//
|
|
4
|
+
// Source files (N=7):
|
|
5
|
+
// - packages/render-graph/src/__tests__/pass-order.test.ts
|
|
6
|
+
// - packages/render-graph/src/__tests__/bloom-topo.test.ts
|
|
7
|
+
// - packages/render-graph/src/__tests__/compile-fail-fast.test.ts
|
|
8
|
+
// - packages/render-graph/src/__tests__/errors.test.ts
|
|
9
|
+
// - packages/render-graph/src/__tests__/list-passes-resources.test.ts
|
|
10
|
+
// - packages/render-graph/src/__tests__/resource-allocation.test.ts
|
|
11
|
+
// - packages/render-graph/src/__tests__/uniform-fallback.test.ts
|
|
12
|
+
//
|
|
13
|
+
// Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
|
|
14
|
+
// source as ancestorTitles[0]. Top-level imports merged + deduped.
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import type { CapMissingDetail, DanglingReadDetail, DuplicateResourceDetail } from '../errors.js';
|
|
18
|
+
import { err, ok, RenderGraphError, type RenderGraphErrorCode } from '../errors.js';
|
|
19
|
+
import { RenderGraph } from '../graph.js';
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
describe('color domain compile contract', () => {
|
|
23
|
+
const caps = { compute: true, storageBuffer: true } as never;
|
|
24
|
+
|
|
25
|
+
function graphWithTargets(sourceDomain: unknown, destinationDomain: unknown): RenderGraph {
|
|
26
|
+
const graph = new RenderGraph();
|
|
27
|
+
graph.addColorTarget('source', {
|
|
28
|
+
format: 'rgba16float',
|
|
29
|
+
size: { w: 1, h: 1 },
|
|
30
|
+
domain: sourceDomain,
|
|
31
|
+
} as never);
|
|
32
|
+
graph.addColorTarget('destination', {
|
|
33
|
+
format: 'rgba8unorm',
|
|
34
|
+
size: { w: 1, h: 1 },
|
|
35
|
+
domain: destinationDomain,
|
|
36
|
+
} as never);
|
|
37
|
+
graph.addPass('source-seed', { reads: [], writes: ['source'] });
|
|
38
|
+
return graph;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it('rejects a domain connection when the source domain is unknown', () => {
|
|
42
|
+
const graph = graphWithTargets('gamma-magic', 'linear-ldr');
|
|
43
|
+
graph.addPass('blend', {
|
|
44
|
+
reads: ['source'],
|
|
45
|
+
writes: ['destination'],
|
|
46
|
+
colorConnections: [{ source: 'source', destination: 'destination' }],
|
|
47
|
+
} as never);
|
|
48
|
+
|
|
49
|
+
const result = graph.compile({ backendKind: 'null', caps });
|
|
50
|
+
expect(result.ok).toBe(false);
|
|
51
|
+
if (result.ok) throw new Error('expected invalid color domain');
|
|
52
|
+
expect(result.error.code).toBe('invalid-color-domain');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects a connection whose destination domain is missing', () => {
|
|
56
|
+
const graph = graphWithTargets('linear-ldr', undefined);
|
|
57
|
+
graph.addPass('blend', {
|
|
58
|
+
reads: ['source'],
|
|
59
|
+
writes: ['destination'],
|
|
60
|
+
colorConnections: [{ source: 'source', destination: 'destination' }],
|
|
61
|
+
} as never);
|
|
62
|
+
|
|
63
|
+
const result = graph.compile({ backendKind: 'null', caps });
|
|
64
|
+
expect(result.ok).toBe(false);
|
|
65
|
+
if (result.ok) throw new Error('expected missing color domain');
|
|
66
|
+
expect(result.error.code).toBe('missing-color-domain');
|
|
67
|
+
expect(result.error.hint).toContain('domain');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects an implicit linear-to-encoded connection', () => {
|
|
71
|
+
const graph = graphWithTargets('linear-ldr', 'display-encoded');
|
|
72
|
+
graph.addPass('blend', {
|
|
73
|
+
reads: ['source'],
|
|
74
|
+
writes: ['destination'],
|
|
75
|
+
colorConnections: [{ source: 'source', destination: 'destination' }],
|
|
76
|
+
} as never);
|
|
77
|
+
|
|
78
|
+
const result = graph.compile({ backendKind: 'null', caps });
|
|
79
|
+
expect(result.ok).toBe(false);
|
|
80
|
+
if (result.ok) throw new Error('expected a domain mismatch');
|
|
81
|
+
expect(result.error.code).toBe('color-domain-mismatch');
|
|
82
|
+
expect(result.error.expected).toContain('explicit');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('accepts an explicit output encoding connection', () => {
|
|
86
|
+
const graph = graphWithTargets('linear-ldr', 'display-encoded');
|
|
87
|
+
graph.addPass('encode', {
|
|
88
|
+
reads: ['source'],
|
|
89
|
+
writes: ['destination'],
|
|
90
|
+
colorConnections: [
|
|
91
|
+
{
|
|
92
|
+
source: 'source',
|
|
93
|
+
destination: 'destination',
|
|
94
|
+
conversion: { kind: 'encode-srgb' },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
} as never);
|
|
98
|
+
|
|
99
|
+
const result = graph.compile({ backendKind: 'null', caps });
|
|
100
|
+
expect(result.ok).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('whole-graph retirement', () => {
|
|
105
|
+
it('relinquishes transient and persistent targets before GPU completion, then reclaims both', async () => {
|
|
106
|
+
const destroyed: object[] = [];
|
|
107
|
+
let resolveSubmittedWork: (() => void) | undefined;
|
|
108
|
+
const submittedWork = new Promise<void>((resolve) => {
|
|
109
|
+
resolveSubmittedWork = resolve;
|
|
110
|
+
});
|
|
111
|
+
const device = {
|
|
112
|
+
createTexture: () => ({ ok: true as const, value: { id: 'transient' } }),
|
|
113
|
+
createTextureView: () => ({ ok: true as const, value: { id: 'view' } }),
|
|
114
|
+
destroyTexture: (texture: object) => {
|
|
115
|
+
destroyed.push(texture);
|
|
116
|
+
return { ok: true as const, value: undefined };
|
|
117
|
+
},
|
|
118
|
+
queue: { onSubmittedWorkDone: () => submittedWork },
|
|
119
|
+
};
|
|
120
|
+
const g = new RenderGraph();
|
|
121
|
+
g.addColorTarget('transient', {
|
|
122
|
+
format: 'rgba16float',
|
|
123
|
+
size: 'swapchain',
|
|
124
|
+
sample: 1,
|
|
125
|
+
usage: 0,
|
|
126
|
+
});
|
|
127
|
+
g.addPass('main', { reads: [], writes: ['transient'] });
|
|
128
|
+
const compiled = g.compile({
|
|
129
|
+
backendKind: 'webgpu',
|
|
130
|
+
caps: {
|
|
131
|
+
backendKind: 'webgpu',
|
|
132
|
+
compute: true,
|
|
133
|
+
storageBuffer: true,
|
|
134
|
+
storageTexture: false,
|
|
135
|
+
timestampQuery: false,
|
|
136
|
+
timestampPeriodNanoseconds: null,
|
|
137
|
+
indirectDrawing: false,
|
|
138
|
+
textureCompressionBc: false,
|
|
139
|
+
textureCompressionEtc2: false,
|
|
140
|
+
textureCompressionAstc: false,
|
|
141
|
+
multiDrawIndirect: false,
|
|
142
|
+
pushConstants: false,
|
|
143
|
+
textureBindingArray: false,
|
|
144
|
+
samplerAliasing: true,
|
|
145
|
+
firstInstanceIndirect: false,
|
|
146
|
+
rgba16floatRenderable: false,
|
|
147
|
+
rg11b10ufloatRenderable: false,
|
|
148
|
+
float32Filterable: false,
|
|
149
|
+
maxColorAttachments: 8,
|
|
150
|
+
},
|
|
151
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
152
|
+
device: device as any,
|
|
153
|
+
});
|
|
154
|
+
expect(compiled.ok).toBe(true);
|
|
155
|
+
|
|
156
|
+
const persistentTexture = { id: 'persistent' };
|
|
157
|
+
const graphInternals = g as unknown as {
|
|
158
|
+
persistentTextures: Map<string, { texture: object; view: object }>;
|
|
159
|
+
transientPool: Map<string, { texture: object; view: object }>;
|
|
160
|
+
retire?: () => void;
|
|
161
|
+
};
|
|
162
|
+
graphInternals.persistentTextures.set('persistent', {
|
|
163
|
+
texture: persistentTexture,
|
|
164
|
+
view: { id: 'persistent-view' },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(graphInternals.retire).toBeTypeOf('function');
|
|
168
|
+
if (graphInternals.retire === undefined) return;
|
|
169
|
+
graphInternals.retire();
|
|
170
|
+
|
|
171
|
+
expect(graphInternals.transientPool).toHaveLength(0);
|
|
172
|
+
expect(graphInternals.persistentTextures).toHaveLength(0);
|
|
173
|
+
expect(destroyed).toEqual([]);
|
|
174
|
+
|
|
175
|
+
const reclaimed = g.reclaimRetiredTransients();
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
expect(destroyed).toEqual([]);
|
|
178
|
+
resolveSubmittedWork?.();
|
|
179
|
+
await reclaimed;
|
|
180
|
+
|
|
181
|
+
expect(destroyed).toHaveLength(2);
|
|
182
|
+
expect(destroyed).toContain(persistentTexture);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
{
|
|
188
|
+
// --- from pass-order.test.ts ---
|
|
189
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function mockCaps() {
|
|
192
|
+
return {
|
|
193
|
+
backendKind: 'webgpu' as const,
|
|
194
|
+
compute: true,
|
|
195
|
+
storageBuffer: true,
|
|
196
|
+
storageTexture: false,
|
|
197
|
+
timestampQuery: false,
|
|
198
|
+
timestampPeriodNanoseconds: null,
|
|
199
|
+
indirectDrawing: false,
|
|
200
|
+
textureCompressionBc: false,
|
|
201
|
+
textureCompressionEtc2: false,
|
|
202
|
+
textureCompressionAstc: false,
|
|
203
|
+
multiDrawIndirect: false,
|
|
204
|
+
pushConstants: false,
|
|
205
|
+
textureBindingArray: false,
|
|
206
|
+
samplerAliasing: true,
|
|
207
|
+
firstInstanceIndirect: false,
|
|
208
|
+
rgba16floatRenderable: false,
|
|
209
|
+
rg11b10ufloatRenderable: false,
|
|
210
|
+
float32Filterable: false,
|
|
211
|
+
maxColorAttachments: 8,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Per-frame pass order ─────────────────────────────────────────
|
|
216
|
+
//
|
|
217
|
+
// feat-20260529-rendergraph-pass-abstraction M4 / w14. Mirrors the EXACT
|
|
218
|
+
// reads/writes the runtime declares for the per-frame passes so the
|
|
219
|
+
// integration-level declaration order is asserted on the real shape:
|
|
220
|
+
// shadow : reads [] writes ['shadowDepth']
|
|
221
|
+
// main : reads ['shadowDepth'] writes ['hdrColor', 'depth']
|
|
222
|
+
// tonemap : reads ['hdrColor'] writes []
|
|
223
|
+
// fxaa : reads [] writes ['fxaaIntermediate']
|
|
224
|
+
|
|
225
|
+
function buildPerFrameGraph() {
|
|
226
|
+
const g = new RenderGraph();
|
|
227
|
+
g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
|
|
228
|
+
g.addResource('shadowDepth', { kind: 'texture', lifetime: 'persistent' });
|
|
229
|
+
g.addResource('fxaaIntermediate', { kind: 'texture', lifetime: 'transient' });
|
|
230
|
+
g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
|
|
231
|
+
g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
|
|
232
|
+
g.addPass('main', { reads: ['shadowDepth'], writes: ['hdrColor', 'depth'] });
|
|
233
|
+
g.addPass('tonemap', { reads: ['hdrColor'], writes: [] });
|
|
234
|
+
g.addPass('fxaa', { reads: [], writes: ['fxaaIntermediate'] });
|
|
235
|
+
return g;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe('per-frame pass order', () => {
|
|
239
|
+
it('executes compiled pass closures in declaration order', () => {
|
|
240
|
+
const calls: string[] = [];
|
|
241
|
+
const g = new RenderGraph<{ readonly frame: number }>();
|
|
242
|
+
g.addResource('X', { kind: 'texture', lifetime: 'transient' });
|
|
243
|
+
g.addPass('producer', {
|
|
244
|
+
reads: [],
|
|
245
|
+
writes: ['X'],
|
|
246
|
+
execute: () => calls.push('producer'),
|
|
247
|
+
});
|
|
248
|
+
g.addPass('consumer', {
|
|
249
|
+
reads: ['X'],
|
|
250
|
+
writes: [],
|
|
251
|
+
execute: () => calls.push('consumer'),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const compiled = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
|
|
255
|
+
expect(compiled.ok).toBe(true);
|
|
256
|
+
const executedPasses: string[] = [];
|
|
257
|
+
g.execute({ frame: 1 }, (name, run) => {
|
|
258
|
+
executedPasses.push(name);
|
|
259
|
+
run();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(calls).toEqual(['producer', 'consumer']);
|
|
263
|
+
expect(executedPasses).toEqual(['producer', 'consumer']);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('preserves shadow -> main pass ordering (shadow before main in compiled order)', () => {
|
|
267
|
+
const r = buildPerFrameGraph().compile({ backendKind: 'wgpu-native', caps: mockCaps() });
|
|
268
|
+
expect(r.ok).toBe(true);
|
|
269
|
+
if (!r.ok) return;
|
|
270
|
+
const order = r.value.passes.map((p) => p.name);
|
|
271
|
+
expect(order.indexOf('shadow')).toBeLessThan(order.indexOf('main'));
|
|
272
|
+
expect(order.indexOf('main')).toBeLessThan(order.indexOf('tonemap'));
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
{
|
|
278
|
+
// --- from bloom-topo.test.ts ---
|
|
279
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function mockCaps() {
|
|
282
|
+
return {
|
|
283
|
+
backendKind: 'webgpu' as const,
|
|
284
|
+
compute: true,
|
|
285
|
+
storageBuffer: true,
|
|
286
|
+
storageTexture: false,
|
|
287
|
+
timestampQuery: false,
|
|
288
|
+
timestampPeriodNanoseconds: null,
|
|
289
|
+
indirectDrawing: false,
|
|
290
|
+
textureCompressionBc: false,
|
|
291
|
+
textureCompressionEtc2: false,
|
|
292
|
+
textureCompressionAstc: false,
|
|
293
|
+
multiDrawIndirect: false,
|
|
294
|
+
pushConstants: false,
|
|
295
|
+
textureBindingArray: false,
|
|
296
|
+
samplerAliasing: true,
|
|
297
|
+
firstInstanceIndirect: false,
|
|
298
|
+
rgba16floatRenderable: false,
|
|
299
|
+
rg11b10ufloatRenderable: false,
|
|
300
|
+
float32Filterable: false,
|
|
301
|
+
maxColorAttachments: 8,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Bloom declaration-order assertion ────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe('bloom graph topology (feat-20260531-bloom)', () => {
|
|
308
|
+
it('compiles an 8-pass graph with bloom resources without error', () => {
|
|
309
|
+
const g = new RenderGraph();
|
|
310
|
+
g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
|
|
311
|
+
g.addResource('shadowDepth', { kind: 'texture', lifetime: 'persistent' });
|
|
312
|
+
g.addResource('fxaaIntermediate', { kind: 'texture', lifetime: 'transient' });
|
|
313
|
+
g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
|
|
314
|
+
g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
|
|
315
|
+
g.addResource('bloomBright', { kind: 'texture', lifetime: 'transient' });
|
|
316
|
+
g.addResource('bloomBlurH', { kind: 'texture', lifetime: 'transient' });
|
|
317
|
+
g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
|
|
318
|
+
g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
|
|
319
|
+
g.addPass('main', { reads: ['shadowDepth'], writes: ['hdrColor', 'depth'] });
|
|
320
|
+
g.addPass('bloom-bright', { reads: ['hdrColor'], writes: ['bloomBright'] });
|
|
321
|
+
g.addPass('bloom-blur-h', { reads: ['bloomBright'], writes: ['bloomBlurH'] });
|
|
322
|
+
g.addPass('bloom-blur-v', { reads: ['bloomBlurH'], writes: ['bloomBlurV'] });
|
|
323
|
+
g.addPass('bloom-composite', {
|
|
324
|
+
reads: ['hdrColor', 'bloomBlurV'],
|
|
325
|
+
writes: ['hdrComposited'],
|
|
326
|
+
});
|
|
327
|
+
g.addPass('tonemap', { reads: ['hdrComposited'], writes: [] });
|
|
328
|
+
g.addPass('fxaa', { reads: [], writes: ['fxaaIntermediate'] });
|
|
329
|
+
|
|
330
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
|
|
331
|
+
expect(r.ok).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('places composite before tonemap (writes hdrComposited -> tonemap reads hdrComposited)', () => {
|
|
335
|
+
const g = new RenderGraph();
|
|
336
|
+
g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
|
|
337
|
+
g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
|
|
338
|
+
g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
|
|
339
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
340
|
+
g.addPass('blur-v', { reads: [], writes: ['bloomBlurV'] });
|
|
341
|
+
g.addPass('bloom-composite', {
|
|
342
|
+
reads: ['hdrColor', 'bloomBlurV'],
|
|
343
|
+
writes: ['hdrComposited'],
|
|
344
|
+
});
|
|
345
|
+
g.addPass('tonemap', { reads: ['hdrComposited'], writes: [] });
|
|
346
|
+
|
|
347
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
|
|
348
|
+
expect(r.ok).toBe(true);
|
|
349
|
+
if (!r.ok) return;
|
|
350
|
+
const order = r.value.passes.map((p) => p.name);
|
|
351
|
+
const compIdx = order.indexOf('bloom-composite');
|
|
352
|
+
const tonemapIdx = order.indexOf('tonemap');
|
|
353
|
+
expect(compIdx).toBeLessThan(tonemapIdx);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('preserves bloom chain order: bright < blur-h < blur-v < composite', () => {
|
|
357
|
+
const g = new RenderGraph();
|
|
358
|
+
g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
|
|
359
|
+
g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
|
|
360
|
+
g.addResource('bloomBright', { kind: 'texture', lifetime: 'transient' });
|
|
361
|
+
g.addResource('bloomBlurH', { kind: 'texture', lifetime: 'transient' });
|
|
362
|
+
g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
|
|
363
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
364
|
+
g.addPass('bloom-bright', { reads: ['hdrColor'], writes: ['bloomBright'] });
|
|
365
|
+
g.addPass('bloom-blur-h', { reads: ['bloomBright'], writes: ['bloomBlurH'] });
|
|
366
|
+
g.addPass('bloom-blur-v', { reads: ['bloomBlurH'], writes: ['bloomBlurV'] });
|
|
367
|
+
g.addPass('bloom-composite', {
|
|
368
|
+
reads: ['hdrColor', 'bloomBlurV'],
|
|
369
|
+
writes: ['hdrComposited'],
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
|
|
373
|
+
expect(r.ok).toBe(true);
|
|
374
|
+
if (!r.ok) return;
|
|
375
|
+
const order = r.value.passes.map((p) => p.name);
|
|
376
|
+
const brightIdx = order.indexOf('bloom-bright');
|
|
377
|
+
const blurHIdx = order.indexOf('bloom-blur-h');
|
|
378
|
+
const blurVIdx = order.indexOf('bloom-blur-v');
|
|
379
|
+
const compIdx = order.indexOf('bloom-composite');
|
|
380
|
+
expect(brightIdx).toBeLessThan(blurHIdx);
|
|
381
|
+
expect(blurHIdx).toBeLessThan(blurVIdx);
|
|
382
|
+
expect(blurVIdx).toBeLessThan(compIdx);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('uses hdrComposited to make the temporal version explicit', () => {
|
|
386
|
+
// bloom-bright reads the hdrColor version written by main.
|
|
387
|
+
// bloom-composite reads hdrColor + bloomBlurV, writes hdrComposited.
|
|
388
|
+
// tonemap reads hdrComposited.
|
|
389
|
+
// The chain: main -> bloom-bright -> blur-h -> blur-v -> composite -> tonemap.
|
|
390
|
+
// The hdrComposited resource name makes the post-composite version explicit while keeping the real
|
|
391
|
+
// GPU texture the same hdrColor slot (composite writes in-place, tonemap
|
|
392
|
+
// reads from it).
|
|
393
|
+
const g = new RenderGraph();
|
|
394
|
+
g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
|
|
395
|
+
g.addResource('hdrComposited', { kind: 'texture', lifetime: 'transient' });
|
|
396
|
+
g.addResource('bloomBright', { kind: 'texture', lifetime: 'transient' });
|
|
397
|
+
g.addResource('bloomBlurH', { kind: 'texture', lifetime: 'transient' });
|
|
398
|
+
g.addResource('bloomBlurV', { kind: 'texture', lifetime: 'transient' });
|
|
399
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
400
|
+
g.addPass('bloom-bright', { reads: ['hdrColor'], writes: ['bloomBright'] });
|
|
401
|
+
g.addPass('bloom-blur-h', { reads: ['bloomBright'], writes: ['bloomBlurH'] });
|
|
402
|
+
g.addPass('bloom-blur-v', { reads: ['bloomBlurH'], writes: ['bloomBlurV'] });
|
|
403
|
+
g.addPass('bloom-composite', {
|
|
404
|
+
reads: ['hdrColor', 'bloomBlurV'],
|
|
405
|
+
writes: ['hdrComposited'],
|
|
406
|
+
});
|
|
407
|
+
g.addPass('tonemap', { reads: ['hdrComposited'], writes: [] });
|
|
408
|
+
|
|
409
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps() });
|
|
410
|
+
expect(r.ok).toBe(true);
|
|
411
|
+
if (!r.ok) return;
|
|
412
|
+
const order = r.value.passes.map((p) => p.name);
|
|
413
|
+
const brightIdx = order.indexOf('bloom-bright');
|
|
414
|
+
const blurHIdx = order.indexOf('bloom-blur-h');
|
|
415
|
+
const blurVIdx = order.indexOf('bloom-blur-v');
|
|
416
|
+
const compIdx = order.indexOf('bloom-composite');
|
|
417
|
+
const tmapIdx = order.indexOf('tonemap');
|
|
418
|
+
expect(brightIdx).toBeLessThan(blurHIdx);
|
|
419
|
+
expect(blurHIdx).toBeLessThan(blurVIdx);
|
|
420
|
+
expect(blurVIdx).toBeLessThan(compIdx);
|
|
421
|
+
expect(compIdx).toBeLessThan(tmapIdx);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
{
|
|
427
|
+
// --- from compile-fail-fast.test.ts ---
|
|
428
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
function mockCaps(
|
|
431
|
+
overrides: { compute?: boolean; storageBuffer?: boolean },
|
|
432
|
+
backendKind: 'webgpu' | 'wgpu-native' | 'wgpu-webgl2' | 'null' = 'webgpu',
|
|
433
|
+
) {
|
|
434
|
+
return {
|
|
435
|
+
backendKind,
|
|
436
|
+
compute: overrides.compute ?? true,
|
|
437
|
+
storageBuffer: overrides.storageBuffer ?? true,
|
|
438
|
+
storageTexture: false,
|
|
439
|
+
timestampQuery: false,
|
|
440
|
+
timestampPeriodNanoseconds: null,
|
|
441
|
+
indirectDrawing: false,
|
|
442
|
+
textureCompressionBc: false,
|
|
443
|
+
textureCompressionEtc2: false,
|
|
444
|
+
textureCompressionAstc: false,
|
|
445
|
+
multiDrawIndirect: false,
|
|
446
|
+
pushConstants: false,
|
|
447
|
+
textureBindingArray: false,
|
|
448
|
+
samplerAliasing: true,
|
|
449
|
+
firstInstanceIndirect: false,
|
|
450
|
+
rgba16floatRenderable: false,
|
|
451
|
+
rg11b10ufloatRenderable: false,
|
|
452
|
+
float32Filterable: false,
|
|
453
|
+
maxColorAttachments: 8,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── dangling-read ────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
describe('compile fail-fast: dangling-read', () => {
|
|
460
|
+
it('returns err with code dangling-read and detail { resourceKey, passName }', () => {
|
|
461
|
+
const g = new RenderGraph();
|
|
462
|
+
// orphanKey is a registered resource (so it is not unknown-resource), but
|
|
463
|
+
// no pass writes it -> dangling-read.
|
|
464
|
+
g.addResource('orphanKey', { kind: 'texture', lifetime: 'transient' });
|
|
465
|
+
g.addPass('passA', { reads: ['orphanKey'], writes: [] });
|
|
466
|
+
|
|
467
|
+
const r = g.compile({
|
|
468
|
+
backendKind: 'webgpu',
|
|
469
|
+
caps: mockCaps({}),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(r.ok).toBe(false);
|
|
473
|
+
if (!r.ok) {
|
|
474
|
+
const e = r.error as RenderGraphError;
|
|
475
|
+
expect(e.code).toBe('dangling-read');
|
|
476
|
+
const detail = e.detail as DanglingReadDetail;
|
|
477
|
+
expect(detail.resourceKey).toBe('orphanKey');
|
|
478
|
+
expect(detail.passName).toBe('passA');
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ── unknown-resource ─────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
describe('compile fail-fast: unknown-resource', () => {
|
|
486
|
+
it('returns err with code unknown-resource when a pass reads an unregistered key', () => {
|
|
487
|
+
const g = new RenderGraph();
|
|
488
|
+
// 'ghost' is never registered via addResource -> unknown-resource.
|
|
489
|
+
g.addPass('passA', { reads: ['ghost'], writes: [] });
|
|
490
|
+
|
|
491
|
+
const r = g.compile({
|
|
492
|
+
backendKind: 'webgpu',
|
|
493
|
+
caps: mockCaps({}),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(r.ok).toBe(false);
|
|
497
|
+
if (!r.ok) {
|
|
498
|
+
const e = r.error as RenderGraphError;
|
|
499
|
+
expect(e.code).toBe('unknown-resource');
|
|
500
|
+
const detail = e.detail as DanglingReadDetail;
|
|
501
|
+
expect(detail.resourceKey).toBe('ghost');
|
|
502
|
+
expect(detail.passName).toBe('passA');
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('returns err with code unknown-resource when a pass writes an unregistered key', () => {
|
|
507
|
+
const g = new RenderGraph();
|
|
508
|
+
g.addPass('writer', { reads: [], writes: ['unregisteredOut'] });
|
|
509
|
+
|
|
510
|
+
const r = g.compile({
|
|
511
|
+
backendKind: 'webgpu',
|
|
512
|
+
caps: mockCaps({}),
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(r.ok).toBe(false);
|
|
516
|
+
if (!r.ok) {
|
|
517
|
+
const e = r.error as RenderGraphError;
|
|
518
|
+
expect(e.code).toBe('unknown-resource');
|
|
519
|
+
const detail = e.detail as DanglingReadDetail;
|
|
520
|
+
expect(detail.resourceKey).toBe('unregisteredOut');
|
|
521
|
+
expect(detail.passName).toBe('writer');
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ── cap-missing (compute) ────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
describe('compile fail-fast: cap-missing', () => {
|
|
529
|
+
it('returns err with code cap-missing when compute pass on caps.compute=false backend', () => {
|
|
530
|
+
const g = new RenderGraph();
|
|
531
|
+
g.addPass('computePass', {
|
|
532
|
+
reads: [],
|
|
533
|
+
writes: ['outBuf'],
|
|
534
|
+
compute: true,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const r = g.compile({
|
|
538
|
+
backendKind: 'webgpu',
|
|
539
|
+
caps: mockCaps({ compute: false }),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(r.ok).toBe(false);
|
|
543
|
+
if (!r.ok) {
|
|
544
|
+
const e = r.error as RenderGraphError;
|
|
545
|
+
expect(e.code).toBe('cap-missing');
|
|
546
|
+
const detail = e.detail as CapMissingDetail;
|
|
547
|
+
expect(detail.cap).toBe('compute');
|
|
548
|
+
expect(detail.passName).toBe('computePass');
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('returns err with code cap-missing when storage buffer pass on caps.storageBuffer=false backend', () => {
|
|
553
|
+
const g = new RenderGraph();
|
|
554
|
+
g.addPass('storagePass', {
|
|
555
|
+
reads: [],
|
|
556
|
+
writes: ['buf'],
|
|
557
|
+
storageBuffer: true,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const r = g.compile({
|
|
561
|
+
backendKind: 'webgpu',
|
|
562
|
+
caps: mockCaps({ storageBuffer: false }),
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(r.ok).toBe(false);
|
|
566
|
+
if (!r.ok) {
|
|
567
|
+
const e = r.error as RenderGraphError;
|
|
568
|
+
expect(e.code).toBe('cap-missing');
|
|
569
|
+
const detail = e.detail as CapMissingDetail;
|
|
570
|
+
expect(detail.cap).toBe('storageBuffer');
|
|
571
|
+
expect(detail.passName).toBe('storagePass');
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe('declaration-order temporal validation', () => {
|
|
577
|
+
it('rejects a read whose writer appears only in the future', () => {
|
|
578
|
+
const g = new RenderGraph();
|
|
579
|
+
g.addResource('X', { kind: 'texture', lifetime: 'transient' });
|
|
580
|
+
g.addResource('Y', { kind: 'texture', lifetime: 'transient' });
|
|
581
|
+
g.addResource('Z', { kind: 'texture', lifetime: 'transient' });
|
|
582
|
+
// A reads Y before any prior writer; later declarations cannot initialize it retroactively.
|
|
583
|
+
g.addPass('A', { reads: ['Y'], writes: ['X'] });
|
|
584
|
+
g.addPass('B', { reads: ['X'], writes: ['Y'] });
|
|
585
|
+
g.addPass('C', { reads: [], writes: ['Z'] });
|
|
586
|
+
const r = g.compile({
|
|
587
|
+
backendKind: 'webgpu',
|
|
588
|
+
caps: mockCaps({}),
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
expect(r.ok).toBe(false);
|
|
592
|
+
if (!r.ok) {
|
|
593
|
+
const e = r.error as RenderGraphError;
|
|
594
|
+
expect(e.code).toBe('dangling-read');
|
|
595
|
+
expect(e.detail).toMatchObject({ resourceKey: 'Y', passName: 'A' });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ── duplicate-resource ───────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
describe('compile fail-fast: duplicate-resource', () => {
|
|
603
|
+
it('returns err with code duplicate-resource and detail { resourceKey }', () => {
|
|
604
|
+
const g = new RenderGraph();
|
|
605
|
+
g.addResource('dupKey', { kind: 'texture', lifetime: 'transient' });
|
|
606
|
+
|
|
607
|
+
const r = g.addResource('dupKey', {
|
|
608
|
+
kind: 'texture',
|
|
609
|
+
lifetime: 'persistent',
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
expect(r.ok).toBe(false);
|
|
613
|
+
if (!r.ok) {
|
|
614
|
+
const e = r.error as RenderGraphError;
|
|
615
|
+
expect(e.code).toBe('duplicate-resource');
|
|
616
|
+
const detail = e.detail as DuplicateResourceDetail;
|
|
617
|
+
expect(detail.resourceKey).toBe('dupKey');
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
{
|
|
624
|
+
// --- from errors.test.ts ---
|
|
625
|
+
// ── Closed union exhaustiveness ──────────────────────────────────
|
|
626
|
+
|
|
627
|
+
describe('RenderGraphErrorCode exhaustive switch', () => {
|
|
628
|
+
it('lists the complete closed error union', () => {
|
|
629
|
+
const allCodes: RenderGraphErrorCode[] = [
|
|
630
|
+
'dangling-read',
|
|
631
|
+
'cap-missing',
|
|
632
|
+
'duplicate-resource',
|
|
633
|
+
'alias-source-missing',
|
|
634
|
+
'unknown-resource',
|
|
635
|
+
'resource-alloc-failed',
|
|
636
|
+
'invalid-format',
|
|
637
|
+
'invalid-color-domain',
|
|
638
|
+
'missing-color-domain',
|
|
639
|
+
'color-domain-mismatch',
|
|
640
|
+
'observation-absent',
|
|
641
|
+
'observation-invalid-format',
|
|
642
|
+
'observation-invalid-size',
|
|
643
|
+
'observation-missing-copy-src',
|
|
644
|
+
'observation-stale',
|
|
645
|
+
'observation-retired',
|
|
646
|
+
'duplicate-pass-name',
|
|
647
|
+
'duplicate-resource-label',
|
|
648
|
+
'builder-sealed',
|
|
649
|
+
'foreign-resource-handle',
|
|
650
|
+
'resource-not-declared-by-pass',
|
|
651
|
+
'uninitialized-read',
|
|
652
|
+
'access-conflict',
|
|
653
|
+
'capability-missing',
|
|
654
|
+
'resource-descriptor-invalid',
|
|
655
|
+
'import-usage-mismatch',
|
|
656
|
+
'resource-allocation-failed',
|
|
657
|
+
'resource-resolution-failed',
|
|
658
|
+
'pass-encode-failed',
|
|
659
|
+
'compiled-graph-retired',
|
|
660
|
+
'resource-retire-failed',
|
|
661
|
+
];
|
|
662
|
+
expect(allCodes).toHaveLength(31);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('allows exhaustive switch without default covering every member', () => {
|
|
666
|
+
function handle(code: RenderGraphErrorCode): string {
|
|
667
|
+
switch (code) {
|
|
668
|
+
case 'dangling-read':
|
|
669
|
+
return 'dangling';
|
|
670
|
+
case 'cap-missing':
|
|
671
|
+
return 'cap';
|
|
672
|
+
case 'duplicate-resource':
|
|
673
|
+
return 'dup';
|
|
674
|
+
case 'alias-source-missing':
|
|
675
|
+
return 'alias-source-missing';
|
|
676
|
+
case 'unknown-resource':
|
|
677
|
+
return 'unknown';
|
|
678
|
+
case 'resource-alloc-failed':
|
|
679
|
+
return 'alloc';
|
|
680
|
+
case 'invalid-format':
|
|
681
|
+
return 'format';
|
|
682
|
+
case 'invalid-color-domain':
|
|
683
|
+
return 'invalid-domain';
|
|
684
|
+
case 'missing-color-domain':
|
|
685
|
+
return 'missing-domain';
|
|
686
|
+
case 'color-domain-mismatch':
|
|
687
|
+
return 'domain-mismatch';
|
|
688
|
+
case 'observation-absent':
|
|
689
|
+
return 'observation-absent';
|
|
690
|
+
case 'observation-invalid-format':
|
|
691
|
+
return 'observation-invalid-format';
|
|
692
|
+
case 'observation-invalid-size':
|
|
693
|
+
return 'observation-invalid-size';
|
|
694
|
+
case 'observation-missing-copy-src':
|
|
695
|
+
return 'observation-missing-copy-src';
|
|
696
|
+
case 'observation-stale':
|
|
697
|
+
return 'observation-stale';
|
|
698
|
+
case 'observation-retired':
|
|
699
|
+
return 'observation-retired';
|
|
700
|
+
case 'duplicate-pass-name':
|
|
701
|
+
case 'duplicate-resource-label':
|
|
702
|
+
case 'builder-sealed':
|
|
703
|
+
case 'foreign-resource-handle':
|
|
704
|
+
case 'resource-not-declared-by-pass':
|
|
705
|
+
case 'uninitialized-read':
|
|
706
|
+
case 'access-conflict':
|
|
707
|
+
case 'capability-missing':
|
|
708
|
+
case 'resource-descriptor-invalid':
|
|
709
|
+
case 'import-usage-mismatch':
|
|
710
|
+
case 'resource-allocation-failed':
|
|
711
|
+
case 'resource-resolution-failed':
|
|
712
|
+
case 'pass-encode-failed':
|
|
713
|
+
case 'compiled-graph-retired':
|
|
714
|
+
case 'resource-retire-failed':
|
|
715
|
+
return code;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
expect(handle('dangling-read')).toBe('dangling');
|
|
720
|
+
expect(handle('cap-missing')).toBe('cap');
|
|
721
|
+
expect(handle('duplicate-resource')).toBe('dup');
|
|
722
|
+
expect(handle('alias-source-missing')).toBe('alias-source-missing');
|
|
723
|
+
expect(handle('unknown-resource')).toBe('unknown');
|
|
724
|
+
expect(handle('resource-alloc-failed')).toBe('alloc');
|
|
725
|
+
expect(handle('invalid-format')).toBe('format');
|
|
726
|
+
expect(handle('invalid-color-domain')).toBe('invalid-domain');
|
|
727
|
+
expect(handle('missing-color-domain')).toBe('missing-domain');
|
|
728
|
+
expect(handle('color-domain-mismatch')).toBe('domain-mismatch');
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// ── M1 new error detail variants ──────────────────────────────────
|
|
733
|
+
|
|
734
|
+
describe('RenderGraphError new detail variants (resource-alloc-failed + invalid-format)', () => {
|
|
735
|
+
it('resource-alloc-failed error has code, expected, hint, and detail with { resourceKey, passName?, rhiCode? }', () => {
|
|
736
|
+
const e = new RenderGraphError({
|
|
737
|
+
code: 'resource-alloc-failed',
|
|
738
|
+
expected: 'device.createTexture must succeed',
|
|
739
|
+
hint: 'check the device state and recreate if lost',
|
|
740
|
+
detail: { resourceKey: 'hdrColor', passName: 'main', rhiCode: 'limit-exceeded' },
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
expect(e.code).toBe('resource-alloc-failed');
|
|
744
|
+
expect(e.expected).toBe('device.createTexture must succeed');
|
|
745
|
+
expect(e.hint).toBe('check the device state and recreate if lost');
|
|
746
|
+
const d = e.detail as { resourceKey: string; passName?: string; rhiCode?: string };
|
|
747
|
+
expect(d.resourceKey).toBe('hdrColor');
|
|
748
|
+
expect(d.passName).toBe('main');
|
|
749
|
+
expect(d.rhiCode).toBe('limit-exceeded');
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it('invalid-format error has code, expected, hint, and detail with { resourceKey, format, expected: string[] }', () => {
|
|
753
|
+
const e = new RenderGraphError({
|
|
754
|
+
code: 'invalid-format',
|
|
755
|
+
expected: "format must be a valid GPU texture format, got 'rgb9e5ufloat'",
|
|
756
|
+
hint: 'use a standard format like rgba16float or bgra8unorm',
|
|
757
|
+
detail: {
|
|
758
|
+
resourceKey: 'myTarget',
|
|
759
|
+
format: 'rgb9e5ufloat',
|
|
760
|
+
expected: ['rgba16float', 'rgba8unorm', 'bgra8unorm'],
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
expect(e.code).toBe('invalid-format');
|
|
765
|
+
expect(e.expected).toContain('rgb9e5ufloat');
|
|
766
|
+
const d = e.detail as { resourceKey: string; format: string; expected: readonly string[] };
|
|
767
|
+
expect(d.resourceKey).toBe('myTarget');
|
|
768
|
+
expect(d.format).toBe('rgb9e5ufloat');
|
|
769
|
+
expect(d.expected).toContain('rgba16float');
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ── RenderGraphError four fields ─────────────────────────────────
|
|
774
|
+
|
|
775
|
+
describe('RenderGraphError structured error', () => {
|
|
776
|
+
it('has all four fields: .code, .expected, .hint, .detail', () => {
|
|
777
|
+
const e = new RenderGraphError({
|
|
778
|
+
code: 'dangling-read',
|
|
779
|
+
expected: 'key registered',
|
|
780
|
+
hint: 'add a write',
|
|
781
|
+
detail: { resourceKey: 'K', passName: 'P' },
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
expect(e.code).toBe('dangling-read');
|
|
785
|
+
expect(e.expected).toBe('key registered');
|
|
786
|
+
expect(e.hint).toBe('add a write');
|
|
787
|
+
expect(e.detail).toEqual({ resourceKey: 'K', passName: 'P' });
|
|
788
|
+
expect(e).toBeInstanceOf(Error);
|
|
789
|
+
expect(e.name).toBe('RenderGraphError');
|
|
790
|
+
expect(e.message).toContain('[RenderGraphError dangling-read]');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('allows .detail to be undefined', () => {
|
|
794
|
+
const e = new RenderGraphError({
|
|
795
|
+
code: 'dangling-read',
|
|
796
|
+
expected: 'x',
|
|
797
|
+
hint: 'y',
|
|
798
|
+
});
|
|
799
|
+
expect(e.detail).toBeUndefined();
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// ── Detail narrowing per code ────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
describe('RenderGraphErrorDetail narrowing', () => {
|
|
806
|
+
it('dangling-read detail has resourceKey and passName', () => {
|
|
807
|
+
const e = new RenderGraphError({
|
|
808
|
+
code: 'dangling-read',
|
|
809
|
+
expected: '',
|
|
810
|
+
hint: '',
|
|
811
|
+
detail: { resourceKey: 'orphan', passName: 'passA' },
|
|
812
|
+
});
|
|
813
|
+
const d = e.detail as { resourceKey: string; passName: string };
|
|
814
|
+
expect(d.resourceKey).toBe('orphan');
|
|
815
|
+
expect(d.passName).toBe('passA');
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it('cap-missing detail has cap and passName', () => {
|
|
819
|
+
const e = new RenderGraphError({
|
|
820
|
+
code: 'cap-missing',
|
|
821
|
+
expected: '',
|
|
822
|
+
hint: '',
|
|
823
|
+
detail: { cap: 'compute', passName: 'computePass' },
|
|
824
|
+
});
|
|
825
|
+
const d = e.detail as { cap: string; passName: string };
|
|
826
|
+
expect(d.cap).toBe('compute');
|
|
827
|
+
expect(d.passName).toBe('computePass');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('duplicate-resource detail has resourceKey', () => {
|
|
831
|
+
const e = new RenderGraphError({
|
|
832
|
+
code: 'duplicate-resource',
|
|
833
|
+
expected: '',
|
|
834
|
+
hint: '',
|
|
835
|
+
detail: { resourceKey: 'dupKey' },
|
|
836
|
+
});
|
|
837
|
+
const d = e.detail as { resourceKey: string };
|
|
838
|
+
expect(d.resourceKey).toBe('dupKey');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('alias-source-missing detail has aliasKey and sourceKey', () => {
|
|
842
|
+
const e = new RenderGraphError({
|
|
843
|
+
code: 'alias-source-missing',
|
|
844
|
+
expected: '',
|
|
845
|
+
hint: '',
|
|
846
|
+
detail: { aliasKey: 'alias', sourceKey: 'source' },
|
|
847
|
+
});
|
|
848
|
+
const d = e.detail as { aliasKey: string; sourceKey: string };
|
|
849
|
+
expect(d.aliasKey).toBe('alias');
|
|
850
|
+
expect(d.sourceKey).toBe('source');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('unknown-resource detail has resourceKey and passName', () => {
|
|
854
|
+
const e = new RenderGraphError({
|
|
855
|
+
code: 'unknown-resource',
|
|
856
|
+
expected: '',
|
|
857
|
+
hint: '',
|
|
858
|
+
detail: { resourceKey: 'orphan', passName: 'passA' },
|
|
859
|
+
});
|
|
860
|
+
const d = e.detail as { resourceKey: string; passName: string };
|
|
861
|
+
expect(d.resourceKey).toBe('orphan');
|
|
862
|
+
expect(d.passName).toBe('passA');
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// ── Result ok/err factories ──────────────────────────────────────
|
|
867
|
+
|
|
868
|
+
describe('Result<T, E> ok / err factories', () => {
|
|
869
|
+
it('ok() produces ResultOk with .ok=true and .value', () => {
|
|
870
|
+
const r = ok(42);
|
|
871
|
+
expect(r.ok).toBe(true);
|
|
872
|
+
if (r.ok) {
|
|
873
|
+
expect(r.value).toBe(42);
|
|
874
|
+
expect(r.unwrap()).toBe(42);
|
|
875
|
+
expect(r.unwrapOr(0)).toBe(42);
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('err() produces ResultErr with .ok=false and .error', () => {
|
|
880
|
+
const e = new RenderGraphError({
|
|
881
|
+
code: 'dangling-read',
|
|
882
|
+
expected: 'x',
|
|
883
|
+
hint: 'y',
|
|
884
|
+
});
|
|
885
|
+
const r = err(e);
|
|
886
|
+
expect(r.ok).toBe(false);
|
|
887
|
+
if (!r.ok) {
|
|
888
|
+
expect(r.error).toBe(e);
|
|
889
|
+
expect(r.unwrapOr(42)).toBe(42);
|
|
890
|
+
expect(() => r.unwrap()).toThrow(e);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it('Result type narrows correctly via .ok discriminant', () => {
|
|
895
|
+
const r1 = ok('hello');
|
|
896
|
+
const r2 = err(
|
|
897
|
+
new RenderGraphError({
|
|
898
|
+
code: 'dangling-read',
|
|
899
|
+
expected: '',
|
|
900
|
+
hint: '',
|
|
901
|
+
}),
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
if (r1.ok) {
|
|
905
|
+
expect(typeof r1.value).toBe('string');
|
|
906
|
+
}
|
|
907
|
+
if (!r2.ok) {
|
|
908
|
+
expect(r2.error).toBeInstanceOf(RenderGraphError);
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
{
|
|
915
|
+
// --- from list-passes-resources.test.ts ---
|
|
916
|
+
describe('listPasses / listResources query interface (D-5)', () => {
|
|
917
|
+
it('listPasses returns passes in declaration order', () => {
|
|
918
|
+
const g = new RenderGraph();
|
|
919
|
+
g.addPass('shadow', { reads: [], writes: ['depth'] });
|
|
920
|
+
g.addPass('main', { reads: ['depth'], writes: ['color'] });
|
|
921
|
+
g.addPass('tonemap', { reads: ['color'], writes: ['swapchain'] });
|
|
922
|
+
|
|
923
|
+
const passes = g.listPasses();
|
|
924
|
+
expect(passes).toHaveLength(3);
|
|
925
|
+
const [p0, p1, p2] = passes;
|
|
926
|
+
expect(p0?.name).toBe('shadow');
|
|
927
|
+
expect(p0?.reads).toEqual([]);
|
|
928
|
+
expect(p0?.writes).toEqual(['depth']);
|
|
929
|
+
expect(p1?.name).toBe('main');
|
|
930
|
+
expect(p1?.reads).toEqual(['depth']);
|
|
931
|
+
expect(p1?.writes).toEqual(['color']);
|
|
932
|
+
expect(p2?.name).toBe('tonemap');
|
|
933
|
+
expect(p2?.reads).toEqual(['color']);
|
|
934
|
+
expect(p2?.writes).toEqual(['swapchain']);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('listResources returns registered resources with key/kind/lifetime', () => {
|
|
938
|
+
const g = new RenderGraph();
|
|
939
|
+
g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
|
|
940
|
+
g.addResource('color', { kind: 'texture', lifetime: 'transient' });
|
|
941
|
+
g.addResource('lightBuf', {
|
|
942
|
+
kind: 'buffer',
|
|
943
|
+
lifetime: 'persistent',
|
|
944
|
+
bufferRole: 'uniform',
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
const resources = g.listResources();
|
|
948
|
+
expect(resources).toHaveLength(3);
|
|
949
|
+
|
|
950
|
+
const depth = resources.find((r) => r.key === 'depth');
|
|
951
|
+
expect(depth).toBeDefined();
|
|
952
|
+
expect(depth?.kind).toBe('texture');
|
|
953
|
+
expect(depth?.lifetime).toBe('persistent');
|
|
954
|
+
|
|
955
|
+
const color = resources.find((r) => r.key === 'color');
|
|
956
|
+
expect(color).toBeDefined();
|
|
957
|
+
expect(color?.kind).toBe('texture');
|
|
958
|
+
expect(color?.lifetime).toBe('transient');
|
|
959
|
+
|
|
960
|
+
const buf = resources.find((r) => r.key === 'lightBuf');
|
|
961
|
+
expect(buf).toBeDefined();
|
|
962
|
+
expect(buf?.kind).toBe('buffer');
|
|
963
|
+
expect(buf?.lifetime).toBe('persistent');
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('listPasses returns readonly (cannot push)', () => {
|
|
967
|
+
const g = new RenderGraph();
|
|
968
|
+
g.addPass('A', { reads: [], writes: ['X'] });
|
|
969
|
+
const passes = g.listPasses();
|
|
970
|
+
expect(Array.isArray(passes)).toBe(true);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('dangling writes appear in listPasses silently (D-5: ignore)', () => {
|
|
974
|
+
const g = new RenderGraph();
|
|
975
|
+
g.addPass('producer', { reads: [], writes: ['orphanOutput'] });
|
|
976
|
+
const passes = g.listPasses();
|
|
977
|
+
expect(passes).toHaveLength(1);
|
|
978
|
+
expect(passes[0]?.writes).toContain('orphanOutput');
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// ── w15: per-frame 4-pass graph enumeration ──────────────────────
|
|
983
|
+
//
|
|
984
|
+
// feat-20260529-rendergraph-pass-abstraction M4 / w15. Asserts listPasses /
|
|
985
|
+
// listResources over the EXACT shape the runtime declares in
|
|
986
|
+
// render-system-record.ts so the AI-user discoverability promise (D-5) is
|
|
987
|
+
// pinned to the real per-frame graph: 4 passes (shadow/main/tonemap/fxaa) in
|
|
988
|
+
// declaration order with the migrated reads/writes, and 4 transient/
|
|
989
|
+
// persistent texture resources. IBL convolution + shadow probe (OOS-3/OOS-4)
|
|
990
|
+
// are NOT graph passes -> not enumerated.
|
|
991
|
+
|
|
992
|
+
function buildPerFrameGraph() {
|
|
993
|
+
const g = new RenderGraph();
|
|
994
|
+
g.addResource('depth', { kind: 'texture', lifetime: 'persistent' });
|
|
995
|
+
g.addResource('shadowDepth', { kind: 'texture', lifetime: 'persistent' });
|
|
996
|
+
g.addResource('fxaaIntermediate', { kind: 'texture', lifetime: 'transient' });
|
|
997
|
+
g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
|
|
998
|
+
g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
|
|
999
|
+
g.addPass('main', { reads: ['shadowDepth'], writes: ['hdrColor', 'depth'] });
|
|
1000
|
+
g.addPass('tonemap', { reads: ['hdrColor'], writes: [] });
|
|
1001
|
+
g.addPass('fxaa', { reads: [], writes: ['fxaaIntermediate'] });
|
|
1002
|
+
return g;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
describe('w15: per-frame 4-pass graph enumeration', () => {
|
|
1006
|
+
it('listPasses returns the 4 per-frame passes in declaration order', () => {
|
|
1007
|
+
const passes = buildPerFrameGraph().listPasses();
|
|
1008
|
+
expect(passes.map((p) => p.name)).toEqual(['shadow', 'main', 'tonemap', 'fxaa']);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('each pass reads/writes match the runtime declaration', () => {
|
|
1012
|
+
const passes = buildPerFrameGraph().listPasses();
|
|
1013
|
+
const byName = new Map(passes.map((p) => [p.name, p]));
|
|
1014
|
+
|
|
1015
|
+
expect(byName.get('shadow')?.reads).toEqual([]);
|
|
1016
|
+
expect(byName.get('shadow')?.writes).toEqual(['shadowDepth']);
|
|
1017
|
+
|
|
1018
|
+
expect(byName.get('main')?.reads).toEqual(['shadowDepth']);
|
|
1019
|
+
expect(byName.get('main')?.writes).toEqual(['hdrColor', 'depth']);
|
|
1020
|
+
|
|
1021
|
+
expect(byName.get('tonemap')?.reads).toEqual(['hdrColor']);
|
|
1022
|
+
expect(byName.get('tonemap')?.writes).toEqual([]);
|
|
1023
|
+
|
|
1024
|
+
// FXAA's intermediate is pass-internal (copy target), declared under
|
|
1025
|
+
// writes so compile() raises no dangling-read (D-5 dangling-write OK).
|
|
1026
|
+
expect(byName.get('fxaa')?.reads).toEqual([]);
|
|
1027
|
+
expect(byName.get('fxaa')?.writes).toEqual(['fxaaIntermediate']);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it('listResources enumerates the 4 texture resources with key/kind/lifetime', () => {
|
|
1031
|
+
const resources = buildPerFrameGraph().listResources();
|
|
1032
|
+
expect(resources).toHaveLength(4);
|
|
1033
|
+
const byKey = new Map(resources.map((r) => [r.key, r]));
|
|
1034
|
+
|
|
1035
|
+
for (const key of ['depth', 'shadowDepth', 'fxaaIntermediate', 'hdrColor']) {
|
|
1036
|
+
expect(byKey.get(key)?.kind).toBe('texture');
|
|
1037
|
+
}
|
|
1038
|
+
// Lifetime split (D-4): depth + shadowDepth persistent (size/mapSize
|
|
1039
|
+
// keyed, survive across frames); fxaaIntermediate + hdrColor transient
|
|
1040
|
+
// (conditional on camera.antialias / camera.tonemap, resize-invalidated).
|
|
1041
|
+
expect(byKey.get('depth')?.lifetime).toBe('persistent');
|
|
1042
|
+
expect(byKey.get('shadowDepth')?.lifetime).toBe('persistent');
|
|
1043
|
+
expect(byKey.get('fxaaIntermediate')?.lifetime).toBe('transient');
|
|
1044
|
+
expect(byKey.get('hdrColor')?.lifetime).toBe('transient');
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('listResources includes the dangling-write fxaaIntermediate (D-5: not pruned)', () => {
|
|
1048
|
+
// fxaaIntermediate is written by 'fxaa' but read by no pass; D-5 keeps it
|
|
1049
|
+
// enumerated (dangling write is a legitimate "external consumer" output).
|
|
1050
|
+
const resources = buildPerFrameGraph().listResources();
|
|
1051
|
+
expect(resources.some((r) => r.key === 'fxaaIntermediate')).toBe(true);
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
{
|
|
1057
|
+
// --- from resource-allocation.test.ts ---
|
|
1058
|
+
// ── AC-01(a): compile with device calls createTexture ──────────────
|
|
1059
|
+
|
|
1060
|
+
describe('addColorTarget compile allocation', () => {
|
|
1061
|
+
it.todo(
|
|
1062
|
+
'addColorTarget(name, desc) returns a handle that resolve() gives a TextureView after compile',
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
it.todo(
|
|
1066
|
+
'compile({device}) calls device.createTexture at least once when the graph has color targets',
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
it.todo(
|
|
1070
|
+
'pass execute closure receives a context where resolve(name) returns a non-null TextureView',
|
|
1071
|
+
);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// ── AC-02(a): transient resource pooling by descriptor ─────────────
|
|
1075
|
+
|
|
1076
|
+
describe('transient resource pooling', () => {
|
|
1077
|
+
it.todo(
|
|
1078
|
+
'transient resources with identical descriptor share the same physical texture (pool hit)',
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
it.todo('transient resource with changed size triggers drift rebuild (new physical texture)');
|
|
1082
|
+
|
|
1083
|
+
it.todo('transient resource with changed format triggers drift rebuild');
|
|
1084
|
+
|
|
1085
|
+
it.todo(
|
|
1086
|
+
'sampleCount in pool key isolates MSAA multi-sampled targets from single-sampled targets',
|
|
1087
|
+
);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// ── AC-02(b): persistent resource behaviour ───────────────────────
|
|
1091
|
+
|
|
1092
|
+
describe('persistent resource lifecycle', () => {
|
|
1093
|
+
it('reuses persistent targets until size drift, then replaces only the drifted target once', async () => {
|
|
1094
|
+
const created: Array<{
|
|
1095
|
+
readonly texture: object;
|
|
1096
|
+
readonly width: number;
|
|
1097
|
+
readonly height: number;
|
|
1098
|
+
}> = [];
|
|
1099
|
+
const destroyed: object[] = [];
|
|
1100
|
+
const device = {
|
|
1101
|
+
createTexture: (descriptor: {
|
|
1102
|
+
readonly size: { readonly width: number; readonly height: number };
|
|
1103
|
+
}) => {
|
|
1104
|
+
const texture = { id: `texture-${created.length + 1}` };
|
|
1105
|
+
created.push({ texture, width: descriptor.size.width, height: descriptor.size.height });
|
|
1106
|
+
return { ok: true as const, value: texture };
|
|
1107
|
+
},
|
|
1108
|
+
createTextureView: (texture: object) => ({
|
|
1109
|
+
ok: true as const,
|
|
1110
|
+
value: { id: `view-${created.length}`, texture },
|
|
1111
|
+
}),
|
|
1112
|
+
destroyTexture: (texture: object) => {
|
|
1113
|
+
destroyed.push(texture);
|
|
1114
|
+
return { ok: true as const, value: undefined };
|
|
1115
|
+
},
|
|
1116
|
+
queue: { onSubmittedWorkDone: () => Promise.resolve(undefined) },
|
|
1117
|
+
};
|
|
1118
|
+
const caps = { backendKind: 'webgpu', compute: true, storageBuffer: true } as never;
|
|
1119
|
+
const graph = new RenderGraph();
|
|
1120
|
+
graph.addColorTarget('persistent', {
|
|
1121
|
+
format: 'rgba16float',
|
|
1122
|
+
size: 'swapchain',
|
|
1123
|
+
lifetime: 'persistent',
|
|
1124
|
+
usage: 0,
|
|
1125
|
+
});
|
|
1126
|
+
graph.addColorTarget('sibling', {
|
|
1127
|
+
format: 'rgba16float',
|
|
1128
|
+
size: { w: 16, h: 16 },
|
|
1129
|
+
lifetime: 'persistent',
|
|
1130
|
+
usage: 0,
|
|
1131
|
+
});
|
|
1132
|
+
graph.addPass('main', { reads: [], writes: ['persistent', 'sibling'] });
|
|
1133
|
+
|
|
1134
|
+
graph.setSwapChainSize(64, 32);
|
|
1135
|
+
expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
|
|
1136
|
+
const persistentTexture = graph.getColorTargetTexture('persistent');
|
|
1137
|
+
const persistentView = graph.getColorTargetView('persistent');
|
|
1138
|
+
const siblingTexture = graph.getColorTargetTexture('sibling');
|
|
1139
|
+
expect(
|
|
1140
|
+
graph.listResources().find((resource) => resource.key === 'persistent')?.lifetime,
|
|
1141
|
+
).toBe('persistent');
|
|
1142
|
+
expect(created.map(({ width, height }) => [width, height])).toEqual([
|
|
1143
|
+
[64, 32],
|
|
1144
|
+
[16, 16],
|
|
1145
|
+
]);
|
|
1146
|
+
|
|
1147
|
+
graph.setSwapChainSize(64, 32);
|
|
1148
|
+
expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
|
|
1149
|
+
expect(graph.getColorTargetTexture('persistent')).toBe(persistentTexture);
|
|
1150
|
+
expect(graph.getColorTargetView('persistent')).toBe(persistentView);
|
|
1151
|
+
expect(graph.getColorTargetTexture('sibling')).toBe(siblingTexture);
|
|
1152
|
+
expect(created).toHaveLength(2);
|
|
1153
|
+
|
|
1154
|
+
graph.setSwapChainSize(128, 32);
|
|
1155
|
+
expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
|
|
1156
|
+
const replacementTexture = graph.getColorTargetTexture('persistent');
|
|
1157
|
+
const replacementView = graph.getColorTargetView('persistent');
|
|
1158
|
+
expect(replacementTexture).not.toBe(persistentTexture);
|
|
1159
|
+
expect(replacementView).not.toBe(persistentView);
|
|
1160
|
+
expect(graph.getColorTargetTexture('sibling')).toBe(siblingTexture);
|
|
1161
|
+
expect(graph.getColorTargetDescriptor('persistent')?.size).toEqual({
|
|
1162
|
+
width: 128,
|
|
1163
|
+
height: 32,
|
|
1164
|
+
});
|
|
1165
|
+
expect(created.map(({ width, height }) => [width, height])).toEqual([
|
|
1166
|
+
[64, 32],
|
|
1167
|
+
[16, 16],
|
|
1168
|
+
[128, 32],
|
|
1169
|
+
]);
|
|
1170
|
+
expect(destroyed).toEqual([]);
|
|
1171
|
+
|
|
1172
|
+
await graph.reclaimRetiredTransients();
|
|
1173
|
+
expect(destroyed).toEqual([persistentTexture]);
|
|
1174
|
+
|
|
1175
|
+
graph.setSwapChainSize(128, 32);
|
|
1176
|
+
expect(graph.compile({ backendKind: 'webgpu', caps, device: device as never }).ok).toBe(true);
|
|
1177
|
+
expect(graph.getColorTargetTexture('persistent')).toBe(replacementTexture);
|
|
1178
|
+
expect(graph.getColorTargetView('persistent')).toBe(replacementView);
|
|
1179
|
+
expect(created).toHaveLength(3);
|
|
1180
|
+
|
|
1181
|
+
graph.drain();
|
|
1182
|
+
graph.drain();
|
|
1183
|
+
expect(destroyed).toEqual([persistentTexture, replacementTexture, siblingTexture]);
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// ── AC-02(c): alias folding (D-2 / KB-1) ─────────────────────────
|
|
1188
|
+
|
|
1189
|
+
describe('alias folding (hdrComposited -> physical texture)', () => {
|
|
1190
|
+
it.todo('alias-folded logical resources resolve to the same physical texture');
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// ── compile fail-fast with device errors ───────────────────────────
|
|
1194
|
+
|
|
1195
|
+
describe('compile fail-fast with device errors (w6)', () => {
|
|
1196
|
+
it('refuses allocation atomically and preserves the active graph for clean retry', () => {
|
|
1197
|
+
const created: object[] = [];
|
|
1198
|
+
const destroyed: object[] = [];
|
|
1199
|
+
let createCalls = 0;
|
|
1200
|
+
let failOnCreateCall: number | undefined;
|
|
1201
|
+
const device = {
|
|
1202
|
+
createTexture: () => {
|
|
1203
|
+
createCalls += 1;
|
|
1204
|
+
if (createCalls === failOnCreateCall) {
|
|
1205
|
+
return { ok: false as const, error: { code: 'oom' } };
|
|
1206
|
+
}
|
|
1207
|
+
const texture = { id: `texture-${createCalls}` };
|
|
1208
|
+
created.push(texture);
|
|
1209
|
+
return { ok: true as const, value: texture };
|
|
1210
|
+
},
|
|
1211
|
+
createTextureView: (texture: object) => ({
|
|
1212
|
+
ok: true as const,
|
|
1213
|
+
value: { texture },
|
|
1214
|
+
}),
|
|
1215
|
+
destroyTexture: (texture: object) => {
|
|
1216
|
+
destroyed.push(texture);
|
|
1217
|
+
return { ok: true as const, value: undefined };
|
|
1218
|
+
},
|
|
1219
|
+
queue: { onSubmittedWorkDone: () => Promise.resolve(undefined) },
|
|
1220
|
+
};
|
|
1221
|
+
const caps = {
|
|
1222
|
+
backendKind: 'webgpu',
|
|
1223
|
+
compute: true,
|
|
1224
|
+
storageBuffer: true,
|
|
1225
|
+
} as never;
|
|
1226
|
+
|
|
1227
|
+
const graph = new RenderGraph();
|
|
1228
|
+
graph.addColorTarget('healthy', {
|
|
1229
|
+
format: 'rgba16float',
|
|
1230
|
+
size: { w: 32, h: 32 },
|
|
1231
|
+
usage: 0,
|
|
1232
|
+
});
|
|
1233
|
+
graph.addPass('healthy-pass', { reads: [], writes: ['healthy'] });
|
|
1234
|
+
|
|
1235
|
+
const initial = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
|
|
1236
|
+
expect(initial.ok).toBe(true);
|
|
1237
|
+
const healthyTexture = graph.getColorTargetTexture('healthy');
|
|
1238
|
+
const healthyView = graph.getColorTargetView('healthy');
|
|
1239
|
+
expect(healthyTexture).toBeDefined();
|
|
1240
|
+
expect(healthyView).toBeDefined();
|
|
1241
|
+
|
|
1242
|
+
graph.addColorTarget('new-target', {
|
|
1243
|
+
format: 'rgba16float',
|
|
1244
|
+
size: { w: 32, h: 32 },
|
|
1245
|
+
usage: 0,
|
|
1246
|
+
});
|
|
1247
|
+
graph.addColorTarget('faulty-target', {
|
|
1248
|
+
format: 'rgba16float',
|
|
1249
|
+
size: { w: 32, h: 32 },
|
|
1250
|
+
usage: 0,
|
|
1251
|
+
});
|
|
1252
|
+
graph.addPass('new-pass', { reads: [], writes: ['new-target'] });
|
|
1253
|
+
graph.addPass('faulty-pass', { reads: [], writes: ['faulty-target'] });
|
|
1254
|
+
failOnCreateCall = createCalls + 2;
|
|
1255
|
+
|
|
1256
|
+
const refused = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
|
|
1257
|
+
expect(refused.ok).toBe(false);
|
|
1258
|
+
if (refused.ok) return;
|
|
1259
|
+
expect(refused.error.code).toBe('resource-alloc-failed');
|
|
1260
|
+
expect(refused.error.detail).toEqual({
|
|
1261
|
+
resourceKey: 'faulty-target',
|
|
1262
|
+
rhiCode: 'oom',
|
|
1263
|
+
});
|
|
1264
|
+
expect(destroyed).toEqual([created[1]]);
|
|
1265
|
+
expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
|
|
1266
|
+
expect(graph.getColorTargetView('healthy')).toBe(healthyView);
|
|
1267
|
+
expect(graph.getColorTargetTexture('new-target')).toBeUndefined();
|
|
1268
|
+
expect(graph.getColorTargetTexture('faulty-target')).toBeUndefined();
|
|
1269
|
+
|
|
1270
|
+
failOnCreateCall = undefined;
|
|
1271
|
+
const recovered = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
|
|
1272
|
+
expect(recovered.ok).toBe(true);
|
|
1273
|
+
expect(graph.getColorTargetTexture('new-target')).not.toBe(created[1]);
|
|
1274
|
+
expect(graph.getColorTargetTexture('faulty-target')).toBeDefined();
|
|
1275
|
+
|
|
1276
|
+
graph.drain();
|
|
1277
|
+
graph.drain();
|
|
1278
|
+
expect(new Set(destroyed)).toEqual(new Set(created));
|
|
1279
|
+
expect(destroyed).toHaveLength(created.length);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it('returns invalid-format before allocation and preserves the active graph', () => {
|
|
1283
|
+
let createTextureCalls = 0;
|
|
1284
|
+
let createTextureViewCalls = 0;
|
|
1285
|
+
const device = {
|
|
1286
|
+
createTexture: () => {
|
|
1287
|
+
createTextureCalls += 1;
|
|
1288
|
+
return { ok: true as const, value: { id: `texture-${createTextureCalls}` } };
|
|
1289
|
+
},
|
|
1290
|
+
createTextureView: () => {
|
|
1291
|
+
createTextureViewCalls += 1;
|
|
1292
|
+
return { ok: true as const, value: { id: `view-${createTextureViewCalls}` } };
|
|
1293
|
+
},
|
|
1294
|
+
destroyTexture: () => ({ ok: true as const, value: undefined }),
|
|
1295
|
+
queue: { onSubmittedWorkDone: () => Promise.resolve(undefined) },
|
|
1296
|
+
};
|
|
1297
|
+
const caps = {
|
|
1298
|
+
backendKind: 'webgpu',
|
|
1299
|
+
compute: true,
|
|
1300
|
+
storageBuffer: true,
|
|
1301
|
+
} as never;
|
|
1302
|
+
|
|
1303
|
+
const graph = new RenderGraph();
|
|
1304
|
+
graph.addColorTarget('healthy', {
|
|
1305
|
+
format: 'rgba8unorm',
|
|
1306
|
+
size: { w: 1, h: 1 },
|
|
1307
|
+
usage: 0,
|
|
1308
|
+
});
|
|
1309
|
+
graph.addPass('healthy-pass', { reads: [], writes: ['healthy'] });
|
|
1310
|
+
const initial = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
|
|
1311
|
+
expect(initial.ok).toBe(true);
|
|
1312
|
+
const healthyTexture = graph.getColorTargetTexture('healthy');
|
|
1313
|
+
const healthyView = graph.getColorTargetView('healthy');
|
|
1314
|
+
expect(createTextureCalls).toBe(1);
|
|
1315
|
+
expect(createTextureViewCalls).toBe(1);
|
|
1316
|
+
|
|
1317
|
+
graph.addColorTarget('candidate', {
|
|
1318
|
+
// Runtime malformed input is intentionally injected past the closed
|
|
1319
|
+
// declaration type so the validation error remains covered.
|
|
1320
|
+
format: 'not-a-gpu-texture-format' as never,
|
|
1321
|
+
size: { w: 1, h: 1 },
|
|
1322
|
+
usage: 0,
|
|
1323
|
+
});
|
|
1324
|
+
graph.addPass('candidate-pass', { reads: [], writes: ['candidate'] });
|
|
1325
|
+
|
|
1326
|
+
const refused = graph.compile({ backendKind: 'webgpu', caps, device: device as never });
|
|
1327
|
+
expect(refused.ok).toBe(false);
|
|
1328
|
+
if (refused.ok) return;
|
|
1329
|
+
expect(refused.error.code).toBe('invalid-format');
|
|
1330
|
+
expect(refused.error.detail).toMatchObject({
|
|
1331
|
+
resourceKey: 'candidate',
|
|
1332
|
+
format: 'not-a-gpu-texture-format',
|
|
1333
|
+
});
|
|
1334
|
+
const detail = refused.error.detail as {
|
|
1335
|
+
readonly resourceKey: string;
|
|
1336
|
+
readonly format: string;
|
|
1337
|
+
readonly expected: readonly string[];
|
|
1338
|
+
};
|
|
1339
|
+
expect(detail.expected).toContain('rgba8unorm');
|
|
1340
|
+
expect(createTextureCalls).toBe(1);
|
|
1341
|
+
expect(createTextureViewCalls).toBe(1);
|
|
1342
|
+
expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
|
|
1343
|
+
expect(graph.getColorTargetView('healthy')).toBe(healthyView);
|
|
1344
|
+
expect(graph.getColorTargetTexture('candidate')).toBeUndefined();
|
|
1345
|
+
|
|
1346
|
+
const duplicateCandidate = graph.addColorTarget('candidate', {
|
|
1347
|
+
format: 'rgba8unorm',
|
|
1348
|
+
size: { w: 1, h: 1 },
|
|
1349
|
+
usage: 0,
|
|
1350
|
+
});
|
|
1351
|
+
expect(duplicateCandidate.ok).toBe(false);
|
|
1352
|
+
if (duplicateCandidate.ok) return;
|
|
1353
|
+
expect(duplicateCandidate.error.code).toBe('duplicate-resource');
|
|
1354
|
+
expect(duplicateCandidate.error.detail).toEqual({ resourceKey: 'candidate' });
|
|
1355
|
+
expect(createTextureCalls).toBe(1);
|
|
1356
|
+
expect(createTextureViewCalls).toBe(1);
|
|
1357
|
+
expect(graph.getColorTargetTexture('healthy')).toBe(healthyTexture);
|
|
1358
|
+
expect(graph.getColorTargetView('healthy')).toBe(healthyView);
|
|
1359
|
+
expect(graph.getColorTargetTexture('candidate')).toBeUndefined();
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
{
|
|
1365
|
+
// --- feat-20260612 M4 / w13: RenderGraph.drain unit test ---
|
|
1366
|
+
//
|
|
1367
|
+
// Coverage matrix (plan-strategy D-7 + OOS-7):
|
|
1368
|
+
// 1. drain() walks transientPool and calls device.destroyTexture for each
|
|
1369
|
+
// pooled texture handle.
|
|
1370
|
+
// 2. drain() walks persistentTextures and calls device.destroyTexture for
|
|
1371
|
+
// each persistent texture handle.
|
|
1372
|
+
// 3. After drain(), both pools are empty (size === 0).
|
|
1373
|
+
// 4. drain() is idempotent: a second call after the pools were cleared
|
|
1374
|
+
// is a no-op (zero further destroyTexture invocations).
|
|
1375
|
+
//
|
|
1376
|
+
// Mock shape: a minimal RhiDevice stub with createTexture / createTextureView
|
|
1377
|
+
// / destroyTexture (the only surface the allocate + drain paths touch).
|
|
1378
|
+
|
|
1379
|
+
function drainMockCaps() {
|
|
1380
|
+
return {
|
|
1381
|
+
backendKind: 'webgpu' as const,
|
|
1382
|
+
compute: true,
|
|
1383
|
+
storageBuffer: true,
|
|
1384
|
+
storageTexture: false,
|
|
1385
|
+
timestampQuery: false,
|
|
1386
|
+
timestampPeriodNanoseconds: null,
|
|
1387
|
+
indirectDrawing: false,
|
|
1388
|
+
textureCompressionBc: false,
|
|
1389
|
+
textureCompressionEtc2: false,
|
|
1390
|
+
textureCompressionAstc: false,
|
|
1391
|
+
multiDrawIndirect: false,
|
|
1392
|
+
pushConstants: false,
|
|
1393
|
+
textureBindingArray: false,
|
|
1394
|
+
samplerAliasing: true,
|
|
1395
|
+
firstInstanceIndirect: false,
|
|
1396
|
+
rgba16floatRenderable: false,
|
|
1397
|
+
rg11b10ufloatRenderable: false,
|
|
1398
|
+
float32Filterable: false,
|
|
1399
|
+
maxColorAttachments: 8,
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
interface DrainProbe {
|
|
1404
|
+
created: number;
|
|
1405
|
+
destroyed: number;
|
|
1406
|
+
destroyedHandles: object[];
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function makeDrainDevice(probe: DrainProbe): {
|
|
1410
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1411
|
+
[k: string]: any;
|
|
1412
|
+
} {
|
|
1413
|
+
return {
|
|
1414
|
+
createTexture: (_desc: unknown) => {
|
|
1415
|
+
probe.created += 1;
|
|
1416
|
+
const handle = { __mock: `tex-${probe.created}` };
|
|
1417
|
+
return { ok: true as const, value: handle };
|
|
1418
|
+
},
|
|
1419
|
+
createTextureView: (_tex: unknown, _desc: unknown) => {
|
|
1420
|
+
return { ok: true as const, value: { __mock: 'view' } };
|
|
1421
|
+
},
|
|
1422
|
+
destroyTexture: (tex: object) => {
|
|
1423
|
+
probe.destroyed += 1;
|
|
1424
|
+
probe.destroyedHandles.push(tex);
|
|
1425
|
+
return { ok: true as const, value: undefined };
|
|
1426
|
+
},
|
|
1427
|
+
queue: {
|
|
1428
|
+
onSubmittedWorkDone: () => Promise.resolve(undefined),
|
|
1429
|
+
},
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
describe('RenderGraph.drain (feat-20260612 M4 / w13)', () => {
|
|
1434
|
+
it('drain destroys every transient pool entry and clears the pool', () => {
|
|
1435
|
+
const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
1436
|
+
const g = new RenderGraph();
|
|
1437
|
+
g.addColorTarget('hdrColor', {
|
|
1438
|
+
format: 'rgba16float',
|
|
1439
|
+
size: { w: 64, h: 64 },
|
|
1440
|
+
sample: 1,
|
|
1441
|
+
usage: 0,
|
|
1442
|
+
});
|
|
1443
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
1444
|
+
|
|
1445
|
+
const r = g.compile({
|
|
1446
|
+
backendKind: 'webgpu',
|
|
1447
|
+
caps: drainMockCaps(),
|
|
1448
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1449
|
+
device: makeDrainDevice(probe) as any,
|
|
1450
|
+
});
|
|
1451
|
+
expect(r.ok).toBe(true);
|
|
1452
|
+
expect(probe.created).toBeGreaterThanOrEqual(1);
|
|
1453
|
+
|
|
1454
|
+
const baselineDestroyed = probe.destroyed;
|
|
1455
|
+
g.drain();
|
|
1456
|
+
|
|
1457
|
+
// Every pool entry got a destroyTexture call.
|
|
1458
|
+
expect(probe.destroyed - baselineDestroyed).toBe(probe.created);
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
it('drain destroys every persistent texture entry and clears the map', () => {
|
|
1462
|
+
const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
1463
|
+
const g = new RenderGraph();
|
|
1464
|
+
g.addColorTarget('shadowDepth', {
|
|
1465
|
+
format: 'depth32float',
|
|
1466
|
+
size: { w: 64, h: 64 },
|
|
1467
|
+
lifetime: 'persistent',
|
|
1468
|
+
usage: 0,
|
|
1469
|
+
});
|
|
1470
|
+
g.addPass('shadow', { reads: [], writes: ['shadowDepth'] });
|
|
1471
|
+
const r = g.compile({
|
|
1472
|
+
backendKind: 'webgpu',
|
|
1473
|
+
caps: drainMockCaps(),
|
|
1474
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1475
|
+
device: makeDrainDevice(probe) as any,
|
|
1476
|
+
});
|
|
1477
|
+
expect(r.ok).toBe(true);
|
|
1478
|
+
expect(probe.created).toBe(1);
|
|
1479
|
+
|
|
1480
|
+
g.drain();
|
|
1481
|
+
expect(probe.destroyed).toBe(1);
|
|
1482
|
+
expect(() => g.drain()).not.toThrow();
|
|
1483
|
+
expect(probe.destroyed).toBe(1);
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it('drain is idempotent: a second drain after a cleared pool is a no-op', () => {
|
|
1487
|
+
const probe: DrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
1488
|
+
const g = new RenderGraph();
|
|
1489
|
+
g.addColorTarget('hdrColor', {
|
|
1490
|
+
format: 'rgba16float',
|
|
1491
|
+
size: { w: 32, h: 32 },
|
|
1492
|
+
sample: 1,
|
|
1493
|
+
usage: 0,
|
|
1494
|
+
});
|
|
1495
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
1496
|
+
|
|
1497
|
+
const r = g.compile({
|
|
1498
|
+
backendKind: 'webgpu',
|
|
1499
|
+
caps: drainMockCaps(),
|
|
1500
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1501
|
+
device: makeDrainDevice(probe) as any,
|
|
1502
|
+
});
|
|
1503
|
+
expect(r.ok).toBe(true);
|
|
1504
|
+
|
|
1505
|
+
g.drain();
|
|
1506
|
+
const afterFirst = probe.destroyed;
|
|
1507
|
+
|
|
1508
|
+
// Second drain: pools already cleared, no further destroyTexture fires.
|
|
1509
|
+
expect(() => g.drain()).not.toThrow();
|
|
1510
|
+
expect(probe.destroyed).toBe(afterFirst);
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('drain on a never-compiled graph is a safe no-op', () => {
|
|
1514
|
+
const g = new RenderGraph();
|
|
1515
|
+
expect(() => g.drain()).not.toThrow();
|
|
1516
|
+
});
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
{
|
|
1521
|
+
// --- from uniform-fallback.test.ts ---
|
|
1522
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
1523
|
+
|
|
1524
|
+
function mockCaps(overrides: { storageBuffer?: boolean }) {
|
|
1525
|
+
return {
|
|
1526
|
+
backendKind: 'webgpu' as const,
|
|
1527
|
+
compute: true,
|
|
1528
|
+
storageBuffer: overrides.storageBuffer ?? true,
|
|
1529
|
+
storageTexture: false,
|
|
1530
|
+
timestampQuery: false,
|
|
1531
|
+
timestampPeriodNanoseconds: null,
|
|
1532
|
+
indirectDrawing: false,
|
|
1533
|
+
textureCompressionBc: false,
|
|
1534
|
+
textureCompressionEtc2: false,
|
|
1535
|
+
textureCompressionAstc: false,
|
|
1536
|
+
multiDrawIndirect: false,
|
|
1537
|
+
pushConstants: false,
|
|
1538
|
+
textureBindingArray: false,
|
|
1539
|
+
samplerAliasing: true,
|
|
1540
|
+
firstInstanceIndirect: false,
|
|
1541
|
+
rgba16floatRenderable: false,
|
|
1542
|
+
rg11b10ufloatRenderable: false,
|
|
1543
|
+
float32Filterable: false,
|
|
1544
|
+
maxColorAttachments: 8,
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function resolvedTypeOf(
|
|
1549
|
+
graph: { resolvedBuffers: readonly { key: string; resolvedBufferType: string }[] },
|
|
1550
|
+
key: string,
|
|
1551
|
+
): string | undefined {
|
|
1552
|
+
return graph.resolvedBuffers.find((b) => b.key === key)?.resolvedBufferType;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// ── uniform fallback (AC-09 / D-6.1) ─────────────────────────────
|
|
1556
|
+
//
|
|
1557
|
+
// compile() resolves a `kind:'buffer'` resource declared with
|
|
1558
|
+
// `bufferRole='auto-storage-or-uniform'` to a concrete RHI binding type by
|
|
1559
|
+
// reading `caps.storageBuffer` -- mirroring the runtime pbr-pipeline cap
|
|
1560
|
+
// switch (research Finding 7), expressed in the RHI-pure graph layer. The two
|
|
1561
|
+
// branches MUST resolve to different types; if the resolution logic is
|
|
1562
|
+
// deleted, `resolvedBuffers` goes empty and these assertions fail.
|
|
1563
|
+
|
|
1564
|
+
describe('uniform fallback (AC-09 / D-6.1)', () => {
|
|
1565
|
+
it('resolves auto-storage-or-uniform to uniform when storageBuffer=false', () => {
|
|
1566
|
+
const g = new RenderGraph();
|
|
1567
|
+
g.addResource('lightBuf', {
|
|
1568
|
+
kind: 'buffer',
|
|
1569
|
+
lifetime: 'persistent',
|
|
1570
|
+
bufferRole: 'auto-storage-or-uniform',
|
|
1571
|
+
});
|
|
1572
|
+
g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
|
|
1573
|
+
|
|
1574
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: false }) });
|
|
1575
|
+
|
|
1576
|
+
expect(r.ok).toBe(true);
|
|
1577
|
+
if (r.ok) {
|
|
1578
|
+
expect(resolvedTypeOf(r.value, 'lightBuf')).toBe('uniform');
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
it('resolves auto-storage-or-uniform to read-only-storage when storageBuffer=true', () => {
|
|
1583
|
+
const g = new RenderGraph();
|
|
1584
|
+
g.addResource('lightBuf', {
|
|
1585
|
+
kind: 'buffer',
|
|
1586
|
+
lifetime: 'persistent',
|
|
1587
|
+
bufferRole: 'auto-storage-or-uniform',
|
|
1588
|
+
});
|
|
1589
|
+
g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
|
|
1590
|
+
|
|
1591
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: true }) });
|
|
1592
|
+
|
|
1593
|
+
expect(r.ok).toBe(true);
|
|
1594
|
+
if (r.ok) {
|
|
1595
|
+
expect(resolvedTypeOf(r.value, 'lightBuf')).toBe('read-only-storage');
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it('the two branches resolve to different types (catches a removed cap switch)', () => {
|
|
1600
|
+
const build = () => {
|
|
1601
|
+
const g = new RenderGraph();
|
|
1602
|
+
g.addResource('lightBuf', {
|
|
1603
|
+
kind: 'buffer',
|
|
1604
|
+
lifetime: 'persistent',
|
|
1605
|
+
bufferRole: 'auto-storage-or-uniform',
|
|
1606
|
+
});
|
|
1607
|
+
g.addPass('mainPass', { reads: [], writes: ['lightBuf'] });
|
|
1608
|
+
return g;
|
|
1609
|
+
};
|
|
1610
|
+
|
|
1611
|
+
const noStorage = build().compile({
|
|
1612
|
+
backendKind: 'webgpu',
|
|
1613
|
+
caps: mockCaps({ storageBuffer: false }),
|
|
1614
|
+
});
|
|
1615
|
+
const withStorage = build().compile({
|
|
1616
|
+
backendKind: 'webgpu',
|
|
1617
|
+
caps: mockCaps({ storageBuffer: true }),
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
expect(noStorage.ok && withStorage.ok).toBe(true);
|
|
1621
|
+
if (noStorage.ok && withStorage.ok) {
|
|
1622
|
+
const a = resolvedTypeOf(noStorage.value, 'lightBuf');
|
|
1623
|
+
const b = resolvedTypeOf(withStorage.value, 'lightBuf');
|
|
1624
|
+
expect(a).toBeDefined();
|
|
1625
|
+
expect(b).toBeDefined();
|
|
1626
|
+
expect(a).not.toBe(b);
|
|
1627
|
+
}
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it('bufferRole=uniform always resolves to uniform regardless of caps', () => {
|
|
1631
|
+
const build = () => {
|
|
1632
|
+
const g = new RenderGraph();
|
|
1633
|
+
g.addResource('camBuf', { kind: 'buffer', lifetime: 'persistent', bufferRole: 'uniform' });
|
|
1634
|
+
g.addPass('mainPass', { reads: [], writes: ['camBuf'] });
|
|
1635
|
+
return g;
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
const withStorage = build().compile({
|
|
1639
|
+
backendKind: 'webgpu',
|
|
1640
|
+
caps: mockCaps({ storageBuffer: true }),
|
|
1641
|
+
});
|
|
1642
|
+
const noStorage = build().compile({
|
|
1643
|
+
backendKind: 'webgpu',
|
|
1644
|
+
caps: mockCaps({ storageBuffer: false }),
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
expect(withStorage.ok && noStorage.ok).toBe(true);
|
|
1648
|
+
if (withStorage.ok && noStorage.ok) {
|
|
1649
|
+
expect(resolvedTypeOf(withStorage.value, 'camBuf')).toBe('uniform');
|
|
1650
|
+
expect(resolvedTypeOf(noStorage.value, 'camBuf')).toBe('uniform');
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it('defaults an unset bufferRole to the auto cap switch', () => {
|
|
1655
|
+
const g = new RenderGraph();
|
|
1656
|
+
g.addResource('plainBuf', { kind: 'buffer', lifetime: 'persistent' });
|
|
1657
|
+
g.addPass('mainPass', { reads: [], writes: ['plainBuf'] });
|
|
1658
|
+
|
|
1659
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: false }) });
|
|
1660
|
+
|
|
1661
|
+
expect(r.ok).toBe(true);
|
|
1662
|
+
if (r.ok) {
|
|
1663
|
+
expect(resolvedTypeOf(r.value, 'plainBuf')).toBe('uniform');
|
|
1664
|
+
}
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it('omits texture resources from resolvedBuffers', () => {
|
|
1668
|
+
const g = new RenderGraph();
|
|
1669
|
+
g.addResource('hdrColor', { kind: 'texture', lifetime: 'transient' });
|
|
1670
|
+
g.addPass('mainPass', { reads: [], writes: ['hdrColor'] });
|
|
1671
|
+
|
|
1672
|
+
const r = g.compile({ backendKind: 'webgpu', caps: mockCaps({ storageBuffer: true }) });
|
|
1673
|
+
|
|
1674
|
+
expect(r.ok).toBe(true);
|
|
1675
|
+
if (r.ok) {
|
|
1676
|
+
expect(r.value.resolvedBuffers).toEqual([]);
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
{
|
|
1683
|
+
// --- feat-20260619-gpu-resource-ownership-symmetric-release-primitive M3 / w9: AC-09 resize drain ---
|
|
1684
|
+
//
|
|
1685
|
+
// Transient pool key includes dimensions (name:format:WxH:usage:sample). When
|
|
1686
|
+
// swap-chain size changes, old-dimension entries become stranded because the
|
|
1687
|
+
// new compile creates entries with new-dimension keys and the old ones are
|
|
1688
|
+
// never accessed again. drainTransient() must release all transient pool
|
|
1689
|
+
// textures on resize before reallocation.
|
|
1690
|
+
//
|
|
1691
|
+
// Coverage:
|
|
1692
|
+
// 1. Resize triggers drain of old transient entries (destroyTexture called per old entry).
|
|
1693
|
+
// 2. After resize drain, old keys are gone from the pool; new keys for new
|
|
1694
|
+
// dimensions exist.
|
|
1695
|
+
// 3. Non-resize recompile does NOT drain (pool entries survive when dimensions unchanged).
|
|
1696
|
+
// 4. drainTransient only touches transientPool, not persistentTextures.
|
|
1697
|
+
|
|
1698
|
+
function resizeDrainMockCaps() {
|
|
1699
|
+
return {
|
|
1700
|
+
backendKind: 'webgpu' as const,
|
|
1701
|
+
compute: true,
|
|
1702
|
+
storageBuffer: true,
|
|
1703
|
+
storageTexture: false,
|
|
1704
|
+
timestampQuery: false,
|
|
1705
|
+
timestampPeriodNanoseconds: null,
|
|
1706
|
+
indirectDrawing: false,
|
|
1707
|
+
textureCompressionBc: false,
|
|
1708
|
+
textureCompressionEtc2: false,
|
|
1709
|
+
textureCompressionAstc: false,
|
|
1710
|
+
multiDrawIndirect: false,
|
|
1711
|
+
pushConstants: false,
|
|
1712
|
+
textureBindingArray: false,
|
|
1713
|
+
samplerAliasing: true,
|
|
1714
|
+
firstInstanceIndirect: false,
|
|
1715
|
+
rgba16floatRenderable: false,
|
|
1716
|
+
rg11b10ufloatRenderable: false,
|
|
1717
|
+
float32Filterable: false,
|
|
1718
|
+
maxColorAttachments: 8,
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
interface ResizeDrainProbe {
|
|
1723
|
+
created: number;
|
|
1724
|
+
destroyed: number;
|
|
1725
|
+
destroyedHandles: object[];
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function makeResizeDrainDevice(probe: ResizeDrainProbe): Record<string, unknown> {
|
|
1729
|
+
return {
|
|
1730
|
+
createTexture: (_desc: unknown) => {
|
|
1731
|
+
probe.created += 1;
|
|
1732
|
+
return { ok: true as const, value: { __mock: `tex-${probe.created}` } };
|
|
1733
|
+
},
|
|
1734
|
+
createTextureView: (_tex: unknown, _desc: unknown) => {
|
|
1735
|
+
return { ok: true as const, value: { __mock: 'view' } };
|
|
1736
|
+
},
|
|
1737
|
+
destroyTexture: (tex: object) => {
|
|
1738
|
+
probe.destroyed += 1;
|
|
1739
|
+
probe.destroyedHandles.push(tex);
|
|
1740
|
+
return { ok: true as const, value: undefined };
|
|
1741
|
+
},
|
|
1742
|
+
queue: {
|
|
1743
|
+
onSubmittedWorkDone: () => Promise.resolve(undefined),
|
|
1744
|
+
},
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
describe('transient pool resize drain (AC-09)', () => {
|
|
1749
|
+
it('resize enqueues old transient entries to pendingDestroy and reclaim destroys them', async () => {
|
|
1750
|
+
const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
1751
|
+
const g = new RenderGraph();
|
|
1752
|
+
g.addColorTarget('hdrColor', {
|
|
1753
|
+
format: 'rgba16float',
|
|
1754
|
+
size: 'swapchain',
|
|
1755
|
+
sample: 1,
|
|
1756
|
+
usage: 0,
|
|
1757
|
+
});
|
|
1758
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
1759
|
+
|
|
1760
|
+
// Compile at 64x64.
|
|
1761
|
+
g.setSwapChainSize(64, 64);
|
|
1762
|
+
const r1 = g.compile({
|
|
1763
|
+
backendKind: 'webgpu',
|
|
1764
|
+
caps: resizeDrainMockCaps(),
|
|
1765
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1766
|
+
device: makeResizeDrainDevice(probe) as any,
|
|
1767
|
+
});
|
|
1768
|
+
expect(r1.ok).toBe(true);
|
|
1769
|
+
const createdAfterFirst = probe.created;
|
|
1770
|
+
expect(createdAfterFirst).toBeGreaterThanOrEqual(1);
|
|
1771
|
+
const destroyedBeforeResize = probe.destroyed;
|
|
1772
|
+
|
|
1773
|
+
// Resize to 128x128 triggers recompile signal.
|
|
1774
|
+
const resizeDetected = g.setSwapChainSize(128, 128);
|
|
1775
|
+
expect(resizeDetected).toBe(true);
|
|
1776
|
+
|
|
1777
|
+
const r2 = g.compile({
|
|
1778
|
+
backendKind: 'webgpu',
|
|
1779
|
+
caps: resizeDrainMockCaps(),
|
|
1780
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1781
|
+
device: makeResizeDrainDevice(probe) as any,
|
|
1782
|
+
});
|
|
1783
|
+
expect(r2.ok).toBe(true);
|
|
1784
|
+
|
|
1785
|
+
// bug-20260622 D-1: old textures pushed to pendingDestroy, not
|
|
1786
|
+
// destroyed immediately. Resize compile should NOT call destroyTexture.
|
|
1787
|
+
const destroyedAtResize = probe.destroyed - destroyedBeforeResize;
|
|
1788
|
+
expect(destroyedAtResize).toBe(0);
|
|
1789
|
+
|
|
1790
|
+
// New textures were created for the new size.
|
|
1791
|
+
expect(probe.created).toBe(createdAfterFirst * 2);
|
|
1792
|
+
|
|
1793
|
+
// After reclaim, old textures are destroyed.
|
|
1794
|
+
await g.reclaimRetiredTransients();
|
|
1795
|
+
expect(probe.destroyed - destroyedBeforeResize).toBe(createdAfterFirst);
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it('non-resize recompile leaves transient pool intact (no spurious drain)', () => {
|
|
1799
|
+
const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
1800
|
+
const g = new RenderGraph();
|
|
1801
|
+
g.addColorTarget('hdrColor', {
|
|
1802
|
+
format: 'rgba16float',
|
|
1803
|
+
size: 'swapchain',
|
|
1804
|
+
sample: 1,
|
|
1805
|
+
usage: 0,
|
|
1806
|
+
});
|
|
1807
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
1808
|
+
|
|
1809
|
+
// Compile at 64x64 — no prior size set, so setSwapChainSize(64,64) returns true.
|
|
1810
|
+
g.setSwapChainSize(64, 64);
|
|
1811
|
+
const r1 = g.compile({
|
|
1812
|
+
backendKind: 'webgpu',
|
|
1813
|
+
caps: resizeDrainMockCaps(),
|
|
1814
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1815
|
+
device: makeResizeDrainDevice(probe) as any,
|
|
1816
|
+
});
|
|
1817
|
+
expect(r1.ok).toBe(true);
|
|
1818
|
+
|
|
1819
|
+
const destroyedAfterFirst = probe.destroyed;
|
|
1820
|
+
const createdAfterFirst = probe.created;
|
|
1821
|
+
|
|
1822
|
+
// Recompile without resize — setSwapChainSize(64,64) returns false.
|
|
1823
|
+
const resizeDetected = g.setSwapChainSize(64, 64);
|
|
1824
|
+
expect(resizeDetected).toBe(false);
|
|
1825
|
+
|
|
1826
|
+
const r2 = g.compile({
|
|
1827
|
+
backendKind: 'webgpu',
|
|
1828
|
+
caps: resizeDrainMockCaps(),
|
|
1829
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1830
|
+
device: makeResizeDrainDevice(probe) as any,
|
|
1831
|
+
});
|
|
1832
|
+
expect(r2.ok).toBe(true);
|
|
1833
|
+
|
|
1834
|
+
// No drain on non-resize: destroyTexture count unchanged, pool hit reused
|
|
1835
|
+
// the existing texture (no new createTexture calls).
|
|
1836
|
+
expect(probe.destroyed).toBe(destroyedAfterFirst);
|
|
1837
|
+
expect(probe.created).toBe(createdAfterFirst);
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
it('drainTransient only clears transientPool, not persistentTextures', () => {
|
|
1841
|
+
const probe: ResizeDrainProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
1842
|
+
const g = new RenderGraph();
|
|
1843
|
+
g.addColorTarget('hdrColor', {
|
|
1844
|
+
format: 'rgba16float',
|
|
1845
|
+
size: 'swapchain',
|
|
1846
|
+
sample: 1,
|
|
1847
|
+
usage: 0,
|
|
1848
|
+
});
|
|
1849
|
+
g.addColorTarget('history', {
|
|
1850
|
+
format: 'rgba16float',
|
|
1851
|
+
size: { w: 32, h: 32 },
|
|
1852
|
+
lifetime: 'persistent',
|
|
1853
|
+
usage: 0,
|
|
1854
|
+
});
|
|
1855
|
+
g.addPass('main', { reads: [], writes: ['hdrColor', 'history'] });
|
|
1856
|
+
|
|
1857
|
+
g.setSwapChainSize(64, 64);
|
|
1858
|
+
const r1 = g.compile({
|
|
1859
|
+
backendKind: 'webgpu',
|
|
1860
|
+
caps: resizeDrainMockCaps(),
|
|
1861
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1862
|
+
device: makeResizeDrainDevice(probe) as any,
|
|
1863
|
+
});
|
|
1864
|
+
expect(r1.ok).toBe(true);
|
|
1865
|
+
|
|
1866
|
+
// Snapshot persistentTextures before resize.
|
|
1867
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
|
|
1868
|
+
const ptexBefore = (g as any).persistentTextures as Map<string, unknown>;
|
|
1869
|
+
const ptexSizeBefore = ptexBefore.size;
|
|
1870
|
+
const historyBefore = g.getColorTargetTexture('history');
|
|
1871
|
+
|
|
1872
|
+
// Resize.
|
|
1873
|
+
g.setSwapChainSize(128, 128);
|
|
1874
|
+
const r2 = g.compile({
|
|
1875
|
+
backendKind: 'webgpu',
|
|
1876
|
+
caps: resizeDrainMockCaps(),
|
|
1877
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1878
|
+
device: makeResizeDrainDevice(probe) as any,
|
|
1879
|
+
});
|
|
1880
|
+
expect(r2.ok).toBe(true);
|
|
1881
|
+
|
|
1882
|
+
// Persistent textures untouched by drainTransient.
|
|
1883
|
+
expect(ptexBefore.size).toBe(ptexSizeBefore);
|
|
1884
|
+
expect(g.getColorTargetTexture('history')).toBe(historyBefore);
|
|
1885
|
+
expect(probe.created).toBe(3);
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
{
|
|
1891
|
+
// --- bug-20260622-hdrcolor-transient-pool-destroy-in-flight M1: red baseline — deferred-destroy fence ---
|
|
1892
|
+
//
|
|
1893
|
+
// The existing AC-09 test asserts "resize triggers drainTransient which
|
|
1894
|
+
// destroys old textures immediately". This is the bug: those textures are
|
|
1895
|
+
// still referenced by the previous frame's in-flight command buffer.
|
|
1896
|
+
//
|
|
1897
|
+
// The fix adds a pendingDestroy queue (D-1) and a reclaimRetiredTransients
|
|
1898
|
+
// method (D-2) that defers actual destroyTexture until
|
|
1899
|
+
// device.queue.onSubmittedWorkDone() resolves.
|
|
1900
|
+
//
|
|
1901
|
+
// This test (M1 RED baseline) asserts the NEW expected behaviour:
|
|
1902
|
+
// 1. Resize compile pushes old textures into pendingDestroy, does NOT
|
|
1903
|
+
// call destroyTexture immediately.
|
|
1904
|
+
// 2. reclaimRetiredTransients() triggers onSubmittedWorkDone then
|
|
1905
|
+
// destroys the pending items.
|
|
1906
|
+
//
|
|
1907
|
+
// Currently RED — drainTransient destroys synchronously, so
|
|
1908
|
+
// destroyedAtResize > 0 (this test asserts destroyedAtResize === 0).
|
|
1909
|
+
|
|
1910
|
+
function deferredMockCaps() {
|
|
1911
|
+
return {
|
|
1912
|
+
backendKind: 'webgpu' as const,
|
|
1913
|
+
compute: true,
|
|
1914
|
+
storageBuffer: true,
|
|
1915
|
+
storageTexture: false,
|
|
1916
|
+
timestampQuery: false,
|
|
1917
|
+
timestampPeriodNanoseconds: null,
|
|
1918
|
+
indirectDrawing: false,
|
|
1919
|
+
textureCompressionBc: false,
|
|
1920
|
+
textureCompressionEtc2: false,
|
|
1921
|
+
textureCompressionAstc: false,
|
|
1922
|
+
multiDrawIndirect: false,
|
|
1923
|
+
pushConstants: false,
|
|
1924
|
+
textureBindingArray: false,
|
|
1925
|
+
samplerAliasing: true,
|
|
1926
|
+
firstInstanceIndirect: false,
|
|
1927
|
+
rgba16floatRenderable: false,
|
|
1928
|
+
rg11b10ufloatRenderable: false,
|
|
1929
|
+
float32Filterable: false,
|
|
1930
|
+
maxColorAttachments: 8,
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
interface DeferredProbe {
|
|
1935
|
+
created: number;
|
|
1936
|
+
destroyed: number;
|
|
1937
|
+
destroyedHandles: object[];
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function makeDeferredDevice(probe: DeferredProbe): Record<string, unknown> {
|
|
1941
|
+
return {
|
|
1942
|
+
createTexture: (_desc: unknown) => {
|
|
1943
|
+
probe.created += 1;
|
|
1944
|
+
return { ok: true as const, value: { __mock: `tex-${probe.created}` } };
|
|
1945
|
+
},
|
|
1946
|
+
createTextureView: (_tex: unknown, _desc: unknown) => {
|
|
1947
|
+
return { ok: true as const, value: { __mock: 'view' } };
|
|
1948
|
+
},
|
|
1949
|
+
destroyTexture: (tex: object) => {
|
|
1950
|
+
probe.destroyed += 1;
|
|
1951
|
+
probe.destroyedHandles.push(tex);
|
|
1952
|
+
return { ok: true as const, value: undefined };
|
|
1953
|
+
},
|
|
1954
|
+
queue: {
|
|
1955
|
+
onSubmittedWorkDone: () => Promise.resolve(undefined),
|
|
1956
|
+
},
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
describe('deferred-destroy: resize enqueues pendingDestroy, not immediate destroy (AC-03 RED)', () => {
|
|
1961
|
+
it('resize compile pushes old transient textures into pendingDestroy (destroy count = 0 at resize)', () => {
|
|
1962
|
+
const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
1963
|
+
const g = new RenderGraph();
|
|
1964
|
+
g.addColorTarget('hdrColor', {
|
|
1965
|
+
format: 'rgba16float',
|
|
1966
|
+
size: 'swapchain',
|
|
1967
|
+
sample: 1,
|
|
1968
|
+
usage: 0,
|
|
1969
|
+
});
|
|
1970
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
1971
|
+
|
|
1972
|
+
// Compile at 64x64.
|
|
1973
|
+
g.setSwapChainSize(64, 64);
|
|
1974
|
+
const r1 = g.compile({
|
|
1975
|
+
backendKind: 'webgpu',
|
|
1976
|
+
caps: deferredMockCaps(),
|
|
1977
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1978
|
+
device: makeDeferredDevice(probe) as any,
|
|
1979
|
+
});
|
|
1980
|
+
expect(r1.ok).toBe(true);
|
|
1981
|
+
const createdAfterFirst = probe.created;
|
|
1982
|
+
expect(createdAfterFirst).toBeGreaterThanOrEqual(1);
|
|
1983
|
+
|
|
1984
|
+
const destroyedBeforeResize = probe.destroyed;
|
|
1985
|
+
|
|
1986
|
+
// Resize to 128x128.
|
|
1987
|
+
const resizeDetected = g.setSwapChainSize(128, 128);
|
|
1988
|
+
expect(resizeDetected).toBe(true);
|
|
1989
|
+
|
|
1990
|
+
const r2 = g.compile({
|
|
1991
|
+
backendKind: 'webgpu',
|
|
1992
|
+
caps: deferredMockCaps(),
|
|
1993
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
1994
|
+
device: makeDeferredDevice(probe) as any,
|
|
1995
|
+
});
|
|
1996
|
+
expect(r2.ok).toBe(true);
|
|
1997
|
+
|
|
1998
|
+
// AC-03 RED: after resize compile, old textures should NOT be destroyed
|
|
1999
|
+
// yet — they are in pendingDestroy queue, waiting for GPU retirement.
|
|
2000
|
+
// Currently RED because drainTransient destroys synchronously.
|
|
2001
|
+
const destroyedAtResize = probe.destroyed - destroyedBeforeResize;
|
|
2002
|
+
expect(destroyedAtResize).toBe(0);
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
it('pendingDestroy is accessible as internal field after resize', () => {
|
|
2006
|
+
const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
2007
|
+
const g = new RenderGraph();
|
|
2008
|
+
g.addColorTarget('hdrColor', {
|
|
2009
|
+
format: 'rgba16float',
|
|
2010
|
+
size: 'swapchain',
|
|
2011
|
+
sample: 1,
|
|
2012
|
+
usage: 0,
|
|
2013
|
+
});
|
|
2014
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
2015
|
+
|
|
2016
|
+
g.setSwapChainSize(64, 64);
|
|
2017
|
+
g.compile({
|
|
2018
|
+
backendKind: 'webgpu',
|
|
2019
|
+
caps: deferredMockCaps(),
|
|
2020
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2021
|
+
device: makeDeferredDevice(probe) as any,
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
g.setSwapChainSize(128, 128);
|
|
2025
|
+
g.compile({
|
|
2026
|
+
backendKind: 'webgpu',
|
|
2027
|
+
caps: deferredMockCaps(),
|
|
2028
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2029
|
+
device: makeDeferredDevice(probe) as any,
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
// After resize, pendingDestroy should contain the old transient textures.
|
|
2033
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
|
|
2034
|
+
const pending = (g as any).pendingDestroy as unknown[];
|
|
2035
|
+
expect(Array.isArray(pending)).toBe(true);
|
|
2036
|
+
expect(pending.length).toBeGreaterThanOrEqual(1);
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
it('reclaimRetiredTransients destroys pending items after onSubmittedWorkDone resolves', async () => {
|
|
2040
|
+
const probe: DeferredProbe = { created: 0, destroyed: 0, destroyedHandles: [] };
|
|
2041
|
+
const g = new RenderGraph();
|
|
2042
|
+
g.addColorTarget('hdrColor', {
|
|
2043
|
+
format: 'rgba16float',
|
|
2044
|
+
size: 'swapchain',
|
|
2045
|
+
sample: 1,
|
|
2046
|
+
usage: 0,
|
|
2047
|
+
});
|
|
2048
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
2049
|
+
|
|
2050
|
+
g.setSwapChainSize(64, 64);
|
|
2051
|
+
g.compile({
|
|
2052
|
+
backendKind: 'webgpu',
|
|
2053
|
+
caps: deferredMockCaps(),
|
|
2054
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2055
|
+
device: makeDeferredDevice(probe) as any,
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
const destroyedBeforeResize = probe.destroyed;
|
|
2059
|
+
|
|
2060
|
+
g.setSwapChainSize(128, 128);
|
|
2061
|
+
g.compile({
|
|
2062
|
+
backendKind: 'webgpu',
|
|
2063
|
+
caps: deferredMockCaps(),
|
|
2064
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2065
|
+
device: makeDeferredDevice(probe) as any,
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
// At resize: no destroy yet.
|
|
2069
|
+
expect(probe.destroyed - destroyedBeforeResize).toBe(0);
|
|
2070
|
+
|
|
2071
|
+
// Reclaim: destroys pending items after queue.onSubmittedWorkDone resolves.
|
|
2072
|
+
// biome-ignore lint/suspicious/noExplicitAny: method not yet public
|
|
2073
|
+
await (g as any).reclaimRetiredTransients();
|
|
2074
|
+
|
|
2075
|
+
// After reclaim, all pending items should be destroyed.
|
|
2076
|
+
expect(probe.destroyed - destroyedBeforeResize).toBeGreaterThanOrEqual(1);
|
|
2077
|
+
|
|
2078
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
|
|
2079
|
+
const pending = (g as any).pendingDestroy as unknown[];
|
|
2080
|
+
expect(pending.length).toBe(0);
|
|
2081
|
+
});
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
{
|
|
2086
|
+
// --- feat-20260619-gpu-resource-ownership-symmetric-release-primitive M3 / w10: AC-08 same-key destroy ---
|
|
2087
|
+
//
|
|
2088
|
+
// Defensive: when transientPool.set(key, pooled) overwrites an existing key,
|
|
2089
|
+
// the old PooledTexture.texture must be destroyed first (symmetric release).
|
|
2090
|
+
// Currently this code path is unreachable because set() only follows a get()
|
|
2091
|
+
// miss; the test exercises the guard through a private helper setTransientEntry
|
|
2092
|
+
// that encapsulates the has-check + destroy + set logic (added in w11).
|
|
2093
|
+
//
|
|
2094
|
+
// The guard (w11, inside allocateColorTargets) is:
|
|
2095
|
+
// this.setTransientEntry(key, pooled)
|
|
2096
|
+
// where setTransientEntry does:
|
|
2097
|
+
// if (this.transientPool.has(key)) {
|
|
2098
|
+
// device.destroyTexture(this.transientPool.get(key)!.texture as Texture);
|
|
2099
|
+
// }
|
|
2100
|
+
// this.transientPool.set(key, pooled);
|
|
2101
|
+
//
|
|
2102
|
+
// This test directly calls setTransientEntry (internal, accessed via 'as any')
|
|
2103
|
+
// to verify the guard behavior. In RED phase (w10, no w11 yet), the method
|
|
2104
|
+
// does not exist — the test fails. In GREEN phase (w11), the method exists
|
|
2105
|
+
// and destroys the old texture before overwriting.
|
|
2106
|
+
|
|
2107
|
+
function sameKeyMockCaps() {
|
|
2108
|
+
return {
|
|
2109
|
+
backendKind: 'webgpu' as const,
|
|
2110
|
+
compute: true,
|
|
2111
|
+
storageBuffer: true,
|
|
2112
|
+
storageTexture: false,
|
|
2113
|
+
timestampQuery: false,
|
|
2114
|
+
timestampPeriodNanoseconds: null,
|
|
2115
|
+
indirectDrawing: false,
|
|
2116
|
+
textureCompressionBc: false,
|
|
2117
|
+
textureCompressionEtc2: false,
|
|
2118
|
+
textureCompressionAstc: false,
|
|
2119
|
+
multiDrawIndirect: false,
|
|
2120
|
+
pushConstants: false,
|
|
2121
|
+
textureBindingArray: false,
|
|
2122
|
+
samplerAliasing: true,
|
|
2123
|
+
firstInstanceIndirect: false,
|
|
2124
|
+
rgba16floatRenderable: false,
|
|
2125
|
+
rg11b10ufloatRenderable: false,
|
|
2126
|
+
float32Filterable: false,
|
|
2127
|
+
maxColorAttachments: 8,
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
describe('transient pool same-key destroy (AC-08)', () => {
|
|
2132
|
+
it('old pooled texture is enqueued to pendingDestroy on same-key set overwrite', () => {
|
|
2133
|
+
const destroyedHandles: object[] = [];
|
|
2134
|
+
const mockDevice = {
|
|
2135
|
+
createTexture: (_desc: unknown) => ({
|
|
2136
|
+
ok: true as const,
|
|
2137
|
+
value: { __mock: `tex-${Date.now()}` },
|
|
2138
|
+
}),
|
|
2139
|
+
createTextureView: (_tex: unknown, _desc: unknown) => ({
|
|
2140
|
+
ok: true as const,
|
|
2141
|
+
value: { __mock: 'view' },
|
|
2142
|
+
}),
|
|
2143
|
+
destroyTexture: (tex: object) => {
|
|
2144
|
+
destroyedHandles.push(tex);
|
|
2145
|
+
return { ok: true as const, value: undefined };
|
|
2146
|
+
},
|
|
2147
|
+
queue: {
|
|
2148
|
+
onSubmittedWorkDone: () => Promise.resolve(undefined),
|
|
2149
|
+
},
|
|
2150
|
+
};
|
|
2151
|
+
|
|
2152
|
+
const g = new RenderGraph();
|
|
2153
|
+
g.addColorTarget('hdrColor', {
|
|
2154
|
+
format: 'rgba16float',
|
|
2155
|
+
size: { w: 64, h: 64 },
|
|
2156
|
+
sample: 1,
|
|
2157
|
+
usage: 0,
|
|
2158
|
+
});
|
|
2159
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
2160
|
+
|
|
2161
|
+
// Compile to set lastDevice and populate transientPool.
|
|
2162
|
+
const r = g.compile({
|
|
2163
|
+
backendKind: 'webgpu',
|
|
2164
|
+
caps: sameKeyMockCaps(),
|
|
2165
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2166
|
+
device: mockDevice as any,
|
|
2167
|
+
});
|
|
2168
|
+
expect(r.ok).toBe(true);
|
|
2169
|
+
|
|
2170
|
+
// Access internal transient pool to get an existing entry.
|
|
2171
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
|
|
2172
|
+
const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
|
|
2173
|
+
const entries = [...pool.entries()];
|
|
2174
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
2175
|
+
// biome-ignore lint/style/noNonNullAssertion: guarded by length check above
|
|
2176
|
+
const [key, oldEntry] = entries[0]!;
|
|
2177
|
+
const oldTexture = oldEntry.texture;
|
|
2178
|
+
|
|
2179
|
+
const destroyedBeforeSet = destroyedHandles.length;
|
|
2180
|
+
|
|
2181
|
+
// Call setTransientEntry to simulate same-key overwrite.
|
|
2182
|
+
// bug-20260622 D-1: old texture pushed to pendingDestroy, not
|
|
2183
|
+
// immediately destroyed.
|
|
2184
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal method access in unit test
|
|
2185
|
+
(g as any).setTransientEntry(key, {
|
|
2186
|
+
texture: { __mock: 'replacement-tex' },
|
|
2187
|
+
view: { __mock: 'replacement-view' },
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
// No immediate destroy — entry is now in pendingDestroy queue.
|
|
2191
|
+
expect(destroyedHandles.length).toBe(destroyedBeforeSet);
|
|
2192
|
+
|
|
2193
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
|
|
2194
|
+
const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
|
|
2195
|
+
expect(pending.length).toBeGreaterThanOrEqual(1);
|
|
2196
|
+
expect(pending.some((p) => p.texture === oldTexture)).toBe(true);
|
|
2197
|
+
});
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
{
|
|
2202
|
+
// --- w15: B-2 recover() clears pendingDestroy (feat-20260622-s5 M3) ---
|
|
2203
|
+
//
|
|
2204
|
+
// The recover() rebuild path (createRenderer M3) tears down the old device
|
|
2205
|
+
// and requests a fresh one. The render-graph's pendingDestroy queue still
|
|
2206
|
+
// holds PooledTexture handles minted against the OLD device; calling
|
|
2207
|
+
// destroyTexture on them against the NEW device is meaningless (and may
|
|
2208
|
+
// throw). recover() therefore drops the queue via clearPendingDestroy()
|
|
2209
|
+
// instead of draining it (B-2 / B-AC-02, plan-strategy D-5). This mirrors
|
|
2210
|
+
// the existing null-device fast path (`pendingDestroy.length = 0`) but is a
|
|
2211
|
+
// public method recover() can call directly.
|
|
2212
|
+
|
|
2213
|
+
function clearPendingCaps() {
|
|
2214
|
+
return {
|
|
2215
|
+
backendKind: 'webgpu' as const,
|
|
2216
|
+
compute: true,
|
|
2217
|
+
storageBuffer: true,
|
|
2218
|
+
storageTexture: false,
|
|
2219
|
+
timestampQuery: false,
|
|
2220
|
+
timestampPeriodNanoseconds: null,
|
|
2221
|
+
indirectDrawing: false,
|
|
2222
|
+
textureCompressionBc: false,
|
|
2223
|
+
textureCompressionEtc2: false,
|
|
2224
|
+
textureCompressionAstc: false,
|
|
2225
|
+
multiDrawIndirect: false,
|
|
2226
|
+
pushConstants: false,
|
|
2227
|
+
textureBindingArray: false,
|
|
2228
|
+
samplerAliasing: true,
|
|
2229
|
+
firstInstanceIndirect: false,
|
|
2230
|
+
rgba16floatRenderable: false,
|
|
2231
|
+
rg11b10ufloatRenderable: false,
|
|
2232
|
+
float32Filterable: false,
|
|
2233
|
+
maxColorAttachments: 8,
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// --- w20: B-1 destroyTexture error tolerance (feat-20260622-s5 M4) ---
|
|
2238
|
+
//
|
|
2239
|
+
// The render-graph's drain() and reclaimRetiredTransients() docstrings
|
|
2240
|
+
// claim per-handle destroy errors are tolerated (swallow-and-continue),
|
|
2241
|
+
// but the current implementation uses bare for-loops with no try/catch.
|
|
2242
|
+
// B-1 fixes the gap (plan-strategy D-4 / PD3): any single
|
|
2243
|
+
// device.destroyTexture failure must not interrupt the destroy chain
|
|
2244
|
+
// for subsequent handles. Test coverage (w20, B-AC-01):
|
|
2245
|
+
// 1. reclaimRetiredTransients snapshot loop — error on item 0,
|
|
2246
|
+
// items 1..N-1 still destroyed, queue empty after.
|
|
2247
|
+
// 2. drain() loops (transient / pendingDestroy / persistent) — same
|
|
2248
|
+
// per-handle tolerance policy via a shared mock device.
|
|
2249
|
+
|
|
2250
|
+
function b1ToleranceCaps() {
|
|
2251
|
+
return {
|
|
2252
|
+
backendKind: 'webgpu' as const,
|
|
2253
|
+
compute: true,
|
|
2254
|
+
storageBuffer: true,
|
|
2255
|
+
storageTexture: false,
|
|
2256
|
+
timestampQuery: false,
|
|
2257
|
+
timestampPeriodNanoseconds: null,
|
|
2258
|
+
indirectDrawing: false,
|
|
2259
|
+
textureCompressionBc: false,
|
|
2260
|
+
textureCompressionEtc2: false,
|
|
2261
|
+
textureCompressionAstc: false,
|
|
2262
|
+
multiDrawIndirect: false,
|
|
2263
|
+
pushConstants: false,
|
|
2264
|
+
textureBindingArray: false,
|
|
2265
|
+
samplerAliasing: true,
|
|
2266
|
+
firstInstanceIndirect: false,
|
|
2267
|
+
rgba16floatRenderable: false,
|
|
2268
|
+
rg11b10ufloatRenderable: false,
|
|
2269
|
+
float32Filterable: false,
|
|
2270
|
+
maxColorAttachments: 8,
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
interface B1Probe {
|
|
2275
|
+
created: number;
|
|
2276
|
+
destroyed: number;
|
|
2277
|
+
destroyedHandles: object[];
|
|
2278
|
+
/** If non-negative, destroyTexture throws on this 0-based invocation. */
|
|
2279
|
+
throwOnCall: number;
|
|
2280
|
+
destroyCallCount: number;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function makeB1Device(probe: B1Probe): {
|
|
2284
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2285
|
+
[k: string]: any;
|
|
2286
|
+
} {
|
|
2287
|
+
return {
|
|
2288
|
+
createTexture: (_desc: unknown) => {
|
|
2289
|
+
probe.created += 1;
|
|
2290
|
+
const handle = { __mock: `tex-${probe.created}` };
|
|
2291
|
+
return { ok: true as const, value: handle };
|
|
2292
|
+
},
|
|
2293
|
+
createTextureView: (_tex: unknown, _desc: unknown) => {
|
|
2294
|
+
return { ok: true as const, value: { __mock: 'view' } };
|
|
2295
|
+
},
|
|
2296
|
+
destroyTexture: (tex: object) => {
|
|
2297
|
+
probe.destroyCallCount += 1;
|
|
2298
|
+
if (probe.destroyCallCount - 1 === probe.throwOnCall) {
|
|
2299
|
+
throw new Error('mock destroyTexture error (simulated)');
|
|
2300
|
+
}
|
|
2301
|
+
probe.destroyed += 1;
|
|
2302
|
+
probe.destroyedHandles.push(tex);
|
|
2303
|
+
return { ok: true as const, value: undefined };
|
|
2304
|
+
},
|
|
2305
|
+
queue: {
|
|
2306
|
+
onSubmittedWorkDone: () => Promise.resolve(undefined),
|
|
2307
|
+
},
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
describe('B-1 destroyTexture error tolerance (w20)', () => {
|
|
2312
|
+
it('reclaimRetiredTransients: item-0 error does not stop item 1..N from being destroyed', async () => {
|
|
2313
|
+
const probe: B1Probe = {
|
|
2314
|
+
created: 0,
|
|
2315
|
+
destroyed: 0,
|
|
2316
|
+
destroyedHandles: [],
|
|
2317
|
+
throwOnCall: 0,
|
|
2318
|
+
destroyCallCount: 0,
|
|
2319
|
+
};
|
|
2320
|
+
const mockDevice = makeB1Device(probe);
|
|
2321
|
+
const g = new RenderGraph();
|
|
2322
|
+
g.addColorTarget('hdrColor', {
|
|
2323
|
+
format: 'rgba16float',
|
|
2324
|
+
size: { w: 64, h: 64 },
|
|
2325
|
+
sample: 1,
|
|
2326
|
+
usage: 0,
|
|
2327
|
+
});
|
|
2328
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
2329
|
+
|
|
2330
|
+
const r = g.compile({
|
|
2331
|
+
backendKind: 'webgpu',
|
|
2332
|
+
caps: b1ToleranceCaps(),
|
|
2333
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2334
|
+
device: mockDevice as any,
|
|
2335
|
+
});
|
|
2336
|
+
expect(r.ok).toBe(true);
|
|
2337
|
+
|
|
2338
|
+
// Populate pendingDestroy with >= 3 entries by replacing the
|
|
2339
|
+
// transient pool entry three times (setTransientEntry pushes the
|
|
2340
|
+
// old one to pendingDestroy each time).
|
|
2341
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal pool + pending access in unit test
|
|
2342
|
+
const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
|
|
2343
|
+
for (let i = 0; i < 3; i++) {
|
|
2344
|
+
const [key] = [...pool.entries()][0] as [string, { texture: object }];
|
|
2345
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal method access
|
|
2346
|
+
(g as any).setTransientEntry(key, {
|
|
2347
|
+
texture: { __mock: `entry-${i}` },
|
|
2348
|
+
view: { __mock: 'view' },
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access
|
|
2353
|
+
const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
|
|
2354
|
+
expect(pending.length).toBe(3);
|
|
2355
|
+
|
|
2356
|
+
// reclaimRetiredTransients takes a snapshot and calls
|
|
2357
|
+
// device.destroyTexture on each. Item 0 throws; items 1,2 must
|
|
2358
|
+
// still be destroyed (swallow-and-continue). The queue must be
|
|
2359
|
+
// empty afterwards.
|
|
2360
|
+
await expect(g.reclaimRetiredTransients()).resolves.toBeUndefined();
|
|
2361
|
+
|
|
2362
|
+
// After reclaim: queue drained (items consumed from the snapshot;
|
|
2363
|
+
// snapshot was spliced before the loop, so pendingDestroy is empty
|
|
2364
|
+
// because the loop consumed the snapshot array in-place).
|
|
2365
|
+
expect(pending.length).toBe(0);
|
|
2366
|
+
|
|
2367
|
+
// Item 0 threw, but items 1,2 succeeded -> at least 2 destroyed.
|
|
2368
|
+
expect(probe.destroyed).toBe(2);
|
|
2369
|
+
// destroyTexture was called 3 times (item 0 threw, items 1,2 succeeded).
|
|
2370
|
+
expect(probe.destroyCallCount).toBe(3);
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
it('drain: transient-pool destroy error on item 0 does not stop subsequent items from being destroyed', () => {
|
|
2374
|
+
const probe: B1Probe = {
|
|
2375
|
+
created: 0,
|
|
2376
|
+
destroyed: 0,
|
|
2377
|
+
destroyedHandles: [],
|
|
2378
|
+
throwOnCall: 0,
|
|
2379
|
+
destroyCallCount: 0,
|
|
2380
|
+
};
|
|
2381
|
+
const mockDevice = makeB1Device(probe);
|
|
2382
|
+
const g = new RenderGraph();
|
|
2383
|
+
g.addColorTarget('a', {
|
|
2384
|
+
format: 'rgba16float',
|
|
2385
|
+
size: { w: 32, h: 32 },
|
|
2386
|
+
sample: 1,
|
|
2387
|
+
usage: 0,
|
|
2388
|
+
});
|
|
2389
|
+
g.addColorTarget('b', {
|
|
2390
|
+
format: 'rgba8unorm',
|
|
2391
|
+
size: { w: 32, h: 32 },
|
|
2392
|
+
sample: 1,
|
|
2393
|
+
usage: 0,
|
|
2394
|
+
});
|
|
2395
|
+
g.addPass('main', { reads: [], writes: ['a', 'b'] });
|
|
2396
|
+
|
|
2397
|
+
const r = g.compile({
|
|
2398
|
+
backendKind: 'webgpu',
|
|
2399
|
+
caps: b1ToleranceCaps(),
|
|
2400
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2401
|
+
device: mockDevice as any,
|
|
2402
|
+
});
|
|
2403
|
+
expect(r.ok).toBe(true);
|
|
2404
|
+
|
|
2405
|
+
// Both color targets created transient pool entries.
|
|
2406
|
+
// drain() walks transientPool.values(), then pendingDestroy, then
|
|
2407
|
+
// persistentTextures. With throwOnCall=0, the very first
|
|
2408
|
+
// destroyTexture (for the first transient pool entry) throws.
|
|
2409
|
+
// Subsequent entries must still be destroyed.
|
|
2410
|
+
//
|
|
2411
|
+
// We can't use compile+resize to enqueue pendingDestroy because
|
|
2412
|
+
// the recompile with the same device would use the same pool.
|
|
2413
|
+
// Instead we exercise the transient pool destroy path directly
|
|
2414
|
+
// via drain(), which walks all 3 loops. The transient pool has 2
|
|
2415
|
+
// entries (a,b). Item 0 throws, item 1 succeeds.
|
|
2416
|
+
const destroyedBefore = probe.destroyed;
|
|
2417
|
+
g.drain();
|
|
2418
|
+
|
|
2419
|
+
// Item 1 was still destroyed after item 0 threw.
|
|
2420
|
+
// Transient pool has 2 entries -> at least 1 destroyed (item 0
|
|
2421
|
+
// threw, item 1 succeeded). pendingDestroy and persistent were
|
|
2422
|
+
// empty/cleared so no further calls.
|
|
2423
|
+
expect(probe.destroyed - destroyedBefore).toBe(1);
|
|
2424
|
+
expect(probe.destroyCallCount - destroyedBefore).toBe(2);
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
it('drain: pendingDestroy teardown error on item 0 does not stop subsequent items', () => {
|
|
2428
|
+
const probe: B1Probe = {
|
|
2429
|
+
created: 0,
|
|
2430
|
+
destroyed: 0,
|
|
2431
|
+
destroyedHandles: [],
|
|
2432
|
+
throwOnCall: 2, // throw on the 3rd destroy call (0-based)
|
|
2433
|
+
// transient pool has 2 entries -> calls 0,1
|
|
2434
|
+
// pendingDestroy has 3 entries -> calls 2,3,4
|
|
2435
|
+
// throwOnCall=2 -> error on pendingDestroy item 0
|
|
2436
|
+
destroyCallCount: 0,
|
|
2437
|
+
};
|
|
2438
|
+
const mockDevice = makeB1Device(probe);
|
|
2439
|
+
const g = new RenderGraph();
|
|
2440
|
+
g.addColorTarget('a', {
|
|
2441
|
+
format: 'rgba16float',
|
|
2442
|
+
size: { w: 32, h: 32 },
|
|
2443
|
+
sample: 1,
|
|
2444
|
+
usage: 0,
|
|
2445
|
+
});
|
|
2446
|
+
g.addPass('main', { reads: [], writes: ['a'] });
|
|
2447
|
+
|
|
2448
|
+
const r = g.compile({
|
|
2449
|
+
backendKind: 'webgpu',
|
|
2450
|
+
caps: b1ToleranceCaps(),
|
|
2451
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2452
|
+
device: mockDevice as any,
|
|
2453
|
+
});
|
|
2454
|
+
expect(r.ok).toBe(true);
|
|
2455
|
+
|
|
2456
|
+
// Push 3 entries into pendingDestroy.
|
|
2457
|
+
for (let i = 0; i < 3; i++) {
|
|
2458
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access
|
|
2459
|
+
(g as any).pendingDestroy.push({ texture: { __mock: `pd-${i}` }, view: { __mock: 'v' } });
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
const destroyedBefore = probe.destroyed;
|
|
2463
|
+
g.drain();
|
|
2464
|
+
|
|
2465
|
+
// transient pool (1 entry) -> calls 0 (ok)
|
|
2466
|
+
// pendingDestroy (3 entries) -> call 1 throws (item 0 of pendingDestroy),
|
|
2467
|
+
// calls 2,3 succeed (items 1,2 of pendingDestroy)
|
|
2468
|
+
// persistent -> empty, nothing
|
|
2469
|
+
// Total: call 0 ok + call 1 throw + call 2 ok + call 3 ok = 3 successes
|
|
2470
|
+
expect(probe.destroyed - destroyedBefore).toBe(3);
|
|
2471
|
+
// 4 total calls attempted
|
|
2472
|
+
expect(probe.destroyCallCount - destroyedBefore).toBe(4);
|
|
2473
|
+
// pendingDestroy cleared
|
|
2474
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access
|
|
2475
|
+
expect(((g as any).pendingDestroy as unknown[]).length).toBe(0);
|
|
2476
|
+
});
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2479
|
+
describe('RenderGraph.clearPendingDestroy (B-2 / B-AC-02)', () => {
|
|
2480
|
+
it('clears the pendingDestroy queue without calling destroyTexture on stale handles', () => {
|
|
2481
|
+
const destroyedHandles: object[] = [];
|
|
2482
|
+
const mockDevice = {
|
|
2483
|
+
createTexture: (_desc: unknown) => ({
|
|
2484
|
+
ok: true as const,
|
|
2485
|
+
value: { __mock: `tex-${Date.now()}-${Math.random()}` },
|
|
2486
|
+
}),
|
|
2487
|
+
createTextureView: (_tex: unknown, _desc: unknown) => ({
|
|
2488
|
+
ok: true as const,
|
|
2489
|
+
value: { __mock: 'view' },
|
|
2490
|
+
}),
|
|
2491
|
+
destroyTexture: (tex: object) => {
|
|
2492
|
+
destroyedHandles.push(tex);
|
|
2493
|
+
return { ok: true as const, value: undefined };
|
|
2494
|
+
},
|
|
2495
|
+
queue: {
|
|
2496
|
+
onSubmittedWorkDone: () => Promise.resolve(undefined),
|
|
2497
|
+
},
|
|
2498
|
+
};
|
|
2499
|
+
|
|
2500
|
+
const g = new RenderGraph();
|
|
2501
|
+
g.addColorTarget('hdrColor', {
|
|
2502
|
+
format: 'rgba16float',
|
|
2503
|
+
size: { w: 64, h: 64 },
|
|
2504
|
+
sample: 1,
|
|
2505
|
+
usage: 0,
|
|
2506
|
+
});
|
|
2507
|
+
g.addPass('main', { reads: [], writes: ['hdrColor'] });
|
|
2508
|
+
|
|
2509
|
+
const r = g.compile({
|
|
2510
|
+
backendKind: 'webgpu',
|
|
2511
|
+
caps: clearPendingCaps(),
|
|
2512
|
+
// biome-ignore lint/suspicious/noExplicitAny: opaque mock device surface
|
|
2513
|
+
device: mockDevice as any,
|
|
2514
|
+
});
|
|
2515
|
+
expect(r.ok).toBe(true);
|
|
2516
|
+
|
|
2517
|
+
// Populate pendingDestroy with a stale entry (simulating a resize-time
|
|
2518
|
+
// transient retirement that has not yet been reclaimed).
|
|
2519
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal pool access in unit test
|
|
2520
|
+
const pool = (g as any).transientPool as Map<string, { texture: object; view: object }>;
|
|
2521
|
+
const [key, oldEntry] = [...pool.entries()][0] as [string, { texture: object; view: object }];
|
|
2522
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal method access in unit test
|
|
2523
|
+
(g as any).setTransientEntry(key, {
|
|
2524
|
+
texture: { __mock: 'replacement-tex' },
|
|
2525
|
+
view: { __mock: 'replacement-view' },
|
|
2526
|
+
});
|
|
2527
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
|
|
2528
|
+
const pending = (g as any).pendingDestroy as { texture: object; view: object }[];
|
|
2529
|
+
expect(pending.length).toBeGreaterThanOrEqual(1);
|
|
2530
|
+
expect(pending.some((p) => p.texture === oldEntry.texture)).toBe(true);
|
|
2531
|
+
|
|
2532
|
+
const destroyedBefore = destroyedHandles.length;
|
|
2533
|
+
|
|
2534
|
+
// B-2: clearPendingDestroy empties the queue WITHOUT calling
|
|
2535
|
+
// destroyTexture (stale handles belong to the lost device).
|
|
2536
|
+
(g as { clearPendingDestroy: () => void }).clearPendingDestroy();
|
|
2537
|
+
|
|
2538
|
+
expect(pending.length).toBe(0);
|
|
2539
|
+
// No destroyTexture call on the stale device handles.
|
|
2540
|
+
expect(destroyedHandles.length).toBe(destroyedBefore);
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
it('clearPendingDestroy on an empty queue is a safe no-op', () => {
|
|
2544
|
+
const g = new RenderGraph();
|
|
2545
|
+
expect(() => (g as { clearPendingDestroy: () => void }).clearPendingDestroy()).not.toThrow();
|
|
2546
|
+
// biome-ignore lint/suspicious/noExplicitAny: internal field access in unit test
|
|
2547
|
+
const pending = (g as any).pendingDestroy as unknown[];
|
|
2548
|
+
expect(pending.length).toBe(0);
|
|
2549
|
+
});
|
|
2550
|
+
});
|
|
2551
|
+
}
|