本文为GAMES 202 PBR Material的笔记

PBR和PBR材质

所谓PBR,指的是渲染过程中的一切都是基于物理的,包括材质、光源、相机和光照传输,不过在实践中一般单指材质。

实时渲染中的PBR Materials

表面的材质主要有两种,Microfacet模型和Disney Principled BRDFs。不过按照课件所说,这两种其实并非是基于物理的,它们仍然遵循着RTR领域的优良传统:hack和近似。

体积渲染的话,关注点更多是在如何快速近似单次或多次散射。

Microfacet(微平面)模型

Microfacet模型将表面近似成若干微小的镜面,并通过控制微平面的法线朝向来模拟不同的材质。回顾一下Microfacet BRDF的公式:

$$ f(\mathbf{i}, \mathbf{o}) = \frac{\mathbf{F}(\mathbf{i}, \mathbf{h})\mathbf{G}(\mathbf{i}, \mathbf{o}, \mathbf{h})\mathbf{D}(\mathbf{h})}{4(\mathbf{n}, \mathbf{i})(\mathbf{n}, \mathbf{o})} $$

其中$\mathbf{F}$项是菲涅尔项,可以让材质展现菲涅尔效应,$\mathbf{G}$项是阴影-遮挡项(阴影和遮挡其实是两码事,下文会解释),$\mathbf{G}$是法线分布。

F

大多数材质在观察视角接近掠射时反射会增强,这就是菲涅尔效应。对于绝缘体来说,这种效应尤其明显,而导体(金属)的菲涅尔效应则没有那么明显,因为无论观察角度如何反射率都很接近$1$。

菲涅尔项的计算非常复杂,不仅要考虑光的极化(偏振),对于金属还会涉及复数域上的计算,因此RTR领域中必须找到快速近似的方法。常用的近似是Schilick’s approximation,它不考虑极化。Schlick’s appoximatio的公式如下,其中$n_1$和$n_2$分别是入射介质和出射介质的折射率:

$$ R(\theta)=R_0+(1-R_0)(1-\cos \theta)^5, R_0=(\frac{n_1-n_2}{n_1+n_2})^2 $$

D

描述法线的分布,分布越集中则高光更明显,分布越分散则越接近diffuse材质。常用的模型有Beckmann和GGX。

Beckmann NDF

Beckmann NDF在形式上类似高斯分布,都是指数族的。各项同性Beckmann分布的公式如下:

其中$\alpha$代表粗糙度,$\theta_h$代表半程向量与法线的夹角。老师也解释了为何其中会有$\tan \theta$项,因为Beckmann是定义在Slope Space(就是垂直于半径的切平面)上的,所以原来的$x$值就被替换为了$\tan \theta$(见下图)。

GGX(or Trowbridge-Reitz)

与Beckmann相比峰度更小,拥有更长的尾部,因而材质会展现出更平缓的高光过渡,公式如下($m$就是半程向量):

GGX还有一个通用的形式GTR(Generalized Trowbridge-Reitz),它可以通过额外的参数$\gamma$进一步控制long-tail的程度,下面是GTR的可视化:

Unity 2022对于GGX的实现如下:

1
2
3
4
5
6
7
inline float GGXTerm (float NdotH, float roughness)
{
float a2 = roughness * roughness;
float d = (NdotH * a2 - NdotH) * NdotH + 1.0f; // 2 mad
return UNITY_INV_PI * a2 / (d * d + 1e-7f); // This function is not intended to be running on Mobile,
// therefore epsilon is smaller than what can be represented by half
}

G

G项为所谓的Shadowing-masking term,用于计算微平面间的自遮挡现象。Shadowing是指入射光被微平面所遮挡,而masking是指出射光被微平面所遮挡,分别对应下面左右两张图:

同时也可以直接从BRDF公式的角度来说明G项存在的必要性,由于归一化常数$(\mathbf{n}, \mathbf{i})$的存在,分母在观察角度近乎垂直时会接近$0$。此时不考虑自遮挡的话,边缘会异常明亮。

Smith Shadowing-masking term

