OpenGL光照渲染技术

1 简介

本系列的文章到目前为止已经介绍完了OpenGL的基础知识,你应该已经了解OpenGL中的大部分特性,也在示例程序中见到过利用它们来实现图像渲染算法。本文对其中部分算法深入讲解,特别时在实时渲染环境下应该关注的算法。

首先我们将会学习一些基本的光照技术,它们使得我们可以在场景中应用有趣的阴影效果。然后我们会认识一些不以写实照片(photo-realism)为目的的渲染方法。最后我们将会讨论一些只有非传统前向渲染几何管道才适用的算法,最终来的本文讲解的高级特性,如何不使用顶点和三角形渲染整个场景。

总的来说,文章将会围绕如下3个知识点详细展开:

  • 如何照亮场景中的像素。
  • 如何将渲染延迟到最后一刻。
  • 如何不使用三角形渲染整个场景。

2 光照模型

辨证的看,所有图像渲染程序的工作都是光线的模拟。无论是最简单的旋转几何体,还是最复杂的电影特性,我们都努力使用户相信他们看见的就是真实的场景,或者说是真实世界的模拟。为了达到这个目的,我们必须为光与表面的交互方式建立模型。最高级的模型能够最真实和准确的反映我们所理解的光的物理属性。然而大多数这些模型在实时环境下效率都很低,因此我们必须接受近似值,或者说即使这些模型产生的结果和物理性质相比并不是非常精确,但是它是可以接受的。下面将介绍在实时场景中如何使用可用的光照模型。

2.1 冯氏照明模型

最常见的一个照明模型是冯氏照明模型(Phong Lighting Model)。它的原理很简单,物体都有3个材质属性,分别是环境反射率,漫反射率和镜面反射率。这些属性都用颜色值表示,更亮的颜色表示更高的反射率。光源有相似的三个属性,它们同样使用颜色值表示光源的环境光颜色,漫射光颜色和反射光颜色。最终计算出的颜色是光源和模型这三个属性交互的和。

2.1.1 环境光

环境光(Ambient Light)不来自于特定的方向。尽管它源于真实位置的光源,但是由于它在房间或者场景中经过多次漫反射,因此它可以被认为是无方向的。环境光会均匀的从所有方向照亮物体的所有表面。可用将环境光看成是一个全局的明亮因子,它又所有光源共同产生。这个照明分量实际上很很接近光洒落在环境中的效果。

在计算环境光对最终像素颜色贡献的时候,将材质的环境光反射率和每个光源的环境光相乘。在GLSL编写的着色器中,可以使用如下的方式定义材质环境光反射率。

uniform vec3 ambient = vec3(0.1, 0.1, 0.1);
2.1.2 漫射光

漫射光(Diffuse Light)是光源中带方向的分量,也是我们之前的示例程序中在计算像素颜色时主要使用的分量。在冯氏光照模型中,材质的漫射光反射率和光源的漫射光颜色相乘后得到最大的漫射光颜色,也就是光照逆方向和像素所处平面的法向量同向时的漫射光颜色。因此我们要通过该平面法向量和光照逆方向(也就是被照射到片段到光源的方向)单位向量的点积计算出有效因子,最后和最大漫反射颜色相乘得到实际的漫反射颜色。在着色器中,其计算的示例代码如下。

uniform vec3 vDiffuseMaterial; 
uniform vec3 vDiffuseLight;
float fDotProduct = max(0.0, dot(vNormal, vLightDir));
vec3 vDiffuseColor = vDiffuseMaterial * vDiffuseLight * fDotProduct;

这里需要注意我们对平面法向量和光照逆方向单位向量的点积做了额外处理,将其和0取最大值,这是因为如果光从平面背面照射时其该值为负,但是很明显此时漫反射光产生的颜色应该为0,所以这里有这步额外的处理。

2.1.3 镜面反射光

和漫反射光一样,镜面反射光具有极强的方向属性,但是不同的是它和表面的交互更聚焦在一个特定的方向上。强镜面反射光(在现实世界中表面具有高镜面反射材质属性时)通常都会在表面形成一个亮点,这被称为镜面高光(specular highlight)。因为镜面反射,因此镜面反射光对最终像素的颜色贡献取决于观察方向和光照方向的夹角相关。点光源和太阳是具有高镜面反射光的很好的例子,当被照射物体具有高镜面反射属性的时候就能形成非常明亮的光斑。

材料镜面反射材质属性和光源的镜面反射光相乘后还需要经过一次缩放,这个缩放因子可以理解为是一个全局的材质属性-反光度(shininess),需要注意这里的计算方式是使用算出的向量点积作为底,材质反光因子作为指数,因此反光因子越大,得到的镜面反色光颜色越暗。其计算的主要代码如下。

uniform vec3 vSpecularMaterial; 
uniform vec3 vSpecularLight; 
float shininess = 128.0;
vec3 vReflection = reflect(-vLightDir, vNormal);
float EyeReflectionAngle = max(0.0, dot(vEyeNormal, vReflection)); 
fSpec = pow(EyeReflectionAngle, shininess);
vec3 vSpecularColor = vSpecularLight * vSpecularMaterial * fSpec;

反光因子shininess可以通过统一变量的方式传入,从OpenGL固定管道时代开始,其最大值通常被设置为128。

现在我们就能得到完整的公式计算光照在模型表面的颜色。考虑模型的环境材质属性为ka,漫反射材质属性为kd,镜面反射材质为ks,反光因子为α,环境光为ia,漫反射光为id,镜面反射光为is,则像素最终的颜色可以由如下公式得出。

等式中的向量N⃗、L⃗、R⃗、和V⃗ 都是单位向量,分别表示像素所处平面的法向量,像素到光源的向量,像素到光源的向量的负向量被像素平面反射向量,像素到观察者的向量。它们表示如下图。

图中,-L⃗为像素到光源的向量的负向量,图中应该和L⃗位于同一直线(画的有点失真)。向量R⃗越偏离观察者,反射光是越暗,当向量R⃗直接指向观察着时,它和向量V⃗的点积最大,其值为1,此时镜面反射光最强,称为镜面高亮。

同样的,在上图中漫射光也很容易理解。光源垂直照射像素时,单位向量L⃗和像素平面垂直,和平面法向量N⃗共线,此时点积最大,漫射光最强。光源验证像素平面方向照射像素时,单位向量L⃗和平面法向量N⃗垂直,此时点积最小,漫射光最弱。

2.1.4 高洛德着色

示例程序phonglighting的着色器使用了上面的公式来计算片段的颜色,其中使用了高洛德着色(Gouraud shading)技术。在该技术中,首先计算每个顶点的颜色值,然后再通过简单的插值计算得到所有片段的颜色值,顶点着色器代码如下。

#version 420 core
// Per-vertex inputs
layout (location = 0) in vec4 position; 
layout (location = 1) in vec3 normal;

// Matrices we’ll need
layout (std140) uniform constants {
    mat4 mv_matrix;
    mat4 view_matrix;
    mat4 proj_matrix; 
};

// Light and material properties,默认光源的漫反射和镜面反射光都为白色
uniform vec3 light_pos = vec3(100.0, 100.0, 100.0); 
// 通常直接设置所有模型的环境光反射率和光源环境光的交互得到的最终环境光颜色
uniform vec3 ambient = vec3(0.1, 0.1, 0.1);
uniform vec3 diffuse_albedo = vec3(0.5, 0.2, 0.7); 
uniform vec3 specular_albedo = vec3(0.7);
uniform float specular_power = 128.0;

// Outputs to the fragment shader
out VS_OUT {
    vec3 color; 
} vs_out;

void main(void) {
    // Calculate view-space coordinate
    vec4 P = mv_matrix * position;
    // Calculate normal in view space
    vec3 N = mat3(mv_matrix) * normal;

    // Calculate view-space light vector
    vec3 L = light_pos - P.xyz;
    // Calculate view vector (simply the negative of the view-space position)
    vec3 V = -P.xyz;
        
    // Normalize all three vectors
    N = normalize(N);
    L = normalize(L);
    V = normalize(V);

    // Calculate R by reflecting -L around the plane defined by N
    vec3 R = reflect(-L, N);
    
    // Calculate the diffuse and specular contributions
    vec3 diffuse = max(dot(N, L), 0.0) * diffuse_albedo;
    vec3 specular = pow(max(dot(R, V), 0.0), specular_power) * specular_albedo;
        
    // Send the color output to the fragment shader
    vs_out.color = ambient + diffuse + specular;
    
    // Calculate the clip-space position of each vertex
    gl_Position = proj_matrix * P;
}

