思考并回答以下问题:
- 静态对象是什么?
- 如何在运行时更换烘焙贴图?
静态对象是Unity提供的一个属性,它可以附加在游戏对象或者Prefab上。它的原理是限制物体在运行中不能发生位移变化,预先生成一些辅助的数据,从而达成一种用内存换时间的优化方式。静态元素的种类很多。如下图所示,选择任意游戏对象,单击右上角的Static下拉框,即可设置该对象的静态元素了,具体如下。
- Lightmap Static:用来表示接受烘焙光照计算,可烘焙光照贴图。
- Occluder Static:表示自身可以被遮挡剔除掉。
- Batching Static:表示支持静态合批。
- Navigation Static:表示可烘焙寻路网格。
- Occludee Static:表示自身是否可以遮挡其他元素。
- Off Mesh Link Generation:寻路连接不同区域的点,就像角色从山顶跳下来。
- Reflection Probe(探测) Static:反射探头,就像玻璃反射一样的镜面效果。
Lightmap
Lightmap技术的原理是将场景中的灯光与物体产生的光照与阴影信息烘焙在一张或者多张Lightmap贴图中,这些物体将不再参与实时光照计算,从而减少了大量的性能开销。它的缺点就是参与烘焙计算的对象在游戏过程中不能发生移动,所以游戏中通常会将物体分成两类:一类是可发生位移变化的,它们使用实时光照计算;另一类是不可发生位移变化的,它们采取预先烘焙Lightmap。
设置烘焙贴图
首先,需要在场景中选中需要参与烘焙计算的游戏对象,设置为Lightmap Static。接着,下面就会出现烘焙信息参数了,我们可以单独调整某一个对象。如下图所示。
当烘焙对象都设置完毕后,在导航菜单栏中选择Window->Lighting->Setting命令,即可打开烘焙面板,如下图所示。同样,设置完烘焙参数后,单击右下角的Bake Reflection Probes命令,即可开始烘焙。如果选中了左边的Auto Generate复选框,将会自动烘焙。但是如果场景中元素很多,可能会造成卡顿,因此不建议开启。
实时光和烘焙光共存
在游戏中,少部分物体确实需要实时光,例如控制主角移动时,需要动态地产生光照和阴影信息。如下图所示,可以在Mode中设置灯光的属性,其中Realtime表示实时光,Mixed表示实时光和烘焙光的混合模式,Baked表示仅烘焙光。所以,游戏中更多的会使用Mixed模式。
灯光管理
游戏做到后期,光源是非常多的,如何管理就是个问题。新版的Unity提供了管理光源的菜单,在导航菜单栏中选择Window->Lighting->Light Explorer命令即可,如下图所示。我们可以快速设置灯光开关状态、灯光类型和模式等,并且点击其中一个光源,即可快速在Scene视图中找到它,使用起来确实很方便。
运行时更换烘焙贴图
如果游戏中有一个白天场景和夜晚场景,那么就需要烘焙出多张烘焙贴图了。在程序中,可以动态更换白天和夜晚的烘焙贴图,如下图所示。
如下代码所示,首先创建LightmapData对象,最终将需要更换的烘焙贴图放入LightmapSettings.lightmaps中即可。将脚本放到摄像机下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24using UnityEngine;
public class ChangeLightmap : MonoBehaviour
{
public Texture2D lightmap1;
public Texture2D lightmap2;
void OnGUI()
{
if (GUILayout.Button("<size=50>lightmap1</size>"))
{
LightmapData data = new LightmapData();
data.lightmapColor = lightmap1;
LightmapSettings.lightmaps = new LightmapData[1] { data };
}
if (GUILayout.Button("<size=50>lightmap2</size>"))
{
LightmapData data = new LightmapData();
data.lightmapColor = lightmap2;
LightmapSettings.lightmaps = new LightmapData[1] { data };
}
}
}
在上述代码中,我们通过点击按钮来动态切换烘焙贴图,例如切换白天与夜晚的效果。
动态更换游戏对象
光照和阴影信息都记录在烘焙贴图上,但是如果需要动态地加载Prefab,就没有烘焙信息了,此时可以给它绑定一个脚本,在生成Prefab的同时将烘焙信息写入这个脚本中,以便在实例化Prefab时再将信息写入。
如下图所示,当场景烘焙完后,选择任意游戏对象,然后在菜单中选择Light->ToPrefab命令,接着在代码中智能判断这个对象是否已经生成Prefab,如果没有生成,则创建新的,最终将烘焙信息序列化在PrefabLightmap脚本中。当以后这个Prefab实例化进场景时,将保存的烘焙预制信息重新赋值给它即可。
单击Load按钮后,在代码中实例化Prefab,相关代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14using UnityEngine;
public class PrefabLightmap :MonoBehaviour
{
public GameObject prefab;
void OnGUI()
{
if(GUILayout.Button("<size=50>Load</size>"))
{
GameObject.Instantiate<GameObject> (prefab);
}
}
}
如下代码所示,Prefab对象绑定了PrefabLightmap脚本,所以在使用Awake()的时候,可以将之前保存的烘焙信息重新赋值给它。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
public class Script_09_03 :MonoBehaviour
{
[ ]
static void DuplicateGameObject()
{
if (Selection.activeTransform)
{
Dictionary<string, Renderer> save = new Dictionary<string, Renderer> ();
//根据相对路径保存Renderer信息
foreach (var renderer in Selection.activeTransform.GetComponentsInChildren<Renderer> ()) {
string path = AnimationUtility.CalculateTransformPath (renderer.transform, Selection.activeTransform);
save [path] = renderer;
}
//执行复制
EditorApplication.ExecuteMenuItem ("Edit/Duplicate");
//还原烘焙信息
foreach (var renderer in Selection.activeTransform.GetComponentsInChildren<Renderer> ()) {
string path = AnimationUtility.CalculateTransformPath (renderer.transform, Selection.activeTransform);
if (save.ContainsKey (path)) {
renderer.lightmapIndex = save [path].lightmapIndex;
renderer.lightmapScaleOffset = save [path].lightmapScaleOffset;
}
}
}
}
}
通过上述代码可以看到,更换烘焙贴图实际上就是设置正确的lightmapIndex和lightmapScaleOffset。
复制游戏对象
光照和阴影信息场景烘焙完毕后,如果直接按Command+D快捷键来复制游戏对象,烘焙信息就是不对的,如下图所示,必须要重新烘焙才行。如果不想重新烘焙,可以自己拓展一个菜单,定义一个新的快捷键Command+Shift+D来执行复制游戏对象的操作,并且动态设置烘焙信息给它。新复制出来的对象中烘焙信息就正确了。需要注意的是,我们只能复制物体身上的光照烘焙信息,物体产生的阴影是无法复制的。
如下代码所示,复制游戏对象的同时,将lightmapIndex和lightmapScaleOffset信息赋值给新对象即可。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
public class Script_09_03 :MonoBehaviour
{
[ ]
static void DuplicateGameObject()
{
if (Selection.activeTransform)
{
Dictionary<string, Renderer> save = new Dictionary<string, Renderer> ();
//根据相对路径保存Renderer信息
foreach (var renderer in Selection.activeTransform.GetComponentsInChildren<Renderer> ()) {
string path = AnimationUtility.CalculateTransformPath (renderer.transform, Selection.activeTransform);
save [path] = renderer;
}
//执行复制
EditorApplication.ExecuteMenuItem ("Edit/Duplicate");
//还原烘焙信息
foreach (var renderer in Selection.activeTransform.GetComponentsInChildren<Renderer> ()) {
string path = AnimationUtility.CalculateTransformPath (renderer.transform, Selection.activeTransform);
if (save.ContainsKey (path)) {
renderer.lightmapIndex = save [path].lightmapIndex;
renderer.lightmapScaleOffset = save [path].lightmapScaleOffset;
}
}
}
}
}
遮挡剔除
游戏中的元素非常多,但是摄像机能看到的内容是有限的,并且有些元素会被另外一些元素挡住,例如城墙一类的,城墙后面的元素就会被它挡住。如果不处理的话,这些元素也会带来一定的开销,此时可以使用遮挡剔除技术来剔除掉这些被挡住的元素。只有摄像机能看到的内容才会被动态保留下来。
遮挡和被遮挡
遮挡关系是由遮挡物与被遮挡物构成的,例如一面墙后面放了很多元素,那么墙属于遮挡物,元素就属于被遮挡物。按照遮挡剔除的原理,墙后面的元素会被剔除掉,这样就会有一个新问题:如果墙是一面透明的墙,显示时它就不会挡住后面的元素了。因此,我们需要设置元素的遮挡与被遮挡关系了。
首先,在场景中将需要参与遮挡以及被遮挡的游戏对象中,选中Occluder Static和Occludee Static标记;接着在导航菜单栏中选择Window->Occlusion Culling命令,打开烘焙面板,如下图所示。我们可以在这里设置最小的遮挡距离、最小的遮挡空隙以及背面的阈值。最后,单击Bake按钮,即可烘焙当前场景。烘焙结束后,Unity会自动在场景所在的位置创建一个同名的文件夹,并且往其中放入OcclusionCullingData.asset文件。
运行游戏后,移动摄像机的位置,当墙完全挡住背景的元素时,将自动剔除背景墙后面的元素。
如果这面墙是透明的,那么当背景元素被剔除时,显示就有问题了,此时墙后面的元素可以取消选择Occludee Static标志。这样无论如何移动摄像机,墙后面的元素都会被剔除掉。如果墙后的元素同样也是一面墙,并且还需要剔除后面的元素,它自身只需要选择Occluder Static标志即可。
遮挡与被遮挡事件
当发生遮挡剔除时,Unity会自动调用GameObject.SetActive(false)方法,这样整个对象的渲染就会被暂停,直到它重新被启动。如下代码所示,可以监听OnBecameInvisible()和OnBecameVisible()方法来处理即将隐藏或显示的逻辑。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class OcclusionEvent : UnityEvent<GameObject>{}
public class OcclusionListener : MonoBehaviour
{
public static OcclusionEvent onInvisible = new OcclusionEvent();
public static OcclusionEvent onVisible = new OcclusionEvent();
//隐藏状态
void OnBecameInvisible()
{
onInvisible.Invoke (gameObject);
}
//显示状态
void OnBecameVisible()
{
onVisible.Invoke (gameObject);
}
}
如下代码所示,我们在代码初始化的地方,自动给所有的Renderer组件挂上脚本,统一监听它的剔除以及显示的方法。如果想主动判断某个对象是否在摄像机显示区域内,也可以调用Renderer.isVisible()方法。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20using UnityEngine;
public class Script_09_04 :MonoBehaviour
{
void Start()
{
foreach (var item in GameObject.FindObjectsOfType<Renderer>()) {
item.gameObject.AddComponent<OcclusionListener> ();
}
OcclusionListener.onInvisible.AddListener (delegate(GameObject gameObject) {
Debug.LogFormat("gameobject {0} 隐藏",gameObject);
});
OcclusionListener.onVisible.AddListener (delegate(GameObject gameObject) {
Debug.LogFormat("gameobject {0} 显示",gameObject);
});
}
}
动态剔除
在游戏对象中,一旦勾选Occluder Static或Occludee Static标记,运行期间就无法修改它们的Transform信息了。如下图所示,可以在Mesh Renderer组件中勾选Dynamic Occluded复选框,表示它将被动态剔除掉。
运行游戏后,在Scene视图中将Mesh Renderer移出摄像机的显示区域,它立刻就被剔除掉了,如下图所示。注意它只会剔除掉渲染,Update还是会更新。默认情况下,建议选中Dynamic Occluded复选框。
自定义遮挡剔除
遮挡剔除虽然很方便,但也未必是好事。如果参与烘焙的元素多了,每次移动摄像机时,遮挡剔除会产生大量的计算,尤其移动平台更为明显。
其实,我们可以自己来实现遮挡剔除。比如,可以将场景上的元素按位置来划分成若干个格子,每个格子里面就是场景中的游戏对象了。无论游戏场景有多大,玩家同一时刻关心的只有1 ~ 9这些区域中的元素。当角色向左上方移动并超出当前格子的位置时,那么红色区域表示需要新加载的,黄色区域表示需要保留的,蓝色区域表示需要释放的。
这可以保证最小化管理所有游戏对象,而且这么做还有个好处:当需要在主角范围内查找最近单元时,参与判断的对象如果很多,就会带来for循环判断的开销,但是由于我们只保留格子范围内的元素,判断就会非常快了。至于遮挡剔除,由于需要管理的对象已经很少了,遮挡剔除的优化几乎可以忽略,所以在移动摄像机的时候,就不会再带来额外的开销了。
如下代码所示,只需要调用StaticBatchingUtility.Combine()方法即可动态设置合批。1
2
3
4
5
6
7
8
9
10using UnityEngine;
public class Script_09_05 :MonoBehaviour
{
public GameObject[] datas;
void Start () {
StaticBatchingUtility.Combine(datas, gameObject);
}
}
这段代码的含义就是将数组中的游戏对象合并在同一个Root节点下,也就是第二个参数指定的。另外,运行游戏后,合并过的Mesh对象是不可以发生位移的,但是可以移动它指定的Root节点。Root游戏对象可以在运行时任意修改位置。
动态合批
动态合批是全自动的,我们不需要做任何事情。但它是有要求的,Mesh的顶点数量需要小于300.如果Shader中使用了顶点位置、法线、UV0、UV1和切线,Mesh的顶点数必须小于180。可能会有朋友问:这么小限制的动态合批适用于哪里呢?其实在粒子特效中它发挥了很大的优势。由于每个特效喷射出来以后都是Mesh,如果不开启动态合批,DrawCall就会非常大。
静态合批的隐患
静态合批的原理就是自动生成Mesh,但是不同Mesh保存的信息可能是不同的。例如Mesh中可能会保存color和tangent,但是大部分Mesh都是不需要这个信息的,如果静态合批中有一个Mesh包含了这个信息,那么合并以后整个Mesh都会带上它,这样无疑会增加一些额外的开销。更多的时候是由于美术人员在导出FBX时,操作不当导致添加了没用的color或tangent信息,所以可以利用FBX官网提供的FBX接口,自己写一个Python脚本来删除它们。