跳转至

11 PBR

文本统计:约 4335 个字 • 345 行代码

理论

PBR 即基于物理的渲染,判断一种PBR光照模型是否是基于物理的,必须满足以下三个条件:

  1. 基于微平面(Microfacet)的表面模型。
  2. 能量守恒。
  3. 应用基于物理的BRDF。

微表面模型,就是达到微观尺度后任何平面都可以用被称为微平面的细小镜面来进行描绘,那么一个平面越粗糙,那么这个平面上的微表面的排列就越混乱。我们假设一个粗糙度的参数,描述众多微平面中朝向的方向沿着半程向量的比例。

能量守恒:出射光线的能量永远不会超过入射光线的能量(发光面除外)光线有一部分被反射,有一部分被折射。

float kS = calculateSpecularComponent(...); // 反射/镜面 部分
float kD = 1.0 - ks;                        // 折射/漫反射 部分

BRDF, 即双向反射分布函数,接受入射方向,出射方向,平面法线,以及一个用来表示微平面粗糙程度的参数 \(a\), 然后输出一个 0-1 的值。

这里采用的模型为 Cook-Torrance BRDF 模型

\[ f_r = k_d f_{lambert} +k_s f_{cook-torrance} \]

其中,\(k_d\) 是入射光线中被折射部分的能量所占的比例,\(k_s\) 是被反射部分的比例。然后而 \(f_{lambert}\)指漫反射的 BRDF,\(f_{cook−torrance}\) 指镜面反射的 BRDF。

然后漫反射部分的BRDF为

\[ f_{lambert} = \frac{c}{\pi} \]

漫反射BRDF怎么推出来的

其中 \(c\) 为表面颜色,镜面反射部分的 BRDF 为

\[ f_{cook-torrance} = \frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)} \]

字母D,F与G分别代表着一种类型的函数,各个函数分别用来近似的计算出表面反射特性的一个特定部分。三个函数分别为法线分布函数(Normal Distribution Function),菲涅尔方程(Fresnel Rquation)和几何函数(Geometry Function):

  • 法线分布函数:估算在受到表面粗糙度的影响下,朝向方向与半程向量一致的微平面的数量。这是用来估算微平面的主要函数。

如何理解法线分布函数?

\[ \int_{\Omega} D(\omega_i)\cdot (n\cdot \omega_i) \text{d}\omega_i=1 \]

单位面积下单位立体角在某个方向的面积

  • 几何函数:描述了微平面自成阴影的属性。当一个平面相对比较粗糙的时候,平面表面上的微平面有可能挡住其他的微平面从而减少表面所反射的光线。
  • 菲涅尔方程:菲涅尔方程描述的是在不同的表面角下表面所反射的光线所占的比率。

这个式子是怎么推出来的

我们先假设几何遮挡函数和菲涅尔系数都为1,关注于法线分布函数对镜面反射部分BRDF的影响

我们需要求的是 BRDF, 相应的定义为

\[ f_{cook-torrance}(\omega_i,\omega_o)= \frac{\text{d}L_o(\omega_o)}{\text{d}E_i(\omega_i)}=\frac{\text{d}L_o(\omega_o)}{L_i(\omega_i)\cos\theta_i\text{d}\omega_i} \]

于是我们需要求 \(\text{d}L_o(\omega_o)/\text{d}\omega_i\)

\[ \frac{\text{d}L_o(\omega_o)}{\text{d}\omega_i}=\frac{\text{d}}{\text{d}\omega_i}\frac{\text{d}^2\Phi_o}{\text{d}\omega_o\text{d}A\cos\theta_o} \]

由法线分布函数的定义可知,单位面积下单位立体角所有法向为h**的微平面的面积为:

\[ \text{d}^2A(\omega_h) = D(\omega_h) \text{d}\omega_h\text{d}A \]

然后我们可以求得单位面积下有效的入射的辐射通量为

\[ \text{d}^3\Phi_i = L(\omega_i)\text{d}\omega_i\text{d}^2A(\omega_h)\cos\theta_h = L(\omega_i)\text{d}\omega_i\cos\theta_hD(\omega_h) \text{d}\omega_h\text{d}A \]

