// AVA — WebGL blend compositor (v7.15). // // Implements the blend keywords Canvas2D has no composite operator for // (21 of the 41): dissolve, linear burn, darker/lighter color, vivid / // linear / pin light, hard mix, a TRUE clamped subtract, divide, average, // negation, phoenix, grain extract/merge, reflect, glow, and the four // After Effects masks (stencil / silhouette, luma + alpha). // // Per frame the engine draws the stroke passes onto a scratch canvas // (source-over, so the two-pass glow keeps its usual tuning), then: // 1. uploads the live canvas (backdrop) and the scratch (stroke layer) // as premultiplied textures, // 2. renders one fullscreen quad with the mode's formula, // 3. the engine copies the result back with composite 'copy'. // // Per pixel: a = straight backdrop color, b = straight stroke color, // c = the stroke's coverage (scratch alpha, AA included). The standard // tail is out = mix(a, modeRGB, c) at opacity mix(dst.a, 1, c) — // outside the strokes the backdrop passes through EXACTLY, so the // 'copy' back is lossless. The four masks scale the backdrop instead, // dissolve speckles at full intensity (re-seeded from uFrame so the // grain animates), and everything is written premultiplied (the WebGL // context is premultipliedAlpha: true). // // Formulas per the sources documented in blend.ts. Fallbacks: if WebGL // is unavailable the engine composites each gpu mode with its nearest // native operator (spec.fallbackOp) — see engine.ts gpuEnsure. const VERT_SRC = ` attribute vec2 aPos; varying vec2 vUV; void main() { // texture t=0 is the canvas's TOP row; flip so the quad renders upright vUV = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5); gl_Position = vec4(aPos, 0.0, 1.0); } ` const FRAG_SRC = ` precision highp float; varying vec2 vUV; uniform sampler2D uDst; uniform sampler2D uSrc; uniform int uMode; uniform float uFrame; float lum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); } float hash(vec2 p) { // Hoskins-style hash: no large-argument sin (which skews badly on some // GPU stacks -- measured in the v7.15 E2E: the classic sin-hash spent // 95% of its values below 0.51, making dissolve nearly deterministic). vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } vec3 vivid(vec3 a, vec3 b) { vec3 burn = max(vec3(1.0) - (vec3(1.0) - a) / max(vec3(2.0) * b, vec3(1e-3)), vec3(0.0)); vec3 dodge = min(a / max(vec3(2.0) - vec3(2.0) * b, vec3(1e-3)), vec3(1.0)); return mix(burn, dodge, step(vec3(0.5), b)); } void main() { vec4 D = texture2D(uDst, vUV); // backdrop, premultiplied upload vec4 S = texture2D(uSrc, vUV); // stroke layer, premultiplied upload float c = S.a; // stroke coverage 0..1 (AA included) vec3 a = D.a > 0.0 ? D.rgb / max(D.a, 1e-4) : vec3(0.0); // straight backdrop vec3 b = c > 0.0 ? S.rgb / max(c, 1e-4) : vec3(0.0); // straight stroke // -- special-tail modes ------------------------------------------------ if (uMode == 1) { // dissolve: stochastic full-intensity speckle // the +uFrame offset re-rolls the grain every frame (animated speckle) gl_FragColor = hash(vUV * 997.0 + vec2(uFrame * 0.618, uFrame * 0.317)) < c ? vec4(b, 1.0) : D; return; } if (uMode == 18) { // stencil luma: the stroke's brightness masks float m = mix(1.0, lum(b), c); gl_FragColor = vec4(D.rgb * m, D.a * m); return; } if (uMode == 19) { // silhouette luma float m = mix(1.0, 1.0 - lum(b), c); gl_FragColor = vec4(D.rgb * m, D.a * m); return; } if (uMode == 20) { // stencil alpha: the stroke's own opacity masks float m = mix(1.0, S.a, c); gl_FragColor = vec4(D.rgb * m, D.a * m); return; } if (uMode == 21) { // silhouette alpha float m = mix(1.0, 1.0 - S.a, c); gl_FragColor = vec4(D.rgb * m, D.a * m); return; } // -- separable families -------------------------------------------------- vec3 R = a; if (uMode == 2) R = max(a + b - 1.0, 0.0); // linear burn else if (uMode == 3) R = (lum(b) < lum(a)) ? b : a; // darker color else if (uMode == 4) R = (lum(b) > lum(a)) ? b : a; // lighter color else if (uMode == 5) R = vivid(a, b); // vivid light else if (uMode == 6) R = clamp(a + 2.0 * b - 1.0, 0.0, 1.0); // linear light else if (uMode == 7) { // pin light vec3 lo = min(a, vec3(2.0) * b); vec3 hi = max(a, vec3(2.0) * b - vec3(1.0)); R = mix(lo, hi, step(vec3(0.5), b)); } else if (uMode == 8) R = step(vec3(0.5), vivid(a, b)); // hard mix else if (uMode == 9) R = max(a - b, 0.0); // subtract (true) else if (uMode == 10) R = min(a / max(b, vec3(1e-3)), vec3(1.0)); // divide else if (uMode == 11) R = (a + b) * 0.5; // average else if (uMode == 12) R = vec3(1.0) - abs(a - b); // negation else if (uMode == 13) R = min(a * a / max(vec3(1.0) - b, vec3(1e-3)), vec3(1.0)); // reflect else if (uMode == 14) R = min(b * b / max(vec3(1.0) - a, vec3(1e-3)), vec3(1.0)); // glow else if (uMode == 15) R = min(a, b) - max(a, b) + vec3(1.0); // phoenix else if (uMode == 16) R = clamp(a - b + 0.5, 0.0, 1.0); // grain extract else if (uMode == 17) R = clamp(a + b - 0.5, 0.0, 1.0); // grain merge // -- standard tail: coverage-weighted, written premultiplied ---------------- float outA = mix(D.a, 1.0, c); gl_FragColor = vec4(mix(a, R, c) * outA, outA); } ` function compileShader(gl: WebGLRenderingContext, kind: number, src: string): WebGLShader | null { const sh = gl.createShader(kind) if (!sh) return null gl.shaderSource(sh, src) gl.compileShader(sh) if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { return null } return sh } export class BlendCompositor { readonly canvas: HTMLCanvasElement /** true once WebGL is proven unavailable — callers fall back per mode */ failed = false private gl: WebGLRenderingContext | null = null private prog: WebGLProgram | null = null private quad: WebGLBuffer | null = null private texDst: WebGLTexture | null = null private texSrc: WebGLTexture | null = null private uDst = 0 private uSrc = 0 private uMode = 0 private uFrame = 0 constructor() { this.canvas = document.createElement('canvas') } /** (Re)create the context/program if needed; false = give up (once). */ ensure(w: number, h: number): boolean { if (this.failed) return false if (!this.gl) { const gl = this.canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, preserveDrawingBuffer: true, }) as WebGLRenderingContext | null if (!gl) { this.failed = true return false } this.gl = gl if (!this.build(gl)) { this.failed = true return false } } if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w this.canvas.height = h } return true } private build(gl: WebGLRenderingContext): boolean { const vs = compileShader(gl, gl.VERTEX_SHADER, VERT_SRC) const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC) if (!vs || !fs) return false const prog = gl.createProgram() if (!prog) return false gl.attachShader(prog, vs) gl.attachShader(prog, fs) gl.linkProgram(prog) if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return false this.prog = prog // fullscreen quad this.quad = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, this.quad) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW) const aPos = gl.getAttribLocation(prog, 'aPos') gl.enableVertexAttribArray(aPos) gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0) // textures (created once; texImage2D re-specifies them per frame) for (const which of [0, 1]) { const tex = gl.createTexture() gl.activeTexture(which === 0 ? gl.TEXTURE0 : gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, tex) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) if (which === 0) this.texDst = tex else this.texSrc = tex } // canvas uploads arrive premultiplied; do the un/premultiply math in the shader gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true) this.uDst = gl.getUniformLocation(prog, 'uDst') this.uSrc = gl.getUniformLocation(prog, 'uSrc') this.uMode = gl.getUniformLocation(prog, 'uMode') this.uFrame = gl.getUniformLocation(prog, 'uFrame') return true } /** Render one blended frame into this.canvas. The engine then copies it * back onto the live canvas with composite 'copy'. */ blend(dst: HTMLCanvasElement, src: HTMLCanvasElement, mode: number, frame: number): void { const gl = this.gl const prog = this.prog if (!gl || !prog) return gl.viewport(0, 0, this.canvas.width, this.canvas.height) gl.useProgram(prog) gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, this.texDst) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, dst) gl.uniform1i(this.uDst, 0) gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, this.texSrc) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src) gl.uniform1i(this.uSrc, 1) gl.uniform1i(this.uMode, mode) gl.uniform1f(this.uFrame, frame) gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) } }