当游戏场景偏大时,由于目标平台资源一般比较有限,通常我们不会把该场景的所有资源一次性展开到内存里。常见做法是,把大大小小的各种资源用某种空间分割的数据结构组织起来,当玩家在场景中移动的时候,可以有效地获取即将出现的资源列表,触发对应的异步加载,并及时释放玩家离开区域的资源。这样的话,无论场景规模多大,同一时刻出现在内存中的数据量总是相对可控的。
为了缓解场景的资源压力,昨天实现了一个基于四叉树的动态资源管理,代码在这里。顺便把设计和实现时的考虑简单地记录一下,以节省沟通的成本。
四叉树是常用的空间分割数据结构。跟 BSP 和 Octree 相比,它非常清晰和规律,在平面上展开时,观察和调试又足够简单。使用四叉树实现的空间管理,可以较好地兼顾开发效率和运行效率。在 开放世界游戏中的大地图背后有哪些实现技术? 一文中我曾提到 “当尺度大到一定规模之后,地形通常退化为相对扁平的2D空间”,在实际的 3D 游戏项目里,水平方向上的场景复杂度一般也会远大于垂直方向上的,因此四叉树比八叉树往往更适合实际项目的需要。
这是一个测试用的模拟场景,内含 5000 个形状和变换各异的测试模型。左边的 Move 按钮和 “Always Move” 复选框用于模拟玩家的移动, “Debug Lines” 用于画出场景中调试线条。
这是动态加载开启后的负载情况。可以见到,在玩家(白色柱子)向目标(紫红柱子) 移动的过程中,任意时刻,只有玩家所在区域附近的物件在内存中持有。这张图上还可以看到,玩家远离的方向比面向的方向物件多,这是 swap-out 比 swap-in 的判定距离远一些的缘故,这样主要是为了避免了玩家来回移动时的内存颠簸,也就是对象的反复加载 (降低了 IO 总量)。
这是在 Scene View 下开启了 Debug Lines 选项后看到的调试视角。可以看到,场景被切分成了均匀四叉树,每一个小的 cell 都是四叉树的一个叶节点 (UQtLeaf) ,其中:
- 灰色 cells 是非活跃叶节点,这些叶节点上的所有物件都被释放,不占用内存
- 白色 cells 是玩家当前持有的所有叶节点 (对应代码中的 _holdingLeaves)
- 绿色 cells 是正在被交换进来 (swap-in) 的叶节点 (这些节点上的所有物件异步加载完成后,这个 cell 会变为白色)
- 红色 cells 是正在被交换出去 (swap-out) 的叶节点 (这些节点上的所有物件销毁后,这个 cell 会变为灰色)
上面三张图基本上已经把功能说得差不多了,接下来我们简单过一下代码,略作补充。
UQtConfig
这个类内含一些参数,用于按需配置和控制 UQuadtree
内部的一些行为。请注意,对这些参数的调整会直接影响性能表现。目前页交换的触发 (SwapTriggerInterval) 是 0.5s 一次,页状态更新 (SwapProcessInterval) 是 0.2s 一次,这些是为了在保持较低的 CPU 开销下,能够有较高的反应速度。
IQtUserData
是每个叶节点上挂载的用户对象所需实现的接口,使用接口而不是独立委托,可以更明确和清晰地定义 UQuadtree
与用户数据之间的约定。一个典型的用户实现 QtTypicalUserData
应当至少包括一个资源路径字符串 (ResourcePath) 和一个 GameObject——当对应的叶节点被交换进内存时,由 ResourcePath 发起异步请求,载入 GameObject;当对应的叶节点被交换出去时, GameObject 被释放,ResourcePath 被保留用于下一次的载入。
代码及对应的测试工程在 Unity-5.0.1f1 下编译和运行通过,还没有来得及做针对 4.6 的向后移植工作,先这样吧。
(全文完)