上篇:UnityShader34:非真实感水体渲染
二、水面反射方案
2.1 SSR(Screen space Reflection)
https://casual-effects.blogspot.com/2014/08/screen-space-ray-tracing.html
SSR 的思路其实非常简单:
- 已知当前位置的世界坐标、法向量,已知视角方向
- 通过前者可以得到视线到该位置的反射方向
- 这个反射射线击中的第一个物体片段就是该点反射的内容
由于项目只有水面需要反射,因此可以直接在水的 Pass 里面做 SSR:即在绘制完所有不透明物体之后,把摄相机的 ColorBuffer 和 DepthBuffer 都 Copy 出来一份,用于 SSR 采样的屏幕空间纹理:这块 URP 是直接有支持的
当然这也意味着放弃反射不透明物体
对于上述的步骤①②所需的数据,都很好得到和计算:
half3 rayOrigin = TransformWorldToViewDir(i.worldPos.xyz - _WorldSpaceCameraPos.xyz);
half3 viewSpaceNormal = TransformWorldToViewDir(normalize(half3(bump.x * _SSPRDistort, 1, bump.y * _SSPRDistort)));
half3 reflectionDir = normalize(reflect(rayOrigin, viewSpaceNormal));
主要是第三点:如何得到反射射线击中的第一个物体片段:这里用了 RayMarching 的思路,经典算法,玩过 shadortoy 的都对这个东西再熟悉不过了:
大致就是在反射的方向上从起点开始暴力模拟每一个点,然后根据当前的 ScreenDepthBuffer 判断当前点是否已经被物体遮挡,如果遇到第一个遮挡的,那么直接采样对应的 ScreenColorBuffer,当然需要采样的点有无数个,没法暴力枚举,因此从最开始每次都给一段步长,离散的选点,如果发现已经超过深度了就适当回退,以此类推
代价就是比较吃 GPU
bool RayMarching(half3 o, half3 r, out half2 hitUV)
{
half3 end = o;
half stepSize = 2;
half thinkness = 1.5;
half triveled = 0;
int max_marching = 100;
half max_distance = 500;
UNITY_LOOP
for (int i = 1; i <= max_marching; ++i)
{
end += r * stepSize;
triveled += stepSize;
if (triveled > max_distance)
return false;
half collied = compareWithDepth(end);
if (collied < 0)
{
if (abs(collied) < thinkness)
{
hitUV = ViewPosToCS(end);
return true;
}
//回到当前起点
end -= r * stepSize;
triveled -= stepSize;
//步进减半
stepSize *= 0.5;
}
}
return false;
}
搞定!但是 SSR 有一个致命的缺点:那就是它不能反射屏幕中看不到的位置,因此它是不太适合一些自由视角下一些非常复杂的场景的,可能会出现明显的表现错误:
SSR 也有优点:那就是不要求反射物是一个平面,并且无论多么复杂的场景,算法的复杂度都大差不差,单看运算的话,压力全在 GPU 身上,没有 CPU 端的性能损耗
2.2 平面反射(Mirror Reflection)
因为 SSR 的方法略有瑕疵,因此对于部分场景,水面反射还支持另一套方案:那就是平面反射,平面反射的思想就更简单了:场景中的所有物体反过来再画一遍,只是实现起来比 SSR 繁琐,一个完备的平面反射方案需要考虑的细节也不少
2.2.1 平面反射开启条件
最坏情况下,平面反射需要将场景中所有物体全部重新绘一遍,这意味着 DrawCall 将近翻倍,CPU 压力会大很多,因此对此这一块的优化必不可少:
最基本的:如果视线范围内没有反射物(水)那么必然无需开启平面反射,大致流程如下:
运行前预处理部分:
- 求出所有水体的包围盒
- 对包围盒进行网格(Grid)划分,每个 Grid 的大小由配置决定
- 预判断每个 Grid 中是否包含水体
网格的可视化:每个网格的默认大小为 50x50
每帧实时计算视野范围内是否包含反射物:水,这一块的内容对应着下面代码段中的 MirrorInstance.CheckCulling 函数(当然具体的计算代码就不贴了,篇幅有限)
- 计算摄像机视锥体与玩家脚底所在平面(该平面垂直于Y轴)的四个交点
- 根据交点世界坐标判断当前哪些 Grid 是在屏幕中可见的
- 这些可见的 Grid ,把所有不含水的筛出去,来确定是否需要开启平面反射的 RenderFeature
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
Camera camera = renderingData.cameraData.camera;
CommandBuffer cmd = CommandBufferPool.Get(_ProfilerTag);
using (new ProfilingScope(cmd, _ProfilingSampler))
{
//反射摄相机必须开启软线性
bool softColorSpaceLinear = ScriptableRenderer.GetPerCameraSwitch(PerCameraSwitchUniforms.TypeMode.SoftColorSpaceLinear);
if (QualitySettings.activeColorSpace == ColorSpace.Gamma)
{
ScriptableRenderer.SetPerCameraSwitch(cmd, PerCameraSwitchUniforms.TypeMode.SoftColorSpaceLinear, false);
}
MirrorReflection MirrorInstance = MirrorReflection.Instance;
float MinDistance = 0;
bool NeedDrawSky;
//判断是否所有的水体都被剔除,当前是否开启了平面反射
bool Culled = MirrorInstance == null ||
(!MirrorInstance.IsEnable() ? true : !MirrorInstance.CheckCulling(camera, out MinDistance, out NeedDrawSky)) ||
!MirrorInstance.EnableMirrorReflection;
MinDistance = Mathf.Clamp(MinDistance, camera.nearClipPlane, camera.farClipPlane);
if (Culled)
{
cmd.SetGlobalFloat(_EnabldMirrorReflection, 0);
ExecuteCommand(context, cmd);
}
else
{
cmd.SetInvertCulling(true);
//绘制
}
ScriptableRenderer.SetPerCameraSwitch(cmd, PerCameraSwitchUniforms.TypeMode.SoftColorSpaceLinear, softColorSpaceLinear);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
2.2.2 哪些物体需要绘制到反射纹理中
然后就是:已经确定视野范围内有大片水域,哪些东西需要反射,毕竟无脑绘制场景中的所有物体怎么都太过于暴力
先把平面反射的 MVP 矩阵搞出来:
V 矩阵很好办,根据一条描述平面的法向量,即可求出关于该平面的对称矩阵,然后直接拿原先的 V 矩阵与这个结果相乘就好了:
Vector3 pos = MirrorInstance.transform.position;
Vector3 normal = Vector3.up;
float d = -Vector3.Dot(normal, pos) - MirrorInstance.clipPlaneOffset;
Vector4 reflectionPlane = new Vector4(normal.x, normal.y, normal.z, d);
//CalculateReflectionMatrix:根据平面法向量,得到对称矩阵
Matrix4x4 reflection = CalculateReflectionMatrix(reflectionPlane);
Matrix4x4 worldToCameraMatrix = camera.worldToCameraMatrix * reflection;
比较有疑问的点是 P 矩阵:可能不好理解这个 P 矩阵为什么要改,因为按道理只是物体对称了一下,你摄像机的视锥范围应该是不会变的,如果这个视锥范围不变,按道理 P 矩阵应该也不需要动才对
考虑到无脑将所有物体按一个平面进行对称可能出现的一个问题:平面上面的物体会被对称成为倒影,这个没问题,但原先平面下面的物体也会被对称到上面,这个肯定是错的,因此我们要避免平面之下的物体显示出来,就要想办法把它裁剪掉:
这就需要修改裁剪矩阵,使得新的裁剪矩阵 P 的近平面为反射平面,这个计算推导过程有专门的论文,Unity 也给我们提供了对应的 API:Camera.CalculateObliqueMatrix 可以直接得到 P:
Vector4 clipPlane = CameraSpacePlane(worldToCameraMatrix, pos, normal, 1.0f, MirrorInstance.clipPlaneOffset);
Matrix4x4 projectionMatrix = camera.CalculateObliqueMatrix(clipPlane);
Vector4 CameraSpacePlane(Matrix4x4 m, Vector3 pos, Vector3 normal, float sideSign, float clipPlaneOffset)
{
Vector3 offsetPos = pos + normal * clipPlaneOffset;
Vector3 cpos = m.MultiplyPoint(offsetPos);
Vector3 cnormal = m.MultiplyVector(normal).normalized * sideSign;
return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal));
}
然后拿到 VP 矩阵后,可以直接在 CPU 层面上就进行剔除,也就是只把会被反射的物体丢给 GPU,尽可能减少没必要的 DrawCall:
bool Culling(ScriptableRenderContext context, ref RenderingData renderingData, out CullingResults cullingResults, Matrix4x4 worldToCameraMatrix, Matrix4x4 projectionMatrix, MirrorReflection MirrorInstance, float MinDistance)
{
// Setup
var camera = renderingData.cameraData.camera;
cullingResults = new CullingResults();
// Never draw in Preview
if (camera.cameraType == CameraType.Preview)
return false;
// Get CullingParameters
var cullingParameters = new ScriptableCullingParameters();
if (!camera.TryGetCullingParameters(out cullingParameters))
return false;
cullingParameters.stereoViewMatrix = worldToCameraMatrix;
cullingParameters.stereoProjectionMatrix = projectionMatrix;
cullingParameters.cullingMatrix = projectionMatrix * worldToCameraMatrix;
cullingParameters.cullingMask = (uint)MirrorInstance.reflectionMask.value;
var planes = GeometryUtility.CalculateFrustumPlanes(cullingParameters.cullingMatrix);
//替换近裁面
Plane frontPlane = planes[0];
frontPlane.Translate(frontPlane.normal * (camera.nearClipPlane - MinDistance));
planes[0] = frontPlane;
for (int i = 0; i < 6; i++)
{
cullingParameters.SetCullingPlane(i, planes[i]);
}
水下不绘制
//float reflectPlaneY = MirrorInstance.transform.position.y + MirrorInstance.clipPlaneOffset;
//cullingParameters.SetCullingPlane(5, new Plane(Vector3.up, reflectPlaneY));
// Culling Results
cullingResults = context.Cull(ref cullingParameters);
return true;
}
摄像机剔除是一方面,分析每个物体是否需要开启反射是另一方面,抛开视角因素其实是没有哪个理论可以完美的确定场景中哪些物体一定不会投影到水面上,除非当前的场景是已经确定的,但是我们依然可以做一个不负责任的假设:那就是所有和水靠边的物体才有可能在水面上看到倒影,否则就一定看不到
基于这个不太正确的假设,我们可以设置所有满足条件物体的 meshRenderer.RenderingLayerMask,对于投影摄像机则通过 FilteringSettings 来筛选哪些 Layer 下的物体可以被绘制
然后编辑器支持自动设置与水相邻的物体的 meshRenderer.RenderingLayerMask,当然你也可以手动设置以防止一些在地图中心但是过高的建筑在水面上没有投影?
2.3 Cubemap 反射:天空与云
只要俯视角观察水,必然能看到天空的倒影,因此这块是特殊处理的,既不需要 SSR 也不需要平面反射,直接采样 Cubemap 即可:
Cubemap 反射对于这种大范围的静态物体非常友好,可以以相对低的开销实现一个非常不错的效果
half3 cubeColor = DecodeHDREnvironment(SAMPLE_TEXTURECUBE(_ReflectionTex, sampler_ReflectionTex, reflect(viewDir, normal)), _ReflectionTex_HDR).rgb;
half3 reflectColor = cubeColor;
2.4 最后再对比下两种反射方案
SSR:
- 不要求水是一个平面
- 无法反射屏幕中看不到的像素,这会导致在一些复杂的场景、又或者是垂直视角下出现倒影丢失的情况
- 依赖于 GPU RayMarching,因此主要压力在 GPU 运算上
- 需要用到当前摄像机的 ColorTexture 及 DepthTexture,这些都需要在执行完不透明物体绘制之后走单独的 Pass 去 Copy 出一份,有一定的带宽要求
平面反射:
- 一定要求水面是一个平面,并且若有多个平面,每个平面的倒影都要独立计算
- 理论可以在所有情况下反射所有物体,无视角要求
- 主要思路就是暴力再绘制一遍场景中所有可能产生倒影的物体,因此主要压力在 CPU 上,最坏情况 DrawCall 翻倍,提前做 CPU 剔除可以有效缓解一下 DrawCall 过多的问题但治标不治本
- 需要绘制 ReflectTexture
到底开启平面反射还是 SSR
可以定义一个全局的 Keyword 或者给材质加一个 uniform 属性(目前项目采取后者,因为要尽可能避免 keyword 太多)
half3 reflectColor = cubeColor; //反射物中必包含天和云
#if !defined(DISABLE_REFLECTION)
if (_EnabldMirrorReflection == 0)
{
// SSPR
}
else
{
half2 reflectUV = screenUV + reflectDistort;
reflectColor = SAMPLE_TEXTURE2D(_MirrorReflectionTex, sampler_MirrorReflectionTex, reflectUV).rgb;
_ReflectionTexColor.a = 1 - min(reflectColor.r + reflectColor.g + reflectColor.b, 1);
reflectColor = lerp(cubeColor * _ReflectionTexColor.rgb, reflectColor, 1 - _ReflectionTexColor.a);
}
#endif
如果想要一个场景中一个大范围的平面水使用平面反射,部分小池塘启用 SSR,那么就需要通过动态修改材质属性,而非 uniform 这种全局性的设置
反射扭曲
水面必然不是镜子,因此一样需要做 uv 偏移动画,uv 偏移的数值采样于 flowMap,强度由个性化配置决定:
half2 reflectDistort = bump.xy * _ReflectDistort;
half2 reflectUV = screenUV + reflectDistort;
?
|