重开游戏应该把它重置,而不是让它变快。可 Steel Cyclone Studios 的气垫曲棍球游戏 Velocity Puck 却出现了相反的情况:玩家反映,重开之后帧率会突然飙到远高于正常水平,玩法感觉异常地快、也不稳定。原因不是电脑变快了,也不是设置被改了,而是第二个、第三个乃至更多的动画循环同时在跑——因为重开游戏时不断启动新循环,却没有可靠地停掉旧循环。
快速信息
来源:Velocity Puck 开发日志第 10 篇,讲重大帧率性能故障修复,2025年11月29日由 Steel Cyclone Studios 发表于 itch.io。本文关于该游戏的事实均出自这篇日志。
游戏:Velocity Puck,Three.js 做的快节奏物理驱动气垫曲棍球游戏,可与 AI 对手或第二名玩家线下单挑。
症状:重开之后帧率飙到远高于正常水平,玩法感觉异常地快、不稳定。按日志的说法,重开几次之后,游戏可能同时跑着 3 个、5 个乃至 10 个动画循环。
本文不声称的内容:不写修复前后的任何帧率数字,不写 Three.js 版本、浏览器、系统或测试硬件,也不声称日志里展示的代码就是发售版游戏代码的逐字复制。日志呈现的是开始、结束与重开流程的简化示意,本文也按简化示意来处理。
最后核实:2026年11月1日。
一个循环是心跳,十个循环就是踩踏
这个故障是玩家报出来的,日志也直说了:正是因为玩家报告了异常表现,问题才被发现。这一点很重要,因为这类故障不会用崩溃或报错来宣告自己。游戏照样能跑,只是跑得不对,而且表现得像变快了,而不是坏掉了。只重开一次的玩家可能什么都察觉不到;重开几次的玩家会得到一款亢奋过头的游戏,本能反应是怪帧率上限、画质设置或机器。可在这个案例里,这些都不是问题,问题是数量:太多循环在同时做同一件工作。这种不匹配也正是症状容易被误读的原因。变快感觉像是性能提升,所以玩家可能先怀疑是设置被改了或上限被调高了,而实际上游戏只是把同一件工作做了好几遍。数循环,而不是欣赏速度,才能把症状重新变回诊断。
要看清这是怎么发生的,得跟着日志描述的循环走。一个叫 animate() 的函数负责更新 3D 场景、移动物体、检测碰撞。游戏用 setInterval(animate, 1000 /
60) 每秒跑它 60 次,一个叫 startGame() 的函数负责创建这个定时器,一个叫 endGame() 的函数负责清除它。到这里都很规整,麻烦出在重开路径上:流程是一局结束走 endGame(),到游戏结束画面,再走 restartGame(),然后 startGame() 又被调用,于是产生一个新的动画循环。可日志说,有时候旧的定时器没有及时清除,甚至根本没有清除。于是每次重开都可能留下一个幸存者:两个 animate 循环同时跑,然后是三个、四个,每个都往浏览器上叠加更多的绘制调用,游戏就快得离谱。日志给出的范围很直白:重开几次之后,游戏可能同时跑着 3 个、5 个乃至 10 个循环。同一段代码里还藏着第二个陷阱:animate() 里有一行守卫 if (!gameStarted) return;,它只停住了逻辑,没有停住定时器本身。嘴上说停的标志,不等于真正停下的循环,而这个故障就是这种差别在真实游戏里的样子。
先停再开:三部分修复
日志给出了三项改进,其中第一项是承重的那一项:启动新循环之前,永远先清除旧的动画循环。一个新函数 stopGameLoop() 负责清除定时器并把句柄置空,而且每次启动之前都会先调用它。在日志展示的最终流程里,startGame() 先调用 stopGameLoop(),再调用 initializeScene(),然后调用 startGameLoop(),而 startGameLoop() 自己在创建定时器之前又会再调用一次 stopGameLoop()。顺序就是关键:启动循环不再被允许假定上一个循环已经没了,它必须先停掉现有的东西来证明这一点。用日志自己的话说,这才能保证任何时候只有一个活动循环。如果这个案例只让你记住一句话,那就记住这个顺序:每次都先停再开,毫无例外。
第二项改进清理的是场景本身。日志指出,Three.js 会把对象存在显存里,旧场景留在内存里会拖慢速度。所以在开始新一局之前,一个叫 cleanupScene() 的函数用 scene.traverse 遍历场景,释放几何体、材质与材质贴图,然后调用 renderer.dispose()。日志对这一步的总结是:没有任何东西会被带到下一局。第三项改进拆掉了那个误导人的守卫:if (!gameStarted) return; 这一行从 animate() 里拿掉,循环改用真正清除定时器的方式来停,而不是靠一个标志假装循环已经关了。报告的结果特意保持克制,本文也保持克制:重开之后帧率不再飙升,而且无论玩家回主菜单或开新局多少次,游戏表现都一致。日志把结果形容为顺滑、内存安全、可预测、帧率稳定,但它没有给出任何帧率数字,所以本文也不给。不要把来源从没说过的数字读进它的说法里。
能复用的部分
下面的步骤不是 Velocity Puck 开发者在已述三项改进之外做过的事,而是把这些改进推广到你自己项目的清单,全部只停留在你自己的项目里。能复用的思路是循环的所有权:谁启动了循环,谁就要负责停掉它,而重开路径正是所有权最容易断掉的地方,因为顺风顺水的首次启动被测得远比第五次运行的路径多。一款首次启动表现正常、第五次重开却出问题的游戏,是在告诉你它的启动与停止逻辑不一致。修复办法不是更快的循环或更严的帧率上限,而是一条单一且强制执行的规则:旧循环没有被证明已经消失之前,不许启动新循环;旧场景也不许活到新一局里。像真实玩家那样去测重开:反复测、不耐烦地测、从每一个能回到比赛的界面测。这正是顺风顺水的首次启动永远给不了你的测试,也是本可以在玩家上报之前就抓住这个故障的测试。
周一早上照着做
在你自己的项目里,连续重开游戏五次,留意速度有没有变化。只有重开几次后才出问题的游戏,指向的是循环生命周期,而不是画质设置。
列出你自己代码里每一个启动动画循环或定时器的地方。如果不止一条路径能启动它,这张清单就是你的嫌疑清单。
在你自己的项目里,把停止作为启动的第一步:在任何新定时器创建之前,先清除现有定时器并把句柄置空,而且要覆盖每条启动路径,不只是主路径。
在你自己的项目里,逐一核查每一个声称循环已关闭的标志。如果标志只停住了逻辑而定时器还在跑,就用真正的停止来取代标志检查,并重新测试重开路径。
在你自己的项目里,退出与重开时释放场景资源——几何体、材质与贴图——让旧场景无法带进下一局、悄悄吃掉性能。
在你自己的项目里,从每一个能回到比赛的界面测试重开——菜单、游戏结束画面、暂停画面都要测,而不只是测干净的首次启动。

