FIELD NOTES / FRAME INTERPOLATION
轻量级 GPU 插帧:
从运动估计到帧融合
先用四张图理解“找运动、搬像素、判断遮挡、融合颜色”,再对照 FSR 3 的公开流程和 Shader 实现。
FSR 3 作为公开工程对照;LK、相位法是独立参考路线。示意图与交互演示用于解释原理,不是实测效果或商业项目内部实现。
先看画面:插帧到底补了什么?
假设一个纹理块从 x=100 移到 x=180。中间时刻应该在 x=140。如果直接把两张图各取 50%,你会看到两个半透明块;先把两侧的块移到 x=140,再融合,才能得到一个完整的块。
这是已知匀速平移的教学演示,不是实际光流估计效果;真实视频还要处理遮挡、形变与错误运动。
参考 FSR 3:为什么要有两种运动线索?
这里参照 AMD FidelityFX Frame Interpolation 1.1.3 与 Optical Flow 1.1.2 文档,限定为公开的传统 FSR 3 路径。其光流采用多尺度块匹配与 SAD 搜索,不能把下文的 LK 或相位法当成 AMD 的实现。
| 线索 | 直观理解 | 对你的 Color-only 路线的意义 |
|---|---|---|
| 游戏运动矢量 | 引擎知道物体和相机怎么动 | 接入需要引擎数据,跨游戏可用性不同 |
| 图像光流 | 对照两张图,猜可见内容怎么动 | 可只用颜色,但匹配可能出错 |
| 深度与遮挡 | 判断谁挡住谁、哪一侧能取到颜色 | 缺深度时,要靠图像一致性等线索近似 |
AMD 的公开流程分别建立游戏运动与光流场,利用遮挡掩码拒绝无效取样,再结合匹配质量融合结果,最后补洞。值得借鉴的是每个阶段都判断信息是否可靠,而不只追求一张运动场。
LK 光流:先猜,再看怎样小步修正
拿上一帧的一个 5×5 小窗口,到下一帧找对应内容。当前猜测向右移了 2 像素,但真实是 2.4 像素。两窗口的亮度还没有完全对上;LK 利用亮度的变化方向,估计再移动多少能减小差异。
可以把 H 理解为“这个窗口能否看清两个方向的移动”。平坦区域没有线索;一条直边通常只能看清垂直于边的运动;角点或丰富纹理更容易判断二维位移。不是求出一个向量就代表可信。
大运动先在小图上找,小图的一像素对应大图的多像素;然后逐层放大结果并微调。这就是金字塔由粗到细。公式和 Shader 放在下面的实现部分。
相位法:追踪亮暗波形的变化
把局部纹理看成不同方向、不同粗细的波形。纹理移动后,波峰波谷跟着移动;相位差就是这种位置变化的线索。它与 LK 沿亮度梯度修正的思路不同。
只有一条横向波形时,无法可靠判断所有二维运动;位移超过一个周期时,还可能把“移了一大段”误看成“只移了一点”。所以简单地插值两帧相位并不等于完成实际视频插帧。
遮挡与 Error:两侧的颜色不一定都能用
前景向右移动,左边会露出此前被挡住的背景。上一帧根本没记录那块背景,不能靠运动把不存在的颜色搬出来。此时应该选择能看见它的一侧,而不是继续平均。
实际实现可以问三个问题:移动后两侧颜色像不像?从上一帧过去再返回,能不能回到原点?目标像素取样位置是否越界或被遮挡?把这些回答转成可信度,就能控制融合与降级。
例如:前向移动 +80px,沿对应位置取到的反向流是 −79px,往返差约 1px;如果反向是 −20px,差就达到 60px,应降低可信度。但往返一致仍不能保证重复纹理匹配正确。
一侧可信,就优先用一侧;两侧可信,再按时间混合;都不可信,则需要补洞或回退真实帧。FSR 的工程启发也在这里:运动、可见性与颜色融合一起决定最终画质。
深入实现:坐标、公式、Compute Shader 与 GPU 成本
先读上面的图,再逐步对照下面的实现。代码是参考基线,未做 GPU 编译和端到端验证。
1. 先约定光流方向和单位
给定相邻真实帧 I₀、I₁,生成时间 t∈[0,1] 的中间帧 Iₜ。光流 F₀₁ 定义在 I₀ 网格上:I₀ 中的 x 对应 I₁ 中的 x+F₀₁(x)。F₁₀ 定义在 I₁ 网格上,方向相反。下文一律使用像素单位;采样时再除以纹理宽高转成 UV。
亮度金字塔
双向运动估计
可信度 / 遮挡
中间帧 Warp
融合 / 降级
相位直接插值是另一条重建路径,不必先得到显式光流;相位运动估计也可以作为光流初值或辅助线索。两条路径要分别设计与评估。
| 资源 | 形状 / 推荐起点 | 语义 |
|---|---|---|
| I₀ / I₁ | H×W×3/4,线性颜色 | 原始真实帧,融合时使用 |
| 亮度金字塔 | 每层 R16F,尺寸逐层减半 | 先低通再下采样,降低混叠 |
| F₀₁ / F₁₀ | 每层 RG16F 或 RG32F | 双向位移,单位为当前层像素 |
| Error / confidence | R16F 或多通道 | 残差、结构可靠性、越界与遮挡 |
| Iₜ | H×W×3/4 | 与输入颜色空间一致的插值帧 |
这里不使用历史生成帧递归估计,避免错误持续传播。需要下一真实帧的双向插值也会引入等待与调度延迟;插帧和降低操作延迟是不同指标。
2. 相位法:位置变化如何进入相位
先区分全局 FFT 相位相关与局部相位表示:前者通常用于块或全局平移匹配;局部相位方法通过多尺度、多个方向的复数滤波响应描述运动。本文讲解后者。
R(s,o,x) = A(s,o,x) · exp(i φ(s,o,x))
Δφ = atan2(sin(φ₁-φ₀), cos(φ₁-φ₀))
局部平移近似:Δφ ≈ -kᵀ d
k [2×1]:子带局部波矢;d [2×1]:像素位移
用固定频率的局部正弦信号理解:平移改变相位而不必改变幅度。多个方向给出位移在不同轴上的约束,只有方向覆盖充分、响应可靠,二维位移才可辨识。相位是周期量,直接相减会遇到 ±π 分支;大运动需要粗尺度提供约束,不能简单以主值相位差恢复任意位移。
参考 GPU 路径
- 通过可重建的复数可转向金字塔,得到每层、每方向的实部与虚部;它不是普通 MIP 金字塔。
- Compute Pass 计算幅度、相位差和可靠性,弱幅度区域降低权重。
- 如果走运动估计路径,用多方向的加权约束求解位移,并借助粗尺度处理相位展开。
- 如果走直接插值路径,在有效解缠条件下构造中间相位、幅度,并使用匹配的合成滤波器重建。
// 仅展示单个子带的局部插值;不是完整论文复现
dphi = atan(sin(phi1-phi0), cos(phi1-phi0));
phi_t = phi0 + t*dphi;
amp_t = mix(amp0, amp1, t);
response_t = amp_t * vec2(cos(phi_t), sin(phi_t));
简单幅度线性插值和主值相位差只是教学基线。遮挡、混合纹理和大位移仍需额外处理。Meyer 等人的公开工作展示了相位表示用于帧插值的路径;本文并不声称上述片段完整重现该算法。
3. LK 光流:把窗口配准化成 2×2 求解
Lucas–Kanade(LK)利用局部图像梯度迭代配准。这里采用平移窗口、前向加性更新作为参考:假设窗口内位移近似一致,并且当前估计足够接近真实运动。
E(v) = Σᵢ wᵢ [I₁(qᵢ+v) - I₀(qᵢ)]²
rᵢ = I₁(qᵢ+v) - I₀(qᵢ)
Jᵢ = ∇I₁(qᵢ+v) [1×2]
H = Σᵢ wᵢ JᵢᵀJᵢ [2×2]
b = Σᵢ wᵢ Jᵢᵀrᵢ [2×1]
H δv = -b;v ← v + δv
亮度恒常不是所有游戏像素都成立:高光、透明、粒子、运动模糊及曝光变化会破坏它。H 的最小特征值可判断局部是否有足够的二维纹理;单独检测 determinant 很难区分全部退化情况。
从粗到细
最粗层:初始化 v=0,或使用可验证的运动先验
每层:Warp I₁ → 计算残差与梯度 → 求解 δv → 更新
进入更细层:v_fine = 2 · bilinear(v_coarse)
每层多次迭代,读写光流纹理 ping-pong
下例每线程处理一个像素,5×5 窗口累加,做一次迭代。是教学版密集 LK 更新,区别于只追踪特征点的稀疏 LK。生产版本可用鲁棒权重、限步长、重算残差和更好的边界策略。
#version 310 es
precision highp float;
layout(local_size_x=8, local_size_y=8) in;
layout(binding=0) uniform sampler2D img0; // 本层亮度
layout(binding=1) uniform sampler2D img1;
layout(binding=2) uniform sampler2D flowIn; // 本层像素单位
layout(rgba16f, binding=0) writeonly uniform highp image2D flowOut;
uniform ivec2 size;
uniform float minEigen; // 按亮度归一化与窗口标定
float L(sampler2D t, vec2 p) {
return textureLod(t, (p + 0.5) / vec2(size), 0.0).r;
}
void main() {
ivec2 p = ivec2(gl_GlobalInvocationID.xy);
if (any(greaterThanEqual(p,size))) return;
vec2 v = texelFetch(flowIn,p,0).xy;
float a=0.0,b=0.0,c=0.0,d=0.0,e=0.0;
float residual=0.0; bool valid=true;
for(int y=-2;y<=2;y++) for(int x=-2;x<=2;x++){
vec2 q=vec2(p)+vec2(x,y), w=q+v;
if(any(lessThan(q,vec2(0))) ||
any(greaterThan(q,vec2(size)-1.0)) ||
any(lessThan(w,vec2(1))) ||
any(greaterThan(w,vec2(size)-2.0))) valid=false;
float gx=0.5*(L(img1,w+vec2(1,0))-L(img1,w-vec2(1,0)));
float gy=0.5*(L(img1,w+vec2(0,1))-L(img1,w-vec2(0,1)));
float r=L(img1,w)-L(img0,q);
a+=gx*gx; b+=gx*gy; c+=gy*gy;
d+=gx*r; e+=gy*r; residual+=abs(r);
}
float eig=0.5*(a+c-sqrt((a-c)*(a-c)+4.0*b*b));
float det=a*c-b*b;
bool ok=valid && eig>minEigen && det>1e-8;
vec2 delta=vec2(0);
if(ok) delta=vec2(b*e-c*d,b*d-a*e)/det; // -H^-1 J^T r
// z:更新前平均残差;w:本次求解是否可信
imageStore(flowOut,p,vec4(v+delta,residual/25.0,ok?1.0:0.0));
}
输入采样器需配置线性过滤与 clamp-to-edge;代码虽然标记边界无效,边缘采样仍依赖合法 sampler 配置。输出 z 是更新前残差,不能当作最终光流残差;最终 confidence Pass 应使用更新后的位移重新计算。
实现前先用 float32 建立基线,再尝试半精度纹理。梯度平方和、determinant 与除法建议保持 float32;代码中的阈值只是起点,须按亮度范围、窗口和设备验证。
4. 重投影与帧 Warp:目标像素应该去哪里取样
Forward Warp 从源像素投到中间位置:q=x₀+tF₀₁(x₀)。实现容易出现空洞、多源竞争与遮挡,需要 splat、归一化累积和可见性决策。Backward Warp 则从中间帧目标像素寻找源坐标,适合纹理 gather,但需要逆映射。
从 I₀ 取样:q = x₀ + t F₀₁(x₀)
从 I₁ 取样:q = x₁ + (1-t) F₁₀(x₁)
固定点迭代:
x₀ ← q - t F₀₁(x₀)
x₁ ← q - (1-t) F₁₀(x₁)
不能把定义在源网格上的 F₀₁(q) 当成精确的目标网格反向流。固定点迭代是在局部平滑、近似线性运动下的参考方法;运动边界可能不收敛,还应检查映射残差和源坐标合法性。这里只展示固定次数迭代的简化形式。
如果能够获得 Depth、相机矩阵或引擎 Motion Vector,可使用几何重投影。但深度解码、坐标系、矩阵约定、jitter 和对象运动必须一致;仅依赖深度与相机矩阵无法恢复所有动态物体运动。Color-only 光流 Warp 不需要这些输入,也失去相应几何约束。
5. Error 不只是一个颜色差
| 误差 / 检查 | 定义或用途 | 限制 |
|---|---|---|
| 光度误差 | eₚ(x)=|Y₀(x)-Y₁(x+F₀₁(x))| | 曝光、高光会造成误判 |
| 前后向一致性 | e_fb=‖F₀₁(x)+F₁₀(x+F₀₁(x))‖ | 采样位置必须对齐;双向一致仍可能同时错误 |
| 结构可靠性 | LK 窗口 H 的最小特征值 | 平坦区域及单边缘存在歧义 |
| 逆映射残差 | ‖q-x₀-tF₀₁(x₀)‖ | 用于检查固定点求解结果 |
| 越界 / 遮挡 | 坐标有效性、前后向不一致及可见性线索 | 不能只靠 clamp 隐藏问题 |
// 可标定的参考可信度,不是唯一标准
c = valid * structureConfidence
* exp(-e_photo/σp)
* exp(-e_fb/σf)
* exp(-e_inverse/σr)
σp 使用亮度单位,σf/σr 使用像素单位;分辨率与金字塔层改变时阈值需要同步。阈值不应只凭单帧画面调参,应对快运动、亮度变化和遮挡分别标定。
可信度引导的双向融合
// 中间帧像素 q;所有坐标/光流均为像素单位。
// 线性运动、局部可逆条件下,用固定点迭代求源坐标。
// sampleFlow()/sampleColor() 为双线性采样;内部处理边界。
vec2 x0=q, x1=q;
for(int k=0;k<4;k++){
x0=q-t*sampleFlow(F01,x0);
x1=q-(1.0-t)*sampleFlow(F10,x1);
}
bool ok0=inBounds(x0), ok1=inBounds(x1);
vec3 C0=sampleColor(I0,x0), C1=sampleColor(I1,x1);
float w0=ok0 ? (1.0-t)*confidence0(x0) : 0.0;
float w1=ok1 ? t*confidence1(x1) : 0.0;
vec3 outColor;
if(t<=0.0) outColor=sampleColor(I0,q);
else if(t>=1.0) outColor=sampleColor(I1,q);
else if(w0+w1>1e-5) outColor=(w0*C0+w1*C1)/(w0+w1);
else outColor=sampleColor(t<0.5?I0:I1,q); // 显式降级:可能跳变
两帧颜色应在线性空间融合。HDR 下先做曝光对齐或使用稳健亮度变换用于估计误差;不要把未经对齐的亮度差直接解释成遮挡。UI 最好独立合成,镜头切换时禁用当前帧对的插值。
两侧都不可信时,用最近真实帧是可解释的低成本降级,但可能出现跳变;空洞修复、单侧传播或更复杂的可见性算法需单独验证。直接平均错误 Warp 往往产生重影。
6. GPU Pass、同步与成本
| Pass | 读写 | 工程关注 |
|---|---|---|
| 预处理 / 金字塔 | 颜色 → 分层亮度 | 低通、曝光、尺寸一致 |
| 粗到细 LK | 亮度、flowIn → flowOut | 每层迭代 ping-pong,避免读写同一资源 |
| 可信度 | 两帧、双向流 → error / confidence | 使用最终光流,显式检查边界 |
| Warp / 融合 | 颜色、双向流、confidence → Iₜ | 可融合 Pass,但需评估寄存器与采样成本 |
OpenGL ES 3.1 的 compute image 写入后,若后续 texture 读取,使用相应 GL_TEXTURE_FETCH_BARRIER_BIT;若继续 image 访问,则使用 GL_SHADER_IMAGE_ACCESS_BARRIER_BIT。同一 dispatch 内 barrier() 只能协调工作组,不能替代跨 dispatch 的 API 同步。Vulkan 使用 compute shader 写 → shader 读的资源依赖;后续若是 fragment shader,目标 stage 对应改为 fragment。
8×8 线程组是参考起点。窗口邻域适合缓存复用,但 Warp 后的 img1 访问是运动相关的,不一定能直接用规则 shared-memory tile 覆盖。显式 shared memory 需要所有相关线程参与 barrier,边界线程不能提前返回而让其他线程卡在同步处。
参考计算量 ∝ 2 × Σₗ (Wₗ Hₗ Kₗ k²)
2:双向;Kₗ:迭代数;k:窗口边长
金字塔总像素数(逐层减半)≈ 4/3 · W H
单张全分辨率 RG16F 光流:4WH 字节
1920×1080 时,一张 RG16F 光流约 7.9 MiB,双向约 15.8 MiB;实际还需 ping-pong、金字塔、误差与颜色资源。不要把这当成完整峰值显存。上述 LK 示例每窗口点需要多次纹理取样,访存可能比 2×2 方程求解更贵。
验证次序
- 用平移测试验证光流符号、像素/UV 转换与金字塔尺度;设置 t=0/1,输出应与真实帧一致。
- 用平坦区域、单边缘、棋盘格测试退化与重复纹理,输出 confidence 可视化。
- 测试遮挡露出、透明粒子、快速镜头、曝光变化和镜头切换。
- 比较单向/双向、分辨率、窗口、迭代次数和半精度,记录每 Pass GPU 时间与显存峰值。
- 分别测插值误差、连续播放稳定性、呈现节奏、额外延迟与设备功耗。生成帧率不能代替真实渲染帧率。
代码尚未进行 GPU 编译和端到端画质验证。先完成可测量基线,再讨论降采样、减少迭代和 Pass 融合的收益。
公开资料
- Lucas & Kanade, 1981 · 原始配准论文
- Meyer 等, CVPR 2015 · Phase-Based Frame Interpolation for Video
- Wadhwa 等, 2013 · Phase-Based Video Motion Processing
公式、Shader 和 Pass 组织为本文整理的参考路径;公开论文用于概念出处,不等同于项目实现说明。