Smith是常用的阴影遮挡项,它假设shadowing和masking是独立的两项,公式如下:

Unity 2022中对应的实现如下:

1
2
3
4
5
6
7
inline half SmithVisibilityTerm (half NdotL, half NdotV, half k)
{
half gL = NdotL * (1-k) + k;
half gV = NdotV * (1-k) + k;
return 1.0 / (gL * gV + 1e-5f); // This function is not intended to be running on Mobile,
// therefore epsilon is smaller than can be represented by half
}

Kulla-Conty Approximation

上文中的Microfacet BRDF存在一些问题,考虑下面白炉测试(white furnace test)的结果,菲涅尔项固定为$1$:

上图是渲染的效果,下图是cosine-weighted BRDF在半球上的积分,从下图中可以很明显地看到有能量损失。消失的能量去哪里了呢,由于菲涅尔项为$1$,而不同法线的分布又不会造成能量的损耗,说明问题只有可能出在$\mathbf{G}$项上。此前我们假设了那些被遮挡的光线被吸收了,然而这与微平面是镜面的假设是相悖的,被遮挡的光线应该在多次弹射后重新射出。只有额外考虑经历Multiple Bounces的光线后,BRDF才是守恒的。

Kulla-Conty即采用了这个思路去实现能量守恒,首先计算反射率:

Kulla-Conty近似希望使用一个额外的BRDF来补全多次弹射的能量,这个额外的BRDF的cosine-weighted积分为$1-E(\mu_o)$。又因为BRDF需要满足reciprocity,Kulla-Conty干脆就假设额外的BRDF满足形式$c(1-E(\mu_i))(1-E(\mu_o))$,这样需要计算归一化常数$c$就可以了。经过一番计算后,可以得到BRDF的公式:

不过$E(\mu)$和$E_{avg}$都是积分,需要预计算才能用在实时渲染中。$E(\mu)$可以制成一张关于roughness和$\mu$的2D纹理,而$E_{avg}$由于对于$\mu$进行了积分,只需要一张1D纹理即可存储。

Kulla-Conty Approximation with Color

如果BRDF带有颜色信息呢?这时我们就不能简单地将菲涅尔项固定为$1$了,因为颜色信息就在$\mathbf{F}$中。颜色就是对光线的吸收,也就是能量损失,所以思路就是再去计算一个衰减系数乘到$f_{ms}$前面。首先定义平均菲涅尔项为:

这时候经历了$k$次弹射的能量为:

$$ F_{avg}^k(1-E_{avg})^k\cdot F_{avg}E_{avg} $$

将各次弹射的能量累加起来进行无穷级数求和后得到:

$$ \frac{F_{avg}E_{avg}}{1-F_{avg}(1-E_{avg})} $$

将这个系数乘到额外的BRDF即可。

结果

使用Kulla-Conty近似后,明显明亮了许多:


GAMES202 HW3的完成记录~

总览

在延迟渲染管线下,为一个光源为方向光,材质为漫反射 (Diffuse) 的场景实现屏幕空间下的全局光照效果(两次反射)。

作业3共分为三个部分:

  1. 实现对场景直接光照的着色 (考虑阴影)。
  2. 实现屏幕空间下光线的求交 (SSR)。
  3. 实现对场景间接光照的着色。

作业文档里使用的术语是BSDF,不过既然本次作业只会涉及到漫反射材质,下文可能会出现BSDF和BRDF的互换。

Part 1:直接光照

这部分的两个子任务是计算漫反射材质BSDF的值以及光照的强度(包含可见性),非常简单。

首先是EvalDiffuse函数的实现。虽然EvalDiffuse接收了三个参数$w_i, w_o$和$uv$,但是对于漫反射材质来说,前两个代表方向的参数都是不需要的。作业说明中提示要用到保存在G-Buffer中的法线信息,意味着计算出来的BSDF是cosine weighted的。知道了这些之后,实现EvalDiffuse就是随手的事啦:

