跟踪 Component 的修改
在 ECS 框架中,每个 System 在每次更新时,都遍历一类 Component 依次处理。这对于游戏的大多数场景都适用,因为游戏引擎要处理的对象通常是易变的。对于每个对象单独判断是否应该处理反而有性能负担。
但是,总有一些应用场景下,只对一类 Component 中的一小部分做修改,而没有被修改的对象可以保持上次的数据,而不必重复运算。在需要重算的对象数量远小于总量时,每个更新就很不划算了。
为了减少运算量,我们通常的解决方案是增加一个脏标记,修改时设置,设置它。这样就可以在处理的时候只处理被标记过的对象。常规的脏标记实现方案有两种,一是在 Component 上加一个 bool 字段,遍历的时候跳过没有标记的对象;二是在 Entity 上动态添加一个用于 tag 的 Component ,视作脏标记,遍历后再清除这个 tag 。
不过这两种方案在 lua 实现的 ecs 框架中,使用代价都比较大。
lua 和 C/C++ 不同,函数调用的成本相对比较大,为脏标记添加一个 set 方法不仅增加了运行成本而且也不符合 lua 的使用惯例(增加了使用成本)。我考虑用元方法来减少成本。
我们可以给需要跟踪的 Component 创建一个叫修改集 Modify 的结构,例如,如果要跟踪 transform 的修改,就创建一个 transform.modify 的修改集。这是一个特殊的有元方法的对象。对 transform.x 的修改,写作 transform.modify.x = newx 。这是一个更符合 lua 风格的用法。置脏的过程就隐藏在元方法里面。
modify 的实现依赖三张表。
- modify 对象本身,里面记录有关联的 entity id ,实际的数据区,元表。
- 持有修改后的数据的数据表。
- 元表,用来触发脏的行为。
工作起来是这样的,元表的 __index
关联到数据的数据表;元表先把 __newindex
关联在一个函数上,这个函数负责把 modify 关联的 entity id 添加到脏集合中,在第一次改写关联数据的时候触发。触发之后,修改 __newindex
元方法,直接引用和 __index
相同的数据表。
从第二次修改开始,就不再触发置脏的过程。元方法是有额外成本的,但 __index
和 __newindex
关联到表而不是函数上,性能可以有极大的改善。
注意,读取 modify 结构本身是不触发置脏流程的。通常,业务逻辑只用关心老版本(上一帧)的 transform 状态;如果它必须关心当前帧是否有人修改 transform ,也可以主动查询 transform.modify 。
然后,我们提供 modify 集合的遍历方法,在一个独立的 system 中集中处理这一帧所有的修改集。在遍历之后,我们重置脏集合以及所有的 modify 的元表中的触发器。
我们实现这样一个机制的动机在于解决场景树的更新问题。场景树上的非叶节点的 transform 的修改会导致整个子树,另外,节点还有可能更换父亲,同样会导致整个子树的变化。
但是,整个场景的结构通常是稳定不变的,只有少量节点会经常更新自己的空间位置。我需要一个相对廉价的方案来决定场景树的哪一部分需要更新重算。
下一篇 blog 我将展开这个具体案例。
Comments
Posted by: 零 | (5) June 14, 2019 09:27 PM
Posted by: rei | (4) March 7, 2019 09:16 AM
Posted by: Anonymous | (3) March 5, 2019 04:36 PM
Posted by: Cloud | (2) February 24, 2019 05:08 PM
Posted by: windy | (1) February 24, 2019 02:46 PM