@actis/core 26.3.0 → 26.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,122 +1,554 @@
1
+ import { bindFramebufferInfo, createBufferInfoFromArrays, createFramebufferInfo, createProgramInfo, drawBufferInfo, resizeFramebufferInfo, setBuffersAndAttributes, setTextureFilteringForSize, setTextureParameters, setUniforms } from "twgl.js";
2
+ //#region src/engine/FrameDriver.ts
3
+ /**
4
+ * Main-thread driver backed by `requestAnimationFrame`. Safe to construct
5
+ * during SSR or in workers: scheduling becomes a no-op when rAF is missing.
6
+ */
7
+ function rafFrameDriver() {
8
+ let id = -1;
9
+ return {
10
+ request(callback) {
11
+ if (typeof requestAnimationFrame === "undefined") return;
12
+ id = requestAnimationFrame(callback);
13
+ },
14
+ cancel() {
15
+ if (id === -1 || typeof cancelAnimationFrame === "undefined") {
16
+ id = -1;
17
+ return;
18
+ }
19
+ cancelAnimationFrame(id);
20
+ id = -1;
21
+ }
22
+ };
23
+ }
24
+ /**
25
+ * Host-agnostic driver backed by a timer. Used inside workers, where
26
+ * `requestAnimationFrame` is unavailable.
27
+ */
28
+ function timerFrameDriver(intervalMs = 1e3 / 60) {
29
+ let id = -1;
30
+ return {
31
+ request(callback) {
32
+ if (typeof setTimeout === "undefined") return;
33
+ id = setTimeout(() => {
34
+ id = -1;
35
+ callback(typeof performance !== "undefined" ? performance.now() : Date.now());
36
+ }, intervalMs);
37
+ },
38
+ cancel() {
39
+ if (id === -1 || typeof clearTimeout === "undefined") {
40
+ id = -1;
41
+ return;
42
+ }
43
+ clearTimeout(id);
44
+ id = -1;
45
+ }
46
+ };
47
+ }
48
+ //#endregion
49
+ //#region src/context/ContextCapabilities.ts
50
+ function isWebGL2Context(gl) {
51
+ return typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext || typeof gl.texStorage2D === "function";
52
+ }
53
+ function detectGLContextCapabilities(gl) {
54
+ const isWebGL2 = isWebGL2Context(gl);
55
+ return Object.freeze({
56
+ isWebGL2,
57
+ supportsFloatTexture: isWebGL2 || gl.getExtension("OES_texture_float") !== null,
58
+ supportsFloatColorBuffer: isWebGL2 ? gl.getExtension("EXT_color_buffer_float") !== null : gl.getExtension("WEBGL_color_buffer_float") !== null,
59
+ supportsFloatTextureLinear: gl.getExtension("OES_texture_float_linear") !== null
60
+ });
61
+ }
62
+ //#endregion
63
+ //#region src/fbo/FramebufferAttachmentSupport.ts
64
+ function resolveFramebufferAttachmentOptions(gl, capabilities, options) {
65
+ if (!isFloatFramebufferAttachment(gl, options)) return { ...options };
66
+ const nextOptions = { ...options };
67
+ if (capabilities.isWebGL2 && nextOptions.internalFormat === void 0 && (nextOptions.format === void 0 || nextOptions.format === gl.RGBA)) nextOptions.internalFormat = gl.RGBA32F;
68
+ if (nextOptions.min === void 0 && nextOptions.mag === void 0 && nextOptions.minMag === void 0) {
69
+ nextOptions.min = gl.NEAREST;
70
+ nextOptions.mag = gl.NEAREST;
71
+ }
72
+ if (nextOptions.wrap === void 0 && nextOptions.wrapS === void 0 && nextOptions.wrapT === void 0) {
73
+ nextOptions.wrapS = gl.CLAMP_TO_EDGE;
74
+ nextOptions.wrapT = gl.CLAMP_TO_EDGE;
75
+ }
76
+ return nextOptions;
77
+ }
78
+ function assertFramebufferAttachmentSupport(gl, capabilities, options) {
79
+ if (!isFloatFramebufferAttachment(gl, options)) return;
80
+ if (!capabilities.supportsFloatTexture) throw new Error("OES_texture_float not supported");
81
+ if (!capabilities.supportsFloatColorBuffer) throw new Error(capabilities.isWebGL2 ? "EXT_color_buffer_float not supported" : "WEBGL_color_buffer_float not supported");
82
+ if (usesLinearFiltering(gl, options) && !capabilities.supportsFloatTextureLinear) throw new Error("OES_texture_float_linear not supported");
83
+ }
84
+ function isFloatFramebufferAttachment(gl, options) {
85
+ return options.attachment === void 0 && options.type === gl.FLOAT && options.samples === void 0;
86
+ }
87
+ function usesLinearFiltering(gl, options) {
88
+ return isLinearFilter(gl, options.min) || isLinearFilter(gl, options.mag) || isLinearFilter(gl, options.minMag);
89
+ }
90
+ function isLinearFilter(gl, filter) {
91
+ return filter === gl.LINEAR || filter === gl.LINEAR_MIPMAP_NEAREST || filter === gl.NEAREST_MIPMAP_LINEAR || filter === gl.LINEAR_MIPMAP_LINEAR;
92
+ }
93
+ //#endregion
94
+ //#region src/fbo/FBO.ts
95
+ /**
96
+ * Wrapper around a WebGL framebuffer and its color attachment texture.
97
+ */
98
+ var FBO = class {
99
+ constructor(gl, capabilities, width, height, texture) {
100
+ this.gl = gl;
101
+ this.capabilities = capabilities;
102
+ this.width = width;
103
+ this.height = height;
104
+ this.texture = texture;
105
+ this.framebufferInfo = this.create(width, height);
106
+ }
107
+ create(width, height) {
108
+ const attachmentOptions = this.getAttachmentOptions();
109
+ this.ensureAttachmentSupport(attachmentOptions);
110
+ const framebufferInfo = createFramebufferInfo(this.gl, [attachmentOptions], width, height);
111
+ this.assertFramebufferComplete(framebufferInfo);
112
+ this.clear(framebufferInfo);
113
+ this.texture.setHandle(framebufferInfo.attachments[0]);
114
+ this.texture.generateMipmap(this.gl, width, height);
115
+ return framebufferInfo;
116
+ }
117
+ get info() {
118
+ return this.framebufferInfo;
119
+ }
120
+ bind() {
121
+ bindFramebufferInfo(this.gl, this.framebufferInfo);
122
+ }
123
+ resize(width, height) {
124
+ this.width = width;
125
+ this.height = height;
126
+ const attachmentOptions = this.getAttachmentOptions();
127
+ this.ensureAttachmentSupport(attachmentOptions);
128
+ resizeFramebufferInfo(this.gl, this.framebufferInfo, [attachmentOptions], width, height);
129
+ this.assertFramebufferComplete(this.framebufferInfo);
130
+ this.texture.setHandle(this.framebufferInfo.attachments[0]);
131
+ this.clear();
132
+ this.texture.generateMipmap(this.gl, width, height);
133
+ }
134
+ clear(framebufferInfo = this.framebufferInfo) {
135
+ bindFramebufferInfo(this.gl, framebufferInfo);
136
+ this.gl.clearColor(0, 0, 0, 0);
137
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT);
138
+ bindFramebufferInfo(this.gl, null);
139
+ }
140
+ getAttachmentOptions() {
141
+ return this.texture.getFramebufferAttachmentOptions(this.gl);
142
+ }
143
+ ensureAttachmentSupport(attachmentOptions) {
144
+ assertFramebufferAttachmentSupport(this.gl, this.capabilities, attachmentOptions);
145
+ }
146
+ assertFramebufferComplete(framebufferInfo) {
147
+ bindFramebufferInfo(this.gl, framebufferInfo);
148
+ const status = this.gl.checkFramebufferStatus(this.gl.FRAMEBUFFER);
149
+ bindFramebufferInfo(this.gl, null);
150
+ if (status !== this.gl.FRAMEBUFFER_COMPLETE) throw new Error(`FBO incomplete: 0x${status.toString(16)}`);
151
+ }
152
+ };
153
+ //#endregion
154
+ //#region src/texture/TextureParameters.ts
155
+ function getTextureFramebufferAttachmentOptions(gl, capabilities, options) {
156
+ const { auto, generateMipmaps, mag, magFilter, min, minFilter, minMag, wrap, wrapS, wrapT, ...rest } = options;
157
+ const resolvedMag = resolveTextureFilter(gl, mag ?? magFilter);
158
+ const resolvedMin = resolveTextureFilter(gl, min ?? minFilter);
159
+ const resolvedMinMag = resolveTextureFilter(gl, minMag);
160
+ const resolvedWrap = resolveTextureWrap(gl, wrap);
161
+ const resolvedWrapS = resolveTextureWrap(gl, wrapS);
162
+ const resolvedWrapT = resolveTextureWrap(gl, wrapT);
163
+ const shouldDefaultToFloatType = rest.type === void 0 && rest.attachment === void 0 && rest.internalFormat === void 0 && (rest.format === void 0 || rest.format === gl.RGBA);
164
+ return resolveFramebufferAttachmentOptions(gl, capabilities, {
165
+ ...rest,
166
+ auto: auto ?? generateMipmaps,
167
+ ...shouldDefaultToFloatType && { type: gl.FLOAT },
168
+ ...resolvedMag !== void 0 && { mag: resolvedMag },
169
+ ...resolvedMin !== void 0 && { min: resolvedMin },
170
+ ...resolvedMinMag !== void 0 && { minMag: resolvedMinMag },
171
+ ...resolvedWrap !== void 0 && { wrap: resolvedWrap },
172
+ ...resolvedWrapS !== void 0 && { wrapS: resolvedWrapS },
173
+ ...resolvedWrapT !== void 0 && { wrapT: resolvedWrapT }
174
+ });
175
+ }
176
+ function shouldUpdateTextureFiltering(options) {
177
+ return (options.auto ?? options.generateMipmaps) === true;
178
+ }
179
+ function resolveTextureFilter(gl, filter) {
180
+ if (filter === void 0) return void 0;
181
+ if (typeof filter === "number") return filter;
182
+ return {
183
+ "nearest": gl.NEAREST,
184
+ "linear": gl.LINEAR,
185
+ "nearest-mipmap-nearest": gl.NEAREST_MIPMAP_NEAREST,
186
+ "linear-mipmap-nearest": gl.LINEAR_MIPMAP_NEAREST,
187
+ "nearest-mipmap-linear": gl.NEAREST_MIPMAP_LINEAR,
188
+ "linear-mipmap-linear": gl.LINEAR_MIPMAP_LINEAR
189
+ }[filter] ?? gl.LINEAR;
190
+ }
191
+ function resolveTextureWrap(gl, wrap) {
192
+ if (wrap === void 0) return void 0;
193
+ if (typeof wrap === "number") return wrap;
194
+ return {
195
+ "repeat": gl.REPEAT,
196
+ "mirrored-repeat": gl.MIRRORED_REPEAT,
197
+ "clamp-to-edge": gl.CLAMP_TO_EDGE
198
+ }[wrap] ?? gl.CLAMP_TO_EDGE;
199
+ }
200
+ //#endregion
201
+ //#region src/texture/Texture.ts
202
+ /**
203
+ * Minimal named wrapper around a WebGL texture handle.
204
+ * Used internally for pass outputs and texture bindings.
205
+ */
206
+ var Texture = class {
207
+ constructor(name, options = {}, handle, capabilities) {
208
+ this.name = name;
209
+ this.capabilities = capabilities;
210
+ this.options = Object.freeze({ ...options });
211
+ this.handleValue = handle;
212
+ }
213
+ get handle() {
214
+ return this.handleValue;
215
+ }
216
+ applyParameters(gl) {
217
+ if (!this.handleValue) return;
218
+ setTextureParameters(gl, this.handleValue, this.getFramebufferAttachmentOptions(gl));
219
+ }
220
+ generateMipmap(gl, width, height) {
221
+ if (!this.handleValue || !shouldUpdateTextureFiltering(this.options)) return false;
222
+ const options = this.getFramebufferAttachmentOptions(gl);
223
+ setTextureFilteringForSize(gl, this.handleValue, options, width, height, options.internalFormat ?? options.format ?? gl.RGBA);
224
+ return true;
225
+ }
226
+ getFramebufferAttachmentOptions(gl) {
227
+ return getTextureFramebufferAttachmentOptions(gl, this.getCapabilities(gl), this.options);
228
+ }
229
+ setHandle(handle) {
230
+ this.handleValue = handle;
231
+ return this;
232
+ }
233
+ getCapabilities(gl) {
234
+ if (!this.capabilities) this.capabilities = detectGLContextCapabilities(gl);
235
+ return this.capabilities;
236
+ }
237
+ };
238
+ //#endregion
1
239
  //#region src/pass/Pass.ts
