基于形态学的方法 MLAA & SMAA

最近在学习一些通用的 postprocessing 技术,看到 Three.js 中的 SMAA 实现。结合知乎上这篇 「反走样技术(一):几何反走样」 文章,深入了解了下其中基于形态学反走样的 MLAA 和 SMAA 思路和实现方式。

几何反走样:基于形态学的方法

走样问题在之前实现实时软阴影时就已经遇到过了。

引用「反走样技术(一):几何反走样」一文中关于形态学反走样的介绍:

形态学反走样属于 Screen Space AA 的一类,它的基本思路是:假设同一物体在某些信息上存在连续性,那么可以通过检测像素在这些信息(颜色,深度,法线)上的不连续找出一些边缘,同时这些边缘根据局部形状不同会形成一些形态模式(pattern),我们通过总结出一些固有模式,然后通过这些模式反推(拟合)出采样前几何边界的解析形式(直线方程),最后通过这些方程再来计算每个像素的覆盖率,利用覆盖率的结果重新混合原始颜色(也就是resolve过程),最终达到反走样的目的。

MLAA(Morphological Antialiasing) 和 SMAA(Subpixel Morphological Antialiasing) 在算法思路上并无区别,只是 MLAA 算法最初提出来是基于 CPU 的算法,而 SMAA 则结合 GPU 的特点进行了工程上的优化。

MLAA

作者在「GPU Pro2」中介绍过 MLAA:

以下是 Slide 中总结的具体步骤:

下面我们来看看具体的思路。

算法思路

我们想找到边缘处的蓝线,蓝线以下黑色,以上白色:

其中存在若干性能问题:

  1. 查找 端点开销大
  2. 查找 crossing edge(黄圈部分)开销大
  3. 找到 crossing edge 之后 revectorization(线段形态)仍有 16 种(4个端点组合)
  4. 以上重复 4 个方向,一个像素点可能有 4 条直线经过

解决方法:

图片 1.png

下面来看实现细节。

实现细节

一共会经过 3 个 pass:

  1. 边缘检测,生成 edge texture
  2. 从每个不连续像素出发,找到经过它的直线的两个端点,记录端点的距离及整个线条的形状(这个形状模式最多有16种),并估算当前像素被这条线段切分后的两个部分的面积
  3. 每个像素最多被四个不同方向的线条切分,则该像素最多有四个面积权重,根据该权重,取周围像素和当前像素进行颜色混合

图片 1.png

边缘检测

这部分具体实现省略,最终输出到纹理图中,假设边缘像素值为1,否则为0。

2nd pass

首先是查找 d,正常情况下需要读取第一步中生成的边缘纹理图,比较相邻两个像素点的值,需要读取纹理两次。但是如果使用 bilinear filtering,对相邻像素点的中点插值后,就只需要读取一次。 例如下图左侧读取菱形处的值为 1,说明仍处于边缘中,不是端点。而右图值为 0.5,说明来到了边缘的端点处。

然后我们需要找到端点线条的形状,仍然采用 bilinear filtering。但是问题是在如下两种情况下值相同,仍然无法完全区分:

解决办法是通过在采样点处增加 0.25 的偏移量来区分:
图片 1.png
最后需要计算覆盖面积。根据前面的优化方案,把 16 种线段形态以及面积覆盖率全部预计算到了一张贴图上,然后根据找到的端点位置和长度作为索引去查找这张4D贴图。
图片 1.png

Neighborhood Blending

根据上一步计算出的面积覆盖率 a,求最终 c 点的像素值:
图片 1.png

SMAA

主要思路和 MLAA 一样,但是在 b、c 这两步做了边缘锐化和对角线处理的优化,以提升展示效果。
图片 1.png

Local Contrast Adaptation

实际的边缘并不是黑白分明的,常常是渐变的,为了得到更准确的结果,我们需要扩大比较范围,除了上下左右,还要考虑上上和左左。
例如下图中绿色边缘其实并不精确:

