Edited at

【Unity】ECSで弾幕STGを作ってみたので技術周りについて解説

More than 1 year has passed since last update.

先日「Unity1週間ゲームジャム(お題「ぎりぎり」)」と言うイベントが開催されました。

簡単に説明するとUnityを用いて1週間でゲームを作成し、日曜日の夜に公開すると言った旨のイベントです。

私の方ですが、こちらのイベントに参加して時間がシビア & ECSがPreviewなのにも関わらずぶっつけ本番な感じでPureECSをメインとした弾幕STGを作成して公開しました。作った作品自体は小規模なものですが、実装中に色々と得られるポイントはあったので、開発時に於ける知見諸々を備忘録としてメモして行ければと思います。


  • ※以下、注意点です。


    • この記事中ではECSについての基礎的な部分についての説明は致しません。ECSの基礎的と思われる部分については以前書いた記事にて検証/解説、参考リンク等を載せてありますので宜しければこちらを御覧ください。

    • 正直言って今回の実装自体正しいかどうかは不明です。。まだまだ手探り状態故にあくまで実装の一例程度に留めて頂ければと思います。。

    • unityroomさんの提出形式がWebGLな都合上、Thread非対応であるために敢えてJobSystemは使っておりません。(逆に言うとモバイル向けに持って行く際にはJobSystemも併用することで最適化の余地があるかもしれません)

    • ECS自体についてもまだまだPreviewな状態であり、将来的には実装が変わってくる可能性もあります。その点を踏まえて参考にして頂ければと思います。



ちなみにゲームのプロジェクト一式については外部ライブラリや一部リソースを代替品に取り替えた上で公開しております。


どんなゲーム?

ギリギリ弾幕STG

bullets.gif

ゲームとしては上記のような縦ベースのSTGであり、敵を倒しながら自機を回復させつつ出来るだけ長く生き延びる事が目的のゲームとなっております。

問題点特徴としてはゲームジャムのテーマである「ぎりぎり」に沿う形で60fpsギリギリまで敵と弾幕を出し続けるというコンセプトです。その為にスペックの良いPCでプレイするほど敵と弾幕の出現数が上昇して難易度が上がっていくはず...でしたが、そもそも「全方位段を撃ってくる敵の凶悪性」「自機の圧倒的な低スペック」諸々の要素が合わさり大体の場合は敵が増える前(運が悪ければ1秒未満)に殺られるという比較的高難易度な部類に入るゲームになっているかと思われます。(多分)

※死亡時の画面。放置すると↓の様な事態になります。余談ですが会社の高スペックマシンで実行した所、これ以上に生成され過ぎて画面がちょっとした流体シミュレーションの様になってました。

bullets2.gif

今回ECSで実装した箇所としては、STGに関するインゲーム部分全般(「自機/敵/弾」の生成/移動/当たり判定/etc...)であり、それ以外(「エントリーポイント兼ゲーム管理用クラス」「ランキングシステム」「UI部分」等)は既存のMonoBehaviourでの実装となります。

先ずは設計から順を追って説明していければと思います。


設計について

Design.png

設計について、先ずはエントリーポイントとなる管理クラス(上記の図で言うMainECS_Manager)を一つ用意します。管理クラスの役割としては以下が挙げられます。(正直言って役割が多く神化している感が否めない所ですが....)


  • ゲーム開始時に必要なEntity(プレイヤー(Player)、敵生成システム(EnemySpawnSystem))の生成及び管理

  • シーケンス管理(プレイヤーの死亡判定を確認したらゲームオーバーを表示するなど)

  • ゲームの設定情報(GameSettings)の参照保持、Entity生成時に必要な情報の受け渡し

Entityの生成については全て管理クラス側で行うことはなく、弾や敵と言ったEntityについては「プレイヤー」「敵生成システム」「敵」と言った各Entityの方で生成を行います。(その為のComponentSystemを実装)


  • なお、EntityからEntityを生成する際に受け渡す情報(ComponentDataのパラメータ)については予めISharedComponentDataとして登録しておいた共通情報を渡すか、又は管理クラスにpublic staticなフィールドとして保持しておき、そちらを随時参照する形で渡しております。


    • ※後者のstatic参照についてはEntityが管理クラスと結合してしまう上に(今回はそもそも使っておりませんが)BurstCompolerが使えなくなるので注意。今回は時短の為に承知した上で使っております。。




EntityとComponentData

Entities.png

