在Unity URP管线下 基于Forward路径 快速实现屏幕空间反射(SSR)


目前网路上大部分的屏幕空间反射都是基于 延迟渲染管线,或者后处理流程来实现的。 其主要原因一个是它是基于屏幕空间的效果,同时对于反射方向还需要考虑反射物体的法线,射线触发方向。 最后的效果部分加上模糊处理等等 对于延迟管线和后处理流程也都顺手拈来。

但是走延迟渲染和后处理流程也有导致事情更复杂的情况,比如要处理自反射,对于自由摆放的反射物体筛选,手机端的性能适配等等。

本篇将基于 Unity 2019 URP Forawrd管线下快速实现一个屏幕空间反射

首先是快速场景搭建,放置1个Plane用作地面,2个Cube 和1个复杂模型(皮卡丘) 这些物件都以Opaque队列渲染
之后补充一个自定义天空盒,用于在反射不到任何物体时,以天空盒进行填充


之后在Plane之上摆放另一个等大且位置重合,近Y轴稍微提高(避免Z Fighting)的Plane,但是其渲染队列为Transparent,用于作为反射平面,之所以不复用之前的地面Plane直接做反射是因为我们接下来要利用 URP 中 CameraOpaqueTexture 一张在非透明队列渲染完毕后屏幕截图,以及CameraDepthAttachment 来制作反射, 而一个Transparent队列的Plane不会被上面2张渲染出来,同时可以取到地面Plane的深度

接下来就开始在这个Transprent 的Plane编写反射Shader了

屏幕空间反射的大致流程如下

1.根据相机方向和反射物件的位置,计算出视野到物体的向量,之后根据物体平面方向向量计算出反射向量
2.以视野方向跟物体相交的位置作为反射起点(反射像素将要填充的位置),以反射向量为方向进行步进, 在每次步进时投影回屏幕空间,进行屏幕空间深度碰撞检测
3.当发现屏幕空间深度和当前步进的检测点相交,或者接近 则视为碰撞成功,返回当前相交点的屏幕空间坐标(UV) 对 CameraOpaqueTexture进行采样像素回贴到反射起点, 否则继续步进,为了防止无限步进导致性能下降,设定一个最远步进距离,超过时,返回天空盒

顶点着色器

v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex);
o.uv = v.uv;

o.positionWS = TransformObjectToWorld(v.vertex).xyz;
o.positionOS = v.vertex.xyzw;

float4 screenPos = TransformObjectToHClip(v.vertex);
screenPos.xyz /= screenPos.w;
screenPos.xy = screenPos.xy * 0.5 + 0.5;

o.positionCS = screenPos;
#if UNITY_UV_STARTS_AT_TOP
o.positionCS.y = 1 - o.positionCS.y;
#endif

float zFar = _ProjectionParams.z;
float4 vsRay = float4(float3(o.positionCS.xy * 2.0 - 1.0, 1) * zFar, zFar);
vsRay = mul(unity_CameraInvProjection, vsRay);

o.vsRay = vsRay;
return o;
}

这里要注意 相机空间下的视野向量不能直接使用

float3 viewDirWS = normalize(positionWs.xyz - WorldSpaceCameraPos.xyz)
float3 viewDirVS = mul(UNITY_MATRIX_V, float4(viewDirWS,0))

这是因为相机射线发射起点并不是在一个点上,而是在一个近平面上。 因此如果直接使用一个点来计算所有射线的起点方向,会导致这个射线呈现一个球形散射。 因此我们需要先变换到近平面空间后再反推射线


float4 vsRay = float4(float3(o.positionCS.xy * 2.0 - 1.0, 1) * zFar, zFar);

这里我们直接构建一个位于远平面的向量坐标,是为了配合之后在片元着色器取出的深度区间百分比后,直接对向量进行缩放计算,可以快速得到当前射线和反射平面的相交点坐标

片元着色器

float4 frag (v2f i) : SV_Target
{
float4 screenPos = i.positionCS;

float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, screenPos.xy);
depth = Linear01Depth(depth, _ZBufferParams);

float3 wsNormal = normalize(float3(0, 1, 0)); //世界坐标系下的法线
float3 vsNormal = (TransformWorldToViewDir(wsNormal)); //将转换到view space

float3 vsRayOrigin = (i.vsRay) * depth;
float3 reflectionDir = normalize(reflect(vsRayOrigin, vsNormal));

