原文地址:Forager: Optimization In GameMaker | Blog | YoYo Games
关于作者: 一年多以前,Gabe “lazyeye” Weiner 发现了开发游戏的乐趣,这种乐趣迅速转化成激情并促使他从大学辍学开始全职进行游戏开发。之前他发布了一系列游戏工具和一些 Game Jam 作品, Forager(浮岛物语) 是他第一个商业作品。你可以关注他的 Twitter来跟进他的日常工作情况,也可以在 Steam 上直接购买 Forager(浮岛物语) 。
Forager 是如何管理成千上百个实例的
有些时候,你很幸运有机会在项目启动时就参与其中,这样可以对你的代码库有更全面的了解和掌控。但另一种情况是,你需要接手一个比较复杂的,将近 50000 行代码的项目然后被告知“搞定这个问题”。嗨,我是 lazyeye ,Forager的程序负责人。
在我成为这个游戏的程序负责人之前,我就定下了一项任务:优化。Forager 是一个规模庞大的游戏,玩家可以在巨大的地图上收集资源并制作各种装置,这意味着游戏中很容易就会出现 5000 个实例同时运作的状况,甚至更多。这种特殊的游戏类型会遇到一个常见的优化难题——玩家具备创建几乎无限实例的能力,而我们还要保证游戏可以在所有的平台上顺畅运行。
实例数量过多是 GameMaker 制作的游戏中常见的一个问题;几乎在所有我参与过的项目中,过多的实例数量都是给性能拖后腿的一环。通常人们会陷入使用对象而不是去学习更有效率的工具的陷阱,比如:粒子系统(particle systems),数据结构(data structures),素材图层(asset layers)等等。了解如何使用这些方法应对不同场合其实非常重要,因为这样可以提升性能并让一些复杂的工作变得更加简单。比如某些物体不会移动,也不需要根据深度进行排序,也没有任何碰撞事件,那就不该用对象来实现它。
但是,Forager 很不一样,它与我见过的大多数项目不同,游戏中几乎每一个物体都应当以实例来实现,并且尝试不用实例来完成类似的效果简直是一场噩梦。这让人非常沮丧,这让我意识到我必须卷起袖子然后跳出这个框架重新思考一下。
我说的大量实例……是真的超级多!
绘制优化
因此,我们坚持使用了实例。但是仍然有许多方法可以改进每一帧运行的代码性能。性能下降通常源于对绘制方法的工作原理不够了解。不幸的是,这个问题对我而言非常超纲(我并不是专家),但一些基本的理解还是有助于避免一些常见的问题。
你之前可能听说过“指令集中断(batch breaks)”,一个指令集(batch)指 GameMaker 发送给 GPU 的一组用于绘制画面的指令。GPU 在绘制成组内容时非常高效,但当发送到 GPU 的指令集过多时,在指令集直接切换的时间就越多,从而影响了性能。每当更新 GameMaker 的绘制配置时都有可能发生指令集中断,比如在修改颜色/Alpha,字体,混合模式等时,以下是一些常见的“指令集断路器”
- Primitives (
draw_rectangle, draw_circle, draw_primitive_begin, etc.
) - Surfaces (
surface_set_target, surface_reset_target
) - Shaders (
shader_set_target, shader_reset_target
) - Draw settings (
draw_set_font, draw_set_colour, etc.
) - GPU settings (
gpu_set_blendmode, gpu_set_alphaenable, etc.
)
这种指令集中断是难免会遇到的,不同平台的处理方式也有差异。而关键是要仔细构建代码尽量减少这些中断的发生。通常情况下,我们可以通过将类似的指令集组合到一起来进行优化。比如,与其让大量实例各自采用以下绘制事件(Draw Event):
// Outline
shader_set(shd_outline);
draw_self();
shader_reset();
你可以尝试在一个控制器对象中统一完成此类操作
// Instance outlines
shader_set(shd_outline);
with (par_outlined) {
draw_self();
}
shader_reset_target();
深度顺序的差异时常会影响这个操作,有时候会使得画面看起来异常。你可以使用图层脚本来进行辅助,更精确地控制图层绘制的先后顺序从而进行优化。
步(Step)事件优化
优化步事件需要不断扪心自问:“这个操作需要每一帧都执行吗?”通常情况下,我会反复思考这个问题几次,刚开始答案总会是“没错,这个操作必须每帧都执行”,但可能到了第八次时就会变成“哦,那个法子可能行得通。”
实例需要每一帧都从全局的数据结构里获取信息吗?也许你在创建(Create)事件里执行一次就够了。你会在步事件里不断刷新玩家的背包吗?也许只需要当发生添加或删除操作时更新一下就行了。没有以上状况需要处理?也许根本没人在意这些代码是不是隔几帧执行一次。
还有一个小技巧是利用 GML 的短路(short-circuiting )特性。这是 GML 里当获得一个 FALSE 后决定是否停止继续执行后续代码的机制。 比如以下这个例子。
if (1 + 1 == 3) && (instance_place(x, y, obj_enemy)) {
// stuff
}
由于 1+1 不可能等于 3,因此 GameMaker 根本不会去判断 instance_place
的调用。因为判断条件里使用了&&
因此两个条件必须都为 TRUE ,因此当第一个条件为 FALSE 时则整个条件不可能返回 TRUE。因此你可以在制定判断条件时控制好顺序,如果你确定某个条件比其它的都重要,那务必把它放在最前面!另外要注意哪些条件最容易返回 FALSE ——当你前五个条件几乎总是 TRUE ,后面跟一个经常出现 FALSE 的条件时,那前面那些判断就都是在浪费时间。
更进一步:动态加载实例
这些优化机制都很棒,但是对于浮岛物语而言,我们真正需要的是一个“宏观”解决方案。作为一个优化者,你的工作是骗过玩家,让他们以为某件事情正在发生即可,而实际上可以把很多东西藏到幕后去处理。这种技巧是优化的基础:再聪明的玩家也想象不到游戏里的处理机制,而只能基于自己看见的内容进行理解。
虽然我们已经确保了所有的实例都是必须的,但这不意味着每一个实例都是那么关键。以“objTree”为例,当玩家和它们发生交互时,树木需要处理深度排序,具备碰撞效果,还有各种视觉效果。但是,如果玩家必须靠近树木才能进行交互,那我们在 95%的状况下可能都不用去实例化这棵树。如果一棵树在森林里消失了,但玩家并没有站在边上看到这一切,他们会在意这些吗?
这需要让我们的动态加载系统来进行处理了。如果玩家看不见某个实例,我们就可以将其停用。如果玩家移动到可以看到该实例的位置,我们可以在它出现在视野里之前就迅速激活它。下面这个 GIF 图再现了这个过程——白色矩形表示了我们的视野区域,在该范围以外的实例会被临时禁用进行动态加载处理。
注意边界位置
一些实际代码
下面这个CullObject
的脚本代码,就是用来在步事件中检测活动的实例是否需要被暂时禁用
所有的截图代码的高亮风格都基于TonyStr的 Dracula 主题
我们把需要动态加载的对象作为参数传入,来检测该实例的图像是否在视野之外。如果在视野外,我们创建一个数组来保存这个实例的 ID 和边界框(Boundary box ),然后把这个数组塞进记录停用实例的列表里。要注意这里的边界框是基于精灵图像缩放绘制的尺寸,而不是基于实例的碰撞盒。我们添加了少许的缩放,因为浮岛物语中偶尔会基于某些变量绘制稍小尺寸的图像。
下一个脚本——ProcessCulls
用于把停用的实例“恢复”
注意:浮岛物语中实际使用的这些脚本中会有更多内容,但是为了方便演示,我把这些脚本都脱水只保留了核心代码
这个脚本里我们只是处理停用实例列表,检测相机视野是否移动到了能展示出某个实例的位置,如果是就立刻激活该实例并将相关数据从列表中删除即可。
等一下,我搞砸了
当我把以上修改代码推送到代码库后不久,我想到“嗯,我需要知道游戏里有没有用到instance_exists
,instance_number
和其它实例函数,这些代码可能会因为实例被停用而出纰漏”
我立刻搜索了instance_find
,instance_exists
和 instance_number
这些关键词,然后看到了 500 多行结果。
糟糕…
这个状况非常棘手——这些函数将无法返回正确的结果,因为这些函数只能作用于激活状态的实例。如果游戏中一直在使用这些实例函数处理相关逻辑……那在动态加载中被禁用的实例就会出大问题。
但我没有放弃,我决定在动态加载系统中再增加一层判断。我需要一个方法来检测实例是否存在,无论这个实例是否处于激活状态。我还需要获得所有这些实例的准确数量并能快速检索它们。处理instance_exists
函数将是一个挑战,这个函数我们可以传入三种类型的参数—— ID,对象名称或者父对象名称
真·实例 函数
第一步是在创建一个可以动态加载的实例时把它添加到一个全局的数据结构里:
我们把这个实例的 ID 添加到我们的实例缓存列表中。接下来,我们遍历所有可能的父对象 ID 数组,检查该实例是否是某个父对象的子对象。如果是,我们把它的 ID 也添加到这个父对象的实例列表中。这是因为 GameMaker 中,当我们把一个父对象属性的实例作为参数传入某个实例函数时,这会影响到这个父对象所有的子对象,因此我们的脚本中同样需要实现这一点。
接下来,我们需要在销毁实例时从缓存中释放掉相关资源,因此在清理事件(Clean Up)中需要以下内容:
这跟之前的过程刚好相反
现在我们的实例已经创建好了,我们需要准备好我们用来替换的函数。
你可能已经注意到了,在所有这些函数中,同时兼容了不属于我们用自定义的“真·实例”系统所处理的实例函数。请记住,在程序中有 500 多处相关代码,因此我试图尽可能节省时间。可以快速进行查找和替换这一点至关重要。
现在我们可以看到这个激活变量的用途——这样一来我们就可以在结果返回之前就确认这个实例是否处于激活状态,如果不是,那就可以将其激活并在临时激活列表中加以记录。但是我们并不会将其真正激活,这样我们就可以很好的区分出真正被激活的实例和那些临时激活的实例。
在返回结果之前临时激活实例是很有必要的,因为这样就可以在实例被恢复之前就通过代码来修改其中的一些值。在 GameMaker 中被停用的实例只能从中读取值而不能直接写入修改。而在一个理想的系统之中,这种激活状态应该是可选的,但因为我需要保持参数格式不变以兼容系统原有的实例函数,因此我把激活状态设为必要的前置条件了。
终于,我们在TrueInstanceExists
可以通过检测传入参数是否大于 100000 来确定这是不是一个实例或对象 ID ,而TrueInstanceFind
可以用来确保在返回某个实例之前先将它激活。
最后,我们必须停用掉我们临时激活的实例,这里会有一个小问题—— GameMaker 在每一个步事件(step)和绘制事件(draw)之间会重建事件队列。这意味着我们必须确保我们的控制器对象在每个事件里都运行以下脚本,否则我们可能会遇到某个实例在不恰当的时机试图触发它的某个事件。
再快一点
回想一下我最开始说的,我们必须反复质疑我们步事件中的代码是否需要每一帧都运行?这同样适用于此——宏(macro)。SYSTEM_CHECK_INTERVAL
,它控制着我们的系统级脚本(比如动态加载机制)的运行频率。其中的脚本会根据特定系数来进行调控,比如,在控制植物生长的系统脚本中,增长值将根据我们的间隔系数进行增长。如果系统每 20 帧刷新一次,则增长值将以 20 为单位进行增长 。我们把需要控制的脚本放进一个 switch 循环,这可以把这些操作平均分配到 20 帧里去。
结语
游戏优化是个极其庞大的话题,我们在这里只是讨论了其中的一部分内容。实例数量是影响大部分项目的一个方面,在 GameMaker 引擎里,正确理解纹理渲染的机制和其它一些游戏开发过程中的问题,对于优化游戏至关重要。
但是,类似“不要太早优化你的游戏”这种建议仍然非常有道理。在你明确自己的游戏有性能上的问题之前,没必要过分担心性能问题,并因此妨碍了开发进度。事实上,GameMaker 对于用它制作的那些游戏而已非常合用——大部分开发人员永远都不需要担心这些问题。
也就是说,如果你最后做了一个体量巨大的游戏而面临重大的性能问题,我支持你充分调动智慧和好奇心,进行各种试验和研究。
或者,你懂的,来找我吧 ;)
在哪里?
有群体,论坛,QQ群? 静待
@陈康:去gm的reddit和discord频道都能找到他,直接发邮件也行