上記の図はEntityとComponentDataの関係(どのEntityがどのComponentDataを所持しているのか?)についての説明となります。



  • 「Player」「Enemy」「EnemySpawnSystem」「*Bullet」と言った大枠の矩形で括られているのがEntity、その中にある「Position2D」「TransfromMatrix」と言った小さい矩形で括られているのがComponentDataです。


    • ComponentDataの表記について、矩形が肌色の物がIComponentDataを実装したもので、ライム色の物がISharedComponentDataを実装したものとなります。


      • ※こちらの違いについて少し補足すると、前者は座標/ライフと言ったEntity毎に異なるデータ(言い換えるとRead/Write想定のデータ)として扱うものであり、後者は描画情報/当たり判定の大きさと言ったEntity共通のデータ(言い換えるとReadOnlyなデータ)として扱うものとなります。






  • こちらについてはEntity生成時の効率化のために管理クラスの方で予めArchetypeとして定義しておき、Entity生成時に参照するようにしております。(※Archetypeについては管理クラスのpublic staticなフィールドに保持)



    • ただ、一点注意点としてISharedComponentDataについてはArchetypeに登録することが出来ず、Entity生成時にAddSharedComponentDataで紐付ける必要がありました。


      • ※その為、コード上ではEntityが持つComponentDataを明示的にする為にコメントの方を残してあります。



    • ※Archetype自体はメモリレイアウトの最適化にも使用されるため、内部的な都合でこうなっている可能性はありますが..。(詳細は不明)



▼ MainECS_Manager.cs

// Archetypes Settings

PlayerArchetype = this._entityManager.CreateArchetype(
typeof(Position2D),
typeof(PlayerInput),
typeof(PlayerLife),
typeof(TransformMatrix));
// ▼ SharedComponentData ※SharedComponentDataはArchetypeに登録出来ないので使用される物をメモ
// - MeshInstanceRenderer
// - PlayerSettings
// - PlayerCollision
// - PlayerColor

EnemyBulletArchetype = this._entityManager.CreateArchetype(
typeof(Position2D),
typeof(BulletData),
typeof(EnemyBullet),
typeof(TransformMatrix));
// ▼ SharedComponentData
// - MeshInstanceRenderer
// - BulletCollision

PlayerBulletArchetype = this._entityManager.CreateArchetype(
typeof(Position2D),
typeof(BulletData),
typeof(PlayerBullet),
typeof(TransformMatrix));
// ▼ SharedComponentData
// - MeshInstanceRenderer
// - BulletCollision

EnemySpawnSystemArchetype = this._entityManager.CreateArchetype(
typeof(EnemySpawnSystemData));
// ▼ SharedComponentData
// - EnemySpawnSystemSettings

CommonEnemyArchetype = this._entityManager.CreateArchetype(
typeof(Position2D),
typeof(EnemyData),
typeof(TransformMatrix));
// ▼ SharedComponentData
// - MeshInstanceRenderer
// - EnemyCollision
// - [BarrageSettings_DirectionBullet] or [BarrageSettings_CircularBullet]
// →こちらについて、登録するISharedComponentDataによって敵が撃ってくる弾幕が変わる。どちらか片方しか登録されない想定。

ComponentData(Shared含む)の内容については以下のソースに纏めてあるのでこちらを参照してみて下さい。

ComponentData.cs


EntityとComponentSystem

Systems.png

上記の図はEntityとComponentSystemの関係(どのEntityがどのComponentSystemにInjectされて更新されるのか?)の説明です。



  • 「PlayerInputSystem」「PlayerMoveSystem」と言った白色の大枠の矩形で括られているのがComponentSystemで、その中にある「Position2D」「TransfromMatrix」と言った小さい矩形で括られているのがInjectされるComponentDataです。


  • ComponentDataの色については所持しているEntityを示しており「Player : ピンク」「Enemy : 水色」「EnemySpawnSystem : 黄色」「PlayerBullet : オレンジ」「EnemyBullet : 黄緑」となります。


    • ※その為に一つのSystemの中にPosition2Dが2つ入ってくるにしても片方はPlayerの座標、もう片方は弾の座標と別れていることがあります。ここらの切り分けについてはComponentSystem内で定義してあるGroupにて分けております。「※例 : プレイヤーの当たり判定に置ける自機と敵弾の切り分けについて

    • ※この様にComponentSystemとEntityの関係は一対一では無く、実装によっては一つのSystemに複数種類のEntityがInjectされる事があります。



  • ComponentSystemについて一つ一つ解説していくと長くなるので全部については解説致しません。以降は要点/注意点と言ったポイントを後述していくので全実装についてはソースの方をご確認下さい。




