需要预先学习:


1. IBL 概述

PBR 的直接光照部分把渲染方程(渲染方程与重要性采样)的积分退化成对光源求和,但环境光仍是个常数——没有方向性,金属物体反射不出环境的颜色,粗糙金属的反射也不会模糊。要补全 PBR 观感,需要来自环境各方向的光。

IBL(Image Based Lighting,基于图像的光照)的做法:用一张环境贴图(cubemap,见 纹理)记录周围环境各方向的入射光 LiL_i,把渲染方程的积分预先算好存进纹理,运行时查表代替求积分。

这套"预计算换实时性"的思路与 ShadowMap与PCF、PCSS、纹理 的 mipmap 一脉相承,是实时渲染的核心套路。


1.1 渲染方程的拆分

漫反射 IBL 与高光 IBL 分开处理,因为两者的积分性质不同:

项 积分 依赖
漫反射 ∫ΩLicos⁡θ dωi\int_\Omega L_i \cos\theta\, d\omega_i 只依赖法线方向,与视角无关
高光 ∫Ωfr Licos⁡θ dωi\int_\Omega f_r\, L_i \cos\theta\, d\omega_i 依赖粗糙度与视角(菲涅尔、GGX lobe)
  • 漫反射积分:与视角无关,对每个法线方向预计算一次即可——结果叫辐照度图(irradiance map);
  • 高光积分:依赖粗糙度和视角,无法预计算成一张图。用分裂求和近似(split-sum approximation)把它拆成两个独立部分的乘积:

∫Ωfr Licos⁡θ dωi≈(∫ΩLi dωi)×(∫Ωfrcos⁡θ dωi)\int_\Omega f_r\, L_i \cos\theta\, d\omega_i \approx \left( \int_\Omega L_i\, d\omega_i \right) \times \left( \int_\Omega f_r \cos\theta\, d\omega_i \right)

前一个积分只依赖粗糙度(按粗糙度预滤波成 mipmap),后一个积分只依赖 n⋅v\mathbf{n} \cdot \mathbf{v} 与粗糙度(预计算成 2D 查找表)。两者相乘就是高光 IBL。

注:分裂求和是近似,但误差在可接受范围,是实时 PBR 的工业标准做法(Unreal Engine 4 的实时光照方案即基于此)。


2. 漫反射 IBL:辐照度图


2.1 卷积的含义

辐照度(irradiance)是单位面积接收的辐射通量。对每个法线方向 n\mathbf{n},辐照度图存的是半球积分:

E(n)=∫ΩLi(ωi) (n⋅ωi) dωiE(\mathbf{n}) = \int_\Omega L_i(\omega_i)\, (\mathbf{n} \cdot \omega_i)\, d\omega_i

几何上看,这就是把环境贴图按余弦权重模糊一遍:每个 texel 的值等于"以该 texel 方向为中心,半球内所有方向取平均"。因为积分抹掉了高频细节,辐照度图是低频信息,分辨率 32×32 就足够,无需 mipmap。


2.2 预计算:卷积 shader

离线阶段把 cubemap 的每个 texel 当作法线方向,在它的半球内均匀采样求和:

// 从 cubemap 采样环境光的辐照度卷积
#version 330 core

out vec4 FragColor;
in vec3 localPos;   // 当前 texel 方向(立方体面的局部坐标)

uniform samplerCube environmentMap;

const float PI = 3.14159265359;

void main()
{
    vec3 N = normalize(localPos);
    vec3 up    = vec3(0.0, 1.0, 0.0);
    vec3 right = normalize(cross(up, N));
    up = cross(N, right);

    vec3 irradiance = vec3(0.0);
    float sampleDelta = 0.025;      // 采样步长(弧度)
    float nrSamples = 0.0;

    // 在半球上按球坐标均匀采样,权重取 cos(theta)
    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 * N;

            irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
            nrSamples++;
        }
    }

    // 归一化:除以样本数再乘 PI。sin(theta) 来自球坐标面积元,
    // 乘 PI 后结果与 Lambert BRDF 的 1/PI 因子抵消,采样一次即为漫反射项
    irradiance = PI * irradiance * (1.0 / nrSamples);
    FragColor = vec4(irradiance, 1.0);
}

代码中的 sin⁡θ\sin\theta 来自球坐标的雅可比(球面面积元 dω=sin⁡θ dθ dϕd\omega = \sin\theta\, d\theta\, d\phi),最后乘以 π\pi 是余弦卷积归一化。把 6 个面分别渲染进辐照度 cubemap 即完成预计算。