1
2
3
4
5
6
7
vec3 EvalDiffuse(vec3 wi, vec3 wo, vec2 uv) {
vec3 albedo = GetGBufferDiffuse(uv);
vec3 n = GetGBufferNormalWorld(uv);
float cos_theta = dot(n, wi);
vec3 bsdf = albedo * max(0.0, cos_theta) * INV_PI;
return bsdf;
}

漫反射BSDF

要注意的一点是,对于最终的漫反射值我们要乘上$\pi$才能保证BSDF的能量守恒。这个结论之前是知道的,下面给出一个简单的推导。首先根据反射率$\rho$的定义有($E_i$和$E_o$分别代表irradiance和radiant exitance):

$$E_o=\int_\Omega L(\omega_o)\cos \theta_o \mathrm{d}\omega=\rho E_i$$

由BRDF的定义可得:

$$\mathrm{d}L(\omega_o) = f_r \mathrm{d}E(\omega_i)$$

我们知道漫反射材质的$f_r$是常数,两边同时积分可得(注意$E(\omega_i)$和$E_i$不是一个东西):

$$L(\omega_o) = f_r E_i$$

带入到反射率的定义后:

$$\rho E_i = \int_\Omega f_r E_i \cos\theta_o \mathrm{d}\omega = f_r E_i \int_\Omega \cos\theta_o \mathrm{d}\omega = \pi f_r E_i$$

从上式可知:

$$f_r = \frac{\rho}{\pi}$$

然后是EvalDirectionalLight的实现,需要考虑可见性项,也就是要从GBuffer中提取阴影信息:

1
2
3
4
5
vec3 EvalDirectionalLight(vec2 uv) {
vec3 Le = vec3(0.0); // 自发光项
vec3 Ld = uLightRadiance * GetGBufferuShadow(uv);
return Le + Ld;
}

至此第一部分就结束了。

Part 2:Ray Marching

第二部分需要实现一个RayMarching算法来完成屏幕空间的求交,基本思路就是从某个像素对应的世界坐标出发,沿着给定的方向按照一定的步长行进若干步,直到当前坐标在屏幕空间中被遮挡,说明找到了交点。

由于我们不知道光线会行进多远,所以必须设置一个最大的行进步数。同时我们也不知道光线每步要走多远,所以这个值也是个超参数。当然,这种所谓线性搜索地方法是很慢的,老师在课上提到了使用HiZ方法来自适应地调整步长进而提高求交效率,不过在作业框架中实现HiZ是很困难的,这里就先留个坑,有意实现可以参考[2]。

回到算法的实现上来,其实就是一个步进光线然后与深度图比较的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
const float threshold = MARCH_STRIDE * 2.0;
for (int i = 0; i < MARCH_MAX_STEPS; i++)
{
ori += dir * MARCH_STRIDE;
float ray_depth = GetDepth(ori);
vec2 uv = GetScreenCoordinate(ori);
float geo_depth = GetGBufferDepth(uv);
if (ray_depth > geo_depth) { // intersection found
if (ray_depth - geo_depth > threshold)
return false;
if (dot(dir, GetGBufferNormalWorld(uv)) >= 0.0)
return false;
hitPos = ori;
return true;
}
}
return false;
}

其中关于threshold会放在第三部分之后解释。其实在其他同学的实现中,只需要判断ray_depth > geo_depth就足够了,不过我这边如果这么写的话阴影部分会出现严重的噪点。分析了一下发现遮挡物如果是背朝光线的话一定不会对最终的结果有贡献,就额外增加了一个判断光线与法线夹角的判断。按照文档的提示验证镜面反射效果:

由于步长并不是非常小,所以在图像中间会有明显的瑕疵(跳变),不过整体效果是对的,说明实现基本正确。

Part 3:间接光照

