Skip to content
zilch edited this page Jul 26, 2021 · 2 revisions

PBR快速(遁)入(空)门 (Physical Based Rendering)

PBR如字面意思,是基于真实物理的一种渲染技术。在2021年才开始PBR,我觉得我已经Tooooooooooooold了。但是亡羊补牢,为时未晚,这几天阅读了很多相关的资料,然后准备动手写一个。

我认为学习PBR有以下4个阶段:

  • 会套用公式写一个PBR渲染流程
  • 理解相关公式背后的物理意义
  • 学会自己推导这些公式
  • 发明自己的PBR渲染公式,秒杀Unity和Unreal

其中阶段一,属于面向搜索引擎编程,只要善用Google,就能达到目标。(在图形领域,大家的技术分享精神还是特别好的,令人感动)。

阶段二和三则是面向论文编程,我觉得还是有些难度的,需要比较强的物理知识和数学能力,然后梳理这么多年来PBR发展历程中的各种关键论文。所幸的是,网上有很多人对这些论文做了总结,或是给出了部分公式的精简推理过程,大大减少了我们阅读原论文的难度。

阶段四则需要遁入空门编程,恐怕只有心无杂念的人才能做到。

有些人学习一个东西,喜欢从最基础的本质出发,一步一步往上再到应用层。但这需要非常大的耐心,可能需要好久的时间才能得到一个可视化的成果,极容易造成劝退。

另一种方式是由上至下学习,不管三七二十一先把东西撸出来再说。接下来再慢慢去探究其背后的基础理论。既然这篇是快速入门,我就准备按照这个思路来做。

  • 在本文的第一部分会使用套公式大法,在Unity的SRP管线下实现PBR材质。这样的话,即便遇到老板请你在一天之内快速写一套PBR材质出来,你也丝毫不虚。
  • 在本文的第二部分,我会详尽的把各种理论参考资料按我认为比较正确的学习路线罗列出来。学完可能需要漫长的岁月。

项目地址附于文末

1. 快速实现版本

完整的PBR光照着色可以拆分为三个部分实现:

  • 直接光照(Direct Light),包括各种平行光、点光源等等
  • 环境间接光镜面反射(Indirect Light Specular)
  • 环境间接光漫反射(Indirect Light Diffuse)

一般可以从直接光照开始,理解PBR的光照模型,然后再推广到间接光部分。间接光的实现有若干种方案,本文将会采用IBL(Image Based Light)方案来实现。

约定:

  • l 为光源方向
  • v为视线方向
  • n为表面法线
  • h = normalize(l +v)为半角向量
  • roughness为粗糙度
  • metalness为金属度
  • a = roughness^2

2. 直接光

针对直接光照,我们使用的着色公式为:

$$

\begin{aligned}

I &= max(0,(n\cdot l)) * L_{light} \

L &= f(l,v) * I; \end{aligned}

$$

  • $L_{light}$为光源颜色(即辐射亮度)
  • I为着色点的收到的辐照度(irradiance)
  • f(l,v)我们称为BRDF,即双向反射分布函数。它代表了着色点出射方向辐射亮度与入射方向辐照度的比值。
  • L为出射方向(即视线方向)的辐射亮度,代表了最终的颜色.

因此只需要给出f(l,v)的公式,就可以计算了。最常用的BRDF公式为Cook-Torrance版本,形式如下:

$f(l,v) = k_df_d + k_sf_s$

其中:

  • $f_d$为漫反射BRDF函数
  • $f_s$为镜面/高光反射BRDF函数
  • $k_s$为镜面反射系数
  • $k_d$为漫反射系数

kd和ks表征了入射能量在镜面反射和漫反射之间进行分配,以满足能量守恒定律。 因此,$k_d + k_s < 1$

1.1.1 漫反射部分

$f_d$是有很多实现版本的,这里我们使用Lambert版本:

$f_d = \frac{C_{diff}}{\pi}$,其中$C_{diff}$为漫反射系数(就是材质颜色)

1.1.2 镜面/高光部分

Cook-Torrance给出的高光部分的BRDF函数如下:

$f_s=\frac{D(h)G(l,v,h)F(v,h)}{4 (n \cdot l)(n \cdot v)} $

在高光项中:

  • D为法线分布函数
  • G为几何遮蔽函数
  • F为菲涅尔反射函数

