TAA 时域抗锯齿渲染器

打开任何一款现代游戏,进入设置找到抗锯齿选项,几乎一定有 TAA。UE4、UE5、Unity HDRP、寒霜引擎、虚幻竞技场……这项技术几乎无处不在。

但 TAA 背后隐藏着一个精妙的思想:不在当前帧多次采样,而是把采样分散到时间轴上——每帧只用 1 个采样点,但通过累积历史帧信息,等效获得几十次超采样的质量。

今天从零实现这套机制,包括它的两个核心难题:如何选择每帧的采样偏移(Jitter),以及如何防止历史帧信息带来的鬼影(Ghosting)。


四图对比

四种渲染模式对比

左上:No-AA(明显锯齿)| 右上:SSAA 4x(参考质量)
左下:TAA + Variance Clipping(无鬼影)| 右下:TAA 无 Variance Clipping(残影可见)


第一章:为什么会有锯齿?

理解 TAA 之前,先理解锯齿的成因。

像素是离散的小方块,而几何边缘是连续的斜线或曲线。当一条斜线穿过像素时,这个像素要么完全被覆盖(点亮),要么完全没有覆盖(不点亮)——没有中间状态。这种二值化就产生了锯齿(Aliasing)

香农采样定理给出了理论解释:要正确还原一个频率为 $f$ 的信号,采样频率必须 $\geq 2f$。像素的采样频率是固定的(每像素一次),而高频的几何边缘超出了这个限制,就产生了混叠(Aliasing)。

解决思路:对每个像素采样多次,取平均值——这就是**超采样(Super Sampling)**的原理。


第二章:SSAA 的代价与 TAA 的思路

SSAA 4x(超采样 4 倍) 是最朴素的解法:每个像素采样 4 次(在像素内 4 个位置各发一条光线),取平均。效果极好,但代价是渲染时间乘以 4。

TAA 的洞察:场景在连续帧之间变化不大。能不能把 4 次采样分散到 4 帧里

  • 第 1 帧:从像素左下角采样
  • 第 2 帧:从像素右下角采样
  • 第 3 帧:从像素左上角采样
  • 第 4 帧:从像素右上角采样
  • 第 5 帧:累积前 4 帧,等效于采样了 4 次

这样每帧仍然只渲染 1 spp,但累积足够帧后,质量接近 4-16x SSAA。

代价:每帧的采样位置不同(产生微小抖动),需要历史帧缓冲(额外显存),以及复杂的鬼影处理逻辑。


第三章:Jitter 采样——如何在像素内均匀分布

每帧的采样偏移(Jitter)不能随机——完全随机的话,采样点可能扎堆,某些区域永远没采到。我们需要低差异序列(Low-Discrepancy Sequence)

Halton 序列

Halton 序列 是最常用的低差异序列,基于”不同进制的反射”(Van der Corput 序列)生成:

对于 base-2 序列(Halton(2)):

  • 1 → 0.1₂ = 0.5
  • 2 → 0.01₂ = 0.25
  • 3 → 0.11₂ = 0.75
  • 4 → 0.001₂ = 0.125

每个新的点会填补之前点之间最大的空隙,保证均匀覆盖。

用 Halton(2, 3) 组合(x 用 base-2,y 用 base-3)作为 2D 像素内的采样位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Halton 序列生成:将整数 index 转换为 0~1 之间的均匀分布值
float halton(int index, int base) {
float f = 1.0f; // 当前位权(1/base, 1/base², ...)
float r = 0.0f; // 累积结果
int i = index;

while (i > 0) {
f /= base; // 下移一位(除以 base)
r += f * (i % base); // 当前位的值 × 权重
i /= base; // 右移一位
}
return r; // 返回 [0, 1) 内的值
}

// 每帧计算亚像素 Jitter
float jx = halton(frameIndex, 2) - 0.5f; // x 方向偏移,范围 [-0.5, 0.5]
float jy = halton(frameIndex, 3) - 0.5f; // y 方向偏移,范围 [-0.5, 0.5]

应用 Jitter 时,把相机的成像平面(或 NDC 坐标)偏移半个像素大小:

1
2
3
4
// 将 Jitter 施加到光线方向:让光线稍微偏离像素中心
float u = (x + 0.5f + jx) / width; // 像素中心 + jitter
float v = (y + 0.5f + jy) / height;
Ray ray = camera.getRay(u, v);

效果:第 1 帧采样每个像素的某个位置,第 2 帧采样稍微不同的位置……16 帧后,像素内的 16 个位置都被均匀采样过。


第四章:历史帧累积——指数移动平均

有了每帧的 Jittered 渲染结果,下一步是如何累积历史帧。

简单平均:$\text{output} = \frac{1}{N}\sum_{t=1}^{N} \text{frame}_t$

问题:需要存储所有历史帧(显存爆炸),而且越来越早的历史帧权重越来越低(不必要的精度浪费)。