Part 3是最有意思的一部分,需要实现支持one-bounce的间接光照。当然,如SSRT的名字所暗示的,采样是不可避免的。对于每个像素点,我们采样一条光线,然后使用Part 2实现的方法来完成屏幕空间内的求交,计算该交点的直接光照后加权并累加到最终的间接光照中去,按照给出的伪代码实现即可。框架提供了均匀采样和按照余弦分布采样两种半球采样方式,后者算是一种重要性采样了,直接无脑选择。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vec3 EvalIndirectLight(vec3 wo, vec2 uv, vec3 worldPos, vec3 lightDir, inout float s) {
vec3 n = GetGBufferNormalWorld(uv);
vec3 t, b;
LocalBasis(n, t, b);
float pdf;
vec3 hitPos;
vec2 uv1;
vec3 L = vec3(0.0); // one bounce
for (int i = 0; i < SAMPLE_NUM; i++)
{
vec3 dir = normalize(mat3(t, b, n) * SampleHemisphereCos(s, pdf));
if (RayMarch(worldPos, dir, hitPos))
{
uv1 = GetScreenCoordinate(hitPos);
L += EvalDiffuse(dir, wo, uv) * EvalDiffuse(lightDir, -dir, uv1) *
EvalDirectionalLight(uv1) / pdf;
}
}
L /= float(SAMPLE_NUM);
return L;
}

由于采样出来的方向向量是在局部空间的,所以需要构造局部坐标系的基向量并将方向向量转换到世界坐标系中。LocalBasis所用的方法似乎是叫Frisvad方法(GPT说的),很容易验证它是对的,但是不知道原理是什么。

改进

终于可以看到结果了,却大失所望,画面中有很多的噪点,而且不同角度下的渲染结果非常不一致,尤其是在圈出的部分有漏光现象:

转动视角后更加明显:

来探究一下原因,其实与老师上课讲的是一样的:屏幕空间会丢失信息。具体来讲,由于GBuffer只会记录位于最前面的表面的信息,当前视角中被遮挡的表面的任何信息都是未知的。在RayMarching的过程中,由于判断交点存在的条件只是深度更大,所以会出现对于遮挡关系的误判。算法错误地认为某个可见的表面会对结果产生贡献,其实真正的交点在更远处的表面上或根本不存在。为了减少误判,[1]中使用了加threshold并动态调整步长的方式在优化。在试验中发现只需要threshold就能达到较好的效果,所需的改动就是如果某一次判断时光线的深度与GBuffer中的深度相差较大就认为交点不可见并返回false,改进后的算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
vec3 last_ori;
const float threshold = MARCH_STRIDE * 2.0;
for (int i = 0; i < MARCH_MAX_STEPS; i++)
{
last_ori = ori;
ori += dir * MARCH_STRIDE;
float ray_depth = GetDepth(ori);
vec2 uv = GetScreenCoordinate(ori);
float geo_depth = GetGBufferDepth(uv);
if (ray_depth > geo_depth) { // intersection found
if (dot(dir, GetGBufferNormalWorld(uv)) >= 0.0 || ray_depth - geo_depth > threshold)
return false;
hitPos = ori;
return true;
}
}
return false;
}

其中threshold的大小与STRIDE呈正相关,降低在某些STRIDE较大的场景中错误地舍弃交点的几率。

最终的结果如下

场景1,采样数=8, stride=0.1, max_steps=50:

场景1,采样数=8, stride=0.1, max_steps=50:

场景3,采样数=8, stride=0.6, max_steps=30(记得在engine.js中切换灯光,要么场景会很暗):

暗处的噪点说实话并不明显,场景三还是很震撼的,除了硬阴影的锯齿有点扎眼外。

参考

  1. Games202 作业三 SSR实现
  2. GAMES202-作业3

该文章内容主要来自于虚幻5的官方文档[4]。

介绍

一开始只觉得Lumen听起来很高大上,以为它除了渲染之外还有一些内容,最后发现是和Nanite搞混了(难绷)。按照文档的介绍,Lumen其实就是UE5新推出的全局光照(GI)和反射系统。最离谱的是,Lumen可以近似反射了无限次的漫反射光照和高光光照,甚至能在复杂环境中达成实时渲染。Lumen是此前各种屏幕空间技术(SSGI)和距离场环境光遮蔽(DFAO)的替代品。P.S. DFAO是虚幻4中所用的环境光遮蔽方法,文档里描述其与SSAO最大的区别是遮蔽在场景空间遮挡物中进行计算,因此出屏丢失数据不会导致瑕疵,至于原理吗,我猜与GAMES202中所讲的距离场阴影类似。

