基于物理的渲染 -- Shader入门精要学习(17)

18 基于物理的渲染

在之前的章节中,我们学习了 Lambert 光照模型、Phong 光照模型合 Blinn-Phong 光照模型。这这些模型的缺点在于,它们都是经验模型。如果我们需要渲染更高质量的画面,这些经验模型就显得不再能满足我们的需求了。

近年来,基于**物理的渲染技术(Physically Based Shading, PBS)**被逐渐应用于实时渲染中。总体来说,PBS 是为例对光和材质之间的行为进行更加真实的建模。PBS 早已被广泛应用到电影行业中,但游戏中的 PBS 是近年来才逐渐流行起来的。Unity 最早在 2012 年的《蝴蝶效应》的 demo 中大量使用了 PBS,并在 Unity 5 中正式将 PBS 引入到引擎渲染中。Unity 5 引入了一个名为 Standard Shader 的可在不同材质之间通用的着色器,而该着色器就是使用了基于物理的光照模型。需要注意的是,PBS 并不意味着渲染出来的画面一定是像照片一样真实的,例如 Pixar 和 Disney 尽管长期使用 PBS 渲染电影画面,但它们得到的风格是非常有特色的艺术风格。相信很多人或多或少都看到过 PBS 渲染出来的画面是多么的酷炫,并很想了解这背后的技术原理。如果你是一个程序员,可能有很大冲动想要实现一个 PBS 的渲染框架,但往往走到后面会发现有很多看不懂的名词以及一大堆与之相关的论文;如果你是一个美工人员,你可能会找到很多关于如何制作 PBS 中使用的纹理教程,但你也大概了解,想要使用 PBS 实现出色的渲染效果,并不是纹理 + 一个 shader 这么简单的问题。

Unity 5 引入的基于物理的渲染不需要我们过多地了解 PBS 是如何实现的,就能利用各种内置工具来实现一个不错的渲染效果。我们很难通过短短的文字来详细地解释这些渲染到底是如何实现的,因为这其中需要牵扯许多复杂的光照模型,如果要完全理解每一种模型的话,大概还要讲很多论文和其他参考文献。

本章中,我们首先会讲解 PBS 的基本原理,我们会花一些时间来介绍Unity 5 引入的 Standard Shader 是如何工作的,以及如何在 Unity 5 中使用它和其他工具来渲染一个场景。

1 PBS的理论和数学基础

主要参考 Naty Hoffman 在 SIGGRAPH 2013 上做的名为 Background:Physics and Math of Shading 的演讲。

1.1 光是什么

光是一种电磁波,首先,光由太阳或其他光源中被发射出来,然后于场景中的对象相交,一些光线被吸收(absorption),而另一些责备散射(scattering),最后光线被一个感应器吸收成像。

通过上面的过程,我们知道材质和光线相交会发生两种物理现象:散射和吸收(其实还有自发光现象)。光线会被吸收是由于光被转化成了其他能量,但吸收并不会改变光的传播方向。相反的,散射则不会改变光的能量,但会改变它的传播方向。在光的传播过程中,影响光的一个重要特效是材质的折射率(refractive rate)。在均匀的介质中,光是沿着直线传播的,但如果光在传播时介质的折射率发生了变化,光的传播方向就会发生变化。特别是,如果折射率是突变的,就会发生光的散射现象。

实际上,在现实生活中,光和物体之间的交互过程是非常复杂的,大多数情况下并不存在一种可分析的解决办法。但为了在渲染中对光照进行建模,我们往往只考虑一种特殊情况,即只考虑两个介质的边界是无限大并且是光学平滑(optical flat)的。尽管真实物体的表面并不是无限延伸的,也不是绝对光滑的,但和光的波长相比,它们的大小可以被近似认为是无限大以及光学平滑的。在这样的前提下,光在不同的介质边界会被分割成两个方向:反射方向和折射方向。而有多少百分比的光会被反射(另一部分就是被折射了)则是由**菲涅尔等式(Fresnel equations)**来描述的,如下图所示。

在这里插入图片描述

但是,这些与光线的交界处真的是像镜子一样平坦吗?尽管在上面我们已经说过,相对于光的波长来说,它们的确可以被认为是光学平坦的。但是,如果想象我们有一个高倍放大镜,去放大这些被照亮的物体表面,就会法线有很多之前肉眼不可见的凹凸不平的平面。在这种情况下,物体表面和光照发生的各种行为,更像是一系列微小的光学平面和光交互的结果,其中每个小平面会把光分割成不同的方向。

这种建立在微表面的模型更容易解释为什么有些物体看起来粗糙,而有些看起来就平滑。想象我们用一个放大镜去观察一个光滑物体的表面,尽管它的表面由许多凹凸不平的微表面构成,但这些微表面的法线方向变化角度小,因此,由这些表面反射的光线方向变化也比较小。如下图左所示,这使得物体的高光反射更加清晰。而下图右所示的粗糙表面则相反,由此得到的高光反射效果更模糊。

在这里插入图片描述

在上面的内容中,我们并没有讨论那些被微表面折射的光。这些光被折射到物体的内部,一部分被介质吸收,一部分又被散射到外部。金属材质具有很高的吸收系数,因此,所有被折射的光往往会被立刻吸收,被金属内部的自由电子转换成其他形式的能量。而非金属材质则会同时表现除吸收和散射两种现象,这些被散射出去的光又被称为次表面反射光(subsurface-scattered light)。我们在下图中给出了一条由微表面折射的光的传播路径。

在这里插入图片描述

现在,我们把放大镜从物体表面拿开,继续从渲染的层级大小上考虑光与表面一点的交互行为。那么,由微表面反射的光可以被认为是该点上一些方向变化不大的反射光。而折射光线则需要更多的考虑。那些次表面散射光会从不同于入射点的位置从物体内部再次射出,如下左图所示。而这些离入射点的距离值和像素大小之前的关系会产生两种建模结果。如果像素要大于这些散射距离的话,意味着这些次表面散射产生的距离可以被忽略,那我们的渲染就可以在局部进行,如下右图所示。如果像素要小于这些散射距离,我们就不可以选择忽略它们了,而实现更真实的次表面散射效果,我们需要使用特殊的渲染模型,也就所谓的次表面散射渲染技术。

在这里插入图片描述

我们下面的内容均建立在不考虑次表面散射的距离,而完全使用局部着色器渲染的前提下。

1.2 双向反射分布函数(BRDF)

在了解了上面的理论基础后,我们现在来学习如何使用数学表达式来表示上面的光照模型。这意味着,我们要对光这个看似抽象的概念进量化。

我们可以用**辐射率(radiance)**来量化光。辐射率是单位面积、单位方向上光源的辐射通量,通常用 L 来表示,被认为是对单一光线的亮度和颜色评估。在渲染中,我们通常会基于表面的入射光线的入射辐射率 L i L_i Li 来计算出射辐射率 L o L_o Lo,这个过程也往往被称为是着色(shading)过程。

而要得到出射辐射率 L o L_o Lo 我们需要知道物体表面一点是如何和光进行交互的。而这个过程就可以使用 **BRDF(Bidirectional Reflection Distribution Function,双向反射二分布函数)来定量分析。大多数情况下,BRDF 可以用 f ( I , v ) f(I, v) f(I,v) 来表示,其中 I I I 为入射方向和 v v v 为观察方向(双向的含义)。这种情况下,绕着表面法线旋转入射方向并不会影响 BRDF 的结果,这种 BRDF 被称为是各向同性(isotropic)的 BRDF。与之对应的则是各向异性(anisotropic)**的BRDF。

那么,BRDF 到底表示的含义是什么呢?BRDF 有两种理解方式——

  1. 当给定入射角度后,BRDF 可以给出所有出射方向上的反射和散射光线的相对分布情况。
  2. 当给定观察方向(即出射方向)后,BRDF 可以给出所有入射方向到该出射方向的光线分布。

一个更直观的理解是,当一束光线沿着入射方向 I I I 到达表面某点时, f ( I , v ) f(I,v) f(I,v) 表示了有多少部分的能量被反射到了观察方向 v v v 上。

据此,我们给出了基于物理渲染的技术中,第一个重要的等式——反射等式(reflection equation):
L o ( v ) = ∫ Ω f ( I , v ) × L i ( I ) ( n ⋅ I ) d ω i L_o(v) = \int_\Omega f(I, v) \times L_i(I)(n \cdot I)d\omega_i Lo(v)=Ωf(I,v)×Li(I)(nI)dωi
反射等式实际上是渲染方程的一个特殊情况,但它是基于物理基础的。尽管上面的式子看起来有些复杂,但很好理解,即给定观察视角 v v v,该方向上的出射辐射率 L o ( v ) L_o(v) Lo(v) 等于所有入射方向的辐射率积分乘以它的 BRDF 值 f ( I , v ) f(I,v) f(I,v),再乘以一个余弦值 ( n ⋅ I ) (n \cdot I) (nI)

