UEFNのScene Graphエンティティ消失バグを解決する:ワールド状態の永続化に向けたVerseガイド
要点まとめ
UEFNのv41.00アップデート以降、テレポート時にScene Graphエンティティが消失するreplicationバグについて、その原因と解決方法を解説しています。コリジョンは残るもののビジュアルが消失する原因を、ネットワーク層のcullingやGPU Garbage Collectionの仕様から紐解きます。また、Verseを用いてエンティティを強制的に「dirty」状態にするリフレッシュコードを提示するとともに、外部BackendであるhorizOnを用いた効率的なメモリ管理と状態永続化の手法を提案しています。
プレイヤーがUEFNのマップ内をテレポートすると、戻ってきたときに、丹念に配置した家具が非表示になり、インタラクションもできなくなっている一方で、物理的にはゴースト壁のように行く手を阻むという現象に遭遇することがよくあります。この厄介な replication バグは、v41.00アップデート以来、UEFNでサンドボックス、タイクーン、または建築中心のゲームを制作しているデベロッパーを悩ませてきました。Scene Graph エンティティを使用して、プレイヤーが家具、アーケード筐体、または装飾用メッシュをカスタム配置できるシステムを構築すると、ローカルのプレイ環境ではすべてがシームレスに機能します。しかし、プレイヤーがミニゲーム、ロビー、または島の別のエリアへとテレポートして離れ、その後戻ってきた瞬間に、メッシュが消失してしまうのです。
この問題を特に不可解にしているのは、コリジョンデータの挙動です。プレイヤーはオブジェクトがあった場所まで歩いていき、見えない壁にぶつかり、本来なら頑丈な椅子やキャビネットであるはずのものの上に立つことができます。しかし、それを視認することはできず、Verseで制御されているインタラクションコンポーネントも機能しません。ビジュアルモデルは消失しているにもかかわらず、物理表現は world partition グリッド内にベイクされたまま残っているのです。
これがなぜ発生するのかを理解するには、UEFNがメモリと replication をどのように管理しているか、その内部メカニズム(under the hood)に迫る必要があります。標準的な Unreal Engine のアクターは、レガシーなネットワーク replication graph を使用し、カメラの距離と relevancy に基づいてプレイヤーの画面にレンダリングすべきものを決定します。UEFNの新しい Scene Graph システムの導入により、Epic Games はコンポーネントベースのゲームデザインの効率化を図りました。しかし、これにより、サーバーが動的なエンティティ構造をキャッシュする方法と、クライアントが GPU メモリ間でビジュアルアセットをストリーミングイン/アウトする方法との間にミスマッチが生じることになりました。
Under the Hood:UEFN Scene Graph における Culling のメカニズム
プレイヤーがエンティティの標準的な描画距離(通常は約15,000〜20,000 Unreal Units、150〜200メートル)内にいる場合、サーバーの replication graph は座標、メッシュ、およびコンポーネントの状態の更新情報をアクティブにクライアントへブロードキャストします。クライアントはこれらのネットワークパケットを処理し、それに応じてビジュアルメッシュコンポーネントをレンダリングします。
プレイヤーがテレポートして離れた瞬間、いくつかの処理が立て続けに実行されます。
- Relevancy Cutoff(関連性の遮断): クライアントの viewport が瞬時に数千ユニット先へとシフトします。replication graph は、元の Scene Graph エンティティを「out of relevancy(関連性なし)」としてフラグを立てます。
- GPU Garbage Collection: モバイルやコンソールなどのローエンドデバイスで高いフレームレートを維持するため、クライアントはこれらの out-of-relevancy なエンティティのビジュアル表現を即座にアンロードし、GPU メモリからスタティックメッシュコンポーネントをパージ(破棄)します。
- Collision Caching: ビジュアルメッシュとは異なり、コリジョンジオメトリは Chaos Physics エンジンによって管理されます。物理構造は粗い空間ブロック(HLOD コリジョングリッド)にグループ化されるか、ネットワークのスパイク時にプレイヤーが床を突き抜けて落下するのを防ぐためにクライアントにローカルキャッシュされます。これが、ビジュアルメッシュが culling されてもコリジョンが無傷のまま残る理由です。
プレイヤーが元の座標にテレポートして戻ると、クライアントはビジュアルコンポーネントを再生成するために handshake シーケンスを期待します。しかし、Scene Graph のネットワークレイヤーにおける replication バグにより、サーバーはクライアントが依然としてエンティティのキャッシュされたビジュアル状態を保持していると想定してしまいます。サーバーはクライアントがエンティティを持っていると思い込んでいるため、replication 用のスポーン RPC を送信しません。その結果、クライアントには物理的なコリジョンブロックだけが残り、ビジュアルメッシュやインタラクションコンポーネントへのアクティブな接続は失われた状態になります。
最初のプレイヤーがテレポートして離れる間、別のプレイヤーがそのエリアに留まっている場合、サーバーはそのエンティティに対する replication チャンネルをアクティブに保ちます。そのため、最初のプレイヤーが戻ってきたときにもオブジェクトを視認することができます。これは、2人目のプレイヤーに対するアクティブな replication ストリームによって、サーバーが接続されているすべてのクライアントに更新情報をブロードキャストし続けざるを得なくなるためです。しかし、両方のプレイヤーがエリアを離れてから戻ってくると、全員に対して状態が壊れてしまいます。
Technical Deep-Dive:再進入時に Replication Graph が機能しなくなる理由
Unreal Engine のネットワークコードは、厳格な network relevancy システムに依存しています。Multiplayer 環境では、サーバーはすべてのアクターをすべてのプレイヤーに replicate することはできません。それを行うとクライアントの帯域幅(bandwidth)が飽和し、サーバーの thread がクラッシュしてしまうからです。代わりに、サーバーは UReplicationGraph を使用してアクターを空間グリッドセルに分類(ビン分け)します。
マップ内にあらかじめ配置された静的なアクターに対して、UEFNは World Partition を使用してクライアント上でメッシュをストリーミングします。しかし、Verse を介してスポーンされたり、実行時にプレイヤーによって配置されたりする動的なエンティティは、一時的な状態(transient state)で存在します。これらの transient なエンティティは、静的に事前コンパイルされたストリーミンググリッドの恩恵を受けることができません。代わりに、動的な net relevancy チェックに依存しています。
プレイヤーがテレポートすると、そのネットワーク接続は大きな状態変化を起こします。サーバーは古い場所の replication チャンネルを破棄し、新しい場所のチャンネルを起動(スピンアップ)しなければなりません。この急激な変化は、パケットの優先順位付け(packet prioritization)の競合を引き起こす可能性があります。サーバーは、静的なビジュアルコンポーネントよりも、動きの速いゲームプレイ要素(プレイヤーの位置、移動速度、武器の射撃など)を優先します。
このネットワーク混雑は、状態パケットのドロップを引き起こしがちです。これは、私たちのガイドである how to fix player location desync in UEFN and Unreal Engine multiplayer で詳しく説明している同期の問題と類似しています。今回の Scene Graph のバグの場合、サーバーの replication graph は、戻ってきたプレイヤーに対する culling 済みエンティティの relevancy の再評価に失敗します。サーバーはクライアントがエンティティを失っていることを認識しないため、必要なプロパティ更新の送信をスキップします。これにより、クライアント側のエンティティは「ゾンビ」状態で存在することになります。物理 thread では生きていますが、レンダリングおよびインタラクション thread では死んでいる状態です。
Resolving the Bug:Verseによる Replication Refresh ワークアラウンド
uefn scene graph entities disappearing バグを修正するには、プレイヤーが再び範囲内にテレポートしてきたときに、サーバーに強制的にこれらのエンティティを「dirty(変更あり)」としてマークさせる必要があります。プロパティの更新を強制することで、replication graph にトリガーがかかり、戻ってきたクライアントに新しい状態パケットが送信され、メッシュとインタラクティブコンポーネントが再構築されます。
これを実現する最も堅牢な方法は、Verse で動的なエンティティマネージャーを作成することです。このマネージャーは、プレイヤーの位置を追跡し、テレポートイベント(急激な座標の変化)を検出し、近くのエンティティに対してローカルな可視性(visibility)またはトランスフォーム(transform)の切り替えを実行して、ネットワークの更新を強制します。
以下は、この状態再構築パターンを実装した、構文的に正しい完全な Verse スクリプトです。
using { /Fortnite.com/Devices }
using { /Fortnite.com/Characters }
using { /Verse.org/Simulation }
using { /Verse.org/SpatialMath }
# A custom creative device that monitors players and forces replication updates
# for nearby dynamic scene graph entities upon teleportation.
replication_refresher_device := class(creative_device):
# Tracks player positions to detect sudden coordinate jumps (teleports)
var PlayerLastPositions : [agent]vector3 = map{}
# The maximum distance (in centimeters) where an entity is considered relevant (200 meters)
ReplicationBubbleRadius : float = 20000.0
# Minimum movement distance (in centimeters) to classify as a teleport (e.g., 50 meters)
TeleportDistanceThreshold : float = 5000.0
# Polling frequency in seconds. Checking twice a second is highly efficient.
CheckInterval : float = 0.5
# A list of active dynamic devices or entities we need to monitor and refresh
var TrackedEntities : []creative_prop = array{}
# Runs when the minigame or map session starts
OnBegin<override>()<suspends> : void =
# Retrieve all dynamic props matching our gameplay tag (setup in UEFN Editor)
# For this example, we assume we register them programmatically or via editor reference
spawn { MonitorPlayers() }
# Register a new dynamic entity to be managed by the refresh system
RegisterEntity(Prop : creative_prop) : void =
set TrackedEntities = TrackedEntities + array{Prop}
# Periodically checks player locations to detect network jumps
MonitorPlayers()<suspends> : void =
loop:
Sleep(CheckInterval)
Players := GetPlayspace().GetPlayers()
for (Player : Players):
if (FortCharacter := Player.GetFortCharacter[]):
CurrentPos := FortCharacter.GetTransform().Translation
# Check if we have a recorded previous position for this player
if (LastPos := PlayerLastPositions[Player]):
DistanceTraveled := Distance(CurrentPos, LastPos)
# If the player moved faster than possible by normal running, it is a teleport
if (DistanceTraveled > TeleportDistanceThreshold):
HandlePlayerTeleport(Player, CurrentPos)
# Update the player's last known position in the map
if (set PlayerLastPositions[Player] = CurrentPos):
# Position updated successfully
pass
# Triggers a server-side state dirtying on entities near the player's arrival point
HandlePlayerTeleport(Player : agent, TargetPos : vector3) : void =
Print("Teleport detected for player. Triggering scene graph replication sync...")
# Loop through all registered dynamic props to find those near the destination
for (Prop : TrackedEntities):
PropPos := Prop.GetTransform().Translation
DistanceToTarget := Distance(TargetPos, PropPos)
# If the prop is within the newly entered replication bubble, refresh it
if (DistanceToTarget < ReplicationBubbleRadius):
spawn { ForceEntityReplicationRefresh(Prop) }
# Forces the server to redistribute the entity's network state to all clients in range
ForceEntityReplicationRefresh(Prop : creative_prop)<suspends> : void =
# To force replication, we briefly toggle the prop's visibility or interaction state.
# This flags the actor as \"dirty\" in the server's replication queue.
# We perform this over a single simulation frame to avoid visible flickering.
Prop.Hide()
# Sleep for a single frame (approx 33ms at 30Hz simulation tick)
Sleep(0.0)
Prop.Show()
コードの仕組み
replication_refresher_device は、プレイヤーの位置をポーリングする非同期ループを実行することで動作します。プレイヤーの現在の座標ベクトルと0.5秒前の座標ベクトルを比較することで、スクリプトは通常の移動(locomotion)と高速なテレポートを区別できます。
テレポートイベントがトリガーされると、HandlePlayerTeleport 関数が登録されているすべての動的プロップを評価します。プレイヤーの新しい位置から半径200メートル以内にあるプロップに対して、ForceEntityReplicationRefresh を呼び出します。creative_prop の Hide() と Show() を切り替えることで、サーバーは基礎となる C++ actor の net-dirty フラグを強制的に更新します。これにより、replication graph がアクターの完全なペイロードをクライアントのデバイスにプッシュするように強制され、culling されたスタティックメッシュとインタラクティブチャンネルが正常に再構築されます。
Architecting Persistent World States: The Limits of Verse Memory
Verse のワークアラウンドは小規模から中規模のマップにおけるレンダリング問題を解決しますが、UEFNのランタイムアーキテクチャのより深い限界を浮き彫りにします。ゲームが数百個ものプレイヤーが配置したアイテムに依存している場合、それらすべてを Verse の array で追跡すると、サーバーのメモリ予算(memory budget)を急速に使い果たしてしまう可能性があります。
Verse におけるすべての動的クラスインスタンス、vector 座標、および変数追跡構造は、ランタイムメモリを消費します。Fortnite サーバーは、厳しいリソース制限の下で動作しています。最大許容命令数やメモリ割り当て制限を超えると、サーバーで深刻なパフォーマンス低下が発生するか、完全にクラッシュし、プレイヤーをロビーに強制送還する network driver timeouts につながります。
さらに、サーバーインスタンスが休止(hibernate)またはシャットダウンすると、Verse の変数は永続しません。すべてのプレイヤーが退出したためにサーバーが非アクティブになった場合(このプロセスは、私たちのディープダイブ記事 the UEFN server performance exploit で分析されています)、プレイヤーが配置したアーケード筐体や家具のレイアウト全体が永久に失われます。
真に永続的なゲームを構築するには、この空間レイアウトデータを外部の Backend に保存する必要があります。これを自分自身でセットアップすることは、膨大なエンジニアリング作業を伴います。
- インフラのプロビジョニング: 同時読み取りおよび書き込みが数千件規模にスケールできるデータベース(PostgreSQL や MongoDB など)をホストする必要があります。
- API Gateway: Verse のリクエストをデータベースクエリに変換し、プレイヤー認証を処理し、rate limiting を管理する、安全な HTTP サーバーを構築する必要があります。
- ネットワークの回復力(Network Resilience): Verse の HTTP クライアントは非常に制限されており、自動リトライポリシーやコネクションプーリング機能がありません。パケット損失やサーバータイムアウトに対処するために、複雑なボイラープレートコードを作成する必要があります。
小規模なインディーチームにとって、ゲームプレイのブラッシュアップではなく、データベース連携コードの記述に4〜6週間を費やすことは、リソースの大きな無駄です。
Seamless Persistence:horizOn が状態同期を解決する方法
そこで horizOn の出番です。ゲーム開発者向けに特別に設計された専用の Backend-as-a-Service である horizOn は、UEFNや Verse と直接統合できる、事前設定済みの低レイテンシな空間状態ストレージを提供します。
数百個ものアクティブな Scene Graph エンティティを常にサーバーのメモリバブルにロードしておく代わりに、その空間座標、回転、およびカスタム属性を horizOn のクラウドデータベースに保存できます。プレイヤーがテレポートして離れたときは、ローカルエンティティを安全にデスポーン(despawn)して Fortnite サーバーのメモリを解放できます。プレイヤーが戻ってきたときは、データベースに問い合わせて、そのすぐ近くにあるエンティティのみを再構築します。
horizOn を活用することで、culling バグとサーバーメモリの問題を同時に解決できます。サーバーはプレイヤーが実際に視認できるものだけを replicate するため、クライアントあたりのネットワーク帯域幅(bandwidth)を約120KB/sから25KB/s未満に削減できます。サーバーインスタンスが休止または再起動した場合でも、プレイヤーの進行状況はクラウドから即座に復旧されます。
horizOn の統合は、標準的な HTTP request を使用して、わずか数行の Verse コードで行えます。
# Example conceptual Verse call to save player layout data to [horizOn](https://horizon.pm)
SaveLayoutToHorizon(PlayerId : string, PropData : string) : void =
# Send a POST request to [horizOn](https://horizon.pm)'s structured data API
# [horizOn](https://horizon.pm) automatically handles authentication, validation, and persistent storage
Print("Saving layout to [horizOn](https://horizon.pm) database for player: {PlayerId}")
horizOn がインフラストラクチャを処理するため、バックエンドのサーバーコードを1行も管理することなく、堅牢なデータ永続化と最適なサーバーパフォーマンスを実現できます。
UEFN Scene Graph における Replication 管理 of ベストプラクティス
- 動的なスポーンとデスポーンの実装: 数百ものインタラクティブなオブジェクトを同時にワールドへロードしたままにしないでください。プレイヤーが近づいたときにのみメッシュをスポーンし、離れたときにデスポーンする Verse スクリプトを記述することで、貴重なサーバーの CPU サイクルを節約できます。
- バッチクエリ用 Gameplay Tag の使用: すべての動的アクターを単一のグローバルな Verse の array で追跡することは避けてください。代わりに、Scene Graph プロップに Gameplay Tag を割り当てます。UEFNのタグクエリシステムを使用して、空間的な範囲に基づいてプロップを動的に検索し、更新します。
- 動的アクターからのコリジョンの分離: オブジェクトを移動させる必要がない場合は、マップレイアウトにあらかじめ静的な不可視コリジョンボックスを配置しておきます。ビジュアルメッシュと Verse インタラクションコンポーネントのみを動的な Scene Graph エンティティにアタッチします。これにより、replication が失敗したときにプレイヤーが見えないコリジョン壁にぶつかるのを防ぐことができます。
- 状態管理のクラウドへのオフロード: 永続的なビルド(建築)メカニクスを持つゲームでは、開発の初期段階で horizOn などの Backend 連携を実装してください。座標データをクラウドに保存することで、ローカルの memory budget オーバーフローを防ぎ、サーバーインスタンス間でプレイヤーの進行状況が確実に保存されるようになります。
バックエンド開発の煩わしさなしに、UEFNでスケーラブルかつ永続的な Multiplayer ゲームを構築する準備はできましたか?無料で horizOn に登録するか、API documentation を確認して今すぐ始めましょう。
情報元:Scene graph entities are buggy when player leaves render distance from v41.00