LearnOpenGL——PBR(理论与光照)

教程地址:简介 - LearnOpenGL CN


理论


引言

基于物理的渲染Physically Based Rendering,简称PBR)是一系列遵循相同物理理论基础、旨在更贴近真实世界光能传递规律的渲染技术集合。由于PBR通过物理可信的方式模拟光线行为,其效果相比传统光照模型(如Phong和Blinn-Phong)显著提升了真实感。更重要的是,基于物理参数化的材质创作方法,使开发者(尤其是美术人员)无需依赖临时技巧或反复调试,即可构建出符合物理规律的表层材质。PBR的核心优势在于:基于物理参数的材质能在任意光照条件下保持视觉正确性,这一特性是非PBR管线无法实现的。

基于物理的渲染(Physically Based Rendering,简称PBR)是一系列遵循相同物理理论基础、旨在更贴近真实世界光能传递规律的渲染技术集合。由于PBR通过物理可信的方式模拟光线行为,其效果相比传统光照模型(如Phong和Blinn-Phong)显著提升了真实感。更重要的是,基于物理参数化的材质创作方法,使开发者(尤其是美术人员)无需依赖临时技巧或反复调试,即可构建出符合物理规律的表层材质。PBR的核心优势在于:基于物理参数的材质能在任意光照条件下保持视觉正确性,这一特性是非PBR管线无法实现的。

值得注意的是,基于物理的渲染仍是对现实的近似模拟(基于物理学原理),因此其命名为"Physically Based"而非"Physical"。一个合格的PBR光照模型需满足以下三个条件(后续章节将详细展开):

  1. 基于微表面模型(Microfacet Surface Model)​
  2. 遵循能量守恒定律(Energy Conserving)​
  3. 使用基于物理的BRDF(Bidirectional Reflectance Distribution Function)​

在接下来的PBR章节中,我们将重点解析由迪士尼公司提出、并被Epic Games应用于实时渲染的PBR框架。这套基于"金属度工作流(Metallic Workflow)"的方案不仅文档完善,且已被主流引擎广泛采用,其视觉效果极为出色。通过本系列学习,您将最终实现如下品质的渲染效果:

image.png

学习前提:本系列内容涉及较多高级主题,建议读者已掌握OpenGL基础及着色器光照原理。需要提前了解的知识点包括:帧缓冲(Framebuffers)立方体贴图(Cubemaps)伽马校正(Gamma Correction)高动态范围(HDR)法线贴图(Normal Mapping)。虽然我们会涉及部分高等数学知识,但笔者将尽力以清晰易懂的方式阐释相关概念。

微表面模型

所有基于物理的渲染技术都建立在 微表面理论(Microfacet Theory)​ 的基础之上。该理论指出:从微观尺度观察,任何表面都可以视为由无数被称为 微表面(Microfacets) 的理想镜面反射单元构成。根据表面粗糙度的差异,这些微小镜面的取向排列会产生显著变化:

image.png

  • 粗糙表面:表面越粗糙,微表面的取向排列就越混乱。当讨论镜面光照/反射时,这种无序取向排列会导致入射光线在粗糙表面上的反射方向高度散射(Scatter),形成范围更广、强度更弱的镜面高光。
  • 光滑表面:表面越光滑,微表面的取向排列越趋于一致。此时入射光线大体上会更趋向于向同一个方向反射,产生更集中、更锐利的镜面高光。

image.png

在微观尺度下,没有任何平面是完全光滑的。然而由于这些微表面已经微小到无法逐像素地对其进行区分,因此我们假设一个 粗糙度(Roughness) 参数,然后用统计学的方法来估计微表面的粗糙程度。基于粗糙度值,我们可以计算出一个平面朝向方向沿着向量 h h h 的微表面所占全部微表面的比例。这个向量 h h h 就是光线方向向量 l l l 与视线方向向量 v v v 的归一化平均向量,被称为半程向量(halfway vector)。我们曾经在高级光照教程中谈到过,它的计算方法如下:

h = l + v ∥ l + v ∥ h=\frac{l+v}{\|l+v\|} h=l+vl+v

微表面的朝向方向与半程向量的方向越是一致,镜面反射的强度越大、形状越锐利。通过使用一个介于 0 到 1 之间的粗糙度参数,我们就能概略地估算微表面的取向情况了:

image.png

我们可以看到,较高的粗糙度显示出来的镜面反射的轮廓要更大一些。与之相反,较小的粗糙度显示出的镜面反射轮廓则更小更锐利。

能量守恒

微表面近似采用了一种 能量守恒(Energy Conservation) 形式:输出的光能量永远不能超过输入的光能量(不包括自发光表面)。从上图中我们可以看到,随着粗糙度的增加,镜面反射区域变大,但其亮度却降低。如果镜面强度在每个像素上都相同(不管反射轮廓的大小),较粗糙的表面会发出更多能量,违反能量守恒原则。这也就是为什么正如我们看到的一样,光滑平面的镜面反射更强烈而粗糙平面的反射更昏暗。

为了保持能量守恒,我们需要明确区分漫反射和镜面反射光。当光线击中表面时,它会被分为折射部分和反射部分。反射部分是直接反射而不进入表面的光,即我们所知的镜面光照。折射部分是剩余进入表面并被吸收的光,即我们所知的漫反射光照。

这里还有一些细节需要处理,因为当光线接触到一个表面的时候折射光是不会立即就被吸收的。通过物理学我们可以得知,光线实际上可以被认为是一束没有耗尽就不停向前运动的能量,而光束是通过碰撞的方式来消耗能量。每一种材料都是由无数微小的粒子所组成,这些粒子都能如下图所示一样与光线发生碰撞。这些粒子在每次的碰撞中都可以吸收光线所携带的一部分或者是全部的能量而后转变成为热量。

image.png

通常情况下,并非所有能量都被吸收,光线会在(大多)随机方向上继续散射,此时它会与其他粒子碰撞,直到能量耗尽或再次离开表面。重新从表面射出的光线会影响表面的观测(漫反射)颜色。然而,在基于物理的渲染中,我们做出了一个简化的假设:所有折射光在非常小的撞击区域被吸收和散射,忽略了散射光线在远处离开表面时的影响。一些特定的着色技术会考虑这种效应,称为次表面散射(subsurface scattering),这显著提高了皮肤、大理石或蜡等材质的视觉质量,但以性能为代价。

对于 金属(Metallic) 表面,当讨论到反射与折射的时候还有一个细节需要注意。金属表面与非金属表面(也称为绝缘体)相比,对光线的反应不同。金属表面同样遵循反射和折射原理,但所有折射光都会被直接吸收而不发生散射。这意味着金属表面只留下反射或镜面光;金属表面不显示漫反射颜色。由于金属与绝缘体之间的明显区别,它们在PBR管线中被区别对待,我们将在本章后续深入探讨。

反射光与折射光之间的这种区分让我们注意到另一个关于能量守恒的观察:反射光与折射光它们二者之间是互斥的关系。无论何种光线,其被材质表面所反射的能量将无法再被材质吸收。因此,诸如折射光这样的余下的进入表面之中的能量正好就是我们计算完反射之后余下的能量。

我们按照能量守恒的关系,首先计算镜面反射部分,它的值等于入射光线被反射的能量所占的百分比。然后折射光部分就可以直接由镜面反射部分计算得出:

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

这样,我们既知道入射光反射的量,也知道折射的量,同时遵守能量守恒原则。采用这种方法,折射/漫反射和反射/镜面贡献的总和不可能超过1.0,从而确保它们的能量总和永不超过入射光能量。这一点在之前的光照章节中我们并未考虑。

反射方程

