@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/README.md +60 -2
- package/dist/index.cjs +1691 -208
- package/dist/index.d.cts +516 -56
- package/dist/index.d.mts +516 -56
- package/dist/index.mjs +1681 -209
- package/dist/worker-entry.mjs +12701 -0
- package/package.json +5 -2
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.
|
|
19
|
-
this.
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
this.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
579
|
+
return DEFAULT_VERTEX_SHADER_GLSL100;
|
|
145
580
|
}
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
174
|
-
return
|
|
685
|
+
isUniformMap(value) {
|
|
686
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
175
687
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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/
|
|
743
|
+
//#region src/engine/ScreenTriangle.ts
|
|
744
|
+
const triangleCache = /* @__PURE__ */ new WeakMap();
|
|
186
745
|
/**
|
|
187
|
-
*
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
this.
|
|
262
|
-
this.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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.
|
|
300
|
-
this.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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;
|