4 更多渲染技术
传统的向前渲染方式需要一个完整的图像渲染管线,以顶点着色器为起点,气候跟随多个后续阶段,通常以片段着色器为终点。片段着色器负责计算每个片段的最终颜色,随着每一条绘制指令的执行,帧缓存对象的内容逐渐变得完整。然而我们并不是只有这一种渲染场景的方式,正如你接下来将在本小节中看到的一样,你可以只计算部分着色信息,当所有模型完成绘制后,仍然能够得到渲染好的场景。甚至你可以绕过传统的基于顶点的几何图形表示,将所有的几何处理逻辑都放在片段着色器中。
4.1 延迟着色(Demo要求OpenGL4.2)
几乎到目前为止我们所有的示例程序中片段着色器都是用于计算当前正在渲染的片段的颜色。现在考虑这样一个场景,当你渲染多个模型时,某些模型的部分区域会覆盖其他模型,而被覆盖的部分实际上已经执行过完整的渲染逻辑,这种现象称为覆盖渲染(overdraw)。这种情况下需要使用新的渲染结果来覆盖之前的渲染结果,也就意味着你之前的那部分渲染工作的结果全部被抛弃。如果片段着色器运行的成本很高,或者有大量的覆盖渲染,这会很影响性能。为了解决这个问题,你可以使用延迟着色(Deferred Shading)技术,它使得片段着色器中的高成本计算逻辑可以被推迟到最后一刻执行。
在使用延迟着色技术时,首先我们需要使用一个非常简单版本的片段着色器,该着色器需要将我们真正执行渲染任务所需要使用到的参数输入片段着色器中。在大多数场景中,我们都需要使用多个帧缓存附件。回顾前面介绍光照效果时使用过的渲染逻辑,在渲染单个片段的时候需要使用到的参数有片段的漫射系数,所处曲面的法向量,在世界坐标系中的位置。尽管在世界坐标系中的位置可以通过片段在屏幕空间内的坐标和深度缓存中的数据重建,但是我们还是将这些数据直接存储到一个帧缓存附件中会更高效,也会更方便。用于存储这部分数据的帧缓存对象我们通常称为G缓存(G-buffer)。在这里G表示的是几何体(Geometry),意为存储的是几何体的点位置而不是图像数据。
当G缓存准备好后,就可以使用一个视图窗口的四边形来绘制整个场景。这一次渲染将执行整个光照算法逻辑,但是我们并没有对场景中所有模型的每个三角形图元光栅化得到的每个片段进行处理,对整个帧缓存中的每个像素仅仅执行了一个高成本的光照计算。这能够很大程度的降低片段着色的性能开销,尤其当使用的着色算法很复杂时这种性能提升更明显。
4.1.1 生成G缓存
延迟着色的第一步是创建一个G缓存,具体的方法是为一个帧缓存对象添加多个附件。OpenGL最多支持为一个帧缓存对象添加8个附件,每个附件最高支持4个32位的通道,如格式GL_RGBA32F
。然而每个附件的每个通道都会消耗一定的内存带宽(Memory Bandwidth),如果我们完全不考虑写入到帧缓存中的数据体积,那么尽管我们提示了渲染逻辑的效率,但是我们却增加了数据存储成本。
通常情况下,使用16位的浮点数据来存储颜色和法向量信息已经足够。32位的浮点数据用于存储对精度要求更高的每个片段在世界坐标系中的位置。此外又是我们还需要存储一些材质数据,例如每个片段的高光指数(Specular Exponent),也可以称为闪光因子(Shininess Factor)。通常对于需要存储的数据我们需要使用不同的格式存储,考虑内存带宽的高效性,一个好的方式是将这些数据打包成为相同的格式,而不是在一个帧缓存对象中使用多个不同数据格式的附件。如将2个16位的数据打包成一个32位的数据,具体方法稍后演示。
在本节的示例程序中,将使用3个16位数据格式的分量来存储每个片段的法向量,3个16位数据格式的分量来存储每个片段自己的颜色,3个32位数据格式的分量来存储每个片段在世界坐标系中的位置,1个32位的整形变量来存储每个片段使用的材质索引,以及1个32位的数据格式分量存储每个像素的高光指数。
总的来说需要6个16位分量和5个32位的分量。我们可以将6个16位分量打包后存储在格式为GL_RGBA32UI的帧缓存的前三个分量中,剩余的1个分量刚好能够存储1个32位的数据。剩下的3个32位世界空间顶点坐标以及1个32位数据可以打包存储在一个格式为GL_RGBA32F的帧缓存中。G缓存创建的代码如下。
GLuint gbuffer;
GLuint gbuffer_tex[3];
glGenFramebuffers(1, &gbuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gbuffer);
glGenTextures(3, gbuffer_tex);
glBindTexture(GL_TEXTURE_2D, gbuffer_tex[0]);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA32UI,
MAX_DISPLAY_WIDTH, MAX_DISPLAY_HEIGHT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, gbuffer_tex[1]);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA32F,
MAX_DISPLAY_WIDTH, MAX_DISPLAY_HEIGHT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, gbuffer_tex[2]);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_DEPTH_COMPONENT32F,
MAX_DISPLAY_WIDTH, MAX_DISPLAY_HEIGHT);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, gbuffer_tex[0], 0);
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, gbuffer_tex[1], 0);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, gbuffer_tex[2], 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
当G缓存准备完毕后,接下来需要做的事情就是向其中填充数据。前面已经提过我们需要将两个个16位的数据打包成为1个32位的数据,这可以通过在着色器语言中调用函数packHalf2x16
(该函数要求OpenGL4.2)将2个32位浮点型数据转换为2个16位浮点型数据,并按位转换为32位整形数据实现。假定我们能够在片段着色器中拿到必要的数据,那么通过如下的代码可以将这些数据通过两个颜色输出填充到帧缓存中。
#version 420 core
layout (location = 0) out uvec4 color0;
layout (location = 1) out vec4 color1;
in VS_OUT {
vec3 ws_coords;
vec3 normal;
vec3 tangent;
vec2 texcoord0;
flat uint material_id;
} fs_in;
layout (binding = 0) uniform sampler2D tex_diffuse;
void main(void) {
uvec4 outvec0 = uvec4(0);
vec4 outvec1 = vec4(0);
vec3 color = texture(tex_diffuse, fs_in.texcoord0).rgb;
outvec0.x = packHalf2x16(color.xy);
outvec0.y = packHalf2x16(vec2(color.z, fs_in.normal.x));
outvec0.z = packHalf2x16(fs_in.normal.yz);
outvec0.w = fs_in.material_id;
outvec1.xyz = fs_in.ws_coords;
outvec1.w = 60.0;
color0 = outvec0;
color1 = outvec1;
}
在准备好G缓存后,下一步就是计算其中所有像素的最终颜色,并将其输出到屏幕上。
4.1.2 使用G缓存
在准备好包含漫射色,法向量,高光指数,片段世界坐标系中顶点以及其他必要信息的G缓存后,需要做的事情是从这个缓存中读取数据,并解包重建原始数据。使用函数unpackHalf2x16
可以执行和上面代码相反的解包逻辑,该函数会将1个32位整形数据按位解包为2个16位浮点型数据,再转化为2个32位浮点型数据。重建原始数据的代码如下。
layout (binding = 0) uniform usampler2D gbuf0;
Layout (binding = 1) uniform sampler2D gbuf1;
struct fragment_info_t {
vec3 color;
vec3 normal;
float specular_power;
vec3 ws_coord;
uint material_id;
};
void unpackGBuffer(ivec2 coord, out fragment_info_t fragment) {
uvec4 data0 = texelFetch(gbuf_tex0, ivec2(coord), 0);
vec4 data1 = texelFetch(gbuf_tex1, ivec2(coord), 0);
vec2 temp;
temp = unpackHalf2x16(data0.y);
fragment.color = vec3(unpackHalf2x16(data0.x), temp.x);
fragment.normal = normalize(vec3(temp.y, unpackHalf2x16(data0.z)));
fragment.material_id = data0.w;
fragment.ws_coord = data1.xyz;
fragment.specular_power = data1.w;
}
将从G缓存中解包重建的数据直接渲染到一个普通的颜色帧缓存中可以直观看到G缓存的内容。其渲染结果如下。源码传送门由于函数unpackHalf2x16
等需要OpenGL4.2接口才能正常工作,该Demo未验证。
左上角的图是直接使用漫射光颜色的渲染结果,右上角的图表示了每个片段的曲面法向量,左下角的图片表示每个片段在世界坐标系中的位置,右下角的图表示了每个片段的材质索引。
对于G缓存解包重建后的数据,可以使用本章节前面的任何光照模型来计算每个片段的最终颜色。本实例中将使用标准冯氏着色模型,具体计算逻辑如下。
vec4 light_fragment(fragment_info_t fragment) {
int I;
vec4 result = vec4(0.0, 0.0, 0.0, 1.0);
if (fragment.material_id != 0) {
for (i = 0; i < num_lights; i++) {
vec3 L = fragment.ws_coord - light[i].position;
float dist = length(L);
L = normalize(L);
vec3 N = normalize(fragment.normal);
vec3 R = reflect(-L, N);
float NdotR = max(0.0, dot(N, R));
float NdotL = max(0.0, dot(N, L));
float attenuation = 50.0 / (pow(dist, 2.0) + 1.0);
vec3 diffuse_color = light[i].color * fragment.color *
NdotL * attenuation;
vec3 specular_color = light[i].color
* pow(NdotR, fragment.specular_power)
* attenuation;
result += vec4(diffuse_color + specular_color, 0.0);
}
}
return result;
}
使用延时着色最终得到的场景渲染结果如下图。
在上图中,通过多实例渲染的方式绘制了超过200个甲壳虫模型,绝大部分片段都存在覆盖渲染情况。最终片段颜色的计算考虑了64个光源,使用延迟着色后,增加或者减少光源的数量对整个程序性能影响不是很大。实际上程序中计算成本大的部分是生成G缓存,以及从G缓存中读取并重建原始数据,单这部分计算只需要执行一次,并且和光源的数量无关。在本实例中为了使代码更容易理解,使用到的G缓存效率并不高,它消耗的内存带宽仍然还有优化的空间,程序的性能也还能进一步优化。
4.1.3 法向量贴图和延迟着色
在前面的章节中介绍过法向量贴图,这种技术可以通过将片段的法向量存储在一个纹理中,通过读取纹理中的值获得单个片段的法向量,从而更精细的控制光照效果,以获得更多的图像细节。大多数法向量贴图算法使用的都是切线空间法向量(Tangent space normals),并在切线空间中执行所有的光照计算。其中需要计算光照向量L和视点向量V,在顶点着色器中,使用TBN矩阵将它们转换到切线空间中,然后将转换后到向量传递到片段着色器中用于光照着色计算。然而,在延迟渲染中,在G缓存中存储的法向量总是以世界坐标系或者视图坐标系为参考。
为了生成存储于G缓存中,用于延迟着色,以视图空间为参考的法向量,我们需要从法线贴图中读取切线空间法向量,并将其转换到视图坐标系中,然后对普通的法向量贴图算法进行微调即可。
首先,在顶点着色器中计算出视图空间法向量N和切向量T,并将它们传递到片段着色器中。在片段着色器中对向量N和T进行标准化处理得到单位向量,通过它们的外积计算出副切线向量B。在片段着色器中通过这三个向量构建出TBN矩阵,然后从法线贴图中读取切线空间的片段法向量,使用TBN的逆矩阵将其转换到视图空间内,由于TBN是正交矩阵,因此其逆矩阵就是它的转置矩阵。被转换到视图空间中的片段法向量随后被存入到G缓存中。
生产G缓存的顶点着色器不需要修改,和上面的延迟着色示例程序使用到的顶点着色器相同。修改后的片段着色器如下。
#version 420 core
layout (location = 0) out uvec4 color0;
layout (location = 1) out vec4 color1;
in VS_OUT {
vec3 ws_coords;
vec3 normal;
vec3 tangent;
vec2 texcoord0;
flat uint material_id;
} fs_in;
layout (binding = 0) uniform sampler2D tex_diffuse;
layout (binding = 1) uniform sampler2D tex_normal_map;
void main(void) {
vec3 N = normalize(fs_in.normal);
vec3 T = normalize(fs_in.tangent);
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N);
vec3 nm = texture(tex_normal_map, fs_in.texcoord0).xyz * 2.0 - vec3(1.0);
nm = TBN * normalize(nm);
uvec4 outvec0 = uvec4(0);
vec4 outvec1 = vec4(0);
vec3 color = texture(tex_diffuse, fs_in.texcoord0).rgb;
outvec0.x = packHalf2x16(color.xy);
outvec0.y = packHalf2x16(vec2(color.z, nm.x));
outvec0.z = packHalf2x16(nm.yz);
outvec0.w = fs_in.material_id;
outvec1.xyz = floatBitsToUint(fs_in.ws_coords);
outvec1.w = 60.0;
color0 = outvec0;
color1 = outvec1;
}
在下图中,左图是使用了法向量贴图的渲染结果,右图是使用片段插值法向量的渲染结果。虽然不明显,但是左侧的突破包含更多细小的细节。示例程序DeferredShading源码传送门由于函数unpackHalf2x16
等需要OpenGL4.2接口才能正常工作,该Demo未验证。
4.1.4 延迟着色的缺点
尽管延迟着色技术能够减少大量复杂的光照着色技术对于程序性能的影响,但是它并不能解决所有问题。除了在生成G缓存时会额外占用大量的内存带宽外,它还存着一些其他缺点。通过一些努力,也许你能解决其中部分问题,但是当你准备写一个延迟渲染器时,你都应该考虑如下几件事情。
首先,你应该仔细考虑延迟渲染着色器所需要的内存带宽。在本小节的示例程序中,G缓存中的每个像素都消耗了256位的内存,我们并没有特别高效的组织数据存储方式。我们将世界坐标系直接存储在G缓存中,这消耗了96位内存空间。然而我们可以在渲染阶段直接获取到屏幕坐标系下每个像素的位置,可以从片段着色器内建变量gl_FragCoord
获取x,y分量,从深度缓存中获取到z分量,从而构建片段在屏幕坐标系(采集坐标系)下的坐标。通过视口变化的逆操作,即简单的缩放和平移操作,得到标准设备坐标系中的位置,再通过应用投影和观察矩阵的逆矩阵将坐标从采集坐标系中移动到世界坐标系中。观察矩阵通常只包含平移和旋转变化,它的逆矩阵运算较为简单。但是投影矩阵以及齐次坐标的逆运算会比较复杂。
另外我们使用了48位来编码表面法向量,但是实际上只需要存储xy分量即可,由于这里使用的都是单位法向量,因此可以通过公式x2 + y2 + z2 = 1来计算z轴分量。当然这里z的符号是未确定的,但是假定我们的曲面法向量z轴都不为负,那么这种方式将不会有任何问题。
另外高光指数和材质ID我们都分别使用了32位数据来保存,但是通常情况下在渲染的场景中材质的数量不会大于16位能够表示的6000个。高光指数也可以保存对数形式的数据,在我们计算的时候对再求2的指数重构原始数据即可,这样也能节省一部分内存开销。
延迟渲染了另一个问题是它在抗锯齿的能力上较弱。通常情况下,使用多重采样抗锯齿的程序会取一个像素多个样本的平均或者加权平均值来作为这个像素的最终输出颜色。因此对于延迟渲染的程序,在启用多重采样特性后我们还需要为所有的数据,如深度数据、法向量以及材质索引等媒体数据准备对多重采样纹理并绑定到G缓存上。更糟糕的是,由于最后真正图像渲染阶段使用的是一个覆盖整个屏幕的四变形,因此对于其内部的所有像素而言,并没有任何边缘像素,这破坏了传统的多重采样抗锯齿的计算逻辑。另外在解析阶段,我们也需要准备一个自定义的解析着色器为每个样本执行光照着色计算,这会极大的增加程序的计算成本。
最后,大多数延迟渲染算法都不能很好的处理透明问题。因为在G缓存中每个像素点我们只存储了一个片段的数据,而在处理透明问题时,我们需要从某个像素点位置离观察者最近的片段开始直至查找到不透明的片段。有一些算法使用这种方式处理透明图元,它们都是图元的顺序不回影响最终结果的场景中使用。另外一种方式首先处理所有不透明的图元,然后再渲染透明的图元。这种方式要求渲染器维护一个列表保存所有透明曲面,在穿越场景逐个渲染模型时跳过这些表面,或者穿越场景两次。无论选择哪种方式都是一个高成本的方案。
总的来说,如果你小心使用延迟渲染技术,很好的处理算法,它将会你的程序性能带来极大的提升。
4.2 基于屏幕空间渲染技术
到目前为止,本系列文章中所使用的渲染技术都是逐图元渲染的。然而在前一小节中讲到的延迟渲染技术并不是这样,这意味着我们可以将一些渲染程序推迟到最后,通过渲染和屏幕空间等大的图元去执行。在这个小节中我们将接受其他一些能够将执行时机推迟的算法。在有些场景中,这是实现某些技术唯一方法,在另外一些场景中延迟计算逻辑到所有的几何体已经渲染完成后会极大的提升程序性能。
4.2.1 环境光遮蔽
现实世界中物体镜面反射或者漫反射出的光线回到我们眼睛中,使得物体呈现特定的颜色。而这种现象根据物体接收到的光源类型分为直接光照和间接光照,其中直接光照指物体接收的光线直接来自于光源的光线,间接光照指物体接收的光线来源于物体之间反复反射后光源后的剩余光,以及物体吸收光线后再次发出的光。全局光照(Global Illumination)则指的是直接光照和间接光照结合的效果。
环境光(Ambient Light)是间接光照产生得到的光的近似值,它是一个小的,固定的量被添加到光照计算公式中。环境光遮蔽(Ambient Occlusion)指在很深的褶皱中或者物体之间的间隙内,附近的曲面遮蔽环境光的现象。实时全局光照是当前到一个研究课题,尽管目前已经有了相当多的工作,但这个课题仍然是一个未解决的问题。然而我们仍然能够使用一些非正式方法和粗略的近似值来模拟一个可以接受的较好结果。接下来讨论的屏幕空间环境光遮蔽(Screen space ambient occlusion, SSAO)就是这样一种近似方法。
我们先考虑2维平面,如果某个曲面上一个片段被任意数量的点光源围绕,这些电光源可以看成是间接光照。环境光可以被认为是照射到这个顶点上光线的总和。在一个非常平整的曲面上,任意一点对于曲面上方所有的光源而言都是可见的。然而在不平整的曲面上,并不是所有的光源都能够照射到曲面上的每一个点,如下图所示,对于曲面上的任意一点,曲面越不平整,能够找到到该点到光源更少。
在上图中围绕曲面均匀分布着8个点光源,对于选定的曲面中心某点,只能接收到来自4个光源的光线,这个时候就需要考虑全局光照对这个点的影响。在完全全局光照模拟中,对于每个顶点,我们需要追踪上百个,甚至上千个不同方向照射到该点的光路径,确定哪些路径能够顺利照射到目标点。然而这对于实时渲染程序而言计算代价过于昂贵,因此我们需要使用一种方法能够直接在屏幕空间中计算每个顶点的环境光遮蔽情况。
在利用该技术时,需要在屏幕空间内对每个像素选多个随机方向延伸出直线,沿着这条线选择多个点判断该像素是否被遮蔽,从而计算出每个像素被遮挡的程度,最后计算每个像素的颜色。具体的方法是先准备一个帧缓存对象,首先正常将场景渲染到它的第一个颜色纹理附件和深度纹理附件中,然后将每个片段的法向量和在观察空间中的深度值存储在同一个帧缓存对象的第二个颜色纹理附件中。
接下来需要使用已经获得的数据计算每个片段的遮蔽程度。这个阶段需要使用遮蔽着色器来渲染一个全屏的四边形。该着色器读取某个片段的深度值,选择一个随机方向并延伸,以一定的距离间隔选取多个点,比较每个点上的插值计算出的深度值和在深度缓存中存储的深度值的大小,如果插值深度值大于深度缓存的值,则认为该插值点被其他几何图像遮挡,也意味着这个方向上的光不能够照射到被延伸的像素。
在选择随机方向之前,需要先准备一个包含大量随机单位向量的缓存对象,并将其作为一个着色器中的统一变量使用。随机向量可能指向任何方向,但是我们只需要考虑和曲面法向量同侧的随机向量。通过计算曲面法向量和选取的随机向量的点积,我们可以筛选出这种条件的随机向量。如下图,如果点积为负,则其指向法向量背侧,此时只需要取其负向量即可。
在上图中,向量v0、v1和v4指向了法向量N同侧,它们的点积为正。向量v2和v3指向了法向量N背侧,它们的点积为负,因此我们需要取其负向量-v2和-v3作为我们判断像素遮蔽情况的延伸方向。
当确定好随机向量V(xv, yv, zv)后,接下来就需要沿着向量计算像素的遮蔽情况。选取屏幕空间上的某点PO(xo, yo),在之前准备好的纹理中查询出视口空间内深度值zo,沿着随机向量的方向延伸一段距离得到新的点PN(xn, yn, zn)。(xn, yn)为屏幕空间内的坐标,通过PO点和向量V2(xv, yv)和步长stepDistance计算得到,zn为视口空间内的坐标,通过PO点和向量V1(zv)计算得到。这里点PN的三个坐标分量不在同一个坐标系中,因此并未严格按照随机向量V进行插值。
通过以(xn, yn)为纹素坐标,在前一步准备好的颜色纹理中查询该坐标对应片段在视口空间中的深度值znv。比较znv和zn的大小,如果znv比zn更小,则这个插值得到的点被渲染的场景中某个片段阻挡,则认为被延伸的点呗遮挡。尽管这种计算方式并不准确,但是就统计学上而言是有效的。选择的随机向量个数,在每个随机向量方向上插值的次数,以及插值的步长都可以控制最终得到的图像质量。这三个值越高,得到的图像质量越好。下图展示了随机向量数量对屏幕空间环境光遮蔽算法处理图像质量的影响。
上图中,从左到右,从上到下,在计算环境光遮蔽时使用到的随机向量数量递增,依次为1、4、16和64。可以明显看到,当选择的随机向量数量达到64个时,图片才变得光滑。随机向量越少,条带越明显。改善图像质量的方式有很多,但是最有效的方法之一就是为每个样本生成一个随机种子,用于确定环境光遮蔽计算中的步长。这种方式引入了图像噪声,但是却提高了图像质量,下图演示了这种技术的效果。
可以明显看到,在确定每个样本步长时应用随机种子能够明显的改善环境光遮蔽的效果。此时在只选取1个随机向量的渲染结果中,尽管图片质量很糟糕,但是仍然能够比固定步长的版本更好,在选取4个随机向量的渲染结果中,图片质量已经可以被接受,其对应固定步长版本的渲染结果则会有很明显的条带。这种方式所引入的图像噪声其实也可以解决,但是已经超出了这个例子所要讨论的问题范围。
介绍完环境光遮蔽计算方式后,需要做的就是对正常渲染的图像应用这种技术。环境光遮蔽是指环境光被阻挡的数量,因此每个片段的环境光计算方式是在着色器颜色计算公式中使用遮蔽系数和环境光相乘即可,这样被渲染的场景中出现褶皱的地方最后其颜色值添加的环境光会更少,使得最终的渲染图像阴影效果看上去更加真实。下图颜色了屏幕空间环境光遮蔽技术的应用效果。源码传送门
上图中,左侧的图片仅仅计算了漫射光和镜面高光,被渲染出来的模型看上去更像是悬挂在一个屏幕上,另外从图片中也很难看出景物的深度。右侧的图片是应用屏幕空间环境光遮蔽技术的渲染结果,可以看到不仅一些模型的细节更加丰满,地面上也能看到软阴影效果,景深的感觉也更明显。
在第一次渲染过程和大多数例子一样,将场景渲染到一个颜色附件中。在第二次渲染过程中需要应用环境光遮蔽计算,示例程序ssao片段着色器代码如下。
#version 430 core
// Samplers for pre-rendered color, normal, and depth
layout (binding = 0) uniform sampler2D sColor;
layout (binding = 1) uniform sampler2D sNormalDepth;
// Final output
layout (location = 0) out vec4 color;
// Various uniforms controlling SSAO effect
uniform float ssao_level = 1.0;
uniform float object_level = 1.0;
uniform float ssao_radius = 5.0;
uniform bool weight_by_angle = true;
uniform uint point_count = 8;
uniform bool randomize_points = true;
// Uniform block containing up to 256 random directions (x,y,z,0)
// and 256 more completely random vectors
layout (binding = 0, std140) uniform SAMPLE POINTS {
vec4 pos[256];
vec4 random_vectors[256];
} points;
void main(void) {
// Get texture position from gl_FragCoord
vec2 P = gl FragCoord.xy / textureSize(sNormalDepth, 0);
// ND = normal and depth
vec4 ND = textureLod(sNormalDepth, P, 0);
// Extract normal and depth
vec3 N = ND.xyz;
float my_depth = ND.w;
// Local temporary variables
int I;
int j;
int n;
float occ = 0.0;
float total = 0.0;
// n is a pseudo-random number generated from fragment coordinate and depth
n = (int(gl_FragCoord.x * 7123.2315 + 125.232) *
int(gl_FragCoord.y * 3137.1519 + 234.8)) ^
int(my_depth);
// Pull one of the random vectors
vec4 v = points.random vectors[n & 255];
// r is our "radius randomizer"
float r = (v.r + 3.0) * 0.1;
if (!randomize_points) {
r = 0.5;
}
// For each random point (or direction)...
for (i = 0; i < point_count; i++) {
// Get direction
vec3 dir = points.pos[i].xyz;
// Put it into the correct hemisphere
if (dot(N, dir) < 0.0) {
dir = -dir;
}
// f is the distance we’ve stepped in this direction
// z is the interpolated depth
float f = 0.0;
float z = my_depth;
// We’re going to take 4 steps - we could make this configurable
total += 4.0;
for (j = 0; j < 4; j++) {
// Step in the right direction
f += r;
// Step towards viewer reduces z
z -= dir.z * f;
// Read depth from current fragment
float their_depth = textureLod(sNormalDepth,
(P + dir.xy * f * ssao_radius), 0).w;
// Calculate a weighting (d) for this fragment’s
// contribution to occlusion
float d = abs(thei_depth - my_depth);
d *= d;
// If we’re obscured, accumulate occlusion
if ((z - their_depth) > 0.0) {
occ += 4.0 / (1.0 + d);
}
}
}
// Calculate occlusion amount
float ao_amount = vec4(1.0 - occ / total);
// Get object color from color texture
vec4 object_color = textureLod(sColor, P, 0);
// Mix in ambient color scaled by SSAO level
color = object_level * object_color +
mix(vec4(0.2), vec4(ao_amount), ssao_level);
}
4.3 无三角形渲染
前面的小节中介绍了一些在屏幕空间上使用的渲染技术,这些技术都是通过渲染一个全窗口的四边形,再对之前有几何体组成的场景渲染结果进一步处理。在本节中,会进一步说明如和使用一个全窗口四边形渲染整个场景。
4.3.1 渲染朱莉娅分形
这小节的示例程序渲染了一个朱莉娅集合(Julia Set),这种分形图像只需要使用纹理坐标即可创建。朱莉娅集合和曼德勃罗集合(Mandelbrot Set)相关,它由如下公式生成。
当Z值超过阈值时,循环结束。如果在允许的迭代次数内Z的值不大于阈值,则认为该点位于曼德勃罗集合内部,并使用某种默认颜色为其着色。如果Z值大于阈值,则认为这个点在集合外部,通常此时使用一个迭代函数来为该点着色。朱莉娅集合和曼德勃罗集合的区别在于Z和C的初始条件不一样。
渲染曼德勃罗集合时,Z被定义为(0+0i),C被定义为执行插值的点坐标。在渲染朱莉娅集合时,Z被定义为执行插值的点坐标,C被定义为一个程序内部指定的常量。因此曼德勃罗集合只有一个,而朱莉娅集合有无穷个。这也意味着朱莉娅集合可以通过编程控制,也可以执行动画。和前面的例子一样,我们使用一个全窗口的四边形来渲染场景,不同的是不在使用帧缓存中准备好的数据,而是直接生成图像。
在片段着色器中定义一个包含纹理坐标的输入变量。声明一个统一变了来保存C值,一个统一变量保存最大迭代数。为了使得生成的朱莉娅集合更好看,使用一个一维渐变颜色纹理为其着色。当确定某个点位于集合内部时,使用迭代次数作为纹理坐标为片段着色。最大迭代数可以平衡图像的细节程度和程序的性能。片段着色器部分代码如下。
#version 430 core
in Fragment {
vec2 tex_coord;
} fragment;
// Here’s our value of c
uniform vec2 c;
// This is the color gradient texture
uniform sampler1D tex_gradient;
// This is the maximum iterations we’ll perform before we consider
// the point to be outside the set
uniform int max iterations;
// The output color for this fragment
out vec4 output color;
确定某个片段是否位于集合内部的代码如下。
int iterations = 0;
vec2 z = fragment.tex coords;
const float threshold_squared = 4.0;
// While there are iterations left and we haven’t escaped from the set yet...
while (iterations < max_iterations && dot(z, z) < threshold_squared) {
// Iterate the value of Z as Z^2 + C
vec2 z_squared;
z_squared.x = z.x * z.x - z.y * z.y;
z_squared.y = 2.0 * z.x * z.y;
z = z_squared + c;
iterations++;
}
如果循环结束后迭代的次数等于最大迭代数,意味着该点位于集合内部,将之涂为黑色,否则到渐变颜色纹理中去查询对应的纹素为其着色器,代码如下。
if (iterations == max_iterations) {
output_color = vec4(0.0, 0.0, 0.0, 0.0);
} else {
output_color = texture(tex_gradient,
float(iterations) / float(max_iterations));
}
剩下的就是提供一个渐变颜色纹理,并设置一个合适的C值即可。在示例程序中,每一帧画面都使用渲染函数中传入的时间参数来更新C的值,从而添加动画效果。下图是示例程序julia中的部分帧的效果图。Demo传送门
4.3.2 片段着色器中的光线追踪
OpenGL的工作原理是基于光栅化,也就是将如线、三角形和点图元分解为片段。几何体进入到OpenGL的图形管线后,对于每个三角形图元,OpenGL将会找出它所覆盖的像素,然后运行我们编写的着色器计算每个像素的颜色。光线追踪的原理与之完全不同,它能够得到更好的效果,但是其计算成本更高。
从观察点向成像窗口上的每个点发出一条射线,直至碰到场景中最近的模型,从而计算每个像素的颜色。和传统的光栅化方式相比,这种方式最大的缺点是并没有OpenGL设计层面的直接支持,这意味着所有的工作都需要在我们自己编写着色器完成。然而,这种方式会带来很多好处,我们可以跳出点、线和三角形的局限,我们可以更直观的理解射线碰到模型表面后的的行为。使用类似之前用到的判断片段可见性的技术,我们用很少的代码就能够模拟光的反射,阴影,甚至光的折射,并且这样的到的效果更加真实。这种成像的方式也更接近现实世界中物体在人眼中成像的方式。
本小节会介绍如何使用片段着色器递构建简单的递归光线追踪器。该光线追踪器能够渲染由简单球体和无限平面构成的场景,可以渲染经典的“盒子中的玻璃球“(Glossy spheres in a box)图像。可以肯定的是,一定有更优秀的光线追踪算法,但是这个追踪器已经足够说明光线追踪算法的基本原理。下图是一个简化版本的2维简单光线追踪器的示意图。
在上图中,观察点为O,从O点向呈像平面中像素P发出一条光线直至碰到场景中的某个模型表面上某个点Io,这个初始光线表示为R_primary,点Io处曲面的法向量为N,从点Io向光源延伸一条指向光源的射线表示为R_shadow,如果这条射线在中途碰到了其他模型,则点Io位于阴影中,否则该点被光源直接照射。另外在点Io处,根据法向量N计算入射光线的反射向量表示为R_reflected。
光线追踪成像方式中像素的颜色计算方式和前面讲到的光照颜色计算公式并不是完全不同的,我们仍然可以分别计算漫射光和反射光,也可以使用法线纹理贴图等提高图像质量。在计算像素P的颜色时,需要计算光源直接照射到点Io产生的颜色,以及反射光线R_reflected寻找到的另外一个模型上的点I1的对点I0的颜色贡献。
以点O为原点,沿着光线R_primary方向向量D,并以其模长为速度,经过时间t后到达了位于球面上某点P,假定球体的圆心为C,球体的半径为r。两个相同向量的点积为其模的平方,则存在如下公式。
替换其中的点P为O+tD,则存在如下公式。
展开多项式,以t作为自变量,可以将上述式子表示如下。
可以简写为At2+Bt+C = 0,其中
可以求得t如下
假定向量D选择的是单位向量,则其长度为1,上式可以进一步简写为
如果4C的值比B2更大,则意味着t无解,表示该光线不会和这个球体相交。如果相等,表示光线和球相切,只有1个交点。如果更小,表示光线穿过球体,有两个交点。如果存在负解,则表示交点在观察点背面。在有两个交点时,选择最小的正值t作为光线和模型相交的时间,并利用公式P=O+tO计算出交点在3D空间内的坐标。
上面寻找光线和模型交点的代码如下。
struct ray {
vec3 origin;
vec3 direction;
};
struct sphere {
vec3 center;
float radius;
};
float intersect_ray_sphere(ray R, sphere S, out vec3 hitpos, out vec3 normal) {
vec3 v = R.origin - S.center;
float B = 2.0 * dot(R.direction, v);
float C = dot(v, v) - S.radius * S.radius;
float B2 = B * B;
float f = B2 - 4.0 * C;
if (f < 0.0) {
return 0.0;
}
float t0 = -B + sqrt(f);
float t1 = -B - sqrt(f);
float t = min(max(t0, 0.0), max(t1, 0.0)) * 0.5;
if (t == 0.0) {
return 0.0;
}
hitpos = R.origin + t * R.direction;
normal = normalize(hitpos - S.center);
return t;
}
函数intersect_ray_sphere
未找到光线和球的交点时返回0,如果找到了交点,则将交点的坐标写入到参数hitpos
中,交点位置在球面的法向量写入到参数normal
中。对于每个点发出的光线,我们需要其和场景中多个模型中最近的交点,可以先设置一个临时的初始值,然后再遍历这些球体,从而寻找到最近的交点。这部分逻辑代码如下。
// Declare a uniform block with our spheres in it.
layout (std140, binding = 1) uniform SPHERES {
sphere S[128];
};
// Textures with the ray origin and direction in them
layout (binding = 0) uniform sampler2D tex_origin;
layout (binding = 1) uniform sampler2D tex_direction;
// Construct a ray using the two textures
ray R;
R.origin = texelFetch(tex_origin, ivec2(gl_FragCoord.xy), 0).xyz;
R.direction = normalize(texelFetch(tex_direction,
ivec2(gl_FragCoord.xy), 0).xyz);
float min_t = 1000000.0f;
float t;
// For each sphere...
for (i = 0; i < num_spheres; i++) {
// Find the intersection point
t = intersect_ray_sphere(R, S[i], hitpos, normal);
// If there is an intersection
if (t != 0.0) {
// And that intersection is less than our current best
if (t < min t) {
// Record it.
min_t = t;
hit_position = hitpos;
hit_normal = normal;
sphere_index = I;
}
}
}
假如对于每个光线追踪寻找到的点都默认着色为白色,则对于包含单个球体的场景,这种方式的渲染结果如下。
接下来我们需要应用光照算法为每个片段着色。在光照计算中,光线追踪函数返回的交点曲面法向量是重要的参数。和前面例子中光照计算的方式一样,使用曲面法向量,光线追踪函数计算出的交点在视点空间坐标,以及材质参数计算每个交点的颜色。应用光照着色算法后同样的场景渲染结果如下。
法向量不仅仅用于光照着色公式中,在接下来最终光线的下一个目标时也发挥重要作用。对于追踪到第一个模型交点此后每个通过法向量计算出的新光线而言,每次追踪到的交点在计算其本身的颜色后都会计算它们对于光线追踪源像素最终颜色的贡献分量,从而确定最终的源像素颜色。这是光线追踪方式相对于传统的光栅化方式的第一个优点。
假设曲面上某点P,光源L和观察点O,初始的光线从点O出发射向点P,点P到L的光线为R_Shadow,其单位向量为D。如果光线R_Shadow能够顺利到达光源,不会触碰到场景中的其他模型,则可以直接使用光照着色公式为该点着色。否则,点P位于阴影中。这种阴影效果也是应用光线追踪算法的优势。
在交点P除了能构建指向光源的光线外,我们还能构建指向任意方向的光线。例如可以构建入射光线在点P处对于法向量的折射光线,并继续追踪光线寻找下一个交点,将得到颜色计入光线追踪源像素点最终颜色。
光线追踪是一个递归算法,追踪一条光线,找到一个交点为其着色,然后创建一条新的光线,再进行下一次迭代。而GLSL中并不支持循环语法,因此在本例中使用由多个纹理组成的栈来实现光线追踪算法,这也是在上面的代码中从纹理中读取光线源点和方向的原因。
具体的方法是创建一组帧缓存对象,每个帧缓存对象添加4个颜色附件。对于帧缓存中的每个像素,这4个附件分别保存了最终合成颜色,光线的源点,当前光线的方向,以及颜色贡献系数。在本例中允许光线最多追踪5个模型的交点,因此创建了5个帧缓存对象。第一个颜色附件,即保存最终合成颜色的附件在多个帧缓存对象中是共享的,而另外3个颜色附件对于每个帧缓存对象都是独有的。在每一次渲染行为中,我们从一组纹理中读取数据,然后写入到另外一组纹理中,如下图。
初始化光线追踪器需要运行着色器将初始的原点和方向写入到纹理中,将最终合成颜色纹理中所有点的值设置为0,将颜色贡献系数纹理中所有点的值设置为1。然后运行光线追踪着色器,每次光线追踪行为都绘制一个全窗口的四边形。在每次绘制行为中,绑定上一次绘制准备好的原点,方向和颜色贡献系数纹理。同时也绑定一个帧缓存对象包含需要输出的原点,方向和颜色贡献系数纹理,这些数据讲在下一次渲染行为中使用到。对于每个像素,光线追踪着色器通过原点和方向构建出一条光线并向场景中追踪,为寻找到的交点着色,再乘以颜色贡献系数后写入到接受数据的帧缓存对象的第一个颜色附件中。
想要计算多次光线追踪结果累积得到的最终颜色,需要讲最终合成颜色纹理作为第一个颜色附件添加到每个帧缓存对象中,并且为这个附件开启颜色混合功能,颜色混合函数的源和目标颜色系数都需要设置为1,这表示直接讲两个颜色叠加混合。
如果在场景中添加更多的球体模型,我们可以通过这个技术更真实的模拟光线在多个模型之间折射的效果。下图中,对于通过光线追踪渲染的场景,随着光线追踪次数增加,其渲染结果的细节更加真实。
上图中,左上角的是未进行第二次光线追踪的渲染效果,此时能够看出场景中的模型都比较暗淡。在右上角的图片中,我们进行了2次光线追踪,能够看见一些球体表面此时已经有反射光的效果。在左下角的图片中,光线追踪的次数增加到3次,此时模型的光泽可以经过两次反射到达另外一个模型表面。在右下角的图片中,光线追踪的次数继续增加到4次,此时能够观察到更多的细节。
为了使得渲染的场景更加有趣,我们可以在其中加入其他类型的模型。尽管在理论上,任意类型的模型都能够被追踪,但是另外一个更容易查询光线是否和其有交点的模型类型是平面。平面的一种表示方法是一个单位法向量,以及对于一个过坐标系原点的法向量,从该法向量和平面的交点沿着它的方向到原点的距离。
法向量由3个分量组成,距离是一个标量,但是其值有正负,沿着法向量方向为正,否则其值为反。可以将前者打包在1个4维向量的x、y、z分量中,将后者打包在同一个4维向量的w分量中。实际上,对于给定的法向量N,和同法向量通向过坐标系原点的线与平面交点沿着法向量方向到原点的距离d,平面可以用如下公式表示。
当上式成立时,P为平面上的任意一点。对于在光线追踪时构建出的线上任意一点可以表示为如下等式。其中O为光线追踪的起点,t为经历的时间,D为速度向量,由于t没有具体的时间单位,因此D为单位向量。
替换第一个公式中的P可以得到如下公式。
然后求解出t。
从上式中可以看出,如果向量D和N的点积为0,即当向量D和平面同向时,t无解。其他情况下,被追踪的光线和参考平面一定存在交点。如果接触的t值为负,交点位于观察者后方,这种情况不做处理。如果t值为正,交点位于观察者前方,此时需要进一步的着色计算。执行光线和平面相交检测的代码如下。
float intersect_ray_plane(ray R, vec4 P, out vec3 hitpos, out vec3 normal) {
vec3 O = R.origin;
vec3 D = R.direction;
vec3 N = P.xyz;
float d = P.w;
float denom = dot(N, D);
if (denom == 0.0) {
return 0.0;
}
float t = -(d + dot(O, N)) / denom;
if (t < 0.0) {
return 0.0;
}
hitpos = O + t * D;
normal = N;
return t;
}
在前文渲染的场景中所有球体的背面添加一个平面后的渲染结果如下图。在左图中,尽管这种方式为场景添加了一些景深效果,但是仍然没有最大发挥光线追踪的潜力,继续增加光线追踪的次数,此时能够在右图中看到平面上出现了更多球体的折射影像,甚至在球体表示上也折射出了平面的内容。
添加更多的平面将整个场景包在一个盒子中,我们可以得到更有趣的图像,其渲染结果如下图。继续增加光线追踪的次数,渲染得到的图像中反射的效果就会越来越明显,在下图中从左至右,从上至下,光线追踪的次数从1次,依次增加到2次、3次和4次。Demo传送门(部分完成Demo)
光线追踪示例程序raytracer使用了暴力计算方式,这种方式对每条构建出的光线都需要执行其和每一个模型是否相交的检测逻辑。当场景中模型的数量以及类型变得越来越复杂时,你可能想要一种加速结构(Acceleration Structure)来执行光线追踪运算。加速结构作为一种在内存中的数据结构,它能够是我们快速判断某条由原点和方向定义的射线会和哪些模型发生碰撞。实际上只要找到对于选定图元的光线碰撞检测算法,光线追踪的计算逻辑并不复杂。然而,光线追踪算法的成本是巨大的,没有强大的经过特殊设计的硬件支持,将会为着色器带来极大的工作量。因此在对一个包含大量球体和平面的场景中,如果需要使用实时光线追踪来渲染场景,加速结构非常关键。当前对于光线追踪的研究几乎都聚焦于如何创建、存储和使用这种加速结构。
关于光线追踪技术,原著写于2013年,这部分知识已经较旧。在2018年的全球游戏开发者大会(Game Developers Conference, GDC)上,微软率先为DirectX 12 API增加了光线追踪模块,命名为DirectX Raytracing (DXR),NVIDIA则是发布了基于实时光线追踪的RTX技术,AMD也宣布是自家的ProRender渲染引擎将支持实时光线追踪。此外诸如EA 寒霜引擎、EA Seed、Unreal 引擎、3DMark、Unity 引擎已经宣布将会引入光线追踪。这在软件层面上对于游戏中使用光线追踪技术做了铺垫。
2018年8月21日在德国举行的科隆游戏展上,英伟达发布了最新的游戏显卡RTX 2080 Ti、RTX 2080、RTX 2070,在硬件层面上对光线追踪技术做了支持,相信接下来应用光线追踪技术的游戏也将越来越多。
5 总结
本章节中,我们使用了在前面的内容中学习到的基本知识和一些渲染技术制作了一些有趣的特效。首先,我们聚焦在光照模型的建立,以及如何为渲染的模型着色。这部分内容包含了冯氏光照模型,冯氏-宾氏光照模型,以及轮廓光内容。我们也介绍了一些比几何图元中的顶点信息包含更高频的光照效果,或者能够表达更多细节的特效技术,如法线贴图,环境贴图以及一些其他纹理。另外我们也演示了如何添加阴影效果,以及如何实现简单的氛围特效。此外我们也讨论了一些非模拟现实的特效技术。
在最后一部分中,我们介绍一些应用在屏幕空间内的渲染技术。延迟着色技术使得在几何图元第一次渲染时的一些复杂并且代价昂贵的计算能够从中解耦。通过在帧缓存对象中存储位置、法向量、颜色和一些其他曲面属性,我们能够在场景渲染的最终阶段执行复杂的着色计算,并且不需要担心性能浪费。在这个过程中,实际上我们只对可见的像素执行标准的光照计算。在屏幕空间环境遮蔽技术中,我们通过技术每个像素周围像素对环境光的阻挡,从而确定像素最终需要添加的环境光亮度,来模拟褶皱曲面中的阴影效果。最后,我们介绍了光线追踪技术,在示例代码中,我们在不使用任何三角形图元的情况下,完成了整个场景的渲染。