Unity实现PBR造轮子实践

本篇博文秉持着学习和记录的目的而写,其中包含的内容基本为Unity源码的造轮子工程,以及向其他大佬学习的内容

本篇文章将主要分成两个部分,一个是直接光(BRDF)的实现,另一个则是IBL光照的实现

1. 直接光

首先认识一下渲染方程
在这里插入图片描述
看起来很吓人,但实际上在做的时候并不需要积分,每一个参数也都能通过公式解出来,所以混个眼熟就行
在实现过程中,后面的光照部分都是已知的,所以我们只需要算 BRDF 项就行
f r = f d i f f + f s p e c f_r = f_{diff} + f_{spec} fr=fdiff+fspec
展开后
f r = k d c π + k s D ( h ) F ( v , h ) G ( l , v , h ) 4 ( n ⋅ l ) ( n ⋅ v ) f_r = k_d\dfrac{c}{\pi} + k_s\dfrac{D(h)F(v,h)G(l,v,h)}{4(n\cdot l)(n\cdot v)} fr=kdπc+ks4(nl)(nv)D(h)F(v,h)G(l,v,h)
下面来详细说明每个部分怎么计算

1.1 漫反射部分

f d i f f = k d c π f_{diff} = k_d\dfrac{c}{\pi} fdiff=kdπc
k d = 1 − k s k_d = 1 - k_s kd=1ks
在 shader 中计算的方法是 kd * albedo

float3 albedo = _Tint * tex2D(_MainTex, i.uv);
float3 kd = (1 - F) * (1 - surface.metallic) * kDieletricSpec.a;
float3 diffColor = kd * albedo / UNITY_PI;

_Tint 是基础颜色, _MainTex 是材质的贴图, kd 的计算需要用到镜面反射部分的内容,在下面会说

1.2 镜面反射部分

f s p e c = k s D ( h ) F ( v , h ) G ( l , v , h ) 4 ( n ⋅ l ) ( n ⋅ v ) f_{spec} = k_s\dfrac{D(h)F(v,h)G(l,v,h)}{4(n\cdot l)(n\cdot v)} fspec=ks4(nl)(nv)D(h)F(v,h)G(l,v,h)

1.2.1 法线分布函数(NDF)

1.2.1.1 GGX

这部分就是公式里的 D(h),业界主流方法是 GGX
D G G X ( h ) = α 2 π ( ( n ⋅ h ) 2 ( α 2 − 1 ) + 1 ) 2 D_{GGX}(h) = \dfrac{\alpha^2}{\pi((n\cdot h)^2(\alpha^2 - 1) + 1)^2} DGGX(h)=π((nh)2(α21)+1)2α2
shader代码:

float D_GGX_(float nh, float roughness)
{
    float a2 = roughness * roughness;
    float nh2 = nh * nh;
    float s = nh2 * (a2 - 1.0) + 1.0; 
    return a2 / (s * s * UNITY_PI);
}
1.2.1.2 GTR

还有一种优化版的 NDF,GTR可以用 γ 控制 NDF 的形状,当 γ = 2 时,就是 GGX
在这里插入图片描述
图是借的,代码如下

float D_GTR_(float nh, float roughness, float gamma)
{
    float a2 = roughness * roughness;
    float nh2 = nh * nh;
    float s = nh2 * (a2 - 1.0) + 1.0; 
    return a2 / (pow(s, gamma) * UNITY_PI);
}

1.2.2 几何函数(Geometry Function)

对应公式里的 G(l,v,h)
Unity 里面是没有这一项的,而是将这部分和分母合并变成了V项
首先是 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) = \dfrac{n\cdot v}{(n\cdot v)(1 - k) + k} GSchlickGGX(n,v,k)=(nv)(1k)+knv
这里的 k 直接光和IBL光照是不一样的
k d i r e c t = ( α + 1 ) 2 8 k_{direct} = \dfrac{(\alpha + 1)^2}{8} kdirect=8(α+1)2
k I B L = α 2 2 k_{IBL} = \dfrac{\alpha^2}{2} kIBL=2α2
代码如下:

float SchlickGGX(float nv, float k)
{
    return nv / (nv * (1.0 - k) + k);
}

但是我们只解决了一个方向的自遮挡问题,对于渲染来说,光照到表面、相机到表面的射线都会产生自遮挡问题,所以真正的 G 项应该是:
G ( l , v , n ) = G S c h l i c k G G X ( l , n , k ) G S c h l i c k G G X ( v , n , k ) G(l,v, n) = G_{SchlickGGX}(l,n,k)G_{SchlickGGX}(v,n,k) G(l,v,n)=GSchlickGGX(l,n,k)GSchlickGGX(v,n,k)
代码如下:

float G_SmithGGX_(float nv, float nl, float roughness)
{
    float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
    return SchlickGGX(nv, k) * SchlickGGX(nl, k);
}

1.2.3 菲涅尔函数 (Fresnel Function)

1.2.3.1 Schlick

一般使用 Schlick 近似 :
F S c h l i c k = F 0 + ( 1 − F 0 ) ( 1 − ( v ⋅ h ) ) 5 F_{Schlick} = F_0 + (1-F_0)(1 - (v\cdot h))^5 FSchlick=F0+(1F0)(1(vh))5
F 0 = ( n − 1 n + 1 ) 2 F_0 = (\dfrac{n-1}{n+1})^2 F0=(n+1n1)2
F0在 shader 中可以通过以下方法直接获取:

float3 F0 = lerp(kDieletricSpec.rgb, albedo, surface.metallic);

kDieletricSpec 是 Unity 定义的绝缘体介质反射率(= 0.04),当金属度越大时,材质的高光反射会越接近它原本的颜色

大多数非金属的F0范围是0.02 ~ 0.04,大多数金属的F0范围是0.7 ~ 1.0,非金属具有单色/灰色镜面反射颜色。而金属具有彩色的镜面反射颜色,也就是说绝缘体的 F0 是 float,而金属的 F0 是 float3

于是我们可以通过下面函数计算出 F 项:

float3 F_Schlick_(float3 f0, float vh)
{
    float x = 1.0 - vh;
    float x2 = x * x;
    float x5 = x2 * x2 * x;
    return f0 + (1 - f0) * x5;
}
// ks
float3 F = F_Schlick_(F0, vh);

菲涅尔项 F 本身就已经体现了镜面反射的所占的比例,可以直接替换 ks,而 kd + ks = 1,所以:

float3 kd = (1 - F) * (1 - surface.metallic) * kDieletricSpec.a;

后面的因数是因为金属会更多的吸收折射光线导致漫反射消失

1.2.3.2 虚幻加速的版本

虚幻引擎给出了另一个版本的 F 项计算方法
F = F 0 + ( 1 − F 0 ) 2 ( − 5.55473 ( v ⋅ h ) − 6.98316 ) ( v ⋅ h ) F = F_0 + (1-F_0)2^{(-5.55473(v\cdot h) - 6.98316)(v\cdot h)} F=F0+(1F0)2(5.55473(vh)6.98316)(vh)

float3 F = F0 + (1 - F0) * exp2((-5.55473 * vh - 6.98316) * vh);

1.3 BRDF

现在我们有了两部分的结果,可以计算出 BRDF 项的所有内容了

 	float3 albedo = _Tint * tex2D(_MainTex, i.uv);

    float roughness = lerp(0.002, 1, PerceptualSmoothnessToRoughness(surface.smoothness));

    // D
    float D = D_GGX_(nh, roughness);
    // G
    float G = G_SmithGGX_(nv, nl, roughness);
    // F
    float3 F0 = lerp(kDieletricSpec.rgb, albedo, surface.metallic);
    float3 F = F_Schlick_(F0, vh);

    // 漫反射系数
    float3 kd = (1 - F) * (1 - surface.metallic) * kDieletricSpec.a;
    
    // BRDF
    float3 diffColor = kd * albedo / UNITY_PI;
    float3 specColor = (D * G * F * 0.25) / (nv * nl);
    float3 DirectBRDF = diffColor + specColor;
    
    float3 DirectLightResult = DirectBRDF * light.color * nl;

2. IBL光照

基于图像的照明(Image-based Lighting)可以帮助我们实现间接光
它依然要满足我们的渲染方程:
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) = \int\limits_{\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_i d\omega_i Lo(p,ωo)=Ω(kdπc+ks4(ωon)(ωin)DFG)Li(p,ωi)nωidωi
在直接光照中,因为点光源、直射光等光源的特性,我们可以将渲染方程的积分hack掉,但是对于环境光来说,它需要接收来自四面八方的 Irradiance,这就限制了我们必须要积分,在离线渲染中我们可以通过蒙特卡洛积分来计算,但是这样的消耗对于实时渲染来说是不现实的

我们先将渲染方程分成漫反射和镜面反射两部分:
L o ( p , ω o ) = ∫ Ω ( k d c π ) L i ( p , ω i ) n ⋅ ω i d ω i + ∫ Ω ( k s D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) ) L i ( p , ω i ) n ⋅ ω i d ω i L_o(p,\omega_o) = \int\limits_{\Omega} (k_d\frac{c}{\pi}) L_i(p,\omega_i) n \cdot \omega_i d\omega_i + \int\limits_{\Omega} (k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}) L_i(p,\omega_i) n \cdot \omega_i d\omega_i Lo(p,ωo)=Ω(kdπc)Li(p,ωi)nωidωi+Ω(ks4(ωon)(ωin)DFG)Li(p,ωi)nωidωi