强烈建议观看 gamse101 关于辐射度量学的讲解:

在这里我们引入了一种被称为渲染方程 (Render Equation)的东西。它是某些聪明绝顶的人所构想出来的一个精妙的方程式,是如今我们所拥有的用来模拟光的视觉效果最好的模型。PBR所坚定遵循的是一种被称为 反射方程(The Reflectance Equation) 的渲染方程的特化版本。要正确理解PBR,首先需要对反射方程建立扎实的理解:

L o ( p , ω o ) = ∫ Ω f r ( p , ω i , ω o ) L i ( p , ω i ) n ⋅ ω i d ω i L_o(p,\omega_o)=\int_\Omega f_r(p,\omega_i,\omega_o)L_i(p,\omega_i)n\cdot\omega_id\omega_i Lo(p,ωo)=Ωfr(p,ωi,ωo)Li(p,ωi)nωidωi

反射方程初看似乎令人生畏,但随着我们逐步解析,你会发现理解它。要正确地理解这个方程式,我们必须要稍微涉足一些 辐射度量学(Radiometry) 的内容。辐射度量学是一种用来度量电磁场辐射(包括可见光)的手段。我们可以用多种辐射度量单位来测量曲面或者某个方向上的光,但我们只会讨论与反射方程相关的一个量,即辐射率(Radiance),在此记为 L L L。辐射率用于量化从单一方向传来的光的大小或强度。由于辐射率由多个物理量的组合,初次理解它有些棘手,所以我们先关注这些物理量:

辐射通量(Radiant Flux):辐射通量 ϕ \phi ϕ 表示的是一个光源单位时间所输出的能量,以瓦特为单位。光是由多种不同波长的能量所集合而成的,而每种波长则与一种特定的(可见的)颜色相关。因此一个光源所放射出来的能量可以被视作这个光源包含的所有各种波长的一个函数。波长在390nm到700nm之间的光被认为是可见光谱的一部分,即人眼能够感知的波长范围。下图展示了日光在不同波长下的能量分布:

image.png

辐射通量将会计算这个由不同波长构成的函数的总面积。直接将这种对不同波长的计量作为参数输入计算机有一些不切实际,因此我们通常简化处理,不将辐射通量表示为波长强度的函数,而是将其编码为RGB三元组(即我们通常所说的光颜色)。这种编码确实会损失相当多的信息,但在视觉方面通常可以忽略不计。

立体角(Solid Angle):立体角用 ω \omega ω 表示,它可以描述一个截面投影到单位球体上的的大小或者面积。投影到这个单位球体上的截面的面积就被称为立体角,你可以把立体角想象成为一个带有体积的方向:

image.png

可以把自己想象成一个站在单位球面中心的观察者,向着投影的方向看。这个投影轮廓的大小就是立体角。立体角等于投影到球体上的面积除以半径的平方:

ω = A r 2 \omega=\frac{A}{r^2} ω=r2A

辐射强度(Radiant Intensity):辐射强度表示的是在单位球面上,一个光源向每单位立体角所投送的辐射通量。举例来说,假设一个全向光源向所有方向均匀的辐射能量,辐射强度就能帮我们计算出它在一个单位面积(立体角)内的能量大小:

image.png

计算辐射强度的公式如下所示:

I = d Φ d ω I=\frac{d\Phi}{d\omega} I=dωdΦ

其中 I I I 表示辐射通量 Φ \Phi Φ 除以立体角 ω \omega ω

在理解了辐射通量,辐射强度与立体角的概念之后,我们终于可以开始讨论辐射率的方程式了。这个方程表示的是,一个拥有辐射强度 Φ \Phi Φ 的光源在单位面积 A A A,单位立体角 ω \omega ω 上的辐射出的总能量:

L = d 2 Φ d A d ω cos ⁡ θ L=\frac{d^2\Phi}{dAd\omega\cos\theta} L=dAdωcosθd2Φ

image.png

辐射率是辐射度量学上表示一个区域平面上光线总量的物理量,它受到入射(Incident)(或者来射)光线与平面法线间的夹角 θ \theta θ 的余弦值 cos ⁡ θ \cos{\theta} cosθ 影响:光线越不直接照射到表面,其强度越弱;当光线与表面完全垂直时,强度最强。这和我们在前面的基础光照教程中对于漫反射光照的概念相似,其中 cos ⁡ θ \cos{\theta} cosθ 就直接对应于光线的方向向量和平面法向量的点积:

float cosTheta = dot(lightDir, N);

辐射率方程很有用,因为它把大部分我们感兴趣的物理量都包含了进去。如果我们把立体角 ω \omega ω 和面积 A A A 看作是无穷小的,那么我们就能用辐射率来表示单束光线穿过空间中的一个点的通量。这就使我们可以计算得出作用于单个(片段)点上的单束光线的辐射率,我们实际上把立体角 ω \omega ω 转变为方向向量 ω \omega ω ,然后把面 A A A 转换为点 p p p。这样我们就能直接在我们的着色器中使用辐射率来计算单束光线对每个片段的作用了。

事实上,当涉及到辐射率时,我们通常关心的是所有投射到点 p p p 上的光线的总和,而这个和就称为辐射照度或者辐照度(Irradiance)。辐照度表示为单位面积接收到光线能量的总和(这个面需要与光线垂直,若不垂直则要计算投影面积,即乘上 cos ⁡ θ \cos{\theta} cosθ)。

E = d Φ d A = ∫ Ω L i ( p , ω ) cos ⁡ θ d ω E=\frac{d\Phi}{dA}=\int_\Omega L_i(p,\omega)\cos{\theta} d\omega E=dAdΦ=ΩLi(p,ω)cosθdω

在理解了辐射率和辐照度的概念之后,让我们再回过头来看看反射方程:

L o ( p , ω o ) = ∫ Ω f r ( p , ω i , ω o ) L i ( p , ω i ) n ⋅ ω i d ω i L_o(p,\omega_o)=\int_\Omega f_r(p,\omega_i,\omega_o)L_i(p,\omega_i)n\cdot\omega_id\omega_i Lo(p,ωo)=Ωfr(p,ωi,ωo)Li(p,ωi)nωidωi

我们知道在渲染方程中 L L L 代表通过某个无限小的立体角 ω i \omega_i ωi 在某个点上的辐射率,而立体角可以视作是入射方向向量 ω i \omega_i ωi。我们需要利用 cos ⁡ θ \cos\theta cosθ (光线与平面的入射角的余弦值)来缩放能量,这在反射方程中表现为 n ⋅ ω i n\cdot\omega_i nωi。用 ω o \omega_o ωo 表示观察方向,也就是出射方向,反射率公式计算了点 p p p ω o \omega_o ωo 方向上被反射出来的辐射率的总和。或者换句话说: L o L_o Lo 表示了从 ω o \omega_o ωo 方向上观察,光线投射到点 p p p 上反射出来的辐照度。

反射方程基于辐照度,即我们测量的所有入射辐射率的总和。所以我们需要计算的就不只单是一个方向上的入射光,而是一个以点 p p p 为球心的半球领域 Ω \Omega Ω 内所有方向上的入射光。一个 半球领域(Hemisphere) 可以描述为以平面法线 n n n 为轴所环绕的半个球体:

image.png

这里没看懂的,强烈建议观看 games101 关于辐射度量学的讲解!

