0%

Screen Space Ray Reflection

1 Summary

本文介绍屏幕空间反射中最重要的一步——基于 hierarchy depth buffer(HZB) 的 ray march算法,主要参考了 [1] 中的实现。

ray march算法的目的是为了根据屏幕空间深度得到光线在屏幕上的交点,相比于世界空间的光追,这是一种开销很低但仅局限于屏幕空间的做法,也就是若光线交于相机看不到的表面,那么屏幕空间ray march则无法得到求交结果。而 hierarchy depth buffer 则提供了四叉树的加速结构,每一级mipmap都取上一级四个深度中的closest depth。假如光线在 closest depth 的表面之上(距相机更近),那么可以光线也不会与更精细层级相交,因此可以快速行进不会相交的区域。

2 hierarchy depth buffer

hierarchy depth buffer(HZB) 是构建depth buffer的mipmap形式,但与API接口提供的mipmap构建方式(取均值)不同,hierarchy ray march需要每一层级取前一层级四个深度中的closest depth,具体取最小或最大由depth实现决定。本文采用 \([0,1]\) 的depth,越大距相机越远,因此取最小深度。

3 Construct Ray

对于屏幕空间,往往已知光线起点像素坐标 fragCoord,以及基于需要采样的光线方向 rayDir (位于世界/相机空间,本文采样相机空间)。ray march 过程在屏幕空间进行,同时为了方便在不同mip层级行进,需要得到screen uv space(即 \([0,1]\times [0,1]\times [0,1]\))下的光线起点,以及屏幕空间的光线方向。

(fragCoord, depth) 变换到view space得到 viewRayPos,沿光线方向行进一个单位得到 nextRayPos = viewRayPos + rayDir,最后将二者都变换到screen uv space,得到 originnextRayScreenPos,光线方向 direction = nextRayScreenPos - origin,注意不需要normalize,因为这并不是三维空间的方向。

4 Ray March

给定光线起点 origin 与光线方向 direction,都位于 screen uv space。在光线行进过程中,会反复执行一下步骤:

  • 先行进 linear step,即在当前层级行进一个像素。执行基于深度的求交判断,有:

    • 如果光线从当前像素在表面上(更靠近相机)掠过,表示无交点,那么去更高层级(表示更大步长)。

    • 如果光线与当前像素相交,表示可能相交,那么去更低层级(表示更小步长)

4.1 Linear March

行进一个步长是希望沿着光线方向行进到下一个像素,同时下个像素内执行一个偏移,如图 Fig-1 所示。

image-20230812143337171

Fig-1 一次Linear March的示意图

行进步长 marchStep 可设置为

1
vec2 marchStep = mix(vec2(0.0), vec2(1.0), greaterThanEqual(direction.xy, vec2(0.0)));

像素内部偏移量 uvOffset 以像素大小为单位,可设置为

1
2
vec2 uvOffset = 0.005 * exp2(baseLevel) * (1.0 / screenResolution);	// baseLevel 表示 ray march 执行的最低层级
uvOffset = mix(-uvOffset, uvOffset, greaterThanEqual(direction.xy, vec2(0.0)));

假设当前光线位置 currentRayCoord 以及当前层级分辨率 currentMipResolution,linear march的执行如下

1
2
3
4
5
6
vec2 LinearMarch(vec2 currentRayCoord, vec2 currentMipResolutionInv)	// currentMipResolutionInv 为倒数
{
vec2 newRayCoord = floor(currentRayCoord) + marchStep;
newRayUV = newRayCoord * currentMipResolutionInv + uvOffset;
return newRayUV;
}

在进行求交之前,光线需要先行进一步,避免与自身所在像素自相交,行进到下一个像素的boundary plane处,有

1
2
3
4
5
6
7
8
9
void InitRay(vec3 origin, vec3 direction, vec3 directionInv, vec2 currentRayCoord, 
vec2 currentMipResolutionInv, out vec3 position, out float t)
{
vec2 xyPlane = LinearMarch(currentRayCoord, currentMipResolutionInv);
// o + d * t = p' => t = (p' - o) * (1 / d)
vec2 tXy = xyPlane * directionInv.xy - origin.xy * directionInv.xy;
t = min(tXy.x, tXy.y);
position = origin + t * direction;
}

