视频混剪-特效篇

视频特效系统:从 Shader 到 Ping-Pong 渲染

BaseCut 技术博客第七篇。这篇讲特效系统的设计与实现------如何用 WebGL 实现专业级的视频特效。

需求分析

视频特效和滤镜不同:

对比项 滤镜 特效
依赖时间 是(随时间变化)
参数 简单(亮度/对比度) 复杂(频率/方向/强度)
采样方式 单点采样 多点/偏移采样
叠加方式 通常只应用一个 可多个叠加

特效需要支持:

  • 时间敏感的动画效果
  • 多特效链式叠加
  • 入场/出场动画
  • 实时预览和导出一致性

架构设计

整体流程

复制代码
视频帧 → FBO 预渲染 → 特效链 → 屏幕输出
               ↓
         Ping-Pong 渲染
        ├── Effect 1 → FBO A
        ├── Effect 2 → FBO B  
        ├── Effect 3 → FBO A
        └── 最终输出 → Screen

核心类

typescript 复制代码
// 特效管理器
class EffectManager {
  // 程序缓存(按特效类型)
  private programCache: Map<VideoEffectType, CompiledEffectProgram>
  
  // 帧缓冲(用于特效链式渲染)
  private framebuffers: WebGLFramebuffer[]
  private frameTextures: WebGLTexture[]
  
  // 核心方法
  applyEffects(inputTexture, effects, timeInClip, globalTime): boolean
}

为什么需要 Ping-Pong 渲染?

问题:多特效叠加

假设用户给视频加了 3 个特效:闪白 → 故障 → 老电影。

错误做法:直接叠加

javascript 复制代码
// ❌ 每个特效都读写同一个纹理
applyFlash(texture)   // 读 texture,写 texture
applyGlitch(texture)  // 读 texture,写 texture (读到的是上一步的结果吗?)
applyFilmGrain(texture)

WebGL 不允许同时读写同一个纹理(会产生未定义行为)。

正确做法:Ping-Pong

javascript 复制代码
// ✅ 交替使用两个 FBO
const fboA = createFramebuffer()
const fboB = createFramebuffer()

// 特效 1:读原始纹理 → 写 FBO A
applyFlash(inputTexture, fboA)

// 特效 2:读 FBO A → 写 FBO B
applyGlitch(fboA.texture, fboB)

// 特效 3:读 FBO B → 写屏幕
applyFilmGrain(fboB.texture, screen)

每次都从上一步的输出读取,写入下一个缓冲区,像打乒乓球一样来回切换。


特效着色器实现

通用 Uniform

所有特效共享这些参数:

glsl 复制代码
uniform sampler2D u_texture;    // 输入纹理
uniform float u_time;           // 全局时间
uniform vec2 u_resolution;      // 画布尺寸
uniform float u_intensity;      // 特效强度 (0~1)

闪白特效 (Flash)

最简单的特效,用白色和原色混合:

glsl 复制代码
void main() {
  vec4 color = texture2D(u_texture, v_texCoord);
  
  // 保留原始 Alpha,仅处理不透明区域
  float alpha = color.a;
  
  // 与白色混合
  color.rgb = mix(color.rgb, u_color, u_intensity);
  color.rgb *= alpha; // 透明区域保持透明
  
  gl_FragColor = color;
}

故障特效 (Glitch)

这个比较复杂,模拟数字信号干扰:

glsl 复制代码
void main() {
  vec2 uv = v_texCoord;
  float alpha = texture2D(u_texture, uv).a;
  
  // 1. 水平抖动
  float noise = fract(sin(dot(uv.y * 100.0, u_time * 10.0)) * 43758.5453);
  float shake = (noise - 0.5) * u_intensity * 0.02;
  uv.x += shake;
  
  // 2. RGB 分离(色差)
  float rgbSplit = u_rgbSplit * u_intensity * 0.01;
  float r = texture2D(u_texture, uv + vec2(rgbSplit, 0.0)).r;
  float g = texture2D(u_texture, uv).g;
  float b = texture2D(u_texture, uv - vec2(rgbSplit, 0.0)).b;
  
  // 3. 扫描线
  float scanline = sin(uv.y * u_resolution.y * 2.0) * 0.5 + 0.5;
  scanline = 1.0 - u_scanlineIntensity * (1.0 - scanline) * u_intensity;
  
  vec3 color = vec3(r, g, b) * scanline;
  
  // 关键:透明区域强制为黑色
  color *= alpha;
  
  gl_FragColor = vec4(color, alpha);
}

