大家好,我是 AlphaLu。
这次给大家介绍笔者所在团队自研的 H5 游戏引擎(小世界,一个非常有意思的项目!)的 Entity 层级设计方案,基于层级机制,我们就能灵活调整 Entity 的层级:
需求描述
我们说的 Entity 层级是指视觉层面上的,当两个 Entity 出现在画布的同一个位置,就必然要确定它们间的覆盖关系,而我们希望这种覆盖关系是由我们确定的,并且是可调整的,具体来说我们想要对 Entity 做如下操作: 上移一层、下移一层、置于顶层、置于底层、锁到顶层、锁到底层、释放锁层。
方案设计
PIXI 层级机制
PIXI 绘制特点
我们的游戏引擎使用 PIXI 作为渲染引擎,它的绘制特点如下:
- 以 Container 作为绘制的单位元素,先绘制的 Container 会被后绘制的 Container 覆盖;
- Container 的绘制顺序和其在父 Container 的 children 里的位置顺序一致,因为渲染过程是深度优先遍历 Container 树的;
- Container 在其在父 Container 的 children 里的位置由其 zIndex 属性和添加顺序确定,Container 的 sortChildren 方法会将它的子 Container 按照 zIndex 递增的顺序排列,若 zIndex 相同,则默认使用添加的顺序;
- PIXI 在渲染过程中,会深度优先遍历 Container 树进行绘制,当发现某个 Container 的 sortDirty 属性为 true,就执行其 sortChildren 方法,再接着遍历绘制;
- Container 的 sortDirty 变为 true 通常是因为发生了 addChild 或者更新了其 zIndex。
使用 PIXI
在 ECSM 架构中,Entity 由 PIXI 绘制,一个 Entity 对应一个 PIXI Container,具体来说就是:
- 在创建 Entity 时,引擎会给每个 Entity 内置一个 Transform Component;
- 当 Entity 被添加到场景时,引擎将 Entity 的 Transform Component 收集到 Render System,在完成添加后,我们会得到一棵 Entity 树;
- 在每帧都执行的 tick 函数里,Render System 会统一处理所有的 Transform Component:先统一给每个 Entity 创建一个 PIXI Container,再统一将这些 Container 添加到对应的父 Container 下,这样我们就得到一棵 Container 树。
- PIXI 渲染 3 中的 Container 树,绘制出整个游戏场景。
这个过程有点问题:因为 Render System 是通过列表遍历来处理 Transform Component的,导致 Container 的添加顺序取决于 Transform Component 在 Render System 里的存储顺序,这不是我们所希望的。我们希望 Container 的添加顺序和 Entity 的添加顺序保持一致,因为比如我们想要后添加的 Entity 覆盖先添加的 Entity。
Entity 层级机制
我们发现此时的 Entity 树和 Container 树上的节点并不是一一对应的,其实 Entity 树是基于游戏场景逻辑构建的,它的结构才是我们应该关心和维护的,也就是我们应当把 Entity 树的结构信息同步给 Container 树,而所谓的结构信息,就是指各节点在其父节点 children 里的位置顺序。
我们借鉴 PIXI Container 的层级概念,实现 Entity 的层级机制:
- Entity 有 zIndex 属性,表征它的视觉层级;
- Entity 有 sortChildren 方法,会将它的子 Entity 按照 zIndex 递增的顺序排列,若 zIndex 相同,则默认使用添加的顺序;
- Entity 有 sortDirty 属性,表征它的 children 是否发生变化,比如当 Entity 添加了子 Entity,意味着它的 children 发生变化,需要重新排序,那么标记其 sortDirty 为 true;
- 引擎会在每一帧内收集 sortDirty 变为 true(通常是发生了 addChild、更新了 zIndex) 的 Entity到一个队列中,然后在下一帧渲染前清空该队列;
- 引擎清空该队列的过程就是把队列中的 Entity 逐个取出,执行其 sortChildren 方法,然后再将其 children 顺序同步给其 Container 的 children,这样就能保证 Entity 树和 Container 树的一致性。
需要注意的是,在 5 中同步后,记得把 PIXI Container 的 sortDirty 置为 false,避免 PIXI 在渲染时又重复执行 sortChildren。
Entity 层级移动
基于 Entity 层级机制,我们来实现将 Entity 上移一层、下移一层、置于顶层、置于底层。
现在有一个 Entity 的 children 如下:
js
[et1, et2, et3, et4, et5] // children
[ 0, 0, 1, 3, 3] // 对应的 zIndex
上 | 下移一层
假设需要将 et2 上移一层,我们就把 et2 往后移一个位置:
js
[et1, et3, et2, et4, et5] // children
[ 0, 1, 0, 3, 3] // 对应的 zIndex
我们发现此时的 zIndex 并不是递增的,为了保证 sortChildren 后 et2 还是紧随 et3 后,我们需要修改 et2 的 zIndex:
js
[et1, et3, et2, et4, et5] // children
[ 0, 1, 1, 3, 3] // 对应的 zIndex
置于顶 | 底层
类似地,如果我们想把 et2 置于顶层,就先把 et2 移动到最后面,然后修改其 zIndex 为最大值即可:
js
[et1, et3, et4, et5, et2] // children
[ 0, 1, 3, 3, 3] // 对应的 zIndex
Entity 层级锁定
基于 Entity 层级机制,我们来实现将 Entity 锁到顶层、锁到底层、释放锁层,这里锁到顶层的含义是该 Entity 在被释放锁层前将永远处于顶层,并且它的层级不会被改变。
现在有一个 Entity 的 children 如下:
js
[et1, et2, et3, et4, et5] // children
[ 0, 0, 1, 3, 3] // 对应的 zIndex
锁到顶 | 底层
假设需要将 et2 锁到顶层,我们就先把 et2 移动到最后面,然后修改其 zIndex 为 Infinity 即可:
js
[et1, et3, et4, et5, et2] // children
[ 0, 1, 3, 3, Infinity] // 对应的 zIndex
Infinity 能保证在 sortChildren 后,et2 都是排在最后一个位置。
另外,我们需要在 Entity 层级移动功能的实现中排除被锁到顶层或底层的 Entity。
释放锁层
当我们想要 et2 不被锁到顶层时,就修改其 zIndex 为最大值即可:
js
[et1, et3, et4, et5, et2] // children
[ 0, 1, 3, 3, 3] // 对应的 zIndex
这样 et2 被释放锁层时,还是处于最顶层,可以避免非预期的视觉突变效果。
总结
本文给大家介绍了 H5 游戏引擎的 Entity 层级设计方案,先描述我们需要的 Entity 层级调整功能,然后分析在 ECSM 架构中单纯使用 PIXI 层级机制的问题,接着设计了 Entity 层级机制,最后讲解基于该机制实现 Entity 层级移动、Entity 层级锁定,欢迎大家交流意见。