mastertravels77 发表于 2022-10-18 21:06

Unity 预计算遮挡剔除应用

0 前言

大场景或复杂场景(下称大场景)由于物件较多往往会带来加载、内存和渲染等方面的压力,需要用一些手段来优化它们,其中遮挡剔除就是一个较为重要的手段,将相机看不见的物体或者像素进行剔除,以减轻渲染上的压力。
剔除技术示例



图片来源:https://zhuanlan.zhihu.com/p/266592981

遮挡剔除技术有很多,包括CPU端(如PVS、SOC等)、GPU端(如Hi-z等)以及管线中的硬件支持(如PreZ、Early-z等),关于遮挡剔除方面的介绍,可以参考这篇文章:https://zhuanlan.zhihu.com/p/66407205
在移动端,其中一些遮挡剔除技术要么是还没被广泛支持(如Hi-z),要么是实时计算开销较大(如SOC等)又或者是硬件可能不支持(如Early-z等),其中PVS(预计算遮挡剔除)算是一个比较适合移动端的方案,因为它是离线将物体的可见性数据烘焙好的,运行时候只需要查询烘焙好的数据即可实现遮挡剔除效果,相较而言,非常高效。
1 PVS

1.1 pvs简介

在UE中已经自带了PVS的实现,它的全称为Precomputed Visibility System,预计算可见性系统,这虽然和Airey在1990年提出的针对建筑物内部稠密度遮挡的Potentially Visible Set(潜在可见集)概念不同,但是原理差不多,都是离线对场景中物体求一个潜在可见集,该预测结果收敛于精确可见集(Extra Visible Set)
UE中的具体做法是:

[*]首先,将场景分为若干个cell
[*]其次,烘焙数据,离线计算相机位于每个cell中时,场景中物体的可见性,并保存数据,数据大小 = cell数 x 场景中物体数 / 8(一个物体用一个bit表示是否可见)
[*]最后,运行时根据相机所在的cell,查数据,得出场景中物体的可见性
由此可见,PVS通过离线烘焙好数据,然后运行时查找数据的方式,效率比较高,但是内存有一定的增加,一般来说,烘焙好的数据,有大量的0或1重复,可以利用一些压缩算法将烘焙数据压缩一下
更多的细节可以参考下面两篇文章,写的比较详细,本文着重介绍我利用PVS技术做的场景资源提前剔除的简单应用
https://zhuanlan.zhihu.com/p/150448978
https://zhuanlan.zhihu.com/p/266592981
1.2 应用——场景资源提前剔除

美术在制作场景的时候,可能会摆放一些在游戏中不会出现的问题,这不仅会带来渲染上的压力,还会使得场景资源变大,因此我们可以做一个工具,在美术提交资源的时候,帮他们自动剔除掉那些看不见的物体,减少资源大小,但是这里有一个至关重要的前提:相机路径是已知的,也就是说我们要在工具中事先传入相机的运行路径数据,然后根据相机的路径数据计算物体对于相机的可见性,即可提前剔除。我们的游戏是战旗类的游戏,可以通过计算得出相机的大概运行路径,利用这种方式提前剔除掉一些资源是行之有效的。
该工具我称之为EasyPVSCullingSystem,下面介绍该工具的具体实现
2 实现

2.1 分类

将场景中的物体分为遮挡物(Occluder)和被遮挡物(Occludee),具体做法:
遍历场景中所有的带有MeshRenderer的对象,并排除掉动态物体,比如带有Animator组件的对象(还有其他类型的动态物体视具体情况而定),因为动态物体是不能作为遮挡物的(可以作为被遮挡物),为了简单起见,可以直接排除动态物体,既不考虑是遮挡物,也不考虑是被遮挡物,一般这种物体在场景中不是很多,忽略不计。
将按照上述规则遍历到的所有对象都挂Occludee脚本,该脚本只是一个简单的MonoBehaviour脚本,标记该物体为被遮挡物
将按照上述规则遍历到的所以对象,排除掉半透明、AlphaTest(比如植被)以及AlphaClip(比如抠洞)等材质的对象,其余的全部挂Occluder脚本,该脚本会为所挂对象添加一个MeshCollider,用于接收射线
注意:半透明、植被、抠洞的物体,由于可以通过它们看见后面的物体,因此不能作为遮挡物,不能给它们挂MeshCollider组件关键代码如下:
//找到所有MeshRenderer物体,并定义遮挡者和被遮挡者
      void FindAllRenderObjects(GameObject root)
      {
            if (root == null || !root.activeSelf || root.GetComponent<Animator>() != null)
                return;
            var childCount = root.transform.childCount;
            if (childCount <= 0)
            {
                var renderer = root.GetComponent<MeshRenderer>();
                if (renderer != null && renderer.sharedMaterial != null)
                {
                  //每一个物体都可以是被遮挡者
                  var occludee = root.GetComponent<OccludeeObject>();
                  if (occludee == null)
                        root.AddComponent<OccludeeObject>();

                  var obb = root.GetComponent<ObjectOBB>();
                  if (obb == null)
                        obb = root.AddComponent<ObjectOBB>();
                  allOccludeeOBBs.Add(obb);

                  var sharedMat = renderer.sharedMaterial;
                  //半透明和开启了AlphaTest【如植被】以及AlphaClip【抠洞】的那种材质的物体不能作为遮挡者//
                  //半透和AlphaTest的材质
                  if ((sharedMat.shader.renderQueue >= (int)RenderQueue.Transparent && sharedMat.shader.renderQueue < (int)RenderQueue.Overlay)
                  || (sharedMat.shader.renderQueue >= (int)RenderQueue.AlphaTest && sharedMat.shader.renderQueue < (int)RenderQueue.GeometryLast))
                  {
                        var occluder = root.GetComponent<OccluderObject>();
                        if (occluder != null)
                            DestroyImmediate(occluder);
                  }
                  // 透明裁剪【抠洞】
                  else if (sharedMat.HasFloat("_AlphaClip") && sharedMat.GetFloat("_AlphaClip") == 1.0f)
                  {
                        var occluder = root.GetComponent<OccluderObject>();
                        if (occluder != null)
                            DestroyImmediate(occluder);
                  }
                  else
                  {
                        var occluder = root.GetComponent<OccluderObject>();
                        if (occluder == null)
                            root.AddComponent<OccluderObject>();
                  }
                }
            }
            else
            {
                foreach (Transform child in root.transform)
                  FindAllRenderObjects(child.gameObject);
            }
      }
