前言
在默认的情况下渲染,会看到物体的边缘会有强烈的锯齿感,究其原因在于采样不足。但是,尝试提升采样的SSAA会增大渲染的负担;而硬件MSAA与延迟渲染又不能协同工作。为此我们可以考虑使用后处理的方式来进行抗锯齿的操作。在这一章中,我们将会讨论一种常见的后处理抗锯齿方法:FXAA。
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。
FXAA
FXAA(Fast approXimate AntiAliasing) 抗锯齿算法是由NVIDIA的Timothy Lottes开发的,核心思想是从图像中分析出哪些像素属于边缘,然后尝试找出边缘的长度,并根据像素所处边缘的位置对其进行抗锯齿处理。
未开抗锯齿
FXAA
作为一种后处理抗锯齿方法,它可以很方便地加入到你的程序当中,只需要一个全屏Pass即可。在完成前面渲染后,将该图像作为输入,然后经过FXAA算法处理后就能得到抗锯齿的结果。该算法并不是从几何体或者线段的角度出发,而仅仅是通过获取当前像素及周围的像素的亮度信息,以此尝试寻找边缘并进行平滑处理。
目前能找到的FXAA最新的版本也都是10多年前的FXAA 3.11了,它有如下两种实现:
- FXAA 3.11 Quality:该版本通常用于PC,注重抗锯齿质量
- FXAA 3.11 Console:该版本通常用于以前的主机,注重效率
本文将围绕FXAA 3.11 Quality的实现展开说明,不过原代码可能是为了效率,可能是用了别的什么工具把代码打乱了一些,然后将循环也暴力代码展开了,可读性弄的很差。对于现在的硬件来说应该也没什么必要,这里我们将代码进行重新整理以提升可读性为主。
Luma(亮度)
首先我们需要求出当前像素的亮度,类似于将RGB转成灰度图的形式。在假定我们使用线性空间的纹理来保存场景的渲染图像情况下, 假设所有像素的颜色分量值最终限定于0-1的范围内,我们可以使用下面这种常用的公式得到luma:
判断当前像素是否需要应用AA
现在我们先只考虑求出当前像素和与它直接相邻的四个像素的亮度。找到其中的最大值与最小值,这两个值的差可以得到局部对比度。当小于一个与最大亮度成正比关系的阈值时,当前像素不会执行抗锯齿。此外,我们也不希望在暗部(如阴影)区域进行抗锯齿的操作,如果局部对比度小于一个绝对的阈值时,也不会执行抗锯齿操作。这时候我们就可以提前输出该像素的颜色。
float2 posM = texCoord; float4 color = g_TextureInput.SampleLevel(g_Sampler, texCoord, 0); // N // W M E // S float lumaM = LinearRGBToLuminance(color.rgb); float lumaS = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(0, 1)).rgb); float lumaE = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(1, 0)).rgb); float lumaN = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(0, -1)).rgb); float lumaW = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_Sampler, posM, 0, int2(-1, 0)).rgb); // // 计算对比度,确定是否应用抗锯齿 // // 求出5个像素中的最大/最小相对亮度,得到对比度 float lumaRangeMax = max(lumaM, max(max(lumaW, lumaE), max(lumaN, lumaS))); float lumaRangeMin = min(lumaM, min(min(lumaW, lumaE), min(lumaN, lumaS))); float lumaRange = lumaRangeMax - lumaRangeMin; // 如果亮度变化低于一个与最大亮度呈正相关的阈值,或者低于一个绝对阈值,说明不是处于边缘区域,不进行任何抗锯齿操作 bool earlyExit = lumaRange < max(g_qualityEdgeThresholdMin, lumaRangeMax * g_qualityEdgeThreshold); // 未达到阈值就提前结束 if (g_EarlyOut && earlyExit) return color;
g_qualityEdgeThreshold
和g_qualityEdgeThresholdMin
的设置参考如下:
// 所需局部对比度的阈值控制 // 0.333 - 非常低(更快) // 0.250 - 低质量 // 0.166 - 默认 // 0.125 - 高质量 // 0.063 - 非常高(更慢) float g_qualityEdgeThreshold; // 对暗部区域不进行处理的阈值 // 0.0833 - 默认 // 0.0625 - 稍快 // 0.0312 - 更慢 float g_qualityEdgeThresholdMin;
确定边界是水平的还是竖直的
为了确定边界的情况,现在我们需要利用中间像素的luma跟周围8个像素的luma。我们使用下面的公式来求出水平和竖直方向的变化程度。若竖直方向的总体变化程度比水平方向的总体变化程度大,说明当前边界是水平的。
// // 确定边界是局部水平的还是竖直的 // // // NW N NE // W M E // WS S SE // edgeHorz = |(NW - W) - (W - WS)| + 2|(N - M) - (M - S)| + |(NE - E) - (E - SE)| // edgeVert = |(NE - N) - (N - NW)| + 2|(E - M) - (M - W)| + |(SE - S) - (S - WS)| float lumaNW = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(-1, -1)).rgb); float lumaSE = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(1, 1)).rgb); float lumaNE = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(1, -1)).rgb); float lumaSW = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posM, 0, int2(-1, 1)).rgb); float lumaNS = lumaN + lumaS; float lumaWE = lumaW + lumaE; float lumaNESE = lumaNE + lumaSE; float lumaNWNE = lumaNW + lumaNE; float lumaNWSW = lumaNW + lumaSW; float lumaSWSE = lumaSW + lumaSE; // 计算水平和垂直对比度 float edgeHorz = abs(lumaNWSW - 2.0 * lumaW) + abs(lumaNS - 2.0 * lumaM) * 2.0 + abs(lumaNESE - 2.0 * lumaE); float edgeVert = abs(lumaSWSE - 2.0 * lumaS) + abs(lumaWE - 2.0 * lumaM) * 2.0 + abs(lumaNWNE - 2.0 * lumaN); // 判断是 局部水平边界 还是 局部垂直边界 bool horzSpan = edgeHorz >= edgeVert;
例如:
// NW N NE 0 0 0 // W M E 1 1 0 // WS S SE 1 1 1 // edgeHorz = |(NW - W) - (W - WS)| + 2|(N - M) - (M - S)| + |(NE - E) - (E - SE)| // = 1 + 2 * 1 + 1 // = 4 // edgeVert = |(NE - N) - (N - NW)| + 2|(E - M) - (M - W)| + |(SE - S) - (S - WS)| // = 0 + 2 * 1 + 0 // = 2 // edgeHorz > edgeVert,属于水平边界
对于这种单像素的线也能有良好的处理:
// 0 1 0 // 0 1 0 // 0 1 0 // edgeHorz = 0 // edgeVert = 8 // edgeHorz < edgeVert,属于竖直边界
至于位于角上的情况:
// 0 0 0 // 0 1 1 // 0 1 1
由于我们只分为水平和竖直边界,对这种情况我们也先归类到其中一种情况后续再处理
计算梯度、确定边界方向
现在我们只是知道了属于边界的类型,还需要确定边界的过渡是怎样的,比如对水平边界来说有两种情况:
我们可以求上方向和下方向的梯度,找到变化绝对值最大的作为该像素的梯度。
// // 计算梯度、确定边界方向 // float luma1 = horzSpan ? lumaN : lumaW; float luma2 = horzSpan ? lumaS : lumaE; float gradient1 = luma1 - lumaM; float gradient2 = luma2 - lumaM; // 求出对应方向归一化后的梯度,然后进行缩放用于后续比较 float gradientScaled = max(abs(gradient1), abs(gradient2)) * 0.25f; // 哪个方向最陡峭 bool is1Steepest = abs(gradient1) >= abs(gradient2);
最后,我们沿着这个梯度移动半个像素大小,然后计算这个点的平均luma。
// // 当前像素沿梯度方向移动半个texel // float lengthSign = horzSpan ? g_TexelSize.y : g_TexelSize.x; lengthSign = is1Steepest ? -lengthSign : lengthSign; float2 posB = posM.xy; // 半texel偏移 if (!horzSpan) posB.x += lengthSign * 0.5; if (horzSpan) posB.y += lengthSign * 0.5; // // 计算与posB相邻的两个像素的luma的平均值 // float luma3 = luma1 + lumaM; float luma4 = luma2 + lumaM; float lumaLocalAvg = luma3; if (!is1Steepest) lumaLocalAvg = luma4; lumaLocalAvg *= 0.5f;
尝试第一次边缘方向的探索
接下来我们沿着边界方向的两边进行搜索。第一次搜索我们尝试向两边步进1个像素,获取这两个位置的luma,然后计算luma与posB处的平均luma值的差异。如果这个差异值大于局部梯度,说明我们到达了这个边界的一侧并停下,否则继续增加指定倍率的水平texel的偏移
// 沿边界向两边偏移 // 0 0 0 // <- posB -> // 1 1 1 float2 offset; offset.x = (!horzSpan) ? 0.0 : g_TexelSize.x; offset.y = (horzSpan) ? 0.0 : g_TexelSize.y; // 负方向偏移 float2 posN = posB - offset * s_SampleDistances[0]; // 正方向偏移 float2 posP = posB + offset * s_SampleDistances[0]; // 对偏移后的点获取luma值,然后计算与中间点luma的差异 float lumaEndN = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posN, 0).rgb); float lumaEndP = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posP, 0).rgb); lumaEndN -= lumaLocalAvg; lumaEndP -= lumaLocalAvg; // 如果端点处的luma差异大于局部梯度,说明到达边缘的一侧 bool doneN = abs(lumaEndN) >= gradientScaled; bool doneP = abs(lumaEndP) >= gradientScaled; bool doneNP = doneN && doneP; // 如果没有到达非边缘点,继续沿着该方向延伸 if (!doneN) posN -= offset * s_SampleDistances[1]; if (!doneP) posP += offset * s_SampleDistances[1];
对上图来说,红框处算出的gradiantScaled = 0.25
,lumaEndN = 0.5 - 0.5 = lumaEndP = 0.0 < gradiantScaled
(由于使用的是双线性插值,lumaEndN
和lumaEndP
经过插值后的结果为0.5),因此我们可以继续往两边遍历。
继续遍历
假设存在一个点没有到达边缘一侧,我们就继续执行遍历。左侧的点在进行第二次步进后,算出的lumaEndN = abs(0 - 0.5) = 0.5 > gradiantScaled
,说明此时已经到达边缘一侧,左侧的点可以停下,右侧的点则可能要经过多次步进才停下。假设每次都是以1个texel的单位步进(s_SampleDistances
的元素都为1.0
),这时候的状态可能为:
// 继续迭代直到两边都到达边缘的一侧,或者达到迭代次数 if (!doneNP) { [unroll] for (int i = 2; i < FXAA_QUALITY__PS; ++i) { if (!doneN) lumaEndN = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posN.xy, 0).rgb) - lumaLocalAvg; if (!doneP) lumaEndP = LinearRGBToLuminance(g_TextureInput.SampleLevel(g_SamplerLinearClamp, posP.xy, 0).rgb) - lumaLocalAvg; doneN = abs(lumaEndN) >= gradientScaled; doneP = abs(lumaEndP) >= gradientScaled; doneNP = doneN && doneP; if (!doneN) posN -= offset * s_SampleDistances[i]; if (!doneP) posP += offset * s_SampleDistances[i]; // 两边都到达边缘的一侧就停下 if (doneNP) break; } }
但是在有限的迭代次数的情况下,每次都只移动1个像素很可能出现还没有到达边缘的情况。为此我们可以考虑随着迭代次数的增加,加大对当前像素的偏移量。在FXAA的原代码中提供了许多预设的偏移量,其中最高质量和最低质量的偏移如下:
// FXAA 质量 - 低质量,中等抖动 #if (FXAA_QUALITY__PRESET == 10) #define FXAA_QUALITY__PS 3 static const float s_SampleDistances[FXAA_QUALITY__PS] = { 1.5, 3.0, 12.0 }; #endif // FXAA 质量 - 高 #if (FXAA_QUALITY__PRESET == 39) #define FXAA_QUALITY__PS 12 static const float s_SampleDistances[FXAA_QUALITY__PS] = { 1.0, 1.0, 1.0, 1.0, 1.0, 1.5, 2.0, 2.0, 2.0, 2.0, 4.0, 8.0 }; #endif
其低质量提供了6个子级别,中等质量提供10个子级别,最高质量只有1个子级别。随着质量的提升,迭代次数增大,用的偏移量也越来越精细。
估算UV的像素偏移量
接下来计算posB
到两个端点的距离,并找到距离最近的端点。然后我们会计算 到最近端点的距离 与 两个端点距离的比值,用来决定UV的偏移程度。若离端点越接近,UV的偏移程度越大。
// 分别计算到两个端点的距离 float distN = horzSpan ? (posM.x - posN.x) : (posM.y - posN.y); float distP = horzSpan ? (posP.x - posM.x) : (posP.y - posM.y); // 看当前点到哪一个端点更近,取其距离 bool directionN = distN < distP; float dist = min(distN, distP); // 两端点间的距离 float spanLength = (distP + distN); // 朝着最近端点移动的像素偏移量 float pixelOffset = -dist / spanLength + 0.5f;
比如说对某一像素,负方向端点的距离为2,正方向端点的距离为4,那么当前像素离负方向的端点更近,算出来的偏移像素单位为pixelOffset = -2.0 / (2 + 4) + 0.5 = 0.16666
然后我们需要进行额外的检查,确保端点计算到的亮度变化和当前像素的亮度变化一致,否则我们可能步进地太远了,从而不使用偏移。
// 当前像素的luma是否小于posB相邻的两个像素的luma的平均值 bool isLumaMSmaller = lumaM < lumaLocalAvg; // 判断这是否为一个好的边界 bool goodSpanN = (lumaEndN < 0.0) != isLumaMSmaller; bool goodSpanP = (lumaEndP < 0.0) != isLumaMSmaller; bool goodSpan = directionN ? goodSpanN : goodSpanP; // 如果不是的话,不进行偏移 float pixelOffsetGood = goodSpan ? pixelOffset : 0.0;
可以看到,(lumaM = 0) < (lumaLocalAvg = 0.5)
为true
,且((lumaEndN = 1 - 0.5) < 0.0)
为false
,从而goodSpanN = true
。因此可以进行偏移。
对于上图,左端点是因为abs(0.5 - 0.75) >= (gradient = 0.5) * 0.25
而停下的。而(lumaM = 0.5) < (lumaLocalAvg = 0.75)
为true
,((lumaEndP = 0.5 - 0.75) < 0.0)
也为true
,从而goodSpanN = false
,认为这不是一个好的边界,就不进行偏移了。
亚像素抗锯齿
另一个计算步骤允许我们处理亚像素走样。例如非常细的单像素线段在屏幕上出现的锯齿。这种情况下,首先我们可以使用下面的算子来求3x3范围内,当前像素的亮度与周围8像素的加权平均亮度的变化来反映与周围的对比度:
// 求3x3范围像素的亮度变化 // [1 2 1] // 1/12 [2 -12 2] // [1 2 1] float subpixNSWE = lumaNS + lumaWE; float subpixNWSWNESE = lumaNWSW + lumaNESE; float subpixA = (2.0 * subpixNSWE + subpixNWSWNESE) * (1.0 / 12.0) - lumaM; // 基于这个亮度变化计算亚像素偏移量 float subpixB = saturate(abs(subpixA) * (1.0 / lumaRange)); float subpixC = (-2.0 * subpixB + 3.0) * subpixB * subpixB; float subpix = subpixC * subpixC * g_QualitySubPix; // 选择最大的偏移 float pixelOffsetSubpix = max(pixelOffsetGood, subpix);
在只考虑亚像素偏移量的情况下,亮度变化越大,像素偏移量也越大。
现在假定g_QualitySubPix = 0.75
。
回到上面这张图,之前算出的pixelOffset = 0.16666
,然后对于亚像素,subpixA = 2/3 - 1/2 = 1/6
,subpixB = 1/3
,subpixC = 7/27
,subpix = 49/729 * 3/4 = 0.0503
。其中两者的最大值为0.16666
,故这里没有检测到亚像素走样的问题。
至于这张图,pixelOffset = -2.0 / (2 + 3) + 0.5 = 0.1
,subpix = 0.411
。显然在这里检测到了亚像素走样的问题。
最终的读取
我们以原像素位置,沿着梯度的方向进行最后的偏移,然后进行最终的纹理采样,将采样后的颜色作为当前像素的最终颜色。模糊后的颜色与梯度方向像素的颜色与偏移程度有关:
if (!horzSpan) posM.x += pixelOffsetSubpix * lengthSign; if (horzSpan) posM.y += pixelOffsetSubpix * lengthSign; return float4(g_TextureInput.SampleLevel(g_Sampler, posM, 0).xyz, lumaM);
最终模糊的效果大致如下:
可以看到模糊的程度取决于边缘的长度及所处的位置,以及亚像素走样的情况。
演示
在本示例程序中,我们可以尝试调整FXAA的相关参数,并结合调试来查看哪些像素会被处理,且采样偏移程度如何(红色偏移程度小,从红到黄到绿偏移程度逐渐变大)。
FXAA的一个缺点在于,移动场景的时候我们可以发现部分高频区域会出现闪烁现象(感受一下“粒子加速器”)。
另一个缺点在于,由于FXAA主要是根据对比度来决定当前像素是否需要处理,对于复杂场景来说,有很多像素并不是我们想要处理的,却依然被模糊了。下面展示了低阈值导致的过度模糊问题:
左:原图;中:FXAA;右:FXAA调试
这部分可以通过调参进行控制,但模糊现象也是难以完全避免的。
此外,FXAA也可以跟其它抗锯齿算法结合,如本示例提供的MSAA。
总体来看,FXAA 3.11 Quality 对需要模糊的像素至少得采样9次,且随着每次迭代额外增加2次采样。但对于现在的硬件而言,跑一次的用时不到0.1ms还是比较可观的。但对电脑用户而言可能需要效果更好的抗锯齿算法,因此FXAA可能更多应用于移动端。在以后的章节(至少不是下一章,目前的每一章可以当做一个独立的技术专题,并不会有过多的前置依赖)我们可能会探讨时间性的抗锯齿算法。
参考
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。