Ghost Actorの再出現:Destructible Grid StructuresにおけるMultiplayer Network Replication Desyncの修正
要点まとめ
本記事では、マルチプレイヤーゲームにおいて破壊可能なグリッド構造物が破壊後に一瞬再出現する「Ghost Build」現象の原因と対策を解説しています。この同期ズレは、クライアントサイドのPredictionと、サーバー側でのReplication(特にProperty更新とChannel閉鎖の順序)のズレによって発生します。解決策として、クライアント側で予測破壊したアクターのプロパティ更新を一時的に無視するC++の実装方法を紹介しています。また、NetUpdateFrequency ofの調整やタイムアウト設定、さらにhorizOnのような専用Backendを活用したインフラ最適化などのベストプラクティスも提示しています。
Ghost Actorの再出現:Destructible Grid StructuresにおけるMultiplayer Network Replication Desync의の修正
プレイヤーが採集ツールを振り、画面上では木製の壁が瞬時に粉砕されたものの、80ミリ秒後にヘルス全快の状態で一瞬だけ再出現し、さらに400ミリ秒後に永久に消滅する――。この「Ghost Build(ゴーストビルド)」として一般に知られる視覚的バグ(アノマリー)は、クライアントサイドにおけるPrediction(予測)のミスマッチと、Replication(レプリケーション)の順序の乱れ(out-of-order replication)が引き起こす典型的な現象です。展開の速いMultiplayer環境において、こうした一時的な状態のRollback(ロールバック)はプレイヤーの没入感を大きく損ない、不自然な表示バグを生み出します。この問題に対処するには、ローカルでの即座のフィードバックとAuthoritativeなServer Validation(サーバー検証)を同期させる、堅牢なMultiplayer Network Replication Desyncの修正を実装する必要があります。
Ghost Buildの仕組み:Prediction vs. Replication
ネットワークマルチプレイヤーゲームを開発する際、クリエイターは操作のレスポンスの良さとServer Authority(サーバー権限)のバランスを取らなければなりません。キャラクターの移動やオブジェクトの破壊を遅延なく感じさせるために、クライアントはサーバーからの承認(Confirmation)を受け取る前にアクションの結果を予測(Prediction)します。例えば、プレイヤーが破壊可能な構造物(Destructible Structure)を攻撃したとき、クライアント側のゲームロジックは即座にダメージ計算を実行し、Collision(コリジョン)を無効化し、パーティクルエフェクトをトリガーします。
内部の仕組み(Under the hood)としては、これによりクライアントのシミュレーションがサーバーより先行する、ほんの一瞬の乖離(Divergence)が発生します。標準的なNetcodeモデルでは、サーバーがクライアントからの入力RPCを処理し、新しい状態をレプリケート(Replicate)して送り返すことで、この乖離が解決されます。しかし、パケットの遅延が発生したり、サーバーのTick Rate(一般に20Hz〜30Hz)がクライアントのフレームレート(60Hz〜120Hz)より遅れていたりすると、レースコンディション(競合状態)が発生します。クライアント側のPredictionはアクターを削除しますが、サーバーから送信される次のReplicationアップデートには、依然としてアクターの古い状態(ヘルスが残っており生存している状態)が含まれているのです。
この特有のレースコンディションは、木製の構造物で特に顕著に現れます。石や金属と比べて木は耐久値(Health)のしきい値が低く(例:90 HP vs. 300 HP)、一撃で破壊されることが多いためです。これにより、プレイヤーのアクションからサーバーの承認(Acknowledgment)までの時間的猶予(タイムウィンドウ)が極めて狭くなります。Replication의の遅延が発生すると、クライアントのネットワークドライバーは強制的に状態を同期(Reconcile)させようとし、サーバーがまだ生存と判定しているアクターをクライアント側で再生成(Reconstruct)してしまいます。
Packet LossとTick Rateの影響
Packet Loss(パケットロス)が発生すると、クライアントが予測した破壊処理は宙に浮いた状態(Limbo)になります。クライアントが送信したダメージパケットがドロップすると、サーバーはその処理を行いませんが、クライアントはダメージが適用されたと仮定して処理を進めます。そしてクライアントは、アクターが消滅したという誤った前提のもとでシミュレーションを継続します。その後、サーバーから次の状態アップデートが送信された時点でミスマッチが明らかになり、クライアントはアクターをワールドに再出現(Spawn)させることを余儀なくされます。この状態のReconciliation(調整・同期)プロセスは不自然なポップノイズ(パッと再出現する現象)を引き起こし、特にドロップが頻発する1.5%〜3%のPacket Loss環境下では深刻な問題となります。
ActorのライフサイクルとChannel Teardownの仕組み
Unreal Engineをはじめとする最新のマルチプレイヤー向けゲームエンジンは、専用のネットワーク接続チャネルを使用してアクターの存在を同期しています。Replicated(レプリケート)される各アクターには、アクターチャネルが割り当てられます。サーバー上でアクターが破壊されると、サーバーはこのチャネルを閉じ、クライアントに対してチャネルクローズの制御メッセージ(NetGUIDの破棄/Retirement)を送信します。
ここで深刻な問題となるのは、プロパティのReplication(Property Replication)とチャネルのクローズ(Channel Closure)が、同じReplicationパスを共有していない点です。プロパティの更新(構造物のHealth変数の更新など)はシリアライズされ、アクターの通常のReplicationバンドルの一部として送信されます。サーバーがダメージイベントを処理したものの、アクターのGarbage Collectionがまだ完了していない場合、アクターが完全に破壊対象としてマークされる前に、最後のプロパティ更新をシリアライズして送信してしまうことがあります。プロパティ更新を含むUDP packetがチャネルクローズを含むパケットよりも先に到着すると、クライアントはアクターのヘルスを更新し、ローカルで予測されていた破壊処理を上書きしてしまいます。
この挙動は、他のNetcode同期問題とも深く関係しています。例えば、Unreal EngineのRPC Replicationが状態を破損させる問題とMultiplayer Desyncの修正ガイドで解説している、RPCとプロパティの実行順序のミスマッチがワールド状態を破壊する仕組みなどが挙げられます。同様に、プレイヤーの位置情報を処理する際にも同様の不一致に遭遇することが多く、これについてはUEFNおよびUnreal Engineのマルチプレイヤーにおけるプレイヤー位置の同期ズレ修正方法で詳しく説明しています。
クライアントが順序の乱れたReplicationパケットを処理すると、アクターがサーバー上でまだ生存していると判定し、アクターを強制的にアクティブプールへと戻します。その結果、クライアントはチャネルクローズのパケットが到着するまで(多くの場合約0.4秒後)待たなければ、アクターを完全に削除できなくなります。
内部的(Under the hood)には、ReplicationパケットはMaximum Transmission Unit(MTU、通常は1400バイト)によって制限されています。ゲームの接続レートが制限されている場合(例:MaxClientRateが15000バイト/秒に設定されている場合)、アップデートはキューに送られ、複数のUDPパケットに分割されます。チャネルクローズの制御メッセージはReliable(高信頼性)で送信されるため受信確認(Acknowledgment)が必要ですが、プロパティの更新はUnreliable(低信頼性)で送信されることが多いためです。ネットワークの混雑やPacket Lossが発生すると、Reliableなチャネルクローズメッセージが古いUnreliableなプロパティパケットより遅れて届くことがあり、クライアント側でアクターが再生成されるというミスマッチが生じます。
C++によるPredictive State Bufferの実装
Ghost Buildを修正するには、クライアント側で「破壊が予測(Predict)されたアクター」に対する受信レプリケーションアップデートをインターセプトする必要があります。クライアントサイドのPrediction Bufferを実装することで、特定の時間(例:500ms)だけプロパティの同期(Property Reconciliation)を抑制し、サーバーからのチャネルクローズパケットが到着するまでの猶予を作ることができます。以下は、予測破壊アクターの動作可能なC++完全実装例です。これはReplicationの挙動をオーバーライドし、ローカルのタイムスタンプを使用してReplicationの同期を抑制すべきかどうかを判定します。
// PredictedDestructibleActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PredictedDestructibleActor.generated.h"
UCLASS()
class MULTIPLAYERGAME_API APredictedDestructibleActor : public AActor
{
GENERATED_BODY()
public:
APredictedDestructibleActor();
protected:
virtual void BeginPlay() override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// Server-authoritative health variable
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Destruction")
float MaxHealth;
// Triggered when health changes on the client
UFUNCTION()
void OnRep_Health();
// Client-side prediction tracking flags
bool bClientPredictedDestroyed;
float ClientPredictionTime;
// Maximum time (in seconds) the client will suppress server updates
UPROPERTY(EditAnywhere, Category = "Networking")
float PredictionTimeout;
// Visual effect helper function
void TriggerDestructionEffects();
public:
// Called when the local player destroys the structure client-side
UFUNCTION(BlueprintCallable, Category = "Destruction")
void PredictDestruction();
// Resets the predicted state if the server rejects the destruction
void ResetPredictionState();
virtual void Tick(float DeltaTime) override;
};
以下は、受信するサーバーの状態をフィルタリングする方法を示す、対応する実装ファイル(.cpp)です。
// PredictedDestructibleActor.cpp
#include "PredictedDestructibleActor.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"
APredictedDestructibleActor::APredictedDestructibleActor()
{
PrimaryActorTick.bCanEverTick = true;
bReplicates = true;
// Set a moderate update frequency to balance bandwidth and responsiveness
NetUpdateFrequency = 33.0f;
MaxHealth = 100.0f;
Health = MaxHealth;
bClientPredictedDestroyed = false;
ClientPredictionTime = 0.0f;
PredictionTimeout = 0.5f; // 500ms safety window
}
void APredictedDestructibleActor::BeginPlay()
{
Super::BeginPlay();
}
void APredictedDestructibleActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(APredictedDestructibleActor, Health);
}
void APredictedDestructibleActor::OnRep_Health()
{
// If the client has predicted this actor's death, suppress server property updates
if (bClientPredictedDestroyed)
{
return;
}
if (Health <= 0.0f)
{
TriggerDestructionEffects();
}
}
void APredictedDestructibleActor::PredictDestruction()
{
// Prediction only runs on the client that simulated the event
if (GetNetMode() == NM_Client)
{
bClientPredictedDestroyed = true;
ClientPredictionTime = GetWorld()->GetTimeSeconds();
// Hide the actor and disable collision immediately for responsive local feedback
SetActorEnableCollision(false);
SetActorHiddenInGame(true);
// Spawn local particles and audio instantly
TriggerDestructionEffects();
}
}
void APredictedDestructibleActor::ResetPredictionState()
{
bClientPredictedDestroyed = false;
SetActorEnableCollision(true);
SetActorHiddenInGame(false);
}
void APredictedDestructibleActor::TriggerDestructionEffects()
{
// Spawn local visual effects (e.g. wood splinters, dust clouds)
// and play destruction audio.
}
void APredictedDestructibleActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (GetNetMode() == NM_Client && bClientPredictedDestroyed)
{
float CurrentTime = GetWorld()->GetTimeSeconds();
// If the timeout expires and the server hasn't torn down the channel,
// the server must have rejected the damage. We must roll back.
if (CurrentTime - ClientPredictionTime > PredictionTimeout)
{
ResetPredictionState();
}
}
}
この予測バッファ(Predictive Buffer)を使用することで、受信したOnRep_Healthコールバックがアクターのビジュアル表示(Visibility)をリセットするのを防ぎます。これにより、チャネルクローズパケットが到着するまで、クライアント側のアクターを非表示かつコリジョンなしの状態に保つことができます。サーバーが破壊を拒否した場合(例:Anti-Cheatによる検証の不一致など)、タイムアウトによってRollbackが実行されるため、シミュレーションが永久に同期ズレを起こすのを防ぐことができます。
Rollbackと検証拒否への対応
このReplicationの修正において極めて重要な要素は、サーバーがプレイヤーのアクションを拒否した場合の処理です。サーバーの検証ロジックが「プレイヤーは構造物にヒットできなかった」と判断した場合、サーバーはダメージを却下します。このシナリオでは、永続的な同期ズレを防ぐためにクライアントは予測された破壊をRollbackしなければなりません。そのため、500ms以内にサーバーからの承認が届かない場合、タイムアウトによってアクターのコリジョンと表示状態を復元します。
手動実装の負担 vs 専用Backendの導入
上記のC++によるソリューションは個々のアクターのゴースティング(Ghosting)を修正しますが、これをゲーム環境全体に適用するのは複雑です。ゲーム開発者は、破壊可能なオブジェクトのタイプごとにPrediction(予測)およびRollback(ロールバック)のコードを手動で記述し、アクティブな予測バッファを追跡し、アクターのTick Rateを最適化し、ネットワークのReplication優先度を管理しなければなりません。インディー開発チームにとって、これらのエッジケースの構築とテストには、専任のネットワークエンジニアリングに容易に4〜6週間かかる可能性があります。
これらをすべて自前で構築するには、ロードバランサー(Load Balancer)、データベースシャarding、そして複雑なWebSocketやUDPサーバーのセットアップが必要です。horizOnなら、これらのBackendサービスがあらかじめ事前設定された状態で提供されるため、インフラ管理に追われることなくゲームの開発・リリースに集中できます。horizOnのリアルタイムロビー管理とセッションオーケストレーションは、プレイヤーの状態とマッチのプロパティを50ms未満の低レイテンシで確実に同期させ、Ghost Buildの原因となるReplicationの遅延を軽減します。
Multiplayer Network Replication Desync修正のための実践的なベストプラクティス
破壊可能なオブジェクトに対してNetcodeを最適化する際は、ワールド状態の同期を維持するために以下のガイドラインに従ってください。
- ビジュアルアセットをActorのライフサイクルから分離する: 視覚的なフィードバックを即時の
AActor::Destroy()の実行に依存させないでください。bIsDeadなどのBoolean Replicationフラグを設定し、ローカルのパーティクルシステムを即座にトリガーします。これにより、サーバーのクリーンアップ処理を待つことなく、クライアント側のCollisionを無効化できます。 - プロパティの更新よりもチャネルの破棄(Channel Destruction)を優先する: 破壊可能オブジェクトに
bOnlyRelevantToOwnerを設定するかNetPriorityを引き上げ、ネットワークドライバーが破壊アップデートを優先的に処理するようにします。これにより、通常の環境プロパティReplicationの後回しになるのを防ぎます。 - アクティブなPredictionタイムアウトウィンドウを設定する: クライアント側のPredictionを無制限に実行させたままにしないでください。サーバーがアクションを拒否した場合にクライアント側のRollbackを強制するため、常に安全なタイムアウト(通常は許容可能な最大RTTの1.5〜2倍に、サーバーのTickのブレを考慮したマージンを加えた値)を実装します。これにより、パケットドロップ時にアクターが非表示のまま残るのを防ぎます。
- NetUpdateFrequencyを調整する: 通常時、破壊可能な構造物のアップデートレートは低く(例:10-15Hz)保ちます。ダメージを受けたときのみ、動的にアップデート頻度を33Hzに引き上げることで、アイドル時の帯域幅を節約しつつ、操作のレスポンスを維持できます。これにより、プレイヤーのインタラクションが多い場面でのネットワーク負荷をバランスよく調整できます。
- サーバーの検証パイプラインを最適化する: サーバー側のダメージ検証(Damage Validation)が迅速かつ軽量であることを確認してください。サーバーがヒットの検証に100ms以上かかると、クライアントのPrediction Bufferがタイムアウトし、画面上のカクつき(Jitter)を誘発する可能性が高くなります。検証用コードを合理化し、処理の遅延を最小限に抑えましょう。
まとめと次のステップ
Replicationの同期ズレ(Desync)を解決するには、ゲームエンジンのネットワークパイプラインに対する深い理解が必要です。クライアント側で破壊が予測されたアクターに対するサーバーからのプロパティ更新を一時的に抑制することで、Ghost Buildを排除し、プレイヤーにシームレスでレスポンスの高い体験を提供することができます。
MultiplayerのBackendをスケールさせ、同期問題を減らす準備はできましたか?horizOnを無料でお試しいただくか、API docsをご覧になり、次のプロジェクトで低レイテンシのセッション管理を実装する方法をご確認ください。
出典: Ghost builds appear shortly after breaking wooden player build structures