Skip to content

客户端内部分层:渲染、逻辑与资源 ​

Category ​

client / Web 前端。节点定位:客户端进程内部再分一层——渲染、逻辑、资源加载的职责与交界;分层直接决定回放、重连、性能归因和多端复用能不能做。

Definition ​

第 8 章 02 页划的是客户端与服务端的边界(表现逻辑、交互逻辑、权威逻辑)。本页往里走一层:客户端进程内部,渲染层、逻辑层、资源加载层怎么分、交界在哪。分层不是美学——它直接决定四件事能不能做:回放、重连、性能归因和多端复用。

Problem ​

不分层的客户端,四件事都做不了:回放(逻辑不可复现)、断线重连(状态无从恢复)、性能归因(渲染和逻辑混在一个循环)、多端复用(逻辑绑死渲染 API)。把伤害结算塞进渲染回调,帧率波动直接改变游戏结果——分层不是美学,是这些能力的地基。

三层的职责 ​

  • 逻辑层:持有游戏状态、执行规则。它知道「血量从 100 变成 60」,不知道「血条往哪画」。理想状态下它是纯计算:输入(指令 + 帧步长)→ 状态迁移 + 事件列表
  • 渲染层:把状态画出来。它读状态、消费事件,产生表现(动画、粒子、音效、飘字)。它不裁决任何规则,也不持有权威状态
  • 资源加载层:管理资产的生命周期(加载、引用计数、释放、预算)。它被前两层调用,不反向下依赖任何业务模块

判断分没分对,看数据流方向:逻辑 → 状态/事件 → 渲染,单向。渲染层出现「改了游戏状态」的代码、逻辑层出现「读 UI 控件」的代码,就是破了。

固定时步与表现插值 ​

逻辑层要可复现(回放、反外挂核对、断线重连,都依赖同样输入算出同样结果),就必须用固定时步,而不是每帧波动的真实帧间隔——固定步长下「同样指令序列」才是良定义的(对照第 4 章确定性与数值一致性)。渲染可以按真实帧率跑,在两个逻辑帧之间做插值:

js
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。

第二步:分层归因——按本页常见误区清单比对 ​

text
分层缺失归因  占比
  渲染层存游戏状态,结算写在渲染回调      44%
  真实帧间隔进规则,帧率改变游戏结果      32%
  资源释放靠自觉,无引用计数无对账        24%

逐项比对:结算在渲染回调、飘字对象持有权威数值,正中「渲染层存游戏状态:表现与状态一旦互相写,回放与重连都不可能对」;暴击概率随帧率漂移正中「用真实帧间隔做规则运算:帧率波动直接改变游戏结果」;每局 18 MB 残留正中「资源释放靠自觉:没有引用计数与对账,「越玩越卡」只是时间问题」。

第三步:根因——数据流方向反了,单向变成互相写 ​

判断分没分对就看数据流方向:逻辑 → 状态/事件 → 渲染,单向。项目里渲染层扣血、逻辑层读 UI 控件,两个方向同时破。逻辑层没有固定时步,规则运算挂在真实帧间隔上,「同样指令序列算出同样结果」这个回放、反外挂核对、断线重连共同依赖的前提从第一天就不成立——帧率高就快、切后台回来瞬移、回放对不上——三类线上问题同源。

第四步:处置与回填——按三层把边界立起来 ​

  1. 固定时步:逻辑层改 accumulator 模式,STEP = 1/60 固定步长,渲染按真实帧率插值;30 帧机暴击率 21.4% → 15.1%,两机一致。
  2. 结算回逻辑层:规则计算与状态迁移只在逻辑层,渲染层只读状态、消费事件;直调 ui.showDamage 的 96 处改为事件订阅——事件承载「已发生的事实」(过去时态),不承载「请执行某操作」(命令)。
  3. 会话资源清单:进战斗预载战斗资源集、出战斗按引用计数对账释放;每局残留 18 → 0.3 MB(24 局 432 → 7.2 MB),稳态 1.07 GB → 650 MB 上下。
  4. 分层回归:逻辑层无渲染单测加整局回放比对(种子 + 指令流 → 状态哈希),资源层加会话结束计数归零测试。

复核账:两机暴击率 21.4% / 14.8% → 15.1% / 15.0%(配置 15%);重连失败工单 410 → 37 条;每局残留 18 → 0.3 MB;整局回放状态哈希两机一致。

回填清单:

回填项动作回填处
固定时步accumulator 固定步长,渲染插值,规则不碰真实帧间隔固定时步与表现插值
单向数据流结算只在逻辑层,渲染层读状态消费事件三层的职责
事件协议事件承载已发生的事实,不承载命令事件:层与层之间的正式通道
资源对账会话开始预载、结束对账释放,计数归零进测试资源加载:预算与生命周期

案例的三个教训 ​

  1. 分层不是美学,是回放与重连的地基——表现与状态互相写的那一刻,回放与重连同时出局。
  2. 固定步长下「同样指令序列」才是良定义的——真实帧间隔进规则,帧率就是隐藏的规则参数。
  3. 资源释放靠对账不靠自觉——每局 18 MB 不显眼,24 局就把低端机顶出进程。

Used By ​

客户端项目骨架设计(三层边界与事件通道);回放与重连方案(固定时步 + 逻辑可复现);性能归因流程(渲染/逻辑分账户)。

关系链:三层职责 → 固定时步与插值 → 事件通道 → 资源预算 → 分层回归。上游:前后端边界(client/02,本页是它往进程内走一层)。下游:client/04(脚本层挂在逻辑层)。

Reference ​

本页为工程经验归纳;页内实战案例为教学示例(数字取整简化)。

游戏知识体系