@forgeax/engine-render-graph 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +184 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/graph-pass-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/graph-pass-kind-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/observation-lifetime-state-owner.test-d.d.ts +2 -0
- package/dist/__tests__/observation-lifetime-state-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/observation.unit.test.d.ts +2 -0
- package/dist/__tests__/observation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-alias-source.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph-alias-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-builder.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph-builder.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-capability-owner.test-d.d.ts +2 -0
- package/dist/__tests__/render-graph-capability-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-graph-errors.test-d.d.ts +2 -0
- package/dist/__tests__/render-graph-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-graph-regressions.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph-regressions.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts +2 -0
- package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resource-declaration-collision.test.d.ts +2 -0
- package/dist/__tests__/resource-declaration-collision.test.d.ts.map +1 -0
- package/dist/__tests__/resource-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/resource-kind-owner.test-d.d.ts.map +1 -0
- package/dist/builder.d.ts +42 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/compiled-graph.d.ts +23 -0
- package/dist/compiled-graph.d.ts.map +1 -0
- package/dist/errors.d.ts +148 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/graph.d.ts +404 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +2256 -0
- package/dist/index.mjs.map +1 -0
- package/dist/kernel-internal.d.ts +84 -0
- package/dist/kernel-internal.d.ts.map +1 -0
- package/dist/observation.d.ts +38 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/pass-registry.d.ts +12 -0
- package/dist/pass-registry.d.ts.map +1 -0
- package/dist/pipeline/__tests__/color-value-domain.test.d.ts +2 -0
- package/dist/pipeline/__tests__/color-value-domain.test.d.ts.map +1 -0
- package/dist/pipeline/color-value-domain.d.ts +35 -0
- package/dist/pipeline/color-value-domain.d.ts.map +1 -0
- package/dist/resource-registry.d.ts +61 -0
- package/dist/resource-registry.d.ts.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/graph-pass-kind-owner.test-d.ts +35 -0
- package/src/__tests__/observation-lifetime-state-owner.test-d.ts +39 -0
- package/src/__tests__/observation.unit.test.ts +103 -0
- package/src/__tests__/render-graph-alias-source.unit.test.ts +118 -0
- package/src/__tests__/render-graph-builder.unit.test.ts +664 -0
- package/src/__tests__/render-graph-capability-owner.test-d.ts +32 -0
- package/src/__tests__/render-graph-errors.test-d.ts +169 -0
- package/src/__tests__/render-graph-regressions.unit.test.ts +126 -0
- package/src/__tests__/render-graph-rhi-null.integration.test.ts +86 -0
- package/src/__tests__/render-graph.unit.test.ts +2549 -0
- package/src/__tests__/resource-declaration-collision.test.ts +151 -0
- package/src/__tests__/resource-kind-owner.test-d.ts +38 -0
- package/src/builder.ts +1009 -0
- package/src/compiled-graph.ts +383 -0
- package/src/errors.ts +205 -0
- package/src/graph.ts +1270 -0
- package/src/index.ts +97 -0
- package/src/kernel-internal.ts +148 -0
- package/src/observation.ts +134 -0
- package/src/pass-registry.ts +43 -0
- package/src/pipeline/__tests__/color-value-domain.test.ts +43 -0
- package/src/pipeline/color-value-domain.ts +139 -0
- package/src/resource-registry.ts +173 -0
- package/src/types.ts +217 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2256 @@
|
|
|
1
|
+
import { ok, err } from '@forgeax/engine-types';
|
|
2
|
+
export { err, ok } from '@forgeax/engine-types';
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
var RenderGraphError = class extends Error {
|
|
6
|
+
code;
|
|
7
|
+
expected;
|
|
8
|
+
hint;
|
|
9
|
+
detail;
|
|
10
|
+
constructor(args) {
|
|
11
|
+
super(`[RenderGraphError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
|
|
12
|
+
this.name = "RenderGraphError";
|
|
13
|
+
this.code = args.code;
|
|
14
|
+
this.expected = args.expected;
|
|
15
|
+
this.hint = args.hint;
|
|
16
|
+
this.detail = args.detail;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// src/kernel-internal.ts
|
|
21
|
+
function textureHandle(owner, id) {
|
|
22
|
+
return Object.freeze({ owner, id, kind: "texture" });
|
|
23
|
+
}
|
|
24
|
+
function textureViewHandle(owner, id, textureId) {
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
owner,
|
|
27
|
+
id,
|
|
28
|
+
kind: "texture-view",
|
|
29
|
+
textureId
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function bufferHandle(owner, id) {
|
|
33
|
+
return Object.freeze({ owner, id, kind: "buffer" });
|
|
34
|
+
}
|
|
35
|
+
function handleData(resource) {
|
|
36
|
+
if (typeof resource !== "object" || resource === null) return void 0;
|
|
37
|
+
const candidate = resource;
|
|
38
|
+
if (typeof candidate.id !== "number" || typeof candidate.owner !== "object") return void 0;
|
|
39
|
+
if (candidate.kind !== "texture" && candidate.kind !== "texture-view" && candidate.kind !== "buffer") {
|
|
40
|
+
return void 0;
|
|
41
|
+
}
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
function accessResourceId(access) {
|
|
45
|
+
const data = handleData(access.resource);
|
|
46
|
+
if (data?.kind === "texture-view") return data.textureId;
|
|
47
|
+
return data?.id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/compiled-graph.ts
|
|
51
|
+
function resolutionError(label, cause) {
|
|
52
|
+
return new RenderGraphError({
|
|
53
|
+
code: "resource-resolution-failed",
|
|
54
|
+
expected: `resource '${label}' resolves to a live RHI handle for this frame`,
|
|
55
|
+
hint: `repair the imported owner for '${label}' before executing the graph`,
|
|
56
|
+
detail: { resourceLabel: label, ...cause === void 0 ? {} : { accesses: [String(cause)] } }
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
var CompiledRenderGraphImpl = class {
|
|
60
|
+
constructor(generation, owner, device, resources, views, passes, info) {
|
|
61
|
+
this.generation = generation;
|
|
62
|
+
this.owner = owner;
|
|
63
|
+
this.device = device;
|
|
64
|
+
this.resources = resources;
|
|
65
|
+
this.views = views;
|
|
66
|
+
this.passes = passes;
|
|
67
|
+
this.info = info;
|
|
68
|
+
}
|
|
69
|
+
generation;
|
|
70
|
+
owner;
|
|
71
|
+
device;
|
|
72
|
+
resources;
|
|
73
|
+
views;
|
|
74
|
+
passes;
|
|
75
|
+
info;
|
|
76
|
+
retired = false;
|
|
77
|
+
retireResult;
|
|
78
|
+
inspect() {
|
|
79
|
+
return this.info;
|
|
80
|
+
}
|
|
81
|
+
execute(frame, runPass) {
|
|
82
|
+
if (this.retired) {
|
|
83
|
+
return err(
|
|
84
|
+
new RenderGraphError({
|
|
85
|
+
code: "compiled-graph-retired",
|
|
86
|
+
expected: "a compiled graph executes only before retire()",
|
|
87
|
+
hint: "publish and execute the replacement compiled graph",
|
|
88
|
+
detail: { generation: this.generation }
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const resolved = this.resolveFrameResources(frame);
|
|
93
|
+
if (!resolved.ok) return resolved;
|
|
94
|
+
for (const [executionIndex, pass] of this.passes.entries()) {
|
|
95
|
+
const resolver = this.createPassResolver(pass, resolved.value);
|
|
96
|
+
try {
|
|
97
|
+
if (pass.pass.descriptor.executeIf?.(frame) === false) continue;
|
|
98
|
+
let encodeResult = ok(void 0);
|
|
99
|
+
const encode = () => {
|
|
100
|
+
switch (pass.pass.kind) {
|
|
101
|
+
case "raster": {
|
|
102
|
+
const descriptor = pass.pass.descriptor;
|
|
103
|
+
const colorAttachments = [];
|
|
104
|
+
for (const attachment of descriptor.colorAttachments) {
|
|
105
|
+
const view = resolver.textureView(attachment.view);
|
|
106
|
+
if (!view.ok) {
|
|
107
|
+
encodeResult = view;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const resolveTarget = attachment.resolveTarget === void 0 ? void 0 : resolver.textureView(attachment.resolveTarget);
|
|
111
|
+
if (resolveTarget !== void 0 && !resolveTarget.ok) {
|
|
112
|
+
encodeResult = resolveTarget;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const clearValue = typeof attachment.clearValue === "function" ? attachment.clearValue(frame) : attachment.clearValue;
|
|
116
|
+
colorAttachments.push({
|
|
117
|
+
view: view.value,
|
|
118
|
+
...resolveTarget === void 0 ? {} : { resolveTarget: resolveTarget.value },
|
|
119
|
+
...clearValue === void 0 ? {} : { clearValue },
|
|
120
|
+
loadOp: attachment.loadOp,
|
|
121
|
+
storeOp: attachment.storeOp,
|
|
122
|
+
...attachment.depthSlice === void 0 ? {} : { depthSlice: attachment.depthSlice }
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
let depthStencilAttachment;
|
|
126
|
+
if (descriptor.depthStencilAttachment !== void 0) {
|
|
127
|
+
const attachment = descriptor.depthStencilAttachment;
|
|
128
|
+
const view = resolver.textureView(attachment.view);
|
|
129
|
+
if (!view.ok) {
|
|
130
|
+
encodeResult = view;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
depthStencilAttachment = {
|
|
134
|
+
view: view.value,
|
|
135
|
+
...attachment.depthClearValue === void 0 ? {} : { depthClearValue: attachment.depthClearValue },
|
|
136
|
+
...attachment.depthLoadOp === void 0 ? {} : { depthLoadOp: attachment.depthLoadOp },
|
|
137
|
+
...attachment.depthStoreOp === void 0 ? {} : { depthStoreOp: attachment.depthStoreOp },
|
|
138
|
+
...attachment.depthReadOnly === void 0 ? {} : { depthReadOnly: attachment.depthReadOnly },
|
|
139
|
+
...attachment.stencilClearValue === void 0 ? {} : { stencilClearValue: attachment.stencilClearValue },
|
|
140
|
+
...attachment.stencilLoadOp === void 0 ? {} : { stencilLoadOp: attachment.stencilLoadOp },
|
|
141
|
+
...attachment.stencilStoreOp === void 0 ? {} : { stencilStoreOp: attachment.stencilStoreOp },
|
|
142
|
+
...attachment.stencilReadOnly === void 0 ? {} : { stencilReadOnly: attachment.stencilReadOnly }
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const encoder = frame.encoder.beginRenderPass({
|
|
146
|
+
label: pass.name,
|
|
147
|
+
colorAttachments,
|
|
148
|
+
...depthStencilAttachment === void 0 ? {} : { depthStencilAttachment }
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
descriptor.encode({ pass: encoder, frame, resources: resolver });
|
|
152
|
+
} finally {
|
|
153
|
+
encoder.end();
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "compute": {
|
|
158
|
+
const begin = pass.pass.descriptor.begin?.(frame);
|
|
159
|
+
let encoder;
|
|
160
|
+
try {
|
|
161
|
+
encoder = frame.encoder.beginComputePass({ ...begin ?? {}, label: pass.name });
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
pass.pass.descriptor.onBeginError?.(frame, cause);
|
|
164
|
+
throw cause;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
pass.pass.descriptor.encode({ pass: encoder, frame, resources: resolver });
|
|
168
|
+
} finally {
|
|
169
|
+
encoder.end();
|
|
170
|
+
}
|
|
171
|
+
pass.pass.descriptor.after?.(frame);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "copy":
|
|
175
|
+
pass.pass.descriptor.encode({ encoder: frame.encoder, frame, resources: resolver });
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
if (runPass === void 0) {
|
|
180
|
+
encode();
|
|
181
|
+
} else {
|
|
182
|
+
runPass({ name: pass.name, kind: pass.pass.kind, executionIndex }, encode);
|
|
183
|
+
}
|
|
184
|
+
if (!encodeResult.ok) return encodeResult;
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
return err(
|
|
187
|
+
new RenderGraphError({
|
|
188
|
+
code: "pass-encode-failed",
|
|
189
|
+
expected: `pass '${pass.name}' encodes without throwing`,
|
|
190
|
+
hint: "inspect detail.cause and repair the pass-owned RHI command",
|
|
191
|
+
detail: { passName: pass.name, passKind: pass.pass.kind, cause }
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return ok(void 0);
|
|
197
|
+
}
|
|
198
|
+
retire() {
|
|
199
|
+
if (this.retireResult !== void 0) return this.retireResult;
|
|
200
|
+
this.retired = true;
|
|
201
|
+
this.retireResult = this.finishRetire();
|
|
202
|
+
return this.retireResult;
|
|
203
|
+
}
|
|
204
|
+
async finishRetire() {
|
|
205
|
+
try {
|
|
206
|
+
await this.device.queue.onSubmittedWorkDone();
|
|
207
|
+
} catch (cause) {
|
|
208
|
+
return err(
|
|
209
|
+
new RenderGraphError({
|
|
210
|
+
code: "resource-retire-failed",
|
|
211
|
+
expected: "the GPU submission fence resolves before graph resources retire",
|
|
212
|
+
hint: "recover the device before retiring the replacement generation",
|
|
213
|
+
detail: { generation: this.generation, cause }
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
let firstFailure;
|
|
218
|
+
for (const compiled of this.resources.values()) {
|
|
219
|
+
if (compiled.record.origin === "imported") continue;
|
|
220
|
+
if (compiled.texture === void 0 && compiled.buffer === void 0) continue;
|
|
221
|
+
try {
|
|
222
|
+
const destroyed = compiled.record.kind === "texture" ? this.device.destroyTexture(compiled.texture) : this.device.destroyBuffer(compiled.buffer);
|
|
223
|
+
if (!destroyed.ok && firstFailure === void 0) {
|
|
224
|
+
firstFailure = new RenderGraphError({
|
|
225
|
+
code: "resource-retire-failed",
|
|
226
|
+
expected: `graph-created resource '${compiled.record.label}' retires exactly once`,
|
|
227
|
+
hint: "inspect detail.cause for the RHI destroy refusal",
|
|
228
|
+
detail: {
|
|
229
|
+
generation: this.generation,
|
|
230
|
+
resourceLabel: compiled.record.label,
|
|
231
|
+
cause: destroyed.error
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} catch (cause) {
|
|
236
|
+
firstFailure ??= new RenderGraphError({
|
|
237
|
+
code: "resource-retire-failed",
|
|
238
|
+
expected: `graph-created resource '${compiled.record.label}' retires exactly once`,
|
|
239
|
+
hint: "inspect detail.cause for the RHI destroy failure",
|
|
240
|
+
detail: { generation: this.generation, resourceLabel: compiled.record.label, cause }
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return firstFailure === void 0 ? ok(void 0) : err(firstFailure);
|
|
245
|
+
}
|
|
246
|
+
resolveFrameResources(frame) {
|
|
247
|
+
const buffers = /* @__PURE__ */ new Map();
|
|
248
|
+
const textures = /* @__PURE__ */ new Map();
|
|
249
|
+
const views = /* @__PURE__ */ new Map();
|
|
250
|
+
for (const compiled of this.resources.values()) {
|
|
251
|
+
if (compiled.usage === 0) continue;
|
|
252
|
+
try {
|
|
253
|
+
if (compiled.record.kind === "texture") {
|
|
254
|
+
const texture = compiled.record.origin === "created" ? compiled.texture : compiled.record.resolve(frame);
|
|
255
|
+
if (texture === void 0) return err(resolutionError(compiled.record.label));
|
|
256
|
+
textures.set(compiled.record.id, texture);
|
|
257
|
+
} else {
|
|
258
|
+
const buffer = compiled.record.origin === "created" ? compiled.buffer : compiled.record.resolve(frame);
|
|
259
|
+
if (buffer === void 0) return err(resolutionError(compiled.record.label));
|
|
260
|
+
buffers.set(compiled.record.id, buffer);
|
|
261
|
+
}
|
|
262
|
+
} catch (cause) {
|
|
263
|
+
return err(resolutionError(compiled.record.label, cause));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const compiledView of this.views.values()) {
|
|
267
|
+
if (this.resources.get(compiledView.record.textureId)?.usage === 0) continue;
|
|
268
|
+
if (compiledView.record.resolve !== void 0) {
|
|
269
|
+
try {
|
|
270
|
+
views.set(compiledView.record.id, compiledView.record.resolve(frame));
|
|
271
|
+
} catch (cause) {
|
|
272
|
+
return err(resolutionError(compiledView.record.label, cause));
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (compiledView.view !== void 0) {
|
|
277
|
+
views.set(compiledView.record.id, compiledView.view);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const texture = textures.get(compiledView.record.textureId);
|
|
281
|
+
if (texture === void 0) return err(resolutionError(compiledView.record.label));
|
|
282
|
+
const created = this.device.createTextureView(texture, compiledView.record.descriptor);
|
|
283
|
+
if (!created.ok) return err(resolutionError(compiledView.record.label, created.error));
|
|
284
|
+
views.set(compiledView.record.id, created.value);
|
|
285
|
+
}
|
|
286
|
+
return ok({ buffers, textures, views });
|
|
287
|
+
}
|
|
288
|
+
createPassResolver(pass, frameResources) {
|
|
289
|
+
const lookup = (resource, expectedKind) => {
|
|
290
|
+
const data = handleData(resource);
|
|
291
|
+
const resourceId = data?.kind === "texture-view" ? data.textureId : data?.id;
|
|
292
|
+
if (data === void 0 || data.owner !== this.owner || data.kind !== expectedKind) {
|
|
293
|
+
return err(
|
|
294
|
+
new RenderGraphError({
|
|
295
|
+
code: "foreign-resource-handle",
|
|
296
|
+
expected: `pass '${pass.name}' resolves a handle owned by this compiled graph`,
|
|
297
|
+
hint: "use only handles created by the builder that declared this pass",
|
|
298
|
+
detail: { passName: pass.name }
|
|
299
|
+
})
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (resourceId === void 0 || !pass.resourceIds.has(resourceId)) {
|
|
303
|
+
const label = this.resources.get(resourceId ?? -1)?.record.label;
|
|
304
|
+
return err(
|
|
305
|
+
new RenderGraphError({
|
|
306
|
+
code: "resource-not-declared-by-pass",
|
|
307
|
+
expected: `pass '${pass.name}' resolves only resources present in its accesses`,
|
|
308
|
+
hint: "add the resource access to this pass before resolving it",
|
|
309
|
+
detail: { passName: pass.name, resourceLabel: label }
|
|
310
|
+
})
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (data.kind === "texture-view" && !pass.viewIds.has(data.id)) {
|
|
314
|
+
const label = this.views.get(data.id)?.record.label;
|
|
315
|
+
return err(
|
|
316
|
+
new RenderGraphError({
|
|
317
|
+
code: "resource-not-declared-by-pass",
|
|
318
|
+
expected: `pass '${pass.name}' resolves only texture views present in its accesses`,
|
|
319
|
+
hint: "add this exact texture view to the pass accesses",
|
|
320
|
+
detail: { passName: pass.name, resourceLabel: label }
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return ok(data);
|
|
325
|
+
};
|
|
326
|
+
return {
|
|
327
|
+
buffer: (resource) => {
|
|
328
|
+
const data = lookup(resource, "buffer");
|
|
329
|
+
if (!data.ok) return data;
|
|
330
|
+
const buffer = frameResources.buffers.get(data.value.id);
|
|
331
|
+
return buffer === void 0 ? err(resolutionError(this.resources.get(data.value.id)?.record.label ?? "buffer")) : ok(buffer);
|
|
332
|
+
},
|
|
333
|
+
texture: (resource) => {
|
|
334
|
+
const data = lookup(resource, "texture");
|
|
335
|
+
if (!data.ok) return data;
|
|
336
|
+
const texture = frameResources.textures.get(data.value.id);
|
|
337
|
+
return texture === void 0 ? err(resolutionError(this.resources.get(data.value.id)?.record.label ?? "texture")) : ok(texture);
|
|
338
|
+
},
|
|
339
|
+
textureView: (resource) => {
|
|
340
|
+
const data = lookup(resource, "texture-view");
|
|
341
|
+
if (!data.ok) return data;
|
|
342
|
+
const view = frameResources.views.get(data.value.id);
|
|
343
|
+
return view === void 0 ? err(resolutionError(this.views.get(data.value.id)?.record.label ?? "texture view")) : ok(view);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// src/builder.ts
|
|
350
|
+
var BUFFER_USAGE = {
|
|
351
|
+
copySrc: 4,
|
|
352
|
+
copyDst: 8,
|
|
353
|
+
index: 16,
|
|
354
|
+
vertex: 32,
|
|
355
|
+
uniform: 64,
|
|
356
|
+
storage: 128,
|
|
357
|
+
indirect: 256
|
|
358
|
+
};
|
|
359
|
+
var TEXTURE_USAGE = {
|
|
360
|
+
copySrc: 1,
|
|
361
|
+
copyDst: 2,
|
|
362
|
+
textureBinding: 4,
|
|
363
|
+
storageBinding: 8,
|
|
364
|
+
renderAttachment: 16
|
|
365
|
+
};
|
|
366
|
+
var nextGeneration = 1;
|
|
367
|
+
function bufferUsage(access) {
|
|
368
|
+
switch (access) {
|
|
369
|
+
case "uniform-read":
|
|
370
|
+
return BUFFER_USAGE.uniform;
|
|
371
|
+
case "storage-read":
|
|
372
|
+
case "storage-write":
|
|
373
|
+
case "storage-read-write":
|
|
374
|
+
return BUFFER_USAGE.storage;
|
|
375
|
+
case "indirect-read":
|
|
376
|
+
return BUFFER_USAGE.indirect;
|
|
377
|
+
case "vertex-read":
|
|
378
|
+
return BUFFER_USAGE.vertex;
|
|
379
|
+
case "index-read":
|
|
380
|
+
return BUFFER_USAGE.index;
|
|
381
|
+
case "copy-src":
|
|
382
|
+
return BUFFER_USAGE.copySrc;
|
|
383
|
+
case "copy-dst":
|
|
384
|
+
return BUFFER_USAGE.copyDst;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function textureUsage(access) {
|
|
388
|
+
switch (access) {
|
|
389
|
+
case "sampled-read":
|
|
390
|
+
return TEXTURE_USAGE.textureBinding;
|
|
391
|
+
case "storage-read":
|
|
392
|
+
case "storage-write":
|
|
393
|
+
case "storage-read-write":
|
|
394
|
+
return TEXTURE_USAGE.storageBinding;
|
|
395
|
+
case "color-attachment":
|
|
396
|
+
case "depth-stencil-read":
|
|
397
|
+
case "depth-stencil-write":
|
|
398
|
+
return TEXTURE_USAGE.renderAttachment;
|
|
399
|
+
case "copy-src":
|
|
400
|
+
return TEXTURE_USAGE.copySrc;
|
|
401
|
+
case "copy-dst":
|
|
402
|
+
return TEXTURE_USAGE.copyDst;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function accessMode(access) {
|
|
406
|
+
switch (access) {
|
|
407
|
+
case "storage-read-write":
|
|
408
|
+
return { read: true, write: true };
|
|
409
|
+
case "storage-write":
|
|
410
|
+
case "color-attachment":
|
|
411
|
+
case "depth-stencil-write":
|
|
412
|
+
case "copy-dst":
|
|
413
|
+
return { read: false, write: true };
|
|
414
|
+
default:
|
|
415
|
+
return { read: true, write: false };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function rangesOverlap(left, right) {
|
|
419
|
+
if (left === void 0 || right === void 0) return true;
|
|
420
|
+
const aspectOverlap = left.aspect === "all" || right.aspect === "all" || left.aspect === right.aspect;
|
|
421
|
+
return aspectOverlap && left.mipStart < right.mipEnd && right.mipStart < left.mipEnd && left.layerStart < right.layerEnd && right.layerStart < left.layerEnd;
|
|
422
|
+
}
|
|
423
|
+
function freezeInfo(info) {
|
|
424
|
+
return Object.freeze({
|
|
425
|
+
passes: Object.freeze(
|
|
426
|
+
info.passes.map(
|
|
427
|
+
(pass) => Object.freeze({
|
|
428
|
+
...pass,
|
|
429
|
+
accesses: Object.freeze(pass.accesses.map((access) => Object.freeze({ ...access }))),
|
|
430
|
+
dependencies: Object.freeze([...pass.dependencies])
|
|
431
|
+
})
|
|
432
|
+
)
|
|
433
|
+
),
|
|
434
|
+
resources: Object.freeze(info.resources.map((resource) => Object.freeze({ ...resource })))
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
var RenderGraphBuilder = class {
|
|
438
|
+
owner = Object.freeze({});
|
|
439
|
+
labels = /* @__PURE__ */ new Set();
|
|
440
|
+
resources = /* @__PURE__ */ new Map();
|
|
441
|
+
views = /* @__PURE__ */ new Map();
|
|
442
|
+
passes = [];
|
|
443
|
+
passNames = /* @__PURE__ */ new Set();
|
|
444
|
+
nextId = 1;
|
|
445
|
+
sealed = false;
|
|
446
|
+
createTexture(label, descriptor) {
|
|
447
|
+
const writable = this.ensureWritable();
|
|
448
|
+
if (!writable.ok) return writable;
|
|
449
|
+
const unique = this.reserveLabel(label);
|
|
450
|
+
if (!unique.ok) return unique;
|
|
451
|
+
const id = this.nextId++;
|
|
452
|
+
this.resources.set(id, { id, label, kind: "texture", origin: "created", descriptor });
|
|
453
|
+
return ok(textureHandle(this.owner, id));
|
|
454
|
+
}
|
|
455
|
+
importTexture(label, descriptor, resolve) {
|
|
456
|
+
const writable = this.ensureWritable();
|
|
457
|
+
if (!writable.ok) return writable;
|
|
458
|
+
const unique = this.reserveLabel(label);
|
|
459
|
+
if (!unique.ok) return unique;
|
|
460
|
+
const id = this.nextId++;
|
|
461
|
+
this.resources.set(id, {
|
|
462
|
+
id,
|
|
463
|
+
label,
|
|
464
|
+
kind: "texture",
|
|
465
|
+
origin: "imported",
|
|
466
|
+
descriptor,
|
|
467
|
+
resolve
|
|
468
|
+
});
|
|
469
|
+
return ok(textureHandle(this.owner, id));
|
|
470
|
+
}
|
|
471
|
+
createBuffer(label, descriptor) {
|
|
472
|
+
const writable = this.ensureWritable();
|
|
473
|
+
if (!writable.ok) return writable;
|
|
474
|
+
const unique = this.reserveLabel(label);
|
|
475
|
+
if (!unique.ok) return unique;
|
|
476
|
+
const id = this.nextId++;
|
|
477
|
+
this.resources.set(id, { id, label, kind: "buffer", origin: "created", descriptor });
|
|
478
|
+
return ok(bufferHandle(this.owner, id));
|
|
479
|
+
}
|
|
480
|
+
importBuffer(label, descriptor, resolve) {
|
|
481
|
+
const writable = this.ensureWritable();
|
|
482
|
+
if (!writable.ok) return writable;
|
|
483
|
+
const unique = this.reserveLabel(label);
|
|
484
|
+
if (!unique.ok) return unique;
|
|
485
|
+
const id = this.nextId++;
|
|
486
|
+
this.resources.set(id, {
|
|
487
|
+
id,
|
|
488
|
+
label,
|
|
489
|
+
kind: "buffer",
|
|
490
|
+
origin: "imported",
|
|
491
|
+
descriptor,
|
|
492
|
+
resolve
|
|
493
|
+
});
|
|
494
|
+
return ok(bufferHandle(this.owner, id));
|
|
495
|
+
}
|
|
496
|
+
view(texture, descriptor = {}) {
|
|
497
|
+
const writable = this.ensureWritable();
|
|
498
|
+
if (!writable.ok) return writable;
|
|
499
|
+
const data = handleData(texture);
|
|
500
|
+
if (data?.kind !== "texture" || data.owner !== this.owner) {
|
|
501
|
+
return err(this.foreignHandleError());
|
|
502
|
+
}
|
|
503
|
+
const textureRecord = this.resources.get(data.id);
|
|
504
|
+
if (textureRecord?.kind !== "texture") return err(this.foreignHandleError());
|
|
505
|
+
const label = descriptor.label ?? `${textureRecord.label}.view.${this.nextId}`;
|
|
506
|
+
const unique = this.reserveLabel(label);
|
|
507
|
+
if (!unique.ok) return unique;
|
|
508
|
+
const id = this.nextId++;
|
|
509
|
+
this.views.set(id, { id, label, textureId: data.id, descriptor });
|
|
510
|
+
return ok(textureViewHandle(this.owner, id, data.id));
|
|
511
|
+
}
|
|
512
|
+
importView(texture, descriptor, resolve) {
|
|
513
|
+
const writable = this.ensureWritable();
|
|
514
|
+
if (!writable.ok) return writable;
|
|
515
|
+
const data = handleData(texture);
|
|
516
|
+
if (data?.kind !== "texture" || data.owner !== this.owner) {
|
|
517
|
+
return err(this.foreignHandleError());
|
|
518
|
+
}
|
|
519
|
+
const textureRecord = this.resources.get(data.id);
|
|
520
|
+
if (textureRecord?.kind !== "texture" || textureRecord.origin !== "imported") {
|
|
521
|
+
return err(
|
|
522
|
+
new RenderGraphError({
|
|
523
|
+
code: "resource-descriptor-invalid",
|
|
524
|
+
expected: "an imported view belongs to an imported texture",
|
|
525
|
+
hint: "use view() for graph-created textures and importView() for host-owned views",
|
|
526
|
+
detail: {
|
|
527
|
+
resourceLabel: textureRecord?.label ?? "foreign",
|
|
528
|
+
field: "origin",
|
|
529
|
+
expected: "imported",
|
|
530
|
+
actual: textureRecord?.origin ?? "foreign"
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
const label = descriptor.label ?? `${textureRecord.label}.view.${this.nextId}`;
|
|
536
|
+
const unique = this.reserveLabel(label);
|
|
537
|
+
if (!unique.ok) return unique;
|
|
538
|
+
const id = this.nextId++;
|
|
539
|
+
this.views.set(id, { id, label, textureId: data.id, descriptor, resolve });
|
|
540
|
+
return ok(textureViewHandle(this.owner, id, data.id));
|
|
541
|
+
}
|
|
542
|
+
addRasterPass(name, descriptor) {
|
|
543
|
+
return this.addPass(name, { kind: "raster", descriptor });
|
|
544
|
+
}
|
|
545
|
+
addComputePass(name, descriptor) {
|
|
546
|
+
return this.addPass(name, { kind: "compute", descriptor });
|
|
547
|
+
}
|
|
548
|
+
addCopyPass(name, descriptor) {
|
|
549
|
+
return this.addPass(name, { kind: "copy", descriptor });
|
|
550
|
+
}
|
|
551
|
+
compile(options) {
|
|
552
|
+
const writable = this.ensureWritable();
|
|
553
|
+
if (!writable.ok) return writable;
|
|
554
|
+
this.sealed = true;
|
|
555
|
+
const descriptors = this.validateDescriptors(options.surfaceSize);
|
|
556
|
+
if (!descriptors.ok) return descriptors;
|
|
557
|
+
const analyzed = this.analyze(options.device.caps);
|
|
558
|
+
if (!analyzed.ok) return analyzed;
|
|
559
|
+
const allocated = this.allocate(
|
|
560
|
+
options,
|
|
561
|
+
analyzed.value.usageByResource,
|
|
562
|
+
analyzed.value.firstUseByResource,
|
|
563
|
+
analyzed.value.lastUseByResource
|
|
564
|
+
);
|
|
565
|
+
if (!allocated.ok) return allocated;
|
|
566
|
+
const info = freezeInfo({
|
|
567
|
+
passes: analyzed.value.passes.map((pass, executionIndex) => ({
|
|
568
|
+
name: pass.name,
|
|
569
|
+
kind: pass.pass.kind,
|
|
570
|
+
executionIndex,
|
|
571
|
+
accesses: pass.pass.descriptor.accesses.map((access) => {
|
|
572
|
+
const id = accessResourceId(access);
|
|
573
|
+
return {
|
|
574
|
+
resource: this.resources.get(id ?? -1)?.label ?? "foreign",
|
|
575
|
+
usage: access.usage
|
|
576
|
+
};
|
|
577
|
+
}),
|
|
578
|
+
dependencies: pass.dependencies.map(
|
|
579
|
+
(dependency) => analyzed.value.passes[dependency]?.name ?? "unknown"
|
|
580
|
+
)
|
|
581
|
+
})),
|
|
582
|
+
resources: [...allocated.value.resources.values()].map((resource) => ({
|
|
583
|
+
label: resource.record.label,
|
|
584
|
+
kind: resource.record.kind,
|
|
585
|
+
origin: resource.record.origin,
|
|
586
|
+
firstUse: resource.firstUse,
|
|
587
|
+
lastUse: resource.lastUse,
|
|
588
|
+
derivedUsage: resource.usage
|
|
589
|
+
}))
|
|
590
|
+
});
|
|
591
|
+
return ok(
|
|
592
|
+
new CompiledRenderGraphImpl(
|
|
593
|
+
nextGeneration++,
|
|
594
|
+
this.owner,
|
|
595
|
+
options.device,
|
|
596
|
+
allocated.value.resources,
|
|
597
|
+
allocated.value.views,
|
|
598
|
+
Object.freeze(analyzed.value.passes),
|
|
599
|
+
info
|
|
600
|
+
)
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
addPass(name, pass) {
|
|
604
|
+
const writable = this.ensureWritable();
|
|
605
|
+
if (!writable.ok) return writable;
|
|
606
|
+
if (this.passNames.has(name)) {
|
|
607
|
+
return err(
|
|
608
|
+
new RenderGraphError({
|
|
609
|
+
code: "duplicate-pass-name",
|
|
610
|
+
expected: `pass name '${name}' is unique within one builder`,
|
|
611
|
+
hint: `rename the second '${name}' pass; names are diagnostics, not identity`,
|
|
612
|
+
detail: { passName: name }
|
|
613
|
+
})
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
for (const access of pass.descriptor.accesses) {
|
|
617
|
+
const valid = this.validateAccessHandle(name, access);
|
|
618
|
+
if (!valid.ok) return valid;
|
|
619
|
+
}
|
|
620
|
+
this.passNames.add(name);
|
|
621
|
+
this.passes.push({ id: this.passes.length, name, pass });
|
|
622
|
+
return ok(void 0);
|
|
623
|
+
}
|
|
624
|
+
validateAccessHandle(passName, access) {
|
|
625
|
+
const data = handleData(access.resource);
|
|
626
|
+
if (data === void 0 || data.owner !== this.owner) {
|
|
627
|
+
return err(this.foreignHandleError(passName));
|
|
628
|
+
}
|
|
629
|
+
if (data.kind === "texture-view") {
|
|
630
|
+
if (!this.views.has(data.id)) return err(this.foreignHandleError(passName));
|
|
631
|
+
return ok(void 0);
|
|
632
|
+
}
|
|
633
|
+
if (data.kind !== "buffer" || !this.resources.has(data.id)) {
|
|
634
|
+
return err(this.foreignHandleError(passName));
|
|
635
|
+
}
|
|
636
|
+
return ok(void 0);
|
|
637
|
+
}
|
|
638
|
+
analyze(caps) {
|
|
639
|
+
const usageByResource = /* @__PURE__ */ new Map();
|
|
640
|
+
const firstUseByResource = /* @__PURE__ */ new Map();
|
|
641
|
+
const lastUseByResource = /* @__PURE__ */ new Map();
|
|
642
|
+
const compiledPasses = [];
|
|
643
|
+
const history = [];
|
|
644
|
+
for (let passIndex = 0; passIndex < this.passes.length; passIndex++) {
|
|
645
|
+
const pass = this.passes[passIndex];
|
|
646
|
+
if (pass === void 0) continue;
|
|
647
|
+
const capability = this.validateCapabilities(pass, caps);
|
|
648
|
+
if (!capability.ok) return capability;
|
|
649
|
+
const normalized = [];
|
|
650
|
+
for (const access of pass.pass.descriptor.accesses) {
|
|
651
|
+
const item = this.normalizeAccess(passIndex, pass, access);
|
|
652
|
+
if (!item.ok) return item;
|
|
653
|
+
normalized.push(item.value);
|
|
654
|
+
const usage = this.resources.get(item.value.resourceId)?.kind === "buffer" ? bufferUsage(access.usage) : textureUsage(access.usage);
|
|
655
|
+
usageByResource.set(
|
|
656
|
+
item.value.resourceId,
|
|
657
|
+
(usageByResource.get(item.value.resourceId) ?? 0) | usage
|
|
658
|
+
);
|
|
659
|
+
if (!firstUseByResource.has(item.value.resourceId)) {
|
|
660
|
+
firstUseByResource.set(item.value.resourceId, passIndex);
|
|
661
|
+
}
|
|
662
|
+
lastUseByResource.set(item.value.resourceId, passIndex);
|
|
663
|
+
}
|
|
664
|
+
const conflict = this.validatePassAccesses(pass, normalized);
|
|
665
|
+
if (!conflict.ok) return conflict;
|
|
666
|
+
const attachments = this.validateAttachments(pass);
|
|
667
|
+
if (!attachments.ok) return attachments;
|
|
668
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
669
|
+
for (const current of normalized) {
|
|
670
|
+
if (current.read) {
|
|
671
|
+
const priorWrite = this.findPriorWrite(history, current);
|
|
672
|
+
if (priorWrite !== void 0) {
|
|
673
|
+
dependencies.add(priorWrite.passIndex);
|
|
674
|
+
} else if (this.resources.get(current.resourceId)?.origin === "created") {
|
|
675
|
+
const label = this.resources.get(current.resourceId)?.label;
|
|
676
|
+
return err(
|
|
677
|
+
new RenderGraphError({
|
|
678
|
+
code: "uninitialized-read",
|
|
679
|
+
expected: `graph-created resource '${label}' is written before pass '${pass.name}' reads it`,
|
|
680
|
+
hint: "add a clear/write/copy-dst pass before the first read, or import initialized data",
|
|
681
|
+
detail: { passName: pass.name, resourceLabel: label, usage: current.usage }
|
|
682
|
+
})
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (current.write) {
|
|
687
|
+
for (const dependency of this.findWriteDependencies(history, current)) {
|
|
688
|
+
dependencies.add(dependency);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
history.push(...normalized);
|
|
693
|
+
compiledPasses.push({
|
|
694
|
+
...pass,
|
|
695
|
+
dependencies: Object.freeze([...dependencies].sort((left, right) => left - right)),
|
|
696
|
+
resourceIds: new Set(normalized.map((access) => access.resourceId)),
|
|
697
|
+
viewIds: new Set(
|
|
698
|
+
normalized.flatMap((access) => access.viewId === void 0 ? [] : [access.viewId])
|
|
699
|
+
)
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
for (const [resourceId, usage] of usageByResource) {
|
|
703
|
+
const resource = this.resources.get(resourceId);
|
|
704
|
+
if (resource?.origin !== "imported") continue;
|
|
705
|
+
if ((resource.descriptor.usage & usage) !== usage) {
|
|
706
|
+
return err(
|
|
707
|
+
new RenderGraphError({
|
|
708
|
+
code: "import-usage-mismatch",
|
|
709
|
+
expected: `imported resource '${resource.label}' physical usage contains derived graph usage ${usage}`,
|
|
710
|
+
hint: "recreate the imported resource with every usage declared by graph accesses",
|
|
711
|
+
detail: {
|
|
712
|
+
resourceLabel: resource.label,
|
|
713
|
+
field: "usage",
|
|
714
|
+
expected: String(usage),
|
|
715
|
+
actual: resource.descriptor.usage
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return ok({
|
|
722
|
+
passes: compiledPasses,
|
|
723
|
+
usageByResource,
|
|
724
|
+
firstUseByResource,
|
|
725
|
+
lastUseByResource
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
normalizeAccess(passIndex, pass, access) {
|
|
729
|
+
const passName = pass.name;
|
|
730
|
+
const data = handleData(access.resource);
|
|
731
|
+
if (data === void 0 || data.owner !== this.owner) {
|
|
732
|
+
return err(this.foreignHandleError(passName));
|
|
733
|
+
}
|
|
734
|
+
const mode = this.accessModeForPass(pass, access);
|
|
735
|
+
if (data.kind === "buffer") {
|
|
736
|
+
return ok({ passIndex, passName, resourceId: data.id, usage: access.usage, ...mode });
|
|
737
|
+
}
|
|
738
|
+
if (data.kind !== "texture-view") return err(this.foreignHandleError(passName));
|
|
739
|
+
const view = this.views.get(data.id);
|
|
740
|
+
const texture = this.resources.get(data.textureId);
|
|
741
|
+
if (view === void 0 || texture?.kind !== "texture") {
|
|
742
|
+
return err(this.foreignHandleError(passName));
|
|
743
|
+
}
|
|
744
|
+
const mipLevels = texture.descriptor.mipLevelCount ?? 1;
|
|
745
|
+
const layers = typeof texture.descriptor.size === "object" ? texture.descriptor.size.depthOrArrayLayers ?? 1 : 1;
|
|
746
|
+
const mipStart = view.descriptor.baseMipLevel ?? 0;
|
|
747
|
+
const layerStart = view.descriptor.baseArrayLayer ?? 0;
|
|
748
|
+
return ok({
|
|
749
|
+
passIndex,
|
|
750
|
+
passName,
|
|
751
|
+
resourceId: data.textureId,
|
|
752
|
+
viewId: data.id,
|
|
753
|
+
usage: access.usage,
|
|
754
|
+
...mode,
|
|
755
|
+
range: {
|
|
756
|
+
mipStart,
|
|
757
|
+
mipEnd: mipStart + (view.descriptor.mipLevelCount ?? mipLevels - mipStart),
|
|
758
|
+
layerStart,
|
|
759
|
+
layerEnd: layerStart + (view.descriptor.arrayLayerCount ?? layers - layerStart),
|
|
760
|
+
aspect: view.descriptor.aspect ?? "all"
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
accessModeForPass(pass, access) {
|
|
765
|
+
const base = accessMode(access.usage);
|
|
766
|
+
if (pass.pass.kind !== "raster" || handleData(access.resource)?.kind !== "texture-view") {
|
|
767
|
+
return base;
|
|
768
|
+
}
|
|
769
|
+
if (access.usage === "color-attachment") {
|
|
770
|
+
const attachment = pass.pass.descriptor.colorAttachments.find(
|
|
771
|
+
(candidate) => candidate.view === access.resource
|
|
772
|
+
);
|
|
773
|
+
return attachment?.loadOp === "load" ? { read: true, write: true } : base;
|
|
774
|
+
}
|
|
775
|
+
if (access.usage === "depth-stencil-write") {
|
|
776
|
+
const attachment = pass.pass.descriptor.depthStencilAttachment;
|
|
777
|
+
if (attachment?.view === access.resource && (attachment.depthLoadOp === "load" || attachment.stencilLoadOp === "load")) {
|
|
778
|
+
return { read: true, write: true };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return base;
|
|
782
|
+
}
|
|
783
|
+
validateCapabilities(pass, caps) {
|
|
784
|
+
if (pass.pass.kind === "compute" && !caps.compute) {
|
|
785
|
+
return this.capabilityError(pass.name, "compute");
|
|
786
|
+
}
|
|
787
|
+
for (const access of pass.pass.descriptor.accesses) {
|
|
788
|
+
if ((access.usage === "storage-read" || access.usage === "storage-write" || access.usage === "storage-read-write") && handleData(access.resource)?.kind === "buffer" && !caps.storageBuffer) {
|
|
789
|
+
return this.capabilityError(pass.name, "storage-buffer", access);
|
|
790
|
+
}
|
|
791
|
+
if ((access.usage === "storage-read" || access.usage === "storage-write" || access.usage === "storage-read-write") && handleData(access.resource)?.kind === "texture-view" && !caps.storageTexture) {
|
|
792
|
+
return this.capabilityError(pass.name, "storage-texture", access);
|
|
793
|
+
}
|
|
794
|
+
if (access.usage === "indirect-read" && !caps.indirectDrawing) {
|
|
795
|
+
return this.capabilityError(pass.name, "indirect", access);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return ok(void 0);
|
|
799
|
+
}
|
|
800
|
+
capabilityError(passName, capability, access) {
|
|
801
|
+
const resourceId = access === void 0 ? void 0 : accessResourceId(access);
|
|
802
|
+
return err(
|
|
803
|
+
new RenderGraphError({
|
|
804
|
+
code: "capability-missing",
|
|
805
|
+
expected: `pass '${passName}' is built only when capability '${capability}' is available`,
|
|
806
|
+
hint: "select the fallback algorithm before adding this pass to the builder",
|
|
807
|
+
detail: {
|
|
808
|
+
passName,
|
|
809
|
+
capability,
|
|
810
|
+
...resourceId === void 0 ? {} : { resourceLabel: this.resources.get(resourceId)?.label, usage: access?.usage }
|
|
811
|
+
}
|
|
812
|
+
})
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
validatePassAccesses(pass, accesses) {
|
|
816
|
+
for (let leftIndex = 0; leftIndex < accesses.length; leftIndex++) {
|
|
817
|
+
const left = accesses[leftIndex];
|
|
818
|
+
if (left === void 0) continue;
|
|
819
|
+
for (let rightIndex = leftIndex + 1; rightIndex < accesses.length; rightIndex++) {
|
|
820
|
+
const right = accesses[rightIndex];
|
|
821
|
+
if (right === void 0 || left.resourceId !== right.resourceId || !rangesOverlap(left.range, right.range)) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (!left.write && !right.write) continue;
|
|
825
|
+
if (left.usage === right.usage && left.usage === "storage-read-write") continue;
|
|
826
|
+
const label = this.resources.get(left.resourceId)?.label;
|
|
827
|
+
return err(
|
|
828
|
+
new RenderGraphError({
|
|
829
|
+
code: "access-conflict",
|
|
830
|
+
expected: `pass '${pass.name}' uses resource '${label}' in one compatible WebGPU usage scope`,
|
|
831
|
+
hint: "split conflicting read/write roles into ordered passes or use storage-read-write once",
|
|
832
|
+
detail: {
|
|
833
|
+
passName: pass.name,
|
|
834
|
+
resourceLabel: label,
|
|
835
|
+
accesses: [left.usage, right.usage]
|
|
836
|
+
}
|
|
837
|
+
})
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (pass.pass.kind === "copy") {
|
|
842
|
+
const invalid = accesses.find(
|
|
843
|
+
(access) => access.usage !== "copy-src" && access.usage !== "copy-dst"
|
|
844
|
+
);
|
|
845
|
+
if (invalid !== void 0) {
|
|
846
|
+
return err(
|
|
847
|
+
new RenderGraphError({
|
|
848
|
+
code: "access-conflict",
|
|
849
|
+
expected: `copy pass '${pass.name}' declares only copy-src/copy-dst accesses`,
|
|
850
|
+
hint: "move shader or attachment work into raster/compute passes",
|
|
851
|
+
detail: { passName: pass.name, usage: invalid.usage }
|
|
852
|
+
})
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return ok(void 0);
|
|
857
|
+
}
|
|
858
|
+
validateAttachments(pass) {
|
|
859
|
+
if (pass.pass.kind !== "raster") return ok(void 0);
|
|
860
|
+
const accesses = pass.pass.descriptor.accesses;
|
|
861
|
+
const has = (view, usage) => accesses.some((access) => access.resource === view && access.usage === usage);
|
|
862
|
+
for (const attachment of pass.pass.descriptor.colorAttachments) {
|
|
863
|
+
if (!has(attachment.view, "color-attachment")) {
|
|
864
|
+
return this.missingAttachmentAccess(pass.name, attachment.view, "color-attachment");
|
|
865
|
+
}
|
|
866
|
+
if (attachment.resolveTarget !== void 0 && !has(attachment.resolveTarget, "color-attachment")) {
|
|
867
|
+
return this.missingAttachmentAccess(
|
|
868
|
+
pass.name,
|
|
869
|
+
attachment.resolveTarget,
|
|
870
|
+
"color-attachment"
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const depth = pass.pass.descriptor.depthStencilAttachment;
|
|
875
|
+
if (depth !== void 0) {
|
|
876
|
+
const usage = depth.depthReadOnly === true ? "depth-stencil-read" : "depth-stencil-write";
|
|
877
|
+
if (!has(depth.view, usage))
|
|
878
|
+
return this.missingAttachmentAccess(pass.name, depth.view, usage);
|
|
879
|
+
}
|
|
880
|
+
return ok(void 0);
|
|
881
|
+
}
|
|
882
|
+
missingAttachmentAccess(passName, view, usage) {
|
|
883
|
+
const data = handleData(view);
|
|
884
|
+
const label = data?.kind === "texture-view" ? this.views.get(data.id)?.label : void 0;
|
|
885
|
+
return err(
|
|
886
|
+
new RenderGraphError({
|
|
887
|
+
code: "resource-not-declared-by-pass",
|
|
888
|
+
expected: `raster pass '${passName}' attachment '${label}' declares '${usage}' access`,
|
|
889
|
+
hint: "add the attachment view and matching usage to accesses",
|
|
890
|
+
detail: { passName, resourceLabel: label, usage }
|
|
891
|
+
})
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
findPriorWrite(history, current) {
|
|
895
|
+
for (let index = history.length - 1; index >= 0; index--) {
|
|
896
|
+
const prior = history[index];
|
|
897
|
+
if (prior !== void 0 && prior.resourceId === current.resourceId && prior.write && rangesOverlap(prior.range, current.range)) {
|
|
898
|
+
return prior;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return void 0;
|
|
902
|
+
}
|
|
903
|
+
findWriteDependencies(history, current) {
|
|
904
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
905
|
+
for (let index = history.length - 1; index >= 0; index--) {
|
|
906
|
+
const prior = history[index];
|
|
907
|
+
if (prior === void 0 || prior.resourceId !== current.resourceId || !rangesOverlap(prior.range, current.range)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (prior.read) dependencies.add(prior.passIndex);
|
|
911
|
+
if (prior.write) {
|
|
912
|
+
dependencies.add(prior.passIndex);
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return [...dependencies];
|
|
917
|
+
}
|
|
918
|
+
validateDescriptors(surfaceSize) {
|
|
919
|
+
if (surfaceSize.width <= 0 || surfaceSize.height <= 0) {
|
|
920
|
+
return err(
|
|
921
|
+
new RenderGraphError({
|
|
922
|
+
code: "resource-descriptor-invalid",
|
|
923
|
+
expected: "surfaceSize width and height are positive integers",
|
|
924
|
+
hint: "compile after the render surface has a non-zero physical extent",
|
|
925
|
+
detail: {
|
|
926
|
+
resourceLabel: "surface",
|
|
927
|
+
field: "surfaceSize",
|
|
928
|
+
expected: "width > 0 and height > 0",
|
|
929
|
+
actual: `${surfaceSize.width}x${surfaceSize.height}`
|
|
930
|
+
}
|
|
931
|
+
})
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
for (const resource of this.resources.values()) {
|
|
935
|
+
if (resource.kind === "buffer" && resource.descriptor.size <= 0) {
|
|
936
|
+
return err(
|
|
937
|
+
new RenderGraphError({
|
|
938
|
+
code: "resource-descriptor-invalid",
|
|
939
|
+
expected: `buffer '${resource.label}' size is greater than zero`,
|
|
940
|
+
hint: "derive a positive byte size before creating/importing the buffer",
|
|
941
|
+
detail: {
|
|
942
|
+
resourceLabel: resource.label,
|
|
943
|
+
field: "size",
|
|
944
|
+
expected: "size > 0",
|
|
945
|
+
actual: resource.descriptor.size
|
|
946
|
+
}
|
|
947
|
+
})
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
if (resource.kind === "texture") {
|
|
951
|
+
const extent = this.resolveExtent(resource.descriptor.size, surfaceSize);
|
|
952
|
+
if (extent.width <= 0 || extent.height <= 0 || extent.depthOrArrayLayers <= 0) {
|
|
953
|
+
return err(
|
|
954
|
+
new RenderGraphError({
|
|
955
|
+
code: "resource-descriptor-invalid",
|
|
956
|
+
expected: `texture '${resource.label}' extent is positive`,
|
|
957
|
+
hint: "repair the authored extent or compile surface size",
|
|
958
|
+
detail: {
|
|
959
|
+
resourceLabel: resource.label,
|
|
960
|
+
field: "size",
|
|
961
|
+
expected: "all extent axes > 0",
|
|
962
|
+
actual: `${extent.width}x${extent.height}x${extent.depthOrArrayLayers}`
|
|
963
|
+
}
|
|
964
|
+
})
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return ok(void 0);
|
|
970
|
+
}
|
|
971
|
+
allocate(options, usageByResource, firstUseByResource, lastUseByResource) {
|
|
972
|
+
const compiledResources = /* @__PURE__ */ new Map();
|
|
973
|
+
const compiledViews = /* @__PURE__ */ new Map();
|
|
974
|
+
const createdTextures = [];
|
|
975
|
+
const createdBuffers = [];
|
|
976
|
+
const discard = () => {
|
|
977
|
+
for (const texture of createdTextures) options.device.destroyTexture(texture);
|
|
978
|
+
for (const buffer of createdBuffers) options.device.destroyBuffer(buffer);
|
|
979
|
+
};
|
|
980
|
+
for (const resource of this.resources.values()) {
|
|
981
|
+
const usage = usageByResource.get(resource.id) ?? 0;
|
|
982
|
+
let texture;
|
|
983
|
+
let buffer;
|
|
984
|
+
if (resource.origin === "created" && usage !== 0) {
|
|
985
|
+
if (resource.kind === "texture") {
|
|
986
|
+
const extent = this.resolveExtent(resource.descriptor.size, options.surfaceSize);
|
|
987
|
+
const created = options.device.createTexture({
|
|
988
|
+
label: resource.label,
|
|
989
|
+
size: extent,
|
|
990
|
+
mipLevelCount: resource.descriptor.mipLevelCount ?? 1,
|
|
991
|
+
sampleCount: resource.descriptor.sampleCount ?? 1,
|
|
992
|
+
dimension: resource.descriptor.dimension ?? "2d",
|
|
993
|
+
format: resource.descriptor.format,
|
|
994
|
+
usage,
|
|
995
|
+
viewFormats: [...resource.descriptor.viewFormats ?? []]
|
|
996
|
+
});
|
|
997
|
+
if (!created.ok) {
|
|
998
|
+
discard();
|
|
999
|
+
return err(
|
|
1000
|
+
new RenderGraphError({
|
|
1001
|
+
code: "resource-allocation-failed",
|
|
1002
|
+
expected: `RHI creates graph texture '${resource.label}'`,
|
|
1003
|
+
hint: "inspect detail.rhiCode and repair the descriptor/capability route",
|
|
1004
|
+
detail: { resourceKey: resource.label, rhiCode: created.error.code }
|
|
1005
|
+
})
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
texture = created.value;
|
|
1009
|
+
createdTextures.push(texture);
|
|
1010
|
+
} else {
|
|
1011
|
+
const created = options.device.createBuffer({
|
|
1012
|
+
label: resource.label,
|
|
1013
|
+
size: resource.descriptor.size,
|
|
1014
|
+
usage,
|
|
1015
|
+
mappedAtCreation: resource.descriptor.mappedAtCreation ?? false
|
|
1016
|
+
});
|
|
1017
|
+
if (!created.ok) {
|
|
1018
|
+
discard();
|
|
1019
|
+
return err(
|
|
1020
|
+
new RenderGraphError({
|
|
1021
|
+
code: "resource-allocation-failed",
|
|
1022
|
+
expected: `RHI creates graph buffer '${resource.label}'`,
|
|
1023
|
+
hint: "inspect detail.rhiCode and repair the descriptor/capability route",
|
|
1024
|
+
detail: { resourceKey: resource.label, rhiCode: created.error.code }
|
|
1025
|
+
})
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
buffer = created.value;
|
|
1029
|
+
createdBuffers.push(buffer);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
compiledResources.set(resource.id, {
|
|
1033
|
+
record: resource,
|
|
1034
|
+
usage,
|
|
1035
|
+
firstUse: firstUseByResource.get(resource.id) ?? null,
|
|
1036
|
+
lastUse: lastUseByResource.get(resource.id) ?? null,
|
|
1037
|
+
...texture === void 0 ? {} : { texture },
|
|
1038
|
+
...buffer === void 0 ? {} : { buffer }
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
for (const view of this.views.values()) {
|
|
1042
|
+
const resource = compiledResources.get(view.textureId);
|
|
1043
|
+
let physicalView;
|
|
1044
|
+
if (resource?.record.origin === "created" && resource.texture !== void 0) {
|
|
1045
|
+
const created = options.device.createTextureView(resource.texture, view.descriptor);
|
|
1046
|
+
if (!created.ok) {
|
|
1047
|
+
discard();
|
|
1048
|
+
return err(
|
|
1049
|
+
new RenderGraphError({
|
|
1050
|
+
code: "resource-allocation-failed",
|
|
1051
|
+
expected: `RHI creates graph texture view '${view.label}'`,
|
|
1052
|
+
hint: "inspect detail.rhiCode and repair the view descriptor",
|
|
1053
|
+
detail: { resourceKey: view.label, rhiCode: created.error.code }
|
|
1054
|
+
})
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
physicalView = created.value;
|
|
1058
|
+
}
|
|
1059
|
+
compiledViews.set(view.id, {
|
|
1060
|
+
record: view,
|
|
1061
|
+
...physicalView === void 0 ? {} : { view: physicalView }
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
return ok({ resources: compiledResources, views: compiledViews });
|
|
1065
|
+
}
|
|
1066
|
+
resolveExtent(extent, surface) {
|
|
1067
|
+
if (extent === "surface") return { ...surface, depthOrArrayLayers: 1 };
|
|
1068
|
+
if (extent === "half-surface") {
|
|
1069
|
+
return {
|
|
1070
|
+
width: Math.ceil(surface.width / 2),
|
|
1071
|
+
height: Math.ceil(surface.height / 2),
|
|
1072
|
+
depthOrArrayLayers: 1
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
width: extent.width,
|
|
1077
|
+
height: extent.height,
|
|
1078
|
+
depthOrArrayLayers: extent.depthOrArrayLayers ?? 1
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
ensureWritable() {
|
|
1082
|
+
return this.sealed ? err(
|
|
1083
|
+
new RenderGraphError({
|
|
1084
|
+
code: "builder-sealed",
|
|
1085
|
+
expected: "a RenderGraphBuilder accepts declarations only before compile()",
|
|
1086
|
+
hint: "create a new builder for a changed topology",
|
|
1087
|
+
detail: {}
|
|
1088
|
+
})
|
|
1089
|
+
) : ok(void 0);
|
|
1090
|
+
}
|
|
1091
|
+
reserveLabel(label) {
|
|
1092
|
+
if (this.labels.has(label)) {
|
|
1093
|
+
return err(
|
|
1094
|
+
new RenderGraphError({
|
|
1095
|
+
code: "duplicate-resource-label",
|
|
1096
|
+
expected: `resource label '${label}' is unique within one builder`,
|
|
1097
|
+
hint: `rename the second '${label}' declaration`,
|
|
1098
|
+
detail: { resourceLabel: label }
|
|
1099
|
+
})
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
this.labels.add(label);
|
|
1103
|
+
return ok(void 0);
|
|
1104
|
+
}
|
|
1105
|
+
foreignHandleError(passName) {
|
|
1106
|
+
return new RenderGraphError({
|
|
1107
|
+
code: "foreign-resource-handle",
|
|
1108
|
+
expected: "every graph resource handle belongs to this builder",
|
|
1109
|
+
hint: "create/import/view the resource on the same builder that declares the pass",
|
|
1110
|
+
detail: { ...passName === void 0 ? {} : { passName } }
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
// src/observation.ts
|
|
1116
|
+
var COPY_SRC = 1;
|
|
1117
|
+
function observationError(code, expected, hint) {
|
|
1118
|
+
return err(new RenderGraphError({ code, expected, hint }));
|
|
1119
|
+
}
|
|
1120
|
+
function createCurrentFrameObservationLease(descriptor, currentFrameId) {
|
|
1121
|
+
if (descriptor.texture === void 0 || descriptor.texture === null) {
|
|
1122
|
+
return observationError(
|
|
1123
|
+
"observation-absent",
|
|
1124
|
+
"a producer-owned texture handle",
|
|
1125
|
+
"provide the current frame color texture before requesting an observation"
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
if (descriptor.format !== "rgba16float") {
|
|
1129
|
+
return observationError(
|
|
1130
|
+
"observation-invalid-format",
|
|
1131
|
+
"current-frame observation format 'rgba16float'",
|
|
1132
|
+
"use the producer target format without reinterpretation"
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
if (!Number.isInteger(descriptor.size.width) || !Number.isInteger(descriptor.size.height) || descriptor.size.width <= 0 || descriptor.size.height <= 0) {
|
|
1136
|
+
return observationError(
|
|
1137
|
+
"observation-invalid-size",
|
|
1138
|
+
"positive integer observation width and height",
|
|
1139
|
+
"capture a non-empty current-frame target"
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
if ((descriptor.usage & COPY_SRC) === 0) {
|
|
1143
|
+
return observationError(
|
|
1144
|
+
"observation-missing-copy-src",
|
|
1145
|
+
"current-frame texture usage includes COPY_SRC",
|
|
1146
|
+
"add COPY_SRC to the producer target before requesting readback"
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
if (descriptor.frameId !== currentFrameId) {
|
|
1150
|
+
return observationError(
|
|
1151
|
+
"observation-stale",
|
|
1152
|
+
`observation frame ${currentFrameId}`,
|
|
1153
|
+
`discard frame ${descriptor.frameId} and request the current producer target`
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
let state = "active";
|
|
1157
|
+
const lifetime = {
|
|
1158
|
+
frameId: descriptor.frameId,
|
|
1159
|
+
get state() {
|
|
1160
|
+
return state;
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
const lease = {
|
|
1164
|
+
descriptor,
|
|
1165
|
+
lifetime,
|
|
1166
|
+
get state() {
|
|
1167
|
+
return state;
|
|
1168
|
+
},
|
|
1169
|
+
beginReadback() {
|
|
1170
|
+
if (state === "retired") {
|
|
1171
|
+
return err(
|
|
1172
|
+
new RenderGraphError({
|
|
1173
|
+
code: "observation-retired",
|
|
1174
|
+
expected: "active current-frame observation lease",
|
|
1175
|
+
hint: "submit the eager copy before the producer retires this frame"
|
|
1176
|
+
})
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
return ok({ texture: descriptor.texture, descriptor, lifetime });
|
|
1180
|
+
},
|
|
1181
|
+
retire() {
|
|
1182
|
+
state = "retired";
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
return ok(lease);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/pass-registry.ts
|
|
1189
|
+
var PassRegistry = class {
|
|
1190
|
+
passes = [];
|
|
1191
|
+
add(name, descriptor, before) {
|
|
1192
|
+
if (this.passes.some((pass) => pass.name === name)) {
|
|
1193
|
+
throw new RenderGraphError({
|
|
1194
|
+
code: "duplicate-pass-name",
|
|
1195
|
+
expected: `pass name '${name}' is unique within one graph`,
|
|
1196
|
+
hint: `rename the second '${name}' pass; labels are diagnostics, not identity`,
|
|
1197
|
+
detail: { passName: name }
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
const entry = { name, descriptor };
|
|
1201
|
+
const beforeIndex = before === void 0 ? -1 : this.passes.findIndex((pass) => pass.name === before);
|
|
1202
|
+
if (beforeIndex < 0) this.passes.push(entry);
|
|
1203
|
+
else this.passes.splice(beforeIndex, 0, entry);
|
|
1204
|
+
return entry;
|
|
1205
|
+
}
|
|
1206
|
+
list() {
|
|
1207
|
+
return this.passes;
|
|
1208
|
+
}
|
|
1209
|
+
count() {
|
|
1210
|
+
return this.passes.length;
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
// src/pipeline/color-value-domain.ts
|
|
1215
|
+
var COLOR_VALUE_DOMAINS = ["linear-hdr", "linear-ldr", "display-encoded"];
|
|
1216
|
+
function isColorValueDomain(value) {
|
|
1217
|
+
return typeof value === "string" && COLOR_VALUE_DOMAINS.includes(value);
|
|
1218
|
+
}
|
|
1219
|
+
function invalidDomain(value) {
|
|
1220
|
+
return new RenderGraphError({
|
|
1221
|
+
code: "invalid-color-domain",
|
|
1222
|
+
expected: `domain is one of ${COLOR_VALUE_DOMAINS.join(", ")}`,
|
|
1223
|
+
hint: "set an explicit color domain; do not infer it from the attachment format",
|
|
1224
|
+
detail: { value: String(value) }
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
function missingDomain(resourceKey) {
|
|
1228
|
+
return new RenderGraphError({
|
|
1229
|
+
code: "missing-color-domain",
|
|
1230
|
+
expected: "every connected color resource has an explicit domain",
|
|
1231
|
+
hint: `add domain to the color resource descriptor${resourceKey === void 0 ? "" : ` '${resourceKey}'`}`,
|
|
1232
|
+
detail: { resourceKey: resourceKey ?? "<descriptor>" }
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
function serializeColorValueDomain(domain) {
|
|
1236
|
+
if (!isColorValueDomain(domain)) throw invalidDomain(domain);
|
|
1237
|
+
return JSON.stringify(domain);
|
|
1238
|
+
}
|
|
1239
|
+
function deserializeColorValueDomain(value) {
|
|
1240
|
+
let candidate = value;
|
|
1241
|
+
if (typeof value === "string") {
|
|
1242
|
+
try {
|
|
1243
|
+
candidate = JSON.parse(value);
|
|
1244
|
+
} catch {
|
|
1245
|
+
candidate = value;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return isColorValueDomain(candidate) ? ok(candidate) : err(invalidDomain(candidate));
|
|
1249
|
+
}
|
|
1250
|
+
function serializeColorResourceDescriptor(descriptor) {
|
|
1251
|
+
if (!isColorValueDomain(descriptor.domain)) throw invalidDomain(descriptor.domain);
|
|
1252
|
+
return JSON.stringify(descriptor);
|
|
1253
|
+
}
|
|
1254
|
+
function deserializeColorResourceDescriptor(value) {
|
|
1255
|
+
if (typeof value !== "object" || value === null) return err(missingDomain());
|
|
1256
|
+
const candidate = value;
|
|
1257
|
+
if (candidate.domain === void 0) return err(missingDomain());
|
|
1258
|
+
if (!isColorValueDomain(candidate.domain)) return err(invalidDomain(candidate.domain));
|
|
1259
|
+
if (typeof candidate.format !== "string" || candidate.format.length === 0) {
|
|
1260
|
+
return err(
|
|
1261
|
+
new RenderGraphError({
|
|
1262
|
+
code: "invalid-color-domain",
|
|
1263
|
+
expected: "color resource descriptor includes a non-empty format",
|
|
1264
|
+
hint: "set format separately from the explicit color domain",
|
|
1265
|
+
detail: { value: String(candidate.format) }
|
|
1266
|
+
})
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
return ok({ domain: candidate.domain, format: candidate.format });
|
|
1270
|
+
}
|
|
1271
|
+
function conversionMatches(source, destination, conversion) {
|
|
1272
|
+
if (conversion.kind === "encode-srgb") {
|
|
1273
|
+
return (source === "linear-hdr" || source === "linear-ldr") && destination === "display-encoded";
|
|
1274
|
+
}
|
|
1275
|
+
if (conversion.kind === "decode-srgb") {
|
|
1276
|
+
return source === "display-encoded" && (destination === "linear-hdr" || destination === "linear-ldr");
|
|
1277
|
+
}
|
|
1278
|
+
return source === "linear-hdr" && (destination === "linear-ldr" || destination === "display-encoded");
|
|
1279
|
+
}
|
|
1280
|
+
function validateColorDomainConnection(source, destination, conversion) {
|
|
1281
|
+
if (source === void 0 || source === null) return { ok: false, error: missingDomain("source") };
|
|
1282
|
+
if (destination === void 0 || destination === null) {
|
|
1283
|
+
return { ok: false, error: missingDomain("destination") };
|
|
1284
|
+
}
|
|
1285
|
+
if (!isColorValueDomain(source)) return { ok: false, error: invalidDomain(source) };
|
|
1286
|
+
if (!isColorValueDomain(destination)) return { ok: false, error: invalidDomain(destination) };
|
|
1287
|
+
if (source === destination) return { ok: true };
|
|
1288
|
+
if (conversion !== void 0 && conversionMatches(source, destination, conversion)) {
|
|
1289
|
+
return { ok: true };
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
ok: false,
|
|
1293
|
+
error: new RenderGraphError({
|
|
1294
|
+
code: "color-domain-mismatch",
|
|
1295
|
+
expected: `source and destination share a domain or use an explicit valid conversion (${source} -> ${destination})`,
|
|
1296
|
+
hint: "insert an explicit linear blend or output encoding pass; never mix into an encoded destination",
|
|
1297
|
+
detail: { sourceDomain: source, destinationDomain: destination }
|
|
1298
|
+
})
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/resource-registry.ts
|
|
1303
|
+
var ResourceRegistry = class {
|
|
1304
|
+
resources = /* @__PURE__ */ new Map();
|
|
1305
|
+
add(key, descriptor) {
|
|
1306
|
+
const entry = {
|
|
1307
|
+
key,
|
|
1308
|
+
descriptor,
|
|
1309
|
+
lifetime: descriptor.lifetime
|
|
1310
|
+
};
|
|
1311
|
+
return this.register(entry);
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Register a color target resource (D-8).
|
|
1315
|
+
* Same semantics as addResource with kind:'texture' plus GPU texture
|
|
1316
|
+
* allocation metadata. Existing callers default to a transient target.
|
|
1317
|
+
*/
|
|
1318
|
+
addColorTarget(name, desc) {
|
|
1319
|
+
const lifetime = desc.lifetime ?? "transient";
|
|
1320
|
+
const colorTargetMeta = {
|
|
1321
|
+
format: desc.format,
|
|
1322
|
+
size: desc.size,
|
|
1323
|
+
sample: desc.sample ?? 1,
|
|
1324
|
+
usage: desc.usage ?? 16 | 4,
|
|
1325
|
+
// RENDER_ATTACHMENT | TEXTURE_BINDING
|
|
1326
|
+
...desc.domain !== void 0 ? { domain: desc.domain } : {},
|
|
1327
|
+
...desc.viewFormats !== void 0 ? { viewFormats: desc.viewFormats } : {}
|
|
1328
|
+
};
|
|
1329
|
+
const entry = {
|
|
1330
|
+
key: name,
|
|
1331
|
+
descriptor: { kind: "texture", lifetime },
|
|
1332
|
+
lifetime,
|
|
1333
|
+
colorTarget: colorTargetMeta
|
|
1334
|
+
};
|
|
1335
|
+
return this.register(entry);
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Register a color target alias that folds into the source's physical
|
|
1339
|
+
* texture at compile time (KB-1 MoveNode pattern, D-2).
|
|
1340
|
+
* The source must already be registered via addColorTarget.
|
|
1341
|
+
*/
|
|
1342
|
+
addColorTargetAlias(name, source) {
|
|
1343
|
+
if (this.resources.has(name)) return this.duplicateResource(name);
|
|
1344
|
+
const sourceMeta = this.resources.get(source)?.colorTarget;
|
|
1345
|
+
if (sourceMeta === void 0) {
|
|
1346
|
+
return err(
|
|
1347
|
+
new RenderGraphError({
|
|
1348
|
+
code: "alias-source-missing",
|
|
1349
|
+
expected: `alias '${name}' source '${source}' must be a registered color target`,
|
|
1350
|
+
hint: `call addColorTarget('${source}', ...) before retrying alias '${name}'`,
|
|
1351
|
+
detail: { aliasKey: name, sourceKey: source }
|
|
1352
|
+
})
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
const entry = {
|
|
1356
|
+
key: name,
|
|
1357
|
+
descriptor: { kind: "texture", lifetime: "transient" },
|
|
1358
|
+
lifetime: "transient",
|
|
1359
|
+
colorTarget: {
|
|
1360
|
+
format: sourceMeta.format,
|
|
1361
|
+
size: sourceMeta.size,
|
|
1362
|
+
sample: sourceMeta.sample,
|
|
1363
|
+
usage: sourceMeta.usage,
|
|
1364
|
+
...sourceMeta.domain !== void 0 ? { domain: sourceMeta.domain } : {},
|
|
1365
|
+
...sourceMeta.viewFormats !== void 0 ? { viewFormats: sourceMeta.viewFormats } : {},
|
|
1366
|
+
aliasedFrom: source
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
return this.register(entry);
|
|
1370
|
+
}
|
|
1371
|
+
duplicateResource(key) {
|
|
1372
|
+
return err(
|
|
1373
|
+
new RenderGraphError({
|
|
1374
|
+
code: "duplicate-resource",
|
|
1375
|
+
expected: `resource key '${key}' registered exactly once`,
|
|
1376
|
+
hint: `remove the duplicate resource declaration for '${key}' or use a different key`,
|
|
1377
|
+
detail: { resourceKey: key }
|
|
1378
|
+
})
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
register(entry) {
|
|
1382
|
+
if (this.resources.has(entry.key)) return this.duplicateResource(entry.key);
|
|
1383
|
+
this.resources.set(entry.key, entry);
|
|
1384
|
+
return ok(entry);
|
|
1385
|
+
}
|
|
1386
|
+
get(key) {
|
|
1387
|
+
return this.resources.get(key);
|
|
1388
|
+
}
|
|
1389
|
+
getColorTargetMeta(key) {
|
|
1390
|
+
return this.resources.get(key)?.colorTarget;
|
|
1391
|
+
}
|
|
1392
|
+
has(key) {
|
|
1393
|
+
return this.resources.has(key);
|
|
1394
|
+
}
|
|
1395
|
+
entries() {
|
|
1396
|
+
return this.resources.values();
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
// src/graph.ts
|
|
1401
|
+
function poolKey(meta) {
|
|
1402
|
+
return `${meta.format}:${meta.width}x${meta.height}:${meta.usage}:${meta.sample}:${JSON.stringify(meta.viewFormats)}`;
|
|
1403
|
+
}
|
|
1404
|
+
var VALID_GPU_TEXTURE_FORMATS = [
|
|
1405
|
+
"r8unorm",
|
|
1406
|
+
"r8snorm",
|
|
1407
|
+
"r8uint",
|
|
1408
|
+
"r8sint",
|
|
1409
|
+
"r16unorm",
|
|
1410
|
+
"r16snorm",
|
|
1411
|
+
"r16uint",
|
|
1412
|
+
"r16sint",
|
|
1413
|
+
"r16float",
|
|
1414
|
+
"rg8unorm",
|
|
1415
|
+
"rg8snorm",
|
|
1416
|
+
"rg8uint",
|
|
1417
|
+
"rg8sint",
|
|
1418
|
+
"r32uint",
|
|
1419
|
+
"r32sint",
|
|
1420
|
+
"r32float",
|
|
1421
|
+
"rg16unorm",
|
|
1422
|
+
"rg16snorm",
|
|
1423
|
+
"rg16uint",
|
|
1424
|
+
"rg16sint",
|
|
1425
|
+
"rg16float",
|
|
1426
|
+
"rgba8unorm",
|
|
1427
|
+
"rgba8unorm-srgb",
|
|
1428
|
+
"rgba8snorm",
|
|
1429
|
+
"rgba8uint",
|
|
1430
|
+
"rgba8sint",
|
|
1431
|
+
"bgra8unorm",
|
|
1432
|
+
"bgra8unorm-srgb",
|
|
1433
|
+
"rgb9e5ufloat",
|
|
1434
|
+
"rgb10a2uint",
|
|
1435
|
+
"rgb10a2unorm",
|
|
1436
|
+
"rg11b10ufloat",
|
|
1437
|
+
"rg32uint",
|
|
1438
|
+
"rg32sint",
|
|
1439
|
+
"rg32float",
|
|
1440
|
+
"rgba16unorm",
|
|
1441
|
+
"rgba16snorm",
|
|
1442
|
+
"rgba16uint",
|
|
1443
|
+
"rgba16sint",
|
|
1444
|
+
"rgba16float",
|
|
1445
|
+
"rgba32uint",
|
|
1446
|
+
"rgba32sint",
|
|
1447
|
+
"rgba32float",
|
|
1448
|
+
"stencil8",
|
|
1449
|
+
"depth16unorm",
|
|
1450
|
+
"depth24plus",
|
|
1451
|
+
"depth24plus-stencil8",
|
|
1452
|
+
"depth32float",
|
|
1453
|
+
"depth32float-stencil8",
|
|
1454
|
+
"bc1-rgba-unorm",
|
|
1455
|
+
"bc1-rgba-unorm-srgb",
|
|
1456
|
+
"bc2-rgba-unorm",
|
|
1457
|
+
"bc2-rgba-unorm-srgb",
|
|
1458
|
+
"bc3-rgba-unorm",
|
|
1459
|
+
"bc3-rgba-unorm-srgb",
|
|
1460
|
+
"bc4-r-unorm",
|
|
1461
|
+
"bc4-r-snorm",
|
|
1462
|
+
"bc5-rg-unorm",
|
|
1463
|
+
"bc5-rg-snorm",
|
|
1464
|
+
"bc6h-rgb-ufloat",
|
|
1465
|
+
"bc6h-rgb-float",
|
|
1466
|
+
"bc7-rgba-unorm",
|
|
1467
|
+
"bc7-rgba-unorm-srgb",
|
|
1468
|
+
"etc2-rgb8unorm",
|
|
1469
|
+
"etc2-rgb8unorm-srgb",
|
|
1470
|
+
"etc2-rgb8a1unorm",
|
|
1471
|
+
"etc2-rgb8a1unorm-srgb",
|
|
1472
|
+
"etc2-rgba8unorm",
|
|
1473
|
+
"etc2-rgba8unorm-srgb",
|
|
1474
|
+
"eac-r11unorm",
|
|
1475
|
+
"eac-r11snorm",
|
|
1476
|
+
"eac-rg11unorm",
|
|
1477
|
+
"eac-rg11snorm",
|
|
1478
|
+
"astc-4x4-unorm",
|
|
1479
|
+
"astc-4x4-unorm-srgb",
|
|
1480
|
+
"astc-5x4-unorm",
|
|
1481
|
+
"astc-5x4-unorm-srgb",
|
|
1482
|
+
"astc-5x5-unorm",
|
|
1483
|
+
"astc-5x5-unorm-srgb",
|
|
1484
|
+
"astc-6x5-unorm",
|
|
1485
|
+
"astc-6x5-unorm-srgb",
|
|
1486
|
+
"astc-6x6-unorm",
|
|
1487
|
+
"astc-6x6-unorm-srgb",
|
|
1488
|
+
"astc-8x5-unorm",
|
|
1489
|
+
"astc-8x5-unorm-srgb",
|
|
1490
|
+
"astc-8x6-unorm",
|
|
1491
|
+
"astc-8x6-unorm-srgb",
|
|
1492
|
+
"astc-8x8-unorm",
|
|
1493
|
+
"astc-8x8-unorm-srgb",
|
|
1494
|
+
"astc-10x5-unorm",
|
|
1495
|
+
"astc-10x5-unorm-srgb",
|
|
1496
|
+
"astc-10x6-unorm",
|
|
1497
|
+
"astc-10x6-unorm-srgb",
|
|
1498
|
+
"astc-10x8-unorm",
|
|
1499
|
+
"astc-10x8-unorm-srgb",
|
|
1500
|
+
"astc-10x10-unorm",
|
|
1501
|
+
"astc-10x10-unorm-srgb",
|
|
1502
|
+
"astc-12x10-unorm",
|
|
1503
|
+
"astc-12x10-unorm-srgb",
|
|
1504
|
+
"astc-12x12-unorm",
|
|
1505
|
+
"astc-12x12-unorm-srgb"
|
|
1506
|
+
];
|
|
1507
|
+
var VALID_GPU_TEXTURE_FORMAT_SET = new Set(VALID_GPU_TEXTURE_FORMATS);
|
|
1508
|
+
var RenderGraph = class {
|
|
1509
|
+
resources = new ResourceRegistry();
|
|
1510
|
+
passes = new PassRegistry();
|
|
1511
|
+
compiled = null;
|
|
1512
|
+
/** Transient texture pool: keyed by descriptor, reused across compiles (D-2). */
|
|
1513
|
+
transientPool = /* @__PURE__ */ new Map();
|
|
1514
|
+
/**
|
|
1515
|
+
* Pending-destroy queue (bug-20260622): replaced textures awaiting GPU
|
|
1516
|
+
* retirement before actual device.destroyTexture. drainTransient(),
|
|
1517
|
+
* setTransientEntry(), and setPersistentEntry() push here instead of destroying immediately;
|
|
1518
|
+
* reclaimRetiredTransients() (called post-queue.submit in recordFrame) drains
|
|
1519
|
+
* the queue when the GPU signals onSubmittedWorkDone.
|
|
1520
|
+
*/
|
|
1521
|
+
pendingDestroy = [];
|
|
1522
|
+
/** Persistent textures: keyed by resource name, kept across compiles. */
|
|
1523
|
+
persistentTextures = /* @__PURE__ */ new Map();
|
|
1524
|
+
/** Swap-chain size for resolving 'swapchain' / 'half-swapchain' sizes. */
|
|
1525
|
+
swapChainWidth = 800;
|
|
1526
|
+
swapChainHeight = 600;
|
|
1527
|
+
/** Last compile-time swap-chain size; diff triggers recompile-invalidation. */
|
|
1528
|
+
compiledWidth = 800;
|
|
1529
|
+
compiledHeight = 600;
|
|
1530
|
+
/**
|
|
1531
|
+
* feat-20260612 M-4 / w15: device reference stashed at compile-time so
|
|
1532
|
+
* drain() can release pooled textures via device.destroyTexture without
|
|
1533
|
+
* a separate parameter. Set by compile(); null until the first compile.
|
|
1534
|
+
* Render-graph stays RHI-pure (no runtime dep): the destroy bookkeeping
|
|
1535
|
+
* SSOT is the RHI shim, exactly as GpuTexture.destroy() routes through it.
|
|
1536
|
+
*/
|
|
1537
|
+
lastDevice = null;
|
|
1538
|
+
/**
|
|
1539
|
+
* Set the current swap-chain dimensions (w7).
|
|
1540
|
+
* The compile allocation phase uses this to resolve 'swapchain' and
|
|
1541
|
+
* 'half-swapchain' size specifiers. Returns true when dimensions differ
|
|
1542
|
+
* from the last compile, signalling that a recompile is needed.
|
|
1543
|
+
*/
|
|
1544
|
+
setSwapChainSize(width, height) {
|
|
1545
|
+
this.swapChainWidth = width;
|
|
1546
|
+
this.swapChainHeight = height;
|
|
1547
|
+
if (width !== this.compiledWidth || height !== this.compiledHeight) {
|
|
1548
|
+
return true;
|
|
1549
|
+
}
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* w7: resolve a color-target name to its compiled TextureView.
|
|
1554
|
+
* Returns the GPU view after compile, or undefined if not yet compiled
|
|
1555
|
+
* or the name was not registered via addColorTarget.
|
|
1556
|
+
*/
|
|
1557
|
+
getColorTargetView(name) {
|
|
1558
|
+
return this.compiled?.resolvedTextures.get(name);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* w7: resolve a color-target name to its compiled GPU Texture handle.
|
|
1562
|
+
* Returns the texture after compile, or undefined if not yet compiled.
|
|
1563
|
+
*/
|
|
1564
|
+
getColorTargetTexture(name) {
|
|
1565
|
+
return this.compiled?.resolvedTextures.get(`${name}::tex`);
|
|
1566
|
+
}
|
|
1567
|
+
getColorTargetDescriptor(name) {
|
|
1568
|
+
const meta = this.resources.getColorTargetMeta(name);
|
|
1569
|
+
const texture = this.compiled?.resolvedTextures.get(`${name}::tex`);
|
|
1570
|
+
if (meta === void 0 || texture === void 0) return void 0;
|
|
1571
|
+
return {
|
|
1572
|
+
texture,
|
|
1573
|
+
format: meta.format,
|
|
1574
|
+
size: {
|
|
1575
|
+
width: this.resolveWidth(meta.size),
|
|
1576
|
+
height: this.resolveHeight(meta.size)
|
|
1577
|
+
},
|
|
1578
|
+
usage: meta.usage,
|
|
1579
|
+
sample: meta.sample
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Declare a color target alias: both names share the same physical texture.
|
|
1584
|
+
* The source must already be registered via addColorTarget.
|
|
1585
|
+
* Used for hdrComposited -> hdrColor folding (KB-1 / D-2). The returned
|
|
1586
|
+
* Result contains the opaque alias handle or a duplicate-resource error.
|
|
1587
|
+
*/
|
|
1588
|
+
addColorTargetAlias(name, source) {
|
|
1589
|
+
const result = this.resources.addColorTargetAlias(name, source);
|
|
1590
|
+
if (!result.ok) return result;
|
|
1591
|
+
return ok(name);
|
|
1592
|
+
}
|
|
1593
|
+
addResource(key, descriptor) {
|
|
1594
|
+
return this.resources.add(key, descriptor);
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Declare a color target resource that the compiler will allocate as a
|
|
1598
|
+
* transient or persistent GPU texture (D-1 / D-8). A successful Result
|
|
1599
|
+
* contains an opaque string handle that can be referenced in pass read/write
|
|
1600
|
+
* arrays and resolved to a TextureView via resolve(name) inside a pass
|
|
1601
|
+
* execute closure. A duplicate key is rejected before registry publication.
|
|
1602
|
+
*
|
|
1603
|
+
* Omitted lifetime preserves the default `transient`; `persistent` retains
|
|
1604
|
+
* identity across unchanged compiles and replaces on descriptor drift.
|
|
1605
|
+
* format/size/sample/usage are stored on the resource entry for the compile
|
|
1606
|
+
* allocation phase (w6).
|
|
1607
|
+
*/
|
|
1608
|
+
addColorTarget(name, desc) {
|
|
1609
|
+
const result = this.resources.addColorTarget(name, desc);
|
|
1610
|
+
if (!result.ok) return result;
|
|
1611
|
+
return ok(name);
|
|
1612
|
+
}
|
|
1613
|
+
addPass(name, descriptor) {
|
|
1614
|
+
return this.passes.add(name, descriptor);
|
|
1615
|
+
}
|
|
1616
|
+
/** @internal Renderer composition seam for declaring feature work at its semantic target. */
|
|
1617
|
+
_addPassBefore(name, before, descriptor) {
|
|
1618
|
+
return this.passes.add(name, descriptor, before);
|
|
1619
|
+
}
|
|
1620
|
+
addComputePass(name, descriptor) {
|
|
1621
|
+
return this.addComputePassAt(name, descriptor);
|
|
1622
|
+
}
|
|
1623
|
+
/** @internal Renderer composition seam for declaring feature work at its semantic target. */
|
|
1624
|
+
_addComputePassBefore(name, before, descriptor) {
|
|
1625
|
+
return this.addComputePassAt(name, descriptor, before);
|
|
1626
|
+
}
|
|
1627
|
+
addComputePassAt(name, descriptor, before) {
|
|
1628
|
+
return this.passes.add(
|
|
1629
|
+
name,
|
|
1630
|
+
{
|
|
1631
|
+
reads: descriptor.reads,
|
|
1632
|
+
writes: descriptor.writes,
|
|
1633
|
+
compute: true,
|
|
1634
|
+
storageBuffer: descriptor.storageBuffer ?? true,
|
|
1635
|
+
execute: (frame, resources) => {
|
|
1636
|
+
const encoder = frame.encoder;
|
|
1637
|
+
const begin = descriptor.begin?.(frame);
|
|
1638
|
+
let pass;
|
|
1639
|
+
try {
|
|
1640
|
+
pass = encoder.beginComputePass({
|
|
1641
|
+
label: name,
|
|
1642
|
+
...begin?.timestampWrites === void 0 ? {} : { timestampWrites: begin.timestampWrites }
|
|
1643
|
+
});
|
|
1644
|
+
} catch (cause) {
|
|
1645
|
+
descriptor.onBeginError?.(frame, cause);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
try {
|
|
1649
|
+
descriptor.encode({ pass, frame, resources });
|
|
1650
|
+
} finally {
|
|
1651
|
+
pass.end();
|
|
1652
|
+
}
|
|
1653
|
+
descriptor.after?.(frame);
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
before
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Validate a producer-scoped current-frame texture without exposing graph
|
|
1661
|
+
* resource names through the observation lease.
|
|
1662
|
+
*/
|
|
1663
|
+
createCurrentFrameObservationLease(descriptor, currentFrameId) {
|
|
1664
|
+
return createCurrentFrameObservationLease(descriptor, currentFrameId);
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Compile the graph into an internalized form.
|
|
1668
|
+
*
|
|
1669
|
+
* Phases (plan-strategy 3.1):
|
|
1670
|
+
* 1. Cap-gate fail-fast
|
|
1671
|
+
* 2. Unknown-resource fail-fast (every pass read/write key is registered)
|
|
1672
|
+
* 3. Dangling-read fail-fast
|
|
1673
|
+
* 4. Preserve declaration order as the temporal authority
|
|
1674
|
+
* 5. Buffer-role resolution (AC-09 / D-6.1)
|
|
1675
|
+
* 6. GPU allocation for color targets (D-1) — when device is provided and the
|
|
1676
|
+
* graph has addColorTarget resources, allocate textures via
|
|
1677
|
+
* device.createTexture/createTextureView. Errors surface as
|
|
1678
|
+
* 'resource-alloc-failed' or 'invalid-format'.
|
|
1679
|
+
*/
|
|
1680
|
+
compile(opts) {
|
|
1681
|
+
const passList = this.passes.list();
|
|
1682
|
+
const { caps, device } = opts;
|
|
1683
|
+
const capErr = this.validateCaps(passList, caps);
|
|
1684
|
+
if (capErr) return capErr;
|
|
1685
|
+
const colorDomainErr = this.validateColorDomains(passList);
|
|
1686
|
+
if (colorDomainErr) return colorDomainErr;
|
|
1687
|
+
const unknownErr = this.validateNoUnknownResource(passList);
|
|
1688
|
+
if (unknownErr) return unknownErr;
|
|
1689
|
+
const danglingErr = this.validateNoDanglingRead(passList);
|
|
1690
|
+
if (danglingErr) return danglingErr;
|
|
1691
|
+
const formatErr = this.validateColorTargetFormats();
|
|
1692
|
+
if (!formatErr.ok) return formatErr;
|
|
1693
|
+
const internalizedPasses = passList.map((pass) => {
|
|
1694
|
+
return {
|
|
1695
|
+
name: pass.name,
|
|
1696
|
+
reads: pass.descriptor.reads,
|
|
1697
|
+
writes: pass.descriptor.writes
|
|
1698
|
+
};
|
|
1699
|
+
});
|
|
1700
|
+
const resolvedBuffers = this.resolveBuffers(caps);
|
|
1701
|
+
const resizeDetected = this.swapChainWidth !== this.compiledWidth || this.swapChainHeight !== this.compiledHeight;
|
|
1702
|
+
const allocatedTextures = this.allocateColorTargets(device, resizeDetected);
|
|
1703
|
+
if (!allocatedTextures.ok) return allocatedTextures;
|
|
1704
|
+
if (resizeDetected) {
|
|
1705
|
+
this.drainTransient();
|
|
1706
|
+
}
|
|
1707
|
+
for (const [key, pooled] of allocatedTextures.value.transient) {
|
|
1708
|
+
this.setTransientEntry(key, pooled);
|
|
1709
|
+
}
|
|
1710
|
+
for (const [key, pooled] of allocatedTextures.value.persistent) {
|
|
1711
|
+
this.setPersistentEntry(key, pooled);
|
|
1712
|
+
}
|
|
1713
|
+
this.compiled = {
|
|
1714
|
+
passes: internalizedPasses,
|
|
1715
|
+
resolvedBuffers,
|
|
1716
|
+
resolvedTextures: allocatedTextures.value.resolvedTextures
|
|
1717
|
+
};
|
|
1718
|
+
this.compiledWidth = this.swapChainWidth;
|
|
1719
|
+
this.compiledHeight = this.swapChainHeight;
|
|
1720
|
+
if (device !== void 0) {
|
|
1721
|
+
this.lastDevice = device;
|
|
1722
|
+
}
|
|
1723
|
+
return ok(this.compiled);
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* feat-20260612 M-4 / w15: release every pooled GPU texture and clear
|
|
1727
|
+
* the pools.
|
|
1728
|
+
*
|
|
1729
|
+
* Walks `transientPool` + `persistentTextures`, forwarding each
|
|
1730
|
+
* `PooledTexture.texture` opaque handle to `device.destroyTexture(...)`,
|
|
1731
|
+
* then clears both Maps. The destroy bookkeeping SSOT is the RHI shim
|
|
1732
|
+
* (architecture-principles §1 SSOT: same path GpuTexture.destroy()
|
|
1733
|
+
* uses); render-graph stays RHI-pure (no runtime dep).
|
|
1734
|
+
*
|
|
1735
|
+
* Plan-strategy D-7: drain covers the dispose exit path (`Renderer.dispose()`);
|
|
1736
|
+
* descriptor-drift replacement during compile is fenced through
|
|
1737
|
+
* `pendingDestroy` and `reclaimRetiredTransients()`.
|
|
1738
|
+
*
|
|
1739
|
+
* Idempotent (architecture-principles §6): a second drain on cleared
|
|
1740
|
+
* Maps is a no-op. drain() before any compile is also a safe no-op.
|
|
1741
|
+
* Per-handle errors from the RHI shim (e.g. 'destroy-after-destroy'
|
|
1742
|
+
* on a stale handle) are tolerated so the dispose chain can make
|
|
1743
|
+
* progress (mirrors gpuStore.destroyAll's swallow-and-continue
|
|
1744
|
+
* policy; plan-strategy D-3 / D-8). The structured error stays
|
|
1745
|
+
* available on the device handle for future inspector hooks.
|
|
1746
|
+
*/
|
|
1747
|
+
drain() {
|
|
1748
|
+
const device = this.lastDevice;
|
|
1749
|
+
if (device === null) {
|
|
1750
|
+
this.transientPool.clear();
|
|
1751
|
+
this.persistentTextures.clear();
|
|
1752
|
+
this.pendingDestroy.length = 0;
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
for (const pooled of this.transientPool.values()) {
|
|
1756
|
+
try {
|
|
1757
|
+
device.destroyTexture(pooled.texture);
|
|
1758
|
+
} catch {
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
this.transientPool.clear();
|
|
1762
|
+
for (const pooled of this.pendingDestroy) {
|
|
1763
|
+
try {
|
|
1764
|
+
device.destroyTexture(pooled.texture);
|
|
1765
|
+
} catch {
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
this.pendingDestroy.length = 0;
|
|
1769
|
+
for (const pooled of this.persistentTextures.values()) {
|
|
1770
|
+
try {
|
|
1771
|
+
device.destroyTexture(pooled.texture);
|
|
1772
|
+
} catch {
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
this.persistentTextures.clear();
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Relinquish every texture owned by this graph after its last frame has
|
|
1779
|
+
* been submitted. Unlike {@link drain}, this does not synchronously destroy
|
|
1780
|
+
* GPU resources: they join `pendingDestroy` and are released by
|
|
1781
|
+
* `reclaimRetiredTransients()` only after `onSubmittedWorkDone` resolves.
|
|
1782
|
+
*
|
|
1783
|
+
* A retired graph is no longer executable. Runtime replaces a memoized
|
|
1784
|
+
* per-frame graph through this entry when topology changes (rather than
|
|
1785
|
+
* dropping the graph and its pools), while `drain()` remains the teardown
|
|
1786
|
+
* path where immediate destruction is safe.
|
|
1787
|
+
*/
|
|
1788
|
+
retire() {
|
|
1789
|
+
const device = this.lastDevice;
|
|
1790
|
+
if (device === null) {
|
|
1791
|
+
this.transientPool.clear();
|
|
1792
|
+
this.persistentTextures.clear();
|
|
1793
|
+
this.pendingDestroy.length = 0;
|
|
1794
|
+
this.compiled = null;
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
for (const pooled of this.transientPool.values()) {
|
|
1798
|
+
this.pendingDestroy.push(pooled);
|
|
1799
|
+
}
|
|
1800
|
+
this.transientPool.clear();
|
|
1801
|
+
for (const pooled of this.persistentTextures.values()) {
|
|
1802
|
+
this.pendingDestroy.push(pooled);
|
|
1803
|
+
}
|
|
1804
|
+
this.persistentTextures.clear();
|
|
1805
|
+
this.compiled = null;
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Release every transient-pool texture while keeping persistentTextures
|
|
1809
|
+
* intact (AC-09: resize drain, plan-strategy D-4).
|
|
1810
|
+
*
|
|
1811
|
+
* Walks `transientPool` values and forwards each `PooledTexture.texture`
|
|
1812
|
+
* opaque handle to `device.destroyTexture(...)`, then clears the transient
|
|
1813
|
+
* pool. Mirror of `drain()` but scoped to the transient pool only.
|
|
1814
|
+
*
|
|
1815
|
+
* Persistent textures survive `drainTransient` — they are only released by
|
|
1816
|
+
* the full `drain()` on teardown. `drainTransient` is an internal helper
|
|
1817
|
+
* called by `compile()` when swap-chain size changes; it is NOT a public API
|
|
1818
|
+
* (callers should use `drain()` for teardown).
|
|
1819
|
+
*
|
|
1820
|
+
* Idempotent (architecture-principles §6): a second drainTransient on an
|
|
1821
|
+
* already-cleared transient pool is a no-op.
|
|
1822
|
+
*/
|
|
1823
|
+
drainTransient() {
|
|
1824
|
+
const device = this.lastDevice;
|
|
1825
|
+
if (device === null) {
|
|
1826
|
+
this.transientPool.clear();
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
for (const pooled of this.transientPool.values()) {
|
|
1830
|
+
this.pendingDestroy.push(pooled);
|
|
1831
|
+
}
|
|
1832
|
+
this.transientPool.clear();
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Guarded transient pool insert (AC-08, plan-strategy D-4).
|
|
1836
|
+
*
|
|
1837
|
+
* Before overwriting a key in the transient pool, destroys the old pooled
|
|
1838
|
+
* texture via `device.destroyTexture(...)` to prevent stranded GPU textures.
|
|
1839
|
+
* The guard is defensive: in current production code flow this code path is
|
|
1840
|
+
* unreachable (set() only follows a get() miss inside allocateColorTargets),
|
|
1841
|
+
* but the single-line guard costs almost nothing and closes the symmetry gap
|
|
1842
|
+
* (every GPU resource allocation has a paired destroy).
|
|
1843
|
+
*
|
|
1844
|
+
* When `lastDevice` is null (no device ever stashed), the old entry is
|
|
1845
|
+
* silently dropped without destroy (mirrors drainTransient's null-device
|
|
1846
|
+
* fast path).
|
|
1847
|
+
*/
|
|
1848
|
+
setTransientEntry(key, pooled) {
|
|
1849
|
+
const old = this.transientPool.get(key);
|
|
1850
|
+
if (old) {
|
|
1851
|
+
this.pendingDestroy.push(old);
|
|
1852
|
+
}
|
|
1853
|
+
this.transientPool.set(key, pooled);
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Publish a persistent replacement only after a complete allocation succeeds.
|
|
1857
|
+
* The old handle remains fenced until the GPU retires work that may still
|
|
1858
|
+
* reference it, just like a transient replacement.
|
|
1859
|
+
*/
|
|
1860
|
+
setPersistentEntry(key, pooled) {
|
|
1861
|
+
const old = this.persistentTextures.get(key);
|
|
1862
|
+
if (old && old.texture !== pooled.texture) {
|
|
1863
|
+
this.pendingDestroy.push(old);
|
|
1864
|
+
}
|
|
1865
|
+
this.persistentTextures.set(key, pooled);
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* bug-20260622 D-2: reclaim pool textures queued in pendingDestroy after
|
|
1869
|
+
* the GPU has retired all prior command buffers.
|
|
1870
|
+
*
|
|
1871
|
+
* Takes a snapshot of pendingDestroy, then calls
|
|
1872
|
+
* `lastDevice.queue.onSubmittedWorkDone()`. When the promise resolves,
|
|
1873
|
+
* the snapshot items are actually destroyed via
|
|
1874
|
+
* `device.destroyTexture(...)` and removed from the queue.
|
|
1875
|
+
*
|
|
1876
|
+
* Idempotent (architecture-principles D-4): a second reclaim on an
|
|
1877
|
+
* already-drained pendingDestroy is a no-op. When lastDevice is null
|
|
1878
|
+
* (no device ever stashed), pendingDestroy is cleared directly.
|
|
1879
|
+
*
|
|
1880
|
+
* Per-handle destroy errors are tolerated (swallow-and-continue,
|
|
1881
|
+
* plan-strategy D-5) — a stale-handle destroy-after-destroy does not
|
|
1882
|
+
* interrupt the reclaim chain.
|
|
1883
|
+
*/
|
|
1884
|
+
async reclaimRetiredTransients() {
|
|
1885
|
+
const device = this.lastDevice;
|
|
1886
|
+
if (device === null) {
|
|
1887
|
+
this.pendingDestroy.length = 0;
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
if (this.pendingDestroy.length === 0) return;
|
|
1891
|
+
const snapshot = this.pendingDestroy.splice(0);
|
|
1892
|
+
await device.queue.onSubmittedWorkDone();
|
|
1893
|
+
for (const pooled of snapshot) {
|
|
1894
|
+
try {
|
|
1895
|
+
device.destroyTexture(pooled.texture);
|
|
1896
|
+
} catch {
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Drop the pendingDestroy queue WITHOUT calling device.destroyTexture
|
|
1902
|
+
* (feat-20260622-s5 M3 / B-2 / B-AC-02).
|
|
1903
|
+
*
|
|
1904
|
+
* Used on the device-lost recover() rebuild path: the queue holds
|
|
1905
|
+
* PooledTexture handles minted against the now-lost device, so calling
|
|
1906
|
+
* destroyTexture on them against the freshly-rebuilt device is meaningless
|
|
1907
|
+
* (the old GPUDevice owns them; spec retires its resources implicitly when
|
|
1908
|
+
* it is lost). recover() calls this after `gpuStore.destroyAll()` and before
|
|
1909
|
+
* `tryCreateWebGPURenderer` so no stale handle reaches the new device.
|
|
1910
|
+
*
|
|
1911
|
+
* device-lost is an upstream judgement (createRenderer's health state); the
|
|
1912
|
+
* graph stays RHI-pure and takes no device parameter — it only exposes the
|
|
1913
|
+
* clear entry. Same effect as the existing null-device fast paths in drain()
|
|
1914
|
+
* / reclaimRetiredTransients() (`pendingDestroy.length = 0`), surfaced as a
|
|
1915
|
+
* method recover() can call directly. Idempotent: a second call on an
|
|
1916
|
+
* already-empty queue is a no-op.
|
|
1917
|
+
*/
|
|
1918
|
+
clearPendingDestroy() {
|
|
1919
|
+
this.pendingDestroy.length = 0;
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Execute the compiled graph in declaration order, calling
|
|
1923
|
+
* each pass's execute closure with the provided context. Passes without an
|
|
1924
|
+
* execute closure are silently skipped.
|
|
1925
|
+
*/
|
|
1926
|
+
execute(ctx, runPass) {
|
|
1927
|
+
const compiled = this.compiled;
|
|
1928
|
+
if (!compiled) return;
|
|
1929
|
+
const resolvedTextures = compiled.resolvedTextures;
|
|
1930
|
+
const resolveCtx = {
|
|
1931
|
+
resolve: (name) => resolvedTextures.get(name)
|
|
1932
|
+
};
|
|
1933
|
+
const passList = this.passes.list();
|
|
1934
|
+
const passByName = new Map(passList.map((p) => [p.name, p]));
|
|
1935
|
+
for (const internalPass of compiled.passes) {
|
|
1936
|
+
const entry = passByName.get(internalPass.name);
|
|
1937
|
+
const execute = entry?.descriptor.execute;
|
|
1938
|
+
if (execute) {
|
|
1939
|
+
if (runPass === void 0) {
|
|
1940
|
+
execute(ctx, resolveCtx);
|
|
1941
|
+
} else {
|
|
1942
|
+
runPass(
|
|
1943
|
+
internalPass.name,
|
|
1944
|
+
() => execute(ctx, resolveCtx)
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
listPasses() {
|
|
1951
|
+
return this.passes.list().map((p) => ({
|
|
1952
|
+
name: p.name,
|
|
1953
|
+
reads: p.descriptor.reads,
|
|
1954
|
+
writes: p.descriptor.writes
|
|
1955
|
+
}));
|
|
1956
|
+
}
|
|
1957
|
+
listResources() {
|
|
1958
|
+
const result = [];
|
|
1959
|
+
for (const entry of this.resources.entries()) {
|
|
1960
|
+
result.push({
|
|
1961
|
+
key: entry.key,
|
|
1962
|
+
kind: entry.descriptor.kind,
|
|
1963
|
+
lifetime: entry.descriptor.lifetime
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
return result;
|
|
1967
|
+
}
|
|
1968
|
+
// ── Private helpers ────────────────────────────────────────────
|
|
1969
|
+
validateCaps(passList, caps) {
|
|
1970
|
+
for (const pass of passList) {
|
|
1971
|
+
const { name, descriptor } = pass;
|
|
1972
|
+
if (descriptor.compute && !caps.compute) {
|
|
1973
|
+
return err(
|
|
1974
|
+
new RenderGraphError({
|
|
1975
|
+
code: "cap-missing",
|
|
1976
|
+
expected: `pass '${name}' is a compute pass but caps.compute is false`,
|
|
1977
|
+
hint: "use a render pass path or enable compute on the backend",
|
|
1978
|
+
detail: { cap: "compute", passName: name }
|
|
1979
|
+
})
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
if (descriptor.storageBuffer && !caps.storageBuffer) {
|
|
1983
|
+
return err(
|
|
1984
|
+
new RenderGraphError({
|
|
1985
|
+
code: "cap-missing",
|
|
1986
|
+
expected: `pass '${name}' requires storage buffer but caps.storageBuffer is false`,
|
|
1987
|
+
hint: "switch to uniform buffer or enable storageBuffer on the backend",
|
|
1988
|
+
detail: {
|
|
1989
|
+
cap: "storageBuffer",
|
|
1990
|
+
passName: name
|
|
1991
|
+
}
|
|
1992
|
+
})
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
validateColorDomains(passList) {
|
|
1999
|
+
for (const pass of passList) {
|
|
2000
|
+
for (const connection of pass.descriptor.colorConnections ?? []) {
|
|
2001
|
+
const source = this.resources.get(connection.source)?.colorTarget?.domain;
|
|
2002
|
+
const destination = this.resources.get(connection.destination)?.colorTarget?.domain;
|
|
2003
|
+
const validation = validateColorDomainConnection(
|
|
2004
|
+
source,
|
|
2005
|
+
destination,
|
|
2006
|
+
connection.conversion
|
|
2007
|
+
);
|
|
2008
|
+
if (!validation.ok) return err(validation.error);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return null;
|
|
2012
|
+
}
|
|
2013
|
+
validateNoUnknownResource(passList) {
|
|
2014
|
+
for (const pass of passList) {
|
|
2015
|
+
for (const key of [...pass.descriptor.reads, ...pass.descriptor.writes]) {
|
|
2016
|
+
if (key === "swapchain") continue;
|
|
2017
|
+
if (!this.resources.has(key)) {
|
|
2018
|
+
return err(
|
|
2019
|
+
new RenderGraphError({
|
|
2020
|
+
code: "unknown-resource",
|
|
2021
|
+
expected: `pass '${pass.name}' references resource key '${key}' but it is not registered`,
|
|
2022
|
+
hint: `call addResource('${key}', ...) before compile, or remove '${key}' from pass '${pass.name}'`,
|
|
2023
|
+
detail: {
|
|
2024
|
+
resourceKey: key,
|
|
2025
|
+
passName: pass.name
|
|
2026
|
+
}
|
|
2027
|
+
})
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
return null;
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Resolve every registered `kind:'buffer'` resource to a concrete RHI
|
|
2036
|
+
* binding type (AC-09 / D-6.1). `bufferRole='auto-storage-or-uniform'`
|
|
2037
|
+
* (the default when unset) picks `'read-only-storage'` when the backend
|
|
2038
|
+
* advertises `caps.storageBuffer`, else falls back to `'uniform'`;
|
|
2039
|
+
* `bufferRole='uniform'` is always `'uniform'`. Mirrors the runtime
|
|
2040
|
+
* `pbr-pipeline.ts` cap switch (research Finding 7), expressed here in the
|
|
2041
|
+
* RHI-pure graph layer so consumers never duplicate the branch.
|
|
2042
|
+
*/
|
|
2043
|
+
resolveBuffers(caps) {
|
|
2044
|
+
const resolved = [];
|
|
2045
|
+
for (const entry of this.resources.entries()) {
|
|
2046
|
+
if (entry.descriptor.kind !== "buffer") continue;
|
|
2047
|
+
const role = entry.descriptor.bufferRole ?? "auto-storage-or-uniform";
|
|
2048
|
+
const resolvedBufferType = role === "uniform" ? "uniform" : caps.storageBuffer ? "read-only-storage" : "uniform";
|
|
2049
|
+
resolved.push({ key: entry.key, resolvedBufferType });
|
|
2050
|
+
}
|
|
2051
|
+
return resolved;
|
|
2052
|
+
}
|
|
2053
|
+
validateColorTargetFormats() {
|
|
2054
|
+
for (const entry of this.resources.entries()) {
|
|
2055
|
+
const meta = entry.colorTarget;
|
|
2056
|
+
if (!meta || VALID_GPU_TEXTURE_FORMAT_SET.has(meta.format)) continue;
|
|
2057
|
+
return err(
|
|
2058
|
+
new RenderGraphError({
|
|
2059
|
+
code: "invalid-format",
|
|
2060
|
+
expected: `addColorTarget format must be a valid GPU texture format; received '${meta.format}'`,
|
|
2061
|
+
hint: `replace '${meta.format}' with one of detail.expected before recompiling`,
|
|
2062
|
+
detail: {
|
|
2063
|
+
resourceKey: entry.key,
|
|
2064
|
+
format: meta.format,
|
|
2065
|
+
expected: VALID_GPU_TEXTURE_FORMATS
|
|
2066
|
+
}
|
|
2067
|
+
})
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
return ok(void 0);
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Phase 7: allocate GPU textures for registered color targets (D-1 / D-2).
|
|
2074
|
+
*
|
|
2075
|
+
* For each addColorTarget resource, resolves the concrete size from the
|
|
2076
|
+
* ColorTargetSize descriptor and swapChainSize, then looks up the transient
|
|
2077
|
+
* pool by descriptor key. Pool hit reuses the same physical texture/view;
|
|
2078
|
+
* pool miss (drift) triggers device.createTexture/createTextureView rebuild.
|
|
2079
|
+
*
|
|
2080
|
+
* Alias targets (addColorTargetAlias) fold into the source's physical texture
|
|
2081
|
+
* (KB-1 MoveNode pattern). Persistent targets are retained across compiles
|
|
2082
|
+
* with size-drift rebuild.
|
|
2083
|
+
*
|
|
2084
|
+
* device === undefined is a no-op (returns an empty map).
|
|
2085
|
+
* Allocation is transactional: newly created textures are destroyed on any
|
|
2086
|
+
* failure, and pool mutations are committed only after every target succeeds.
|
|
2087
|
+
*/
|
|
2088
|
+
allocateColorTargets(device, invalidateTransientPool = false) {
|
|
2089
|
+
const result = /* @__PURE__ */ new Map();
|
|
2090
|
+
if (!device || typeof device.createTexture !== "function")
|
|
2091
|
+
return ok({ resolvedTextures: result, transient: /* @__PURE__ */ new Map(), persistent: /* @__PURE__ */ new Map() });
|
|
2092
|
+
const stagedTransient = /* @__PURE__ */ new Map();
|
|
2093
|
+
const stagedPersistent = /* @__PURE__ */ new Map();
|
|
2094
|
+
const stagedAllocations = [];
|
|
2095
|
+
const discardStaged = () => {
|
|
2096
|
+
for (const pooled of stagedAllocations) {
|
|
2097
|
+
try {
|
|
2098
|
+
device.destroyTexture(pooled.texture);
|
|
2099
|
+
} catch {
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
};
|
|
2103
|
+
for (const entry of this.resources.entries()) {
|
|
2104
|
+
const meta = entry.colorTarget;
|
|
2105
|
+
if (!meta) continue;
|
|
2106
|
+
if (meta.aliasedFrom !== void 0) {
|
|
2107
|
+
const sourceView = result.get(meta.aliasedFrom);
|
|
2108
|
+
const sourceTexture = result.get(`${meta.aliasedFrom}::tex`);
|
|
2109
|
+
if (sourceView === void 0 || sourceTexture === void 0) {
|
|
2110
|
+
discardStaged();
|
|
2111
|
+
return err(
|
|
2112
|
+
new RenderGraphError({
|
|
2113
|
+
code: "alias-source-missing",
|
|
2114
|
+
expected: `alias '${entry.key}' source '${meta.aliasedFrom}' must resolve to a compiled color target`,
|
|
2115
|
+
hint: `register color target '${meta.aliasedFrom}' before compiling alias '${entry.key}'`,
|
|
2116
|
+
detail: {
|
|
2117
|
+
aliasKey: entry.key,
|
|
2118
|
+
sourceKey: meta.aliasedFrom
|
|
2119
|
+
}
|
|
2120
|
+
})
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
result.set(entry.key, sourceView);
|
|
2124
|
+
result.set(`${entry.key}::tex`, sourceTexture);
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
const width = this.resolveWidth(meta.size);
|
|
2128
|
+
const height = this.resolveHeight(meta.size);
|
|
2129
|
+
const lifetime = entry.lifetime;
|
|
2130
|
+
const descriptorKey = poolKey({
|
|
2131
|
+
format: meta.format,
|
|
2132
|
+
width,
|
|
2133
|
+
height,
|
|
2134
|
+
usage: meta.usage,
|
|
2135
|
+
sample: meta.sample,
|
|
2136
|
+
viewFormats: meta.viewFormats ?? []
|
|
2137
|
+
});
|
|
2138
|
+
const key = `${entry.key}:${descriptorKey}`;
|
|
2139
|
+
if (lifetime === "transient") {
|
|
2140
|
+
const pooled2 = invalidateTransientPool ? void 0 : this.transientPool.get(key);
|
|
2141
|
+
if (pooled2) {
|
|
2142
|
+
result.set(entry.key, pooled2.view);
|
|
2143
|
+
result.set(`${entry.key}::tex`, pooled2.texture);
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
} else if (lifetime === "persistent") {
|
|
2147
|
+
const persisted = this.persistentTextures.get(entry.key);
|
|
2148
|
+
if (persisted?.descriptorKey === descriptorKey) {
|
|
2149
|
+
result.set(entry.key, persisted.view);
|
|
2150
|
+
result.set(`${entry.key}::tex`, persisted.texture);
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
const texResult = device.createTexture({
|
|
2155
|
+
label: entry.key,
|
|
2156
|
+
size: { width, height, depthOrArrayLayers: 1 },
|
|
2157
|
+
mipLevelCount: 1,
|
|
2158
|
+
sampleCount: meta.sample,
|
|
2159
|
+
dimension: "2d",
|
|
2160
|
+
format: meta.format,
|
|
2161
|
+
usage: meta.usage,
|
|
2162
|
+
viewFormats: meta.viewFormats ?? []
|
|
2163
|
+
});
|
|
2164
|
+
if (!texResult.ok) {
|
|
2165
|
+
discardStaged();
|
|
2166
|
+
return err(
|
|
2167
|
+
new RenderGraphError({
|
|
2168
|
+
code: "resource-alloc-failed",
|
|
2169
|
+
expected: `device.createTexture must succeed for color target '${entry.key}'`,
|
|
2170
|
+
hint: `retry after recovering the RHI allocation failure for '${entry.key}'`,
|
|
2171
|
+
detail: {
|
|
2172
|
+
resourceKey: entry.key,
|
|
2173
|
+
rhiCode: texResult.error.code
|
|
2174
|
+
}
|
|
2175
|
+
})
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
const viewResult = device.createTextureView(texResult.value, {});
|
|
2179
|
+
if (!viewResult.ok) {
|
|
2180
|
+
try {
|
|
2181
|
+
device.destroyTexture(texResult.value);
|
|
2182
|
+
} catch {
|
|
2183
|
+
}
|
|
2184
|
+
discardStaged();
|
|
2185
|
+
return err(
|
|
2186
|
+
new RenderGraphError({
|
|
2187
|
+
code: "resource-alloc-failed",
|
|
2188
|
+
expected: `device.createTextureView must succeed for color target '${entry.key}'`,
|
|
2189
|
+
hint: `retry after recovering the RHI view allocation failure for '${entry.key}'`,
|
|
2190
|
+
detail: {
|
|
2191
|
+
resourceKey: entry.key,
|
|
2192
|
+
rhiCode: viewResult.error.code
|
|
2193
|
+
}
|
|
2194
|
+
})
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
const pooled = {
|
|
2198
|
+
texture: texResult.value,
|
|
2199
|
+
view: viewResult.value,
|
|
2200
|
+
descriptorKey
|
|
2201
|
+
};
|
|
2202
|
+
stagedAllocations.push(pooled);
|
|
2203
|
+
if (lifetime === "transient") {
|
|
2204
|
+
stagedTransient.set(key, pooled);
|
|
2205
|
+
} else {
|
|
2206
|
+
stagedPersistent.set(entry.key, pooled);
|
|
2207
|
+
}
|
|
2208
|
+
result.set(entry.key, viewResult.value);
|
|
2209
|
+
result.set(`${entry.key}::tex`, texResult.value);
|
|
2210
|
+
}
|
|
2211
|
+
return ok({
|
|
2212
|
+
resolvedTextures: result,
|
|
2213
|
+
transient: stagedTransient,
|
|
2214
|
+
persistent: stagedPersistent
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
resolveWidth(size) {
|
|
2218
|
+
if (typeof size === "string") {
|
|
2219
|
+
return size === "half-swapchain" ? Math.ceil(this.swapChainWidth / 2) : this.swapChainWidth;
|
|
2220
|
+
}
|
|
2221
|
+
return size.w;
|
|
2222
|
+
}
|
|
2223
|
+
resolveHeight(size) {
|
|
2224
|
+
if (typeof size === "string") {
|
|
2225
|
+
return size === "half-swapchain" ? Math.ceil(this.swapChainHeight / 2) : this.swapChainHeight;
|
|
2226
|
+
}
|
|
2227
|
+
return size.h;
|
|
2228
|
+
}
|
|
2229
|
+
validateNoDanglingRead(passList) {
|
|
2230
|
+
const writers = /* @__PURE__ */ new Set();
|
|
2231
|
+
for (const pass of passList) {
|
|
2232
|
+
for (const key of pass.descriptor.reads) {
|
|
2233
|
+
const imported = this.resources.get(key)?.descriptor.lifetime === "persistent";
|
|
2234
|
+
if (key !== "swapchain" && !imported && !writers.has(key)) {
|
|
2235
|
+
return err(
|
|
2236
|
+
new RenderGraphError({
|
|
2237
|
+
code: "dangling-read",
|
|
2238
|
+
expected: `pass '${pass.name}' reads key '${key}' but no pass writes it`,
|
|
2239
|
+
hint: `add a pass that writes '${key}', or remove '${key}' from pass '${pass.name}' reads`,
|
|
2240
|
+
detail: {
|
|
2241
|
+
resourceKey: key,
|
|
2242
|
+
passName: pass.name
|
|
2243
|
+
}
|
|
2244
|
+
})
|
|
2245
|
+
);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
for (const key of pass.descriptor.writes) writers.add(key);
|
|
2249
|
+
}
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
2252
|
+
};
|
|
2253
|
+
|
|
2254
|
+
export { COLOR_VALUE_DOMAINS, RenderGraph, RenderGraphBuilder, RenderGraphError, createCurrentFrameObservationLease, deserializeColorResourceDescriptor, deserializeColorValueDomain, isColorValueDomain, serializeColorResourceDescriptor, serializeColorValueDomain, validateColorDomainConnection };
|
|
2255
|
+
//# sourceMappingURL=index.mjs.map
|
|
2256
|
+
//# sourceMappingURL=index.mjs.map
|