LearnOpen GL:高级光照

高级光照

Blinn-Phong对比Phnog

计算方法不一样

Phong需要计算反射向量,而Blinn-Phong是计算半程向量(光线方向和观察方向的角平分线方向)

避免高光断层

Phong是根据观察方向与反射方向的夹角余弦值来计算高光亮度,当夹角大于等于90°时,余弦值变为负数,高光值被clamp为0。这就导致在一定范围之外会完全看不到高光。如果物体表面的高光度shininess比较低,高光衰减就得比较慢,到达那个临界点时就会出现明显的断层

而Blinn-Phong是通过比较半程向量和表面法线的夹角来计算高光亮度

而半程向量和表面法线的夹角肯定会小于90°(除非光从后面打上来),所以高光不会消失,从而避免了Phong里的高光断层。

高光更小

因为半程向量和表面法线的夹角一般小于观察方向和反射向量的夹角,高光更加锐利,一般需要将高光度设为Phong的2倍多

Gamma矫正

原因

以前多是阴极射线管显示器(CRT),电压与亮度之间并不是线性关系,而是指数关系,指数约为2.2,这叫gamma值,对应颜色空间叫sRGB颜色空间。因为亮度范围是0.0~1.0,所以除了两个端点外,显示出来的亮度都会沿着指数曲线往下压,也就是下图的实线

图中的点虚线才是计算的亮度数值,所以显示出来的结果会更暗。(但却符合人眼视觉)

为了显示正确,在计算数值的时候就要把亮度调高些,最好是经过指数运算之后刚好达到线性变化。而这个数值就是上图的短划线,也就是提前取1/2.2的幂,然后被显示器取2.2的幂,就会中和为线性了。这就叫做Gamma矫正。

应用

glEnable(GL_FRAMEBUFFER_SRGB)一句话即可开启OpenGL自带的矫正。如果要手动矫正,一定要在最后一步输出颜色时才矫正,也就是在每个片段着色器里

1
2
3
4
5
6
7
void main(){
// do super fancy lighting
[...]
// apply gamma correction
float gamma = 2.2;
fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

这样要改很多片段着色器,很麻烦,可以使用之前的后处理,直接对渲染出来的纹理进行矫正。

对贴图的影响

由于在显示之前应用了gamma矫正,而设计师在调节图片颜色时,是看着屏幕调的,设置的是在sRGB颜色空间下的颜色,所以而这个颜色实际上就是gamma矫正过的,实际数值比看到的更亮。如果直接把图片读进来,就会发现很亮。所以在漫反射贴图上采样得到的颜色需要进行重较,计算出对应的线性空间颜色

1
2
float gamma = 2.2;
vec3 diffuseColor = pow(texture(diffuse, texCoords).rgb, vec3(gamma));

自己处理会非常麻烦,可以在创建纹理时指定内部格式(glTexImage2D第三个参数)为GL_SRGB(如果有alpha值,则是GL_SRGB_ALPHA),之后OpenGL会自动进行转换。注意只有漫反射贴图等需要采样颜色的需要这么设置。(j结果就是比不开gamma矫正的颜色更亮一些)

总之,如果输出前进行了gamma矫正,那就要在读取图片时指定sRGB、没开gamma矫正,就不需要指定sRGB

阴影映射

思路

从光源的视角创建一个深度缓冲,其中保存了光源视角下每个坐标的最小深度,如果像素点的深度比深度贴图里的最小深度大,说明前面有像素点挡住了它,也就是在阴影里。

关于视角:跟摄像机一样创建viewMatprojMat。定向光需要在光的方向上取一点,并使用正交投影。

关于深度缓冲尺寸:摄像机看到的就是屏幕大小,而灯光看到的是光的照射范围,不在深度缓冲中的自然不会被光照射到。

关于shader:顶点着色器执行MVP变换,片段着色器什么都不做

深度贴图

还是跟帧缓冲中的做法一样,把缓冲值保存到一张纹理上,但是这里不需要颜色缓冲,而不包含颜色缓冲的帧缓冲对象是不完整的。所以还需要调用glDrawBufferglReadBuffer把读和绘制缓冲设置为GL_NONE来告诉OpenGL不使用任何颜色数据进行渲染

渲染阴影

顶点着色器计算光空间里的坐标(MVP变换,其中VP都是从光源的视角),并传递给片段着色器。

片段着色器对光空间坐标(裁剪空间坐标)执行透视除法(除以w)得到标准化设备坐标(xyz范围都是[-1,1]),然后通过*0.5+0.5变换到[0,1](这步就是视口变换的一部分,这里只需要对z值变换,具体公式推导过程在这里)。之后对输入的uniform sampler2D的深度贴图进行采样得到对应位置最小深度值,如果大于最小深度值就说明是在影子里。

阴影失真Shadow Acne

因为阴影贴图的分辨率有限,当光源于表面的夹角比较小的时候,可能有多个同一表面上的片段对应到了阴影贴图的同一个坐标。然而阴影贴图里保存的是最小深度值,于是这些片段里只有最小深度对应的那个片段会被认为是被光照射到的,其它片段都被认为是在影子里。所以虽然上都是在被光照射到的表面上,却会出现相间的黑条纹。

解决办法是加一点偏移量,稍微减少每个片段的深度之后再去跟深度缓存比较:

1
2
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005); // 0.005~0.05
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;

