简介
常见的抗锯齿手段有两种,一种是基于采样的 SSAA 和 MSAA,另一种是基于后处理的如 FXAA、TAA。
效果上:SSAA > MSAA > TAA > FXAA (但是 TAA 会让部分玩家头晕,我自己用 FXAA 比较多)
效率上:TAA > FXAA > MSAA > SSAA
TAA 和 FXAA 的效率差距其实很小,并且基于后处理的 AA 要比前一种效率高很多。这些抗锯齿选项基本是每个游戏的标配了。
基本原理
锯齿通常发生在图像边缘的地方,在频域上属于高频分量,但是基于采样的 AA 都有个共同的缺点,那就是会在非边缘部分浪费许多计算。SSAA 尤其明显!
例如在三角形的内部,基本不会出现锯齿(不考虑 Shading 引起的锯齿,例如高光),但是 SSAA 需要为这部分付出额外三倍的计算量和存储空间,MSAA 还好,但也需要额外三倍的 FrameBuffer 开销。
而 FXAA 很好地避免了这个问题,它使用 CV 里常见的图像边缘检测技术,先把图像中的边缘提取出来,然后再做抗锯齿。
算法的基本步骤如下:
-
将 RGB 颜色转换成亮度图,可以用 NTSC 1953 的经验公式
- Gray = 0.30R + 0.59G + 0.11B
-
在亮度图下计算每个四周的梯度,梯度大的方向就是边缘的法线方向
-
沿着边缘的切线方向前进,计算出边缘的两个端点
-
选取最近的一个,并且颜色不相似的端点,计算起点与它的距离
-
通过距离算出一个混合系数,接着从原点出发,与沿着法线方向的某个像素点混合,得到输出。
代码实现
设置屏幕空间的顶点
由于我们做的是全屏后处理效果,因此考虑在顶点着色器设置一个足以覆盖整个屏幕空间的三角形,这样在光栅化后,片元着色器就能处理屏幕空间的每个像素。另外还需要配套一个屏幕空间的 UV 坐标。
#version 310 es
#extension GL_GOOGLE_include_directive : enable
#include "constants.h"
layout(location = 0) out vec2 out_uv;
void main()
{
const vec3 fullscreen_triangle_positions[3] =
vec3[3](vec3(3.0, 1.0, 0.5), vec3(-1.0, 1.0, 0.5), vec3(-1.0, -3.0, 0.5));
// 计算每个顶点对应屏幕空间下的 UV 坐标
out_uv = 0.5 * (fullscreen_triangle_positions[gl_VertexIndex].xy + vec2(1.0, 1.0));
// 该三角形在经过裁剪后会成为两个覆盖屏幕空间的直角三角形
gl_Position = vec4(fullscreen_triangle_positions[gl_VertexIndex], 1.0);
}
FS 输入
片元着色器的输入和一些宏定义先放在这里,文章末尾有详细代码。
layout(set = 0, binding = 0) uniform sampler2D in_color;
layout(location = 0) in vec2 in_uv;
layout(location = 0) out vec4 out_color;
计算亮度矩阵
在片元着色器中,in_uv 的值是当前片元的坐标值,将这个片元叫作起点。
首先计算起点周围 3x3 的亮度值,如果周围亮度值的最大值和最小值的差异小于一个阈值,可以认定它并不是我们要找的边缘,直接返回。
mediump ivec2 screen_size = textureSize(in_color, 0);
// 计算屏幕空间下片元的两个边长
highp vec2 uv_step = vec2(1.0 / float(screen_size.x), 1.0 / float(screen_size.y));
// 计算当前像片元四周的亮度值
float luma_mat[9];
for(int i = 0; i < 9; i++){
luma_mat[i] = RGB2LUMA(texture(in_color, in_uv + uv_step * STEP_MAT[i]).rgb);
}
float luma_max = max(luma_mat[CENTER], max(max(luma_mat[LEFT], luma_mat[RIGHT]), max(luma_mat[UP], luma_mat[DOWN])));
float luma_min = min(luma_mat[CENTER], min(min(luma_mat[LEFT], luma_mat[RIGHT]), min(luma_mat[UP], luma_mat[DOWN])));
// 如果3x3色块内的亮度差异并不大,那就跳过
if(luma_max - luma_min < max(EDGE_THRESHOLD_MIN, luma_max * EDGE_THRESHOLD_MAX)) {
out_color = texture(in_color, in_uv);
return;
}
计算梯度
分别沿着竖直方向和水平方向计算梯度,我们通过两个方向的梯度值的大小来判断直线是水平走势还是垂直走势。
例如竖直方向的梯度大的话,那就说明边缘是沿着水平方向的。
// 沿着竖直方向的梯度
float luma_horizontal =
abs(luma_mat[UP_LEFT] + luma_mat[DOWN_LEFT] - 2.0*luma_mat[LEFT]) +
abs(luma_mat[UP_RIGHT] + luma_mat[DOWN_RIGHT] - 2.0*luma_mat[RIGHT]) +
abs(luma_mat[UP] + luma_mat[DOWN] - 2.0*luma_mat[CENTER]);
// 沿着水平方向的梯度
float luma_vertial =
abs(luma_mat[UP_LEFT] + luma_mat[UP_RIGHT] - 2.0*luma_mat[UP]) +
abs(luma_mat[DOWN_LEFT] + luma_mat[DOWN_RIGHT] - 2.0*luma_mat[DOWN]) +
abs(luma_mat[LEFT] + luma_mat[RIGHT] - 2.0*luma_mat[CENTER]);
// 竖直方向的梯度大的话,那就说明边缘是沿着水平方向的
bool is_horizontal = abs(luma_horizontal) > abs(luma_vertial);
计算混合方向
上一步我们找到了边缘和它的方向,但是对于一个片元来说,我们应该和谁去混合?混合的比例是多少?
答案是片元应该沿着梯度方向去混合&