除非你使用了很高等级的曲面细分,否则对于一个指定的三角形,它只有3个顶点,同时会产生大量的片段填充这个三角形图元。在这种情况下逐顶点的光照计算方式以及高洛德着色技术变得十分高效,只需要对每个顶点计算一次光照形成的颜色值。下图为示例程序PhongLighting的渲染效果。源码传送门

2.1.5 冯氏着色

在上图中,可以很清晰的看出高洛德着色的一个缺点,即镜面光亮显示出明显的星形光斑。在静态图像中,这仅仅是一个星形光斑,但是对于运动的模型,当球体旋转时这个光斑会十分晃眼,也是不愿意被看见的。这种现象是由于在三角形顶点中进行线性插值计算颜色时,球面上相邻的两个三角形之间的颜色不连续所造成的。三角形内的亮线在单个三角形内部具有相同的颜色。一种解决这种光斑的方案是将三角形划分得更小。

另外一个能够取得更好效果的方法称为冯氏着色(Phong Shading)。需要注意的是冯氏着色和冯氏光照模型是两个不同的概念,尽管他们都是有同一个人在同一时期所发明的。冯氏着色法不再是对每个顶点进行颜色插值计算,而是对每个顶点的法向量进行插值计算,再用得到的结果在对每个像素计算光照颜色值。示例程序phonglighting经过一定修改,使用冯氏着色得到如下的图像。源码传送门

这种做法的代价是我们需要在片段着色器中执行更多的计算操作,这样会使得程序渲染同一个模型花费更多的时间。新的顶点着色器代码如下。

#version 420 core
// Per-vertex inputs
layout (location = 0) in vec4 position; 
layout (location = 1) in vec3 normal;

// Matrices we’ll need
layout (std140) uniform constants {
    mat4 mv_matrix;
    mat4 view_matrix;
    mat4 proj_matrix; 
};

// Inputs from vertex shader
out VS_OUT {
    vec3 N; 
    vec3 L; 
    vec3 V;
} vs_out;
    
// Position of light
uniform vec3 light_pos = vec3(100.0, 100.0, 100.0); 

void main(void) {
    // Calculate view-space coordinate
    vec4 P = mv_matrix * position;
    // Calculate normal in view-space
    vs_out.N = mat3(mv_matrix) * normal; 
    // Calculate light vector
    vs_out.L = light_pos - P.xyz;
    // Calculate view vector
    vs_out.V = -P.xyz;
    // Calculate the clip-space position of each vertex
    gl_Position = proj_matrix * P;
}

每个像素的颜色都根据插值后的平面法向量,光源向量和视口向量计算,而不是通过每个顶点的颜色进行插值计算。在顶点着色器中输出每个顶点的这三个向量分别为vs_out.N,vs_out.L和vs_out.V。在片段着色中就可以得到插值后到向量,其源码如下。

#version 420 core 
// Output
layout (location = 0) out vec4 color;

// Input from vertex shader
in VS_OUT {
    vec3 N; 
    vec3 L; 
    vec3 V;
} fs_in;
    
// Material properties
uniform vec3 diffuse_albedo = vec3(0.5, 0.2, 0.7); 
uniform vec3 specular_albedo = vec3(0.7);
uniform float specular_power = 128.0;

void main(void) {
    // Normalize the incoming N, L, and V vectors
    vec3 N = normalize(fs_in.N); 
    vec3 L = normalize(fs_in.L); 
    vec3 V = normalize(fs_in.V);

    // Calculate R locally
    vec3 R = reflect(-L, N);
    
    // Compute the diffuse and specular components for each fragment
    vec3 diffuse = max(dot(N, L), 0.0) * diffuse_albedo;
    vec3 specular = pow(max(dot(R, V), 0.0), specular_power) * specular_albedo;
        
    // Write final color to the framebuffer
    color = vec4(diffuse + specular, 1.0); 
}

在现代的图像硬件上,通常使用的是类似冯氏着色方法的高质量渲染方法。视觉效果通常是我们更关注的,因此需要在性能上做一定的让步。当然,在低性能低设备上,或者场景中已经于很多耗性能的任务需要执行时,高洛德着色法依然是最好的选择。正如我们所见,一个通用的着色器优化法制就是尽可能的将片段着色器的任务移动到顶点着色器执行,但是有时为了更高质量的渲染效果也不得不做性能低妥协。

无论是逐顶点或者逐片段颜色计算,冯氏光照计算公式中的主要参数是漫反射率,镜面反射率和镜面反光度。前两个参数可以计算出模拟材质漫反射和镜面反射的颜色值。通常情况下他们的值相同,或者漫反射率反映的是材质的颜色,而镜面反射率为1,即全反射。当然你也可以设置镜面反射率完全不同于漫反射率。镜面反光度控制了镜面高光的收敛速度。下图显示了设置不同镜面反射率和镜面反光度时得到的渲染结果。场景中具有单一的白光源。从左到右,镜面反光率从0变化到1,从上到下,镜面反光度从4增加到256,每隔1行,该值增加1倍。左上角球体的镜面反光看上去暗淡并且均匀,然而右下角的球体镜面反光看上去更亮并且收敛。源码传送门

尽管上图仅仅模拟了白色的点光源,对于其他颜色的光源,像素颜色的计算方式也类似。

2.2 宾氏-冯氏光照模型

宾氏-冯氏光照模型(Blinn-Phong Lighting Model)可以看作是冯氏光照模型的一个扩展和优化。在冯氏光照模型中,我们计算了每个顶点或者片段的R⃗和N⃗向量点积。然而我们可以使用向量N⃗和H⃗的点积计算出的点积替代,这里向量H⃗是光源向量L⃗和视口向量E⃗的中间向量,可以通过如下方式计算。

技术上讲,在应用冯氏光照公式的地方都需要做这样的计算,并且在计算的每一步都需要对向量执行标准化操作(向量需要除以自身的模)。虽然这看上去计算任务较大,但是我们却不需要再计算向量R⃗,避免了调用计算反射向量的函数。现代的图像处理器都足够强大,计算标准向量H⃗的成本和计算反射向量的成本差异几乎可以忽略。然而,如果三角形图元所在曲面的曲率足够小,三角形图元的大小相对于其与光源和观察者的距离足够小,向量H⃗在图元内部的变化并不会太明显,因此可以在顶点、几何或者曲面细分计算着色器中计算出向量H⃗,并将其作为flat类型的变量传入到片段着色器中。即便这样做会有一些误差,但是也可以通过加大镜面反光度来弥补。示例程序BlinnPhong的片段着色器代码如下,其中使用宾氏-冯氏光照模型对每个片段的颜色进行计算。

#version 420 core 
// Output 
layout (location = 0) out vec4 color;

// Input from vertex shader
in VS_OUT {
    vec3 N; 
    vec3 L; 
    vec3 V;
} fs_in;
    
// Material properties
uniform vec3 diffuse_albedo = vec3(0.5, 0.2, 0.7); 
uniform vec3 specular_albedo = vec3(0.7);
uniform float specular_power = 128.0;

void main(void) {
    // Normalize the incoming N, L, and V vectors
    vec3 N = normalize(fs_in.N); 
    vec3 L = normalize(fs_in.L); 
    vec3 V = normalize(fs_in.V);
        
    // Calculate the half vector, H
    vec3 H = normalize(L + V);
    // Compute the diffuse and specular components for each fragment
    vec3 diffuse = max(dot(N, L), 0.0) * diffuse_albedo; 
    // Replace the R.V calculation (as in Phong) with N.H
    vec3 specular = pow(max(dot(N, H), 0.0), specular_power) * specular_albedo;
    // Write final color to the framebuffer
    color = vec4(diffuse + specular, 1.0); 
}

下图是分别使用冯氏光照模型(左侧)和宾氏-冯氏光照模型(右侧)得到的图片,其中冯氏光照模型使用的反光度为128,而宾氏-冯氏光照模型使用的反光度为200。在调整该参数后,两种光照模型得到了相似的结果。源码传送门

2.3 边缘光照

