CSharpGL(55)我是这样理解PBR的

CSharpGL(55)我是这样理解PBR的

简介

PBR(Physically Based Rendering),基于物理的渲染,据说是目前最先进的实时渲染方法。它比Blinn-Phong方法的真实感更强,几乎是照片级的效果。

下图就是PBR的一个例子,读者可在CSharpGL中找到。

+BIT祝威+悄悄在此留下版了个权的信息说:

 

应用题

PBR虽然看起来很复杂,但仍旧是在解一个应用题,只要明确了已知条件和所求问题,就没有什么难以理解的了。

已知条件如下:

对于不透明的三维模型(Cube、Sphere、Teapot等等任何三维模型)上的任意一点,我们知道它的位置vec3 p、法线向量vec3 N和纹理坐标vec2 texCoord。当观察者(你,我,摄像机等等)从某个位置观察三维模型上的这个点p时,从点p到观察者的向量记作vec3 v或vec3 wo。照射到点p的每一束光线vec3 Li,根据某种规则,都会被点p反射到很多方向上去。观察者看到的点p的颜色,就是所有恰好反射到v或wo方向上的光线的颜色。

(注意,为论述方便,在本文中,Li是点p到入射光源的向量;v和wo是点p观察者方向的向量;所有向量的长度都是1。)

+BIT祝威+悄悄在此留下版了个权的信息说:

 所求问题:

观察者看到的颜色是什么?(用Lo(p, wo)表示)

解答:这个问题目前是不可能100%完美解决的,所以只给出各种近似的计算模型,凑合着用。

Blinn-Phong

Blinn-Phong模型

Blinn-Phong模型就是其中一种近似方案。

(注意,这里“Blinn-Phong模型”中的“模型”与“三维模型”中的“模型”是两个不同的概念。“Blinn-Phong模型”中的“模型”是对光照现象的某种计算方法。“三维模型”中的“模型”指的是三维空间中的物体的形状。)

Blinn-Phong将物体反射到每一个方向上的光,都分为漫反射diffuse和镜面反射specular这2个部分。它处理的光源,一般是平行光、点光源、聚光灯这种,从某一个点发射光的光源。

为什么在PBR的文章里要介绍Blinn-Phong?因为PBR可以被(我)认为是Blinn-Phong的进化版本。

在Blinn-Phong中,漫反射强度由N、Li共同决定:

float diffuse = dot(N, Li);

镜面反射强度由N、Li、v共同决定:

float specular = dot(N, normalize(Li + v));

(注意,这里的式子没有考虑diffuse和specular小于0的情况,这是为了突出重点。)

这2种反射光加起来,配合物体的材质和光源的颜色,就得到了物体在点p处被观察者看到的颜色:

vec3 fragColor = diffuse * material.diffuse * light.diffuse + specular * material.specular * light.specular;

当然,最后还要加上个环境光(用常量表示):

vec3 fragColor += ambientColor;

有的Blinn-Phong实现可能与此稍有不同:有的将ambient和diffuse加在一起,有的用纹理(Texture)表示物体的材质,等等。但是思路都是一样的,不要纠结这里。

+BIT祝威+悄悄在此留下版了个权的信息说:

Blinn-Phong的缺点

Blinn-Phong是个很不错的模型,但是它有一个比较明显的缺点:反射光的总量可能大于入射光的总量。也就是说,有时候物体反射的光的总强度居然比入射光还要大。这是不符合物理实际的。

例如,当Li、v都等于N(即入射光和观察者都与法线方向重合)时,diffuse=1,specular=1,两者相加=2>1。我们知道,Blinn-Phong将物体反射出来的每一个方向上的光,都分为漫反射diffuse和镜面反射specular这2个部分。即使物体能够100%反射所有的入射光,(diffuse+specular)最多也就是1而已,不可能超过1。

也就是说,Blinn-Phong虽然能保证diffuse和specular各自不超过1,但是不能保证(diffuse+specular)也不超过1。

PBR解决了这个问题。

PBR

PBR不仅保证了 (diffuse+specular)<=1 ,还有别的优点:

它能把周围环境当作一个整体的光源,这扩大了光源的范围。

它以真实的物理量为参数,因而对美工更友好。

