返回博客

破坏世界状态的 Unreal Engine Multiplayer Sync Bug(及其修复方法)

发布于 2026年2月23日
破坏世界状态的 Unreal Engine Multiplayer Sync Bug(及其修复方法)

你花了几个月的时间构建了一个宏大的、电影级的世界变换。在单人模式下,它执行得天衣无缝。古老的闹鬼房子沉入地下,崭新的版本如约从深处升起。但当第二个玩家连接到服务器的那一刻,你的杰作就变成了一场无法进行的、重叠的噩梦。房屋合并,Collision 损坏,你的玩家被困在几何体的炼狱中。

每个多人游戏独立开发者最终都会遇到这样一堵墙:客户端的视觉逻辑与 Server-Authoritative 的现实发生了剧烈碰撞。如果你试图使用 Cinematic Sequence Device 或由本地玩家事件触发的 timeline 动画来移动数百个资源,你实际上是在诱发 Unreal Engine Multiplayer Sync Bug。

在本教程中,我们将深入分析为什么移动大量 Actor 会导致灾难性的 desync,为什么 Cinematic Sequences 对 Late-joiners 无效,以及如何使用 C++ 和 Unreal Engine 5 的 Data Layers 构建一个坚如磐石的 Server-Authoritative 世界状态管理器。

Desync 的剖析:为什么你的房子会合并

要解决这个问题,你首先需要了解 Unreal Engine 的 Replication 系统在处理你的变换序列时出现“窒息”背后的数学原理。

假设你的序列移动了大约 450 个单独的资源(墙壁、道具、灯光)来将“房子 1”更换为“房子 2”。当你移动一个被复制的 Actor 时,Unreal Engine 使用 FRepMovement 结构体来在网络上同步其位置、旋转和速度。

一个标准的压缩移动更新每个 Actor 大约消耗 40 到 50 字节。

如果 450 个 Actor 在 5 秒的电影序列中同时移动,以每秒 30 次的适度频率更新,计算结果如下: 450 actors × 50 bytes × 30 updates/sec = 675,000 bytes per second (675 KB/s)。

Unreal Engine 默认的 MaxClientRate(允许服务器发送给单个客户端的最大带宽)通常限制在 15,000 到 100,000 字节/秒之间。

你的序列需求几乎是可用带宽的 7 倍。网络通道瞬间饱和。服务器开始激进地限制更新、丢包,并根据 NetPriority 优先处理其他 Actor。结果,你的一半“房子 1”资源停在地下深处,一半“房子 2”资源从未到达地面。你留下了一个永久合并、不同步的烂摊子。

此外,如果你通过客户端事件(例如玩家踩入 trigger box)在本地触发此序列,那么 10 分钟后加入服务器的玩家将永远不会执行该序列。他们将看到默认的地图状态,而第一个玩家看到的是变换后的状态。

第 1 步:放弃 Transform 操作,改用 Data Layers

移动 450 个 Actor 是一种浪费 CPU 周期和网络带宽的暴力方法。在 Unreal Engine 5 中,处理大规模世界变化的正确架构方法是 Data Layers(Level Streaming 的进化版)。

你不再将“房子 1”移动到地下,而是将所有房子 1 的资源分配给 House1_DataLayer,将所有房子 2 的资源分配给 House2_DataLayer。当时间线切换时,你只需卸载第一层并加载第二层。

这完全消除了带宽瓶颈。服务器不再流式传输 675 KB/s 的连续移动数据,而是发送一个微小的状态更新:“Data Layer 2 现在处于活动状态。” 客户端的本地引擎会无缝地从磁盘处理加载。

第 2 步:构建 Server-Authoritative 状态管理器

为了确保每个玩家(包括晚加入的玩家)都能看到完全相同的 World State,我们需要一个核心真相来源。我们将在 C++ 中创建一个 WorldStateManager Actor,使用 RepNotify 变量来跟踪房屋的当前时代。

头文件 (WorldStateManager.h)