在游戏渲染中,我们通常是和一些**精确光源(punctual light sources)**打交道的,而不是计算所有入射光线在半球面上的积分。精确光源指的是那些方向确定、大小为无线小的光源,例如,常见的点光源、聚光灯等等。我们使用 I c I_c Ic 来表示它的方向,使用 c l i g h t c_{light} clight 表示它的颜色。使用精确光源的最大的好处在于,我们可以大大简化上面的反射等式。这里省略推导过程,直接给出结论,即对于一个精确光源,我们可以使用下面的等式来计算它在某个观察方向 v v v 上的出射辐射率:
L 0 ( v ) = π f ( I c , v ) × c l i g h t ( n ⋅ I c ) L_0(v) = \pi f(I_c, v) \times c_{light}(n \cdot I_c) L0(v)=πf(Ic,v)×clight(nIc)
和之前使用积分形式的原始反射等式相比,上面的式子使用了一个特定的 BRDF 值来代替积分操作,这大大简化了计算。如果场景中包含了多个精确光源,我们可以把它们分别代入上面的式子进行计算,然后把它们的结果相加即可。

下面,我们来看一下反射等式的重要组成部分——BRDF是如何得到的。可以看出,BRDF 决定了着色过程是否是基于物理的。这可以由 BRDF 是否满足两个特性来判断,它是否满足交换律(reciprocity)能量守恒(energy conservation)。交换律要求当交换 I I I v v v 值后,BRDF 值保持不变,即
f ( I , v ) = f ( v , I ) f(I, v) = f(v, I) f(I,v)=f(v,I)
而能量守恒则要求表面反射的能量不能超过入射的光能,即
∀ I , ∫ Ω f ( I , v ) ( n ⋅ I ) d ω o ⩽ 1 \forall I, \int_\Omega f(I, v)(n \cdot I)d\omega_o \leqslant 1 I,Ωf(I,v)(nI)dωo1
基于这些理论,BRDF 可以用于描述两种不同的物理现象:表面反射和次表面反射。针对每种现象,BRDF 通常会包含一个单独的部分来描述它们——用于描述表面反射的部分被称为高光反射项(specular term),以及用于描述次表面散射的漫反射项(diffuse term)

在这里插入图片描述

1.3 漫反射项

我们之前所学习的 Lambert 模型就是最简单、也是应用最广泛的漫反射 BRDF。准确的 Lambertian BRDF 的表示为:
f L a m b e r t ( I , v ) = c d i f f π f_{Lambert}(I, v) = \frac{c_{diff}}{\pi} fLambert(I,v)=πcdiff
其中, c d i f f c_{diff} cdiff 表示漫反射光线所占的比例,它也通常被称为是漫反射颜色(diffuse color)。与我们之前讲过的 Lambert 光照模型不太一样的是,上面的式子实际上是一个定值,我们常见到的余弦(即( n cot ⁡ I n\cot I ncotI))因子部分实际是反射等式的一部分,而不是 BRDF 的部分。上面的式子之所以要除以 π \pi π,是因为我们假设漫反射在所有方向上的强度都是相同的,而 BRDF 要求在半球内的积分值为 I。因此,给定入射方向 I 的光源在表面某点的出射漫反射辐射率为:
L d i f f = c d i f f π × L i ( I ) ( n ⋅ I ) L_{diff} = \frac{c_{diff}}{\pi} \times L_{i}(I)(n \cdot I) Ldiff=πcdiff×Li(I)(nI)
Lambert 模型虽然简单,但很多基于物理的渲染选择使用了更复杂的漫反射项来模拟次表面散射的结果。例如,在 Disney 使用的 BRDF 中,它的漫反射项为:
f d i f f ( I ⋅ v ) = b a s e C o l o r π ( 1 + ( F D 90 − 1 ) ( 1 − n ⋅ I ) 5 ) ( 1 + ( F D 90 − 1 ) ( 1 − n ⋅ v ) 5 ) f_{diff}(I \cdot v) = \frac{baseColor}{\pi}(1 + (F_{D90} - 1)(1 - n \cdot I)^5)(1 + (F_{D90} - 1)(1 - n \cdot v)^5) fdiff(Iv)=πbaseColor(1+(FD901)(1nI)5)(1+(FD901)(1nv)5)
其中, F D 90 = 0.5 + 2 r o u g h n e s s ( h ⋅ I ) 2 F_{D90} = 0.5 + 2roughness(h \cdot I)^2 FD90=0.5+2roughness(hI)2

在 Disney 的实现中,baseColor 是表面颜色,通常由纹理采样得到,roughness 是表面的粗糙度。上面的漫反射项既考虑了在掠射角(glancing angles)漫反射项的能量变化,还考虑了表面的粗糙度对漫反射的影响。而上面的式子也正是 Unity 5 内部使用的漫反射项。

1.4 高光反射项

在现实生活中,几乎所有的物体都或多或少有高光反射现象。John Hable 在他的文章中就强调了 Everything is Shiny。但在许多传统的 shader 中,很多材质只考虑了漫反射效果,而并没有添加高光反射,这使得渲染出来的画面并不那么真实可信。在基于物理的渲染中,BRDF 中的高光反射项大多数都是建立在**微面元理论(microfacet theory)**的假设上的。微面元理论认为,物体表面实际是由许多人眼看不到的微面元组成的,虽然物体表面并不是光学平滑的,但这些微面元可以被认为是光学平滑的,也就是说它们具有完美的高光反射。当光线和物体表面一点相交时,实际上是和一系列微面元交互的结果。正如我们在 1.1 节中看到的,当光和这些微面元相交时,光线会被分割成两个方向——反射方向和折射方向。这里我们只需要考虑被反射的光线,而折射光线已经在之前的漫反射中考虑过了。当然,微面元理论也仅仅是真实世界的散射的一种近似理论,它也有自身的缺限,仍然有一些材质是无法使用微面元理论来描述的。

假设表面法线为 n,这些微面元的法线 m 并不都等于 n,因此,不同的微面元会把同一入射方向的光线反射到不同的方向上。而当我们计算 BRDF 时,入射方向 I 和观察方向 v 都会被给定。这意味着只有一部分微面元反射的光线才会进入到我们的眼睛中,这部分微面元会恰好把光线反射到方向 v 上,即它们的法线 m 等于 I 和 v 的一半,也就是我们一直看到的半角度矢量 h(half-angle vector,也被称为 half vector),如下图(a)所示。

然而,这些 m = h 的微面元反射也并不会全部添加到 BRDF 的计算中。这是因为,它们其中一部分会在入射方向 I 上被其他微面元挡住(shadowing),如下图(b)所示,或是在它们的反射方向 v 上被其他微面元挡住了(masking),如下图(c)所示。微面元理论认为,所有这些被遮挡住的微面元不会添加到高光反射项的计算中(实际上它们中的一些由于多次反射仍然会被我们看到,但这不在微面元理论的考虑范围内)

在这里插入图片描述

基于微面元理论的这些假设,BRDF 的高光反射项可以用下面的形式来表示:
f s p e c ( I , v ) = F ( I , h ) G ( I , v , h ) D ( h ) 4 ( n , I ) ( n ⋅ v ) f_{spec}(I, v) = \frac{F(I, h)G(I, v, h)D(h)}{4(n, I)(n \cdot v)} fspec(I,v)=4(n,I)(nv)F(I,h)G(I,v,h)D(h)
这就是著名的 Torrance-Sparrow 微面元模型。上面的式子看起来难以理解,实际上其中的各个项对应了我们之前讲到的不同现象。D(h)是微面元的法线分布函数(normal distribution function,NDF),它用于计算有多少比例的微面元的法线满足 m = h,只有这部分微面元才会把光线从 I 方向反射到 v 上。 G ( I , v , h ) G(I, v, h) G(I,v,h)阴影—遮掩函数(shadowing-masking function),它用于计算那些满足 m = h m = h m=h 的微面元中有多少会由于遮挡而不会被人眼看到,因此它给出了活跃的微面元(active microfacets)所占的浓度,只有活跃的微面元才会成功地把光线反射到观察方向上。 F ( I , h ) F(I, h) F(I,h) 则是这些活跃微面元的**菲涅尔反射(Fresnel reflectance)**函数,它可以告诉我们每个活跃的微面元会把多少入射光线反射到观察方向上,即表示了反射光线占入射光线的比率。事实上,现实生活中几乎所有的物体都会表现出菲涅尔现象,读者可以在一篇很有意思的文章 Everything has Fresnel 中看到一些这样的例子。最后,分母 4 ( n ⋅ I ) ( n ⋅ v ) 4(n \cdot I)(n \cdot v) 4(nI)(nv) 是用于校正从微面元的局部空间到整体的宏观表面数量差异的校正因子。