边缘光照(Rim Lighting)也称为背光(Back Lighting),它指被观察模型在观察者和光源之间时,光从模型的边缘向模型内部渗透的效果,或者模型的阴暗面没有光照的效果。边缘光照是由被照射的模型轮廓发光而得名。在摄影中,可以通过将被观察到物体置于相机和光源之间获得这种效果,而在计算机图形学中,可以通过模拟观察方向获得近似的效果。

模拟边缘光照需要视点向量和表面法向量,在前面描述的两个光照模型中我们已经描述过着两个向量。当观察方向时面对平面时,视点向量和平面法向量共线,边缘光照效果最不明显。当观察方向和平面法向量垂直时,边缘光照效果最明显。

下图演示了边缘光照和视点向量V⃗和平面法向量N⃗之间的关系。图中,向量N⃗1和V⃗1几乎垂直,此时从光源绕着物体的轮廓投射到被观测片段的光线最多。然而向量N⃗2和V⃗2几乎是共线同向的,此时光源几乎完全被物体所遮挡,投射到该片段上的轮廓光最少。

向量的点积计算方便,它与两个向量的角度成比例。当两个标准向量平行且同向时,它们的点积为0,当它们正交时,点积为0。因此我们可以通过计算视点向量和平面法向量的点击,再通过对其取反比,从而计算边缘光。为了更好的模拟边缘光照,我们引入了一个亮度系数和一个收敛指数,边缘光最终的计算公式如下。

在上面的公式中,向量N⃗和V⃗ 分别为平面法向量和视点向量,Crim和Prim分别是边缘光的颜色和亮度,Lrim为最终计算出某个片段的边缘光。下面的着色器代码片段使用该公式计算边缘光。

// Uniforms controlling the rim light effect
uniform vec3 rim_color; 
uniform float rim_power;
vec3 calculate_rim(vec3 N, vec3 V) {
    // Calculate the rim factor
    float f = 1.0 - dot(N, V);
    // Constrain it to the range 0 to 1 using a smooth step function
    f = smoothstep(0.0, 1.0, f);
    // Raise it to the rim exponent
    f = pow(f, rim_power);
    // Finally, multiply it by the rim color
    return f * rim_color; 
}

示例程序RimLight使用冯氏光照模型,应用了边缘光照效果,其渲染结果如下图。左上角的图禁用了边缘光照,右上角的图应用了中等强度的边缘光和中等级的光强度,可以看见龙的肚子边缘处有环境光效果形成的白光。左下角的图增加了边缘光的颜色和光强度,能够看到边缘光变得更加收敛,或者更聚焦了。右下角的图调低了边缘光颜色和强度,这使得边缘光向模型内部渗透得更远,看上去像环境光的效果。Demo传送门

对于一个给定的场景,边缘光的颜色通常是固定的,或者随着物体在世界坐标系中的位置不同产生变化(或者是不同的物体被不同颜色的光源照量,当然这看上去十分怪异)。然而,边缘光的能量本质上可以理解为是边缘光向物体中渗透的强度,它随着材质的不同而发生变化。例如在头发、毛皮等软材质,或者大理石等半透明材质上,边缘光的渗透能力更强,在如木头、岩石等硬材质上,边缘光的渗透能力更弱。

2.4 法线贴图

到目前为止计算片段光照颜色的示例中,我们使用了高洛德着色和冯氏着色两种颜色计算方法。前者对每个顶点的颜色进行计算,然后在各个顶点中插值从而得到每个片段的颜色。后者通过每个顶点的属性计算出一些向量,然后将向量在顶点中插值从而计算出每个片段的近似向量,如法向量,最后再计算出每个片段的颜色。为了使物体表面更逼真,也为了使顶点间插值产生的误差更小,大多数情况下我们需要在OpenGL管道中使用大量几何图形,这样三角形就会足够小,误差就会更新,但这样每个三角形只能覆盖少量的像素。

一种不需要增加顶点数量,仍能提高模型表面细节的方式是法线(Normal Mapping)贴图,有时也称为凹凸贴图(Bump Mapping)。想要应用法线贴图,我们首先需要一个能存储模型表面每个纹素法向量的纹理。然后将该纹理应用到模型上,在片段着色器中计算出每个片段的法向量,再利用某个选定的光照模型计算出每个片段经光照后应该呈现的颜色。一个法线贴图使用的包含法向量的纹理如下。

需要注意的是,尽管顶点属性也能定义法线,但是这样只能对一个图元的特定几个顶点定义,并且它定义的是曲面的法向量。而通过法线贴图,我们可以对每个纹素的法线定义,这样就能定义更多的细节。

上图中每个纹素的颜色值RGB都可以转换成为一个XYZ的三维单位向量,其中颜色的取值为[0, 1],而向量坐标的取值为[-1, 1],只需要做简单的映射即可。另外在上图中,我们将整个虫子的所有模型的法线贴图都集成到了一个纹理中,这样只需要在顶点属性中传入正确的纹理坐标就能取到对应纹素的法向量了,当然获取顶点属性的模型通常是由专业的软件生成的。

在法线贴图中最常用的是切线空间,它是一个局部的坐标系,其中z轴和曲面的法向量同向。在切线空间中的两外两个轴的向量被称为切向量(Tangent Vectors)和副切向量(Bitangent Vectors),它们的选择组合有很多,为了方便计算和统一,通常将它们和纹理的U、V向量对齐。如下图中,蓝色向量为曲面法向量,红色向量为选定的切向量,绿色向量为副切向量。这里纹素的法向量并未列出是因为对于图中的纹素,其纹素切向量和曲面切向量相同。

需要注意的是因为每个纹素的法向量都是是描述在它们自己的曲面切线空间内部的,而每个纹素的曲面切线空间并不相同,因此每个纹素的法向量都在不同空间内。由于大多数的纹素法向量都和曲面法向量差异不大,因此它们的z轴分量都远远大于其他两个分量,这也是为什么上面的法线纹理整体看上去呈现蓝色的原因。

通常情况下每个顶点的切向量作为顶点的一个属性,已经和位置等属性一起被包含在模型文件中。这样我们在顶点着色器中就可以知道切空间的法向量,切向量,由于我们建立切空间时使用的是正交坐标系,因此其副切向量可以由法向量和切向量的叉乘得到。⚠️由于OpenGL使用了右手坐标系,向量的叉乘不遵守乘法交换律,不能交换顺序。

通常的做法是在顶点坐标系中计算出光源向量、视点向量等变量后,将其传递到片段着色器中,OpenGL会计算每个片段插值后的结果,此时我们再用这些插值后的向量,和从颜色贴图中取得的材质漫射色,和从法线贴图中取得的片段法向量,共同计算出每个片段的颜色。但是这里有一个问题,光源向量、视点向量都是定义在视口空间内,而纹素法向量是定义在切空间内,顶点属性的向量定义在模型空间内的,它们不能直接计算,因此我们不能直接计算。通常的做法是在顶点着色器内将相关的向量都转换到切空间内计算,得到转换后的视点向量和光源向量再传入到顶点着色器中。

要将视图空间中的向量转换到切线空间内,首先需要使用曲面法向量、切线向量、副切线向量构建一个旋转矩阵,只需要将这三个单位向量作为矩阵中的三行(如需将切线空间内的点向视图空间转化,则将这三个单位向量表示为三列)即可。其构造方式如下。

这样在片段着色器中,我们得到转换后的视点向量和光源向量,以及发现贴图中的片段法线向量就都位于切线空间内,这样我们就能使用冯氏照明模型正确的计算出片段的颜色。

示例程序BumpMapping演示了上述的计算逻辑,其顶点着色器代码如下。

#version 420 core
layout (location = 0) in vec4 position; 
layout (location = 1) in vec3 normal; 
layout (location = 2) in vec3 tangent; 
layout (location = 4) in vec2 texcoord;

out VS_OUT {
    vec2 texcoord; 
    vec3 eyeDir; 
    vec3 lightDir;
} vs_out;

uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform vec3 light_pos = vec3(0.0, 0.0, 100.0);

