« Apple watch 折腾记 | 返回首页 | 抽王八的新玩法 »

近期 ECS 的一些优化

最近在优化我们的 3d engine 。引擎的渲染对象管理层是基于 ECS 框架,且整个引擎基于 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 优化。还有另外一个关于材质系统的性能优化,下次再谈。

Comments

之前看过skynet,感觉风大太依赖lua了,虽然写着舒服但是也是有代价的,上面的引擎感觉和UE的DS差不多,而且UE是很成熟的引擎,没必要再造轮子,优化下lua的c源码感觉都要好很多,现在服务器大规模使用golang和k8s已经不是我们那个年代的服务器能比的了,客户端UE慢慢会成为主流,大部分游戏不管是客户端还是服务器在分布式加持下在线压力都不是问题,最值得学习的还是魔兽世界的无缝世界服务器设计,现在慢慢出现很多强大的工具级别的语言和工具不管是golang还是k8s,在负载和维护上面基本上没什么难点,难的还是游戏内部机制的实现,守望的ECS魔兽世界的无缝大世界各种细节,至于常见的游戏只能说没必要多去研究,

特别期待引擎开源。

期待开源

Post a comment

非这个主题相关的留言请到:留言本