WebGL 实时合成管线:纹理、Shader 滤镜链与离屏渲染调度

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 输出)。不一致的表现:预览颜色比原片"灰一点/艳一点",导出后又不一样。最低限度的正确性要求:

  1. 视频纹理上传时确认浏览器是否已转 sRGB(HTMLVideoElement 经 texImage2D 通常是 sRGB 值)。
  2. shader 内线性计算与 sRGB 编码的边界要清楚------视觉要求高时用 SRGB8_ALPHA8 内部格式或手动 sRGB↔linear 转换。
  3. 与 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 写得多炫,而在"预览与导出共享同一套效果语义"------同一条包络、同一个进度、同一份色彩空间。用户感受到的"专业",就是预览窗口里看到的每一帧,都能在导出文件里逐帧兑现。

相关推荐
粥里有勺糖1 小时前
体验一下最近比较🔥的闪卡SKILL
前端·github
前端snow1 小时前
ai agent --- LangSmith 全链路观测
前端
Csvn1 小时前
Hooks 原理:把"每次重跑的函数"变成"有记忆的组件"
前端
计算机魔术师1 小时前
用AI拒批老人看病?美国这个试点项目的激励机制出了大问题
前端
风骏时光牛马1 小时前
AI服务线上响应异常故障
前端
IT_陈寒1 小时前
JavaScript的this指向问题又让我加了个班
前端·人工智能·后端
PC2005_cloud1 小时前
Nginx 学习笔记:Server 块配置详解,域名路由与多站点部署实战
前端·后端
YIAN1 小时前
LangChain.js 对话记忆体系(一):内存存储与文件持久化,让 AI 拥有对话记忆
前端·后端·langchain
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY64
前端