注:辐照度卷积的采样数是 64×64 量级,每 texel 都要做——预计算在初始化时做一次,代价可以接受。这就是"预计算换实时性"。


3. 高光 IBL:预滤波环境贴图


3.1 按粗糙度模糊成 mipmap

高光 lobe 的形状随粗糙度剧烈变化:光滑表面的 lobe 极窄,反射清晰;粗糙表面的 lobe 宽而模糊。一张模糊图无法同时表达——解决方案是按粗糙度把环境贴图预滤波成 mipmap 链:mip 0 是原始环境,mip 越高越模糊,采样时按粗糙度选择 mip 层。

预滤波的卷积与漫反射类似,但有两个区别:

  1. 采样分布用 GGX 重要性采样(渲染方程与重要性采样)而不是均匀采样——lobe 窄,均匀采样会大量浪费样本;
  2. 采样方向围绕反射方向 R\mathbf{R}(而不是法线)分布,且按 n⋅v\mathbf{n} \cdot \mathbf{v} 加权。
// 预滤波环境贴图:每个 mip 层对应一个粗糙度
#version 330 core

out vec4 FragColor;
in vec3 localPos;

uniform samplerCube environmentMap;
uniform float roughness;

const float PI = 3.14159265359;

// Hammersley 序列与 GGX 重要性采样(见《渲染方程与重要性采样》)
float radicalInverse_VdC(uint bits) { /* ... 位反转 ... */ }
vec2 hammersley(uint i, uint N) { return vec2(float(i) / float(N), radicalInverse_VdC(i)); }
vec3 importanceSampleGGX(vec2 xi, vec3 N, float roughness) { /* ... 见前文 ... */ }

void main()
{
    vec3 N = normalize(localPos);
    vec3 R = N;
    vec3 V = R;     // 预计算时假设视线方向 == 反射方向(等价于法线)

    const uint SAMPLE_COUNT = 1024u;
    vec3 prefilteredColor = vec3(0.0);
    float totalWeight = 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;
        }
    }

    FragColor = vec4(prefilteredColor / totalWeight, 1.0);
}

3.2 生成 mipmap 链

对粗糙度 rr 在 [0,1][0, 1] 间取若干档(如每 mip 层一档),分别渲染到预滤波 cubemap 的对应 mip 层:

// 预滤波 cubemap:5 个 mip 层,粗糙度从 0 到 1
unsigned int prefilterMap;
glGenTextures(1, &prefilterMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, prefilterMap);

for (unsigned int i = 0; i < 6; ++i)
{
    glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0,
                 GL_RGB16F, 128, 128, 0, GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_CUBE_MAP);

// 逐 mip 层渲染卷积
for (unsigned int mip = 0; mip < 5; ++mip)
{
    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 / (5 - 1);
    glUniform1f(glGetUniformLocation(prefilterShader, "roughness"), roughness);

    // 渲染 6 个面到对应 mip 层
    for (unsigned int face = 0; face < 6; ++face)
    {
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                               GL_TEXTURE_CUBE_MAP_POSITIVE_X + face,
                               prefilterMap, mip);
        renderCube();
    }
}

4. 高光 IBL:BRDF 积分 LUT


4.1 分裂求和的后半部分

分裂求和剩下的积分只与 BRDF 有关,与环境光无关:

∫Ωfrcos⁡θ dωi=∫Ω(F+DFG4(n⋅v)(n⋅l))cos⁡θ dωi\int_\Omega f_r \cos\theta\, d\omega_i = \int_\Omega \left( F + \frac{DFG}{4(\mathbf{n}\cdot\mathbf{v})(\mathbf{n}\cdot\mathbf{l})} \right) \cos\theta\, d\omega_i

把 Schlick 菲涅尔近似 F=F0+(1−F0)(1−cos⁡θ)5F = F_0 + (1 - F_0)(1 - \cos\theta)^5 代入后,F0F_0 可以提出积分号外,剩下只依赖两个变量的部分:

∫Ωfrcos⁡θ dωi=F0⋅scale+bias\int_\Omega f_r \cos\theta\, d\omega_i = F_0 \cdot \text{scale} + \text{bias}

其中 scale\text{scale} 与 bias\text{bias} 只依赖 n⋅v\mathbf{n} \cdot \mathbf{v} 和粗糙度。于是预计算一张 2D 查找表(LUT):横轴 n⋅v\mathbf{n} \cdot \mathbf{v},纵轴粗糙度,每个 texel 存 (scale, bias) 两个值。运行时一行代码查表,就得到了"这个粗糙度、这个视角下的菲涅尔积分"。


4.2 预计算 LUT

LUT 用 GGX 重要性采样预计算,需要把它定义成只依赖两个标量——把积分变换到 HH 空间的极坐标后,样本的 ϕ\phi 角是均匀的,cos⁡θ\cos\theta 由 DD 分布采样得到:

// BRDF LUT:输出 (scale, bias),写入 2D 纹理
#version 330 core

out vec2 FragColor;
in vec2 texCoords;

const float PI = 3.14159265359;

uniform sampler2D lutTexture;   // 占位,实际无需采样

float radicalInverse_VdC(uint bits) { /* ... */ }
vec2 hammersley(uint i, uint N) { /* ... */ }
vec3 importanceSampleGGX(vec2 xi, vec3 N, float roughness) { /* ... */ }

// 几何函数(与 PBR 一致)
float geometrySchlickGGX(float NdotV, float roughness) { /* ... */ }
float geometrySmith(vec3 N, vec3 V, vec3 L, float roughness) { /* ... */ }

vec2 integrateBRDF(float NdotV, float roughness)
{
    vec3 V = vec3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV);   // 由 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)
        {
            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;    // scale:F0 的系数
            B += Fc * G_Vis;            // bias:与 F0 无关的项
        }
    }

    return vec2(A, B) / float(SAMPLE_COUNT);
}

void main()
{
    FragColor = integrateBRDF(texCoords.x, texCoords.y);
}

LUT 的尺寸 512×512 即可满足精度,渲染到 GL_RG16F 纹理(两个分量存 scale 与 bias)。


5. 组合采样

运行时,PBR 的环境光由三部分查表组成:

// 环境光:漫反射 IBL + 高光 IBL
#version 330 core

uniform samplerCube irradianceMap;      // 辐照度图(漫反射)
uniform samplerCube prefilterMap;       // 预滤波环境贴图(高光)
uniform sampler2D   brdfLUT;            // BRDF 查找表

uniform vec3  albedo;
uniform float metallic;
uniform float roughness;
uniform vec3  viewPos;

in vec3 FragPos;
in vec3 Normal;

const float MAX_REFLECTION_LOD = 4.0;   // 预滤波 mip 层数 - 1

// 菲涅尔项(与 PBR 一致)
vec3 fresnelSchlick(float cosTheta, vec3 F0) { /* ... */ }

void main()
{
    vec3 N = normalize(Normal);
    vec3 V = normalize(viewPos - FragPos);
    vec3 R = reflect(-V, N);            // 反射方向

    vec3 F0 = mix(vec3(0.04), albedo, metallic);
    vec3 F  = fresnelSchlick(max(dot(N, V), 0.0), F0);
    vec3 kS = F;
    vec3 kD = (1.0 - kS) * (1.0 - metallic);

    // 漫反射:辐照度图按法线采样,一次采样即完成半球积分
    vec3 irradiance = texture(irradianceMap, N).rgb;
    vec3 diffuse = irradiance * albedo;

    // 高光:按反射方向 + 粗糙度采样预滤波 mip,再乘 LUT 查表结果
    vec3 prefilteredColor = textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb;
    vec2 brdf = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
    vec3 specular = prefilteredColor * (F0 * brdf.x + brdf.y);

    vec3 ambient = kD * diffuse + specular;
    // 与直接光照 Lo 相加输出……
}

对照三张纹理看它们各承担渲染方程(渲染方程与重要性采样)的哪一部分:

纹理 预计算内容 采样方式 替代的积分
辐照度图 余弦卷积后的环境光 按法线 NN 采样 漫反射半球积分
预滤波贴图 按粗糙度模糊的 mipmap 链 按反射方向 RR + 粗糙度采样 高光积分的环境光部分
BRDF LUT 菲涅尔积分的 (scale, bias) 按 N⋅VN \cdot V + 粗糙度采样 高光积分的 BRDF 部分

注:至此 PBR 的最后一块拼图完成——金属反射出环境的颜色,粗糙金属的反射是模糊的,掠射角有菲涅尔增强。整条链路的理论根基是 渲染方程与重要性采样 的蒙特卡洛与重要性采样:预计算卷积本身就是用重要性采样做蒙特卡洛积分,只是把昂贵的采样挪到了初始化阶段。