2.2 被遮挡物OBB

前面分别标记了场景中的遮挡物和被遮挡物,为了进行下一步的射线检测,还需要为每个被遮挡物生成包围盒,可以直接用AABB(轴对称包围盒),但是这种不太准确,没有考虑物体旋转,精确一点的是采用OBB(有向包围盒),如下图所示,绿色线条表示OBB的轮廓


OBB的实现比较简单,只需要计算出立方体的8个顶点即可,即先拿到Mesh的bounds,然后根据bounds的size和center,经过模型的localToWorldMatrix矩阵即可算出世界空间中的8个顶点位置
获取mesh的bounds以及它的size和center
Bounds bounds
{
   get
   {
         var meshFilter = transform.GetComponent<MeshFilter>();
         if (meshFilter != null && meshFilter.sharedMesh != null)
            return meshFilter.sharedMesh.bounds;
          else
            return new Bounds(Vector3.zero, Vector3.one);
   }
}
public Vector3 size { get { return bounds.size; } }
public Vector3 center { get { return bounds.center; } }
计算OBB的8个顶点
public Vector3 p0 { get { return LocalToWorldMatrix(center + new Vector3(-size.x * 0.5f, -size.y * 0.5f, -size.z * 0.5f)); } }
public Vector3 p1 { get { return LocalToWorldMatrix(center + new Vector3(size.x * 0.5f, -size.y * 0.5f, -size.z * 0.5f)); } }
public Vector3 p2 { get { return LocalToWorldMatrix(center + new Vector3(size.x * 0.5f, size.y * 0.5f, -size.z * 0.5f)); } }
public Vector3 p3 { get { return LocalToWorldMatrix(center + new Vector3(-size.x * 0.5f, size.y * 0.5f, -size.z * 0.5f)); } }
public Vector3 p4 { get { return LocalToWorldMatrix(center + new Vector3(-size.x * 0.5f, -size.y * 0.5f, size.z * 0.5f)); } }
public Vector3 p5 { get { return LocalToWorldMatrix(center + new Vector3(size.x * 0.5f, -size.y * 0.5f, size.z * 0.5f)); } }
public Vector3 p6 { get { return LocalToWorldMatrix(center + new Vector3(size.x * 0.5f, size.y * 0.5f, size.z * 0.5f)); } }
public Vector3 p7 { get { return LocalToWorldMatrix(center + new Vector3(-size.x * 0.5f, size.y * 0.5f, size.z * 0.5f)); } }

public Vector3 LocalToWorldMatrix(Vector3 point)
{
   var result = gameObject.transform.localToWorldMatrix.MultiplyPoint3x4(point);
   return result;
}
计算出了8个顶点,立方体的6个面就很简单了,不一一介绍了
为什么要知道OBB的6个面呢?这是因为我们需要在这6个面上采样取点,然后用这些采样点用相机连线,即从相机到这些点发射线,采样点的计算有很多方式,比如随机序列,低差序列,等差序列,在该工具中用的是低差序列
2.2 低差异序列

随机采样的点,有些地方分布稀疏有些地方分布稠密,等差采样的点,物体的面很小的话,采样点比较少,这两种方法计算采样点都不是很好,因此采用了低差序列,低差序列等到的采用点相对较为均匀,而且数量可控
关于介绍低差序列算法的文章网上有很多,不一一介绍了,这里直接用Unity实现的HaltonSequence算法,HaltonSequence是在CoreRPLibrary里面提供的,如果项目中没有安装CoreRPLibray Package也没关系,代码很简单,直接抄下来放到自己的项目工程中即可
public class MathUtility
   {
       //低差序列
       public static float HaltonSequence(int index, int radix)
       {
         float result = 0f;
         float fraction = 1f / radix;
         while (index > 0)
         {
               result += (index % radix) * fraction;

               index /= radix;
               fraction /= radix;
         }
         return result;
       }
   }