void main(void) {
    // Calculate vertex position in view space.
    vec4 P = mv_matrix * position;

    // Calculate normal (N) and tangent (T) vectors in view space from 
    // incoming object space vectors.
    vec3 N = normalize(mat3(mv_matrix) * normal);
    vec3 T = normalize(mat3(mv_matrix) * tangent);
    // Calculate the bitangent vector (B) from the normal and tangent vectors.
    vec3 B = cross(N, T);

    // The light vector (L) is the vector from the point of interest to 
    // the light. Calculate that and multiply it by the TBN matrix. 
    vec3 L = light_pos - P.xyz;
    vs_out.lightDir = normalize(vec3(dot(V, T), dot(V, B), dot(V, N)));

    // The view vector is the vector from the point of interest to the
    // viewer, which in view space is simply the negative of the position. 
    // Calculate that and multiply it by the TBN matrix.
    vec3 V = -P.xyz;
    vs_out.eyeDir = normalize(vec3(dot(V, T), dot(V, B), dot(V, N)));

    // Pass the texture coordinate through unmodified so that the fragment 
    // shader can fetch from the normal and color maps.
    vs_out.texcoord = texcoord;

    // Calculate clip coordinates by multiplying our view position by 
    // the projection matrix.
    gl_Position = proj_matrix * P;
}

该示例程序片段着色器代码如下。

#version 420 core 
out vec4 color;
// Color and normal maps
layout (binding = 0) uniform sampler2D tex_color; 
layout (binding = 1) uniform sampler2D tex_normal;

in VS_OUT {
    vec2 texcoord; 
    vec3 eyeDir; 
    vec3 lightDir;
} fs_in;

void main(void) {
    // Normalize our incoming view and light direction vectors.
    vec3 V = normalize(fs_in.eyeDir);
    vec3 L = normalize(fs_in.lightDir);
    // Read the normal from the normal map and normalize it.
    vec3 N = normalize(texture(tex_normal, fs_in.texcoord).rgb * 2.0 - vec3(1.0));
    // Calculate R ready for use in Phong lighting.
    vec3 R = reflect(-L, N);
   
    // Fetch the diffuse albedo from the texture.
    vec3 diffuse_albedo = texture(tex_color, fs_in.texcoord).rgb; 
    // Calculate diffuse color with simple N dot L.
    vec3 diffuse = max(dot(N, L), 0.0) * diffuse_albedo;
    // Uncomment this to turn off diffuse shading
    // diffuse = vec3(0.0);
    
    // Assume that specular albedo is white - it could also come from a texture
    vec3 specular_albedo = vec3(1.0);
    // Calculate Phong specular highlight
    vec3 specular = max(pow(dot(R, V), 5.0), 0.0) * specular_albedo; 
    // Uncomment this to turn off specular highlights
    // specular = vec3(0.0);
    
    // Final color is diffuse + specular
    color = vec4(diffuse + specular, 1.0); }

上面的着色器计算结果会根据法线贴图中的细节来表现镜面高光,计算过程不再依靠模型数据提供的几何细节。在下图中左上角的图片显示的是添加漫射光后的结果,右上角的图片是添加反射光的结果,左下角的图片是这两种效果的组合。作为对照,右下角的图是使用表面法向量在顶点之间插值后再计算各个片段颜色的方式得到的结果。我们可以明显看出左下角使用了法线贴图的计算方式比右下角的图片多出了更多的细节。Demo传送门

2.5 环境贴图

前面的几个小节已经介绍了如何在模型的表面应用光照效果计算片段颜色。但是对于现实世界中的一个具体环境,想要在着色器中模拟环境光照可能使其内部逻辑变得十分复杂,最终影响程序的性能。另外,实际上我们也不能建立一个能表示任意环境的公式。环境贴图(Environment Mapping)很好的解决了这个问题,它在避免复杂的光学效果计算同时,仍能很好的模拟光滑表面(如镜子等)对周围环境的反射效果。在实时图像处理程序中常见的环境贴图类型有球面环境贴图(Spherical Environment Map),等矩形球面环境贴图(Equirectangular Map),和立方体环境贴图(Cube Map)。球面环境贴图模拟环境投影到一个半球体上,再将其投影到一个圆形来表示不同法向量下投影的环境颜色。相对于球面环境贴图只能使用一个半球表示被投影的环境,等矩形球面贴图将球面坐标投影到一个矩形上,从而能够表示360度全方位环境细节。立方体环境贴图是一个由6个平面组成的特殊纹理,它可以被看成是一个玻璃盒子,当观察点位于立方体中心时能够完整的看到整个环境。后面的小节会详细介绍这三种类型的环境贴图。

2.5.1 球面环境贴图

前面已经讲到使用要模拟的材质建立一个球体模型,然后将模拟环境产生的光投影到这个球体上,最终将其投影到一个平面的圆形图片上就得到了球面环境贴图。在渲染场景时,通过计算渲染模型的每个片段视点向量和表面法向量的关系计算出正确的纹理坐标,再到环境贴图中查询正确的光照系数,从而模拟环境光照效果。尽管这个系数可以存储任意和光照相关的系数,但是在最简单的场景中,这个系数就是在模拟的环境光照下的颜色值。下图是一些球面环境贴图的例子。

图中,法向量为和屏幕垂直,正方向朝外,由于球面被投影到了xy平面上,圆心到球面每一个点都是三维空间中的单位向量,因此当我们计算出视点单位向量时,直接使用其xy值就能计算出对应的光照系数。

实现球面环境贴图的第一步是在顶点着色器中将输入的顶点法向量转换到视图空间中,并计算出每个顶点的观察向量。在片段着色器中将会使用这两个变量计算出纹理的坐标从而在环境贴图中查询出具体纹素。顶点着色器的代码如下。

#version 420 core 

uniform mat4 mv_matrix;
uniform mat4 proj_matrix;

layout (location = 0) in vec4 position;
layout (location = 1) in vec3 normal;

out VS_OUT {
    vec3 normal;
    vec3 view; 
} vs_out;

void main(void) {
    vec4 pos_vs = mv_matrix * position;
    vs_out.normal = mat3(mv_matrix) * normal; 
    vs_out.view = pos_vs.xyz;
    gl_Position = proj_matrix * pos_vs;
}

在顶点着色器运行后,我们在片段着色器中就能够得到每个片段的法向量和视点单位向量,从而就能够计算出正确的坐标,再到环境贴图中查询到正确的纹素。对应的片段着色器代码如下。

#version 420 core

layout (binding = 0) uniform sampler2D tex_envmap;

in VS_OUT {
    vec3 normal;
    vec3 view; 
} fs_in;

out vec4 color;

void main(void) {
    // 计算标准观测向量
    vec3 u = normalize(fs_in.view);

    // 计算观测向量于法向量的反射向量
    vec3 r = reflect(u, normalize(fs_in.normal));
    
    // 假设球面贴图上存在点A,由于球面贴图的制作原理,认为球面上的
    // 每个点都是无穷远处观察到的反射景色,因此从观测点到球面任意一点
    // 的单位观测向量都可以认为是(0,0,-1),即对于每个兴趣点视点向
    // 量都为(0, 0, 1),片段的单位反射向量和该向量之和标准化后即可得
    // 到球面贴图中的法向量,即可以用于确定片段的颜色
    vec3 sphericalR = normalize(r + vec3(0, 0, 1));

    // 标准向量的三个分量的取值范围为[-1,1],需要将其值映射到[0,1]的
    // 区间才能正确的计算出纹理坐标对于单位球面上的点A,其在球形纹理贴
    // 图中的投影坐标可以由其x和y值表示
    color = texture(tex_envmap, sphericalR.xy * 0.5 + vec2(0.5));
}

使用上图球面环境贴图的最后一张图像渲染后的结果如下图,源码传送门。

2.5.2 等矩形球面环境贴图

等矩形球面环境贴图(Equirectangular Environment Maps)和球面环境贴图类似,但是它更不容易出现图像变形,这种变形在球体环境贴图的极点比较明显。下图是一个等矩形球面环境贴图的例子。

这里仍然需要在顶点着色器中计算出视图空间内的法向量和观察向量,在片段着色器中得到插值结果后,计算出观察向量的反射向量。

在说明纹理坐标方式的时候先简要说明如何将球面坐标投影到等矩形坐标之上,即等距球面投影(Equidistant Cylindrical Projection)。将单位球体放入到一个圆柱体中,将其经线映射为投影后的x坐标,将其纬线映射为投影后的y坐标,这样在投影变换后的纹理中,等距离经线和纬线距离仍不会发生改变。值得关注的是我们平时看到的世界地图也有采用类似的投影技术。另外这种投影方式会发生变形,纬度越高,形变越大。