为了计算某个区域或(在半球情况下)体积内的值总和,我们会需要用到一种称为 积分(Integral) 的数学手段,在反射方程中以 ∫ \int 表示,它的运算覆盖了半球 Ω \Omega Ω 内所有入射方向 d ω i d\omega_i dωi。积分运算的值等于一个函数曲线的面积,它的计算结果要么是解析解要么就是数值解。由于渲染方程和反射方程都没有解析解,我们将会用离散的方法来求得这个积分的数值解。这个问题就转化为,在半球领域 Ω \Omega Ω 中按一定的步长将反射方程分散求解,然后再按照步长大小将所得到的结果平均化。这种方法被称为黎曼和(Riemann sum) ,我们可以用下面的代码粗略的演示一下:

int steps = 100;
float sum = 0.0f;
vec3 P    = ...;
vec3 Wo   = ...;
vec3 N    = ...;
float dW  = 1.0f / steps;
for(int i = 0; i < steps; ++i) 
{
    vec3 Wi = getNextIncomingLightDir(i);
    sum += Fr(P, Wi, Wo) * L(P, Wi) * dot(N, Wi) * dW;
}

通过利用 dW 来对所有离散部分进行缩放,其和最后就等于积分函数的总面积或者总体积。这个用来对每个离散步长进行缩放的 dW 可以认为就是反射方程中的 d ω i d\omega_i dωi。在数学上,用来计算积分的 d ω i d\omega_i dωi 表示的是一个连续的符号,而我们使用的 dW 在代码中和它并没有直接的联系(因为它代表的是黎曼和中的离散步长),但这样理解会有帮助。请牢记,使用离散步长得到的是函数总面积的一个近似值。细心的读者可能已经注意到了,我们可以通过增加离散部分的数量来提高黎曼和的准确度(Accuracy)。

反射方程概括了在半球领域 Ω \Omega Ω 内,碰撞到了点 p p p 的所有入射方向 ω i \omega_i ωi 上的光线的辐射率,并受到 f r f_r fr 的约束,然后返回观察方向上反射光的 L o L_o Lo。正如我们所熟悉的那样,入射光的辐射率可以由光源获得,此外还可以利用一个环境贴图来测算所有入射方向上的辐射率,我们将在未来的IBL教程中讨论这个方法。

现在唯一剩下的未知符号就是 f r f_r fr 了,它被称为BRDF,或者双向反射分布函数(Bidirectional Reflective Distribution Function) ,它的作用是基于表面材质属性来对入射辐射率进行缩放或者加权。

BRDF

BRDF,或者说双向反射分布函数,它接受入射(光)方向 ω i \omega_i ωi,出射(观察)方向 ω o \omega_o ωo,平面法线 n n n 以及一个用来表示微表面粗糙程度的参数 a a a 作为函数的输入参数。BRDF可以近似的求出每束光线对一个给定了材质属性的不透明平面上最终反射出来的光线所作出的贡献程度。举例来说,如果一个平面拥有完全光滑的表面(比如镜面),那么对于所有的入射光线 ω i \omega_i ωi(除了一束以外)而言,BRDF函数都会返回0.0 。只有一束与出射光线 ω o \omega_o ωo 拥有相同(被反射)角度的光线会得到1.0这个返回值。

BRDF 基于我们之前所探讨过的微表面理论来近似的求得材质的反射与折射属性。对于一个 BRDF,为了实现物理学上的可信度,它必须遵守能量守恒定律,也就是说反射光线的总和永远不能超过入射光线的总量。严格上来说,同样采用 ω i \omega_i ωi ω o \omega_o ωo 作为输入参数的 Blinn-Phong 光照模型也被认为是一个 BRDF。然而由于 Blinn-Phong 模型并没有遵循能量守恒定律,因此它不被认为是基于物理的渲染。现在已经有好几种BRDF都能近似的得出物体表面对于光的反应,但是几乎所有实时渲染管线使用的都是一种被称为 Cook-Torrance BRDF 模型。

Cook-Torrance BRDF 兼有漫反射和镜面反射两个部分:

f r = k d f l a m b e r t + k s f c o o k − t o r r a n c e f_r=k_df_{lambert}+k_sf_{cook-torrance} fr=kdflambert+ksfcooktorrance

这里的 k d k_d kd 是早先提到过的入射光线中被折射部分的能量所占的比率,而 k s k_s ks被反射部分的比率。BRDF 的左侧表示的是漫反射部分,这里用 f l a m b e r t f_{lambert} flambert 来表示。它被称为Lambertian漫反射,这和我们之前在漫反射着色中使用的常数因子类似,用如下的公式来表示:

f l a m b e r t = c π f_{lambert}=\frac{c}{\pi} flambert=πc

c c c 表示表面颜色(回想一下漫反射表面纹理)。除以 π \pi π 是为了对漫反射光进行标准化,因为前面含有 BRDF 的积分方程受 π \pi π 所影响(我们会在 IBL 的教程中探讨这个问题)。

你也许会感到好奇,这个 Lambertian 漫反射和我们之前经常使用的漫反射到底有什么关系:之前我们是用表面法向量与光照方向向量进行点乘,然后再将结果与平面颜色相乘得到漫反射参数。点乘依然还在,但是却不在 BRDF 之内,而是转变成为了 L o L_o Lo 积分公式末尾处的 n ⋅ ω i n\cdotω_i nωi

BRDF 的漫反射部分存在不同的方程,这些方程看起来更真实,但计算成本也更高。不过按照 Epic 公司给出的结论,对于大多数实时渲染目的,Lambertian 反射已经足够。

BRDF 的镜面反射部分更为复杂,描述为:

f c o o k − t o r r a n c e = D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) f_{cook-torrance}=\frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)} fcooktorrance=4(ωon)(ωin)DFG

Cook-Torrance BRDF 的镜面反射部分包含三个函数,此外分母部分还有一个标准化因子。字母D,F与G分别代表着一种类型的函数,各个函数分别用来近似的计算出表面反射特性的一个特定部分。三个函数分别为法线分布函数(Distribution of Normal),菲涅尔方程(Fresnel Rquation)和几何函数(Geometry Function):

  • 法线分布函数:估算在受到表面粗糙度的影响下,朝向方向与半程向量一致的微表面的数量。这是用来估算微表面的主要函数。
  • 几何函数:描述了微表面自成阴影的属性。当一个平面相对比较粗糙的时候,平面表面上的微表面有可能挡住其他的微表面从而减少表面所反射的光线。
  • 菲涅尔方程:菲涅尔方程描述的是在不同的入射角(法线与观察方向的夹角)下表面所反射的光线所占的比率。

以上的每一种函数都是用来估算相应的物理参数的,而且你会发现用来实现相应物理机制的每种函数都有不止一种形式。它们有的非常真实,有的则性能高效。你可以按照自己的需求任意选择自己想要的函数实现方法。英佩游戏公司的 Brian Karis 对于这些函数的多种近似实现方式进行了大量的研究。我们将会采用Epic Games 在 Unreal Engine 4中所使用的函数:

  • D 使用 Trowbridge-Reitz GGX
  • F 使用 Fresnel-Schlick 近似(Fresnel-Schlick Approximation)
  • G 使用 Smith’s Schlick-GGX

法线分布函数

法线分布函数(Normal Distribution Function, NDF) D 从统计学上近似计算微表面中那些取向方向与半程向量 h 保持一致的比率。举例来说,假设给定半程向量 h h h,如果我们的微表面中有35%与半程向量 h h h 取向一致,则法线分布函数或者说NDF将会返回0.35。目前有很多种 NDF 都可以从统计学上来根据粗糙度等参数近似估算微表面的总体取向度。我们马上将要用到的是 Trowbridge-Reitz GGX:

N D F G G X T R ( n , h , α ) = α 2 π ( ( n ⋅ h ) 2 ( α 2 − 1 ) + 1 ) 2 NDF_{GGXTR}(n,h,\alpha)=\frac{\alpha^2}{\pi((n\cdot h)^2(\alpha^2-1)+1)^2} NDFGGXTR(n,h,α)=π((nh)2(α21)+1)2α2

