前言

继续学习Colin大神的渲染示例库,这次学习的是屏幕空间平面反射(ScreenSpacePlanarReflection),一个可以用在移动端的平面反射库,但是对图形API有要求,PC/console/vulkan android/Metal iOS,OSX,因为其中用到了Compute Shader加速计算。项目还对不同平台做了差异化处理,干货很多。

学习过程中我也有很多疑问,有一些是百度谷歌看PPT解决的,有一些就实在不知道怎么办了,在文中有说明,望知道的大佬能不吝赐教。

正文

Compute Shader

简单来说Compute Shader是运行在GPU中的计算管线中的程序,其与渲染管线相互独立,旨在将任务切分成一个个运行单元,然后充分利用GPU的并行计算能力来提高目标的运行效率,也是现代GPGPU(General Purpose Computing on GPU)的基石。

其应用起来的相关概念用一张图即可概括(有一说一这张图感觉比NVIDIA的好看和明了多了,AMD YES!):

v2-1c5fea9e7f70885b59e0a637daffd43f_180x120

有关Compute Shader的更多内容参见:知乎文章:Compute Shader : Optimize your game using compute

SSPR大体思路

一个大前提是将所有的图像处理(重建世界坐标,孔洞修复,边缘拉伸,自定义的“深度测试”等)放在Compute Shader中进行加速,通过RenderFeature来协调ComputeShader计算和正常的Shader渲染

  • 从深度图重建当前世界坐标,将重建的世界坐标以反射平面为基准进行翻转处理
  • 计算翻转后的世界坐标的屏幕UV
  • 对当前屏幕纹理进行采样暂存为一个ReflectColor
  • 将ReflectColor存入ColorRT,但索引是反射后的屏幕像素值,也就是翻转后的世界坐标的屏幕UV * ColorRT.size,这样在最后Shader中采样的时候就可以采样到反射后的颜色了
  • 在反射平面的Shader中用模型的屏幕UV对ColorRT进行采样
  • 在反射平面的Shader中采样噪声图进行混合,采样Reflection Probe进行混合来尽可能的让穿帮不会太明显

SSPR存在的问题

反射渲染顺序错误

由于我们使用了翻转后世界坐标进行投影变换+屏幕映射,所以会出现这种情况:原屏幕纹理中没有问题的像素会因为翻转+投影变换的原因会出现在反射平面中重合的情况,造成渲染顺序错误,并且由于Compute Shader执行的乱序性,会出现闪烁的情况

v2-b4b23b0c9defce7bc1571c07b1f7e81b_1440w

解决方案就是自己在ComputeShader中对ColorRT做深度测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/////////////////////////////////////////////
//移动平台处理
/////////////////////////////////////////////
PosWSyRT[uint2(id.xy)] = 9999999;

uint2 reflectedScreenID = reflectedScreenUV * _RTSize;
float3 posWS = ConvertScreenIDToPosWS(id);
//只有当前ColorRT索引的颜色深度值小于我们自定义的深度缓存(PosWSyRT)才会被写入到ColorRT中
if(posWS.y < PosWSyRT[reflectedScreenID])
{
float2 screenUV = id.xy / _RTSize;
half3 inputPixelSceneColor = _CameraOpaqueTexture.SampleLevel(LinearClampSampler, screenUV, 0).rgb;

ColorRT[reflectedScreenID] = half4(inputPixelSceneColor,1);
PosWSyRT[reflectedScreenID] = posWS.y;
}

/////////////////////////////////////////////
//PC平台处理
/////////////////////////////////////////////
uint hash = id.y << 20 | id.x << 8 | fadeoutAlphaInt;
//利用自带的原子写入来进行“深度测试”
InterlockedMin(HashRT[reflectedScreenID],hash);

反射空洞

同样因为我们对翻转后的世界坐标进行透视投影变换,导致因为其近大远小的特性,像素会被偏移,也就导致我们最后的存在ColorRT中的纹理索引不对了(比如一个像素本该映射到(233, 233)索引的,可能会被映射到(233, 232),导致(233, 233)这个索引处的纹理颜色一直为空,也就导致了空洞的出现),下图中对于墙壁边缘的偏移现象最为明显,其余地方的空洞也是由于这个偏移造成的

v2-d9c410d690ed383caf5a6572ecad5837_1440w