Sharp Geometric Features

之前 MLAA 在重建线段形态时(16种),如果遇到这种阶梯状在角落处会失真:

解决方法也很简单,增加一种线段的类型,扩大查找端点的范围就行了:
图片 1.png

Diagonals

MLAA 在遇到另一种线段的形态时,也会出现较差的效果。左图中对角线方向并不笔直,而是呈现锯齿状:

解决方式就是增加对角线的形态以及面积覆盖率的贴图:

「WIP」Accurate Searches

这部分是为了改进查找端点的精确性,提升查找效率。

WebGL 实现

原作者论文中的实现是 OpenGL 版本的,WebGL 的版本基于 Three.js 的后处理 Composer。 Three.js 实现

边缘检测实现

对于边缘纹理进行线性插值:

this.edgesRT = new THREE.WebGLRenderTarget( width, height, {
  depthBuffer: false,
  stencilBuffer: false,
  generateMipmaps: false,
  minFilter: THREE.LinearFilter,
  format: THREE.RGBFormat
});
this.edgesRT.texture.name = "SMAAPass.edges";

vs 中记录采样点附近上下左右以及左左,上上一共六个邻节点的纹理坐标:

uniform vec2 resolution;

varying vec2 vUv;
varying vec4 vOffset[ 3 ];

void SMAAEdgeDetectionVS( vec2 texcoord ) {
	// 左,上
  vOffset[ 0 ] = texcoord.xyxy + resolution.xyxy * vec4( -1.0, 0.0, 0.0,  1.0 );
  // 右,下
  vOffset[ 1 ] = texcoord.xyxy + resolution.xyxy * vec4(  1.0, 0.0, 0.0, -1.0 );
  // 左左,上上
  vOffset[ 2 ] = texcoord.xyxy + resolution.xyxy * vec4( -2.0, 0.0, 0.0,  2.0 );
}

fs 中负责将边缘检测判断结果输出到 rg 分量中:

// 以下是具体方法实现
gl_FragColor = SMAAColorEdgeDetectionPS( vUv, vOffset, tDiffuse );

// 记录颜色 delta
vec4 delta;
// 当前 fragment 的颜色
vec3 C = texture2D( colorTex, texcoord ).rgb;

// 左邻居
vec3 Cleft = texture2D( colorTex, offset[0].xy ).rgb;
vec3 t = abs( C - Cleft );
// 与左邻居颜色 rgb 最大差值记录在 delta.x 分量
delta.x = max( max( t.r, t.g ), t.b );

// 同理上邻居
vec3 Ctop = texture2D( colorTex, offset[0].zw ).rgb;
t = abs( C - Ctop );
delta.y = max( max( t.r, t.g ), t.b );

// 设置一个阈值进行 0 1 划分,阈值之内认为非边缘
vec2 edges = step( threshold, delta.xy );

// 如果不是边缘(小于阈值),直接丢弃
if ( dot( edges, vec2( 1.0, 1.0 ) ) == 0.0 )
	discard;
  
// 省略右,下,左左,上上邻居计算

// 计算上下左右直接邻居,以及左左,上上节点的最大差值
float maxDelta = max( max( max( delta.x, delta.y ), delta.z ), delta.w );

// Local contrast adaptation 实现,以 0.5max 为阈值进行 0 1 划分
edges.xy *= step( 0.5 * maxDelta, delta.xy );

// 输出到最终纹理中
return vec4( edges, 0.0, 0.0 );

查找端点&形态

这部分是最复杂的一步,首先在上一步边缘检测中,纹理图的 rg 分量表示边缘在垂直或者水平方向。 如果在水平方向,需要向左右两侧查找

