WebGL 实时合成管线:纹理、Shader 滤镜链与离屏渲染调度
当预览层数和特效超过 DOM/CSS 的能力------混合模式、LUT 调色、模糊、发光、自定义转场------就需要一条真正的 GPU 合成管线:把每路视频变成纹理,在 shader 里逐像素处理,最终合成到屏幕。本文拆解浏览器端 WebGL/WebGL2 合成器的工程结构:纹理生命周期、滤镜链的图模型、 uniforms 与时间轴参数绑定、离屏 FBO 调度与性能边界。
一、为什么需要它,以及什么时候不该用
DOM 合成(每层一个 <video> + CSS transform/opacity/mix-blend-mode)零代码成本,且浏览器合成器本身走 GPU。它的边界:
- mix-blend-mode 的可用集合固定,无法自定义像素运算。
- CSS filter 只有 blur/brightness/contrast 等十几个固定效果,没有 LUT、色彩空间转换、局部遮罩。
- 转场只能用预定义动画,做不了"位移扭曲""自定义擦除 shader"。
越过这些边界就上 WebGL。但要守住一条纪律:WebGL 是为"DOM 表达不了的效果"存在的,不是重写整个渲染栈------能 CSS 解决的(简单位移、透明度、基础圆角)就留在 CSS,shader 数量每多一个,调试成本和性能风险都上一个台阶。
二、最小合成器:从视频到屏幕的一帧
渲染循环的骨架:
ts
class Compositor {
private gl: WebGL2RenderingContext
private layers: Layer[] = []
frame(timelineTime: number) {
const gl = this.gl
gl.bindFramebuffer(gl.FRAMEBUFFER, null) // 最终目标:默认帧缓冲(屏幕)
gl.viewport(0, 0, this.width, this.height)
gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
for (const layer of this.layersInZOrder(timelineTime)) {
if (!layer.visibleAt(timelineTime)) continue
layer.updateTextures() // video → texImage2D(仅内容变化时)
this.pass(layer, timelineTime) // 一个 draw call
}
}
}
每一层 = 一个四边形(两个三角形)+ 一张视频纹理 + 一组 uniforms(变换矩阵、opacity、时间)。视频到纹理的更新:
ts
function uploadVideoTexture(gl: WebGL2RenderingContext, tex: WebGLTexture, video: HTMLVideoElement) {
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) // 视频顶行朝下,纹理底行朝上
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
}
只在 video.requestVideoFrameCallback 报告新帧时上传(Chrome 的 rVFC 给出精确的呈现时间戳),避免每帧重复 texImage2D。纹理参数用 CLAMP_TO_EDGE + LINEAR,非幂次尺寸 WebGL2 原生支持。
三、图层的几何:时间线参数到变换矩阵
clip 的 transform(x/y/scale/rotation/opacity)在 shader 侧体现为一个 3×3 仿射矩阵,矩阵在 CPU 端计算后以 uniform 传入:
ts
function clipModelMatrix(clip: Clip, canvas: CanvasSize): Mat3 {
// 时间线百分比/像素 → NDC(归一化设备坐标 -1..1)
const translate = mat3Translation(ndcX(clip.transform.x, canvas.w), ndcY(clip.transform.y, canvas.h))
const rotate = mat3Rotation(-clip.transform.rotation * Math.PI / 180)
const scale = mat3Scale(clip.transform.scale, clip.transform.scale)
return multiply(translate, rotate, scale)
}
顶点着色器只做矩阵乘法,片段着色器只做采样与混合------几何放 CPU、像素放 GPU 是合成器的标准分工:
glsl
// vertex
attribute vec2 a_pos; attribute vec2 a_uv;
uniform mat3 u_model;
varying vec2 v_uv;
void main() {
vec3 p = u_model * vec3(a_pos, 1.0);
gl_Position = vec4(p.xy, 0.0, 1.0);
v_uv = a_uv;
}
// fragment(最简:纹理 × 不透明度)
precision mediump float;
uniform sampler2D u_tex; uniform float u_opacity;
varying vec2 v_uv;
void main() {
vec4 c = texture2D(u_tex, v_uv);
gl_FragColor = vec4(c.rgb, c.a * u_opacity);
}
四、滤镜链:从串行滤镜到有向图
简单需求用"一层一 pass"串链即可:画面 → 调色 → 模糊 → 发光 → 合成。但每加一个效果都全分辨率画一遍,多个全屏 pass 的带宽开销陡增。工程化结构是滤镜图(filter graph):
ts
interface RenderNode {
id: string
inputs: string[] // 上游节点 id(纹理)
program: WebGLProgram
uniforms: UniformBinding[]
target?: Framebuffer // 无 target → 屏幕
}
// 拓扑排序后顺序执行;输出到 FBO 的纹理供下游采样
典型图:
text
video-A ─┬─→ LUT 调色 ──────────────┐
└─→ 高斯模糊(降采样 1/2) ──┤→ 混合(add,发光) → 遮罩 → 输出
video-B ─→ 基础处理 ───────────────┘
字幕层 ─────────────────────────→ alpha over 叠加
图模型的三个好处:可复用中间纹理(模糊结果同时供发光和阴影使用)、可裁剪死分支(某效果强度为 0 的节点整支跳过)、可做分辨率分层(模糊在 1/2 尺寸 FBO 上跑再放大,省 75% 填充率,且模糊本就不需要高频细节)。
FBO 池:不要每帧新建纹理
离屏渲染目标(FBO + 挂颜色纹理)的创建很贵且会触发显存碎片。维护一个按尺寸复用的池:
ts
class FboPool {
private pool = new Map<string, WebGLFramebuffer[]>()
acquire(w: number, h: number): Framebuffer {
const key = `${w}x${h}`
return this.pool.get(key)?.pop() ?? this.create(w, h)
}
release(fbo: Framebuffer) { this.pool.get(`${fbo.w}x${fbo.h}`)?.push(fbo) }
}
每帧开始时清空池的"在用"标记、结束时全部回收;尺寸对齐到 2 的幂或固定档位(full/half/quarter),避免池里出现几十种相近尺寸。
五、uniforms 与时间轴的绑定
特效参数随时间变化(淡入、转场进度、调色关键帧)。绑定层把 document 数据映射成 uniform 值:
ts
const bindings: UniformBinding[] = [
{ name: 'u_opacity', value: (clip, t) => fadeEnvelope(clip, t) },
{ name: 'u_transition', value: (_clip, t) => clamp01((t - trans.start) / trans.duration) },
{ name: 'u_lut', value: () => lutTexture },
{ name: 'u_time', value: (_clip, t) => t },
]
fadeEnvelope 把 clip 的 fadeIn/fadeOut 字段折成一条包络(前 fadeIn 秒 0→1、后 fadeOut 秒 1→0),与导出端 FFmpeg afade/xfade 用同一套时间语义------预览所见必须等于导出所得,所有效果参数的计算函数要在共享包里实现,预览与导出两条管线引用同一份(导出端把函数结果翻译成滤镜参数)。这是合成器工程最容易翻车的地方:预览 shader 里手写一条缓动,导出滤镜里另写一条,两边永远对不齐。
六、转场:两路纹理 + 进度 uniform
转场不是一层,是同时采样两个 clip 的特殊 pass:
glsl
uniform sampler2D u_from; uniform sampler2D u_from_next;
uniform float u_p; // 0..1 转场进度
varying vec2 v_uv;
void main() {
vec4 a = texture2D(u_from, v_uv);
vec4 b = texture2D(u_from_next, v_uv);
// 例:线性擦除
float edge = u_p;
float mask = step(v_uv.x, edge);
gl_FragColor = mix(a, b, mask);
}
叠化(crossfade)是 mix(a,b,u_p);位移扭曲、缩放擦除、故障风等只是 mask 函数不同。转场区间内两个视频都要保持解码(前一段结尾和后一段开头同时上传纹理),这是 visible-set 管理要特殊处理的情况------转场期临时扩展可见集,转场结束立即释放前一段的解码器。
七、色彩:最容易被忽略的正确性问题
视频帧、CSS 颜色、画布输出的色彩空间可能不一致(BT.601/BT.709 YUV→RGB、sRGB 输出)。不一致的表现:预览颜色比原片"灰一点/艳一点",导出后又不一样。最低限度的正确性要求:
- 视频纹理上传时确认浏览器是否已转 sRGB(HTMLVideoElement 经 texImage2D 通常是 sRGB 值)。
- shader 内线性计算与 sRGB 编码的边界要清楚------视觉要求高时用
SRGB8_ALPHA8内部格式或手动 sRGB↔linear 转换。 - 与 FFmpeg 导出端的色彩矩阵(-colorspace bt709、zscale)对齐,1080p 用 709、4K HDR 用 2020/PQ。
不追求色彩管理专家级别,但"预览和导出颜色一致"必须列入验收:同一帧导出 PNG 与预览 canvas 截图做像素对比,平均色差 ΔE 控制在可接受阈值内。
八、性能预算与降级
- 填充率是主瓶颈:1080p 一个全屏 pass = 200 万像素 ×(多次纹理采样)。预算按 pass 数 × 采样数估算,而非图层数。
- 分辨率分级:模糊/发光类在 1/2~1/4 FBO 跑;最终合成才全分辨率。
- 静态层缓存:完全静止的层(图片、已结束动画的字幕)渲染到 FBO 后复用,不重画。
- 上下文丢失(context lost) :
webglcontextlost必须处理------显卡驱动重置/休眠唤醒后所有纹理和 program 消失,监听webglcontextrestored重建全部资源,否则预览永久黑屏。 - 能力降级:WebGL2 不可用(极老机器/虚拟机)→ 退回 DOM 合成,只保留基础效果,转场降级为简单淡入淡出。
九、小结
| 子系统 | 关键决策 |
|---|---|
| 边界 | CSS 能表达的不上 shader;WebGL 只做自定义像素效果 |
| 上传 | rVFC 驱动,仅新帧 texImage2D;UNPACK_FLIP_Y |
| 几何 | CPU 算 3×3 模型矩阵,shader 只采样混合 |
| 滤镜 | 有向图 + 拓扑序;中间纹理复用;模糊降分辨率 |
| FBO | 按尺寸池化复用,档位对齐 |
| 参数 | uniform 绑定函数与导出滤镜共享,保证所见即所得 |
| 转场 | 双纹理 + u_p;转场期临时扩展可见解码集 |
| 色彩 | sRGB/709 边界清晰;预览-导出色差验收 |
| 健壮性 | context lost 重建;WebGL2 不可用降级 DOM |
实时合成器的本质是把时间线数据翻译成一条每 16ms 执行一次的 GPU 指令流:哪些层可见、用什么矩阵、挂哪些纹理、跑哪个 program、画到哪个 FBO。架构上最值钱的决策不在 shader 写得多炫,而在"预览与导出共享同一套效果语义"------同一条包络、同一个进度、同一份色彩空间。用户感受到的"专业",就是预览窗口里看到的每一帧,都能在导出文件里逐帧兑现。