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(&#34;_AlphaClip&#34;) && sharedMat.GetFloat(&#34;_AlphaClip&#34;) == 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等,以后有时间再深究 预计算结果是怎么个数据结构呢,大佬
页:
[1]