S/W · 王术 / RENDER LAB

FIELD NOTES / FRAME INTERPOLATION

轻量级 GPU 插帧:
从运动估计到帧融合

先用四张图理解“找运动、搬像素、判断遮挡、融合颜色”,再对照 FSR 3 的公开流程和 Shader 实现。

FSR 3 作为公开工程对照;LK、相位法是独立参考路线。示意图与交互演示用于解释原理,不是实测效果或商业项目内部实现。

先看画面:插帧到底补了什么?

假设一个纹理块从 x=100 移到 x=180。中间时刻应该在 x=140。如果直接把两张图各取 50%,你会看到两个半透明块;先把两侧的块移到 x=140,再融合,才能得到一个完整的块。

同一个纹理块:两帧之间向右移动 80 像素真实帧 I₀中间帧 I₀.₅真实帧 I₁向中间移 +40px向中间移 −40px图为示意,绘图距离不按像素等比例;先对齐同一物体,再混合颜色。
图 1 · 插帧先解决位置,再解决颜色。光流回答“去了哪里”,Warp 执行搬运,融合决定信谁。

直接混合运动补偿

这是已知匀速平移的教学演示,不是实际光流估计效果;真实视频还要处理遮挡、形变与错误运动。

参考 FSR 3:为什么要有两种运动线索?

这里参照 AMD FidelityFX Frame Interpolation 1.1.3 与 Optical Flow 1.1.2 文档,限定为公开的传统 FSR 3 路径。其光流采用多尺度块匹配与 SAD 搜索,不能把下文的 LK 或相位法当成 AMD 的实现。

线索直观理解对你的 Color-only 路线的意义
游戏运动矢量引擎知道物体和相机怎么动接入需要引擎数据,跨游戏可用性不同
图像光流对照两张图,猜可见内容怎么动可只用颜色,但匹配可能出错
深度与遮挡判断谁挡住谁、哪一侧能取到颜色缺深度时,要靠图像一致性等线索近似

AMD 的公开流程分别建立游戏运动与光流场,利用遮挡掩码拒绝无效取样,再结合匹配质量融合结果,最后补洞。值得借鉴的是每个阶段都判断信息是否可靠,而不只追求一张运动场。

AMD 帧插值文档 · AMD 光流文档

LK 光流:先猜,再看怎样小步修正

拿上一帧的一个 5×5 小窗口,到下一帧找对应内容。当前猜测向右移了 2 像素,但真实是 2.4 像素。两窗口的亮度还没有完全对上;LK 利用亮度的变化方向,估计再移动多少能减小差异。

LK:小窗口不做大范围穷举,而是沿局部误差曲面修正位移当前猜测修正 δv更好的匹配误差 E候选位移 v二维优化的一维截面示意;真实误差可能有多个局部极小值。
图 2 · 梯度提供修正方向,窗口内多个像素共同决定二维位移。

可以把 H 理解为“这个窗口能否看清两个方向的移动”。平坦区域没有线索;一条直边通常只能看清垂直于边的运动;角点或丰富纹理更容易判断二维位移。不是求出一个向量就代表可信。

大运动先在小图上找,小图的一像素对应大图的多像素;然后逐层放大结果并微调。这就是金字塔由粗到细。公式和 Shader 放在下面的实现部分。

相位法:追踪亮暗波形的变化

把局部纹理看成不同方向、不同粗细的波形。纹理移动后,波峰波谷跟着移动;相位差就是这种位置变化的线索。它与 LK 沿亮度梯度修正的思路不同。

相位:同一条亮暗纹理移动后,波形峰值的位置发生变化上一帧下一帧测“波形移动了多少” → 相位差 → 局部位移线索重复波形有周期歧义,大运动需要多尺度;这不是 FSR 光流算法说明。
图 3 · 单一周期有歧义,多尺度与多个方向提供更多约束。

只有一条横向波形时,无法可靠判断所有二维运动;位移超过一个周期时,还可能把“移了一大段”误看成“只移了一点”。所以简单地插值两帧相位并不等于完成实际视频插帧。

遮挡与 Error:两侧的颜色不一定都能用

前景向右移动,左边会露出此前被挡住的背景。上一帧根本没记录那块背景,不能靠运动把不存在的颜色搬出来。此时应该选择能看见它的一侧,而不是继续平均。

背景分成 A、B、C 三段,前景向右移动I₀I₁融合决策A:可见背景B:被前景挡住C:可见背景B:新露出背景C:被前景挡住两侧都有信息优先可见一侧优先可见一侧这张图解释可见性,不代表中间时刻的精确遮挡范围;掩码需在目标帧坐标下判断。
图 4 · 遮挡问题是“信息缺失”,不是简单的光流数值不够准确。

实际实现可以问三个问题:移动后两侧颜色像不像?从上一帧过去再返回,能不能回到原点?目标像素取样位置是否越界或被遮挡?把这些回答转成可信度,就能控制融合与降级。

例如:前向移动 +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 / confidenceR16F 或多通道残差、结构可靠性、越界与遮挡
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 路径

  1. 通过可重建的复数可转向金字塔,得到每层、每方向的实部与虚部;它不是普通 MIP 金字塔。
  2. Compute Pass 计算幅度、相位差和可靠性,弱幅度区域降低权重。
  3. 如果走运动估计路径,用多方向的加权约束求解位移,并借助粗尺度处理相位展开。
  4. 如果走直接插值路径,在有效解缠条件下构造中间相位、幅度,并使用匹配的合成滤波器重建。
// 仅展示单个子带的局部插值;不是完整论文复现
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 方程求解更贵。

验证次序

  1. 用平移测试验证光流符号、像素/UV 转换与金字塔尺度;设置 t=0/1,输出应与真实帧一致。
  2. 用平坦区域、单边缘、棋盘格测试退化与重复纹理,输出 confidence 可视化。
  3. 测试遮挡露出、透明粒子、快速镜头、曝光变化和镜头切换。
  4. 比较单向/双向、分辨率、窗口、迭代次数和半精度,记录每 Pass GPU 时间与显存峰值。
  5. 分别测插值误差、连续播放稳定性、呈现节奏、额外延迟与设备功耗。生成帧率不能代替真实渲染帧率。

代码尚未进行 GPU 编译和端到端画质验证。先完成可测量基线,再讨论降采样、减少迭代和 Pass 融合的收益。

公开资料

公式、Shader 和 Pass 组织为本文整理的参考路径;公开论文用于概念出处,不等同于项目实现说明。