D、G、F这几个函数均有不同的实现版本,Brian Karis在给UE4写渲染的时候尝试了很多版本,写成了一个参考列表 - specular-brdf-reference

目前比较流行的D、G、F实现如下:

法线分布函数采用GGX (Trowbridge-Reitz) : $$ D(h) = \frac{a^2}{\pi} * \frac{1}{((n \cdot h)^2(a^2-1)+1)^2} $$

几何遮蔽函数使用GGX(Schlick-Smith) (原版里$k = a\sqrt{2/\pi} $,UE4里使用了以下近似):

$$ \begin{aligned} & k = \frac{a}{2} \\ &G_1(v)= \frac{n \cdot v}{(n \cdot v)(1-k)+k} \\ & G(l,v,h) = G_1(l) G_1(v) \end{aligned} $$

菲涅尔反射的近似公式:

$$ F(v,h) = F_0 + (1-F_0)(1-v \cdot h)^5 $$

其中$F_0$为垂直材质表面观察时,材质的反射率。不同的材质,这个值是不一样的。通常来说,金属反射率很高,非金属则很低。

我们可以令:

$F_0 = metalness * Albedo + (1 - metalness) * 0.04$

  • Albedo表示物体自有颜色(对金属来说,可以视作反射率,对非金属来说则视作漫反射系数)
  • metalness为金属度,用以在金属反射率(Albedo)和非金属反射率(0.04)之间进行插值。

1.1.3 $k_s$与$k_d$

既然F代表了材质表面反射率,那么我们可以直接让$k_s = F$,然后令$k_d = (1-k_s)*(1-metalness)$。这实际上是将入射能量在镜面反射和漫反射之间进行了分配,以满足能量守恒定律。

这里的$k_d$之所以要乘以(1-metalness)是因为金属会吸收所有折射光,几乎不产生任何漫反射。

1.1.4 Shader实现

既然我们已经列出了Direct Light PBR所要的全部公式,那么就可以开始快速写一个Shader了。

首先我们定义两个结构PBRDesc和BRDFData,并对其完成初始化

struct PBRDesc{
    float roughness; //粗糙度
    float metalness;//金属度
    half3 albedo;//漫反射系数/反射率 
    half a; // a = roughness^2
    half a2; // a2 = a^2
    half k; //k = 0.5a
    half oneMinusK; //1 - k
    half3 f0; //菲涅尔反射的f0
};

struct BRDFData{
    half3 h; //h = normalize(l + v)
    half NoL; //dot(n,l)
    half NoV;//dot(n,v)
    half NoH;//dot(n,h)
    half VoH; //dot(v,h)
};
  • PBRDesc保存材质的固有属性,与灯光和视线无关。并预计算好一些变量,避免不同公式中的重复计算
  • BRDFData同样预计算好了一些变量,与灯光和视线相关

菲涅尔函数实现(F项)

公式:

$$ F(v,h) = F_0 + (1-F_0)(1-v \cdot h)^5 $$

Shader:

half3 F_Schlick(half3 f0,half VoH){
    return f0 + (1 - f0) * pow(1 - VoH,5);
}

法线分布函数实现(D项)

公式:

$$ D(h) = \frac{a^2}{\pi} * \frac{1}{((n \cdot h)^2(a^2-1)+1)^2} $$

Shader:

float D_TrowbridgeReitzGGX(half a2,half NoH){
    half nh2 = NoH * NoH;
    half b = nh2 * (a2 - 1) + 1.00001f;
    return a2 * INV_PI / (b * b);
}

几何遮蔽函数实现(G项)

公式:

$$ \begin{aligned} & k = \frac{a}{2} \\ &G_1(v)= \frac{n \cdot v}{(n \cdot v)(1-k)+k} \\ & G(l,v,h) = G_1(l) G_1(v) \end{aligned} $$

这里有一个小优化,我们发现G项的分子包含了$n \cdot l$和$n \cdot v$,同时Cook-Torrance给出的公式分母中也包含了这两个值,因此可以约去。在实现时,可以将其合并为V项,即:

$$ V = \frac{G}{(n \cdot l)(n \cdot v)} $$

Shader:

float V_SmithGGX(PBRDesc desc,BRDFData brdfData){
    float oneMinusK = desc.oneMinusK;
    float k = desc.k;
    return rcp(brdfData.NoV * oneMinusK + k) * rcp(brdfData.NoL * oneMinusK + k);
}

将F、D、V合成Cook-Tookance BRDF公式:

half3 BRDF(PBRDesc desc,BRDFData brdfData){
    half3 F = F_Schlick(desc.f0,brdfData.VoH);
    float D = D_TrowbridgeReitzGGX(desc.a2,brdfData.NoH);
    float V = V_SmithGGX(desc,brdfData);
    half3 specular = D * V * F * 0.25;
    half3 ks = F;
    half3 kd = (1 - ks)*(1 - desc.metalness); //金属没有漫反射
    return kd * INV_PI * desc.albedo  + ks * specular;
}

在Pixel Shader中将BRDF应用到平行光如下:

half4 PBRFrag(Varyings input){
    half4 albedo = UNITY_SAMPLE_TEX2D(_AlbedoMap,input.uv);
    half4 metalInfo = UNITY_SAMPLE_TEX2D(_MetalMap,input.uv);
    float3 positionWS = input.positionWS;
    float3 normalWS = normalize(input.normalWS);
    float3 viewDir = normalize(_WorldSpaceCameraPos - positionWS);

    XDirLight mainLight = GetMainLight();

    //初始化相关参数
    float3 lightDir = mainLight.direction;

    PBRDesc pbrDesc;
    pbrDesc.albedo = albedo * _Color;
    pbrDesc.roughness = (1 - metalInfo.a * (1 - _Roughness)); //MetalMap的alpha通道保存的是光滑度
    pbrDesc.metalness = _Metalness * metalInfo.r; //MetalMap的r通道保存了金属度

    BRDFData brdfData;
    InitializeData(lightDir,viewDir,normalWS,pbrDesc,brdfData);

    
    float shadowAtten = GetMainLightShadowAtten(positionWS,normalWS);
    //计算平行光在着色点的辐照度
    half3 directIrradiance = brdfData.NoL * mainLight.color * shadowAtten;
    //辐照度乘以BRDF得到最终颜色
    half3 color = BRDF(pbrDesc,brdfData) * directIrradiance;

    return half4(color,1);
}

放一个单平行光的效果图,使用了一张Metal Map和一张Albedo Map:

1.2 间接光照的镜面反射实现

这里我们采用IBL(基于图像的光照)技术来实现。其原理其实有些类似于传统的CubeMap。虽然同为CubeMap的形式,但CubeMap中所存的像素意义却是不一样的。

如前面所述,有了双向分布函数f(l,v),我们就可以根据入射方向的辐照度计算出出射方向的亮度。 但是对于环境中的间接光照,是存在于四面八方的,这样的入射光线有无数条。我们不能进行实时计算,只能采用预计算的方式。

用简单的公式表示一下就是:

$$ L_{IndirectSpecular} = \int_{l \in Env} f_s(l,v) I(l) $$

如果精确的按照以上公式进行预计算,生成的数据量太大了,因为涉及到的索引参数有太多了

  • metalness
  • roughness
  • lightDir
  • viewDir
  • normal
  • albedo

那怎么办呢? UE4的图形程序推导出了以上公式的一个近似版本,将其拆分成如下形式:

$L_{indirect} \approx A * (F_0 * scale + bias)$

其中A项只与入射光线相关,scale与bias只与视线相关,于是我们可以分别针对A、scale、bias进行预计算。

  • 对A项预计算得到具有Mip结构的CubeMap,我们称为IBLSpecularCubeMap,使用视线在Normal下的反射向量进行索引。
  • 对scale和bias项,我们将其预计算到同一张贴图的r和g分量中,称作BRDFLUT,以(dot(n,v),roughness)进行索引

因为是快速实现,这里不阐述具体的理论和推导过程。

关于A项,在Unity中,编辑器已经提供了这个预计算功能:

导入一张CubeMap,将ConvolutionType选为Specular,编辑器即会为我们完成卷积计算。需要额外注意的是,这个预计算与我们选用的D、G函数是息息相关的,如果我们选用了一些其他的D、G函数实现,就需要自己实现这个预计算过程(虽然我不知道Unity现在使用的是什么D,G函数,但暂且先用它内置的实现吧)

另外一张BRDF LUT,我也直接从网上下了,毕竟是快速入门:)

Shader实现很简单(主要功夫还是在预计算的工具制作上):

