CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

全引擎的下一拍在这里敲:主循环Update解剖

2026-10-03 11:17 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorldController.lua 的 Update 函数——全引擎每秒 60 次的下一次拍,都从这里敲出去。这一篇把主循环拆开讲:固定工位、延迟标志、性能预算三件套,看懂它,你看任何系统都有了"它在哪一拍干活"的坐标。

主循环的价值定位要说清楚:前面十几篇讲的每个系统——走路、攻击、Buff、血条、掉落——都有自己的 Tick,但它们的 Tick 都是被"别人"在固定时机叫醒的。谁叫的?很大一部分就是这条主循环,或者它驱动的下游调度。主循环是全引擎时间感的源头:delta 从这里流出、工位从这里排开、安全点在这里预留。读懂它之前你学的是各个系统的"本领",读懂它之后你才能把所有系统在时间轴上摆到正确的位置——这一篇是前面所有篇的坐标系。

主循环的注册方式也顺带看一眼,它挂的是带优先级的调度:

lua
self:scheduleUpdateWithPriorityLua(handler(self, self.Update), 0)

优先级 0 意味着它在所有普通调度回调里最先跑——心脏的跳动必须先于一切依赖时间的系统。优先级这个参数平时没人注意,出问题时它是排障的关键线索:如果你写的系统要在"本帧网络数据收完之后"干活,优先级就得排在主循环之后,排错了读到的是上一帧的旧数据,症状是"永远慢一拍"。调度优先级就是主循环世界的户口,谁先谁后全看它。

先说这个 Update 有多重:网络收发、掉落物、技能 CD、心跳检查,四个系统一帧不落。它还是"带伤上岗"的典范——注释里两条 issue(消息回调里不能离场、schedule 回调会崩)都是真实事故的墓志铭。主循环是全引擎容错要求最高的函数:它自己崩了,游戏直接黑屏,所以你看到的所有设计都在为"绝不崩、绝不乱序"服务。

Update 的调度注册还有个隐藏信息:scheduleUpdateWithPriorityLua(handler, 0) 的优先级 0 只是众多回调中的一个,真正的顺序感不完全靠优先级数字——靠的是 Update 内部那段固定工位序列。换句话说,外部调度决定它什么时候跑,内部序列决定它按什么顺序干活,两层顺序各管各的。你写自己的常驻系统时,这个两层模型照搬:外层管节奏,内层管次序,混在一层里管两边迟早打架。

一、固定工位:顺序就是架构

Update 里按顺序叫醒的系统,摘主干如下:

lua
function GameWorldController:Update(delta)
    -- 1. 延迟标志最先处理(细节见下一节)
    ...

    -- 2. 网络收发消息包
    global.networkCtl:GetNetClient():Tick()

    -- 3. 掉落物
    global.dropItemController:Tick(delta)

    -- 4. 技能CD / 连击技能CD
    global.CalcSkillCDMediator:Tick(delta)
    global.CalcComboSkillCDMediator:Tick(delta)

    -- 5. 心跳检查
    ...CheckRecvHeartBeat / CheckSendHeartBeat...
end

顺序不是随手排的,是依赖关系定的:网络 Tick 排最前,因为这一帧的服务端消息要先收进来,后面的系统才有新鲜数据可处理;掉落物跟在网络后,捡拾判定要用最新的角色位置;技能 CD 独立成段,因为它只依赖时间不依赖别的系统;心跳检查垫底,因为它只是问一句"网络还活着吗",早问晚问不影响别人。主循环的顺序表就是一张依赖关系图拍平了的样子——读懂顺序,就读懂了系统间的数据流向。

这个顺序的纪律性极强:加新系统不 许插队,只能排队尾或者申请特定的位置(要走评审)。有团队往网络 Tick 前面插了一个"本地预测"系统,结果预测用的还是上一帧的服务端数据,预测越准错得越远——位置错了,逻辑必错。主循环的位置就是架构,动它之前先画依赖图。

Update 里的 LuaProfiler 开头那句也先划个重点:BeginSample("GameWorldController:Update", 16)——16 是整帧预算,说明这个函数自带性能账本,第三节细说。主循环的所有工位都在账本上,这就是"心跳检查垫底、网络先行"能被持续维护的原因:顺序有依据、耗时有人管。

二、延迟标志:回调里不许干重活

Update 开头最先处理的两个标志,背后是两条带注释的教训:

lua
function GameWorldController:Update(delta)
    if self.mIsRestart then
        self.mIsRestart = false       -- 用完即清
        global.L_NativeBridgeManager:GN_accountLogout()
        global.Facade:sendNotification(global.NoticeTable.RestartGame)
        return                        -- 处理完重活,本帧到此为止
    end

    if self.mIsLeaveWorld then
        self.mIsLeaveWorld = false
        global.Facade:sendNotification(global.NoticeTable.LeaveWorld)
        return
    end
    ...
end

function GameWorldController:OnGameLeaveWorld()
    -- issue, can not leaveWorld in handle msg
    self.mIsLeaveWorld = true         -- 只立标志,不干活
end

那条 -- issue, can not leaveWorld in handle msg 的注释是全文件最贵的一行:网络消息的回调里不能直接执行离开世界——回调发生在消息处理栈里,此时栈上还压着网络层的半条命,立刻清场景等于把自家地基抽了。引擎的解法是回调里只立 mIsLeaveWorld 标志,Update 的开头统一处理——消息栈先安全退出,下下帧再做重活。OnGameRestart 同款处理,连注释都写着"schedule callback crash"的血泪。

处理完标志后的那个 return 也有讲究:本帧的心跳、掉落全跳过——反正下一帧就要离场了,谁还需要心跳。标志处理是"抢占式"的:它处理的是比常规 Tick 紧急得多的事,处理完直接结束本帧,常规工位让路。这也解释了为什么标志检查必须排在工位序列的最前面:排后面的话,这一帧就白跑工位了。

再往深处想一步:为什么标志处理能安全地发 LeaveWorld 通知?因为此刻处于 Update 的最开头,网络包已经处理完(上一帧的事)、渲染还没开始(本帧的事),是全帧唯一"两不沾"的窗口期。安全点不是随便挑的帧内时刻,是依赖链的自然间隙——这个概念在多线程系统里叫 safe point,主循环虽然单线程,道理相通:重活要挑没有半成品状态的时点干。

这套"立标志、下一拍处理"的模式你已经见过一串:网络篇的粘包攒批、行为树的输入缓存、血条班车的合批——本质都是生产者和消费者的节奏解耦。主循环把这套思想用在了最惊险的地方:任何可能导致场景重建的操作(重启、离场、切图)都必须延迟到主循环的安全点执行。回调链越深,越要立标志,这条军规的主循环版,值得贴在工位上。

mermaid
flowchart TD
A[每帧 Update(delta)] --> B{mIsRestart 标志?}
B -->|是| C[登出 + 发 RestartGame 通知] --> Z[本帧结束]
B -->|否| D{mIsLeaveWorld 标志?}
D -->|是| E[发 LeaveWorld 通知] --> Z
D -->|否| F[networkCtl:Tick 收发包]
F --> G[dropItemController:Tick]
G --> H[技能CD ×2 Tick]
H --> I[心跳收发检查]
I --> Z

三、性能预算:每段工位的毫秒工资

Update 里每个系统段都包着 LuaProfiler 的采样,还带预算数字:

lua
LuaProfiler.BeginSample("GameWorldController:Update", 16)      -- 整帧预算16ms
LuaProfiler.BeginSample("global.networkCtl:GetNetClient():Tick()", 10)
global.networkCtl:GetNetClient():Tick()
LuaProfiler.EndSample()

LuaProfiler.BeginSample("global.dropItemController:Tick")
global.dropItemController:Tick( delta )
LuaProfiler.EndSample()

BeginSample 的第二个参数是这段工位的预算毫秒数:整帧 16 毫秒(60 帧的底线),网络 Tick 单独 10 毫秒。超预算的段会被 Profiler 点名——每段工位都有明码标价的工资,超支就上报表。这套预算制的价值在"掉帧追凶"时体现:Profiler 拉出超支段排行榜,凶手自己浮出来,不用全引擎二分排查。

采样本身的开销也要交底:BeginSample/EndSample 一对大约零点几微秒,一帧十几个采样点总共几微秒,相对 16 毫秒的预算是万分之几——观测成本远低于观测收益,这就是为什么预算制值得全段覆盖。但采样也别撒到函数级:每个小函数都包一对,开销就攒起来了,预算给到"工位级"(系统段)是精度和成本的最优点。

预算数字本身也是信息:网络 Tick 独占 10 毫秒,说明引擎作者把网络解析当成最重的活预警——和你从协议篇得来的直觉一致(粘包、解包、JSON 编解码都是大户)。掉落物 Tick 没给预算数字,说明它历史上从没超支过,不需要盯。预算表就是系统的体检历史,谁的数字大谁的历史不干净。

