@liquid-glass-forge/webgl 0.0.0-stage → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/atlas.js ADDED
@@ -0,0 +1,611 @@
1
+ import { createFrame, validateSource, MAX_PIXELS } from '@liquid-glass-forge/core';
2
+ import { effects } from './effects.js';
3
+ import { VERTEX, OPTICS, BLUR, encodingTextureData } from './shaders.js';
4
+ export class WebGLError extends Error {
5
+ code;
6
+ constructor(code, message, options) {
7
+ super(message, options);
8
+ this.code = code;
9
+ this.name = 'WebGLError';
10
+ }
11
+ }
12
+ export const WEBGL_ATTRIBUTES = Object.freeze({ alpha: true, premultipliedAlpha: true, preserveDrawingBuffer: false, antialias: false, depth: false, stencil: false });
13
+ // Bounded lazy pool, not an animation service. Sequential scenes reuse a context
14
+ // after deleting their handles. No browser access occurs at import/construction.
15
+ const pool = [];
16
+ export function webglContextPoolDiagnostics() { return Object.freeze({ contexts: pool.length, leased: pool.filter(e => e.busy).length, idle: pool.filter(e => !e.busy).length, cap: 4 }); }
17
+ export function releaseIdleWebGLContexts() {
18
+ for (let i = pool.length - 1; i >= 0; i--)
19
+ if (!pool[i].busy) {
20
+ const e = pool.splice(i, 1)[0];
21
+ e.gl.getExtension('WEBGL_lose_context')?.loseContext();
22
+ e.canvas.width = e.canvas.height = 1;
23
+ }
24
+ }
25
+ function acquire(factory) {
26
+ if (!factory) {
27
+ const available = pool.find(e => !e.busy && !e.gl.isContextLost());
28
+ if (available) {
29
+ available.busy = true;
30
+ return available;
31
+ }
32
+ }
33
+ if (!factory && pool.length >= 4)
34
+ throw new WebGLError('CONTEXT_LIMIT', 'Four context slots are already retained; release idle/lost slots or share a scene');
35
+ const canvas = factory ? factory() : document.createElement('canvas');
36
+ canvas.width = canvas.height = 1;
37
+ let gl;
38
+ try {
39
+ gl = canvas.getContext('webgl2', WEBGL_ATTRIBUTES);
40
+ }
41
+ catch (cause) {
42
+ throw new WebGLError('WEBGL_UNAVAILABLE', 'WebGL2 creation failed', { cause });
43
+ }
44
+ if (!gl)
45
+ throw new WebGLError('WEBGL_UNAVAILABLE', 'WebGL2 unavailable');
46
+ const e = { canvas, gl, busy: true };
47
+ if (!factory)
48
+ pool.push(e);
49
+ return e;
50
+ }
51
+ function storageProfile(doc) {
52
+ const canvas = doc.createElement('canvas');
53
+ canvas.width = 6;
54
+ canvas.height = 1;
55
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
56
+ if (!ctx)
57
+ throw new WebGLError('TARGET_UNAVAILABLE', 'Canvas2D unavailable');
58
+ const bytes = new Uint8ClampedArray([93, 24, 240, 57, 220, 78, 25, 62, 1, 255, 150, 1, 13, 120, 250, 13, 140, 35, 97, 128, 255, 10, 2, 255]);
59
+ const image = ctx.createImageData(6, 1);
60
+ image.data.set(bytes);
61
+ ctx.putImageData(image, 0, 0);
62
+ const actual = ctx.getImageData(0, 0, 6, 1).data;
63
+ for (const up of [false, true])
64
+ if (bytes.every((v, i) => {
65
+ if (i % 4 === 3)
66
+ return actual[i] === v;
67
+ const a = bytes[i - i % 4 + 3], p = Math.floor((v * a + (up ? 254 : 127)) / 255);
68
+ return actual[i] === Math.min(255, up ? Math.floor(p * 255 / a) : Math.round(p * 255 / a));
69
+ })) {
70
+ canvas.width = canvas.height = 0;
71
+ return up;
72
+ }
73
+ canvas.width = canvas.height = 0;
74
+ throw new WebGLError('ALPHA_STORAGE_UNSUPPORTED', 'Unknown Canvas2D byte storage profile');
75
+ }
76
+ /** Persistent atlas. update() finishes GPU→default framebuffer→Canvas2D copies
77
+ * synchronously, before returning. Callers own target canvases and source bytes. */
78
+ export class WebGLAtlas {
79
+ #options;
80
+ #entry;
81
+ #state = 'new';
82
+ #textures = new Set();
83
+ #fbos = new Set();
84
+ #programs = new Set();
85
+ #shaders = new Set();
86
+ #optics;
87
+ #blur;
88
+ #source;
89
+ #encoding;
90
+ #output;
91
+ #fbo;
92
+ #blurTextures = [];
93
+ #blurFbos = [];
94
+ #width = 0;
95
+ #height = 0;
96
+ #sourceWidth = 0;
97
+ #sourceHeight = 0;
98
+ #blurPixels = 0;
99
+ #sourceLinear = false;
100
+ #decodeProbeError;
101
+ #maxSize = 0;
102
+ #maxViewport = new Int32Array(2);
103
+ #quantizeUp = false;
104
+ #layoutKey = '';
105
+ #sourceKey = '';
106
+ #effectKey = '';
107
+ #blurKey = '';
108
+ #surfaceKeys = new Map();
109
+ #targets = new Map();
110
+ #lastGeneration = -1;
111
+ #lastRevision = -1;
112
+ #lastSourceRevision = -1;
113
+ #restores = 0;
114
+ #handleGeneration = 0;
115
+ #draws = 0;
116
+ #batches = 0;
117
+ #uploads = 0;
118
+ #targetBytes = 0;
119
+ #lastBatch = { dirty: 0, gpuSnapshots: 0, uploadMs: 0, drawMs: 0, snapshotMs: 0, copyMs: 0, batchMs: 0, completedAt: [] };
120
+ #failure;
121
+ constructor(options = {}) {
122
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Reflect.ownKeys(options).some(k => typeof k !== 'string' || !['maxBytes', 'maxRestores', 'canvasFactory'].includes(k)))
123
+ throw new WebGLError('INVALID_OPTIONS', 'Invalid atlas options');
124
+ const maxBytes = options.maxBytes === undefined ? 64 * 1024 ** 2 : options.maxBytes, maxRestores = options.maxRestores === undefined ? 2 : options.maxRestores;
125
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 65544 || maxBytes > 256 * 1024 ** 2 || !Number.isInteger(maxRestores) || maxRestores < 0 || maxRestores > 4 || (options.canvasFactory !== undefined && typeof options.canvasFactory !== 'function'))
126
+ throw new WebGLError('INVALID_OPTIONS', 'Invalid WebGL resource limits');
127
+ this.#options = { ...options, maxBytes, maxRestores };
128
+ }
129
+ get context() { return this.#entry?.gl; }
130
+ get diagnostics() {
131
+ return Object.freeze({ backend: 'webgl', contextType: 'webgl2', state: this.#state, failure: this.#failure, restores: this.#restores,
132
+ handleGeneration: this.#handleGeneration, width: this.#width, height: this.#height, sourceWidth: this.#sourceWidth, sourceHeight: this.#sourceHeight,
133
+ renderCount: this.#draws, batchCount: this.#batches, uploadCount: this.#uploads, targetBytes: this.#targetBytes,
134
+ requestedAttributes: WEBGL_ATTRIBUTES, attributes: this.#entry?.gl.getContextAttributes(), pool: webglContextPoolDiagnostics(),
135
+ resources: Object.freeze({ contexts: this.#entry ? 1 : 0, textures: this.#textures.size, framebuffers: this.#fbos.size, programs: this.#programs.size, shaders: this.#shaders.size, buffers: 0, vertexArrays: 0 }),
136
+ ownedImageBytes: this.#sourceWidth * this.#sourceHeight * 4 + (this.#entry ? this.#entry.canvas.width * this.#entry.canvas.height * 4 : 0) + this.#width * this.#height * 4 + (this.#encoding ? 65536 : 0) + this.#blurPixels * 32,
137
+ lastBatch: Object.freeze({ ...this.#lastBatch, completedAt: Object.freeze([...this.#lastBatch.completedAt]) }),
138
+ sourceDecoding: this.#sourceLinear ? 'calibrated-srgb-texture' : 'explicit-shader', decodeProbeMaxByteError: this.#decodeProbeError,
139
+ colorConvention: 'straight sRGB upload; manual linear-premultiplied sampling/blur; calibrated premultiplied sRGB byte presentation', storageProfile: this.#quantizeUp ? 'upward' : 'nearest' });
140
+ }
141
+ #lost = (event) => {
142
+ event.preventDefault();
143
+ if (this.#state === 'disposed')
144
+ return;
145
+ this.#state = 'lost';
146
+ this.#dropHandles(false);
147
+ if (this.#restores >= this.#options.maxRestores) {
148
+ this.#state = 'unavailable';
149
+ this.#failure = 'RESTORE_LIMIT';
150
+ }
151
+ };
152
+ #restored = () => {
153
+ if (this.#state !== 'lost')
154
+ return;
155
+ this.#restores++;
156
+ try {
157
+ this.#build();
158
+ this.#state = 'ready';
159
+ }
160
+ catch (cause) {
161
+ this.#dropHandles(true);
162
+ this.#state = 'unavailable';
163
+ this.#failure = cause instanceof WebGLError ? cause.code : 'RESTORE_FAILED';
164
+ }
165
+ };
166
+ initialize() {
167
+ if (this.#state !== 'new')
168
+ throw new WebGLError('INVALID_STATE', 'Atlas initialization is explicit and occurs once');
169
+ try {
170
+ this.#entry = acquire(this.#options.canvasFactory);
171
+ this.#entry.canvas.addEventListener('webglcontextlost', this.#lost);
172
+ this.#entry.canvas.addEventListener('webglcontextrestored', this.#restored);
173
+ this.#quantizeUp = storageProfile(this.#entry.canvas.ownerDocument);
174
+ this.#build();
175
+ this.#state = 'ready';
176
+ }
177
+ catch (cause) {
178
+ this.#failure = cause instanceof WebGLError ? cause.code : 'INITIALIZATION_FAILED';
179
+ this.#state = 'unavailable';
180
+ this.#release();
181
+ throw cause;
182
+ }
183
+ }
184
+ #assert() {
185
+ if (this.#state !== 'ready' || !this.#entry)
186
+ throw new WebGLError(this.#state === 'lost' ? 'CONTEXT_LOST' : this.#state === 'disposed' ? 'DISPOSED' : 'WEBGL_UNAVAILABLE', 'WebGL atlas is ' + this.#state);
187
+ if (this.#entry.gl.isContextLost())
188
+ throw new WebGLError('CONTEXT_LOST', 'WebGL context lost');
189
+ return this.#entry.gl;
190
+ }
191
+ #check(label) { const gl = this.#entry.gl; if (gl.isContextLost())
192
+ throw new WebGLError('CONTEXT_LOST', label); const e = gl.getError(); if (e !== gl.NO_ERROR)
193
+ throw new WebGLError('WEBGL_ERROR', label + ' error ' + e); }
194
+ #texture() { const gl = this.#entry.gl, t = gl.createTexture(); if (!t)
195
+ throw new WebGLError('ALLOCATION_FAILED', 'Texture allocation failed'); this.#textures.add(t); gl.bindTexture(gl.TEXTURE_2D, t); for (const p of [gl.TEXTURE_MIN_FILTER, gl.TEXTURE_MAG_FILTER])
196
+ gl.texParameteri(gl.TEXTURE_2D, p, gl.NEAREST); for (const p of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T])
197
+ gl.texParameteri(gl.TEXTURE_2D, p, gl.CLAMP_TO_EDGE); return t; }
198
+ #framebuffer(texture) {
199
+ const gl = this.#entry.gl, f = gl.createFramebuffer();
200
+ if (!f)
201
+ throw new WebGLError('ALLOCATION_FAILED', 'Framebuffer allocation failed');
202
+ this.#fbos.add(f);
203
+ try {
204
+ gl.bindFramebuffer(gl.FRAMEBUFFER, f);
205
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
206
+ this.#check('Framebuffer allocation');
207
+ if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE)
208
+ throw new WebGLError('FRAMEBUFFER_INCOMPLETE', 'Framebuffer incomplete');
209
+ return f;
210
+ }
211
+ catch (cause) {
212
+ gl.deleteFramebuffer(f);
213
+ this.#fbos.delete(f);
214
+ throw cause;
215
+ }
216
+ }
217
+ #deleteTexture(t) { if (t) {
218
+ this.#entry.gl.deleteTexture(t);
219
+ this.#textures.delete(t);
220
+ } }
221
+ #deleteFbo(f) { if (f) {
222
+ this.#entry.gl.deleteFramebuffer(f);
223
+ this.#fbos.delete(f);
224
+ } }
225
+ #program(fragment, names) {
226
+ const gl = this.#entry.gl, shaders = [];
227
+ for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, fragment]]) {
228
+ const shader = gl.createShader(type);
229
+ if (!shader)
230
+ throw new WebGLError('ALLOCATION_FAILED', 'Shader allocation failed');
231
+ this.#shaders.add(shader);
232
+ shaders.push(shader);
233
+ gl.shaderSource(shader, text);
234
+ gl.compileShader(shader);
235
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
236
+ throw new WebGLError('SHADER_COMPILE_FAILED', gl.getShaderInfoLog(shader) || 'Shader compilation failed');
237
+ }
238
+ const handle = gl.createProgram();
239
+ if (!handle)
240
+ throw new WebGLError('ALLOCATION_FAILED', 'Program allocation failed');
241
+ this.#programs.add(handle);
242
+ for (const s of shaders)
243
+ gl.attachShader(handle, s);
244
+ gl.linkProgram(handle);
245
+ if (!gl.getProgramParameter(handle, gl.LINK_STATUS))
246
+ throw new WebGLError('PROGRAM_LINK_FAILED', gl.getProgramInfoLog(handle) || 'Program link failed');
247
+ for (const s of shaders) {
248
+ gl.detachShader(handle, s);
249
+ gl.deleteShader(s);
250
+ this.#shaders.delete(s);
251
+ }
252
+ return { handle, locations: Object.fromEntries(names.map(n => [n, gl.getUniformLocation(handle, 'u' + n)])) };
253
+ }
254
+ #build() {
255
+ const gl = this.#entry.gl, a = gl.getContextAttributes();
256
+ if (!a || Object.entries(WEBGL_ATTRIBUTES).some(([k, v]) => a[k] !== v))
257
+ throw new WebGLError('ATTRIBUTES_UNSUPPORTED', 'Required context attributes not granted');
258
+ this.#maxSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
259
+ this.#maxViewport = gl.getParameter(gl.MAX_VIEWPORT_DIMS);
260
+ this.#optics = this.#program(OPTICS, ['Source', 'Encoding', 'Size', 'Origin', 'Rect', 'Radius', 'Bevel', 'Strength', 'Prepared', 'QuantizeUp', 'SourceLinear', 'Calibration', 'Dispersion', 'Tint', 'Light', 'Rim', 'Specular', 'LensProfile']);
261
+ this.#blur = this.#program(BLUR, ['Source', 'Encoding', 'Axis', 'Radius', 'Prepared', 'SourceLinear']);
262
+ this.#source = this.#texture();
263
+ this.#encoding = this.#texture();
264
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 64, 64, 0, gl.RGBA, gl.FLOAT, encodingTextureData());
265
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
266
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
267
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
268
+ gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
269
+ for (const c of [gl.BLEND, gl.DITHER, gl.DEPTH_TEST, gl.STENCIL_TEST, gl.SCISSOR_TEST])
270
+ gl.disable(c);
271
+ this.#calibrateDecode();
272
+ this.#check('Initialization');
273
+ this.#handleGeneration++;
274
+ this.#invalidate();
275
+ }
276
+ // Probe the native sRGB sampler once per handle generation. Packed 16-bit
277
+ // linear values reach Canvas2D synchronously; production never calls readPixels.
278
+ // Select the fast sampler only when all 256 byte values satisfy a fixed bound.
279
+ #calibrateDecode() {
280
+ this.#sourceLinear = false;
281
+ this.#decodeProbeError = undefined;
282
+ if (this.#options.maxBytes < 72000)
283
+ return;
284
+ const gl = this.#entry.gl, c = this.#entry.canvas, p = this.#optics, u = p.locations;
285
+ c.width = 256;
286
+ c.height = 1;
287
+ const data = new Uint8Array(1024);
288
+ for (let i = 0; i < 256; i++)
289
+ data.set([i, i, i, 255], i * 4);
290
+ gl.activeTexture(gl.TEXTURE0);
291
+ gl.bindTexture(gl.TEXTURE_2D, this.#source);
292
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.SRGB8_ALPHA8, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
293
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
294
+ gl.useProgram(p.handle);
295
+ gl.viewport(0, 0, 256, 1);
296
+ gl.uniform1i(u.Source, 0);
297
+ gl.uniform1i(u.Calibration, 1);
298
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
299
+ const probe = c.ownerDocument.createElement('canvas');
300
+ probe.width = 256;
301
+ probe.height = 1;
302
+ const ctx = probe.getContext('2d', { willReadFrequently: true });
303
+ if (!ctx)
304
+ throw new WebGLError('TARGET_UNAVAILABLE', 'Decode calibration unavailable');
305
+ ctx.drawImage(c, 0, 0);
306
+ const bytes = ctx.getImageData(0, 0, 256, 1).data;
307
+ let error = 0;
308
+ for (let i = 0; i < 256; i++) {
309
+ const linear = (bytes[i * 4] * 256 + bytes[i * 4 + 1]) / 65535;
310
+ const encoded = (linear <= .0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - .055) * 255;
311
+ error = Math.max(error, Math.abs(encoded - i));
312
+ }
313
+ this.#decodeProbeError = error;
314
+ this.#sourceLinear = error <= .075;
315
+ probe.width = probe.height = 0;
316
+ gl.uniform1i(u.Calibration, 0);
317
+ gl.texImage2D(gl.TEXTURE_2D, 0, this.#sourceLinear ? gl.SRGB8_ALPHA8 : gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
318
+ this.#sourceWidth = this.#sourceHeight = 1;
319
+ c.width = c.height = 1;
320
+ }
321
+ #invalidate() { this.#layoutKey = this.#sourceKey = this.#effectKey = this.#blurKey = ''; this.#surfaceKeys.clear(); this.#targets.clear(); }
322
+ #dropHandles(remove) {
323
+ if (this.#entry && remove) {
324
+ const gl = this.#entry.gl;
325
+ for (const s of this.#shaders)
326
+ gl.deleteShader(s);
327
+ for (const p of this.#programs)
328
+ gl.deleteProgram(p);
329
+ for (const t of this.#textures)
330
+ gl.deleteTexture(t);
331
+ for (const f of this.#fbos)
332
+ gl.deleteFramebuffer(f);
333
+ }
334
+ this.#textures.clear();
335
+ this.#fbos.clear();
336
+ this.#programs.clear();
337
+ this.#shaders.clear();
338
+ this.#source = this.#encoding = this.#output = this.#fbo = undefined;
339
+ this.#optics = this.#blur = undefined;
340
+ this.#blurTextures = [];
341
+ this.#blurFbos = [];
342
+ this.#width = this.#height = this.#sourceWidth = this.#sourceHeight = this.#blurPixels = 0;
343
+ this.#invalidate();
344
+ }
345
+ #release() {
346
+ this.#dropHandles(true);
347
+ if (this.#entry) {
348
+ const { canvas, gl } = this.#entry;
349
+ canvas.removeEventListener('webglcontextlost', this.#lost);
350
+ canvas.removeEventListener('webglcontextrestored', this.#restored);
351
+ canvas.width = canvas.height = 1;
352
+ this.#entry.busy = false;
353
+ if (this.#options.canvasFactory)
354
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
355
+ }
356
+ this.#entry = undefined;
357
+ this.#targetBytes = 0;
358
+ }
359
+ dispose() { if (this.#state === 'disposed')
360
+ return; this.#state = 'disposed'; this.#release(); }
361
+ #allocateOutput(width, height) {
362
+ if (width === this.#width && height === this.#height)
363
+ return;
364
+ const gl = this.#entry.gl;
365
+ let t, f;
366
+ try {
367
+ t = this.#texture();
368
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
369
+ f = this.#framebuffer(t);
370
+ }
371
+ catch (e) {
372
+ this.#deleteTexture(t);
373
+ this.#deleteFbo(f);
374
+ throw e;
375
+ }
376
+ this.#deleteTexture(this.#output);
377
+ this.#deleteFbo(this.#fbo);
378
+ this.#output = t;
379
+ this.#fbo = f;
380
+ this.#entry.canvas.width = width;
381
+ this.#entry.canvas.height = height;
382
+ if (gl.drawingBufferWidth !== width || gl.drawingBufferHeight !== height)
383
+ throw new WebGLError('SIZE_UNSUPPORTED', 'Drawing buffer allocation refused');
384
+ this.#width = width;
385
+ this.#height = height;
386
+ }
387
+ #prepareBlur(radius, key) {
388
+ const gl = this.#entry.gl;
389
+ if (!radius) {
390
+ for (const t of this.#blurTextures)
391
+ this.#deleteTexture(t);
392
+ for (const f of this.#blurFbos)
393
+ this.#deleteFbo(f);
394
+ this.#blurTextures = [];
395
+ this.#blurFbos = [];
396
+ this.#blurPixels = 0;
397
+ this.#blurKey = '';
398
+ return this.#source;
399
+ }
400
+ if (!gl.getExtension('EXT_color_buffer_float'))
401
+ throw new WebGLError('BLUR_UNAVAILABLE', 'GPU blur requires renderable float color buffers');
402
+ if (key === this.#blurKey)
403
+ return this.#blurTextures[1];
404
+ if (this.#blurPixels !== this.#sourceWidth * this.#sourceHeight || this.#blurTextures.length !== 2) {
405
+ const ts = [], fs = [];
406
+ try {
407
+ for (let i = 0; i < 2; i++) {
408
+ const t = this.#texture();
409
+ ts.push(t);
410
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, this.#sourceWidth, this.#sourceHeight, 0, gl.RGBA, gl.FLOAT, null);
411
+ fs.push(this.#framebuffer(t));
412
+ }
413
+ }
414
+ catch (e) {
415
+ for (const t of ts)
416
+ this.#deleteTexture(t);
417
+ for (const f of fs)
418
+ this.#deleteFbo(f);
419
+ throw e;
420
+ }
421
+ for (const t of this.#blurTextures)
422
+ this.#deleteTexture(t);
423
+ for (const f of this.#blurFbos)
424
+ this.#deleteFbo(f);
425
+ this.#blurTextures = ts;
426
+ this.#blurFbos = fs;
427
+ this.#blurPixels = this.#sourceWidth * this.#sourceHeight;
428
+ }
429
+ const p = this.#blur, u = p.locations;
430
+ gl.disable(gl.SCISSOR_TEST);
431
+ gl.useProgram(p.handle);
432
+ gl.viewport(0, 0, this.#sourceWidth, this.#sourceHeight);
433
+ gl.activeTexture(gl.TEXTURE0);
434
+ gl.uniform1i(u.Source, 0);
435
+ gl.uniform1i(u.Radius, radius);
436
+ gl.uniform1i(u.SourceLinear, this.#sourceLinear ? 1 : 0);
437
+ gl.activeTexture(gl.TEXTURE1);
438
+ gl.bindTexture(gl.TEXTURE_2D, this.#encoding);
439
+ gl.uniform1i(u.Encoding, 1);
440
+ gl.activeTexture(gl.TEXTURE0);
441
+ for (let i = 0; i < 2; i++) {
442
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.#blurFbos[i]);
443
+ gl.bindTexture(gl.TEXTURE_2D, i ? this.#blurTextures[0] : this.#source);
444
+ gl.uniform2i(u.Axis, i ? 0 : 1, i ? 1 : 0);
445
+ gl.uniform1i(u.Prepared, i);
446
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
447
+ }
448
+ this.#check('GPU blur');
449
+ this.#blurKey = key;
450
+ return this.#blurTextures[1];
451
+ }
452
+ update(source, entries, options = {}) {
453
+ const gl = this.#assert(), begin = performance.now();
454
+ validateSource(source);
455
+ if (!options || typeof options !== 'object' || Reflect.ownKeys(options).some(k => typeof k !== 'string' || !['generation', 'revision', 'dirtyIds', 'effects', 'signal'].includes(k)))
456
+ throw new WebGLError('INVALID_OPTIONS', 'Invalid update options');
457
+ if (options.signal !== undefined && (!options.signal || typeof options.signal.aborted !== 'boolean' || typeof options.signal.addEventListener !== 'function'))
458
+ throw new WebGLError('INVALID_OPTIONS', 'Invalid abort signal');
459
+ if (options.signal?.aborted)
460
+ throw new WebGLError('ABORTED', 'GPU update aborted');
461
+ const generation = options.generation === undefined ? 0 : options.generation;
462
+ const revision = options.revision === undefined ? (generation > this.#lastGeneration ? 0 : this.#lastRevision + 1) : options.revision;
463
+ if (![generation, revision].every(n => Number.isSafeInteger(n) && n >= 0) || generation < this.#lastGeneration || (generation === this.#lastGeneration && (revision < this.#lastRevision || source.revision < this.#lastSourceRevision)))
464
+ throw new WebGLError('STALE', 'Invalid/stale frame identity');
465
+ if (!Array.isArray(entries) || entries.length < 1 || entries.length > 50)
466
+ throw new WebGLError('BATCH_LIMIT', 'Atlas accepts1..50 surfaces');
467
+ const e = effects(options.effects), tiles = entries.map(entry => {
468
+ const surface = createFrame({ contractVersion: 1, generation, revision, sourceRevision: source.revision, surfaces: [entry.surface] }, source).surfaces[0];
469
+ const target = entry.target, view = target?.ownerDocument?.defaultView;
470
+ if (!view || !(target instanceof view.HTMLCanvasElement))
471
+ throw new WebGLError('INVALID_TARGET', 'Target must be a Canvas2D canvas');
472
+ const ctx = target.getContext('2d', { alpha: true, willReadFrequently: true });
473
+ if (!ctx || ctx.getContextAttributes?.().alpha === false)
474
+ throw new WebGLError('TARGET_UNAVAILABLE', 'Alpha Canvas2D target required');
475
+ return { surface, target, ctx, x: 0, y: 0 };
476
+ });
477
+ if (new Set(tiles.map(t => t.surface.id)).size !== tiles.length || new Set(tiles.map(t => t.target)).size !== tiles.length)
478
+ throw new WebGLError('INVALID_BATCH', 'Surface IDs and targets must be unique');
479
+ const ids = new Set(tiles.map(t => t.surface.id));
480
+ if (options.dirtyIds !== undefined && (!Array.isArray(options.dirtyIds) || options.dirtyIds.some(id => !ids.has(id))))
481
+ throw new WebGLError('INVALID_BATCH', 'Unknown dirty ID');
482
+ const cellW = Math.max(...tiles.map(t => t.surface.width)), cellH = Math.max(...tiles.map(t => t.surface.height));
483
+ let layout;
484
+ const maxW = Math.min(this.#maxSize, this.#maxViewport[0]), maxH = Math.min(this.#maxSize, this.#maxViewport[1]);
485
+ for (let columns = 1; columns <= tiles.length; columns++) {
486
+ const width = columns * cellW, height = Math.ceil(tiles.length / columns) * cellH;
487
+ if (width > maxW || height > maxH || width * height > MAX_PIXELS)
488
+ continue;
489
+ if (!layout || width * height < layout.width * layout.height || (width * height === layout.width * layout.height && Math.max(width, height) < Math.max(layout.width, layout.height)))
490
+ layout = { width, height, columns };
491
+ }
492
+ if (!layout || source.width > this.#maxSize || source.height > this.#maxSize)
493
+ throw new WebGLError('SIZE_UNSUPPORTED', 'Source/atlas exceeds device or core pixel limits');
494
+ const bytes = source.width * source.height * (4 + (e.blur ? 32 : 0)) + layout.width * layout.height * 8 + 65536;
495
+ const oldAtlas = this.#width * this.#height, newAtlas = layout.width * layout.height;
496
+ const oldBlur = this.#blurPixels * 32, newBlur = e.blur ? source.width * source.height * 32 : 0, sourceBytes = Math.max(source.width * source.height, this.#sourceWidth * this.#sourceHeight) * 4;
497
+ const peak = Math.max(bytes, sourceBytes + oldBlur + oldAtlas * 8 + (newAtlas !== oldAtlas ? newAtlas * 4 : 0) + 65536, source.width * source.height * 4 + newAtlas * 8 + newBlur + (e.blur && this.#blurPixels === source.width * source.height ? 0 : oldBlur) + 65536);
498
+ if (peak > this.#options.maxBytes)
499
+ throw new WebGLError('BUDGET', 'GPU image storage including resize overlap exceeds maxBytes');
500
+ const layoutKey = JSON.stringify([layout, tiles.map(t => [t.surface.id, t.surface.width, t.surface.height])]);
501
+ const sourceKey = `${generation}/${source.revision}/${source.width}/${source.height}`, effectKey = JSON.stringify(e);
502
+ const full = layoutKey !== this.#layoutKey || sourceKey !== this.#sourceKey || effectKey !== this.#effectKey;
503
+ const dirty = new Set(options.dirtyIds ?? ids);
504
+ const keys = new Map();
505
+ tiles.forEach((t, i) => { t.x = i % layout.columns * cellW; t.y = Math.floor(i / layout.columns) * cellH; const key = JSON.stringify(t.surface); keys.set(t.surface.id, key); if (full || key !== this.#surfaceKeys.get(t.surface.id) || t.target !== this.#targets.get(t.surface.id) || t.target.width !== t.surface.width || t.target.height !== t.surface.height)
506
+ dirty.add(t.surface.id); });
507
+ if (!dirty.size) {
508
+ this.#lastGeneration = generation;
509
+ this.#lastRevision = revision;
510
+ this.#lastSourceRevision = source.revision;
511
+ this.#lastBatch = { dirty: 0, gpuSnapshots: 0, uploadMs: 0, drawMs: 0, snapshotMs: 0, copyMs: 0, batchMs: 0, completedAt: [] };
512
+ return this.diagnostics.lastBatch;
513
+ }
514
+ try {
515
+ this.#allocateOutput(layout.width, layout.height);
516
+ if (sourceKey !== this.#sourceKey) {
517
+ gl.activeTexture(gl.TEXTURE0);
518
+ gl.bindTexture(gl.TEXTURE_2D, this.#source);
519
+ const data = new Uint8Array(source.data.buffer, source.data.byteOffset, source.data.byteLength);
520
+ if (source.width === this.#sourceWidth && source.height === this.#sourceHeight)
521
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, source.width, source.height, gl.RGBA, gl.UNSIGNED_BYTE, data);
522
+ else {
523
+ gl.texImage2D(gl.TEXTURE_2D, 0, this.#sourceLinear ? gl.SRGB8_ALPHA8 : gl.RGBA8, source.width, source.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
524
+ this.#blurKey = '';
525
+ }
526
+ this.#sourceWidth = source.width;
527
+ this.#sourceHeight = source.height;
528
+ this.#uploads++;
529
+ this.#check('Source upload');
530
+ }
531
+ const uploadedAt = performance.now(), texture = this.#prepareBlur(e.blur, sourceKey + '/' + e.blur), program = this.#optics, u = program.locations;
532
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.#fbo);
533
+ gl.enable(gl.SCISSOR_TEST);
534
+ gl.useProgram(program.handle);
535
+ gl.activeTexture(gl.TEXTURE0);
536
+ gl.bindTexture(gl.TEXTURE_2D, texture);
537
+ gl.uniform1i(u.Source, 0);
538
+ gl.activeTexture(gl.TEXTURE1);
539
+ gl.bindTexture(gl.TEXTURE_2D, this.#encoding);
540
+ gl.uniform1i(u.Encoding, 1);
541
+ gl.uniform1i(u.SourceLinear, this.#sourceLinear ? 1 : 0);
542
+ gl.uniform1i(u.Prepared, e.blur ? 1 : 0);
543
+ gl.uniform1i(u.QuantizeUp, this.#quantizeUp ? 1 : 0);
544
+ gl.uniform1i(u.LensProfile, e.lens ? 1 : 0);
545
+ gl.uniform1f(u.Dispersion, e.dispersion);
546
+ gl.uniform4f(u.Tint, ...e.tint);
547
+ gl.uniform2f(u.Light, ...e.light);
548
+ gl.uniform1f(u.Rim, e.rim);
549
+ gl.uniform1f(u.Specular, e.specular);
550
+ for (const t of tiles)
551
+ if (dirty.has(t.surface.id)) {
552
+ const s = t.surface, bottom = layout.height - t.y - s.height;
553
+ gl.viewport(t.x, bottom, s.width, s.height);
554
+ gl.scissor(t.x, bottom, s.width, s.height);
555
+ gl.clearColor(0, 0, 0, 0);
556
+ gl.clear(gl.COLOR_BUFFER_BIT);
557
+ gl.uniform2f(u.Size, s.width, s.height);
558
+ gl.uniform2f(u.Origin, t.x, bottom);
559
+ gl.uniform4f(u.Rect, s.sourceRect.x, s.sourceRect.y, s.sourceRect.width, s.sourceRect.height);
560
+ gl.uniform1f(u.Radius, s.radius);
561
+ gl.uniform1f(u.Bevel, Math.max(s.bevel, 1.17549435e-38));
562
+ gl.uniform1f(u.Strength, s.strength);
563
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
564
+ this.#draws++;
565
+ }
566
+ gl.disable(gl.SCISSOR_TEST);
567
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.#fbo);
568
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
569
+ gl.blitFramebuffer(0, 0, layout.width, layout.height, 0, 0, layout.width, layout.height, gl.COLOR_BUFFER_BIT, gl.NEAREST);
570
+ this.#check('Atlas draw/blit');
571
+ // Copy the completed default framebuffer directly into each owned target
572
+ // before yielding. Retaining a second full atlas in Canvas2D exceeded the
573
+ // dynamic-scene image budget when source acquisition was included.
574
+ const drawnAt = performance.now(), completedAt = [];
575
+ for (const t of tiles)
576
+ if (dirty.has(t.surface.id)) {
577
+ if (options.signal?.aborted)
578
+ throw new WebGLError('ABORTED', 'GPU presentation aborted');
579
+ if (t.target.width !== t.surface.width)
580
+ t.target.width = t.surface.width;
581
+ if (t.target.height !== t.surface.height)
582
+ t.target.height = t.surface.height;
583
+ if (typeof t.ctx.reset === 'function')
584
+ t.ctx.reset();
585
+ else
586
+ t.target.width = t.surface.width;
587
+ t.ctx.globalCompositeOperation = 'copy';
588
+ t.ctx.imageSmoothingEnabled = false;
589
+ t.ctx.drawImage(this.#entry.canvas, t.x, t.y, t.surface.width, t.surface.height, 0, 0, t.surface.width, t.surface.height);
590
+ completedAt.push(performance.now());
591
+ }
592
+ this.#check('Synchronous tile copies');
593
+ this.#layoutKey = layoutKey;
594
+ this.#sourceKey = sourceKey;
595
+ this.#effectKey = effectKey;
596
+ this.#surfaceKeys = keys;
597
+ this.#targets = new Map(tiles.map(t => [t.surface.id, t.target]));
598
+ this.#lastGeneration = generation;
599
+ this.#lastRevision = revision;
600
+ this.#lastSourceRevision = source.revision;
601
+ this.#batches++;
602
+ this.#targetBytes = tiles.reduce((sum, t) => sum + t.surface.width * t.surface.height * 4, 0);
603
+ this.#lastBatch = { dirty: dirty.size, gpuSnapshots: dirty.size, uploadMs: uploadedAt - begin, drawMs: drawnAt - uploadedAt, snapshotMs: 0, copyMs: performance.now() - drawnAt, batchMs: performance.now() - begin, completedAt };
604
+ return this.diagnostics.lastBatch;
605
+ }
606
+ catch (cause) {
607
+ this.#invalidate();
608
+ throw cause;
609
+ }
610
+ }
611
+ }