将其带入可知

\[ f_{cook-torrance}(\omega_i,\omega_o)=\frac{1}{L_i(\omega_i)\cos\theta_i}\cdot \frac{L(\omega_i)\text{d}\omega_i\cos\theta_hD(\omega_h) \text{d}\omega_h\text{d}A}{\text{d}\omega_i\text{d}\omega_o\text{d}A\cos\theta_o} = \frac{\cos\theta_hD(\omega_h)\text{d}\omega_h}{\cos\theta_o\cos\theta_i\text{d}\omega_o} \]

其中 \(\theta_i\) 是入射光线 \(ω_i\) 与宏观法线方向 \(n\) 的夹角,\(θ_o\)是出射光线 \(ω_o\) 与宏观法线方向 \(n\) 的夹角,\(θ_h\) 是入射光线 \(ω_i\) 与微平面法线方向 \(ω_h\) 的夹角。

现在需要找到 \(\text{d}\omega_h/\text{d}{\omega_o}\) 的值,把其放到球里面去,如下图所示

根据立体角的计算公式可知

\[ \begin{aligned} \text{d}\omega_h = \sin{\theta_h} \text{d}\theta_h\text{d}\phi\\ \text{d}\omega_o = \sin{2\theta_h} \text{d}2\theta_h\text{d}\phi\\ \end{aligned} \]

从而

\[ \frac{\text{d}\omega_h}{\text{d}{\omega_o}} = \frac{1}{4\cos\theta_h} \]

那么

\[ f_{cook-torrance}(\omega_i,\omega_o) = \frac{D(\omega_h)}{4\cos\theta_o\cos\theta_h} \]

那么反射率方程为

\[ L_o(p,\omega_o)=\int_{\Omega}(k_d\frac{c}{\pi}+k_s\frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)})L_i(p,\omega_i)n \cdot \omega_i \text{d}\omega_i \]

光照

对光照部分单独进行采样,与之前使用 Blinn-Phong 模型的逻辑是类似的,只是需要利用上述的反射率方程

void main()
{       
    vec3 N = normalize(Normal);
    vec3 V = normalize(camPos - WorldPos);

    // calculate reflectance at normal incidence; if dia-electric (like plastic) use F0 
    // of 0.04 and if it's a metal, use the albedo color as F0 (metallic workflow)    
    vec3 F0 = vec3(0.04); 
    F0 = mix(F0, albedo, metallic);

    // reflectance equation
    vec3 Lo = vec3(0.0);
    for(int i = 0; i < 4; ++i) 
    {
        // calculate per-light radiance
        vec3 L = normalize(lightPositions[i] - WorldPos);
        vec3 H = normalize(V + L);
        float distance = length(lightPositions[i] - WorldPos);
        float attenuation = 1.0 / (distance * distance);
        vec3 radiance = lightColors[i] * attenuation;

        // Cook-Torrance BRDF
        float NDF = DistributionGGX(N, H, roughness);   
        float G   = GeometrySmith(N, V, L, roughness);      
        vec3 F    = fresnelSchlick(clamp(dot(H, V), 0.0, 1.0), F0);

        vec3 numerator    = NDF * G * F; 
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001; // + 0.0001 to prevent divide by zero
        vec3 specular = numerator / denominator;

        // kS is equal to Fresnel
        vec3 kS = F;
        // for energy conservation, the diffuse and specular light can't
        // be above 1.0 (unless the surface emits light); to preserve this
        // relationship the diffuse component (kD) should equal 1.0 - kS.
        vec3 kD = vec3(1.0) - kS;
        // multiply kD by the inverse metalness such that only non-metals 
        // have diffuse lighting, or a linear blend if partly metal (pure metals
        // have no diffuse light).
        kD *= 1.0 - metallic;     

        // scale light by NdotL
        float NdotL = max(dot(N, L), 0.0);        

        // add to outgoing radiance Lo
        Lo += (kD * albedo / PI + specular) * radiance * NdotL;  // note that we already multiplied the BRDF by the Fresnel (kS) so we won't multiply by kS again
    }   

    // ambient lighting (note that the next IBL tutorial will replace 
    // this ambient lighting with environment lighting).
    vec3 ambient = vec3(0.03) * albedo * ao;

    vec3 color = ambient + Lo;

    // HDR tonemapping
    color = color / (color + vec3(1.0));
    // gamma correct
    color = pow(color, vec3(1.0/2.2)); 

    FragColor = vec4(color, 1.0);
}

