视频转场动画:从原理到Shader实战
转场动画,说白了就是两个画面之间的「过渡」。你想想看,如果视频播放时画面突然切换,观众会觉得很生硬。转场动画就是解决这个问题的。
我在做视频播放器的时候,最开始只做了简单的淡入淡出。后来产品经理说「太单调了」,我才开始深入研究各种转场效果。今天我就把这块的经验分享给你。
转场动画的核心原理
转场动画的本质是什么?其实就一句话:在时间轴上,让画面A逐渐消失,画面B逐渐出现。
具体来说,我们需要两个关键要素:
- 两个纹理输入:当前帧(画面A)和下一帧(画面B)
- 一个进度值:从0到1,控制过渡的完成度
嗯,这里要注意。转场不是简单的「A变透明,B变不透明」。那样太粗糙了。真正的转场需要结合像素位置、时间进度、甚至几何变换来产生丰富的视觉效果。
核心公式:
输出像素 = mix(画面A像素, 画面B像素, 过渡因子)
其中过渡因子 = f(进度, 像素坐标, 时间)
我习惯把转场分为三类:
- 线性转场:滑动、擦除等,基于像素位置做判断
- 非线性转场:淡入淡出、溶解等,基于时间做混合
- 自定义转场:用Shader实现任意效果
滑动转场:最常用的转场效果
滑动转场,说白了就是「画面B把画面A推开」。实现起来其实很简单:
- 根据进度值计算一个分割线位置
- 分割线左边显示画面B,右边显示画面A
- 随着进度增加,分割线从左向右移动
我的经验:滑动转场在Android上性能最好。因为每个像素只需要做一次比较运算,不需要复杂的数学计算。我在做短视频编辑SDK时,默认转场就用的滑动效果。
// 滑动转场 Fragment Shader
precision mediump float;
uniform sampler2D u_textureA; // 当前帧
uniform sampler2D u_textureB; // 下一帧
uniform float u_progress; // 0.0 ~ 1.0
varying vec2 v_texCoord;
void main() {
// 根据进度计算分割线位置
float split = u_progress;
// 判断当前像素在分割线的哪一侧
if (v_texCoord.x < split) {
// 左侧显示画面B
gl_FragColor = texture2D(u_textureB, v_texCoord);
} else {
// 右侧显示画面A
gl_FragColor = texture2D(u_textureA, v_texCoord);
}
}
你可能会问:「为什么不用if-else?GPU分支效率低啊。」嗯,你说得对。但在这个场景下,所有像素走同一个分支,所以没问题。如果每个像素走不同分支,那才需要担心。
淡入淡出转场:最平滑的过渡
淡入淡出,就是画面A逐渐变透明,画面B逐渐变不透明。实现起来更简单:
// 淡入淡出 Fragment Shader
precision mediump float;
uniform sampler2D u_textureA;
uniform sampler2D u_textureB;
uniform float u_progress;
varying vec2 v_texCoord;
void main() {
vec4 colorA = texture2D(u_textureA, v_texCoord);
vec4 colorB = texture2D(u_textureB, v_texCoord);
// 线性插值
gl_FragColor = mix(colorA, colorB, u_progress);
}
我曾经踩过一个坑:直接用mix做淡入淡出,结果画面中间出现「鬼影」。后来发现是因为两个纹理的色域不一致。画面A是sRGB,画面B是Display P3,直接混合当然出问题。
避坑指南:做淡入淡出前,一定要确保两个纹理在同一个色彩空间。我建议统一转成线性空间再做混合,最后再转回输出色域。
自定义转场Shader:玩出花样的关键
前面两种转场都是「线性」的。如果你想做更炫酷的效果,比如:
- 圆形展开:画面B从一个点向外扩散
- 百叶窗:画面B像百叶窗一样逐条出现
- 波浪过渡:画面A像波浪一样被画面B覆盖
这些都需要自定义Shader。核心思路就是:设计一个过渡函数,根据像素位置和时间进度,决定显示哪个画面。
// 圆形展开转场
precision mediump float;
uniform sampler2D u_textureA;
uniform sampler2D u_textureB;
uniform float u_progress;
uniform vec2 u_resolution;
varying vec2 v_texCoord;
void main() {
vec2 uv = v_texCoord;
// 计算像素到中心的距离(归一化)
vec2 center = vec2(0.5, 0.5);
float dist = distance(uv, center);
// 最大距离(对角线的一半)
float maxDist = distance(vec2(0.0, 0.0), center);
// 根据进度计算半径
float radius = u_progress * maxDist;
// 在圆内显示画面B,圆外显示画面A
float alpha = smoothstep(radius - 0.05, radius + 0.05, dist);
vec4 colorA = texture2D(u_textureA, uv);
vec4 colorB = texture2D(u_textureB, uv);
gl_FragColor = mix(colorA, colorB, 1.0 - alpha);
}
关键点:自定义转场的核心是「距离场」+「阈值判断」。你设计什么样的距离场,就得到什么样的转场效果。
我习惯把转场Shader拆成三个部分:
| 组件 | 作用 | 示例 |
|---|---|---|
| 距离计算 | 定义像素到某个参考点的距离 | 欧几里得距离、曼哈顿距离 |
| 阈值函数 | 根据进度决定哪些像素被替换 | step、smoothstep |
| 混合策略 | 在边界处做平滑处理 | mix、线性插值 |
举个例子,如果你想做「百叶窗」效果,距离计算就变成:
// 百叶窗转场
float stripWidth = 0.1; // 每条的宽度
float stripIndex = floor(uv.x / stripWidth);
float stripPos = fract(uv.x / stripWidth);
// 根据进度决定每条显示哪个画面
float threshold = u_progress;
float showB = step(stripPos, threshold);
你看,思路都是一样的。只是距离计算的方式不同。
我的建议:刚开始做自定义转场时,先画一个「距离场热力图」。把距离值映射成颜色,看看你的距离场长什么样。这样调试起来特别直观。
最后说一句,转场动画的性能瓶颈往往不在Shader本身,而在纹理上传。如果你每帧都从CPU上传纹理到GPU,那再好的Shader也救不了。我一般用SurfaceView的Surface直接渲染,或者用OpenGL的PBO做异步纹理上传。
好了,转场动画的原理和实战就这些。记住核心思路:两个纹理 + 一个进度 + 一个过渡函数 = 无限可能。