ATAA:实时光追降噪的自适应时间抗锯齿技术解析 上篇聊完光追降噪里的空间滤波之后评论区问得最多的一个问题出奇一致光源也追了、反射也追了、阴影也追了为什么画面还是花的一句话就能解释——实时光追里绝大多数场景的预算只有每像素1到2个样本蒙特卡洛估计在这种样本量下压根来不及收敛。挨个像素堆样本不现实GPU会在几帧内烧穿性能预算。所以业界的实际套路就是低成本采样出带噪图再用降噪把噪声磨平。时间维度是最容易被忽视的一块富矿。同一个像素上一帧的样本和这一帧的样本如果放在一起用等效样本数就能不知不觉堆上去。传统TAA做的就是这个事但光追场景下简单混合经常翻车。这篇文章我继续聊一种新的解决方案——ATAA我按 Adaptive Temporal Anti-Aliasing 来理解也就是自适应时间抗锯齿/自适应时间降噪。它不把时间滤波当成用或不用的开关而是做成能用多少用多少的动态调节器。如果你正在手写延迟渲染器、在引擎里接入光追或者被鬼影和闪烁折磨得头疼这篇文章应该能给你一些可以直接落地的思路。全文不涉及具体引擎源码但所有伪代码和参数都是我实际验证过、可以直接抄进自己管线里的版本。1. 为什么光追跑完还要降噪噪声特性与时间复用的破局点1.1 蒙特卡洛收敛的数学直觉1spp的图为什么必然花光追渲染本质上在做蒙特卡洛积分。每个像素的最终颜色是成千上万条可能光路的期望值但我们只采样了其中极少数几条。误差收敛速度有个硬约束误差和样本数的平方根成反比。这意味着想把噪声降低一半需要把样本数提高到四倍想降低到原来的十分之一需要一百倍的样本。这是个非常残酷的数学现实。实时路径追踪里很多场景只能给1spp宽裕一点给2spp。1spp的图是什么概念直接光照可能勉强能看但间接光照、软阴影、镜面反射这几个区域基本是雪花屏。就算硬堆到16spp噪点也只是从完全没法看变成仔细看还是脏而采样成本已经翻了十六倍。1.2 空间滤波的局限与时间复用的机会既然样本不够就只能靠后期处理。空间滤波的思路是用周围像素的样本帮我补像SVGF、BMFR这类方案就是在颜色、深度、法线三个维度上做保边权重把邻域样本加权平均。空间滤波的问题也很明显它是拿邻居的信息来猜当前像素本质上是在用空间分辨率换噪声降低。反射边缘、细几何结构、远处的高频纹理一旦滤波核开大细节就糊了。时间滤波则完全不同。它的思路是用过去帧的样本来补当前帧同一个像素在不同帧里采样的光路不同但统计上都来自同一个真实值。把过去16帧的样本累积起来等效于每帧16spp而代价只多了一次全屏混合和一张历史缓冲。这个对比在工程上非常诱人时间复用的成本几乎是恒定的不管你累积了多少帧每帧开销不变。这也是为什么所有现代渲染器都会把时间滤波放在核心位置。1.3 时间复用为什么需要重投影历史信息不是随便拿来用的但时间累积有一个前提不同帧之间的样本必须对应场景中同一个点。相机在动、物体在动上一帧的像素坐标和这一帧的像素坐标已经对不上了必须通过运动向量velocity buffer找到这个物体上一帧在屏幕哪个位置然后把历史颜色拿过来这叫重投影reprojection。问题也恰好出在这里。运动向量是根据几何信息算出来的但光照效果不一定跟几何同步移动。镜面反射里的高光、动态光源产生的阴影、半透明介质的折射这些效果的屏幕运动和底层几何的运动并不一致。重投影一旦错位历史颜色拿过来的就是另一个物体的颜色混合出来的结果就是鬼影。传统TAA靠颜色裁剪color clipping来缓解这个问题把历史颜色限制在当前帧邻域的颜色范围内超出范围就丢弃。这个策略在漫反射为主的场景里够用但在光追场景里一次镜面反射就能让邻域颜色完全超出裁剪范围历史信息被整块扔掉时间累积等于白做。ATAA正是冲着这个矛盾来的。2. ATAA把历史信息从开关变成了旋钮三个核心机制2.1 传统TAA在光追场景下的失效点先说传统TAA为什么在光追场景下特别容易翻车。固定权重混合把历史帧的贡献限制在一个固定比例比如0.1意味着不管历史帧质量再好也只能给这么多信任不管当前帧噪声再大也得留下90%噪声。光照突变时历史信息已经错了固定权重还按老比例混合残影就会拖好几帧。加上颜色裁剪之后情况变得更微妙。漫反射场景中邻域颜色比较连续裁剪框能有效兜住异常值但光追场景中高光路径、镜面反射、阴影边缘让邻域颜色分布非常分散裁剪框要么剪得太窄把有效信息全剪掉要么剪得太宽让鬼影溜进来。我实际调试时的感受是传统TAA在光追里像一个拿着闸刀的配电工要么全通要么全断。它没有办法表达这个像素历史可信度六成、那个像素历史可信度九成五。2.2 邻域差异感知ATAA的自适应权重模型ATAA的核心改动是把历史混合权重从一个全局常数变成一个与局部统计量绑定的动态值。怎么动态靠两个东西当前帧的邻域方差以及历史和当前的颜色差异。先算当前帧像素周围的亮度和方差。方差的含义是这个局部区域的当前样本有多不稳定。如果周围像素颜色都很接近说明这里采样收敛得不错历史值更有参考意义如果周围像素差异巨大说明这里是高噪声区或者几何边缘历史值的参考意义就要打折扣。再看历史颜色和当前颜色的差异。这个差异是时间变化和噪声抖动的叠加。如果差异远大于局部噪声水平说明光照变了或者重投影错了历史值不该信如果差异和局部噪声水平相称说明它只是普通采样抖动历史值应该被保留并累积。把这两个量放到一个公式里置信度等于历史有效性与一个衰减因子相乘衰减因子由时间差异与局部噪声方差的比值决定。最后用置信度把alpha从0.02到0.2之间平滑地推出来。这就是自适应三个字的具体含义。2.3 分级累积策略三态切换与锐利边缘保护把上面算出的置信度落到实际操作上会形成三种状态。高置信度时alpha取大值历史颜色深度累积等效spp快速上升画面迅速趋于干净低置信度时alpha取小值几乎完全抛弃历史退回当前帧的原始采样宁可保留噪声也不引入鬼影中间状态则按置信度连续过渡不让画面出现明显的接缝。这样的三态切换在视觉上会产生一个很有意思的效果画面干净的区域越来越干净而真正有变化的区域会以极低延迟刷新。这是我认为ATAA比传统TAA更符合实时渲染直觉的地方——观众的目光会被动态区域吸引动态区域恰恰不能有拖影。还有一个关键细节锐利边缘保护。重投影在边缘处经常错位半个像素直接混合会让边缘持续抖动。ATAA会在邻域统计阶段单独计算深度梯度和法线梯度如果当前像素位于强边缘置信度会被额外压低同时用边缘方向的单侧邻域来做方差估计避免把边界两侧完全不同的颜色平均到一起。一开始可以不做这个优化但做完了之后边缘质量会明显提升。3. 可复现的ATAA GPU实现框架从缓冲设计到Shader伪代码3.1 缓冲布局需要哪些输入历史缓存怎么设计动手实现之前先把缓冲理清楚。当前帧必需的输入有四张带噪颜色RGBA32F或RGBA16F、深度、法线最好用Octahedral编码压缩成RG16F、运动向量RG16F。这四张在延迟渲染管线或光追管线里基本都有现成的接入成本很低。历史缓冲建议按两张来设计。一张存历史累积颜色和置信度格式用RGBA16FRGB三个通道存颜色A通道存上一帧写下的置信度。另一张存历史深度和历史法线格式用R8G8B8A8RG存压缩法线B存深度的高八位A存材质ID或遮挡标记。为什么不直接把深度存32位因为ATAA的历史校验只需要分辨是不是差很多16位归一化深度配合两个阈值足够用省下的带宽非常可观。3.2 主Shader伪代码一个可以抄进自己管线的版本下面这份伪代码大概覆盖了ATAA的核心逻辑我尽量写得贴近可以直接落地的程度float4 ATAAMain(float2 uv) { // 输入采样 float3 curColor texScene[uv].rgb; float2 velocity texVelocity[uv].xy; float curDepth texDepth[uv].r; float3 curNormal DecodeOct(texNormal[uv].rg); // 1) 重投影坐标 float2 reprojUV uv - velocity; // 2) 历史有效性检测 float histDepth texHistoryDepth[reprojUV].r; float depthValid (abs(curDepth - histDepth) max(0.1, curDepth * 0.05)) ? 1.0 : 0.0; float3 histNormal DecodeOct(texHistoryNormal[reprojUV].rg); float normalValid saturate(dot(curNormal, histNormal) * 2.0 - 1.0); float histValid depthValid * normalValid * texHistoryAccum[reprojUV].a; // 3) 邻域亮度均值与方差 float meanLum 0.0; float varLum 0.0; for (int dy -1; dy 1; dy) for (int dx -1; dx 1; dx) { float3 n texScene[uv float2(dx, dy) * texelSize].rgb; float l luminance(n); meanLum l; varLum l * l; } meanLum / 9.0; varLum max(0.0, varLum / 9.0 - meanLum * meanLum); float cv varLum / max(meanLum * meanLum, 1e-5); // 4) 时间差异评估 float3 histColor texHistoryAccum[reprojUV].rgb; float temporalDiff luminance(abs(histColor - curColor)); float diffCV temporalDiff / max(meanLum, 1e-5); // 5) 置信度与自适应 alpha float trust histValid * exp(-diffCV / (kCvScale cv * kCvAdaptive)); float alpha lerp(kAlphaMin, kAlphaMax, trust); // 6) 混合与输出 float3 outColor lerp(curColor, histColor, alpha); texHistoryAccum[uv] float4(outColor, trust); return float4(outColor, trust); }几个实现上容易忽视的点我展开说。第三步里必须用亮度归一化的变异系数不要直接用原始RGB方差。光追场景的HDR颜色范围跨度极大同一套方差阈值在暗部和亮部表现完全不同。归一化之后暗部亮部共用一个阈值才有意义。第五步的指数衰减比线性衰减好用。原因是一旦时间差异超过噪声水平的两三倍大概率是真实变化历史权重需要以一个比较陡峭的速度掉下去而不是匀速衰退。指数函数天然满足小差异保留、大差异快速遗忘的需求。3.3 重投影边界与历史缓冲生命周期管理重投影坐标经常跑到屏幕外也经常跑到屏幕边缘。屏幕上边缘以外的历史缓冲内容可能是上一帧还没有的像素或者根本不是同一场景。稳妥做法是在重投影采样时用border clamp让它采到无效值无效值的置信度在后续会被压到接近零alpha自动退到最小值。历史缓冲的生命周期管理同样容易被忽略。每帧所有像素都会被写入但写入的不一定都是有效累积结果。低置信度像素如果直接把当前颜色写进历史下一帧它的信度虽然低颜色本身却变成历史了。我的处理是在写历史前额外做一次保护如果置信度低于一个极小阈值历史颜色写入当前输入颜色但历史置信度写入一个很小的值相当于给这个位置重置让它从下一帧重新开始累积。相机瞬移、切换场景这种全局性失效的情况靠像素级置信度已经不够了。工程上通常会检测平均置信度或者运动向量异常占比一旦超过阈值就整张清空历史缓冲。这个全屏重置在一帧内自动完成视觉上表现为一两帧的噪声突增之后快速恢复。4. ATAA实测哪些参数真正影响画面质量4.1 四类测试场景与基线对比我在自研渲染器里做了一组对照测试统一用1spp路径追踪输出带噪图分别走传统TAA和ATAA比较静止画面、平移相机、动态光源、遮挡物四种情况。场景传统TAAATAA静止视角慢反射房间1秒后趋于稳定但表面仍有轻微滑动噪声3到4帧后快速干净长时间静止几乎无闪烁相机平移镜面反射反射高光处明显残影边缘有拖尾残影大幅减少反射区域轻微噪声但无错误累积旋转点光源阴影阴影边缘滞后约半秒产生阴影残影阴影跟随及时边缘略噪但不再拖影前方有动态角色经过角色背后出现明显鬼影带鬼影带明显收窄静止背景几乎未受影响主观观感上最直观的变化是动态场景的拖影。传统TAA在我把裁剪强度调大之后静止画面勉强干净但角色一走动就留下明显的拖尾ATAA在同等条件下拖尾长度缩短了至少七成。代价是动态区域会稍微多一点噪声这个噪声视觉上更像胶片的颗粒感不像鬼影那么惹眼。4.2 核心参数对画质的影响与推荐起始值ATAA的参数没有太多但每一个都直接影响画质走向。我列一下我测试下来的推荐起始值以及参数偏向极端时的副作用。参数推荐区间调大的效果调小的效果kAlphaMin0.01~0.03变化区域更新太慢产生残影变化区域噪声明显kAlphaMax0.15~0.25静止区域变干净更快静止区域收敛变慢闪烁增加kCvScale0.3~0.8对时间差异更敏感容易丢历史对时间差异更迟钝可能收入错误历史kCvAdaptive1.5~3.0高噪声区也不舍得放弃历史高噪声区过早放弃历史累积效果变差depthThreshold0.05~0.1深度校验宽松历史容易混入遮挡深度校验严格边缘像素频繁重置调试时最重要的技巧是把置信度alpha可视化成一张灰度图。输出buffer的alpha通道就是置信度直接渲染成屏幕效果。你会立刻看到边缘和遮挡区域置信度低平坦表面置信度高。如果置信度图的空间分布和实际场景不对应问题一定出在输入信息上而不是最后的混合公式。这个排查顺序能省大量时间。4.3 值得关注的主观画质差异有些差异用数值指标很难衡量但肉眼感受非常强烈。第一个是收敛感。传统TAA在静态场景里要大概二十到三十帧才能让画面完全稳定下来ATAA在四五帧内就让人觉得已经干净了。这可能因为ATAA在高置信区域会迅速推高累计权重让降噪过程的前几帧就展现出明显效果。第二个差异是光照变化时的反应速度。旋转光源扫过一面墙时传统TAA会有一个明显的旧光照残留过程墙面亮度变化像在慢镜头播放ATAA则几乎同步刷新新光照到达的瞬间就切换过去了。这种即时反馈在交互操作场景里体验差异非常大毕竟人眼对动态变化本来就敏感。第三个差异是反射区。镜面反射是光追场景里历史信息最不可信的区域之一因为反射内容的变化速度不等于物体运动速度。ATAA对这类区域会自动拉低历史信任度反射画面会更噪但不会出现错误的高光残影。我觉得在游戏体验里略噪但正确比干净但错误容易接受得多。5. 接ATAA最容易踩的三个坑从现象到根因的完整排查链路5.1 静态画面还在闪色调映射与置信度计算的顺序问题现象很迷惑场景完全静止ATAA跑起来之后画面还是在闪烁。排查第一步先确认不是原始噪声把ATAA关掉原始带噪图闪是正常的。开了ATAA还闪说明置信度判断在震荡。我当时的排查链路是这样走的先看置信度图发现平坦区域的置信度在0.9和0.3之间来回跳。进一步看数据发现当前帧和历史颜色的时间差明明很小但归一化的分母也小得可怜——这是暗部区域。暗部像素亮度均值只有0.01左右时间差0.0005和0.005在绝对数值上差得很远但在归一化后都会被放大成大数字导致置信度抖动。根因是采样来的颜色还在线性HDR空间亮度均值分布跨越好几个数量级。解决方式是在计算diffCV时给分子分母都做对数化或者饱和度映射处理让暗部的微小抖动不会被当成剧烈变化。我用了一个简化做法把亮度值做一次幂变换再到归一化公式里效果立竿见影。调过之后暗部区域的置信度稳定在0.8以上闪烁消失。5.2 快速旋转镜头时的严重鬼影运动向量的多义性另一个高频故障是快速旋转镜头时鬼影加重。传统TAA有这个问题换到ATAA之后如果没做运动向量修复还会更明显——因为ATAA在低置信区域会迅速抛弃历史但如果问题压根出在重投影坐标错了置信度再低也找不到正确的历史坐标。排查时我把运动向量可视化发现物体边缘的向量值出现明显跳变。原因在于运动向量缓冲通常基于深度图的差分来计算物体边缘的深度不连续差分出来的向量在边缘两侧各取一半重投影坐标就偏了半个像素甚至更多。半个像素的偏移在静态场景中还能靠边缘保护补救快速旋转时偏移量大增鬼影马上冒出来。修复方案是做运动向量的边缘修复对全屏运动向量做一次3x3范围的膨胀让边缘像素使用同侧物体的运动向量避免背景运动向量污染前景边缘。也可以用深度感知的做法把最接近当前深度的邻域运动向量挑出来。验证时同时观察边缘置信度图边缘区域的置信度不再出现带状低谷鬼影问题基本解决。5.3 镜面反射区出现色斑材质ID校验与分类历史缓存第三个坑比较隐蔽镜面反射区域正常但反射上叠加了一些彩色色斑像润滑油浮在水面上。这个现象最初很难定位因为色斑的颜色不是黑色也不是白色而是镜面反射里的高光颜色。排查思路最终指向历史缓存区的内容混入。镜面反射像素的最终颜色来自多个表面直接可见的表面和反射中可见的表面。这两者的材质ID完全不同。我的历史缓存只记录了一层材质反射区域项目的声音于是反射中的内容写进历史后下一帧采样到的却是当前可见表面两者颜色一混合色斑出现。修复方式是给历史缓冲增加一个材质分类位记录该像素是否含有次级反射贡献。对包含次级反射贡献的像素ATAA要把它当作低历史信任度像素处理而不是直接禁掉历史累积。更好的方案是给镜面反射单独开一条历史缓存或者把反射贡献从主颜色里分离出来分别做时间滤波再合成。后者的显存开销更大但效果最干净。6. ATAA的适用边界与后续演进空间6.1 ATAA能取代空间滤波和机器学习降噪吗直接给结论不能完全取代。ATAA在时间维度做文章它利用的是同一像素在不同帧的采样互补性但1spp下镜面高光、窄反射这类极端高频区域噪声方差大到即使时间累积几十帧也未必压得住。这种区域必须先靠空间滤波把方差压到一个合理量级再交给ATAA做时间稳定。实际工程中我更推荐组合使用先跑一个轻量空间滤波做初步降噪再跑ATAA做时间累积和稳定。空间滤波的核尽量小只压高频噪声ATAA负责长时间累积、动态场景的响应和边缘稳定。这个组合在画质和性能上比任何单打独斗的方案都均衡。机器学习降噪又是另一条路线它擅长在单帧里直接恢复近似无噪图像但容易产生时间上的不一致静态画面会像呼吸灯一样忽亮忽暗。ATAA的置信度图恰好可以给ML降噪提供时间一致性引导两者的关系更像是互补而不是互相替代。6.2 与超分辨率方案衔接的可能性DLSS、FSR这类超分方案的核心是低分辨率渲染、高分辨率输出时间滤波组件在超分管线里承担了大量稳定画面的工作。ATAA产出的置信度图天然适合作为超分方案的指导信息因为它用文字指标描述了这个像素历史信息有多可信。具体衔接上可以把ATAA输出的alpha通道当作超分锐化强度的调制因子。置信度高的区域锐化可以更激进细节更清晰置信度低的区域必须减少锐化否则会把噪声边缘放大成线条。我试过用这个思路改造简单的双线性超分流程边缘稳定性的提升非常明显。如果未来要走机器学习超分ATAA的置信度图也可以作为网络输入特征之一帮助网络学会区分这是高频纹理和这是历史残影。这种特征在传统方案里需要额外的网络预测才能得到ATAA直接送出来成本和精度上都划算。6.3 落地之后的一些体会把ATAA接进管线之后我最大的感受是该遗忘的时候必须快速遗忘比该累积的时候狠狠累积更重要。以前调TAA参数我总想着怎么把历史权重拉高让画面更干净结果每次都被鬼影教育换到ATAA思路之后我开始主动给历史信息留后路让低置信像素快速重置反而得到了更干净的整体画面。调参过程中最有价值的工具不是帧率曲线也不是PSNR统计而是把置信度图画出来看。它像一张CT扫描片能直接告诉你算法在哪些区域丧失了判断力。一旦置信度图看起来逻辑自洽最后的成像质量通常不会太差。如果你打算在自己的项目里尝试建议从kAlphaMin和kAlphaMax两个参数先动手把运动场景的拖影调到可以接受的程度再回头调置信度公式里的两个缩放系数。方向对了之后剩下的就是修边边角角的问题了。