它表现出照片级的真实感,且物体看起来就像本来就属于场景中一样。

PBR模型

PBR也将物体反射到每个方向上的光,都分为漫反射diffuse和镜面反射specular这2个部分。

同时,它对这2种反射光的形成机制给出了自己的解释:

 

 如图所示,一些入射光Li打在点p上。仔细想想,点p实际上不是数学意义上的点,而是由很多微小的平面(长度大于光的波长,小于像素,简称微平面)组成的一小块“褶子”(褶皱程度就是粗糙度roughness)。入射光Li打在褶子上,一部分会被褶子直接反射,另一部分会被吸收进褶子内部。直接反射的,就是specular部分;吸收后,在褶子内部经过若干次碰撞(组成褶子的原子、分子会不断地反射或吸收剩下的光),有一些光会再次被反射出来,这就是diffuse部分。

PBR模型的关键,就在于光的波长、微平面的大小、像素的大小这三者的大小关系。由于光的波长远远小于微平面的尺寸,所以就不用考虑光的衍射等现象。由于微平面的尺寸远远小于一个像素,所以可以将一个个像素视为一个个“褶子”。这样一来,虽然入射光的diffuse部分,其出射位置与入射位置不完全相同,但仍旧在同一个像素范围内,所以可以视作位置相同。

(有人会说,会不会有的光在褶子内部被反射的很远,最终超出了一个像素的范围呢?答案是,会。那么,这种情况如何处理呢?PBR的答案是,忽略不计。)

“褶子”只是一个称呼,事实上完美光滑的“褶子”,即微平面的排列完全平整,一点都不褶(光学平滑)是存在的,你可以在高端望远镜上找到。当然了,这是微平面级别的完美光滑,不是原子级别的。原子级别的完美光滑,据我所知还做不到。

+BIT祝威+悄悄在此留下版了个权的信息说:

PBR认为 (diffuse+specular)==1 始终成立。那么,先算出其中一个,自然就得知另一个了(1-specular)。

Specular部分

菲涅耳方程F