在这里 h h h 表示半程向量,而 a a a 表示表面粗糙度。如果我们将 h h h 设为表面法线与观察方向向量之间的中间向量,并在不同的粗糙度参数下观察,我们会得到以下视觉结果:

image.png

当粗糙度很低(也就是说表面很光滑)的时候,与半程向量取向一致的微表面会高度集中在一个很小的半径范围内。由于这种集中性,NDF 最终会生成一个非常明亮的斑点。但是当表面比较粗糙的时候,微表面的取向方向会更加的随机。你将会发现与 h h h 向量取向一致的微表面分布在一个大得多的半径范围内,但是同时较低的集中性也会让我们的最终效果显得更加灰暗。

最终效果呈现更灰暗,是因为本节教程忽略了光线在微表面之内多次弹射,并最后弹射出微表面的情况,这造成了能量损失。解决办法是为 BRDF 增加一个补偿项,更多的理论知识请参考 gams202 关于 PBR 的讲解:

在 GLSL 中,Trowbridge-Reitz GGX 法线分布函数表示为以下代码:

float DistributionGGX(vec3 N, vec3 H, float a)
{
    float a2     = a * a;
    float NdotH  = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
	
    float nom    = a2;
    float denom  = (NdotH2 * (a2 - 1.0) + 1.0);
    denom        = PI * denom * denom;
	
    return nom / denom;
}

几何函数

几何函数从统计学上近似的求得了微表面间相互遮蔽的比率,这种相互遮蔽会损耗光线的能量。

image.png

与 NDF 类似,几何函数采用一个材料的粗糙度参数作为输入参数,粗糙度较高的表面其微表面间相互遮蔽的概率就越高。我们将要使用的几何函数是 GGX 与Schlick-Beckmann 近似的结合体,因此又称为Schlick-GGX:

G S c h l i c k G G X ( n , v , k ) = n ⋅ v ( n ⋅ v ) ( 1 − k ) + k G_{SchlickGGX}(n,v,k)=\frac{n\cdot v}{(n\cdot v)(1-k)+k} GSchlickGGX(n,v,k)=(nv)(1k)+knv

这里 k k k 是根据我们是将几何函数用于直接光照还是 IBL 光照,对粗糙度参数 α α α 的重映射(Remapping):

k d i r e c t = ( α + 1 ) 2 8 k_{direct}=\frac{(\alpha+1)^2}{8} kdirect=8(α+1)2

k I B L = α 2 2 k_{IBL}=\frac{\alpha^2}{2} kIBL=2α2

注意,根据你的引擎把粗糙度转化为 α α α 的方式不同,得到 α α α 的值也有可能不同。在接下来的教程中,我们将会深入地讨论这个重映射是如何起作用的。

为了有效地近似几何部分,我们需要同时考虑观察方向(几何遮蔽(Geometry Obstruction))和光照方向向量(几何阴影(Geometry Shadowing))。我们可以使用 Smith 方法同时考虑这两者:

G ( n , v , l , k ) = G s u b ( n , v , k ) G s u b ( n , l , k ) G(n,v,l,k)=G_{sub}(n,v,k)G_{sub}(n,l,k) G(n,v,l,k)=Gsub(n,v,k)Gsub(n,l,k)


Smith 方法基于以下假设:

  • 微表面高度场服从正态分布​(如高斯分布)。
  • 遮蔽(Obstruction)和阴影(Shadowing)效应可分离,即光线方向(阴影)和视线方向(遮蔽)的遮挡概率相互独立。

通过统计建模,Smith 方法将几何函数 G 分解为两部分:

G ( ω i ​ , ω o ​ ) = G ​ ( ω i ​ ) ⋅ G ( ω o ​ ) G(\omega_i​,\omega_o​)=G​(\omega_i​) \cdot G_(\omega_o​) G(ωi,ωo)=G(ωi)G(ωo)

  • G ( ω i ​ ) G(\omega_i​) G(ωi):光线方向 ω i \omega_i ωi 的阴影概率。
  • G ​ ( ω o ​ ) G​(\omega_o​) G(ωo):观察方向 ω o \omega_o ωo ​ 的遮蔽概率。

使用 Smith 方法结合 Schlick-GGX 作为子函数 G s u b G_{sub} Gsub,在不同粗糙度 α \alpha α 下的视觉效果如下:

image.png

几何函数是一个介于 [0.0, 1.0] 之间的乘数,其中1.0(或白色)表示没有微表面阴影,0.0(或黑色)表示彻底被遮蔽。

使用 GLSL 编写的几何函数代码如下:

float GeometrySchlickGGX(float NdotV, float k)
{
    float nom   = NdotV;
    float denom = NdotV * (1.0 - k) + k;
	
    return nom / denom;
}
  
float GeometrySmith(vec3 N, vec3 V, vec3 L, float k)
{
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx1 = GeometrySchlickGGX(NdotV, k);
    float ggx2 = GeometrySchlickGGX(NdotL, k);
	
    return ggx1 * ggx2;
}

菲涅尔方程

菲涅尔方程(Fresnel Equation,发音为“Freh-nel”)方程描述的是被反射的光线对比光线被折射的部分所占的比率,这个比率会随着我们观察的角度不同而不同。当光线碰撞到一个表面的时候,菲涅尔方程会根据观察角度告诉我们被反射的光线所占的百分比。利用这个反射比率和能量守恒原则,我们可以直接得出光线被折射的部分以及光线剩余的能量。

当垂直观察的时候,任何物体或者材质表面都有一个基础反射率(Base Reflectivity),但是如果以一定的角度往平面上看的时候所有反光都会变得明显起来。你可以自己尝试一下,用垂直的视角观察你自己的木制/金属桌面,此时一定只有最基本的反射性。但是如果你从近乎90度(译注:应该是指和法线的夹角)的角度观察的话反光就会变得明显的多。如果从理想的90度视角观察,所有的平面理论上来说都能完全的反射光线。这种现象被称为菲涅尔效应,用菲涅尔方程描述。

菲涅尔方程本身相当复杂,但幸运的是,我们可以使用 Fresnel-Schlick 近似 来简化它:

F S c h l i c k ( h , v , F 0 ) = F 0 + ( 1 − F 0 ) ( 1 − ( h ⋅ v ) ) 5 F_{Schlick}(h,v,F_0)=F_0+(1-F_0)(1-(h \cdot v))^5 FSchlick(h,v,F0)=F0+(1F0)(1(hv))5

其中 F 0 F_0 F0 表示平面的基础反射率,我们通过称为折射率(Indices of Refraction, IOR)的东西来计算。然后正如你可以从球体表面看到的那样,我们越是朝球面掠角的方向上看(此时视线和表面法线的夹角接近90度)菲涅尔现象就越明显,反光就越强:

image.png

菲涅尔方程还存在一些细微的问题。其中一个问题是 Fresnel-Schlick 近似仅仅对绝缘体或者说非金属表面有定义。对于导体(Conductor)表面(金属),使用它们的折射率计算基础反射率并不能得出正确的结果,这样我们就需要使用一种不同的菲涅尔方程来对导体表面进行计算。由于这样很不方便,所以我们预计算出平面对于法向入射的结果( F 0 F_0 F0,处于0度角,好像直接看向表面一样),然后基于相应观察角的 Fresnel-Schlick 近似对这个值进行插值,用这种方法来进行进一步的估算。这样我们就能对金属和非金属材质使用同一个公式了。

