Pantropy ― Massive Performance Boost by Using NetworkArrays Photon Bolt
この寄稿はインディー・ゲームスタジオであるBrain StoneでPantropyの主任ディベロッパーを務めるJulian Kaulitzki氏によるものです。
Pantropyは、派閥ごとに戦うPC向けSFメカゲームです。この記事を書いている時点では、クローズドアルファ版が公開されています。Pantropyは、1つのジョイントセッションで128プレイヤーまで許容可能となる見込みです。プレイではエイリアンの世界の征服や、鉱石の発掘、基地の建築を楽しめます。また武器や乗物などのアイテムを制作して他の派閥のプレイヤーや、AIに制御された集団からの攻撃を防御することもできます。
このゲームの詳細情報は、Pantropyのキックスターター・キャンペーンを参照してください
プレアルファ版でのゲームパフォーマンスの問題
弊社は、2016年後半にプレイヤーがゲームの様々な機能をテストできるプレアルファ版を公開しました。これらの機能の1つとして、土台、壁、床、発電機、溶鉱炉など多様なパーツを使用して基地を構築する機能があります。弊社は、プレアルファ版ではサーバーとクライアントのパフォーマンスが徐々に低下することを認識していました。時にはパフォーマンスが非常に低下したため、サーバーを再起動する必要さえありました。
デバッグをおこなったところ、フレームレートがサーバー上でアクティブなBoltEntityの量に比例していることが判明しました。このため、たとえば10人のプレイヤーがオンラインで、各プレイヤーの基地に含まれるパーツの数が100以上あり、各パーツにBoltEntityが付属している場合には、結果的に同時にアクティブとなるBoltEntityが1,000以上になります。お分かりのとおり、非常に多数です。
(簡略な免責事項ですが、このデバッグの際には範囲設定/フリーズ化/アイドリング/プライオリティ計算は使用しませんでした。)
ソリューション
ゲームプレイや基地のサイズに影響をおよぼさずに、より少ないBoltEntityで稼動するシステムが必要でした。基地の構築に使用するパーツは、おもに2つのグループに分けられます:
受動パーツ ― これらのパーツは、ネットワーク上で以下の値のみ同期する必要があります:すなわち位置、回転、構造統合性、ヘルス、PrefabIDです。パーツの例としては、土台、柱、壁、床などです。
能動パーツ ― これらのパーツでは、上記以外の値も同期する必要があります。発電機、溶鉱炉、保管箱、工房備品などのパーツは、アイテムや電気関連など、その他の値も保持しています。
基地の95%は受動パーツから成ります。これらの受動パーツで同期が必要な情報は非常にわずかなので、弊社はこれらのデータを個別のBoltEntityではなくNetworkArray_Objectsに保存しました。
NetworkArray_Objectは、ステート上でBoltObjectsを配列に保存するBoltのクラスです。
この場合、NetworkArrayはBaseElement_ObjタイプのBoltObjectを保存します。
各BoltObjectは基地の受動パーツを表しています。このため、BaseElement_Objには以下の値が保存されます。
Position (Vector3) - World Space内でBasePartが置かれている位置。
Rotation (Quaternion) - World Space内でのBasePartの方位。
Structural Integrity (float) -このパーツの安定性を示します。
Health (int) - このパーツがどれくらいのヘルスを保持しているかを示します。
PrefabID (int) - 同じくPrefabIDとよばれる、Boltに実装されている構造体と混同しないよう留意が必要です。 この値はパーツのタイプを識別するのに使用されます。
IsSpawned (bool) - NetworkArrayのこのエントリーが未使用かどうかを判別します。
Boltは各ステートでの最大プロパティ数を1,024に限定するため、配列の最大エントリー数は170です。
BaseElementsManagerは、BoltのEntityBehaviorから継承するスクリプトです。このスクリプトは、自身のステートに「BaseElements」とよばれるプロパティを所有しています。このプロパティはタイプBaseElement_Objの
NetworkArrayです(上図参照)。
以下が、重要な事項です:
これの主要な機能は、受動パーツのデータを保管し、このデータのプロキシを処理することです。
プロキシはBoltEntityを付属していない純粋なGameObjectで、NetworkArrayでのエントリーを表します。
このため、「IsSpawned = True」とマークされたエントリーにはすべて、プレイヤーが相互作用できる世界でのGameObject(プロキシ)があります。この場合、プロキシは基地のパーツです。
基地は1つ以上のBaseElementsManagerから成るため、これらすべてを管理する別のスクリプトが必要です。BaseManagerスクリプトは、受動パーツを追加/削除/変更する依頼を処理し、基地のBaseElementsManagerを管理します。たとえば基地に新しいパーツを追加したいが、その基地にはすでに170個の受動パーツがあるとします。この場合には、現在のBaseElementsManagerのNetworkArrayはすべて埋まっているため新たなBaseElementsManagerが作成され、新しいパーツのデータ保管に使用されます。
またNetworkArray「BaseElements」が変更されたため、BaseElementsManageスクリプト内のコールバックOnBaseElementsStateArrayChangedが呼ばれます。続いて、コールバック内で変更されたエントリーのarrayIndexを渡すことで、HandleBaseElementメソッドが呼ばれます。さらに、変更済みのエントリーが「IsSpawned = True」とマークされたかメソッドHandleBaseElementが確認し、マークされている場合にはそのエントリーに対してプロキシがすでに存在するかが確認されます。そのエントリーにプロキシがない場合には、プロキシが生成されます。
ただし、そのエントリーが「IsSpawned = False」とマークされ、そのエントリーにプロキシがある場合には、そのプロキシは削除されます。
private void OnBaseElementsStateArrayChanged(IState s, string path, ArrayIndices indices) { int ArrayIndex = indices[0]; HandleBaseElement(ArrayIndex); } private void HandleBaseElement(int arrayIndex) { // state.BaseElements is the NetworkArray of type BaseElement_Obj BaseElement_Obj element_Obj = state.BaseElements[arrayIndex]; if(element_Obj.IsSpawned) { // ProxyBaseElements is an array of type BaseElement with a length equivalent to the NetworkArray if (ProxyBaseElements[arrayIndex] == null) SpawnBaseElementProxy(element_Obj, arrayIndex); }else { if (ProxyBaseElements[arrayIndex] != null) RemoveBaseElementProxy(ProxyBaseElements[arrayIndex], arrayIndex); } }
エントリーの値が変更された際には常に、NetworkArrayへのコールバックが呼ばれる点に留意してください。
このため、NetworkArray内のBaseElement_Objのヘルスが変更された場合には、コールバックが呼ばれます。
ちょっとしたヒント
メモリ・リークを防止するため、プロキシはプールされています。各BaseElementsManager上で実行されるルーチンがあり、このルーチンではプレイヤーが近くにいるかが確認されます。プレイヤーが近くにいる場合にはプロキシを生成する必要があります。プレイヤーが近くにいない場合には、すでに生成済みのプロキシがプールに戻され、他の基地がそのプロキシを使用できるようになります。
新たなパーツが追加/削除された場合には常に、すべての受動パーツの平均的な位置が計算され、基地の中心点が算出されます。その後、BaseManagerとBaseElementsManagersがこの中心点まで移動されます。結果的に、BaseManagerとBaseElementsManagersの位置を、範囲設定、フリーズ化、アイドリング、プライオリティの計算に使用できるようになります。
結論
範囲設定、フリーズ化、アイドリング、プライオリティを計算しつつプロキシを使用することで、数千もの個々のBoltEntityを管理するよりも効率的かつ容易にNetworkArray内に保管されたデータを表示できます。
このシステムを利用することで、受動パーツのBoltEntity総数を10進数で1700から10にまで減らすことができました!
また、このシステムはゲームで使用される鉱石にも役立ちました。このゲームでは、世界中に5,000の鉱石が分散していて、それぞれの鉱石は元来個別のBoltEntityです。弊社は現在、15個のOreNodesManagerを使用しており、それぞれのOreNodesManagerには1つのBoltEntityが付属して、340個の鉱石ノードを管理しています。
このシステムによって、1個の種を使用して世界中に木を分散させることができます。また1個のBoltEntityで500本の木を管理することが可能です。
プロキシシステムによってどのくらいパフォーマンスが向上したかの数値を、以下に示します。
例として、3,380個のパーツからなる立方体を作成するための簡単なスクリプトを記述しました。
立方体の大きさは、縦13×横13×高さ20です。
最初に実行したテストでは、各パーツにBoltEntityが付属していました。各エンティティはアクティブで、インアクティブやフリーズした状態ではありません。結果的に、オーバーヘッドはBoltのBoltPoll.FixedUpdateループによって32.20ミリ秒となりました(以下のスクリーンショットでハイライトされています)。
2回目のテストは、私のプロキシシステムを使用して実行しました。このシステムには25個のBaseElementsManagersがあり、それぞれのBaseElementsManagerは140個のプロキシを管理しています。つまり、立方体全体はわずか25個のBoltEntityから成っています。各エンティティはアクティブで、インアクティブやフリーズした状態ではありません。結果的に、BoltのBoltPoll.FixedUpdateループが0.69ミリ秒となりました(以下のスクリーンショットでハイライトされています)。
上記の成果物としてのゲームを参照したい方、またご不明点のある方はぜひ弊社のKickstarterをご覧ください:
派閥ごとにシューティングをおこなう、SFメカマルチプレイヤーゲームです。ゲーム内で建築や制作をおこなえるのが特徴的です。
コメント
0件のコメント
サインインしてコメントを残してください。