244 lines
9.9 KiB
TypeScript
Executable file
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)
|
|
}
|
|
}
|