或者可以认为这其实是设置了偏差值,偏差小于这个限度的都算是最小深度

阴影悬浮

因为上面使用了偏移量,影子根部会向着远处偏移(好像物体是悬浮的一样),尤其是当偏移值比较大时更明显。一种方法是在渲染深度贴图的时候开启正面剔除(默认是背面),然后不使用偏移量,就算有阴影失真,也只是在内表面,看不到。既然没有使用偏移量,也就不会有阴影悬浮问题了。

远处的阴影

因为深度贴图大小有限,远处深度大于1,需要设置纹理环绕方式和边缘颜色

代码

1
2
3
4
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GLfloat borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

对于超出光空间远平面的深度也大于1,需要在着色器里额外判断,并直接设置不再影子里

PCF

因为深度贴图分辨率有限,多个片段对应到同一个纹理像素,于是影子相同,产生明显的锯齿。可以增加深度贴图的分辨率,而PCFd的思想就是在纹理周围多采样几次并取平均值从而模糊边缘的阴影。

片段着色器

1
2
3
4
5
6
7
8
9
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0); // 单个像素大小 = 1.0 / 纹理的0级mipmap的vec2类型的宽和高
for(int x = -1; x <= 1; ++x) [ // 纹理四周3x3采样,增加采样范围可以得到更柔和的影子
for(int y = -1; y <= 1; ++y){
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0; // 取均值

使用透视投影

定向光适合用正交投影,而点光源和聚光灯更适合使用透视投影。

点阴影

万向阴影贴图

阴影映射只适合定向光,而点光源阴影需要各种方向。前者用的是一张深度贴图,后者需要立方体贴图。

首先需要计算6个方向的光空间变换矩阵(透视投影,fov为90°),但是渲染场景6次d额开销太大,所以改用几何着色器一次性生成。下面是用于生成万象阴影贴图的着色器

顶点着色器进行M变换

1
gl_Position = modelMat * vec4(aPos, 1);

几何着色器输入三角形和uniform的光空间变换矩阵数组,索引数组的同时,通过给内建变量gl_Layer赋值[0,5]即可控制渲染到立方体贴图的哪个面

1
2
3
4
5
6
7
8
9
for(int face = 0; face < 6; ++face){
gl_Layer = face; // built-in variable that specifies to which face we render.
for(int i = 0; i < 3; ++i){ // 三角形三个点
FragPos = gl_in[i].gl_Position;
gl_Position = shadowMatrices[face] * FragPos; // 执行对应面方向的变换
EmitVertex();
}
EndPrimitive();
}

片段着色器需要自行计算深度值(片段和光源间的距离并映射到[0,1])

1
2
3
float lightDistance = length(FragPos.xyz - lightPos);
lightDistance = lightDistance / far_plane;
gl_FragDepth = lightDistance; // 指定深度值

万象阴影

着色器中是通过方向向量对立方体贴图采样,深度值要和距离进行比较

1
2
3
4
5
6
7
float CalcShadow(vec3 fragPos){
vec3 fragToLight = fragPos - lightPos; // 不需要单位化
float closestDepth = texture(depthMap, fragToLight).r * far_plane; // 采样之后要转回距离
float currentDepth = length(fragToLight);
float bias = 0.05;
return shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
}

PCF

在三个维度上多次采样并取均值,然而采样数量过大,很多方向非常接近,可以忽略掉,于是采用硬编码设置偏移数组

偏移数组

1
2
3
4
5
6
7
vec3 sampleOffsetDirections[20] = vec3[](
vec3( 1, 1, 1), vec3( 1, -1, 1), vec3(-1, -1, 1), vec3(-1, 1, 1),
vec3( 1, 1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1), vec3(-1, 1, -1),
vec3( 1, 1, 0), vec3( 1, -1, 0), vec3(-1, -1, 0), vec3(-1, 1, 0),
vec3( 1, 0, 1), vec3(-1, 0, 1), vec3( 1, 0, -1), vec3(-1, 0, -1),
vec3( 0, 1, 1), vec3( 0, -1, 1), vec3( 0, -1, -1), vec3( 0, 1, -1)
);

同时设置远距离的偏移半径更大,把diskRadius乘到偏移值上去

1
float diskRadius = (1.0 + (viewDistance / far_distance)) / 25.0;

采样出来的深度就是:

1
2
float closestDepth = texture(depthMap, fragToLight + sampleOffsetDirections[i] * diskRadius).r;
closestDepth *= far_plane; // Undo mapping [0;1]

法线贴图

高光贴图可以设置不同位置的高光度,而法线贴图就是设置不同片段的法线向量。使用rgb三个值保存法线向量需要做[-1,1]到[0,1]的映射,也就是normal * 0.5 + 0.5。因为主要还是偏向于z轴,对应b值比较大,所以偏向蓝色调。

切线空间

需要注意的是,法线贴图默认表面原本的法线向量是正z的,如原本并不是正z的,那就会导致法线错误,进而导致整个光照错误。

其实是因为法线贴图上的法线向量是在切线空间中定义的,也就是相对于平面而言,垂直平面向外的是正z,而不是世界空间中的正z。所以需要从切线空间变换到世界空间,于是需要知道切线空间的三个坐标轴(切线、副切线、法线)在世界坐标里的表示。

TBN

取法线贴图上的的三个点,知道这三个点的世界坐标和uv坐标,就可以求出转换矩阵。求解过程的关键就是这个uv坐标对应就是切线和副切线方向上的坐标,三个点可以求出两个向量\(E_1\)\(E_2\),并用切线方向向量\(T\)和副切线方向向量\(B\)表示,同时因为知道世界坐标,所以还可以使用世界坐标进行表示,于是可以得到变换矩阵 $$ \[\begin{aligned} \overrightarrow{E_1} &= \Delta U_1 \overrightarrow{T}+\Delta V_1 \overrightarrow{B}\\ \overrightarrow{E_2} &= \Delta U_2 \overrightarrow{T}+\Delta V_2 \overrightarrow{B}\\\\ (E_{1x}, E_{1y}, E_{1z}) &= \Delta U_1 (T_x,T_y,T_z)+\Delta V_1(B_x,B_y,B_z)\\ (E_{2x}, E_{2y}, E_{2z}) &= \Delta U_2 (T_x,T_y,T_z)+\Delta V_2(B_x,B_y,B_z)\\\\ \begin{pmatrix} E_{1x} & E_{1y} & E_{1z}\\ E_{2x} & E_{2y} & E_{2z} \end{pmatrix} &= \begin{pmatrix} \Delta U_1 & \Delta V_1\\ \Delta U_2 & \Delta V_2 \end{pmatrix} \begin{pmatrix} T_x & T_y & T_z\\ B_x & B_y & B_z \end{pmatrix}\\\\ \begin{pmatrix} T_x & T_y & T_z\\ B_x & B_y & B_z \end{pmatrix} &= \frac{1}{\Delta U_1 \Delta V_2 - \Delta U_2 \Delta V_1} \begin{pmatrix} \Delta V_2 & -\Delta V_1\\ -\Delta U_2 & \Delta U_1 \end{pmatrix} \begin{pmatrix} E_{1x} & E_{1y} & E_{1z}\\ E_{2x} & E_{2y} & E_{2z} \end{pmatrix} \end{aligned}\]

$$ 最后一步是直接根据“1除以矩阵的行列式,再乘以伴随矩阵”得到逆矩阵。求出来的转换矩阵叫做TBN矩阵(Tangent切线, BiTangent副切线, Normal法线),其实只需要知道其中两个向量就可以求出另外一个

C++手动计算切线和副切线

1
2
3
4
5
6
7
8
9
10
11
12
13
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);

tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent1 = glm::normalize(tangent1);

bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent1 = glm::normalize(bitangent1);

[...] // 对平面的第二个三角形采用类似步骤计算切线和副切线

之后一种比较直观的方法是直接使用TBN矩阵变换法线,然后正常计算

顶点着色器建立TBN矩阵

1
2
3
4
vec3 T = normalize(vec3(model * vec4(tangent,   0.0)));
vec3 B = normalize(vec3(model * vec4(bitangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
mat3 TBN = mat3(T, B, N);

片段着色器设置法线

1
2
3
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0); // [0,1] -> [-1,1]
normal = normalize(fs_in.TBN * normal);

但是因为每个片段都要计算矩阵乘法,不如在顶点着色器中把光线方向、位置的向量变换到切线空间中(要左乘TBN的逆矩阵,而且因为TBN是正交的,用transpose即可),然后片段着色器在切线空间中进行光照计算。

Assimp载入法线贴图

载入模型时开启计算切线向量

1
2
3
const aiScene* scene = importer.ReadFile(
path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);

计算切线

1
2
3
4
vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;

载入法线贴图

1
2
3
vector<Texture> specularMaps = this->loadMaterialTextures(
material, aiTextureType_HEIGHT, "texture_normal"
);

重正交化

为了看起来更加平滑,会将切线向量平均化,但是这会导致TBN不再正交,需要采用格拉姆-施密特正交化对TBN进行重正交化

顶点着色器

1
2
3
4
5
6
7
8
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
// re-orthogonalize T with respect to N
T = normalize(T - dot(T, N) * N);
// then retrieve perpendicular vector B with the cross product of T and N
vec3 B = cross(T, N);

mat3 TBN = mat3(T, B, N)

视差贴图

视差贴图属于位移贴图(Displacement Mapping)技术的一种,根据贴图上的高度值对顶点进行位移(但实际上是对纹理坐标进行了偏移,顶点并没有动)

片段着色器

1
2
3
4
5
6
7
// 输入原uv和观察方向,输出为偏移之后的uv
// 朝着观察方向进行位移,正比于高度图采样值
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir){
float height = texture(depthMap, texCoords).r; // 对高度图采样
vec2 p = viewDir.xy * height * height_scale; // height_scale用于控制程度
return texCoords - p;
}

HDR

默认亮度和颜色都是限制在[0,1],当亮度超过1却都被限制为了1,于是就无法区分出物体。

首先需要改用浮点帧缓冲GLRGB16FGLRGB32F保存颜色,然后使用色调映射(Tone Mapping)转换回[0,1]。

Reinhard色调映射

1
2
3
4
5
6
7
8
9
10
11
void main(){             
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;

// Reinhard色调映射
vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));

color = vec4(mapped, 1.0);
}