这些不同的部分又可以衍生出很多不同的 BRDF 实现。例如,我们之前学习的 Blinn-Phong 模型就是一种非常简单的模型,它使用的法线分布函数 D ( h ) D(h) D(h) 为:
D b l i n n ( h ) = ( n ⋅ h ) g l o s s D_{blinn}(h) = (n \cdot h)^{gloss} Dblinn(h)=(nh)gloss
但实际上 Blinn-Phong 模型并不能真实地反映很多真实世界中物体的微面元法线反射分布,因此,很多更加复杂的分布函数被提了出来,例如 GGX、Beckman 等。同样,阴影-遮掩函数 G ( I , v , h ) G(I, v, h) G(I,v,h) 也有很多相关工作被提了出来,例如 Smith 模型。这些数学模型都是为了更加接近使用光学测量仪器测量出来的真实物体的反射分布数据。

尽管存在很多基于物理的 BRDF 模型,但在真实的电影或游戏制作中,我们希望在直观性和物理可信度之间找到一个平衡点,使得实现的 BRDF 即可以让美工人员直观的调节各个参数,而又有一定的物理可信度。当然,有时候为了满足直观性我们不得不牺牲一定的物理特性,得到的 BRDF 可能不是严格基于物理原理的。

1.5 Unity 中的 PBS 实现

在之前的内容中,我们提到了 Unity 5 的 PBS 实际上是受 Disney 的 BRDF 的启发。这种 BRDF 最大的好处之一就是很直观,只需要提供一个万能的 shader 就可以让美工人员通过调整少量参数来渲染绝大部分常见的材质。我们可以在 Unity 内置的 UnityStandardBRDF.cginc 文件中找到它的实现。

总体来说,Unity 5 一共实现了两种 PBS 模型。一种是基于 GGX 模型的,另一种是基于归一化的 Blinn-Phong 模型的。这两种模型使用了不同的公式来计算高光反射项中的法线分布函数 D(h) 和阴影—遮掩函数 G ( I , v , h ) G(I, v, h) G(I,v,h)。在默认情况下,Unity 5 使用基于归一化后的 Blinn-Phong 模型来实现基于物理的渲染(尽管很多引擎选择使用 GGX 模型)。

如前面所讲,Unity 使用的 BRDF 中的漫反射项使用的公式如下:
f d i f f ( I , v ) = b a s e C o l o r π ( 1 + ( F D 90 − 1 ) ( 1 − n ⋅ I ) 5 ) ( 1 + ( F D 90 − 1 ) ( 1 − n ⋅ v ) 5 ) f_{diff}(I, v) = \frac{baseColor}{\pi}(1 + (F_{D90} - 1)(1 - n \cdot I)^5)(1 + (F_{D90 - 1})(1 - n\cdot v)^5) fdiff(I,v)=πbaseColor(1+(FD901)(1nI)5)(1+(FD901)(1nv)5)
其中, F D 90 = 0.5 + 2 r o u g h n e s s ( h ⋅ I ) 2 F_{D90} = 0.5 + 2roughness(h\cdot I)^2 FD90=0.5+2roughness(hI)2

下面我们给出基于 GGX 模型的高光反射项公式。对于基于归一化的 Blinn-Phong 模型的高光反射公式,可以从 UnityStandardBRDF.cginc 文件找到它们的实现。

Unity 对高光反射项中的法线分布函数 D(h) 采用了 GGX 模型的一种实现:
D G G X = a 2 π ( a 2 − 1 ) ( n ⋅ h ) 2 + 1 D_{GGX} = \frac{a^2}{\pi(a^2 - 1)(n \cdot h)_2 + 1} DGGX=π(a21)(nh)2+1a2
其中, α = r o u g h n e s s 2 \alpha = roughness^2 α=roughness2

阴影-遮掩函数 G ( I , v , h ) G(I, v, h) G(I,v,h) 则使用了一种由 GGX 衍生出的 Smith-Schlick 模型:
G ( I , v , h ) = 1 ( ( n ⋅ I ) ( 1 − k ) + k ) ( ( n ⋅ v ) ( 1 − k ) + k ) G(I, v, h) = \frac{1}{((n \cdot I)(1 - k)+k)((n \cdot v)(1 - k) + k)} G(I,v,h)=((nI)(1k)+k)((nv)(1k)+k)1
其中, k = r o u g h n e s s 2 2 k = \frac{roughness^2}{2} k=2roughness2

而菲涅尔反射 F ( I , h ) F(I, h) F(I,h) 则使用了图形学中经常使用的 S c h l i c k Schlick Schlick 菲涅尔近似等式[7]:
F ( l , h ) = F 0 + ( 1 − F 0 ) ( 1 − l ⋅ h ) 5 F(l, h) = F_0 + (1 - F_0)(1 - l \cdot h)^5 F(l,h)=F0+(1F0)(1lh)5
其中 F 0 F_0 F0 表示高光反射系数,在 Unity 中往往指的就是高光反射颜色。

上面的公式可能比较晦涩难懂,实际上,这些数学大多来源于对真实世界中各种物体的 BRDF 分析,再使用不同的数学模型进行逼近。如果想要深入了解基于物理的渲染的数学原理的话,可以参见扩展阅读部分。

幸运的是,在 Uniy 中我们不需要自己在 shader 中实现上面的公式,Unity 已经为我们提供了现成的基于物理着色的 shader,也就是 Standard Shader。

2 Unity 5 的 Standard Shader

当我们在 Unity 5 中新创建一个模型或是新创建一个材质时,其默认使用的着色器都是一个名为 Standard 的着色器。这个 Standard Shader 就使用了我们之前所讲的基于物理的渲染。

Unity 支持两种流行的物理工作流程:金属工作流(Metalic workflow)高光反射工作流(Specular workflow)。其中,金属工作流是默认的工作流程,对应的 Shader 为 Standard Shader。而如果想要使用高光反射工作流,就需要在材质的 Shader 下拉框中选择 Standard(Specular setup)。需要注意的是,通常来讲,使用不同的工作流可以实现相同的效果,只是它们使用的参数不同而已。金属工作流也不意味着它只能模拟金属类型的材质,金属工作流的名字来源于它定义了材质表面的金属值(是金属类型还是非金属类型)。高光反射工作流的名字来源于它可以直接指定表面的高光反射颜色(有很强的高光反射还是很弱的高光反射)等,而在金属工作流中,这个颜色需要由漫反射颜色和金属值衍生而来。在实际的游戏制作过程中,我们可以选择自己更偏好的工作流来制作场景,这更多的是个人喜好的问题。当然也可以同时混用两种工作流。

在下面的内容中,我们用 Standard Shader 来统称 Standard 和 Standard(Specular setup)着色器。Unity 提供的 Standard Shader 允许让我们只使用这一种 shader 来为场景中所有的物体进行着色,而不需要考虑它们是否是金属材质还是塑料材质等,从而大大减少我们不断调整材质参数所花费的时间。

2.1 它们是如何实现的

Standard 和 Standard(Specular setup)的 Shader 源代码可以在 Unity 内置的 builtin_shaders-5.x/DefaultResourcesExtra 文件夹中找到,这些 shader 依赖于 builtin_shaders-5.x/CGIncludes 文件夹中定义的一些头文件。这些相关的头文件的名称大多类似于 UnityStandardXXX.cginc,其中定义了 PBS 相关的各个函数、结构体和宏等。下表列出了这些头文件的名称以及它们的主要用处

