游戏中的元素非常多,但是摄像机能看到的内容是有限的,并且有些元素会被另外一些元素挡住。遮挡剔除顾名思义,摄像机看不见的,就剔除掉。

遮挡剔除 - Unity 手册 (unity3d.com)

遮挡关系

例如一面墙后面放了很多元素,那么墙就属于遮挡物Occluder,墙后的物体就是被遮挡物Occludee。

我们在场景中放一些方体来做遮挡,在最前面放一个很大的做墙(注意一定要很大才行,unity默认情况下需要很大的Occluder),这些方体都要勾选Static Occluder和Static Occludee。

很大的遮挡物

勾选Occluder和Occludee

选择Window——Rendering——Occlusion Culling,打开烘焙面板,选择Bake选项卡,可以在里面设置最小遮挡距离,最小遮挡空隙以及背面的阈值,最后单击Bake按钮即可烘焙Static对象

Occlusion面板

烘焙结束后,Unity会在场景scene所在位置创建一个StaticObject文件夹,然后在其中放入OcclusionCullingData.asset文件,之前讲的场景烘焙文件也放在这里

遮挡信息存储位置

然后,在scene面板中的Occlusion Culling小面板选择Visualize模式,在场景中就能实时查看剔除效果

背面物体被剔除

Unity打开Occlusion面板后会不断地占用CPU和内存进行烘焙,所以烘焙完一定要关掉

遮挡剔除和锥形剔除是同时起效果的,具体可以查看官方手册

如果最前面的墙是透明的,那么墙后面的元素可以取消选择Occludee Static标志,如果最前面的墙是透明的,但是后面又是一块不透明的墙,那么后面的墙依然保留Occludee Static和Occluder Static勾选状态,实际应用中的思路便是如此。

遮挡与被遮挡事件

当发生遮挡剔除时,Unity会自动调用gameObject.SetActive(false)方法,这样整个对象的渲染就会被暂停,直到它重新被启动。这时可以监听OnBecomeInvisible()OnBecomeVisible()方法来处理一些逻辑

我们在之前的2D碰撞检测中使用过基于Unity事件系统的CollisionListenerTriggerListener用法,这里相似

新建OcclusionListener脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using UnityEngine;
using UnityEngine.Events;

public class OcclusionEvent : UnityEvent<GameObject> { }

public class OcclusionListener : MonoBehaviour
{
public static OcclusionEvent onInvisable = new OcclusionEvent();
public static OcclusionEvent onVisable = new OcclusionEvent();
private void OnBecameInvisible()
{
onInvisable.Invoke(gameObject);
}
private void OnBecameVisible()
{
onVisable.Invoke(gameObject);
}
}

然后我们再使用下面的脚本来让游戏初始化时给所有的Render组件挂上OcclusionListener脚本,统一监听它的剔除及显示方法。

如果想主动判断某个对象是否在摄像机显示区域内,也可以调用Renderer.isVisable()方法。

新建AutoAddOcclusionListener脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;

public class AutoAddOcclusionListener : MonoBehaviour
{
void Start()
{
foreach (var item in GameObject.FindObjectsOfType<Renderer>())
{
item.gameObject.AddComponent<OcclusionListener>();
}
OcclusionListener.onInvisable.AddListener(go =>
{
Debug.LogFormat("gameObject {0}隐藏", go);
});
OcclusionListener.onVisable.AddListener(go =>
{
Debug.LogFormat("gameObject {0}显示", go);
});
}
}

Debug显示

动态剔除

任何游戏对象设置了Static后,在运行时都是禁止移动的。如果我们想要对象能被剔除又能够移动,我们可以设置动态剔除。

每一个Mesh Renderer的动态剔除都是默认开启的

动态剔除

勾选了动态剔除的对象,在Occluder Static静态物体后面,或者是位于摄像机视椎之外,都会被剔除。剔除只是关闭渲染,Update依然会更新。