曝光色调映射

1
2
3
4
5
6
7
8
9
10
11
12
uniform float exposure; // 默认1.0,调高可以看清暗处,调低可以看清亮处
void main(){
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;

// 曝光色调映射
vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));

color = vec4(mapped, 1.0);
}

泛光

首先渲染到HDR颜色缓冲,然后提取超出一定亮度阈值的片段到另一个纹理,模糊处理之后添加到原来的HDR场景纹理上。最后的效果取决于模糊过滤器。

两个颜色缓冲

其中提取亮片段到另一个纹理的过程可以添加两个颜色附件,并设置渲染到这两个颜色缓冲中。然后在片段着色器中指定两个颜色缓冲输出,把超过一定亮度的写入提取出的片段。这叫做多渲染目标(Multiple Render Targets, MRT)技术。

片段着色器

1
2
3
4
5
6
7
8
9
10
11
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor; // 提取出的亮色部分

void main(){
[...] // first do normal lighting calculations and output results
FragColor = vec4(lighting, 1.0f);

float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722)); // 通过转换为灰度值来计算亮度
if(brightness > 1.0)
BrightColor = vec4(FragColor.rgb, 1.0);
}

两步高斯模糊

对提取出来的亮区纹理进行高斯模糊。高斯模糊跟之前的模糊一样都是采用一个模糊核取周围的均值。3x3的核需要采样9次,10x10的就要采样1024次!为了加速,改为使用两步高斯模糊,先左右各采样5次,然后上下各采样5次,只需要10次