EMA(指数移动平均)

$$\text{output}t = (1 - \alpha) \cdot \text{history}{t-1} + \alpha \cdot \text{current}_t$$

只需要存储上一帧的结果,新帧的权重是 $\alpha$,历史帧权重是 $(1-\alpha)$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void accumulate(const Image& currentFrame, Image& history, Image& output,
float alpha, bool useVarianceClipping) {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
Vec3 cur = currentFrame.at(x, y); // 当前帧颜色
Vec3 hist = history.at(x, y); // 上一帧累积结果

if (useVarianceClipping) {
hist = varianceClip(hist, currentFrame, x, y); // 见下章
}

// EMA 混合:alpha=0.1 表示当前帧占 10%,历史帧占 90%
output.at(x, y) = hist * (1.0f - alpha) + cur * alpha;
}
}
history = output; // 更新历史缓冲
}

alpha 的选择

  • alpha = 0.1(历史帧权重 90%):平滑,需要约 10 帧才收敛,抗锯齿质量高
  • alpha = 0.2(历史帧权重 80%):收敛更快,但历史积累少,效果差一些
  • 理论上,经过 $N$ 帧后,等效超采样倍数约为 $1/\alpha$——alpha=0.1 约等效于 10 spp

等效超采样倍数推导

EMA 中,第 $k$ 帧的权重为 $\alpha \cdot (1-\alpha)^k$。权重之和为 1,方差缩减比为 $\sum_k [\alpha(1-\alpha)^k]^2 = \frac{\alpha}{2-\alpha}$。对于 alpha=0.1,约等于 $0.1/1.9 \approx 0.053$,即等效于约 19 个独立采样。


第五章:鬼影——TAA 最大的问题

Ghosting(鬼影/残影) 是 TAA 的主要缺陷:当场景中有运动(相机移动、物体移动),历史帧的颜色与当前帧不对应,”残像”就会粘在物体上。

想象用手在屏幕前快速移动:如果 TAA 一直累积历史,你的手的旧位置会留下朦胧的残影——这就是鬼影。

运动向量(Motion Vector)

基本的鬼影处理:使用运动向量(Motion Vector)。对每个像素,存储它在上一帧的对应位置(屏幕空间偏移):

1
2
3
4
5
6
// 对当前像素 (x, y) 计算上一帧的对应位置
// 方法:把当前像素的世界坐标,用上一帧的 MVP 矩阵重新投影
Vec2 motionVec = prevFrameProjection(worldPos) - currentScreenPos;

// 用运动向量采样历史帧(从历史帧的对应位置取颜色,而非同一像素)
Vec3 hist = history.sample(uv + motionVec);

但运动向量只能处理刚体运动,对遮挡/反遮挡(一个物体从另一个物体后面出来)无能为力。

Variance Clipping——最重要的防鬼影技术

Variance Clipping(方差裁剪) 是 TAA 中防鬼影最核心的技术(由 Salvi 等人在 2016 年系统化):

核心思想:历史帧的颜色必须”合理”。什么叫合理?当前帧 3×3 邻域内的颜色分布就是”合理范围”——如果历史帧颜色超出这个范围(太亮、太暗、颜色偏差太大),说明它是鬼影,强制裁剪回合理范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Vec3 varianceClip(Vec3 histColor, const Image& current, int cx, int cy) {
// 计算 3×3 邻域的统计量
Vec3 m1(0,0,0), m2(0,0,0); // 一阶矩、二阶矩
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
int nx = clamp(cx+dx, 0, width-1);
int ny = clamp(cy+dy, 0, height-1);
Vec3 c = current.at(nx, ny);
m1 += c; // 累加颜色(用于计算均值)
m2 += c * c; // 累加颜色平方(用于计算方差)
}
}
m1 /= 9.0f; // 均值
m2 /= 9.0f; // 平方均值

// 方差 = E[X²] - E[X]²
Vec3 variance = m2 - m1 * m1;
Vec3 sigma = sqrtv(maxv(variance, Vec3(0,0,0))); // 标准差

// 将历史颜色裁剪到 [mean - γσ, mean + γσ](γ通常取1.5)
float gamma = 1.5f;
Vec3 cmin = m1 - sigma * gamma;
Vec3 cmax = m1 + sigma * gamma;

// clamp 历史颜色到合理区间
return clamp3(histColor, cmin, cmax);
}

直觉:如果当前帧的 3×3 邻域颜色都是蓝色调(均值偏蓝,标准差小),但历史帧颜色是红色(因为上一帧该位置是红色的物体),那这个红色肯定是鬼影,把它裁剪到蓝色区间就能消除鬼影。

γ 的选择:γ 越小,裁剪越激进(鬼影少,但可能过度丢弃历史,效果退化);γ 越大,历史保留越多(更平滑,但鬼影可能残留)。1.5 是常用经验值。


第六章:核心代码汇总