2
240
  /**
3
241
  * Represents a render pass in the WebGL pipeline.
4
- * Responsible for managing framebuffer, texture, and draw calls for a single pass.
5
242
  */
6
243
  var Pass = class {
7
- constructor(gl, shader, width, height, offscreen = true, textures = []) {
244
+ constructor(gl, capabilities, shader, geometry, width, height, offscreen = true, textures = [], pingPong = false, textureOptions = {}) {
8
245
  this.gl = gl;
9
246
  this.shader = shader;
247
+ this.bufferInfo = geometry;
10
248
  this.width = width;
11
249
  this.height = height;
12
- this.texture = this.createTexture();
13
- this.framebuffer = this.createFramebuffer(this.texture);
14
- this.next = null;
15
250
  this.offscreen = offscreen;
251
+ this.pingPong = offscreen && pingPong;
16
252
  this.textures = textures;
17
- this.positionAttributeLocation = this.shader.getAttribLocation("a_position");
18
- this.positionBuffer = this.createPositionBuffer();
253
+ this.fbos = [];
254
+ this.readBufferIndex = 0;
255
+ if (this.offscreen) {
256
+ const framebufferCount = this.pingPong ? 2 : 1;
257
+ for (let index = 0; index < framebufferCount; index++) this.fbos.push(new FBO(gl, capabilities, width, height, new Texture(shader.passName, textureOptions, void 0, capabilities)));
258
+ }
259
+ }
260
+ get fbo() {
261
+ return this.readFBO;
262
+ }
263
+ get texture() {
264
+ return this.readFBO?.texture;
19
265
  }
20
266
  use() {
21
267
  this.shader.use();
22
- if (this.offscreen) {
23
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.framebuffer);
24
- this.gl.viewport(0, 0, this.width, this.height);
25
- } else {
26
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
268
+ if (this.offscreen && this.writeFBO) this.writeFBO.bind();
269
+ else {
270
+ bindFramebufferInfo(this.gl, null);
27
271
  this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
28
272
  }
29
273
  }
30
274
  resize(width, height) {
31
275
  this.width = width;
32
276
  this.height = height;
33
- if (this.offscreen) {
34
- this.texture = this.createTexture();
35
- this.framebuffer = this.createFramebuffer(this.texture);
36
- }
277
+ if (this.offscreen) this.fbos.forEach((fbo) => fbo.resize(width, height));
37
278
  }
38
279
  draw() {
39
- const positions = new Float32Array([
40
- -1,
41
- -1,
42
- 1,
43
- -1,
44
- -1,
45
- 1,
46
- -1,
47
- 1,
48
- 1,
49
- -1,
50
- 1,
51
- 1
52
- ]);
53
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.positionBuffer);
54
- this.gl.bufferData(this.gl.ARRAY_BUFFER, positions, this.gl.STATIC_DRAW);
55
- this.gl.enableVertexAttribArray(this.positionAttributeLocation);
56
- this.gl.vertexAttribPointer(this.positionAttributeLocation, 2, this.gl.FLOAT, false, 0, 0);
57
- this.textures.forEach((texture, index) => {
58
- this.gl.activeTexture(this.gl.TEXTURE0 + index);
59
- this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
60
- this.shader.setUniform(`u_texture${index}`, "1i", index);
280
+ setBuffersAndAttributes(this.gl, this.shader.programInfo, this.bufferInfo);
281
+ drawBufferInfo(this.gl, this.bufferInfo);
282
+ if (this.pingPong) this.swap();
283
+ this.texture?.generateMipmap(this.gl, this.width, this.height);
284
+ }
285
+ clear() {
286
+ this.readBufferIndex = 0;
287
+ this.fbos.forEach((fbo) => fbo.clear());
288
+ }
289
+ get readFBO() {
290
+ return this.fbos[this.readBufferIndex] ?? null;
291
+ }
292
+ get writeFBO() {
293
+ if (!this.offscreen) return null;
294
+ if (!this.pingPong) return this.readFBO;
295
+ return this.fbos[(this.readBufferIndex + 1) % this.fbos.length] ?? null;
296
+ }
297
+ swap() {
298
+ this.readBufferIndex = (this.readBufferIndex + 1) % this.fbos.length;
299
+ }
300
+ };
301
+ //#endregion
302
+ //#region src/pipeline/graph/KahnPipelineSorter.ts
303
+ var KahnPipelineSorter = class {
304
+ sort(registry) {
305
+ const entries = registry.getAll();
306
+ const graph = this.buildGraph(entries, registry);
307
+ return this.topoSort(entries, graph, registry);
308
+ }
309
+ buildGraph(entries, registry) {
310
+ const inDegree = /* @__PURE__ */ new Map();
311
+ const adjacency = /* @__PURE__ */ new Map();
312
+ for (const entry of entries) {
313
+ inDegree.set(entry.name, 0);
314
+ adjacency.set(entry.name, []);
315
+ }
316
+ for (const entry of entries) for (const dep of entry.dependencies) {
317
+ if (!registry.has(dep)) throw new Error(`Pass "${entry.name}" depends on "${dep}", which is not registered`);
318
+ adjacency.get(dep).push(entry.name);
319
+ inDegree.set(entry.name, inDegree.get(entry.name) + 1);
320
+ }
321
+ return {
322
+ inDegree,
323
+ adjacency
324
+ };
325
+ }
326
+ topoSort(entries, { inDegree, adjacency }, registry) {
327
+ const queue = entries.filter((entry) => inDegree.get(entry.name) === 0).map((entry) => entry.name);
328
+ const ordered = [];
329
+ while (queue.length > 0) {
330
+ const name = queue.shift();
331
+ const entry = registry.get(name);
332
+ if (entry === void 0) throw new Error(`Internal error: pass "${name}" is referenced in the graph but missing from the registry`);
333
+ ordered.push(entry.pass);
334
+ for (const dependent of adjacency.get(name) ?? []) {
335
+ const nextDegree = inDegree.get(dependent) - 1;
336
+ inDegree.set(dependent, nextDegree);
337
+ if (nextDegree === 0) queue.push(dependent);
338
+ }
339
+ }
340
+ if (ordered.length !== entries.length) {
341
+ const cyclic = entries.map((entry) => entry.name).filter((name) => (inDegree.get(name) ?? 0) > 0);
342
+ throw new Error(`Pipeline contains a cyclic dependency among: ${cyclic.join(", ")}`);
343
+ }
344
+ return ordered;
345
+ }
346
+ };
347
+ //#endregion
348
+ //#region src/pipeline/graph/PipelineGraph.ts
349
+ var PipelineGraph = class {
350
+ constructor() {
351
+ this.entries = /* @__PURE__ */ new Map();
352
+ }
353
+ add(entry) {
354
+ if (this.entries.has(entry.name)) throw new Error(`Pass "${entry.name}" is already registered`);
355
+ this.entries.set(entry.name, {
356
+ ...entry,
357
+ dependencies: [...entry.dependencies]
61
358
  });
62
- this.gl.drawArrays(this.gl.TRIANGLES, 0, positions.length / 2);
63
- }
64
- createTexture() {
65
- const texture = this.gl.createTexture();
66
- this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
67
- this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
68
- this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
69
- this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
70
- this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
71
- this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.width, this.height, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, null);
72
- return texture;
73
- }
74
- createFramebuffer(texture) {
75
- const framebuffer = this.gl.createFramebuffer();
76
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, framebuffer);
77
- this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, texture, 0);
78
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
79
- return framebuffer;
80
- }
81
- createPositionBuffer() {
82
- const buffer = this.gl.createBuffer();
83
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
84
- return buffer;
359
+ }
360
+ clear() {
361
+ this.entries.clear();
362
+ }
363
+ get(name) {
364
+ const entry = this.entries.get(name);
365
+ if (entry === void 0) return void 0;
366
+ return {
367
+ ...entry,
368
+ dependencies: [...entry.dependencies]
369
+ };
370
+ }
371
+ getAll() {
372
+ return [...this.entries.values()];
373
+ }
374
+ has(name) {
375
+ return this.entries.has(name);
376
+ }
377
+ };
378
+ //#endregion
379
+ //#region src/pipeline/Pipeline.ts
380
+ /**
381
+ * Orchestrates pass storage and dependency resolution for the render graph
382
+ */
383
+ var Pipeline = class {
384
+ constructor(registry = new PipelineGraph(), sorter = new KahnPipelineSorter()) {
385
+ this.registry = registry;
386
+ this.sorter = sorter;
387
+ this.orderedPasses = [];
388
+ this.dirty = false;
389
+ }
390
+ add(name, pass, dependencies = []) {
391
+ const sanitizedDependencies = dependencies.filter((dependency) => {
392
+ if (dependency !== name) return true;
393
+ if (pass.pingPong) return false;
394
+ throw new Error(`Pass "${name}" samples itself and requires pingPong to read the previous frame`);
395
+ });
396
+ this.registry.add({
397
+ name,
398
+ pass,
399
+ dependencies: sanitizedDependencies
400
+ });
401
+ this.dirty = true;
402
+ }
403
+ clear() {
404
+ this.registry.clear();
405
+ this.orderedPasses = [];
406
+ this.dirty = false;
407
+ }
408
+ resize(width, height) {
409
+ this.forEach((pass) => {
410
+ pass.resize(width, height);
411
+ });
412
+ }
413
+ forEach(callback) {
414
+ for (const pass of this.getOrderedPasses()) callback(pass);
415
+ }
416
+ get(name) {
417
+ return this.registry.get(name)?.pass;
418
+ }
419
+ toArray() {
420
+ return [...this.getOrderedPasses()];
421
+ }
422
+ getOrderedPasses() {
423
+ if (this.dirty) {
424
+ this.orderedPasses = this.sorter.sort(this.registry);
425
+ this.dirty = false;
426
+ }
427
+ return this.orderedPasses;
428
+ }
429
+ };
430
+ //#endregion
431
+ //#region src/pipeline/PipelineCompiler.ts
432
+ var PipelineCompiler = class {
433
+ compile(config) {
434
+ if (config.passes.length === 0) return { passes: [] };
435
+ const plannedPasses = config.passes.map((passConfig) => this.planPassConfig(passConfig));
436
+ const canvasPassName = this.resolveCanvasPassName(plannedPasses);
437
+ return { passes: plannedPasses.map((passConfig) => this.resolveRenderTarget(passConfig, canvasPassName)) };
438
+ }
439
+ planPassConfig(passConfig) {
440
+ const textures = [...passConfig.textures];
441
+ return {
442
+ ...passConfig,
443
+ textures,
444
+ dependencies: [...new Set(textures.filter((textureName) => textureName !== "" && textureName !== passConfig.name))],
445
+ pingPong: textures.includes(passConfig.name) || Boolean(passConfig.pingPong)
446
+ };
447
+ }
448
+ resolveCanvasPassName(passConfigs) {
449
+ const passConfigsByName = new Map(passConfigs.map((passConfig) => [passConfig.name, passConfig]));
450
+ const declarationIndexByName = new Map(passConfigs.map((passConfig, index) => [passConfig.name, index]));
451
+ const dependentsByName = new Map(passConfigs.map((passConfig) => [passConfig.name, []]));
452
+ for (const passConfig of passConfigs) for (const dependency of passConfig.dependencies) {
453
+ const dependents = dependentsByName.get(dependency);
454
+ if (!dependents || !passConfigsByName.has(dependency)) throw new Error(`Pass "${passConfig.name}" depends on "${dependency}", which is not registered`);
455
+ dependents.push(passConfig.name);
456
+ }
457
+ const canvasPasses = passConfigs.filter((passConfig) => (dependentsByName.get(passConfig.name)?.length ?? 0) === 0).map((passConfig) => passConfig.name);
458
+ if (canvasPasses.length === 0) throw new Error("Pipeline must contain at least one terminal pass that can be presented to the canvas.");
459
+ if (canvasPasses.length === 1) return canvasPasses[0];
460
+ const upstreamMemo = /* @__PURE__ */ new Map();
461
+ const depthMemo = /* @__PURE__ */ new Map();
462
+ return canvasPasses.map((name) => ({
463
+ name,
464
+ score: this.scoreCanvasPass(name, declarationIndexByName.get(name), passConfigsByName, upstreamMemo, depthMemo)
465
+ })).sort((left, right) => this.compareCanvasPassScore(right.score, left.score))[0].name;
466
+ }
467
+ resolveRenderTarget(passConfig, canvasPassName) {
468
+ const presentToCanvas = passConfig.name === canvasPassName;
469
+ const offscreen = !presentToCanvas || passConfig.pingPong;
470
+ return {
471
+ ...passConfig,
472
+ offscreen,
473
+ presentToCanvas
474
+ };
475
+ }
476
+ scoreCanvasPass(passName, declarationIndex, passConfigsByName, upstreamMemo, depthMemo) {
477
+ return {
478
+ upstreamCount: this.collectUpstreamPasses(passName, passConfigsByName, /* @__PURE__ */ new Set(), upstreamMemo).size,
479
+ depth: this.computeDependencyDepth(passName, passConfigsByName, /* @__PURE__ */ new Set(), depthMemo),
480
+ declarationIndex
481
+ };
482
+ }
483
+ collectUpstreamPasses(passName, passConfigsByName, visiting, memo) {
484
+ const memoized = memo.get(passName);
485
+ if (memoized) return memoized;
486
+ const passConfig = passConfigsByName.get(passName);
487
+ if (!passConfig) throw new Error(`Pass "${passName}" is not registered`);
488
+ if (visiting.has(passName)) throw new Error(`Pipeline contains a cyclic dependency involving "${passName}"`);
489
+ visiting.add(passName);
490
+ const upstreamPasses = /* @__PURE__ */ new Set();
491
+ for (const dependency of passConfig.dependencies) {
492
+ upstreamPasses.add(dependency);
493
+ for (const ancestor of this.collectUpstreamPasses(dependency, passConfigsByName, visiting, memo)) upstreamPasses.add(ancestor);
494
+ }
495
+ visiting.delete(passName);
496
+ memo.set(passName, upstreamPasses);
497
+ return upstreamPasses;
498
+ }
499
+ computeDependencyDepth(passName, passConfigsByName, visiting, memo) {
500
+ const memoized = memo.get(passName);
501
+ if (memoized !== void 0) return memoized;
502
+ const passConfig = passConfigsByName.get(passName);
503
+ if (!passConfig) throw new Error(`Pass "${passName}" is not registered`);
504
+ if (visiting.has(passName)) throw new Error(`Pipeline contains a cyclic dependency involving "${passName}"`);
505
+ visiting.add(passName);
506
+ let depth = 0;
507
+ for (const dependency of passConfig.dependencies) depth = Math.max(depth, 1 + this.computeDependencyDepth(dependency, passConfigsByName, visiting, memo));
508
+ visiting.delete(passName);
509
+ memo.set(passName, depth);
510
+ return depth;
511
+ }
512
+ compareCanvasPassScore(left, right) {
513
+ if (left.upstreamCount !== right.upstreamCount) return left.upstreamCount - right.upstreamCount;
514
+ if (left.depth !== right.depth) return left.depth - right.depth;
515
+ return left.declarationIndex - right.declarationIndex;
85
516
  }
86
517
  };