片段着色器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
uniform sampler2D image;
uniform bool horizontal;
uniform float weight[5] = float[] (0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);

void main(){
vec2 tex_offset = 1.0 / textureSize(image, 0); // 像素大小
vec3 result = texture(image, TexCoords).rgb * weight[0]; // current fragment's contribution
if(horizontal){
for(int i = 1; i < 5; ++i){
result += texture(image, TexCoords + vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
result += texture(image, TexCoords - vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
}
}
else{
for(int i = 1; i < 5; ++i){
result += texture(image, TexCoords + vec2(0.0, tex_offset.y * i)).rgb * weight[i];
result += texture(image, TexCoords - vec2(0.0, tex_offset.y * i)).rgb * weight[i];
}
}
FragColor = vec4(result, 1.0);
}

混合

片段着色器

1
2
3
4
vec3 hdrColor = texture(scene, TexCoords).rgb;      
vec3 bloomColor = texture(bloomBlur, TexCoords).rgb;
hdrColor += bloomColor; // additive blending
// 之后是HDR色调映射和gamma矫正

延迟着色

之前的着色都是正向着色(Forward Shading),也就是对每个光源都要遍历每个片段,但是实际上很多片段颜色会被覆盖掉。

两个过程

延迟着色包含两个处理阶段(Pass):

  • 第一个几何处理阶段(Geometry Pass):先渲染场景一次,之后获取对象的各种几何信息(位置、颜色、法向、高光),并储存在一系列叫做G缓冲(G-buffer)的纹理中。这个过程可以使用MRT技术一次性处理完

  • 第二个光照处理阶段(Lighting Pass):片段着色器从G缓冲而不是顶点着色器获取几何数据,对每个片段计算光照。

好处是只需要对经过了深度测试的片段进行计算,坏处就是不能混色(因为前后多个片段混合)、不能MSAA,而且保存数据需要大量显存。

几何处理

位置和法向量用16位或32位浮点数保存,需要单独一张纹理,而颜色(反照率)的各通道和镜面值只需要8位浮点数,可以共用一张纹理

创建GBuffer和各纹理附件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 帧缓冲对象
GLuint gBuffer;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
GLuint gPosition, gNormal, gColorSpec;

// 位置缓冲
glGenTextures(1, &gPosition);
glBindTexture(GL_TEXTURE_2D, gPosition);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gPosition, 0);

// 法线缓冲
glGenTextures(1, &gNormal);
glBindTexture(GL_TEXTURE_2D, gNormal);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, gNormal, 0);