漫反射辐照

首先介绍一下什么叫做 IBL (基于图像的光照),将周围环境整体视为一个大光源,通常使用环境立方体贴图,将立方体贴图中的每个像素视为光源,然后在渲染方程中直接使用它。

我们先将漫反射部分分离出来

\[ \int_{\Omega}k_d\frac{c}{\pi}L_i(p,\omega_i)n\cdot \omega_i\text{d}\omega_i = k_d\frac{c}{\pi}\int_{\Omega}L_i(p,\omega_i)n\cdot \omega_i\text{d}\omega_i \]

该部分是一个只依赖与 \(\omega_i\) 有关的积分,也就是说与我观察的视角是无关的,于是我们就可以预处理这部分内容,我们可以对环境贴图进行卷积,我们通过对半球 \(Ω\) 上的大量方向进行离散采样并对其辐射度取平均值,来计算每个输出采样方向 \(\omega_o\) 的积分。用来采样方向 \(\omega_i\) 的半球,要面向卷积的输出采样方向 \(wo\)

上面就是解决漫反射部分的具体思路,下面我们将仔细讨论如何生成这样的辐射度贴图

(1)加载进入一张 HDR 文件格式的环境贴图

(2)利用环境贴图设计相应的着色器转化为立方体贴图,

(3)对生成的立方体贴图进行卷积,得到相应的辐射度贴图

如何导入一张 HDR 文件格式?

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;
}

怎么将环境贴图转化为立方体贴图?

  • 添加帧缓冲 captureFBO, 然后利用将立方体贴图中的每个面依次绑定为颜色附件
  • 绘制一个立方体,调整照相机视角,将相应的画面渲染到立方体的各个面上

由于这里加载的是等距柱状投影图,所以我们渲染的时候还需要进行一定的处理

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;
}

如何处理等距柱状投影图?

相应的顶点着色器与片段着色器为

#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)); // make sure to normalize localPos
    vec3 color = texture(equirectangularMap, uv).rgb;

    FragColor = vec4(color, 1.0);
}

那么怎么利用该着色器生成相应的立方体贴图?

[...创建帧缓冲]

[...生成相应的立方体贴图]

//接下来对立方体进行渲染
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))
};

// convert HDR equirectangular environment map to cubemap equivalent
equirectangularToCubemapShader.use();
equirectangularToCubemapShader.setInt("equirectangularMap", 0);
equirectangularToCubemapShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTexture);

glViewport(0, 0, 512, 512); // don't forget to configure the viewport to the capture dimensions.
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(); // renders a 1x1 cube
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

Warning

注意一下这里 projection 和 view 矩阵的选择

至此,我们获得了相应的立方体贴图,接着我们要对立方体贴图进行卷积,立方体的卷积操作与之前的操作思路是类似的,依然是创建一个立方体贴图,然后利用帧缓冲对该帖图进行渲染

对之前得到的环境贴图进行卷积操作

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)
    {
        // spherical to cartesian (in tangent space)
        // 切线空间的采样向量
        vec3 tangentSample = vec3(sin(theta) * cos(phi),  sin(theta) * sin(phi), cos(theta));
        // tangent space to world
        // 将切线空间的采样向量转化到世界空间的采样向量
        vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N; 

        // 对立方体贴图进行采样
        irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
        nrSamples++;
    }
}
irradiance = PI * irradiance * (1.0 / float(nrSamples));

