HiZ 还在施工中……


0x00 To begin with

这篇文章将会包含以下内容:

  • 部分课程内容回顾
  • GAMES202 作业 3

For reference👇:

master 分支上是 202 全部作业汇总,working 分支用于提交代码。需要原始作业可下载 master 分支的包


0x01 课程回顾 Recap

SSR(Screen Space Reflection)是一种基于屏幕空间的反射算法,也被应用于模拟全局光照。因为是屏幕空间的算法,不能且无需像传统全局光照算法那样需要获取场景的原始几何信息,而是只在当前相机的视野范围计算反射,所以效率相对较高,但也存在一些屏幕空间的 Artifacts。

SSR 算法有两个假设:

  1. 屏幕范围内的反射光,其反射源也同样来自屏幕空间的某个像素;
  2. 所有被直接光照亮的物体(在屏幕空间中则表现为像素),都可以作为次级光源参与对反射的贡献;

SSR 对反射表面没有要求,即不仅能模拟镜面反射,还可以模拟各种粗糙度的反射

重用屏幕空间信息 GAMES202

SSR 算法的步骤如下:

  1. 对每个需要计算反射的像素沿反射方向发射一条光线;
  2. 沿光线方向步进在屏幕空间中查找深度缓冲并与场景求交;
  3. 若有交点,则将交点颜色作为反射颜色添加到原始像素上;

其中最关键的步骤就是光线求交,最简单的方法就是 Linear RayMarching,光线每次行进一个固定的距离,每次行进后获取该位置的深度值再与场景深度进行比较,如果某次判断光线深度大于场景深度,则认为发生了相交。

固定步长的光线步进 GAMES202

当然这种方法的精细度很依赖光线步长,小的步长效果更好但消耗大,反之,更大的步长则可能导致光线“穿过”场景而误判相交,造成错误计算反射的情况。

为了在提高精度的同时节省开销,研究人员又提出了一种动态步长的方法,即 Hierarchical ray trace。允许光线根据场景信息在行进期间选取不同的步长,例图中光线一开始至少可以以 7 倍的 gap 作为步长,之后再缩减到原始的步长。

层级光线追踪 GAMES202

说到实时渲染中的一些动态操作,几乎就离不开预处理。同样的,这种方法运行的前提是,光线需要知道行进的“最大安全距离”,即可以迈多大的步子而不与场景发生相交。

这里最大安全距离是类比 SDF 的说法,或者应该说当前层级规定的步长

一种实现它的方法是预先生成深度图的 Mip-Map,这里四个像素的最小值作为 Mip 生成的逻辑,具体原因涉及到比较的保号性:如果光线与上一级 Mip 都不相交($D_{ray}

深度 Mip-Map GAMES202

拿到这些 Mip 之后就可以对 RayMarching 算法进行改良:一开始仍然以一个比较小的步长,如果行进后没有交点,则下次步进则在上一个层级进行(增加步长);反之若有交点,则不走这步,回到下一个层级(回退步长)再次尝试步进。

这里层级的上下是基于图像金字塔的位置关系,上面的层级信息更粗糙,下面的层级信息更精细

层级 Tracing GAMES202

这种层级方法叫做 HiZ(Hierarchical Z-buffer),能够显著减少 RayMarching 的迭代次数。


0x02 直接光照 Direct Lighting

直接光照总是比较简单的,而且之前的两个 Pass 已经帮我们把漫反射、世界空间法线和可见性信息(其实就是 ShadowMap)写入了 GBuffer,直接根据屏幕 UV 采样就能获取对应信息。具体而言要完善两个函数:EvalDiffuse 和 EvalDirectionalLight。

EvalDiffuse 函数返回漫反射 BSDF 的值,注意要把 cos 项算进去:

 1vec3 EvalDiffuse(vec3 wi, vec3 wo, vec2 uv) {
 2  vec3 L = vec3(0.0);
 3
 4  vec3 rho = GetGBufferDiffuse(uv);
 5  vec3 normalWS = GetGBufferNormalWorld(uv);
 6  
 7  float cosTheta = max(dot(normalWS, wi), 0.0);
 8  L = rho * INV_PI * cosTheta;
 9
10  return L;
11}

EvalDirectionalLight 返回 Shading Point 获得的 Light 的 Radience,这里考虑阴影,从 ShadowMap 中获取可见性关系,然后直接和 uLightRadience 相乘即可。

1vec3 EvalDirectionalLight(vec2 uv) {
2  vec3 Le = vec3(0.0);
3
4  float visibility = GetGBufferuShadow(uv);
5  Le = uLightRadiance * visibility;
6
7  return Le;
8}

然后在 main 函数更新一下 L 的计算逻辑:


0x03 屏幕空间光线追踪 SSR

RayMarch 函数用来判断采样的光线是否与场景相交,相交时将参数 hitPos 设置为交点,并返回 true。参数 ori 和 dir 分别为世界坐标下光线的起点位置和单位方向向量。

具体思路是:对于一条给定的射线,记录它的起点和方向,每次迭代时判断当前射线的起点处的深度与 GBuffer 中记录的深度的大小,如果其相减大于预先设定的阈值,则认为光线在该处相交;反之则继续向光线方向前进一段距离。到达最大迭代次数或找到交点时,算法终止。

 1#define MAX_ITERATION 100
 2#define INTERSECTION_THRESH 1e-5
 3#define RAY_STEP 0.02
 4
 5bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
 6  vec3 curPos = ori;
 7  vec3 rayStride = normalize(dir) * RAY_STEP;
 8  
 9  for(int i = 0; i < MAX_ITERATION; i++){
10    // get GBuffer infos
11    vec2 screenUV = GetScreenCoordinate(curPos);
12    float rayDepth = GetDepth(curPos);
13    float sceneDepth = GetGBufferDepth(screenUV);
14
15    // test intersection
16    if(rayDepth - sceneDepth >= INTERSECTION_THRESH){
17      hitPos = curPos;
18      return true;
19    }
20    curPos += rayStride;
21  }
22  return false;
23}