我们需要一个 Enum 来定义我们的状态,以及一个带有 ReplicatedUsing 条件的 Replicated 变量。

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Info.h"
#include "WorldDataLayers/WorldDataLayers.h"
#include "WorldStateManager.generated."

UENUM(BlueprintType)
enum class EWorldEraState : uint8
{
    Past_House1 UMETA(DisplayName = "Past (House 1)"),
    Future_House2 UMETA(DisplayName = "Future (House 2)")
};

UCLASS()
class MYGAME_API AWorldStateManager : public AInfo
{
    GENERATED_BODY()

public:
    AWorldStateManager();

    // The server-side function to trigger the transformation
    UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly, Category = "World State")
    void AdvanceWorldEra();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

    // The replicated variable tracking our current state
    UPROPERTY(ReplicatedUsing = OnRep_CurrentEra, Transient)
    EWorldEraState CurrentEra;

    // The RepNotify function that fires on clients when CurrentEra changes
    UFUNCTION()
    void OnRep_CurrentEra();

    // Helper to toggle Data Layers
    void UpdateDataLayers(EWorldEraState NewState);
};

实现文件 (WorldStateManager.cpp)

这是见证奇迹的时刻。注意我们如何使用 DOREPLIFETIME 来注册变量,以及 OnRep 函数如何保证视觉状态与逻辑状态匹配。

#include "WorldStateManager.h"
#include "Net/UnrealNetwork.h"
#include "Engine/World.h"
#include "WorldPartition/DataLayer/DataLayerSubsystem.h"

AWorldStateManager::AWorldStateManager()
{
    bReplicates = true;
    bAlwaysRelevant = true; // Ensure all players always receive updates for this actor
    CurrentEra = EWorldEraState::Past_House1;
}

void AWorldStateManager::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    
    // Replicate to all clients
    DOREPLIFETIME(AWorldStateManager, CurrentEra);
}

void AWorldStateManager::BeginPlay()
{
    Super::BeginPlay();
    
    // Ensure the initial state is set correctly on the server
    if (HasAuthority())
    { 
        UpdateDataLayers(CurrentEra);
    }
}

void AWorldStateManager::AdvanceWorldEra()
{
    // Only the server can change the era
    if (!HasAuthority()) return;

    CurrentEra = EWorldEraState::Future_House2;
    
    // The server updates its own local Data Layers immediately
    UpdateDataLayers(CurrentEra);
}

// This fires automatically on clients when the server changes CurrentEra
void AWorldStateManager::OnRep_CurrentEra()
{
    UpdateDataLayers(CurrentEra);
}

void AWorldStateManager::UpdateDataLayers(EWorldEraState NewState)
{
    UWorld* World = GetWorld();
    if (!World) return;

    UDataLayerSubsystem* DataLayerSubsystem = World->GetSubsystem<UDataLayerSubsystem>();
    if (!DataLayerSubsystem) return;

    // Pseudocode for Data Layer toggling - replace with your specific Data Layer Asset references
    if (NewState == EWorldEraState::Past_House1)
    {
        // Load House 1, Unload House 2
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Unloaded);
    }
    else if (NewState == EWorldEraState::Future_House2)
    {
        // Load House 2, Unload House 1
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House2Layer, EDataLayerRuntimeState::Activated);
        // DataLayerSubsystem->SetDataLayerInstanceRuntimeState(House1Layer, EDataLayerRuntimeState::Unloaded);
    }
}

第 3 步:解决 Late-Joiner 问题

开发者在尝试修复 Unreal Engine Multiplayer Sync Bug 时犯的最大错误是使用 Multicast RPCs (Remote Procedure Calls) 来触发世界事件。

如果你使用 Multicast RPC 来执行 Multicast_PlayHouseTransformation(),它只会在那一毫秒正连接到服务器的客户端上执行。如果一个玩家掉线并在 30 秒后重新连接,他们就错过了这个 RPC。他们加载地图后会看到房子 1,而其他所有人看到的都是房子 2。