float2 hitUV = 0;
float3 col = 0;
if (rayMarching(vsRayOrigin, reflectionDir, hitUV))
{
float3 hitCol = SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, hitUV).xyz;
col += hitCol;
}
else {
float3 viewPosToWorld = normalize(i.positionWS.xyz - _WorldSpaceCameraPos.xyz);
float3 reflectDir = reflect(viewPosToWorld, wsNormal);
col = SAMPLE_TEXTURECUBE(_SkyBoxCubeMap, sampler_SkyBoxCubeMap, reflectDir);
}

return float4(col, 1);
}

首先我们根据当前反射平面的屏幕坐标计算当前位置处于相机空间下距离远平面的百分比

float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, screenPos.xy);
depth = Linear01Depth(depth, _ZBufferParams);

虽然Unity封装的名字是Linear01Depth,但实际上可以直接理解为当前值为近平面->远平面的区间百分比【0-1】

float3 vsRayOrigin = (i.vsRay) * depth;

因此直接进行缩放可以得到和反射平面的相交位置,之后根据反射平面的朝向计算出反射向量

最后就是进行RayMarching 传入相交起点,反射方向,以及深度碰撞成功后的uv坐标

float compareWithDepth(float3 vpos)
{
float2 uv = ViewPosToCS(vpos);
float depth = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, uv);
depth = LinearEyeDepth(depth, _ZBufferParams);
int isInside = uv.x > 0 && uv.x < 1 && uv.y > 0 && uv.y < 1; return lerp(0, abs(vpos.z + depth), isInside); } bool rayMarching(float3 o, float3 r, out float2 hitUV) { float3 start = o; float3 end = o; float stepSize = 0.15; float thinkness = 0.1; float triveled = 0; int max_marching = 256; float max_distance = 500; UNITY_LOOP for (int i = 1; i <= max_marching; ++i) { end += r * stepSize; triveled += stepSize; if (triveled > max_distance)
return false;

float collied = compareWithDepth(end);
if (collied > 0)
{
if (collied < thinkness) { hitUV = ViewPosToCS(end); return true; } } } return false; }

在射线的每一次步进计算一次屏幕坐标,取当前位置的深度值
LinearEyeDepth(depth, _ZBufferParams); 计算的结果是距离当前相机的距离
因此我们直接计算当前射线的位置和深度直接的差异,如果接近或者相交则表示碰撞成功,返回当前射线的屏幕uv值

注意这里要引入一个thinkness 粗细的概念, 想象一下屏幕中的Cube宽高只有1米, 但是此时射线已经位于该物体背后10米远了,因为是根据射线屏幕坐标来计算碰撞的,因此这个射线在碰撞检测上有碰撞的,因此会导致在反射上这个物体被拉扯成10米长了,因此需要考虑物体的粗细概念,或者厚度。 因此需要额外判断 VPos.z 和 LinearEyeDepth 后的深度差 不能超过物体自身厚度。

考虑粗细的效果

不考虑粗细的效果

到这一步反射基本完成了,但是发现反射物件的横条感非常严重,这是因为射线步进的距离过大导致的,我们可以通过减少射线的步进距离来优化这个效果,但是减少步进会导致Shader中的For循环数量大增,性能下降。因此这里要引入二分查找进行优化。
首先进行大步进检测碰撞检测, 碰撞成功后对这一大步进行二分查找缩减,找到碰撞的最小距离, 这样可以最大化减少物件横条感,也不会过于耗费性能。


...
float stepSize = 0.5; //这里可能将步长加大
...
float collied = compareWithDepth(end);
if (collied < 0) { if (abs(collied) < thinkness) { hitUV = ViewPosToCS(end); return true; } //回到当前起点 end -= r * stepSize; triveled -= stepSize; //步进减半 stepSize *= 0.5; }

改进完之下效果好多了,但是还是有些许瑕疵

作为Forward路径,增加TAA,或者Blur都很麻烦,实际上如果做的是水面反射,直接来一步Noise扭曲就行


...
float2 noiseTex = (SAMPLE_TEXTURE2D(_Noise, sampler_Noise, (i.uv * 5) + _Time.x).xy * 2 - 1) * 0.1;
float3 wsNormal = normalize(float3(noiseTex.x, 1, noiseTex.y)); //世界坐标系下的法线
...

对反射平面向量进行扰动后需要重新归1化

加完扰动后,感觉基本看不出反射瑕疵了,也省了TAA,Blur啥的,并且可以在移动端上流畅运行

完整源码
https://github.com/dreamfairy/Unity_URP_SSR

发表评论

电子邮件地址不会被公开。 必填项已用*标注