本文主要解决两个问题:
1、什么是抗锯齿?
2、如何在OpenGL中使用抗锯齿?
引言
抗锯齿,英文名是anti-aliasing,直译过来叫反走样,但是由于抗锯齿这个名字接受度更广,所以笔者在这里也使用抗锯齿来称呼。“锯齿”这种东西在3D渲染中十分常见,你之前肯定也注意到了,就是立方体边缘那种像台阶一样的东西:
抗锯齿就是要消除这些台阶,让边缘看起来更加平滑,更真实。
锯齿产生原因
锯齿不可避免,因为我们显示器显示场景的时候要将某个具体的像素设置成一定的值。如果你仔细凑近显示器看,你会发现,显示器看上去很像一个个小方格的组合。这些小方格我们就称之为像素。当我们要绘制3D物体的时候,计算物体的边缘位置,如果这个位置把大半个像素包括进去了,那么显示器会将这个像素设置成3D物体的颜色,如果没有包一半以上,那么这个像素就会设置成背景色。我们无法把一半像素设置成物体颜色,另一半像素设置成背景色,这就是锯齿产生的根本原因!
超级采样抗锯齿(SSAA, super sample anti-aliasing)
明白了锯齿产生的原因之后,我们就要来想办法抹平这个锯齿了。最早想出的办法是一种称为超级采样抗锯齿的技术,简称SSAA。SSAA方法是把图像映射到缓存并放大,再对放大后的图像像素进行采样,一般选取2个或4个邻近像素,把这些采样混合起来后生成最终像素,每个像素就拥有了邻近像素的特特征,像素和像素之间的过度也就平缓了。最后将这个大图还原到原来大小的图像,输出到显示器上。
这种方法虽然也有抗锯齿的效果,但是它对资源的消耗太大了,因为每一帧都需要进行放大、采样、缩小的工作。所以,在经历过一段辉煌时期之后就被扫进历史的垃圾堆里了。
多重采样抗锯齿(MSAA,multisample anti-aliasing)
我们重点介绍的方法是多重采样抗锯齿,简称MSAA。要理解MSAA,我们要对OpenGL的光栅化原理了解地深入一些,对我们以后选方法、优化都有帮助。
光栅化在整个渲染流程中处于顶点处理完成与片元着色之间。它所做的工作就是将图元的所有顶点都转换成可以着色的片元(像素)。但是,顶点坐标可以是任意的而显示器上的像素不是,它有大小,这个大小受限于硬件的技术。所以,绝大多数情况下,顶点和片元不会是完美的一一对应关系,所以光栅化的过程会确定图元的边界位置。
上面的图中,框框内红色或者黑色的点被成为采样点。注意:采样点不是像素!!!默认情况下,采样点只有一个(单点采样),在片元中心。所以我们的边界只有在包括一半以上的片元时才能将这个片元涂成物体的颜色。上图中的三角形光栅化之后的结果是:
可以很明显地看到,在边上有些像素被设置成红色,有些没有(即使它有一部分是在三角形内的),这样就形成了锯齿。
MSAA的原理是在一个片元内设置多个采样点(2个、4个或者8个),根据被包含的采样点数量来确定当前像素的颜色。也就是说,如果我设置了4个采样点,其中有两个采样点被包含进去了,那么这个像素的颜色就是物体颜色的一半浓度。用一张图来展示就是这样:
左边的模式是单点采样,采样点没被包括到三角形之内,那么对不起,这个像素就跟你三角形颜色没关系。右边的模式是MSAA(4点),有两个采样点被包含进去了,那这个点的颜色就是三角形颜色的一半浓度,成为淡蓝色。但是,如果使用多重采样,颜色缓存的大小也会变大,因为原本只要存1个采样点的颜色,现在要存4个。
事实上,采样点的数量是无限制的,你可以设置任何数量,只要你的机子跑的动!
事情开始变的有趣起来了。我们来猜一下OpenGL是如何实现将颜色平均效果的。是不是对每个采样点运行一次片元着色器,然后将所有的颜色做平均?是的,你猜的没错。每个有效采样点都要运行一次片元着色器,在最后输出时才会取平均。
我们来看看用了MSAA之后的三角形原理图:
我们对每个像素设置了四个采样点,在三角形边缘的部分可能只有一两个采样点被包围进来。对每个采样点,我们都要进行一次片元着色计算出其颜色,最后输出的时候要用。最终输出的颜色取决于包含采样点的个数,这三角形的渲染情况应当是像这样的:
这样看上去比前面的效果好多了。
开启MSAA影响的不仅仅是颜色缓存。没错,深度缓存和模板缓存也是每份采样一个。深度值会成为顶点深度到采样点的内差值,模板值只是每个采样点保留一份而已。当然,这也就意味着随着采样点数量的增加深度缓存和模板缓存占用的内存也会增大。
原理方面到这就差不多了,记住,OpenGL 内部实现比这里讲的要复杂一点,但我们了解这么多就已经足够了。
OpenGL中使用MSAA
要在OpenGL中使用MSAA,我们就必须使用能够保存超过一个颜色值的颜色缓存。我们要一种新的缓存类型,称为多重采样缓存(multisample buffer)
大多数窗口系统能够提供一个多重采样缓存,包括GLFW。在GLFW中,我们若要使用多重采样,操作非常简单。使用glfw提供的函数glfwWindowHint就可以实现:
glfwWindowHint(GLFW_SAMPLES, 4);
当我们使用glfwCreateWindow函数创建窗口时,创建的窗口就会包含多重采样缓存。GLFW也自动创建了深度缓存和模板缓存的多重采样版。也就是说,所有的缓存大小都是原来的4倍。
现在,我们需要启用多重采样。启用的方式是调用glEnable函数并传入GL_MULTISAMPLE参数。大多数OpenGL驱动都会默认启用多重采样,不过我们要养成一个好的编程习惯,在每次需要启用多重采样的时候都调用一次glEnable函数:
glEnable(GL_MULTISAMPLE);
启用之后,编译一下示例代码,你就能看到边缘明显没那么坑坑洼洼了:
离屏MSAA
MSAA对默认帧缓存来说非常简单,它已经在背后偷偷地完成了所有工作。但要在自己创建的帧缓存中实现就没那么简单了(也不复杂)。
有两种创建多重缓存附件到帧缓存的方式:纹理附件和渲染缓存附件,这点和之前帧缓存章节中讨论的非常类似。
多重采样纹理附件
创建用于多重采样的纹理,我们只需要调用glTexImage2DMultisample函数替代glTexImage2D函数就行了。前者可以接受GL_TEXTURE_2D_MULTISAPLE参数,这就是我们要用到的重要参数。
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, tex);
glTexImage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, 4, GL_RGB, width, height, GL_TRUE);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, 0);
第二个参数指定我们想要的采样数目。最后一个参数设置成GL_TRUE,OpenGL就会使用独立的采样位置,对每个纹素都用相同数目的采样数。
将纹理附加到帧缓存上同样是调用glFramebufferTexture2D函数,不同的是,我们要传递的参数是GL_TEXTURE_2D_MULTISAMPLE:
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D_MULTISAMPLE, tex, 0);
现在,帧缓存就有了多重采样的颜色缓存了。
多重采样渲染缓存对象
类似的,创建渲染缓存对象的方式是用glRenderbufferStorageMultisample代替glRenderbufferStorage:
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPSTH24_STENCIL8, width, height);
同样是第二个参数,设置成4表示启用4个采样点。
渲染到多重采样的帧缓存
渲染到多重采样的帧缓不需要我们管,它自己就搞定了。光栅化过程会自动掌管所有多重采样的操作。我们得到的就是多重采样后的颜色、深度、模板缓存。因为多重采样缓存的特殊性,我们无法直接使用缓存图片进行其他操作,比如在着色器中采样。
因为多重采样图片中包含大量的数据,我们需要将它降级到普通图片状态。降级操作通常使用glBlitFramebuffer函数来实现。它在将一个帧缓存拷贝到另一个的时候自动进行了降级工作。
使用glBlitFramebuffer要指定源拷贝区域和目标拷贝区域,这些区域都是屏幕空间坐标。在帧缓存章节中,我们绑定帧缓存到GL_FRAMEBUFFER。这里我们用GL_READ_FRAMEBUFFER和GL_DRAW_FRAMEBUFFER函数来指定对应的源拷贝区域和目标拷贝区域。然后,glBlitFramebuffer函数就帮我们完成了剩余工作:
glBindFramebuffer(GL_READ_FRAMEBUFFER, multisampledFBO);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
使用这种方式绘制前面的绿色立方体,显示的效果不会有啥区别,不过代码很不一样:
下载这里的源码进行比对。
自定义抗锯齿算法
当然,将多重采样的纹理图直接传递给着色器(在降级之前)也是可以的。GLSL就让我们自己去采样每个纹理图中的像素,所以,自定义抗锯齿算法也是可行的,在大型图形应用中经常使用。
当然,要使用多重采样的纹理不能简单的用sampler2D来采样,我们需要sampler2DMS类型的采样方式:
uniform sampler2DMS screenTextureMS;
使用texelFetch函数就可以从某个采样中提取纹素:
vec4 colorSample = texelFetch(screenTextureMS, TexCoords, 3); // 从第四个样本中提取
自定义抗锯齿算法超出了本文的讨论范围,详细的内容就靠读者自己去研究了。
总结
本章中,我们详细地讨论了锯齿产生的原因以及MSAA算法的原理,对每个像素进行多次采样来平滑锯齿边缘是一个普遍并且高效的方法。当然,MSAA只是其中一种方法,还有CSAA,FXAA等等很多算法可以抗锯齿,这里就只是入个门而已,大家尽可以去研究这些方法,记得回来介绍给笔者哦!
参考资料
www.learnopengl.com(非常好的网站,建议学习)