// 颜色 + 镜面缓冲(镜面值将会保存到alpha分量)
glGenTextures(1, &gAlbedoSpec);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, gAlbedoSpec, 0);

// 因为使用了多渲染目标,需要设置将要使用(帧缓冲的)哪种颜色附件来进行渲染
// 这里0、1、2三个分别对应上面绑定的位置、法线、颜色+镜面
GLuint attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
glDrawBuffers(3, attachments);

/* 添加渲染缓冲对象为深度缓冲,并检查完整性 */

在渲染循环中:

1
2
3
4
5
// 使用GBuffer作为帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
gBufferShader.Use();
/* 遍历对象配置矩阵并渲染 */

其中的gBufferShader通过MRT把数据保存到GBuffer

gBufferShader.vert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;

out vec3 FragPos;
out vec2 TexCoords;
out vec3 Normal;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
vec4 worldPos = model * vec4(position, 1.0f);
FragPos = worldPos.xyz;
gl_Position = projection * view * worldPos;
TexCoords = texCoords;

mat3 normalMatrix = transpose(inverse(mat3(model)));
Normal = normalMatrix * normal;
}

gBufferShader.frag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#version 330 core
layout (location = 0) out vec3 gPosition; // location会作为下标索引上面的attachments数组
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;

in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;

uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;

void main()
{
// 片段位置
gPosition = FragPos;
// 片段法线
gNormal = normalize(Normal);
// 片段颜色
gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb;
// 镜面强度保存到alpha分量
gAlbedoSpec.a = texture(texture_specular1, TexCoords).r;
}

光照处理

在渲染循环中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 几何处理 */

// 使用默认帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT);
lightingPassShader.Use();
// 绑定各纹理单元
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
/* 设置uniform:光照、相机数据 */
// 渲染四边形
RenderQuad();

