游戏锯齿:为什么边缘像锯子?

你有没有注意过,在一些游戏里,角色的边缘,建筑的轮廓,远处的栏杆,看起来像是用锯子锯出来的。

一格一格的,粗糙,生硬,不自然。

你把画面截图放大,会看到那条本来应该是平滑曲线的边缘,实际上是一堆小方块拼出来的阶梯。

这个现象,叫做锯齿,英文叫Aliasing。

它让画面看起来廉价,让精心设计的场景显得粗糙。

而消灭它的技术,叫做抗锯齿,Anti-Aliasing。

其中最经典、最广泛使用的一种,叫做多重采样抗锯齿,MSAA,Multisample Anti-Aliasing。

今天,我们用一个切蛋糕的比喻,把这件事说清楚。


一、锯齿是怎么来的

要理解抗锯齿,先要理解锯齿是怎么产生的。

屏幕,是由像素组成的。

像素,是屏幕上最小的发光 单元,是一个小方块。

你的屏幕,可能是1920×1080的分辨率,也就是横向1920个像素,纵向1080个像素,总共两百多万个小方块。

每个像素,只能显示一种颜色。

现在,你要在屏幕上画一条斜线。

这条斜线,在数学上是无限细的,它可以精确地穿过任何位置。

但屏幕上的像素,是离散的,是有固定大小的方块。

斜线穿过的地方,有些像素被完全覆盖,有些像素被部分覆盖,有些像素完全没有被覆盖。

问题来了:那些被部分覆盖的像素,怎么处理?

最简单的方式,是做一个二元判断:

如果这个像素的中心点,在线条内部,就把这个像素涂成线条的颜色。

如果中心点在线条外部,就不涂。

这个方式,简单粗暴,但结果就是那些阶梯状的锯齿。

因为你把一个连续的、平滑的线条,强行映射到了一个离散的、方块状的网格上,信息丢失了,平滑性丢失了。

这个过程,在信号处理里叫做采样,锯齿是采样率不足导致的走样现象。


二、切蛋糕的比喻

现在,我们用切蛋糕来理解这个问题。

想象你有一个大蛋糕,你要把它切成很多小块,每一小块代表屏幕上的一个像素。

切完之后,你需要决定每一小块蛋糕的颜色。

规则是:看这一小块蛋糕的正中心,中心是什么颜色,这块蛋糕就是什么颜色。

现在,有一条巧克力酱画出来的斜线,穿过蛋糕。

对于那些中心点恰好在巧克力酱上的蛋糕块,颜色是棕色。

对于那些中心点在奶油上的蛋糕块,颜色是白色。

但有很多蛋糕块,巧克力酱只覆盖了它的一部分,中心点在奶油上,所以整块被判定为白色。

结果,那条本来平滑的斜线,变成了一串棕色方块拼成的阶梯。

这就是锯齿的来源。

问题的根本,是你只看了每块蛋糕的一个点,就决定了整块蛋糕的颜色。

这一个点,不能代表整块蛋糕的真实情况。


三、最直觉的解法:把蛋糕切得更小

既然一个点不够代表整块蛋糕,那就把蛋糕切得更小,让每块更小的蛋糕,更接近于一个点。

这就是超级采样抗锯齿,SSAA,Super Sampling Anti-Aliasing。

把渲染分辨率提高到屏幕分辨率的四倍,每个屏幕像素对应四个渲染像素。

渲染完成后,把四个渲染像素的颜色平均一下,得到最终的屏幕像素颜色。

效果非常好,锯齿几乎消失。

但代价极大。

分辨率提高四倍,渲染的像素数量提高四倍,GPU的工作量提高四倍,帧率可能直接腰斩。

这就像为了让蛋糕切得更准确,你把每块蛋糕切成原来的四分之一大小,然后再把四小块合并成一块。

结果是准了,但你切蛋糕的工作量,变成了原来的四倍。

太贵了。


