最近一段时间都在公司内寻找项目可以合作推进 Ant Engine 的使用。我觉得自研引擎的一个重要优势在于我们可以针对具体游戏更好的做性能优化。在目标设备硬件性能允许的范畴内,把画面质量和交互体验做到更好。而同样的优化手段,在通用商业引擎上面做会困难的多,甚至无法顺利完成。
我们用 Ant Engine 制作的第一款游戏 Red Frontier 在一年前是性能完全不达标的。它在 iPhone 8 上甚至都达不到 30fps ,无法流畅游戏。很多性能问题是已知问题,比如我们用 Lua 搭建了整个引擎,一开始只考虑了引擎结构和正确性,把性能搁置在一边待后面再处理。
优化方案是一开始就想好的:借助 lua ecs 框架,把数据结构放在 C 内存中,必要时可以绕过 Lua 代码,直接用 C 代码控制核心数据。我们花了大约 3 个多月的时间将核心渲染系统用 C 重写后,就把性能提高了 1 个数量级以上。这个过程可以说是一直掌握在手中,按计划推进。
但即使可以让游戏运行在 60fps 下,优化的目标也远远没有达到。这是因为对于手机设备来说,用户更容易产生电量焦虑。在固定座位上插着电玩主机或 PC 游戏,玩家不会去想游戏机耗了多少电;即使把 switch 外带玩游戏,也可以一直玩到没电;但用手机不光是用来玩游戏的,如果消耗电量太快,玩家会担心手机等一下会不会无法支付交通费用,不能扫码吃饭……
我甚至一度怀疑,手机并不适合长时间沉浸式的游戏类型。或许放置游戏这类玩一下放一下的游戏类型更合适一些?
游戏达到要求帧率后,继续做性能优化和传统游戏引擎非常不同。
过去我们做性能优化,追求的是每项任务的低延迟。最快的做完每件事就能提高帧率。但节能不是这样的考虑,在较长尺度内,尽量少做重复运算,才能减少总的能量开销。能缓存住耗能较高的计算结果,哪怕复用这些结果比重新计算更慢,恐怕也是值得的。
其实手机的硬件设备就是在向这个方向努力。例如 Tile based rendering 的 GPU 架构,它本质上不是为了渲染更快,而是用能耗更低的手段检测出潜在的重复计算,缓存下渲染结构,争取在下一帧复用。最重要的目的就是节能。
软件设计上更是如此。我在之前的 blog 中就探讨过可能会做的一些优化方向 。
另一方面,我们需要针对具体的游戏去考虑优化方案。
比如我们在现在的游戏中使用了这样一些优化手段:
虽然场景中有大量的对象时刻在运动,且这些运动和 gameplay 息息相关。例如在公路上跑的小货车、仓库和机器间来回运转的无人机。如果用常规方案,它们均需要每一帧都重新计算位置。如果数百个这样的对象都在 Lua 中计算位置,性能就会受到冲击。不光是位置计算,还涉及把状态同步到渲染底层。
我们实际在 gameplay 层用了一个非常低的帧率计算这些对象(小车/无人机)的位置。然后在引擎中实现了一个运动插值器,可以直接在 C 中补足运动轨迹,把 gameplay 中计算出的离散点变成连续轨迹。这样就大大减少了在 Lua 中的计算量。
另一个例子是动画。
游戏中大量的建筑都带有丰富的动画。一开始,我们在 blender 中制作建筑的细节动画,给建筑做上蒙皮、骨骼,再导出到游戏中。一般来说,受硬件限制,即使有大量相同建筑,但因为其动画轨迹上的相位不同,我们也很难把这些带有蒙皮的建筑事先都提交到显存中用单一绘图指令批量绘制,即所谓的合批操作。
但我们在这个游戏中还是做了一些尝试。例如,我们把建筑的动画分为四类,停工动画、正常运转动画、以及两个状态之间的衔接动画。然后,把这四组动画均做成相同时长三秒。gameplay 层在需要渲染时,都在 3 秒间隔上切换动画。这样,就让所有相同建筑播放同一个动画时,动画轨迹的相位是完全相同的。
在此基础上,我们就可以让引擎把相同建筑带上蒙皮合并渲染了。
前几个月,我发现,我们游戏中用到的动画全部都是一些机械运动。看起来使用蒙皮动画并不是必须的。只要我们可以把机器都拆解成一个个机械零件,每个零件其实都是刚体,骨骼只需要直接驱动这些刚体,而不必借助蒙皮去驱动网格上的点集(蒙皮)。如果把动画改成这种形式描述,整个游戏完全不需要蒙皮动画,而机器的那些相同的零部件天然就可以合并成同一绘图指令。
要做到这一点,只需要做好美术用的工具,让美术直接用定制的工具来制作机器动画就可以了。我们花了 3 个月的时间,项目组内唯一的一个美术就完成了将原有的蒙皮动画全部转换为自有形式的刚体动画的工作。游戏性能也就大大提高了。
目前,我们游戏在 iPhone 上已经不太耗电,正常游戏过程,相同时间甚至比玩微信刷抖音还要省电,所以我对游戏性能方面的表现也就没那么焦虑了。接下来可以有更多时间去考虑 gameplay 方面的调整。
前几天,我看了公司另外一个刚开始的新项目。是一个沙盒生存类的 3D 游戏。第一眼看上去场景有点像塞尔达,又或者说有点魔兽世界场景的感觉。场景特别的复杂。目前是用 Unity 制作的,虽然最终会在手机上运行,但现阶段在 PC 上也不到 30 fps 。简单看了一下,场景中 gameobject 就多达十万个之量级。CPU 有非常大的压力。
虽然该项目短期不开可能更换引擎。但我还是忍不住去想了一下,如果我们用 Ant Engine 参与制作的话,应该怎样优化如此之大的场景。
Ant Engine 目前已经很好的处理了:“内存中的对象数量对当前帧需要渲染的对象数量无太大影响”这个问题。即使内存中有 100 万个对象,但如果有方法确定只渲染其中 1000 个对象的话,100 万数量级不会对性能造成冲击。关键问题在于怎样从待渲染集中剔除掉大量不需要的对象。
针对魔兽世界这种视角的 3d 游戏场景,一个最大的特点是,摄像头跟随着玩家,它永远以相对场景规模来说较慢的速度运动。也就是说,一旦我们以人物为中心,剔除掉大量不需要渲染的对象,这个剔除过程并不需要非常频繁的操作,而只需要及其低频的修正剔除结果就够了。
游戏场景虽然复杂,但却是基本固定的。所以,我们可以做相当多的离线标注。例如,在房间中,如果没有窗户,就不会关心房间之外的世界;反之,如果在户外,即使房子近在眼前,也不用关心室内的家具。
用距离和物件的大小两者就能剔除大量的物件,而不需要特别复杂的空间分割方案。物件越小,可见距离就越短。
剔除过程其实可以放在独立线程中,根据离线标注做一个非常粗略的筛选。筛选结果比设计可见性要宽泛一点。这样,即使筛选结果有一点的误差,也不影响正确性。我们在渲染系统中,再对这个粗筛可见集再做一次视锥体剔除。这个剔除过程完全可以做成异步请求,即根据玩家的位置等参数,请求一个粗略的可见集。
这个计算过程可以比较复杂,利用来离线标注的信息。因为它非常独立,所以这些复杂性不会增加系统整体的复杂度。而结果允许晚几帧甚至晚几秒才到达渲染线程。这样做的正确性是由“玩家在场景中移动速度相较场景规模非常慢” 这个预设前提来保证的。如果 gameplay 要求玩家在场景中做瞬间大范围移动,那么也可以简单的把异步请求改为同步请求。
同样,如果我们在场景中需要摆放很多的点光源来增强表现力的话,也不应该完全把光源剔除的工作完全做在渲染层。使用一个异步光源剔除方案且结合游戏场景特点定制(使用更多的离线标准信息)会更为廉价。