今天我们来将LearnOpenGL的高级光照部分彻底完结:
Bloom
泛光是一个非常常见的用于改善图像质量的手段,其主要做法就是将某个高亮度区域的亮度向四周发善以实现该区域更亮的视觉效果(因为显示器的亮度范围有限,需要通过泛光来体现出更亮的地方更亮)
明亮的光源和区域经常很难向观察者表达出来,因为显示器的亮度范围是有限的。一种在显示器上区分明亮光源的方式是使它们发出光芒,光芒从光源向四周发散。这有效地给观众一种这些光源或明亮的区域非常亮的错觉。(译注:这个问题的提出简单来说是为了解决这样的问题:例如有一张在阳光下的白纸,白纸在显示器上显示出是出白色,而前方的太阳也是纯白色的,所以基本上白纸和太阳就是一样的了,给太阳加一个光晕,这样太阳看起来似乎就比白纸更亮了)
这种光流,或发光效果,是通过一种叫做泛光(Bloom)的后期处理效果来实现的。泛光使场景中所有明亮的区域都具有类似发光的效果。
光看这些介绍你会联想起之前我们知道的HDR(高动态范围),事实上二者确实有着密不可分的联系。
简单地总结一下就是,我们本身的显示器的亮度范围是0到1的话,HDR允许我们暂时的突破亮度的上限,我们通过HDR(更准确地说是浮点数类型的帧缓冲)将亮度超过阈值的部分提取出来后对其进行泛光处理(本质上就是模糊以及扩散化),最后再执行诸如色调映射和曝光调整的操作即可。
那现在我们先来实现提取超过阈值的亮度这一操作:
vec3 result = ambient + lighting;
// 计算亮度值(使用标准的亮度转换权重)
float brightness = dot(result, vec3(0.2126, 0.7152, 0.0722));
// 如果亮度超过阈值,输出到BrightColor
if(brightness > 1.0)
BrightColor = vec4(result, 1.0);
else
BrightColor = vec4(0.0, 0.0, 0.0, 1.0);
// 输出普通渲染结果
FragColor = vec4(result, 1.0);
这个result是我们最终着色器接收到的光照强度,我们根据亮度转换权重得到亮度之后再判断是否超过阈值,超过的话我们记录下来,否则我们输出黑色。
这里补充一下这个亮度转换权重的概念,我也记不得之前的笔记中有没有提到过了:
现在我们获取了图像中所有亮度超过阈值的部分,接下来我们要进行高斯模糊,但是首先的问题是:为什么是高斯模糊?
这就是为什么在Bloom效果中特别适合使用高斯模糊。它不仅能够产生更真实的光线扩散效果,而且在性能和效果之间取得了很好的平衡。通过调整高斯模糊的参数,我们可以控制Bloom效果的强度和范围,从而创造出各种不同的视觉效果。
uniform float weight[5] = float[] (0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162);
void main()
{
vec2 tex_offset = 1.0 / textureSize(image, 0); // gets size of single texel
vec3 result = texture(image, TexCoords).rgb * weight[0];
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);
}
可以看到首先定义了一组五个权重(归一化处理,总和为一),分别代表当前片元右边、左边、上边、下边以及本身保留的亮度的权重,对于每个片元分别取上下或左右共九个片元(包含自己,其实就是一边各四个)的亮度和自己原来的亮度进行一个混合即可,然后先上下后左右混合两次。
高斯模糊后,我们最后把模糊处理过的超过亮度阈值的部分加到原来的图像中并通过色调映射和曝光处理即可:
uniform sampler2D scene; // 原始场景
uniform sampler2D bloomBlur; // 经过高斯模糊的亮部
uniform bool bloom; // 是否启用泛光效果
uniform float exposure; // 曝光度
...
vec3 hdrColor = texture(scene, TexCoords).rgb; // 获取原始场景颜色
vec3 bloomColor = texture(bloomBlur, TexCoords).rgb; // 获取模糊后的亮部颜色
...
if(bloom)
hdrColor += bloomColor; // 加法混合
...
vec3 result = vec3(1.0) - exp(-hdrColor * exposure);//色调映射
...
result = pow(result, vec3(1.0 / gamma));//gamma校正
效果如图:
Deferred Shading
延迟着色,当然在Unity里写Shader的时候我们也叫他延迟渲染,终究还是到了这一步。
我们现在一直使用的光照方式叫做正向渲染(Forward Rendering)或者正向着色法(Forward Shading),它是我们渲染物体的一种非常直接的方式,在场景中我们根据所有光源照亮一个物体,之后再渲染下一个物体,以此类推。它非常容易理解,也很容易实现,但是同时它对程序性能的影响也很大,因为对于每一个需要渲染的物体,程序都要对每一个光源每一个需要渲染的片段进行迭代,这是非常多的!因为大部分片段着色器的输出都会被之后的输出覆盖,正向渲染还会在场景中因为高深的复杂度(多个物体重合在一个像素上)浪费大量的片段着色器运行时间。
延迟着色法(Deferred Shading),或者说是延迟渲染(Deferred Rendering),为了解决上述问题而诞生了,它大幅度地改变了我们渲染物体的方式。这给我们优化拥有大量光源的场景提供了很多的选择,因为它能够在渲染上百甚至上千光源的同时还能够保持能让人接受的帧率。
比较直白地说,正向渲染就是我们有一个物体就去渲染一个物体,哪怕现在有一千万个光源我们也先把这一千万个光源在这一个物体上的光照效果全部计算完再进行下一个物体的光照效果计算。从我这略显夸张的措辞也可以看出,当光源很多时这个渲染方式效率非常低下,所以针对多光源场景,我们需要换一种思路去渲染。
// 1. 几何处理阶段(Geometry Pass)
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
// 渲染场景到G-Buffer
// 存储位置、法线、颜色等信息
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. 光照处理阶段(Lighting Pass)
// 使用G-Buffer中的信息计算光照
shaderLightingPass.use();
// 绑定G-Buffer纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
我们可以把当前场景中的所有物体中涉及到光照计算的数据先缓冲起来,等到后续需要计算的时候我们再统一将所有缓存起来的数据进行计算,就好比去餐馆里面点菜,我们先把所有客人的需求都收集好再拿给厨师来做菜,而不是先收集一个客人的需求后马上拿给厨师做,省去了大量的中间过程开销。而这个缓冲我们一般叫:G-Buffer。
// 创建G-Buffer帧缓冲
unsigned int gBuffer;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
// 创建三个颜色附件
// 1. 位置信息
glGenTextures(1, &gPosition);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
// 2. 法线信息
glGenTextures(1, &gNormal);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
// 3. 颜色和镜面反射信息
glGenTextures(1, &gAlbedoSpec);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
虽然延迟渲染在针对多光源场景下有着奇效,但是并非完美:事实上,倒不如说延迟渲染有着非常大的局限性,那就是其 单层几何信息存储机制(即G-Buffer仅记录最靠近相机的片元数据),这直接导致其在半透明渲染和深度相关技术上的不足。
好在我们的LearnOpenGL中给出了结合正向渲染和延迟渲染的解决方法:
// 1. 先进行延迟渲染
// ... 延迟渲染代码 ...
// 2. 复制深度缓冲
glBindFramebuffer(GL_READ_FRAMEBUFFER, 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);
// 3. 使用正向渲染渲染特殊效果(如透明物体)
shaderForward.use();
// ... 渲染透明物体 ...
整个执行流程可以理解为:我们先进行延迟渲染,然后我们将G-Buffer的缓冲内容复制到正常渲染流程中涉及的缓冲如深度缓冲(这个其实最重要因为延迟渲染主要缺少的就是深度信息,有了深度信息就能有后续的其他几何信息)、颜色缓冲等,然后针对延迟渲染无法渲染的对象去渲染(如透明对象)。
效果如图:
然后在LearnOpenGL中,我们还学习到了一个新的内容:光体积(Light Volume)。
通常情况下,当我们渲染一个复杂光照场景下的片段着色器时,我们会计算场景中每一个光源的贡献,不管它们离这个片段有多远。很大一部分的光源根本就不会到达这个片段,所以为什么我们还要浪费这么多光照运算呢?
隐藏在光体积背后的想法就是计算光源的半径,或是体积,也就是光能够到达片段的范围。由于大部分光源都使用了某种形式的衰减(Attenuation),我们可以用它来计算光源能够到达的最大路程,或者说是半径。我们接下来只需要对那些在一个或多个光体积内的片段进行繁重的光照运算就行了。这可以给我们省下来很可观的计算量,因为我们现在只在需要的情况下计算光照。
总结来说就是,我们先去计算每一个光源能辐射到的半径,然后延迟渲染时不是要挨个光源计算在G-BUFFER中的几何信息的光照效果吗,我们只去计算在光照半径内的几何信息的光照效果即可。更具体的内容大家可以自行下去了解,主要难点就是如何去计算光源的半径。
SSAO
SSAO(Screen-Space Ambient Occlusion),中文名是屏幕空间环境光遮蔽,是一种对环境光照的模拟。之前的学习中我们都知道环境光是最难被模拟的一种光照,因为所有其他物体无论反射多少次的光照都可以被归类为环境光照,且在现实生活中光照是可以无限弹射的,而我们的计算机的计算能力有限。因此在我们之前学习的光照模型,我们都是非常粗暴地把环境光照直接用一个常数来表示,而现在如果我们想让视觉效果更上一层楼的话,去对环境光照进行更高层次的模拟是一个不错的方向。
在现实中,光线会以任意方向散射,它的强度是会一直改变的,所以间接被照到的那部分场景也应该有变化的强度,而不是一成不变的环境光。其中一种间接光照的模拟叫做环境光遮蔽(Ambient Occlusion),它的原理是通过将褶皱、孔洞和非常靠近的墙面变暗的方法近似模拟出间接光照。这些区域很大程度上是被周围的几何体遮蔽的,光线会很难流失,所以这些地方看起来会更暗一些。站起来看一看你房间的拐角或者是褶皱,是不是这些地方会看起来有一点暗?
环境光遮蔽这一技术会带来很大的性能开销,因为它还需要考虑周围的几何体。我们可以对空间中每一点发射大量光线来确定其遮蔽量,但是这在实时运算中会很快变成大问题。在2007年,Crytek公司发布了一款叫做屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)的技术,并用在了他们的看家作孤岛危机上。这一技术使用了屏幕空间场景的深度而不是真实的几何体数据来确定遮蔽量。这一做法相对于真正的环境光遮蔽不但速度快,而且还能获得很好的效果,使得它成为近似实时环境光遮蔽的标准。
SSAO背后的原理很简单:对于铺屏四边形(Screen-filled Quad)上的每一个片段,我们都会根据周边深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子之后会被用来减少或者抵消片段的环境光照分量。遮蔽因子是通过采集片段周围球型核心(Kernel)的多个深度样本,并和当前片段深度值对比而得到的。高于片段深度值样本的个数就是我们想要的遮蔽因子。
看起来似乎非常复杂,但是其实本质上就是:当我们开启SSAO功能之后,我们的屏幕上会多一层四边形,也就是铺屏四边形,这个四边形会检查每一个在屏幕里的像素:准确地说是以每个像素为球心扩展出一个球,然后我们在这个球内容去采样后检查采样点和像素的深度进行比较,比如这个球内采样了一百个点,六十个的深度值小于该像素深度而四十个大于,那么这个像素的遮蔽因子就是0.4,也就是他只会收获到正常亮度的百分之六十。
最开始的时候确实是一个以像素为球心的球进行采样,但是大家发现这样会有一些问题:
说白了就是以球体来采样的话,一定会采样到本来深度就小于当前像素的区域,那样即使实际上没有几何体遮蔽的部分也会亮度降低,所以采取法向半球来采样会更合理一些。
这里就需要提一下关于随机性的重要性:
现在我们来看具体的实现:
// 1. 创建G-Buffer
unsigned int gBuffer;
glGenFramebuffers(1, &gBuffer);
// 存储位置、法线和颜色信息
// 2. 创建SSAO帧缓冲
unsigned int ssaoFBO, ssaoBlurFBO;
glGenFramebuffers(1, &ssaoFBO);
glGenFramebuffers(1, &ssaoBlurFBO);
SSAO可以基于延迟渲染实现。
std::vector<glm::vec3> ssaoKernel;
for (unsigned int i = 0; i < 64; ++i)
{
glm::vec3 sample(randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator));
sample = glm::normalize(sample);
sample *= randomFloats(generator);
float scale = float(i) / 64.0f;
scale = ourLerp(0.1f, 1.0f, scale * scale);
sample *= scale;
ssaoKernel.push_back(sample);
}
根据像素位置和法线方向生成法线半球。
std::vector<glm::vec3> ssaoNoise;
for (unsigned int i = 0; i < 16; i++)
{
glm::vec3 noise(randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
0.0f);
ssaoNoise.push_back(noise);
}
生成噪声纹理以引入随机性,从而达到消除SSAO中固定采样模式的问题。
void main()
{
// 获取输入数据
vec3 fragPos = texture(gPosition, TexCoords).xyz;
vec3 normal = normalize(texture(gNormal, TexCoords).rgb);
vec3 randomVec = normalize(texture(texNoise, TexCoords * noiseScale).xyz);
// 创建TBN矩阵
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
// 计算遮蔽
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{
// 获取采样位置
vec3 samplePos = TBN * samples[i];
samplePos = fragPos + samplePos * radius;
// 投影采样位置
vec4 offset = vec4(samplePos, 1.0);
offset = projection * offset;
offset.xyz /= offset.w;
offset.xyz = offset.xyz * 0.5 + 0.5;
// 获取采样深度
float sampleDepth = texture(gPosition, offset.xy).z;
// 范围检查和累积
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
}
// 计算最终遮蔽值
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;
}
整个SSAO计算流程,效果如下:
可以看到环境光的效果不再像之前一样哪里都是一样亮了,更加真实。