当我们离散地对两个球坐标轴进行采样时,每个采样近似代表了半球上的一小块区域,如上图所示。注意,由于球的一般性质,当采样区域朝向中心顶部会聚时,天顶角 \(θ\) 变高,半球的离散采样区域变小。为了平衡较小的区域贡献度,我们使用 \(\sinθ\) 来权衡区域贡献度,这就是多出来的 \(\sin\) 的作用。

这样我们就得到了漫反射辐照贴图。

镜面IBL

接着我们看镜面反射的部分

\[ \int_{\Omega}f_r(p,\omega_i,\omega_o)L_i(p,\omega_i)n\cdot \omega_i\text{d}\omega_i \]

我们无法以合理的性能实时求解积分的镜面反射部分。因此,我们最好预计算这个积分,以得到像镜面 IBL 贴图这样的东西,用片段的法线对这张图采样并计算。但是这个部分的计算不仅仅依赖 \(\omega_i\)。我们对上述积分进行分解,运用到 Epic Games 的分割求和近似法来求解。

\[ \frac{\int _{\Omega}L_i(p,\omega_i)\text{d}\omega_i}{\int_{\Omega}\text{d}\omega_i}\int_{\Omega}f_r(p,\omega_i,\omega_o)n\cdot \omega_i\text{d}\omega_i \]

为什么要这么分解?

我们所需要的是提前求解相应的量,然后在渲染的时候可以直接查询得到结果,这样才能做到实时渲染。之前漫反射辐照的时候,值只与 \(\omega_i\) 有关,可以通过三维的立方体贴图进行插值。而在这里是五维的取值,所以我将其拆开后分别在三维立方体贴图中取值与二维纹理中取值,从而达到快速计算的效果。

为什么这样的分解是有效的?

Split Sum Approximation理解 - 知乎

深入理解 PBR/基于图像照明 (IBL) - 知乎

对前半部分的采样的pdf 可以是均匀采样,也可以是按照 \(N\cdot l\) 来采样

第一部分称为预滤波环境贴图,与辐照度贴图是类似的,只不过采样方式是不同的

第二部分为镜面反射积分的BRDF部分,这部分与粗糙度、光线和法线的夹角有关,可以存在一张2D 查找纹理当中

接下来我们分别得到这两张纹理图。

预滤波HDR环境贴图

与之前在处理漫反射辐照时对立方体贴图进行卷积的操作是一样的,只不过我们这次采样的定义域仅仅只是在理想入射方向附近进行采样。

首先根据微表面理论,法线的分布情况与表面的粗糙度有关,我么需要一个函数对法线进行采样。

我们的GGX分布函数为

\[ D(m)= \frac{\alpha^2}{\pi(\alpha^2\cos^2\theta+\sin^2\theta)^2} \]

其中 \(α=\text{roughness}^2\)\(θ\)为微表面法线与宏观法线的夹角。

那么由于方位角 \(\phi\) 均匀分布,我们只需处理 \(\theta\) 的边际分布

\[ P(\theta) = \int_0^\theta \frac{\alpha^2}{\pi(\alpha^2\cos^2\theta'+\sin^2\theta')^2}\cos\theta'\sin\theta'\cdot 2\pi\text{ d}\theta'=\frac{\sin^2\theta}{\alpha^2\cos^2\theta+\sin^2\theta} \]

那么均匀随机数 \(X_i .y\) 对应的是 \(\theta\) 的边际分布函数

\[ \frac{\sin^2\theta}{\alpha^2\cos^2\theta+\sin^2\theta} = X_i.y \]

从而得到

\[ \cos\theta = \sqrt{\frac{1-X_i.y}{1+(\alpha^2-1)X_i.y}} \]
const float PI = 3.14159265359;

//随机生成低差异序列,均匀采样
float RadicalInverse_VdC(uint bits) 
{
    bits = (bits << 16u) | (bits >> 16u);
    bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
    bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
    bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
    bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
    return float(bits) * 2.3283064365386963e-10; // / 0x100000000
}
// ----------------------------------------------------------------------------
vec2 Hammersley(uint i, uint N)
{
    return vec2(float(i)/float(N), RadicalInverse_VdC(i));
}

//转化为
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness)
{
    float a = roughness*roughness;

    float phi = 2.0 * PI * Xi.x;
    float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y));
    float sinTheta = sqrt(1.0 - cosTheta*cosTheta);

    // from spherical coordinates to cartesian coordinates
    vec3 H;
    H.x = cos(phi) * sinTheta;
    H.y = sin(phi) * sinTheta;
    H.z = cosTheta;

    // from tangent-space vector to world-space sample vector
    vec3 up        = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
    vec3 tangent   = normalize(cross(up, N));
    vec3 bitangent = cross(N, tangent);

    vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
    return normalize(sampleVec);
}

