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

血条的十毫秒班车:actorHUDHPManager合批调度

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

法师一个冰咆哮砸下去,屏幕上八只怪同时掉血、八个血条同时要刷新——这一瞬间客户端发生了什么?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。所有合批系统里性价比最高的组件就是这张去重登记簿,没有之一。

lua
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 毫秒,队里有货当场就卸。低延迟和合批两头都要,靠的就是这手"发车即首运"。

二、班车与收车:Tick 的两行哲学

班车上做的事少到不像话:

lua
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 停表,一个定时器都不多养:

lua
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 为空,重新发车就是,发车成本可以忽略不计。

停表时机的选择还有个对称性可以品:发车的条件是"队空且有新请求",停车的条件是"队空"。两个条件共享同一个"空"字,班车永远只在没活干的时候休息,绝不在还有一件货时提前收班——那件货会变成永久的孤儿。对照队列篇讲过的"出队验货",批处理系统的三条军规凑齐了:忙时发车、闲时收车、出队验货。七十行的调度员,三条军规一条不少,这就是它值得整篇讲解的底气。

mermaid
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

三、UpdateNow 的直通车:紧急件不走班车

调度员还留了一条加急通道:

lua
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,再感受合批的极限——快与稳的平衡点,是你可以亲手拧的:

demo
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 技术组基于 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 技术组