戴森球与行星级生产基地
目前这个存档的游戏时间是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中分析这些数据,逐一还原每个建筑在当前帧的建模。
在vertex shader中还原当前帧建模部分代码
按照这个思路,我们成功使用GPU Instancing还原了所有建筑的动画,还顺便使用了建筑状态数据来控制建筑贴图上指示灯的开关。在动画和建筑状态更新方面,CPU被完全解放。而在GPU中,仅仅只是多了150MB左右的显存数据用于存储所有建筑的动画数据(仅相当于几张4096贴图)与若干在vertex shader中的寻址与插值计算。可以说是一顿免费的午餐了!
粒子特效
为了将特效渲染纳入上面的框架中,所有的建筑特效均无法使用引擎自带的粒子特效,必须将原本是作为粒子来渲染的特效包含在模型网格中,再使用shader来逐一实现各个建筑不同的特效需求。
特效片元在制作时就包含在模型中
在游戏中,几乎每一个不同的建筑都定制了不同的shader,对于同一个建筑特效的不同片元,我们按照类别涂上不同的顶点色,哪些是辉光,哪些是要拉长的,哪些应该从下至上亮度递增,通过不同的片元顶点色,就能做不同的处理。
整个建筑的动作与特效在一个shader中实现
需要时刻朝向太阳方向的建筑,逻辑不能让CPU来处理。我们将建筑模型按照“底座”、“横向转动部分”、“俯仰转动部分”涂上不同的顶点色加以区分,再在shader里面计算这些顶点应该如何旋转,从而朝向目标点。
朝向太阳的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是如何轻松达到“数十万”这个数量级!
满满干货,期待一下篇~!
卧槽太牛逼了!
最近由 Wao 修改于:2021-01-30 04:14:08面向数据编程的部分真是醍醐灌顶!城市天际线这类存在大量市民和车辆对象的游戏若能采用这种编程思想定能大幅提升帧率表现!
太厉害了,佩服
预计下期内容
1. 怒操memory layout,提升cache hit rate
1. shared memory / texture memory
1. 强改data顺序,降低branching
1. Nvidia nsight截图
1. 强行用18bit浮点数
1. 人工stream scheduling
牛!神优化还得需要底子硬!
面向数据编程真的有点叼哈哈!!学到了
太牛批了,大佬考不考虑在b站出个视频,应该很多人看的
最近由 dx3906b 修改于:2021-02-03 09:12:26作为一个喜欢mc,factorio, spaceEngineer,satisfactory过来的程序,看到戴森球这样的项目真的狂喜。然后还有技术分享,真的双喜临门,希望多多分享技术细节。赶紧更新博客, 游戏也一样。
厉害,学到了很多,期待下一篇技术分享
感觉思路和Unity的DOTS一样。实现方式文章里没有写,但是之前ECS也有很多hybird的解决方案,所以你们是不是相当于用自己的方式去实现ECS?
没看明白,看来作者不是太想分享技术细节
太强了,这性能优化,简直逆天了。一边看一边直呼 new bee
强者如斯!