这样我们通过 ImportanceSampleGGX 就完成了采样向量的生成,然后我们就可以对之前的立方体贴图进行采样,而由于随着粗糙度的增加,参与环境贴图卷积的采样向量会更分散,导致反射更模糊(这样我们可以用更低的像素来存储),所以对于卷积的每个粗糙度级别,我们将按顺序把模糊后的结果存储在预滤波贴图的 mipmap 中。

从而我们就可以完成预滤波器卷积着色器的采样部分

const uint SAMPLE_COUNT = 1024u;
float totalWeight = 0.0;   
vec3 prefilteredColor = vec3(0.0);     
for(uint i = 0u; i < SAMPLE_COUNT; ++i)
{
    vec2 Xi = Hammersley(i, SAMPLE_COUNT);
    vec3 H  = ImportanceSampleGGX(Xi, N, roughness);
    vec3 L  = normalize(2.0 * dot(V, H) * H - V);

    float NdotL = max(dot(N, L), 0.0);
    if(NdotL > 0.0)
    {
        prefilteredColor += texture(environmentMap, L).rgb * NdotL;
        totalWeight      += NdotL;
    }
}
prefilteredColor = prefilteredColor / totalWeight;

FragColor = vec4(prefilteredColor, 1.0);

如何利用着色器输出一系列相应的预滤波HDR环境贴图?

[生成 prefilter 立方体贴图并生成相应的 mipmap]
...

prefilterShader.use();
prefilterShader.setInt("environmentMap", 0);
prefilterShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
unsigned int maxMipLevels = 5;
for (unsigned int mip = 0; mip < maxMipLevels; ++mip)
{
    // reisze framebuffer according to mip-level size.
    unsigned int mipWidth = 128 * std::pow(0.5, mip);
    unsigned int mipHeight = 128 * std::pow(0.5, mip);
    glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
    glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mipWidth, mipHeight);
    glViewport(0, 0, mipWidth, mipHeight);

    float roughness = (float)mip / (float)(maxMipLevels - 1);
    prefilterShader.setFloat("roughness", roughness);
    for (unsigned int i = 0; i < 6; ++i)
    {
        prefilterShader.setMat4("view", captureViews[i]);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
            GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, prefilterMap, mip);

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        renderCube();
    }
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

这里还需要处理两个问题

  • 高粗糙度的立方体贴图接缝
  • 预过滤卷积的亮点

高粗糙度的立方体贴图接缝:在具有粗糙表面的表面上对预过滤贴图采样,也就等同于在较低的 mip 级别上对预过滤贴图采样。在对立方体贴图进行采样时,默认情况下,OpenGL不会在立方体面之间进行线性插值。由于较低的 mip 级别具有更低的分辨率,并且预过滤贴图代表了与更大的采样波瓣卷积,因此缺乏立方体的面和面之间的滤波的问题就更明显。OpenGL 可以启用 GL_TEXTURE_CUBE_MAP_SEAMLESS,以为我们提供在立方体贴图的面之间进行正确过滤的选项:

预过滤卷积的亮点:镜面反射的光强变化剧烈(如 HDR 环境中的明亮光源),且反射方向在粗糙表面上分散。在低粗糙度(高光锐利)时,反射方向集中,少量采样即可覆盖主要能量;但在高粗糙度(mip 级别较低)时,反射方向分散,需要极多采样点才能覆盖所有可能方向。若采样不足,某些高频细节(如强光源)未被充分采样,积分时能量未被平均化,导致局部能量堆积,形成亮点。粗糙表面应使用较低分辨率(更高模糊度)的 Mipmap 层级以平滑高频细节。若未正确选择 Mip 级别,高粗糙度表面仍使用高分辨率 Mipmap,则无法有效抑制高频噪声,导致亮点。

float D   = DistributionGGX(NdotH, roughness);
float pdf = (D * NdotH / (4.0 * HdotV)) + 0.0001; 

float resolution = 512.0; // resolution of source cubemap (per face)
float saTexel  = 4.0 * PI / (6.0 * resolution * resolution);
float saSample = 1.0 / (float(SAMPLE_COUNT) * pdf + 0.0001);

float mipLevel = roughness == 0.0 ? 0.0 : 0.5 * log2(saSample / saTexel); 

根据粗糙度选择合适的 mipmap 等级进行采样

预计算 BRDF

关注第二部分的积分

\[ \int_{\Omega}f_r(p,\omega_i,\omega_o)n\cdot \omega_i\text{d}\omega_i \]

我们已经在预过滤贴图的各个粗糙度级别上预计算了分割求和近似的左半部分。右半部分要求我们在 \(n \cdot \omega_o\) 、表面粗糙度、菲涅尔系数 \(F_0\) 上计算 BRDF 方程的卷积。这等同于在纯白的环境光或者辐射度恒定为 \(L_i = 1.0\) 的设置下,对镜面 BRDF 求积分。对3个变量做卷积有点复杂,不过我们可以把 \(F_0\) 移出镜面 BRDF 方程:

\[ \int_\Omega f_r(p, \omega_i, \omega_o) n \cdot \omega_i \text{d}\omega_i = \int_\Omega f_r(p, \omega_i, \omega_o) \frac{F(\omega_o, h)}{F(\omega_o, h)} n \cdot \omega_i \text{d}\omega_i \]

\(F\) 为菲涅耳方程。将菲涅耳分母移到 BRDF 下面可以得到如下等式:

\[ \int_\Omega \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} F(\omega_o, h) n \cdot \omega_i \text{d}\omega_i \]

用 Fresnel-Schlick 近似公式替换右边的 \(F\) 可以得到:

\[ \int_\Omega \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 + (1 - F_0)(1 - \omega_o \cdot h)^5) n \cdot \omega_i \text{d}\omega_i \]

让我们用 \(\alpha\) 替换 \((1 - \omega_o \cdot h)^5\) 以便更轻松地求解 \(F_0\)

\[ \int_\Omega \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 + (1 - F_0)\alpha) n \cdot \omega_i \text{d}\omega_i \]
\[ \int_\Omega \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 + 1*\alpha - F_0*\alpha) n \cdot \omega_i \text{d}\omega_i \]
\[ \int_\Omega \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0*(1-\alpha) + \alpha) n \cdot \omega_i \text{d}\omega_i \]

然后我们将菲涅耳函数 \(F\) 分拆到两个积分里:

\[ \int_\Omega \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0*(1-\alpha)) n \cdot \omega_i d\omega_i + \int_\Omega \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (\alpha) n \cdot \omega_i d\omega_i \]

这样,\(F_0\)在整个积分上是恒定的,我们可以从积分中提取出 \(F_0\)。接下来,我们将 \(\alpha\) 替换回其原始形式,从而得到最终分割求和的 BRDF 方程:

\[ F_0 \int_\Omega f_r(p, \omega_i, \omega_o) (1-(1-\omega_o \cdot h)^5) n \cdot \omega_i \text{d}\omega_i + \int_\Omega f_r(p, \omega_i, \omega_o) (1-\omega_o \cdot h)^5 n \cdot \omega_i \text{d}\omega_i \]

我们以前者为例,来解释如何进行正确的重要性采样【回顾一下蒙特卡洛积分的方法】

