简介
常见的抗锯齿手段有两种,一种是基于采样的 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
-
在亮度图下计算每个四周的梯度,梯度大的方向就是边缘的法线方向
-
沿着边缘的切线方向前进,计算出边缘的两个端点
-
选取最近的一个,并且颜色不相似的端点,计算起点与它的距离
扫描二维码关注公众号,回复: 15247163 查看本文章 -
通过距离算出一个混合系数,接着从原点出发,与沿着法线方向的某个像素点混合,得到输出。
代码实现
设置屏幕空间的顶点
由于我们做的是全屏后处理效果,因此考虑在顶点着色器设置一个足以覆盖整个屏幕空间的三角形,这样在光栅化后,片元着色器就能处理屏幕空间的每个像素。另外还需要配套一个屏幕空间的 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);
计算混合方向
上一步我们找到了边缘和它的方向,但是对于一个片元来说,我们应该和谁去混合?混合的比例是多少?
答案是片元应该沿着梯度方向去混合,混合比例根据由它所处的位置决定。我们会计算距离这个片元最近的端点,然后看一下这个端点是什么颜色,由它来决定我们的混合比例。
因此代码首先需要计算出梯度的方向,以水平方向的边缘为例,如果朝下的梯度比朝上的梯度大,那么说明梯度方向是朝下的。那么在后续混合的时候我们应该和下面的片元的颜色值进行混合。
// 计算沿着边缘的法线方向上下(左右)的梯度
float grandient_down_left = (is_horizontal ? luma_mat[DOWN] : luma_mat[LEFT]) - luma_mat[CENTER];
float grandient_up_right = (is_horizontal ? luma_mat[UP] : luma_mat[RIGHT]) - luma_mat[CENTER];
// 如果下面的梯度大于上面的梯度,则法线是沿着朝下的,竖直方向同理
bool is_down_left = abs(grandient_down_left) > abs(grandient_up_right);
float gradient_start = is_down_left ? grandient_down_left : grandient_up_right;
// 计算切线方向的法线方向的步长
vec2 step_tangent = (is_horizontal ? vec2(1.0, 0.0) : vec2(0.0, 1.0)) * uv_step;
vec2 step_normal = (is_down_left ? -1.0 : 1.0) * (is_horizontal ? vec2(0.0, 1.0) : vec2(1.0, 0.0)) * uv_step;
// 沿着法线方向前进0.5格,到达片元的边界
vec2 uv_start = in_uv + 0.5 * step_normal;
端点搜索
接下来要先计算出端点,然后根据端点的距离和像素值来决定混合比例。我们沿着切线方向每次步进一定距离,那么如何能够确定我们到达了端点呢?
可以计算每个点的梯度,如果它的梯度绝对值比起点的梯度绝对值 1/4 要大,那么就说明找到端点了。
// 边界附近两个片元亮度的均值
float luma_average_start = luma_mat[CENTER] + 0.5 * gradient_start;
// 从起点出发
vec2 uv_forward = uv_start;
vec2 uv_backward = uv_start;
float delta_luma_forward = 0.0;
float delta_luma_backward = 0.0;
bool reached_forward = false;
bool reached_backward = false;
bool reached_both = false;
for(int i = 1; i < STEP_COUNT_MAX; i++){
if(!reached_forward) uv_forward += QUALITY(i) * step_tangent;
if(!reached_backward) uv_backward += - QUALITY(i) * step_tangent;
// 计算出移动后的亮度值
delta_luma_forward = RGB2LUMA(texture(in_color, uv_forward).rgb) - luma_average_start;
delta_luma_backward = RGB2LUMA(texture(in_color, uv_backward).rgb) - luma_average_start;
// 前面半部分是用平均亮度计算的梯度,因此所以算出的梯度会偏小
// 这里只是为了找到端点,所以对 gradient_start 乘以缩放因子 1/4
reached_forward = abs(delta_luma_forward) > GRADIENT_SCALE * abs(gradient_start);
reached_backward = abs(delta_luma_backward) > GRADIENT_SCALE * abs(gradient_start);
reached_both = reached_forward && reached_backward;
if(reached_both) break;
}
计算混合比例
混合比例的计算公式是下面的式子:
L 1 = ∣ L e f t E n d − S t a r t ∣ L 2 = ∣ L e f t R i g h t − S t a r t ∣ A l p h a = − 1.0 ∗ m i n ( L 1 , L 2 ) / ( L 1 + L 2 ) + 0.5 L1 = |LeftEnd - Start|\\ L2 = |LeftRight - Start|\\ Alpha = -1.0 * min(L1, L2) / (L1 + L2) + 0.5 L1=∣LeftEnd−Start∣L2=∣LeftRight−Start∣Alpha=−1.0∗min(L1,L2)/(L1+L2)+0.5
意思就是找到最近的端点的距离,然后除以总距离。因为分子总是分母两项的 Min,所以它的值域是 ( 0.0 , 0.5 ] (0.0, 0.5] (0.0,0.5]。
另外实现中有个细节,我们需要判断更加接近的那个点和自己的颜色是否接近,如果接近那就不用混合了。
如果不这么做,那么混合就具有对称性:片元 A 可以混合片元 B 反过来 B 也会混合 A。 边缘两边的片元都执行混合不是我们想要的,我们需要打破这种对称性。
// 计算混合比例
float length_forward = max(abs(uv_forward - uv_start).x, abs(uv_forward - uv_start).y);
float length_backward = max(abs(uv_backward - uv_start).x, abs(uv_backward - uv_start).y);
bool is_forward_near = length_forward < length_backward;
float pixel_offset = -1.0 * ((is_forward_near ? length_forward : length_backward) / (length_forward + length_backward)) + 0.5;
// 判断更加接近的那个点,和自己的颜色是否接近,如果接近那就不用混合
if( ((is_forward_near ? delta_luma_forward : delta_luma_backward) < 0.0) ==
(luma_mat[CENTER] < luma_average_start)) pixel_offset = 0.0;
out_color = texture(in_color, in_uv + pixel_offset * step_normal);
低通滤波
上面的那些都是为了解决边缘引起的锯齿,但是在文章开头也说了,有一些锯齿并不是由于边缘引起了,典型的就是高光,它在通常出现在物体表面的内部(而不是边界上),并且它们所占有的像素很少,属于图像中极高频的信息。
那么如何判断一个点是不是所谓的高频信息呢?可以计算它与周围点平均亮度的差值,再通过一个经验公式将差值映射成混合比例。
float luma_average_center = 0.0;
float average_weight_mat[] = float[9](
1.0, 2.0, 1.0,
2.0, 0.0, 2.0,
1.0, 2.0, 1.0
);
for (int i = 0; i < 9; i++) luma_average_center += average_weight_mat[i] * luma_mat[i];
luma_average_center /= 12.0;
float subpixel_luma_range = clamp(abs(luma_average_center - luma_mat[CENTER]) / (luma_max - luma_min), 0.0, 1.0);
float subpixel_offset = (-2.0 * subpixel_luma_range + 3.0) * subpixel_luma_range * subpixel_luma_range;
subpixel_offset = subpixel_offset * subpixel_offset * SUBPIXEL_QUALITY;
pixel_offset = max(pixel_offset, subpixel_offset);
这个公式长这样,虽然我们不知道是怎么总结出来的,但从函数的图像可以看出,在 x x x 值不大的时候,说明这个点并不是高频点,输出的函数值比较小,max(pixel_offset, subpixel_offset)
会得到前面边缘的混合比例。
当 x x x 比较大的时候,说明找到高频点了,这时候混合比例是有可能超过 0.5 的(前面计算的混合比例最大也才 0.5)。
所以当画面上高光产生锯齿比较明显的时候,例如波光粼粼的海面,我们可以调整公式,增大混合比例。但这也带来的后果是高光会被平滑。
f ( x ) = 0.75 ∗ x 4 ( − 2 x + 3 ) 2 f(x) = 0.75 * x^4(-2x+3)^2 f(x)=0.75∗x4(−2x+3)2
最终效果
FXAA OFF | FXAA ON |
---|---|
有了低通滤波器以后,看到右边墙面上由于高光产生的锯齿就没那么强烈了。
无低通滤波 | 有低通滤波 |
---|---|
完整代码
#version 310 es
#extension GL_GOOGLE_include_directive : enable
#include "constants.h"
precision highp float;
precision highp int;
layout(set = 0, binding = 0) uniform sampler2D in_color;
layout(location = 0) in vec2 in_uv;
layout(location = 0) out vec4 out_color;
#define UP_LEFT 0
#define UP 1
#define UP_RIGHT 2
#define LEFT 3
#define CENTER 4
#define RIGHT 5
#define DOWN_LEFT 6
#define DOWN 7
#define DOWN_RIGHT 8
#define EDGE_THRESHOLD_MIN 0.0312
#define EDGE_THRESHOLD_MAX 0.125
#define SUBPIXEL_QUALITY 0.75
#define GRADIENT_SCALE 0.25
#define STEP_COUNT_MAX 12
float QUALITY(int i) {
if (i < 5) return 1.0;
if (i == 5) return 1.5;
if (i < 10) return 2.0;
if (i == 10) return 4.0;
if (i == 11) return 8.0;
return 8.0;
}
vec2 STEP_MAT[] = vec2[9](
vec2(-1.0, 1.0), vec2( 0.0, 1.0), vec2( 1.0, 1.0),
vec2(-1.0, 0.0), vec2( 0.0, 0.0), vec2( 1.0, 0.0),
vec2(-1.0,-1.0), vec2( 0.0,-1.0), vec2( 1.0,-1.0)
);
float RGB2LUMA(vec3 rgb_color){
return dot(vec3(0.299, 0.578, 0.114), rgb_color);
}
void main()
{
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);
// 计算沿着边缘的法线方向上下(左右)的梯度
float grandient_down_left = (is_horizontal ? luma_mat[DOWN] : luma_mat[LEFT]) - luma_mat[CENTER];
float grandient_up_right = (is_horizontal ? luma_mat[UP] : luma_mat[RIGHT]) - luma_mat[CENTER];
// 如果下面的梯度大于上面的梯度,则法线是沿着朝下的,竖直方向同理
bool is_down_left = abs(grandient_down_left) > abs(grandient_up_right);
float gradient_start = is_down_left ? grandient_down_left : grandient_up_right;
vec2 step_tangent = (is_horizontal ? vec2(1.0, 0.0) : vec2(0.0, 1.0)) * uv_step;
vec2 step_normal = (is_down_left ? -1.0 : 1.0) * (is_horizontal ? vec2(0.0, 1.0) : vec2(1.0, 0.0)) * uv_step;
// 沿着法线方向前进0.5格,到达片元的边界
vec2 uv_start = in_uv + 0.5 * step_normal;
// 边界附近两个片元亮度的均值
float luma_average_start = luma_mat[CENTER] + 0.5 * gradient_start;
// 从起点出发
vec2 uv_forward = uv_start;
vec2 uv_backward = uv_start;
float delta_luma_forward = 0.0;
float delta_luma_backward = 0.0;
bool reached_forward = false;
bool reached_backward = false;
bool reached_both = false;
for(int i = 1; i < STEP_COUNT_MAX; i++){
if(!reached_forward) uv_forward += QUALITY(i) * step_tangent;
if(!reached_backward) uv_backward += - QUALITY(i) * step_tangent;
// 计算出移动后的亮度值
delta_luma_forward = RGB2LUMA(texture(in_color, uv_forward).rgb) - luma_average_start;
delta_luma_backward = RGB2LUMA(texture(in_color, uv_backward).rgb) - luma_average_start;
// 前面半部分是用平均亮度计算的梯度,因此所以算出的梯度会偏小
// 这里只是为了找到端点,所以对 gradient_start 乘以缩放因子 1/4
reached_forward = abs(delta_luma_forward) > GRADIENT_SCALE * abs(gradient_start);
reached_backward = abs(delta_luma_backward) > GRADIENT_SCALE * abs(gradient_start);
reached_both = reached_forward && reached_backward;
if(reached_both) break;
}
// 计算混合比例
float length_forward = max(abs(uv_forward - uv_start).x, abs(uv_forward - uv_start).y);
float length_backward = max(abs(uv_backward - uv_start).x, abs(uv_backward - uv_start).y);
bool is_forward_near = length_forward < length_backward;
float pixel_offset = -1.0 * ((is_forward_near ? length_forward : length_backward) / (length_forward + length_backward)) + 0.5;
// 判断更加接近的那个点,和自己的颜色是否接近,如果接近那就不用混合
if( ((is_forward_near ? delta_luma_forward : delta_luma_backward) < 0.0) ==
(luma_mat[CENTER] < luma_average_start)) pixel_offset = 0.0;
float luma_average_center = 0.0;
float average_weight_mat[] = float[9](
1.0, 2.0, 1.0,
2.0, 0.0, 2.0,
1.0, 2.0, 1.0
);
for (int i = 0; i < 9; i++) luma_average_center += average_weight_mat[i] * luma_mat[i];
luma_average_center /= 12.0;
// 经验公式
float subpixel_luma_range = clamp(abs(luma_average_center - luma_mat[CENTER]) / (luma_max - luma_min), 0.0, 1.0);
float subpixel_offset = (-2.0 * subpixel_luma_range + 3.0) * subpixel_luma_range * subpixel_luma_range;
subpixel_offset = subpixel_offset * subpixel_offset * SUBPIXEL_QUALITY;
pixel_offset = max(pixel_offset, subpixel_offset);
out_color = texture(in_color, in_uv + pixel_offset * step_normal);
}
参考资料
[1] Catlike Coding, Advanced Rendering, FXAA Smoothing Pixels