因此在计算纹理坐标时,我们首先提取出y分量,这可以理解为高度,然后将
其设置为0,再对向量进行标准化操作,从而将反射向量投影在xz平面上。在这个标准化向量中提取出x分量从而得到第二个纹理坐标,这可以理解为其方位。这样便能组合出高度和方位角,就能够在等矩形球面环境贴图中查询到正确的纹素。

下面的片段着色器演示了示例程序Equirectangular中如何使用等矩形球面环境贴图。

#version 410 core

// 统一变量,环境纹理采样器
uniform sampler2D tex_envmap;

// 输入变量
in VS_OUT {
    vec3 normal;
    vec3 view;
} fs_in;

// 输出变量
out vec4 color;

void main(void) {
    // 计算标准观察向量
    vec3 u = normalize(fs_in.view);

    // 计算视图坐标系中的观察向量沿法向量的反射向量,即为“光源向量”
    vec3 r = reflect(u, normalize(fs_in.normal));

    // 计算环境纹理贴图中的采样坐标
    vec2 tc;
    // 反射向量的y被直接投影到环境纹理中的t坐标,可以理解为高度
    tc.y = r.y;
    // 将反射向量y轴分量设置为0,在求其标准向量,从而将其投影到xz平面,再通过其x和z值计算出环境纹理贴图中的采样坐标s分量
    r.y = 0.0;
    tc.x = normalize(r).x;

    // 1. 等矩形球面贴图的制作方式决定了其坐标的原点和被投影模型在视图空间中的(x:0,z:-1)对应
    // 2. 并且x轴和s轴是线性相关,也就是直接将模型投影到xy平面上,因此从被投影模型(xz)坐标到纹理坐标(st)的计算方式如下
    // 当z > 0时,tc.s = 0.5 + 0.25tc.s    => tc.s = 0.75 - 0.25 * sign(r.z) + 0.25 * tc.s * sign(r.z)
    // 当z < 0时,tc.s = 1 - 0.25tc.s      => tc.s = 0.75 - 0.25 * sign(r.z) + 0.25 * tc.s * sign(r.z)
    // 合并上面两个情况 tc.s = 0.75 - sign(r.z) * 0.25 (1 - tc.s) = 0.75 - s * 0.25 * (1 - tc.s)
    
    // 需要注意在被投影模型的(x: 0, z: -1) -> (x: -1, z: 0) -> (x: 0, z: 1) -> (x: 1, z: 0) -> (x: 0, z: -1)
    // 变化过程中其纹理坐标tc.s分别被投影成了0 -> 1.25/0.25(跳跃间断点) -> 0.5 -> 0.75 -> 1
    // 由于我们设置了纹理过滤模式为Repeat,因此上述变化可以被映射到0 -> 0.25 -> 0.5 -> 0.75 -> 1,因此能够正确采样
    float s = sign(r.z);
    tc.s = 0.75 - s * 0.25 * (1 - tc.s);
    // 将从被投影模型的到的y轴坐标分量取值区间为[-1, 1]映射至[0, 1]之间
    tc.t = 0.5 + 0.5 * tc.t;

    // 从环境纹理采样确定片段最终颜色
    color = texture(tex_envmap, tc);
}

其渲染结果如下图,Demo传送门

2.5.3 立方体环境贴图

尽管立方体环境贴图(Cube Map)被当作是单个纹理对象,但是它是由6个正方体的2维纹理组成,它们构成正方体的每一个面。从3维光照贴图,到反射光贴图,和高精度环境贴图都可以看见它的应用。下图是展示了一个立方体环境贴图的六个面,我们将在示例程序CubeMap中用到。

在继续讨论使用立方体环境贴图时纹理坐标的计算方式之前,简单说明下如何从球体投影到立方体贴图。假设有一个单位球体,其表面反射除周围的环境。在球体中心存在一个光源,其发散出的光投影在立方体的六个内表面,单位球面上所有的纹素就能够被投影到立方体贴图中。

加载立方体纹理首先需要创建一个纹理对象,并将其绑定到靶点GL_TEXTURE_CUBE_MAP上,然后调用函数glTexStorage2D()为纹理对象分配内存空间,再对立方体的每个面调用函数glTexSubImage2D()向纹理中填充数据。在该函数中我们会用到6个特殊的靶点GL_TEXTURE_CUBE_MAP_POSITIVE_X,GL_TEXTURE_CUBE_MAP_NEGATIVE_X, GL_TEXTURE_CUBE_MAP_POSITIVE_Y, GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, GL_TEXTURE_CUBE_MAP_POSITIVE_Z, 和GL_TEXTURE_CUBE_MAP_NEGATIVE_Z。他们是连续的数字,因此我们可以通过一个简单的循环来填充纹理数据。其代码如下。

GLuint texture;

glGenTextures(1, &texture); 
glBindTexture(GL_TEXTURE_CUBE_MAP, texture);

glTexStorage2D(GL_TEXTURE_CUBE_MAP, levels, internalFormat, width, height);
for (face = 0; face < 6; face++) {
    glTexSubImage2D(GL_TEXURE_CUBE_MAP_POSITIVE_X + face,
                    0,
                    0, 0,
                    width, height,
                    format, type,
                    data + face * face_size_in_bytes);
}

立方体环境贴图同样支持多分辨率贴图,如果要使用这种类型的纹理,上面的代码只需要进行简单的修改。Khronos的纹理文件格式都支持立方体环境贴图,本书中的.KTX文件加载器也可以帮组你完成这部分工作。

尽管立方体纹理贴图是一系列二维纹理的集合,但是在使用时仍需要三维的纹理坐标。在真实的三维纹理中,由S、T和R分量构成的一个有向向量以纹理中心为原点,向外延伸至一个具体的点。而在立方体纹理中,这个向量将会和立方体的某个面相交,在焦点附近的纹素将被采样并计算出最终的颜色值。

立方体纹理最常用的场景是创建一个模型,其表面反射了周围环境。利用立方体贴图还可以创建出一个天空盒(Sky box),它可以完全反射出周围的环境。天空盒可以理解为一个盒子包含了整个场景,在游戏中应用使得玩家站在盒子中心能够看到非常理想的环境,盒子模型的每个内表面分别投影出从屏幕中心向六个方向的风景。

要渲染立方体纹理贴图,我们需要以观察者为中心绘制一个大的立方体,并对其应用环境贴图。然而我们有更简单的方法实现这个目的,模拟的立方体模型超出视野的部分都将会被裁剪掉,另外我们还需要观察的整个场景都是周围的环境,从而获得沉浸式的体验。假设我们只观察环境中的一个面,我们可以简单的绘制一个全屏幕的四边形,接下来需要做的事情就是计算视野的四个角上的纹理坐标,从而就能渲染出立方体贴图。

在本例子中,立方体纹理直接映射到模拟的立方体中,因此模拟立方体的顶点位置就是纹理坐标,再经过观察矩阵的子矩阵(因为无需考虑投影问题,也就不需要使用齐次坐标,选取左上3✖️3子矩阵)对这些坐标进一步处理,使其和视图空间内的模型对其。这些操作都在顶点着色器中进行,其代码如下。

#version 410 core

// 统一变量,仿射矩阵
uniform mat4 view_matrix;

// 输出变量
out VS_OUT {
    vec3    tc;
} vs_out;

void main(void) {
    // 1. 定义一个全窗口的矩形模型
    vec3[4] vertices = vec3[4](vec3(-1.0, -1.0, 1.0),
                               vec3( 1.0, -1.0, 1.0),
                               vec3(-1.0,  1.0, 1.0),
                               vec3( 1.0,  1.0, 1.0));
    // 2. 计算采样纹理坐标
    // 立方体纹理使用的采样纹理坐标是观察向量,这个向量没有必要是标准向量,
    // 只要指定了方向,OpenGL的纹理采样器就能够获取到正确的像素颜色
    vs_out.tc = mat3(view_matrix) * vertices[gl_VertexID];

    // 3. 计算顶点在投影空间的位置
    // 绘制全窗口的矩形不涉及到投影变换,因此这里直接使用设置好的顶点坐标
    gl_Position = vec4(vertices[gl_VertexID], 1.0);
}

因为我们使用硬编码的方式定义了立方体某个平面的坐标,因此我们不需要额外的顶点属性,也不需要额外的缓存对象来存储这些数据。另外,可以通过调整每个顶点的z轴分量来实现对该平面的缩放,z轴分量越大,最后模型经过透视投影到屏幕空间内的图像就会越小。渲染立方体环境贴图的片段着色器也很简单,其源码如下。

