谢邀,就引用一篇之前我们家制作人大大的内容吧~
目前这个存档的游戏时间是118小时。
游戏玩到后期卡吗?这也许是大家最关心的问题之一。
数千艘运输机在忙碌,数千座设施在运作,数万个太阳帆在环绕,数十万货物在运送,斗转星移,地面上所有太阳能板都面朝着太阳… 这计算量可不是闹着玩的!而以上还只是这一个星球。
本篇我将主要介绍为了保证游戏的流畅性,我们是如何实现游戏性能优化的。由于该话题涉及的内容与技术细节实在太多,所以我打算将优化系列分为三篇开发日志,粗略的谈一下我们所用的方法与技术。
游戏的帧率机制
游戏中的帧率分为渲染帧和物理帧:
渲染帧主要负责渲染游戏画面;
物理帧主要负责运行游戏逻辑。
我们在游戏中制作了帧率计数器以监控实时性能,其中左边的数字是渲染帧,右边的数字是物理帧。也可以在设置面板中设定渲染帧的帧速率。当开启垂直同步时,渲染帧速率会和显示器刷新率一致。
帧速率越高,GPU的使用率也就越高,当使用率接近100%时,就会自动降低画面帧率。
在游戏的生产系统中,我们需要一套能精确量化,服从“决定论”的逻辑,即同样的操作只能导致同样的结果。要抛开帧率带来的影响,首先是游戏中所有的时间必须按物理帧计数,例如制造时间为3秒的“电弧熔炉”,其实在内部逻辑里就是180个物理帧。再例如360kW的工作功率,在内部逻辑里其实是每个物理帧消耗6kJ的能量,颇有一种“量子化”的感觉。
根据以上的逻辑,负责游戏逻辑的物理帧必须相对稳定,不能被渲染帧率所影响,否则玩起来就会感觉时间一会快,一会慢,所以我们将物理帧率锁定为60帧,只有当渲染帧率太低时,才会折减物理帧率,以免塞帧。
使用DOP来代替OOP
DOP和OOP分别指“面向数据编程”和“面向对象编程”。具体的比较可以写好长一篇文章,而且前人已充分讨论,所以这里就不赘述了。简单来讲,在面对游戏中大量物件的情况下,面向对象会造成许多不必要的开销,造成性能低下,而面向数据编程则将对象中的属性逐一拆分出来,形成紧密排列的数组,使得相关逻辑能够更快速的进行遍历,甚至一些数据还能直接传给GPU来处理。
那么为什么大家不都用DOP来代替OOP呢?这是因为“面向对象”更符合人们的认知和代码书写习惯,也便于项目管理,而“面向数据”的代码写起来有一些反人类,有时甚至相似的结构写好几遍,不方便管理,但实际运行效率却极高。所以为了游戏的优化,我们还是选择了“面向数据”来作为该项目的编程核心思想与框架。
读起来太晦涩了吗?那实在不行的话我们就这样吧!
不开玩笑了,回到正题
物理帧的大部分工作是由CPU来完成的,要维持60的帧率,一个物理帧允许的CPU处理时间不能超过16ms(毫秒),除开提交DrawCall渲染的时间和其他必须的开销,只有大约11ms可用于一帧的游戏核心逻辑。
单核游戏?多核游戏?都不是,这是一个GPU游戏!
目前CPU的性能发展遇到了瓶颈,已经没办法再大规模提升单核性能,只能靠提升核心数量。而在很多实际应用的情况下,对核心数量的提升,远不如增加一点点单核频率提升的性能多。
虽然我们在星系的随机生成及模拟、星球地形动态生成、无缝加载等逻辑中使用了多线程来缓解主线程的压力,但是对于游戏逻辑中如此庞大的计算量,就算有100个核火力全开多线程完美配合也未必驾驭得了!
从《戴森球计划》的想法诞生之初,就决定了这是一个GPU游戏。看那数万颗太阳帆,每一颗的运动都遵循着万有引力定律,每一颗都在计算发电量,每一颗都能被近距离观看。像大规模并行计算这样的工作,使用GPU是不二的选择。
我们将凡是可以并行计算的那部分计算工作,全部交给了GPU,剩下的那部分计算量,只要GPU能在16ms之内按时渲染出来,CPU就绝不会掉链子!
这同时也意味着,好的GPU的确能为《戴森球计划》带来更流畅的体验,而CPU的影响则相对较小。
使用GPU来渲染大批量动画
在现今主流游戏引擎中,动画大多是靠移动部件或骨骼的位置(Position)、旋转(Rotation)、缩放(Scale)来完成的,如下图:
就是这样一个建筑,它的动画部件已超过50个,其中还包括父子Transform层级的嵌套,假设我们有1000个这样的建筑,那CPU需要同时处理的动画子部件就有超过50000个,这显然是CPU驾驭不了的,而这还仅仅只是建筑动画,并不是核心逻辑。
用CPU去逐一计算这50000个部件的Transform的确是相当不划算的,这些动画其实可以并行计算,只要在画面帧结束时,所有建筑的动画均就绪即可,这对于具有强大并行计算能力的GPU来说简直就是小菜一碟。
我们在编辑器中将这个建筑的所有动画帧中所有的顶点位置、法线等信息按照一定的顺序,事先录制在一个VERTA文件中。
当游戏加载时,只需要载入这些文件,通过ComputeBuffer将所有信息传递给GPU,这样GPU就拿到了所有建筑动画的预烘焙信息,接下来再将每个建筑的动画状态组成一个数组,传给GPU。
最后,还需要在vertex shader中分析这些数据,逐一还原每个建筑在当前帧的建模。
按照这个思路,我们成功使用GPU Instancing还原了所有建筑的动画,还顺便使用了建筑状态数据来控制建筑贴图上指示灯的开关。在动画和建筑状态更新方面,CPU被完全解放。而在GPU中,仅仅只是多了150MB左右的显存数据用于存储所有建筑的动画数据(仅相当于几张4096贴图)与若干在vertex shader中的寻址与插值计算。可以说是一顿免费的午餐了!
粒子特效
为了将特效渲染纳入上面的框架中,所有的建筑特效均无法使用引擎自带的粒子特效,必须将原本是作为粒子来渲染的特效包含在模型网格中,再使用shader来逐一实现各个建筑不同的特效需求。
在游戏中,几乎每一个不同的建筑都定制了不同的shader,对于同一个建筑特效的不同片元,我们按照类别涂上不同的顶点色,哪些是辉光,哪些是要拉长的,哪些应该从下至上亮度递增,通过不同的片元顶点色,就能做不同的处理。
需要时刻朝向太阳方向的建筑,逻辑不能让CPU来处理。我们将建筑模型按照“底座”、“横向转动部分”、“俯仰转动部分”涂上不同的顶点色加以区分,再在shader里面计算这些顶点应该如何旋转,从而朝向目标点。
有了这套机制,我们就只需要在主线程中计算好每个建筑当前的状态,形成ComputeBuffer,传递给GPU来进行统一渲染,在这个过程中,动画、IK朝向、特效、自发光变化等等,就统统都有了。
Unity Profiler性能测试
说了这么多,直接上目前的性能测试图吧!
下面是100小时规模的存档在Unity Profiler里面的性能测试图
从图中可以看到,游戏主逻辑物理帧CPU耗时5.11ms,也就是说理论上每秒可以跑到195个物理帧,所以CPU这边肯定是没问题,而最终画面能跑多少帧,就看GPU的能力了,我的卡是660Ti,在畅玩了100小时后,还能跑上40帧,不过我们还将继续不懈的优化,争取将我这张卡的帧率提到60!
除了物理帧的优化以外,我们还非常看重C#的垃圾回收机制的优化,因为过多的内存垃圾会导致游戏时常卡顿,严重影响游戏体验。
一般来讲,每次触发垃圾回收机制都会出现不同程度的卡顿,卡顿出现的频率取决于GC Alloc的大小,而每次卡顿的时间取决于数据结构的复杂度。
为了尽可能消除游戏卡顿现象,从立项开始,程序在数据结构上就严格把控,能用数组的地方用数组,尽量少的使用Dictionary或List,凡是物理帧的逻辑除数组扩容等操作以外,均不能产生GC Alloc,在UI逻辑中严格控制字符串的操作,避免不必要的开销。
据测试,目前游戏的GC.Collect卡顿时间已控制在30ms以内,出现频率为几乎没有。
下图为100小时规模下的GC性能统计:
游戏主逻辑在物理帧上的GC Alloc为0,只有UI上有132字节的开销,加上一些引擎必要的开销,每帧总的GC Alloc为5.0KB,目前这个数值处于非常低的水平。
以后如果有时间的话,我们会介绍物流运输机、戴森云的优化,看看GPU是如何轻松达到“数十万”这个数量级!