87
518
  //#endregion
88
519
  //#region src/shader/Shader.ts
89
520
  const ERROR_LOG_REGEX = /ERROR: 0:(\d+): (.*)(?=\n|$)/;
521
+ const GLSL_300_ES_REGEX = /^\s*#version\s+300\s+es\b/m;
522
+ const DEFAULT_VERTEX_SHADER_GLSL100 = `
523
+ attribute vec4 a_position;
524
+ void main() {
525
+ gl_Position = a_position;
526
+ }`;
527
+ const DEFAULT_VERTEX_SHADER_GLSL300ES = `#version 300 es
528
+ in vec4 a_position;
529
+ void main() {
530
+ gl_Position = a_position;
531
+ }`;
90
532
  /**
91
- * Compiles and manages a WebGL shader program.
533
+ * Compiles and manages a WebGL shader program using twgl.js.
92
534
  * Responsible for compiling, linking, and providing access to uniforms and attributes.
93
535
  */
94
536
  var Shader = class {
95
- constructor(gl, vertexSource, fragmentSource, onError, passName) {
537
+ constructor(gl, capabilities, vertexSource, fragmentSource, onError, passName) {
538
+ this.capabilities = capabilities;
96
539
  this.gl = gl;
97
540
  this.onError = onError;
98
541
  this.passName = passName;
99
- this.uniformLocations = /* @__PURE__ */ new Map();
100
- const vertexShader = this.compileShader(vertexSource || this.defaultVertexShader(), gl.VERTEX_SHADER);
101
- const fragmentShader = this.compileShader(fragmentSource, gl.FRAGMENT_SHADER);
102
- this.program = this.linkProgram(vertexShader, fragmentShader);
103
- }
104
- compileShader(source, type) {
105
- const shader = this.gl.createShader(type);
106
- if (!shader) throw new Error("Failed to create shader");
107
- this.gl.shaderSource(shader, source);
108
- this.gl.compileShader(shader);
109
- if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
110
- const log = this.gl.getShaderInfoLog(shader);
111
- this.gl.deleteShader(shader);
112
- const coords = this.extractErrorCoords(log || "");
542
+ const sources = [vertexSource || this.defaultVertexShader(fragmentSource), fragmentSource];
543
+ const programInfo = createProgramInfo(this.gl, sources, (msg) => {
544
+ const coords = this.extractErrorCoords(msg);
113
545
  this.onError({
114
546
  passName: this.passName,
115
547
  coords
116
548
  });
117
- throw new Error(`Failed to compile shader: ${log}`);
118
- }
119
- return shader;
549
+ });
550
+ if (!programInfo) throw new Error(`Failed to create program for pass ${passName}`);
551
+ this.programInfo = programInfo;
120
552
  }
121
553
  extractErrorCoords(log) {
122
554
  const match = ERROR_LOG_REGEX.exec(log);
@@ -126,79 +558,277 @@ var Shader = class {
126
558
  };
127
559
  return {
128
560
  line: 0,
129
- message: ""
561
+ message: log
130
562
  };
131
563
  }
132
- linkProgram(vertexShader, fragmentShader) {
133
- const program = this.gl.createProgram();
134
- if (!program) throw new Error("Failed to create program");
135
- if (vertexShader) this.gl.attachShader(program, vertexShader);
136
- this.gl.attachShader(program, fragmentShader);
137
- this.gl.linkProgram(program);
138
- if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
139
- const log = this.gl.getProgramInfoLog(program);
140
- this.gl.deleteProgram(program);
141
- throw new Error(`Failed to link program: ${log}`);
564
+ use() {
565
+ this.gl.useProgram(this.programInfo.program);
566
+ }
567
+ setUniforms(uniforms) {
568
+ setUniforms(this.programInfo, uniforms);
569
+ }
570
+ getAttribLocation(name) {
571
+ return this.gl.getAttribLocation(this.programInfo.program, name);
572
+ }
573
+ defaultVertexShader(fragmentSource) {
574
+ if (GLSL_300_ES_REGEX.test(fragmentSource)) {
575
+ if (!this.capabilities.isWebGL2) throw new Error(`Pass "${this.passName}" requires WebGL2 for GLSL ES 3.00 shaders`);
576
+ return DEFAULT_VERTEX_SHADER_GLSL300ES;
142
577
  }
143
- return program;
578
+ return DEFAULT_VERTEX_SHADER_GLSL100;
144
579
  }
145
- use() {
146
- this.gl.useProgram(this.program);
580
+ };
581
+ //#endregion
582
+ //#region src/types/renderer.ts
583
+ const TEXTURE_CHANNEL_COUNT = 4;
584
+ //#endregion
585
+ //#region src/types/uniform.ts
586
+ function isStaticUniformProvider(provider) {
587
+ return typeof provider.resolve !== "function";
588
+ }
589
+ //#endregion
590
+ //#region src/uniform/UniformManager.ts
591
+ const DATE_UNIFORM_PROVIDER = {
592
+ id: "uniform:date",
593
+ resolve: (context) => ({ u_date: context.date })
594
+ };
595
+ const FRAME_UNIFORM_PROVIDER = {
596
+ id: "uniform:frame",
597
+ resolve: (context) => ({
598
+ u_frame: context.frame,
599
+ u_frameRate: context.frameRate,
600
+ u_time: context.time,
601
+ u_timeDelta: context.timeDelta
602
+ })
603
+ };
604
+ const RESOLUTION_UNIFORM_PROVIDER = {
605
+ id: "uniform:resolution",
606
+ resolve: (context) => ({ u_resolution: context.resolution })
607
+ };
608
+ const MOUSE_UNIFORM_PROVIDER = {
609
+ id: "uniform:mouse",
610
+ resolve: (context) => ({ u_mouse: context.mouse })
611
+ };
612
+ const TEXTURE_UNIFORM_PROVIDER = {
613
+ id: "uniform:textures",
614
+ resolve: ({ textures }) => {
615
+ if (textures.length === 0) return;
616
+ return textures.reduce((uniforms, texture, index) => {
617
+ uniforms[`u_texture${index}`] = texture?.handle ?? null;
618
+ return uniforms;
619
+ }, {});
147
620
  }
148
- setUniform(name, type, ...values) {
149
- let location = this.uniformLocations.get(name);
150
- if (!location) {
151
- location = this.gl.getUniformLocation(this.program, name);
152
- this.uniformLocations.set(name, location);
621
+ };
622
+ /**
623
+ * Resolves shader uniforms through a composable provider pipeline.
624
+ * Later providers override earlier values, which keeps the default set extensible.
625
+ */
626
+ var UniformManager = class UniformManager {
627
+ constructor(runtimeStateProvider = UniformManager.createDefaultRuntimeSnapshot, providers = UniformManager.createDefaultProviders()) {
628
+ this.providers = /* @__PURE__ */ new Map();
629
+ this.runtimeStateProvider = runtimeStateProvider;
630
+ providers.forEach((provider) => this.registerProvider(provider));
631
+ }
632
+ static createDefaultProviders() {
633
+ return [
634
+ DATE_UNIFORM_PROVIDER,
635
+ FRAME_UNIFORM_PROVIDER,
636
+ RESOLUTION_UNIFORM_PROVIDER,
637
+ MOUSE_UNIFORM_PROVIDER,
638
+ TEXTURE_UNIFORM_PROVIDER
639
+ ];
640
+ }
641
+ static createDefaultRuntimeSnapshot() {
642
+ return {
643
+ now: /* @__PURE__ */ new Date(0),
644
+ frame: 0,
645
+ frameRate: 0,
646
+ mouse: [0, 0],
647
+ time: 0,
648
+ timeDelta: 0
649
+ };
650
+ }
651
+ registerProvider(provider) {
652
+ const id = provider.id.trim();
653
+ if (!id) throw new Error("Uniform provider id must not be empty");
654
+ if (typeof provider.resolve !== "function") throw new TypeError(`Uniform provider "${id}" must define a resolve function`);
655
+ if (this.providers.has(id)) throw new Error(`Uniform provider "${id}" is already registered`);
656
+ this.providers.set(id, {
657
+ ...provider,
658
+ id
659
+ });
660
+ }
661
+ unregisterProvider(id) {
662
+ return this.providers.delete(id.trim());
663
+ }
664
+ resolve(request) {
665
+ const context = this.createContext(request);
666
+ const uniforms = {};
667
+ for (const provider of this.providers.values()) {
668
+ const resolvedUniforms = this.resolveProvider(provider, context);
669
+ if (!resolvedUniforms) continue;
670
+ Object.assign(uniforms, resolvedUniforms);
153
671
  }
154
- switch (type) {
155
- case "1f":
156
- this.gl.uniform1f(location, values[0]);
157
- break;
158
- case "1i":
159
- this.gl.uniform1i(location, values[0]);
160
- break;
161
- case "2fv":
162
- this.gl.uniform2fv(location, values[0]);
163
- break;
164
- case "3fv":
165
- this.gl.uniform3fv(location, values[0]);
166
- break;
167
- case "4fv":
168
- this.gl.uniform4fv(location, values[0]);
169
- break;
672
+ return uniforms;
673
+ }
674
+ resolveProvider(provider, context) {
675
+ try {
676
+ const resolvedUniforms = provider.resolve(context);
677
+ if (resolvedUniforms === void 0) return;
678
+ if (!this.isUniformMap(resolvedUniforms)) throw new Error("Uniform provider must return an object map or undefined");
679
+ return resolvedUniforms;
680
+ } catch (error) {
681
+ throw new Error(`Failed to resolve uniforms for provider "${provider.id}" on ${context.target} "${context.passName}": ${this.toErrorMessage(error)}`);
170
682
  }
171
683
  }
172
- getAttribLocation(name) {
173
- return this.gl.getAttribLocation(this.program, name);
684
+ isUniformMap(value) {
685
+ return value !== null && !Array.isArray(value) && typeof value === "object";
174
686
  }
175
- defaultVertexShader() {
176
- return `
177
- attribute vec4 a_position;
178
- void main() {
179
- gl_Position = a_position;
180
- }`;
687
+ createContext(request) {
688
+ const runtimeState = this.getRuntimeState();
689
+ const passName = request.passName.trim();
690
+ if (!passName) throw new Error("Uniform request passName must not be empty");
691
+ if (!this.isTarget(request.target)) throw new Error(`Uniform request target "${String(request.target)}" is invalid`);
692
+ return {
693
+ target: request.target,
694
+ passName,
695
+ date: this.toDateUniformValue(runtimeState.now),
696
+ frame: runtimeState.frame,
697
+ frameRate: runtimeState.frameRate,
698
+ mouse: this.clonePair(runtimeState.mouse, "runtime mouse"),
699
+ resolution: this.clonePair(request.resolution, "uniform resolution"),
700
+ textures: [...request.textures ?? []],
701
+ time: runtimeState.time,
702
+ timeDelta: runtimeState.timeDelta
703
+ };
704
+ }
705
+ getRuntimeState() {
706
+ const runtimeState = this.runtimeStateProvider();
707
+ if (!this.isRuntimeState(runtimeState)) throw new Error("Uniform runtime state provider returned an invalid state object");
708
+ return runtimeState;
709
+ }
710
+ isRuntimeState(value) {
711
+ if (value === null || typeof value !== "object") return false;
712
+ const runtimeState = value;
713
+ return runtimeState.now instanceof Date && this.isNumber(runtimeState.frame) && this.isNumber(runtimeState.frameRate) && this.isPair(runtimeState.mouse) && this.isNumber(runtimeState.time) && this.isNumber(runtimeState.timeDelta);
714
+ }
715
+ isTarget(value) {
716
+ return value === "pass" || value === "present";
717
+ }
718
+ isPair(value) {
719
+ return Array.isArray(value) && value.length === 2 && this.isNumber(value[0]) && this.isNumber(value[1]);
720
+ }
721
+ isNumber(value) {
722
+ return typeof value === "number" && Number.isFinite(value);
723
+ }
724
+ clonePair(value, label) {
725
+ if (!this.isPair(value)) throw new Error(`${label} must be a pair of finite numbers`);
726
+ return [value[0], value[1]];
727
+ }
728
+ toDateUniformValue(now) {
729
+ return [
730
+ now.getFullYear(),
731
+ now.getMonth() + 1,
732
+ now.getDate(),
733
+ now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() + now.getMilliseconds() / 1e3
734
+ ];
735
+ }
736
+ toErrorMessage(error) {
737
+ if (error instanceof Error) return error.message;
738
+ return String(error);
181
739
  }
182
740
  };
183
741
  //#endregion
184
- //#region src/engine/WebGLRenderer.ts
742
+ //#region src/engine/ScreenTriangle.ts
743
+ const triangleCache = /* @__PURE__ */ new WeakMap();
185
744
  /**
186
- * Renderer class responsible for managing the WebGL context, render passes, and animation loop.
745
+ * Provides a cached full-screen triangle (Screen Triangle) BufferInfo.
746
+ *
747
+ * Uses a single large triangle instead of a two-triangle quad for full-screen passes.
748
+ * This improves cache coherency by maintaining spatial locality during rasterization,
749
+ * avoiding the cache invalidation that occurs when switching between quad triangles.
750
+ *
751
+ * Vertices are at (-1, -1), (-1, 3), (3, -1) in clip space, covering the viewport
752
+ * after clipping.
753
+ *
754
+ * @see {@link https://michaldrobot.com/2014/04/01/gcn-execution-patterns-in-full-screen-passes/}
187
755
  */
188
- var WebGLRenderer = class {
189
- constructor(canvas, onError) {
190
- this.canvas = canvas;
191
- this.animationRequestID = -1;
192
- this.gl = this.initializeWebGLContext(canvas);
193
- this.passes = null;
756
+ function getScreenTriangle(gl) {
757
+ let bufferInfo = triangleCache.get(gl);
758
+ if (!bufferInfo) {
759
+ bufferInfo = createBufferInfoFromArrays(gl, { a_position: {
760
+ numComponents: 2,
761
+ data: new Float32Array([
762
+ -1,
763
+ -1,
764
+ -1,
765
+ 3,
766
+ 3,
767
+ -1
768
+ ])
769
+ } });
770
+ triangleCache.set(gl, bufferInfo);
771
+ }
772
+ return bufferInfo;
773
+ }
774
+ //#endregion
775
+ //#region src/engine/RendererCore.ts
776
+ /**
777
+ * Host-agnostic WebGL renderer core: GL context, passes, uniforms, loop.
778
+ *
779
+ * Deliberately free of DOM APIs (`window`, `document`, listeners, rAF) so it
780
+ * can run inside a worker against an `OffscreenCanvas`. Hosts own the
781
+ * surrounding concerns and feed the core explicitly:
782
+ * - viewport size via {@link setDisplaySize}
783
+ * - pointer position via {@link setPointer} (device pixels, Y-up)
784
+ * - frame scheduling via the injected {@link FrameDriver}
785
+ */
786
+ var RendererCore = class RendererCore {
787
+ static {
788
+ this.presentFragmentShader = `
789
+ precision highp float;
790
+ uniform sampler2D u_texture0;
791
+ uniform vec2 u_resolution;
792
+
793
+ void main() {
794
+ vec2 uv = gl_FragCoord.xy / u_resolution;
795
+ gl_FragColor = texture2D(u_texture0, uv);
796
+ }
797
+ `;
798
+ }
799
+ constructor(options) {
800
+ this.frameScheduled = false;
801
+ this.thumbnailScratchFramebuffer = null;
802
+ this.thumbnailScratchTexture = null;
803
+ this.thumbnailScratchWidth = 0;
804
+ this.thumbnailScratchHeight = 0;
805
+ this.canvas = options.canvas;
806
+ this.frameDriver = options.frameDriver ?? timerFrameDriver();
807
+ this.gl = this.initializeWebGLContext(options.canvas);
808
+ this.capabilities = detectGLContextCapabilities(this.gl);
809
+ this.pipeline = new Pipeline();
810
+ this.passConfigs = /* @__PURE__ */ new Map();
194
811
  this.textureMap = /* @__PURE__ */ new Map();
195
812
  this.now = /* @__PURE__ */ new Date();
196
- this.onError = onError;
813
+ this.compiler = new PipelineCompiler();
814
+ this.screenTriangle = getScreenTriangle(this.gl);
815
+ this.uniformManager = new UniformManager(() => ({
816
+ now: this.now,
817
+ frame: this.currentFrame,
818
+ frameRate: this.frameRate,
819
+ mouse: [this.mouseX, this.mouseY],
820
+ time: this.currentTime,
821
+ timeDelta: this.timeDelta
822
+ }));
823
+ this.onError = options.onError || (({ passName, coords }) => {
824
+ console.error(`[Actis] Error in pass "${passName}" at line ${coords.line}: ${coords.message}`);
825
+ });
826
+ this.presentShader = new Shader(this.gl, this.capabilities, void 0, RendererCore.presentFragmentShader, () => {}, "__actis_present__");
197
827
  this.mouseX = 0;
198
828
  this.mouseY = 0;
199
829
  this.time = 0;
200
830
  this.timeDelta = 0;
201
- this.realToCSSPixels = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
831
+ this.realToCSSPixels = options.pixelRatio ?? 1;
202
832
  this.paused = false;
203
833
  this.playbackTime = 0;
204
834
  this.lastTime = 0;
@@ -206,8 +836,8 @@ var WebGLRenderer = class {
206
836
  this.currentFrame = 0;
207
837
  this.currentTime = 0;
208
838
  this.startTime = 0;
209
- this.initMouseEvents();
210
- if (typeof window !== "undefined") window.addEventListener("resize", this.resizeCanvasToDisplaySize.bind(this));
839
+ this.pausedAt = 0;
840
+ this.boundRender = (time) => this.render(time);
211
841
  }
212
842
  initializeWebGLContext(canvas) {
213
843
  const opts = {
@@ -219,122 +849,964 @@ var WebGLRenderer = class {
219
849
  preserveDrawingBuffer: true,
220
850
  powerPreference: "high-performance"
221
851
  };
222
- const gl = canvas.getContext("webgl2", opts) || canvas.getContext("experimental-webgl2", opts) || canvas.getContext("webgl", opts) || canvas.getContext("experimental-webgl", opts);
852
+ const legacy = canvas;
853
+ const gl = canvas.getContext("webgl2", opts) || canvas.getContext("webgl", opts) || legacy.getContext("experimental-webgl2", opts) || legacy.getContext("experimental-webgl", opts);
223
854
  if (!gl) throw new Error("WebGL not supported");
224
855
  return gl;
225
856
  }
226
- initMouseEvents() {
227
- this.canvas.addEventListener("mousemove", this.setMousePosition.bind(this));
228
- this.canvas.addEventListener("touchstart", this.preventDefault, { passive: false });
229
- this.canvas.addEventListener("touchmove", this.handleTouchMove.bind(this), { passive: false });
857
+ /**
858
+ * Pointer position in device pixels, Y-up relative to the drawing buffer.
859
+ * Hosts compute this from DOM events; the core performs no layout reads.
860
+ */
861
+ setPointer(x, y) {
862
+ this.mouseX = x;
863
+ this.mouseY = y;
230
864
  }
231
- setMousePosition(e) {
232
- const mouse = {
233
- x: e.clientX || e.pageX,
234
- y: e.clientY || e.pageY
865
+ setPixelRatio(ratio) {
866
+ if (Number.isFinite(ratio) && ratio > 0) this.realToCSSPixels = ratio;
867
+ }
868
+ /**
869
+ * Explicit drawing-buffer size. Replaces the implicit
870
+ * `clientWidth`-based resize, which `OffscreenCanvas` cannot provide.
871
+ */
872
+ setDisplaySize(width, height) {
873
+ const w = Math.max(1, Math.floor(width));
874
+ const h = Math.max(1, Math.floor(height));
875
+ if (this.canvas.width === w && this.canvas.height === h) return;
876
+ this.canvas.width = w;
877
+ this.canvas.height = h;
878
+ this.pipeline.resize(w, h);
879
+ this.syncPassTextures();
880
+ }
881
+ addPass(pass) {
882
+ this.pipeline.add(pass.shader.passName, pass);
883
+ }
884
+ getPass(name) {
885
+ return this.pipeline.get(name);
886
+ }
887
+ getPasses() {
888
+ return this.pipeline.toArray();
889
+ }
890
+ forEachPass(callback) {
891
+ this.pipeline.forEach(callback);
892
+ }
893
+ getPassNames() {
894
+ return this.pipeline.toArray().map((pass) => pass.shader.passName);
895
+ }
896
+ /**
897
+ * Capture downscaled raw pixels of a pass output. Hosts encode these for
898
+ * display (2D canvas on the main thread, `ImageBitmap` in a worker).
899
+ * Returns null when unavailable. Never throws.
900
+ */
901
+ capturePassPixels(name, maxSize = 64) {
902
+ try {
903
+ const pass = this.pipeline.get(name);
904
+ const srcInfo = pass?.fbo?.info;
905
+ const srcFramebuffer = srcInfo?.framebuffer;
906
+ const srcWidth = pass?.width ?? 0;
907
+ const srcHeight = pass?.height ?? 0;
908
+ if (!pass || !srcInfo || !srcFramebuffer || !srcWidth || !srcHeight) return null;
909
+ const scale = Math.min(1, maxSize / Math.max(srcWidth, srcHeight));
910
+ const thumbWidth = Math.max(1, Math.round(srcWidth * scale));
911
+ const thumbHeight = Math.max(1, Math.round(srcHeight * scale));
912
+ const gl2 = this.gl;
913
+ if (!!gl2 && typeof gl2.blitFramebuffer === "function" && this.capabilities.isWebGL2) return this.capturePassViaBlit(gl2, srcFramebuffer, srcWidth, srcHeight, thumbWidth, thumbHeight);
914
+ return this.capturePassViaFullRead(srcInfo, srcWidth, srcHeight, thumbWidth, thumbHeight);
915
+ } catch {
916
+ return null;
917
+ }
918
+ }
919
+ capturePassViaBlit(gl2, srcFramebuffer, srcWidth, srcHeight, thumbWidth, thumbHeight) {
920
+ const gl = this.gl;
921
+ this.ensureThumbnailScratch(thumbWidth, thumbHeight);
922
+ if (!this.thumbnailScratchFramebuffer) return null;
923
+ gl2.bindFramebuffer(gl2.READ_FRAMEBUFFER, srcFramebuffer);
924
+ gl2.bindFramebuffer(gl2.DRAW_FRAMEBUFFER, this.thumbnailScratchFramebuffer);
925
+ gl2.blitFramebuffer(0, 0, srcWidth, srcHeight, 0, 0, thumbWidth, thumbHeight, gl2.COLOR_BUFFER_BIT, gl2.LINEAR);
926
+ gl2.bindFramebuffer(gl2.READ_FRAMEBUFFER, this.thumbnailScratchFramebuffer);
927
+ gl2.bindFramebuffer(gl2.DRAW_FRAMEBUFFER, null);
928
+ const pixels = new Uint8Array(thumbWidth * thumbHeight * 4);
929
+ gl.readPixels(0, 0, thumbWidth, thumbHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
930
+ bindFramebufferInfo(gl, null);
931
+ return {
932
+ data: pixels,
933
+ width: thumbWidth,
934
+ height: thumbHeight
235
935
  };
236
- const rect = this.canvas.getBoundingClientRect();
237
- if (mouse.x >= rect.left && mouse.x <= rect.right && mouse.y >= rect.top && mouse.y <= rect.bottom) {
238
- this.mouseX = (mouse.x - rect.left) * this.realToCSSPixels;
239
- this.mouseY = this.canvas.height - (mouse.y - rect.top) * this.realToCSSPixels;
936
+ }
937
+ capturePassViaFullRead(srcInfo, srcWidth, srcHeight, thumbWidth, thumbHeight) {
938
+ const gl = this.gl;
939
+ bindFramebufferInfo(gl, srcInfo);
940
+ const pixels = new Uint8Array(srcWidth * srcHeight * 4);
941
+ gl.readPixels(0, 0, srcWidth, srcHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
942
+ bindFramebufferInfo(gl, null);
943
+ const thumb = new Uint8Array(thumbWidth * thumbHeight * 4);
944
+ const xRatio = srcWidth / thumbWidth;
945
+ const yRatio = srcHeight / thumbHeight;
946
+ for (let y = 0; y < thumbHeight; y++) {
947
+ const srcY = Math.min(srcHeight - 1, Math.floor(y * yRatio));
948
+ for (let x = 0; x < thumbWidth; x++) {
949
+ const srcX = Math.min(srcWidth - 1, Math.floor(x * xRatio));
950
+ const s = (srcY * srcWidth + srcX) * 4;
951
+ const d = (y * thumbWidth + x) * 4;
952
+ thumb[d] = pixels[s] ?? 0;
953
+ thumb[d + 1] = pixels[s + 1] ?? 0;
954
+ thumb[d + 2] = pixels[s + 2] ?? 0;
955
+ thumb[d + 3] = 255;
956
+ }
240
957
  }
958
+ return {
959
+ data: thumb,
960
+ width: thumbWidth,
961
+ height: thumbHeight
962
+ };
241
963
  }
242
- handleTouchMove(e) {
243
- e.preventDefault();
244
- if (e.touches.length > 0) this.setMousePosition(e.touches[0]);
964
+ ensureThumbnailScratch(width, height) {
965
+ const gl = this.gl;
966
+ if (this.thumbnailScratchFramebuffer && this.thumbnailScratchWidth === width && this.thumbnailScratchHeight === height) return;
967
+ this.disposeThumbnailScratch();
968
+ const texture = gl.createTexture();
969
+ if (!texture) return;
970
+ gl.bindTexture(gl.TEXTURE_2D, texture);
971
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
972
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
973
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
974
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
975
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
976
+ const framebuffer = gl.createFramebuffer();
977
+ if (!framebuffer) {
978
+ gl.deleteTexture(texture);
979
+ return;
980
+ }
981
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
982
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
983
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
984
+ gl.bindTexture(gl.TEXTURE_2D, null);
985
+ this.thumbnailScratchTexture = texture;
986
+ this.thumbnailScratchFramebuffer = framebuffer;
987
+ this.thumbnailScratchWidth = width;
988
+ this.thumbnailScratchHeight = height;
245
989
  }
246
- preventDefault(e) {
247
- e.preventDefault();
990
+ disposeThumbnailScratch() {
991
+ const gl = this.gl;
992
+ if (this.thumbnailScratchFramebuffer) {
993
+ gl.deleteFramebuffer(this.thumbnailScratchFramebuffer);
994
+ this.thumbnailScratchFramebuffer = null;
995
+ }
996
+ if (this.thumbnailScratchTexture) {
997
+ gl.deleteTexture(this.thumbnailScratchTexture);
998
+ this.thumbnailScratchTexture = null;
999
+ }
1000
+ this.thumbnailScratchWidth = 0;
1001
+ this.thumbnailScratchHeight = 0;
248
1002
  }
249
- addPass(pass) {
250
- if (this.passes) {
251
- let current = this.passes;
252
- while (current.next) current = current.next;
253
- current.next = pass;
254
- } else this.passes = pass;
255
- }
256
- resizeCanvasToDisplaySize() {
257
- const displayWidth = Math.floor(this.canvas.clientWidth * this.realToCSSPixels);
258
- const displayHeight = Math.floor(this.canvas.clientHeight * this.realToCSSPixels);
259
- if (this.canvas.width !== displayWidth || this.canvas.height !== displayHeight) {
260
- this.canvas.width = displayWidth;
261
- this.canvas.height = displayHeight;
262
- let current = this.passes;
263
- while (current) {
264
- current.resize(displayWidth, displayHeight);
265
- current = current.next;
266
- }
1003
+ getMetrics() {
1004
+ return {
1005
+ paused: this.paused,
1006
+ time: this.currentTime,
1007
+ frameRate: this.frameRate,
1008
+ width: this.canvas.width,
1009
+ height: this.canvas.height
1010
+ };
1011
+ }
1012
+ getContextState() {
1013
+ return Object.freeze({
1014
+ capabilities: this.capabilities,
1015
+ webglVersion: this.capabilities.isWebGL2 ? 2 : 1
1016
+ });
1017
+ }
1018
+ clear() {
1019
+ this.resetPlaybackState();
1020
+ this.pipeline.forEach((pass) => pass.clear());
1021
+ this.syncPassTextures();
1022
+ this.clearCanvas();
1023
+ if (this.getPasses().length > 0) this.renderFrame();
1024
+ }
1025
+ registerUniformProvider(provider) {
1026
+ if (isStaticUniformProvider(provider)) {
1027
+ const { id, values } = provider;
1028
+ this.uniformManager.registerProvider({
1029
+ id,
1030
+ resolve: () => values
1031
+ });
1032
+ return;
267
1033
  }
1034
+ this.uniformManager.registerProvider(provider);
268
1035
  }
269
- updateUniforms(pass) {
270
- pass.shader.setUniform("u_date", "4fv", [
271
- this.now.getFullYear(),
272
- this.now.getMonth() + 1,
273
- this.now.getDate(),
274
- this.now.getHours() * 3600 + this.now.getMinutes() * 60 + this.now.getSeconds() + this.now.getMilliseconds() / 1e3
275
- ]);
276
- pass.shader.setUniform("u_frame", "1i", this.currentFrame);
277
- pass.shader.setUniform("u_time", "1f", this.currentTime);
278
- pass.shader.setUniform("u_timeDelta", "1f", this.timeDelta);
279
- pass.shader.setUniform("u_frameRate", "1f", this.frameRate);
280
- pass.shader.setUniform("u_resolution", "2fv", [pass.width, pass.height]);
281
- pass.shader.setUniform("u_mouse", "2fv", [this.mouseX, this.mouseY]);
1036
+ unregisterUniformProvider(providerId) {
1037
+ return this.uniformManager.unregisterProvider(providerId);
282
1038
  }
283
1039
  updateTime(currentTime) {
1040
+ this.now = /* @__PURE__ */ new Date();
284
1041
  if (this.paused) {
285
1042
  this.timeDelta = 0;
1043
+ this.frameRate = 0;
286
1044
  return;
287
1045
  }
288
1046
  const t = currentTime ?? (typeof performance !== "undefined" ? performance.now() : Date.now());
1047
+ const hasStarted = this.startTime !== 0;
289
1048
  if (this.startTime === 0) this.startTime = t;
290
- if (this.lastTime === 0) this.lastTime = t;
291
- this.timeDelta = (t - this.startTime) / 1e3;
292
- this.currentTime += this.timeDelta;
293
- this.frameRate = 1 / this.timeDelta;
1049
+ if (this.pausedAt !== 0) {
1050
+ if (hasStarted) this.startTime += t - this.pausedAt;
1051
+ this.pausedAt = 0;
1052
+ this.lastTime = t;
1053
+ this.timeDelta = 0;
1054
+ } else if (this.lastTime === 0) {
1055
+ this.lastTime = t;
1056
+ this.timeDelta = 0;
1057
+ } else {
1058
+ this.timeDelta = (t - this.lastTime) / 1e3;
1059
+ this.lastTime = t;
1060
+ }
1061
+ this.currentTime = (t - this.startTime) / 1e3;
1062
+ this.time = this.currentTime;
1063
+ this.playbackTime = this.currentTime;
1064
+ this.frameRate = this.timeDelta > 0 ? 1 / this.timeDelta : 0;
294
1065
  this.currentFrame++;
295
- this.startTime = t;
296
1066
  }
297
1067
  render(currentTime) {
298
- this.updateTime(currentTime);
299
- this.resizeCanvasToDisplaySize();
300
- let current = this.passes;
301
- while (current) {
302
- current.use();
303
- this.updateUniforms(current);
304
- current.draw();
305
- current = current.next;
1068
+ this.frameScheduled = false;
1069
+ this.renderFrame(currentTime);
1070
+ if (!this.paused) {
1071
+ this.frameScheduled = true;
1072
+ this.frameDriver.request(this.boundRender);
306
1073
  }
307
- if (typeof requestAnimationFrame !== "undefined") this.animationRequestID = requestAnimationFrame(this.render.bind(this));
308
1074
  }
309
1075
  setup(config) {
310
- const displayWidth = Math.floor(this.canvas.clientWidth * this.realToCSSPixels);
311
- const displayHeight = Math.floor(this.canvas.clientHeight * this.realToCSSPixels);
312
- config.passes.forEach((passConfig) => {
1076
+ this.cancelScheduledFrame();
1077
+ this.pipeline.clear();
1078
+ this.passConfigs.clear();
1079
+ this.textureMap.clear();
1080
+ this.resetPlaybackState();
1081
+ this.clearCanvas();
1082
+ const displayWidth = this.canvas.width;
1083
+ const displayHeight = this.canvas.height;
1084
+ let pipelinePlan;
1085
+ try {
1086
+ pipelinePlan = this.compiler.compile(config);
1087
+ } catch (error) {
1088
+ console.error(`[Actis] Failed to compile pipeline: ${error.message}`);
1089
+ return;
1090
+ }
1091
+ let setupFailed = false;
1092
+ pipelinePlan.passes.forEach((passConfig) => {
313
1093
  try {
314
- const shader = new Shader(this.gl, passConfig.vertexShader, passConfig.fragmentShader, this.onError, passConfig.name);
315
- const offscreen = passConfig.offscreen || passConfig.name !== "MainBuffer";
316
- const pass = new Pass(this.gl, shader, displayWidth, displayHeight, offscreen, passConfig.textures.map((textureName) => this.textureMap.get(textureName)));
317
- this.addPass(pass);
318
- this.textureMap.set(passConfig.name, pass.texture);
1094
+ const shader = new Shader(this.gl, this.capabilities, passConfig.vertexShader, passConfig.fragmentShader, this.onError, passConfig.name);
1095
+ const pass = new Pass(this.gl, this.capabilities, shader, this.screenTriangle, displayWidth, displayHeight, passConfig.offscreen, [], passConfig.pingPong, passConfig.texture);
1096
+ this.pipeline.add(passConfig.name, pass, passConfig.dependencies);
1097
+ this.passConfigs.set(passConfig.name, passConfig);
1098
+ if (pass.texture) this.textureMap.set(passConfig.name, pass.texture);
319
1099
  } catch (error) {
1100
+ setupFailed = true;
320
1101
  console.error(`Error in pass ${passConfig.name}: ${error.message}`);
321
1102
  }
322
1103
  });
1104
+ if (setupFailed) {
1105
+ this.pipeline.clear();
1106
+ this.passConfigs.clear();
1107
+ this.textureMap.clear();
1108
+ this.clearCanvas();
1109
+ return;
1110
+ }
1111
+ this.syncPassTextures();
323
1112
  }
324
1113
  play() {
1114
+ if (this.frameScheduled) {
1115
+ this.paused = false;
1116
+ return;
1117
+ }
325
1118
  this.paused = false;
326
- this.animationRequestID = requestAnimationFrame(this.render.bind(this));
1119
+ this.frameScheduled = true;
1120
+ this.frameDriver.request(this.boundRender);
1121
+ }
1122
+ resume() {
1123
+ this.play();
327
1124
  }
328
1125
  pause() {
1126
+ if (this.paused) return;
329
1127
  this.paused = true;
330
- cancelAnimationFrame(this.animationRequestID);
1128
+ this.pausedAt = typeof performance !== "undefined" ? performance.now() : Date.now();
1129
+ this.cancelScheduledFrame();
331
1130
  }
332
1131
  reset() {
1132
+ this.clear();
1133
+ }
1134
+ /**
1135
+ * Release loop scheduling and GL scratch resources. Passes keep their GL
1136
+ * objects; hosts owning the context decide its lifetime.
1137
+ */
1138
+ dispose() {
1139
+ this.cancelScheduledFrame();
1140
+ this.disposeThumbnailScratch();
1141
+ }
1142
+ renderFrame(currentTime) {
1143
+ this.updateTime(currentTime);
1144
+ let presentedTexture;
1145
+ this.pipeline.forEach((pass) => {
1146
+ this.syncPassTexturesForPass(pass);
1147
+ pass.use();
1148
+ const uniforms = this.uniformManager.resolve({
1149
+ target: "pass",
1150
+ passName: pass.shader.passName,
1151
+ resolution: [pass.width, pass.height],
1152
+ textures: this.getTextureBindings(pass.textures)
1153
+ });
1154
+ pass.shader.setUniforms(uniforms);
1155
+ pass.draw();
1156
+ this.updateTextureMapForPass(pass);
1157
+ if (this.passConfigs.get(pass.shader.passName)?.presentToCanvas) presentedTexture = pass.texture;
1158
+ });
1159
+ if (presentedTexture) this.presentTexture(presentedTexture);
1160
+ }
1161
+ syncPassTextures() {
1162
+ this.textureMap.clear();
1163
+ this.pipeline.forEach((pass) => {
1164
+ this.updateTextureMapForPass(pass);
1165
+ });
1166
+ this.pipeline.forEach((pass) => {
1167
+ this.syncPassTexturesForPass(pass);
1168
+ });
1169
+ }
1170
+ syncPassTexturesForPass(pass) {
1171
+ const passName = pass.shader.passName;
1172
+ const passConfig = this.passConfigs.get(passName);
1173
+ if (!passConfig) return;
1174
+ pass.textures = passConfig.textures.map((textureName) => {
1175
+ const texture = this.textureMap.get(textureName);
1176
+ if (!texture) {
1177
+ console.warn(`Texture ${textureName} not found for pass ${passName}`);
1178
+ return;
1179
+ }
1180
+ return texture;
1181
+ });
1182
+ }
1183
+ updateTextureMapForPass(pass) {
1184
+ const texture = pass.texture;
1185
+ if (!texture) return;
1186
+ this.textureMap.set(pass.shader.passName, texture);
1187
+ }
1188
+ presentTexture(texture) {
1189
+ if (!texture.handle) return;
1190
+ bindFramebufferInfo(this.gl, null);
1191
+ this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
1192
+ this.presentShader.use();
1193
+ setBuffersAndAttributes(this.gl, this.presentShader.programInfo, this.screenTriangle);
1194
+ const uniforms = this.uniformManager.resolve({
1195
+ target: "present",
1196
+ passName: this.presentShader.passName,
1197
+ resolution: [this.gl.canvas.width, this.gl.canvas.height],
1198
+ textures: this.getTextureBindings([texture])
1199
+ });
1200
+ this.presentShader.setUniforms(uniforms);
1201
+ drawBufferInfo(this.gl, this.screenTriangle);
1202
+ }
1203
+ getTextureBindings(textures) {
1204
+ const slotCount = Math.max(4, textures.length);
1205
+ return Array.from({ length: slotCount }, (_, index) => textures[index]);
1206
+ }
1207
+ cancelScheduledFrame() {
1208
+ this.frameScheduled = false;
1209
+ this.frameDriver.cancel();
1210
+ }
1211
+ clearCanvas() {
1212
+ bindFramebufferInfo(this.gl, null);
1213
+ this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
1214
+ this.gl.clearColor(0, 0, 0, 0);
1215
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT);
1216
+ }
1217
+ resetPlaybackState() {
1218
+ this.now = /* @__PURE__ */ new Date();
1219
+ this.time = 0;
1220
+ this.timeDelta = 0;
333
1221
  this.playbackTime = 0;
1222
+ this.lastTime = 0;
1223
+ this.frameRate = 0;
334
1224
  this.currentFrame = 0;
1225
+ this.currentTime = 0;
1226
+ this.startTime = 0;
1227
+ this.pausedAt = 0;
1228
+ }
1229
+ };
1230
+ //#endregion
1231
+ //#region src/engine/WebGLRenderer.ts
1232
+ /**
1233
+ * Main-thread `WebGLRenderer`: {@link RendererCore} plus the DOM shell —
1234
+ * pointer/resize listeners, `requestAnimationFrame` loop, and 2D-canvas
1235
+ * thumbnail encoding.
1236
+ *
1237
+ * @deprecated Prefer `createRenderer(canvas)` — worker-first with automatic
1238
+ * main-thread fallback and the same public surface. Direct construction
1239
+ * always renders on the main thread; use `createRenderer(canvas,
1240
+ * { mode: 'main' })` to pin that explicitly.
1241
+ */
1242
+ var WebGLRenderer = class WebGLRenderer extends RendererCore {
1243
+ constructor(canvas, onError) {
1244
+ super({
1245
+ canvas,
1246
+ onError,
1247
+ pixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
1248
+ frameDriver: rafFrameDriver()
1249
+ });
1250
+ this.thumbnailCanvas = null;
1251
+ this.resizeObserver = null;
1252
+ this.displayCanvas = canvas;
1253
+ this.handleResize = () => this.syncSizeToDisplay();
1254
+ this.handleMouseMove = (e) => this.setMousePosition(e);
1255
+ this.handleTouchStart = (e) => e.preventDefault();
1256
+ this.handleTouchMove = (e) => {
1257
+ e.preventDefault();
1258
+ if (e.touches.length > 0) this.setMousePosition(e.touches[0]);
1259
+ };
1260
+ canvas.addEventListener("mousemove", this.handleMouseMove);
1261
+ canvas.addEventListener("touchstart", this.handleTouchStart, { passive: false });
1262
+ canvas.addEventListener("touchmove", this.handleTouchMove, { passive: false });
1263
+ if (typeof window !== "undefined") window.addEventListener("resize", this.handleResize);
1264
+ if (typeof ResizeObserver !== "undefined") {
1265
+ this.resizeObserver = new ResizeObserver(this.handleResize);
1266
+ this.resizeObserver.observe(canvas);
1267
+ }
1268
+ this.syncSizeToDisplay();
1269
+ }
1270
+ setMousePosition(e) {
1271
+ const mouse = {
1272
+ x: e.clientX || e.pageX,
1273
+ y: e.clientY || e.pageY
1274
+ };
1275
+ const rect = this.displayCanvas.getBoundingClientRect();
1276
+ if (mouse.x >= rect.left && mouse.x <= rect.right && mouse.y >= rect.top && mouse.y <= rect.bottom) this.setPointer((mouse.x - rect.left) * this.realToCSSPixels, this.displayCanvas.height - (mouse.y - rect.top) * this.realToCSSPixels);
1277
+ }
1278
+ syncSizeToDisplay() {
1279
+ const width = Math.floor(this.displayCanvas.clientWidth * this.realToCSSPixels);
1280
+ const height = Math.floor(this.displayCanvas.clientHeight * this.realToCSSPixels);
1281
+ this.setDisplaySize(width, height);
1282
+ }
1283
+ /**
1284
+ * Capture a downscaled data-URL thumbnail of a pass output.
1285
+ * Cheap by design: blits to a small scratch FBO (WebGL2) so only
1286
+ * thumbnail-sized pixels are read back. Returns null when unavailable.
1287
+ */
1288
+ capturePassDataURL(name, maxSize = 64) {
1289
+ const pixels = this.capturePassPixels(name, maxSize);
1290
+ if (!pixels) return null;
1291
+ return WebGLRenderer.pixelsToDataURL(this.thumbnailCanvasRef(), pixels.data, pixels.width, pixels.height);
1292
+ }
1293
+ thumbnailCanvasRef() {
1294
+ if (!this.thumbnailCanvas) this.thumbnailCanvas = document.createElement("canvas");
1295
+ return this.thumbnailCanvas;
1296
+ }
1297
+ /** Encode bottom-up GL pixels to a data URL. Shared with worker hosts. */
1298
+ static pixelsToDataURL(canvas, pixels, width, height) {
1299
+ const target = canvas;
1300
+ if (target.width !== width || target.height !== height) {
1301
+ target.width = width;
1302
+ target.height = height;
1303
+ }
1304
+ const ctx = target.getContext("2d");
1305
+ if (!ctx) return null;
1306
+ const image = ctx.createImageData(width, height);
1307
+ const rowBytes = width * 4;
1308
+ for (let y = 0; y < height; y++) {
1309
+ const src = (height - 1 - y) * rowBytes;
1310
+ image.data.set(pixels.subarray(src, src + rowBytes), y * rowBytes);
1311
+ }
1312
+ ctx.putImageData(image, 0, 0);
1313
+ return target.toDataURL();
1314
+ }
1315
+ dispose() {
1316
+ this.displayCanvas.removeEventListener("mousemove", this.handleMouseMove);
1317
+ this.displayCanvas.removeEventListener("touchstart", this.handleTouchStart);
1318
+ this.displayCanvas.removeEventListener("touchmove", this.handleTouchMove);
1319
+ if (typeof window !== "undefined") window.removeEventListener("resize", this.handleResize);
1320
+ this.resizeObserver?.disconnect();
1321
+ this.resizeObserver = null;
1322
+ super.dispose();
1323
+ }
1324
+ };
1325
+ //#endregion
1326
+ //#region src/worker/host.ts
1327
+ /**
1328
+ * Map a client-coordinate pointer event to device pixels (Y-up), or null
1329
+ * when outside the canvas. Shared by the main-thread shell and worker hosts
1330
+ * so both paths handle edges identically.
1331
+ */
1332
+ function normalizePointer(canvas, clientX, clientY, pixelRatio) {
1333
+ const rect = canvas.getBoundingClientRect();
1334
+ if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) return null;
1335
+ return [(clientX - rect.left) * pixelRatio, canvas.height - (clientY - rect.top) * pixelRatio];
1336
+ }
1337
+ function currentPixelRatio() {
1338
+ return typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
1339
+ }
1340
+ /**
1341
+ * Wire a visible canvas to a renderer target. Installs resize observation
1342
+ * (window resize + layout changes from panels/sidebars) and pointer
1343
+ * listeners, pushing normalized values to the target. Returns a dispose
1344
+ * function removing every listener/observer.
1345
+ */
1346
+ function attachRendererHost(canvas, target) {
1347
+ const syncSize = () => {
1348
+ const ratio = currentPixelRatio();
1349
+ target.setPixelRatio(ratio);
1350
+ target.setDisplaySize(Math.floor(canvas.clientWidth * ratio), Math.floor(canvas.clientHeight * ratio));
1351
+ };
1352
+ const handleResize = () => syncSize();
1353
+ const handleMouseMove = (e) => {
1354
+ const point = normalizePointer(canvas, e.clientX || e.pageX, e.clientY || e.pageY, target.realToCSSPixels);
1355
+ if (point) target.setPointer(point[0], point[1]);
1356
+ };
1357
+ const handleTouchStart = (e) => e.preventDefault();
1358
+ const handleTouchMove = (e) => {
1359
+ e.preventDefault();
1360
+ const touch = e.touches[0];
1361
+ if (!touch) return;
1362
+ const point = normalizePointer(canvas, touch.clientX, touch.clientY, target.realToCSSPixels);
1363
+ if (point) target.setPointer(point[0], point[1]);
1364
+ };
1365
+ canvas.addEventListener("mousemove", handleMouseMove);
1366
+ canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
1367
+ canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
1368
+ if (typeof window !== "undefined") window.addEventListener("resize", handleResize);
1369
+ let resizeObserver = null;
1370
+ if (typeof ResizeObserver !== "undefined") {
1371
+ resizeObserver = new ResizeObserver(handleResize);
1372
+ resizeObserver.observe(canvas);
1373
+ }
1374
+ syncSize();
1375
+ return () => {
1376
+ canvas.removeEventListener("mousemove", handleMouseMove);
1377
+ canvas.removeEventListener("touchstart", handleTouchStart);
1378
+ canvas.removeEventListener("touchmove", handleTouchMove);
1379
+ if (typeof window !== "undefined") window.removeEventListener("resize", handleResize);
1380
+ resizeObserver?.disconnect();
1381
+ };
1382
+ }
1383
+ //#endregion
1384
+ //#region src/worker/OffscreenWebGLRenderer.ts
1385
+ function thumbnailKey(name, maxSize) {
1386
+ return `${name}@${maxSize}`;
1387
+ }
1388
+ /**
1389
+ * Main-thread proxy for a worker-hosted {@link RendererCore}. Same public
1390
+ * surface as {@link WebGLRenderer}: fire-and-forget commands cross the
1391
+ * boundary, while synchronous reads (`getMetrics`, `getPassNames`,
1392
+ * `capturePassDataURL`) are served from caches the worker pushes.
1393
+ * Construct via {@link createRenderer}, never directly.
1394
+ */
1395
+ var OffscreenWebGLRenderer = class {
1396
+ constructor(canvas, worker, options) {
1397
+ this.isOffscreenRenderer = true;
1398
+ this.mouseX = 0;
1399
+ this.mouseY = 0;
1400
+ this.time = 0;
1401
+ this.timeDelta = 0;
1402
+ this.realToCSSPixels = 1;
1403
+ this.paused = false;
1404
+ this.playbackTime = 0;
335
1405
  this.lastTime = 0;
336
1406
  this.frameRate = 0;
1407
+ this.currentFrame = 0;
1408
+ this.currentTime = 0;
1409
+ this.startTime = 0;
1410
+ this.passNames = [];
1411
+ this.metrics = {
1412
+ paused: false,
1413
+ time: 0,
1414
+ frameRate: 0,
1415
+ width: 0,
1416
+ height: 0
1417
+ };
1418
+ this.contextState = null;
1419
+ this.thumbnails = /* @__PURE__ */ new Map();
1420
+ this.captureId = 0;
1421
+ this.disposed = false;
1422
+ this.displayCanvas = canvas;
1423
+ this.worker = worker;
1424
+ this.onError = options?.onError ?? (({ passName, coords }) => {
1425
+ console.error(`[Actis] Error in pass "${passName}" at line ${coords.line}: ${coords.message}`);
1426
+ });
1427
+ this.detachHost = options?.detachHost ?? null;
1428
+ this.encodeCanvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
1429
+ }
1430
+ /**
1431
+ * Take over the worker message stream. Called by the factory once the
1432
+ * init handshake succeeds; replays the buffered first event.
1433
+ */
1434
+ connect(firstEvent) {
1435
+ this.worker.onmessage = (event) => this.handleEvent(event.data);
1436
+ if (firstEvent) this.handleEvent(firstEvent);
1437
+ }
1438
+ /** Late-bind host teardown (the host needs the proxy to exist first). */
1439
+ setDetachHost(detach) {
1440
+ this.detachHost = detach;
1441
+ }
1442
+ /** The visible canvas (transferred control; the browser composites it). */
1443
+ get canvas() {
1444
+ return this.displayCanvas;
1445
+ }
1446
+ post(command, transfer) {
1447
+ if (this.disposed) return;
1448
+ this.worker.postMessage(command, transfer ?? []);
1449
+ }
1450
+ handleEvent(event) {
1451
+ switch (event.type) {
1452
+ case "metrics":
1453
+ this.applyMetrics(event.metrics);
1454
+ break;
1455
+ case "passes":
1456
+ this.passNames = [...event.names];
1457
+ break;
1458
+ case "context":
1459
+ this.contextState = Object.freeze({
1460
+ capabilities: { ...event.capabilities },
1461
+ webglVersion: event.webglVersion
1462
+ });
1463
+ break;
1464
+ case "error":
1465
+ this.onError(event.details);
1466
+ break;
1467
+ case "capture-result":
1468
+ this.applyCapture(event.name, event.maxSize, event.data, event.width, event.height);
1469
+ break;
1470
+ case "ready":
1471
+ case "fatal": break;
1472
+ }
1473
+ }
1474
+ applyMetrics(next) {
1475
+ this.metrics = { ...next };
1476
+ this.paused = next.paused;
1477
+ this.time = next.time;
1478
+ this.currentTime = next.time;
1479
+ this.playbackTime = next.time;
1480
+ this.frameRate = next.frameRate;
1481
+ }
1482
+ applyCapture(name, maxSize, data, width, height) {
1483
+ if (!data || !this.encodeCanvas) return;
1484
+ const url = WebGLRenderer.pixelsToDataURL(this.encodeCanvas, new Uint8Array(data), width, height);
1485
+ if (url) this.thumbnails.set(thumbnailKey(name, maxSize), url);
1486
+ }
1487
+ setup(config) {
1488
+ this.passNames = [];
1489
+ this.post({
1490
+ type: "setup",
1491
+ config
1492
+ });
1493
+ }
1494
+ play() {
1495
+ this.paused = false;
1496
+ this.post({ type: "play" });
1497
+ }
1498
+ resume() {
1499
+ this.play();
1500
+ }
1501
+ pause() {
1502
+ this.paused = true;
1503
+ this.post({ type: "pause" });
1504
+ }
1505
+ reset() {
1506
+ this.post({ type: "reset" });
1507
+ }
1508
+ clear() {
1509
+ this.post({ type: "clear" });
1510
+ }
1511
+ setPointer(x, y) {
1512
+ this.mouseX = x;
1513
+ this.mouseY = y;
1514
+ this.post({
1515
+ type: "pointer",
1516
+ x,
1517
+ y
1518
+ });
1519
+ }
1520
+ setPixelRatio(ratio) {
1521
+ if (Number.isFinite(ratio) && ratio > 0) this.realToCSSPixels = ratio;
1522
+ }
1523
+ setDisplaySize(width, height) {
1524
+ this.metrics = {
1525
+ ...this.metrics,
1526
+ width,
1527
+ height
1528
+ };
1529
+ this.post({
1530
+ type: "resize",
1531
+ width,
1532
+ height,
1533
+ pixelRatio: this.realToCSSPixels
1534
+ });
1535
+ }
1536
+ registerUniformProvider(provider) {
1537
+ if (!isStaticUniformProvider(provider)) throw new Error(`[Actis] Function uniform providers cannot cross the worker boundary (provider "${provider.id}"). Use a static { id, values } provider, or create the renderer with mode: "main".`);
1538
+ this.post({
1539
+ type: "register-provider",
1540
+ provider: {
1541
+ id: provider.id,
1542
+ values: provider.values
1543
+ }
1544
+ });
1545
+ }
1546
+ unregisterUniformProvider(providerId) {
1547
+ this.post({
1548
+ type: "unregister-provider",
1549
+ id: providerId
1550
+ });
1551
+ return true;
1552
+ }
1553
+ getPassNames() {
1554
+ return [...this.passNames];
1555
+ }
1556
+ getMetrics() {
1557
+ return { ...this.metrics };
1558
+ }
1559
+ getContextState() {
1560
+ if (this.contextState) return this.contextState;
1561
+ return Object.freeze({
1562
+ capabilities: {
1563
+ isWebGL2: false,
1564
+ supportsFloatColorBuffer: false,
1565
+ supportsFloatTexture: false,
1566
+ supportsFloatTextureLinear: false
1567
+ },
1568
+ webglVersion: 1
1569
+ });
1570
+ }
1571
+ /**
1572
+ * Last encoded thumbnail for a pass, or null until the worker round-trip
1573
+ * completes. Call {@link requestPassCapture} first (e.g. on a poll
1574
+ * interval); the following read observes the fresh frame.
1575
+ */
1576
+ capturePassDataURL(name, maxSize = 64) {
1577
+ return this.thumbnails.get(thumbnailKey(name, maxSize)) ?? null;
1578
+ }
1579
+ /** Ask the worker to capture and push a fresh thumbnail for `name`. */
1580
+ requestPassCapture(name, maxSize = 64) {
1581
+ this.captureId += 1;
1582
+ this.post({
1583
+ type: "capture",
1584
+ id: this.captureId,
1585
+ name,
1586
+ maxSize
1587
+ });
1588
+ }
1589
+ addPass(_pass) {
1590
+ throw new Error("[Actis] addPass() requires mode: \"main\" — Pass objects cannot cross the worker boundary. Use setup() with a RendererConfig instead.");
1591
+ }
1592
+ getPass(_name) {
1593
+ throw new Error("[Actis] getPass() requires mode: \"main\" — Pass objects cannot cross the worker boundary.");
1594
+ }
1595
+ getPasses() {
1596
+ throw new Error("[Actis] getPasses() requires mode: \"main\" — Pass objects cannot cross the worker boundary. Use getPassNames() instead.");
1597
+ }
1598
+ forEachPass(_callback) {
1599
+ throw new Error("[Actis] forEachPass() requires mode: \"main\" — Pass objects cannot cross the worker boundary.");
1600
+ }
1601
+ dispose() {
1602
+ if (this.disposed) return;
1603
+ this.disposed = true;
1604
+ this.detachHost?.();
1605
+ try {
1606
+ this.worker.postMessage({ type: "dispose" });
1607
+ } catch {}
1608
+ this.worker.terminate();
337
1609
  }
338
1610
  };
339
1611
  //#endregion
340
- export { Pass, Shader, WebGLRenderer };
1612
+ //#region src/worker/createRenderer.ts
1613
+ var WorkerUnsupportedError = class extends Error {
1614
+ constructor(reason, options) {
1615
+ super(`[Actis] Offscreen worker renderer unavailable: ${reason}`, options);
1616
+ this.name = "WorkerUnsupportedError";
1617
+ this.reason = reason;
1618
+ }
1619
+ };
1620
+ /**
1621
+ * Canvases already handed to a worker. Transfer is irreversible and the DOM
1622
+ * exposes no "transferred" flag, so the factory stamps every canvas it
1623
+ * transfers to fail fast with a clear error instead of a raw DOMException.
1624
+ */
1625
+ const transferredCanvases = /* @__PURE__ */ new WeakSet();
1626
+ /**
1627
+ * Whether this canvas was already transferred by {@link createRenderer}.
1628
+ * A stamped canvas can never render on the main thread again — mount a
1629
+ * fresh element and dispose the previous renderer first.
1630
+ */
1631
+ function isCanvasAlreadyTransferred(canvas) {
1632
+ return transferredCanvases.has(canvas);
1633
+ }
1634
+ function resolveWorkerUrl(explicit) {
1635
+ if (explicit) return explicit;
1636
+ try {
1637
+ const entry = import.meta.url.endsWith(".ts") ? "./worker-entry.ts" : "./worker-entry.mjs";
1638
+ return new URL(entry, import.meta.url).href;
1639
+ } catch {
1640
+ return null;
1641
+ }
1642
+ }
1643
+ /** Cheap pre-flight: no GPU at all means the worker path cannot succeed. */
1644
+ function hasWebGL() {
1645
+ try {
1646
+ if (typeof document === "undefined") return false;
1647
+ const canvas = document.createElement("canvas");
1648
+ return !!(canvas.getContext("webgl2") || canvas.getContext("webgl"));
1649
+ } catch {
1650
+ return false;
1651
+ }
1652
+ }
1653
+ function canTransfer(canvas) {
1654
+ return typeof canvas.transferControlToOffscreen === "function";
1655
+ }
1656
+ function spawnWorker(url) {
1657
+ return new Worker(url, {
1658
+ type: "module",
1659
+ name: "actis-renderer"
1660
+ });
1661
+ }
1662
+ function waitForReady(worker, timeoutMs) {
1663
+ return new Promise((resolve, reject) => {
1664
+ const timer = typeof setTimeout !== "undefined" ? setTimeout(() => {
1665
+ worker.onmessage = null;
1666
+ reject(/* @__PURE__ */ new Error("handshake-timeout"));
1667
+ }, timeoutMs) : null;
1668
+ worker.onmessage = (event) => {
1669
+ const data = event.data;
1670
+ if (data?.type === "ready") {
1671
+ if (timer !== null && typeof clearTimeout !== "undefined") clearTimeout(timer);
1672
+ worker.onmessage = null;
1673
+ if (data.version !== 1) {
1674
+ reject(/* @__PURE__ */ new Error("version-mismatch"));
1675
+ return;
1676
+ }
1677
+ resolve();
1678
+ }
1679
+ };
1680
+ worker.onerror = () => {
1681
+ if (timer !== null && typeof clearTimeout !== "undefined") clearTimeout(timer);
1682
+ reject(/* @__PURE__ */ new Error("spawn-failed"));
1683
+ };
1684
+ });
1685
+ }
1686
+ /**
1687
+ * Create a renderer, worker-first by default.
1688
+ *
1689
+ * `auto` probes `Worker` -> `transferControlToOffscreen` -> WebGL ->
1690
+ * handshake, falling back to the main-thread `WebGLRenderer` at the first
1691
+ * failure (`onFallback` reports why). `worker` throws
1692
+ * {@link WorkerUnsupportedError} instead of falling back; `main` always
1693
+ * constructs {@link WebGLRenderer} directly.
1694
+ *
1695
+ * Note: transferring a canvas is irreversible. A GPU that works on the main
1696
+ * thread is assumed to work in the worker; the pre-flight probe plus versioned
1697
+ * handshake make a post-transfer failure unlikely. If the worker still dies
1698
+ * after transfer, the canvas cannot be recovered on the main thread, so
1699
+ * creation throws {@link WorkerUnsupportedError} (`worker-transferred-fatal`)
1700
+ * even in `auto` mode.
1701
+ */
1702
+ async function createRenderer(canvas, options = {}) {
1703
+ const { mode = "auto", onFallback, onError, handshakeTimeoutMs = 2e3, attachHost = true } = options;
1704
+ const strict = mode === "worker";
1705
+ const fail = (reason) => {
1706
+ if (strict) throw new WorkerUnsupportedError(reason);
1707
+ onFallback?.(reason);
1708
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") console.info(`[Actis] Offscreen worker unavailable (${reason}); using main-thread renderer.`);
1709
+ try {
1710
+ return new WebGLRenderer(canvas, onError);
1711
+ } catch (error) {
1712
+ throw new WorkerUnsupportedError(reason, { cause: error });
1713
+ }
1714
+ };
1715
+ if (mode === "main" || typeof window === "undefined" || typeof document === "undefined") {
1716
+ if (mode === "main") {
1717
+ if (isCanvasAlreadyTransferred(canvas)) throw new WorkerUnsupportedError("canvas-already-bound", { cause: /* @__PURE__ */ new Error("This canvas was transferred to a worker by a previous createRenderer() call. Transfer is irreversible: dispose the previous renderer and mount a fresh canvas element.") });
1718
+ return new WebGLRenderer(canvas, onError);
1719
+ }
1720
+ return fail("ssr-no-dom");
1721
+ }
1722
+ if (isCanvasAlreadyTransferred(canvas)) {
1723
+ const err = new WorkerUnsupportedError("canvas-already-bound", { cause: /* @__PURE__ */ new Error("This canvas was transferred to a worker by a previous createRenderer() call. Transfer is irreversible: dispose the previous renderer and mount a fresh canvas element.") });
1724
+ if (strict) throw err;
1725
+ onFallback?.("canvas-already-bound");
1726
+ throw err;
1727
+ }
1728
+ if (typeof Worker === "undefined" && !options.worker) return fail("no-worker");
1729
+ if (!canTransfer(canvas)) return fail("no-offscreen-canvas");
1730
+ if (!hasWebGL()) return fail("no-webgl");
1731
+ let worker = options.worker ?? null;
1732
+ if (!worker) {
1733
+ const workerUrl = resolveWorkerUrl(options.workerUrl);
1734
+ if (!workerUrl) return fail("worker-spawn-failed");
1735
+ try {
1736
+ worker = spawnWorker(workerUrl);
1737
+ } catch {
1738
+ return fail("worker-spawn-failed");
1739
+ }
1740
+ }
1741
+ try {
1742
+ await waitForReady(worker, handshakeTimeoutMs);
1743
+ } catch (error) {
1744
+ worker.terminate();
1745
+ const message = error?.message;
1746
+ return fail(message === "version-mismatch" ? "worker-version-mismatch" : message === "spawn-failed" ? "worker-spawn-failed" : "worker-handshake-timeout");
1747
+ }
1748
+ const rectWidth = Math.max(1, Math.floor(canvas.clientWidth || 1));
1749
+ const rectHeight = Math.max(1, Math.floor(canvas.clientHeight || 1));
1750
+ const pixelRatio = window.devicePixelRatio || 1;
1751
+ let offscreen;
1752
+ try {
1753
+ offscreen = canvas.transferControlToOffscreen();
1754
+ transferredCanvases.add(canvas);
1755
+ } catch {
1756
+ worker.terminate();
1757
+ return fail("no-offscreen-canvas");
1758
+ }
1759
+ const hosted = new OffscreenWebGLRenderer(canvas, worker, { onError });
1760
+ if (attachHost) hosted.setDetachHost(attachRendererHost(canvas, {
1761
+ setDisplaySize: (w, h) => hosted.setDisplaySize(w, h),
1762
+ setPixelRatio: (r) => hosted.setPixelRatio(r),
1763
+ setPointer: (x, y) => hosted.setPointer(x, y),
1764
+ get realToCSSPixels() {
1765
+ return hosted.realToCSSPixels;
1766
+ }
1767
+ }));
1768
+ const init = {
1769
+ type: "init",
1770
+ version: 1,
1771
+ canvas: offscreen,
1772
+ width: Math.max(1, Math.floor(rectWidth * pixelRatio)),
1773
+ height: Math.max(1, Math.floor(rectHeight * pixelRatio)),
1774
+ pixelRatio
1775
+ };
1776
+ const firstEvent = await new Promise((resolve) => {
1777
+ const timer = typeof setTimeout !== "undefined" ? setTimeout(resolve, handshakeTimeoutMs, null) : null;
1778
+ const done = (value) => {
1779
+ if (timer !== null && typeof clearTimeout !== "undefined") clearTimeout(timer);
1780
+ worker.onmessage = null;
1781
+ resolve(value);
1782
+ };
1783
+ worker.onmessage = (event) => {
1784
+ const data = event.data;
1785
+ if (data?.type === "fatal") done(data);
1786
+ else if (data?.type === "context" || data?.type === "metrics" || data?.type === "error") done(data);
1787
+ };
1788
+ worker.onerror = () => done({
1789
+ type: "fatal",
1790
+ reason: "worker-gl-unavailable"
1791
+ });
1792
+ try {
1793
+ worker.postMessage(init, [offscreen]);
1794
+ } catch {
1795
+ done({
1796
+ type: "fatal",
1797
+ reason: "worker-gl-unavailable"
1798
+ });
1799
+ }
1800
+ });
1801
+ if (firstEvent === null || firstEvent.type === "fatal") {
1802
+ worker.terminate();
1803
+ hosted.dispose();
1804
+ const mapped = (firstEvent?.reason ?? "").startsWith("version-mismatch") ? "worker-version-mismatch" : "worker-gl-unavailable";
1805
+ onFallback?.("worker-transferred-fatal");
1806
+ throw new WorkerUnsupportedError(mapped);
1807
+ }
1808
+ hosted.connect(firstEvent);
1809
+ return hosted;
1810
+ }
1811
+ //#endregion
1812
+ export { FBO, OffscreenWebGLRenderer, Pass, Pipeline, RendererCore, Shader, TEXTURE_CHANNEL_COUNT, Texture, WebGLRenderer, WorkerUnsupportedError, createRenderer, isCanvasAlreadyTransferred, rafFrameDriver, timerFrameDriver };