Unity ハイパフォーマンスゲーム制作入門 Vol.3
はじめに
この記事は Unity ハイパフォーマンスゲーム制作入門Vol.2 の続きです。
前回までの記事はこちら👇
この記事ではVol.2〜2-2で作ったゲームについて、主にオンライン化について学びながら作っていくことを目的としています。
今回のゲームの完成形
今回の記事では、
プレイヤー、カメラ操作の実装
NetCodeを用いてオンライン化
当たり判定とスコアの表示機能を作成
ゲームをブラッシュアップして完成
という流れでお送りしていきます。なお使用する技術は順次解説していきます。
さて、Vol.2〜2-2ではECSを用いてゲームを作成しました、ECSでオンラインゲームを作る上でNetCodeというパッケージが使えます。
(ちなみにこの記事で対応するNetCodeのバージョンは0.6.0です。)
Unity NetCodeとは
NetCodeとは、ECSを使ってサーバーランタイムをUnityで簡単に制作できるパッケージです。
これまで主流のP2Pのリレー方式のオンライン化では、24人を超えるプレイヤーを同期しようとすると問題が生じることが多いため、それ以上に関しては、専用サーバーでサーバーランタイムを動かす方式への移行が推奨されています。
また専用サーバーではP2Pには無いメリットもあります。ですがその分実装も複雑になるというイメージでした。
専用ゲームサーバーのメリット
チートが発生しづらい
例えば、アイテムの取得判定や当たり判定などをサーバーで行うことができるようになるので、チートが発生しづらいです。(P2PではBanするなどするしか対策がない)
レイテンシ耐性
P2P Relay ではレイテンシーが大きくなり、200ミリ秒未満に抑えられないことが往々にしてあるそうなので、ラグがあまり発生してはいけないゲームでは、専用サーバーが必要になると考えられます。
ということが挙げられると思います。
このようなサーバーランタイムは通常C++などで書かれることが多いと思いますが、Unity Transport PackageやNetcodeを使用することで、Unity上で簡単にサーバーランタイムを作成することが出来ます。
また更に、プレイヤー数が 80名以上だったり、500 個以上のオブジェクトや AI の同期が必要な、かなりのパフォーマンスが必要な場合はNetcodeを使用することが推奨されています。
Unity Transport Package
Unity Transport Packageというパッケージは、TCP、UDPの通信などいわゆるローレイヤーをサポートしてくれていて、NetCodeの内部でも使用しているパッケージです。
リリース時期に関して
Entities 0.50 :リリース済み(2022/3/17)
Entities 0.51:2022年第2四半期中
Entities 1.0:2022 Tech Streamでの本番サポートを予定
という感じということなので、来年くらいには製品版のDOTSがでてくるのではといった感じです。
また、UNet、Unity Relayは2022年までサポートされる予定ですので、それ以降にはUnity Transport PackageやNetCodeがUnityのネットワークライブラリを用いる場合は主流になると考えられます。(既にNetcode for GameObjectsなども出てきていますね)
MultiPlayer
また、ホスティングサービスとしてMultiPlayerが計画されています。
Among UsやApex Legendsで採用実績のあるサービスで、基本的にこれはサーバー管理してくれるものです。
いくつかのベアメタルインスタンスを持っていて、負荷に応じてクラウドインスタンスに処理を分散することができるというシステムになっています。(CEDEC2019の講演スライドがわかりやすかったです)
基本的にホスティングサービスなので、マルチプラットフォームで、ゲームエンジン非依存のサービスです。向いているゲームセッションベースのアクションゲームが向いているらしく、リアルタイム、世界規模のもので利用可能だとか。
もちろん、Netcode for Entities(やNetcode for GameObjects)に対応し、基盤として利用できるようです。
準備
それでは早速作って学んでいきたいと思います。ちなみにまだNetCodeはインストールしないでください。
前回のおさらい
前回の記事では、クジラ(プレイヤー)が魚を捕食するゲームを作ることを目的としていて
ECS(Entity Component System)を用いてBoids(群れのアルゴリズム)を実装
Burstでマルチスレッド化
というところまで実装しました。
今回は、肝心なプレイヤー(クジラ)の実装、NetCodeを用いたオンライン化をして完成まで進めていきたいと思います。
プレイヤーの実装
それではまず、オフラインでのプレイヤーを実装していきましょう。
まず、プレイヤーを識別するためのComponentを作成していきます。このようなコードを記述します、今はプレイヤーかどうか判定できればいいので、中身は空です。
using Unity.Entities;
using Unity.Mathematics;
[GenerateAuthoringComponent]
public struct PlayerComponent : IComponentData
{
}
このスプリクトをWhaleプレハブ(空のゲームオブジェクト)にアタッチします。
プレイヤーの操作
次にこのComponentがあるEntityを操作するSystemを作成していきます。以下のスクリプトを作成します。
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
public class PlayerSystem : ComponentSystem
{
// 向いてる方向
private float3 front = new float3 (0, 0, 1);
private float angleH = 0.0f;
private float angleV = 0.0f;
private float speed = 2.0f;
protected override void OnUpdate()
{
Entities.ForEach((ref Translation pos, ref Rotation rot, ref PlayerComponent playercomponent) =>
{
angleH += Input.GetAxis("Horizontal");
angleV += Input.GetAxis("Vertical");
var rotation = Quaternion.AngleAxis(angleH, new float3(0, 1, 0))
* Quaternion.AngleAxis(angleV, new float3(1, 0, 0));
var dir = new float3(rotation * front);
pos = new Translation { Value = pos.Value + dir * speed * Time.DeltaTime};
rot = new Rotation { Value = rotation };
});
}
}
こんな感じで操作できるようにしました。操作感についてはよく分からなかったので、気に食わない方は好きにスクリプトを変更しても大丈夫です。
軽く解説しますと、前回もやったEntities.ForEachを用いて、Translation、Rotation、PlayerComponentを持つEntityを対象に処理を行っています。あとはキー入力を受け取って、回転値を計算したり位置を更新したりする処理です。Transformと違って結構自分で計算しないといけないので注意。
プレハブの配置
そして先程のWhaleプレハブをヒエラルキーにドラッグ&ドロップで配置します。
ここでの注意点は、配置されたプレハブはその時点ではただのゲームオブジェクトでしか無いので、Entityに変換する必要があります。
そのため以下のようにConver To Entityスプリクトを追加して、Entityへ変換してあげます。(プレハブは変更を保存しないでください)
これで再生すると動くはずです。wasdキーで動くことを確認します。
魚がプレイヤーを避けるようにする
次に 魚がプレイヤーを避ける機能を実装していきます。このままでは魚がのんきすぎてみんな食べられてしまいます。
前回作成した BoidsSimulationSystem にコードを追記してきます。まず、プレイヤーから避けるようにするにはプレイヤーがどこにいるのか分からなくてはなりません。このように追記します。
public class BoidsSimulationSystem : SystemBase
{
EntityQuery wallQuery;
EntityQuery boidsQuery;
EntityQuery playerQuery;
protected override void OnCreate()
{
wallQuery = GetEntityQuery(typeof(Translation), typeof(Acceleration));
boidsQuery = GetEntityQuery(typeof(Translation), typeof(Velocity), typeof(NeighborsEntityBuffer));
playerQuery = GetEntityQuery(typeof(Translation), typeof(PlayerComponent));
}
...
前回もやったEntityQueryで特定のEntityを検索してこれます。
そして次のようなjobを作成します。
[BurstCompile]
public struct PlayerJob : IJobChunk
{
public ComponentTypeHandle<Acceleration> AccelerationTypeHandle;
[ReadOnly] public ComponentTypeHandle<Translation> TranslationTypeHandle;
[ReadOnly] public float separationWeight;
[DeallocateOnJobCompletion][ReadOnly] public NativeArray<Translation> playerEntities;
public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
var chunkAccel = chunk.GetNativeArray(AccelerationTypeHandle);
var chunkTrans = chunk.GetNativeArray(TranslationTypeHandle);
for (var i = 0; i < chunk.Count; i++)
{
if (playerEntities.Length == 0) return;
var pos0 = chunkTrans[i].Value;
var force = float3.zero;
for (int j = 0; j < playerEntities.Length; ++j)
{
var pos1 = playerEntities[j].Value;
var distance = math.abs(pos0 - pos1);
force += (math.normalize(pos0 - pos1) * separationWeight) / math.pow(distance, 2);
}
force /= playerEntities.Length;
var dAccel = force;
chunkAccel[i] = new Acceleration { Value = chunkAccel[i].Value + dAccel };
}
}
}
```
このjobで各プレイヤーから避けるようなベクトルを加えています。のちのちオンラインにすることを考えて複数名のプレイヤーに対応するようにしています。
`OnUpdate`の CohesionJob と MoveJob の間に、次のように追記します。
```BoidsSimulationSystem.cs
...
var player = new PlayerJob
{
AccelerationTypeHandle = GetComponentTypeHandle<Acceleration>(),
TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),
separationWeight = Bootstrap.Param.separationWeight,
playerEntities = playerQuery.ToComponentDataArray<Translation>(Allocator.TempJob),
};
this.Dependency = player.ScheduleParallel(wallQuery, this.Dependency);
…
```
playerQuery.ToComponentDataArray<Translation>(Allocator.TempJob) というところで、対象のEntityのComponentのデータのみを持ってくることが出来ます。
解説点としては以上でしょうか。このままだと少しわかりにくいので、Wall Scaleを10、Wall Distanceを5くらいにして、再生するとわかりやすいと思います。
避けているような動きをするのが分かりますね。確認したらヒエラルキーにあるWhaleプレハブは削除してもらって大丈夫です。ここで先にオンラインに対応させたいと思います。
NetCodeを用いてオンライン化
いよいよオンラインに対応させていきます。次のパッケージをWindow > Package Managerからインストールしてください。(0.6.0より新しいバージョンがインストールされた場合ダウングレードしてください)
com.unity.netcode(0.6.0-preview.7)
Client server Worlds
ちなみに、この状態で再生ボタンを押してみると、何も表示されなくなることが分かります。これはクライアントロジックとサーバーロジックは別々のワールド(クライアントワールドとサーバーワールド)に存在しているためで、Unityの default World には何も置かれないということです。
これは、Window -> Analysis -> Entity Debuggerからも確認できます。Entity Debuggerは重要ですので覚えておきましょう。
データの共有
先程、サーバーとクライアントは別々のワールドで存在することを説明しました。サーバーとクライアントの間でデータを共有するには、空のGameObject(SharedData)を作成し、ConvertToClientServerEntityコンポーネントを追加します。
Conversion Targetで Cliant And Server を指定したため、このShapreDataのオブジェクト、また子のオブジェクトはクライアント、サーバーのどちらにも同期されるようになります。
このようにNetCodeではちょっと特殊な方法でEntityにしなければ同期されない点を注意しましょう。
ゴースト化
ghostとはネットワーク化されたオブジェクトのことで、サーバークライアントで同期したいオブジェクトはghostにする必要があります。
ゴーストスナップショット
また、サーバー上に存在するエンティティをすべてのクライアントに同期するゴーストスナップショットと言われるシステムがあり、サーバーはすべてのゴーストの現在の状態のスナップショット(差分のみ)をクライアントに送信、サーバーが所有しているオブジェクトはクライアント側での操作はできない仕組みになっています。
(サーバーはエンティティごとではなくECSチャンクごとに処理します。受信側では、エンティティごとに処理が行われます)
プレイヤーのゴースト化
では実際にプレイヤーを追加してサーバークライアント間で同期してみたいと思います。
NetCodeでは、プレーヤーをPlayerIdで識別するため、先ほど作成した PlayerComponent を次のようにします。
using Unity.Entities;
using Unity.NetCode;
[GenerateAuthoringComponent]
public struct PlayerComponent : IComponentData
{
[GhostField]
public int PlayerId;
}
[GhostField] のアトリビューションがついている変数が自動的に同期されます。
そしてWhaleプレハブを開いて、GhostAuthoringComponentを追加します。これがゴーストに変換してくれるスクリプトです。追加したら、Update Component Listを押すと変換されるComponentのリストが表示されます。
また、個別のComponentでは有効無効を制御できるプロパティがあり、例えば、Serverを無効にすると、ゴーストはサーバーでインスタンス化されたときはそのComponentを持たなくなりますが、クライアントでインスタンス化されたときには持つようになります。
(不具合で、エディターから実際に無効にすることはできませんが、prefabファイルを直接編集することで変更することができます。0.6.0のみの不具合ですが…)
続いて、Default Client Instantiation TypeをOwner Predictedに変更します。Default Client Instantiation Typeには3つの設定があり
Interpolated は主にサーバーで処理を行い同期されるモードです。
Predicted はクライアント側でも"予測"を行い、サーバーと同期されるモードです。
Owner predicted は所有者(クライアント)が処理を行って、サーバーを介して全てのクライアントに同期される設定です。
そして、GhostOwnerComponentAuthoringコンポーネントを追加します。(Owner Predictedの場合に必要なコンポーネントです)
予測
ここで、クライアント側での"予測"について説明します。これはローカルのプレーヤーも処理を実行し、サーバーとの同期は、ローカルのプレーヤーの計算ミスやプレーヤーの影響があった場合のみサーバーに合わすようにして行われるということです。
サーバーは常の速度で演算(デフォルト数60FPS)を行い、サーバーの演算が遅くなる(処理落ち)を防ぐようにリミッターがあります。また、クライアントは通常動的なFPSで実行されるので、クライアントは常にサーバー時間(tick)と一致する必要があります。
また、ECS版の物理演算のPhysicsでは物理シミュレーションがサーバーでのみ実行されるようにしなければ行けないらしく、その場合ゴーストはInterpolatedに設定します。
Entityのスポーン
クライアント側の話で、サーバーから新しいゴーストを受信すると、クライアント側でEntityが自動的に生成されます。
また、さきほどご紹介した、Interpolatedでは若干や遅延してスポーン(生成)されます。
処理方法です。
Predictedでは、クライアント側でも"予測"を行うため、遅延スポーンすることなく生成され、以後サーバーと同期されます。
Owner predictedはもちろんクライアント側が所有者なので新しいエンティティは作成されません。この後、他のプレイヤー全てに同期されます。
また、位置などの同期処理は自動的に行われるので必要はありません。
Boidのゴースト化
同じような要領でBoidプレハブもゴースト化してしまいましょう。Boidプレハブを開き、GhostAuthoringComponentを追加します。次のように設定します。
ゴーストコレクション
次に作成したゴーストをNetCodeで使用してもらうためGhost Collectionを作成します。ゴーストコレクションとは
とのことです。ゴーストコレクションはクライアントとサーバーのエンティティワールドの一部である必要があるため、ShareDataを右クリックして空のゲームオブジェクト(GhostCollection)を作成します。そして、GhostCollectionAuthoringComponentを追加します。Update ghost listを押すと、先ほど作成したゴーストたちが表示されるはずです。
ここまでで下準備は完了です。次回は実際にネットワーク接続へ進んでいきます。お楽しみに!
Vol.3-2はこちら👇