四、MSAA的聪明之处:只在边缘多切几刀

MSAA的设计者,发现了一个关键的事实:

锯齿,只出现在边缘。

在一个纯色的区域内部,不管你怎么采样,结果都是一样的颜色,不会有锯齿。

锯齿,只出现在两种颜色的交界处,也就是几何体的边缘。

所以,没有必要对整个画面都做超级采样。

只需要在边缘处,多采样几次,就够了。

这就是MSAA的核心思想。

回到切蛋糕的比喻。

MSAA说:我不需要把整块蛋糕都切成四份。

我只需要在巧克力酱经过的地方,也就是边缘处,多取几个采样点,看看这块蛋糕里,有多少比例被巧克力酱覆盖,然后按比例混合颜色。

对于那些完全在奶油里,或者完全在巧克力酱里的蛋糕块,还是只取一个点,不需要额外工作。

只有那些被边缘穿过的蛋糕块,才需要多取几个点。

这样,工作量大幅减少,但效果接近于超级采样。


五、MSAA的具体工作流程

说完原理,来看看MSAA具体是怎么工作的。

以4x MSAA为例,每个像素有4个采样点。

第一步:确定采样点的位置。

每个像素内部,有4个采样点,它们的位置是预先确定的,不是随机的,也不是均匀分布在四个角,而是经过精心设计的位置,能最好地覆盖像素内部的不同区域。

常见的一种排列,叫做旋转网格采样,4个点分布在像素内部的不同位置,互相错开,避免对齐产生的规律性误差。

第二步:几何体覆盖测试。

对于每个像素,GPU检查这4个采样点,哪些点在几何体内部,哪些在外部。

注意,这里只是做覆盖测试,判断点在不在几何体里,还没有计算颜色。

第三步:着色计算。

这是MSAA最关键的优化所在。

对于一个像素,不管它的4个采样点有几个在几何体内部,着色计算只做一次。

着色,就是计算这个像素应该是什么颜色,包括光照计算、纹理采样等,是GPU工作量最大的部分。

MSAA只在像素中心做一次着色,得到一个颜色值。

第四步:按覆盖率混合颜色。

假设4个采样点里,有3个在几何体内部,1个在外部。

覆盖率是3/4,也就是75%。

最终这个像素的颜色,是75%的几何体颜色,加上25%的背景颜色。

这个混合,让边缘变得平滑,不再是非黑即白的硬边。

第五步:resolve。

所有像素处理完成后,把多采样的结果合并成最终的单采样图像,输出到屏幕。


六、用数字感受一下效果

来看一个具体的例子。

一条斜线,穿过一排像素。

没有抗锯齿的情况:
像素1:中心点在线条外,颜色 = 背景色(白)
像素2:中心点在线条内,颜色 = 线条色(黑)
像素3:中心点在线条外,颜色 = 背景色(白)
像素4:中心点在线条内,颜色 = 线条色(黑)

结果:白黑白黑,硬边,锯齿明显。

4x MSAA的情况:

像素1:4个采样点,1个在线条内
覆盖率 = 1/4 = 25%
颜色 = 25%黑 + 75%白 = 浅灰

像素2:4个采样点,3个在线条内
覆盖率 = 3/4 = 75%
颜色 = 75%黑 + 25%白 = 深灰

像素3:4个采样点,2个在线条内
覆盖率 = 2/4 = 50%
颜色 = 50%黑 + 50%白 = 中灰

像素4:4个采样点,4个在线条内
覆盖率 = 4/4 = 100%
颜色 = 100%黑 = 黑

结果:浅灰、深灰、中灰、黑,有过渡,边缘平滑。

这个过渡,就是抗锯齿的本质:

用颜色的渐变,模拟几何体边缘的平滑性。


七、MSAA的内存代价

MSAA不是免费的,它有内存代价。

4x MSAA,每个像素有4个采样点,每个采样点需要存储颜色值和深度值。

