视频混剪-特效篇

视频特效系统:从 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. 视频特效系统
相关推荐
凉辰4 小时前
使用uni.createInnerAudioContext()播放指定音频(踩坑分享功能)
开发语言·javascript·音视频
AI资源库4 小时前
Remotion 一个用 React 程序化制作视频的框架
人工智能·语言模型·音视频
永远都不秃头的程序员(互关)5 小时前
基于CANN的ops-signal仓库实现AIGC音频生成中的动态窗函数融合优化——从STFT预处理到端到端低延迟合成
aigc·音视频
薛定谔的猫喵喵5 小时前
基于PyQt5的视频答题竞赛系统设计与实现
开发语言·qt·音视频
byte轻骑兵6 小时前
从HCI报文透视LE Audio重连流程(3):音频流建立、同步与终止
音视频·蓝牙·le audio·cig/cis·广播音频
三十_A6 小时前
零基础通过 Vue 3 实现前端视频录制 —— 从原理到实战
前端·vue.js·音视频
愚公搬代码7 小时前
【愚公系列】《AI短视频创作一本通》018-AI语音及音乐的创作(短视频背景音乐的选择及创作)
人工智能·音视频
sweetone18 小时前
LINN莲CLASSIK桌面音响微修
经验分享·音视频
晚霞的不甘1 天前
CANN 编译器深度解析:UB、L1 与 Global Memory 的协同调度机制
java·后端·spring·架构·音视频
美狐美颜SDK开放平台1 天前
多终端适配下的人脸美型方案:美颜SDK工程开发实践分享
人工智能·音视频·美颜sdk·直播美颜sdk·视频美颜sdk