完整的 TAA 累积流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// TAA 主循环:渲染 N 帧,每帧用不同 Jitter
Image history(width, height, Vec3(0,0,0)); // 历史缓冲,初始为黑

for (int frame = 0; frame < NUM_FRAMES; frame++) {
// 1. 计算当前帧的 Jitter
float jx = halton(frame, 2) - 0.5f; // x 偏移 [-0.5, 0.5]
float jy = halton(frame, 3) - 0.5f; // y 偏移 [-0.5, 0.5]

// 2. 用 Jitter 渲染当前帧(发射带偏移的光线)
Image current = render(scene, camera, jx, jy);

// 3. TAA 累积
Image output(width, height);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
Vec3 cur = current.at(x, y);
Vec3 hist = history.at(x, y);

// Step A: Variance Clipping(防鬼影)
hist = varianceClip(hist, current, x, y);

// Step B: EMA 混合(时域超采样的核心)
output.at(x, y) = hist * (1.0f - TAA_ALPHA)
+ cur * TAA_ALPHA;
}
}
history = output; // 更新历史
}

// 最终 output 就是 TAA 的结果

第七章:与 SSAA 的质量对比

1
2
3
4
5
6
7
量化验证结果:
No-AA 全图亮度均值:(155.0, 168.2, 185.7)
SSAA 全图亮度均值:(155.2, 168.4, 185.9)
TAA 全图亮度均值:(155.2, 168.4, 185.9)

TAA vs SSAA 亮度差:0.01 ← 几乎完全相同 ✅
中心球 RGB(TAA):(117, 155, 196) ← 非黑,正确 ✅

TAA 累积 16 帧后,全图亮度均值与 SSAA 4x 相差不到 0.01——几乎完全一致,但每帧的渲染代价只有 1 spp(而 SSAA 需要 4 spp)。

性能对比

方法 每帧采样数 质量(收敛后) 实时可行
No-AA 1 spp 差(明显锯齿)
MSAA 4x ~2 spp 中(仅处理几何边缘) ⚠️
SSAA 4x 4 spp ❌(4× 渲染时间)
TAA 1 spp 好(收敛后)

MSAA 只处理几何边缘的锯齿,对 shader 内计算的高频(如镜面高光、纹理细节)无效;TAA 处理所有来源的锯齿,这也是它取代 MSAA 成为主流的原因。


第八章:TAA 的缺陷与现代进化

鬼影未完全消除

Variance Clipping 能缓解鬼影,但不能完全消除,特别是:

  • 快速运动的物体
  • 遮挡/反遮挡边缘(物体从遮挡区域出现)
  • 闪烁的光源或高频纹理

糊图(Temporal Blurring)

TAA 把锯齿变成了轻微的模糊——在静止画面上几乎不可察觉,但在运动时,画面可能有一种”糊”的感觉。这是抗锯齿和清晰度之间的 trade-off。

现代进化:DLSS、FSR、XeSS

TAA 是这些超分辨率技术的基础:

  • DLSS 3/4(Nvidia):用神经网络替代简单的 EMA 混合,更智能地处理历史帧,同时还能把低分辨率画面超分到高分辨率
  • FSR 2(AMD):改进的时域超采样,不依赖硬件光追,用运动向量驱动历史采样
  • XeSS(Intel):基于矩阵运算加速的时域超分辨率

核心思想都是 TAA:用时间轴上的信息来补充空间采样的不足。区别在于”如何更聪明地混合历史帧”——从简单的 EMA 到深度神经网络。

延伸阅读

  • Karis, High Quality Temporal Supersampling, Siggraph 2014(UE4 TAA 的经典论文)
  • Salvi, An Excursion in Temporal Supersampling, GDC 2016(Variance Clipping 的系统化论述)
  • Yang et al., Temporal Anti-aliasing and Supersampling Survey, EGSR 2020(综述)

小结

TAA 的完整流程:

  1. Halton Jitter:每帧用低差异序列偏移采样位置,保证像素内均匀覆盖
  2. EMA 累积:$\text{output} = (1-\alpha) \cdot \text{history} + \alpha \cdot \text{current}$,用时间换空间
  3. Variance Clipping:把历史颜色裁剪到当前邻域的统计范围 $[\mu - 1.5\sigma, \mu + 1.5\sigma]$,消除鬼影

最后,TAA 的本质就一句话:把空间超采样的代价摊到时间轴上,以 1 spp 的开销获得接近 N spp 的质量。 代价是多了一个历史缓冲和鬼影处理逻辑,但这个 trade-off 在实时渲染中非常值得。


完成时间: 2026-03-11 05:36
迭代次数: 1 次(一次编译通过)
运行时间: 0.82s
编译器: g++ -std=c++17 -O2

代码仓库: https://github.com/chiuhoukazusa/daily-coding-practice/tree/main/2026/03/03-11-taa-temporal-anti-aliasing