项目医院(Project Hospital)是一款游戏,它管理所有类型的标准方面的医院建筑物:玩家创建的动态场景,UI系统部署的许多活动角色和对象。 为了使游戏在不同的设备上运行,我们需要付出很多努力,这就是臭名昭著的“千刀斩死”的一个很好的例子-许多小步骤解决了许多非常具体的问题,并花费了大量时间进行性能分析。
绩效水平:我们想要实现的目标
在开发的早期阶段,我们决定了主要参数:场景的最大大小,性能水平和系统要求。
我们将自己的任务设定为在一个屏幕上为至少一百个活动和完整动画角色提供支持,总共三百个活动角色,尺寸约为100x100的瓦片地图,以及建筑物中最多四层的支持。
我们坚信游戏即使在集成显卡上也应以1080p的帧率运行,而实现这一目标本身并不难:主要限制因素是CPU,尤其是随着医院数量的增加。 现代集成显卡仅在大约2560 x 1440的分辨率下才开始遇到问题。
为了简化对mod的支持,大多数数据都是开放的,也就是说,我们不得不牺牲打包文件所获得的性能,但这并没有特别强烈的影响,只是下载时间略长。
图形
Project Hospital是一款“经典”等距2D游戏,因此您可以了解所有内容都是从前绘制的-在Unity中,这是通过沿各个图形对象的Z轴(或距相机的距离)设置适当的值来完成的。 如果可能,将彼此不交互的对象分层放置,例如,地板独立于对象和角色。
等轴测渲染场景中的所有几何图形都是在C#中动态创建的,因此图形性能的两个最重要方面之一是几何图形重建的频率。 第二方面是绘制调用的数量。
抽签
一帧中绘制的单个对象的数量(无论其简单性)是主要限制,尤其是在设备较差的情况下(此外,Unity引擎本身会增加过多的资源消耗)。 显而易见的解决方案是将尽可能多的图形对象分组(批处理)到一个绘制调用中。 因此,您可以获得一些非常有趣的结果,例如,将与摄像头距离相同的对象分组,以便其余图形可以正确地呈现在它们的后面或前面。
以下是一些数字:理论上,在96 x 96的图块上,您可以放置9216个对象,这将需要9216个绘制调用。 批处理后,此数字降至192。
但是,在现实生活中,一切都有些复杂,因为您只能将具有相同纹理的对象组合在一起,这就是为什么结果的最佳化稍差一些,但是系统仍然运行良好的原因。
为了控制结果,大多数批次都是手动完成的。 此外,作为最后的手段,我们还使用Unity动态批处理,但这是一把双刃剑-它实际上有助于减少绘图调用的次数,但会导致每帧不必要的资源,并且在某些情况下可能是不可预测的。 例如,可以以不同的顺序渲染在不同帧中距相机相同距离的两个叠加的精灵,这将导致闪烁,而在手动批处理时不会出现。
多层
玩家可以建造多层建筑,这增加了复杂性,但是令人惊讶的是,它有助于提高性能。 只需要对活动楼层和街道上的角色进行渲染和动画处理,而医院其他楼层的所有内容都可以隐藏。
着色器
Project Hospital使用相对简单的自写着色器,并带有一些小技巧,例如颜色交换。 假设字符着色器最多可以替换五种颜色(取决于着色器代码中的条件),因此非常昂贵,但这似乎不会引起问题,因为字符很少占用大量屏幕空间。 着色器证明了投入其中的精力是合理的,因为使用无限数量的服装颜色的能力可以大大增加角色和环境的可变性。
此外,我们很快学会了避免指定着色器参数,并尽可能使用顶点颜色。
质地品质
一个有趣的事实-在Project Hospital中,我们不使用任何纹理压缩:图形以矢量样式完成,在某些纹理上,压缩看起来非常糟糕。
为了在小于1 GB的系统上节省CPU内存,我们会自动将游戏中纹理的大小减小到一半分辨率(用户界面纹理除外)-这可以通过在选项中看到“纹理质量:低”参数来理解。 UI纹理保留其原始分辨率。
优化CPU性能-多线程
尽管Unity脚本逻辑本质上是单线程的,但我们始终能够直接在C#中运行多个线程。 也许这种方法不适用于游戏逻辑,但通常可以通过组织任务系统在单独的线程中执行对时间要求严格的任务。 在我们的例子中,线程用于两个功能:
1.查找路径的任务(尤其是在布置混乱的大型地图上)可能要花费数百毫秒,因此这是从主流传输的主要候选对象。 并行任务考虑了计算机的硬件线程数。
2.照明卡也可以在一个单独的流中进行更新,但一次只能更新一个楼层-这不是关键系统,并且房间中的自动灯熄灭的速度足以使罕见的更新就足够了。
动画制作
几乎在开发之初,我们就决定使用二维骨骼动画系统。 在研究了各种现代动画程序之后,我们最终决定修改我几年前创建的简单系统(本质上是一个业余项目),以适应Project Hospital的需求-它类似于简化的Spine,直接支持创建角色变化。 像Spine一样,它使用C#运行时,这显然比本机代码昂贵,因此在开发过程中,我们进行了几个优化周期。 幸运的是,我们的装备非常简单,每个角色只有20根骨头。
奇怪的事实:优化单个骨骼转换的最有用的改进竟然是从地图搜索到简单数组索引的过渡。
除了不在摄像机外部对角色进行动画处理之外,还有另一个技巧:隐藏在主UI窗口后面的角色也不需要进行动画处理。 不幸的是,在游戏的最终版本中,我们切换到了半透明的UI,因此我们无法使用它。
快取
如果可能的话,我们仅尝试进行影响最大的计算,而更改会影响它们的值。 最好的例子是房间和电梯:当玩家放置电梯或建造墙壁时,我们运行填充算法,标记可以从中获得电梯和房间的瓷砖。 这样可以加快后续路径搜索的速度,并可以用来向播放器显示尚不可用的房间。
分散更新和延迟更新
在某些情况下,仅部分执行某些更新是合乎逻辑的。 以下是一些示例:
只能在部分字符的每个帧中执行某些更新,例如,一半患者的行为脚本仅在奇数帧中更新,而后半部分-在偶数帧中更新(尽管动画和动作流畅执行)。
在某些情况下,尤其是当字符处于待机模式但调用了代码的昂贵部分(例如,员工检查需要填充的内容并寻找空置的设备)时,操作仅按特定的间隔执行,例如每秒执行一次。
最昂贵且同时也是常见挑战之一是检查可为每个患者使用的测试。 同时,需要评估许多因素-例如,该部门的哪些人员目前正在忙碌,以及保留了哪些设备。 另外,该信息并非对所有患者都通用,因为它会受到例如分配给他们的医生及其说话能力的影响。 必须检查数十种可用的分析类型,因此,在一帧中仅对某些帧执行更新,并在下一帧中继续进行。
结论
事实证明,优化具有许多相互作用部分的游戏管理器是一个漫长的过程。 我经常不得不使用Unity分析器并解决最明显的问题,这已成为开发过程中不可或缺的一部分。
当然,总会有改进的空间,但是我们对结果感到非常满意。 游戏可以应付我们的任务,并且玩家不断为其创建mod,大大超出了角色数量的原始限制。
还值得一提的是,即使与我从事过的某些AAA游戏相比,在项目医院中我也遇到了实践中最复杂的游戏逻辑,所以很多问题都是针对该项目的。 尽管如此,我仍然建议根据游戏的复杂性在任何项目中留出足够的时间进行优化。