openvisengine/src/lib/ava/blend-gpu.ts

244 lines
9.9 KiB
TypeScript
Executable file

// 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)
}
}