其中用到的lightingPassShader从纹理中采样得到数据进行计算片段颜色

lightingPassShader

1
2
3
4
5
6
7
8
9
10
11
12
uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedoSpec;

[...]
// 从G缓冲中获取数据
vec3 FragPos = texture(gPosition, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb;
float Specular = texture(gAlbedoSpec, TexCoords).a;

/* 之后的计算和以前一样 */

结合延迟渲染与正向渲染

前面说过,延迟渲染不能做混合等效果,所以延迟渲染之后需要对一部分物体使用正向渲染。

因为先延迟渲染然后正向渲染,而默认帧缓冲里是没有原来的深度信息的,所以直接进行正向渲染会出现深度错误。需要在正向渲染之前把GBuffer的深度信息复制到默认的帧缓冲:

复制深度信息

1
2
3
4
5
6
glBindFramebuffer(GL_READ_FRAMEBUFFER, gBuffer); // 读GBuffer
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); // 写入默认帧缓冲
glBlitFramebuffer( // 复制
0, 0, SCR_WIDTH, SCR_HEIGHT, 0, 0, SCR_WIDTH, SCR_HEIGHT, GL_DEPTH_BUFFER_BIT, GL_NEAREST
);
glBindFramebuffer(GL_FRAMEBUFFER, 0); // 恢复默认帧缓冲

渲染多光源

延迟渲染本身并不能支持非常大量的光源,需要使用光体积(Light Volumes)进行优化。也就是在计算光照之前计算光的距离衰减,提前根据临界值(如5/256)计算处光照半径,如果超过光照半径就不执行光照计算。

然而上面的过程用到了分支判断,但是GPU为了提高并行性,实际上会对所有分支都执行一遍,所以实际并没有跳过光照计算。

解决办法是以每个光源为球心,光照范围为半径渲染一个光球。为防止两次渲染需要开启面剔除,之后为防止球体内不渲染要使用模板缓冲技巧?

其它方法:延迟光照(Deferred Lighting)和切片式延迟着色(Tile-based Deferred Shading)

SSAO

环境光遮蔽(Ambient Occlusion)是指将褶皱、孔洞和非常靠近的墙面变暗,需要使用周围的数据。

屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)的核心是得到屏幕上各像素点处的遮蔽因子:首先采集片段周围(球或半球)的点,如果大多数是被挡住的(没有通过深度测试),则说明当前片段是处于角落里。最后得到的AO系数是通过深度测试的与全部采样点个数的比值。

因为需要的是屏幕上每个片段的信息,正好可以把GBuffer用上,把屏幕空间的深度gl_FragCoord.z也保存到GBuffer。最后把计算出的遮蔽系数乘到环境光上面即可

在view-space计算的详细过程

SSAO的核心是获取屏幕空间上各个像素点的遮蔽因子,这个遮蔽因子来源于像素点周围半球范围内的数据,通过比较周围的深度,得知遮蔽情况。具体过程如下:

保存view-space中的深度:因为OpenGL在投影后会自动执行透视除法,变换到NDC,范围是[-1,1]。之后还会把z分量线性映射到[0,1](近平面对应0),这个值是gl_FragCoord.z。而我们在GBuffer中保存的是view-space中的深度,于是需要转换:首先把gl_FragCoord.z线性映射到[-1,1],也就变换回了NDC(近平面对应-1),然后从NDC一步转换到view-space,详细变换过程可以参考这篇文章,里面算出了NDC和view-space之间的变换(反比例函数)。

读取深度:虽然保存的深度值是view-space中的,但是要从纹理中采样出来,需要一个NDC空间中的uv。这里就需要执行投影、透视除法、线性映射([-1,1]=>[0,1]),然后采样深度图得到深度值。注意深度值在纹理数据的w分量上,xyz保存的是坐标

生成采样点:在C++中生成随机的采样点,使用uniform变量传递到GPU中,作为在view-space中的采样位置。

比较并累加遮蔽值:将采样点的深度值和对应点的最小深度进行比较。注意这里是深度值越大对应越远(因为近平面是0)。因为当两个像素相隔比较远时可以认为不会造成遮蔽,所以还需要添加范围判断,当像素的深度值之差过大时也不算产生遮蔽。

1
occl += sample.z <= minD; oocl /= num; ambient *= occl; // occl应该算是未遮蔽量