2.1 漫反射部分

L o ( p , ω o ) = c π ∫ Ω k d L i ( p , ω i ) n ⋅ ω i d ω i L_o(p,\omega_o) = \frac{c}{\pi} \int\limits_{\Omega} k_dL_i(p,\omega_i) n \cdot \omega_i d\omega_i Lo(p,ωo)=πcΩkdLi(p,ωi)nωidωi
先将常数项移出积分,其中 kd 是不能移出积分项的,因为它与 F 项有关(这里可以看上文关于 kd 的计算),然后积分项就是一个半球面
在这里插入图片描述
我们可以对这个半球面进行预计算(没错!就是贴图)
而贴心的 Unity 已经为我们准备好了,它保存在球谐函数里(需要开启光照探针)
于是我们可以求出环境光的漫反射颜色了:

	float3 F_IBL = fresnelSchlickRoughness(max(nv, 0.0), F0, roughness);
    float3 kD = (1 - F_IBL) * (1 - surface.metallic);
	// 通过法线采样球谐函数
    half3 irradiance = SampleSH(surface.normal);
    float3 iblDiffuse = irradiance * albedo;
    
    float3 iblDiffColor = iblDiffuse * kD;

这里的 F 项使用的并非是直接光照里面的 Schlick 函数,而是加入了粗糙度影响后改进的方程

float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness)
{
    return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5);
}

2.2 镜面反射部分

镜面反射部分要更为复杂:
L o ( p , ω o ) = ∫ Ω ( k s D F G 4 ( ω o ⋅ n ) ( ω i ⋅ n ) L i ( p , ω i ) n ⋅ ω i d ω i = ∫ Ω f r ( p , ω i , ω o ) L i ( p , ω i ) n ⋅ ω i d ω i L_o(p,\omega_o) = \int\limits_{\Omega} (k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)} L_i(p,\omega_i) n \cdot \omega_i d\omega_i = \int\limits_{\Omega} f_r(p, \omega_i, \omega_o) L_i(p,\omega_i) n \cdot \omega_i d\omega_i Lo(p,ωo)=Ω(ks4(ωon)(ωin)DFGLi(p,ωi)nωidωi=Ωfr(p,ωi,ωo)Li(p,ωi)nωidωi
但是我们可以像这样将它拆成两部分求解:
L o ( p , ω o ) = ∫ Ω L i ( p , ω i ) d ω i ∗ ∫ Ω f r ( p , ω i , ω o ) n ⋅ ω i d ω i L_o(p,\omega_o) = \int\limits_{\Omega} L_i(p,\omega_i) d\omega_i * \int\limits_{\Omega} f_r(p, \omega_i, \omega_o) n \cdot \omega_i d\omega_i Lo(p,ωo)=ΩLi(p,ωi)dωiΩfr(p,ωi,ωo)nωidωi

这种拆解只是一种近似的方法,它足够准确的前提是 积分域小 或者 足够平滑,而我们的镜面反射可以看作是对单位立体角的积分(积分域小),漫反射则是变化的频率很低(足够平滑)

前面这部分就是环境贴图,Unity 也贴心的为我们准备好了,在 Frame Debugger下我们可以找到环境贴图 unity_SpecCube0 (需要开启反射探针),默认为天空盒,我们可以通过给 GameObject 添加 Reflection Probe 组件来获取周围环境的信息
在这里插入图片描述
shader代码如下:

	float3 reflectVec = reflect(-surface.viewDir, surface.normal);
    half mip = PerceptualRoughnessToMipmapLevel(PerceptualSmoothnessToPerceptualRoughness(surface.smoothness));
    half4 prefilterColor = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflectVec, mip);
    float3 iblSpecular = DecodeHDREnvironment(prefilterColor, unity_SpecCube0_HDR);

后面部分(BRDF)可以通过预计算得到,也就是下面的这张图

在这里插入图片描述
我们只需要对这张贴图进行采样就能够获取到环境光的 BRDF 值

float2 envBRDF = tex2D(_LUT, float2(lerp(0, 0.99, nv), lerp(0, 0.99, roughness))).rg;

将这两部分乘起来就得到了镜面反射的颜色信息了

float3 iblSpecColor = iblSpecular * (F_IBL * envBRDF.x + envBRDF.y);

最后我们将求得的直接光照和 IBL 加起来就完成了

float3 IndirectResult = iblDiffColor + iblSpecColor;
float4 result = float4(DirectLightResult + IndirectResult, 1);

3. 渲染结果

  • 金属球
    在这里插入图片描述
  • 塑料球(大概?)
    在这里插入图片描述
  • 开启反射探针
    在这里插入图片描述
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值