最近在优化我们的 3d engine 。引擎的渲染对象管理层是基于 EC++S 框架,且整个引擎基于 Lua 设计和构建。也就是说,渲染部分的数据都可以通过 Lua 读写。但是,对于核心渲染循环,Lua 的性能有限,当需要渲染的对象很多时,之前用 Lua 编写的循环的性能问题就显露出来。
为此,我们很早就设计了 luaecs。把数据放在 C 结构中,并给出 Lua 访问的接口。这样就方便了初期使用 Lua 快速开发,后期针对核心循环用 C 重构优化。今年年初时,我们把渲染核心系统用 C 重构了一遍,基本解决了性能问题。
最近,我们在 profile 的基础上,又做了一些优化工作。这次发现的性能热点在于游戏场景中存在大量的对象,但镜头内需要渲染的比例很少。之前,针对这个场景已经做过一次优化,针对方案是给 ecs 框架中加入分组这个特性 。
合理的分组,可以快速对镜头裁剪后的待渲染对象打上 tag ,针对 tag 可以快速对 ECS 管理的对象快速筛选。筛选是在 C 中进行的,所以极大的提高了 Lua 操作 ecs 的性能。但当我们把核心循环移到 C 中时,我们发现这块还有优化的空间。
原因是:当 ECS 管理的对象远大于渲染循环最终需要筛选出来的对象个数时,这些对象在储存空间中是离散的,检索单个组件的时间复杂度为 O (Log n) 。这会导致最终的核心循环的时间复杂度为 O (n Log n) 。luaecs 在对此做了非常有限的优化:它会记录一个组件最后查询的位置,为下次查询参考使用。所以,循序遍历这些离散的 tag 会有一定的加速,但提高有限。
这次优化的目标是:在渲染核心循环遍历所有镜头内的 Entity 的相关 Components 时,整个过程在大多数情况下能做到 O (Log N) 。为了达到这一点,自然是用空间换时间了。
设想一下在非 ECS 框架下如何做到这一点?通常需要额外建一个线性容器,保存所有可见对象的引用。ECS 框架下,对象是由若干组件动态构成的,组件和组件之间的关系会更加复杂。如果我们以组件为最小单位的视角来看,即需要有一个高效的容器放下需要处理的所有组件,其数量非常大(估算在十万数量级)。每次访问都是性能敏感的。这个容器显然是一个 Cache ,镜头的变化、对象的生死都会影响它。
缓存失效是最难应对的问题。这里依照传统方法使用类智能指针的方案一定是及其影响性能的。ECS 框架中使用 id 会是个更好的方案。先前觉得有序 id 用对分查找不会是大问题,但实际 profile 下来,这里还有优化空间。我额外实现了一个索引 cache ,在遍历过程中,记录下每个组件的具体位置,用于下一次遍历过程的参考。这个位置缓存没必要和实际情况保持一致,反正每次查找都会再核对一次,错了就更新。实测下来果然提高了不少性能。在 iPhone 8 上的对象数量相当多的大规模场景测试中,每帧渲染核心循环占据的 cpu 时间可以控制在 10ms 以下,已经能满足我们预定的性能需求。
接来来的一个小问题是:这个 cache 对象应该放在哪里?用一个全局变量显然是不合适的,虽然我们现在只有一个 ecs world ,但不排除日后变成多个。且全局变量本身就是个坏味道。
看起来,cache 对象最好是和 world 绑定的。但按传统方法直接加到 world 对象中也是个坏味道。毕竟它只和渲染系统相关,并非 ECS 框架的基础设施。
那么,作为一个独立组件类型放在 world 里怎样?也就是作为一个 singleton 的组件和其它组件共存。这里的问题有两个:
其一,目前 ecs 框架中,独立类型的个数是有限的,如果很多类似系统都为自己独有的全局对象增加新的类型,会用掉大量的类型资源。
其二,组件类型是排它的。如果很多系统都申请自己的类型,必须在某个统一地方声明,相互区隔。这样,原本是一个渲染系统内部实现的东西,就必须把自己暴露出来,维护代码的时候除了在实现文件内编写代码外,还需要在一个对外的接口中加上一笔,也不是什么好味道。
在思考解决方案时,我的想法从 C++ 的全局变量考虑起。C++ 的全局对象是个很复杂的东西,它比 C 的全局变量复杂之处在于其构造和析构过程。这需要 linker 多做很多事情。也就是 linker 协调了各个独立模块中的全局对象。
如果我们需要的不是一个全局对象,而是把对象绑定在 world 上,每个 world 中保持唯一该怎么办?我们可以给 world 开辟一个全局对象区(以一个组件单例的形式存在),把所有这类对象都在运行时绑定在这个区。该区就是一个对象指针数组,每个全局对象拥有一个独立的索引号即可。那么索引号该怎样分配才能做到系统之间相互不冲突呢?我认为可以借助一个全局对象的自增 id 分配器完成。即:这个自增 id 的分配器只为每个模块用到的全局对象分配唯一 id 而不是对象本身;而借助 id ,我们就可以在每个不同的 world 的全局对象区申请到一个唯一的槽位,保存对象的指针。最终,我们就可以做到每个 world 都有自己的独立全局对象组,而访问它们还是 O(1) 的时间复杂度。
以上,就是最近做的一点 ECS 优化。还有另外一个关于材质系统的性能优化,下次再谈。