其中index可以是for循环中的i,radix是一个质数
用法示例:


下面两张图分别展示了OBB正前面的随机采样点分布和低差采样点分布



随机采样点分布



低差采用点分布

可见,低差采样点分布更加均匀
2.3 射线检测

有了遮挡物(挂有MeshCollider)、被遮挡物以及采样点(挂有OBB),就可以从相机发射线与OBB的每个面上的采样点连线了,它们之间的射线如果中间经过遮挡物,会被MeshCollider阻挡,当相机与某个被遮挡物只要有一条射线没有被阻挡,就说明看的见,当相机跑完整个运动路径时,就可以统计出哪些物体始终没有被相机看到了,没被相机看见的直接删除。
具体做法:
首先,给相机挂一个OBB,遍历所有的被遮挡物OBB,判断相机的OBB是否和被遮挡物的OBB相交,如果相交,则认为被遮挡物是可以被相机看见的,OBB的相交算法,网上一大堆,此处不贴了,下图展示了两个OBB的相交的情景(边框线变红表示相机)


其次,与相机OBB不相交的被遮挡物,则需要从相机位置发射线与被遮挡物的面上的采样点连线,被遮挡物有6个面,但并不一定是所有的面都能面向相机,因此可以提前判断哪些面是面向相机的,这样可以减少射线的数量,从而加快检测的时间。
如何判断哪些面是面向相机的?如果某个面是面向相机的话,那么该面上的4顶点与相机位置的连线与该面的法向量夹角应该小于90度,即它们的单位向量点积大于0
代码如下:
bool IsPointFacePlane(Vector3 p1, OBBPlane plane)
      {
            var points = plane.Points;
            foreach (var p2 in points)
            {
                var dir = (p2 - p1).normalized;
                if (Vector3.Dot(dir, plane.normal) > 1e-5f)
                  return true;
            }
            return false;
      }
最后,从相机位置出发,与被遮挡物的所有面向相机的面上的采样点连线,这里还有一个地方需要判断,就是那种超出视域的射线要剔除掉,具体做法是,计算相机的horizontalFOV,取它与viewOfView的较大值,如果射线与相机forward向量的夹角超过该值,则直接跳过
代码如下:
float GetCameraHorizontalFOV(Camera camera)
{
      var halfHeight = camera.farClipPlane * Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
      var halfWidth = halfHeight * camera.aspect;
      var hfov = 2 * Mathf.Atan(halfWidth / camera.farClipPlane) * Mathf.Rad2Deg;//水平FOV
      return hfov;
}
var cameraAngle = Mathf.Cos(Mathf.Max(horizontalFOV * 0.5f, sceneCamera.fieldOfView) * Mathf.Deg2Rad);
最终的射线检测代码
for (int m = 0; m < rayCount; m++)
{
   var endPt = samplePoints;
   var dir = endPt - startPt;
   if (Vector3.Dot(dir.normalized, cameraForward) >= cameraAngle && !Physics.Raycast(startPt, dir, dir.magnitude) && !Physics.Raycast(endPt, -dir, dir.magnitude))
   {
          if (!visibleOBBs.Contains(occludeeOBB))
            visibleOBBs.Add(occludeeOBB);
   }
}
值得注意的是,在调用Raycast的时候,如果被遮挡物有MeshCollider,要先禁用,之后再开启,否则可能会出现Raycast始终返回true的情况以下是发射200条射线的示例:


3 存在的问题

问题一:剔除有一定的错误率,即有些本来不应该被剔除物体的被剔除了,可以通过将被遮挡物的OBB放大一点来解决,但是这样又可能使得剔除率降低,根据需求取舍吧,作为工具来说,出发点是减少资源的大小,保证正确的情况下,降低一点剔除率是可以接受的
问题二、比较耗时,当场景中物体非常多时,耗时非常大,比如场景中有10000个物体,对每个物体可见的面(面向相机的面)发射300条射线,那么计算量还是蛮大的,可以利用空间加速结构(如层次包围盒、四叉树、八叉树等)组织场景物体,当父节点看不见时,就不在检测其包含的子节点,也可以利用shader来加速射线检测,网上有个大佬用DXR技术来加速射线检测(参考:https://github.com/zhing2006/Bake-PVS-By-GPU-Ray-Tracing),不过我试了一下,好像不太行,放弃了。我们场景上的物件没有非常多,一次剔除耗时大概在80~140秒左右,还算能接受,因此没有继续深究加速相关的东西
4 其他方案

以上是基于CPU端的实现方案,也可考虑GPU端的实现,比如利用深度图、利用DXR等,以后有时间再深究

zt3ff3n 发表于 2022-10-18 21:15

预计算结果是怎么个数据结构呢,大佬
页: [1]
查看完整版本: Unity 预计算遮挡剔除应用