在官方的文档中,关闭动态剔除可以用来制作墙后人物轮廓勾勒的特效。

自定义遮挡剔除

如果参与遮挡剔除的元素很多,每次移动摄像机时就会产生大量的计算,在移动平台更为明显。

我们可以自己实现遮挡剔除。比如,可以将场景上的元素按位置划分成若干个格子,每个格子里面就是场景中的游戏对象。无论游戏场景有多大,玩家同一时刻关心的只有1~9区域中的元素。当角色向左上方移动并超出当前格子的位置时,那么红色区域表示需要重新加载的,黄色区域表示需要保留的,蓝色区域表示需要释放的。

演示

这可以保证最小化地管理所有游戏对象,而且这么做有个好处,当需要在主角范围内查找最近单元时,参与判断的对象如果很多,就会带来for循环判断的开销,但是由于我们只保留格子范围内的元素,判断就会非常快了。至于遮挡剔除,由于需要管理的对象已经很少了,遮挡剔除的优化几乎可以忽略,所以在移动摄像机的时候,就不会带来额外的开销了。

另外,还需要处理近景、远景的元素。比如近处的房子、树和草可能需要很快剔除掉,但是远处的城楼,山和云彩等不需要剔除,根据美术要求,将特殊元素过滤掉即可。

Bathing(静态合批)

模型是一个一个独立的,它们的材质以及贴图很有可能是完全一样的,但是由于模型不一样,放入Unity时就会多占很多的DrawCall,所以Unity提供了一个属性来做静态合批,可以将它们合并在一个Mesh里。

设置静态合批

静态合批首先需要在Player Settings——Other Settings勾选Static Bathing,想要动态合批也可以勾选Dynamic Bathing

勾选静态合批

然后在场景中选择需要合批的游戏对象,打开Bathing Static标记,然后运行游戏,Mesh Filter会自动生成一个新的Mesh,如果有相同的材质、Shader并且参数一致的话,就会合并DrawCall。

合并批次

脚本静态合批

假如场景非常大,那么自动静态合并出来的Mesh就会非常大。运行游戏后,只要有一小部分出现在摄像机内,那么整个Mesh都要参与渲染。另外,静态合批的最大顶点数是65535,如果顶点数超过了它,Unity就会自动合并出多个多个Mesh。

我们可以利用脚本来动态设置需要合批在一起的游戏对象。注意,如果需要脚本合并,游戏对象不需要选中Static标记

新建CustomBatching脚本

1
2
3
4
5
6
7
8
9
10
using UnityEngine;

public class CustomBatching : MonoBehaviour
{
public GameObject[] datas;
private void Start()
{
StaticBatchingUtility.Combine(datas, gameObject);
}
}

要使用此脚本,需要一个根对象,然后将需要合批的游戏对象全部放在数组内

根对象

应用数组

在应用了此脚本后,设置合批的对象就像设置Static标签一样不能移动,但是根对象(Root)是可以移动的

动态合批

Dynamic batching - Unity 手册 (unity3d.com)

动态合批是全自动的。但它是有要求的,

  • Mesh的顶点数量需要小于300
  • 如果Shader中使用了顶点位置、法线、UV0、UV1和切线,Mesh的顶点数必须小于180.

在Unity的Built-in粒子系统和Line Renderers、Trail Renderers组件中,动态合批都是自动开启的,可以优化很多DrawCall

静态合批的隐患

静态合批的原理是自动生成Mesh,但是不同Mesh保存的信息可能是不同的。例如Mesh中可能保存color和tangent,但是大部分Mesh是不需要这个信息的,如果静态合批中有一个Mesh包含了这个信息,那么合并以后整个Mesh都会带上它,这样无疑会增加一些额外的开销。

在3D软件中导出FBX时,可能会自动附带一些color信息,我们可以利用FBX官网提供的FBX接口,自己写一个Python脚本来删除它们

(书中只给提示,并未提供详细方法)