float3 reflectDir = reflect(-viewDir,normalWS);
//从_IBLSpec中提取A项
float3 prefilteredColor = texCUBElod(_IBLSpec,float4(reflectDir,pbrDesc.roughness*_IBLSpecMaxMip)).rgb;
//从BRDFLUT中提取B项
float4 scaleBias = UNITY_SAMPLE_TEX2D(_BRDFLUT,float2(brdfData.NoV,pbrDesc.roughness));
//合成最终的间接光镜面反射亮度
half3 indirectSpec = (f0 * scaleBias.x + scaleBias.y) * prefilteredColor;

把indirectSpec与之前计算的直接光照相加后,得到如下效果图:

左侧是加入了环境间接光镜面反射效果的。这个球比较锈,可能并不能很好的看出反射效果,下面换一个光滑的金属球

1.3 间接光照的漫反射实现

漫反射部分比较简单,同样看积分式:

$$ L_{IndirectDiff} = \int_{l \in Env} f_d I(l) $$

我们前面说了,漫反射的BRDF函数与view和light都无关,是一个固定常数$\frac{1}{\pi}$,因此生成预计算贴图也就很简单了。Unity中同样提供了这个预计算功能:

但我用了一下,发现有问题。。生成的始终是一张全白的图,但这里假设我们已有这么一个预计算好的Indirect Diffuse Irradiance Map了。那么只要按法线方向采样,就能得到环境光照的漫反射项。

另外一种可以接受的近似方式是,使用1.2中的高光IBL CubeMap的高阶Mip来作为近似的Diffuse项,这样就能省下一个Diffuse CubeMap,效果也还能接受。

本文即使用这种方式做近似。

float3 indirectDiff = texCUBElod(_IBLSpec,float4(normalWS,_IBLSpecMaxMip / 3)) * pbrDesc.albedo * INV_PI;
  • Tips:这里乘了个INV_PI,因为这里用了高阶SpecularIBL做近似的缘故。如果自己烘培Diffuse Irradiance CubeMap,则这个INV_PI可以直接烘培进去,这里就不用乘了。

1.4 间接光的kd和ks

对于环境间接光,我们同样要计算ks和kd来进行能量分配。由于环境光没有一个固定的入射方向,因此也没有什么半角向量,一般使用法向量来代替半角向量进行计算。因此:

half3 ks = F_Schlick(pbrDesc.f0,brdfData.NoV);
half3 kd = (1 - ks)*(1 - pbrDesc.metalness);
half3 indirectColor = kd * indirectDiff + indirectSpec;
  • 疑问: 为什么这里indirectSpec没有乘以ks呢? 网上的一些资料说Spec的预计算部分已经考虑了菲涅尔反射了,所以这里不用。但我其实是比较奇怪的,因为在计算直接光部分,Specualr部分同样是乘了F,但在最终和Diffuse部分合成时,还是乘了ks的。

这样我们就得到了完整的IBL实现的间接光照。下面看下几种材质的实时效果:

看一下实时调节金属度和粗糙度的效果:

2. 理论理解

首先纵览式的阅读我推荐:

下面给出我认为比较顺的学习路径:

  • 首先要从物理上的辐射度量学开始,只有理解了其中每个物理量的意义,才能进行下一步.

  • 在辐射度量学基础去,再去理解BRDF双向反射分布函数的物理意义

    • Tips: 关于BRDF很多地方的解释都模凌两可,包括Wiki上都不太准确,WIKI上解释是反射光线的辐射率和同一点上射入的光线的辐射率的比值,但其实正确的物理意义应当是反射光线的辐射率与入射光线的辐射照度的比值
  • 理解了BRDF函数之后,再去理解渲染方程.

  • 学习微平面理论

  • 学习Cook-Torrance公式中,F、D、G几项的各个实现版本

  • 学习PBR的IBL部分实现理论

    • IBL同样基于Cook-Torrance公式以及我们选择的D,G,F项去实现的。只不过IBL的大头工作在离线计算部分,Runtime的代码很简单。IBL分为同样分为Diffuse部分和Specular部分。
    • IBL Diffuse 可以参考LearnOpenGL的理论介绍和实现,其中也包含公式推导 - LearnOpenGL - Diffuse Irradiance
    • IBL Specular部分同样可以参考LearnOpenGL - LearnOpenGL - 镜面反射 IBL
      • ps:分割求和近似法简直是神来之笔

3. 其他

我承认在IBL的预计算部分我偷懒了,等后面有兴致了我再回过头来实现自己的预计算生成工具吧。

相关资源下地址: