World States を破壊する Unreal Engine Multiplayer Sync Bug とその修正方法
数ヶ月かけて、大規模でシネマティックなワールドの変貌を作り上げたとします。シングルプレイヤーでは完璧に動作します。古い幽霊屋敷が地面に沈み、新しい、美しいバージョンが深淵から完璧なタイミングで浮上します。しかし、2人目のプレイヤーがサーバーに接続した瞬間、あなたの傑作はプレイ不可能な、オブジェクトが重なり合う悪夢へと変わります。家が融合し、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 システムがなぜあなたのトランスフォーム・シーケンスで「窒息」しているのか、その背後にある計算を理解する必要があります。
あなたのシーケンスが、「家1」と「家2」を入れ替えるために約450個の個別のアイテム(壁、プロップ、照明)を動かすと仮定しましょう。Replicated 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 に割り当てます。タイムラインが切り替わるとき、単に最初のレイヤーをアンロードし、2番目のレイヤーをロードするだけです。
これにより、帯域幅のボトルネックが完全に解消されます。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_PlayHouseTransformation() のような Multicast RPC を使用すると、それはその瞬間にサーバーに接続しているクライアントでのみ実行されます。プレイヤーがクラッシュして30秒後に再接続した場合、彼らはその RPC を逃してしまいます。結果として、他の全員が「家2」を見ている中で、そのプレイヤーだけが「家1」を見ることになります。
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 を初期化するだけで、プレイヤーは中断した場所からシームレスに再開できます。データベースのマイグレーションを書く代わりに、ホラーゲームのデザインに集中できるのです。
バックエンドとの即時かつ双方向の通信が必要な場合(パッチを必要とせずにワールドステートをグローバルに変更するライブ運用イベントのトリガーなど)は、Ditch Http Polling An Unreal Engine Websockets Tutorial For Real Time Backends の解説も読んでみてください。
マルチプレイヤー状態同期の 5 つのベストプラクティス
二度と壊滅的な Unreal Engine Multiplayer Sync Bug に直面しないために、以下のルールをアーキテクチャに組み込んでください:
- 論理的な状態に Sequences を使用しない: Cinematic Sequence Devices や Timelines は、厳密にビジュアル演出(VFX、カメラシェイク、ローカルUI)のために使用すべきです。ゲームプレイに影響を与える変数の設定を、タイムラインの終了に依存させてはいけません。
- イベントには RPC、状態には RepNotify: 一時的なイベント(手榴弾の爆発、サウンドの再生)には Multicast RPC を使用します。永続的で継続的な状態(ドアが開いている、家が変貌している、発電機が稼働している)には、RepNotify 付きの Replicated 変数を使用します。
- 帯域幅制限を尊重する: ネットワークプロファイラ (
Stat Net) を監視してください。50〜100個以上の Actor のトランスフォームを同時にレプリケートしている場合、おそらくチャネルを飽和させています。めったに動かないプロップには Network Dormancy (ENetDormancy::DORM_Initial) を使用してください。 bAlwaysRelevantを慎重に設定する: グローバルステートマネージャー(今回のAWorldStateManagerなど)では、bAlwaysRelevant = trueを確保してください。この Actor がプレイヤーのネットワークカリング距離から外れると、アップデートの受信が止まり、局所的な desync につながります。- Server Authority は絶対: クライアントはサーバーに「リクエスト」を送信するだけであるべきです(例:
Server_RequestInteract())。サーバーがリクエストを検証し、Replicated 変数を更新し、Replication システムにビジュアルの変更をすべてのクライアントへ伝播させます。
エンジンと戦うのをやめよう
マルチプレイヤーゲームの開発は非常に困難ですが、同期バグの90%は、クライアント側のツールにサーバー側の仕事をさせようとすることから生じます。ブルートフォースなトランスフォーム操作から Data Layers に切り替え、ローカルトリガーの代わりに RepNotifies を活用することで、ゲームを Unreal Engine が意図したネットワークアーキテクチャに適合させることができます。
インフラの悩みを抱えずにマルチプレイヤーバックエンドをスケールさせ、ワールドステートを永続化する準備はできましたか?horizOn を無料で試すか、API docs をチェックして、永続的なクラウドステートを Unreal プロジェクトにいかに簡単に統合できるかを確認してください。