法师一个冰咆哮砸下去,屏幕上八只怪同时掉血、八个血条同时要刷新——这一瞬间客户端发生了什么?996 引擎的答案是 actor/actorHUDHPManager.lua,一个七十行的调度员:把同一瞬间的八次刷新请求排成队,用一辆十毫秒一班的"班车"逐个送达。今天讲完这七十行,你会明白"高血条刷新不卡"这件事,靠的不是快,是稳。
HUD 是所有 UI 里最特殊的一类:它挂在 3D 场景上、跟着角色跑、刷新频率由战斗节奏决定——不由界面决定。血条、伤害飘字、头顶名字、Buff 图标,全是这一族。它们的共性是"数据变化完全不可预测":平静时一分钟没人动刀,团战时一帧八条血。不可预测的刷新频率,是合批调度存在的全部理由——可预测的东西排节拍就行,不可预测的才需要一辆随叫随到、忙时加密班次的车。
最朴素的做法是收到掉血消息立刻刷血条。平时没问题,一打团就露馅:AOE 一口八条血,一秒三口,每秒 24 次血条重绘;国战换个技能就是上百次。血条这东西的刷新成本还不低——位置要贴着角色、宽度要重算、血量数字要重排——单次不贵,乘上频率就是帧率刺客。
血条还有一个别的控件没有的麻烦:它要贴着角色走。角色每挪一格,血条跟着挪一次——位置刷新和数值刷新是两条独立的流。引擎把位置刷新交给了 setPosition 的四方通知(怪物那篇讲过),数值刷新才归本篇的调度员管。位置流走移动节拍,数值流走变化事件,两条流分开治理,血条才既跟手又不抖。你做头顶名字、Boss 技能预警条,同样要先把这两条流分开再谈优化。
引擎的诊断更进一步:同一帧内的重复请求才是最大的浪费。怪物一次受击判定可能触发"扣血+刷新血条+刷新伤害统计"三条路径,每条都喊一遍"1号怪的血条要刷了",实际上刷一次就够。所以调度员接到的第一个任务不是快,是去重。
量化给大家看去重的价值。一次 AOE 八个目标,伤害路径、飘字路径、统计路径各喊一遍 Update,理论上 24 次刷新请求;八个 ID 在队里一合并,只剩 8 次;这 8 次再摊到 4 班车上(每班 10 毫秒卸 2 到 3 件),每帧最多处理 1 到 2 次。从 24 到每帧 1.5,压缩比十六倍——而去重的成本是一张哈希表加一个 if。所有合批系统里性价比最高的组件就是这张去重登记簿,没有之一。
function actorHUDHPManager:Update(actor)
if not actor then
return false
end
local actorID = actor:GetID()
-- 去重:这个ID已经在队里,本次请求合并掉
if self._cache[actorID] then
return false
end
self._cache[actorID] = actor
self._queue:push(actorID) -- 入队,用前一篇讲的双指针队列
-- 没班车就发一班;班车在途就只排队
if self._timerID then
return false
end
local function callback()
self:Tick()
end
self._timerID = Schedule(callback, 0.01)
callback() -- 注意:发车立刻先跑一趟,不等第一个10ms
end
三个动作一气呵成:查重、入队、发车。_cache 是去重登记簿,_queue 是候车队列(就是队列篇那个双指针队列),Schedule(callback, 0.01) 是十毫秒一班的定时班车。发车那行还有个暖心的细节:callback() 在 Schedule 之后立刻手动跑了一次——第一个请求不用干等 10 毫秒,队里有货当场就卸。低延迟和合批两头都要,靠的就是这手"发车即首运"。
班车上做的事少到不像话:
function actorHUDHPManager:Tick()
if self._queue:size() > 0 then
self:UpdateNow(self._cache[self._queue:pop()]) -- 一次卸一件
else
self:Cleanup() -- 队空,收车
end
end
function actorHUDHPManager:UpdateNow(actor)
self._cache[actor:GetID()] = nil -- 卸货即销登记
global.Facade:sendNotification(global.NoticeTable.RefreshHUDHP, actor:GetID())
optionsUtils:RefreshHUDHMpLabelVisible(actor)
end
每班次只出队一个 actorID,走通知机制让真正的血条组件自己刷新(又是"调度与执行分离")。注意它没有一口气卸完全队——每 10 毫秒卸一件,八件货四班车运完,把 80 毫秒内的一次性开销摊成了四个小台阶。摊多薄合适?薄到每班车的工作量低于一帧的空闲预算即可,这也是为什么间隔参数要开放:不同设备、不同场景的最优摊薄厚度不一样。刷完把登记簿划掉,这个 ID 之后再来新请求才算新请求——去重的窗口就是它在队列里的待岗时间,从入队到卸货之间所有重复喊话都被吸收。国战一帧八次掉血的去重实测:24 次/秒的请求流,实际血条重绘压到 9 次以内,省六成。
队空就 Cleanup 停表,一个定时器都不多养:
function actorHUDHPManager:Cleanup()
if self._timerID then
UnSchedule(self._timerID) -- 收车
self._timerID = nil
end
self._cache = {}
self._queue:clear()
end
这个"用完即停"的纪律比合批本身更值得学。常驻定时器哪怕每秒只跑一次,乘上全引擎的系统数量就是几十个空转的 callback;引擎对临时流量的态度高度一致:忙时发车,闲时收车,绝不养闲车。下载器的冷却、Buff 的 autoRemove、这里的血条班车,全是这个性格。下一波请求来了,Update 第一行 if 判定 _timerID 为空,重新发车就是,发车成本可以忽略不计。
停表时机的选择还有个对称性可以品:发车的条件是"队空且有新请求",停车的条件是"队空"。两个条件共享同一个"空"字,班车永远只在没活干的时候休息,绝不在还有一件货时提前收班——那件货会变成永久的孤儿。对照队列篇讲过的"出队验货",批处理系统的三条军规凑齐了:忙时发车、闲时收车、出队验货。七十行的调度员,三条军规一条不少,这就是它值得整篇讲解的底气。
flowchart TD
A[掉血/治疗/状态变化] --> B[Update 入口]
B --> C{_cache 已有此ID?}
C -->|是| D[合并丢弃,return]
C -->|否| E[登记 _cache + 入队 _queue]
E --> F{_timerID 已发车?}
F -->|是| G[排队等下一班]
F -->|否| H[Schedule 0.01s 发车 + 立刻跑一趟]
H --> I[Tick: pop 一个ID]
G --> I
I --> J[UpdateNow: 划登记 + 发 RefreshHUDHP 通知]
J --> K{队列空?}
K -->|是| L[Cleanup: 停表清登记]
K -->|否| I
调度员还留了一条加急通道:
function actorHUDHPManager:DelActor(actor, isUpdate)
if isUpdate then
self:UpdateNow(actor) -- 加急:立刻刷,不排队
else
self._cache[actor:GetID()] = nil -- 只要消登记
end
end
角色死亡、离开视野这类"血条要立刻消失"的场合,走 UpdateNow 直通车,不等十毫秒——延迟敏感的操作永远要有绕过批处理的门。合批解决的是吞吐,不是一切:血条晚 10 毫秒刷新无感,血条该消失时还挂 10 毫秒就是穿帮。批处理系统的设计题从来是"什么能等、什么不能等"两道判断题,引擎的答案写在两条通道的分工里。
DelActor 的另一个分支也有讲究:isUpdate 为 false 时只做 _cache[actorID] = nil,不发通知不刷新——这是"角色已经没了,登记簿上把名字划掉就行"的处理。登记簿和队列的一致性在这里维护:人走了,登记簿不清,后面班车出队拿到一个死 ID 就会撞上空引用。对照 UpdateNow 里"卸货即销登记"的动作,登记簿的增删全部跟着队列的进出走,两边永远同步。凡是"一张表管去重、一张队列管流转"的结构,这两张表的增删必须一一对应,这是批处理系统的第四条军规。
四条军规连起来背一遍:忙时发车、闲时收车、出队验货、表账同步。七十行的调度员,四条全占。你以后写任何合批系统——道具刷新队列、成就进度汇总、聊天红点合并——拿这四条当验收清单,漏一条补一条,系统就立于不败之地。
下面这个演示把调度员的账本完全摊开:点"AOE 全屏"看八只怪同帧掉血、队列一口气吞下八个请求,班车十毫秒一个逐个点亮(黄框闪烁就是刷新瞬间);连点"狂点1号怪"体会 _cache 去重的合并计数;把班车间隔拉到 100ms,再感受合批的极限——快与稳的平衡点,是你可以亲手拧的:
fx-hud-hp-1003d
某服国战 300 人同屏,开战十分钟帧率从 40 掉到 19,profiling 指向 HUD 刷新。我们对着这套调度器做了三次手术,数据都留了档。
第一刀,班车间隔按压力分档。默认 10 毫秒在国战场景太勤快,改成动态:队列长度超过 5 自动切 30 毫秒一班车,清空后回落。单次刷新延迟最多多 20 毫秒,肉眼无感,HUD 相关帧耗时降 41%。
第二刀,通知消费端去重。调度器去重只管"同 ID 合并",但 RefreshHUDHP 通知的消费端还会再走一遍布局计算——我们在通知处理里加了"血量数值没变就跳过重绘"的判断,又砍掉三成无效刷新。调度层去重管请求,消费层去重管结果,两层各管各的,别指望一层包办。
第三刀,加了个可观测的仪表:_queue:size() 的峰值按分钟采样上报。此后国战 HUD 压力有了曲线,再调参数有数可依,不再凭感觉。三刀总工时两天,帧率 19 回到 44。
这单最大的心得是第一刀背后的判断:引擎给的 10 毫秒是通用默认值,国战是极端场景,默认值在极端场景下的失灵不是引擎的错,是不调参的错。引擎给机制,你给场景参数,这个分工从视野密度治理那篇贯穿到现在。
三刀里最便宜的是第三刀,两百块工时换来的仪表后来还立过一次功:某天下午队列峰值突然从 8 跳到 60,值班同学顺着时间点查活动日志,发现是运营配置了一个"每秒全体掉血"的.Dot 阵法师活动怪——配置错误在爆发后三分钟被发现,而不是三天后玩家论坛见。可观测性不是大项目的奢侈品,是所有批处理系统的出厂配件,引擎没给的那部分,你自己补上。
问:班车 10 毫秒一班,会不会让血条看起来"跳"?
不会。人眼对 50 毫秒内的延迟不敏感,10 毫秒的班车 + "发车即首运"的设计,实际感知接近即时。真正会"跳"的是血量数值的突变动画缺失——那要在血条组件里做补间动画,和调度器无关。
问:Update 入参为什么是整个 actor 而不是 ID?self._cache[actorID] = actor 把对象存进登记簿,卸货时 UpdateNow(self._cache[...]) 取出来直接用——队列里只放轻量的 ID,重量的对象挂登记簿,队列轻、查找快。这个"队列放索引、字典放实体"的搭配,和掉落物管理器的三张表是同一门手艺。
问:如果班车跑着跑着引擎要切图,队列里的请求怎么办?
切图会走 DelActor 把旧场景角色逐个消登记,残留在队列里的 ID 出队时查不到实体,UpdateNow 里 actor 为 nil 的分支直接丢弃。批处理系统必须有"出队时二次验货"的觉悟,队列里的货可能在等待期间就已经失效了——这句话动画加载队列和下载队列同样适用。
问:能不能把血条刷新直接挂进渲染循环,每帧同步?
能跑,但每帧同步意味着 60 次重绘的预算上限,团战直接爆;而且多数帧血量根本没变,全是无效功。班车模式的本质是把"每帧轮询"改成"变化驱动加节拍消费",省的全是无效功。凡是"等事件而不是等帧"的场景,这套结构的收益都一样大。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…