PageSourceSearch

https://nopaint.art/aesthetic.computer/lib/gpu-effects.mjs

js nopaint.art collected 2026-09-25 20:56:23 UTC 87,882 bytes, 2,405 lines download raw bytes

1// gpu-effects.mjs - WebGL2 GPU-accelerated effects using OffscreenCanvas
2// Combines spin, zoom, scroll, contrast into a single efficient render pass
3// Can run directly in a Web Worker without main thread involvement
4
5let gl = null;
6let spinProgram = null;
7let compositeProgram = null;
8let invertProgram = null;  // Invert shader program
9let floodSeedProgram = null;  // Flood fill seed initialization
10let floodJFAProgram = null;   // Flood fill Jump Flooding Algorithm pass
11let floodFillProgram = null;  // Flood fill final color application
12let shearProgram = null;  // KidPix-style shear
13let suckProgram = null;   // Radial suck/blow
14let layerCompositeProgram = null;  // Multi-layer alpha compositing
15let positionBuffer = null;
16let texCoordBuffer = null;
17let texture = null;
18let framebuffer = null;
19let outputTexture = null;
20let readbackBuffer = null;
21let canvas = null;
22let initialized = false;
23let lastWidth = 0;
24let lastHeight = 0;
25
26// VAO for efficient attribute setup
27let vao = null;
28
29// Cached uniform locations to avoid getUniformLocation calls every frame
30let spinUniforms = null;
31let compositeUniforms = null;
32let invertUniforms = null;  // Invert shader uniforms
33let kawaseDownProgram = null;  // Dual Kawase blur downsample
34let kawaseUpProgram = null;    // Dual Kawase blur upsample
35let kawaseDownUniforms = null;
36let kawaseUpUniforms = null;
37let kawaseMips = [];           // [{texture, framebuffer, width, height}, ...] mip chain
38let sharpenUniforms = null;
39let floodSeedUniforms = null;
40let floodJFAUniforms = null;
41let floodFillUniforms = null;
42let shearUniforms = null;
43let suckUniforms = null;
44let layerCompositeUniforms = null;
45
46// Fragment shader precision — highp if the GPU supports it, mediump otherwise.
47// Some Android GPUs claim WebGL2 but silently produce blank output with highp.
48let fragPrecision = "highp";
49
50// Mobile safe mode — Mali and Adreno GPUs produce intermittent blank output on
51// OffscreenCanvas WebGL2 (tested: Jelly Star WebView, Mali-G57 MC2). When true,
52// ALL GPU effects return false (CPU fallback) EXCEPT gpuBlur which uses Dual Kawase
53// (a mobile-native algorithm with separate mip-chain FBOs that avoids the hazard).
54let mobileSafeMode = false;
55
56// Accumulated values (matching CPU behavior)
57let spinAccumulator = 0;
58let scrollAccumulatorX = 0;
59let scrollAccumulatorY = 0;
60let zoomAccumulator = 0;
61
62const VERTEX_SHADER = `#version 300 es
63in vec2 a_position;
64in vec2 a_texCoord;
65out vec2 v_texCoord;
66void main() {
67  gl_Position = vec4(a_position, 0.0, 1.0);
68  v_texCoord = a_texCoord;
69}`;
70
71// =========================================================================
72// SPIN SHADER - Exact match to CPU algorithm
73// Uses gl_FragCoord and texelFetch for pixel-perfect addressing
74// =========================================================================
75const SPIN_FRAGMENT_SHADER = `#version 300 es
76precision highp float;
77
78uniform sampler2D u_texture;
79uniform vec2 u_resolution;
80uniform vec2 u_center;
81uniform float u_steps;    // vortex intensity (same unit as CPU spinBlockBased)
82uniform vec4 u_bounds;
83
84in vec2 v_texCoord;
85out vec4 fragColor;
86
87void main() {
88  int destX = int(gl_FragCoord.x);
89  int destY = int(gl_FragCoord.y);
90
91  int minX = int(u_bounds.x);
92  int minY = int(u_bounds.y);
93  int maxX = int(u_bounds.z);
94  int maxY = int(u_bounds.w);
95
96  if (destX < minX || destX >= maxX || destY < minY || destY >= maxY) {
97    fragColor = texelFetch(u_texture, ivec2(destX, destY), 0);
98    return;
99  }
100
101  // Vortex algorithm: angle_change = steps / distance (matches CPU path)
102  // Pixels near center rotate more, creating the spiral/swirl effect.
103  float dx = float(destX) - u_center.x;
104  float dy = float(destY) - u_center.y;
105  float dist2 = dx * dx + dy * dy;
106
107  // Center pixel — no rotation needed
108  if (dist2 < 1.0) {
109    fragColor = texelFetch(u_texture, ivec2(destX, destY), 0);
110    return;
111  }
112
113  float dist = sqrt(dist2);
114  float angle = atan(dy, dx);
115  float angleChange = u_steps / dist;
116  float srcAngle = angle - angleChange;
117
118  float srcXf = u_center.x + dist * cos(srcAngle);
119  float srcYf = u_center.y + dist * sin(srcAngle);
120
121  // Wrap into bounds (toroidal)
122  int boundsW = maxX - minX;
123  int boundsH = maxY - minY;
124  int srcX = int(round(srcXf));
125  int srcY = int(round(srcYf));
126  srcX = minX + ((srcX - minX) % boundsW + boundsW) % boundsW;
127  srcY = minY + ((srcY - minY) % boundsH + boundsH) % boundsH;
128  srcX = clamp(srcX, minX, maxX - 1);
129  srcY = clamp(srcY, minY, maxY - 1);
130
131  fragColor = texelFetch(u_texture, ivec2(srcX, srcY), 0);
132}`;
133
134// =========================================================================
135// COMPOSITE SHADER - Zoom OR Scroll OR Contrast (not combined)
136// Uses gl_FragCoord and texelFetch for pixel-perfect addressing
137// =========================================================================
138const COMPOSITE_FRAGMENT_SHADER = `#version 300 es
139precision highp float;
140
141uniform sampler2D u_texture;
142uniform vec2 u_resolution;
143uniform vec4 u_bounds;  // minX, minY, maxX, maxY
144
145uniform float u_zoomScale;
146uniform vec2 u_zoomAnchor;  // Anchor in pixel coords
147uniform vec2 u_scrollOffset;  // dx, dy
148uniform float u_flipY;
149uniform float u_contrast;
150uniform float u_brightness;
151
152in vec2 v_texCoord;
153out vec4 fragColor;
154
155void main() {
156  int destX = int(gl_FragCoord.x);
157  int destY = int(gl_FragCoord.y);
158  
159  int minX = int(u_bounds.x);
160  int minY = int(u_bounds.y);
161  int maxX = int(u_bounds.z);
162  int maxY = int(u_bounds.w);
163  int boundsWidth = maxX - minX;
164  int boundsHeight = maxY - minY;
165  
166  // Check if outside working area
167  if (destX < minX || destX >= maxX || destY < minY || destY >= maxY) {
168    fragColor = texelFetch(u_texture, ivec2(destX, destY), 0);
169    return;
170  }
171  
172  int srcX, srcY;
173  
174  // Check which operation to perform (they're mutually exclusive in practice)
175  bool hasZoom = u_zoomScale != 1.0;
176  bool hasScroll = u_scrollOffset.x != 0.0 || u_scrollOffset.y != 0.0;
177  
178  if (hasZoom && !hasScroll) {
179    // ZOOM ONLY - find source pixel by inverse transform from anchor
180    // CPU uses: srcX = (destX - anchorPixelX) * invScale + anchorPixelX
181    float anchorLocalX = u_zoomAnchor.x - float(minX);
182    float anchorLocalY = u_zoomAnchor.y - float(minY);
183    float localX = float(destX - minX);
184    float localY = float(destY - minY);
185    
186    float invScale = 1.0 / u_zoomScale;
187    float srcXf = (localX - anchorLocalX) * invScale + anchorLocalX;
188    float srcYf = (localY - anchorLocalY) * invScale + anchorLocalY;
189    
190    // Match CPU: wrap floats FIRST using normalize-then-denormalize approach
191    // CPU: normalizedX = (srcX - minX) / workingWidth
192    //      wrappedNormX = normalizedX - floor(normalizedX)
193    //      wrappedSrcX = minX + wrappedNormX * workingWidth
194    float invWidth = 1.0 / float(boundsWidth);
195    float invHeight = 1.0 / float(boundsHeight);
196    
197    float normalizedX = srcXf * invWidth;
198    float normalizedY = srcYf * invHeight;
199    float wrappedNormX = normalizedX - floor(normalizedX);
200    float wrappedNormY = normalizedY - floor(normalizedY);
201    float wrappedSrcXf = wrappedNormX * float(boundsWidth);
202    float wrappedSrcYf = wrappedNormY * float(boundsHeight);
203    
204    // CPU uses round() for nearest-neighbor, then final modulo clamp
205    int nearestX = int(round(wrappedSrcXf));
206    int nearestY = int(round(wrappedSrcYf));
207    
208    // Final modulo wrap and clamp (match CPU's finalSrcX calculation)
209    srcX = minX + ((nearestX % boundsWidth) + boundsWidth) % boundsWidth;
210    srcY = minY + ((nearestY % boundsHeight) + boundsHeight) % boundsHeight;
211    
212  } else if (hasScroll) {
213    // SCROLL ONLY - wrap around bounds
214    int localX = destX - minX;
215    int localY = destY - minY;
216    
217    int dx = int(u_scrollOffset.x);
218    int dy = int(u_scrollOffset.y);
219    
220    int srcLocalX = (localX + dx) % boundsWidth;
221    int srcLocalY = (localY + boundsHeight - dy) % boundsHeight;
222    
223    if (srcLocalX < 0) srcLocalX += boundsWidth;
224    if (srcLocalY < 0) srcLocalY += boundsHeight;
225    
226    srcX = minX + srcLocalX;
227    srcY = minY + srcLocalY;
228    
229  } else {
230    // NO TRANSFORM - just pass through
231    srcX = destX;
232    srcY = destY;
233  }
234
235  // Optional vertical flip inside the current bounds
236  if (u_flipY != 0.0) {
237    srcY = minY + (boundsHeight - 1 - (srcY - minY));
238  }
239  
240  vec4 color = texelFetch(u_texture, ivec2(srcX, srcY), 0);
241  
242  // Apply contrast
243  if (u_contrast != 1.0) {
244    color.rgb = clamp((color.rgb - 0.5) * u_contrast + 0.5, 0.0, 1.0);
245  }
246  
247  // Apply brightness
248  if (u_brightness != 0.0) {
249    color.rgb = clamp(color.rgb + u_brightness / 255.0, 0.0, 1.0);
250  }
251  
252  fragColor = color;
253}`;
254
255// =========================================================================
256// INVERT SHADER - Simple RGB inversion (255 - value) while preserving alph
256a
257// Uses gl_FragCoord and texelFetch for pixel-perfect addressing
258// =========================================================================
259const INVERT_FRAGMENT_SHADER = `#version 300 es
260precision highp float;
261
262uniform sampler2D u_texture;
263uniform vec2 u_resolution;
264uniform vec4 u_bounds;  // minX, minY, maxX, maxY
265
266in vec2 v_texCoord;
267out vec4 fragColor;
268
269void main() {
270  int destX = int(gl_FragCoord.x);
271  int destY = int(gl_FragCoord.y);
272  
273  int minX = int(u_bounds.x);
274  int minY = int(u_bounds.y);
275  int maxX = int(u_bounds.z);
276  int maxY = int(u_bounds.w);
277  
278  // Check if outside working area
279  if (destX < minX || destX >= maxX || destY < minY || destY >= maxY) {
280    fragColor = texelFetch(u_texture, ivec2(destX, destY), 0);
281    return;
282  }
283  
284  vec4 color = texelFetch(u_texture, ivec2(destX, destY), 0);
285  
286  // Skip transparent pixels
287  if (color.a == 0.0) {
288    fragColor = color;
289    return;
290  }
291  
292  // Invert RGB channels (1.0 - value), preserve alpha
293  fragColor = vec4(1.0 - color.r, 1.0 - color.g, 1.0 - color.b, color.a);
294}`;
295
296// =========================================================================
297// DUAL KAWASE BLUR — Downsample pass (ARM SIGGRAPH 2015)
298// 5-tap diagonal sampling at half-pixel offsets. Uses texture() with LINEAR
299// filtering to exploit free bilinear interpolation. No arrays, no loops.
300// Works on Mali, Adreno, PowerVR — the standard mobile blur algorithm.
301// =========================================================================
302const KAWASE_DOWN_FRAGMENT_SHADER = `#version 300 es
303precision highp float;
304
305uniform sampler2D u_texture;
306uniform vec2 u_texelSize;  // 1.0 / source resolution
307
308in vec2 v_texCoord;
309out vec4 fragColor;
310
311void main() {
312  vec2 hp = u_texelSize * 0.5;
313
314  vec4 color = texture(u_texture, v_texCoord) * 4.0;
315  color += texture(u_texture, v_texCoord + vec2(-hp.x, -hp.y));
316  color += texture(u_texture, v_texCoord + vec2( hp.x, -hp.y));
317  color += texture(u_texture, v_texCoord + vec2(-hp.x,  hp.y));
318  color += texture(u_texture, v_texCoord + vec2( hp.x,  hp.y));
319
320  fragColor = color / 8.0;
321}`;
322
323// =========================================================================
324// DUAL KAWASE BLUR — Upsample pass
325// 8-tap pattern: 4 cardinal (1x) + 4 diagonal (2x), total weight 12.
326// =========================================================================
327const KAWASE_UP_FRAGMENT_SHADER = `#version 300 es
328precision highp float;
329
330uniform sampler2D u_texture;
331uniform vec2 u_texelSize;  // 1.0 / source resolution (input mip)
332
333in vec2 v_texCoord;
334out vec4 fragColor;
335
336void main() {
337  vec2 hp = u_texelSize * 0.5;
338
339  vec4 color = vec4(0.0);
340  color += texture(u_texture, v_texCoord + vec2(-hp.x * 2.0, 0.0));
341  color += texture(u_texture, v_texCoord + vec2( hp.x * 2.0, 0.0));
342  color += texture(u_texture, v_texCoord + vec2(0.0, -hp.y * 2.0));
343  color += texture(u_texture, v_texCoord + vec2(0.0,  hp.y * 2.0));
344
345  color += texture(u_texture, v_texCoord + vec2(-hp.x,  hp.y)) * 2.0;
346  color += texture(u_texture, v_texCoord + vec2( hp.x,  hp.y)) * 2.0;
347  color += texture(u_texture, v_texCoord + vec2(-hp.x, -hp.y)) * 2.0;
348  color += texture(u_texture, v_texCoord + vec2( hp.x, -hp.y)) * 2.0;
349
350  fragColor = color / 12.0;
351}`;
352
353// =========================================================================
354// SHARPEN SHADER - Unsharp mask convolution
355// =========================================================================
356const SHARPEN_FRAGMENT_SHADER = `#version 300 es
357precision highp float;
358
359uniform sampler2D u_texture;
360uniform vec2 u_resolution;
361uniform vec4 u_bounds;
362uniform float u_strength;
363
364in vec2 v_texCoord;
365out vec4 fragColor;
366
367void main() {
368  // Use texelFetch with integer coordinates — matches working effects
369  // and avoids texture() float UV issues on Mali GPUs.
370  ivec2 tc = ivec2(v_texCoord * u_resolution);
371  ivec2 size = ivec2(u_resolution);
372
373  int minX = int(u_bounds.x);
374  int minY = int(u_bounds.y);
375  int maxX = int(u_bounds.z);
376  int maxY = int(u_bounds.w);
377
378  // Outside working area (with 1px border for kernel) — pass through
379  if (tc.x < minX + 1 || tc.x >= maxX - 1 || tc.y < minY + 1 || tc.y >= maxY - 1) {
380    fragColor = texelFetch(u_texture, tc, 0);
381    return;
382  }
383
384  // Sample center and 4 neighbors via texelFetch
385  vec4 center = texelFetch(u_texture, tc, 0);
386  vec4 top    = texelFetch(u_texture, ivec2(tc.x, tc.y - 1), 0);
387  vec4 bottom = texelFetch(u_texture, ivec2(tc.x, tc.y + 1), 0);
388  vec4 left   = texelFetch(u_texture, ivec2(tc.x - 1, tc.y), 0);
389  vec4 right  = texelFetch(u_texture, ivec2(tc.x + 1, tc.y), 0);
390
391  // Skip transparent pixels
392  if (center.a == 0.0) {
393    fragColor = center;
394    return;
395  }
396
397  // Unsharp mask: center * (1 + 4*strength) - neighbors * strength
398  float cw = 1.0 + 4.0 * u_strength;
399  float ew = -u_strength;
400
401  vec3 sharpened = center.rgb * cw +
402                   (top.rgb + bottom.rgb + left.rgb + right.rgb) * ew;
403
404  fragColor = vec4(clamp(sharpened, 0.0, 1.0), center.a);
405}`;
406
407// =========================================================================
408// SHEAR SHADER - KidPix-style row/column shifting
409// Each row shifts horizontally based on distance from vertical center,
410// each column shifts vertically based on distance from horizontal center.
411// Wraps around within the working area bounds.
412// =========================================================================
413const SHEAR_FRAGMENT_SHADER = `#version 300 es
414precision highp float;
415
416uniform sampler2D u_texture;
417uniform vec2 u_resolution;
418uniform vec4 u_bounds;  // minX, minY, maxX, maxY
419uniform float u_shearX; // horizontal shear factor
420uniform float u_shearY; // vertical shear factor
421
422in vec2 v_texCoord;
423out vec4 fragColor;
424
425void main() {
426  int destX = int(gl_FragCoord.x);
427  int destY = int(gl_FragCoord.y);
428
429  int minX = int(u_bounds.x);
430  int minY = int(u_bounds.y);
431  int maxX = int(u_bounds.z);
432  int maxY = int(u_bounds.w);
433  int boundsWidth = maxX - minX;
434  int boundsHeight = maxY - minY;
435
436  // Outside working area — pass through
437  if (destX < minX || destX >= maxX || destY < minY || destY >= maxY) {
438    fragColor = texelFetch(u_texture, ivec2(destX, destY), 0);
439    return;
440  }
441
442  int localX = destX - minX;
443  int localY = destY - minY;
444
445  // Apply horizontal shear (rows shift based on distance from vertical center)
446  int srcLocalX = localX;
447  if (u_shearX != 0.0) {
448    float distFromCenter = float(localY) - float(boundsHeight) / 2.0;
449    int rowShift = int(round(u_shearX * distFromCenter));
450    srcLocalX = localX - rowShift;
451    srcLocalX = ((srcLocalX % boundsWidth) + boundsWidth) % boundsWidth;
452  }
453
454  // Apply vertical shear (columns shift based on distance from horizontal center)
455  int srcLocalY = localY;
456  if (u_shearY != 0.0) {
457    float distFromCenter = float(localX) - float(boundsWidth) / 2.0;
458    int colShift = int(round(u_shearY * distFromCenter));
459    srcLocalY = localY - colShift;
460    srcLocalY = ((srcLocalY % boundsHeight) + boundsHeight) % boundsHeight;
461  }
462
463  int srcX = minX + srcLocalX;
464  int srcY = minY + srcLocalY;
465
466  fragColor = texelFetch(u_texture, ivec2(srcX, srcY), 0);
467}`;
468
469// =========================================================================
470// SUCK SHADER - Radial inward/outward pixel displacement
471// Each pixel samples along its radial ray from center, shifted by displacement.
472// Suck (direction=1): pulls from further out (content moves inward)
473// Blow (direction=-1): pulls from closer in (content moves outward)
474// =========================================================================
475const SUCK_FRAGMENT_SHADER = `#version 300 es
476precision highp float;
477
478uniform sampler2D u_texture;
479uniform vec2 u_resolution;
480uniform vec4 u_bounds;        // minX, minY, maxX, maxY
481uniform float u_displacement; // absolute displacement amount
482uniform float u_direction;    // 1.0 = suck inward, -1.0 = blow outward
483uniform vec2 u_center;        // center point (in pixel coords)
484
485in vec2 v_texCoord;
486out vec4 fragColor;
487
488void main() {
489  int destX = int(gl_FragCoord.x);
490  int destY = int(gl_FragCoord.y);
491
492  int minX = int(u_bounds.x);
493  int minY = int(u_bounds.y);
494  int maxX = int(u_bounds.z);
495  int maxY = int(u_bounds.w);
496
497  // Outside working area — pass through
498  if (destX < minX || destX >= maxX || destY < minY || destY >= maxY) {
499    fragColor = texelFetch(u_texture, ivec2(destX, destY), 0);
500    return;
501  }
502
503  float dx = float(destX) - u_center.x;
504  float dy = float(destY) - u_center.y;
505  float distance = sqrt(dx * dx + dy * dy);
506
507  // Very center pixels stay put
508  if (distance < 1.0) {
509    fragColor = texelFetch(u_texture, ivec2(destX, destY), 0);
510    return;
511  }
512
513  // Source distance along same radial ray
514  float srcDistance = distance + u_direction * u_displacement;
515
516  // Scale factor to move along the radial ray (no atan2, no spiral)
517  float scale = srcDistance / distance;
518  float srcX = u_center.x + dx * scale;
519  float srcY = u_center.y + dy * scale;
520
521  // Clamp to bounds
522  float fMinX = float(minX);
523  float fMinY = float(minY);
524  float fMaxX = float(maxX) - 1.0;
525  float fMaxY = float(maxY) - 1.0;
526  srcX = clamp(srcX, fMinX, fMaxX);
527  srcY = clamp(srcY, fMinY, fMaxY);
528
529  // Bilinear interpolation
530  int x0 = int(floor(srcX));
531  int y0 = int(floor(srcY));
532  int x1 = min(x0 + 1, int(fMaxX));
533  int y1 = min(y0 + 1, int(fMaxY));
534  float wx = srcX - float(x0);
535  float wy = srcY - float(y0);
536
537  vec4 tl = texelFetch(u_texture, ivec2(x0, y0), 0);
538  vec4 tr = texelFetch(u_texture, ivec2(x1, y0), 0);
539  vec4 bl = texelFetch(u_texture, ivec2(x0, y1), 0);
540  vec4 br = texelFetch(u_texture, ivec2(x1, y1), 0);
541
542  vec4 top = mix(tl, tr, wx);
543  vec4 bottom = mix(bl, br, wx);
544  fragColor = mix(top, bottom, wy);
545}`;
546
547// =========================================================================
548// FLOOD FILL SHADERS - Jump Flooding Algorithm (JFA)
549// Three-pass algorithm:
550// 1. Seed pass: Initialize distance field from seed point
551// 2. JFA passes: Propagate nearest seed using halving step sizes (O(log n))
552// 3. Fill pass: Apply fill color where distance < infinity and color matches
553// =========================================================================
554
555// Pass 1: Initialize seed - mark seed point with distance 0, others with infinity
556const FLOOD_SEED_FRAGMENT_SHADER = `#version 300 es
557precision highp float;
558
559uniform sampler2D u_texture;     // Original image
560uniform vec2 u_resolution;
561uniform vec2 u_seedPoint;        // Seed coordinate (x, y)
562uniform vec4 u_targetColor;      // Color to match (RGBA normalized)
563uniform float u_colorTolerance;  // Color matching tolerance
564
565in vec2 v_texCoord;
566out vec4 fragColor;
567
568// Output encoding:
569// RGB = nearest seed position (normalized 0-1)
570// A = 1.0 if reachable (same color as target), 0.0 if not
571
572bool colorsMatch(vec4 c1, vec4 c2, float tolerance) {
573  return abs(c1.r - c2.r) <= tolerance &&
574         abs(c1.g - c2.g) <= tolerance &&
575         abs(c1.b - c2.b) <= tolerance &&
576         abs(c1.a - c2.a) <= tolerance;
577}
578
579void main() {
580  ivec2 coord = ivec2(gl_FragCoord.xy);
581  vec4 sourceColor = texelFetch(u_texture, coord, 0);
582  
583  // Check if this pixel matches the target color
584  bool matchesTarget = colorsMatch(sourceColor, u_targetColor, u_colorTolerance);
585  
586  // Check if this is the seed point
587  bool isSeed = (coord.x == int(u_seedPoint.x) && coord.y == int(u_seedPoint.y));
588  
589  if (isSeed && matchesTarget) {
590    // Seed point: store own position, mark as reachable
591    fragColor = vec4(gl_FragCoord.xy / u_resolution, 0.0, 1.0);
592  } else if (matchesTarget) {
593    // Same color region: unknown seed, mark as reachable but infinite distance
594    // Use -1,-1 to indicate "no seed found yet"
595    fragColor = vec4(-1.0, -1.0, 1.0, 1.0);  // High distance placeholder
596  } else {
597    // Different color: boundary, not reachable
598    fragColor = vec4(0.0, 0.0, 0.0, 0.0);
599  }
600}`;
601
602// Pass 2: JFA propagation - find nearest seed using jump flooding
603const FLOOD_JFA_FRAGMENT_SHADER = `#version 300 es
604precision highp float;
605
606uniform sampler2D u_seedMap;     // Current seed map from previous pass
607uniform vec2 u_resolution;
608uniform int u_stepSize;          // Current jump distance (halves each pass)
609
610in vec2 v_texCoord;
611out vec4 fragColor;
612
613void main() {
614  ivec2 coord = ivec2(gl_FragCoord.xy);
615  vec4 current = texelFetch(u_seedMap, coord, 0);
616  
617  // Not reachable (different color) - pass through
618  if (current.a == 0.0) {
619    fragColor = current;
620    return;
621  }
622  
623  vec2 bestSeed = current.xy;
624  float bestDist = 999999.0;
625  
626  // If we have a valid seed position, calculate its distance
627  if (current.x >= 0.0) {
628    vec2 seedPos = current.xy * u_resolution;
629    bestDist = distance(vec2(coord), seedPos);
630  }
631  
632  // Check 8 neighbors at current step size (+ self)
633  for (int dy = -1; dy <= 1; dy++) {
634    for (int dx = -1; dx <= 1; dx++) {
635      ivec2 neighbor = coord + ivec2(dx, dy) * u_stepSize;
636      
637      // Bounds check
638      if (neighbor.x < 0 || neighbor.y < 0 || 
639          neighbor.x >= int(u_resolution.x) || neighbor.y >= int(u_resolution.y)) {
640        continue;
641      }
642      
643      vec4 neighborData = texelFetch(u_seedMap, neighbor, 0);
644      
645      // Skip if neighbor is not reachable or has no seed yet
646      if (neighborData.a == 0.0 || neighborData.x < 0.0) continue;
647      
648      // Calculate distance from this pixel to neighbor's seed
649      vec2 neighborSeed = neighborData.xy * u_resolution;
650      float dist = distance(vec2(coord), neighborSeed);
651      
652      if (dist < bestDist) {
653        bestDist = dist;
654        bestSeed = neighborData.xy;
655      }
656    }
657  }
658  
659  fragColor = vec4(bestSeed, 0.0, current.a);
660}`;
661
662// Pass 3: Apply fill color to all reachable pixels
663const FLOOD_FILL_FRAGMENT_SHADER = `#version 300 es
664precision highp float;
665
666uniform sampler2D u_texture;     // Original image
667uniform sampler2D u_seedMap;     // Final seed map from JFA
668uniform vec2 u_resolution;
669uniform vec4 u_fillColor;        // Color to fill with (RGBA normalized)
670
671in vec2 v_texCoord;
672out vec4 fragColor;
673
674void main() {
675  ivec2 coord = ivec2(gl_FragCoord.xy);
676  vec4 original = texelFetch(u_texture, coord, 0);
677  vec4 seedData = texelFetch(u_seedMap, coord, 0);
678  
679  // If reachable (alpha = 1) and has valid seed, fill with new color
680  if (seedData.a > 0.5 && seedData.x >= 0.0) {
681    fragColor = u_fillColor;
682  } else {
683    // Keep original color
684    fragColor = original;
685  }
686}`;
687
688// =========================================================================
689// LAYER COMPOSITE SHADER - GPU-accelerated alpha blending for multiple layers
690// Composites up to 8 layers in a single draw call
691// =========================================================================
692const LAYER_COMPOSITE_FRAGMENT_SHADER = `#version 300 es
693precision highp float;
694
695uniform sampler2D u_background;  // Background/destination buffer
696uniform sampler2D u_layer0;      // Layer textures (up to 8)
697uniform sampler2D u_layer1;
698uniform sampler2D u_layer2;
699uniform sampler2D u_layer3;
700uniform sampler2D u_layer4;
701uniform sampler2D u_layer5;
702uniform sampler2D u_layer6;
703uniform sampler2D u_layer7;
704
705// Layer configuration arrays
706uniform vec4 u_layerBounds[8];   // x, y, width, height for each layer
707uniform float u_layerAlpha[8];   // Alpha multiplier (0-255) for each layer
708uniform int u_layerCount;        // Number of active layers (1-8)
709uniform vec2 u_resolution;       // Output resolution
710
711in vec2 v_texCoord;
712out vec4 fragColor;
713
714// Alpha blend source over destination
715vec4 blend(vec4 dst, vec4 src, float alphaMultiplier) {
716  // Apply alpha multiplier to source alpha
717  float srcAlpha = src.a * (alphaMultiplier / 255.0);
718  
719  // Skip fully transparent
720  if (srcAlpha < 0.004) return dst;  // ~1/255
721  
722  // Standard alpha blending: result = src * srcA + dst * (1 - srcA)
723  vec3 blended = src.rgb * srcAlpha + dst.rgb * (1.0 - srcAlpha);
724  float outAlpha = srcAlpha + dst.a * (1.0 - srcAlpha);
725  
726  return vec4(blended, outAlpha);
727}
728
729// Sample from layer texture based on layer index
730vec4 sampleLayer(int idx, ivec2 layerCoord) {
731  if (idx == 0) return texelFetch(u_layer0, layerCoord, 0);
732  if (idx == 1) return texelFetch(u_layer1, layerCoord, 0);
733  if (idx == 2) return texelFetch(u_layer2, layerCoord, 0);
734  if (idx == 3) return texelFetch(u_layer3, layerCoord, 0);
735  if (idx == 4) return texelFetch(u_layer4, layerCoord, 0);
736  if (idx == 5) return texelFetch(u_layer5, layerCoord, 0);
737  if (idx == 6) return texelFetch(u_layer6, layerCoord, 0);
738  if (idx == 7) return texelFetch(u_layer7, layerCoord, 0);
739  return vec4(0.0);
740}
741
742void main() {
743  ivec2 coord = ivec2(gl_FragCoord.xy);
744  
745  // Start with background color
746  vec4 color = texelFetch(u_background, coord, 0);
747  
748  // Composite each layer in order (painter's algorithm)
749  for (int i = 0; i < 8; i++) {
750    if (i >= u_layerCount) break;
751    
752    vec4 bounds = u_layerBounds[i];
753    float lx = bounds.x;
754    float ly = bounds.y;
755    float lw = bounds.z;
756    float lh = bounds.w;
757    
758    // Check if this pixel is within the layer's bounds
759    float px = float(coord.x);
760    float py = float(coord.y);
761    
762    if (px >= lx && px < lx + lw && py >= ly && py < ly + lh) {
763      // Calculate layer-local coordinates
764      ivec2 layerCoord = ivec2(px - lx, py - ly);
765      
766      // Sample layer and blend
767      vec4 layerColor = sampleLayer(i, layerCoord);
768      color = blend(color, layerColor, u_layerAlpha[i]);
769    }
770  }
771  
772  fragColor = color;
773}`;
774
775// (blur now uses Kawase downsample/upsample — see kawaseDownProgram/kawaseUpProgram)
776let sharpenProgram = null;
777let pingPongTexture = null;  // For multi-pass effects
778let pingPongFramebuffer = null;
779
780// Additional textures for flood fill JFA
781let floodTexture1 = null;  // JFA ping texture
782let floodTexture2 = null;  // JFA pong texture
783let floodFramebuffer1 = null;
784let floodFramebuffer2 = null;
785
786// Layer composite textures (up to 8 layers + background)
787let layerTextures = null;  // Array of textures [layer0, layer1, ..., layer7]
788const MAX_LAYERS = 8;
789
790// Helper to resize textures without recreating the entire context
791function resizeTextures(width, height) {
792  // Resize input texture
793  gl.bindTexture(gl.TEXTURE_2D, texture);
794  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
795  
796  // Resize output texture
797  gl.bindTexture(gl.TEXTURE_2D, outputTexture);
798  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
799  
800  // Resize ping-pong texture
801  gl.bindTexture(gl.TEXTURE_2D, pingPongTexture);
802  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
803  
804  // Resize flood fill JFA textures (RGBA32F for position data)
805  if (floodTexture1) {
806    gl.bindTexture(gl.TEXTURE_2D, floodTexture1);
807    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null);
808  }
809  if (floodTexture2) {
810    gl.bindTexture(gl.TEXTURE_2D, floodTexture2);
811    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null);
812  }
813  
814  // Resize Kawase mip chain
815  let mipW = width, mipH = height;
816  for (let i = 0; i < kawaseMips.length; i++) {
817    mipW = Math.max(1, mipW >> 1);
818    mipH = Math.max(1, mipH >> 1);
819    gl.bindTexture(gl.TEXTURE_2D, kawaseMips[i].texture);
820    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, mipW, mipH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
821    kawaseMips[i].width = mipW;
822    kawaseMips[i].height = mipH;
823  }
824
825  // Resize canvas
826  canvas.width = width;
827  canvas.height = height;
828
829  // Reallocate readback buffer
830  readbackBuffer = new Uint8Array(width * height * 4);
831
832  lastWidth = width;
833  lastHeight = height;
834}
835
836function initWebGL2(width, height) {
837  // If already initialized, just resize textures if needed
838  if (initialized && gl) {
839    if (width !== lastWidth || height !== lastHeight) {
840      resizeTextures(width, height);
841    }
842    return true;
843  }
844  
845  try {
846    // Create OffscreenCanvas (works in workers!)
847    canvas = new OffscreenCanvas(width, height);
848    gl = canvas.getContext('webgl2', {
849      antialias: false,
850      depth: false,
851      stencil: false,
852      preserveDrawingBuffer: true,
853      powerPreference: 'high-performance'
854    });
855    
856    if (!gl) {
857      console.warn('🎮 GPU Effects: WebGL2 not available');
858      return false;
859    }
860
861    // Detect fragment shader precision — some Android GPUs claim WebGL2
862    // but silently fail with highp. Fall back to mediump if needed.
863    try {
864      const hpf = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
865      fragPrecision = (!hpf || hpf.precision === 0) ? "mediump" : "highp";
866    } catch {
867      fragPrecision = "mediump";
868    }
869    console.log(`🎮 GPU Effects: fragment precision = ${fragPrecision}`);
870
871    // Detect GPU renderer — Mali and Adreno GPUs need CPU fallback for
872    // blur/sharpen/contrast due to multi-pass rendering artifacts.
873    try {
874      const dbgInfo = gl.getExtension('WEBGL_debug_renderer_info');
875      if (dbgInfo) {
876        const renderer = gl.getParameter(dbgInfo.UNMASKED_RENDERER_WEBGL);
877        console.log('🎮 GPU Effects: renderer = ' + renderer);
878        if (/mali|adreno/i.test(renderer)) {
879          mobileSafeMode = true;
880          console.log('🎮 GPU Effects: Mali/Adreno detected — blur/sharpen/contrast will use CPU fallback');
881        }
882      }
883    } catch {
884      // Extension unavailable — assume desktop GPU, no workaround needed.
885    }
886
887    // Compile spin program
888    const spinVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
889    const spinFrag = compileShader(gl, gl.FRAGMENT_SHADER, SPIN_FRAGMENT_SHADER);
890    if (!spinVert || !spinFrag) return false;
891    
892    spinProgram = gl.createProgram();
893    gl.attachShader(spinProgram, spinVert);
894    gl.attachShader(spinProgram, spinFrag);
895    gl.linkProgram(spinProgram);
896    if (!gl.getProgramParameter(spinProgram, gl.LINK_STATUS)) {
897      console.error('🎮 GPU Effects: Spin program link failed:', gl.getProgramInfoLog(spinProgram));
898      return false;
899    }
900    
901    // Compile composite program
902    const compVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
903    const compFrag = compileShader(gl, gl.FRAGMENT_SHADER, COMPOSITE_FRAGMENT_SHADER);
904    if (!compVert || !compFrag) return false;
905    
906    compositeProgram = gl.createProgram();
907    gl.attachShader(compositeProgram, compVert);
908    gl.attachShader(compositeProgram, compFrag);
909    gl.linkProgram(compositeProgram);
910    if (!gl.getProgramParameter(compositeProgram, gl.LINK_STATUS)) {
911      console.error('🎮 GPU Effects: Composite program link failed:', gl.getProgramInfoLog(compositeProgram));
912      return false;
913    }
914    
915    // Compile invert program
916    const invertVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
917    const invertFrag = compileShader(gl, gl.FRAGMENT_SHADER, INVERT_FRAGMENT_SHADER);
918    if (!invertVert || !invertFrag) return false;
919    
920    invertProgram = gl.createProgram();
921    gl.attachShader(invertProgram, invertVert);
922    gl.attachShader(invertProgram, invertFrag);
923    gl.linkProgram(invertProgram);
924    if (!gl.getProgramParameter(invertProgram, gl.LINK_STATUS)) {
925      console.error('🎮 GPU Effects: Invert program link failed:', gl.getProgramInfoLog(invertProgram));
926      return false;
927    }
928    
929    // Compile Kawase downsample program
930    const kawaseDownVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
931    const kawaseDownFrag = compileShader(gl, gl.FRAGMENT_SHADER, KAWASE_DOWN_FRAGMENT_SHADER);
932    if (!kawaseDownVert || !kawaseDownFrag) return false;
933
934    kawaseDownProgram = gl.createProgram();
935    gl.attachShader(kawaseDownProgram, kawaseDownVert);
936    gl.attachShader(kawaseDownProgram, kawaseDownFrag);
937    gl.linkProgram(kawaseDownProgram);
938    if (!gl.getProgramParameter(kawaseDownProgram, gl.LINK_STATUS)) {
939      console.error('🎮 GPU Effects: Kawase Down program link failed:', gl.getProgramInfoLog(kawaseDownProgram));
940      return false;
941    }
942
943    // Compile Kawase upsample program
944    const kawaseUpVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
945    const kawaseUpFrag = compileShader(gl, gl.FRAGMENT_SHADER, KAWASE_UP_FRAGMENT_SHADER);
946    if (!kawaseUpVert || !kawaseUpFrag) return false;
947
948    kawaseUpProgram = gl.createProgram();
949    gl.attachShader(kawaseUpProgram, kawaseUpVert);
950    gl.attachShader(kawaseUpProgram, kawaseUpFrag);
951    gl.linkProgram(kawaseUpProgram);
952    if (!gl.getProgramParameter(kawaseUpProgram, gl.LINK_STATUS)) {
953      console.error('🎮 GPU Effects: Kawase Up program link failed:', gl.getProgramInfoLog(kawaseUpProgram));
954      return false;
955    }
956    
957    // Compile sharpen program
958    const sharpenVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
959    const sharpenFrag = compileShader(gl, gl.FRAGMENT_SHADER, SHARPEN_FRAGMENT_SHADER);
960    if (!sharpenVert || !sharpenFrag) return false;
961    
962    sharpenProgram = gl.createProgram();
963    gl.attachShader(sharpenProgram, sharpenVert);
964    gl.attachShader(sharpenProgram, sharpenFrag);
965    gl.linkProgram(sharpenProgram);
966    if (!gl.getProgramParameter(sharpenProgram, gl.LINK_STATUS)) {
967      console.error('🎮 GPU Effects: Sharpen program link failed:', gl.getProgramInfoLog(sharpenProgram));
968      return false;
969    }
970
971    // Compile shear program
972    const shearVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
973    const shearFrag = compileShader(gl, gl.FRAGMENT_SHADER, SHEAR_FRAGMENT_SHADER);
974    if (!shearVert || !shearFrag) return false;
975
976    shearProgram = gl.createProgram();
977    gl.attachShader(shearProgram, shearVert);
978    gl.attachShader(shearProgram, shearFrag);
979    gl.linkProgram(shearProgram);
980    if (!gl.getProgramParameter(shearProgram, gl.LINK_STATUS)) {
981      console.error('🎮 GPU Effects: Shear program link failed:', gl.getProgramInfoLog(shearProgram));
982      return false;
983    }
984
985    // Compile suck program
986    const suckVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
987    const suckFrag = compileShader(gl, gl.FRAGMENT_SHADER, SUCK_FRAGMENT_SHADER);
988    if (!suckVert || !suckFrag) return false;
989
990    suckProgram = gl.createProgram();
991    gl.attachShader(suckProgram, suckVert);
992    gl.attachShader(suckProgram, suckFrag);
993    gl.linkProgram(suckProgram);
994    if (!gl.getProgramParameter(suckProgram, gl.LINK_STATUS)) {
995      console.error('🎮 GPU Effects: Suck program link failed:', gl.getProgramInfoLog(suckProgram));
996      return false;
997    }
998
999    // Cache all uniform locations to avoid getUniformLocation calls every frame
1000    spinUniforms = {
1001      u_resolution: gl.getUniformLocation(spinProgram, 'u_resolution'),
1002      u_center: gl.getUniformLocation(spinProgram, 'u_center'),
1003      u_steps: gl.getUniformLocation(spinProgram, 'u_steps'),
1004      u_bounds: gl.getUniformLocation(spinProgram, 'u_bounds'),
1005      u_texture: gl.getUniformLocation(spinProgram, 'u_texture'),
1006    };
1007    
1008    compositeUniforms = {
1009      u_resolution: gl.getUniformLocation(compositeProgram, 'u_resolution'),
1010      u_bounds: gl.getUniformLocation(compositeProgram, 'u_bounds'),
1011      u_zoomScale: gl.getUniformLocation(compositeProgram, 'u_zoomScale'),
1012      u_zoomAnchor: gl.getUniformLocation(compositeProgram, 'u_zoomAnchor'),
1013      u_scrollOffset: gl.getUniformLocation(compositeProgram, 'u_scrollOffset'),
1014      u_flipY: gl.getUniformLocation(compositeProgram, 'u_flipY'),
1015      u_contrast: gl.getUniformLocation(compositeProgram, 'u_contrast'),
1016      u_brightness: gl.getUniformLocation(compositeProgram, 'u_brightness'),
1017      u_texture: gl.getUniformLocation(compositeProgram, 'u_texture'),
1018    };
1019    
1020    invertUniforms = {
1021      u_resolution: gl.getUniformLocation(invertProgram, 'u_resolution'),
1022      u_bounds: gl.getUniformLocation(invertProgram, 'u_bounds'),
1023      u_texture: gl.getUniformLocation(invertProgram, 'u_texture'),
1024    };
1025    
1026    kawaseDownUniforms = {
1027      u_texelSize: gl.getUniformLocation(kawaseDownProgram, 'u_texelSize'),
1028      u_texture: gl.getUniformLocation(kawaseDownProgram, 'u_texture'),
1029    };
1030
1031    kawaseUpUniforms = {
1032      u_texelSize: gl.getUniformLocation(kawaseUpProgram, 'u_texelSize'),
1033      u_texture: gl.getUniformLocation(kawaseUpProgram, 'u_texture'),
1034    };
1035    
1036    sharpenUniforms = {
1037      u_resolution: gl.getUniformLocation(sharpenProgram, 'u_resolution'),
1038      u_bounds: gl.getUniformLocation(sharpenProgram, 'u_bounds'),
1039      u_strength: gl.getUniformLocation(sharpenProgram, 'u_strength'),
1040      u_texture: gl.getUniformLocation(sharpenProgram, 'u_texture'),
1041    };
1042
1043    shearUniforms = {
1044      u_resolution: gl.getUniformLocation(shearProgram, 'u_resolution'),
1045      u_bounds: gl.getUniformLocation(shearProgram, 'u_bounds'),
1046      u_shearX: gl.getUniformLocation(shearProgram, 'u_shearX'),
1047      u_shearY: gl.getUniformLocation(shearProgram, 'u_shearY'),
1048      u_texture: gl.getUniformLocation(shearProgram, 'u_texture'),
1049    };
1050
1051    suckUniforms = {
1052      u_resolution: gl.getUniformLocation(suckProgram, 'u_resolution'),
1053      u_bounds: gl.getUniformLocation(suckProgram, 'u_bounds'),
1054      u_displacement: gl.getUniformLocation(suckProgram, 'u_displacement'),
1055      u_direction: gl.getUniformLocation(suckProgram, 'u_direction'),
1056      u_center: gl.getUniformLocation(suckProgram, 'u_center'),
1057      u_texture: gl.getUniformLocation(suckProgram, 'u_texture'),
1058    };
1059
1060    // Compile flood fill programs (3 passes: seed, JFA, fill)
1061    const floodSeedVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
1062    const floodSeedFrag = compileShader(gl, gl.FRAGMENT_SHADER, FLOOD_SEED_FRAGMENT_SHADER);
1063    if (floodSeedVert && floodSeedFrag) {
1064      floodSeedProgram = gl.createProgram();
1065      gl.attachShader(floodSeedProgram, floodSeedVert);
1066      gl.attachShader(floodSeedProgram, floodSeedFrag);
1067      gl.linkProgram(floodSeedProgram);
1068      if (!gl.getProgramParameter(floodSeedProgram, gl.LINK_STATUS)) {
1069        console.warn('🎮 GPU Effects: Flood seed program link failed:', gl.getProgramInfoLog(floodSeedProgram));
1070        floodSeedProgram = null;
1071      }
1072    }
1073    
1074    const floodJFAVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
1075    const floodJFAFrag = compileShader(gl, gl.FRAGMENT_SHADER, FLOOD_JFA_FRAGMENT_SHADER);
1076    if (floodJFAVert && floodJFAFrag) {
1077      floodJFAProgram = gl.createProgram();
1078      gl.attachShader(floodJFAProgram, floodJFAVert);
1079      gl.attachShader(floodJFAProgram, floodJFAFrag);
1080      gl.linkProgram(floodJFAProgram);
1081      if (!gl.getProgramParameter(floodJFAProgram, gl.LINK_STATUS)) {
1082        console.warn('🎮 GPU Effects: Flood JFA program link failed:', gl.getProgramInfoLog(floodJFAProgram));
1083        floodJFAProgram = null;
1084      }
1085    }
1086    
1087    const floodFillVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
1088    const floodFillFrag = compileShader(gl, gl.FRAGMENT_SHADER, FLOOD_FILL_FRAGMENT_SHADER);
1089    if (floodFillVert && floodFillFrag) {
1090      floodFillProgram = gl.createProgram();
1091      gl.attachShader(floodFillProgram, floodFillVert);
1092      gl.attachShader(floodFillProgram, floodFillFrag);
1093      gl.linkProgram(floodFillProgram);
1094      if (!gl.getProgramParameter(floodFillProgram, gl.LINK_STATUS)) {
1095        console.warn('🎮 GPU Effects: Flood fill program link failed:', gl.getProgramInfoLog(floodFillProgram));
1096        floodFillProgram = null;
1097      }
1098    }
1099    
1100    // Cache flood fill uniform locations if programs compiled
1101    if (floodSeedProgram) {
1102      floodSeedUniforms = {
1103        u_texture: gl.getUniformLocation(floodSeedProgram, 'u_texture'),
1104        u_resolution: gl.getUniformLocation(floodSeedProgram, 'u_resolution'),
1105        u_seedPoint: gl.getUniformLocation(floodSeedProgram, 'u_seedPoint'),
1106        u_targetColor: gl.getUniformLocation(floodSeedProgram, 'u_targetColor'),
1107        u_colorTolerance: gl.getUniformLocation(floodSeedProgram, 'u_colorTolerance'),
1108      };
1109    }
1110    if (floodJFAProgram) {
1111      floodJFAUniforms = {
1112        u_seedMap: gl.getUniformLocation(floodJFAProgram, 'u_seedMap'),
1113        u_resolution: gl.getUniformLocation(floodJFAProgram, 'u_resolution'),
1114        u_stepSize: gl.getUniformLocation(floodJFAProgram, 'u_stepSize'),
1115      };
1116    }
1117    if (floodFillProgram) {
1118      floodFillUniforms = {
1119        u_texture: gl.getUniformLocation(floodFillProgram, 'u_texture'),
1120        u_seedMap: gl.getUniformLocation(floodFillProgram, 'u_seedMap'),
1121        u_resolution: gl.getUniformLocation(floodFillProgram, 'u_resolution'),
1122        u_fillColor: gl.getUniformLocation(floodFillProgram, 'u_fillColor'),
1123      };
1124    }
1125    
1126    // Compile layer composite shader
1127    const layerCompositeVert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
1128    const layerCompositeFrag = compileShader(gl, gl.FRAGMENT_SHADER, LAYER_COMPOSITE_FRAGMENT_SHADER);
1129    if (layerCompositeVert && layerCompositeFrag) {
1130      layerCompositeProgram = gl.createProgram();
1131      gl.attachShader(layerCompositeProgram, layerCompositeVert);
1132      gl.attachShader(layerCompositeProgram, layerCompositeFrag);
1133      gl.linkProgram(layerCompositeProgram);
1134      if (!gl.getProgramParameter(layerCompositeProgram, gl.LINK_STATUS)) {
1135        console.warn('🎮 GPU Effects: Layer composite program link failed:', gl.getProgramInfoLog(layerCompositeProgram));
1136        layerCompositeProgram = null;
1137      }
1138    }
1139    
1140    // Cache layer composite uniform locations
1141    if (layerCompositeProgram) {
1142      layerCompositeUniforms = {
1143        u_background: gl.getUniformLocation(layerCompositeProgram, 'u_background'),
1144        u_layer0: gl.getUniformLocation(layerCompositeProgram, 'u_layer0'),
1145        u_layer1: gl.getUniformLocation(layerCompositeProgram, 'u_layer1'),
1146        u_layer2: gl.getUniformLocation(layerCompositeProgram, 'u_layer2'),
1147        u_layer3: gl.getUniformLocation(layerCompositeProgram, 'u_layer3'),
1148        u_layer4: gl.getUniformLocation(layerCompositeProgram, 'u_layer4'),
1149        u_layer5: gl.getUniformLocation(layerCompositeProgram, 'u_layer5'),
1150        u_layer6: gl.getUniformLocation(layerCompositeProgram, 'u_layer6'),
1151        u_layer7: gl.getUniformLocation(layerCompositeProgram, 'u_layer7'),
1152        u_layerBounds: gl.getUniformLocation(layerCompositeProgram, 'u_layerBounds'),
1153        u_layerAlpha: gl.getUniformLocation(layerCompositeProgram, 'u_layerAlpha'),
1154        u_layerCount: gl.getUniformLocation(layerCompositeProgram, 'u_layerCount'),
1155        u_resolution: gl.getUniformLocation(layerCompositeProgram, 'u_resolution'),
1156      };
1157      
1158      // Create layer textures array
1159      layerTextures = [];
1160      for (let i = 0; i < MAX_LAYERS; i++) {
1161        const tex = gl.createTexture();
1162        gl.bindTexture(gl.TEXTURE_2D, tex);
1163        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1164        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1165        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1166        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1167        layerTextures.push(tex);
1168      }
1169      // console.log('🎮 GPU Layer Composite: Available (up to 8 layers)');
1170    }
1171    
1172    // Create VAO for efficient attribute setup
1173    vao = gl.createVertexArray();
1174    gl.bindVertexArray(vao);
1175    
1176    // Create geometry (full-screen quad)
1177    positionBuffer = gl.createBuffer();
1178    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
1179    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
1180      -1, -1,  1, -1,  -1, 1,
1181      -1,  1,  1, -1,   1, 1
1182    ]), gl.STATIC_DRAW);
1183    
1184    // Set up position attribute in VAO
1185    const posLoc = gl.getAttribLocation(spinProgram, 'a_position');
1186    gl.enableVertexAttribArray(posLoc);
1187    gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
1188    
1189    texCoordBuffer = gl.createBuffer();
1190    gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
1191    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
1192      0, 0,  1, 0,  0, 1,
1193      0, 1,  1, 0,  1, 1
1194    ]), gl.STATIC_DRAW);
1195    
1196    // Set up texCoord attribute in VAO
1197    const texLoc = gl.getAttribLocation(spinProgram, 'a_texCoord');
1198    gl.enableVertexAttribArray(texLoc);
1199    gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
1200    
1201    gl.bindVertexArray(null);
1202    
1203    // Create input texture (NEAREST for pixel-perfect sampling)
1204    texture = gl.createTexture();
1205    gl.bindTexture(gl.TEXTURE_2D, texture);
1206    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1207    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1208    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1209    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1210    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1211    
1212    // Create framebuffer for output
1213    framebuffer = gl.createFramebuffer();
1214    outputTexture = gl.createTexture();
1215    gl.bindTexture(gl.TEXTURE_2D, outputTexture);
1216    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1217    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1218    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1219    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1220    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1221    
1222    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1223    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, outputTexture, 0);
1224
1225    // Check framebuffer completeness — some Android GPUs silently fail here
1226    const fbStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
1227    if (fbStatus !== gl.FRAMEBUFFER_COMPLETE) {
1228      console.error(`🎮 GPU Effects: Output framebuffer incomplete (0x${fbStatus.toString(16)})`);
1229      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1230      return false;
1231    }
1232    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1233
1234    // Create ping-pong texture and framebuffer for multi-pass effects (blur)
1235    pingPongTexture = gl.createTexture();
1236    gl.bindTexture(gl.TEXTURE_2D, pingPongTexture);
1237    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1238    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1239    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1240    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1241    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1242
1243    pingPongFramebuffer = gl.createFramebuffer();
1244    gl.bindFramebuffer(gl.FRAMEBUFFER, pingPongFramebuffer);
1245    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, pingPongTexture, 0);
1246
1247    const ppStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
1248    if (ppStatus !== gl.FRAMEBUFFER_COMPLETE) {
1249      console.error(`🎮 GPU Effects: PingPong framebuffer incomplete (0x${ppStatus.toString(16)})`);
1250      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1251      return false;
1252    }
1253    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1254    
1255    // Create flood fill JFA textures and framebuffers (RGBA32F for position data)
1256    // Only create if flood programs compiled successfully
1257    if (floodSeedProgram && floodJFAProgram && floodFillProgram) {
1258      // Check for float texture support
1259      const floatExt = gl.getExtension('EXT_color_buffer_float');
1260      if (floatExt) {
1261        floodTexture1 = gl.createTexture();
1262        gl.bindTexture(gl.TEXTURE_2D, floodTexture1);
1263        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1264        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1265        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1266        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1267        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null);
1268        
1269        floodFramebuffer1 = gl.createFramebuffer();
1270        gl.bindFramebuffer(gl.FRAMEBUFFER, floodFramebuffer1);
1271        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, floodTexture1, 0);
1272        
1273        floodTexture2 = gl.createTexture();
1274        gl.bindTexture(gl.TEXTURE_2D, floodTexture2);
1275        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1276        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1277        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1278        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1279        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null);
1280        
1281        floodFramebuffer2 = gl.createFramebuffer();
1282        gl.bindFramebuffer(gl.FRAMEBUFFER, floodFramebuffer2);
1283        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, floodTexture2, 0);
1284        
1285        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1286        // console.log('🎮 GPU Effects: Flood fill JFA initialized');
1287      } else {
1288        console.warn('🎮 GPU Effects: Float textures not supported, flood fill GPU disabled');
1289        floodSeedProgram = null;
1290        floodJFAProgram = null;
1291        floodFillProgram = null;
1292      }
1293    }
1294    
1295    // Create Kawase blur mip chain (up to 4 levels: 1/2, 1/4, 1/8, 1/16)
1296    // Each level has its own texture (LINEAR filtering) and framebuffer.
1297    kawaseMips = [];
1298    let mipW = width, mipH = height;
1299    for (let i = 0; i < 4; i++) {
1300      mipW = Math.max(1, mipW >> 1);
1301      mipH = Math.max(1, mipH >> 1);
1302      const tex = gl.createTexture();
1303      gl.bindTexture(gl.TEXTURE_2D, tex);
1304      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1305      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1306      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1307      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1308      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, mipW, mipH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1309      const fbo = gl.createFramebuffer();
1310      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1311      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
1312      kawaseMips.push({ texture: tex, framebuffer: fbo, width: mipW, height: mipH });
1313    }
1314    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1315
1316    // Pre-allocate readback buffer
1317    readbackBuffer = new Uint8Array(width * height * 4);
1318
1319    lastWidth = width;
1320    lastHeight = height;
1321    initialized = true;
1322    
1323    // console.log(`🎮 GPU Effects: Initialized ${width}x${height}`);
1324    return true;
1325  } catch (e) {
1326    console.error('🎮 GPU Effects: Init failed:', e);
1327    return false;
1328  }
1329}
1330
1331function compileShader(gl, type, source) {
1332  // Substitute precision for fragment shaders based on GPU capability
1333  const src = type === gl.FRAGMENT_SHADER
1334    ? source.replace(/precision highp float;/, `precision ${fragPrecision} float;`)
1335    : source;
1336  const shader = gl.createShader(type);
1337  gl.shaderSource(shader, src);
1338  gl.compileShader(shader);
1339  
1340  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1341    console.error('🎮 GPU Effects: Shader compile failed:', gl.getShaderInfoLog(shader));
1342    gl.deleteShader(shader);
1343    return null;
1344  }
1345  return shader;
1346}
1347
1348// Sanity check: detect if GPU produced blank output when input had data.
1349// Samples ~100 evenly-spaced bytes for speed. Returns true if output looks valid.
1350function sanityCheck(readback, pixels, effectName) {
1351  const step = Math.max(1, Math.floor(readback.length / 100));
1352  let outHasData = false;
1353  for (let i = 0; i < readback.length && !outHasData; i += step) {
1354    if (readback[i] !== 0) outHasData = true;
1355  }
1356  if (outHasData) return true; // output has content — OK
1357
1358  let inHasData = false;
1359  for (let i = 0; i < pixels.length && !inHasData; i += step) {
1360    if (pixels[i] !== 0) inHasData = true;
1361  }
1362  if (!inHasData) return true; // input was also blank — OK (nothing to corrupt)
1363
1364  console.warn(`🎮 GPU ${effectName}: Output blank but input had data — falling back to CPU`);
1365  return false;
1366}
1367
1368/**
1369 * GPU-accelerated spin operation - EXACT MATCH to CPU algorithm
1370 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1371 * @param {number} width - Buffer width
1372 * @param {number} height - Buffer height  
1373 * @param {number} steps - Rotation steps (accumulates fractionally like CPU)
1374 * @param {number|null} anchorX - Anchor X (null = center)
1375 * @param {number|null} anchorY - Anchor Y (null = center)
1376 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1377 * @returns {boolean} - True if GPU spin was used, false if fallback needed
1378 */
1379export function gpuSpin(pixels, width, height, steps, anchorX = null, anchorY = null, mask = null) {
1380  // Fast no-op: only skip truly zero delta.
1381  // Small deltas must accumulate over time to preserve smooth spin behavior.
1382  if (steps === 0) {
1383    return true;
1384  }
1385  
1386  // Accumulate fractional steps (same as CPU)
1387  spinAccumulator += steps;
1388  const integerSteps = Math.floor(spinAccumulator);
1389  spinAccumulator -= integerSteps;
1390  
1391  if (integerSteps === 0) {
1392    return true;
1393  }
1394  
1395  // Initialize WebGL2 if needed
1396  if (!initWebGL2(width, height)) {
1397    return false;
1398  }
1399  if (mobileSafeMode) return false;
1400
1401  try {
1402    // Determine bounds
1403    const minX = mask?.x ?? 0;
1404    const minY = mask?.y ?? 0;
1405    const maxX = mask ? mask.x + mask.width : width;
1406    const maxY = mask ? mask.y + mask.height : height;
1407    
1408    const workingWidth = maxX - minX;
1409    const workingHeight = maxY - minY;
1410    const centerX = anchorX ?? (minX + Math.floor(workingWidth / 2));
1411    const centerY = anchorY ?? (minY + Math.floor(workingHeight / 2));
1412    
1413    // Upload pixels directly (no flip - shader handles Y flip when sampling)
1414    gl.bindTexture(gl.TEXTURE_2D, texture);
1415    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
1416    
1417    // Render to framebuffer
1418    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1419    gl.viewport(0, 0, width, height);
1420    
1421    gl.useProgram(spinProgram);
1422    
1423    // Set uniforms (using cached locations for performance)
1424    gl.uniform2f(spinUniforms.u_resolution, width, height);
1425    gl.uniform2f(spinUniforms.u_center, centerX, centerY);
1426    gl.uniform1f(spinUniforms.u_steps, integerSteps);
1427    gl.uniform4f(spinUniforms.u_bounds, minX, minY, maxX, maxY);
1428    gl.uniform1i(spinUniforms.u_texture, 0);
1429    
1430    // Use VAO for efficient attribute binding
1431    gl.bindVertexArray(vao);
1432    
1433    gl.activeTexture(gl.TEXTURE0);
1434    gl.bindTexture(gl.TEXTURE_2D, texture);
1435    
1436    gl.drawArrays(gl.TRIANGLES, 0, 6);
1437    
1438    // Read into Uint8Array first (Android Chrome fails with Uint8ClampedArray)
1439    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
1440    if (!sanityCheck(readbackBuffer, pixels, "Spin")) {
1441      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1442      return false;
1443    }
1444    pixels.set(readbackBuffer);
1445
1446    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1447
1448    return true;
1449  } catch (e) {
1450    console.error('🎮 GPU Spin: Render failed:', e);
1451    return false;
1452  }
1453}
1454
1455/**
1456 * GPU-accelerated composite effects (zoom + scroll + contrast in one pass)
1457 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1458 * @param {number} width - Buffer width
1459 * @param {number} height - Buffer height
1460 * @param {Object} options - { zoom, zoomAnchorX, zoomAnchorY, scrollX, scrollY, flipY, contrast, brightness, mask }
1461 * @returns {boolean}
1462 */
1463export function gpuComposite(pixels, width, height, options = {}) {
1464  const {
1465    zoom = 1.0,
1466    zoomAnchorX = 0.5,
1467    zoomAnchorY = 0.5,
1468    scrollX = 0,
1469    scrollY = 0,
1470    flipY = false,
1471    contrast = 1.0,
1472    brightness = 0,
1473    mask = null
1474  } = options;
1475  
1476  // Early exit if no effects
1477  if (zoom === 1.0 && scrollX === 0 && scrollY === 0 && !flipY && contrast === 1.0 && brightness === 0) {
1478    return true;
1479  }
1480
1481  if (!initWebGL2(width, height)) {
1482    return false;
1483  }
1484  if (mobileSafeMode) return false;
1485
1486  try {
1487    const minX = mask?.x ?? 0;
1488    const minY = mask?.y ?? 0;
1489    const maxX = mask ? mask.x + mask.width : width;
1490    const maxY = mask ? mask.y + mask.height : height;
1491    
1492    const workingWidth = maxX - minX;
1493    const workingHeight = maxY - minY;
1494    
1495    // Anchor in pixel coordinates
1496    const anchorPixelX = minX + workingWidth * zoomAnchorX;
1497    const anchorPixelY = minY + workingHeight * zoomAnchorY;
1498    
1499    // Upload pixels directly (no flip - shader handles Y flip when sampling)
1500    gl.bindTexture(gl.TEXTURE_2D, texture);
1501    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
1502    
1503    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1504    gl.viewport(0, 0, width, height);
1505    
1506    gl.useProgram(compositeProgram);
1507    
1508    // Set uniforms (using cached locations for performance)
1509    gl.uniform2f(compositeUniforms.u_resolution, width, height);
1510    gl.uniform4f(compositeUniforms.u_bounds, minX, minY, maxX, maxY);
1511    gl.uniform1f(compositeUniforms.u_zoomScale, zoom);
1512    gl.uniform2f(compositeUniforms.u_zoomAnchor, anchorPixelX, anchorPixelY);
1513    gl.uniform2f(compositeUniforms.u_scrollOffset, scrollX, scrollY);
1514    gl.uniform1f(compositeUniforms.u_flipY, flipY ? 1.0 : 0.0);
1515    gl.uniform1f(compositeUniforms.u_contrast, contrast);
1516    gl.uniform1f(compositeUniforms.u_brightness, brightness);
1517    gl.uniform1i(compositeUniforms.u_texture, 0);
1518    
1519    // Use VAO for efficient attribute binding
1520    gl.bindVertexArray(vao);
1521    
1522    gl.activeTexture(gl.TEXTURE0);
1523    gl.bindTexture(gl.TEXTURE_2D, texture);
1524    
1525    gl.drawArrays(gl.TRIANGLES, 0, 6);
1526    
1527    // Read into Uint8Array first (Android Chrome fails with Uint8ClampedArray)
1528    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
1529    if (!sanityCheck(readbackBuffer, pixels, "Composite")) {
1530      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1531      return false;
1532    }
1533    pixels.set(readbackBuffer);
1534
1535    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1536
1537    return true;
1538  } catch (e) {
1539    console.error('🎮 GPU Composite: Render failed:', e);
1540    return false;
1541  }
1542}
1543
1544/**
1545 * GPU-accelerated zoom (uses composite shader with zoom only)
1546 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1547 * @param {number} width - Buffer width
1548 * @param {number} height - Buffer height
1549 * @param {number} scale - Zoom scale (1.0 = no change, >1 = zoom in, <1 = zoom out)
1550 * @param {number} anchorX - Anchor X (0-1, default 0.5 = center)
1551 * @param {number} anchorY - Anchor Y (0-1, default 0.5 = center)
1552 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1553 * @returns {boolean}
1554 */
1555export function gpuZoom(pixels, width, height, scale = 1.0, anchorX = 0.5, anchorY = 0.5, mask = null) {
1556  if (scale === 1.0) return true;
1557  if (mobileSafeMode) return false;
1558
1559  // Use composite shader with only zoom enabled
1560  return gpuComposite(pixels, width, height, {
1561    zoom: scale,
1562    zoomAnchorX: anchorX,
1563    zoomAnchorY: anchorY,
1564    scrollX: 0,
1565    scrollY: 0,
1566    contrast: 1.0,
1567    brightness: 0,
1568    mask
1569  });
1570}
1571
1572/**
1573 * GPU-accelerated scroll (pixel wrapping)
1574 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1575 * @param {number} width - Buffer width
1576 * @param {number} height - Buffer height
1577 * @param {number} dx - Horizontal scroll amount (integer pixels)
1578 * @param {number} dy - Vertical scroll amount (integer pixels)
1579 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1580 * @returns {boolean}
1581 */
1582export function gpuScroll(pixels, width, height, dx = 0, dy = 0, mask = null) {
1583  if (dx === 0 && dy === 0) return true;
1584  if (mobileSafeMode) return false;
1585
1586  // Use composite shader with only scroll enabled
1587  return gpuComposite(pixels, width, height, {
1588    zoom: 1.0,
1589    zoomAnchorX: 0.5,
1590    zoomAnchorY: 0.5,
1591    scrollX: dx,
1592    scrollY: dy,
1593    contrast: 1.0,
1594    brightness: 0,
1595    mask
1596  });
1597}
1598
1599/**
1600 * GPU-accelerated contrast adjustment (uses composite shader with contrast only)
1601 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1602 * @param {number} width - Buffer width
1603 * @param {number} height - Buffer height
1604 * @param {number} level - Contrast level (1.0 = no change, >1 = more contrast, <1 = less)
1605 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1606 * @returns {boolean}
1607 */
1608export function gpuContrast(pixels, width, height, level = 1.0, mask = null) {
1609  if (level === 1.0) return true;
1610  if (mobileSafeMode) return false;
1611
1612  // Use composite shader with only contrast enabled
1613  return gpuComposite(pixels, width, height, {
1614    zoom: 1.0,
1615    zoomAnchorX: 0.5,
1616    zoomAnchorY: 0.5,
1617    scrollX: 0,
1618    scrollY: 0,
1619    contrast: level,
1620    brightness: 0,
1621    mask
1622  });
1623}
1624
1625/**
1626 * GPU-accelerated brightness adjustment (uses composite shader with brightness only)
1627 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1628 * @param {number} width - Buffer width
1629 * @param {number} height - Buffer height
1630 * @param {number} adjustment - Brightness adjustment (-255 to +255, 0 = no change)
1631 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1632 * @returns {boolean}
1633 */
1634export function gpuBrightness(pixels, width, height, adjustment = 0, mask = null) {
1635  if (adjustment === 0) return true;
1636  if (mobileSafeMode) return false;
1637
1638  // Use composite shader with only brightness enabled
1639  return gpuComposite(pixels, width, height, {
1640    zoom: 1.0,
1641    zoomAnchorX: 0.5,
1642    zoomAnchorY: 0.5,
1643    scrollX: 0,
1644    scrollY: 0,
1645    contrast: 1.0,
1646    brightness: adjustment,
1647    mask
1648  });
1649}
1650
1651/**
1652 * GPU-accelerated invert (255 - RGB value, preserve alpha)
1653 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1654 * @param {number} width - Buffer width
1655 * @param {number} height - Buffer height
1656 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1657 * @returns {boolean}
1658 */
1659export function gpuInvert(pixels, width, height, mask = null) {
1660  if (!initialized || !gl || !invertProgram) return false;
1661  if (mobileSafeMode) return false;
1662
1663  try {
1664    ensureResources(width, height);
1665    
1666    // Upload texture (Y-flip happens here)
1667    uploadPixels(pixels, width, height);
1668    
1669    // Set up render target
1670    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1671    gl.viewport(0, 0, width, height);
1672    
1673    // Use invert program
1674    gl.useProgram(invertProgram);
1675    
1676    // Set uniforms
1677    gl.uniform2f(invertUniforms.u_resolution, width, height);
1678    
1679    // Set bounds (mask or full screen)
1680    const bounds = mask ? [mask.x, mask.y, mask.x + mask.width, mask.y + mask.height] : [0, 0, width, height];
1681    gl.uniform4f(invertUniforms.u_bounds, bounds[0], bounds[1], bounds[2], bounds[3]);
1682    
1683    // Bind texture
1684    gl.activeTexture(gl.TEXTURE0);
1685    gl.bindTexture(gl.TEXTURE_2D, texture);
1686    gl.uniform1i(invertUniforms.u_texture, 0);
1687    
1688    // Draw
1689    gl.drawArrays(gl.TRIANGLES, 0, 6);
1690    
1691    // Read back pixels (Y-flip happens here too)
1692    renderAndReadback(pixels, width, height);
1693    
1694    return true;
1695  } catch (e) {
1696    console.error('🎮 GPU Invert: Render failed:', e);
1697    return false;
1698  }
1699}
1700
1701/**
1702 * Check if GPU effects are available
1703 */
1704export function isGpuEffectsAvailable() {
1705  try {
1706    const testCanvas = new OffscreenCanvas(1, 1);
1707    const testGl = testCanvas.getContext('webgl2');
1708    return !!testGl;
1709  } catch {
1710    return false;
1711  }
1712}
1713
1714/**
1715 * GPU-accelerated Dual Kawase blur (ARM SIGGRAPH 2015)
1716 * Downsample/upsample through a mip chain — works on Mali, Adreno, PowerVR.
1717 * Uses texture() with LINEAR filtering (bilinear interpolation trick).
1718 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1719 * @param {number} width - Buffer width
1720 * @param {number} height - Buffer height
1721 * @param {number} strength - Blur strength (maps to mip levels: 1-2→1, 3-4→2, 5-7→3+)
1722 * @param {Object|null} mask - Optional mask bounds (ignored — Kawase operates full-frame)
1723 * @returns {boolean}
1724 */
1725export function gpuBlur(pixels, width, height, strength = 1, mask = null) {
1726  if (strength <= 0.1) return true;
1727
1728  if (!initWebGL2(width, height)) {
1729    return false;
1730  }
1731
1732  if (!kawaseDownProgram || !kawaseUpProgram || kawaseMips.length === 0) {
1733    return false;
1734  }
1735
1736  try {
1737    // Map blur strength to number of downsample/upsample levels (1-4)
1738    const levels = Math.min(4, Math.max(1, Math.ceil(strength / 2)));
1739
1740    // Upload pixels to input texture — switch to LINEAR for Kawase sampling
1741    gl.bindTexture(gl.TEXTURE_2D, texture);
1742    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1743    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1744    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
1745
1746    gl.bindVertexArray(vao);
1747    gl.activeTexture(gl.TEXTURE0);
1748
1749    // === DOWNSAMPLE CHAIN ===
1750    gl.useProgram(kawaseDownProgram);
1751    gl.uniform1i(kawaseDownUniforms.u_texture, 0);
1752
1753    let srcTex = texture;
1754    let srcW = width, srcH = height;
1755
1756    for (let i = 0; i < levels; i++) {
1757      const mip = kawaseMips[i];
1758      gl.uniform2f(kawaseDownUniforms.u_texelSize, 1.0 / srcW, 1.0 / srcH);
1759      gl.bindFramebuffer(gl.FRAMEBUFFER, mip.framebuffer);
1760      gl.viewport(0, 0, mip.width, mip.height);
1761      gl.bindTexture(gl.TEXTURE_2D, srcTex);
1762      gl.drawArrays(gl.TRIANGLES, 0, 6);
1763      srcTex = mip.texture;
1764      srcW = mip.width;
1765      srcH = mip.height;
1766    }
1767
1768    // === UPSAMPLE CHAIN ===
1769    gl.useProgram(kawaseUpProgram);
1770    gl.uniform1i(kawaseUpUniforms.u_texture, 0);
1771
1772    for (let i = levels - 2; i >= 0; i--) {
1773      const mip = kawaseMips[i];
1774      gl.uniform2f(kawaseUpUniforms.u_texelSize, 1.0 / srcW, 1.0 / srcH);
1775      gl.bindFramebuffer(gl.FRAMEBUFFER, mip.framebuffer);
1776      gl.viewport(0, 0, mip.width, mip.height);
1777      gl.bindTexture(gl.TEXTURE_2D, srcTex);
1778      gl.drawArrays(gl.TRIANGLES, 0, 6);
1779      srcTex = mip.texture;
1780      srcW = mip.width;
1781      srcH = mip.height;
1782    }
1783
1784    // Final upsample back to full resolution → output framebuffer
1785    gl.uniform2f(kawaseUpUniforms.u_texelSize, 1.0 / srcW, 1.0 / srcH);
1786    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1787    gl.viewport(0, 0, width, height);
1788    gl.bindTexture(gl.TEXTURE_2D, srcTex);
1789    gl.drawArrays(gl.TRIANGLES, 0, 6);
1790
1791    // Read back result
1792    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
1793
1794    // Restore NEAREST filtering on input texture (other effects need pixel-perfect)
1795    gl.bindTexture(gl.TEXTURE_2D, texture);
1796    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1797    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1798
1799    if (!sanityCheck(readbackBuffer, pixels, "Blur")) {
1800      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1801      return false;
1802    }
1803    pixels.set(readbackBuffer);
1804
1805    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1806    return true;
1807  } catch (e) {
1808    console.error('🎮 GPU Blur: Render failed:', e);
1809    return false;
1810  }
1811}
1812
1813/**
1814 * GPU-accelerated sharpen (unsharp mask)
1815 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1816 * @param {number} width - Buffer width
1817 * @param {number} height - Buffer height
1818 * @param {number} strength - Sharpen strength
1819 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1820 * @returns {boolean}
1821 */
1822export function gpuSharpen(pixels, width, height, strength = 1, mask = null) {
1823  if (strength <= 0) return true;
1824
1825  if (!initWebGL2(width, height)) {
1826    return false;
1827  }
1828  if (mobileSafeMode) return false;
1829  
1830  try {
1831    const minX = mask?.x ?? 0;
1832    const minY = mask?.y ?? 0;
1833    const maxX = mask ? mask.x + mask.width : width;
1834    const maxY = mask ? mask.y + mask.height : height;
1835    
1836    // Flush prior GPU work (Mali pipeline hazard workaround)
1837    gl.finish();
1838
1839    // Upload pixels to texture
1840    gl.bindTexture(gl.TEXTURE_2D, texture);
1841    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
1842
1843    // Render sharpen to framebuffer
1844    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1845    gl.viewport(0, 0, width, height);
1846    gl.useProgram(sharpenProgram);
1847
1848    // Set uniforms (using cached locations for performance)
1849    gl.uniform2f(sharpenUniforms.u_resolution, width, height);
1850    gl.uniform4f(sharpenUniforms.u_bounds, minX, minY, maxX, maxY);
1851    gl.uniform1f(sharpenUniforms.u_strength, strength);
1852    gl.uniform1i(sharpenUniforms.u_texture, 0);
1853
1854    gl.bindVertexArray(vao);
1855    gl.activeTexture(gl.TEXTURE0);
1856    gl.bindTexture(gl.TEXTURE_2D, texture);
1857    gl.drawArrays(gl.TRIANGLES, 0, 6);
1858
1859    // Read back pixels (no Y-flip needed since we didn't flip on upload)
1860    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
1861
1862    if (!sanityCheck(readbackBuffer, pixels, "Sharpen")) {
1863      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1864      return false;
1865    }
1866
1867    pixels.set(readbackBuffer);
1868    
1869    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1870    return true;
1871  } catch (e) {
1872    console.error('🎮 GPU Sharpen: Render failed:', e);
1873    return false;
1874  }
1875}
1876
1877/**
1878 * GPU-accelerated shear (KidPix-style row/column shifting)
1879 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1880 * @param {number} width - Buffer width
1881 * @param {number} height - Buffer height
1882 * @param {number} shearX - Horizontal shear factor
1883 * @param {number} shearY - Vertical shear factor
1884 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1885 * @returns {boolean}
1886 */
1887export function gpuShear(pixels, width, height, shearX = 0, shearY = 0, mask = null) {
1888  if (shearX === 0 && shearY === 0) return true;
1889
1890  if (!initWebGL2(width, height)) {
1891    return false;
1892  }
1893  if (mobileSafeMode) return false;
1894
1895  try {
1896    const minX = mask?.x ?? 0;
1897    const minY = mask?.y ?? 0;
1898    const maxX = mask ? mask.x + mask.width : width;
1899    const maxY = mask ? mask.y + mask.height : height;
1900
1901    // Upload pixels
1902    gl.bindTexture(gl.TEXTURE_2D, texture);
1903    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
1904
1905    // Render to framebuffer
1906    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1907    gl.viewport(0, 0, width, height);
1908
1909    gl.useProgram(shearProgram);
1910
1911    // Set uniforms
1912    gl.uniform2f(shearUniforms.u_resolution, width, height);
1913    gl.uniform4f(shearUniforms.u_bounds, minX, minY, maxX, maxY);
1914    gl.uniform1f(shearUniforms.u_shearX, shearX);
1915    gl.uniform1f(shearUniforms.u_shearY, shearY);
1916    gl.uniform1i(shearUniforms.u_texture, 0);
1917
1918    // Use VAO for efficient attribute binding
1919    gl.bindVertexArray(vao);
1920
1921    gl.activeTexture(gl.TEXTURE0);
1922    gl.bindTexture(gl.TEXTURE_2D, texture);
1923
1924    gl.drawArrays(gl.TRIANGLES, 0, 6);
1925
1926    // Read into Uint8Array first (Android Chrome fails with Uint8ClampedArray)
1927    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
1928    if (!sanityCheck(readbackBuffer, pixels, "Shear")) {
1929      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1930      return false;
1931    }
1932    pixels.set(readbackBuffer);
1933
1934    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1935
1936    return true;
1937  } catch (e) {
1938    console.error('🎮 GPU Shear: Render failed:', e);
1939    return false;
1940  }
1941}
1942
1943/**
1944 * GPU-accelerated radial suck/blow
1945 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
1946 * @param {number} width - Buffer width
1947 * @param {number} height - Buffer height
1948 * @param {number} displacement - Absolute displacement amount
1949 * @param {number} direction - 1 = suck inward, -1 = blow outward
1950 * @param {number} centerX - Center X in pixel coords
1951 * @param {number} centerY - Center Y in pixel coords
1952 * @param {Object|null} mask - Optional mask bounds {x, y, width, height}
1953 * @returns {boolean}
1954 */
1955export function gpuSuck(pixels, width, height, displacement, direction, centerX, centerY, mask = null) {
1956  if (displacement === 0) return true;
1957
1958  if (!initWebGL2(width, height)) {
1959    return false;
1960  }
1961  if (mobileSafeMode) return false;
1962
1963  try {
1964    const minX = mask?.x ?? 0;
1965    const minY = mask?.y ?? 0;
1966    const maxX = mask ? mask.x + mask.width : width;
1967    const maxY = mask ? mask.y + mask.height : height;
1968
1969    // Upload pixels
1970    gl.bindTexture(gl.TEXTURE_2D, texture);
1971    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
1972
1973    // Render to framebuffer
1974    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
1975    gl.viewport(0, 0, width, height);
1976
1977    gl.useProgram(suckProgram);
1978
1979    // Set uniforms
1980    gl.uniform2f(suckUniforms.u_resolution, width, height);
1981    gl.uniform4f(suckUniforms.u_bounds, minX, minY, maxX, maxY);
1982    gl.uniform1f(suckUniforms.u_displacement, displacement);
1983    gl.uniform1f(suckUniforms.u_direction, direction);
1984    gl.uniform2f(suckUniforms.u_center, centerX, centerY);
1985    gl.uniform1i(suckUniforms.u_texture, 0);
1986
1987    // Use VAO for efficient attribute binding
1988    gl.bindVertexArray(vao);
1989
1990    gl.activeTexture(gl.TEXTURE0);
1991    gl.bindTexture(gl.TEXTURE_2D, texture);
1992
1993    gl.drawArrays(gl.TRIANGLES, 0, 6);
1994
1995    // Read into Uint8Array first (Android Chrome fails with Uint8ClampedArray)
1996    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
1997    if (!sanityCheck(readbackBuffer, pixels, "Suck")) {
1998      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1999      return false;
2000    }
2001    pixels.set(readbackBuffer);
2002
2003    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2004
2005    return true;
2006  } catch (e) {
2007    console.error('🎮 GPU Suck: Render failed:', e);
2008    return false;
2009  }
2010}
2011
2012/**
2013 * GPU-accelerated flood fill using Jump Flooding Algorithm (JFA)
2014 * O(log n) complexity vs O(n) for CPU scanline algorithm
2015 * @param {Uint8ClampedArray} pixels - Source/destination pixel buffer
2016 * @param {number} width - Buffer width
2017 * @param {number} height - Buffer height
2018 * @param {number} x - Seed point X coordinate
2019 * @param {number} y - Seed point Y coordinate
2020 * @param {Array} fillColor - Fill color [R, G, B, A] (0-255)
2021 * @returns {{success: boolean, area: number}} - Result with filled area count
2022 */
2023export function gpuFlood(pixels, width, height, x, y, fillColor) {
2024  // Check if flood fill programs are available
2025  if (!floodSeedProgram || !floodJFAProgram || !floodFillProgram || !floodTexture1 || !floodTexture2) {
2026    return { success: false, area: 0 };
2027  }
2028  
2029  // Bounds check
2030  if (x < 0 || y < 0 || x >= width || y >= height) {
2031    return { success: true, area: 0 };
2032  }
2033  
2034  // Initialize WebGL2 if needed
2035  if (!initWebGL2(width, height)) {
2036    return { success: false, area: 0 };
2037  }
2038  if (mobileSafeMode) return { success: false, area: 0 };
2039
2040  try {
2041    // Get target color at seed point (RGBA 0-255)
2042    const seedIdx = (y * width + x) * 4;
2043    const targetColor = [
2044      pixels[seedIdx] / 255,
2045      pixels[seedIdx + 1] / 255,
2046      pixels[seedIdx + 2] / 255,
2047      pixels[seedIdx + 3] / 255
2048    ];
2049    
2050    // Normalize fill color to 0-1
2051    const fillColorNorm = [
2052      fillColor[0] / 255,
2053      fillColor[1] / 255,
2054      fillColor[2] / 255,
2055      fillColor[3] / 255
2056    ];
2057    
2058    // If target is transparent, nothing to fill
2059    if (targetColor[3] === 0) {
2060      return { success: true, area: 0 };
2061    }
2062    
2063    // Upload source pixels to main texture (flip Y for WebGL)
2064    gl.bindTexture(gl.TEXTURE_2D, texture);
2065    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
2066    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
2067    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
2068    
2069    // Flip Y coordinate for WebGL
2070    const seedY = height - 1 - y;
2071    
2072    // === PASS 1: Seed initialization ===
2073    gl.bindFramebuffer(gl.FRAMEBUFFER, floodFramebuffer1);
2074    gl.viewport(0, 0, width, height);
2075    gl.useProgram(floodSeedProgram);
2076    
2077    gl.uniform1i(floodSeedUniforms.u_texture, 0);
2078    gl.uniform2f(floodSeedUniforms.u_resolution, width, height);
2079    gl.uniform2f(floodSeedUniforms.u_seedPoint, x, seedY);
2080    gl.uniform4f(floodSeedUniforms.u_targetColor, targetColor[0], targetColor[1], targetColor[2], targetColor[3]);
2081    gl.uniform1f(floodSeedUniforms.u_colorTolerance, 0.004); // ~1/255 tolerance
2082    
2083    gl.bindVertexArray(vao);
2084    gl.activeTexture(gl.TEXTURE0);
2085    gl.bindTexture(gl.TEXTURE_2D, texture);
2086    gl.drawArrays(gl.TRIANGLES, 0, 6);
2087    
2088    // === PASS 2: JFA propagation passes ===
2089    // Number of passes = ceil(log2(max(width, height)))
2090    const maxDim = Math.max(width, height);
2091    const numPasses = Math.ceil(Math.log2(maxDim));
2092    
2093    let readTex = floodTexture1;
2094    let writeFB = floodFramebuffer2;
2095    let writeTex = floodTexture2;
2096    
2097    gl.useProgram(floodJFAProgram);
2098    gl.uniform2f(floodJFAUniforms.u_resolution, width, height);
2099    
2100    for (let pass = 0; pass < numPasses; pass++) {
2101      const stepSize = Math.pow(2, numPasses - 1 - pass);
2102      
2103      gl.bindFramebuffer(gl.FRAMEBUFFER, writeFB);
2104      gl.uniform1i(floodJFAUniforms.u_stepSize, stepSize);
2105      gl.uniform1i(floodJFAUniforms.u_seedMap, 0);
2106      
2107      gl.activeTexture(gl.TEXTURE0);
2108      gl.bindTexture(gl.TEXTURE_2D, readTex);
2109      gl.drawArrays(gl.TRIANGLES, 0, 6);
2110      
2111      // Swap buffers for next pass
2112      if (writeFB === floodFramebuffer2) {
2113        readTex = floodTexture2;
2114        writeFB = floodFramebuffer1;
2115        writeTex = floodTexture1;
2116      } else {
2117        readTex = floodTexture1;
2118        writeFB = floodFramebuffer2;
2119        writeTex = floodTexture2;
2120      }
2121    }
2122    
2123    // readTex now contains the final JFA result
2124    
2125    // === PASS 3: Apply fill color ===
2126    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
2127    gl.useProgram(floodFillProgram);
2128    
2129    gl.uniform2f(floodFillUniforms.u_resolution, width, height);
2130    gl.uniform4f(floodFillUniforms.u_fillColor, fillColorNorm[0], fillColorNorm[1], fillColorNorm[2], fillColorNorm[3]);
2131    
2132    // Bind original texture to unit 0, seed map to unit 1
2133    gl.activeTexture(gl.TEXTURE0);
2134    gl.bindTexture(gl.TEXTURE_2D, texture);
2135    gl.uniform1i(floodFillUniforms.u_texture, 0);
2136    
2137    gl.activeTexture(gl.TEXTURE1);
2138    gl.bindTexture(gl.TEXTURE_2D, readTex);
2139    gl.uniform1i(floodFillUniforms.u_seedMap, 1);
2140    
2141    gl.drawArrays(gl.TRIANGLES, 0, 6);
2142    
2143    // Read back pixels (flip Y)
2144    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
2145    
2146    // Count filled pixels and copy back with Y-flip
2147    let filledCount = 0;
2148    const rowSize = width * 4;
2149    for (let row = 0; row < height; row++) {
2150      const srcRow = (height - 1 - row) * rowSize;
2151      const dstRow = row * rowSize;
2152      
2153      for (let col = 0; col < width; col++) {
2154        const srcIdx = srcRow + col * 4;
2155        const dstIdx = dstRow + col * 4;
2156        
2157        // Check if pixel was filled (different from original)
2158        const wasFilled = (
2159          readbackBuffer[srcIdx] !== pixels[dstIdx] ||
2160          readbackBuffer[srcIdx + 1] !== pixels[dstIdx + 1] ||
2161          readbackBuffer[srcIdx + 2] !== pixels[dstIdx + 2]
2162        );
2163        if (wasFilled) filledCount++;
2164        
2165        pixels[dstIdx] = readbackBuffer[srcIdx];
2166        pixels[dstIdx + 1] = readbackBuffer[srcIdx + 1];
2167        pixels[dstIdx + 2] = readbackBuffer[srcIdx + 2];
2168        pixels[dstIdx + 3] = readbackBuffer[srcIdx + 3];
2169      }
2170    }
2171    
2172    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2173    gl.activeTexture(gl.TEXTURE0);
2174    
2175    return { success: true, area: filledCount };
2176  } catch (e) {
2177    console.error('🎮 GPU Flood: Render failed:', e);
2178    return { success: false, area: 0 };
2179  }
2180}
2181
2182/**
2183 * Check if GPU flood fill is available
2184 */
2185export function isGpuFloodAvailable() {
2186  return !!(floodSeedProgram && floodJFAProgram && floodFillProgram && floodTexture1 && floodTexture2);
2187}
2188
2189/**
2190 * Check if GPU layer compositing is available
2191 */
2192export function isGpuLayerCompositeAvailable() {
2193  return !!(layerCompositeProgram && layerTextures);
2194}
2195
2196/**
2197 * GPU-accelerated multi-layer compositing
2198 * Composites up to 8 layers onto a background in a single GPU pass
2199 * 
2200 * @param {Uint8ClampedArray} backgroundPixels - Destination/background buffer
2201 * @param {number} width - Output width
2202 * @param {number} height - Output height  
2203 * @param {Array<{pixels: Uint8ClampedArray, x: number, y: number, width: number, height: number, alpha: number}>} layers - Array of layer objects
2204 * @returns {{success: boolean}} - Result
2205 */
2206export function gpuCompositeLayers(backgroundPixels, width, height, layers) {
2207  if (!layerCompositeProgram || !layerTextures || !layers || layers.length === 0) {
2208    return { success: false };
2209  }
2210  if (mobileSafeMode) return { success: false };
2211
2212  if (layers.length > MAX_LAYERS) {
2213    console.warn(`🎮 GPU Layer Composite: Too many layers (${layers.length}), max is ${MAX_LAYERS}`);
2214    return { success: false };
2215  }
2216  
2217  try {
2218    // Ensure canvas/resources match output size
2219    if (lastWidth !== width || lastHeight !== height) {
2220      resizeTextures(width, height);
2221      lastWidth = width;
2222      lastHeight = height;
2223    }
2224    
2225    gl.viewport(0, 0, width, height);
2226    gl.bindVertexArray(vao);
2227    
2228    // Upload background to main texture (with Y-flip for WebGL)
2229    gl.activeTexture(gl.TEXTURE0);
2230    gl.bindTexture(gl.TEXTURE_2D, texture);
2231    
2232    // Y-flip background pixels for WebGL
2233    const flippedBackground = new Uint8Array(backgroundPixels.length);
2234    const rowSize = width * 4;
2235    for (let row = 0; row < height; row++) {
2236      const srcRow = row * rowSize;
2237      const dstRow = (height - 1 - row) * rowSize;
2238      flippedBackground.set(backgroundPixels.subarray(srcRow, srcRow + rowSize), dstRow);
2239    }
2240    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, flippedBackground);
2241    
2242    // Upload each layer texture (with Y-flip)
2243    const layerBounds = new Float32Array(MAX_LAYERS * 4);  // x, y, w, h for each
2244    const layerAlphas = new Float32Array(MAX_LAYERS);
2245    
2246    for (let i = 0; i < layers.length; i++) {
2247      const layer = layers[i];
2248      const tex = layerTextures[i];
2249      
2250      gl.activeTexture(gl.TEXTURE1 + i);
2251      gl.bindTexture(gl.TEXTURE_2D, tex);
2252      
2253      // Y-flip layer pixels
2254      const layerW = layer.width;
2255      const layerH = layer.height;
2256      const layerRowSize = layerW * 4;
2257      const flippedLayer = new Uint8Array(layer.pixels.length);
2258      for (let row = 0; row < layerH; row++) {
2259        const srcRow = row * layerRowSize;
2260        const dstRow = (layerH - 1 - row) * layerRowSize;
2261        flippedLayer.set(layer.pixels.subarray(srcRow, srcRow + layerRowSize), dstRow);
2262      }
2263      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, layerW, layerH, 0, gl.RGBA, gl.UNSIGNED_BYTE, flippedLayer);
2264      
2265      // Store bounds (Y-flip the position too)
2266      const flippedY = height - layer.y - layerH;
2267      layerBounds[i * 4] = layer.x;
2268      layerBounds[i * 4 + 1] = flippedY;
2269      layerBounds[i * 4 + 2] = layerW;
2270      layerBounds[i * 4 + 3] = layerH;
2271      
2272      layerAlphas[i] = layer.alpha !== undefined ? layer.alpha : 255;
2273    }
2274    
2275    // Render to framebuffer
2276    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
2277    gl.useProgram(layerCompositeProgram);
2278    
2279    // Set uniforms
2280    gl.uniform2f(layerCompositeUniforms.u_resolution, width, height);
2281    gl.uniform1i(layerCompositeUniforms.u_layerCount, layers.length);
2282    
2283    // Bind background texture
2284    gl.uniform1i(layerCompositeUniforms.u_background, 0);
2285    
2286    // Bind layer textures
2287    gl.uniform1i(layerCompositeUniforms.u_layer0, 1);
2288    gl.uniform1i(layerCompositeUniforms.u_layer1, 2);
2289    gl.uniform1i(layerCompositeUniforms.u_layer2, 3);
2290    gl.uniform1i(layerCompositeUniforms.u_layer3, 4);
2291    gl.uniform1i(layerCompositeUniforms.u_layer4, 5);
2292    gl.uniform1i(layerCompositeUniforms.u_layer5, 6);
2293    gl.uniform1i(layerCompositeUniforms.u_layer6, 7);
2294    gl.uniform1i(layerCompositeUniforms.u_layer7, 8);
2295    
2296    // Set layer bounds and alpha arrays
2297    gl.uniform4fv(layerCompositeUniforms.u_layerBounds, layerBounds);
2298    gl.uniform1fv(layerCompositeUniforms.u_layerAlpha, layerAlphas);
2299    
2300    // Draw
2301    gl.drawArrays(gl.TRIANGLES, 0, 6);
2302    
2303    // Read back result (with Y-flip back to CPU coordinates)
2304    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, readbackBuffer);
2305
2306    if (!sanityCheck(readbackBuffer, backgroundPixels, "CompositeLayers")) {
2307      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2308      gl.activeTexture(gl.TEXTURE0);
2309      return { success: false };
2310    }
2311
2312    for (let row = 0; row < height; row++) {
2313      const srcRow = (height - 1 - row) * rowSize;
2314      const dstRow = row * rowSize;
2315      for (let col = 0; col < rowSize; col++) {
2316        backgroundPixels[dstRow + col] = readbackBuffer[srcRow + col];
2317      }
2318    }
2319
2320    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2321    gl.activeTexture(gl.TEXTURE0);
2322
2323    return { success: true };
2324  } catch (e) {
2325    console.error('🎮 GPU Layer Composite: Render failed:', e);
2326    return { success: false };
2327  }
2328}
2329
2330/**
2331 * Reset accumulators (call when context changes)
2332 */
2333export function resetAccumulators() {
2334  spinAccumulator = 0;
2335  scrollAccumulatorX = 0;
2336  scrollAccumulatorY = 0;
2337  zoomAccumulator = 0;
2338}
2339
2340/**
2341 * Clean up GPU resources
2342 */
2343export function cleanupGpuEffects() {
2344  if (gl) {
2345    if (texture) gl.deleteTexture(texture);
2346    if (outputTexture) gl.deleteTexture(outputTexture);
2347    if (pingPongTexture) gl.deleteTexture(pingPongTexture);
2348    if (framebuffer) gl.deleteFramebuffer(framebuffer);
2349    if (pingPongFramebuffer) gl.deleteFramebuffer(pingPongFramebuffer);
2350    if (positionBuffer) gl.deleteBuffer(positionBuffer);
2351    if (texCoordBuffer) gl.deleteBuffer(texCoordBuffer);
2352    if (spinProgram) gl.deleteProgram(spinProgram);
2353    if (compositeProgram) gl.deleteProgram(compositeProgram);
2354    if (invertProgram) gl.deleteProgram(invertProgram);
2355    if (kawaseDownProgram) gl.deleteProgram(kawaseDownProgram);
2356    if (kawaseUpProgram) gl.deleteProgram(kawaseUpProgram);
2357    for (const mip of kawaseMips) {
2358      if (mip.texture) gl.deleteTexture(mip.texture);
2359      if (mip.framebuffer) gl.deleteFramebuffer(mip.framebuffer);
2360    }
2361    kawaseMips = [];
2362    if (sharpenProgram) gl.deleteProgram(sharpenProgram);
2363    if (shearProgram) gl.deleteProgram(shearProgram);
2364    if (suckProgram) gl.deleteProgram(suckProgram);
2365    // Flood fill resources
2366    if (floodSeedProgram) gl.deleteProgram(floodSeedProgram);
2367    if (floodJFAProgram) gl.deleteProgram(floodJFAProgram);
2368    if (floodFillProgram) gl.deleteProgram(floodFillProgram);
2369    if (floodTexture1) gl.deleteTexture(floodTexture1);
2370    if (floodTexture2) gl.deleteTexture(floodTexture2);
2371    if (floodFramebuffer1) gl.deleteFramebuffer(floodFramebuffer1);
2372    if (floodFramebuffer2) gl.deleteFramebuffer(floodFramebuffer2);
2373    // Layer composite resources
2374    if (layerCompositeProgram) gl.deleteProgram(layerCompositeProgram);
2375    if (layerTextures) {
2376      layerTextures.forEach(tex => gl.deleteTexture(tex));
2377      layerTextures = null;
2378    }
2379    gl = null;
2380  }
2381  canvas = null;
2382  initialized = false;
2383  resetAccumulators();
2384}
2385
2386export default { 
2387  gpuSpin, 
2388  gpuComposite,
2389  gpuZoom,
2390  gpuScroll,
2391  gpuContrast,
2392  gpuBrightness,
2393  gpuInvert,
2394  gpuBlur,
2395  gpuSharpen,
2396  gpuShear,
2397  gpuSuck,
2398  gpuFlood,
2399  gpuCompositeLayers,
2400  isGpuEffectsAvailable,
2401  isGpuFloodAvailable,
2402  isGpuLayerCompositeAvailable,
2403  resetAccumulators,
2404  cleanupGpuEffects 
2405};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.