Forager 的 5000 个实例差点拖垮性能,剔除系统是怎么救回来的

Forager 同屏能跑 5000 个实例,主程 Gabe lazyeye Weiner 接手时的任务就是让它跑得动。每个实例都有存在的理由,所以解法不是少用对象:能省则省、便宜的判断先算、工作摊到多帧,再加一套剔除系统——把玩家看不到的实例停掉,快进入视野前再唤醒;以及第二套系统,修好剔除弄坏的 500 多处代码调用。

Forager 的 5000 个实例差点拖垮性能,剔除系统是怎么救回来的
本篇导航6 个章节

Forager 同屏能跑 5000 个实例。这就是 Gabe lazyeye Weiner 被请来解决的问题——而显而易见的解法行不通的原因,正是这个故事值得偷师的地方。

Forager 主视觉图。Steam 商店官方图。

快速信息

  • Forager,HopFrog 开发的制作类游戏,用 GameMaker 开发。

  • 问题:同屏最多 5000 个实例,甚至更多,地图巨大且由玩家自由建造。

  • 解决者:Gabe lazyeye Weiner,最初只被请来做优化,后来成为游戏主程。

  • 核心解法:实例剔除——玩家看不到的实例停掉,快进入视野前再唤醒。

  • 代价:500 多处 instance_exists、instance_number、instance_find 调用只能看到激活的实例,剔除把它们弄坏了。

  • 来源:lazyeye 在 GameMaker 官方博客的文章。

  • 最后核实:2026年10月26日。

接手 5 万行代码,任务只有一个:让它跑得动

有时候你从项目一开始就在,代码什么样自己说了算。另一些时候,你接手的是一个 5 万行、有点麻烦的项目,任务只有一句:麻烦修一下。这就是 lazyeye 接手 Forager 的开局:他最初只被请来做优化,面对的是一款大型制作类游戏——玩家在巨大的地图上收集资源、建造建筑。同屏轻松就能跑到 5000 个实例,甚至更多,而这个类型还会雪上加霜:玩家能创造看似无限的实例,游戏却要在所有平台上都跑得顺。

在 lazyeye 做过的几乎每个项目里,过多的实例数都是最拖性能的元凶,而常见的坑就是该用别的工具时却用了对象:粒子系统、数据结构、资源层都行。他的经验法则很简单——一个东西如果不动、不参与深度排序、不参与碰撞,那它大概就不该是个对象。但 Forager 不一样:几乎每个实例都有存在的理由,不用实例去模拟它们简直是噩梦。所以还没开始动手,少用对象这条路就已经被堵死了。

Forager 的岛屿世界,玩家可以自由建造。Steam 商店官方图。

三笔开销,和一个大障眼法

第一笔是绘制开销。批次是 GameMaker 发给 GPU 的一组指令,指令成组发出时 GPU 跑得最快;但代码每改一次绘制设置——颜色、透明度、字体、混合模式——或者碰一下表面、着色器、图元,批次就断了,GPU 就得切换指令集。解法是归组:别让每个带描边的实例在自己的绘制事件里各设各的着色器,改成在控制器的绘制事件里一次性画完所有描边。深度排序有时会挡住这招,但层脚本可以在一层绘制前后精确执行代码,帮上忙。

第二笔是 Step 事件的开销,要问的问题永远只有一个:这段代码真有必要每帧都跑吗?每帧都从全局数据结构里取信息?改成在创建事件里取一次。每帧都遍历整个玩家背包?改成只有增减物品时才遍历。都不沾?那改成隔帧跑可能根本没人察觉。再加上短路求值:GML 读到第一个假值就不再往后读条件,所以把轻量的判断放前面、把最可能为假的判断放前面——五个几乎恒真的判断后面跟一个几乎恒假的判断,纯属浪费。

第三笔是最大的一笔,而答案是个障眼法。拿一棵树来说:它参与深度排序、有碰撞、有视觉效果、玩家靠近时还能互动。但玩家只有靠近时才能跟它互动,所以 95% 的时间里它根本不需要运算。森林里一棵树消失了,玩家又不在场,他们会发现吗?剔除系统的答案是不会:玩家看不到的实例,直接停掉;玩家走到它快进入视野的位置时,赶在它出现之前唤醒。判定用的包围盒按实际绘制的精灵及其缩放来算,不是碰撞盒,还要留一点余量,因为 Forager 有时会对绘制做轻微缩放。

修复弄坏了一切,于是又有了第二个修复

剔除代码提交后没多久,lazyeye 想到一件事:游戏里大量用到了 instance_exists、instance_number 这类实例函数。一搜,500 多处。这些函数只能查到激活的实例,剔除马上就要变成大问题——还有第二个麻烦:停掉的实例可以读、不能写,而拿到实例的代码往往就是要往里写值。

他没有放弃,而是又加了一层。每个可剔除的实例在创建时都会登记进一个全局缓存数据结构:ID 进自己对象的列表,如果它继承自某个父对象,也进父对象的列表——因为 GameMaker 的实例函数用父对象做参数时,会包含该父对象的所有子对象。实例销毁时,清理事件再把它从缓存里删掉。替代函数——TrueInstanceExists、TrueInstanceFind 等等——查缓存,返回实例前先临时激活它,但不改它真正的激活标志,这样系统能分清临时激活和真正激活。控制器脚本再在 Step 和 Draw 两个事件里把临时激活的实例重新停掉,两个事件都要跑,因为 GameMaker 会在 Step 和 Draw 之间重建事件队列。

把活摊开,不然修复本身会变成瓶颈

同一个每帧问题也适用于这套系统本身。一个叫 SYSTEM_CHECK_INTERVAL 的宏控制系统脚本多久跑一次:如果检查每 20 帧跑一次,植物生长这类数值就乘以 20,再由一个 Switch 事件把工作均匀摊到这 20 帧里。lazyeye 最后的建议也值得留着:不要过早优化。GameMaker 对它所服务的项目已经够友好了,大多数开发者永远用不上这些——但如果你真的做出了有性能问题的大游戏,那就多动脑子、多好奇、多实验、多查资料。

周一早上照着做

  • 先数数:重写任何东西之前,先确认实例数真的是最拖性能的元凶。

  • 换掉没理由的对象:不动、不参与深度排序、不参与碰撞的东西,改用粒子系统、数据结构或资源层。

  • 判断先排便宜的:轻量判断和最可能为假的判断放前面,让短路求值跳过重活。

  • 每个 Step 事件都问一句每帧吗:能放创建事件里跑一次的跑一次,能只在变化时跑的只在变化时跑,没人察觉的改成隔帧跑。

  • 剔除之前先搜 instance_exists、instance_number、instance_find:它们只能看到激活的实例,停掉的实例不能写——先把缓存和替代函数建好。

  • 给看不见的实例做剔除并留余量,快进入视野前再唤醒——判定用绘制精灵的盒子,不是碰撞盒。