Netcode for GameObjectsでプレイヤー間で同期する物体の生成 / 破棄

f:id:fb8r5jymw6fd:20211210202730p:plain

はじめに

エンジニアの松原です。この記事はSynamon Advent Calendar 2021の11日目の記事になります。

前々回の記事前回の記事では Netcode for GameObjects について紹介してきました。今回は以前の内容を元に、プレイヤーが生成でき、かつプレイヤー間で同期処理を行うことができる物体の生成 / 破棄について紹介したいと思います。

 

前回に引き続き、今回の内容について個人のGitHubリポジトリにアップロードしておりますので、もしよろしければ触ってみてください!

 

今回の機能を反映したサンプルシーン名は SampleScene_NetworkShooting.unity になります。

f:id:fb8r5jymw6fd:20211210085018p:plain

 

NetworkManagerに同期可能オブジェクトを登録する

Netcode for GameObjects では Prefab 化したオブジェクトを下図のように NetworkManager の NetworkPrefabs の箇所に追加することで、対象のオブジェクトをプレイヤー間で同期できるオブジェクトとして登録されます。

f:id:fb8r5jymw6fd:20211210085203p:plain

 

このPrefabの中身は最低 NetworkObject が含まれていればよいですが、単純に 生成 / 破棄 しかできないため、 生成後に同期オブジェクトの操作を行う場合は NetworkBehaviour を継承したクラス内に手続きを記述し、Prefab にアタッチします。

下図は今回サンプルに用意した Bullet_NetworkTransform.prefab で、プレイヤーが射撃ボタンを押した際に、この Prefab を生成するように作られています。

必須の NetworkObject コンポーネントの他、弾丸のようにまっすぐ飛び続ける処理が実装されている BulletSyncBehaviour (NetworkBehaviour 継承クラス) をアタッチしており、動きの補完処理を行うための ClientNetworkTransform もアタッチしています。ユーザーからの操作が無い場合であれば、 NetworkTransform でも良いかもしれません。 (ClientNetworkTransform は前回の記事で紹介しています。)

f:id:fb8r5jymw6fd:20211210101437p:plain

BulletSyncBehaviour の実装に関しては同期オブジェクトの削除のパートで解説したいと思います。

 

スクリプトから同期可能オブジェクトの生成を行う

同期可能オブジェクトを実際に生成するには、 NetworkBehaviour 継承クラスから呼び出す必要があります。その呼び出し方法について紹介していきます。

下図は今回用意された Player Prefab として、 Player_NetworkShooting.prefab のコンポーネントになります。緑の枠で囲まれているのは、 NetworkBehaviour を継承した PlayerSyncShootingBehaviour のクラスになります。

このクラスのシリアライズされた Prefab に、先ほど登場した Bullet_NetworkTransform.prefab がアタッチされています。

f:id:fb8r5jymw6fd:20211210090655p:plain

 

同期オブジェクト生成のきっかけになるトリガーを作る

今回はガンシューティングのように、特定のトリガーが押されたときに同期オブジェクトを生成するように実装しています。キーボードのスペースか、ゲームパッドの右トリガーに入力が割り当てられています。

f:id:fb8r5jymw6fd:20211211123028p:plain

 

こちらは以前の記事で登場した PlayerInputHelper のクラスに HasFireInput のプロパティを定義し、トリガーボタンが押されているかを検出する処理を追加しています。

[SerializeField]
private float triggerDeadZone = 0.4f;

public float Fire { get; private set; }

public bool HasFireInput => Fire > triggerDeadZone;

public void OnFire(InputValue value) => Fire = value.Get<float>();

 

シリアライズ可能なプロパティとして triggerDeadZone が追加されています。

f:id:fb8r5jymw6fd:20211211124346p:plain

ゲームパッドなどの左右トリガーはアナログ入力になっており、0~1の範囲でトリガーの引き具合(押し込み量)が変化します。指でどれぐらい引けばボタンとして反応するかのしきい値をこのプロパティに設定しています。

余談になりますが、下図のように InputActionAsset で入力に対しての ActionType を Button にしている場合、トリガーなどのアナログ入力のしきい値は0.5がデフォルトになるようです。

f:id:fb8r5jymw6fd:20211211125109p:plain

 

トリガー発火時に同期オブジェクトを生成する処理を行う

以下のコードは PlayerSyncShootingBehaviour から抜粋したもので、 GameObject.Update() から呼び出されています。ここに先ほどのトリガー入力判定 (PlayerInputHelper.HasFireInput プロパティの読み取り)を行っています。この入力を元に同期オブジェクトを生成するギミックを呼び出します。

同期可能オブジェクトの生成はサーバー側で行う必要があります。

自分がサーバーの場合は直接生成するメソッド (GenerateBulletOnServer) を呼び出すようにしています。自分がクライアント端末の場合は GenerateBulletOnServer() のメソッドをサーバー側で呼び出すためのRPCメソッドを指定しています。

private void UpdatePlayerInputs(float delta)
{
if (playerInputHelper == null || gunRoot == null) return;

fireDelta += delta;

if (fireDelta > fireRate && playerInputHelper!.HasFireInput)
{
fireDelta = 0;
if (IsServer)
{
GenerateBulletOnServer(gunRoot.transform.position, gunRoot.transform.rotation);
}
else
{
SubmitGeneratingBulletRequestServerRpc(gunRoot.transform.position, gunRoot.transform.rotation);
}
}
}

 

以下はそれぞれ上記のメソッドの実装部分になります。

最初に GameObject.Instantiate() メソッドを使い、同期可能オブジェクトの Prefab を GameObject として生成しています。

次に、この Prefab には NetworkObject のコンポーネントが含まれているので、このコンポーネントを GetComponent<NetworkObject>() メソッドで取得します。

そして NetworkObject.Spawn() メソッドを呼び出すことにより、プレイヤーに生成した GameObject を同期可能オブジェクトとして生成することができます。

private void GenerateBulletOnServer(Vector3 position, Quaternion rotation)
{
var bullet = Instantiate(
bulletPrefab,
position,
rotation
);
bullet?.GetComponent<NetworkObject>().Spawn();
}

[ServerRpc]
private void SubmitGeneratingBulletRequestServerRpc(Vector3 position, Quaternion rotation)
{
GenerateBulletOnServer(position, rotation);
}

 

上記の記事についてはUnity公式の Object Spawning のページで紹介されています。

 

同期可能オブジェクトの破棄

一度生成した同期可能オブジェクトは、ライフタイムが用意されていないので、オーナー権限を持っているクライアントがログアウトしない限り残り続けます。

今回のサンプルでは同期可能オブジェクト (Bullet_NetworkTransform.prefab) 側にライフタイムを設定しており、指定した時間経過した時に削除する処理を実装しています。

以下のコードはサンプルの同期可能オブジェクトにアタッチしている BulletSyncBehaviour (NetworkBehaviour 継承のクラスになります) のコード一部になります。

UpdateLifeTime() はUpdateから呼び出されます。 elapsedTime に経過した時間が保持されており、任意の経過時間が過ぎた場合、サーバーであれば RemoveBulletOnServer() のメソッドが直接呼び出され、クライアントであれば SubmitRemoveBulletRequestServerRPC() が呼び出され、間接的にサーバーから上記のメソッドが同じように呼ばれます。

同期オブジェクトの破棄は NetworkObject.Despawn() で行います。このメソッドが呼び出されると サーバーからこの同期可能オブジェクトの破棄が行われます。

private void UpdateLifeTime(float delta)
{
if (elapsedTime > lifeTime)
{
if (IsServer)
{
RemoveBulletOnServer();
}
else
{
SubmitRemovingBulletRequestServerRpc();
}
}
}

private void RemoveBulletOnServer()
{
gameObject.GetComponent<NetworkObject>().Despawn();
}

[ServerRpc]
private void SubmitRemovingBulletRequestServerRpc()
{
RemoveBulletOnServer();
}

 

NetworkObject.Despawn()の注意点

NetworkObject.Despawn() を呼び出すことにより、同期可能オブジェクトはクライアント側では実際にインスタンスそのものが破棄されるようですが、サーバー側ではインスタンスがメモリ上に残っているようです。

その理由として、 Netcode for GameObjects では同期可能オブジェクトは Object Pooling の管理下にあるようで、同期オブジェクトの 生成 /破棄 によるメモリ操作のオーバーヘッドを減らすために Object Pool の仕組みが組み込まれているようです。

詳しいふるまいをカスタマイズできるようなので、ここもいつか深く追ってみてみたいと思います。

 

今回できたもの

f:id:fb8r5jymw6fd:20211210201404g:plain

 

最後に

実際にはゲームロジックやスコアボードなど、マルチプレイに必要な機能はたくさんありますが、今回でマルチプレイを行うための必須の機能がだいたい解説できたと思います。

ただし、実際にリモートサーバー上で実行した場合のパフォーマンスのチューニングなどまではできていないので、今後はそのあたりを考慮した実装も必要になってくるかもしれません。

 

宣伝

今年のSynamonはアドベントカレンダーを実施しております、よろしければ他の記事もお読みいただけると嬉しいです!

qiita.com

 

また、弊社ではUnityエンジニアを募集しています。興味がある方は是非以下のページを覗いてみてください!

twitter.com

meety.net

herp.careers