要启用Lumen,需要在Project Settings->Rnedering中启用Dynamic Global Illumination和Reflections这两项(新项目应该是默认启用的)。

特性

全局光照

  • 不限制弹射次数的间接漫反射光照
  • 真实阴影(在光线追踪器中,阴影就不需要用传统方法去做了)
  • 要求实时性的情况下会以较低的分辨率来计算间接光照

天光(Sky Lighting)

天光在UE中负责模拟来自天空或远景的间接光照[1],Skylight计算的光照会填充场景中的阴影部分,增强环境光照。Lumen也会为半透明材质和体积雾计算一个低质量的全局光照。

自发光材质

自发光材质的贡献在Lumen的Final Gather Process中被计算。目前Final Gather还不太理解,留个坑。

反射

Lumen支持各种粗糙度材质反射,包括清漆(clear coat)和不透明材质的glossy反射,打开对应选项后也能渲染出半透明材质在最前面一层surface上的反射。Lumen还支持渲染单层水面的镜面反射。

Clear Coat材质

即基础层上覆盖了薄薄的一层透明涂料,比如车漆[2]。Clear coat材质也可以使用Cook-Torrance microfacet BRDF来表示[3]。

双面树叶

允许光线在树叶中的次表面散射,效果出奇的好。下图右边是开启Two-Sided Foliage之后的效果

设置(略)

补充说明

Lumen的更新速度

因为lumen似乎使用的是类似光照探针的技术并将计算压力分摊到多帧上来保证实时性的,所以局部光照条件的变化会快速传播,而全局的光照变化(如禁用阳光)需要一定的时间才能收敛。实测如果在项目中将主要的平行光设置为不可见的话,场景会缓慢变暗。而且如果仔细观察的话,直接光照会立即消失,间接光照则是缓慢黯淡。

Lumen反射

Lumen Reflections没有与Lumen GI耦合,可以单独拿出来配合静态烘焙光照(Lightmap)来使用。

材质环境光遮蔽(AO贴图)

由于使用了光线追踪,屏幕空间的AO和AO贴图也就没有必要了,不过还是可以在项目设置中启用的。
P.S.1 UE编辑器界面中Buffer Visualization->Ambient Occulusion似乎指的是SSAO,此时由于未启用SSAO所以应该只能观察到纯白色。
P.S.2 [5]中提到,Buffer Visualization其实就是绘制了G-Buffer中的内容,但是材质里面的AO贴图并不会被写入到G-buffer中,而是会被叠加到Lightmap,Stationary sky light和Reflection capture specular中去,因此预览Material Ambient Occlusion无法得到正确的内容。

参考资料

  1. What exactly is a Skylight in UE4?
  2. 车漆渲染做法Clear-Coat
  3. Physically Based Rendering in Filament#Clear coat model
  4. Lumen Global Illumination and Reflections
  5. Unreal | AO那些事

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

背景

PreZ技术的全称是Pre-depth Pass,从这个名字中大概就能得知该技术的一些要点如2-pass和depth pass。PreZ是为了防止开启Alpha Test时可能出现的渲染顺序错误。假如现在我们开启了Alpha Test并且启用了Early-Z来提高渲染效率,由于在片元着色器运行之前我们并不知道哪些片元会被Alpha Test所剔除,提前进行深度测试很可能会导致错误。

方法

PreZ的思路也很简单,既然将深度测试提前的终极目标就是尽可能地减少渲染的开销,那能不能在保持Alpha Test开启的情况下只渲染最低限度的信息?既然Alpha Test会导致深度信息错误,那不如直接把depth buffer取出来单独渲染,得到正确的深度信息后再进行更复杂的光照计算。这样我们就构想出了一个两趟的算法:

  1. Pass 1:保持Alpha Test开启渲染深度图

  2. Pass 2:关闭Alpha Test,开启Early-Z并将深度测试的条件设置为EQUAL,运行片元着色

总结与思考

整体算法的思路清晰简单,单独渲染深度图的开销一般也很小。

0%