文件描述
UnityPBSLighting.cginc定义了表面着色器使用的标准光照函数和相关的结构体等,如 LightingStandardSpecular 函数和 SurfaceOutputStandardSpecular 结构体
UnityStandardCore.cginc定义了 Standard 和 Srandard(Specular setup)Shader 使用的顶点/片元着色器和相关的结构体、辅助函数等,如 vertForwardBase、fragForwardBase、MetallicSetup、SpecularSetup 函数和 VertexOutputForwardBase、FragmentCommonData 结构体
UnityStandardBRDF.cginc实现了 Unity 中基于物理的渲染技术、定义了 BRDF1_Unity_PBS、BRDF2_Unity_PBS 和 BRDF3_Unity_PBS 等函数,来实现不同平台下的 BRDF
UnityStandardInput.cginc声明了 Standard Shader 使用的相关输入,包括 shader 使用的属性和顶点着色器的输入结构体 VertexInput,并定义了基于这些输入的辅助函数,如 TexCoords、Albedo、Occulusion、SpecularGloss 等函数
UnityStandardUtils.cgincStandard Shader 使用的一些辅助函数,将来可能会移到 UnityCG.cginc 文件中
UnityStandardConfig.cginc对 Standard Shader 的相关配置,例如默认情况下关闭简化版的 PBS 实现(将 UNITY_STANDARD_SIMPLE 设为 0),以及使用基于归一化的 Blinn-Phong 模型而非 GGX 模型来实现 BRDF(将 UNITY_BRDF_GGX 设为 0)
UnityStandardMeta.cginc定义了 Standard Shader 中 “LightMode” 为 “Meta” 的 Pass(用于提取光照纹理和全局光照的相关信息)使用的顶点/片元着色器,以及它们使用的输入/输出结构体
UnityStandardShadow.cginc定义了 Standard Shader 中 “LightMode” 为 “ShadowCaster” 的 Pass(用于投射阴影)使用的顶点/片元着色器,以及他们使用的输入/输出结构体
UnityGlobalIllumination.cginc定义了和全局光照相关的函数,如 UnityGlobalIllumination 函数