平滑处理:遮蔽值对应的是一张灰度图,我们对它执行高斯模糊。最后得到的结果就是当前屏幕上各像素点对应的遮蔽因子,在之后光照阶段给环境光乘上对应的遮蔽因子即可。

保存深度值

统一在观察空间进行计算

vertex

1
2
3
4
5
6
7
8
9
void main(){
vec4 viewPos = view * model * vec4(position, 1.0f);
FragPos = viewPos.xyz; // 观察空间的坐标
gl_Position = projection * viewPos;
TexCoords = texCoords;

mat3 normalMatrix = transpose(inverse(mat3(view * model)));
Normal = normalMatrix * normal; // 观察空间的法线
}

fragment

1
2
3
4
5
6
7
8
9
10
11
12
13
const float NEAR = 0.1;  // 投影矩阵的近平面
const float FAR = 50.0f; // 投影矩阵的远平面
float LinearizeDepth(float depth){ // gl_FragCoord.z是屏幕空间的深度[0, 1]
float z = depth * 2.0 - 1.0; // 回到NDC,[-1, 1]
return (2.0 * NEAR * FAR) / (FAR + NEAR - z * (FAR - NEAR));
}

void main(){
gPositionDepth.xyz = FragPos;
gPositionDepth.a = LinearizeDepth(gl_FragCoord.z);
gNormal = normalize(Normal);
gAlbedoSpec.rgb = vec3(0.95);
}

采样

这里的采样范围是以表面法线为中心的单位半球,为了减少采样又不与期望值偏差太大,需要引入一定的随机量

生成法向半球采样点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::uniform_real_distribution<GLfloat> randomFloats(0.0, 1.0); // 随机浮点数,范围[0.0,1.0)
std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
for (GLuint i = 0; i < 64; ++i)
{
glm::vec3 sample(
randomFloats(generator) * 2.0 - 1.0, // [-1, 1)
randomFloats(generator) * 2.0 - 1.0, // [-1, 1)
randomFloats(generator) // 只是半球,所以z是[0, 1)
);
sample = glm::normalize(sample); // 现在是在一个单位半球(切线空间中)的表面
sample *= randomFloats(generator); // 缩放到半球内部一个随机位置
GLfloat scale = GLfloat(i) / 64.0; // scale in [0, 1), scale^2 in [0, 1)而且更偏向于0
scale = lerp(0.1f, 1.0f, scale * scale); // 在0.1和1间插值,因为比例更偏向于0,所以结果更偏向于0.1
// lerp就是: 0.1 + x * (1 - 0.1)
sample *= scale; // scale更偏向于0.1,从而采样点靠近原点
ssaoKernel.push_back(sample);
}

会有条带?所以要绕着z轴随机转动,生成的随机旋转矩阵保存在一张4x4纹理(GL_REPEAT)上

随机转动量

1
2
3
4
5
6
7
8
9
std::vector<glm::vec3> ssaoNoise;
for (GLuint i = 0; i < 16; i++)
{
glm::vec3 noise(
randomFloats(generator) * 2.0 - 1.0, // [-1, 1)
randomFloats(generator) * 2.0 - 1.0, // [-1, 1)
0.0f); // 绕z轴旋转,z分量不变
ssaoNoise.push_back(noise);
}

然后创建一个帧缓冲对象用来保存遮蔽值的计算结果(一张灰度值纹理)

片段着色器采样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for(int i = 0; i < kernelSize; ++i)
{
// 获取样本位置
vec3 sample = TBN * samples[i]; // 切线->观察空间
sample = fragPos + sample * radius;

// 获取样本深度
vec4 offset = vec4(sample, 1.0);
offset = projection * offset; // 观察->裁剪空间
offset.xyz /= offset.w; // 透视划分
offset.xyz = offset.xyz * 0.5 + 0.5; // 变换到0.0 - 1.0的值域
float sampleDepth = -texture(gPositionDepth, offset.xy).w;

// 累加遮蔽值...
}

为了防止两个并不靠近的表面也产生遮蔽效果,需要使用范围检查,也就是深度差大于一定范围就不算遮蔽

1
2
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;

模糊

因为随机量会导致噪声,还需要进行模糊处理。另外创建一个帧缓冲对象来保存模糊结果,用另外一个着色器对SSAO纹理进行2x2采样并取均值