CSharpGL(55)我是这样理解PBR的
简介
PBR(Physically Based Rendering),基于物理的渲染,据说是目前最先进的实时渲染方法。它比Blinn-Phong方法的真实感更强,几乎是照片级的效果。
下图就是PBR的一个例子,读者可在CSharpGL中找到。
应用题
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。)
所求问题:
观察者看到的颜色是什么?(用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)表示物体的材质,等等。但是思路都是一样的,不要纠结这里。
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)<= ,还有别的优点:
它能把周围环境当作一个整体的光源,这扩大了光源的范围。
它以真实的物理量为参数,因而对美工更友好。
它表现出照片级的真实感,且物体看起来就像本来就属于场景中一样。
PBR模型
PBR也将物体反射到每个方向上的光,都分为漫反射diffuse和镜面反射specular这2个部分。
同时,它对这2种反射光的形成机制给出了自己的解释:
如图所示,一些入射光Li打在点p上。仔细想想,点p实际上不是数学意义上的点,而是由很多微小的平面(长度大于光的波长,小于像素,简称微平面)组成的一小块“褶子”(褶皱程度就是粗糙度roughness)。入射光Li打在褶子上,一部分会被褶子直接反射,另一部分会被吸收进褶子内部。直接反射的,就是specular部分;吸收后,在褶子内部经过若干次碰撞(组成褶子的原子、分子会不断地反射或吸收剩下的光),有一些光会再次被反射出来,这就是diffuse部分。
PBR模型的关键,就在于光的波长、微平面的大小、像素的大小这三者的大小关系。由于光的波长远远小于微平面的尺寸,所以就不用考虑光的衍射等现象。由于微平面的尺寸远远小于一个像素,所以可以将一个个像素视为一个个“褶子”。这样一来,虽然入射光的diffuse部分,其出射位置与入射位置不完全相同,但仍旧在同一个像素范围内,所以可以视作位置相同。
(有人会说,会不会有的光在褶子内部被反射的很远,最终超出了一个像素的范围呢?答案是,会。那么,这种情况如何处理呢?PBR的答案是,忽略不计。)
“褶子”只是一个称呼,事实上完美光滑的“褶子”,即微平面的排列完全平整,一点都不褶(光学平滑)是存在的,你可以在高端望远镜上找到。当然了,这是微平面级别的完美光滑,不是原子级别的。原子级别的完美光滑,据我所知还做不到。
PBR认为 (diffuse+specular)== 始终成立。那么,先算出其中一个,自然就得知另一个了(1-specular)。
Specular部分
菲涅耳方程F
当你站在清澈的海边、河边、湖边,低头向下看时,能够看到水面下的沙石泥土,但平视远处的水面时,就只能看到强烈的反光,很难看到水面下的景象。这种现象被称为菲涅耳(Fresnel)效应。更多图文介绍可以参考(http://blog.sina.com.cn/s/blog_798bec050100rigq.html)。
这种现象说明,入射光被拆分后,specular所占的比例,与入射光Li和观察者v的方向有关。当然,它还与物质的材质有关。菲涅耳方程(Fresnel Equation)给出了一个计算specular的公式。不过那玩意计算起来比较费时,业界一般用它的一个近似版本:
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
当然,其他版本的F函数也是存在的。
其中的cosTheta = max(, dot(v, normalize(v + Li))) 。可见“它与入射光Li和观察者v的方向有关”,此言不虚。
其中的F0就是物质的材质属性。每种材质都一个对应的F0常数。
其返回结果为vec3 specular,就是说,黄金、白银、钢铁、巧克力,材质对光的RGB通道的反射能力不同。嗯这很科学。
有了specular,当然就有了 vec3 diffuse = vec3(, , ) - specular 。我们稍后再讨论diffuse。
几何函数G
菲涅耳公式给出的,是在入射光Li和观察者v条件下,specular所占的比例。但是,褶子是粗糙的,会遮挡住specular的一部分。
因此,需要计算出没有被遮挡的比例,这就是几何函数(Geometry Function):
(K是指平行光、点光源、聚光灯这样的光源应采用的公式;K是将整个图片作为光源时应采用的公式。α表示表面粗糙度)
float GeometrySchlickGGX(float NdotV, float roughness)
{
float r = (roughness + 1.0);
float k = (r*r) / 8.0; float nom = NdotV;
float denom = NdotV * (1.0 - k) + k; return nom / 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;
}
当然,其他版本的G函数也是存在的。
从参数可知,遮蔽比例与入射光方向Li、法线N、观察者方向v和粗糙度roughness都是有关的。
法线分布函数D
那么,那些没有被遮蔽的specular部分,就全部进入观察者的眼中了吗?并没有。在这些顺利逃出来的specular中,只有那些法线方向与(V+L)相同的微平面反射的光,才能进入观察者眼中。
法线分布函数(Normal Distribution Function)就给出了这个比例:
(α表示表面粗糙度)
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 nom = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom; return nom / denom;
}
当然,其他版本的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部分可以将一些常数提取出来:
现在,积分内部的含义是,在半球范围内,将所有方向上的入射光向量分别与法线相乘,再加起来。这个积分在shader中当然要用离散的方式计算。半球嘛,立体的,所以分别在水平方向和竖直方向上进行累加比较方便。
此时,我们就可以把上述方程稍微变形下:
然后变为对应的离散的形式:
从原来的积分形式变为离散形式,使用了蒙特卡罗积分原理。感兴趣的同学可以自行搜索研究一下。本文中,只要知道可以这么转换就行了。
在shader中表示这个离散公式的代码如下:
#version core
out vec4 FragColor;
in vec3 WorldPos; uniform samplerCube environmentMap; const float PI = 3.14159265359; void main()
{
vec3 N = normalize(WorldPos); vec3 irradiance = vec3(0.0); // tangent space calculation from origin point
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 right = cross(up, N);
up = cross(N, right); float sampleDelta = 0.025;
float nrSamples = 0.0f;
for(float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta)
{
for(float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta)
{
// spherical to cartesian (in tangent space)
vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
// tangent space to world
vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N; irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
nrSamples++;
}
}
irradiance = PI * irradiance * (1.0 / float(nrSamples)); FragColor = vec4(irradiance, 1.0);
}
代码中的双重for循环,就是在离散地计算积分值。最后得到的irradiance,再乘以Kd*c,就是diffuse部分的颜色值了。这个值加上接下来马上要讲解的specular部分的颜色值,就是应用题的答案。
所有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。下图就是:
分别从卷积贴图里采样,再算到公式里就得到specular部分的颜色了。
贴图总结
首先,我们需要从一个*.hdr文件加载二维纹理texHDR。
然后,将texHDR转换为天空盒纹理sampleCube environmentMap。
然后,用environmentMap分别生成irradianceMap和多mipmap层的prefilterMap。
最后,brdfLUT是独立生成的,与别的贴图无关。
只需加载其他的*.hdr文件,就可以将物体置于其他天空盒下。PBR将天空盒视作光源,照射物体。这就是PBR能让物体保持融入各个场景中原因。
下图是我在CSharpGL中使用的newport_loft.hdr加载后的样子:
这样的样式,在头顶和脚底方向上的数据损失会多一点。不过,一般用户关注的都是平视方向,所以没问题。
总结
PBR是对Blinn-Phong的一种极大的改进。它用几个贴图帮助求解积分,所以显得难以理解,难以实现。其实也就那么回事。
更新取消