这意味着,颜色缓冲区和深度缓冲区,都需要扩大到原来的4倍。

一个1080p的游戏,颜色缓冲区大约需要8MB。

开启4x MSAA,颜色缓冲区变成32MB。

深度缓冲区同样扩大4倍。

这些额外的内存,对现代GPU来说不是大问题,但在内存带宽上,会有一定的压力。

读写更大的缓冲区,需要更多的内存带宽,这会影响GPU的整体性能。


八、MSAA的局限:它解决不了所有锯齿

MSAA很好,但它有一个明显的局限。

它只能处理几何体边缘产生的锯齿。

对于着色产生的锯齿,它无能为力。

什么是着色产生的锯齿?

比如,一个表面上有高频纹理,纹理里有很细的条纹。

这些条纹,不是几何体的边缘,而是着色计算的结果。

MSAA对每个像素只做一次着色,所以这种锯齿,MSAA处理不了。

还有透明物体,比如铁丝网、树叶。

这些物体,用透明度来模拟细节,透明边缘产生的锯齿,MSAA也处理得不好。

这就是为什么后来出现了FXAA、TAA等其他抗锯齿技术,它们用不同的方式,解决MSAA解决不了的问题。

但MSAA作为最经典的抗锯齿方案,在几何边缘的处理上,至今仍然是最可靠的选择之一。


九、在代码里,MSAA是怎么开启的

如果你用OpenGL 或者Vulkan做图形开发,开启MSAA的方式,大致是这样的。

OpenGL里:

// 创建支持多重采样的帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);

// 创建多重采样的颜色缓冲
glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
glRenderbufferStorageMultisample(
    GL_RENDERBUFFER, 
    4,                    // 采样数,4x MSAA
    GL_RGBA8,             // 颜色格式
    width, height
);
glFramebufferRenderbuffer(
    GL_FRAMEBUFFER, 
    GL_COLOR_ATTACHMENT0,
    GL_RENDERBUFFER, 
    colorBuffer
);

// 创建多重采样的深度缓冲
glBindRenderbuffer(GL_RENDERBUFFER, depthBuffer);
glRenderbufferStorageMultisample(
    GL_RENDERBUFFER,
    4,                    // 同样是4x
    GL_DEPTH_COMPONENT24,
    width, height
);
glFramebufferRenderbuffer(
    GL_FRAMEBUFFER,
    GL_DEPTH_ATTACHMENT,
    GL_RENDERBUFFER,
    depthBuffer
);

// 渲染完成后,resolve到普通帧缓冲
glBindFramebuffer(GL_READ_FRAMEBUFFER, msaaFramebuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolveFramebuffer);
glBlitFramebuffer(
    0, 0, width, height,
    0, 0, width, height,
    GL_COLOR_BUFFER_BIT,
    GL_NEAREST
);

关键的函数是glRenderbufferStorageMultisample,第二个参数是采样数。

4就是4x MSAA,8就是8x MSAA,采样数越高,效果越好,性能开销越大。

最后的glBlitFramebuffer,就是resolve步骤,把多采样的结果合并成最终图像。

回到那个切蛋糕的比喻。

普通渲染,是看每块蛋糕的中心点,一刀切,非黑即白。

SSAA,是把蛋糕切成更小的块,工作量翻倍,但结果更准确。

MSAA,是只在边缘处多取几个点,内部区域还是一个点,用最小的额外工作量,换来边缘的平滑。

这个聪明的权衡,让MSAA在性能和效果之间,找到了一个很好的平衡点。

它不是最完美的方案,但它是最经典的方案。

理解了它,你就理解了抗锯齿这件事的核心逻辑:

锯齿,是采样不足的结果。

抗锯齿,是用更多的采样信息,还原边缘的真实形态。

多采样几刀,蛋糕就切得更准。


版权声明:本文为CSDN博主「你一身傲骨怎能输」的原创文章,
遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_33060405/article/details/159254192

最新文章