各種要点/注意点など


敵弾と自機弾の識別について

bullet.png

敵弾と自機弾について、これら2点は上記のEntityの図からも分かる通りデータとして重複している部分が殆どとなるために何かしらの情報を持たせた上で自機の弾か敵の弾かを識別する必要がありました。(所持しているComponentDataのセットが同じだと同じEntityとして扱われるため)

アプローチの一つとして、ComponentDataの内容に「敵か自機か」の情報を持たせて弾自体を同じEntityとして扱い、参照時に識別する形で管理すると言うやり方も思い浮かびましたが、今回の実装としてはPlayerBullet/EnemyBulletと言う識別子用のComponentDataを振り分けることで別Entityとして生成する形にしております。

これら2点の内容については識別子用と言う事もあり、実装としては空の構造体となっております。

※このやり方について、正直最適解かどうかは分かりませんが....公式サンプルであるTwoStickShooterのPureECS実装でも似たような実装があったので参考としつつ採用しております。

▼ ComponentData.cs

public struct EnemyBullet : IComponentData { }

public struct PlayerBullet : IComponentData { }


敵が撃ってくる弾幕の指定について

Enemy.png

今回の敵の違いとしては撃ってくる弾幕の種類しか無いという仕様となっているので、敵のEntityが所持しているComponentDataは可能な限り同じものを持たせて、最後に弾幕設定が入っているISharedComponendDataのどちらかを付ける事で撃ってくる弾幕が変わるよう実装となっております。


  • 弾幕設定が入っているISharedComponentDataとしては上記画像の赤枠で囲っている2点が該当し、BarrageSettings_DirectionBulletが設定されたらDirectionBullet_BarrageSystem(下記の図で言う左側)が呼び出される様になり、BarrageSettings_CircularBulletが設定されたらCircularBullet_BarrageSystem(下記で言う右側)が呼び出される様になります。


    • ※弾幕の違いについて簡単に解説すると以下のようになってます


      • DirectionBullet_BarrageSystemの方は自機に向かって一定間隔で発砲

      • CircularBullet_BarrageSystemの方は全方位に一定間隔で発砲



    • ※DirectionBullet_BarrageSystemの方は自機を参照するのでPlayerのEntityの情報もInjectするようにしてます。



Barrage.png

▼ EnemySpawnSystem.cs

// 敵の生成

void SpawnEnemy(ref EnemySpawnSystemData data, ref EnemySpawnSystemSettings spawnSettings)
{
++data.SpawnedEnemyCount;

var type = UnityEngine.Random.Range(0, spawnSettings.MaxBarrageType);
var pos = spawnSettings.RandomArea();

PostUpdateCommands.CreateEntity(MainECS_Manager.CommonEnemyArchetype);
PostUpdateCommands.SetComponent(new Position2D { Value = pos });
PostUpdateCommands.SetComponent(new EnemyData { });
PostUpdateCommands.AddSharedComponent(MainECS_Manager.EnemyLook);
PostUpdateCommands.AddSharedComponent(MainECS_Manager.EnemyCollision);

// 抽選結果に応じて撃ってくる弾幕が変わる
switch ((BarrageType)type)
{
case BarrageType.CircularBullet:
{
PostUpdateCommands.AddSharedComponent(MainECS_Manager.BarrageSettings_CircularBullet);
}
break;
case BarrageType.DirectionBullet:
{
PostUpdateCommands.AddSharedComponent(MainECS_Manager.BarrageSettings_DirectionBullet);
}
break;
}
}


MeshInstanceRendererについて


  • やっている事は「前回書いたECSの記事」と同様にMeshInstanceRendererSystemと言うECSのパッケージに入っている描画システムを用いております。


  • ただ、これを使うに当たっては幾つか注意点がありました。


    • MeshInstanceRendererSystemは内部でGPU Instancingを行っており、使用するにはPlatform側のGraphicsAPIがGPU Instancingに対応している必要があります。Unity1週間ゲームジャムの提出先であるunityroomさんはWebGLビルドを出す形となっており、WebGL自体は2.0に対応していればGPU Instancingを使えるので、今回はWebGL2.0専用のゲームとして設定し、対応している一部ブラウザ(Chrome、Firefox)のみで動くゲームとして割り切りました。


      • ※WebGL 1.0はPlayerSettingsレベルで外しているので、逆に言うとEdgeやSafariと言った非対応ブラウザについてはゲームを動かすことが出来ません。

      • ※ブラウザに於けるWebGL2.0の対応状況についてはUnity WebGLのドキュメントに纏まっております。古いバージョンのページを見ると対応状況が古いままとなっているので新しいページを参照するのが無難かと思われます。



    • SpritePackerについて、今回の実装で単純にこちらを用いると画像のUV情報が相違するためか正常に表示されない状態となるので注意する必要があるかと思われます。(たぶん自分で補完処理を書けば解決できるかと思われますが今回は未対応&未使用)

    • 下記のコードのコメントにもある通りUnity.Mathematicsが行優先と思われる都合上、座標を動かすとZ-Upみたいな挙動になるのでカメラを上から見下ろす形で映す必要がありました(※下記画像参照)。今回は辻褄を合わせるためにSpriteをMeshに変換する際に頂点レベルで予め回転させております。


      • ※この時に起こる問題の内容をもう少し詳しく説明すると、Position2Dと言うECSのパッケージに含まれているComponentDataを経由してのY座標の値の更新を行うとUnityの座標系で言うZ軸方向に移動します。。その為にCameraを上から見下ろす形にする事で辻褄を合わせてあります。

      • ※公式サンプルであるTwoStickShooterのPureECS実装の方も同じ様な実装で解決しているように思われました。(見下ろし撮影)





scene.png

▼ MainECS_Manager.cs

/// <summary>

/// 渡した表示データからMeshInstanceRendererの生成
/// </summary>
/// <param name="data">表示データ</param>
/// <returns>生成したMeshInstanceRenderer</returns>
MeshInstanceRenderer CreateMeshInstanceRenderer(MeshInstanceRendererData data)
{
// Sprite to Mesh
var mesh = new Mesh();

// Unity.Mathematicsが行優先な都合上、Z-Upみたいな挙動になるので辻褄を合わせるためにMeshの頂点レベルで回しておく。
// 後はデフォルトだとデカかったので序に表示サイズも設定。
var vertices = Array.ConvertAll(data.Sprite.vertices, _ => (Vector3)_).ToList();
Matrix4x4 mat = Matrix4x4.TRS(
new Vector3(0f, 0f, 0f),
Quaternion.Euler(90f, 0f, 0f),
Vector3.one * this._gameSettings.ObjectScale);
for (int i = 0; i < vertices.Count; ++i)
{
vertices[i] = mat.MultiplyPoint3x4(vertices[i]);
}
mesh.SetVertices(vertices);
mesh.SetUVs(0, data.Sprite.uv.ToList());
mesh.SetTriangles(Array.ConvertAll(data.Sprite.triangles, _ => (int)_), 0);

var matInst = new Material(data.Material);

// 渡すマテリアルはGPU Instancingに対応させる必要がある
var meshInstanceRenderer = new MeshInstanceRenderer();
meshInstanceRenderer.mesh = mesh;
meshInstanceRenderer.material = matInst;
return meshInstanceRenderer;
}


当たり判定について

現時点でのPureECSはColliderを使うことが出来ないので当たり判定から自作する必要があります。

今回は単純に全て円同士の当たり判定で対応しております。以下のコードはプレイヤーの当たり判定周りについての物となります。


  • 当たり判定の設定情報としては「*Collision」と言うISharedComponentDataを用意し、そちらをEntityに渡しております。データの値自体はGameSettingsと言うScriptableObjectが保持しているのでそちらで変更可能です。

  • 後は開発時に於いては調整しやすいように当たり判定を視覚化する為のデバッグ機能も実装しました。暗黒再車輪...

※画像は調整時のもの

collider.jpg

▼ PlayerHitCheckSystem.cs

// 敵弾のEntityを総当たりでチェック(※ここらはJobSystemを併用すれば高速化できそうだが、WebGLはThread非対応なので今回は未使用....)

for (int i = 0; i < this._enemyBulletGroup.Length; ++i)
{
float2 bulletPos = this._enemyBulletGroup.Position[i].Value;
float bRadius = this._enemyBulletGroup.Collision[i].Radius;

// 円と円の衝突判定
if (math.pow((playerPos.x - bulletPos.x), 2) + math.pow((playerPos.y - bulletPos.y), 2)
<= math.pow((pRadius + bRadius), 2))
{
PostUpdateCommands.DestroyEntity(this._enemyBulletGroup.Entities[i]);

playerLife.Value -= bulletDamage;
if (playerLife.Value <= 0)
{
playerLife.Value = 0;
PostUpdateCommands.DestroyEntity(this._playerGroup.Entities[0]);
break;
}

}
}


参考/関連サイト