下面这个演示把主循环做成慢放动画:五个工位轮流点亮,点"消息回调里喊 LeaveWorld"看延迟标志在下一帧开头被安全处理;点"注入卡顿"给掉落段超预算,看总耗时条冲破 16.6 毫秒的红线——掉帧的账目从此清清楚楚:

demo
skill-main-loop-1003f

四、实战案例:一次"帧率腰斩"的主循环会诊

某服反馈进主城后帧率从 55 掉到 27。Profiler 超支榜第一名:掉落物 Tick,单帧 22 毫秒。进掉落管理器一查,主城同屏掉落 1700 件(国庆活动产物),dropItemController:Tick 每帧遍历全部做动画推进——掉落管理那篇讲过坐标桶和 O(1) 删除,但 Tick 的遍历没做分层。

治理三板斧。第一斧,掉落 Tick 分频:动画推进这类视觉逻辑降到每 3 帧跑一次(视觉无感),拾取判定保持每帧。第二斧,视野外跳过:出视野的掉落连数据推进都冻结(和怪物视野冻结同款)。第三斧,上限熔断:同屏掉落实体超过 800 时新掉落直接入包不落地。三斧之后掉落 Tick 回到 3 毫秒,帧率 52。总工时一天,Profiler 超支榜从会诊到验收全程导航。

这单的方法论价值:主循环的预算制让性能问题从"感觉卡"变成"谁超支"。没有预算制的团队掉帧了全引擎乱查,有预算制的团队看一眼排行榜。给主循环的每个工位发工资(预算)并记账(采样),是引擎运维的第一基础设施——它便宜到只多写几行 BeginSample,贵到能省下整个团队的排查夜。

会诊报告里还有一条经验值得单列:掉帧工单接手的第一动作不是改代码,是把 Profiler 超支榜截图发出来"对账"——让全组看到"掉落 Tick 22ms"这个事实再讨论方案。很多性能争论的根源是各方对"谁在超支"没有共同事实,预算榜就是那个共同事实。先对账再吵架,能省一半的会。

那次会诊还有个连带收获:掉落 Tick 降下来之后,同帧的技能 CD 计算分到了更多预算余量,玩家反馈"技能 CD 显示更跟手了"。主循环是公共管道,任何一段的疏通都会润泽全段——这也是为什么主循环的优化要整体看:局部超支拖累全帧,局部修复惠及全局,单点思维在主循环上不适用。

五、常见疑问

问:为什么网络 Tick 用 controller 的 Update 驱动而不是独立线程?
Lua 层没有多线程,且单线程收包天然免锁——所有系统读的都是"本帧已收完的数据",没有半包竞争。性能上 Tick 一帧批量收发足够,协议篇的节流合批都在这条路上。线程化是伪需求,真瓶颈在解析不在收发。

问:新系统想进主循环,怎么申请工位?
三条路:跟某个现有系统搭伙(像技能 CD 那样并列)、挂通知自己驱动、或者评审申请新工位。默认走前两条——主循环的工位是稀缺资源,每个新工位都是一帧的固定成本,评审的标准就一条:这个系统需要每帧知道时间吗?不需要的都该走事件驱动。

问:Update 的 delta 用在哪?
传给所有 Tick 系统(掉落、CD)做时长计算——delta 是这一帧的真实耗时,帧率波动时系统自动慢放快放。用固定值(比如写死 0.016)代替 delta 的团队,掉帧时全游戏变慢动作,这是新手最经典的错误之一,引擎的 delta 透传就是防它。

问:两个延迟标志同时置位怎么办?
按代码顺序 Restart 优先——先登出重启,离场标志留给下次进世界后处理。两个标志的优先级设计对应真实场景的重叠概率(断线重连时正好点退出),引擎选了"重启吞掉离场"因为重启后本来就要重新进世界。多标志共存的优先级要按场景概率定,这条在两个延迟标志的小函数里也有讲究。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

一把钥匙开全引擎的配置门:MetaValue元变量系统

全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…

2026-10-03 11:55 996 技术组
进阶实战游戏功能

ui.按钮名就能拿到控件:LuaExtend元表的八十行魔法

读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…

2026-10-03 11:30 996 技术组
进阶实战游戏功能

断线是谁先发现的:心跳三兄弟与切后台补发

玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…

2026-10-03 11:24 996 技术组
进阶实战游戏功能

全引擎的下一拍在这里敲:主循环Update解剖

每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…

2026-10-03 11:17 996 技术组
进阶实战游戏功能

元宝数字为什么全屏同步:CostItemCell消耗格台账

玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…

2026-10-03 11:10 996 技术组
进阶实战游戏功能

每个东西都有专属座位:sceneGraph功能节点树

新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…

2026-10-03 11:02 996 技术组