@aelionsdk/renderer-worker 0.1.0-beta.1
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 +21 -0
- package/README.md +7 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +264 -0
- package/dist/font-manager.d.ts +30 -0
- package/dist/font-manager.d.ts.map +1 -0
- package/dist/font-manager.js +87 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/ir-renderer.d.ts +62 -0
- package/dist/ir-renderer.d.ts.map +1 -0
- package/dist/ir-renderer.js +1163 -0
- package/dist/protocol.d.ts +117 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/dist/webgl2-worker.d.ts +2 -0
- package/dist/webgl2-worker.d.ts.map +1 -0
- package/dist/webgl2-worker.js +1100 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
class RendererBackendError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = 'RendererBackendError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function emptyResources(inputFrames) {
|
|
11
|
+
return {
|
|
12
|
+
activeRequests: 0,
|
|
13
|
+
cancelledRequests: 0,
|
|
14
|
+
webgpuDevices: 0,
|
|
15
|
+
webgpuPipelines: 0,
|
|
16
|
+
webgpuBuffers: 0,
|
|
17
|
+
webgpuTextures: 0,
|
|
18
|
+
webgl2Contexts: 0,
|
|
19
|
+
webgl2Programs: 0,
|
|
20
|
+
webgl2Buffers: 0,
|
|
21
|
+
webgl2Textures: 0,
|
|
22
|
+
inputFrames,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function backendError(error, fallbackCode) {
|
|
26
|
+
return error instanceof RendererBackendError
|
|
27
|
+
? error
|
|
28
|
+
: new RendererBackendError(fallbackCode, error instanceof Error ? error.message : 'Unknown renderer backend error');
|
|
29
|
+
}
|
|
30
|
+
const GPU_BUFFER_USAGE = {
|
|
31
|
+
COPY_DST: 0x0008,
|
|
32
|
+
UNIFORM: 0x0040,
|
|
33
|
+
};
|
|
34
|
+
const GPU_TEXTURE_USAGE = {
|
|
35
|
+
COPY_DST: 0x0002,
|
|
36
|
+
COPY_SRC: 0x0001,
|
|
37
|
+
RENDER_ATTACHMENT: 0x0010,
|
|
38
|
+
TEXTURE_BINDING: 0x0004,
|
|
39
|
+
};
|
|
40
|
+
let persistentGpuDevice;
|
|
41
|
+
let persistentGpuDeviceTask;
|
|
42
|
+
const persistentGpuPipelines = new Map();
|
|
43
|
+
const persistentGpuTextures = new Map();
|
|
44
|
+
const MAX_POOLED_GPU_TEXTURE_BYTES = 256 * 1_024 * 1_024;
|
|
45
|
+
let pooledGpuTextureBytes = 0;
|
|
46
|
+
class WebGpuCanvasRuntime {
|
|
47
|
+
device;
|
|
48
|
+
width;
|
|
49
|
+
height;
|
|
50
|
+
canvas;
|
|
51
|
+
context;
|
|
52
|
+
constructor(device, width, height) {
|
|
53
|
+
this.device = device;
|
|
54
|
+
this.width = width;
|
|
55
|
+
this.height = height;
|
|
56
|
+
this.canvas = new OffscreenCanvas(width, height);
|
|
57
|
+
const context = this.canvas.getContext('webgpu');
|
|
58
|
+
if (context === null)
|
|
59
|
+
throw new Error('Worker WebGPU canvas context is unavailable');
|
|
60
|
+
context.configure({
|
|
61
|
+
device,
|
|
62
|
+
format: 'rgba8unorm',
|
|
63
|
+
usage: GPU_TEXTURE_USAGE.RENDER_ATTACHMENT | GPU_TEXTURE_USAGE.COPY_DST,
|
|
64
|
+
alphaMode: 'premultiplied',
|
|
65
|
+
});
|
|
66
|
+
this.context = context;
|
|
67
|
+
}
|
|
68
|
+
dispose() {
|
|
69
|
+
this.context.unconfigure?.();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const MAX_WEBGPU_CANVAS_RUNTIMES = 2;
|
|
73
|
+
const webGpuCanvasRuntimes = new Map();
|
|
74
|
+
let webGpuCanvasRuntimeClock = 0;
|
|
75
|
+
const webGpuCanvasAccess = new Map();
|
|
76
|
+
function webGpuCanvasRuntime(device, width, height) {
|
|
77
|
+
const key = `${width.toString()}x${height.toString()}`;
|
|
78
|
+
let runtime = webGpuCanvasRuntimes.get(key);
|
|
79
|
+
if (runtime !== undefined && runtime.device !== device) {
|
|
80
|
+
runtime.dispose();
|
|
81
|
+
webGpuCanvasRuntimes.delete(key);
|
|
82
|
+
webGpuCanvasAccess.delete(key);
|
|
83
|
+
runtime = undefined;
|
|
84
|
+
}
|
|
85
|
+
if (runtime === undefined) {
|
|
86
|
+
runtime = new WebGpuCanvasRuntime(device, width, height);
|
|
87
|
+
webGpuCanvasRuntimes.set(key, runtime);
|
|
88
|
+
}
|
|
89
|
+
webGpuCanvasAccess.set(key, ++webGpuCanvasRuntimeClock);
|
|
90
|
+
while (webGpuCanvasRuntimes.size > MAX_WEBGPU_CANVAS_RUNTIMES) {
|
|
91
|
+
const oldest = [...webGpuCanvasAccess.entries()].sort((left, right) => left[1] - right[1])[0];
|
|
92
|
+
if (oldest === undefined)
|
|
93
|
+
break;
|
|
94
|
+
webGpuCanvasRuntimes.get(oldest[0])?.dispose();
|
|
95
|
+
webGpuCanvasRuntimes.delete(oldest[0]);
|
|
96
|
+
webGpuCanvasAccess.delete(oldest[0]);
|
|
97
|
+
}
|
|
98
|
+
return runtime;
|
|
99
|
+
}
|
|
100
|
+
function clearWebGpuCanvasRuntimes() {
|
|
101
|
+
webGpuCanvasRuntimes.forEach(runtime => runtime.dispose());
|
|
102
|
+
webGpuCanvasRuntimes.clear();
|
|
103
|
+
webGpuCanvasAccess.clear();
|
|
104
|
+
}
|
|
105
|
+
function gpuTextureKey(width, height, usage) {
|
|
106
|
+
return `${width.toString()}x${height.toString()}:rgba8unorm:${usage.toString()}`;
|
|
107
|
+
}
|
|
108
|
+
function acquireGpuTexture(device, width, height, usage) {
|
|
109
|
+
const key = gpuTextureKey(width, height, usage);
|
|
110
|
+
const bytes = width * height * 4;
|
|
111
|
+
const bucket = persistentGpuTextures.get(key);
|
|
112
|
+
const texture = bucket?.pop();
|
|
113
|
+
if (texture !== undefined) {
|
|
114
|
+
pooledGpuTextureBytes -= bytes;
|
|
115
|
+
if (bucket?.length === 0)
|
|
116
|
+
persistentGpuTextures.delete(key);
|
|
117
|
+
return { texture, key, bytes };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
texture: device.createTexture({
|
|
121
|
+
size: { width, height },
|
|
122
|
+
format: 'rgba8unorm',
|
|
123
|
+
usage,
|
|
124
|
+
}),
|
|
125
|
+
key,
|
|
126
|
+
bytes,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function releaseGpuTexture(acquired) {
|
|
130
|
+
if (pooledGpuTextureBytes + acquired.bytes > MAX_POOLED_GPU_TEXTURE_BYTES) {
|
|
131
|
+
acquired.texture.destroy();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const bucket = persistentGpuTextures.get(acquired.key) ?? [];
|
|
135
|
+
bucket.push(acquired.texture);
|
|
136
|
+
persistentGpuTextures.set(acquired.key, bucket);
|
|
137
|
+
pooledGpuTextureBytes += acquired.bytes;
|
|
138
|
+
}
|
|
139
|
+
function clearGpuTexturePool() {
|
|
140
|
+
persistentGpuTextures.forEach(bucket => bucket.forEach(texture => texture.destroy()));
|
|
141
|
+
persistentGpuTextures.clear();
|
|
142
|
+
pooledGpuTextureBytes = 0;
|
|
143
|
+
}
|
|
144
|
+
function shaderIdentifier(value) {
|
|
145
|
+
return value.replaceAll(/[^a-zA-Z0-9_]/gu, '_');
|
|
146
|
+
}
|
|
147
|
+
function shaderWithExternalInputs(shader, ports) {
|
|
148
|
+
let output = shader;
|
|
149
|
+
for (const port of ports) {
|
|
150
|
+
const name = `input_${shaderIdentifier(port)}`;
|
|
151
|
+
output = output
|
|
152
|
+
.replace(`var ${name}: texture_2d<f32>;`, `var ${name}: texture_external;`)
|
|
153
|
+
.replaceAll(`textureSample(${name}, source_sampler,`, `textureSampleBaseClampToEdge(${name}, source_sampler,`);
|
|
154
|
+
}
|
|
155
|
+
return output;
|
|
156
|
+
}
|
|
157
|
+
async function gpuDevice() {
|
|
158
|
+
if (persistentGpuDevice !== undefined)
|
|
159
|
+
return persistentGpuDevice;
|
|
160
|
+
persistentGpuDeviceTask ??= (async () => {
|
|
161
|
+
const gpu = navigator.gpu;
|
|
162
|
+
if (gpu === undefined)
|
|
163
|
+
throw new Error('WebGPU is unavailable');
|
|
164
|
+
const adapter = await gpu.requestAdapter();
|
|
165
|
+
if (adapter === null)
|
|
166
|
+
throw new Error('WebGPU adapter is unavailable');
|
|
167
|
+
const device = await adapter.requestDevice();
|
|
168
|
+
persistentGpuDevice = device;
|
|
169
|
+
void device.lost.then(() => {
|
|
170
|
+
if (persistentGpuDevice === device) {
|
|
171
|
+
persistentGpuDevice = undefined;
|
|
172
|
+
persistentGpuDeviceTask = undefined;
|
|
173
|
+
persistentGpuPipelines.clear();
|
|
174
|
+
clearWebGpuCanvasRuntimes();
|
|
175
|
+
clearGpuTexturePool();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return device;
|
|
179
|
+
})().catch((error) => {
|
|
180
|
+
persistentGpuDeviceTask = undefined;
|
|
181
|
+
throw error;
|
|
182
|
+
});
|
|
183
|
+
return persistentGpuDeviceTask;
|
|
184
|
+
}
|
|
185
|
+
function disposeGpuRuntime() {
|
|
186
|
+
clearWebGpuCanvasRuntimes();
|
|
187
|
+
clearGpuTexturePool();
|
|
188
|
+
persistentGpuDevice?.destroy();
|
|
189
|
+
persistentGpuDevice = undefined;
|
|
190
|
+
persistentGpuDeviceTask = undefined;
|
|
191
|
+
persistentGpuPipelines.clear();
|
|
192
|
+
}
|
|
193
|
+
async function composeWebGpu(request, resources, timing) {
|
|
194
|
+
const webgpu = request.program.webgpu;
|
|
195
|
+
if (webgpu === undefined)
|
|
196
|
+
throw new Error('Material has no WebGPU program');
|
|
197
|
+
const frames = webgpu.inputPorts.map(port => {
|
|
198
|
+
const frame = request.inputs[port];
|
|
199
|
+
if (frame === undefined)
|
|
200
|
+
throw new Error(`WebGPU Material input ${port} is missing`);
|
|
201
|
+
return frame;
|
|
202
|
+
});
|
|
203
|
+
const device = await gpuDevice();
|
|
204
|
+
resources.webgpuDevices += 1;
|
|
205
|
+
device.pushErrorScope('validation');
|
|
206
|
+
let uniformBuffer;
|
|
207
|
+
try {
|
|
208
|
+
const presentation = webGpuCanvasRuntime(device, request.width, request.height);
|
|
209
|
+
const outputTexture = presentation.context.getCurrentTexture();
|
|
210
|
+
const inputTextures = frames.map(frame => device.importExternalTexture({ source: frame }));
|
|
211
|
+
resources.webgpuTextures += inputTextures.length;
|
|
212
|
+
if (request.debugSimulateLoss === 'webgpu-device') {
|
|
213
|
+
disposeGpuRuntime();
|
|
214
|
+
throw new RendererBackendError('RENDERER_WEBGPU_DEVICE_LOST', 'WebGPU device was lost during composition');
|
|
215
|
+
}
|
|
216
|
+
const uniformData = new Float32Array(Math.max(1, webgpu.uniforms.length) * 4);
|
|
217
|
+
webgpu.uniforms.forEach((uniform, index) => {
|
|
218
|
+
const value = uniform.source.kind === 'parameter'
|
|
219
|
+
? request.parameters[uniform.source.id]
|
|
220
|
+
: request.systems[uniform.source.id];
|
|
221
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
222
|
+
throw new TypeError(`WebGPU Material uniform ${uniform.source.id} must be finite`);
|
|
223
|
+
}
|
|
224
|
+
uniformData[index * 4] = value;
|
|
225
|
+
});
|
|
226
|
+
uniformBuffer = device.createBuffer({
|
|
227
|
+
size: uniformData.byteLength,
|
|
228
|
+
usage: GPU_BUFFER_USAGE.UNIFORM | GPU_BUFFER_USAGE.COPY_DST,
|
|
229
|
+
});
|
|
230
|
+
resources.webgpuBuffers += 1;
|
|
231
|
+
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
|
|
232
|
+
const shader = shaderWithExternalInputs(webgpu.shader, webgpu.inputPorts);
|
|
233
|
+
const pipelineKey = `${request.program.graphHash}:external:${webgpu.inputPorts.join(',')}:${shader}`;
|
|
234
|
+
let pipeline = persistentGpuPipelines.get(pipelineKey);
|
|
235
|
+
if (pipeline === undefined) {
|
|
236
|
+
const module = device.createShaderModule({ code: shader });
|
|
237
|
+
pipeline = device.createRenderPipeline({
|
|
238
|
+
layout: 'auto',
|
|
239
|
+
vertex: { module, entryPoint: 'vs' },
|
|
240
|
+
fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
|
|
241
|
+
primitive: { topology: 'triangle-list' },
|
|
242
|
+
});
|
|
243
|
+
persistentGpuPipelines.set(pipelineKey, pipeline);
|
|
244
|
+
}
|
|
245
|
+
resources.webgpuPipelines += 1;
|
|
246
|
+
const bindGroup = device.createBindGroup({
|
|
247
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
248
|
+
entries: [
|
|
249
|
+
...(inputTextures.length === 0
|
|
250
|
+
? []
|
|
251
|
+
: [
|
|
252
|
+
{
|
|
253
|
+
binding: 0,
|
|
254
|
+
resource: device.createSampler({ magFilter: 'linear', minFilter: 'linear' }),
|
|
255
|
+
},
|
|
256
|
+
]),
|
|
257
|
+
...inputTextures.map((texture, index) => ({
|
|
258
|
+
binding: index + 1,
|
|
259
|
+
resource: texture,
|
|
260
|
+
})),
|
|
261
|
+
{ binding: inputTextures.length + 1, resource: { buffer: uniformBuffer } },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
const encoder = device.createCommandEncoder();
|
|
265
|
+
const pass = encoder.beginRenderPass({
|
|
266
|
+
colorAttachments: [
|
|
267
|
+
{
|
|
268
|
+
view: outputTexture.createView(),
|
|
269
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
270
|
+
loadOp: 'clear',
|
|
271
|
+
storeOp: 'store',
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
pass.setPipeline(pipeline);
|
|
276
|
+
pass.setBindGroup(0, bindGroup);
|
|
277
|
+
pass.draw(3);
|
|
278
|
+
pass.end();
|
|
279
|
+
const gpuStartedAt = performance.now();
|
|
280
|
+
device.queue.submit([encoder.finish()]);
|
|
281
|
+
const validationError = await device.popErrorScope();
|
|
282
|
+
timing.gpuCompletionUs += Math.round((performance.now() - gpuStartedAt) * 1_000);
|
|
283
|
+
if (validationError !== null) {
|
|
284
|
+
throw new Error(`WebGPU validation failed: ${validationError.message}`);
|
|
285
|
+
}
|
|
286
|
+
return presentation.canvas.transferToImageBitmap();
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
uniformBuffer?.destroy();
|
|
290
|
+
resources.webgpuDevices = 0;
|
|
291
|
+
resources.webgpuPipelines = 0;
|
|
292
|
+
resources.webgpuBuffers = 0;
|
|
293
|
+
resources.webgpuTextures = 0;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function frameGraphWebGpuInput(device, node, port, externalFrames, importedExternalTextures, nodeTextures) {
|
|
297
|
+
const reference = node.inputs[port];
|
|
298
|
+
if (reference === undefined) {
|
|
299
|
+
throw new RangeError(`Frame graph node ${node.id} is missing input ${port}`);
|
|
300
|
+
}
|
|
301
|
+
if (reference.kind === 'external') {
|
|
302
|
+
const frame = externalFrames.get(reference.id);
|
|
303
|
+
if (frame === undefined) {
|
|
304
|
+
throw new RangeError(`Frame graph node ${node.id} references unknown external input ${reference.id}`);
|
|
305
|
+
}
|
|
306
|
+
let texture = importedExternalTextures.get(reference.id);
|
|
307
|
+
if (texture === undefined) {
|
|
308
|
+
texture = device.importExternalTexture({ source: frame });
|
|
309
|
+
importedExternalTextures.set(reference.id, texture);
|
|
310
|
+
}
|
|
311
|
+
return { external: true, resource: texture };
|
|
312
|
+
}
|
|
313
|
+
const texture = nodeTextures.get(reference.id);
|
|
314
|
+
if (texture === undefined) {
|
|
315
|
+
throw new RangeError(`Frame graph node ${node.id} references unknown prior node ${reference.id}`);
|
|
316
|
+
}
|
|
317
|
+
return { external: false, resource: texture.texture.createView() };
|
|
318
|
+
}
|
|
319
|
+
async function composeWebGpuFrameGraph(request, resources, timing) {
|
|
320
|
+
if (!Number.isInteger(request.width) ||
|
|
321
|
+
!Number.isInteger(request.height) ||
|
|
322
|
+
request.width <= 0 ||
|
|
323
|
+
request.height <= 0) {
|
|
324
|
+
throw new RangeError('Frame graph dimensions must be positive integers');
|
|
325
|
+
}
|
|
326
|
+
if (request.nodes.length === 0)
|
|
327
|
+
throw new RangeError('Frame graph has no nodes');
|
|
328
|
+
const device = await gpuDevice();
|
|
329
|
+
resources.webgpuDevices = 1;
|
|
330
|
+
const textureUsage = GPU_TEXTURE_USAGE.TEXTURE_BINDING |
|
|
331
|
+
GPU_TEXTURE_USAGE.COPY_DST |
|
|
332
|
+
GPU_TEXTURE_USAGE.COPY_SRC |
|
|
333
|
+
GPU_TEXTURE_USAGE.RENDER_ATTACHMENT;
|
|
334
|
+
const allocatedTextures = [];
|
|
335
|
+
const uniformBuffers = [];
|
|
336
|
+
const externalFrames = new Map(Object.entries(request.inputs));
|
|
337
|
+
const importedExternalTextures = new Map();
|
|
338
|
+
const nodeTextures = new Map();
|
|
339
|
+
let errorScopeOpen = false;
|
|
340
|
+
try {
|
|
341
|
+
device.pushErrorScope('validation');
|
|
342
|
+
errorScopeOpen = true;
|
|
343
|
+
const encoder = device.createCommandEncoder();
|
|
344
|
+
for (const node of request.nodes) {
|
|
345
|
+
if (cancelledRequestIds.has(request.id)) {
|
|
346
|
+
throw new DOMException('Renderer request cancelled', 'AbortError');
|
|
347
|
+
}
|
|
348
|
+
if (nodeTextures.has(node.id)) {
|
|
349
|
+
throw new RangeError(`Frame graph node id ${node.id} is duplicated`);
|
|
350
|
+
}
|
|
351
|
+
const webgpu = node.program.webgpu;
|
|
352
|
+
if (webgpu === undefined) {
|
|
353
|
+
throw new RendererBackendError('RENDERER_WEBGPU_GRAPH_NODE_UNAVAILABLE', `Frame graph node ${node.id} (${node.program.graphHash}) has no WebGPU program`);
|
|
354
|
+
}
|
|
355
|
+
const inputs = webgpu.inputPorts.map(port => frameGraphWebGpuInput(device, node, port, externalFrames, importedExternalTextures, nodeTextures));
|
|
356
|
+
const output = acquireGpuTexture(device, request.width, request.height, textureUsage);
|
|
357
|
+
allocatedTextures.push(output);
|
|
358
|
+
nodeTextures.set(node.id, output);
|
|
359
|
+
const uniformData = new Float32Array(Math.max(1, webgpu.uniforms.length) * 4);
|
|
360
|
+
webgpu.uniforms.forEach((uniform, index) => {
|
|
361
|
+
uniformData[index * 4] = materialUniformValue(uniform, node.parameters, node.systems);
|
|
362
|
+
});
|
|
363
|
+
const uniformBuffer = device.createBuffer({
|
|
364
|
+
size: uniformData.byteLength,
|
|
365
|
+
usage: GPU_BUFFER_USAGE.UNIFORM | GPU_BUFFER_USAGE.COPY_DST,
|
|
366
|
+
});
|
|
367
|
+
uniformBuffers.push(uniformBuffer);
|
|
368
|
+
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
|
|
369
|
+
const externalPorts = webgpu.inputPorts.filter((_port, index) => inputs[index]?.external === true);
|
|
370
|
+
const shader = shaderWithExternalInputs(webgpu.shader, externalPorts);
|
|
371
|
+
const pipelineKey = `${node.program.graphHash}:external:${externalPorts.join(',')}:${shader}`;
|
|
372
|
+
let pipeline = persistentGpuPipelines.get(pipelineKey);
|
|
373
|
+
if (pipeline === undefined) {
|
|
374
|
+
const module = device.createShaderModule({ code: shader });
|
|
375
|
+
pipeline = device.createRenderPipeline({
|
|
376
|
+
layout: 'auto',
|
|
377
|
+
vertex: { module, entryPoint: 'vs' },
|
|
378
|
+
fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
|
|
379
|
+
primitive: { topology: 'triangle-list' },
|
|
380
|
+
});
|
|
381
|
+
persistentGpuPipelines.set(pipelineKey, pipeline);
|
|
382
|
+
}
|
|
383
|
+
const bindGroup = device.createBindGroup({
|
|
384
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
385
|
+
entries: [
|
|
386
|
+
...(inputs.length === 0
|
|
387
|
+
? []
|
|
388
|
+
: [
|
|
389
|
+
{
|
|
390
|
+
binding: 0,
|
|
391
|
+
resource: device.createSampler({
|
|
392
|
+
magFilter: 'linear',
|
|
393
|
+
minFilter: 'linear',
|
|
394
|
+
}),
|
|
395
|
+
},
|
|
396
|
+
]),
|
|
397
|
+
...inputs.map((texture, index) => ({
|
|
398
|
+
binding: index + 1,
|
|
399
|
+
resource: texture.resource,
|
|
400
|
+
})),
|
|
401
|
+
{ binding: inputs.length + 1, resource: { buffer: uniformBuffer } },
|
|
402
|
+
],
|
|
403
|
+
});
|
|
404
|
+
const pass = encoder.beginRenderPass({
|
|
405
|
+
colorAttachments: [
|
|
406
|
+
{
|
|
407
|
+
view: output.texture.createView(),
|
|
408
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
409
|
+
loadOp: 'clear',
|
|
410
|
+
storeOp: 'store',
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
pass.setPipeline(pipeline);
|
|
415
|
+
pass.setBindGroup(0, bindGroup);
|
|
416
|
+
pass.draw(3);
|
|
417
|
+
pass.end();
|
|
418
|
+
}
|
|
419
|
+
const output = nodeTextures.get(request.outputNodeId);
|
|
420
|
+
if (output === undefined) {
|
|
421
|
+
throw new RangeError(`Frame graph output node ${request.outputNodeId} is missing`);
|
|
422
|
+
}
|
|
423
|
+
const presentation = webGpuCanvasRuntime(device, request.width, request.height);
|
|
424
|
+
const presentationTexture = presentation.context.getCurrentTexture();
|
|
425
|
+
encoder.copyTextureToTexture({ texture: output.texture }, { texture: presentationTexture }, { width: request.width, height: request.height });
|
|
426
|
+
resources.webgpuTextures =
|
|
427
|
+
allocatedTextures.length + importedExternalTextures.size;
|
|
428
|
+
resources.webgpuBuffers = uniformBuffers.length;
|
|
429
|
+
resources.webgpuPipelines = request.nodes.length;
|
|
430
|
+
const gpuStartedAt = performance.now();
|
|
431
|
+
device.queue.submit([encoder.finish()]);
|
|
432
|
+
const validationError = await device.popErrorScope();
|
|
433
|
+
timing.gpuCompletionUs += Math.round((performance.now() - gpuStartedAt) * 1_000);
|
|
434
|
+
errorScopeOpen = false;
|
|
435
|
+
if (validationError !== null) {
|
|
436
|
+
throw new RendererBackendError('RENDERER_WEBGPU_VALIDATION_FAILED', `WebGPU validation failed: ${validationError.message}`);
|
|
437
|
+
}
|
|
438
|
+
return presentation.canvas.transferToImageBitmap();
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
if (errorScopeOpen)
|
|
442
|
+
void device.popErrorScope();
|
|
443
|
+
uniformBuffers.forEach(buffer => buffer.destroy());
|
|
444
|
+
allocatedTextures.forEach(texture => {
|
|
445
|
+
if (persistentGpuDevice === device)
|
|
446
|
+
releaseGpuTexture(texture);
|
|
447
|
+
else
|
|
448
|
+
texture.texture.destroy();
|
|
449
|
+
});
|
|
450
|
+
resources.webgpuDevices = 0;
|
|
451
|
+
resources.webgpuPipelines = 0;
|
|
452
|
+
resources.webgpuBuffers = 0;
|
|
453
|
+
resources.webgpuTextures = 0;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const vertexShaderSource = `#version 300 es
|
|
457
|
+
in vec2 a_position;
|
|
458
|
+
in vec2 a_uv;
|
|
459
|
+
out vec2 v_uv;
|
|
460
|
+
void main() {
|
|
461
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
462
|
+
v_uv = a_uv;
|
|
463
|
+
}`;
|
|
464
|
+
class WebGl2Runtime {
|
|
465
|
+
width;
|
|
466
|
+
height;
|
|
467
|
+
canvas;
|
|
468
|
+
gl;
|
|
469
|
+
#programs = new Map();
|
|
470
|
+
#textures = [];
|
|
471
|
+
#positionBuffer;
|
|
472
|
+
#uvBuffer;
|
|
473
|
+
lastAccess = 0;
|
|
474
|
+
constructor(width, height) {
|
|
475
|
+
this.width = width;
|
|
476
|
+
this.height = height;
|
|
477
|
+
this.canvas = new OffscreenCanvas(width, height);
|
|
478
|
+
const gl = this.canvas.getContext('webgl2', {
|
|
479
|
+
alpha: true,
|
|
480
|
+
antialias: false,
|
|
481
|
+
depth: false,
|
|
482
|
+
desynchronized: true,
|
|
483
|
+
premultipliedAlpha: true,
|
|
484
|
+
preserveDrawingBuffer: false,
|
|
485
|
+
stencil: false,
|
|
486
|
+
});
|
|
487
|
+
if (gl === null)
|
|
488
|
+
throw new Error('Worker WebGL2 context is unavailable');
|
|
489
|
+
this.gl = gl;
|
|
490
|
+
this.#positionBuffer = this.#createBuffer([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);
|
|
491
|
+
this.#uvBuffer = this.#createBuffer([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]);
|
|
492
|
+
}
|
|
493
|
+
program(fragmentShader) {
|
|
494
|
+
const cached = this.#programs.get(fragmentShader);
|
|
495
|
+
if (cached !== undefined)
|
|
496
|
+
return cached;
|
|
497
|
+
const program = createProgram(this.gl, fragmentShader);
|
|
498
|
+
this.#programs.set(fragmentShader, program);
|
|
499
|
+
return program;
|
|
500
|
+
}
|
|
501
|
+
bindAttributes(program) {
|
|
502
|
+
this.#bindAttribute(program, 'a_position', this.#positionBuffer);
|
|
503
|
+
this.#bindAttribute(program, 'a_uv', this.#uvBuffer);
|
|
504
|
+
}
|
|
505
|
+
uploadTexture(index, source) {
|
|
506
|
+
const gl = this.gl;
|
|
507
|
+
let texture = this.#textures[index];
|
|
508
|
+
if (texture === undefined) {
|
|
509
|
+
texture = gl.createTexture();
|
|
510
|
+
this.#textures[index] = texture;
|
|
511
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
512
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
513
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
514
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
515
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
519
|
+
}
|
|
520
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
521
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
|
522
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
523
|
+
return texture;
|
|
524
|
+
}
|
|
525
|
+
snapshot() {
|
|
526
|
+
return { programs: this.#programs.size, textures: this.#textures.length, buffers: 2 };
|
|
527
|
+
}
|
|
528
|
+
dispose() {
|
|
529
|
+
const gl = this.gl;
|
|
530
|
+
this.#programs.forEach(program => gl.deleteProgram(program));
|
|
531
|
+
this.#programs.clear();
|
|
532
|
+
this.#textures.forEach(texture => gl.deleteTexture(texture));
|
|
533
|
+
this.#textures.length = 0;
|
|
534
|
+
gl.deleteBuffer(this.#positionBuffer);
|
|
535
|
+
gl.deleteBuffer(this.#uvBuffer);
|
|
536
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
|
537
|
+
}
|
|
538
|
+
#createBuffer(values) {
|
|
539
|
+
const buffer = this.gl.createBuffer();
|
|
540
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
|
|
541
|
+
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(values), this.gl.STATIC_DRAW);
|
|
542
|
+
return buffer;
|
|
543
|
+
}
|
|
544
|
+
#bindAttribute(program, name, buffer) {
|
|
545
|
+
const location = this.gl.getAttribLocation(program, name);
|
|
546
|
+
if (location < 0)
|
|
547
|
+
throw new Error(`Missing WebGL attribute ${name}`);
|
|
548
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
|
|
549
|
+
this.gl.enableVertexAttribArray(location);
|
|
550
|
+
this.gl.vertexAttribPointer(location, 2, this.gl.FLOAT, false, 0, 0);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const MAX_WEBGL2_RUNTIMES = 2;
|
|
554
|
+
const webGl2Runtimes = new Map();
|
|
555
|
+
let webGl2RuntimeClock = 0;
|
|
556
|
+
function webGl2Runtime(width, height) {
|
|
557
|
+
const key = `${width.toString()}x${height.toString()}`;
|
|
558
|
+
let runtime = webGl2Runtimes.get(key);
|
|
559
|
+
if (runtime?.gl.isContextLost() === true) {
|
|
560
|
+
runtime.dispose();
|
|
561
|
+
webGl2Runtimes.delete(key);
|
|
562
|
+
runtime = undefined;
|
|
563
|
+
}
|
|
564
|
+
if (runtime === undefined) {
|
|
565
|
+
runtime = new WebGl2Runtime(width, height);
|
|
566
|
+
webGl2Runtimes.set(key, runtime);
|
|
567
|
+
}
|
|
568
|
+
runtime.lastAccess = ++webGl2RuntimeClock;
|
|
569
|
+
while (webGl2Runtimes.size > MAX_WEBGL2_RUNTIMES) {
|
|
570
|
+
const oldest = [...webGl2Runtimes.entries()].sort((left, right) => left[1].lastAccess - right[1].lastAccess)[0];
|
|
571
|
+
if (oldest === undefined)
|
|
572
|
+
break;
|
|
573
|
+
oldest[1].dispose();
|
|
574
|
+
webGl2Runtimes.delete(oldest[0]);
|
|
575
|
+
}
|
|
576
|
+
return runtime;
|
|
577
|
+
}
|
|
578
|
+
function disposeWebGl2Runtimes() {
|
|
579
|
+
webGl2Runtimes.forEach(runtime => runtime.dispose());
|
|
580
|
+
webGl2Runtimes.clear();
|
|
581
|
+
}
|
|
582
|
+
function compileShader(gl, type, source) {
|
|
583
|
+
const shader = gl.createShader(type);
|
|
584
|
+
if (shader === null)
|
|
585
|
+
throw new Error('Unable to allocate WebGL shader');
|
|
586
|
+
gl.shaderSource(shader, source);
|
|
587
|
+
gl.compileShader(shader);
|
|
588
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
589
|
+
const log = gl.getShaderInfoLog(shader) ?? 'unknown shader error';
|
|
590
|
+
gl.deleteShader(shader);
|
|
591
|
+
throw new Error(log);
|
|
592
|
+
}
|
|
593
|
+
return shader;
|
|
594
|
+
}
|
|
595
|
+
function createProgram(gl, fragmentShaderSource) {
|
|
596
|
+
const vertex = compileShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
|
|
597
|
+
const fragment = compileShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
|
|
598
|
+
const program = gl.createProgram();
|
|
599
|
+
gl.attachShader(program, vertex);
|
|
600
|
+
gl.attachShader(program, fragment);
|
|
601
|
+
gl.linkProgram(program);
|
|
602
|
+
gl.deleteShader(vertex);
|
|
603
|
+
gl.deleteShader(fragment);
|
|
604
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
605
|
+
const log = gl.getProgramInfoLog(program) ?? 'unknown program link error';
|
|
606
|
+
gl.deleteProgram(program);
|
|
607
|
+
throw new Error(log);
|
|
608
|
+
}
|
|
609
|
+
return program;
|
|
610
|
+
}
|
|
611
|
+
function uniformValue(uniform, request) {
|
|
612
|
+
return materialUniformValue(uniform, request.parameters, request.systems);
|
|
613
|
+
}
|
|
614
|
+
function materialUniformValue(uniform, parameters, systems) {
|
|
615
|
+
const value = uniform.source.kind === 'parameter'
|
|
616
|
+
? parameters[uniform.source.id]
|
|
617
|
+
: systems[uniform.source.id];
|
|
618
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
619
|
+
throw new TypeError(`Material uniform ${uniform.source.id} must be a finite number`);
|
|
620
|
+
}
|
|
621
|
+
return value;
|
|
622
|
+
}
|
|
623
|
+
function renderWebGl2Pass(request, fragmentShader, passInputs, uniforms, timing) {
|
|
624
|
+
const runtime = webGl2Runtime(request.width, request.height);
|
|
625
|
+
const gl = runtime.gl;
|
|
626
|
+
const program = runtime.program(fragmentShader);
|
|
627
|
+
gl.useProgram(program);
|
|
628
|
+
runtime.bindAttributes(program);
|
|
629
|
+
passInputs.forEach((input, index) => {
|
|
630
|
+
gl.activeTexture(gl.TEXTURE0 + index);
|
|
631
|
+
runtime.uploadTexture(index, input.source);
|
|
632
|
+
gl.uniform1i(gl.getUniformLocation(program, `u_input_${input.sampler}`), index);
|
|
633
|
+
});
|
|
634
|
+
for (const uniform of uniforms) {
|
|
635
|
+
gl.uniform1f(gl.getUniformLocation(program, uniform.name), uniformValue(uniform, request));
|
|
636
|
+
}
|
|
637
|
+
gl.viewport(0, 0, request.width, request.height);
|
|
638
|
+
const gpuStartedAt = performance.now();
|
|
639
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
640
|
+
gl.finish();
|
|
641
|
+
timing.gpuCompletionUs += Math.round((performance.now() - gpuStartedAt) * 1_000);
|
|
642
|
+
if (gl.isContextLost()) {
|
|
643
|
+
webGl2Runtimes.delete(`${request.width.toString()}x${request.height.toString()}`);
|
|
644
|
+
runtime.dispose();
|
|
645
|
+
throw new RendererBackendError('RENDERER_WEBGL_CONTEXT_LOST', 'WebGL2 context was lost during composition');
|
|
646
|
+
}
|
|
647
|
+
const bitmap = runtime.canvas.transferToImageBitmap();
|
|
648
|
+
// Chromium may evict a context between GPU completion and bitmap transfer
|
|
649
|
+
// when several tabs/workers contend for the page context budget. Never return
|
|
650
|
+
// the resulting transparent/stale bitmap as a successful composition.
|
|
651
|
+
if (gl.isContextLost()) {
|
|
652
|
+
bitmap.close();
|
|
653
|
+
webGl2Runtimes.delete(`${request.width.toString()}x${request.height.toString()}`);
|
|
654
|
+
runtime.dispose();
|
|
655
|
+
throw new RendererBackendError('RENDERER_WEBGL_CONTEXT_LOST', 'WebGL2 context was lost while transferring the composed frame');
|
|
656
|
+
}
|
|
657
|
+
return bitmap;
|
|
658
|
+
}
|
|
659
|
+
function composeWebGl2MultiPass(request, resources, timing) {
|
|
660
|
+
const passes = request.program.passes;
|
|
661
|
+
if (passes === undefined || passes.length === 0) {
|
|
662
|
+
throw new TypeError('Multi-pass WebGL2 program has no passes');
|
|
663
|
+
}
|
|
664
|
+
const outputs = new Map();
|
|
665
|
+
resources.webgl2Contexts = passes.length;
|
|
666
|
+
resources.webgl2Programs = passes.length;
|
|
667
|
+
resources.webgl2Buffers = passes.length * 2;
|
|
668
|
+
try {
|
|
669
|
+
for (const pass of passes) {
|
|
670
|
+
const inputs = pass.inputs.map(input => {
|
|
671
|
+
const source = input.source.kind === 'external'
|
|
672
|
+
? request.inputs[input.source.port]
|
|
673
|
+
: outputs.get(input.source.passId);
|
|
674
|
+
if (source === undefined) {
|
|
675
|
+
throw new RangeError(`Material pass ${pass.id} is missing input ${input.sampler}`);
|
|
676
|
+
}
|
|
677
|
+
return { sampler: input.sampler, source };
|
|
678
|
+
});
|
|
679
|
+
resources.webgl2Textures = inputs.length;
|
|
680
|
+
const bitmap = renderWebGl2Pass(request, pass.fragmentShader, inputs, pass.uniforms, timing);
|
|
681
|
+
outputs.set(pass.id, bitmap);
|
|
682
|
+
}
|
|
683
|
+
const last = outputs.get(passes.at(-1)?.id ?? '');
|
|
684
|
+
if (last === undefined)
|
|
685
|
+
throw new Error('Multi-pass Material produced no output');
|
|
686
|
+
outputs.delete(passes.at(-1)?.id ?? '');
|
|
687
|
+
return last;
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
outputs.forEach(bitmap => bitmap.close());
|
|
691
|
+
resources.webgl2Contexts = 0;
|
|
692
|
+
resources.webgl2Programs = 0;
|
|
693
|
+
resources.webgl2Buffers = 0;
|
|
694
|
+
resources.webgl2Textures = 0;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function composeWebGl2(request, resources, timing) {
|
|
698
|
+
if (!Number.isInteger(request.width) ||
|
|
699
|
+
!Number.isInteger(request.height) ||
|
|
700
|
+
request.width <= 0 ||
|
|
701
|
+
request.height <= 0) {
|
|
702
|
+
throw new RangeError('Composition dimensions must be positive integers');
|
|
703
|
+
}
|
|
704
|
+
for (const port of request.program.inputPorts) {
|
|
705
|
+
if (request.inputs[port] === undefined) {
|
|
706
|
+
throw new RangeError(`Material input ${port} is missing`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (request.program.passes !== undefined) {
|
|
710
|
+
return composeWebGl2MultiPass(request, resources, timing);
|
|
711
|
+
}
|
|
712
|
+
const inputs = request.program.inputPorts.map(port => {
|
|
713
|
+
const source = request.inputs[port];
|
|
714
|
+
if (source === undefined)
|
|
715
|
+
throw new RangeError(`Material input ${port} is missing`);
|
|
716
|
+
return { sampler: port.replaceAll(/[^a-zA-Z0-9_]/gu, '_'), source };
|
|
717
|
+
});
|
|
718
|
+
if (request.debugSimulateLoss === 'webgl2-context') {
|
|
719
|
+
const runtime = webGl2Runtime(request.width, request.height);
|
|
720
|
+
runtime.gl.getExtension('WEBGL_lose_context')?.loseContext();
|
|
721
|
+
webGl2Runtimes.delete(`${request.width.toString()}x${request.height.toString()}`);
|
|
722
|
+
runtime.dispose();
|
|
723
|
+
throw new RendererBackendError('RENDERER_WEBGL_CONTEXT_LOST', 'WebGL2 context was lost during composition');
|
|
724
|
+
}
|
|
725
|
+
return renderWebGl2Pass(request, request.program.fragmentShader, inputs, request.program.uniforms, timing);
|
|
726
|
+
}
|
|
727
|
+
async function composeWebGl2WithAdmissionRetry(request, resources, timing) {
|
|
728
|
+
const maxAttempts = 80;
|
|
729
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
730
|
+
try {
|
|
731
|
+
return composeWebGl2(request, resources, timing);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
const unavailable = error instanceof Error && error.message === 'Worker WebGL2 context is unavailable';
|
|
735
|
+
if (!unavailable)
|
|
736
|
+
throw error;
|
|
737
|
+
if (cancelledRequestIds.has(request.id)) {
|
|
738
|
+
throw new DOMException('Renderer request cancelled', 'AbortError');
|
|
739
|
+
}
|
|
740
|
+
if (attempt === maxAttempts - 1) {
|
|
741
|
+
throw new RendererBackendError('RENDERER_WEBGL_ADMISSION_TIMEOUT', 'Timed out waiting for a page WebGL2 context budget');
|
|
742
|
+
}
|
|
743
|
+
await new Promise(resolve => globalThis.setTimeout(resolve, 50));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
throw new RendererBackendError('RENDERER_WEBGL_ADMISSION_TIMEOUT', 'Timed out waiting for a page WebGL2 context budget');
|
|
747
|
+
}
|
|
748
|
+
const FRAME_GRAPH_COPY_SHADER = `#version 300 es
|
|
749
|
+
precision highp float;
|
|
750
|
+
uniform sampler2D u_input_source;
|
|
751
|
+
in vec2 v_uv;
|
|
752
|
+
out vec4 out_color;
|
|
753
|
+
void main() {
|
|
754
|
+
out_color = texture(u_input_source, v_uv);
|
|
755
|
+
}`;
|
|
756
|
+
function createFrameGraphTexture(gl, width, height, source) {
|
|
757
|
+
const texture = gl.createTexture();
|
|
758
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
759
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
760
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
761
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
762
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
763
|
+
if (source === undefined) {
|
|
764
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
765
|
+
const framebuffer = gl.createFramebuffer();
|
|
766
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
767
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
768
|
+
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
|
|
769
|
+
gl.deleteFramebuffer(framebuffer);
|
|
770
|
+
gl.deleteTexture(texture);
|
|
771
|
+
throw new RendererBackendError('RENDERER_WEBGL_FRAMEBUFFER_INCOMPLETE', 'Unable to allocate a complete WebGL2 frame graph target');
|
|
772
|
+
}
|
|
773
|
+
return { texture, framebuffer };
|
|
774
|
+
}
|
|
775
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
776
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
|
777
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
778
|
+
return { texture };
|
|
779
|
+
}
|
|
780
|
+
function bindFrameGraphInputs(runtime, program, inputs) {
|
|
781
|
+
const gl = runtime.gl;
|
|
782
|
+
inputs.forEach((input, index) => {
|
|
783
|
+
if (index >= gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)) {
|
|
784
|
+
throw new RangeError('Frame graph node exceeds the WebGL2 texture unit limit');
|
|
785
|
+
}
|
|
786
|
+
gl.activeTexture(gl.TEXTURE0 + index);
|
|
787
|
+
gl.bindTexture(gl.TEXTURE_2D, input.texture);
|
|
788
|
+
gl.uniform1i(gl.getUniformLocation(program, `u_input_${input.sampler}`), index);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function drawFrameGraphPass(runtime, fragmentShader, inputs, uniforms, parameters, systems, target) {
|
|
792
|
+
const gl = runtime.gl;
|
|
793
|
+
const program = runtime.program(fragmentShader);
|
|
794
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, target);
|
|
795
|
+
gl.useProgram(program);
|
|
796
|
+
runtime.bindAttributes(program);
|
|
797
|
+
bindFrameGraphInputs(runtime, program, inputs);
|
|
798
|
+
for (const uniform of uniforms) {
|
|
799
|
+
gl.uniform1f(gl.getUniformLocation(program, uniform.name), materialUniformValue(uniform, parameters, systems));
|
|
800
|
+
}
|
|
801
|
+
gl.viewport(0, 0, runtime.width, runtime.height);
|
|
802
|
+
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
803
|
+
}
|
|
804
|
+
function frameGraphInputTexture(request, node, port, externalTextures, nodeTextures) {
|
|
805
|
+
const reference = node.inputs[port];
|
|
806
|
+
if (reference === undefined) {
|
|
807
|
+
throw new RangeError(`Frame graph node ${node.id} is missing input ${port}`);
|
|
808
|
+
}
|
|
809
|
+
const resolved = reference.kind === 'external'
|
|
810
|
+
? externalTextures.get(reference.id)
|
|
811
|
+
: nodeTextures.get(reference.id);
|
|
812
|
+
if (resolved === undefined) {
|
|
813
|
+
const sourceKind = reference.kind === 'external' ? 'external input' : 'prior node';
|
|
814
|
+
throw new RangeError(`Frame graph node ${node.id} references unknown ${sourceKind} ${reference.id}`);
|
|
815
|
+
}
|
|
816
|
+
if (reference.kind === 'external' && request.inputs[reference.id] === undefined) {
|
|
817
|
+
throw new RangeError(`Frame graph external input ${reference.id} is missing`);
|
|
818
|
+
}
|
|
819
|
+
return resolved.texture;
|
|
820
|
+
}
|
|
821
|
+
function executeFrameGraphNode(request, runtime, node, externalTextures, nodeTextures, allocated) {
|
|
822
|
+
if (nodeTextures.has(node.id)) {
|
|
823
|
+
throw new RangeError(`Frame graph node id ${node.id} is duplicated`);
|
|
824
|
+
}
|
|
825
|
+
const passes = node.program.passes;
|
|
826
|
+
if (passes === undefined) {
|
|
827
|
+
const target = createFrameGraphTexture(runtime.gl, request.width, request.height);
|
|
828
|
+
allocated.push(target);
|
|
829
|
+
const inputs = node.program.inputPorts.map(port => ({
|
|
830
|
+
sampler: port.replaceAll(/[^a-zA-Z0-9_]/gu, '_'),
|
|
831
|
+
texture: frameGraphInputTexture(request, node, port, externalTextures, nodeTextures),
|
|
832
|
+
}));
|
|
833
|
+
drawFrameGraphPass(runtime, node.program.fragmentShader, inputs, node.program.uniforms, node.parameters, node.systems, target.framebuffer ?? null);
|
|
834
|
+
nodeTextures.set(node.id, target);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (passes.length === 0) {
|
|
838
|
+
throw new TypeError(`Frame graph node ${node.id} has an empty multi-pass program`);
|
|
839
|
+
}
|
|
840
|
+
const passTextures = new Map();
|
|
841
|
+
for (const pass of passes) {
|
|
842
|
+
if (passTextures.has(pass.id)) {
|
|
843
|
+
throw new RangeError(`Material pass id ${pass.id} is duplicated in node ${node.id}`);
|
|
844
|
+
}
|
|
845
|
+
const target = createFrameGraphTexture(runtime.gl, request.width, request.height);
|
|
846
|
+
allocated.push(target);
|
|
847
|
+
const inputs = pass.inputs.map(input => {
|
|
848
|
+
const texture = input.source.kind === 'external'
|
|
849
|
+
? frameGraphInputTexture(request, node, input.source.port, externalTextures, nodeTextures)
|
|
850
|
+
: passTextures.get(input.source.passId)?.texture;
|
|
851
|
+
if (texture === undefined) {
|
|
852
|
+
throw new RangeError(`Frame graph node ${node.id} pass ${pass.id} references unknown input ${input.sampler}`);
|
|
853
|
+
}
|
|
854
|
+
return { sampler: input.sampler, texture };
|
|
855
|
+
});
|
|
856
|
+
drawFrameGraphPass(runtime, pass.fragmentShader, inputs, pass.uniforms, node.parameters, node.systems, target.framebuffer ?? null);
|
|
857
|
+
passTextures.set(pass.id, target);
|
|
858
|
+
}
|
|
859
|
+
const output = passTextures.get(passes.at(-1)?.id ?? '');
|
|
860
|
+
if (output === undefined)
|
|
861
|
+
throw new Error(`Frame graph node ${node.id} produced no output`);
|
|
862
|
+
nodeTextures.set(node.id, output);
|
|
863
|
+
}
|
|
864
|
+
function composeWebGl2FrameGraph(request, resources, timing) {
|
|
865
|
+
if (!Number.isInteger(request.width) ||
|
|
866
|
+
!Number.isInteger(request.height) ||
|
|
867
|
+
request.width <= 0 ||
|
|
868
|
+
request.height <= 0) {
|
|
869
|
+
throw new RangeError('Frame graph dimensions must be positive integers');
|
|
870
|
+
}
|
|
871
|
+
if (request.nodes.length === 0)
|
|
872
|
+
throw new RangeError('Frame graph has no nodes');
|
|
873
|
+
const runtime = webGl2Runtime(request.width, request.height);
|
|
874
|
+
const gl = runtime.gl;
|
|
875
|
+
const allocated = [];
|
|
876
|
+
const externalTextures = new Map();
|
|
877
|
+
const nodeTextures = new Map();
|
|
878
|
+
resources.webgl2Contexts = 1;
|
|
879
|
+
resources.webgl2Buffers = 2;
|
|
880
|
+
try {
|
|
881
|
+
for (const [id, frame] of Object.entries(request.inputs)) {
|
|
882
|
+
const texture = createFrameGraphTexture(gl, request.width, request.height, frame);
|
|
883
|
+
allocated.push(texture);
|
|
884
|
+
externalTextures.set(id, texture);
|
|
885
|
+
}
|
|
886
|
+
const gpuStartedAt = performance.now();
|
|
887
|
+
for (const node of request.nodes) {
|
|
888
|
+
if (cancelledRequestIds.has(request.id)) {
|
|
889
|
+
throw new DOMException('Renderer request cancelled', 'AbortError');
|
|
890
|
+
}
|
|
891
|
+
executeFrameGraphNode(request, runtime, node, externalTextures, nodeTextures, allocated);
|
|
892
|
+
}
|
|
893
|
+
const output = nodeTextures.get(request.outputNodeId);
|
|
894
|
+
if (output === undefined) {
|
|
895
|
+
throw new RangeError(`Frame graph output node ${request.outputNodeId} is missing`);
|
|
896
|
+
}
|
|
897
|
+
drawFrameGraphPass(runtime, FRAME_GRAPH_COPY_SHADER, [{ sampler: 'source', texture: output.texture }], [], {}, {}, null);
|
|
898
|
+
gl.finish();
|
|
899
|
+
timing.gpuCompletionUs += Math.round((performance.now() - gpuStartedAt) * 1_000);
|
|
900
|
+
if (gl.isContextLost()) {
|
|
901
|
+
webGl2Runtimes.delete(`${request.width.toString()}x${request.height.toString()}`);
|
|
902
|
+
runtime.dispose();
|
|
903
|
+
throw new RendererBackendError('RENDERER_WEBGL_CONTEXT_LOST', 'WebGL2 context was lost during frame graph composition');
|
|
904
|
+
}
|
|
905
|
+
resources.webgl2Programs = runtime.snapshot().programs;
|
|
906
|
+
resources.webgl2Textures = allocated.length;
|
|
907
|
+
const bitmap = runtime.canvas.transferToImageBitmap();
|
|
908
|
+
if (gl.isContextLost()) {
|
|
909
|
+
bitmap.close();
|
|
910
|
+
webGl2Runtimes.delete(`${request.width.toString()}x${request.height.toString()}`);
|
|
911
|
+
runtime.dispose();
|
|
912
|
+
throw new RendererBackendError('RENDERER_WEBGL_CONTEXT_LOST', 'WebGL2 context was lost while transferring the frame graph result');
|
|
913
|
+
}
|
|
914
|
+
return bitmap;
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
918
|
+
allocated.forEach(({ framebuffer, texture }) => {
|
|
919
|
+
if (framebuffer !== undefined)
|
|
920
|
+
gl.deleteFramebuffer(framebuffer);
|
|
921
|
+
gl.deleteTexture(texture);
|
|
922
|
+
});
|
|
923
|
+
resources.webgl2Contexts = 0;
|
|
924
|
+
resources.webgl2Programs = 0;
|
|
925
|
+
resources.webgl2Buffers = 0;
|
|
926
|
+
resources.webgl2Textures = 0;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async function composeWebGl2FrameGraphWithAdmissionRetry(request, resources, timing) {
|
|
930
|
+
const maxAttempts = 80;
|
|
931
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
932
|
+
try {
|
|
933
|
+
return composeWebGl2FrameGraph(request, resources, timing);
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
const unavailable = error instanceof Error && error.message === 'Worker WebGL2 context is unavailable';
|
|
937
|
+
if (!unavailable)
|
|
938
|
+
throw error;
|
|
939
|
+
if (cancelledRequestIds.has(request.id)) {
|
|
940
|
+
throw new DOMException('Renderer request cancelled', 'AbortError');
|
|
941
|
+
}
|
|
942
|
+
if (attempt === maxAttempts - 1) {
|
|
943
|
+
throw new RendererBackendError('RENDERER_WEBGL_ADMISSION_TIMEOUT', 'Timed out waiting for a page WebGL2 context budget');
|
|
944
|
+
}
|
|
945
|
+
await new Promise(resolve => globalThis.setTimeout(resolve, 50));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
throw new RendererBackendError('RENDERER_WEBGL_ADMISSION_TIMEOUT', 'Timed out waiting for a page WebGL2 context budget');
|
|
949
|
+
}
|
|
950
|
+
const worker = globalThis;
|
|
951
|
+
const MAX_ACTIVE_REQUESTS = 8;
|
|
952
|
+
const activeRequestIds = new Set();
|
|
953
|
+
const cancelledRequestIds = new Set();
|
|
954
|
+
function finishRequest(id, resources) {
|
|
955
|
+
activeRequestIds.delete(id);
|
|
956
|
+
cancelledRequestIds.delete(id);
|
|
957
|
+
resources.activeRequests = activeRequestIds.size;
|
|
958
|
+
resources.cancelledRequests = cancelledRequestIds.size;
|
|
959
|
+
}
|
|
960
|
+
function acknowledgeCancellation(id) {
|
|
961
|
+
worker.postMessage({ type: 'cancelled', id });
|
|
962
|
+
}
|
|
963
|
+
worker.addEventListener('message', (event) => {
|
|
964
|
+
const request = event.data;
|
|
965
|
+
if (request.type === 'dispose') {
|
|
966
|
+
disposeGpuRuntime();
|
|
967
|
+
disposeWebGl2Runtimes();
|
|
968
|
+
worker.close();
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (request.type === 'cancel') {
|
|
972
|
+
if (activeRequestIds.has(request.id))
|
|
973
|
+
cancelledRequestIds.add(request.id);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (request.type === 'inspect-resources') {
|
|
977
|
+
request.responsePort.postMessage({
|
|
978
|
+
activeRequests: activeRequestIds.size,
|
|
979
|
+
cancelledRequests: cancelledRequestIds.size,
|
|
980
|
+
});
|
|
981
|
+
request.responsePort.close();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (activeRequestIds.size >= MAX_ACTIVE_REQUESTS) {
|
|
985
|
+
Object.values(request.inputs).forEach(frame => frame.close());
|
|
986
|
+
worker.postMessage({
|
|
987
|
+
type: 'failed',
|
|
988
|
+
id: request.id,
|
|
989
|
+
code: 'RENDERER_QUEUE_FULL',
|
|
990
|
+
message: `Renderer Worker reached its ${MAX_ACTIVE_REQUESTS.toString()} active request limit`,
|
|
991
|
+
});
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
activeRequestIds.add(request.id);
|
|
995
|
+
void (async () => {
|
|
996
|
+
const workerStartedAt = performance.now();
|
|
997
|
+
let response;
|
|
998
|
+
const diagnostics = [];
|
|
999
|
+
const resources = emptyResources(Object.keys(request.inputs).length);
|
|
1000
|
+
const timing = { gpuCompletionUs: 0 };
|
|
1001
|
+
let inputsClosed = false;
|
|
1002
|
+
const closeInputs = () => {
|
|
1003
|
+
if (inputsClosed)
|
|
1004
|
+
return;
|
|
1005
|
+
inputsClosed = true;
|
|
1006
|
+
Object.values(request.inputs).forEach(frame => frame.close());
|
|
1007
|
+
resources.inputFrames = 0;
|
|
1008
|
+
};
|
|
1009
|
+
try {
|
|
1010
|
+
let bitmap;
|
|
1011
|
+
let backend;
|
|
1012
|
+
if (request.type === 'compose-frame-graph') {
|
|
1013
|
+
if (request.preferredBackend === 'webgpu') {
|
|
1014
|
+
try {
|
|
1015
|
+
bitmap = await composeWebGpuFrameGraph(request, resources, timing);
|
|
1016
|
+
backend = 'webgpu';
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
const failure = backendError(error, 'RENDERER_WEBGPU_FRAME_GRAPH_FAILED');
|
|
1020
|
+
if (!request.allowFallback)
|
|
1021
|
+
throw failure;
|
|
1022
|
+
diagnostics.push({ code: failure.code, message: failure.message });
|
|
1023
|
+
bitmap = await composeWebGl2FrameGraphWithAdmissionRetry(request, resources, timing);
|
|
1024
|
+
backend = 'webgl2';
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
bitmap = await composeWebGl2FrameGraphWithAdmissionRetry(request, resources, timing);
|
|
1029
|
+
backend = 'webgl2';
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
else if (request.preferredBackend === 'webgpu') {
|
|
1033
|
+
try {
|
|
1034
|
+
bitmap = await composeWebGpu(request, resources, timing);
|
|
1035
|
+
backend = 'webgpu';
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
const failure = backendError(error, 'RENDERER_WEBGPU_FAILED');
|
|
1039
|
+
if (!request.allowFallback)
|
|
1040
|
+
throw failure;
|
|
1041
|
+
diagnostics.push({ code: failure.code, message: failure.message });
|
|
1042
|
+
bitmap = await composeWebGl2WithAdmissionRetry(request, resources, timing);
|
|
1043
|
+
backend = 'webgl2';
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
bitmap = await composeWebGl2WithAdmissionRetry(request, resources, timing);
|
|
1048
|
+
backend = 'webgl2';
|
|
1049
|
+
}
|
|
1050
|
+
closeInputs();
|
|
1051
|
+
if (cancelledRequestIds.has(request.id)) {
|
|
1052
|
+
bitmap.close();
|
|
1053
|
+
finishRequest(request.id, resources);
|
|
1054
|
+
acknowledgeCancellation(request.id);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
finishRequest(request.id, resources);
|
|
1058
|
+
response = {
|
|
1059
|
+
type: 'composed',
|
|
1060
|
+
id: request.id,
|
|
1061
|
+
bitmap,
|
|
1062
|
+
backend,
|
|
1063
|
+
graphHash: request.type === 'compose'
|
|
1064
|
+
? request.program.graphHash
|
|
1065
|
+
: `frame-graph:${request.nodes
|
|
1066
|
+
.map(node => `${node.id}:${node.program.graphHash}`)
|
|
1067
|
+
.join('|')}`,
|
|
1068
|
+
diagnostics,
|
|
1069
|
+
resources,
|
|
1070
|
+
outputBitmapOwner: 'caller',
|
|
1071
|
+
timing: {
|
|
1072
|
+
totalWorkerUs: Math.round((performance.now() - workerStartedAt) * 1_000),
|
|
1073
|
+
gpuCompletionUs: timing.gpuCompletionUs,
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
worker.postMessage(response, [bitmap]);
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
if (cancelledRequestIds.has(request.id)) {
|
|
1080
|
+
finishRequest(request.id, resources);
|
|
1081
|
+
acknowledgeCancellation(request.id);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
finishRequest(request.id, resources);
|
|
1085
|
+
const failure = backendError(error, 'RENDERER_WORKER_COMPOSE_FAILED');
|
|
1086
|
+
response = {
|
|
1087
|
+
type: 'failed',
|
|
1088
|
+
id: request.id,
|
|
1089
|
+
code: failure.code,
|
|
1090
|
+
message: failure.message,
|
|
1091
|
+
};
|
|
1092
|
+
worker.postMessage(response);
|
|
1093
|
+
}
|
|
1094
|
+
finally {
|
|
1095
|
+
closeInputs();
|
|
1096
|
+
finishRequest(request.id, resources);
|
|
1097
|
+
}
|
|
1098
|
+
})();
|
|
1099
|
+
});
|
|
1100
|
+
export {};
|