第 38 章 主循环架构与固定步长:游戏为什么”不掉逻辑”
你前面学过第 17 章的组件,也知道每个脚本里的 Update 每一帧会被引擎叫一次。但你有没有认真想过一个更底层的问题:
引擎到底是”怎么”一遍又一遍地叫你的 Update 的?它背后那个”一遍又一遍”的东西,长什么样?
很多人写游戏,Update 用了几百次,却从没见过游戏真正的”心脏”。这就像你天天用 HTTP 接口,却从没看过你那台 Go 服务器里 for { conn := listener.Accept(); go handle(conn) } 那行主循环——接口能跑,但你不知道服务器到底”为什么”一直在跑。
这一章,我就要把游戏那颗”心脏”挖出来给你看。看完你会发现:第 17 章的 Update、FixedUpdate,根本不是什么魔法,它们只是被挂在一条死循环上的”钩子”而已。而这条死循环怎么写,直接决定了你的游戏在不同机器上”跑得快还是慢""物理崩不崩""联机能不能回放”。
我们还是走老路子:现象 → 现实问题 → 工程师方案 → 落进引擎成了什么。
38.1 游戏本质是个死循环:while(running){ 输入; 更新; 渲染; }
先给你一个最朴素、也最震撼的事实:
游戏程序从头到尾,就跑在一个死循环里。
你把它写出来,其实就是这么三行骨架:
while (running) {
采集输入(); // 键盘鼠标手柄,玩家按了什么
更新世界(); // 怪往前走、塔开火、玩家扣血
渲染画面(); // 把这一帧画到屏幕上
}
“running” 是个布尔开关,你点右上角那个叉,running 变 false,循环退出,游戏结束。
每一趟循环,我们叫它一帧(frame)。现在的游戏大多要跑到每秒 60 趟,也就是 60 帧(FPS=60)。你在第 1 章就见过那个”16 毫秒”的约束——1000 毫秒 ÷ 60 ≈ 16.67 毫秒,意思就是:上面这三步,必须在 16 毫秒里全干完,才能稳在 60 帧。干不完,就掉帧。
现在回头看第 17 章。你当时学的 Update,是不是”每一帧被引擎调用一次”?现在谜底揭开了:Update 不是凭空被叫的,它就是上面的”更新世界()“那一步里,引擎替你展开的一堆钩子。 引擎在自己那个死循环里,跑到”更新世界”这一步时,会把场景里所有激活物体的 Update 依次调一遍。你写的每一行 transform.position += ... 都是在”更新世界”这趟循环里执行的。
反问你一下:既然 Update 是”每一帧一次”,那它跟”帧率”是绑死的——帧率高就调得勤,帧率低就调得稀。那你有没有想过,这个”绑死”会埋下什么雷?我们下一节就爆给你看。
把”游戏=死循环”这个心智模型焊死,后面一切都好懂了。你写 Go 时,一个网络服务器也是 for { 收请求; 处理; 回响应 } 的循环;游戏不过是把”收请求”换成了”采输入”,把”处理”换成了”更新世界”,把”回响应”换成了”渲染画面”。本质都是循环,只是游戏的循环快得离谱(每秒 60 次),而且每一趟都得”画”点东西出来。
38.2 变帧率之痛:同一份代码,在不同机器上跑出不同世界
上一节我说 Update 跟帧率绑死。这听着没啥,但你琢磨一个具体场景:
假设敌人每一帧往前移动 1 米。高配电脑跑 120 帧,低配手机跑 30 帧。一秒钟后,敌人分别走了多远?
算给你看:
- 高配:120 帧 × 1 米 = 移动了 120 米。
- 低配:30 帧 × 1 米 = 移动了 30 米。
同一秒、同一个逻辑、同一份代码,敌人却走到了两个不同的位置。 高配机器上怪一秒冲出 120 米,低配上慢吞吞挪 30 米。这还只是移动,你想想碰撞、受力、计时……全乱套了。
这就是变帧率(variable framerate)最致命的问题:帧率决定了”时间流速”。 你以为你在写”每秒移动多少”,其实你写的是”每帧移动多少”——而每帧的时间长度在不同机器上根本不一样。
更阴险的是:就算同一台机器,帧率也在波动。你屏幕上一团粒子特效炸开,那一帧算了 40 毫秒,下一帧空荡荡只算 5 毫秒。于是敌人一会儿”瞬移”一大步,一会儿”龟爬”一小步,肉眼都能看出一顿一顿的。这就是常说的”卡顿感”的一个来源。
生活化类比:好比餐厅厨房里,你规定”每上一道菜,厨师就切一片肉”。结果高峰期出菜快(一秒出 5 道),闲时出菜慢(一秒出 1 道)。一天下来,两个时段厨师切的肉量天差地别——你的”按出菜次数切肉”这个规则,本质是拿”频率”当了”时间”。正确的做法应该是”每分钟切多少”,而不是”每道菜切多少”。
所以问题出在哪?出在”以帧为单位”来推进逻辑。帧是渲染的概念,它的快慢由机器决定,不该成为衡量时间的尺子。
工程师的解法很直接:别用”每帧”当尺子,改用”每过固定一段时间(比如 0.016 秒)“当尺子。 这就是”固定步长(fixed timestep)“思想的由来。把”每帧 1 米”改成”每 0.016 秒 1 米”,不管机器一秒跑 30 帧还是 120 帧,时间这把尺子都不变,世界就不会因为机器快慢而分裂。
反问:如果改成”每 0.016 秒移动 1 米”,那 120 帧的机器一秒里这个”0.016 秒”会触发几次?30 帧的机器又会触发几次?答案是一样的——都是约 62.5 次(1 秒 ÷ 0.016)。你看,尺子一旦固定,快慢机器就统一了。但怎么在代码里实现”攒够 0.016 秒才走一步”?下一节给你累加器。
38.3 固定时间步 + 累加器模式:攒够时间才走一步
现在目标很明确:我希望”更新世界”这件事,不是”每帧做一点”,而是”每过固定的一小段时间 dt,就规规矩矩地做一次完整更新”。dt 通常取 1/60 秒(约 0.0166 秒),这样逻辑每秒恰好推进 60 次,和理想的 60 帧对齐。
但渲染的帧率仍然是飘的(30、60、120 都可能)。怎么让”飘的渲染帧”去驱动”稳的逻辑步”?答案是一个叫累加器(accumulator) 的小技巧。
思路是这样:
- 每一趟渲染循环,先量一下”距离上一帧过去了多少真实时间”,记为 elapsed。
- 把这个 elapsed 加进累加器:acc += elapsed。
- 只要累加器攒够了至少一个 dt,就跑一次”固定步长的逻辑更新”,并扣掉一个 dt:
while (acc >= dt) {
update(dt); // 逻辑更新,永远按固定 dt 走
acc -= dt;
}
// 循环结束,acc 里剩下的零头(不足 dt 的部分)留到下一帧
你看,关键点来了:
- 逻辑更新 update(dt) 永远带着同一个 dt 被调用。不管你机器快慢,敌人每次走的距离都一样(dt 时间内走的距离),物理积分不会漂移。
- 累加器里没花完的零头会留到下一帧。比如这一帧 elapsed 是 0.02 秒(假设卡了一下),dt 是 0.0166,那 acc 攒够后跑一次 update,剩下 0.0034 秒留着,跟下一帧的 elapsed 接着攒。这样”真实流逝的时间”和”逻辑消耗的时间”长期是对得上的,不会越差越多。
这招在游戏圈叫 “固定步长 + 累加器”(Fix Your Timestep,源自大佬 Glenn Fiedler 那篇经典文章)。你写 Go 时其实用过同构的东西:一个定时任务,你不是”每收到一个请求就执行一次”,而是”用一个 ticker 累积时间,每过 200 毫秒触发一次”——那个 ticker 就是累加器。
再补一刀:那渲染怎么办?渲染还是每帧都画(因为屏幕得一直刷新)。但渲染时,逻辑可能”还没到下一次更新”,于是画面就画”上一帧逻辑算出的状态”。为了让画面不抖,工程师会在渲染时做一个叫渲染插值(render interpolation) 的事:把上一帧逻辑状态和当前帧逻辑状态之间,按 acc/dt 这个”进度比例”插出一个中间值来画。比如敌人逻辑上从 A 走到了 B,但渲染帧比逻辑帧密,那中间的几帧就画 A 和 B 之间的插值点,肉眼看就是”平滑滑动”而不是”一跳一跳”。
连回第 17 章你学的 FixedUpdate:你现在知道了,FixedUpdate 就是引擎帮你实现的”累加器驱动的那次 update(dt)“。引擎在它自己的死循环里,早就把 38.1 的骨架升级成了”带累加器的版本”,然后它发现”攒够 dt 了”就调一次你的 FixedUpdate,而且调用时传给你的就是那个固定的 dt(你可以用 Time.fixedDeltaTime 取到,默认 0.02 秒,即每秒 50 次)。所以你之前被告知”FixedUpdate 是物理步、频率固定”,根子就在这:它不是普通钩子,它是被累加器喂出来的、永远踩着固定节拍的钩子。
反问:既然有累加器,那如果某台机器卡爆了,一帧 elapsed 高达 0.5 秒(掉到 2 帧),累加器会一口气补跑 30 次 update 把时间追上——但这 30 次 update 本身又要算很久,于是更卡,陷入”死亡螺旋”。业界怎么挡这招?提示:给累加器设个上限(clamp),比如超过 0.25 秒的零头直接丢弃,宁可”游戏时间变慢”也不让主循环冻死。你写 Go 处理背压(backpressure)时是不是也这么干——超出缓冲就丢,别把自己撑死?
38.4 Update 与 FixedUpdate 的分工:一个跟眼睛,一个跟物理
前面三节把机制讲透了,这一节落到你每天写代码要做的”分工决策”。
引擎把循环拆成了两个口子,对应你脚本里的两个方法:
-
Update:跟着”渲染帧率”走。 它每渲染一帧调一次,频率是飘的(30/60/120)。适合放”表现层、跟画面强相关”的逻辑:
- 采集输入:读鼠标键盘、手柄摇杆(输入本来就是每帧采最新的)。
- 摄像机跟随:相机平滑跟着玩家,属于”看”的事。
- 纯表现动画:UI 抖动、特效播放进度、倒计时显示。
- 任何”差一点没关系、肉眼看着顺”的东西。
-
FixedUpdate:跟着”物理步”走。 频率固定(默认每秒 50 次,由累加器保证)。适合放”确定性强、差一点就崩”的逻辑:
- 受力与移动:Rigidbody 施加力、速度积分。
- 碰撞检测:所有”碰没碰到”的判定都在物理步里算。
- 确定性逻辑:需要可复现、可回放、可联机预测的东西(下面讲)。
为什么物理必须固定步?两个硬理由:
第一,物理积分怕”步长不均”。 物理引擎算”力→加速度→速度→位移”用的是数值积分(你可以理解为一步步加)。如果步长忽大忽小,积分误差会累积漂移,同样的初始状态两次跑出来结果不同。固定步长保证”同样的输入永远得到同样的输出”——这是物理稳定的命根子。
第二,联机和回放需要”确定性”。 想想第 19 章的联机。服务器权威下,客户端要”预测”敌人下一步在哪(19.7 节埋的坑)。预测的前提是:客户端用和服务器完全相同的物理步长去算,才能算得和服务器一样。如果物理跟着飘的帧率走,两台机器步长不同,算出来的轨迹就分叉,预测全废。再想想”录像回放”:只要记录”每步的输入”,用固定步重放,就能 100% 复现整场战斗。你写 Go 时做过的”幂等”(同样的请求执行两次结果一致)也是这股劲——确定性就是游戏的幂等。
生活化类比:Update 像红绿灯的”读秒显示器”,它是给人看的、可以抖可以跳;FixedUpdate 像红绿灯控制柜里的”定时继电器”,它必须严格按秒跳闸,差 0.1 秒就可能撞车。表现层可以宽容,物理层必须刻薄。
所以给你一条铁律,也是面试常考:“凡是碰 Rigidbody、碰碰撞、碰受力、以及任何要求可复现的逻辑,全部进 FixedUpdate;凡是碰输入、碰相机、碰纯表现,进 Update。” 你第 17 章就听过这句,现在你从”主循环”的层面真正懂了它为什么必须如此。
反问:如果你把”敌人受击后往后弹开(一个物理力)“写进了 Update,会发生什么?答案是:高帧率机器上敌人弹得远、低帧率上弹得近,甚至因为步长飘导致弹飞/穿透。这正好呼应第 17 章说的”物理写进 Update 的隐患”——现在你知道那个隐患的底层机制叫”变帧率导致积分漂移”。
38.5 主循环还顺手管了哪些”家务事”
你别以为主循环只干”输入/更新/渲染”三件。一个真实引擎的循环,每趟还要顺手处理一堆”家务”,主程必须心里有数,不然出了事不知道去哪查:
- 输入采集与去抖:把操作系统级别的按键事件,归整成游戏能读的”输入状态”。还包括”这一帧刚按下”和”一直按住”的区分(你说”按下开火”和”按住连发”是两种语义)。
- 事件分发(消息总线):第 17.4 节讲的”敌人死亡广播”,底层就是主循环每帧把事件队列里的消息派发出去。事件不是”瞬间到达”,而是”入队,下一帧循环统一派发”——这和你 Go 里”把消息丢进 channel,另一个 goroutine 消费”同构。
- 场景切换与资源加载:切关卡时,旧场景的物体要卸、新场景要载。这件事卡在主循环里做,所以切换时你会看到”读条”——那读条就是主循环在”渲染当前帧”和”后台加载新资源”之间分配时间。
- GC(垃圾回收)时机:C# 有垃圾回收。主循环里如果某帧突然生成一大堆临时对象,GC 一触发就卡顿(“GC 尖峰”)。主程要知道:大循环里少 new 临时对象、用对象池(第 39 章主角),把 GC 压力摊平。
- 帧率同步(垂直同步 / vsync):显示器有刷新率,主循环可以”等显示器准备好再画下一帧”,避免画面撕裂。这又是一个”真实时间”和”渲染节奏”的协调点。
- 超时与暂停:游戏暂停(running 还是 true,但 update 被跳过)、后台切走(失焦降频省电),都是主循环在管。
你会发现,主循环其实是整个游戏的”调度中枢”——它决定”什么在什么时候跑”。这和你 Go 里写的 main 函数里那个事件循环、或者你用 select 调度的那个中枢,角色一模一样:都是”把所有子系统串起来按时跑”的总指挥。
生活化类比:主循环就像餐厅厨房的”出餐传送带”。传送带每转一圈(一帧),厨师(输入)把新订单放上,传菜员(更新)把在做的菜推进一道工序,服务员(渲染)把做好的端给客人。而”换菜单”(场景切换)、“叫保洁”(GC)、“广播今日特价”(事件分发),全都搭在这条传送带的节奏上。传送带一停,整个餐厅瘫痪——这就是为什么掉帧会被玩家骂。
38.6 Go 后端类比:主循环像事件循环,FixedUpdate 像定时 tick
因为你是我专门写给 Go 程序员的,这一节我帮你把”游戏主循环”翻译成你最熟的语言,以后你跟别的后端同事解释时,一句话就能对上:
- 游戏主循环 ≈ 你 Go 服务器的主事件循环。
while(running){输入;更新;渲染}对应for { req := accept(); handle(req) }。都是”不停兜圈,每圈处理一批事”。区别只在游戏的圈转得极快(60 次/秒)且每圈要出画面。 - Update(每帧)≈ 每请求一次的处理。 请求来频率不定、你想”来一个处理一个”,就像 Update 跟着飘的帧率走。你写 handler 时从不在 handler 里假设”每秒恰好来 60 个请求”,同理你也不该在 Update 里假设”每秒恰好 60 帧”——都得用真实时间间隔(Go 里
time.Since,Unity 里Time.deltaTime)当尺子。 - FixedUpdate(固定步)≈ 你用 time.Ticker 起的那个定时任务。
ticker := time.NewTicker(20 * time.Millisecond)每 20 毫秒触发一次,不管别的活多忙(当然 Go 的 ticker 不保证严格不漂移,但思想一致)。物理、统计、心跳这类”必须按固定节拍”的事,你都丢给 ticker;游戏里你丢给 FixedUpdate。 - 累加器 ≈ 你做”限流/采样”时的计数窗口。 比如”每攒够 100 个请求才写一次库”,你用一个 counter 累加,够了就 flush——和
while(acc>=dt){update;acc-=dt}是同一颗脑袋。 - 事件队列派发 ≈ 你用 channel + 单个 consumer goroutine 做的解耦。 生产方只管往 channel 丢,消费方每轮循环取出来处理,互不阻塞。第 17.4 节的”敌人死亡广播”就是这结构。
把这套类比记牢,你再看任何游戏架构文章,都能瞬间翻译成后端词汇。
我再补一个你做主程一定会撞上的真实场景,帮你在”主循环”这层建立排错直觉。假设你的塔防上线后,有玩家反馈”偶尔怪会穿过我的塔、直接走到基地”。你第一反应可能是”碰撞体没挂好”。但如果你已经懂了本章,你会立刻怀疑另一个更隐蔽的元凶:固定步长下的高速穿透(tunneling)。当敌人移动速度很快、而物理步 dt 又相对大时,一次 FixedUpdate 里敌人可能从塔的一侧直接跨到了另一侧,“中间的碰撞”根本没被采样到——就像你拿相机拍高速旋转的扇叶,会拍到”扇叶好像消失了”。这不是组件没挂,是物理步分辨率跟不上速度。主程的解法通常是:调小物理步 dt(更密的采样)、给高速物体加”连续碰撞检测(CCD,Continuous Collision Detection)“、或者限制最大移动速度。你看,同一个 bug,不懂主循环的人改一晚上组件,懂的人三句话定位到 dt——这就是架构认知的复利。
再往深一层说,固定步长还是”确定性联机”的地基,这和第 19 章的服务器权威是咬合的。客户端要做”预测”(本地先按物理步算出敌人下一步位置,不等服务器),它必须能”用和服务器完全一样的 dt、完全一样的输入,算出完全一样的结果”——没有固定步长,预测就会和服务器对不上,玩家看到敌人”瞬移回滚”,体验崩坏。所以你今天学的累加器,其实是你后面做联机手感的隐藏 prerequisites。主程看问题,永远看三层:这一章它解决什么、它连着哪章、它撑着后面哪块。
还有一个常被忽略的点:主循环的”更新顺序”本身也是个设计问题。引擎每帧调用各物体的 Update 时,顺序并不保证(默认按实例化顺序或 hierarchy 顺序,不确定)。如果你的逻辑是”A 的 Update 依赖 B 的 Update 先跑完算出的结果”,那就可能出现”这一帧 A 用了 B 上一帧的旧值”的诡异时序 bug。这和你 Go 里”多个 goroutine 读写同一变量没加锁”是同源问题——主程要么用明确的有序管理(比如一个中心 GameManager 统一调度顺序),要么用事件解耦(第 17.4 节)让顺序无关。Unity 提供的 Script Execution Order 设置就是给你”指定谁先跑”的旋钮,本质是对”更新顺序”这个隐性依赖的显式声明。这也是你做游戏主程的”母语优势”:别人觉得玄乎的主循环,在你这就是”一个跑得飞快的事件循环”,没什么新鲜的。
反问:你 Go 服务里如果有个 handler 自己
time.Sleep(1秒),整个服务器会怎样?——会卡住别的请求(除非你用了 goroutine)。同理,游戏主循环里如果你在 Update 里Thread.Sleep或者同步等网络(第 19.4 节警告过),一帧就冻了,后面所有系统全停。所以”别在主循环里阻塞”这条后端铁律,在游戏里是生死线。你前面第 19.8 节的坑点 1(主线程做网络 IO 卡顿),根子就在这颗循环心脏上。
38.7 这一章你该带走的
- 游戏 = 一个死循环
while(running){输入;更新;渲染},每圈叫一帧;第 17 章的 Update 只是”更新世界”那一步展开的钩子。 - 用”每帧移动 N”当尺子会翻车:变帧率让同一份代码在不同机器跑出不同世界。尺子要换成”每过固定时间 dt”。
- 固定步长 + 累加器:
while(acc>=dt){update(dt);acc-=dt;}让逻辑永远踩固定节拍,零头留下次;渲染用插值补平滑。 - Update(跟渲染帧率)放输入/相机/表现;FixedUpdate(跟物理步)放受力/碰撞/确定性逻辑。物理必须固定步,否则漂移、且无法联机预测/回放。
- 主循环还是调度中枢:输入采集、事件分发、场景切换、资源加载、GC 时机都搭在它节奏上。
- 翻译成 Go:主循环≈事件循环,FixedUpdate≈ticker,累加器≈计数窗口,事件派发≈channel 消费。主循环里绝不阻塞,否则全盘冻结。
下一章我们顺着”主循环”这棵脉,往它要喂的三个日常系统走:资源怎么加载、对象池怎么治 GC、状态机怎么驱行为——它们仨,是主程每天睁眼就要打交道的三件套。
【思考题】
- 为什么”敌人每帧移动 1 米”在不同帧率的机器上会跑出不同距离?如果改成”敌人每秒移动 60 米、用真实时间 deltaTime 当尺子”,问题能解决吗?这和第 38.3 节的固定步长,是同一件事还是两件事?
- 累加器模式里,为什么 update(dt) 一定要用”固定的 dt”被调用,而不是用”这一帧真实的 elapsed”?如果用真实的 elapsed 当步长,物理积分会发生什么?
- 你说”物理必须进 FixedUpdate”。那”玩家按住 W 键角色往前走(直接改 Transform 位置,不用 Rigidbody)“这种移动,放进 Update 还是 FixedUpdate?为什么这种”非物理移动”对步长没那么敏感?
- 渲染插值说”在上一帧逻辑状态和当前帧逻辑状态之间插中间值来画”。如果某一帧因为卡顿,逻辑连一次 update 都没跑(acc 没攒够 dt),渲染这帧该画什么?这会不会让画面”倒退”或”冻结”?
【参考答案】
- “每帧 1 米”把”帧次数”当成了”时间”的代理,但帧次数由机器性能决定(高配 120、低配 30),所以同样一秒移动距离不同。改成”每秒 60 米 × 真实 deltaTime”能解决”快慢机器距离不同”的问题——这叫”帧率无关的移动”,是固定步长思想的简化版。但它和 38.3 的”固定步长+累加器”不是完全同一件事:deltaTime 法让”距离”对了,可物理积分仍用飘的步长,碰撞/受力仍可能漂移;累加器是连”积分步长”都固定,确定性更强。简单移动用 deltaTime 足够,物理模拟必须用累加器固定步。
- 物理积分是”一步步加”的数值方法,步长不均会让误差累积漂移、结果不可复现。若用真实飘的 elapsed 当步长:高帧率时步长小、积分准;低帧率时步长暴涨,一次跨太大直接”穿墙”或数值爆炸。固定 dt 保证”同样的输入永远同样的输出”,这是物理稳定与联机预测/回放的前提。所以 update 必须吃固定 dt,飘的时间由累加器在外面统一”攒够再喂”。
- 这种”直接改 Transform、不用 Rigidbody”的移动通常放 Update 即可。因为它不涉及物理积分,只是”把位置设到某处”,用 deltaTime 缩放一下就能帧率无关(见第 1 题)。它不要求确定性复现(你不会去回放玩家的手动移动),所以对步长不敏感。反过来说,一旦你用了 Rigidbody、力、碰撞,就必须进 FixedUpdate——判断标准就是”这事要不要物理引擎参与、要不要可复现”。
- 若某帧 acc 没攒够 dt,说明这段真实时间里逻辑还”不该走下一步”,那渲染就画”当前已有的逻辑状态”(即上一帧算出的状态),画面表现为短暂”冻结/静止”一帧,不会倒退(因为没新的逻辑状态,只是沿用旧的)。这其实比”硬插一个不完整的状态”更安全——冻结一帧玩家几乎无感,但若强行用插值反而会画出自相矛盾的位置。插值只在”已有新旧两个逻辑状态”之间做,没有新状态就不插。这也是为什么累加器要留零头:把时间对齐到逻辑步,渲染才有可靠的”新旧两点”可插。