网络同步与 Flecs:ECS 架构下的权威服务器与预测回滚 Authority Server & Rollback Netcode on an ECS Backbone
ECS 不只是性能标签——它天生就是联机游戏的骨架,但回滚窗口开多大,决定了你是翻车还是封神。
为什么网络同步是 ECS 架构的结业考试
先说个反常识的。Flecs 文档里翻到最后一页,也没有一章叫「网络同步指南」。作者 Sander Mertens 从没把联机当框架卖点。可你把 Flecs 玩到第三个项目,就会突然意识到:这家伙是给联机量身定做的骨架,只是它没明说。
原因很朴素。联机游戏最怕的事,是「状态到处散落,改一个数牵连一串」。OOP 写个玩家,给玩家挂个控制器,控制器再挂武器,武器再挂个 buff 管理器——真到联机时你开始头疼:玩家的「整个状态」到底几个字节、存在哪、怎么一次性拷给服务器?ECS 就是解药:组件是纯数据,实体只是编号,查询是张表,给整个世界拍张照就是把表里的数据 dup 出来。
但这是故事的前半段。后半段才要命:ECS 让同步变容易了,可一旦加上预测回滚(今天押对了算你运气,押错了把锅甩回来重算),你所有的假设都会原地爆炸。
这篇就干三件事:先讲清 ECS 在同步上的先天优势;再把「组件怎么分类,哪些跟着服务器走,哪些自己骗自己」这条线讲透;最后把快照、回滚这套后悔药机制落到 Flecs 的具体做法上,顺带聊聊传输层怎么接。读完你至少不会在第一次做联机时,写出「客户端和服务器各算各的、然后看对方瞬移」这种经典事故。我当年就干过,服务器和客户端同时算位置、互相纠偏,两个角色像喝了假酒一样来回抽搐,测试小哥当场笑出声。
第1层:为什么 ECS 天生就是为同步而生的
先拆「同步」到底是什么。它不是把对方搬到屏幕上,而是让两个独立的世界(服务器和客户端)在同一时间点对同一件事的认知保持一致。靠什么保持一致?靠状态能被精确描述、序列化、差分、重放。
OOP 让你写玩法很爽,但它把状态藏得到处都是。一个敌人对象里有 AI、碰撞盒、模型、音效播放器,互相引用。你去序列化它,要么写个巨大的保存函数,要么靠反射(慢),要么根本序列化不了,上线才发现漏了一个字段。
ECS 不一样。一个实体就是一个编号,它的「状态」就是它身上挂的那些组件,每个组件定长、连续。整个世界的状态,就是所有 archetype 表里所有组件的连续内存块。你可以按表遍历、按组件 dump、按实体 diff。说漂亮话没用,真金白银的好处有四个:
- 状态边界清晰:哪些要同步,全看它是不是组件;不在组件里的东西天然不同步。
- 组件内存连续:拷一批组件是一次顺序读写,比反射快一个数量级。
- 序列化可裁剪:只想同步位置和血量,就勾这两个;粒子、音效自动进黑名单。
- 重放成本低:回滚时把组件值覆盖回去就行,没有构造析构、没有父子引用重建。
这四条加一起,就是「反射序列化是灾难、ECS 序列化是天花板」的根本原因。
守望先锋:被网络同步逼出来的 ECS 鼻祖
说到这得请出守望先锋。很多人知道它是 ECS 的活化石(2017 年 GDC 那场著名演讲),却很少注意:那套架构最初是被网络同步逼出来的。OW 每帧产生的变化数据量大得离谱——几十个英雄、每个有状态、能力、护盾、弹药、位置、朝向,还有「受击闪光」「技能就绪」这类只影响本地表现的东西。他们的解法是把「服务器要同步的数据」和「客户端要表现的数据」在设计上就切开:前者是组件,后者更多归客户端子系统。这就是组件分类的鼻祖思路。
更关键的是 OW 的权威架构:客户端只发命令(我是谁、我按了什么),服务器在固定 tick 里消费命令、更新组件,再把变化广播回去;客户端本地跑一份预测,让手感不卡。这叫「服务器权威 + 客户端预测 + 纠偏」。它的地基,就是一个所有状态都能一次性量化的数据结构——组件。
一句话:ECS 不是「方便做网络同步」,它是「让状态可描述、可拷贝、可回放」,而这恰好是同步的全部底层需求。
第2层:组件同步分类——把每个组件站好队
新手第一次做联机,大概率会问:该同步哪些东西?很多人的答案是「全同步」。头一周能跑,第二周带宽爆炸,第三周出现「明明打中对方却没伤害」——因为位置和判定是两套数据、同步频率不一样。
答案不是全同步,是分类同步。我把组件分四类,每类在 Flecs 里用 tag 或约定组件标注,代码里好查、调试器里好看。
| 类别 | 数据流向 | 典型组件 | 同步策略 | 纠偏方式 | 带宽权重 |
|---|---|---|---|---|---|
| 权威件 | 服务器到客户端 | Health、Score、Inventory | 变化才发,可低频 | 客户端直接覆盖 | 低 |
| 预测件 | 双向(预测/纠正) | Transform、Velocity、ActionState | 高频,可丢 | 回滚或平滑纠偏 | 高 |
| 表现件 | 仅本地 | AudioEvent、Particle、UIHint | 不同步 | 无 | 零 |
| 输入件 | 客户端到服务器 | InputCmd、MoveIntent | 可靠/带序号 | 服务器重放 | 中 |
这张表不是背的,是设计的出发点。设计每个组件之前先问一句:「它是谁说了算?」
守望先锋把「能力冷却」「大招充能」这类关键状态归入权威件(服务器决定能不能用),把「子弹拖尾」「命中反馈」归入表现件(纯本地)。这直接解释了社区流传多年的「我的大招怎么没放出来」争议——有些是被服务器优化掉了,有些确实是预测和权威打架。
火箭联盟把位置和冲量做成预测件,但把「球被谁碰到」这类判定严格放服务器。结果就是高延迟玩家也能丝滑飘移,但偶尔会出现「我明明打到了球,服务器说没碰到」的幽灵时刻。这不是 bug,这是分类的代价——你选了预测换手感,就得接受偶发的认知错位。
分类的边界情况才是真难点
分类从来不是永久的。武器冷却就是个典型:本地要显示冷却进度条(预测件),服务器又要校验你到底能不能开火(权威件)。最省事的错误做法是「一个组件两头用」,最后只能靠 if 打补丁。正确做法是拆成两个组件:一个给 UI 读的进度、一个给服务器校验的判定。分开存、分开同步,互不打架。
我踩过最惨的一个坑,是给一个「子弹命中判定」组件打上了预测标签,客户端一枪打出去本地先算命中、播击倒动画,服务器回来一看判你空枪——玩家明明看见敌人倒了,下一帧又站起来了(他们管这叫「薛定谔的击杀」)。后来老老实实拆成「本地表现命中 + 服务器权威命中」两个组件,问题消失。
分类没有银弹,但有一条黄金原则:凡是玩家能直接感知到对错、且影响胜负的,权威件;凡是只影响手感和画面的,预测件或表现件。
第3层:快照序列化与预测回滚——存档就是后悔药
这层是深水区,我们慢慢潜。
预测回滚这词,格斗游戏玩家更熟。用一个生活比喻:你打牌时总是先估对手会出什么、提前把手牌甩桌上;结果他出错了,你把手牌收回来、重新按他真实出的牌再打一遍。这个「收回来重打」,就是回滚。
在游戏里过程是这样:
- 每一帧,客户端给当前世界状态拍一张快照,存进环形缓冲。
- 客户端读到的输入是「服务器追认过的历史输入」。当服务器发来的包表明「你在第 100 帧其实搞错了」,客户端就退回第 100 帧,塞进正确输入,从第 100 帧一路重算到现在。
- 重算途中有新的服务器包进来,就继续退、继续算。
所以回滚的前提是三件事:状态能拍快照、快照能覆盖回去、重算成本可控。ECS 在这三件事上天生占优——拍快照就是把组件值 dump 出来,覆盖回去就是写回去,重算就是重跑一遍系统。没有对象构造析构、没有引用重建。
但优势不等于自动就快。整 World 全量快照,在实体上千的项目里能压垮内存。粗算一下:一个实体 5 个组件、每个 32 字节、1 万个实体,就是 1.6MB 每帧;存 60 帧历史,约 100MB。这还没算布局开销、实体元信息、多线程序列化。所以全量快照只配原型阶段,上线前必须做增量。
增量快照怎么做?一点不神秘:只对需要回滚的组件做快照,表现件直接排除;记录上次快照的版本号,只存改过的实体和组件;连续同值做压缩合并;不每帧都拍,每 N 帧拍一个关键帧,中间靠重放补。全做下来,内存和 CPU 能降一个数量级。你要是偷懒,上线才发现 8 核机器上每帧多花 2ms 拍快照——这就是帧时间的隐形杀手,你甚至查不出来是谁在占 CPU,因为它在系统内部。
火箭联盟:时间无关物理是回滚的命根子
火箭联盟是「时间无关物理」的经典案例:物理步长固定(每秒约 120 次固定步进),不受帧率影响。这意味着今天重放一帧,结果和服务器当时算的完全一致,回滚不会因为帧率漂移出现累进误差。很多自称「支持回滚」的游戏翻车,就是因为物理不是确定性的,重放一帧结果对不上,滚雪球越滚越大。火箭联盟把这一层拿捏死了,所以它在中高延迟下依然能做出「加速冲顶」这种需要极精确手感的核心操作——玩家甚至感觉不到延迟,因为他看到的是自己的预测,回滚在后台无声无息地抹平了差异。
GGPO:把回滚做到极致的格斗圈
格斗游戏圈把回滚玩到了极致。GGPO 是开源回滚网络库的鼻祖,Skullgirls 和一批独立格斗游戏都吃到了红利。它们的特点是:实体数极少(两个角色加几个飞行道具),可每一帧的输入判定极其严格,容不得一帧误差。所以取舍跟 FPS 完全相反——回滚窗口很小(通常 2 到 8 帧),但每一帧必须精确、必须确定性。
两个案例放一起看,能得出一个判断:回滚不是万能药,它取决于「状态规模」和「确定性要求」怎么权衡。你做 2D 格斗,回滚是命根子;你做一个四人合作生存,可能简单的主从权威加插值就够了,回滚是过度设计。别听人吹回滚,先算算你的单帧快照有多大。
回滚的成本公式
粗略说:每帧额外开销约等于快照大小乘拍快照频率、再乘(1 加平均回滚次数乘每次重放帧数)。你要么压快照大小(增量),要么压回滚次数(合理插值、输入缓冲),要么压重放帧数(确定性物理、减小回滚窗口)。三选一,或者全上。
这里得承认一句:我做过一个四人合作射击项目,为了手感把快照窗口开到 120 帧、每帧全量快照,结果移动端一测内存直接爆、帧率掉一半。后来砍到 30 帧、改增量才活过来。教训是:回滚窗口不是越大越好,它是「网络质量乘内存预算乘 CPU 预算」三者调出来的,凭直觉定必翻车。
第4层:GameNetworkingSockets 集成——从网线到游戏逻辑的四层漏斗
讲完状态和回滚,聊传输。很多文章一上来就讲 UDP/TCP、帧同步状态同步,讲到最后你也不知道 Flecs 到底在哪一层。我把它拆成四层漏斗,从下往上。
| 层次 | 职责 | 典型实现 | 坑 |
|---|---|---|---|
| ① 传输层 | 送字节、丢包、乱序、重传、连接 | GNS、ENet、自研 UDP | 用 TCP 做实时位置等于队头阻塞 |
| ② 序列化层 | 状态变字节流(快照/增量) | Flecs World 序列化、自定义差分 | 全量快照在新手阶段先上、后期必换增量 |
| ③ 会话层 | 连接握手、世界开始、断线重连 | 自己写或引擎带 | 重连时全量重发世界等于体验灾难 |
| ④ 应用层 | 输入、预测、回滚、纠偏 | 你的 Flecs 系统 | 四层混一起,出问题查不出是谁的锅 |
第一层传输层负责把字节从 A 送到 B。Valve 开源的 GameNetworkingSockets(GNS)是这个层次的成熟选择——内置加密、可靠性通道、连接状态机,有「不可靠保序」「可靠保序」等语义,特别适合快节奏游戏。ENet 也行,更轻。
第二层序列化层才是 Flecs 的主场。两种选择:整 World 序列化(简单粗暴),或自定义增量序列化(快但要维护差异)。原型先用整 World,上线前换增量,这个顺序别搞反。
第三层会话层决定「谁连我、什么时候开始同步、断线怎么办」。这层最常被忽略,但它决定真实体验。重连时你要么让服务器全量重发世界(慢、可能崩),要么让客户端从本地存档「追」回来(快,但要想清楚共识怎么建立)。Flecs 的 World 序列化在这里其实能帮大忙——整 World 就是一份可传输的存档。
第四层应用层是你写玩法的地方。输入打包、预测帧、纠偏、作弊检测全在这层。Flecs 的 Pipeline 和 Phase 安排会直接影响正确性:输入系统必须在预测系统之前跑,预测系统必须在渲染之前跑,纠偏系统要能插入到正确 Phase 里。你要是把回滚代码塞进坐标更新函数里,出了 bug 根本不知道是网络、玩法还是渲染的问题。
分信道这一招能救你整个联机体验
GNS 有个特别好的设计:把「可靠性」和「顺序」拆成两个维度。你可以要「可靠但不保序」,也可以要「不可靠但保序」,还能分信道。这对游戏太关键——你绝不想让一条 200ms 的重传(比如聊天消息)把位置同步堵死。
| 信道 | 例子 | 可靠性 | 顺序 | 频率 |
|---|---|---|---|---|
| 状态快照 | 位置、血量 | 不可靠 | 保序 | 20 到 60Hz |
| 输入命令 | 按键、摇杆 | 可靠 | 保序 | 每帧 |
| 世界事件 | 开箱、成就 | 可靠 | 保序 | 低频 |
| 本地表现 | 粒子、音效 | 不同步 | 无 | 零 |
参数化设计工具:一张能直接抄的参数表
前面拆了四层,现在收拢成一张能直接对标的表。这不是标准答案,是起点,你得按自己游戏的节奏调。
| 参数 | 建议区间 | 备注 |
|---|---|---|
| 服务器 tick 频率 | FPS/动作 60 到 120Hz;MOBA/RTS 20 到 30Hz;合作生存 10 到 20Hz | 越高手感越好,带宽和 CPU 成本平方式上升 |
| 客户端渲染频率 | 60 到 144Hz | 与 tick 解耦,靠插值 |
| 快照保留帧数 | 8 到 30 帧(格斗 2 到 8,MMO 可到 60) | 内存等于单帧快照乘帧数 |
| 回滚窗口 | 1 到 8 帧延迟 | 网络抖动大就加大,但边际收益递减很快 |
| 纠偏阈值 | 位置偏差超过 0.3 到 2 米才回滚 | 太小会抖,太大会穿模 |
| 带宽预算 | 快节奏每玩家 30 到 100kbps;回合制 5 到 20kbps | 留 50% 余量防高峰 |
| 组件同步频率 | 关键状态 20 到 60Hz,冷状态变化才发 | 冷热分层能省一大半带宽 |
| 组件 | 分类 | 同步频率 | 是否参与回滚 | 备注 |
|---|---|---|---|---|
| Transform(位置) | 预测件 | 每 tick | 是 | 误差超过 1 米需回滚 |
| Health | 权威件 | 变化即发 | 是 | 客户端不可预测 |
| ActionState(拔枪/技能) | 预测件加权威件拆分 | 每 tick | 是 | 表现与判定分离 |
| Cooldown | 预测件(UI)加权威件(校验) | 变化即发 | 部分 | 拆两个组件 |
| Score | 权威件 | 变化即发 | 否 | 客户端直接覆盖 |
| Particle / Audio | 表现件 | 不同步 | 否 | 本地生成 |
| InputCmd | 输入件 | 每帧 | 是(重放) | 带序号 |
双路径:初级用户速览 / 进阶深入
初级用户速览
刚做完第一个 Flecs 项目想加联机,只想一个能跑的原型,先把四件事做对:
- 用 GNS 或 ENet 搭一条 UDP 通道,别用 TCP。TCP 的队头阻塞会把位置同步卡成 PPT——你见过网络游戏里角色一动一卡吗?八成就是 TCP 干的。
- 服务器权威:客户端只发输入,服务器算结果。别在客户端算伤害,客户端不可信。
- 组件先只分两类:权威件、预测件。表现件默认不同步,输入走单独通道。就这三条能做出能玩的原型。
- 序列化先整 World,卡了再优化。本末倒置是新手最常见的坑——原型阶段为了性能做增量,结果功能都没做完。
进阶深入
做过一个联机项目、卡在「帧率不稳」或「回滚重放不同步」的话,你要补的是:
- 确定性:物理步长固定、随机种子固定、浮点运算尽量避开平台差异。回滚重放不一致,九成是确定性没做干净。
- 增量快照:只快照脏组件,做关键帧差分。
- 分信道:输入、快照、事件、聊天四条信道,别让一个重传堵死整个同步。
- 调度对齐:Flecs 的 Pipeline 要让输入系统在预测前、预测在渲染前、纠偏能插到正确 Phase。
- 诊断工具:用 Explorer 看实体和组件分布,用往返延迟和丢包统计看网络质量,别靠肉眼猜。
争议与常见坑
- ECS 序列化不是免费的。关系(ChildOf、IsA)和层级结构序列化时会遇到递归、循环引用、引用重建成本,别指望一个函数把整个世界吐出来就完事。Flecs 的 World 序列化能救急,但要清楚它的成本。
- 回滚不是万能药。状态规模一大,回滚窗口一开,内存和 CPU 双爆。先把「状态规模」算清楚,再决定要不要回滚。
- 过度预测。把所有东西都预测,看起来手感最好,实际上一致性最难调,纠偏频率一高玩家感觉在抖。
- 反作弊与回滚的两难。预测意味着客户端有算力,算力就意味着作弊空间。权威件永远放服务器,客户端预测只是「表现层」,别把判定权交给客户端。
- 把四层混成一锅粥。业务逻辑里直接发 socket、回滚代码塞进坐标更新,出 bug 查不出是谁的锅。层次拆干净,调试省一半命。
FAQ
问:Flecs 自带网络同步吗?
不带。Flecs 是 ECS 框架,不是联机框架。它给你的是「状态可描述、可序列化、可回放」的底座。同步的传输、会话、预测、回滚,要你自己搭,或者用现成网络库(GNS、ENet、Netcode)拼。Flecs 的序列化能当快照基础,这是它做得比很多框架顺的地方。
问:整 World 快照能直接当回滚存档吗,性能如何?
能,原型阶段就该这么干。给整 World 拍快照,存进环形缓冲,需要回滚就反序列化回去。代价是内存大、反序列化慢,实体上千的项目会吃力。上线前换增量快照——只存脏组件、做关键帧差分,成本能降一个数量级。别在原型期就用增量,那会让你怀疑人生。
问:组件同步分类会不会太碎、太麻烦?
会,一开始做会很痛苦。但分类是给你一个思考框架,不是逼你精确到每个字段。早期两类(权威、预测)就够用,项目跑起来再细拆。分类的意义在于:你在设计组件时就会想「谁说了算」,而不是写完联机才发现两个系统互相打架。
问:ECS 加回滚适合哪些游戏类型?
实体数少、状态可量化、确定性要求高的特别合适——格斗、赛车、抢旗类竞技。实体成千上万、状态复杂、还有很多不可预测交互的合作生存、开放世界,回滚是过度设计,主从权威加插值更现实。判断标准只有一个:你的单帧快照能不能在预算内拍出来。
问:反作弊怎么办,预测回滚会不会削弱权威?
不会,只要判定权始终在服务器。客户端预测只是「本地表现」,服务器纠正才是真相。反作弊的关键不是取消预测,是别把判定逻辑交给客户端——你没在客户端算伤害,就没法靠预测作弊。真遇到外挂,走行为检测和服务器校验这条路,回滚本身不会让你更脆弱。
最后抛个开放问题给你:如果你正在做的联机游戏,明知道回滚能把延迟体感压到 20ms 以内,但代价是内存涨 40%、开发多两周,你会做吗?还是先上线、等玩家真的骂了再补?说句实话,我两个选项都选过,两次都后悔过——一次是没早点砍回滚窗口,一次是砍得太狠丢了手感。这就是联机,永远在「猜」和「准」之间走钢丝。