4.2 Intersection

每次迭代都需要进行判断是否与将要行进到的像素有交点,以此来决定下一步应该如何执行。如何判断光线与将要行进像素是否相交?

假设光线当前位置为 positton,将要行进位置为 new position,如图 Fig-1 所示。new position 处的 (x, y, surfaceZ) (surfaceZ为该处像素的深度) 形成了一个bounding box的一半,即相邻的三个面组成的boundary,可以看到 Fig-1 中,俯视近平面所展示的 x/y plane 形成的黄色 boundary plane。从侧面看近平面如图 Fig-2 所示,相机位于上方,两条黄色 boundary line交点处即为 new position。

image-20230812194326749
Fig-2 从侧面看近平面

对于一个像素而言,其内部深度都是相同的,光线是否会与即将行进的像素相交,通过查看两个端点 position与new position即可:

  • 光线当前位置 position 与表面的关系有:position.z < surfaceZ,光线在表面之上;否则,光线在表面之下,可能与像素相交
  • 求光线与new position形成的boundary plane (x, y, surfaceZ)的最近交点,如果没有与z plane相交,则表示光线可以从new position上方掠过

因此,对于光线当前位置 position 的两种情况而言:

  • position.z < surfaceZ:光线在表面之上,不会与position所在像素相交。
      1. 光线与 z plane 相交,表示光线与下一个像素相交
      1. 否则,即与 x/y plane 相交,表示光线与下一个像素也不相交
    1. 否则,光线在表面之下,与position所在像素相交

为了能够使用 hierarchy depth buffer 加快速度,应该有:

  • 如果本次迭代的行进有可能相交,则去更细粒度层级,即使用更小行进步长。例如上述情况 a,光线可以行进到 z plane 交点位置;c,光线位置不变,等待更细粒度层级测试
  • 如果不会相交,则去更粗粒度层级,即使用更大行进步长。例如上述情况 b,光线可以行进到 x/y plane 的交点位置。

上述迭代一直进行到没有更细粒度层级可用时,表示已找到可能交点;否则,达到迭代次数上限后,表示无法找到交点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// surfaceZ: depth at currentRayCoord.xy
bool AdvanceRay(vec3 origin, vec3 direction, vec2 currentRayCoord, float surfaceZ. vec2 currentMipResolutionInv, inout vec3 position, inout float t)
{
// new position 处的 boundary plane
vec2 xyPlane = LinearMarch(currentRayCoord, currentMipResolutionInv);
vec3 boundaryPlane = vec3(xyPlane, surfaceZ);
// o + d * t = p' => t = (p' - o) * (1 / d)
vec3 tXyz = boundaryPlane * directionInv - origin * directionInv;
// 特殊情况,// Prevent using z plane when shooting out of the depth buffer.
tXyz.z = direction.z > 0 ? tXyz.z : SSR_FLOAT_MAX;
float tMin = min(min(tXyz.x, tXyz.y), tXyz.z);
bool isAboveSurface = position.z < surfaceZ;
bool skipTile = tMin != tXyz.z && isAboveSurface; // 是否为情况 b
t = isAboveSurface ? tMin : t;
position = origin + t * direction;
return skipTile;
}

每次迭代后,调整层级

1
2
3
4
bool skipTile = AdvanceRay(...);
currentLevel += skipTile ? 1 : -1;
currentMipResolution *= skipTile ? 0.5 : 2.0;
currentMipResolutionInv *= skipTile ? 2.0 : 0.5;

4.2.1 特殊情况 :confused: :question:

上面描述都是以光线方向背离相机为例,当光线方向朝向相机时,需要特殊注意:

  • position.z >= surfaceZ:与前述过程没有不同
  • position.z < surfaceZ:此时不能使用 z plane 的交点,因为得到的

4.2.2 线性查找

前面讲到,迭代到没有更细粒度层级时,表示找到交点,退出迭代。但如果是超出最大层级时呢?一种做法是,可以看作无法找到交点,但如果想要查找更充分,此时可以降为在最大层级线性查找过程,迭代后层级调整实现修改如下

