找回密码
 立即注册
查看: 324|回复: 1

Unity 预计算遮挡剔除应用

[复制链接]
发表于 2022-10-18 21:06 | 显示全部楼层 |阅读模式
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[m];
     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等,以后有时间再深究

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-10-18 21:15 | 显示全部楼层
预计算结果是怎么个数据结构呢,大佬
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-9-21 22:00 , Processed in 0.094080 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表