当你站在清澈的海边、河边、湖边,低头向下看时,能够看到水面下的沙石泥土,但平视远处的水面时,就只能看到强烈的反光,很难看到水面下的景象。这种现象被称为菲涅耳(Fresnel)效应。更多图文介绍可以参考(http://blog.sina.com.cn/s/blog_798bec050100rigq.html)。

 

 这种现象说明,入射光被拆分后,specular所占的比例,与入射光Li和观察者v的方向有关。当然,它还与物质的材质有关。菲涅耳方程(Fresnel Equation)给出了一个计算specular的公式。不过那玩意计算起来比较费时,业界一般用它的一个近似版本:

 

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

当然,其他版本的F函数也是存在的。

其中的cosTheta =  max(0, dot(v, normalize(v + Li))) 。可见“它与入射光Li和观察者v的方向有关”,此言不虚。

其中的F0就是物质的材质属性。每种材质都一个对应的F0常数。

其返回结果为vec3 specular,就是说,黄金、白银、钢铁、巧克力,材质对光的RGB通道的反射能力不同。嗯这很科学。

有了specular,当然就有了 vec3 diffuse = vec3(1, 1, 1) - specular 。我们稍后再讨论diffuse。

几何函数G

菲涅耳公式给出的,是在入射光Li和观察者v条件下,specular所占的比例。但是,褶子是粗糙的,会遮挡住specular的一部分。

+BIT祝威+悄悄在此留下版了个权的信息说:

 

 因此,需要计算出没有被遮挡的比例,这就是几何函数(Geometry Function):

  

  

(Kdirect是指平行光、点光源、聚光灯这样的光源应采用的公式;KIBL是将整个图片作为光源时应采用的公式。α表示表面粗糙度)

 1 float GeometrySchlickGGX(float NdotV, float roughness)
 2 {
 3     float r = (roughness + 1.0);
 4     float k = (r*r) / 8.0;
 5 
 6     float nom   = NdotV;
 7     float denom = NdotV * (1.0 - k) + k;
 8 
 9     return nom / denom;
10 }
11 // ----------------------------------------------------------------------------
12 float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
13 {
14     float NdotV = max(dot(N, V), 0.0);
15     float NdotL = max(dot(N, L), 0.0);
16     float ggx2 = GeometrySchlickGGX(NdotV, roughness);
17     float ggx1 = GeometrySchlickGGX(NdotL, roughness);
18 
19     return ggx1 * ggx2;
20 }

当然,其他版本的G函数也是存在的。

+BIT祝威+悄悄在此留下版了个权的信息说:

从参数可知,遮蔽比例与入射光方向Li、法线N、观察者方向v和粗糙度roughness都是有关的。

法线分布函数D

那么,那些没有被遮蔽的specular部分,就全部进入观察者的眼中了吗?并没有。在这些顺利逃出来的specular中,只有那些法线方向与(V+L)相同的微平面反射的光,才能进入观察者眼中。

 

法线分布函数(Normal Distribution Function)就给出了这个比例:

 

 (α表示表面粗糙度)

 1 float DistributionGGX(vec3 N, vec3 H, float roughness)
 2 {
 3     float a = roughness*roughness;
 4     float a2 = a*a;
 5     float NdotH = max(dot(N, H), 0.0);
 6     float NdotH2 = NdotH*NdotH;
 7 
 8     float nom   = a2;
 9     float denom = (NdotH2 * (a2 - 1.0) + 1.0);
10     denom = PI * denom * denom;
11 
12     return nom / denom;
13 }

当然,其他版本的D函数也是存在的。

经过FGD的层层筛选,specular部分就很接近物理真实了。

漫反射常量

diffuse部分相对简单些,用一个常数c表示材质本身的颜色,与diffuse相乘即可。当然,这也是一种近似,其他的近似函数也是存在的。

反射率方程

将上面的各种函数综合起来,再配合一些数学系数,总的PBR公式(反射率方程)就是这样:

 

总结一下就是:

 

 反射率方程左侧的意思是:观察者在wo方向上观察点p,他所看到的光的颜色Lo是多少?

反射率方程右侧:Kd是diffuse所占的比例,Ks是specular所占的比例(注意Kd+Ks=1);c是材质的颜色,可以是单一的颜色vec3(r, g, b),也可以是用一个材质贴图描述texture(texMaterial, texCoord);π是数学常数;n是点p的法线向量;wi是某个入射光线的方向;DFG是上文所述的法线分布函数、菲涅耳函数和几何函数;Li(p, wi)是在wi方向上照射到点p的入射光的颜色;最左边那个长长的S和Ω符号,加上最右边的dwi符号,是积分的意思,Ω符号表示在法线n方向上的半球范围内积分。

 

 右侧的意思是:将所有入射光Li与其约束比例相乘,再加起来,就是我们应用题的答案。

本质上这仍旧是将diffuse和specular分别计算后再相加而已,只不过PBR对specular和diffuse的量都做了限制,从而保证其和不超过1。

其中的fr部分就是常说的BRDF函数。可见它包含了各种玩意,对物体反射光的量进行约束。

这个公式是如何推导出来的?我不知道,暂时不是解决这个问题的时候。作为工程师,我先理解它,实现它,是第一要务。之后再从理论上推导它。

反射率方程是不能直接用shader来写的,因为达不到实时的性能。所以我们一步步做简化。

首先,右侧可以从加法的位置上拆分为diffuse部分和specular部分:

 

 这样,就可以分别去研究如何实现这2个部分,最后简单加起来就行了。

实现diffuse部分

首先,diffuse部分可以将一些常数提取出来:

+BIT祝威+悄悄在此留下版了个权的信息说:

 

 现在,积分内部的含义是,在半球范围内,将所有方向上的入射光向量分别与法线相乘,再加起来。这个积分在shader中当然要用离散的方式计算。半球嘛,立体的,所以分别在水平方向和竖直方向上进行累加比较方便。

 

 此时,我们就可以把上述方程稍微变形下:

 

 然后变为对应的离散的形式:

 

 从原来的积分形式变为离散形式,使用了蒙特卡罗积分原理。感兴趣的同学可以自行搜索研究一下。本文中,只要知道可以这么转换就行了。

在shader中表示这个离散公式的代码如下:

 1 #version 330 core
 2 out vec4 FragColor;
 3 in vec3 WorldPos;
 4 
 5 uniform samplerCube environmentMap;
 6 
 7 const float PI = 3.14159265359;
 8 
 9 void main()
10 {        
11     vec3 N = normalize(WorldPos);
12 
13     vec3 irradiance = vec3(0.0);   
14     
15     // tangent space calculation from origin point
16     vec3 up    = vec3(0.0, 1.0, 0.0);
17     vec3 right = cross(up, N);
18     up         = cross(N, right);
19        
20     float sampleDelta = 0.025;
21     float nrSamples = 0.0f;
22     for(float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta)
23     {
24         for(float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta)
25         {
26             // spherical to cartesian (in tangent space)
27             vec3 tangentSample = vec3(sin(theta) * cos(phi),  sin(theta) * sin(phi), cos(theta));
28             // tangent space to world
29             vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N; 
30 
31             irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
32             nrSamples++;
33         }
34     }
35     irradiance = PI * irradiance * (1.0 / float(nrSamples));
36     
37     FragColor = vec4(irradiance, 1.0);
38 }

代码中的双重for循环,就是在离散地计算积分值。最后得到的irradiance,再乘以Kd*c,就是diffuse部分的颜色值了。这个值加上接下来马上要讲解的specular部分的颜色值,就是应用题的答案。

+BIT祝威+悄悄在此留下版了个权的信息说:

所有Fragment Shader的计算结果都会保存到一个立方体贴图中。这个贴图叫做irradianceMap。这个计算过程叫做“卷积”。

注意,计算diffuse部分的输入数据中,用到了一个立方体贴图samplerCube environmentMap,它其实就是物体所处于的环境,也叫天空盒。这里实际上就是将整个天空盒当作一个大光源来处理了。下图展示了将输入的立方体贴图(左侧)卷积后得到的irradianceMap(右侧):

 

 另外,这里将点p选在原点(0, 0, 0)上,稍后计算specular部分时也会这样设置。读者会问,那就只能描述在原点处的光照喽?也不尽然。只要在场景中的其他关键位置上也分别执行一遍PBR公式,就可以在整个场景中安排好这种“探针”。计算光照时,将距离物体最近的那几个探针的颜色加权平均一下,就可以得到需要的颜色了。本文不讨论“探针”的问题。

实现specular部分

现在,提取出specular部分:

 

 这个积分里有wi和wo两个变量,如果要离散地计算,就得对wi和wo的所有组合都算一遍。这是达不到实时要求的。Epic游戏公司给了一个近似公式,可以解决这个问题:

 

 左边的积分和上文的diffuse部分很相似,不同之处是,要对不同的粗糙度分别计算结果,并依次保存到一个立方体贴图的不同mipmap层上(越高的粗糙度保存在越高(分辨率小)的mipmap层上)。这个过程也是卷积,得到的贴图是个多mipmap层的立方体贴图,叫做prefilterMap。下图展示了一个被卷积好了的prefilterMap:

 

 右边的积分,以n与wi的乘积为参数1,以粗糙度为参数2,进行卷积,得到一个普通的二维纹理,叫做brdfLUT。下图就是:

 

+BIT祝威+悄悄在此留下版了个权的信息说:

 分别从卷积贴图里采样,再算到公式里就得到specular部分的颜色了。

贴图总结

首先,我们需要从一个*.hdr文件加载二维纹理texHDR。

然后,将texHDR转换为天空盒纹理sampleCube environmentMap。

然后,用environmentMap分别生成irradianceMap和多mipmap层的prefilterMap。

最后,brdfLUT是独立生成的,与别的贴图无关。

只需加载其他的*.hdr文件,就可以将物体置于其他天空盒下。PBR将天空盒视作光源,照射物体。这就是PBR能让物体保持融入各个场景中原因。

下图是我在CSharpGL中使用的newport_loft.hdr加载后的样子:

 

 这样的样式,在头顶和脚底方向上的数据损失会多一点。不过,一般用户关注的都是平视方向,所以没问题。

 总结

PBR是对Blinn-Phong的一种极大的改进。它用几个贴图帮助求解积分,所以显得难以理解,难以实现。其实也就那么回事。

更新取消

猜你喜欢

转载自www.cnblogs.com/bitzhuwei/p/csharpgl-55-How-I-understand-PBR.html
PBR