这里可以简单理解为:非金属的 F 0 F_0 F0 可以用折射率计算,金属的不能,为了简化流程,直接将 F 0 F_0 F0 预定义,然后使用 Fresnel-Schlick 近似以简化流程。

平面对于法向入射的响应或者说基础反射率可以在一些大型数据库中找到,比如这个。下面列举的这一些常见数值就是从 Naty Hoffman 的课程讲义中所得到的:

image.png

这里可以观察到的一个有趣的现象,所有绝缘体材质表面的基础反射率都不会高于 0.17,这其实是例外而非普遍情况。导体材质表面的基础反射率起点更高一些并且(大多)在 0.5 和 1.0 之间变化。此外,对于导体或者金属表面而言基础反射率一般是带有色彩的,这也是为什么 F 0 F_0 F0 要用 RGB 三原色来表示的原因(法向入射的反射率可随波长不同而不同)。这种现象我们只能在金属表面观察的到。

这些金属表面相比于绝缘体表面所独有的特性引出了所谓的金属工作流的概念。也就是我们需要额外使用一个被称为金属度(Metalness)的参数来参与编写表面材质。金属度用来描述一个材质表面是金属还是非金属的。

理论上来说,一个表面的金属度应该是二元的:要么是金属要么不是金属,不能两者皆是。但是,大多数的渲染管线都允许在0.0至1.0之间线性的调配金属度。这主要是由于材质纹理精度不足以描述一个拥有诸如细沙/沙状粒子/刮痕的金属表面。通过对这些小的类非金属粒子/刮痕调整金属度值,我们可以获得非常好看的视觉效果。

通过为绝缘体和导体预计算 F 0 F_0 F0,我们可以使用相同的 Fresnel-Schlick 近似处理两种表面,但是如果是金属表面的话就需要对基础反射率添加色彩。我们一般是按下面这样来实现的:

vec3 F0 = vec3(0.04);
F0      = mix(F0, surfaceColor.rgb, metalness);

我们定义了一个适用于大多数绝缘体表面的近似基础反射率。 F 0 F_0 F0 取最常见的绝缘体表面的平均值,这又是一个近似值。不过对于大多数绝缘体表面而言使用0.04 作为基础反射率已经足够好了,而且可以在不需要输入额外表面参数的情况下得到物理可信的结果。然后,根据表面的金属度,我们将绝缘体的基础反射率与金属的基础反射率进行 mix,又因为金属表面会吸收所有折射光线而没有漫反射,所以我们可以直接使用表面颜色纹理来作为它们的基础反射率。

Fresnel Schlick近似可以用代码表示为:

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

其中 cosTheta 是表面法线向量 n n n 与半程向量 h h h(或观察方向向量 v v v)的点积结果。

关于金属度,diffuse 与 specular 的理解,这篇文章尤其推荐:金属,塑料,傻傻分不清楚 - 知乎

Cook-Torrance反射方程

在描述了Cook-Torrance BRDF的每个组成部分后,我们可以将基于物理的BRDF纳入现在最终的反射方程中:

L o ( p , ω o ) = ∫ Ω ( k d c π + k s 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}+k_s\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+ks4(ωon)(ωin)DFG)Li(p,ωi)nωidωi

然而,这个方程在数学上并不完全正确。你可能还记得,菲涅尔项 F F F 表示平面上反射光线的比例。这实际上就是我们的比例 k s k_s ks,意味着反射方程的镜面(BRDF)部分隐式地包含了反射比例 k s k_s ks。鉴于此,我们最终的反射方程变为:

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)=\int_{\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(ωon)(ωin)DFG)Li(p,ωi)nωidωi

这个方程现在完整的描述了一个基于物理的渲染模型,它现在可以认为就是我们一般意义上理解的基于物理的渲染(也就是 PBR)。如果你还没有能完全理解我们将如何把所有这些数学运算结合到一起并融入到代码当中去的话也不必担心。在下一个教程当中,我们将探索如何实现反射方程来在我们渲染的光照当中获得更加物理可信的结果,而所有这些零零星星的碎片将会慢慢组合到一起来。

编写PBR材质

在了解了PBR的底层数学模型后,我们将通过描述艺术家通常如何创作表面的物理属性来结束讨论,这些属性可以直接输入到PBR方程中。PBR管线所需的每个表面参数都可以通过纹理来定义或建模。使用纹理使我们能够逐片段控制每个表面点如何对光做出反应:该点是金属的、粗糙的还是光滑的,或者表面如何响应不同波长的光。

以下是你会在 PBR 管线中经常看到的纹理列表,以及将其提供给 PBR 渲染器时的视觉输出:

image.png

  • 反照率(Albedo):反照率纹理为每个纹素指定表面的颜色,或者如果该纹素是金属的,则指定基础反射率。这和我们之前使用过的漫反射纹理相当类似,不同的是所有光照信息都是由一个纹理中提取的。漫反射纹理的图像当中常常包含一些细小的阴影或者深色的裂纹,而反照率纹理中是不会有这些东西的。它应该只包含表面的颜色(或者折射吸收系数)。
  • 法线(Normal):法线贴图纹理和我们之前在法线贴图教程中所使用的贴图是完全一样的。法线贴图使我们可以逐片段的指定独特的法线,来为表面制造出起伏不平的假象。
  • 金属度(Metallic):金属度贴图为每个纹素指定该纹素是金属的还是非金属的。根据PBR引擎设置的不同,美术师们既可以将金属度编写为灰度值又可以编写为 1 或 0 这样的二元值。
  • 粗糙度(Roughness):粗糙度贴图可以以纹素为单位指定某个表面有多粗糙。采样得来的粗糙度数值会影响一个表面的微表面统计学上的取向度。一个比较粗糙的表面会得到更宽阔更模糊的镜面反射(高光),而一个比较光滑的表面则会得到集中而清晰的镜面反射。某些 PBR 引擎预设采用的是对某些美术师来说更加直观的光滑度(Smoothness)贴图而非粗糙度贴图,不过这些数值在采样之时就马上用(1.0 – 光滑度)转换成了粗糙度。
  • 环境光遮蔽(AO):环境光遮蔽(AO)贴图指定表面及潜在周围几何体的额外阴影因子。例如,对于砖块表面,反照率纹理不应包含砖缝中的阴影信息。然而,AO贴图会指定这些变暗的边缘,因为光线更难逃逸。在光照阶段末尾考虑环境光遮蔽可以显著提升场景的视觉质量。网格/表面的AO贴图可以手动生成,或在3D建模程序中预计算。

美术师们可以在纹素级别设置或调整这些基于物理的输入值,还可以以现实世界材料的表面物理性质来建立他们的材质数据。这是 PBR 渲染管线最大的优势之一,因为不论环境或者光照的设置如何,改变这些表面的性质是不会改变的,这使得美术师们可以更便捷地获取物理可信的结果。在 PBR 渲染管线中编写的表面可以非常方便的在不同的PBR渲染引擎间共享使用,不论处于何种环境中,它们看上去都会是正确的,因此看上去也会更自然。

延伸阅读


光照


引言

上一个教程中,我们讨论了一些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)=\int_{\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(ωon)(ωin)DFG)Li(p,ωi)nωidωi

我们大致上清楚这个反射方程在干什么,但我们仍然留有一些迷雾尚未揭开:我们如何具体表示场景的 辐照度(Irradiance)辐射率(radiance) L L L。我们知道辐射率 L L L(在计算机图形领域中)表示光源的 辐射通量(Radiant flux) ϕ \phi ϕ,或光源在给定立体角 ω \omega ω 下发出的光能。在我们的例子中,我们假设立体角 ω \omega ω 是无穷小的,此时辐射率测量的是光源在单条光线或方向向量上的辐射通量。

基于以上的知识,我们如何将其转化为之前的教程中所积累的一些光照知识呢?假设我们有一个单一的点光源(一个在所有方向都具有相同亮度的光源),其辐射通量为用RGB表示的三元组(23.47, 21.31, 20.79)。该光源的 辐射强度(Radiant Intensity) 在其所有出射方向光线上等于其辐射通量。然而,当我们为表面上的某个特定点 p p p 着色时,在其半球领域 Ω \Omega Ω 的所有可能的入射方向上,只有一个入射方向向量 ω i \omega_i ωi 直接来自于该点光源。假设我们在场景中只有一个光源,位于空间中的某一个点,因而对于 p p p 点的其他可能的入射光线方向上的辐射率为0:

image.png

如果从一开始,我们就假设点光源不受光线衰减(光照强度会随着距离变暗)的影响,那么无论我们把光源放在哪,入射光线的辐射率总是一样的(除去入射角 cos ⁡ θ \cos{\theta} cosθ 对辐射率的影响之外)。这是因为无论我们从哪个角度观察它,点光源总具有相同的辐射强度,我们可以有效地将其辐射强度建模为其辐射通量: 一个常量向量 (23.47, 21.31, 20.79)

然而,辐射率也需要将位置 p p p 作为输入,并且任何现实的点光源都会考虑光的衰减,因此点光源的辐射强度会根据点 p p p 与光源之间的距离进行某种程度的缩放。然后,根据原始辐射方程提取的结果,会再乘以表面法线 n n n 与入射光方向 ω i \omega_i ωi 的点积进行缩放。

用更实际的术语来说:对于直接点光源,辐射率函数 L L L 首先获取光源的颜色,然后根据其到 p p p 的距离进行衰减,接着按 n ⋅ ω i n \cdot \omega_i nωi 进行缩放,但这仅计算了击中 p p p 的单一光线 ω i \omega_i ωi(从 p p p 指向光源的方向向量)。在代码中,这翻译为:

vec3  lightColor  = vec3(23.47, 21.31, 20.79);
vec3  wi          = normalize(lightPos - fragPos);
float cosTheta    = max(dot(N, wi), 0.0);
float attenuation = calculateAttenuation(fragPos, lightPos);
vec3  radiance    = lightColor * attenuation * cosTheta;

除了术语不同,这段代码对你来说应该非常熟悉:这正是我们迄今为止计算漫反射光照的方式。对于直接光照,辐射率的计算与我们之前计算光照的方式类似,因为只有一个光方向向量对表面的辐射率有贡献。

请注意,这个假设成立的条件是点光源体积无限小,相当于在空间中的一个点。如果我们认为该光源是具有体积的,它的辐射率会在不只一个入射光方向上非零。

对于其他从单点发出的光源类型,我们类似地计算辐射率。例如,方向光源具有恒定的 ω i \omega_i ωi,没有衰减因子。而聚光灯的辐射强度不是恒定的,而是按聚光灯的正向方向向量缩放。

对于其它类型的从单点发出来的光源我们类似地计算出辐射率。比如,定向光(directional light)拥有恒定的 ω i \omega_i ωi 而不会有衰减因子;而一个聚光灯光源则没有恒定的辐射强度,其辐射强度会根据聚光灯的方向向量来缩放。

这也让我们回到表面半球 Ω \Omega Ω 上的积分 ∫ \int 。由于我们事先知道所有贡献光源的位置,因此对物体表面上的一个点着色我们并不需要尝试去求解积分。我们可以直接拿光源的(已知的)数目,去计算它们的总辐照度,因为每个光源仅仅只有一个方向上的光线会影响物体表面的辐射率。这使得直接光源上的PBR相对简单,因为我们实际上只需循环遍历有贡献的光源。而当在把环境照明也考虑在内的 IBL 教程中,我们就必须采取积分去计算了,因为光可能从任何方向传来。

一个PBR表面模型

现在让我们开始写片段着色器来实现上述的PBR模型吧~ 首先我们需要把PBR相关的输入放进片段着色器。

#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;

uniform vec3 camPos;

uniform vec3  albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;

我们把通用的顶点着色器的输出作为输入的一部分。另一部分输入则是物体表面模型的一些材质参数。

然后,在片段着色器的开头,我们进行任何光照算法所需的常规计算:

void main()
{
    vec3 N = normalize(Normal); 
    vec3 V = normalize(camPos - WorldPos);
    [...]
}

直接光照

在本章的例子中我们会采用总共4个点光源来表示场景的辐照度。为了满足反射方程,我们需要遍历每个光源,计算其单独的辐射率,并将其贡献与BRDF及光线的入射角缩放后求和。我们可以将这个循环视为对直接光源在半球 Ω \Omega Ω 上求解积分 ∫ \int 。首先,我们计算每个光源的相关变量:

vec3 Lo = vec3(0.0);
for(int i = 0; i < 4; ++i) 
{
    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; 
    [...]

由于我们在线性空间内计算光照(我们会在着色器的最后进行Gamma校正),我们使用在物理上更为准确的平方倒数作为衰减。

虽然这在物理上是正确的,但你可能仍想使用常数-线性-二次衰减方程(尽管它在物理上不正确),因为它能为你提供对光能量衰减的更多控制。

然后,对于每个光源,我们需要计算完整的Cook-Torrance镜面BRDF项:

D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) \frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)} 4(ωon)(ωin)DFG

我们首先要做的,是计算镜面反射与漫反射之间的比例,即表面反射光线与折射光线的比例。我们从上一章知道,菲涅尔方程正是用来计算这个比例的(注意这里使用了 clamp 来防止黑点出现):

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

Fresnel-Schlick近似法接收一个参数 F0,被称为0°入射角的反射率,或者说是直接(垂直)观察表面时有多少光线会被反射。这个参数 F0 会因为材料不同而不同,而且对于金属材质会带有颜色。在PBR金属工作流中我们简单地认为大多数的绝缘体在 F0 为0.04的时候看起来视觉上是正确的,对于金属表面我们根据反射率特别地指定 F0。因此代码上看起来会像是这样:

vec3 F0 = vec3(0.04); 
F0      = mix(F0, albedo, metallic);
vec3 F  = fresnelSchlick(max(dot(H, V), 0.0), F0);

可以看到,对于非金属表面,F0 始终为0.04。对于金属表面,我们根据金属度属性,在原始 F0 和金属的反射率值之间进行线性插值来改变 F0

我们已经算出 F F F,剩下的项就是计算法线分布函数 D D D 和几何遮蔽函数 G G G 了。

在直接PBR光照着色器中 D D D 代码:

N D F G G X T R ( n , h , α ) = α 2 π ( ( n ⋅ h ) 2 ( α 2 − 1 ) + 1 ) 2 NDF_{GGXTR}(n,h,\alpha)=\frac{\alpha^2}{\pi((n\cdot h)^2(\alpha^2-1)+1)^2} NDFGGXTR(n,h,α)=π((nh)2(α21)+1)2α2

float DistributionGGX(vec3 N, vec3 H, float roughness)
{
    float a      = roughness * roughness;
    float a2     = a * a;
    float NdotH  = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
	
    float num   = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom       = PI * denom * denom;
	
    return num / denom;
}

这里比较重要的是和理论章节相比,我们直接把粗糙度(roughness)作为参数传给了上述函数;通过这种方式,我们可以针对每一个不同的项对粗糙度做一些修改。根据迪士尼公司给出的观察以及后来被Epic Games公司采用的光照模型,在几何遮蔽函数和法线分布函数中采用粗糙度的平方会让光照看起来更加自然。

G G G 的代码:

G S c h l i c k G G X ( n , v , k ) = n ⋅ v ( n ⋅ v ) ( 1 − k ) + k G_{SchlickGGX}(n,v,k)=\frac{n\cdot v}{(n\cdot v)(1-k)+k} GSchlickGGX(n,v,k)=(nv)(1k)+knv

k d i r e c t = ( α + 1 ) 2 8 k_{direct}=\frac{(\alpha+1)^2}{8} kdirect=8(α+1)2

G ( n , v , l , k ) = G s u b ( n , v , k ) G s u b ( n , l , k ) G(n,v,l,k)=G_{sub}(n,v,k)G_{sub}(n,l,k) G(n,v,l,k)=Gsub(n,v,k)Gsub(n,l,k)

float GeometrySchlickGGX(float NdotV, float roughness)
{
    float r = (roughness + 1.0);
    float k = (r * r) / 8.0;

    float num   = NdotV;
    float denom = NdotV * (1.0 - k) + k;
	
    return num / denom;
}

float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx2  = GeometrySchlickGGX(NdotV, roughness);
    float ggx1  = GeometrySchlickGGX(NdotL, roughness);
	
    return ggx1 * ggx2;
}

定义了这两个函数后,在反射循环中计算NDF和G项就很简单了:

float NDF = DistributionGGX(N, H, roughness);       
float G   = GeometrySmith(N, V, L, roughness);       

这样我们就凑够了足够的项来计算Cook-Torrance BRDF:

D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) \frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)} 4(ωon)(ωin)DFG

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

注意,我们在分母中添加了0.0001,以防止点积为0.0时出现除以零的情况。

现在,我们终于可以计算每个光源对反射方程的贡献。由于菲涅尔值直接对应于 k S k_S kS,我们可以使用 F 表示所有打在物体表面上的镜面反射光的贡献。从 k S k_S kS 我们可以很容易的计算折射比例 k D k_D kD

vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
  
kD *= 1.0 - metallic;

我们可以认为 k S k_S kS 表示光能中被反射的能量的比例,而剩下的光能会被折射,比值为 k D k_D kD。此外,因为金属不会折射光线,因此不会有漫反射。所以如果表面是金属的,我们会把系数 k D k_D kD 变为0。这样,我们终于集齐所有变量来计算出射光线的值:

    const float PI = 3.14159265359;
  
    float NdotL = max(dot(N, L), 0.0);        
    Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}

所得的 Lo 值,就是出射光线的辐射率,实际上是反射方程在半球 Ω \Omega Ω 上积分 ∫ \int 的结果。但是我们实际上不需要去求积分,因为对于所有可能的入射光线方向,我们知道只有4个方向的入射光线会影响片段的着色。因此,我们可以直接遍历这些入射光方向。

剩下的工作就是加一个环境光照项给 Lo,然后我们就拥有了片段的最后颜色:

vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color   = ambient + Lo;  

线性空间和HDR渲染

到目前为止,我们假设的所有计算都在线性颜色空间中进行,因此我们需要在着色器最后做伽马矫正。在线性空间中计算光照非常重要,因为PBR要求所有输入都是线性的。如果不考虑这一点,将导致光照计算错误。此外,我们希望光照的输入接近其物理等效值,使得它们的辐射率或颜色值可以在色谱上有比较大的变化空间。因此,Lo 作为结果可能会得变大(超过1),然后由于默认低动态范围(LDR)输出的限制,被裁剪到0.0到1.0之间。所以在伽马矫正之前我们采用色调映射使 Lo 从LDR的值映射为HDR的值。

color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2));

这里我们采用的色调映射方法为 Reinhard 操作,使得我们在伽马矫正后可以保留尽可能多的辐照度变化。我们没有单独的帧缓冲区或后处理阶段,因此可以在前向片段着色器的末尾直接应用色调映射和伽马校正步骤。

image.png

采用线性颜色空间和HDR在PBR渲染管线中非常重要。如果没有这些操作,几乎是不可能正确地捕获到因光照强度变化的细节,这最终会导致你的计算变得不正确,在视觉上看上去非常不自然。

完整的直接光照PBR着色器

现在剩下的事情就是把经过色调映射和伽马矫正的颜色值传给片段着色器的输出,然后我们就拥有了自己的直接光照PBR着色器。 为了完整性,这里给出了完整的片段着色器代码:

#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;

// 材质参数
uniform vec3  albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;

// 光源
uniform vec3 lightPositions[4];
uniform vec3 lightColors[4];

uniform vec3 camPos;

const float PI = 3.14159265359;
  
float DistributionGGX(vec3 N, vec3 H, float roughness);
float GeometrySchlickGGX(float NdotV, float roughness);
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness);
vec3 fresnelSchlick(float cosTheta, vec3 F0);

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

    vec3 F0 = vec3(0.04); 
    F0 = mix(F0, albedo, metallic);
	           
    // 反射方程
    vec3 Lo = vec3(0.0);
    for(int i = 0; i < 4; ++i) 
    {
        // 计算每个光源的辐射率
        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(max(dot(H, V), 0.0), F0);       
        
        vec3 kS = F;
        vec3 kD = vec3(1.0) - kS;
        kD *= 1.0 - metallic;	  
        
        vec3 numerator    = NDF * G * F;
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
        vec3 specular     = numerator / denominator;  
            
        // 添加到出射辐射率 Lo
        float NdotL = max(dot(N, L), 0.0);                
        Lo += (kD * albedo / PI + specular) * radiance * NdotL; 
    }   
  
    vec3 ambient = vec3(0.03) * albedo * ao;
    vec3 color = ambient + Lo;
	
    color = color / (color + vec3(1.0));
    color = pow(color, vec3(1.0/2.2));  
   
    FragColor = vec4(color, 1.0);
}

希望通过上一章的理论知识以及学习过关于反射方程的一些知识后,这个着色器不再显得那么令人畏惧。如果我们采用这个着色器,加上4个点光源和一些球体,同时令这些球体的金属性(metallic)和粗糙度(roughness)沿垂直和水平方向分别变化,我们会得到这样的结果:

image.png

(上述图片)从下往上球体的金属性从0.0变到1.0,从左到右球体的粗糙度从0.0变到1.0。你可以看到仅仅改变这两个值,显示的效果就会发生巨大的改变!

你可以在这里找到源代码

本次项目源码:直接光照 - GitCode

带贴图的PBR

将系统扩展为接受纹理而非均匀值作为表面参数,可以让我们逐片段控制表面材质的属性:

[...]
uniform sampler2D albedoMap;
uniform sampler2D normalMap;
uniform sampler2D metallicMap;
uniform sampler2D roughnessMap;
uniform sampler2D aoMap;
  
void main()
{
    vec3 albedo     = pow(texture(albedoMap, TexCoords).rgb, vec3(2.2));
    vec3 normal     = getNormalFromNormalMap();
    float metallic  = texture(metallicMap, TexCoords).r;
    float roughness = texture(roughnessMap, TexCoords).r;
    float ao        = texture(aoMap, TexCoords).r;
    [...]
}

需要注意的是美术家提供的反射率(albedo)纹理通常是在sRGB空间中创作的,因此我们在使用反射率进行光照计算之前需要先将它们转换到线性空间。根据生成环境光遮蔽贴图的系统,你可能也需要将它从sRGB转换到线性空间。金属度和粗糙度贴图几乎总是在线性空间中创作的。不过金属性(Metallic)和粗糙度(Roughness)贴图大多数都会保证在线性空间中进行创作。

只是把之前的球体的材质性质换成纹理属性,就会在视觉上有巨大的提升:

PixPin_2025-03-22_15-32-56.gif

