教程地址:简介 - LearnOpenGL CN
IBL——漫反射辐照度
引言
基于图像的光照(Image based lighting, IBL) 是一类光照技术的集合。其光源不是如前一节教程中描述的那种可分解的直接光源,而是将周围环境整体视为一个大光源。IBL 通常使用(取自现实世界或从3D场景生成的)环境立方体贴图 (Cubemap) ,我们可以将立方体贴图的每个像素视为光源,在渲染方程中直接使用它。这种方式可以有效地捕捉环境的全局光照和氛围,使物体更好地融入其环境。
由于基于图像的光照算法会捕捉部分甚至全部的环境光照,通常认为它是一种更精确的环境光照输入形式,甚至也可以说是一种对全局光照的粗略近似。基于此特性,IBL 对 PBR 很有意义,因为当我们将环境光纳入计算之后,物体在物理方面看起来会更加准确。
为了开始将 IBL 引入我们的 PBR 系统,让我们再次快速回顾一下反射方程:
L o ( p , ω o ) = ∫ Ω ( k d c π + D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) ) L i ( p , ω i ) n ⋅ ω i d ω i L_o(p,\omega_o)=\intop_\Omega(k_d\frac{c}{\pi}+\frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)})L_i(p,\omega_i)n\cdot\omega_id\omega_i Lo(p,ωo)=Ω∫(kdπc+4(ωo⋅n)(ωi⋅n)DFG)Li(p,ωi)n⋅ωidωi
如之前所述,我们的主要目标是求解半球 Ω \Omega Ω 上所有入射光 ω i \omega_i ωi 的积分。在上一章中求解积分很简单,因为我们事先已经知道了对积分有贡献的几个 ω i \omega_i ωi 的确切光方向。然而这一次,周围环境的每个入射光方向 ω i \omega_i ωi 都可能具有一定的辐射率,这使得求解积分不再那么简单。这为解决积分提出了两个要求:
- 我们需要某种方法,根据任意方向向量 ω i \omega_i ωi 获取场景的辐射率。
- 求解积分需要快速且实时。
第一个需求相对容易实现,我们已经有了一些思路:表示环境或场景辐照度的一种方式是(预处理过的)环境立方体贴图,给定这样的立方体贴图,我们可以将立方体贴图的每个纹素视为一个光源。使用一个方向向量 ω i \omega_i ωi 对此立方体贴图进行采样,我们就可以获取该方向上的场景辐射率。
如此,给定方向向量 ω i \omega_i ωi ,获取此方向上场景辐射率的方法就简化为:
vec3 radiance = texture(_cubemapEnvironment, wi).rgb;
尽管如此,求解积分要求我们不仅从一个方向采样环境贴图,而是从半球 Ω \Omega Ω 上所有可能的方向 ω i \omega_i ωi 采样,这对于每个片段着色器调用来说代价太高。为了更高效地求解积分,我们希望预处理或预计算大部分计算。为此,我们需要更深入地研究反射方程:
L o ( p , ω o ) = ∫ Ω ( k d c π + D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) ) L i ( p , ω i ) n ⋅ ω i d ω i L_o(p,\omega_o)=\intop_\Omega(k_d\frac{c}{\pi}+\frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)})L_i(p,\omega_i)n\cdot\omega_id\omega_i Lo(p,ωo)=Ω∫(kdπc+4(ωo⋅n)(ωi⋅n)DFG)Li(p,ωi)n⋅ωidωi
仔细研究反射方程,我们发现 BRDF 的漫反射 k d k_d kd 和镜面 k s k_s ks 项是相互独立的,我们可以将积分分成两部分:
L o ( p , ω o ) = ∫ Ω ( k d c π ) L i ( p , ω i ) n ⋅ ω i d ω i + ∫ Ω ( D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) ) L i ( p , ω i ) n ⋅ ω i d ω i L_{o}(p,\omega_{o}) = \int_{\Omega} \left(k_{d} \frac{c}{\pi}\right) L_{i}(p,\omega_{i}) n \cdot \omega_{i} d\omega_{i} + \int_{\Omega} \left(\frac{DFG}{4(\omega_{o} \cdot n)(\omega_{i} \cdot n)}\right) L_{i}(p,\omega_{i}) n \cdot \omega_{i} d\omega_{i} Lo(p,ωo)=∫Ω(kdπc)Li(p,ωi)n⋅ωidωi+∫Ω(4(ωo⋅n)(ωi⋅n)DFG)Li(p,ωi)n⋅ωidωi
通过将积分分成两部分,我们可以分开研究漫反射和镜面反射部分,本章的重点是漫反射积分部分。
仔细观察漫反射积分,我们发现漫反射兰伯特项是一个常数项(颜色 c c c 、折射率 k d k_d kd 和 π \pi π 在整个积分是常数),不依赖于任何积分变量。基于此,我们可以将常数项移出漫反射积分:
L o ( p , ω o ) = k d c π ∫ Ω L i ( p , ω i ) n ⋅ ω i d ω i L_{o}(p,\omega_{o}) = k_{d} \frac{c}{\pi} \int_{\Omega} L_{i}(p,\omega_{i}) n \cdot \omega_{i} d\omega_{i} Lo(p,ωo)=kdπc∫ΩLi(p,ωi)n⋅ωidωi
这给了我们一个只依赖于 ω i \omega_i ωi 的积分(假设 p p p 位于环境贴图的中心)。有了这些知识,我们就可以计算或预计算一个新的立方体贴图,它在每个采样方向——也就是纹素——中存储漫反射积分的结果,这些结果是通过卷积计算出来的。
对于旧的环境贴图,我们根据采样方向向量 ω o \omega_o ωo ,可以得到该 ω o \omega_o ωo 向量对应到环境贴图的辐射率是多少。而对于新的环境贴图,我们根据采样方向向量 ω o \omega_o ωo ,可以得到以该 ω o \omega_o ωo 向量为法线的半球 Ω \Omega Ω 内的所有方向向量在旧的环境贴图采样得到的辐射率的总和,也就是漫反射积分结果。
为了对环境贴图进行卷积,我们通过离散地采样半球 Ω \Omega Ω 上的大量方向 ω i \omega_i ωi,并对它们的辐射率取平均值,来为每个输出采样方向 ω o \omega_o ωo 求解积分。所有采样方向 ω i \omega_i ωi 形成的半球是以我们正在卷积的输出采样方向 ω o \omega_o ωo 为法线的(准确来说 ω o \omega_o ωo 是半球对应的那个底面的法线)。
这个预计算的立方体贴图,为每个采样方向 ω o \omega_o ωo 上存储其积分结果,可以理解为场景中所有能够击中以 ω o \omega_o ωo 为法线的表面的间接漫反射光的预计算总和。这样的立方体贴图被称为辐照度贴图(Irradiance Map),因为经过卷积计算的立方体贴图能让我们从任何方向有效地直接采样场景(预计算好的)的辐照度。
辐射方程也依赖了位置 p p p ,不过这里我们假设它位于辐照度贴图的中心。这就意味着所有漫反射间接光只能来自同一个环境贴图,这样可能会破坏现实感(特别是在室内)。渲染引擎通过在场景中放置多个反射探针来解决此问题,每个反射探针单独预计算其周围环境的辐照度贴图。这样,位置 p p p 处的辐照度(以及辐射率)是取离其最近的反射探针之间的辐照度(辐射率)的插值。目前,我们假设总是从中心采样环境贴图,把反射探针的讨论留给后面的教程。
以下是一个立方体环境贴图及其生成的辐照度贴图示例(由 Wave 引擎提供)
由于立方体贴图每个纹素中存储了( ω o \omega_o ωo 方向的)卷积结果,辐照度贴图展示的像是环境的平均颜色或光照表现。使用任何一个向量对立方体贴图进行采样,就可以获取该方向上的场景辐照度。
PBR 和 HDR
我们在上一章中简单提到过:在 PBR 管线中考虑场景光照的高动态范围(HDR)非常重要。由于 PBR 的大多数输入基于真实的物理属性和测量值,将入射光值与其物理等效值密切匹配是非常重要的。无论我们是对每个光源的辐射通量进行合理猜测,还是直接使用它们的物理等效值,一个简单的灯泡与太阳之间的差异无论如何都是显著的。如果不工作在 HDR 渲染环境中,就无法正确指定每个光源的相对强度。
因此,PBR 和 HDR 需要密切合作,但这些与 IBL 有什么关系?我们在之前的教程中已经看到,让 PBR 在 HDR 下工作还比较容易。然而,对于 IBL,我们基于环境立方体贴图的颜色值来确定环境的间接光强度,因此我们需要某种方法将光照的高动态范围存储到环境贴图中。
到目前为止,我们使用的环境贴图(例如用作天空盒的立方体贴图)是低动态范围(LDR)的。我们直接使用各个面的图像的颜色值,其范围介于 0.0 和 1.0 之间,计算过程也是照值处理。虽然这对于视觉输出可能没问题,但当将其作为物理输入参数时,就行不通了。
辐射率的 HDR 文件格式
这时就引入了辐射率(radiance)文件格式,辐射率文件格式(扩展名为 .hdr)以浮点数据的形式存储完整的立方体贴图,包括所有 6 个面。这允许我们指定超出 0.0 到 1.0 范围的颜色值,以赋予光源正确的颜色强度。该文件格式使用了一个巧妙的技巧来存储每个浮点值:不是每个通道使用 32 位,而是每个通道使用8位,并将颜色的 alpha 通道作为指数(这确实会带来精度的损失)。这种方式效果很好,但要求解析程序将每个颜色重新转换为其浮点等效值。
sIBL 档案 中有很多可以免费获取的辐射率 HDR 环境贴图,下面是一个示例:
可能与您期望的完全不同,因为图像非常扭曲,并且没有我们之前看到的环境贴图的六个立方体贴图面。这个环境贴图是从球体投影到平面上,以便我们更容易将环境存储为单一图像,这种图像被称为等距柱状投影贴图(Equirectangular Map)。水平视角附近分辨率较高,而底部和顶部方向分辨率较低,在大多数情况下,这是一个不错的折衷方案,因为对于几乎所有渲染器来说,大部分有意义的光照和环境信息都在水平视角附近方向。
HDR 和 stb_image.h
直接加载辐射率 HDR 图像需要一些文件格式的知识,这并不太难,但仍然有些繁琐。幸运的是,一个常用的头文件库 stb_image.h 支持将辐射率 HDR 图像直接加载为一个浮点数数组,完全符合我们的需要。将 stb_image 添加到项目中之后,加载HDR图像非常简单,如下:
#include "stb_image.h"
[...]
stbi_set_flip_vertically_on_load(true);
int width, height, nrComponents;
float *data = stbi_loadf("newport_loft.hdr", &width, &height, &nrComponents, 0);
unsigned int hdrTexture;
if (data)
{
glGenTextures(1, &hdrTexture);
glBindTexture(GL_TEXTURE_2D, hdrTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, width, height, 0, GL_RGB, GL_FLOAT, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
stbi_image_free(data);
}
else
{
std::cout << "Failed to load HDR image." << std::endl;
}
stb_image.h 自动将 HDR 值映射到一个浮点数列表:默认情况下,每个通道32位,每个颜色 3 个通道。这就是我们要将等距柱状投影 HDR 环境贴图转存到 2D 浮点纹理中所要做的全部工作。
从等距柱状投影贴图到立方体贴图
我们可以直接使用等距柱状投影贴图获取环境信息,但这些操作可能相对昂贵,在这种情况下,直接采样立方体贴图的性能更好。因此,在本章中,我们首先将等距柱状投影贴图转换为立方体贴图以备进一步处理。请注意,在此过程中,我们还会展示如何将等距柱状投影贴图当作3D环境贴图进行采样,你可以自由选择你喜欢的解决方案。
要将等距柱状投影贴图转换为立方体贴图,我们需要渲染一个(单位)立方体,并从内部将等距柱状图投影到立方体的每个面,并将立方体的六个面的图像构造成立方体贴图。此立方体的顶点着色器只是简单地渲染立方体,并将其局部坐标作为 3D 采样向量传递给片段着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
out vec3 localPos;
uniform mat4 projection;
uniform mat4 view;
void main()
{
localPos = aPos;
gl_Position = projection * view * vec4(localPos, 1.0);
}
而在片段着色器中,我们为立方体的每个部分着色,方法类似于将等距柱状投影贴图整齐地折叠到立方体的每个面一样。为了实现这一点,我们先获取片段的采样方向,这个方向是利用立方体的局部坐标进行插值得到的,然后使用这个方向向量和一些三角函数魔法(球面到笛卡尔坐标)来采样等距柱状投影贴图,就好像它本身是一个立方体贴图。我们直接将结果存储到立方体每个面的片段中,以下就是我们需要做的:
#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform sampler2D equirectangularMap;
const vec2 invAtan = vec2(0.1591, 0.3183);
vec2 SampleSphericalMap(vec3 v)
{
vec2 uv = vec2(atan(v.z, v.x), asin(v.y));
uv *= invAtan;
uv += 0.5;
return uv;
}
void main()
{
vec2 uv = SampleSphericalMap(normalize(localPos)); // 确保归一化 localPos
vec3 color = texture(equirectangularMap, uv).rgb;
FragColor = vec4(color, 1.0);
}
这里利用采样立方体贴图所需的方向向量去采样等距柱状投影贴图,唯一要做的就是将采样立方体贴图的方向向量转化为采样等距柱状投影贴图所需的 uv 坐标。这个转化过程我们需要两步:
- 将立方体坐标映射到球面坐标
- 将三维球面坐标映射到二维平面 uv 坐标
第一步很简单,我们只需归一化 localPos
即可(确保方向向量长度为 1)。
对于第二步,我们需要明白:等距柱状投影是一种将三维球面坐标映射到二维平面的简单方法,其核心思想是将球面的经度和纬度直接线性映射到平面的横纵坐标 。以下是详细的映射过程:
假设球面上的点由三维方向向量 v = ( x , y , z ) v = (x, y, z) v=(x,y,z) 表示,且向量已归一化(单位长度)。将其转换为球面坐标:
- 经度(
ϕ
\phi
ϕ) :在XZ平面上的投影与Z轴的夹角,范围为
[
−
π
,
π
]
[-\pi, \pi]
[−π,π]。
ϕ = a r c t a n ( v x v z ) \phi=arctan(\frac{v_x}{v_z}) ϕ=arctan(vzvx) - 纬度(
θ
\theta
θ) :向量与XY平面的夹角(或与Y轴的夹角),范围为
[
−
π
/
2
,
π
/
2
]
[-\pi/2, \pi/2]
[−π/2,π/2]。
θ = a r c s i n ( v y ) \theta=arcsin(v_y) θ=arcsin(vy)
将球面坐标 ( ϕ , θ ) (\phi, \theta) (ϕ,θ) 线性映射到二维纹理坐标 ( u , v ) (u, v) (u,v),范围 [ 0 , 1 ] [0, 1] [0,1]:
- 经度(
ϕ
\phi
ϕ)映射到U轴:
u = ϕ 2 π + 0.5 u=\frac{\phi}{2\pi}+0.5 u=2πϕ+0.5- 将 ϕ ∈ [ − π , π ] \phi \in [-\pi, \pi] ϕ∈[−π,π] 映射到 u ∈ [ 0 , 1 ] u \in [0, 1] u∈[0,1],对应纹理坐标的水平方向。
- 纬度(
θ
\theta
θ)映射到 V 轴:
v = θ π + 0.5 v=\frac{\theta}{\pi}+0.5 v=πθ+0.5- 将 θ ∈ [ − π / 2 , π / 2 ] \theta \in [-\pi/2, \pi/2] θ∈[−π/2,π/2] 映射到 v ∈ [ 0 , 1 ] v \in [0, 1] v∈[0,1],对应纹理坐标的垂直方向。
综合上述步骤,球面到平面的映射公式为:
{ u = arctan ( v x , v y ) 2 π + 0.5 v = arcsin ( v y ) π + 0.5 \begin{equation} \begin{cases} u = \frac{\arctan(v_x, v_y)}{2\pi} + 0.5 \\ v = \frac{\arcsin(v_y)}{\pi} + 0.5 \end{cases} \end{equation} {u=2πarctan(vx,vy)+0.5v=πarcsin(vy)+0.5
对应到代码中,SampleSphericalMap
完成的就是第二步操作,invAtan
存储的是
1
2
π
\frac{1}{2\pi}
2π1 和
1
π
\frac{1}{\pi}
π1 的近似值。
如果给定 HDR 等距柱状投影贴图并在场景的中心渲染一个立方体,将得到如下所示的内容:
这表明我们有效地将等距柱状投影贴图映射到了立方体,但我们还需要将源HDR图像转换为立方体贴图纹理。为了实现这一点,我们必须对同一个立方体渲染六次,每次面对立方体的一个面,并用帧缓冲对象记录其结果:
unsigned int captureFBO, captureRBO;
glGenFramebuffers(1, &captureFBO);
glGenRenderbuffers(1, &captureRBO);
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, captureRBO);
当然,我们还需要生成对应的立方体贴图颜色纹理,为其6个面预分配内存:
unsigned int envCubemap;
glGenTextures(1, &envCubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
for (unsigned int i = 0; i < 6; ++i)
{
// 注意我们为每个面使用16位浮点值存储
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F,
512, 512, 0, GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
接下来要做的是将等距柱状 2D 纹理捕获到立方体贴图的面上。
这里不会详细说明代码细节,因为这些内容在帧缓冲和点阴影教程中已经讨论过了。实际过程可以概括为:面向立方体六个面设置六个不同的视图矩阵,设置投影矩阵的 fov 为 90 度以捕捉整个面,并渲染立方体六次,将结果存储在浮点帧缓冲中:
glm::mat4 captureProjection = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 10.0f);
glm::mat4 captureViews[] =
{
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f))
};
// 将HDR等距柱状投影贴图转换为立方体贴图等效形式
equirectangularToCubemapShader.use();
equirectangularToCubemapShader.setInt("equirectangularMap", 0);
equirectangularToCubemapShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTexture);
glViewport(0, 0, 512, 512); // 不要忘记将视口配置为捕获尺寸
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for (unsigned int i = 0; i < 6; ++i)
{
equirectangularToCubemapShader.setMat4("view", captureViews[i]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, envCubemap, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
renderCube(); // 渲染一个1x1的立方体
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
我们获取帧缓冲的颜色附件,并为立方体贴图的每个面切换其纹理目标,直接将场景渲染到立方体贴图的一个面上。一旦这个过程完成(我们只需执行一次),立方体贴图 envCubemap
应该就是我们原始HDR图像的立方体贴图版本。
让我们通过编写一个非常简单的天空盒着色器来测试立方体贴图,将其显示在我们周围:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 projection;
uniform mat4 view;
out vec3 localPos;
void main()
{
localPos = aPos;
mat4 rotView = mat4(mat3(view)); // 从视图矩阵中移除平移
vec4 clipPos = projection * rotView * vec4(localPos, 1.0);
gl_Position = clipPos.xyww;
}
注意这里的 xyww
技巧,它确保渲染的立方体片段的深度值始终为1.0,即最大深度值,如立方体贴图章节中所述。请注意,我们需要将深度比较函数更改为 GL_LEQUAL
:
glDepthFunc(GL_LEQUAL);
片段着色器使用立方体的局部片段位置直接采样立方体环境贴图:
#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform samplerCube environmentMap;
void main()
{
vec3 envColor = texture(environmentMap, localPos).rgb;
envColor = envColor / (envColor + vec3(1.0));
envColor = pow(envColor, vec3(1.0/2.2));
FragColor = vec4(envColor, 1.0);
}
我们使用插值的立方体顶点坐标对环境贴图进行采样,这些坐标直接对应于正确的采样方向向量。注意,相机的平移分量被忽略掉了,使用这个着色器渲染一个立方体应该会给你一个不能动的环境贴图背景。另外还请注意,由于我们将环境贴图的 HDR 值直接输出到默认 LDR 帧缓冲,我们需要正确地对颜色值进行色调映射。此外,默认情况下,几乎所有的 HDR 贴图默认都在线性颜色空间中,因此我们需要在写入默认帧缓冲之前应用伽马校正。
现在,在之前渲染的球体上渲染环境贴图,效果应该如下图:
嗯……我们花了不少功夫才走到这一步,但我们成功地读取了一个 HDR 环境贴图,将其从等距柱状投影贴图转换为立方体贴图,并将 HDR 立方体贴图作为天空盒渲染到场景中。此外,我们建立了一个小系统来渲染立方体贴图的所有六个面,我们在计算环境贴图卷积时还会需要它。您可以在此处找到整个转化过程的源代码。
本次项目源码:HDR贴图转换 - GitCode
立方体贴图的卷积
如本章开头所述,我们的主要目标是计算所有间接漫反射光的积分,其中光照的辐照度以环境立方体贴图的形式给出。我们知道,通过在方向 ω i \omega_i ωi 上采样 HDR 环境贴图,可以获得场景在此方向上的辐射率 L ( p , ω i ) L(p, \omega_i) L(p,ωi)。为了求解积分,我们必须为每个片段采样半球 Ω \Omega Ω 内所有可能方向上的场景辐射率。
然而,计算上又不可能从 Ω \Omega Ω 的每个可能的方向采样环境光照,理论上可能的方向数量是无限的。不过我们可以对有限数量的方向采样以近似求解,在半球内均匀间隔或随机取方向可以获得一个相当精确的辐照度近似值;这实际上是离散地求解积分 ∫ \int ∫。
然而,对于每个片段实时执行此操作仍然太昂贵,因为仍然需要非常大的样本数量才能获得不错的结果,因此我们希望预计算这一点。既然半球的朝向决定了我们捕捉辐照度的位置,我们可以预先计算每个可能的半球朝向的辐照度,这些半球朝向涵盖了所有可能的出射方向 ω o \omega_o ωo :
L o ( p , ω o ) = k d c π ∫ Ω L i ( p , ω i ) n ⋅ ω i d ω i L_{o}(p,\omega_{o}) = k_{d} \frac{c}{\pi} \int_{\Omega} L_{i}(p,\omega_{i}) n \cdot \omega_{i} d\omega_{i} Lo(p,ωo)=kdπc∫ΩLi(p,ωi)n⋅ωidωi
在光照阶段中,给定任意方向向量 ω i \omega_i ωi,我们可以通过采样预计算的辐照度贴图来检索来自方向 ω i \omega_i ωi 的总漫反射辐照度。为了确定片段上间接漫反射光的数量(辐照度),我们获取以表面法线为中心的半球的总辐照度。获取场景辐照度的方法就简化为:
vec3 irradiance = texture(irradianceMap, N).rgb;
现在,为了生成辐照度贴图,我们需要对已转换为立方体贴图的环境光照进行卷积。考虑到对于每个片段,表面的半球是沿着法线向量 N N N 对齐的,卷积立方体贴图等同于计算沿 N N N 对齐的半球 Ω \Omega Ω 中每个方向 ω i \omega_i ωi 的总平均辐射率。
值得庆幸的是,本节教程中所有繁琐的设置并非毫无用处,因为我们现在可以直接获取转换后的立方体贴图,在片段着色器中对其进行卷积,渲染所有六个面,并将其结果用帧缓冲捕捉到新的立方体贴图中。之前已经将等距柱状投影贴图转换为立方体贴图,这次我们可以采用完全相同的方法,但使用不同的片段着色器:
#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform samplerCube environmentMap;
const float PI = 3.14159265359;
void main()
{
// 采样方向等于半球的朝向
vec3 normal = normalize(localPos);
vec3 irradiance = vec3(0.0);
[...] // 卷积代码
FragColor = vec4(irradiance, 1.0);
}
environmentMap
是从等距柱状投影贴图转换而来的 HDR 立方体贴图。
有很多方法可以对环境贴图进行卷积,但是对于本教程,我们的方法是:对于立方体贴图的每个纹素,在纹素所代表的方向的半球 Ω \Omega Ω 内生成固定数量的采样向量,并对采样结果取平均值。数量固定的采样向量将均匀地分布在半球内部。注意,积分是连续函数,在采样向量数量固定的情况下,离散地采样只是一种近似计算方法,我们采样的向量越多,就越接近正确的结果。
反射方程的积分 ∫ \int ∫ 围绕立体角 d ω \mathrm{d}\omega dω 展开,这相当难以处理。我们不直接对立体角 d ω \mathrm{d}\omega dω 进行积分,而是对其等效的球坐标 θ \theta θ 和 ϕ \phi ϕ 进行积分。
d ω = s i n ( θ ) d ϕ d θ \mathrm{d}\omega=sin{(\theta)}\mathrm{d}\phi\mathrm{d}\theta dω=sin(θ)dϕdθ,推导可参考:立体角 (Solid Angle) - 知乎
我们使用极方位角 ϕ \phi ϕ(范围在 0 0 0 到 2 π 2\pi 2π 之间)对半球环进行环绕采样,并使用天顶角 θ \theta θ(范围在 0 0 0 到 π / 2 \pi/2 π/2 之间)对半球上逐渐增大的同心环进行采样。这种采样方式可以推导出更新后的反射积分公式:
L o ( p , ϕ o , θ o ) = k d c π ∫ ϕ = 0 2 π ∫ θ = 0 1 2 π L i ( p , ϕ i , θ i ) cos ( θ ) sin ( θ ) d ϕ d θ L_{o}(p,\phi_{o},\theta_{o}) = k_{d} \frac{c}{\pi} \int_{\phi = 0}^{2\pi} \int_{\theta = 0}^{\frac{1}{2}\pi} L_{i}(p,\phi_{i},\theta_{i}) \cos(\theta) \sin(\theta) d\phi d\theta Lo(p,ϕo,θo)=kdπc∫ϕ=02π∫θ=021πLi(p,ϕi,θi)cos(θ)sin(θ)dϕdθ
求解积分需要我们在半球 Ω \Omega Ω 内采集固定数量的离散样本并对其结果求平均值。分别给每个球坐标轴指定离散样本数量 n 1 n1 n1 和 n 2 n2 n2 以求其黎曼和,积分式会转换为以下离散版本:
L o ( p , ϕ o , θ o ) = k d c π n 1 ⋅ n 2 ∑ m = 0 n 1 ∑ n = 0 n 2 L i ( p , ϕ m , θ n ) cos ( θ n ) sin ( θ n ) L_{o}(p,\phi_{o},\theta_{o}) = k_{d} \frac{c\pi}{n1 \cdot n2} \sum_{m = 0}^{n1} \sum_{n = 0}^{n2} L_{i}(p,\phi_{m},\theta_{n}) \cos(\theta_n) \sin(\theta_n) Lo(p,ϕo,θo)=kdn1⋅n2cπm=0∑n1n=0∑n2Li(p,ϕm,θn)cos(θn)sin(θn)
译文与原文中给出的离散版本积分公式有误,下面是来自讨论区 He Davy 的纠正(并非笔主推导:原链接):
当我们离散地对两个球坐标轴进行采样时,每个采样近似代表了半球上的一小块区域,如上图所示。(后面的话省略,理解 d ω d\omega dω 如何用 d θ d\theta dθ 与 d ϕ d\phi dϕ 表示即可)
给定每个片段的积分球坐标,对半球进行离散采样,过程代码如下:
vec3 irradiance = vec3(0.0);
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 right = normalize(cross(up, normal));
up = normalize(cross(normal, right));
float sampleDelta = 0.025;
float nrSamples = 0.0;
for(float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta)
{
for(float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta)
{
// 球坐标到笛卡尔坐标(在切线空间中)
vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
// 切线空间到世界空间
vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * normal;
irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
nrSamples++;
}
}
irradiance = PI * irradiance * (1.0 / float(nrSamples));
球坐标到笛卡尔坐标的转换公式如下:
x = sin ( θ ) cos ( ϕ ) y = sin ( θ ) sin ( ϕ ) z = cos ( θ ) \begin{align} x &= \sin(\theta) \cos(\phi) \\ y &= \sin(\theta) \sin(\phi) \\ z &= \cos(\theta) \end{align} xyz=sin(θ)cos(ϕ)=sin(θ)sin(ϕ)=cos(θ)
这里不需要与纹理 UV 对齐,所以任意选一个切线向量构造 TBN 矩阵即可,代码中用 right、up 名字替换了切线、副切线,本质上代表了这里只进行坐标空间的转换。
s a m p l e V e c = [ r x u x n x r y u y n y r z u z n z ] ∗ t a n g e n t S a m p l e sampleVec = \begin{bmatrix} r_x & u_x & n_x \\ r_y & u_y & n_y \\ r_z & u_z & n_z \end{bmatrix} * tangentSample sampleVec= rxryrzuxuyuznxnynz ∗tangentSample
我们以一个固定的 sampleDelta
增量值遍历半球,减小(或增加)这个增量将会增加(或减少)精确度。
在两个循环内,我们获取一个球面坐标并将它们转换为 3D 笛卡尔坐标向量,将向量从切线空间转换为世界空间,并使用此向量直接采样 HDR 环境贴图。我们将每个采样结果加到 irradiance
,最后除以采样的总数并乘上
π
\pi
π,得到积分后的辐照度。注意别忘了对采样的颜色值乘以系数
cos
(
θ
)
\cos(\theta)
cos(θ) 与
sin
(
θ
)
\sin(\theta)
sin(θ)。
现在剩下要做的就是设置 OpenGL 渲染代码,以便我们可以对之前捕捉的 envCubemap
求卷积。首先我们创建一个辐照度立方体贴图(重复一遍,我们只需要在渲染循环之前执行一次):
unsigned int irradianceMap;
glGenTextures(1, &irradianceMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, irradianceMap);
for (unsigned int i = 0; i < 6; ++i)
{
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 32, 32, 0,
GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
由于辐照度贴图对周围所有的辐射率取了平均值,因此它丢失了大部分高频细节,所以我们可以以较低的分辨率(32x32)存储,并让 OpenGL 的线性过滤完成大部分工作。接下来,我们将捕捉到的帧缓冲图像缩放到新的分辨率:
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 32, 32);
我们使用卷积着色器——和捕捉环境立方体贴图类似的方式——来对环境贴图求卷积:
irradianceShader.use();
irradianceShader.setInt("environmentMap", 0);
irradianceShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glViewport(0, 0, 32, 32); // 不要忘记将视口配置为捕获尺寸
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for (unsigned int i = 0; i < 6; ++i)
{
irradianceShader.setMat4("view", captureViews[i]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, irradianceMap, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
renderCube();
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
现在,完成这个流程之后,我们应该得到了一个预计算好的辐照度贴图,可以直接将其用于漫反射 IBL 计算。为了查看我们是否成功地对环境贴图进行了卷积,让我们将天空盒的环境采样贴图替换为辐照度贴图:
如果它看起来像模糊的环境贴图,说明您已经成功地对环境贴图进行了卷积。
本次项目源码:卷积 - GitCode
PBR 和间接辐照度光照
辐照度贴图代表了反射积分的漫反射部分,积累了所有周围间接光的影响。由于光不是来自直接光源,而是来自周围环境,我们将漫反射和镜面反射的间接光照都视为环境光,替换之前设置的常量项。
首先,确保将预计算的辐照度贴图添加为一个立方体采样器:
uniform samplerCube irradianceMap;
有了包含场景所有间接漫反射光的辐照度贴图,获取影响片段的辐照度只需根据表面法线进行一次纹理采样即可:
// vec3 ambient = vec3(0.03);
vec3 ambient = texture(irradianceMap, N).rgb;
然而,由于间接光照包含漫反射和镜面反射两部分(正如我们在反射方程的分割版本中看到的),我们需要适当地加权漫反射部分。类似于我们在上一章中所做的,我们使用菲涅尔方程来确定表面的间接反射比率,并从中推导出折射(或漫反射)比率:
vec3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0);
vec3 kD = 1.0 - kS;
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse = irradiance * albedo;
vec3 ambient = (kD * diffuse) * ao;
由于环境光来自半球内围绕法线 N 的所有方向,因此没有一个确定的半程向量来计算菲涅尔效应。为了模拟菲涅尔效应,我们用法线和视线之间的夹角计算菲涅尔系数。然而,之前我们是以受粗糙度影响的微表面半向量作为菲涅尔公式的输入,但我们目前没有考虑任何粗糙度,表面的反射率总是会相对较高。间接光和直射光遵循相同的属性,因此我们期望较粗糙的表面在边缘反射较弱。由于我们没有考虑表面的粗糙度,间接菲涅尔反射在粗糙非金属表面上看起来有点过强(为了演示目的略微夸大):
严格来说,半程向量 H H H 本身并不直接受粗糙度的影响,因为它仅依赖于入射光方向 L L L 和视角方向 V V V。我查阅 Sébastien Lagarde 原文,他的解答是这样的:
在预过滤立方体贴图中应用菲涅尔项会产生不良效果,即即使对粗糙表面,边缘区域也会始终显示高亮度的镜面反射颜色。
适用于未经过滤的环境贴图(例如完全光滑的镜面)的菲涅尔项并不适用于经过滤的环境贴图。这是因为预过滤过程会将来自多个方向的入射光颜色进行平均,但此时仅使用了一个基于反射方向计算的单一半固定菲涅尔值。正确的菲涅尔函数在视角方向与表面法线对齐时(即 v = n v=n v=n),其值应与常规菲涅尔表达式一致,但在掠射角度(即视角或光源方向接近表面切线方向时)的行为应有所不同。尤其在粗糙表面的情况下,从基础高光颜色到纯白色(全反射)的线性插值(lerp)不应在掠射角度完全过渡到白色。
所以将粗糙度引入到了菲涅尔方程中,对其进行调整。
我们可以通过在 Sébastien Lagarde 提出的 Fresnel-Schlick 方程中加入粗糙度项来缓解这个问题:
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness)
{
return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
}
在计算菲涅耳效应时引入表面粗糙度,环境光代码最终确定为:
vec3 kS = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
vec3 kD = 1.0 - kS;
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse = irradiance * albedo;
vec3 ambient = (kD * diffuse) * ao;
如你所见,实际的基于图像的光照计算非常简单,只需要采样一次立方体贴图,大部分的工作量在于将环境贴图预计算或卷积成辐照度贴图。
回到我们在光照教程中建立的初始场景,其中每个球体的金属度沿垂直方向增加,粗糙度沿水平方向增加,并添加漫反射 IBL,结果看起来会像这样:
现在看起来仍然有点奇怪,因为金属度较高的球体需要某种形式的反射以便看起来更像金属表面(因为金属表面不反射漫反射光),而目前这些反射仅(勉强)来自点光源。尽管如此,你已经可以感觉到球体在环境中显得更加协调(特别是在切换环境贴图时),因为表面会根据环境的环境光照做出相应的反应。
您可以在此处找到以上讨论过的整套源代码。在下一节教程中,我们将添加反射积分的间接镜面反射部分,此时我们将真正看到 PBR 的力量。
现在生锈的铁球变为了:
本次项目源码:间接漫反射光 - GitCode
进阶阅读
- Coding Labs: Physically based rendering:介绍 PBR ,如何以及为何要生成辐照度贴图。
- The Mathematics of Shading:借助 ScratchAPixel,对本教程中涉及的一些数学知识的简要介绍,特别是关于极坐标和积分。