#version 410 core

// 统一变量:立方体环境贴图采样器
uniform samplerCube tex_cubemap;

// 输入变量
in VS_OUT {
    vec3    tc;
} fs_in;

// 输出变量
layout (location = 0) out vec4 color;

void main(void) {
    // 计算该片段的最终颜色
    color = texture(tex_cubemap, fs_in.tc);
}

当渲染好天空盒后,再渲染一个小的模型在场景中心,使其反射出天空盒构建出的环境。前面已经讲过,用于在立方体环境贴图中查询纹理的坐标是以其中心为原点,朝向外的一个有向向量,通过和立方体纹理表面相交从而获得最终的颜色,我们需要做的只是计算出这些向量,剩下的OpenGL都会帮我们完成。同学我们需要计算出每个片段的观察向量和法向量。

这些工作都在顶点着色器中完成,计算好的数据将会被传递到片段着色器中,并被标准化处理。和之前的很多例子一样,我们需要根据片段法向量定义的平面计算出观察向量的反射向量。假定天空盒中的场景都足够远,反射向量都被认为是从圆心发射而出,则这些反射向量可以被看作是纹理坐标。顶点着色器的代码如下。

#version 410 core

// 输入变量
layout (location = 0) in vec4 position;
layout (location = 1) in vec3 normal;

// 统一变量:仿射矩阵
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;

// 输出变量
out VS_OUT {
    vec3 normal;
    vec3 view;
} vs_out;

void main(void) {
    // 1. 计算顶点在视图坐标系中的坐标
    vec4 pos_vs = mv_matrix * position;
    // 2. 将顶点法向量转换到视图坐标系
    vs_out.normal = mat3(mv_matrix) * normal;
    // 3. 计算兴趣点点观察矩阵
    vs_out.view = pos_vs.xyz;
    // 4. 计算顶点在投影坐标系下的坐标
    gl_Position = proj_matrix * pos_vs;
}

片段着色器的代码如下。

#version 410 core

// 输入变量
in VS_OUT {
    vec3 normal;
    vec3 view;
} fs_in;

// 统一变量:立方体环境纹理采样器
uniform samplerCube tex_cubemap;

// 输出变量
out vec4 color;

void main(void) {
    // 1. 计算片段的反射向量,追踪光源位置
    vec3 r = reflect(fs_in.view, normalize(fs_in.normal));

    // 2. 使用反射向量在立方体环境纹理中采样,计算出该片段应该反射的环境颜色
    // 再乘以其材质属性的漫反射率得到最终的颜色
    color = texture(tex_cubemap, r) * vec4(0.95, 0.80, 0.45, 1.0);
}

上面的程序会在来一个天空盒,并在其中绘制了一个模型来反应天空盒的环境,其运行效果如下。源码传送门

当然,场景中心的模型并不一定是直接从立方体环境贴图中获取。例如,你可以将环境的颜色和模型本身材质的颜色相乘从而得到其他有趣的结果。如下图渲染了一条金龙。

2.6 材质属性

在前面的例子中,我们对整个模型使用的都是同一种材质。这使得渲染出的整只龙看上去具有相同的光泽,渲染出的瓢虫看上去塑料感十足。然而,现实中一个模型可以由多种不同的材质所组成。实际上,我们可以定义每个表面,每个三角形,甚至每个像素的材质,只需要将这些信息存储在一个纹理对象中就可以达到这个目的。例如通过纹理存储高光指数,在渲染模型的时候应用这个纹理,就可以使模型中不同部分表现出不同程度的反射效果。

通过预模糊环境贴图,再通过存储在纹理中的光泽系数混合清晰和模糊的纹素可以使模型具有有趣的光泽。在这个例子中会再次使用到简单的球面环境贴图。下图分别是清晰的环境贴图,模糊的环境贴图,以及用做提取光泽系数的光泽纹理。在光泽纹理中,越亮的部分使用越多的清晰环境纹素,反之则使用更多的模糊环境纹素。

我们可以将两个环境纹理合并成一个只有两层的三维纹理。再将光泽纹理中查询到的亮度值作为第三个坐标分量,从而在合并后的三维纹理中查询颜色。将清晰的环境贴图作为第一层,将模糊后的环境贴图作为第二层,这样OpenGL就能够自动在它们之间平滑插值,从而计算出最终的颜色。

负责读取光泽系数,环境纹理,并计算最终每个片段的颜色的片段着色器源码如下。

#version 420 core

layout (binding = 0) uniform sampler3D tex_envmap;
layout (binding = 1) uniform sampler2D tex_glossmap;

in VS_OUT {
    vec3 normal; 
    vec3 view; 
    vec2 tc;
} fs_in;

out vec4 color;

void main(void) {
    // u will be our normalized view vector
    vec3 u = normalize(fs_in.view);
    
    // Reflect u about the plane defined by the normal at the fragment
    vec3 r = reflect(u, normalize(fs_in.normal));
        
    // Compute scale factor
    r.z += 1.0;
    float m = 0.5 * inversesqrt(dot(r, r));
        
    // Sample gloss factor from glossmap texture
    float gloss = texture(tex_glossmap, fs_in.tc * vec2(3.0, 1.0) * 2.0).r; 
    
    // Sample from scaled and biased texture coordinate
    vec3 env_coord = vec3(r.xy * m + vec2(0.5), gloss);
        
    // Sample from two-level environment map
    color = texture(tex_envmap, env_coord);
}

示例程序PerPixelGloss的运行结果如下[WIP: Demo工程中3D纹理采样仍存在问题,待查明]。源码传送门

2.7 制作阴影

目前为止的所有光照着色器算法都假定每个片段的颜色都是由光照决定的,实际上在由多个模型的场景中,片段的最终颜色还受到其他因素影响。模型会在自己以及其他模型上投射阴影,如果这些阴影在最终渲染出的场景中被忽略掉,那么整个场景看上去就不是那么真实。这小节主要介绍一些技术来模拟模型产生的阴影效果。

2.7.1 阴影贴图

任何阴影计算的第一步都是计算某个点是否被光源照射。实际上,我们必须计算出从某个点到光源之间是否有任何障碍物,这种计算也可以称为可见性计算。幸运的是深度缓存能够是我们很高效的完成这一计算。

阴影贴图技术通过从光源的角度去渲染一个场景,能够得到整个场景的可见性。在这个计算过程中,我们只需要深度信息,因此我们在创建帧缓存对象的时候只需要添加一个深度附件。以光源的视角渲染整个场景后,我们能够得到光源照射到场景中最近模型片段的距离。当我们在正常渲染场景时,可以计算每个片段到光源的距离,并将其和从之前计算好的深度缓存中取出的距离值相比较,从而知道该片段是否可见。当然,在比较之前我们需要将待比较的片段坐标从视图空间转换到光源空间,也就是之前计算深度缓存时使用的坐标系统。

如果计算出当前片段到光源的距离大于从深度缓存中读取出的最近可见片段距离,当前片段位于阴影中。实际上,这种可见性计算在图像学领域是一个很常见的操作,甚至OpenGL还提供了一个特殊的采样器来完成这部分工作,即阴影采样器(Shadow Sampler)。在着色器语言中,对于2D纹理的采样器使用关键字sampler2DShadow声明,这也是我们将要在下面例子中使用到的采样器类型。你也可以使用关键字sampler1DShadow声明1维的阴影采样器,使用关键字samplerRectShadow声明矩形阴影采样器,甚至你可以声明这些类型(除矩形阴影采样器)对应的数组类型。

下面的代码演示了在渲染阴影贴图之前,如何准备只有1个深度附件的帧缓存对象。

GLuint shadow_buffer; GLuint shadow_tex;

glGenFramebuffers(1, &shadow_buffer); 
glBindFramebuffer(GL_FRAMEBUFFER, shadow_buffer);

glGenTextures(1, &shadow_tex); 
glBindTexture(GL_TEXTURE_2D, shadow_tex); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_DEPTH_COMPONENT32, 
               DEPTH_TEX_WIDTH, DEPTH_TEX_HEIGHT); 

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE,
                GL_COMPARE_REF_TO_TEXTURE); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, shadow_tex, 0);
    
glBindFramebuffer(GL_FRAMEBUFFER, 0);

