ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UE5 MassReplication实战:大规模单位网络同步架构与优化

UE5 MassReplication实战:大规模单位网络同步架构与优化 1. 项目背景与核心问题拆解1.1 为什么大规模单位同步是个老大难做过多人联机游戏的人都有一个共识几十个单位的同步和几千个单位的同步完全是两个维度的工程问题。前者靠引擎自带的网络复制Replication就能糊弄过去后者则会直接把你的带宽、CPU和帧率全部吃干净。UE5 的 MassEntity 框架本身是为大规模实体十万级甚至百万级设计的它用 Archetype 存储、Chunk 内存布局和 Processor 系统把 CPU 缓存命中率拉满。但问题在于MassEntity 从设计之初就是一个“单机优先”的框架它压根没打算帮你解决网络同步。你可以在单机里跑十万个 Agent 做 RTS 或者城市模拟但一旦要把这些实体的状态同步给多个客户端原生 Replication 系统就会直接跪。这就是 MassReplication 这个方向要解决的核心矛盾如何把 MassEntity 的大规模数据处理能力和网络同步的带宽约束、延迟容忍、一致性要求结合起来。标题里的“43”大概率是某个系列文章的编号说明这是一个持续深入的话题而不是一篇能讲完的东西。1.2 MassReplication 到底在解决什么先把问题定义清楚。假设你有一个 RTS 游戏地图上有 2000 个单位在移动、攻击、采集。每个单位有位置、朝向、血量、状态机、目标等属性。如果用传统的 Actor Replication每个单位是一个 Actor每个 Actor 每帧或每 Tick 都要走属性同步流程2000 个 Actor 的属性对比、序列化、RPC 调用会直接把 GameThread 打满带宽方面假设每个单位每帧同步 20 字节2000 个单位就是 40KB/帧60 帧就是 2.4MB/s这还没算可靠通道的开销MassReplication 的思路完全不同。它不把每个单位当成独立的网络实体而是把“一批实体”当成一个同步单元。核心手段包括批量序列化把同一帧内所有变化实体的数据打包成一个二进制块而不是逐个属性对比增量同步只同步发生变化的实体静止的单位不占带宽LOD 分级远处的单位降低同步频率近处的单位保持高频率客户端预测与插值客户端本地模拟一部分逻辑减少对服务器的依赖这套思路的本质是把网络同步从“面向对象”变成“面向数据”这和 MassEntity 本身的 ECS 哲学是一致的。1.3 适合谁来参考这篇内容适合以下几类人正在用 UE5 做 RTS、MOBA、SLG 或者大规模多人沙盒的开发者已经用过 MassEntity 做单机模拟想把它扩展到多人环境的团队对网络同步底层机制感兴趣想了解批量同步和增量同步实现细节的程序员正在评估“到底用 Actor Replication 还是自研同步层”的技术负责人如果你只是做 4 人合作射击那原生 Replication 够用不需要往下看。但如果你面对的是几百上千个动态单位那 MassReplication 这套东西值得花时间研究。2. 核心架构设计与方案选型2.1 整体分层从 MassEntity 到网络层一个完整的 MassReplication 系统通常分成四层我从下往上说第一层是 MassEntity 模拟层。这一层跑在服务器上负责所有实体的逻辑运算移动、寻路、战斗、状态转换。Processor 每帧处理所有 Chunk产出最新的实体状态。这一层不关心网络只关心算得对不对、快不快。第二层是状态采集与差异检测层。这一层负责从 MassEntity 的 Fragment 里提取需要同步的字段和上一帧的快照做对比找出变化的实体。关键设计是不是所有 Fragment 都需要同步只有标记了Replicated的才进同步队列。第三层是序列化与打包层。把变化实体的数据按固定格式写成二进制流。这里要考虑字节对齐、压缩、量化。比如位置可以用 16 位定点数代替 32 位浮点朝向可以用 8 位表示角度。第四层是网络传输层。通过 UE 的 NetDriver 或者自定义的 UDP 通道把数据包发出去。客户端收到后反序列化更新本地的 MassEntity 或者 Actor 表现。这个分层的意义在于每一层都可以独立优化。模拟层可以换 Processor采集层可以调同步频率序列化层可以改压缩算法传输层可以换协议。耦合度低调试也方便。2.2 为什么不用原生 Replication很多人第一反应是UE 不是有 Replication Graph 吗不能直接用吗Replication Graph 确实比默认的 Replication 强它支持空间分区、频率分级、 dormancy 等特性。但它的底层仍然是 Actor 级别的属性同步。2000 个 Actor 意味着 2000 个 NetGUID、2000 个属性通道、2000 次序列化调用。即使 Replication Graph 帮你做了空间裁剪剩下的开销依然很大。更关键的是MassEntity 的实体不是 Actor。你可以给每个实体创建一个 Actor 来承载同步但这等于把 MassEntity 的优势全扔了——你又回到了 Actor 的世界内存布局散了缓存命中率掉了CPU 又上去了。所以 MassReplication 的核心决策是绕开 Actor Replication自建一套面向 MassEntity 的同步通道。这不是说完全不用 UE 的网络层而是说不用它的属性同步机制只借用它的连接管理、RPC 通道和可靠性保证。2.3 同步频率与 LOD 策略大规模同步的第一个优化点就是频率。不是所有实体都需要每帧同步。我一般按距离和重要性分三档档位距离范围同步频率数据精度适用场景高0-30米每帧全精度玩家附近、战斗焦点中30-80米每3帧位置量化到0.1米视野内但非焦点低80米以上每10帧位置量化到1米远景、小地图这个分档不是拍脑袋定的。30米大约是玩家能清楚看到单位细节的距离80米是大多数 RTS 的默认视野边缘。频率方面每3帧同步一次在60帧下是20Hz对于移动单位来说足够平滑因为客户端有插值。实现上每个实体有一个ReplicationLOD组件服务器每帧根据实体和所有客户端的距离计算 LOD 等级然后决定这一帧是否要采集这个实体的数据。这里有个坑不同客户端的 LOD 可能不同所以采集时要按客户端分别打包不能全局只打一个包。2.4 增量同步与脏标记增量同步的核心是脏标记。每个需要同步的 Fragment 里加一个bDirty标志或者用一个独立的DirtyTag组件。Processor 在修改数据时顺手把脏标记置上。采集层每帧遍历所有带脏标记的实体把数据写入同步缓冲区然后清除脏标记。这样静止的单位完全不占带宽。但这里有个细节位置一直在变的单位怎么办。比如一个正在移动的单位它每帧都是脏的那增量同步对它没意义。这时候要靠量化和降频来省带宽而不是靠增量。另一个细节是首次同步。新进入玩家视野的单位需要全量同步一次包括所有属性。之后才走增量。所以采集层要维护每个客户端对每个实体的“已知状态”新实体走全量老实体走增量。2.5 可靠性通道与不可靠通道的取舍网络同步里最纠结的问题之一用可靠通道还是不可靠通道。可靠通道TCP 或 UE 的可靠 UDP保证数据一定到达但会有延迟抖动和队头阻塞。不可靠通道裸 UDP延迟低但会丢包。我的经验是混合使用实体生成和销毁走可靠通道。这些事件不能丢丢了会导致客户端实体数量对不上。位置和朝向走不可靠通道。丢一帧没关系下一帧就补上了客户端有插值。血量和状态走可靠通道。血量错了会影响游戏逻辑状态错了会导致表现异常。动画和特效触发走可靠通道但允许合并。比如连续触发同一个特效可以只发最后一次。这个策略的核心逻辑是能靠插值和预测弥补的走不可靠影响逻辑正确性的走可靠。3. 核心细节解析与实操要点3.1 数据结构设计同步什么怎么存先定义哪些数据需要同步。以一个 RTS 单位为例// 需要同步的核心 Fragment struct FReplicatedTransform { FVector_NetQuantize10 Location; // 量化到0.1米 uint8 Yaw; // 8位表示0-360度 uint8 LODLevel; // 当前LOD等级 }; struct FReplicatedHealth { uint16 CurrentHP; // 0-65535 uint16 MaxHP; uint8 StateFlags; // 位标记眩晕、隐身、无敌等 }; struct FReplicatedTarget { FEntityHandle TargetEntity; // 目标实体ID uint8 ActionType; // 攻击、移动、采集等 };注意几个设计决策位置用FVector_NetQuantize10。UE 自带这个类型把浮点位置量化到 0.1 米精度三个轴各用 16 位整数表示总共 6 字节。如果用原始FVector是 12 字节省了一半。对于 RTS 来说0.1 米精度完全够用玩家根本看不出差别。朝向只用 Yaw。大多数地面单位不需要 Pitch 和 Roll一个字节表示 256 个方向精度约 1.4 度足够。血量用 uint16。不要用 floatfloat 序列化要 4 字节uint16 只要 2 字节而且血量本来就是整数。实体 ID 用FEntityHandle。MassEntity 的实体句柄本身就是一个索引加版本号可以直接序列化。但要注意服务器和客户端的实体 ID 必须一致。这意味着客户端不能自己生成实体必须由服务器分配 ID 后同步给客户端。3.2 序列化格式手写二进制还是用引擎工具UE 提供了FArchive和FNetBitWriter两套序列化工具。我的建议是批量数据用FNetBitWriter。它支持位级别的写入可以把 bool 压成 1 位把枚举压成 3-4 位。对于大量实体来说位级别的节省很可观。调试阶段用FArchive。它可读性好方便打日志。上线前再换成FNetBitWriter。一个典型的批量序列化流程void SerializeEntityBatch(FNetBitWriter Writer, const TArrayFEntitySyncData Batch) { // 写入实体数量用变长编码 Writer.WriteIntWrapped(Batch.Num(), 1024); for (const auto Data : Batch) { // 实体ID用变长编码 Writer.WriteIntWrapped(Data.EntityId, MAX_ENTITIES); // 位置量化后写入 Writer.WriteIntWrapped(Data.Location.X, 65536); Writer.WriteIntWrapped(Data.Location.Y, 65536); Writer.WriteIntWrapped(Data.Location.Z, 65536); // 朝向8位 Writer.WriteIntWrapped(Data.Yaw, 256); // 状态标志按位写入 Writer.WriteBit(Data.bIsMoving); Writer.WriteBit(Data.bIsAttacking); Writer.WriteBit(Data.bIsStunned); } }这里的关键是WriteIntWrapped它用最少的位数表示一个范围内的整数。比如实体数量最多 1024就用 10 位。位置量化到 65536 个刻度用 16 位。这样每个实体的位置加朝向加状态大约 7 字节2000 个实体就是 14KB一帧发一次在 60 帧下是 840KB/s比原始方案省了三分之二。3.3 客户端插值与预测客户端收到同步数据后不能直接硬切位置否则会看到单位瞬移。必须做插值。标准做法是维护一个快照缓冲区保存最近几帧的服务器状态。客户端渲染时渲染时间比服务器时间延迟约 100ms然后在两个快照之间做线性插值。FVector InterpolatePosition(const FEntitySnapshot From, const FEntitySnapshot To, float Alpha) { return FMath::Lerp(From.Location, To.Location, Alpha); }100ms 的延迟是经验值。太小了插值缓冲不够网络抖动会导致卡顿太大了操作手感变差。RTS 可以接受 100-150msFPS 要控制在 50-80ms。预测方面MassReplication 一般不做完整的客户端预测因为大规模单位的预测回滚成本太高。但可以做局部预测玩家自己控制的单位在客户端立即响应输入同时把输入发给服务器服务器模拟后再校正。如果校正偏差小于阈值就不回滚超过阈值才硬拉。3.4 带宽控制与拥塞避免带宽是硬约束。假设你的服务器给每个客户端分配 256KB/s 的下载带宽那同步数据不能超过这个数。控制手段有几个优先级队列。把所有需要同步的实体按重要性排序重要的先发。重要性可以按距离、是否在战斗、是否是玩家单位来算。带宽不够时低优先级的实体直接跳过这一帧。动态降频。当带宽使用率超过 80% 时自动把中远距离实体的同步频率降一档。带宽降下来后再恢复。数据压缩。对批量数据做 LZ4 压缩通常能压到 60-70%。但压缩有 CPU 开销要在服务器 CPU 和带宽之间权衡。我的经验是实体数量超过 500 时压缩划算低于 500 时压缩的 CPU 开销比省下的带宽更贵。拥塞检测。如果连续几帧的发送队列都在堆积说明带宽不够了要主动降级。UE 的 NetDriver 有内置的拥塞控制但自建通道要自己实现。3.5 实体生命周期同步实体的生成和销毁是最容易出 bug 的地方。生成服务器创建一个 MassEntity 后分配一个网络 ID把生成事件发给所有相关客户端。客户端收到后创建一个本地实体挂上表现组件Mesh、动画等。这里要注意生成事件必须可靠丢了会导致客户端少一个单位。销毁服务器销毁实体前先发销毁事件等客户端确认后再真正销毁。或者用一个“墓碑”机制实体销毁后保留 ID 一段时间防止迟到的同步包引用已销毁的实体。归属切换当一个单位从玩家 A 的视野进入玩家 B 的视野时要处理同步通道的切换。我的做法是每个客户端维护自己的“已知实体集合”服务器每帧对比当前应该同步的集合和已知集合差集就是需要生成或销毁的。注意实体 ID 的分配要用全局计数器不要用 MassEntity 的本地索引。因为不同客户端的实体创建顺序可能不同本地索引会对不上。4. 实操过程与核心环节实现4.1 服务器端从 MassEntity 采集同步数据先定义一个 SyncProcessor它在 MassEntity 的 Tick 流程中运行负责采集脏数据。UCLASS() class UMassReplicationProcessor : public UMassProcessor { GENERATED_BODY() public: UMassReplicationProcessor() { ExecutionFlags (int32)EProcessorExecutionFlags::Server; ProcessingPhase EMassProcessingPhase::PrePhysics; } protected: virtual void Execute(UMassEntitySubsystem EntitySubsystem, FMassExecutionContext Context) override { // 查询所有带同步标记的实体 EntityQuery.AddRequirementFReplicatedTransform(EMassFragmentAccess::ReadOnly); EntityQuery.AddRequirementFReplicatedHealth(EMassFragmentAccess::ReadOnly); EntityQuery.AddTagRequirementFReplicatedTag(EMassFragmentPresence::All); EntityQuery.ForEachEntityChunk(EntitySubsystem, Context, [this](FMassExecutionContext Context) { const auto Transforms Context.GetFragmentViewFReplicatedTransform(); const auto Healths Context.GetFragmentViewFReplicatedHealth(); for (int32 i 0; i Context.GetNumEntities(); i) { FEntitySyncData Data; Data.EntityId GetNetworkId(Context.GetEntity(i)); Data.Location Transforms[i].Location; Data.Yaw Transforms[i].Yaw; Data.CurrentHP Healths[i].CurrentHP; // 写入同步缓冲区 SyncBuffer.Add(Data); } }); } };这个 Processor 每帧跑一次把所有需要同步的实体数据收集到SyncBuffer。注意这里没有做差异检测差异检测放在后面的打包阶段因为打包时才知道每个客户端的已知状态。4.2 差异检测与按客户端打包每个客户端有自己的“已知实体状态”缓存。打包时对比当前状态和缓存只发变化的部分。void BuildPacketForClient(FClientSyncState ClientState, FNetBitWriter Writer) { TArrayFEntitySyncData ChangedEntities; for (const auto Data : SyncBuffer) { // 检查LOD决定这一帧是否同步 if (!ShouldSyncThisFrame(ClientState, Data.EntityId)) continue; // 查找客户端已知状态 FEntitySyncData* Known ClientState.KnownEntities.Find(Data.EntityId); if (!Known) { // 新实体全量同步 ChangedEntities.Add(Data); ClientState.KnownEntities.Add(Data.EntityId, Data); } else if (HasChanged(*Known, Data)) { // 已有实体增量同步 ChangedEntities.Add(Data); *Known Data; } } // 序列化变化实体 SerializeEntityBatch(Writer, ChangedEntities); // 处理销毁的实体 TArrayuint32 DestroyedIds; for (auto Pair : ClientState.KnownEntities) { if (!SyncBuffer.ContainsEntity(Pair.Key)) { DestroyedIds.Add(Pair.Key); } } SerializeDestroyedEntities(Writer, DestroyedIds); // 从已知集合中移除已销毁的 for (uint32 Id : DestroyedIds) { ClientState.KnownEntities.Remove(Id); } }HasChanged函数要做量化对比不能直接比浮点。比如位置量化到 0.1 米后再比否则浮点误差会导致每帧都判定为“变化”。4.3 客户端接收、反序列化与表现更新客户端收到包后反序列化更新本地实体状态然后驱动表现层。void OnSyncPacketReceived(FNetBitReader Reader) { int32 EntityCount; Reader.ReadIntWrapped(EntityCount, 1024); for (int32 i 0; i EntityCount; i) { FEntitySyncData Data; Reader.ReadIntWrapped(Data.EntityId, MAX_ENTITIES); Reader.ReadIntWrapped(Data.Location.X, 65536); Reader.ReadIntWrapped(Data.Location.Y, 65536); Reader.ReadIntWrapped(Data.Location.Z, 65536); Reader.ReadIntWrapped(Data.Yaw, 256); // 更新本地快照缓冲区 UpdateSnapshotBuffer(Data.EntityId, Data); } // 处理销毁 int32 DestroyedCount; Reader.ReadIntWrapped(DestroyedCount, 256); for (int32 i 0; i DestroyedCount; i) { uint32 Id; Reader.ReadIntWrapped(Id, MAX_ENTITIES); DestroyLocalEntity(Id); } }快照缓冲区保存最近 10 帧的数据渲染时取当前渲染时间对应的两个快照做插值。4.4 表现层从 MassEntity 到 Actor客户端收到同步数据后需要把数据映射到可见的表现。这里有两种方案方案 A每个实体创建一个 Actor。简单直接可以用 UE 的动画蓝图、材质、特效系统。但 Actor 数量多了性能差2000 个 Actor 在低端机上会卡。方案 B用 ISMInstanced Static Mesh批量渲染。所有单位用同一个 ISM 组件渲染每个实例的位置和朝向从同步数据更新。性能好但动画和特效支持弱。我的建议是混合方案近距离的单位用 Actor远距离的用 ISM。当单位从远变近时销毁 ISM 实例创建 Actor从近变远时反过来。这个切换逻辑要处理好避免闪烁。4.5 调试与可视化工具大规模同步没有好的调试工具就是灾难。我一般会做几个东西同步数据包可视化。在屏幕上显示每帧发送的实体数量、字节数、各 LOD 档位的分布。实体同步状态覆盖层。在游戏世界里给每个实体画一个圈绿色表示正在同步黄色表示降频红色表示不同步。一眼就能看出哪些实体有问题。带宽曲线图。实时显示发送和接收带宽以及丢包率。带宽突然飙升时能立刻发现。回放系统。把服务器每帧的同步数据录下来可以回放对比客户端表现。排查“为什么这个单位在客户端位置不对”这类问题时特别有用。实操心得调试大规模同步时先把实体数量降到 10 个确保逻辑正确。然后逐步加到 100、500、2000每加一档观察性能曲线。不要一上来就 2000 个实体调那样你根本不知道是逻辑问题还是性能问题。5. 常见问题与排查技巧实录5.1 实体位置抖动或瞬移这是最常见的问题。原因通常有三个插值缓冲不足。如果客户端只保存了 2 帧快照网络抖动时插值会跳变。解决方法是把快照缓冲区加到 5-10 帧渲染延迟调到 100-150ms。服务器时间戳不同步。客户端和服务器的时间基准不一致导致插值计算错误。解决方法是服务器在每个包里带上服务器时间戳客户端根据时间戳对齐。量化精度不够。位置量化到 1 米时慢速移动的单位会出现“跳格”。解决方法是近距离单位用高精度量化远距离才用低精度。排查步骤先看服务器端的实体位置是否正确。如果服务器就不对那是模拟层的问题。再看客户端收到的原始数据是否正确。打日志对比服务器发送和客户端接收的值。最后看插值逻辑。把插值关掉直接硬切位置如果硬切是对的但插值不对那就是插值算法的问题。5.2 带宽突然飙升带宽飙升通常是因为某个事件导致大量实体同时变脏。比如一次 AOE 技能命中了 500 个单位所有单位的血量同时变化玩家切换视角大量新实体进入视野触发全量同步服务器卡顿后恢复积压的同步数据一次性发出应对策略限流。每帧最多发送 N 个实体的数据超出的排队到下一帧。N 根据带宽预算算比如 256KB/s 的预算每帧最多发 4KB大约 500 个实体。合并。血量变化可以合并只发最终值。状态变化可以合并只发最终状态。分批全量同步。新进入视野的实体不要一帧全发分 3-5 帧发完平滑带宽曲线。5.3 客户端实体数量对不上服务器有 2000 个实体客户端只有 1998 个。这种问题通常是生成或销毁事件丢了。排查方法在服务器端记录每个实体的生成和销毁事件打上时间戳在客户端记录收到的生成和销毁事件对比两边的事件序列找出丢失的事件预防措施生成和销毁走可靠通道定期做全量对账。比如每 10 秒服务器发一个实体 ID 列表的校验和客户端对比自己的列表不一致就请求全量同步5.4 不同客户端的 LOD 不一致导致表现差异玩家 A 看到单位在平滑移动玩家 B 看到同一个单位在跳格。这是因为两个客户端的 LOD 等级不同。这个问题严格来说不是 bug是设计取舍。但如果你希望所有客户端表现一致那就不能用基于距离的 LOD而要用基于重要性的 LOD。比如玩家自己的单位永远高 LOD盟友的单位中 LOD敌人的单位低 LOD。这样同一阵营的客户端表现一致。5.5 常见问题速查表问题现象可能原因排查方法解决方案单位瞬移插值缓冲不足检查快照缓冲区大小增加到5-10帧带宽飙升大量实体同时变脏打日志看变脏实体数限流合并分批实体数量对不上生成/销毁事件丢失对比两端事件序列走可靠通道定期对账位置抖动量化精度不够对比原始值和量化值提高近距离量化精度客户端卡顿Actor数量过多看Profiler的Actor数远距离改用ISM同步延迟高可靠通道队头阻塞看可靠通道队列长度位置走不可靠通道内存泄漏实体销毁后未清理看内存曲线检查销毁逻辑和引用5.6 独家避坑技巧技巧一用固定时间步长做同步。不要用帧率做同步基准用固定时间步长比如 1/30 秒。这样帧率波动时同步频率不变带宽更稳定。技巧二同步数据里带一个序列号。客户端收到乱序的包时按序列号排序后再处理。UDP 不保证顺序没有序列号会导致旧数据覆盖新数据。技巧三实体 ID 用 64 位。32 位在长时间运行的服务器上可能溢出。64 位虽然多占 4 字节但省去了 ID 回收的麻烦。技巧四定期做全量同步。不管增量同步做得多好总会有累积误差。每 30-60 秒做一次全量同步把客户端状态拉回正轨。技巧五服务器端做同步预算。给每个客户端分配一个带宽预算每帧的同步数据不能超过预算。超了就降级不要硬发。硬发的后果是丢包率上升反而更糟。技巧六客户端做平滑校正。当服务器校正客户端预测时不要瞬间拉过去用 0.5-1 秒的时间平滑过渡。玩家几乎察觉不到但体验好很多。技巧七用二进制日志排查问题。把每帧的同步数据写成二进制文件出问题时可以精确回放。文本日志在大规模数据下根本看不过来。技巧八压力测试要模拟真实场景。不要只测静止单位要测移动、战斗、技能释放、单位死亡等各种场景。很多 bug 只在特定场景下出现。技巧九客户端和服务器的 MassEntity 配置要一致。Archetype 的 Fragment 组合、Processor 的执行顺序、Tick 频率都要对齐。不一致会导致模拟结果偏差。技巧十不要同步不需要的东西。每个字段都要问客户端真的需要这个吗能本地算出来吗能推导出来吗每去掉一个字段带宽就省一点CPU 就省一点。5.7 性能优化 checklist上线前对照这个清单过一遍[ ] 位置是否量化量化精度是否合理[ ] 朝向是否只用 Yaw是否用 8 位表示[ ] 血量是否用整数是否用最小位宽[ ] 状态标志是否用位域是否合并了多个 bool[ ] 是否做了 LOD 分级分级阈值是否合理[ ] 是否做了增量同步脏标记是否正确[ ] 是否做了带宽预算超预算时是否有降级策略[ ] 是否做了插值插值延迟是否合理[ ] 是否做了预测校正是否平滑[ ] 生成/销毁是否走可靠通道[ ] 是否定期全量对账[ ] 是否有调试可视化工具[ ] 是否做了压力测试测试场景是否覆盖真实情况这套东西我在实际项目里跑过 3000 单位的 RTS 场景服务器单核能扛住客户端在中等配置机器上能跑 60 帧。关键是把 LOD、增量、量化、限流这四个手段都用上缺一个都会导致性能不达标。MassReplication 不是什么黑科技就是把 ECS 的数据导向思维应用到网络同步上把该省的省掉该批的批掉该降的降掉。
返回列表