我们可以打开 Standard.shader 和 StandardSpecular.shader 文件来分析 Unity 是如何实现基于物理的渲染的。总体来讲,这两个 shader 的代码基本相同——它们都定义了两个 SubShader,第一个 SubShader 使用的计算更加复杂,主要针对非移动平台(通过#pragma exclude_renderers gles 代码来排除 GLES 平台),并定义了前向渲染路径和延迟渲染路径使用的 Pass,以及用于投射阴影和提取元数据的 Pass;第二个 SubShader 定义了 4 个 Pass,其中两个 Pass 用于前向渲染路径,一个 Pass 用于投射阴影,另一个 Pass 用于提取元数据,该 SubShader 主要针对移动平台。Standard.shader 和 StandardSpecular.shader 最大的不同之处在于,它们在设置 BRDF 的输入时使用了不同的函数来设置各个参数——基于金属工作流的 Standard Shader 使用 MetallicSetup 函数来设置各个参数,基于高光反射工作流的 Standard(Specular setup)Shader 使用 SpecularSetup 函数来设置。MetallicSetup 和 SpecularSetup 函数均在 UnityStandardCore.cginc 文件中被定义。图 18.7 给出了 Standard Shader 中用于前向渲染的典型实现,这是由对内置文件的分析所得。

从图 18.7 可以看出,两个 Pass 的代码大体相同,只是 ForwardBase Pass 进行了更多的光照计算,例如,计算全局光照、自发光等效果。这些计算只需要在物体的整个渲染过程中计算一次即可,因此不需要在 ForwardAdd Pass 中再计算一次,这与我们之前学习前向渲染时的经验一致。

在这里插入图片描述

2.2 如何使用 Standard Shader

我们之前提到,Unity 5 的 Standard Shader 适用于各种材质的物体,但是,我们应该如何使用 Standard Shader 来得到不同的材质效果呢?

我们首先来回答一个问题,为什么不同的材质看起来是如此不同呢?这需要回顾我们在 18.1 节中讲到的内容。我们知道,材质和光的交互可以分成漫反射和高光反射两个部分,其中漫反射对应了次表面散射的结果,而高光反射则对应了表面反射的结果。通过对金属材质和非金属材质的分析,我们可以得到它们的漫反射和高光反射的一些特点。

  1. 金属材质
    • 几乎没有漫反射,因为所有被吸收的光都会被自由电子立刻转化为其他形式的能量;
    • 有非常强烈的高光反射;
    • 高光反射通常是有颜色的,例如金子的反光颜色为黄色。
  2. 非金属材质
    • 大多数角度高光反射的强度比较弱,但在掠射角时高光反射强度反而会增强,即菲涅尔现象;
    • 高光反射颜色比较单一;
    • 漫反射的颜色多种多样。

但真实的材质大多混合了上面的这些特性,Unity 提供的工作流是为了更方便地让我们针对以上特性来调整材质效果。Unity 官方提供的示例项目 Shader Calibration Scene 中,Unity 提供了两个非常有参考价值的校准表格,如图 18.8 所示,它们分别对应了金属工作流和高光反射工作流使用的参考属性值,来方便我们针对不同类型的材质来调整参数。

在这里插入图片描述

我们以图 18.8 的左图,即金属工作流使用的校准表格为例,来解释如何使用这张校准表格来指导我们调整材质。由于基于物理的渲染需要使用线性空间,所以需要在 Edit → \rightarrow Project Settings → \rightarrow Player → \rightarrow Other Settings → \rightarrow Color Space 中选择 Linear 才可以得到渲染效果。

在这里插入图片描述

在金属工作流中:

  • Albedo:材质面板中的 Albedo 定义了物体的整体颜色,它通常就是我们视觉上认为的物体颜色。从亮度来看,非金属材质的亮度范围在 50-234 之间,而金属材质的亮度一般在 186 255之间。Unity 给的校准表格(图 18.8 左图)中还给出了一些非金属材质和金属材质使用的示例 Albedo 属性值,我们可以直接使用这些示例值来作为材质属性。当然,也可以直接使用一张纹理作为材质的 Albedo 值。在我们的例子中,我们把金属材质的 Albedo 设为银灰色,而把塑料材质的设为蓝绿色。
  • Metallic:材质面板下另一个属性是 Metallic,它定义了该物体表面看起来是否更像金属或非金属。同样,我们也可以使用一张纹理来采样得到表面的 Metallic 值,此时纹理中的 R 通道值将对应了 Metallic 值。在我们的例子中,我们把金属材质的 Metallic 值设为 1,表明该物体几乎完全是一个金属材质,同时把塑料材质的 Meltallic 值设为 0,表明该物体几乎没有任何金属特性。
  • Smoothness:最后一个重要的材质属性是 Smoothness,它是上一个属性 Metallic 的附属值,定义了从视觉上来看该表面的光滑程度。如果我们在设置 Metallic 属性时使用的是一张纹理,那么这张纹理的 A 通道就对应了表面的 Smoothness 值(此时纹理的 GB 通道则被忽略)。在我们的例子中,我们把金属材质的 Smoothness 值设置为相对较大的 0.7,表明该金属表面比较光滑,而把塑料材质的 Smoothness 值设为 0.4,表明该塑料表面比较粗糙。

在高光反射工作流中:

  • Albedo:材质的 Albedo 属性定义了表面的漫反射强度。对于非金属材质,它的值通常仍然是视觉上认为的物体颜色,但对于金属材质,Albedo 的值通常非常接近黑色(金属材质几乎不存在次表面散射现象)
  • Specular:定义了表面的高光反射强度。非金属材质通常使用一个灰度值范围为 0-55 的深灰色来作为 Specular 值,表明非金属材质的高光反射较弱。金属材质则通常会使用视觉上认为的该金属的颜色作为它的 Specular 值。
  • SmoothnessSpecular 属性同样也有一个子属性 Smoothness,它定义了从视觉上来看该表面的光滑程度。和上面的金属工作流类似,如果使用了一张纹理来为 Specular 属性赋值,那么纹理的 RGB 通道对应了 Specular 属性值,A 通道对应了 Smoothness 属性值。

上述材质属性都属于材质面板中的 Main Maps 部分,除了上述提到的属性外,Main Maps 还包含了其他材质属性,例如,切线空间下的法线纹理、遮挡纹理、自发光纹理等。Main Maps 部分的下面还有一个 Secondary Maps 的属性部分,这个部分的属性是用来定义额外的细节信息,这些细节通常会直接绘制在 Main Maps 的上面,来为材质提供更多的微表面或细节表现。

除了上述属性,我们还可以为 Standard Shader 选择它的渲染模式,即材质面板上的 Render Mode 选项。Standard Shader 支持 4 中渲染模式,分别是 Opaque、Cutout、Fade 和 Transparent。

  • Opaque:用于渲染最常见的不透明物体,也是默认的渲染模式。
  • Transparent:渲染像玻璃这样的材质,在这个渲染模式下,Albedo 属性的 A 通道用于控制材质的透明度。
  • Cutout:Albedo 属性中纹理的 A 通道通常会成为一个掩码纹理,而它的子属性 Alpha Cutoff 将是透明度测试时使用的阈值。
  • Fade:和 Transparent 模式类似,不同的是,在 Transparent 模式下,当材质的透明值不断降低时,它的反射仍然能被保留,而在 Fade 模式下,该材质的所有渲染效果都会逐渐从屏幕上淡出。

需要注意的是,尽管 Standard Shader 的材质面板有许多可调节的属性,但我们不用担心由于没有使用一些属性而会对性能有所影响。Unity 在背后已经进行了高度优化,在我们生成可执行程序时,Unity 会检查哪些属性没有被使用到,同时也会针对目标平台进行相应的优化。

从上面的内容可以看出,要想得到可信度更高的渲染结果,我们需要对不同的材质使用合适的属性值,尤其是一些重要的属性值,例如 Albedo、Metallic 和 Specular。当然,想要让整个场景的渲染结果令人满意,尤其包含了复杂光照的场景,仅仅有这些使用了 PBS 的材质是不够的,我们需要使用 Unity 提供的其他一些重要的技术,例如 HDR 格式的 Skybox、全局光照、反射探针、光照探针、HDR 后处理和屏幕后处理等。

3 一个更加复杂的例子

为了实现基于物理的渲染,我们需要在 Edit → \rightarrow Project Settings → \rightarrow Player → \rightarrow Other Settings → \rightarrow Color Space 中选择 Linear。

3.1 设置光照环境

默认情况下,Unity 5 中一个新创建的场景会包含一个默认的 Skybox。在本例中,我们使用一个自定义的 Skybox 来代替默认值。做法是,打开 Window → \rightarrow Lighting 下替换自己的 Skybox。

对于使用了 HDR 格式的 Cubemap 的 Skybox,HDR 格式可以让场景中物体的反射更加真实,有利于我们得到更加可信的光照效果。

我们还可以设置场景中使用的环境光照,这些环境光照可以对场景中所有的物体表面产生影响。我们可以选择环境光照的来源(Ambient Skybox),是来自于场景使用的 Skybox,还是使用渐变值,亦或是某个固定的颜色。我们还可以设置环境光的强度(Ambient Intensity 参数),如果想要场景中的所有物体不接受任何环境光照,可以把该值设为 0。在使用了 Standard Shader 的前提下,如果我们关闭场景中所有的光源,并把环境光照的强度设为 0,场景中的物体仍然可以接受一些光照。

那么,这些光照是从哪里来的呢?答案就是反射。默认的反射源(Reflection Source)是场景使用的 Skybox。如果我们不想让场景中的物体接受任何默认的反射光照,可以把反射元设置为自定义(Custom),并把自定义的 Cubemap 保留为空即可(另一种方式是直接把场景使用的 Skybox 设置为空)。但为了得到更加逼真的渲染结果,我们通常是不会这样做的。在渲染实现上,即便场景中没有任何光源,Unity 在内部仍然会调用 ForwardBase Pass(假设使用的是前向渲染路径的话),并使用反射的光照信息来填充光源信息,再进行基于物理的渲染计算。读者可以通过帧调试器(Frame Debugger)来查看渲染过程。需要注意的是,这里设置的反射源是默认的反射源,如果我们在场景中添加了其他反射探针(Reflection Probes),物体可能会使用其他反射源。当默认反射源是 Skybox 时,Unity 会有场景使用的 Skybox 生成一个 Cubemap,我们可以通过 Resolution 选项来控制它每个面的分辨率。

除了 Standard Shader 外,Unity 还引入了一个重要的流水线——实时全局光照(Global Illumination,GL)流水线。使用 GI,场景中的物体不仅可以受直接光照影响,还可以接受间接光照的影响。直接光照指的是那些直接把光照射到物体表面的光源,在本书之前的章节中,我们使用的都是直接光照来渲染场景中的物体。但在现实生活中,物体还会受到简介光照的影响。例如,想象一个红色墙壁旁边放置了一个球体,尽管墙壁本身不发光,但球体靠近墙的一面仍会有少许的红色,这是由于红色墙壁把一些简介光照投射到了球体上。在 Unity 中,间接光照指的就是那些场景中其他物体反弹的光,这些间接光照会受反弹光的表面的颜色影响(例如之前例子中的红色墙壁),这些表面会在反弹光线时把自身表面的颜色添加到反射光的计算中。在 Unity 5 中,我们可以使用这些直接光照和间接光照来创建更加真实的视觉效果。

下面,我们首先设置场景使用的直接光照——一个平行光。在 PBR(Physical Based Rendering)中,想要让渲染真实可信,我们需要保证平行光的方向和 Skybox 中的太阳或其他光源一致,使得物体产生的光照i西南西可以与 Skybox 互相吻合。有时,我们可能会使用一张耀斑纹理(Flare Texture)来模拟太阳等光源,此时我们同样需要确保平行光的方向与耀斑纹理的位置一致。与之类似的还有平行光的颜色,我们应该尽量让平行光的颜色和场景环境相匹配。例如,场景的光照环境为日落时分,因此平行光的颜色为浅黄色,而如果场景的光照环境更接近傍晚,此时平行光的颜色为淡蓝色。我们还在 Skybox 的材质面板上调整天空的旋转角度及曝光度,来调整场景的背景。

在平行光面板的烘焙(Baking)中,我们选择了 Realtime 模式,这意味着,场景中受平行光影响的所有物体都会进行实时的光照计算,当光源或场景中其他物体的位置、旋转角度等发生变化时,场景中的光照结果也会随之变化。然而,实时光照往往需要较大的性能消耗,对于移动平台这样资源比较短缺的平台,我么可以选择 Baked 模式,此时,Unity 会把该光源的光照效果烘焙到一张光照纹理(lightmap)中,这样我们就不用实时为物体计算复杂的光照,而只需要通过纹理采样来得到光照结果。选择烘焙模式的缺点在于,如果场景中的物体发生了移动,但是它的阴影等光照效果并不会发生变化。烘焙选项中 Mix 模式则允许我们混合使用实时模式和烘焙模式,它会把场景中的静态物体(即被标识为 Static 的物体)的光照烘焙到光照纹理中,但仍然会对动态物体产生实时光照。

Unity 5 引入了实时间接光照的功能,在这个系统下,场景中的直接光照会在场景中各个物体之间来回反射,产生间接光照。正如我们之前讲到的,间接光照可以让那些没有直接被光源照亮的物体同样可以接受到一定的光源信息,这些光照是由它周围的物体反射到它的表面上的。当一条光线从光源被发射出来后,它会与场景中的一些物体相交,第一个和光线相交的物体受到的光照即为直接光照。当得到直接光照在该物体上的光照结果后,该物体还会继续反射该光线,从而对其他物体产生间接光照。此后与该光照相交的物体,就会受到间接光照的影响,同时它们也会继续反射。当经过多次反射后,该光线最后完全消失。这些间接光照的强度是由 GI 系统计算得到的默认亮度值。光源面板中 Bounce Intensity 参数可以让我们调节这些间接光照的强度。当我们把它设为 0 时,意味着一条光线仅会和一个物体相交,不再继续反射,也就是说,场景中的物体只会受到直接光照的影响。

除了上述调整单个光源的间接光照强度,我们也可以对整个场景的间接光照强度进行调整。在光照面板的 Scene 标签下,我们可以调整 General GI 参数块中的 Bounce Boost 参数来控制场景中反射的间接光照的强度,它会和单个光源的 Bounce Intensity 参数来一起控制间接光照的反射强度。除此之外,把 Indirect Intensity 参数调大同样可以增大间接光照的强度。需要注意的是,间接光照还可能来自一些自发光的物体。

3.2 放置反射探针

当我们提到环境映射的时候讲到,在实时渲染中,我们经常会使用 Cubemap 来模拟物体的反射效果。例如,在赛车游戏中,我们需要对车身或车窗使用反射映射的技术来模拟它们的反光材质。然而,如果我们永远使用同一个 Cubemap,那么,当赛车周围的场景发生较大变化时,就很容易出现“穿帮镜头”,因为车身或车窗的环境反射并没随着环境变化而发生变化。一种解决办法是可以在脚本中控制何时生成从当前位置观察到的 Cubemap,而 Unty 5 为我们提供了一种更加方便的途径,即使用反射探针(Reflection Probes)。反射谈着的工作原理和光照探针(Light Probes)类似,它允许我们在场景中特定的位置上对整个场景的环境反射进行采样,并把采样结果存储在每个探针上。当游戏中包含反射效果的物体从这些探针附近经过时,Unity 会把从这些邻近探针存储的反射结果传递给物体使用的反射纹理。如果物体周围存在多个反射探针,Unity 还会在这些反射结果之间进行插值,来得到平滑渐变的反射效果。实际上,Unity 会在场景中放置一个默认的反射探针,这个反射探针存储了对场景使用 Skybox 的反射结果,来作为场景的环境光照。如果我们需要让场景中的物体包含额外的反射效果,就需要放置更多的反射探针。

反射探针同样有 3 种类型:

  • Baked:这种类型的反射探针是通过提前烘焙来得到该位置使用的 Cubemap 的,在游戏运行时反射探针中存储的 Cubemap 并不会发生变化。需要注意的时,这种类型的反射探针在烘焙时同样只会处理那些静态物体(即那些被标识为 Reflection Probe Static 的物体)。
  • Realtime:这种类型则会实时更新当前的 Cubemap,并且不受静态物体还是动态物体的影响。当然,这种类型的反射探针需要花费更多的处理时间,因此,在使用时应当非常小心它们的性能。幸运的是,Unity 允许我们从脚本中通过触发来精确控制反射探针的更新。
  • Custom:这种类型的探针既可以让我们从编辑器中烘焙它,也可以让我们使用一个自定义的 Cubemap 来作为反射映射,但自定义的 Cubemap 不会被实时更新。

在方式反射探针时,我们选取的位置并不是任意的。通常来说,反射探针应该被放置在那些具有明显反射现象的物体的旁边,或是一些墙角等容易发生遮挡的物体周围。通常情况下,反射探针的影响区域之间往往会有重叠,此时,Unity 会计算反射物体的包围盒与这些重叠区域的交叉部分,并据此来选择使用的反射映射。如果当前的目标平台使用的时 SM 3.0 及以上的话,Unity 还可以允许我们在这些互相重叠的反射探针之间进行混合,来实现平缓的反射过渡效果。

使用 Unity 内置的反射探针的另一个好处是,我们可以模拟互相反射(interreflection)。我们曾在 10.1 节中讲到使用传统的 Cubemap 方法无法模拟互相反射的效果,例如,假设场景中有两面互相面对面的镜子,在理想情况下,它们不仅会反射自己对面的那面镜子,也会反射那面镜子里的图像。只要反射光没有被完全吸收,反射就会一直进行下去。要实现这种效果,就需要追踪光线的反射轨迹,这是传统的反射方法无法实现的。Unity 5 引入的 GI 系统让这种效果变成了可能。

我们在场景中放置两个金属球,在每个金属球的位置处放置了一个反射探针,并把每个金属球上的 Mesh Renderer 组件中的 Reflection Probes 设置为 Simple,这样保证它们只会使用离它们最近的一个反射探针。默认情况下,反射探针只会捕捉一次反射,也就是说,左边金属球使用的反射探针只会捕捉到由右边的金属球第一次反射过来的光线。但在理想情况下,反射过来的光线会继续被左边的金属球反射,并对右边的金属球造成影响。Unity 允许我们控制物体之间这样来回反射的次数,这可以通过改变 Reflection Bounces 参数来实现。

实际上使用反射探针往往会需要更多的计算空间,这些探针实际上也是通过在它的位置上放置一个摄像机,来渲染得到一个 Cubemap。如果我们把反射次数设置的很大,或是使用实时渲染,那么这些探针很可能会造成性能瓶颈。更多关于如何优化反射探针以及它的高级用法可以参考 Unity 官方手册。

3.3 调整材质

要得到真实可信的渲染效果,我们需要为场景中的物体指定合适的材质。需要再次提醒读者的是,基于物体的渲染并不意味着一定要模拟像照片真实的效果。基于物理的渲染更多的好处在于,可以让我们的场景在各种光照条件下都能得到令人满意的效果,同时不需要频繁地调整材质参数。

在 Unity 中,要想和全局光照、反射探针等内置功能良好地配合来得到出色的渲染结果,就需要使用 Unity 内置的 Standard Shader。我们已经学习了如何针对不同类别的物体来调整它们使用的材质属性。

3.4 线性空间

在使用基于物理的渲染方法渲染整个场景时,我们应该使用线性空间(Linear Space)来得到最好的渲染效果。默认情况下,Unity 会只用伽马空间(Gamma Space),如果要使用线性空间的话,我们需要在 Edit → \rightarrow Project Settings → \rightarrow Player → \rightarrow Other Settings → \rightarrow Color Space 中选择 Linear。

使用线性空间可以得到更加真实的效果,但它的缺点在于,需要一些硬件支持来实现线性计算,但一些移动平台对它的支持并不好。这种情况下,我们往往只能退而求其次,选择伽马空间进行渲染和计算。

那么,线性空间,伽马空间到底是什么意思呢?为什么线性空间可以得到更加真实的效果呢?这就需要介绍伽马校正(Gamma Correction)的相关内容了。实际上,当我们在默认的伽马空间下进行渲染计算时,由于使用了非线性的输入数据,导致很多计算都是在非线性空间下进行的,这意味着我们得到的结果并不符合真实的物理期望。除此之外,由于输出时没有考虑显示器的现实伽马的影响,会导致渲染出来的画面整体偏暗,总是和真实世界不像。

尽管在 Unity 中我们可以通过之前所说的步骤直接选择在线性空间进行渲染,Unity 会在背后为我们照顾好一切,但了解伽马校正的原理对我们理解渲染计算有很大的帮助。

4 补充

4.1 什么是全局光照

全局光照,指的就是模拟光线是如何在场景中传播的,它不仅会考虑那些直接光照的结果,还会计算光线被不同的物体表面反射而产生的间接光照。在使用基于物理的着色技术时,当渲染表面上一点时,我们需要计算该点的半球范围内所有会反射到观察方向的入射光线的光照结果,这些入射光线中就包含了直接光照和间接光照。

通常来讲,这些间接光照的计算是非常耗时间的,通常不会用在实时渲染中。一个传统的方法是使用光线追踪,来追踪场景中每一条重要的光线的传播路径。使用光线追踪能得到非常出色的画面效果,因此,被大量应用在电影制作中。但是,这种方法往往需要大量时间才能得到一帧,并不能满足实时的要求。

Unity 采用了 Enlighten 解决方案来让全局光照能够在平台上有不错的性能表现。事实上,Enlighten 也已经被集成在虚幻引擎(Unreal Engine 中),它已经在很多 3A 大作中展示了自身强大的渲染能力。总体来说,Unity 使用了实时+预计算的方法来模拟场景中的光照。其中,实时光照用于计算那些直接光源对场景的影响,当物体移动时,光照也会随之发生变化。但正如我们之前所说,实时光照无法模拟光线被多次反射的效果。为了得到更加真实的渲染效果,Unity 又引入了预计算光照的方法,使得全局光照甚至在一些高端的移动设备上也可以达到实时的要求。

预计算光照包含了我们常见的光照烘焙,也就是指我们把光源对场景中静态物体的光照效果提前烘焙到一张光照纹理中,然后把这张光照纹理直接贴在这些物体的表面,来得到光照效果。这些光照纹理不仅存储了直接光照的结果,还包含了那些由物体反射得到的间接光照。但是,这些光照纹理无法在游戏运行时不断更新,也就是说,它们是静态的。不过这种方法的确为移动平台的复杂光照提供了一个有效途径。以上提到的这些技术很多读者都非常熟悉,并可能已经在实际工作中大量使用了它们。

由于静态的光照烘焙无法在光照条件改变时更新物体的光照效果,因此,Unity 使用了**预计算实时全局光照(Precomputed Realtime GI)**为我们提供了一个解决途径,来动态地为场景实时更新复杂光照结果。正如我们之前看到的,使用这种技术我们可以让场景中的物体包含丰富的全局光照效果,例如多次反射等,并且这些计算都是实时的,可以随着光源的物体的移动而发生变化。这是使用之前的实时光照或烘焙光照无法实现的。

那么,这些是如何实现的呢?它们实际上都利用了一个事实——一旦物体和光源的位置被固定了,这些物体对光线的反弹路径以及漫反射光照(我们假设漫反射光照在各个方向的分布是相同的)也是固定的,也就是说和摄像机无关的。因此,我们可以使用预计算方法来把这些物体之间的关系提前计算出来,而在实时运行时,只要光源的位置(光源的颜色是可以实时变化的)不变,即使改变了光源颜色和强度、物体材质属性(指的是漫反射和自发光相关的属性),这些信息就一直有效,不需要实时更新。在预计算阶段,Enlighten 会在由所有静态物体组成的场景上,进行简化的“光线追踪”过程。在这个过程中 Enlighten 会自动把场景分割成很多个子系统,他并不是为了得到精确的光照效果,而是为了得到场景中物体之间的关系。需要注意的是,这些预计算都是在静态物体上进行的,因此,为了利用上述的预计算方法,我们至少需要把场景中的一个物体标识为 Static(至少需要把 Lightmap Static 勾选上)。一个例外是物体的高光反射,这是和摄像机的位置相关的,Unity 的解决方案是使用反射探针,正如我们之前看到的那样。对于动态移动的物体来说,我们可以使用光照探针来模拟它的光照环境。因此,在实时运行时,Unity 会利用预计算得到的信息来计算光照信息,并把它们存储在额外的光照纹理、光照探针或 Cubemap 中,再和物体材质进行必要的光照计算,得到最后的渲染效果。

Unity 全新的全局光照解决方案可以大大提高一些基于 PC /游戏机平台的大型游戏的画面质量,但如果要在移动平台上使用仍需要非常小心它的性能。一些低端手机是不适合使用这种比较复杂的基于物理的渲染,不过,Unity 会在后续道版本中持续更新和优化。而且随着手机硬件的发展,未来移动平台上大量使用 PBS 也已经不再是遥不可及的梦想了。

4.2 什么是伽马校正

要想渲染出更符合真实光照环境的场景就需要使用线性空间。而 Unity 默认的空间是伽马空间,在伽马空间下进行渲染会导致很多非线性空间下的计算,从而引入了一些误差。而要把伽马空间切换到线性空间,就需要进行伽马校正(Gamma Correction)

相信很多读者都听过伽马校正这个名词,但对于伽马校正是什么、为什么要有它、怎么使用它都存在着很多疑问。伽马校正中的伽马一词来源伽马曲线。通常,伽马曲线的表达式如下:
L o u t = L i n γ L_{out} = L^{\gamma}_{in} Lout=Linγ
其中指数部分的发音就是伽马。最开始的时候,人们使用伽马曲线来对拍摄的图像进行伽马编码(gamma encoding)。事情的起因可以从在真实环境中拍摄一张图片说起。摄像机的原理可以简化为,把进入到镜头内的光线亮度编码成图像中的像素。如果采集到的亮度是 0,像素就是 0;亮度是 1,像素就是 1;亮度是 0.5,像素就是 0.5。如果我们只用 8 位空间来存储像素的每个通道的话,这意味着 0~1 区间可以对应 256 种不同的亮度值。但是,后来人们发现,人眼有一个有趣的特性,就是对光的灵敏度在不同亮度上是不一样的。在正常的光照条件下,人眼对较暗区域的变化更加敏感,如下图所示。

在这里插入图片描述

图 18.8 说明了一件事情,亮度上的线性变化对人眼感知来说是非均匀的,Youtube 上有一个名为 Color is Broken 的非常有趣的视频,在这个视频种,作者用了一个非常生动的例子来说明这个现象。当一个屋子的光照由一盏灯增加到两盏灯的时候,人眼对这种亮度变化的感知性要远远大于从 101 盏灯增加到 102 盏灯的变化,尽管从物理上来说这两种变化基本是相同的。那么,这和之前讲的拍照有什么关系呢?如果使用 8 位空间来存储每个通道的话,我们仍然把 0.5 亮度编码成值为 0.5 的像素,那么暗部和亮部区域我们都使用了 128 种颜色来表示,但实际上,对亮部区域使用这么多颜色是种存储浪费。一种更好的方法是,我们应该把更多的空间来存储更多的暗部区域,这样存储空间就可以被充分利用起来了。摄影设备如果使用了 8 位空间来存储照片的话,会使用大约 0.45 的编码伽马来对输入的亮度进行编码,得到一张编码后的图像。因此,图像中 0.5 像素对应的亮度其实并不是 0.5,而大约为 0.22,这是因为:
0.5 ≈ 0.2 2 0.45 0.5 \approx 0.22^{0.45} 0.50.220.45
如上所见,对拍摄图像使用的伽马编码使得我们可以充分利用图像的存储空间。但当把图片放到显示器里显示时,我们应该对图像在进行一次解码操作,使得屏幕输出的亮度和捕捉到的亮度是符合线性的。这时,人们发现一个奇妙的巧合——CRT 显示器本身几乎已经自动做了解码操作。在早期,CRT(Cathode Ray Tube,阴极射线管)几乎是唯一的显示设备。这类设备的显示机制是,使用一个电压轰击它屏幕上的一种图层,这个图层就可以发亮,我们就可以看到图像了。但 CRT 显示器有一个特性,它的输入电压和显示出来的亮度关系不是线性的,也就是说,如果我们把输入电压调高两倍,屏幕亮度并没有提高两倍。我们把显示器的这个伽马曲线称为显示伽马(display gamma)。非常巧合的是,CRT 的显示伽马值大约就是编码伽马的倒数。CRT 显示器的这种特性,正好补偿了图像捕捉设备的伽马曲线。虽然现在 CRT 设备很少见了,并且后来出现的显示设备有着不同的伽马响应曲线,但是,人们仍在硬件上做了调整来提供兼容性,图 18.19 展示了编码伽马在图像捕捉和显示时的作用。

在这里插入图片描述

随后,微软联合爱普生、惠普提供了sRGB颜色空间标准,推荐显示器的显示伽马指为2.2,并配合0.45的编码伽马就可以保证最后伽马曲线之间可以相互抵消(因为 2.2 × 0.45 ≈ 1 2.2 \times 0.45 \approx 1 2.2×0.451 )。绝大多数的摄像机、PC和打印机都使用了上述的 sRGB 标准。

事实上,游戏界长期以来都忽略了伽马校正的问题,造成了游戏渲染出来总是暗沉沉的,总是和真实世界不像。由于编码伽马和现实伽马的存在,我们一不小心就可能在非线性空间下进行计算,或是使得输出的图象是非线性的。

对于输出来说,如果我们直接输出渲染结果而不进行任何处理,在经过显示器的显示伽马处理后,会导致图像整体偏暗,出现失真的情况。伽马空间下的渲染结果整体偏暗,然而,实际此时屏幕输出的亮度和球面的光照结果并不是线性的。假设球面上有一点 A,它的法线方向和光线方向成 6 0 ∘ 60^\circ 60,还有一点 B,它的法线和光线方向成 9 0 ∘ 90^\circ 90。那么,在 shader 种计算漫反射光照时,我们会得出 A 的输出是 ( 0.5 , 0.5 , 0.5 ) (0.5, 0.5, 0.5) (0.5,0.5,0.5),B 的输出是 ( 1.0 , 1.0 , 1.0 ) (1.0, 1.0, 1.0) (1.0,1.0,1.0)。在图 18.20 左图中,我们没有进行伽马校正,因此,由于显示器存在显示伽马就引入了非线性关系,也就是说 A 点的亮度并不是 B 亮度的一半,而约为它的 1/4。在图 18.20 右图中,我们使用了线性空间,Unity 会在把像素写入颜色缓冲前进一次伽马校正,来抵消屏幕的显示伽马的作用,此时,得到屏幕亮度才是真正跟像素值成正比的。

在这里插入图片描述

伽马的存在还会对混合造成影响。图 18.21 中,我们放置了 3 个互相重叠的圆,它们使用的材质均为简单的透明混合材质,并使用了一个边界模糊的圆作为输入纹理。场景在伽马空间和线性空间下的效果如下。

在这里插入图片描述

在图 18.21 左图所示的伽马空间下,我们可以看到在绿色和红色的混合边界处出现了不正常的蓝色渐变。而正确的混合结果应该是如图 18.21 右边图所示的从绿色到红色的渐变。除此之外,我们也可以看到图 18.21 左边图中交叉的边界似乎都变暗了。这是因为在混合后进行输出时,显示器的显示伽马导致接缝处颜色变暗。

实际上,渲染中非线性输入最有可能的来源就是纹理。为了充分利用存储空间,大多数图像文件都进行了提前的校正,即已经使用了一个编码伽马对像素值编码。但这意味着它们是非线性的,如果我们在 shader 中直接使用纹理采样值就会造成在非线性空间的计算,使得结果和真实世界的结果不一致。我们在使用多级渐远纹理(mipmaps)时也需要注意。如果纹理存储在非线性空间中,那么在计算多级渐远纹理时就会在非线性空间里计算。由于多级渐远纹理的计算是种线性计算——即采样过程,需要对某个方形区域内的像素值取平均值,这样会得到错误的结果。正确的做法是,我们要把非线性的纹理转换到线性空间后再计算多级渐远纹理。

如上所说,伽马的存在使得我们很容易得到非线性空间下的渲染结果。在游戏渲染中,我们应该保证所有的输入都被转换到了线性空间下,并在线性空间下进行各种光照计算,最后在输出前通过一个编码伽马进行伽马校正后再输出到颜色缓冲中。Unity 的颜色空间设置就可以满足我们的需求。

  • 当我们选择伽马空间时,实际上就是”放任模式“,不会对 Shader 的输入进行任何处理,即使输入可能是非线性的,也不会对输出像素进行任何处理,这意味着输出的像素会经过显示器的显示伽马转换后得到非预期的亮度,通常表现为整个场景会比较昏暗。

  • 当选择线性空间时,Unity 会把输入纹理设置为 sRGB 模式,在这种模式下,硬件在对纹理进行采样时会自动将其转换到线性空间中;并且,GPU 会在 Shader 写入颜色缓冲前自动进行伽马校正或是保持线性再后面进行伽马校正,这取决于当前的渲染配置。如果开启了 HDR 的话,渲染就会使用一个浮点精度的缓冲。这些缓冲有足够的精度不需要我们进行任何伽马校正,此时所有的混合和屏幕后处理都是在线性空间下进行的。当渲染完成要写入显示设备的后备缓冲区(back buffer)时,再进行一次最后的伽马校正。如果我们没有使用 HDR,那么 Unity 就会把缓冲设置成 sRGB 格式,这种格式的缓冲就像一个普通的纹理一样,再写入缓冲前需要进行伽马校正,在读取缓冲时需要再进行一次解码操作。如果此时开启了混合,在每次混合时,硬件会首先把之前颜色缓冲中存储的颜色值转换回线性空间中,然后再与当前的颜色进行混合,完成后再进行伽马校正,最后把校正后的混合结果写入颜色缓冲中。这里需要注意,透明通道是不会参加伽马校正的。

然而,Unity 的线性空间并不是所有平台都支持的,例如,移动平台就无法使用线性空间。此时,我们就需要自己在 shader 中进行伽马校正。对非线性输入纹理的校正代码通常如下:

float3 diffuseCol = pow(tex2D(diffTex, texCoord), 2.2);

在最后输出前,对输出像素值的校正代码通常如下面这样:

fragColor.rgb = pow(fragColor.rgb, 1.0/2.2);
return fragColor;

但是,手工对输出像素进行伽马校正回在使用混合时出现问题。这是因为,校正会导致写入颜色缓冲内的颜色是非线性的,这样混合就发生在非线性空间中。一种解决办法是,在中间计算时不要对输出颜色值进行伽马校正,但在最后需要进行一个屏幕后处理操作来对最后的输出进行伽马校正,也就是我们需要保证伽马校正发生在渲染的最后一步中,但这可能会造成一定的性能损耗。

你会说,伽马这么麻烦,什么时候舍弃它呢?如果有一天我们对图像的存储空间能够大大提升,通用格式不再是 8 位时,例如是 32 位时,伽马也许就会消失。因为,我们有足够多的颜色空间可以利用,不需要为了充分利用存储空间进行伽马编码的工作了。这就是我们下面要讲的 HDR。

4.3 什么是 HDR

在使用基于物理的渲染时,我们经常会听到一个名词就是 HDR。HDR 是 High Dynamic Range 的缩写,即高动态范围,与之相对的是低动态范围(Low Dynamic Range,LDR)。那么这个动态范围是指什么呢?通俗来讲,动态范围指的就是最高和最低的亮度值之间的比值。在真实世界中,一个场景中最亮和最暗区域的范围可以非常大,例如,太阳发出的光可能要比场景中某个影子上的点的亮度要高出几万倍,这些范围远远超过图像或显示器能够显示的范围。通常在显示设备使用的颜色缓冲中每个通道的精度为 8 位,意味着我们只能用这 256 种不同的亮度来表示真实世界种所有的亮度,因此,在这个过程中一定会存在一定的精度损失。早期的拍摄设备利用人眼的特点,使用了伽马曲线来对捕捉到的图像进行编码,尽可能充分利用这些有限的存储空间,这点我们已经解释过了。然而,HDR 的出现给我们带来了新的希望,HDR 使用远远高于 8 位的精度来记录亮度信息,使得我们可以表示超过 0~1 内的亮度值,从而可以更加精确地反映真实的光照环境。尽管我们最后还是把信息转换到显示设备使用的 LDR 内,但中间的计算却可以让我们得到更加真实可信的效果。Nvidia 曾总结过使用 HDR 进行渲染的动机:让亮的物体可以真地非常亮,暗的物体可以真地非常暗,同时又可以看到两者之间的细节。

使用 HDR 来存储的图像被称为高动态范围图像(HDRI),例如,我们在 18.3 节种就是使用了一张 HDRI 图像来作为场景的 Skybox。这样的 Skybox 可以更加真实地反映物体周围的环境,从而得到更加真实的反射效果。不仅如此,HDR 对光照叠加也有非常重要的作用。如果我们的场景中有很多光源或是光源强度很大,那么一个物体在经过多次光照渲染叠加后最终得到的光照亮度很可能会超过1。如果没有使用 HDR,这些超过 1 的部分全部会截取到 1,使得场景丢失了很多亮部区域的细节。但如果开启了 HDR,我们就可以保留这些超过范围的光照结界,尽管最后我们仍然需要把它们转换到 LDR 进行显示,但我们可以使用色调映射(tonemapping)技术来控制这个转换的过程,从而允许我们最大限度地保留需要的亮度细节。

HDR 的使用可以允许我们在屏幕后处理中拥有更多的控制权。例如,我们常常同时使用 HDR 和 Bloom 效果。我们曾在 12.5 节中解释了 Bloom 特效的实现原理,Bloom 效果需要检测屏幕中亮度大于某个阈值的像素,把它们提取出来后进行模糊,在叠加到原图像中。但是,如果不使用 HDR 的话,我们只能使用小于 1 的阈值来提取需要的像素,但很多时候我们实际上是需要提取那些非常亮的区域,例如车窗上对太阳的强烈反光。由于没有使用 HDR,这些值实际上很可能和街上一些颜色偏白的区域几乎一样,造成不希望的区域也会出现泛光的效果。如果我们使用 HDR,这些就都可以解决了,我们只需要使用超过 1 的阈值来只提取那些非常亮的区域即可。

总体来说,使用 HDR 可以让我们不会丢失高亮度区域的颜色值,提供了更真实的光照效果,并为一些屏幕后处理提供了更多的控制能力。但 HDR 也有自身的缺点,首先由于使用了浮点缓冲来存储高精度图像,不仅需要更大的显存空间,渲染速度会变慢,除此之外,一些硬件并不支持 HDR。而且一旦使用了 HDR,我们无法在利用硬件的抗锯齿功能。事实上,在 Unity 中如果我们同时打开了硬件的抗锯齿(在 Edit → \rightarrow Project Settings → \rightarrow Quality → \rightarrow Anti Aliasing 中打开)和摄像机的 HDR,Unity 会发出警告来提示我们由于开启了抗锯齿,因此,无法使用 HDR 缓冲。尽管如此,我们可以使用基于屏幕后处理的抗锯齿操作来弥补这一点。

在 Unity 中使用 HDR 也非常简单,我么可以在 Camera 组件面板中打开 HDR 选择即可。此时,场景就会被渲染到一个 HDR 的图像缓冲中,这个缓冲的精度范围可以远远超过 0~1。最后,我们可以再使用一个色调映射的屏幕后处理脚本来把 HDR 图像转换到 LDR 图像进行显示。

4.4 PBS 适合什么游戏

在把 PBS 引入当前的游戏项目之前,我们需要权衡一下它的优缺点。需要再次提醒的是,PBS 不意味着游戏画面需要追求和照片一样真实的渲染效果。事实上,很多游戏都不需要刻意去追去与照片一样的真实感,玩家眼中的真实感大多也并不是如此。PBS 的优点在于,我们只需要一个万能的 shader 就可以渲染一大部分的材质,而不是使用传统的作法为每种材质写一个特定的 shader。同时,PBS 可以保证在各种光照条件下,材质都可以自然地和光源进行交互,而不需要我们反复地调整材质参数。

然而,在使用 PBS 时我们也需要考虑到它带来的代价。如上面提到的,PBS 往往需要更复杂的光照配合,例如大量使用光照探针和反射探针等。而且 PBS 也需要开启 HDR 以及一些必不可少的屏幕特效,例如抗锯齿、Bloom 和色调映射,如果这些屏幕特效对当前游戏来说需要消耗过多地性能,那么 PBS 就不适合当前的游戏,我们应该使用传统的 shader 来渲染游戏。使用 PBS 对美工人员来说同样是个挑战。美术资源的制作过程和使用传统的 shader 有很大的不同,普通的法线纹理+高光反射纹理的组合不再适用,我们需要更细腻复杂的纹理集,包括金属值纹理、高光反射纹理、粗糙底纹理、遮挡纹理,有些还需要使用额外的细节纹理来给材质添加更多的细节表面。除了使用图片扫描的传统辅助方法外,这些纹理的制作通常还需要更专业的工具来绘制。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值