这里的参数需要调整至合适值,尤其是步长和阈值

可以写一个函数,假设表面是绝对镜面,检查一下 SSR 的实现,直接返回交点的漫反射颜色作为 SSR 的结果:

 1vec3 EvalSSR(vec3 wi, vec3 wo, vec2 uv) {
 2 vec3 L = vec3(0.0);
 3 
 4 vec3 posWS = vPosWorld.xyz;
 5 vec3 normalWS = GetGBufferNormalWorld(uv);
 6 vec3 reflectDir = normalize(reflect(-wo, normalWS));
 7 vec3 hitPos = vec3(0.0);
 8
 9 if(RayMarch(posWS, reflectDir, hitPos)){
10  vec2 screenReflectUV = GetScreenCoordinate(hitPos);
11  L = GetGBufferDiffuse(screenReflectUV);
12 }
13
14 return L;
15}

main 函数中再将这个镜反射的贡献加上,最终的效果应该是这样的,可以看到是正确反射了地面的颜色:


0x04 间接光照明 Indirect Lighting

这块的逻辑伪代码也给得很清楚了,要注意的有两个点:

  1. 两个采样函数返回的向量位于局部/球面/切线坐标系下,需要变换到世界坐标;
  2. 所有 RayMarching 找到的交点都视为次级光源,次级光源的 wi 为直接光方向(因为是 Directionnal Light),wo 为投射光线的逆(相当次级光照);

对于 1,框架中给出了 LocalBasis 用于计算局部空间中的基向量,给定法线 n,即可构造出局部空间到世界空间的变换矩阵 TBN;至于 2,对次级光源调用 EvalDiffuse 时分别对应设置其 wi 和 wo 即可。

 1vec3 EvalIndirectLight(vec3 wi, vec3 wo, vec2 uv, float s) {
 2  vec3 Li,L = vec3(0.0);
 3
 4  // load TBN matrix
 5  vec3 n = GetGBufferNormalWorld(uv);
 6  vec3 t, b;
 7  LocalBasis(n, t, b);
 8  mat3 TBN = mat3(t,b,n);
 9
10  float pdf = 0.0;
11  vec3 ori = vPosWorld.xyz;
12  vec3 hitPos = vec3(0.0);
13  vec2 screenIndirectUV = vec2(0.0);
14
15  for(int i=0; i<SAMPLE_NUM; i++){
16    // transform from tangent space to world space
17    // this ray cast from pos0 to pos1
18    // pos1 also serve as a light source
19    vec3 rayDir = normalize(TBN * SampleHemisphereCos(s,pdf));
20
21    if(RayMarch(ori,rayDir,hitPos)){
22      screenIndirectUV = GetScreenCoordinate(hitPos);
23      // light is directional hence for each shading point uses the same wi for lightDir
24      L = EvalDiffuse(rayDir,wo,uv)/pdf * EvalDiffuse(wi,-rayDir,screenIndirectUV) * EvalDirectionalLight(screenIndirectUV);
25      Li += L;
26    }
27
28    }
29
30  Li /= float(SAMPLE_NUM);
31  return Li;
32}

一切正常的话可以换个场景看看效果,engine.js 中,修改以下几处地方:

更换光源信息(65-71 行)和加载的模型(90 行):

更换摄像机位置(34-35 行):

去到网页就能看到大概这样的效果,可以看到暗部也有不错的间接光效果,如果觉得噪点太多可以修改采样数或 RayMarching 的参数,但是配置太精细网页会比较卡。

采样数为 50 的结果