视频混剪-特效篇

视频特效系统:从 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. 视频特效系统
相关推荐
沐禾安信15 小时前
AVI 视频打不开?2026 四款转 MP4 工具封神
音视频·电脑录屏
兵叔物联17 小时前
基于FFmpeg的短视频自动剪辑工具
ffmpeg·音视频
AORUO奥偌17 小时前
从“拼凑”到“集成”:音视频系统如何重塑弱电智能化建设逻辑
音视频·公共广播系统·音视频系统·多媒体会议与ip广播系统·多媒体会议系统
禹亮科技18 小时前
大型国企会议室音视频集成实战:3间120㎡空间Poly G7500+LED大屏+讯飞多语言会议系统落地方案
音视频·视频会议系统集成·上海禹亮科技·讯飞听见多语言会议系统·poly g7500
音视频牛哥20 小时前
SmartMediaKit macOS版RTSP/RTMP低延迟播放器:技术架构、核心能力与应用实践
音视频·低延迟rtsp播放器·macos直播播放器·macos rtsp播放器·macos rtmp播放器·rtsp播放器mac版·rtmp播放器mac版
潜创微科技1 天前
沁恒CH9339:USB3.0超高速对拷,两台电脑一根线共享一切
音视频
Cxiaomu1 天前
从 TRTC Demo 到独立音视频服务:React + TRTC Web SDK 服务化实践
前端·react.js·音视频
EasyGBS1 天前
政务视频资源汇聚:国标GB28181视频平台EasyGBS在“一网统管”中的定位和落地路径
网络·音视频·政务
蝉蜕日记2 天前
视频压缩 v1.1.31:高效压缩,无损画质,释放存储空间
音视频·生活·视频编解码·视频·软件需求
lumengabc2 天前
视频制作流程MP4旁白语音生成裁剪视频压缩python处理ppt转视频M4a格式
音视频