通过使用 UPROPERTY(ReplicatedUsing = OnRep_CurrentEra),我们自动解决了 Late-joiner 问题。当新玩家连接时,服务器会向他们发送 CurrentEra 的当前值。由于他们收到的值 (Future_House2) 与其默认初始值 (Past_House1) 不同,Unreal Engine 会在他们加载的那一刻自动为该特定客户端触发 OnRep_CurrentEra()。他们会立即加载正确的 Data Layer。无需自定义加入逻辑。

如果你正在构建较小的基于会话的原型,请查看我们的指南:How To Architect A Local Co Op Shooter Prototype In Unreal Engine Step By Step

在游戏会话之外持久化世界状态

上述 C++ 解决方案对于单个运行的服务器实例非常完美。但如果你的服务器崩溃了怎么办?或者如果你正在构建一个持久的生存恐怖游戏,其中“时代”需要在数周的游戏过程中保持保存,即使所有玩家都下线且服务器关闭?

这就是仅依靠 Unreal Engine 的内存 Replication 的不足之处。为了持久化全局世界状态,你需要一个后端数据库。

自己构建这套系统需要设置 PostgreSQL 数据库、编写 REST APIs 来处理状态序列化、管理服务器身份验证以及配置 auto-scaling 基础设施——这很容易耗费 4-6 周乏味的后端开发时间。

通过 horizOn,这些后端服务是预先配置好的。你可以通过我们的 SDK 将世界状态更改直接推送到托管的 Game State 数据库。当你的专用服务器启动时,它只需查询 horizOn 后端,检索 {"CurrentEra": "Future_House2"},初始化 WorldStateManager,你的玩家就可以无缝地从他们离开的地方继续。你可以专注于设计恐怖游戏,而不是编写数据库迁移。

如果你的游戏需要与后端进行即时的双向通信(例如,触发改变全球世界状态的 live-ops 事件而无需补丁),你还应该阅读我们关于如何 Ditch Http Polling An Unreal Engine Websockets Tutorial For Real Time Backends 的分析。

多人游戏状态同步的 5 个最佳实践

为了确保你不再面临灾难性的 Unreal Engine Multiplayer Sync Bug,请将这些规则融入你的架构中:

  1. 永远不要将 Sequences 用于逻辑状态: Cinematic Sequence Devices 和 Timelines 应严格用于视觉效果(VFX、相机抖动、本地 UI)。永远不要依赖时间线的结束来设置影响游戏玩法的变量。
  2. RPC 用于事件,RepNotifies 用于状态: 使用 Multicast RPC 处理瞬时的、临时事件(手榴弹爆炸、播放声音)。使用带有 RepNotifies 的 Replicated 变量处理持久的、持续的状态(门是开着的、房子已变换、发电机已通电)。
  3. 遵守带宽限制: 监控你的网络分析器 (Stat Net)。如果你同时为超过 50-100 个 Actor 复制 Transform,你可能已经使通道饱和了。对于很少移动的道具,使用 Network Dormancy (ENetDormancy::DORM_Initial)。
  4. 谨慎设置 bAlwaysRelevant 对于全局状态管理器(如我们的 AWorldStateManager),确保 bAlwaysRelevant = true。如果此 Actor 超出玩家的网络剔除距离,他们将停止接收更新,从而导致局部 desync。
  5. Server Authority 是绝对的: 客户端只能向服务器发送“请求”(例如 Server_RequestInteract())。服务器验证请求,更新 Replicated 变量,并让 Replication 系统将视觉更改传播回所有客户端。

停止与引擎对抗

多人游戏开发极其困难,但 90% 的同步错误源于试图强迫客户端工具执行服务器端任务。通过从暴力 Transform 操作切换到 Data Layers,并利用 RepNotifies 而不是本地触发器,你可以使你的游戏符合 Unreal Engine 预期的网络架构。

准备好扩展你的多人游戏后端并持久化你的世界状态,而无需担心基础设施问题了吗?免费试用 horizOn 或查看 API docs,了解如何轻松地将持久云状态集成到你的 Unreal 项目中。


来源: Houses Merged Weirdly HELPPPP