拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FSR 1.0核心解析:EASU边缘自适应上采样原理与源码实现

FSR 1.0核心解析:EASU边缘自适应上采样原理与源码实现

几个月前为了给自己的渲染器加一套低分辨率渲染方案,我把AMD开源的FSR 1.0完整读了一遍。坦白说,第一眼看到ffx_fsr1.h里那堆AH4、AF3、AMul宏的时候,我是想直接关掉页面的。后来耐着性子把EASU这条推理链理顺,才意识到它没有想象中那么玄:先把图像里边缘的方向摸清楚,沿着边缘方向做适度平滑,垂直方向保持锐利,最后再用一步锐化把感觉找补回来。EASU厉害的地方在于,这套逻辑被压到了每个输出像素十几个tap的运算量里,在几乎所有平台上都能实时跑。这篇文章不是逐行翻译源码,而是把EASU从坐标映射、边缘检测、滤波核构造到性能优化一整套链路完整串起来。适合正在啃FSR源码、或者想在项目里接入低分辨率渲染的图形开发者、引擎爱好者看。

开始之前先给个定位:FSR 1.0是一个纯空间上采样方案,EASU负责放大,RCAS负责锐化。它不需要历史帧,不需要运动矢量,不需要深度信息,这让它可以像普通后处理一样插进任意管线。很多人把FSR 1.0和FSR 2.0、DLSS摆在一起比较,其实它们解决的虽然有交集,技术路线完全不同。理解EASU,会让你对“边缘自适应”这四个字产生很具体的认识。

1. FSR 1.0和EASU:空间上采样为什么非要“边缘自适应”

1.1 纯空间算法与时间算法的本质差异

FSR 2.0和DLSS走的是时间累积路线:拿当前帧+历史帧+运动矢量,把多帧信息叠到一张结果图上。这种做法上限更高,但代价是需要引擎提供运动矢量、深度、防止鬼影的反馈机制,集成成本一下就上去了。FSR 1.0不碰这些东西,它只看当前这一帧的低分辨率输入,直接把它变成高分辨率输出。

只靠一帧信息做放大,信息不可能是凭空创造出来的——低分辨率图里没有的细节,任何空间算法都变不出来。EASU的真正目标是:在放大过程中不要制造新的错误,把已有的边缘信息尽量稳定地投射到高分辨率网格上。普通双线性插值的问题是边缘会被糊掉,出现锯齿和过冲;EASU则根据局部梯度估算出边缘方向,让插值核沿着边缘走,从而保留轮廓的锐利感。这就是“边缘自适应”的核心动机。

1.2 EASU在FSR管线里的位置与数据流

FSR 1.0以两个pass完成整条上采样链路:第一个pass是EASU,第二个pass是RCAS。EASU从低分辨率RT读取颜色,输出高分辨率结果;RCAS再在高分辨率结果上做对比度自适应锐化。两个pass都是全屏后处理,不访问其它帧数据。

EASU的输入输出总结如下:

项目说明
输入低分辨率颜色纹理(通常带alpha通道)
输出高分辨率颜色纹理
依赖资源无历史帧、无运动矢量、无深度
每像素计算量4x4邻域内的多次tap累积,以FP16运算为主
适用APIDirectX 12、Vulkan,OpenGL和Metal也可移植

EASU本身不负责把画面“变清晰”,RCAS那一刀锐化才是观感上细节变明显的重要原因。所以读FSR源码时不要把两个pass割裂开,它们的组合是有意设计的:EASU做保守但稳定的放大,RCAS再把对比度拉回来。

2. 坐标映射与常量预计算:EASU源码的第一道坎

2.1 输出像素如何映射回输入坐标

EASU的shader主入口拿到一个输出像素的整数坐标pid,第一步要算出它对应输入纹理上的哪个非整数位置。源码里不会在shader内部直接做坐标除法,而是在CPU或者应用初始化阶段把比例算好,通过常量缓冲区传进去。核心公式很朴素:

inputPos = (outputPos + 0.5) * (inputSize / outputSize) - 0.5

加0.5再减0.5是为了让像素中心和像素中心对齐。如果用整数坐标直接缩放,会出现半像素偏移,放大后的图像整体就是偏的。这个细节在静态图上看不出来,一旦镜头移动,画面会有一帧一帧的“拖拽感”。