1
2
3
4
5
6
7
8
bool skipTile = AdvanceRay(...);
int prevLevel = currentLevel;
currentLevel += skipTile ? 1 : -1;
currentLevel = min(currentLevel, LevelCount - 1);
if (currentLevel != prevLevel) { // 只有层级发生改变时
currentMipResolution *= skipTile ? 0.5 : 2.0;
currentMipResolutionInv *= skipTile ? 2.0 : 0.5;
}

4.2.3 交点是否有效

前述讲的求交测试都是基于深度信息进行的,如果光线走到了像素背后,则表示相交。但实际上,像素占据了表面一定面积,而该小部分表面并非与近平面平行或者并非是平坦的,因此所在表面存在一定厚度,有可能光线从表面背后穿过去,只有在像素厚度范围内的光线,才算是相交。因此,在得到交点后,还需要进行验证是否有效。

提取交点 position 处的深度值 surfaceZ,分别将 position.zsurfaceZ 变换到线性空间(相机/世界空间),得到 rayDepthhitDepth,有

1
2
3
4
5
6
bool TestThickness(float rayZ, float hitZ)
{
float rayDepth = Linearize(rayZ);
float hitDepth = Linearize(hitZ);
return abs(rayDepth - hitDepth) < (relativeThickness * max(abs(hitDepth), 1e-5));
}

4.3 Put All Together

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
vec3 HierarchyRayMarch(vec3 origin, vec3 direction, int maxIteration, 
float relativeThickness, out bool isValidHit)
{
// 一些参数初始化
const vec3 directionInv = 1.0 / direction;
const int baseLevel = 0; // 0 means full resolution depth
int currentLevel = baseLevel;
vec2 currentMipResolution = GetMipResolution(screenResolution, currentLevel);
vec2 currentMipResolutionInv = 1.0 / currentMipResolution;
// 设置 Linear March 步长
vec2 marchStep = mix(vec2(0.0), vec2(1.0), greaterThanEqual(direction.xy, vec2(0.0)));
vec2 uvOffset = 0.005 * exp2(baseLevel) * (1.0 / screenResolution); // baseLevel 表示 ray march 执行的最低层级
uvOffset = mix(-uvOffset, uvOffset, greaterThanEqual(direction.xy, vec2(0.0)));
vec3 position;
float t;
// 光线先行进一个单位,避免与自身像素相交
InitRay(origin, direction, currentMipResolution * origin.xy, currentMipResolutionInv, position, t);
int i = 0;
while (i < maxIteration && currentLevel >= baseLevel && InsideScreen(position.xy)) {
vec2 currentRayCoord = currentMipResolution * position.xy;
float surfaceZ = texelFetch(hiDepthTexArray[currentLevel], ivec2(currentRayCoord), 0).r;
bool skipTile = AdvanceRay(origin, direction, currentRayCoord, surfaceZ,
currentMipResolutionInv, position, t);
int prevLevel = currentLevel;
currentLevel += skipTile ? 1 : -1;
currentLevel = min(currentLevel, LevelCount - 1);
if (currentLevel != prevLevel) { // 只有层级发生改变时
currentMipResolution *= skipTile ? 0.5 : 2.0;
currentMipResolutionInv *= skipTile ? 2.0 : 0.5;
}
++i;
}

// 验证交点是否有效
isValidHit = false;
if (currentLevel < baseLevel && InsideScreen(position.xy)) {
vec2 currentRayCoord = screenResolution * position.xy;
float hitZ = texelFetch(hiDepthTexArray[0], ivec2(currentRayCoord), 0).r;
isValidHit = TestThickness(position.z, hitZ);
}

return position;
}

5 Reflection

最后简要介绍下屏幕空间反射的反射部分。屏幕空间反射希望得到金属材质的着色点的反射信息,首先需要在着色点处根据材质信息,重要性采样得到反射光线的方向。以着色点为起点、反射光线的方向组成的光线,在屏幕空间执行ray march,找到的交点即为反射点。接下来,SSR直接将上一帧反射点的颜色(不包含反射)作为反射点向着色点发出的radiance。注意:这是一种简单的近似,并非PBR过程,因为屏幕上的颜色实际上应该是屏幕像素向相机发出的radiance,这里直接近似作为其向着色点的radiance。 

Reference

[1] https://github.com/GPUOpen-Effects/FidelityFX-SSSR