\[ \begin{aligned} \int_\Omega f_r(p, \omega_i, \omega_o) (1-(1-\omega_o \cdot h)^5) n \cdot \omega_i \text{d}\omega_i=&\frac{1}{N} \sum_{k}^{N} \frac{\frac{D(\omega_h^{(k)}) G(\omega_o, \omega_i^{(k)})}{4 (\omega_o \cdot n) (\omega_i^{(k)} \cdot n)} (1 - (1 - \omega_o \cdot \omega_h^{(k)})^5) (\omega_i^{(k)} \cdot n)}{p(\omega_i^{(k)})}\\ =& \frac{1}{N} \sum_{k}^{N} \frac{\frac{D(\omega_h^{(k)}) G(\omega_o, \omega_i^{(k)})}{4 (\omega_o \cdot n) (\omega_i^{(k)} \cdot n)} (1 - (1 - \omega_o \cdot \omega_h^{(k)})^5) (\omega_i^{(k)} \cdot n)}{\frac{D(\omega_h^{(k)}) (\omega_i^{(k)} \cdot n)}{4 (\omega_o \cdot \omega_h^{(k)})}}\\ =& \frac{1}{N} \sum_{k}^{N} \frac{G(\omega_o, \omega_i^{(k)}) (\omega_o \cdot \omega_h^{(k)}) (1 - (1 - \omega_o \cdot \omega_h^{(k)})^5)}{(\omega_o \cdot n) (\omega_i^{(k)} \cdot n)} \end{aligned} \]

这样我们就可以正确写出相应的着色器以完成BRDF卷积部分 2D LUT 的生成了

vec2 IntegrateBRDF(float NdotV, float roughness)
{
    //得到出射光线,在切线空间中,法线向量为 (0,0,1)
    vec3 V;
    V.x = sqrt(1.0 - NdotV*NdotV);
    V.y = 0.0;
    V.z = NdotV;

    float A = 0.0;
    float B = 0.0;

    vec3 N = vec3(0.0, 0.0, 1.0);

    const uint SAMPLE_COUNT = 1024u;
    for(uint i = 0u; i < SAMPLE_COUNT; ++i)
    {
        vec2 Xi = Hammersley(i, SAMPLE_COUNT);
        //还是一样,对微表面法线进行采样
        vec3 H  = ImportanceSampleGGX(Xi, N, roughness);
        //得到入射光线
        vec3 L  = normalize(2.0 * dot(V, H) * H - V);

        float NdotL = max(L.z, 0.0);
        float NdotH = max(H.z, 0.0);
        float VdotH = max(dot(V, H), 0.0);

        if(NdotL > 0.0)
        {
            //得到几何函数值,注意这里k的取值
            float G = GeometrySmith(N, V, L, roughness);
            float G_Vis = (G * VdotH) / (NdotH * NdotV);
            float Fc = pow(1.0 - VdotH, 5.0);

            A += (1.0 - Fc) * G_Vis;
            B += Fc * G_Vis;
        }
    }
    A /= float(SAMPLE_COUNT);
    B /= float(SAMPLE_COUNT);
    return vec2(A, B);
}

为什么我们需要将这个部分拆成两个部分

因为菲涅尔项与材质是相关的

\[ F = F_0 + (1-F_0)(1-\cos\theta)^5 \]

我们无法仅根据 NdotVroughness 求解得到,而我们将其拆成两部分,我们就可以支持任意的 \(F_0\)

IBL 反射

得到了辐射度贴图、预滤波HDR环境贴图与预计算的BRDF的2D纹理,我们就可以完成 IBL 反射,完善我们的 PBR 渲染器

添加三张贴图

uniform samplerCube irradianceMap;
uniform samplerCube prefilterMap;
uniform sampler2D   brdfLUT;  

根据反射率方程,添加间接光

// 计算间接光照
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 prefilteredColor = textureLod(prefilterMap, R,  roughness * MAX_REFLECTION_LOD).rgb;   
vec2 envBRDF  = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F0 * envBRDF.x + envBRDF.y);

vec3 ambient = (kD * diffuse + specular) * ao;
vec3 color   = ambient + Lo;  

然后我们就可以得到这样的结果

评论区

对你有帮助的话请给我个赞和 star => GitHub stars
欢迎跟我探讨!!!