在上面的代码中,多次调用了函数glTexParameteri()和参数GL_TEXTURE_COMPARE_MODE以及GL_TEXTURE_COMPARE_FUNC设置了纹理写入时的比较模式为参考值和纹理存储值相比较,比较函数为小于或者等于,即如果计算出某个片段深度值比纹理中存储的值更小,就会替代纹理中对应的值。当我们创建了用于渲染深度的帧缓存对象后,就可以以光源的位置为视图原点来渲染整个场景。假定光源的位置为light_pos,以该点指向世界坐标系的原点,我们就可以构建出如下的光源的模型-视口投影矩阵。

vmath::mat4 model_matrix = vmath::rotate(currentTime, 0.0f, 1.0f, 0.0f); 
vmath::mat4 light_view_matrix =
        vmath::lookat(light_pos,
                      vmath::vec3(0.0f),
                      vmath::vec3(0.0f, 1.0f, 0.0f);
vmath::mat4 light_proj_matrix =
       vmath::frustum(-1.0f, 1.0f, -1.0f, 1.0f,
                      1.0f, 1000.0f);
vmath::mat4 light_mvp_matrix = light_projection_matrix * 
                               light_view_matrix *
                               model_matrix;

前文的渲染任务结束后,得到的帧缓存对象中存储了从光源到以其为视角最近的模型片段距离。通过如下灰度图我们可以直观的观察到这个结果,对于模型区域,其中黑色部分深度值为0,白色部分深度值为1,也就是说光源照射到的片段距光源最远。

在使用这些深度信息来生成阴影效果,我们需要对渲染着色器进行一定的修改。首先我们需要声明阴影采样器,并从中读取数据。有趣的部分是如何计算从深度缓存中读取数据所使用到的纹理坐标。实际上这很简单,在顶点着色器中通常会计算裁剪坐标系中的位置,即先将世界坐标系中的顶点投影到模拟出的相机视图坐标系中,最后再投影到相机的截锥体中。与此同时,我们可以使用光源的视图投影矩阵执行类似的操作,将得到的结果传递到片段着色器中,最后在其运算的时候就能够获得每个片段在光源裁剪坐标系的位置,从而计算出查询深度纹理时所需要用到的坐标。

出来执行坐标系转换外,我们还必须对得到的裁剪坐标进一步处理。需要记住,OpenGL中标准的裁剪坐标空间其x和y轴上的取值范围为[-1.0, 1.0],而在y轴上的取值为[0, 1.0]。将顶点坐标从物体空间转换到光源裁剪空间的矩阵成为阴影矩阵(Shadow Matrix),其计算过程如下。

const vmath::mat4 scale_bias_matrix = 
      vmath::mat4(vmath::vec4(0.5f, 0.0f, 0.0f, 0.0f),
                  vmath::vec4(0.0f, 0.5f, 0.0f, 0.0f),
                  vmath::vec4(0.0f, 0.0f, 0.5f, 0.0f),
                  vmath::vec4(0.5f, 0.5f, 0.5f, 1.0f));

vmath::mat4 shadow_matrix = scale_bias_matrix * light_proj_matrix * 
                            light_view_matrix * model_matrix;

阴影矩阵可以被作为统一变量传递到顶点着色器中,一个简化版本的顶点着色器如下。

#version 420 core

uniform mat4 mv_matrix; 
uniform mat4 proj_matrix; 
uniform mat4 shadow_matrix;

layout (location = 0) in vec4 position;

out VS_OUT {
    vec4 shadow_coord;
} vs_out;

void main(void) {
    gl_Position = proj_matrix * mv_matrix * position;
    vs_out.shadow_coord = shadow_matrix * position;
}

在顶点着色器中输出变量shadow_coord经过插值运算后被传递到片段着色器中,每个片段的阴影坐标接下来会被投影到标准设备坐标系中,从而被用于纹理坐标在之前得到的阴影纹理中查询数据。通常的方式是将这些坐标都除以w轴分量,但是OpenGL提供的函数textureProj中会自动完成这一步处理。当我们使用该函数查询阴影贴图的时候,OpenGL会先将坐标的x、y和z轴分量都除以w分量,然后再使用处理后的x和y轴分量在纹理中查询数据,最后使用制定的函数将查询到的值和处理后的z轴分量比较,如果通过测试则返回1.0,否则返回0.0。

如果选择的纹理过滤模式是GL_LINEAR或者开启了多重采样,则OpenGL会对多个片段进行上述测试,最终取它们的平均值返回,也就是说此时该函数返回的值将会在区间[0.0, 1.0]中。这样我们就可以根据函数textureProj的返回值来判断某个片段是否位于阴影之中。一个高度简化版的阴影计算片段着色器如下。

#version 420 core

layout (location = 0) out vec4 color;

layout (binding = 0) uniform sampler2DShadow shadow_tex;

in VS_OUT {
    vec4 shadow_coord;
} fs_in;

void main(void) {
    color = textureProj(shadow_tex, fs_in.shadow_coord) * vec4(1.0); 
}

上述简化版代码渲染的结果并没有应用到光照计算,只是简单使用黑白的图像演示模型的阴影渲染结果。在代码中,这里将从阴影纹理查询结果直接乘以白色,实际上我们可以使用前文所介绍到的光照计算方法,或者从纹理中加载纹素得到有光照的颜色,再将其和阴影纹理查询到的结果想乘,从而得到最终的颜色。下图是示例程序ShadowMapping的运行结果,其中左图展示了模型的阴影信息,右图在此基础上应用了光照效果。源码传送门

阴影贴图也有缺点,这种技术对内存的消耗非常大,对于每一个光源都需要生成一个应用贴图,并且对于每个光源都需要复杂的计算决定片段是否位于阴影内,这样成本很大。这种计算很容易快速累积,从而影响程序的性能。阴影贴图的分辨率应该足够大,使得屏幕空间内的多个像素被映射到阴影纹理中的单个纹素,这在执行光照计算时比较高效。最后,对于阴影区域的条带和不规则图案可能会出现自遮挡效果。可以通过几何体位移(Polygon Offset)技术在一定程度上减弱这种影响。当开启这个特性后,OpenGL会自动对所有几何体以及三角形应用一个微小的位移,使得它们远离或者靠近观察者。调用如下函数可以配置该特性的参数。

void glPolygonOffset(GLfloat factor, GLfloat units);

片段的深度偏移值计算公式为offset = factor * change + units * smallChange,其中change是和集合体的深度位移系数,其值和其在屏幕上的大小相关,smallChange是深度缓存中两个不同深度的最小差值,和具体的实现相关。通常这两个参数设置为1即可,我们也可以通过设置不同的值来获得我们想要的效果。当参数配置完成以后,调用函数glEnable()和参数GL_POLYGON_OFFSET_FILL就可以开启深度位移功能,当然使用同样的参数调用函数glDisable()可以关闭该功能。

2.8 环境特效

总的来说,场景的渲染需要建立光照模型,并且考虑光线和场景所处世界的交互。目前为止,我们在渲染模型的时候并未考虑到光线传递的介质。通常情况下介质是空气,然而空气并不是完全透明的,其中包含有细小颗粒,水蒸气以及一些特殊气体,这些微粒在光传播的路径上会吸收和散射光。模拟这种光的散射和吸收特性,我们能够渲染出带有深度感觉的场景,从而推断出景物的距离,这样渲染出的场景会更接近现实世界。

2.8.1 雾

我们都对雾很熟悉,在雾天,我们只能看到近距离的物体,浓密的雾还能带有危险的感觉。雾是由悬浮在空气中的水蒸汽,其他气体以及如烟尘和污染物等固体颗粒组成。当光穿过雾的时候回发生两件事情,部分光线会被这些微粒吸收,另外部分光线会在这些颗粒表面折射,甚至这些颗粒自己也会发出新的光线。光线被雾吸收被称为消光(Extinction),小消光效果最明显的时候,所有光线都会被吸收。然而因为光线也会在这些颗粒之间折射,被吸收的光也会再次发出,因此通常情况下光总能找到一条路径逃离雾区,这种现象称为内散射(Inscattering)。我们可以建立一个包含消光和内散射的模型,从而简单高效的模拟雾天的光效。

在这个例子中,我们将用到前面章节《图元处理》中曲面细分渲染风景的例子。在前面的例子中,天空是纯色的,并且山脉也仅用简单的纹理着色。这样我们很难推断出一处微景观和我们的距离,这本节的例子中,我们将使用雾化效果优化这个场景渲染效果。

修改曲面细分着色器,同时计算出世界坐标系和视图坐标系中的每个顶点坐标,并将其传入到片段着色器中,其代码如下。其中世界坐标系的顶点位置用于计算消光和散色系数,视图坐标系中的顶点位置用于计算片段到观察者的位置,从而计算片段的颜色。

#version 420 core

layout (quads, fractional_odd_spacing) in; 

uniform sampler2D tex_displacement;

uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform float dmap_depth;

out vec2 tc;

in TCS_OUT {
    vec2 tc;
} tes_in[];

out TES_OUT {
    vec2 tc;
    vec3 world_coord; 
    vec3 eye_coord;
} tes_out;

void main(void) {
    vec2 tc1 = mix(tes_in[0].tc, tes_in[1].tc, gl_TessCoord.x); 
    vec2 tc2 = mix(tes_in[2].tc, tes_in[3].tc, gl_TessCoord.x); 
    vec2 tc = mix(tc2, tc1, gl_TessCoord.y);

    vec4 p1 = mix(gl_in[0].gl_Position, gl_in[1].gl_Position, gl_TessCoord.x);
    vec4 p2 = mix(gl_in[2].gl_Position, gl_in[3].gl_Position, gl_TessCoord.x);
    vec4 p = mix(p2, p1, gl_TessCoord.y);
    p.y += texture(tex_displacement, tc).r * dmap_depth;

    vec4 P_eye = mv_matrix * p;

    tes_out.tc = tc;
    tes_out.world_coord = p.xyz;
    tes_out.eye_coord = P_eye.xyz;

    gl_Position = proj_matrix * P_eye;
}

在片段着色器中,我们像往常一样从风景纹理中查询纹素数据,然后应用简单的雾化模型计算最终的颜色值。通过计算在视图坐标系中的顶点位置计算出片段距离观察点点距离,这也是光线需要穿越的距离,也是雾化公式的输入参数。消光因子和内散色因子计算公式如下。

在上面点公式中,fe为消光因子,fi为内散色因子,de和di分别是消光和内散色系数,z是观察点到需要渲染片段的距离。当z趋于0时,消光因子和内散色因子都趋于1,此时观察到的是片段本来的颜色。当z增加时,消光因子和内散色因子减小,最终趋于0,此时观察到的为雾的颜色。指数函数的部分曲线图像如下。

片段着色器部分的源码如下。

#version 420 core

out vec4 color;

layout (binding = 1) uniform sampler2D tex_color;

uniform bool enable_fog = true;
uniform vec4 fog_color = vec4(0.7, 0.8, 0.9, 0.0);

in TES_OUT {
    vec2 tc;
    vec3 world_coord; 
    vec3 eye_coord;
} fs_in;

vec4 fog(vec4 c) {
    float z = length(fs_in.eye_coord);
    
    float de = 0.025 * smoothstep(0.0, 6.0, 10.0 - fs in.world coord.y);
    float di = 0.045 * smoothstep(0.0, 40.0, 20.0 - fs_in.world_coord.y);

    float extinction = exp(-z * de);
    float inscattering = exp(-z * di);
    
    return c * extinction + fog_color * (1.0 - inscattering);
}

void main(void) {
    vec4 landscape = texture(tex_color, fs_in.tc);
    
    if (enable_fog) {
        color = fog(landscape);
    } else {
        color = landscape;
    }
}

在片段着色器中定义了雾化函数vec4 fog(vec4 c),它根据输入的片段颜色叠加雾化效果计算出模拟雾化效果后的颜色。雾化效果的颜色为元素片段颜色经过消光后的颜色,和被内散射效果扣除后雾自己的颜色的叠加。当观察着的距离越远时,内散色因子接近0,因此我们看到的时雾自己的颜色,所以我们需要在计算时使用1减去内散射因子来计算需要叠加多少雾的颜色。该例子的渲染结果如下图,其中左图未应用场景效果,右图应用了场景效果,在观察右图时,可以明显感觉到片段的景深。源码传送门

3 非真实渲染

通常情况下,计算机图形学渲染目标是尽可能模拟现实世界的场景,然而在一些程序中,或者由于一些艺术原因,我们并不希望渲染出真实世界的效果。例如可能我们想要渲染出铅笔素描效果,或者是完全使用一种抽象的方式。这种绘制方式称为非真实渲染,简称为NPR(Non-Photo-Realistic Rendering)。

3.1 细胞着色-纹素为光

在前面的章节中,大多数的例子使用的都是2维纹理,这是最简单也是最容易理解的。我们能够很好的理解将二维的纹理贴合到一个2D或者3D的几何体之中。一维纹理在电脑游戏中很常用,它们通常被用于渲染卡通类型的场景。卡通着色(Toon Shading),有时也被称为细胞着色(Cell Shading),它使用一维纹理作为有限颜色查询表,使用查询(纹理过滤函数需要设置为GL_NEAREST)到的固定颜色填充几何图形。

卡通着色的基础思想是使用一个逐渐变亮的1维颜色查询表,使用漫射光强度(可以通过在视图空间中的单位视点向量和平面法向量的点积计算得出)作为待查询的纹理坐标,从颜色表中查处对应的颜色。下图演示了一个由4个逐渐变亮的红色组成的一维纹理。

上图纹理的创建和数据加载代码如下。

static const GLubyte toon_tex_data[] = {
        0x44, 0x00, 0x00, 0x00,
        0x88, 0x00, 0x00, 0x00,
        0xCC, 0x00, 0x00, 0x00,
        0xFF, 0x00, 0x00, 0x00
};

glGenTextures(1, &tex_toon);
glBindTexture(GL_TEXTURE_1D, tex_toon);
glTexStorage1D(GL_TEXTURE_1D, 1, GL_RGB8, sizeof(toon_tex_data) / 4); 
glTexSubImage1D(GL_TEXTURE_1D, 0, 
                0, sizeof(toon_tex_data) / 4, 
                GL_RGBA, GL_UNSIGNED_BYTE, 
                toon_tex_data);
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); 
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); 
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);