// 判断当前像素点是否是边缘
vec2 e = texture2D( edgesTex, texcoord ).rg;
// 是水平方向的边缘
if ( e.g > 0.0 ) {
  vec2 d;
  vec2 coords;
  // 查找 dLeft
  coords.x = SMAASearchXLeft( edgesTex, searchTex, offset[ 0 ].xy, offset[ 2 ].x );
  // 0.25 偏移量解决形态区分问题
  coords.y = offset[1].y; // offset[1].y = texcoord.y - 0.25 * resolution.y (@CROSSING_OFFSET)
  d.x = coords.x;
  
  // 查找端点 e1
  float e1 = texture2D( edgesTex, coords, 0.0 ).r;

同理查找 dRight 和 e2,此时有了边缘长度以及两个端点 e1 e2,就可以在形态纹理(160 x 560)中查找了:

weights.rg = SMAAArea( areaTex, sqrt_d, e1, e2, float( subsampleIndices.y ) );

vec2 SMAAArea( sampler2D areaTex, vec2 dist, float e1, float e2, float offset ) {
  // Rounding prevents precision errors of bilinear filtering:
  vec2 texcoord = float( SMAA_AREATEX_MAX_DISTANCE ) * round( 4.0 * vec2( e1, e2 ) ) + dist;

  // SMAA_AREATEX_PIXEL_SIZE ( 1.0 / vec2( 160.0, 560.0 ) )
  texcoord = SMAA_AREATEX_PIXEL_SIZE * texcoord + ( 0.5 * SMAA_AREATEX_PIXEL_SIZE );

  // subsampleIndices 传入都是 vec2(0, 0) 可忽略
  texcoord.y += SMAA_AREATEX_SUBTEX_SIZE * offset;

  // 查找面积覆盖率
  return texture2D( areaTex, texcoord, 0.0 ).rg;
}

最终混合

经过当前 fragment 的直线有四条(上下左右),我们需要找到覆盖面积最大的一条进行后续的混合:

vec4 SMAANeighborhoodBlendingPS( vec2 texcoord, vec4 offset[ 2 ], sampler2D colorTex, sampler2D blendTex )

// Fetch the blending weights for current pixel:
vec4 a;
a.xz = texture2D( blendTex, texcoord ).xz;
a.y = texture2D( blendTex, offset[ 1 ].zw ).g;
a.w = texture2D( blendTex, offset[ 1 ].xy ).a;
      
// Up to 4 lines can be crossing a pixel (one through each edge). We
// favor blending by choosing the line with the maximum weight for each
// direction:
vec2 offset;
offset.x = a.a > a.b ? a.a : -a.b; // left vs. right
offset.y = a.g > a.r ? -a.g : a.r; // top vs. bottom

// 根据水平垂直方向调整偏移分量
if ( abs( offset.x ) > abs( offset.y )) { // horizontal vs. vertical
	offset.y = 0.0;
} else {
	offset.x = 0.0;
}

// 找到待混合的邻居 Cop 以及混合系数 s
vec4 C = texture2D( colorTex, texcoord, 0.0 );
texcoord += sign( offset ) * resolution;
vec4 Cop = texture2D( colorTex, texcoord, 0.0 );
float s = abs( offset.x ) > abs( offset.y ) ? abs( offset.x ) : abs( offset.y );

// 使用 s 进行混合,要注意先转换到 sRGB 空间进行混合运算,最后再进行 gamma 校正
C.xyz = pow(C.xyz, vec3(2.2));
Cop.xyz = pow(Cop.xyz, vec3(2.2));
vec4 mixed = mix(C, Cop, s);
mixed.xyz = pow(mixed.xyz, vec3(1.0 / 2.2));

return mixed;

WebGL2 

https://github.com/shrekshao/MoveWebGL1EngineToWebGL2/blob/master/Move-a-WebGL-1-Engine-To-WebGL-2-Blog-2.md#multisampled-renderbuffers

总结

本文总结了基于形态学的几何反走样技术 MLAA 和 SMAA,在下一篇中我们将介绍基于时间的方法 TAA。 其实在 SMAA Paper 和 Slide 最后也介绍了 SMAA 和 TAA 结合的使用方式。

参考资料