解决方案就是取得周围有效像素去填补空洞处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void FillHoles(uint3 id : SV_DispatchThreadID)
{
//fill holes inside each 2*2
id.xy *= 2;

//cache read
half4 center = ColorRT[id.xy + uint2(0, 0)];
half4 right = ColorRT[id.xy + uint2(0, 1)];
half4 bottom = ColorRT[id.xy + uint2(1, 0)];
half4 bottomRight = ColorRT[id.xy + uint2(1, 1)];

//find best inside 2*2
half4 best = center;
best = right.a > best.a + 0.5 ? right : best;
best = bottom.a > best.a + 0.5 ? bottom : best;
best = bottomRight.a > best.a + 0.5 ? bottomRight : best;

//write better rgba
ColorRT[id.xy + uint2(0, 0)] = best.a > center.a + 0.5 ? best : center;
ColorRT[id.xy + uint2(0, 1)] = best.a > right.a + 0.5 ? best : right;
ColorRT[id.xy + uint2(1, 0)] = best.a > bottom.a + 0.5 ? best : bottom;
ColorRT[id.xy + uint2(1, 1)] = best.a > bottomRight.a + 0.5 ? best : bottomRight;
}

修补后发现墙壁边缘的走样依旧存在,这是因为我们只是根据周边像素去修补空洞,但是没有去“纠正”已经偏移的像素造成的,而我们为了透视正确,这应该是必要的牺牲吧

v2-81ce40e5b7bb0974b8aae4b19dcd4a3b_1440w

遮挡空洞

由于模型的遮挡问题,某些视角下反射平面将无法正确显示被遮挡的模型(比如一个墙壁前有一个浮空的Cube,那么在反射平面上的墙壁就会出现一个Cube投影状的空白)

解决方案未知,有知道的大佬希望能指点一二。

v2-0ef9c3d692d1f2f80463115b0c590501_1440w

边缘缺失

某些视角下(反射平面所需要的信息在相机渲染出来的屏幕纹理之外)会造成大块空白内容,只能通过拉伸这部分uv去处理

v2-5715562b708d7803404b6bd02e3d6505_1440w

但是这种方式没有办法处理所有角度的拉伸,仅适用于近乎平视且高度较低的情况,这是因为相机角度/高度过高会导致丢失的贴图位置偏高,而我们拉伸又是根据反转的世界坐标距离反射平面远近来确定拉伸系数的,所以就没有办法处理了

v2-96d1f4b8297e8c8c99870bc3caab9516_1440w

1
2
3
4
5
6
7
8
float Threshold = _ScreenLRStretchThreshold;
float Intensity = _ScreenLRStretchIntensity;
float HeightStretch = (abs(reflectedPosWS.y - _HorizontalPlaneHeightWS));
float AngleStretch = (-_CameraDirection.z);
float ScreenStretch = saturate(abs(reflectedScreenUV.x * 2 - 1) - Threshold);
reflectedScreenUV.x = reflectedScreenUV.x * 2 - 1;
reflectedScreenUV.x *= 1 + HeightStretch * AngleStretch * ScreenStretch * Intensity;
reflectedScreenUV.x = saturate(reflectedScreenUV.x * 0.5 + 0.5);

补充说明

这种SSPR方案有一些十分明显的缺点,这些缺点都是因为其自身是基于屏幕空间的,如屏幕纹理原本就没有的内容,我们是没有办法变出来的,只能用各种Trick去处理,虽然做了种种Trick减缓了各种穿帮,但是仔细看还是会发现的,正所谓成也屏幕空间,败也屏幕空间,所以这边再多补充两个反射类型供参考

  • Planar Reflection,需要利用另一个与原相机A相对于反射平面对称的相机B再进行渲染一次,并且此次渲染会使用一个反射矩阵,这个反射矩阵用于将顶点相对于反射器平面进行翻转,所以需要放到观察变化之后,投影变换之前去做,然后将Shader挂载到平面上,根据其屏幕坐标去采样B相机渲染出来的屏幕纹理即可。这种方案优点是渲染信息全面,不容易穿帮,缺点是Drawcall直接翻倍,不是很能接受
  • Screen Space Reflection,另一种屏幕空间的反射,但他是基于一种光线步进的方式来做的,大体思路从平面开始根据场景的深度法线贴图去做碰撞/求交,得到反射颜色信息进行渲染。优点是基于RayMarching支持非平面,并且相对于本文记录的SSPR效果会好很多,缺点是显而易见的性能消耗爆炸(分支计算+求交操作)

本文是我边学边记录而成的一篇文章,可能有疏漏,错误之处,还请各位大佬不吝赐教。

参考

ColinLeung-NiloCat/UnityURP-MobileScreenSpacePlanarReflection

AMD PPT&&Video:Compute Shaders: Optimize your engine using compute / Lou Kramer, AMD (video)

知乎文章:Compute Shader : Optimize your game using compute

screen-space-plane-indexed-reflection-in-ghost-recon-wildlands

Unity_StochasticSSR[1]

Optimized pixel-projected reflections for planar reflectors