
几个月前为了给自己的渲染器加一套低分辨率渲染方案我把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、VulkanOpenGL和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代表halfAF代表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的集成门槛不高以下是简化后的完整步骤从FidelityFX SDK拿到ffx_fsr1.h头文件确认平台对应的HLSL或GLSL编译选项。在渲染管线中把3D场景渲染到低分辨率渲染目标目标宽度通常是输出分辨率的50%到67%。创建输出分辨率RT执行EASU pass输入低分辨率RT输出全分辨率RT。在输出RT上执行RCAS pass做锐化使用初始sharpness参数0.2~0.5。UI、HUD、文字在FSR之后绘制避免被放大模糊。如果游戏支持动态分辨率把分辨率比例和FSR的常量预计算参数串成一套避免每次分辨率变化都重建管线。注意低分辨率渲染目标的格式要和主RT保持一致尤其不要随意切换sRGB格式颜色空间不一致会导致锐化结果发灰或者过饱和。FSR会读取RGBA四个通道alpha通道如果有特殊用途比如UI遮罩要提前确认它不会参与颜色tap的累积。6.4 性能参考与功耗权衡FSR 1.0最舒服的使用场景是渲染本体的压力明显大于后处理压力的项目。在常见RDNA2显卡上1080p输入放大到4KEASU大约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那堆宏和类型定义就会发现每一行都是为稳定性和跨平台一致性服务的并没有多余的东西。