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.