示例程序toonshading中使用了这部分纹理创建和加载代码,该程序渲染了一个旋转环状几何体,并使用了卡通着色技术。这里的模型文件中包含了顶点的2维纹理坐标,但是我们在顶点着色器中并不需要使用这部分数据,仅仅使用位置和法线数据,顶点着色器代码如下。

#version 420 core 

uniform mat4 mv_matrix;
uniform mat4 proj_matrix;

layout (location = 0) in vec4 position;
layout (location = 1) in vec3 normal;

out VS_OUT {
    vec3 normal;
    vec3 view; 
} vs_out;

void main(void) {
    vec4 pos_vs = mv_matrix * position;
    // Calculate eye-space normal and position
    vs_out.normal = mat3(mv_matrix) * normal; 
    vs_out.view = pos_vs.xyz;

    // Send clip-space position to primitive assembly
    gl_Position = proj_matrix * pos_vs;
}

该着色器计算视图空间中的顶点坐标和法向量,将之传递给片段着色器,在片段着色器中,其代码如下。

#version 420 core

layout (binding = 0) uniform sampler1D tex_toon;

uniform vec3 light_pos = vec3(30.0, 30.0, 100.0);

in VS_OUT {
    vec3 normal;
    vec3 view; 
} fs_in;

out vec4 color;

void main(void) {
    // Calculate per-pixel normal and light vector
    vec3 N = normalize(fs_in.normal);
    vec3 L = normalize(light_pos - fs_in.view);
        
    // Simple N dot L diffuse lighting
    float tc = pow(max(0.0, dot(N, L)), 5.0);
    
    // Sample from cell shading texture
    color = texture(tex_toon, tc) * (tc * 0.8 + 0.2);
}

和之前的例子一样,在片段着色器中计算出漫射光系数,但是这次不直接使用这个系数计算颜色,而是将其当作纹理坐标使用,从而从纹理中查询出片段的颜色。这里对漫射光系数做了指数级的操作,最终查询到的颜色也进行了缩放处理,这样可以使得颜色变化更为剧烈,另外也能够增加一些景深感。

该示例程序的渲染结果如下,由于使用卡通着色的方式,在图中我们能够看到很清晰的条带以及高亮部分。源码传送门

你可能感兴趣的:(OpenGL光照渲染技术)