老电影特效 (Film Grain)

模拟老胶片的颗粒感:

glsl 复制代码
// 噪声函数
float noise(vec2 p) {
  return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}

void main() {
  vec4 color = texture2D(u_texture, v_texCoord);
  float alpha = color.a;
  
  // 1. 颗粒噪声
  float grain = noise(v_texCoord * u_resolution + u_time * 1000.0);
  grain = (grain - 0.5) * u_grainIntensity * u_intensity;
  
  // 2. 垂直划痕
  float scratchX = floor(v_texCoord.x * 100.0) / 100.0;
  float scratch = noise(vec2(scratchX, u_time * 10.0));
  scratch = scratch > 0.97 ? u_scratchIntensity : 0.0;
  
  // 3. 亮度闪烁
  float flicker = 1.0 - u_flickerIntensity * (noise(vec2(u_time * 8.0, 0.0)) - 0.5);
  
  // 4. 复古色调(棕褐色)
  float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
  vec3 sepia = vec3(gray) * vec3(1.2, 1.0, 0.8);
  color.rgb = mix(color.rgb, sepia, u_sepiaAmount * u_intensity);
  
  // 合成
  color.rgb = (color.rgb + grain + scratch) * flicker;
  color.rgb *= alpha;
  
  gl_FragColor = vec4(color.rgb, alpha);
}

入场/出场动画

特效不应该突然出现或消失,需要渐入渐出:

typescript 复制代码
function getEffectIntensity(effect: VideoEffect, timeInClip: number): number {
  const { startTime, duration, fadeIn = 0, fadeOut = 0 } = effect
  const endTime = startTime + duration
  
  // 计算在特效内的相对时间
  const relativeTime = timeInClip - startTime
  
  // 入场动画
  if (relativeTime < fadeIn) {
    return relativeTime / fadeIn
  }
  
  // 出场动画
  if (relativeTime > duration - fadeOut) {
    return (duration - relativeTime) / fadeOut
  }
  
  // 正常强度
  return 1.0
}

这个强度值会乘以 u_intensity,实现平滑过渡。


关键修复:透明区域处理

问题

竖屏视频在横屏画布上会有黑边:

复制代码
┌─────────────────────────┐
│  黑边  │  视频  │  黑边  │
│        │        │        │
│        │        │        │
└─────────────────────────┘

如果特效不处理透明区域,就会出现"特效溢出"------黑边也被特效污染了。

原因

故障特效使用偏移采样:

glsl 复制代码
float r = texture2D(u_texture, uv + vec2(rgbSplit, 0.0)).r;

当采样点在透明区域(黑边)时,可能采样到视频边缘的像素。

解决方案

硬性 Alpha 遮罩

glsl 复制代码
// 在着色器最后
color *= alpha;  // RGB 乘以 Alpha

gl_FragColor = vec4(color, alpha);

这确保透明区域的 RGB 始终为 0(黑色),即使采样到了其他内容。


导出一致性

问题

预览正常,但导出黑屏。

根因分析

特效有独立的时间范围。一个视频片段可能:

  • 0-2 秒:无特效
  • 2-4 秒:有特效
  • 4-6 秒:无特效

代码流程:

typescript 复制代码
// 检测到片段有特效附加
if (effects.length > 0) {
  // 渲染到 FBO
  renderFrame(source, fbo)
  
  // 应用特效
  const applied = applyEffects(fboTexture, effects, timeInClip)
  
  // BUG:如果当前帧没有激活的特效,applyEffects 返回 false
  // FBO 内容没有被复制到屏幕!
  if (!applied) {
    // 这里需要手动将 FBO 内容渲染到屏幕
    renderTextureToScreen(fboTexture)  // ← 关键修复
  }
}

解决方案

检查 applyEffects 返回值,如果返回 false(无激活特效),手动将 FBO 纹理渲染到屏幕。


状态沙箱

WebGL 是状态机,特效切换时必须正确管理状态:

typescript 复制代码
// 特效渲染完成后,恢复干净状态
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, null)
gl.disable(gl.BLEND)
gl.useProgram(null)

否则上一个特效的状态会污染下一帧的渲染。


性能优化

1. 程序缓存

着色器编译很慢(可能需要几十毫秒),所以按类型缓存:

typescript 复制代码
private programCache: Map<VideoEffectType, CompiledEffectProgram> = new Map()

getOrCreateProgram(type: VideoEffectType): CompiledEffectProgram {
  if (this.programCache.has(type)) {
    return this.programCache.get(type)!
  }
  
  // 只在首次使用时编译
  const program = this.compileProgram(type)
  this.programCache.set(type, program)
  return program
}

2. 静态几何缓冲

特效使用的全屏 Quad 是固定的,所以用静态缓冲:

typescript 复制代码
private initBuffers(): void {
  // 顶点位置(永远是全屏)
  const positions = new Float32Array([
    -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1
  ])
  
  gl.bindBuffer(gl.ARRAY_BUFFER, this.geometryBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW)  // STATIC_DRAW
}

3. 懒初始化

只有在真正需要特效时才初始化 FBO:

typescript 复制代码
getFramebuffer(index: number): WebGLFramebuffer | null {
  if (this.framebuffers.length === 0) {
    this.initFramebuffers()  // 懒初始化
  }
  return this.framebuffers[index] || null
}

支持的特效列表

特效 类型 主要参数
Flash 闪白 颜色、强度
Shake 抖动 频率、方向
Glitch 故障 RGB分离、扫描线、块状干扰
Radial Blur 径向模糊 中心点、采样数
Chromatic 色差 角度
Pixelate 像素化 像素大小
Invert 反色 -
Film Grain 老电影 颗粒、划痕、闪烁、复古色调
Vignette 暗角 半径、柔和度
Split Screen 分屏 分屏数、方向、间隔

下一篇

系列目录:

  1. 技术选型与项目结构
  2. 时间轴数据模型
  3. WebGL 渲染与滤镜
  4. 转场动画实现
  5. WebCodecs 视频导出
  6. LeaferJS 贴纸系统
  7. 视频特效系统
相关推荐
de之梦-御风18 小时前
【视频投屏】最小可用(MVP)局域网投屏”开源项目架构
架构·开源·音视频
努力犯错18 小时前
如何在ComfyUI中配置LTX-2:2026年AI视频生成完整指南
大数据·人工智能·计算机视觉·语言模型·开源·音视频
玖日大大18 小时前
Wan2.1视频生成模型本地部署完整指南
人工智能·音视频
jbk33111 天前
开发了一个免费的批量视频语音字幕识别工具,核心点是可批量自动处理识别任务
音视频
Coovally AI模型快速验证1 天前
超越Sora的开源思路:如何用预训练组件高效训练你的视频扩散模型?(附训练代码)
人工智能·算法·yolo·计算机视觉·音视频·无人机
weixin_436804071 天前
音频在线预览工具 - 输入URL即刻播放远程音频
音视频
kaizq1 天前
AI-MCP本地流媒音频服务器设计与CherryStudio部署应用
音视频·cherrystudio·fastmcp·streamablehttp·本地mcp流媒服务器
thinkMoreAndDoMore1 天前
audioservice
音视频
TEL189246224771 天前
IT6517BFN:单芯片DisplayPort转VGA转换器,集成MCU
音视频·实时音视频·视频编解码