你可以在这里找到纹理贴图示例的完整源代码,以及使用的纹理集(带有白色AO贴图)。请记住,在直接光照环境中,金属表面由于没有漫反射而往往看起来太暗。当考虑环境的镜面环境光照时,它们会显得更正确,这将是我们在接下来的章节中关注的重点。

本次项目源码:贴图PBR - GitCode


源代码中将切线空间的法线转化到世界空间的代码值得注意:

vec3 getNormalFromMap()
{
    vec3 tangentNormal = texture(normalMap, TexCoords).xyz * 2.0 - 1.0;

    vec3 Q1  = dFdx(WorldPos);
    vec3 Q2  = dFdy(WorldPos);
    vec2 st1 = dFdx(TexCoords);
    vec2 st2 = dFdy(TexCoords);

    vec3 N   = normalize(Normal);
    vec3 T  = normalize(Q1*st2.t - Q2*st1.t);
    vec3 B  = -normalize(cross(N, T));
    mat3 TBN = mat3(T, B, N);

    return normalize(TBN * tangentNormal);
}

dFdxdFdy 是 GLSL 中用于计算屏幕空间内变量沿 x 轴和 y轴方向变化率的函数。

假设物体表面点坐标可用纹理坐标参数化:

P ⃗ ( s , t ) = W o r l d P o s ( s , t ) \vec{P}(s,t)=\mathrm{WorldPos}(s,t) P (s,t)=WorldPos(s,t)

其中 ( s , t ) (s,t) (s,t) 是纹理坐标,对应着色器中的 TexCoords

通过 dFdxdFdy 得到屏幕空间导数:

{ ∂ P ⃗ ∂ x = ∂ s ∂ x ⋅ ∂ P ⃗ ∂ s + ∂ t ∂ x ⋅ ∂ P ⃗ ∂ t ∂ P ⃗ ∂ y = ∂ s ∂ y ⋅ ∂ P ⃗ ∂ s + ∂ t ∂ y ⋅ ∂ P ⃗ ∂ t \begin{cases}\frac{\partial\vec{P}}{\partial x}=\frac{\partial s}{\partial x}\cdot\frac{\partial\vec{P}}{\partial s}+\frac{\partial t}{\partial x}\cdot\frac{\partial\vec{P}}{\partial t}\\\frac{\partial\vec{P}}{\partial y}=\frac{\partial s}{\partial y}\cdot\frac{\partial\vec{P}}{\partial s}+\frac{\partial t}{\partial y}\cdot\frac{\partial\vec{P}}{\partial t}&\end{cases} {xP =xssP +xttP yP =yssP +yttP

代码中:

∂ P ⃗ ∂ x = Q 1 ∂ P ⃗ ∂ y = Q 2 ∂ s ∂ x = s t 1. s , ∂ t ∂ x = s t 1. t ∂ s ∂ y = s t 2. s , ∂ t ∂ y = s t 2. t \begin{aligned}&{\frac{\partial{\vec{P}}}{\partial x}}=\mathrm{Q}1\\&\frac{\partial\vec{P}}{\partial y}=\mathrm{Q}2\\&\frac{\partial s}{\partial x}=\mathrm{st}1.\mathrm{s},\frac{\partial t}{\partial x}=\mathrm{st}1.\mathrm{t}\\&\frac{\partial s}{\partial y}=\mathrm{st}2.\mathrm{s},\frac{\partial t}{\partial y}=\mathrm{st}2.\mathrm{t}\end{aligned} xP =Q1yP =Q2xs=st1.s,xt=st1.tys=st2.s,yt=st2.t

切线 T ⃗ = ∂ P ⃗ ∂ s \vec{T}=\frac{\partial{\vec{P}}}{\partial s} T =sP ,副切线 B ⃗ = ∂ P ⃗ ∂ t \vec{B}=\frac{\partial{\vec{P}}}{\partial t} B =tP ,则:

{ Q ⃗ 1 = s t 1. s ⋅ T ⃗ + s t 1. t ⋅ B ⃗ Q ⃗ 2 = s t 2. s ⋅ T ⃗ + s t 2. t ⋅ B ⃗ \begin{cases}\vec{Q}_1=\mathrm{st}1.\mathrm{s}\cdot\vec{T}+\mathrm{st}1.\mathrm{t}\cdot\vec{B}\\\vec{Q}_2=\mathrm{st}2.\mathrm{s}\cdot\vec{T}+\mathrm{st}2.\mathrm{t}\cdot\vec{B}&\end{cases} {Q 1=st1.sT +st1.tB Q 2=st2.sT +st2.tB

解此方程就可得到, T ⃗ \vec{T} T B ⃗ \vec{B} B

通过交叉相乘消元法解方程:

T ⃗ = Q ⃗ 1 ⋅ s t 2. t − Q ⃗ 2 ⋅ s t 1. t s t 1. s ⋅ s t 2. t − s t 2. s ⋅ s t 1. t \vec{T}=\frac{\vec{Q}_1\cdot\mathrm{st}2.\mathrm{t}-\vec{Q}_2\cdot\mathrm{st}1.\mathrm{t}}{\mathrm{st}1.\mathrm{s}\cdot\mathrm{st}2.\mathrm{t}-\mathrm{st}2.\mathrm{s}\cdot\mathrm{st}1.\mathrm{t}} T =st1.sst2.tst2.sst1.tQ 1st2.tQ 2st1.t

代码中忽略分母(归一化时自动处理),直接取分子部分:

T ⃗ ∝ Q ⃗ 1 ⋅ s t 2. t − Q ⃗ 2 ⋅ s t 1. t \vec{T}\propto\vec{Q}_1\cdot\mathrm{st}2.\mathrm{t}-\vec{Q}_2\cdot\mathrm{st}1.\mathrm{t} T Q 1st2.tQ 2st1.t

法线贴图中也提到了如何手动计算切线与副切线,这里的思路与其一致,请参考下图:

image.png

[ T x T y T z B x B y B z ] = 1 Δ U 1 Δ V 2 − Δ U 2 Δ V 1 [ Δ V 2 − Δ V 1 − Δ U 2 Δ U 1 ] [ E 1 x E 1 y E 1 z E 2 x E 2 y E 2 z ] \begin{bmatrix}T_x&T_y&T_z\\B_x&B_y&B_z\end{bmatrix}=\frac{1}{\Delta U_1\Delta V_2-\Delta U_2\Delta V_1}\begin{bmatrix}\Delta V_2&-\Delta V_1\\-\Delta U_2&\Delta U_1\end{bmatrix}\begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z}\end{bmatrix} [TxBxTyByTzBz]=ΔU1ΔV2ΔU2ΔV11[ΔV2ΔU2ΔV1ΔU1][E1xE2xE1yE2yE1zE2z]

Q 1 ⃗ \vec{Q_1} Q1 类比于 E 1 ⃗ \vec{E_1} E1 Q 2 ⃗ \vec{Q_2} Q2 类比于 E 2 ⃗ \vec{E_2} E2 s t 2. t \mathrm{st}2.\mathrm{t} st2.t 类比于 Δ V 2 \Delta V_2 ΔV2 s t 1. t \mathrm{st}1.\mathrm{t} st1.t 类比于 Δ V 1 \Delta V_1 ΔV1 。我们只需要得到切线 T T T,副切线 B B B 通过叉乘就可得到。


虽然与你在外面找到的一些PBR渲染演示相比(因为我们还没有加入基于图像的光照-IBL),视觉效果可能不是那么惊艳,但我们现在的系统仍然是一个基于物理的渲染器,即使没有IBL,你也会发现光照看起来更加真实。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MustardJim

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值