Netcode for Entities 实战指南:如何让 ECS 多人同步驯服延迟、告别橡皮筋
【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
你按下射击键,角色 0.3 秒后才开火;松开摇杆,角色又往前滑了两米。如果你曾在多人游戏里被这种手感卡住,那 ECS 网络同步就是绕不开的一课。Netcode for Entities 是 Unity 官方给出的 DOTS 生态答案:服务器持有唯一真实状态,客户端负责"即时性",两者之间隔着一层薄而精确的网络协议。这里的示例全部来自官方示例仓库的 NetcodeSamples 与 Dots101/Netcode101 目录,可以打开编辑器跟着跑。
想象这一群鱼,每条鱼都是一个玩家角色——你要的是:所有客户端看到的"鱼群"一致,但每个人自己的操作按下键就生效。
按了射击键 0.3 秒才开火:多人游戏卡在哪道坎上
⚠️ 多人项目的第一个坑几乎从来不是"发不出去",而是"发出去但不一样"。三类抱怨会在每个项目里重复出现:开枪的人说"我明明先开的枪",挨打的人说"我明明躲开了",围观的人说"为什么你们俩看到的画面不一样"。
这三句抱怨背后是同一个矛盾:服务器要花时间验证和计算,你想要"服务器说了算",就得付出等待的代价。Netcode 的选择不是二选一,而是让两边同时跑——客户端先"演一遍",服务器后"复述一遍",两边对不上时,由框架把差异平滑掉。整个架构因此围绕三个角色运转:服务器 World 跑全部权威逻辑;客户端 World 做预测、插值和渲染;中间的 NetStream 通道把命令往上送、把快照往下送。
你不需要手工搭这三样东西。仓库里 Dots101/Netcode101 的 Kickball 示例就是一个完整的最小闭环:同一台编辑器开两个客户端,一个玩家踢出球,另一个立刻看到。上手时建议先跑一遍 Kickball 再读代码,体感会比文字清楚得多。
接受了"服务器说了算",紧接着的第一个具体问题就是:哪些数据值得过网络?这是同步的地基。
同步地基:三步定好 Ghost 同步类型
新手最常问的一句话是:生命值要不要标 [GhostComponent]?速度要不要?枪口火花要不要?全标上,带宽账单爆炸;标少了,客户端连画面都画不出来。先打个比方——Ghost 同步类型就像群聊里的 @ 规则:AllPredicted 是"@所有人,而且你能预览我发的内容",Server 是"群主单方面宣布,其他人只管收",Client 是"你自己的草稿,谁也别想看"。
游戏里的数据其实只有两种:状态(你站在哪、还剩多少血)和命令(这一帧我做了什么)。Netcode 把两者统一成 Ghost 组件:实现 ICommandData 的是命令,其余是状态;命令身上还挂着一个 NetworkTick,告诉服务器"这条命令属于哪一帧"。
| 这份数据在干什么 | 谁能写 | 谁能读 | 该选的类型 |
|---|---|---|---|
| 玩家输入(摇杆、开火) | 本地客户端 | 所有人(服务器拿它回放) | AllPredicted + ICommandData |
| 角色位置与速度 | 服务器算、客户端预测 | 所有人渲染 | AllPredicted |
| 血量、分数、击杀数 | 服务器 | 所有人 | Server |
| 本地 UI、粒子、屏幕震动 | 本机客户端 | 仅本机 | Client |
第一步是定义消息。Asteroids 示例里飞船的操纵键就是下面这样——关键看两处:[GhostComponent] 属性决定同步策略,ICommandData 接口告诉 Netcode"这是命令不是状态"。
[GhostComponent(PrefabType = GhostPrefabType.AllPredicted)] public struct ShipCommandData : ICommandData { public NetworkTick Tick { get; set; } // 关键:标记这条命令属于哪一帧 public byte left; public byte right; public byte thrust; public byte shoot; }四个动作各用一个 byte 装下,是省带宽的小例子;快照里实际只会发送变化过的字段,这一点在带宽账单部分再拆开说。源码在 ShipCommandData.cs。
第二步是把按键写进缓冲。Kickball 的输入系统是个标准模板,注意 GhostInputSystemGroup 和 GhostOwnerIsLocal 两处:前者保证写入发生在 Netcode 规定的输入阶段,后者保证只改本地玩家的缓冲,不会动到别人输入副本。
[UpdateInGroup(typeof(GhostInputSystemGroup))] public partial struct PlayerInputSystem : ISystem { public void OnUpdate(ref SystemState state) { var moveValue = InputSystem.actions.FindAction("Move").ReadValue<Vector2>(); // 关键:只写本地玩家的输入缓冲 foreach (var input in SystemAPI.Query<RefRW<PlayerInput>>() .WithAll<GhostOwnerIsLocal>()) { input.ValueRW.Horizontal = moveValue.x; input.ValueRW.Vertical = moveValue.y; // ... 此处省略约 10 行按键检测(如踢球标志位) } } }写完输入后就不用再想"怎么发",Netcode 会自动打上 tick 号发往服务器。第三步最容易被忘掉:把角色预制体注册进 Subscene 的 EntityPrefabs——不注册的话,服务器不知道这个实体长什么样,客户端收到的只会是一串没法定型的 ID。源码见 PlayerInputSystem.cs。
数据流向定好之后,下一个问题就是手感:玩家不想等服务器那一个来回。
手感优先:预测半径调多大才不橡皮筋
角色比你的按键慢 100 毫秒,你就会觉得"这不是我的角色"。预测的做法像"点外卖先自己炒一盘吃上,外卖到了再对账":玩家先吃(先看到),对账后置。
完整链路是这样:按下键 → 客户端当场本地预测出移动,本帧就生效 → 命令同时带 tick 上行 → 服务器用权威逻辑重放同一帧 → 快照下行 → 客户端把"我预测的"和"服务器说的"对账。对得上,玩家毫无感知;对不上,Netcode 把误差摊到几帧里修正。这套"先演后对账"就是客户端预测回滚机制。
预测不是免费的,每个预测实体都要让客户端多跑一份模拟,所以不是场上每个实体都值得预测。Asteroids 示例用两个并行 Job 做"近处预测、远处插值",注意它用了两个半径:
var queues = SystemAPI.GetSingletonRW<GhostPredictionSwitchingQueues>().ValueRW; // 半径内:把 ghost 切到"预测" new SwitchToPredictedGhostViaRange { playerPos = playerPos, enterRadiusSq = settings.predictionRadius * settings.predictionRadius, predictedQueue = queues.ConvertToPredictedQueue, // ... 此处省略约 5 行并行命令缓冲初始化 }.ScheduleParallel(); // 退出半径 = 预测半径 + 余量,防跨界状态抖动 new SwitchToInterpolatedGhostViaRange { playerPos = playerPos, exitRadiusSq = (settings.predictionRadius + settings.predictionRadiusMargin) * (settings.predictionRadius + settings.predictionRadiusMargin), interpolatedQueue = queues.ConvertToInterpolatedQueue, // ... 此处省略约 5 行并行命令缓冲初始化 }.ScheduleParallel();💡 半径怎么定?给个实操公式:覆盖"最坏往返时间里角色能跑出的距离"(100ms ping × 10m/s ≈ 一两米)再加余量,margin 取半径的 1.2~2 倍。两个半径错开就是"不橡皮筋"的关键:同一阈值切换时,玩家贴着边界走会逐帧在预测/插值之间反复横跳,错开之后进入和离开各只触发一次切换。自己的角色建议永远预测,其他角色交给半径。源码在 AsteroidSwitchPredictionSystem.cs。
服务器和预测对不上时,Netcode 手里握着每个实体的预测历史:快照一到就回滚到对应 tick、重放几步,差异小就按 TransitionDurationSeconds 摊开,差异大(通常是服务器端物理或延迟造成)就先瞬移再混合。玩家只会觉得"轻轻一推",而不是"被传走了"。
有了预测,也少不了跟服务器"打个电话"——玩家要求进对局、服务器要给某个特定客户端单独推消息时,靠的就是 RPC。
跨端对话:RPC 从定义到广播的完整链路
RPC 像在群里寄一封实体信:把内容(命令结构体)写进信封,Netcode 负责投递和回执。完整链路五步:定义实现 IRpcCommand 的消息结构体 → 客户端造一个临时实体,挂上消息 + SendRpcCommandRequest 发出 → 服务器端它变成一个带 ReceiveRpcCommandRequest 的实体 → 你的系统处理,单播(设置了 TargetConnection)或广播(不设置)→ 处理完销毁实体。
flowchart LR A[定义 IRpcCommand 结构体] --> B[客户端: 临时实体<br/>+ SendRpcCommandRequest] B --> C[Netcode 网络传输] C --> D[服务器: 实体携带<br/>ReceiveRpcCommandRequest 出现] D --> E{发送方式} E -->|TargetConnection 已设置| F[单播: 仅该连接收到]【免费下载链接】EntityComponentSystemSamples项目地址: https://gitcode.com/GitHub_Trending/en/EntityComponentSystemSamples
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考