2.2 常量预计算到底省了什么

FsrEasuCon这类接口在FSR源码里承担的就是预计算任务。它的输入是输入分辨率、输出分辨率,输出是四组打包好的向量常量,包含缩放比例、平移量、视口边界信息。Shaders shader内只需要对每个像素做一次乘加就能拿到对应的虚拟坐标。

我最早自己写缩放shader时习惯在像素着色器里直接除,后来对比性能才发现,全屏pass里的除法消耗比想象中大。FSR把能预计算的统统放到外面,shader内部只剩加法和乘法,配合FP16能跑得非常快。这个思路对任何后处理pass都通用:凡是和像素坐标无关的常量,不要留给GPU每个像素算一遍。代码结构大约长这样:

void FsrEasuCon( out float4 con0, out float4 con1, float inputW, float inputH, float outputW, float outputH) { // 输入视口和输出视口的缩放比例 float sx = inputW / outputW; float sy = inputH / outputH; // 中心对齐需要的平移量 float ox = -0.5f * sx + 0.5f; float oy = -0.5f * sy + 0.5f; con0 = float4(sx, sy, ox, oy); // 后续还会打包视口尺寸、纹素尺寸等 }

这里给出的只是逻辑还原,真实的ffx_fsr1.h里参数打包方式更讲究,但数学本质一样。读懂这一步,后面看tap偏移和采样坐标才不会被绕晕。

2.3 边界处理策略

EASU在采样4x4邻域时,如果坐标超出纹理范围,默认行为是clamp到边界颜色,而不是wrap或者镜像。这一个选择很重要:边缘像素如果走wrap,画面边缘会出现杂色条纹;走clamp,最多就是边缘锐度差一点,但整体稳定。源码逻辑在用Load或者Sample时通过边界寻址模式控制,集成时记得把输入纹理的SamplerState设成Clamp,这是很多初学者移植EASU时最容易翻车的地方。

3. 边缘方向检测:从梯度到主方向的推理链

3.1 用梯度找边缘方向的基本直觉

如果图片上有一条从左上到右下的白色斜线,那么沿斜线方向看,颜色几乎没有变化;垂直斜线方向看,颜色变化最剧烈。所以边缘方向就是颜色变化最缓慢的方向。数学上,把水平梯度和垂直梯度组合起来,可以得到一个二阶张量,叫结构张量,它的特征向量就指明了边缘方向。

结构张量的教科书算法需要对每个像素求梯度外积再做2x2矩阵特征分解,这个成本在实时渲染里偏贵。EASU源码没有直接做特征分解,而是用一组精心挑选的差分操作来近似相同的事情:对中心像素周围几个方向的颜色差做加权组合,得到方向向量和边缘强度。贴近源码思路的简化版本如下:

// 输入:中心像素附近4x4区域采样结果 p00..p33 // 计算水平梯度和垂直梯度 float gx = (p02 - p01) + (p12 - p11) + (p22 - p21); float gy = (p20 - p10) + (p21 - p11) + (p22 - p12); // 近似二阶信息,用于判断角点和端点 float gxx = p02 - 2.0f * p01 + p00; float gyy = p20 - 2.0f * p10 + p00; // 合成主方向 float2 dir = normalize(float2(gx, gy));

这里刻意用了相邻差分求和而不是中心差分,相当于做了一个小范围模糊,避免单像素噪声把方向带偏。同时,gxx和gyy作为二阶导的近似值,可以反映当前像素是不是位于角点或者端点——这种位置的方向估计本身不可靠,EASU会相应缩减滤波半径,防止方向误判导致拉丝。

3.2 为什么方向估计要输出“强度”而不是只有角度

方向强度反映当前像素处边缘到底有多明显。平坦区域梯度接近零,方向本身没有意义,此时EASU会让滤波器退化为接近各向同性的平滑;而高对比度边缘处梯度大,方向可信度高,滤波器会大胆地沿边缘拉长。源码里这个强度会参与后续每一组tap权重的计算,相当于把“置信度”揉进了插值过程。

这个设计非常实用。如果只输出角度,平坦区域会被随机方向噪声支配,画面会出现颗粒状闪烁。我在移植中实测过:把强度项故意置为常数后,静态图片看不出太大问题,但镜头一动,远处的天空和地面会冒出大量噪点,就是因为平坦区域的方向估计在帧与帧之间跳变。EASU源码里大量使用saturate和min/max来限制强度范围,目的就是压制这种不稳定。

3.3 方向估计的精度边界

EASU的方向估计是基于有限邻域的差分,它对高频纹理和稀疏细小物体并不总是能给出稳定方向。比如一排远处围栏的竖条,放大后很容易被识别成独立边缘,方向被拉得很乱。源码中对此类场景主要依靠两个兜底机制:一是RCAS的对比度自适应,对超过一定对比度范围的像素降低锐化量,避免振铃;二是EASU本身的权重多项式会让远离中心像素的tap权重快速衰减,抑制噪声传播。所以不要试图单独加强方向检测的精度,FSR的整体策略是“方向检测给一个大概齐的初值,再由滤波器核的保守设计来兜底”。

4. 滤波核tap权重:Lanczos近似与方向加权的源码级拆解

4.1 tap布局与4x4邻域

EASU对每个输出像素会围绕映射后的输入坐标取一个4x4邻域,每个邻域像素都是一个tap。不过它不会对这16个tap一视同仁,而是根据前面算出的方向向量和强度,给每个tap重新分配偏移和权重。沿着边缘方向的tap权重被抬高,相当于做了边缘方向的平滑;垂直边缘方向的tap权重被压低,用来保留边缘锐度。

tap偏移并不仅是整数坐标,EASU还会根据输出像素在输入像素栅格中的相对位置做微调,这等于在亚像素级别对滤波核做了扭曲。坐标映射加方向加权,让EASU的表现更接近一个随内容变形的椭圆核,而不是固定形状的方形卷积核。

4.2 权重多项式为什么长这样

EASU源码里每个tap的权重不会直接调用sin或者sinc,而是用四次多项式逼近Lanczos核。Lanczos2核的数学形式是:

L(x) = sinc(x) * sinc(x / 2), |x| < 2 = 0, otherwise

直接逐tap算这个式子,计算量太大。EASU的做法是预计算若干关键点的系数,在shader内用乘加组合出近似结果。源码里能看到的权重形式基本类似于:

float d2 = d * d; float w = coeff0 * d2 * d2 + coeff1 * d2 * d + coeff2 * d2 + coeff3 * d + coeff4;

其中d是tap到中心的归一化距离。多项式的好处是只靠几条FP16乘加就能完成,不需要分支,不需要查表,也不依赖GPU是否支持特殊数学函数。LPU开销低,而且数值行为在硬件间高度一致,这对谁都能用的跨平台方案很重要。

4.3 方向和距离如何同时影响权重

单个tap的最终权重至少由两部分组成:距离项描述“离中心越近越重要”,方向项描述“沿着边缘方向越远越可以被接受”。直观理解是:边缘方向的颜色变化本来就慢,稍远一点的像素拿来平均不会出问题;垂直边缘方向颜色跳变剧烈,远距离像素混进来就会糊边。EASU计算中引入方向向量的点积作为调制参数,让两个方向的衰减曲线不同,从而实现“沿边缘拉长、垂直边缘收窄”的自适应效果。

累积阶段非常简单:

float3 accumColor = 0; float accumWeight = 0; for (int i = 0; i < tapCount; i++) { float3 c = LoadColor(baseCoord + tapOffset[i]); float w = ComputeWeight(tapOffset[i], edgeDir, edgeStrength); accumColor += c * w; accumWeight += w; } float3 result = accumColor / accumWeight;

最后必须除以累积权重做归一化。这一步如果省略,画面亮度会明显漂移。源码里还会把权重和钳制在一个positive范围内,避免除零。

4.4 源码对tap数的取舍

FSR在不同平台上有不同的tap数量配置。默认路径下每个像素执行10余次tap累积,这是精度与性能之间的折中。tap越多,插值质量越好,但ALU压力和纹理带宽线性上涨;tap太少,滤波核形状还原不到位,边缘仍会出现锯齿。源码里通过宏控制tap循环的展开次数,这在移动端可以降到更低档位。

我的体会是:不要一上来就追求最高质量的tap配置,先跑通默认档,再对照放大倍率和目标帧耗时来调整。2倍放大时默认配置已经足够,3倍以上放大时提高tap数才能看到可感知的改善。

5. 性能优化细节:半精度打包、查表和消灭分支

5.1 为什么到处都是AH4、AF3这种类型

FSR源码为了同时支持HLSL和GLSL,定义了一套自己的类型缩写:AH代表half,AF代表float,后面的数字代表分量数。AH4就是一个包含4个half的向量。这样打包最大的好处是:一次纹理采样拿到的RGBA可以直接塞进一个128位寄存器,后续所有颜色运算都是SIMD形式,ALU吞吐量成倍释放。

在支持FP16硬件上,EASU会把大部分颜色计算切成half路径,原因很简单:half的寄存器和ALU吞吐通常是float的两倍。RDNA架构对FP16的加速尤其明显,这也是FSR为什么在AMD显卡上跑得那么快的缘由之一。源码里同时保留了FsrEasuTapH和FsrEasuTapF两条路径,遇到不支持快速half的平台会自动回退到float。

初次读源码的人很容易被AH4和AF4的互相转换搞晕。关键认知是:颜色计算尽量用half,坐标和方向等对精度敏感的变量用float,只在必要时降精度。不要一股脑全转half,否则放大倍数大时边缘会出现色阶断裂。

5.2 用查表和近似替代昂贵数学函数

EASU里几乎没有atan2、cos、sin这类三角函数的身影。方向向量由梯度归一化得到,角度转方向的步骤被常量数组和线性插值替代。源码里能看到一组静态常量数组,比如不同方向区间对应的预计算权重、Lanczos核的采样系数等,这些都是在编译期可确定的常量,运行时只需索引和插值。

查表法的优势不只在于快,还在于数值稳定。sin/cos在不同驱动、不同GPU上可能产生微小误差,而查表结果完全确定,这对跨平台一致性很重要。常量数组也存在寄存器压力问题,但EASU把表控制在很小的规模内,不会爆寄存器。

5.3 消灭分支:用数学代替流程控制

GPU着色器最怕分支发散。同一个wavefront里的像素如果分别走if和else两边,两条路径都会被执行一次,性能直接减半。EASU源码里很少看到if,取而代之的是saturate、min、max这类指令。例如:

// 避免直接写:if (length > limit) length = limit; float clampedLength = min(length, limit);

把条件判断变成数值钳制,GPU可以在所有像素上执行同一条指令,只是数据不同。这个思路在RCAS里更明显:锐化强度要根据局部对比度自适应,源码不会在对比度超过阈值时才触发锐化,而是让锐化量随对比度连续变化,天然避免了跳变和分支,同时不会出现锐化强度突然切换的视觉闪烁。

5.4 指令集宏:为了在每一代硬件上都不吃亏

FSR源码里有大量类似AMul、ARcp的宏,初看像在故意把代码搞复杂。实际原因是AMD在多个硬件代际上对不同数学指令的吞吐量做了权衡,例如:

宏作用背后的原因
AMul(a, b)乘法在部分架构上强制使用特定half乘法路径,避免精度损失
ARcp(v)倒数用rcp指令代替除法,吞吐量更高
AExp2(v)2的幂避免通用pow的精度和开销问题,用底层指数指令
ASat(v)饱和钳制明确告诉编译器走饱和指令,减少一步min/max

这些宏在目标平台编译时会展开成最适合该平台的指令序列。对于集成方来说,最省事的方法就是保留FSR原本的宏抽象层,不要自作主张把它们全都替换成标准库函数。我曾经为了代码整洁把AMul改成普通乘号,结果在某个老显卡驱动上出现了微妙的重影问题,排查了很久才意识到是half乘法在不同驱动下舍入行为不同。

6. 集成调试与实际落地:伪影排查和经验参数

6.1 用RenderDoc观察权重和方向

把EASU接入自己项目后,第一件事不是看最终画面好不好看,而是验证权重计算是否正确。在RenderDoc里抓一帧,把每个像素的累积权重输出为颜色,正常画面应该是亮度均匀的灰色;如果某些区域特别亮或者特别暗,说明tap坐标或采样边界出了问题。

接下来可以做方向可视化:把边缘方向向量映射成颜色输出,平坦区域输出中性色,边缘区域输出带有方向的颜色。这样能一眼看出方向估计是否在物体轮廓处保持连续。如果边缘内部方向整齐,但交界处方向混乱,属于正常现象;如果平坦区域也有大量彩色噪声,说明强度项没有生效或者clamp策略错了。

6.2 常见伪影与排查思路

我在移植和调试EASU过程中遇到过几类看得见的画面问题,整理成一张表格:

现象可能原因排查/解决方向
边缘拉丝、方向感怪异方向估计在细小高频纹理处跳变检查梯度计算中的模糊项,确认强度项是否生效
高对比边缘出现光晕RCAS锐化过度降低sharpness参数,或改用按对比度缩放锐化量
镜头移动时闪烁方向估计帧间不稳定确认坐标映射使用的是像素中心对齐,检查是否缺少邻域钳制
文字和UI发虚放大倍率过高把UI/HUD放在FSR之后绘制,或者降低渲染分辨率比例
画面整体偏亮或偏暗累积权重没有归一化检查最后是否除以累积权重,确认权重恒大于零

RCAS的sharpness参数是FSR 1.0集成时最需要调的旋钮。经验值在0.2到0.5之间。越低画面越柔和,越高边缘越锐利但风险是过冲和振铃。按我的经验,先从一个比较低的0.2起步,静态画面和动态画面各看一遍,再往上加。不要只顾静态截图好看,动态才是FSR发挥价值的主场。

6.3 在引擎里的接入步骤

FSR 1.0的集成门槛不高,以下是简化后的完整步骤:

  1. 从FidelityFX SDK拿到ffx_fsr1.h头文件,确认平台对应的HLSL或GLSL编译选项。
  2. 在渲染管线中把3D场景渲染到低分辨率渲染目标,目标宽度通常是输出分辨率的50%到67%。
  3. 创建输出分辨率RT,执行EASU pass:输入低分辨率RT,输出全分辨率RT。
  4. 在输出RT上执行RCAS pass做锐化,使用初始sharpness参数0.2~0.5。
  5. UI、HUD、文字在FSR之后绘制,避免被放大模糊。
  6. 如果游戏支持动态分辨率,把分辨率比例和FSR的常量预计算参数串成一套,避免每次分辨率变化都重建管线。

注意低分辨率渲染目标的格式要和主RT保持一致,尤其不要随意切换sRGB格式,颜色空间不一致会导致锐化结果发灰或者过饱和。FSR会读取RGBA四个通道,alpha通道如果有特殊用途(比如UI遮罩),要提前确认它不会参与颜色tap的累积。

6.4 性能参考与功耗权衡

FSR 1.0最舒服的使用场景是渲染本体的压力明显大于后处理压力的项目。在常见RDNA2显卡上,1080p输入放大到4K,EASU大约0.1到0.2毫秒,RCAS大约0.03到0.05毫秒,整个上采样链路的GPU开销在0.15到0.25毫秒这个量级。具体数据会随驱动和设备差异变化,但这个量级足以让FSR在大部分平台上物超所值。

如果目标是移动端或者核显,建议优先保证FP16路径被正确启用。部分移动GPU为了省电会在驱动里对FP16进行降频处理,此时float路径甚至可能反超。最好的验证方式是在目标设备上实测两种路径,而不是只看PC上的表现。

另外,FSR 1.0的分辨率比例有实际使用边界。放大倍数在1.5到2.25倍之间时效果最好,超过2.5倍之后,低分辨率输入里已经没有足够的高频信息可供恢复,EASU和RCAS再怎么配合也无法无中生有。项目里如果要做类似动态分辨率的功能,尽量把最低档卡在50%分辨率,也就是2倍放大左右,再低就真需要FSR 2.0那种时间累积才能救回来了。

调试EASU最容易被忽视的一点是:它在静态帧上的表现温和,但在镜头运动时,边缘方向估计的稳定性才真正决定观感。如果你在移植中动了方向检测部分的代码,一定不要只看静态截图,锁帧后让镜头围绕物体转几圈,观察远处栅栏、树枝、天空交界处的闪烁情况。方向估计稍有跳动,画面就会给人“磨砂玻璃在抖动”的感觉,比清晰度下降更让人难受。

按照上面这套流程,把常量预计算、坐标映射、4x4邻域采样、方向估计和tap权重累积逐个验证一遍,再花时间调RCAS的锐化量,基本就能在自己项目里复刻出FSR 1.0的观感了。等你把这些细节都摸透,回头看ffx_fsr1.h那堆宏和类型定义,就会发现每一行都是为稳定性和跨平台一致性服务的,并没有多余的东西。

返回列表