特效接口的重构
上次提到,经过数据分析发现,我们引擎(游戏)目前特效系统占了很大的 CPU 比例。虽然特效的计算已经放在独立线程,不影响帧率,但 CPU 的开销会导致电池消耗,最终会引起手机热量上升,最终让手机降频。所以,当时想了一些牺牲准确性的方案来做优化:即,不在镜头内的特效不运算。让视觉裁剪同时也裁剪掉特效粒子片的计算。
最近做了这方面的重构工作。其中的难点在于:特效模块是第三方的,并不完全贴合我们的引擎设计,而短期内又没有重新实现或改造的计划。
其中最麻烦的一点是,我们引擎有一个挂钩 (hitch) 的特性。场景中可以有很多挂钩挂接的是同一个子树。这样,相同的对象就会被渲染多次,状态完全相同,只是位置因挂钩的不同而不同。我们引擎也针对它做了一些优化,比如可以把多个部件用 instance 绘制优化。
对于引擎内部支持的模型,可以很方便的基于 Hitch 做批量提交。内部数据结构只有一份,但可以在每帧绘制多次。但是,当 Hitch 挂接的的部件中有特效时,特效模块并不支持复制多份多次提交绘制。我们只能在特效模块内创建多个相同的对象,且这些对象的内部状态是独立的,而我们又需要让它们永远保持状态一致,这会导致实现上的一些麻烦。
好在当我们场景上需要有多个相同的物件,都带着相同的特效时,按引擎的定义它们是完全一致的。虽然我们创建了多个特效对象,但并不需要严格的和屏幕对象持久对应。比如,这一帧屏幕上有四个状态相同的烟雾,而下一帧有一个移除了镜头看不见了,另一个移入了镜头,其它三个可能移动了位置,总数没有变化。我们并不需要在下一帧找回那三个位置发生改变的烟雾和上一帧的对象对应起来。引擎只需要了解,这四个烟雾对应新的位置各在哪里。
特效对象的内部状态是一帧帧推进的,每帧状态是基于一个 delta 量从上帧变化而来。特效模块没有提供 clone 对象的方法,所以为了保证完全一致,只能重新发射并从头推进到当前进度。这是一个相当耗时的操作,我考虑之后决定牺牲一定的准确性:即,当镜头中多出一个特效时,可能是从头发射的,而不是和之前的保持一致。当然,如果不用 Hitch 复制特效的话,也能保持正确。
重构后的实现用了一个独立的系统封装场景中存在的特效,每个特效在内部可以有多份实例。每一个渲染帧,场景节点是特效时,和模型一样,需要提交当前的矩阵到对应的模块:模型进入的是模型渲染模块,而特效进入的是特效系统。特效系统每帧的工作是收集每个特效当前帧被提交了多少次。镜头外被裁剪掉的特效不需要渲染(甚至不需要计算状态,这是我们游戏的特别优化)。被 Hitch 多次引用的同一特效会被以多个不同的场景矩阵提交,这时,启用特效系统内部的缓存,复用上一帧的相同特效对应(并对应的推进状态)。如果这一帧数量比上一帧多的话,会发射(Clone)一个新的特效出来;如果少的话,则可以选择删掉多余的对象或暂时把多余的设为不可显示。
在重构过程中,我们反思了之前一版提供的接口语义:如果想发射一个特效,就创建出一个 Entity ,在消失后再销毁它。销毁过程可以是主动进行,也可以自动销毁。经过这段时间的使用,我觉得这个接口语义是不对的。我们创建的 Entity 应该是特效的发射器而不是发射出来的特效。而发射器是不应该频繁创建和销毁。发射器在不工作时,它应该只占用空间,而没有 CPU 开销。我们应只在需要时让发射器发射。
基于这个思路,在开发时用编辑器创建的预制件 Prefab 中,我们应该把特效发射器都在里面制作好。某些预制件动画的特定帧会触发特效:比如,工作的烟囱在工作时会冒烟;烟雾就是一个粒子特效。在过去的实现中,动画关键帧会创建一个烟雾特效 Entity 出来;而现在的版本则应该在 Prefab 中制作好烟雾特效发射器,动画关键帧在运行时驱动该发射器发射。
通常,我们并不需要在运行时控制特效发射器发射出来的特效。但在编辑环境,需要细致的控制它。暂停、加速、减速、播放到特定帧…… 。发射器返回发射后的特效 Handle ,并提供额外的接口控制它,这是底层特效库提供的方案。但对于应用层来说,区分发射器对象和特效对象就过于细节易用错。现在的方案是,把这些控制接口直接放在发射器上,即如果想控制特效,直接操作发射器,这些操作都转发到发射器发射的所有特效对象上。如果发射器发射了多个特效,它们同时存在,这些操作就反应在所有存在的特效上。
文章来源:
Author:云风
link:https://blog.codingnow.com/2023/09/effect_system_rework.html