こんにちは、ISID金融ソリューション事業部の孫です。
この記事は、私がUnreal Engine(以下UE)のネットワーク同期(以下レプリケーション)に関する知識を学んだ知見です。
UEのレプリケーション機能は、マルチプレイヤーゲームの開発において非常に重要なコアな機能です。
Web上に公開されているUEのレプリケーションプログラミングは、現在BluePrintを用いたビジュアルプログラミングが主となっています。
確かにBluePrintは便利で迅速な開発が可能ですが、UEの内部動作ロジックをより深く理解するためにはC++プログラミングが不可欠です。
この記事では、C++を使用してシンプルなネットワーク同期のデモを実装する方法を紹介します。このデモの開発を通じて、UEのレプリケーション機能の実装方法を学ぶことができます。
はじめに
UEのレプリケーション部分について触れると、Dedicated Serverという概念をまず理解する必要があります。
ゲームネットワークアーキテクチャにおいてDedicated Serverが導入された背景については、金融ソリューション事業部の山下さんの記事 を参照していただければと思います。
UEのDedicated Serverについて、UEのクライアントコードとサーバーコードは一体であることが特徴です。
通常、一般的なフロントエンドとバックエンドの分離とは異なり、UEではクライアントとサーバーが同じプロジェクト内に存在します。このため、クライアントとサーバーのコードは混在していることになります。
※コードの間で以下のマクロを使ってサーバーコードとクライアントコードの区別が可能:
WITH_EDITOR
: コードがエディタ環境で動作しているときにTrueになります。UE_SERVER
: コードがサーバー環境で動作しているときにTrueになります。UE_CLIENT
: コードがクライアント環境で動作しているときにTrueになります。
Dedicated Serverが必要な理由は、C/S(クライアント/サーバー)モードではサーバーがクライアントの業務も担当するため、運用負荷が高くなるからです。Dedicated Serverの導入により、クライアントとサーバーの役割が分離され、負荷を軽減できます。
Dedicated Serverは、UEがFPSの同期問題を解決するために設計された専用のサーバーです。また、Dedicated ServerはEpicが開発した特別な最適化されたネットワークプロトコルを使用しており、高性能な同期(遅延問題の解決)を実現できます。
Dedicated Serverの構築方法については、以前の記事を参照してください。
開発環境/ツール
- Unreal Engine 5.2.0
- Windows10 21H2 x64
- RAM 16GM, SSD 1TB
- NVIDIA GeForce GTX 3080
- Visual Studio 2019 version 16.11.21(以下VS2019)
それでは、デモの制作を開始しましょう。以下の手順で進めていきます。
※デモはUEのサードパーソンテンプレートの上でレプリケーション機能を実装
- UEのネットワーク知識とActorの権限確認(ユーザーネーム表示用キャラクターの作成含め)
- ユーザー名入力画面の作成
- ユーザーネームのレプリケーション実装
- Dedicated Server側の実装
- デモの確認
このような手順でデモの制作を進めていくと、ユーザーネームを持つキャラクターを生成し、それを全てのクライアントで同期できます。
1.UEのネットワーク知識とActor権限の確認
本番の作成を開始する前に、まずは2点の前提知識を説明します。
UEのネットワークモデル
UEのネットワークモデルでは、Actorのレプリケーションを通じてゲームオブジェクトの状態をクライアント間で同期します。これにより、各クライアントが一貫したゲームワールドを見ることができます。
UEのネットワークモデルには、いくつかのキーポイントと概念があります:
Actor Replication(アクターレプリケーション)
:Unreal Engineでは、各ゲームオブジェクトはActorと呼ばれます。サーバー内のActorの状態はクライアントにレプリケーション(複製)される可能性があり、これを「レプリケーション」と呼びます。どのActorがレプリケーションされ、どのようにレプリケーションされるかは、開発者が特定の属性と関数を設定することで決定されます。State Synchronization(状態同期)
:サーバーは自身の状態をネットワークを通じて各クライアントに送信し、全てのクライアントが一貫したゲームワールドを見ることができるようにします。この過程を状態同期と呼びます。これがサーバーコンテンツを各クライアントに分散する必要性の理由です。この過程がなければ、クライアントは古いか、または一貫性のないゲームワールドの状態を見ることになりゲーム体験が低下します。Client Prediction(クライアント予測)
:ネットワークの遅延がゲーム体験に影響を与えるのを減らすために、クライアントは「クライアント予測」と呼ばれる技術を使用します。つまり、サーバーからの応答が到着する前に、クライアントはあらかじめいくつかのアクションを実行します。サーバーからの応答を受け取ったら、クライアントは自身の状態を調整してサーバーの状態に一致させます。Lag Compensation(ラグ補償)
:これはネットワーク遅延の影響を減らす別のテクニックです。サーバーは、クライアントがリクエストを発行した時点のゲーム状態に戻って、その状態でリクエストされた操作を実行します。
これらのコンポーネントやテクニックを組み合わせることで、UEのネットワークモデルは安定性と効率性を持ち、多人数プレイにおける一貫したゲーム体験を実現します。
各コンポーネントは重要な役割を果たしますが、その中でも核心的な概念は「状態同期」です。状態同期は、すべてのプレイヤーが一貫したゲームワールドを見ることを保証し、各自異なる視覚体験やインタラクション体験が生じることを防ぎます。
Actorの所有権-ROLE
クライアントとサーバーの間に区別がある以上、Actorの所有権においてもクライアントとサーバーの区別があることは当然です。
UEでは、Actorの制御権限を3つのカテゴリに分けています。それらは以下のとおりです:
ROLE_None
:特定の制御権限に属さない状態を表します。ROLE_Authority
:サーバー側でActorの制御権を持つことを示します。ROLE_AutonomousProxy
:クライアント側でローカルなActorの制御権を持つことを示します。ROLE_SimulatedProxy
:他クライアントがActor制御権を持つことを示します。
これらの三つの属性はUEがActorを設計する際に、Actorに固有属性として設計されています。これはActorがどこに存在するかを判断するために使われます。
UEでは、サーバーのコードとクライアントのコードが一体化しているため、Actorがこの属性を持つことは非常に必要です。
その辺の権限関係をテストしてみましょう。
- テンプレートのCharacterにRendertextを追加します。
xxxCharacter.h #include "Components/TextRenderComponent.h" ... public: UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = playername, meta = (AllowPrivateAccess = "true")) class UTextRenderComponent* playerNameTag; ... xxxCharacter.cpp #include "Components/SkeletalMeshComponent.h" // コンストラクタ関数にキャラクターのSkeletalメッシュ配下にTextRenderComponent追加 xxxCharacter::xxxCharacter() { ... // Create a Text Component playerNameTag = CreateDefaultSubobject<UTextRenderComponent>(TEXT("playerName")); USkeletalMeshComponent* SkeletalMesh = GetMesh(); playerNameTag->SetupAttachment(SkeletalMesh); playerNameTag->SetText(FText::FromString("test")); // Set Component Location Rotation playerNameTag->SetHorizontalAlignment(EHTA_Center); playerNameTag->SetRelativeLocation(FVector(0.0f, 0.0f, 180.0f)); playerNameTag->SetRelativeRotation(FRotator(0.0f, 90.0f, 0.0f)); ...
- 制御Roleを表示します。
xxxCharacter.cpp void Atest_DEServerCharacter::BeginPlay() { if (GetLocalRole() == ROLE_Authority) { playerNameTag->SetText(FText::FromString("ROLE_Authority")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on the server.")); } else if (GetLocalRole() == ROLE_AutonomousProxy) { playerNameTag->SetText(FText::FromString("ROLE_AutonomousProxy")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on the owning client.")); } else if (GetLocalRole()== ROLE_SimulatedProxy) { playerNameTag->SetText(FText::FromString("ROLE_SimulatedProxy")); UE_LOG(LogTemp, Warning, TEXT("Other Client Actor is ROLE_SimulatedProxy.")); } else { playerNameTag->SetText(FText::FromString("ROLE_None")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on a non-owning client.")); } }
- 権限Roleを確認します。
サーバーのウィンドウでは、すべてのキャラクターが「ROLE_Authority」と表示されていることがわかります。
それに対して二つのクライアントのウィンドウでは、自分が制御しているキャラクターだけ「ROLE_AutonomousProxy」と表示され、他のすべては「ROLE_SimulatedProxy」と表示されています。
その中にはサーバーが生成したキャラクターも含まれていますが、このクライアントにとってはそれも他のエンドのActorに属するものとなります。
次に、ステップバイステップでレプリケーションデモを作成します。
2.ユーザー名入力画面の作成
入力用Widget UIの作成
- Content Browserで Content フォルダを開き、右側の空白部分で右クリックしてUser Interface -> Widget Blueprintを選択してUIを作成します。
- 新しく作成したBlueprintをダブルクリックして開き、以下の画像のように「ゲーム開始Button」「ユーザー名入力のEditableText」とタイトル表示の「TextBlockウィジェット」を追加します。
Widget UIの親Classファイルの作成
Content Browser
でC++Classes
フォルダを開き、右側の空白部分で右クリックしNew C++ Class
->UserWidget
を選択します。- 新しく作成したクラスファイルが自動的にVisual Studioで開かれます。
- このクラスがBlueprintのUIを制御するために、Blueprintの親クラスを新しく作成したクラスに変更します。
- UIのBlueprintをダブルクリックして開き、
File
->Reparent Blueprint
をクリックし、表示されるダイアログで新しく作成したクラスを選択します。
- UIのBlueprintをダブルクリックして開き、
ユーザー名の取得コードの実装
- 制御するウィジェットの定義を追加します。
//LoginHUDWidget.h UCLASS() class TEST_DESERVER_API ULoginHUDWidget : public UUserWidget { GENERATED_BODY() public: void NativePreConstruct(); UFUNCTION() void OnPlayGameButtonClicked(); UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UEditableText* inputName; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* statusLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* playLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UButton* playBtn; };
//LoginHUDWidget.cpp void ULoginHUDWidget::NativePreConstruct() { Super::NativePreConstruct(); inputName->SetHintText(FText::FromString("Please input your name")); statusLabel->SetText(FText::FromString("Test Replication Demo")); playLabel->SetText(FText::FromString("Play")); FScriptDelegate StartPlayDelegate; StartPlayDelegate.BindUFunction(this, "OnPlayGameButtonClicked"); playBtn->OnClicked.Add(StartPlayDelegate); };
- ゲーム開始ボタンがクリックされた後の処理ロジックを追加します。
UGameplayStatics::OpenLevel
関数を使用してDedicated Serverに接続します。- ユーザーが入力したプレイヤー名はOptionsを通じてDedicated Serverに渡されます。
void ULoginHUDWidget::OnPlayGameButtonClicked() { FString NickName = inputName->GetText().ToString(); FString LevelName = "127.0.0.1:7777"; FString Options = FString::Printf(TEXT("?NickName=%s"), *NickName); UGameplayStatics::OpenLevel(GetWorld(), FName(*LevelName), false, Options); }
//xxxGameMode.h protected: UPROPERTY(EditAnywhere, Category = "UI") TSubclassOf<UUserWidget> LoginWidgetClass; private: UPROPERTY() ULoginHUDWidget* loginWidget; //xxxGameMode.cpp AxxxGameMode::AxxxGameMode() { static ConstructorHelpers::FClassFinder<UUserWidget> LoginWidgetObj(TEXT("/Game/UI/LoginHUD_UI")); LoginWidgetClass = LoginWidgetObj.Class; }; void AxxxGameMode::BeginPlay() { Super::BeginPlay(); APlayerController* PlayerController = GetWorld()->GetFirstPlayerController(); if (PlayerController != nullptr) { PlayerController->bShowMouseCursor = true; } if (LoginWidgetClass != nullptr) { UUserWidget* loginWidget = CreateWidget<UUserWidget>(GetWorld(), LoginWidgetClass); if (loginWidget != nullptr) { loginWidget->AddToViewport(); } } }
3.ユーザーネームのレプリケーション実装
クライアントの属性が変更されたときに、その属性値が他のクライアントに同期するためには、以下の2点を覚えておく必要があります:
- ① 属性のReplicationはReplicated or ReplicatedUsingに設定すべきです
- ② 属性を変更するコードはDedicated Server上で実行されます
Actorのレプリケーション
ReplicationはUObjectから派生した任意クラスの変数の固有属性で、この変数がネットワーク同期を許可するかどうかを指定します。
ブループリントでは、以下の3つの項目がReplication属性に対して設定可能です:
None
:ネットワーク同期を許可しません。Replication
:ネットワーク同期を許可します。RepNotify
:属性はネットワーク同期を許可し、さらにコールバック関数にバインドします。属性が変化すると、このコールバックが呼び出されます。ブループリントでは、このコールバック関数はFUNCTION内に自動的に作成され、OnRep_で始まり属性名で終わるようになっています。例えば、pos属性のコールバック関数はOnRep_posとなります。
C++でこの部分は、APlayerState
クラスで実装されます。
APlayerState
はUnreal Engineのクラスであり、各プレイヤーに関連するゲーム情報を格納および管理するために使用されます。
この情報は、プレイヤーが現在のシーンにいるかどうかに関係なく、通常はゲームセッション全体で永続的です。この設計により、APlayerState
はゲームセッション全体のレベル内でプレイヤー情報を格納する理想的な場所となります。
※注意:APlayerState
はプレイヤーの入力やゲームワールド内での状態(位置、速度、アニメーションの状態など)を格納するためのものではありません。これらの情報は APlayerController
に格納する必要があります。
該当するC++コードの例は以下のようになります。
# ネットワーク同期を許可しません UPROPERTY() # ネットワーク同期を許可します UPROPERTY(Replicated) # 属性はネットワーク同期を許可し、さらにコールバック関数にバインドします UPROPERTY(ReplicatedUsing=OnRep_xxx)
ユーザーネームのレプリケーション実装
- Playstateのサブクラスを新規作成します。
- Widget Classを作成したのと同じ手順で、C++Classesフォルダで右クリックし、
New C++ Class
->playerState
を選択します。 - 作成が完了すると、Visual Studioが自動的に新しいクラスファイルを開きます。
- Widget Classを作成したのと同じ手順で、C++Classesフォルダで右クリックし、
- Replicatedとして定義します。
DOREPLIFETIME
Unreal Engineのネットワークプログラミングにおけるマクロであり、特定のプロパティがネットワーク上で複製可能であることを設定するために使用されます。
//playerstate.h public: UPROPERTY(Replicated) FString NickName; virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; //playerstate.cpp void AMetaPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMetaPlayerState, NickName); }
- GameModeのコンストラクタ関数に
PlayerState
をロードします。
AxxxGameMode::AxxxGameMode() { PlayerStateClass = AMetaPlayerState::StaticClass(); };
- サーバーからの更新メッセージを受け取り、キャラクターに値を割り当てます。
//xxxCharacter.h private: virtual void OnRep_PlayerState() override; //xxxCharacter.cpp void AxxxCharacter::OnRep_PlayerState() { Super::OnRep_PlayerState(); APlayerState* OwningPlayerState = GetPlayerState(); if (OwningPlayerState != nullptr) { AMetaPlayerState* MetaPlayerState = Cast<AMetaPlayerState>(OwningPlayerState); if (MetaPlayerState != nullptr) { FString NickName = MetaPlayerState->NickName; if (NickName.Len() > 0 ) { playerNameTag->SetText(FText::FromString(NickName)); } } } }
4.Dedicated Server側の実装
UEのAGameModeBase
および AGameMode
クラスは、ゲームの基本ルールとロジックを定義するために使用されます。
以下に、これらのクラスでいくつかの重要なライフサイクル関数と、それらが通常どのような役割を果たすかを示します。
InitGame()
:この関数はサーバーが起動し、すべてのオブジェクトがロードされ、ゲームがまだ実行されていない状態で呼び出されます。ここで変数や状態を初期化できます。PreLogin()
:これはクライアントが接続を許可される前にサーバーで呼び出される関数です。プレイヤーの資格情報(例:ユーザー名やパスワードの確認)を検証したり、プレイヤーの接続を他の形式で事前検証したりするために使用できます。検証が失敗した場合、ここでプレイヤーの接続を拒否できます。PostLogin()
:プレイヤーが正常に接続され、検証された後、PostLogin()関数が呼び出されます。ここでは、プレイヤーがゲームに参加した後すぐに実行する必要のあるコードを実行できます。例えば、歓迎メッセージを送信したり、プレイヤーのゲームデータを初期化したりできます。InitNewPlayer()
:この関数はプレイヤーがサーバーに接続して初期化されたときに呼び出されます。この関数では、「プレイヤーの属性の初期化」「プレイヤーのチームの割り当て」「新しいプレイヤーに必要なゲーム情報の送信」など、多くのタスクを実行できます。BeginPlay()
:この関数はゲームの開始時に呼び出されます。ゲーム開始時に実行する必要があるコードをここで実行できます。Logout()
:プレイヤーがゲームから退出するときにこの関数が呼び出されます。ここでは、プレイヤーがゲームから退出する際に実行する必要のあるコード(例:プレイヤーのゲームデータの保存や、プレイヤーの退出メッセージの送信など)を実行できます。
これらの関数は最も一般的に使用され、ゲームの異なる段階で何を実行するかをサーバーサイドで処理するために使用されます。
これらの関数により、ゲームのロジックや要件に合わせて特定のコードを適切なタイミングで実行できます。
前述のように、同期を実現するには2つの条件を満たす必要があります。「② 属性の変更コードはDedicated Server上で実行される」 という条件を満たすために、上記の関数の中で、特に InitNewPlayer()
関数を選択する必要があります。
なぜなら、この関数はプレイヤーの PlayState
初期化が行われるタイミングですから。
//.xxxGameMode.h virtual FString InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal) override; //.xxxGameMode.cpp FString xxxServerGameMode::InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal) { FString InitializedString = Super::InitNewPlayer(NewPlayerController, UniqueId, Options, Portal); const FString& nickName = UGameplayStatics::ParseOption(Options, "NickName"); if (NewPlayerController != nullptr) { APlayerState* PlayerState = NewPlayerController->PlayerState; if (PlayerState != nullptr) { AMetaPlayerState* ServerPlayerState = Cast<AMetaPlayerState>(PlayerState); if (ServerPlayerState) { ServerPlayerState->NickName = nickName; } } } return InitializedString; }
5.デモの確認
ここまでで、ユーザーネームのレプリケーションに関する実装が全部完了しました!
Dedicated Serverをパッケージ化して試してみましょう。確認ポイントは以下となります:
- 各クライアントでユーザーがユーザーネームを入力し、ゲームが開始できること
- 各クライアントで対応するキャラクターの名前が表示されること
上記の確認が取れましたら、いわゆるネットワーク上での状態同期が成功し、すべてのクライアントが一貫したゲーム世界を見ることができるようになりました!
※パッケージング手順についてはAmazon GameLift × Unreal Engines 5 でオンラインマルチプレイゲームを作るの記事を参照してください。
注意事項
属性の同期を実装する際には、以下の点を注意してください。
それは、キャラクターモデルの変更をAPlayerStateクラスに実装しないということです。
筆者がコードを書く際、最初に APlayerState
クラスに以下のようなコールバック関数を書いたことがありました。
void ATestPlayerState::OnRep_NickName() { APlayerController* PC = GetGameInstance()->GetFirstLocalPlayerController(); if (PC && PC->GetPawn()) { AMetaPlayerController* PlayerController = Cast<AMetaPlayerController>(PC); if (PlayerController) { Atest_DEServerCharacter* MyCharacter = Cast<Atest_DEServerCharacter>(PlayerController->GetPawn()); if (MyCharacter) { MyCharacter->playerNameTag->SetText(FText::FromString(NickName)); } } } }
実行結果として、もともとPlayer1を制御していたユーザーが、Player2がログインした後に制御しているキャラクターの表示がPlayer2のユーザー名になってしまうという問題が発生しました。
これは、私が APlayerState
の OnRep_NickName
関数でキャラクターを取得し、名前ラベルを変更していたためです。
この関数は、NickNameフィールドがクライアントに複製されたときに呼び出されます。しかし一部の場合では、NickNameフィールドがクライアントに複製された時点では、クライアントが新しいキャラクターの情報をまだ受信していない可能性があります。つまり、GetPawn()関数がnullを返すか、もしくはキャラクターが存在していても既に存在する他のプレイヤーのキャラクターになるかもしれません。
この問題を解決するための方法は、APlayerState
のOnRep関数内でキャラクターを取得し、名前ラベルを変更しないことです。
代わりに、キャラクターのOnRep_PlayerState関数内で、キャラクター自身のPlayerStateを取得しキャラクターの名前ラベルを変更する必要があります。先ほど実装したコードと同様に、キャラクター自身のOnRep_PlayerState関数でこれを行ってください。
終わりに
このユーザーネーム属性のレプリケーションデモを通じて、Unreal Engineのネットワーク同期モデルについて一定の理解を得ることができました。
Unreal Engineは、さまざまなタイプのゲームや仮想現実アプリケーションをサポートする強力なゲームエンジンです。3Dおよびメタバースの開発領域では、ネットワーク同期、マルチプレイヤーゲーム、物理シミュレーション、シーンのレンダリングなど、さまざまな技術と応用を探求できます。学習と研究を続けることで、この領域においてより深い理解と高い技術力を身につけることができます。
現在ISIDはweb3領域のグループ横断組織を立ち上げ、Web3およびメタバース領域のR&Dを行っております(カテゴリー「3DCG」の記事はこちら)。
もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください!
私たちと同じチームで働いてくれる仲間を、是非お待ちしております!
ISID採用ページ(Web3/メタバース/AI)
参考文献
執筆:@chen.sun、レビュー:@yamashita.yuki
(Shodoで執筆されました)