声明:参考https://blog.csdn.net/mobilebbki399/article/details/79491544和《游戏编程模式》
当场景元素过多时,需要实时的显示及隐藏物体使得性能提示,但是物体那么多,怎么知道哪些物体需要显示,哪些物体不需要显示的。当然,遍历物体判断该物体是否可以显示是最容易想到的方法,但是每次更新要遍历所有物体的代价很高,有没有其他可以替代的方法呢,当然有,四叉树就是其中一个方法。
假设场景是一维的,所有物体从左到右排成一条线,那么用二分法就可以快速找出距离自己一定范围内的物体。
同样四叉树的原理像二分一样,只是二分法处理的是一维世界, 四叉树处理的是二维世界,再往上三维世界用八叉树处理,这里用四叉树管理,八叉树暂时不讨论,原理类似。
这里先展示效果:
四叉树结构:
根节点是整个场景区域,然后分成四块:左上右上左下右下,分别作为根节点的儿子,然后每个儿子又分成四块重复之前步骤,这就是一棵四叉树。
每个节点保存四个儿子节点的引用,并且有存放在自己节点的物体列表,为什么物体不全部存放在叶子节点呢?因为有可能某个物体比较大,刚好在两个块的边界上。
这时候有两种做法:
1、这个物体同时插入两个节点的物体列表中
2、这个物体放在两个几点的父亲节点的物体列表中
第一种方法管理起来比较麻烦,所以在此采用第二种方法。
首先定义场景物体的数据类:
1 [System.Serializable] 2 public class ObjData 3 { 4 [SerializeField] 5 public string sUid;//独一无二的id,通过guid创建 6 [SerializeField] 7 public string resPath;//prefab路径 8 [SerializeField] 9 public Vector3 pos;//位置 10 [SerializeField] 11 public Quaternion rotation;//旋转 12 public ObjData(string resPath, Vector3 pos, Quaternion rotation) 13 { 14 this.sUid = System.Guid.NewGuid().ToString(); 15 this.resPath = resPath; 16 this.pos = pos; 17 this.rotation = rotation; 18 } 19 }
定义节点的接口:
1 public interface INode 2 { 3 Bounds bound { get; set; } 4 /// <summary> 5 /// 初始化插入一个场景物体 6 /// </summary> 7 /// <param name="obj"></param> 8 void InsertObj(ObjData obj); 9 /// <summary> 10 /// 当触发者(主角)移动时显示/隐藏物体 11 /// </summary> 12 /// <param name="camera"></param> 13 void TriggerMove(Camera camera); 14 void DrawBound(); 15 }
定义节点:
1 public class Node : INode 2 { 3 public Bounds bound { get; set; } 4 5 private int depth; 6 private Tree belongTree; 7 private Node[] childList; 8 private List<ObjData> objList; 9 10 public Node(Bounds bound, int depth, Tree belongTree) 11 { 12 this.belongTree = belongTree; 13 this.bound = bound; 14 this.depth = depth; 15 objList = new List<ObjData>(); 16 } 17 18 public void InsertObj(ObjData obj) 19 {} 20 21 public void TriggerMove(Camera camera) 22 {} 23 24 private void CerateChild() 25 {} 26 }
一棵完整的树:
1 public class Tree : INode 2 { 3 public Bounds bound { get; set; } 4 private Node root; 5 public int maxDepth { get; } 6 public int maxChildCount { get; } 7 8 public Tree(Bounds bound) 9 { 10 this.bound = bound; 11 this.maxDepth = 5; 12 this.maxChildCount = 4; 13 root = new Node(bound, 0, this); 14 } 15 16 public void InsertObj(ObjData obj) 17 { 18 root.InsertObj(obj); 19 } 20 21 public void TriggerMove(Camera camera) 22 { 23 root.TriggerMove(camera); 24 } 25 26 public void DrawBound() 27 { 28 root.DrawBound(); 29 } 30 }
初始化场景物体时,对于每个物体,需要插入四叉树中:判断该物体属于根节点的哪个儿子中,如果有多个儿子都可以包含这个物体,那么这个物体属于该节点,否则属于儿子,进入儿子中重复之前的步骤。
代码如下:
1 public void InsertObj(ObjData obj) 2 { 3 Node node = null; 4 bool bChild = false; 5 6 if(depth < belongTree.maxDepth && childList == null) 7 { 8 //如果还没到叶子节点,可以拥有儿子且儿子未创建,则创建儿子 9 CerateChild(); 10 } 11 if(childList != null) 12 { 13 for (int i = 0; i < childList.Length; ++i) 14 { 15 Node item = childList[i]; 16 if (item == null) 17 { 18 break; 19 } 20 if (item.bound.Contains(obj.pos)) 21 { 22 if (node != null) 23 { 24 bChild = false; 25 break; 26 } 27 node = item; 28 bChild = true; 29 } 30 } 31 } 32 33 if (bChild) 34 { 35 //只有一个儿子可以包含该物体,则该物体 36 node.InsertObj(obj); 37 } 38 else 39 { 40 objList.Add(obj); 41 } 42 }
当role走动的时候,需要从四叉树中找到并创建摄像机可以看到的物体
1 public void TriggerMove(Camera camera) 2 { 3 //刷新当前节点 4 for(int i = 0; i < objList.Count; ++i) 5 { 6 //进入该节点中意味着该节点在摄像机内,把该节点保存的物体全部创建出来 7 ResourcesManager.Instance.LoadAsync(objList[i]); 8 } 9 10 if(depth == 0) 11 { 12 ResourcesManager.Instance.RefreshStatus(); 13 } 14 15 //刷新子节点 16 if (childList != null) 17 { 18 for(int i = 0; i < childList.Length; ++i) 19 { 20 if (childList[i].bound.CheckBoundIsInCamera(camera)) 21 { 22 childList[i].TriggerMove(camera); 23 } 24 } 25 } 26 }
游戏运行的一开始,先构造四叉树,并把场景物体的数据插入四叉树中由四叉树管理数据:
1 [System.Serializable] 2 public class Main : MonoBehaviour 3 { 4 [SerializeField] 5 public List<ObjData> objList = new List<ObjData>(); 6 public Bounds mainBound; 7 8 private Tree tree; 9 private bool bInitEnd = false; 10 11 private Role role; 12 13 public void Awake() 14 { 15 tree = new Tree(mainBound); 16 for(int i = 0; i < objList.Count; ++i) 17 { 18 tree.InsertObj(objList[i]); 19 } 20 role = GameObject.Find("Role").GetComponent<Role>(); 21 bInitEnd = true; 22 } 23 ... 24 }
每次玩家移动则创建物体:
1 [System.Serializable] 2 public class Main : MonoBehaviour 3 { 4 ... 5 6 private void Update() 7 { 8 if (role.bMove) 9 { 10 tree.TriggerMove(role.mCamera); 11 } 12 } 13 ... 14 15 }
怎么计算出某个节点的bound是否与摄像机交叉呢?
我们知道,渲染管线是局部坐标系=》世界坐标系=》摄像机坐标系=》裁剪坐标系=》ndc-》屏幕坐标系,其中在后三个坐标系中可以很便捷的得到某个点是否处于摄像机可视范围内。
在此用裁剪坐标系来判断,省了几次坐标转换,判断某个点在摄像机可视范围内方法如下:
将该点转换到裁剪空间,得到裁剪空间中的坐标为vec(x,y,z,w),那么如果-w<x<w&&-w<y<w&&-w<z<w,那么该点在摄像机可视范围内。
对bound来说,它有8个点,当它的8个点同时处于摄像机裁剪块上方/下方/前方/后方/左方/右方,那么该bound不与摄像机可视范围交叉
代码如下:
1 public static bool CheckBoundIsInCamera(this Bounds bound, Camera camera) 2 { 3 System.Func<Vector4, int> ComputeOutCode = (projectionPos) => 4 { 5 int _code = 0; 6 if (projectionPos.x < -projectionPos.w) _code |= 1; 7 if (projectionPos.x > projectionPos.w) _code |= 2; 8 if (projectionPos.y < -projectionPos.w) _code |= 4; 9 if (projectionPos.y > projectionPos.w) _code |= 8; 10 if (projectionPos.z < -projectionPos.w) _code |= 16; 11 if (projectionPos.z > projectionPos.w) _code |= 32; 12 return _code; 13 }; 14 15 Vector4 worldPos = Vector4.one; 16 int code = 63; 17 for (int i = -1; i <= 1; i += 2) 18 { 19 for (int j = -1; j <= 1; j += 2) 20 { 21 for (int k = -1; k <= 1; k += 2) 22 { 23 worldPos.x = bound.center.x + i * bound.extents.x; 24 worldPos.y = bound.center.y + j * bound.extents.y; 25 worldPos.z = bound.center.z + k * bound.extents.z; 26 27 code &= ComputeOutCode(camera.projectionMatrix * camera.worldToCameraMatrix * worldPos); 28 } 29 } 30 } 31 return code == 0 ? true : false; 32 }
以上是物体的创建,物体的消失放在resourcesmanager中。
建立两个字典分别保存当前显示的物体,和当前隐藏的物体
1 public class ResourcesManager : MonoBehaviour 2 { 3 public static ResourcesManager Instance; 4 5 ... 6 private Dictionary<string, SceneObj> activeObjDic;//<suid,SceneObj> 7 private Dictionary<string, SceneObj> inActiveObjDic;//<suid,SceneObj> 8 ... 9 }
开启一段协程,每过一段时间就删除在隐藏字典中的物体:
1 private IEnumerator IEDel() 2 { 3 while (true) 4 { 5 bool bDel = false; 6 foreach(var pair in InActiveObjDic) 7 { 8 ... 9 Destroy(pair.Value.obj); 10 } 11 InActiveObjDic.Clear(); 12 if (bDel) 13 { 14 Resources.UnloadUnusedAssets(); 15 } 16 yield return new WaitForSeconds(delTime); 17 } 18 }
每次triggerMove创建物体后刷新资源状态,将此次未进入节点(status = old)的物体从显示字典中移到隐藏字典中,并将此次进入节点(status = new)的物体标记为old为下次创建做准备
1 public void RefreshStatus() 2 { 3 DelKeysList.Clear(); 4 foreach (var pair in ActiveObjDic) 5 { 6 SceneObj sceneObj = pair.Value; 7 if(sceneObj.status == SceneObjStatus.Old) 8 { 9 DelKeysList.Add(pair.Key); 10 } 11 else if(sceneObj.status == SceneObjStatus.New) 12 { 13 sceneObj.status = SceneObjStatus.Old; 14 } 15 } 16 for(int i = 0; i < DelKeysList.Count; ++i) 17 { 18 MoveToInActive(ActiveObjDic[DelKeysList[i]].data); 19 } 20 }
至此,比较简单的四叉树就完毕了。
更复杂的四叉树还需要实现物体在节点之间移动,比如物体是动态的可能从某个节点块移动到另个节点块;物体不消失而用LOD等,在此就不讨论了
项目地址:https://github.com/MCxYY/unity-Multi-tree-manage-scenario