電通総研 テックブログ

電通総研が運営する技術ブログ

UE5 同期処理(レプリケーション)のC++実装

こんにちは、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の構築方法については、以前の記事を参照してください。

開発環境/ツール

それでは、デモの制作を開始しましょう。以下の手順で進めていきます。
※デモはUEのサードパーソンテンプレートの上でレプリケーション機能を実装

  1. UEのネットワーク知識とActorの権限確認(ユーザーネーム表示用キャラクターの作成含め)
  2. ユーザー名入力画面の作成
  3. ユーザーネームのレプリケーション実装
  4. Dedicated Server側の実装
  5. デモの確認

このような手順でデモの制作を進めていくと、ユーザーネームを持つキャラクターを生成し、それを全てのクライアントで同期できます。

1.UEのネットワーク知識とActor権限の確認

本番の作成を開始する前に、まずは2点の前提知識を説明します。

UEのネットワークモデル

UEのネットワークモデルでは、Actorのレプリケーションを通じてゲームオブジェクトの状態をクライアント間で同期します。これにより、各クライアントが一貫したゲームワールドを見ることができます。

UEのネットワークモデルには、いくつかのキーポイントと概念があります:

  1. Actor Replication(アクターレプリケーション)Unreal Engineでは、各ゲームオブジェクトはActorと呼ばれます。サーバー内のActorの状態はクライアントにレプリケーション(複製)される可能性があり、これを「レプリケーション」と呼びます。どのActorがレプリケーションされ、どのようにレプリケーションされるかは、開発者が特定の属性と関数を設定することで決定されます。
  2. State Synchronization(状態同期):サーバーは自身の状態をネットワークを通じて各クライアントに送信し、全てのクライアントが一貫したゲームワールドを見ることができるようにします。この過程を状態同期と呼びます。これがサーバーコンテンツを各クライアントに分散する必要性の理由です。この過程がなければ、クライアントは古いか、または一貫性のないゲームワールドの状態を見ることになりゲーム体験が低下します。
  3. Client Prediction(クライアント予測):ネットワークの遅延がゲーム体験に影響を与えるのを減らすために、クライアントは「クライアント予測」と呼ばれる技術を使用します。つまり、サーバーからの応答が到着する前に、クライアントはあらかじめいくつかのアクションを実行します。サーバーからの応答を受け取ったら、クライアントは自身の状態を調整してサーバーの状態に一致させます。
  4. 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を確認します。

OnServer_Role
Client1_Role
Client2_Role

サーバーのウィンドウでは、すべてのキャラクターが「ROLE_Authority」と表示されていることがわかります。

それに対して二つのクライアントのウィンドウでは、自分が制御しているキャラクターだけ「ROLE_AutonomousProxy」と表示され、他のすべては「ROLE_SimulatedProxy」と表示されています。

その中にはサーバーが生成したキャラクターも含まれていますが、このクライアントにとってはそれも他のエンドのActorに属するものとなります。

次に、ステップバイステップでレプリケーションデモを作成します。

2.ユーザー名入力画面の作成

入力用Widget UIの作成

  • Content Browserで Content フォルダを開き、右側の空白部分で右クリックしてUser Interface -> Widget Blueprintを選択してUIを作成します。

create_ui

  • 新しく作成したBlueprintをダブルクリックして開き、以下の画像のように「ゲーム開始Button」「ユーザー名入力のEditableText」とタイトル表示の「TextBlockウィジェット」を追加します。

design_ui

Widget UIの親Classファイルの作成

  • Content BrowserC++Classes フォルダを開き、右側の空白部分で右クリックし New C++ Class -> UserWidget を選択します。
    • 新しく作成したクラスファイルが自動的にVisual Studioで開かれます。

create_class
create_class2

  • このクラスがBlueprintのUIを制御するために、Blueprintの親クラスを新しく作成したクラスに変更します。
    • UIのBlueprintをダブルクリックして開き、 File -> Reparent Blueprint をクリックし、表示されるダイアログで新しく作成したクラスを選択します。

reparent_1
reparent_2
reparent_3

ユーザー名の取得コードの実装

  • 制御するウィジェットの定義を追加します。
    • 定義に UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) を追加して、Blueprint Widget内の対応するウィジェットにバインドできるようにします。
//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 クラスで実装されます。
APlayerStateUnreal Engineのクラスであり、各プレイヤーに関連するゲーム情報を格納および管理するために使用されます。
この情報は、プレイヤーが現在のシーンにいるかどうかに関係なく、通常はゲームセッション全体で永続的です。この設計により、APlayerState はゲームセッション全体のレベル内でプレイヤー情報を格納する理想的な場所となります。

注意APlayerState はプレイヤーの入力やゲームワールド内での状態(位置、速度、アニメーションの状態など)を格納するためのものではありません。これらの情報は APlayerController に格納する必要があります。

該当するC++コードの例は以下のようになります。

# ネットワーク同期を許可しません
  UPROPERTY()
# ネットワーク同期を許可します
  UPROPERTY(Replicated)
# 属性はネットワーク同期を許可し、さらにコールバック関数にバインドします
  UPROPERTY(ReplicatedUsing=OnRep_xxx)

ユーザーネームのレプリケーション実装

  • Playstateのサブクラスを新規作成します。
    • Widget Classを作成したのと同じ手順で、C++Classesフォルダで右クリックし、New C++ Class -> playerState を選択します。
    • 作成が完了すると、Visual Studioが自動的に新しいクラスファイルを開きます。

playstate_class

  • 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();
};
  • サーバーからの更新メッセージを受け取り、キャラクターに値を割り当てます。
    • コントロールしているキャラクターについて、上記で定義したNickNameが変更された場合、OnRep_PlayerState 関数が実行されます。
    • OnRep_PlayerState 関数は APlayerState クラスに定義されており、それを継承する必要があります。
//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 クラスは、ゲームの基本ルールとロジックを定義するために使用されます。
以下に、これらのクラスでいくつかの重要なライフサイクル関数と、それらが通常どのような役割を果たすかを示します。

  1. InitGame():この関数はサーバーが起動し、すべてのオブジェクトがロードされ、ゲームがまだ実行されていない状態で呼び出されます。ここで変数や状態を初期化できます。

  2. PreLogin():これはクライアントが接続を許可される前にサーバーで呼び出される関数です。プレイヤーの資格情報(例:ユーザー名やパスワードの確認)を検証したり、プレイヤーの接続を他の形式で事前検証したりするために使用できます。検証が失敗した場合、ここでプレイヤーの接続を拒否できます。

  3. PostLogin():プレイヤーが正常に接続され、検証された後、PostLogin()関数が呼び出されます。ここでは、プレイヤーがゲームに参加した後すぐに実行する必要のあるコードを実行できます。例えば、歓迎メッセージを送信したり、プレイヤーのゲームデータを初期化したりできます。

  4. InitNewPlayer():この関数はプレイヤーがサーバーに接続して初期化されたときに呼び出されます。この関数では、「プレイヤーの属性の初期化」「プレイヤーのチームの割り当て」「新しいプレイヤーに必要なゲーム情報の送信」など、多くのタスクを実行できます。

  5. BeginPlay():この関数はゲームの開始時に呼び出されます。ゲーム開始時に実行する必要があるコードをここで実行できます。

  6. 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のユーザー名になってしまうという問題が発生しました。   
これは、私が APlayerStateOnRep_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で執筆されました