本篇博文秉持着学习和记录的目的而写,其中包含的内容基本为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(n⋅l)(n⋅v)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=1−ks
在 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(n⋅l)(n⋅v)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)=π((n⋅h)2(α2−1)+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)=(n⋅v)(1−k)+kn⋅v
这里的 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+(1−F0)(1−(v⋅h))5
F
0
=
(
n
−
1
n
+
1
)
2
F_0 = (\dfrac{n-1}{n+1})^2
F0=(n+1n−1)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+(1−F0)2(−5.55473(v⋅h)−6.98316)(v⋅h)
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(ωo⋅n)(ωi⋅n)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(ωo⋅n)(ωi⋅n)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(ωo⋅n)(ωi⋅n)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. 渲染结果
- 金属球
- 塑料球(大概?)
- 开启反射探针