客户端内部分层:渲染、逻辑与资源
Category
client / Web 前端。节点定位:客户端进程内部再分一层——渲染、逻辑、资源加载的职责与交界;分层直接决定回放、重连、性能归因和多端复用能不能做。
Definition
第 8 章 02 页划的是客户端与服务端的边界(表现逻辑、交互逻辑、权威逻辑)。本页往里走一层:客户端进程内部,渲染层、逻辑层、资源加载层怎么分、交界在哪。分层不是美学——它直接决定四件事能不能做:回放、重连、性能归因和多端复用。
Problem
不分层的客户端,四件事都做不了:回放(逻辑不可复现)、断线重连(状态无从恢复)、性能归因(渲染和逻辑混在一个循环)、多端复用(逻辑绑死渲染 API)。把伤害结算塞进渲染回调,帧率波动直接改变游戏结果——分层不是美学,是这些能力的地基。
三层的职责
- 逻辑层:持有游戏状态、执行规则。它知道「血量从 100 变成 60」,不知道「血条往哪画」。理想状态下它是纯计算:输入(指令 + 帧步长)→ 状态迁移 + 事件列表
- 渲染层:把状态画出来。它读状态、消费事件,产生表现(动画、粒子、音效、飘字)。它不裁决任何规则,也不持有权威状态
- 资源加载层:管理资产的生命周期(加载、引用计数、释放、预算)。它被前两层调用,不反向下依赖任何业务模块
判断分没分对,看数据流方向:逻辑 → 状态/事件 → 渲染,单向。渲染层出现「改了游戏状态」的代码、逻辑层出现「读 UI 控件」的代码,就是破了。
固定时步与表现插值
逻辑层要可复现(回放、反外挂核对、断线重连,都依赖同样输入算出同样结果),就必须用固定时步,而不是每帧波动的真实帧间隔——固定步长下「同样指令序列」才是良定义的(对照第 4 章确定性与数值一致性)。渲染可以按真实帧率跑,在两个逻辑帧之间做插值:
const STEP = 1 / 60
let acc = 0
let last = performance.now()
function frame(now) {
acc += Math.min((now - last) / 1000, 0.25) // 上限防切后台回来的巨帧
last = now
while (acc >= STEP) {
update(STEP) // 逻辑:固定步长,可复现
acc -= STEP
}
render(acc / STEP) // 渲染:多余的时间交给插值
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)这段是经典的 accumulator 模式:逻辑每 1/60 秒走一步(落后了就连走几步、领先了就空转),渲染每帧都跑、用 acc / STEP 作为插值系数在前后两个逻辑状态间过渡。没有这一层,帧率高就快、切后台回来瞬移、回放对不上——三类线上问题同源。
事件:层与层之间的正式通道
逻辑层不该直接调用 ui.showDamage(),它发布事件(「实体 12 受到 30 点伤害」),渲染层订阅并决定表现。事件带来的解耦有代价:时序变得不直观、调试链路变长。治理原则:事件承载「已发生的事实」(过去时态),不承载「请执行某操作」(命令);同一事实的消费者各自解读,而不是逻辑层为每个消费者定制事件。
资源加载:预算与生命周期
资源层的核心机制是引用计数与预算:进战斗加载战斗资源集、出战斗按计数释放,常驻资源(UI 底板、公共音效)单独管理。两个常见断点:加载发生在使用点(进战斗才加载技能特效,玩家感知为卡顿)与释放没有对账(加载了没释放,内存单调上涨)。工程上把「一次玩法会话需要哪些资源」做成清单,会话开始预载、结束对账释放,是大多数团队从「越玩越卡」里走出来的第一步(对照第 8 章 03 页资源系统与工具链)。
分层与回归:每层怎么验
分层的价值要用测试兑现:逻辑层纯计算,可以做无渲染的单元测试与整局回放比对(种子 + 指令流 → 状态哈希);渲染层难自动化,退而求其次做「状态到表现」的快照抽检;资源层做加载对账测试(会话结束计数归零)。教程 20《游戏测试与质量保障》的分层思路在客户端同样成立。
常见误区
- 渲染层存游戏状态:动画回调里扣血、飘字对象持有权威数值——表现与状态一旦互相写,回放与重连都不可能对
- 逻辑层碰表现对象:规则代码里
node.position = ...,这行代码既不可单测、也不可回放 - 用真实帧间隔做规则运算:暴击概率、移动积分依赖帧时长,帧率波动直接改变游戏结果
- 事件满天飞:一切皆事件,十行代码的事绕三层总线——事件用于解耦边界,模块内部直接调用
- 资源释放靠自觉:没有引用计数与对账,「越玩越卡」只是时间问题
实战案例:一次「同一段录像,两台机器打出两个结果」的分层缺失排查
案例为教学示例(非摘自真实项目),数字经过取整简化。它把本页「三层的职责」「固定时步与表现插值」「事件:层与层之间的正式通道」用到深水区:回放做不了、重连接不住、越玩越卡——三类线上问题同源的归因排查,也是「分层不是美学」的完整展开。
背景与现象:客户端接入回放功能评估时发现:同一段录像在 60 帧机与 30 帧机上结果不同——暴击概率配置 15%,30 帧机实测 21.4%、60 帧机 14.8%;断线重连状态无从恢复,重连失败工单 410 条;连玩 24 局(约 2 小时)内存从基线 640 MB 涨到 1.07 GB,低端机被系统回收。
第一步:现象与口径——把确定性、恢复、内存三本账拆开
确定性口径:暴击判定用真实帧间隔做规则运算——帧间隔进随机种子推导,帧率一变概率就变;30 帧机的 21.4% 与配置 15% 差 6.4 个百分点。
恢复口径:伤害结算写在渲染回调里,状态散在 UI 控件上——断线后没有任何一份可恢复的游戏状态,重连失败 410 条。
内存口径:进战斗才加载技能特效,出战斗没有对账释放;每局残留 18 MB,24 局累计 432 MB,加基线 640 MB 顶到 1.07 GB。
第二步:分层归因——按本页常见误区清单比对
分层缺失归因 占比
渲染层存游戏状态,结算写在渲染回调 44%
真实帧间隔进规则,帧率改变游戏结果 32%
资源释放靠自觉,无引用计数无对账 24%逐项比对:结算在渲染回调、飘字对象持有权威数值,正中「渲染层存游戏状态:表现与状态一旦互相写,回放与重连都不可能对」;暴击概率随帧率漂移正中「用真实帧间隔做规则运算:帧率波动直接改变游戏结果」;每局 18 MB 残留正中「资源释放靠自觉:没有引用计数与对账,「越玩越卡」只是时间问题」。
第三步:根因——数据流方向反了,单向变成互相写
判断分没分对就看数据流方向:逻辑 → 状态/事件 → 渲染,单向。项目里渲染层扣血、逻辑层读 UI 控件,两个方向同时破。逻辑层没有固定时步,规则运算挂在真实帧间隔上,「同样指令序列算出同样结果」这个回放、反外挂核对、断线重连共同依赖的前提从第一天就不成立——帧率高就快、切后台回来瞬移、回放对不上——三类线上问题同源。
第四步:处置与回填——按三层把边界立起来
- 固定时步:逻辑层改 accumulator 模式,STEP = 1/60 固定步长,渲染按真实帧率插值;30 帧机暴击率 21.4% → 15.1%,两机一致。
- 结算回逻辑层:规则计算与状态迁移只在逻辑层,渲染层只读状态、消费事件;直调
ui.showDamage的 96 处改为事件订阅——事件承载「已发生的事实」(过去时态),不承载「请执行某操作」(命令)。 - 会话资源清单:进战斗预载战斗资源集、出战斗按引用计数对账释放;每局残留 18 → 0.3 MB(24 局 432 → 7.2 MB),稳态 1.07 GB → 650 MB 上下。
- 分层回归:逻辑层无渲染单测加整局回放比对(种子 + 指令流 → 状态哈希),资源层加会话结束计数归零测试。
复核账:两机暴击率 21.4% / 14.8% → 15.1% / 15.0%(配置 15%);重连失败工单 410 → 37 条;每局残留 18 → 0.3 MB;整局回放状态哈希两机一致。
回填清单:
| 回填项 | 动作 | 回填处 |
|---|---|---|
| 固定时步 | accumulator 固定步长,渲染插值,规则不碰真实帧间隔 | 固定时步与表现插值 |
| 单向数据流 | 结算只在逻辑层,渲染层读状态消费事件 | 三层的职责 |
| 事件协议 | 事件承载已发生的事实,不承载命令 | 事件:层与层之间的正式通道 |
| 资源对账 | 会话开始预载、结束对账释放,计数归零进测试 | 资源加载:预算与生命周期 |
案例的三个教训
- 分层不是美学,是回放与重连的地基——表现与状态互相写的那一刻,回放与重连同时出局。
- 固定步长下「同样指令序列」才是良定义的——真实帧间隔进规则,帧率就是隐藏的规则参数。
- 资源释放靠对账不靠自觉——每局 18 MB 不显眼,24 局就把低端机顶出进程。
Used By
客户端项目骨架设计(三层边界与事件通道);回放与重连方案(固定时步 + 逻辑可复现);性能归因流程(渲染/逻辑分账户)。
Related
关系链:三层职责 → 固定时步与插值 → 事件通道 → 资源预算 → 分层回归。上游:前后端边界(client/02,本页是它往进程内走一层)。下游:client/04(脚本层挂在逻辑层)。
Reference
本页为工程经验归纳;页内实战案例为教学示例(数字取整简化)。