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