Help us understand the problem. What is going on with this article?

【Unity】ECS + GameObject/MonoBehaviourの連携を踏まえてゲームを作ってみたので技術周りについて解説

More than 1 year has passed since last update.

凡そ一ヶ月近く前にはなりますが、Unity1週間ゲームジャム(お題「10」)と言うイベントが開催されました。

私自身は前々回の時に参加して「ECS(EntityComponentSystem)で弾幕STGを作ってみたので技術周りについて解説」と言う記事を書いたイベントでもあるのですが...その時に作成したものはインゲーム部分でGameObject/MonoBehaviourを殆ど使用しないPureECSオンリーのゲームであり1、そもそもECSの設計周りについて理解が足りていなくて(多分)設計的に微妙かもしれない?所があったり、ゲーム自体もSEやエフェクトと言った演出が一切入っていないシンプルなゲームで留まっている状況では有りました。

後は実際にECSを導入するとしても、恐らくはPureECSオンリーと言うよりは何かしらのGameObject/MonoBehaviourと組み合わせて使うだろうなーと個人的には予想しており、これらを踏まえて設計を考え直してみたいと思っている所でもあったので、今回のイベントを機に前回では満たせなかった要素を満たせるように設計する事を前提として、検証も兼ねた簡単なゲームを一本作ってみたので、開発時に於けるTips等を備忘録序にメモしていければと思います。

■ プロジェクト/各種バーション

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

mao-test-h/OneWeekGameJam_Ten

  • Unity
    • 2018.3.0f2
  • dependencies
    • "com.unity.entities": "0.0.12-preview.21"
    • "com.unity.postprocessing": "2.1.2"

■ 注意点など

ECS(EntityComponentSystem)について

  • この記事中ではECSの基礎的な部分についての説明は致しません。
    • 例 : そもそもEntityComponentSystemとは何か?と言った話など。
    • ※一番下の「▽ 参考/関連サイト」の項目に初学者向けのリンクなど載せております。宜しければ..。
  • ECS自体についてもまだまだPreviewな状態であり、将来的には実装が変わってくる可能性もあります。その点を踏まえて参考にして頂ければと思います。
  • 記事中で触れるECSについてはPureECSを前提としたものとなります。(Hybridについては触れません)

ゲームの設計について

  • 全部解説すると長くなるので、要点のみ取り上げる形で解説してます。
    • ※例えばUI周りと言ったECSとの関連が薄い部分についてはフルで割愛
  • ソースについても載せてある物については基本的には一部引用となるので、全体についてはGitHubの公開リポジトリを御覧ください。
  • 内容としては手探りな部分が多々あります。
    • その為に確実に正しい実装や考え方かと言われると微妙な部分もあるかもしれないので、あくまで実装の一例程度に留めて頂けると幸いです。。(中には「実はHybridで実装できたのでは?」的な所もありそう感...)
  • Unity1週間ゲームジャムの開催元であるunityroomさんの提出形式はWebGLビルドとなるので、JobSystemは機能しない状態となりますが、今回の実装としてはJob周りの検証も踏まえているので敢えてJobSystemを使う形で実装してみてます。
    • ※ちなみにWebGL上でのJobSystemの実装自体は全く機能しないわけではなく、全てMainThread上で実行される模様。

▽ 作ったゲーム

弾幕STG 10

sample.gif

ゲームとしては上記の様な弾幕系のSTGになるかと思います。(「また弾幕STGかよ...」禁止)

そこまで凝ったものではないのですが...前作では組み込めなかった演出(SE/エフェクト)や弾幕の種類2と言った要素を増やす形で作成してみたものとなります。

前作の即死ゲーの反省点を活かし、「自機に敵の弾を無効化するシールドを搭載」と言った各種要素を増やしてみたものの...まぁ色々とあって10秒以上生き残れば運が良い方と言う比較的高難易度な部類に入るゲームになっているかと思われます。(多分)

では早速本題の設計周りについて解説していければと思います。(ゲームの内容から話を逸らす)

▽ 全体的な設計について

▼ MonoBehaviour

MonoBehaviour周りの設計としては以下の様になっております。

GameManagerがエントリーポイント兼ゲーム全体の管理クラスとなり、ECSManager/EnemySpawner/Playerの生成及び初期化(ScriptableObjectの参照注入など)を行います。
※但しECSに関する初期化処理諸々(Worldの生成/ComponentSystemの登録/Prefab Entityの生成/etc..)についてはGameManager側では行っておらず、全てECSManagerの方で行うようにしてあります。

その為に初期化の順番としては、先にECSManagerの初期化を行った後にGameManager経由でPlayer/EnemySpawner等に参照を流し込み、各クラスはECSManager経由でEntityの生成やComponentDataの書き換えと言った操作を行うようにしてます。

今回はこの様な形で参照解決を行ってみましたが...正直言って今回で言うECSManager的な責務を持つクラスを用意するとしたら、クラスが増えた際の対応などを踏まえると結構面倒くさいので、素直にシングルトンで実装するなり(未検証ですが)Zenjectで流し込むなり出来ないか調べてみても良い気がしてきました..。
(後は根本的な問題としてECSManagerが巨大化しているので、ECSの管理クラスを分割できないか検証してみるなど..。)

ManagedUML.png

▼ ECS

こちらは設計というよりはWorldに登録されているComponentSystemの実行順になります。
※ちなみに頭に「★」と付いているものはentitiesに含まれている既存のComponentSystem。(それ以外は自作したComponentSystemを指す)

もう少し模索すれば効率よいやり方があるかもしれませんが...一先ずはこの流れで制御してます。

ComponentSystem.png

▽ プレイヤーと敵の生成処理はMonoBehaviour側で実装/制御

表題の通り、プレイヤーと敵の生成処理についてはMonoBehaviour側での実装/制御となります。
(正確に言うと敵の生成処理についてはMonoBehaviourの派生クラスでは無く、C#のピュアなクラスとして実装されている。今回の実装としてECSに関わらない制御部分は全体的に「MonoBehaviour側での実装/制御」と言うニュアンスで記載している。)

Update3内でロジックの制御を行い、更新された情報を元にECSManager経由でEntityの生成やSetComponentDataで一部のEntityの情報を書き換えと言った処理を行います。具体的な内容については後述していきます。

▼ プレイヤーについて

プレイヤーはGameObject(Prefab)/MonoBehaviourとして実装しており、GameManagerから初期化時のタイミングでInstantiateされます。
その為に描画についてもSpriteRendererで行っていたりと、割と普通(?)な実装となっております。

ただし今回の実装として「弾全般」と「敵」についてはECS側で制御を行っているために、自機弾のショットや衝突判定と行った物はECSと同期を取る必要があります。

先ずはショットなどを含めたMonoBehaviour側からのEntity生成周りについて解説していきます。
(衝突判定は後述)

▼ Entity各種の生成処理について

■ 弾の生成

弾の生成としてはプレイヤークラス内でショットの入力を検知したらECSManagerを経由してECS側に弾の生成通知を行います。
通知と言えどもやっている事としてはEntityを生成しているだけであり、ECS側で通知用のEntityを検知したら実際の弾EntityをInstantiateする流れになっております。

ソースコードの一部を以下に引用します。
全体につきましてはプロジェクトの方をご覧ください。

Player.cs
void UpdateInternal()
{
    // Player Controller
    {
        .....

        // ショット
        if (Input.GetButton(Constants.Shot))
        {
            this._bulletCooldownTimeCount -= deltaTime;
            if (this._bulletCooldownTimeCount <= 0f)
            {
#if ENABLE_FULL_VERSION 
                // 発砲時のCameraShake. ※GitHubの公開プロジェクトには搭載されていない。
                this._mainCamera.transform.DOShakePosition(
                    this._shotShakeDuration,
                    strength: this._shotShakeStrength);
#endif
                this._bulletCooldownTimeCount = this._playerSettings.BulletCooldownTime;

                // 発射角度と言った必要なデータを作ってECSManagerに送る。
                var bulletData = new BulletData
                {
                    Speed = this._playerSettings.BulletSpeed,
                    Angle = angle,
                    Lifespan = this._playerSettings.BulletLifespan,
                };
                var bulletPos = new float2(localPos.x, localPos.y);
                this._ecsManager.CreatePlayerBullet(bulletData, bulletPos);
                this._shotSubject.OnNext(bulletPos);
            }
        }

ECSManager.cs
/// <summary>
/// プレイヤー弾の生成
/// </summary>
/// <param name="bulletData">弾情報</param>
/// <param name="position2D">生成位置</param>
public void CreatePlayerBullet(BulletData bulletData, float2 position2D)
{
    // 弾が関連する処理の実行順などを踏まえて、ここでは生成用通知であるEntityを生成するだけ。
    // →実際の弾のEntity自体はComponentSystem側で作られる。
    var entity = this._entityManager.Instantiate(this._playerBulletGeneratePrefab);
    this._entityManager.SetComponentData(entity, bulletData);
    this._entityManager.SetComponentData(entity, new PlayerBulletGenerate
    {
        CreatePosition = position2D,
    });
}
MainRoutineSystem.cs
protected override void OnCreateManager()
{
    .......

    // 自機弾の生成通知Entityを取得するためのComponentGroup
    this._playerBulletGenerateGroup = base.GetComponentGroup(
        ComponentType.ReadOnly<BulletData>(),
        ComponentType.ReadOnly<PlayerBulletGenerate>());
    .......
}

protected override unsafe JobHandle OnUpdate(JobHandle inputDeps)
{
    .......

    // 自機弾の生成
    {
        var length = this._playerBulletGenerateGroup.CalculateLength();
        var entities = this._playerBulletGenerateGroup.GetEntityArray();
        var generateData = this._playerBulletGenerateGroup.GetComponentDataArray<PlayerBulletGenerate>();
        var bulletData = this._playerBulletGenerateGroup.GetComponentDataArray<BulletData>();
        for (int i = 0; i < length; i++)
        {
            commandBuffer.Instantiate(this._prefabEntities.PlayerBulletPrefab);
            commandBuffer.SetComponent(new Position2D { Value = generateData[i].CreatePosition });
            commandBuffer.SetComponent(bulletData[i]);

            // 通知用Entityの破棄
            commandBuffer.DestroyEntity(entities[i]);
            // 弾のエネルギー消費
            playerStatus.BarrierPoint -= playerStatus.PlayerParam.ShotEnergy;
        }
    }

ここで一点ポイントとして、「そもそも何故プレイヤークラス内で自機弾のEntityを直接生成しないのか?」と言う点について触れておくと、こちらはECS及びJobSystemでの実行順を合わせるためです。

例えば弾に関連するJobを走らせているタイミングでMonoBehaviour側から横入りする形で弾のEntityを生成してしまうと、Jobを走らせたタイミングのEntityの情報と差異が出来て何かしらの影響が出そうかと思われたのでこうしております。
(ここらについてはまだ完全に理解しきれてない所もありますが...実装中にそれっぽいエラーが出たのを確認..。)

この前提がある為にMonoBehaviour側から生成するEntityは原則4として生成通知用のEntityを経由して生成するようにしてあります。
→その上で生成通知からのEntity生成はJob化せずにComponentSystem内で同期を取れるように実装。

■ 敵の生成

敵の生成についても弾と同様にMonoBehaviour側から生成通知用のEntityを生成 → 実態はComponentSystem側で作るようにしてます。

※ちなみに敵の生成ロジックとしてはObservable.Intervalで一定間隔にランダムな位置に生成するといった単純なものとなります。
「この単純さがゲームの難易度を上げているのでは?」とか言わない

EnemySpawner.cs
public void Activate()
{
    // 一定間隔ごとに生成していくだけ
    var createTime = TimeSpan.FromSeconds(this._enemySettings.GenerateInterval);
    this._createDisposable = Observable.Interval(createTime).Subscribe(_ =>
    {
        // _spawnPointsには各生成位置の座標情報が入っており、その中からランダムな生成位置を選択して生成している。
        var pointIndex = UnityRandom.Range(0, this._spawnPoints.Length);
        Vector2 point = this._spawnPoints[pointIndex];
        var enemyID = UnityRandom.Range(0, this._maxEnemyID);
        var enemyData = new EnemyData { EnemyID = (EnemyID)enemyID, SpawnPoint = (SpawnPoint)pointIndex };
        this._ecsBoostrap.CreateEnemy(point, enemyData);
    });
}
ECSManager.cs
/// <summary>
/// 敵の生成
/// </summary>
/// <param name="position2D">生成位置</param>
/// <param name="data">敵情報</param>
public void CreateEnemy(float2 position2D, EnemyData data)
{
    // 弾と同様に生成通知用Entityを出すだけ
    var entity = this._entityManager.Instantiate(this._enemyGeneratePrefab);
    this._entityManager.SetComponentData(entity, data);
    this._entityManager.SetComponentData(entity, new EnemyGenerate
    {
        CreatePosition = position2D,
    });
}

ESC側の処理は割愛しますが、弾と同じ感じにMainRoutineSystem.cs内で生成を行ってます。

▽ 衝突判定は全てECS側で処理

表題にもある通り、衝突判定については全てECS側で行います。
Entity同士(またはGameObject同士)の衝突判定であれば同じレイヤーに所属するのでイメージが付きやすいかもしれませんが、「GameObject/MonoBehaviour」と「Entity」となると所属するレイヤーが違うので少し工夫する必要が出てきます。

そこで今回の実装としては、衝突判定用に裏でPlayerのEntityも作成 → こちらをMonoBehaviour側のPlayerの座標などの情報と同期させる事でECS側で衝突判定を取れるようにしました。

ちなみにECSでの衝突判定については既存のシステムがまだ未実装となるので、簡単な衝突判定を自作する形で対応する必要があります。
こちらの実装については以前PureECSベースでの衝突判定についての解説記事を書いたのでこちらをご覧ください。
(今回の実装としては記事中の衝突判定をベースにゲーム用にカスタムしたものを実装)

ソースについては全て載せると長くなるので、関連する箇所としては以下のソースを参照。

衝突判定周りに関する幾つかのTipsについて解説していきます。

▼ プレイヤーのEntityの作成と同期

こちらの処理については単純です。
アーキタイプに位置情報や衝突プリミティブ等を持ったEntityを一体作成し、それに対してPlayerクラス内のUpdateにてSetComponentDataで値を書き換えているだけです。

衝突判定などの各種処理ではこちらで更新されるEntityを見て判定を取ってます。

ECSManager.cs
public ECSManager(LookSettings lookSettings, Collider2DSettings collider2DSettings, EnemySettings enemySettings, GameStatus gameStatus)
{
    .......

    {
        var playerArchetype = this._entityManager.CreateArchetype(
            ComponentType.Create<PlayerTag>(),
            ComponentType.Create<PlayerStatus>(),
            // 衝突判定、破棄可能
            ComponentType.Create<SphereCollider2D>(), ComponentType.Create<Destroyable>(),
            // Transform
            ComponentType.Create<Position2D>(),
            // Built-in ComponentData
            ComponentType.Create<Prefab>());
        this._playerPrefab = this._entityManager.CreateEntity(playerArchetype);
        this._entityManager.SetComponentData(
            this._playerPrefab,
            new SphereCollider2D { Radius = radiusSettings.Player, OffsetPosition = offsetSettings.Player });
    }

    .......
}

/// <summary>
/// プレイヤーの生成
/// </summary>
/// <param name="status">プレイヤーの状態</param>
/// <returns>生成したEntity</returns>
public Entity CreatePlayer(PlayerStatus status)
{
    // こちらのみ初期化時に呼ばれることが保証されている前提なので、MonoBehaviour側から直接生成してしまう。
    var entity = this._entityManager.Instantiate(this._playerPrefab);
    this._entityManager.SetComponentData(entity, status);
    return entity;
}
Player.cs
// Observable.EveryUpdate()経由で呼び出されている
void UpdateInternal()
{
    .......

    {
        // Entityとの位置同機
        this._ecsManager.EntityManager.SetComponentData(
            this._playerEntity,
            new Position2D { Value = new float2(trs.localPosition.x, trs.localPosition.y) });
    }

    .......
}

▼ 敵が破壊された時の通知について

例えばゲームの演出として「敵を破壊したらSEを再生しつつその位置にエフェクトを出したい」と言った要件が出てくるかもしれません。
ECS側で判定を取りつつもこれらの演出を行うとなると、何かしらのMonoBehaviourの派生クラスを通じて演出を組み込んでいくことになるかと思われます。5

そこで今回の実装としては、敵が破壊されたタイミングでComponentSystem側からMonoBehaviourに対し通知を送り、受け取った派生クラス側で演出の再生を行うと言った仕組みで実装してみました。

実装としては以下のようにComponentSystem側にSubject<float2>を持たせて外からSubscribeできるようにしてあります。
※float2には破壊された位置が渡される。
※ECSと言えどもComponentSystem自体はピュアなクラスであり、マネージドの管理下であるので普通にSubjectなり(と言うよりは参照型)を持たせる事が可能。

後はECSManagerを経由してGameManager側で敵の破壊通知を購読 → OnNextが呼ばれたらエフェクト/SEを再生すると言った仕組みとなります。

※今回の実装として、衝突判定周りが同期的な実装となっているのでこのやり方でも処理できておりますが...Jobとかに回すとなるともう少し違う手段を考える必要が出てくるかもしれません。

Collision2DSystem.cs
Subject<float2> _destroyEnemySubject = new Subject<float2>();

/// <summary>
/// 敵が破壊された時
/// </summary>
/// <value>破壊された位置(float2)</value>
public IObservable<float2> OnDestroyEnemy { get { return this._destroyEnemySubject; } }

protected override void OnUpdate()
{
    .......

    // PlayerBullet → Enemy
    {
        var playerBulletGroupLength = this._playerBulletGroup.CalculateLength();
        var enemyGroupLength = this._enemyGroup.CalculateLength();

        var playerBulletColliders = this._playerBulletGroup.GetComponentDataArray<SphereCollider2D>();
        var playerBulletDestroyables = this._playerBulletGroup.GetComponentDataArray<Destroyable>();
        var enemyColliders = this._enemyGroup.GetComponentDataArray<SphereCollider2D>();
        var enemyDestroyables = this._enemyGroup.GetComponentDataArray<Destroyable>();
        for (int i = 0; i < playerBulletGroupLength; i++)
        {
            var bulletColl = playerBulletColliders[i];
            for (int j = 0; j < enemyGroupLength; ++j)
            {
                var enemyColl = enemyColliders[j];
                if (enemyColl.Intersect(ref bulletColl))
                {
                    playerBulletDestroyables[i] = Destroyable.Kill;
                    enemyDestroyables[j] = Destroyable.Kill;

                    // ※今回の実装はOnUpdate内でチェックしているが、データ的にはJobの方でも加算可能な設計。
                    this._gameStatus.AddScore();

                    // 仮に破棄周りをJobに回す実装にするならば、managedのobjectは渡すことが出来ないので何かしら別の手段を検討する必要がある。
                    // → 逆に今回の例のようにComponentSystem内ならComponentSystem自体がクラスなので問題はない。
                    this._destroyEnemySubject.OnNext(bulletColl.Position);
                }
            }
        }
    }
}

protected override void OnDestroyManager() => this._destroyEnemySubject.Dispose();
ECSManager.cs
public unsafe class ECSManager : IDisposable
{
    /// <summary>
    /// 敵が破壊された時
    /// </summary>
    /// <value>破壊された位置(float2)</value>
    public IObservable<float2> OnDestroyEnemy { get { return this._collision2DSystem.OnDestroyEnemy; } }

▼ 衝突形状のデバッグ表示

以前弾幕STGを作った際にも実装した内容にはなりますが、衝突形状をGizmo表示で確認できるようにしてます。

やっている事は単純にデバッグ表示用のComponentSystem(DrawCollider2DExtensions)で衝突形状(SphereCollider2D)のComponentDataを持つEntityを集めて、MonoBehaviourの派生クラスとして実装している表示用クラス(DrawCollider2D)が持つフィールドのNativeArrayに結果を詰める → 後はDrawCollider2D側のOnDrawGizmosでそれを見て表示といった流れです。

ソースについてはDrawCollider2D.csを御覧下さい。

※ちなみに分かってはいましたが...MonoBehaviour + Gizmoベースなので大量に表示する分だけ重くなります..。(今回は少数のEntityを生成しつつ、ある程度の形状が絞れたら良かったので許容)
もしゲームの要件的に大量にデバッグ表示が必要とかであれば自前で描画するComponentSystemを自作した方が良いかもしれません。

check.png

※上記は判定の表示イメージ。「プレイヤーの判定は赤」「敵の判定は青」と言った形で表示している。

▽ パラメータ調整について

ゲームバランスに関するパラメータ等については以下のScriptableObjectで管理してます。

  • Collider2DSettings
    • 衝突判定プリミティブのサイズなど
  • EnemySettings
    • 敵に関する情報(出現間隔、弾の発射間隔や弾速など)
  • LookSettings
    • 表示するSpriteに関する情報
  • PlayerSettings
    • プレイヤーに関する情報(エネルギー、弾に関する情報など)

これらはMonoBehaviourでの実装であれば、そのままScriptableObjectの参照を渡してロジック中で値を見るように実装しておくことで、例えば実行中でもScriptableObjectの値をInspectorから変更する事で動的に変更を反映すると言ったことができますが...IComponentDataやJobSystemのフィールドに渡す事を踏まえるとBlittable型の制限が出てくるために、素直に参照を渡すと言ったことができません。

そこで今回のアプローチとしては、ScriptableObjectに持たせるパラメータはBlittable型な構造体にした上で、アンマネージドメモリにコピーを確保→そのポインタを必要とするComponentSystem(JobComponentSystem)に配ることで共通の値を参照させると言った実装にしてみました。6

言葉だけだとイメージしにくいかもしれないので実装を載せていきます。

▼ ScriptableOjectが持つ弾幕のパラメータ

弾幕用のパラメータを例にとって解説していきます。

以下のBarrageParamと言う値はScriptableObjectが持つ弾幕用のパラメータです。
ScriptableObjectから操作できるように単純にSerializableな構造体として定義し、フィールドに持たせているだけです。

EnemySettings.cs
/// <summary>
/// 弾幕設定
/// </summary>
[Serializable]
public struct BarrageParam
{
    [Serializable]
    public struct CircleParam
    {
        public int BulletCount;
    }
    public CircleParam Circle;

    [Serializable]
    public struct CommonWayParam
    {
        public float Range;
    }
    public CommonWayParam CommonWay;

    [Serializable]
    public struct SpriralCircleParam
    {
        public int BulletCount;
        public int AnimationSpeed;
    }
    public SpriralCircleParam SpriralCircle;

    [Serializable]
    public struct RandomWayParam
    {
        public float Range;
    }
    public RandomWayParam RandomWay;

    [Serializable]
    public struct WaveCircleParam
    {
        public int BulletCount;
        public int AnimationSpeed;
    }
    public WaveCircleParam WaveCircle;

    [Serializable]
    public struct WaveWayParam
    {
        public int BulletCount;
        public float Range;
        public int AnimationSpeed;
    }
    public WaveWayParam WaveWay;
}

/// <summary>
/// 敵の設定情報
/// </summary>
[CreateAssetMenu(fileName = "EnemySettings", menuName = "ScriptableObject/EnemySettings")]
public sealed class EnemySettings : ScriptableObject
{
    /// <summary>
    /// 生成間隔
    /// </summary>
    public float GenerateInterval = 3f;

    /// <summary>
    /// 敵の設定
    /// </summary>
    public EnemyParam[] EnemyParams = new EnemyParam[Enum.GetNames(typeof(EnemyID)).Length];

    /// <summary>
    /// 弾幕設定
    /// </summary>
    public BarrageParam Barrage;
}

▼ ECSManager側でアンマネージドメモリにコピー

ECSManager.csの方ではUnsafeUtility.Mallocでメモリを確保して構造体の値をコピーし、後はコピーしたメモリのポインタを渡す必要のあるComponentSystemのコンストラクタ引数に渡していきます。

ちなみに一点補足として、結局はScriptableObjectが持つ構造体の参照ではなくアンマネージドメモリにコピーした値を渡すことになっているので、ただ渡すだけでは値の参照こそ出来るもののInspectorからの動的な調整は行なえません。
そこで今回は力技ではありますが、「SYNC_BARRAGE_PARAM」と言うデバッグ用のシンボルが定義されている時のみ毎フレームメモリの値を書き換えることで動的な調整を行えるようにしてあります。。
(「どうせ一時的な調整用且つビルド時には含まれんしええやろ」と言うノリです)

ECSManager.cs
public unsafe class ECSManager : IDisposable
{
    // Pointer
    BarrageParam* _barrageParamPtr = null;

    public ECSManager(LookSettings lookSettings, Collider2DSettings collider2DSettings, EnemySettings enemySettings, GameStatus gameStatus)
    {
        .......

        // ComponentSystem(JobComponentSystem)にパラメータを渡すために一部の設定データはポインタとして持つ
        {
            // 弾幕設定のポインタを取得
            var barrageParamSize = UnsafeUtility.SizeOf<BarrageParam>();
            this._barrageParamPtr = (BarrageParam*)UnsafeUtility.Malloc(barrageParamSize, UnsafeUtility.AlignOf<BarrageParam>(), Allocator.Persistent);
            UnsafeUtility.MemClear(this._barrageParamPtr, barrageParamSize);
            UnsafeUtility.CopyStructureToPtr<BarrageParam>(ref this._enemySettings.Barrage, this._barrageParamPtr);
        }

#if SYNC_BARRAGE_PARAM
        // シンボル有効時は毎フレーム構造体の値をポインタにコピーして常時反映されるようにする
        this._debugEveryUpdate = Observable.EveryUpdate().Subscribe(_ =>
        {
            UnsafeUtility.CopyStructureToPtr<BarrageParam>(ref this._enemySettings.Barrage, this._barrageParamPtr);
            this._enemyParams.CopyFrom(this._enemySettings.EnemyParams);
        });
#endif

        .......

        // ※ポインタを直接object型にはキャスト出来ないので、遠回りでは有るが一つ構造体に挟んで渡す
        var mainRoutineSystem = World.Active.CreateManager<MainRoutineSystem>(
            new MainRoutineSystem.ConstructorParam { BarrageParamPtr = this._barrageParamPtr, EnemyParams = this._enemyParams });
    }

    public void Dispose()
    {
        .......

        UnsafeUtility.Free(this._barrageParamPtr, Allocator.Persistent);
    }

※コード中にしれっと出てきているUnsafeUtility.CopyStructureToPtrと言うAPIですが、こちらは構造体の値を引数に渡したポインタのメモリにコピーするという便利なAPIです。
※これの逆を行うUnsafeUtility.CopyPtrToStructureと言うAPIも存在します。

▼ 後はJobとかでポインタを参照するだけ

後は以下の様にコンストラクタ経由で受け取ったポインタをJobのフィールドに渡し、Job内の各種処理で参照している流れとなります。

※ちなみに今回はBurstCompilerを使ってない(+使えない)ので、static変数に入れて参照する形でも実装できると言えば出来るかと思われます。(これについては後述の「※補足 : static変数について」でも補足)

MainRoutineSystem.cs
/// <summary>
/// 敵の更新(ロジック) Job
/// </summary>
unsafe struct EnemyUpdateJob : IJobParallelFor
{
    .......

    // 乱数生成器のポインタ
    [NativeDisableUnsafePtrRestriction] public Random* RandomPtr;
    // 弾幕情報のポインタ
    [NativeDisableUnsafePtrRestriction] public BarrageParam* BarrageParamPtr;

    .......

    public void Execute(int i)
    {
        // 後はthis.BarrageParamPtrを参照して弾幕の処理など...
        // ちなみにポインタが指すデータのアクセスはアロー演算子で見れる模様
        // int bulletCount = this.BarrageParamPtr->Circle.BulletCount;
    }
}

protected override unsafe JobHandle OnUpdate(JobHandle inputDeps)
{
    .......

    // 敵の更新
    var enemyGroupLength = this._enemyGroup.CalculateLength();
    handle = new EnemyUpdateJob
    {
        ........
        RandomPtr = this._randomPtr,
        BarrageParamPtr = this._barrageParamPtr,
        .......
    }.Schedule(enemyGroupLength, 32, handle);

    // 弾の移動
    handle = new BulletUpdateJob { DeltaTime = deltaTime }.Schedule(this, handle);

    .......
}

※補足 : static変数について

今回は例としてポインタ経由で共通項目の値を見に行くと言った実装で解説しましたが、似たようなやり方としてはstatic変数として持たせてそれを参照しに行くと言ったやり方もあります。
但し、staticを参照するとBurstCompilerが使えなくなるので注意する必要があります。
今回の実装としてはBurst未使用(そもそも使えない)ではありますが、上記の仕様を踏まえて敢えてstaticを避けた実装にしてみました。

※補足 : 乱数について

上記の処理にも一部載せておりますが、乱数で用いているUnity.Mathematics.Randomについても同様に構造体をアンマネージドメモリにコピーし、Jobにはポインタを渡すようにしてあります。
逆にこちらはポインタで渡さないとJobにはコピーした値が行き渡ってしまい、内部で持っているseed値が更新されないと言った現象を確認したので必然的にこうしてます。

MainRoutineSystem.cs
protected override void OnCreateManager()
{
    .......

    // 乱数生成器の構造体をJobに渡すためにPtrに変換
    // ※Ptrで渡さないと値渡しになってシード値が維持されないため。
    var random = new Random((uint)System.DateTime.Now.Ticks);
    var randomStrSize = UnsafeUtility.SizeOf<Random>();
    this._randomPtr = (Random*)UnsafeUtility.Malloc(randomStrSize, UnsafeUtility.AlignOf<Random>(), Allocator.Persistent);
    UnsafeUtility.MemClear(this._randomPtr, randomStrSize);
    UnsafeUtility.CopyStructureToPtr<Random>(ref random, this._randomPtr);
}

▽ Entityの生成は基本的にはPrefabをInstantiate

今回の実装としてはEntityの生成周りは全てPrefab EntityからInstantiateする形で実装してます。
※Prefab Entityそのものについては以前記事を書いたので「こちら」を参照。

ECSManager.cs
public unsafe class ECSManager : IDisposable
{
    ........

    public ECSManager(LookSettings lookSettings, Collider2DSettings collider2DSettings, EnemySettings enemySettings, GameStatus gameStatus)
    {
        .......

        // Create Entity Prefabs
        // ※Prefabベースでの生成を想定しているのでArchetypeは保存しない
        {
            // Player Bullet
            var playerBulletArchetype = this._entityManager.CreateArchetype(
                ComponentType.Create<BulletTag>(), ComponentType.Create<PlayerTag>(),
                ComponentType.Create<BulletData>(),
                // 衝突判定、破棄可能
                ComponentType.Create<SphereCollider2D>(), ComponentType.Create<Destroyable>(),
                // Transform
                ComponentType.Create<Position2D>(),
                // Built-in ComponentData
                ComponentType.Create<Prefab>(), ComponentType.Create<LocalToWorld>(), ComponentType.Create<MeshInstanceRenderer>());
            this._playerBulletPrefab = this._entityManager.CreateEntity(playerBulletArchetype);
            this._entityManager.SetComponentData(
                this._playerBulletPrefab,
                new SphereCollider2D { Radius = radiusSettings.PlayerBullet, OffsetPosition = offsetSettings.PlayerBullet });
            this._entityManager.SetSharedComponentData(this._playerBulletPrefab, this._playerBulletLook);

            // Enemy Bullet
            var enemyBulletArchetype = this._entityManager.CreateArchetype(
                ComponentType.Create<BulletTag>(), ComponentType.Create<EnemyTag>(),
                ComponentType.Create<BulletData>(),
                // 衝突判定、破棄可能
                ComponentType.Create<SphereCollider2D>(), ComponentType.Create<Destroyable>(),
                // Transform
                ComponentType.Create<Position2D>(),
                // Built-in ComponentData
                ComponentType.Create<Prefab>(), ComponentType.Create<LocalToWorld>(), ComponentType.Create<MeshInstanceRenderer>());
            this._enemyBulletPrefab = this._entityManager.CreateEntity(enemyBulletArchetype);
            this._entityManager.SetComponentData(
                this._enemyBulletPrefab,
                new SphereCollider2D { Radius = radiusSettings.EnemyBullet, OffsetPosition = offsetSettings.EnemyBullet });
            this._entityManager.SetSharedComponentData(this._enemyBulletPrefab, this._enemyBulletLook);

        ........

Prefab Entityを用いる利点としては単純にEntityを複製する際の実装コストを下げる点などがありますが、 大きいところとしてはJobに回す処理でもEntityを作りやすくなると言った点などが挙げられるかと思われます。

例えば以下の処理は敵が撃ってくる弾幕の一つである「自機狙い弾」の実装部分となりますが、JobのフィールドにPrefabとなるEntityを1つ渡すだけで内部で敵弾をInstantiateすることができます。

(作りにもよるかもですが...)逆にPrefabを使わないでJobでEntityを生成するとなると、構築に必要な情報(各種初期値、描画情報など)を何とかJobに渡して自前で生成する必要が出てくるかと思われます。このやり方だと情報量が増える上に共通化できるであろう生成時の初期化処理を複数実装してしまう事になるので恐らくは保守的にも良くない印象ではあります..。

MainRoutineSystem.cs
/// <summary>
/// 敵の更新(ロジック) Job
/// </summary>
unsafe struct EnemyUpdateJob : IJobParallelFor
{
    // 敵弾のPrefab 
    [ReadOnly] public Entity EnemyBulletPrefab;

    // 自機狙い弾
    void Aiming(ref int jobIndex, ref Position2D enemyPosition, ref EnemyParam param, ref EnemyData data)
    {
        CommandBuffer.Instantiate(jobIndex, this.EnemyBulletPrefab);
        CommandBuffer.SetComponent(jobIndex, enemyPosition);
        CommandBuffer.SetComponent(jobIndex, new BulletData
        {
            Speed = param.BulletParam.Speed,
            Angle = MathHelper.Aiming(enemyPosition.Value, this.PlayerPosition),
            Lifespan = param.BulletParam.Lifespan,
        });
    }

▽ おまけ

▼ IL2CPPならCode Strippingに注意

ECSを使用しているプロジェクトでIL2CPPビルドする際の注意点です。(WebGLもIL2CPPが走る)
主に既存のComponentSystem(Transform/Renderer)を使う際に注意が必要となってくる項目ではありますが、今回の実装では描画周りのみ既存の物を使用しているので対応する必要がありました。

問題点を簡潔に纏めると「既存のComponentSystemはIL2CPPビルド時のCode Strippingの影響で削られてしまうので、正しくlink.xmlを設定しておく必要がある」と言った内容になります。
詳細については別記事に纏めてあるので以下の記事を参照。

【Unity】ECSを使用しているプロジェクトでIL2CPPビルドする際の注意点

▼ プレイヤーのシールド件エネルギーゲージについてはシェーダーで実装

プレイヤーの周囲にある敵弾を防ぐシールド件エネルギーゲージでもあるこちらについてはシェーダーで実装してます。
実装としては球体に対し、視線ベクトルと法線ベクトルから輪郭部分を求めて光らせているイメージです。
(中央から輪郭部分に行くに連れて透明度が低くなっていくイメージ)

後はこちらの色や発色の強さ等の数値を外部から設定できるようにしてます。

ぶっちゃけ2D想定ならTextureを用意してAlpha落とすなりでも実装でき(ry

baria.gif

Art000.png

Unlit-RimBarrier-Instancing.shader
Shader "Unlit/Unlit-RimBarrier-Instancing"
{
    Properties
    {
        _RimColor ("Rim Color", Color) = (1,1,1,1)
        _Intensity ("Rim Power", float) = 1
        _Alpha ("Alpha", float) = 1
    }
    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
            "IgnoreProjector"="True"
            "RenderType"="Transparent"
        }
        Cull Off
        Lighting Off
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float3 normal : NORMAL;
                float3 viewDir  : TEXCOORD1;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            fixed4 _RimColor;
            float _Intensity;
            float _Alpha;

            v2f vert (appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

                o.vertex = UnityObjectToClipPos(v.vertex);
                o.normal = normalize(mul(unity_ObjectToWorld, v.normal));
                float3 WorldSpaceCameraPos = float3(_WorldSpaceCameraPos.xy, _WorldSpaceCameraPos.z - 1000);
                o.viewDir = normalize(WorldSpaceCameraPos - mul(unity_ObjectToWorld, v.vertex));
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                float rim = 1 - abs(dot(i.viewDir, i.normal));
                fixed4 col = _RimColor * pow(rim, _Intensity);
                return col * _Alpha;
            }
            ENDCG
        }
    }
}

▼ 敵のアニメーションについて

敵のアニメーションについて、本当は(3D想定ですが)こちらの記事にあるように、StructuredBufferにデータを渡してシェーダー内でゴニョゴニョすると言ったことをやりたかったのですが...WebGLで使用できるGraphicsAPIが対応していないので、適当簡単な回転処理のみで済ませてます。。

言いたかった事としては、複雑なアニメーションであればComponentSystem側で何かしらの仕組みを作って制御する必要が出てくるかもしれませんが、例のように簡単なループアニメーションぐらいのものであればシェーダー内で済ませてしまうと言う手もあります。

anim.gif

※敵の回転運動の実装としては頂点シェーダー内で回しているだけ。

Unlit-Cutout-Animation-Instancing.shader
v2f vert (appdata v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    // Z軸回転させる
    float rot = sin(_Time.w) / 4;
    float sin_ = sin(rot);
    float cos_ = cos(rot);
    float2x2 rotationMatrix = float2x2(cos_, -sin_, sin_, cos_);
    v.vertex.xy = mul(rotationMatrix, v.vertex.xy);

    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return o;
}

▼ Transform2Dは自作

ECSで実装していると言えどもWebGL上での動作となるので、パフォーマンスには注意する必要がありました。(Job/Burstが使えないですし)
そこで今回は仕様的に最低限必要であろう「2D想定の平行移動」だけに限定したTransform2DSystemと言う物を自作して、モデル変換行列の作成に掛かる負荷を軽減出来ないか検証を行ってみました。

Transform2DSystem.cs
/// <summary>
/// Transform関連用 BarrierSystem
/// </summary>
[UpdateAfter(typeof(Transform2DSystem))]
public sealed class TransformBarrierSystem : BarrierSystem { }

/// <summary>
/// 2D用 TransformSystem(平行移動のみ)
/// </summary>
[UpdateAfter(typeof(MainRoutineSystem))]
public sealed unsafe class Transform2DSystem : JobComponentSystem
{
    [BurstCompile]
    struct TransToMatrix : IJobProcessComponentData<Position2D, LocalToWorld>
    {
        public void Execute([ReadOnly]ref Position2D position, ref LocalToWorld localToWorld)
        {
            // 平行移動行列だけ作る
            var pos = position.Value;
            localToWorld.Value = float4x4.Translate(new float3(pos.x, pos.y, 0.0f));
        }
    }

#if ENABLE_TRANSFORM2D_ROTATION

    /// <summary>
    /// 2D用 TransformSystem(回転処理)
    /// </summary>
    [BurstCompile]
    struct RotTransToMatrix : IJobProcessComponentData<Position2D, RotationZ, LocalToWorld>
    {
        public void Execute([ReadOnly]ref Position2D position, [ReadOnly]ref RotationZ rotationZ, ref LocalToWorld localToWorld)
        {
            float2 pos = position.Value;
            var sin = math.sin(rotationZ.Value);
            var cos = math.cos(rotationZ.Value);
            // 列優先のZ軸回転
            localToWorld.Value = new float4x4
            {
                c0 = new float4(cos, sin, 0f, 0f),
                c1 = new float4(-sin, cos, 0f, 0f),
                c2 = new float4(0f, 0f, 1f, 0f),
                c3 = new float4(pos.x, pos.y, 0f, 1f)
            };
        }
    }

#endif

    protected override unsafe JobHandle OnUpdate(JobHandle inputDeps)
    {
        var handle = inputDeps;
        handle = new TransToMatrix().Schedule(this, handle);
#if ENABLE_TRANSFORM2D_ROTATION
        handle = new RotTransToMatrix().Schedule(this, handle);
#endif
        return handle;
    }
}

平行移動処理は本当に単純で変換行列を作っているだけです。
後は内部的にはシンボルで切る形で回転も含めておりますが、実際には平行移動だけで済ませております。

■ 具体的な負荷検証

負荷検証について少し載せておきます。

  • 検証環境
    • Entity数は1万体
    • Entity自体はただのCubeで、それを初期化時に2D座標系想定でランダムな位置に配置
      • ※Shaderについては単色Unlit + GPU Instancing有り
    • WebGLビルドで確認(ブラウザはChrome)

例えば以下の様なオブジェクトを適当に動かすだけのComponentSystemを実装してWebGL上で実行→プロファイラを見てみると...

public sealed class MovingSytstem : JobComponentSystem
{
#if DEFAULT_SYSTEM
    [BurstCompile]
    public struct Job : IJobProcessComponentData<Position>
    {
        float _time;
        public Job(float time) => this._time = time;
        public void Execute(ref Position pos)
        {
            float2 add = new float2(math.sin(this._time) / 3f);
            pos.Value += new float3(add, 0f);
        }
    }
#else
    [BurstCompile]
    public struct Job : IJobProcessComponentData<Position2D>
    {
        float _time;
        public Job(float time) => this._time = time;
        public void Execute(ref Position2D pos)
        {
            float2 add = new float2(math.sin(this._time) / 3f);
            pos.Value += add;
        }
    }
#endif
    protected override JobHandle OnUpdate(JobHandle inputDeps) => new Job(Time.time).Schedule(this, inputDeps);
}

結果としては以下のようになっておりました。
上の方が「既存のシステム(EndFrameTransformSystem)」、下の方が「自作システム(Transform2DSystem)」です。

nornal.png

optimize.png

上記はあくまで1フレームでの結果となるので全体的な平均?を見てみると以下のような印象です。

  • 既存のシステム(EndFrameTransformSystem)
    • trs : 2.40 ~ 2.70 ms
    • moving : 1.10 ~ 1.20 ms
  • 自作システム(Transform2DSystem)
    • trs : 2.30 ~ 2.60
    • moving : 0.80 ~ 0.90

確かに軽量化には成功しました。(但し無理してでも導入する必要があるかと言われるとう~ん...)
※それこそ初期の頃に検証した時にはもっと違いが出ていた印象ではあったが...確認時に手違いでもあったのか...。とりあえずは念頭に置いておく..。

▼ 「Graphics Emulation -> WebGL 2.0」の設定が元に戻る...?

Switch Platformやビルドと言ったタイミングで何故か「Graphics Emulation」の設定がWebGL 1.0に戻ってしまう現象を確認しました。。

aaa.png

これの何が困るのかと言うと、基本的に表示するEntityやGameObject全般はGPU Instancingを期待しているので、それが対応していないWebGL 1.0に戻る度に設定を戻さなくてはならない事です。(※WebGL 1.0の状態でGPU Instancingに対応しているオブジェクトを表示しようとするとエラーが出て怒られる。。)

軽くは調べてみたものの...深追い出来ていない所もあるかもしれませんが...
とりあえずは今回のプロジェクトに於いては以下のEditor拡張を実装することで「Graphics Emulation -> WebGL 2.0」の設定を自動化するようにしました。
ご参考までにソースを載せておきます。

AutoGraphicsEmulatorSetting.cs
#if UNITY_EDITOR && UNITY_WEBGL
namespace MyContents.Editor
{
    using UnityEngine;
    using UnityEditor;

    /// <summary>
    /// 「Graphics Emulation -> WebGL 2.0」の自動設定
    /// </summary>
    /// <remarks>
    /// こちらの設定は何故かビルドやSwitch Platformと行ったタイミングで
    /// いちいちWebGL 1.0に戻ってしまうので、設定を自動化するようにした。
    /// ※GPU Instancingを使うためにWebGL 2.0が必須となっているので。
    /// </remarks>
    [InitializeOnLoad]
    public class AutoGraphicsEmulatorSetting
    {
        static AutoGraphicsEmulatorSetting()
        {
            EditorApplication.update += Update;
        }

        static void Update()
        {
            bool isSuccess = EditorApplication.ExecuteMenuItem("Edit/Graphics Emulation/WebGL 2.0");
            if (isSuccess)
            {
                EditorApplication.update -= Update;
            }
        }
    }
}
#endif

▽ 参考/関連サイト

  • ECS初学者に向けてのTips集
    • Unity ECS完全に理解した」と言う勉強会にて登壇した際の発表資料。初学者向けのリンク集など色々載せている。
    • ※但し公開から少し時間が経っているので、現時点ではもう少し参考となる記事は増えているかもしれない。

▼ ECS関連


  1. 「インゲーム部分でGameObject/MonoBehaviourを殆ど使用しない」について軽く補足すると、実際にはECSの初期化周りやUI/ランキング等と言った一部の処理では普通にGameObject/MonoBehaviourを使って実装してました。それ以外の「自機/敵/弾」の生成/ロジック周りと言ったインゲームの実装全般については全てPureECSで実装していたというニュアンスです。(詳細は前回の記事参照) 

  2. お題に沿って10種類ぐらい用意した覚え。 

  3. UpdateについてはObservable.EveryUpdate()に登録している所も指す。 

  4. 一部のEntity(例えばゲーム全体の初期化時のみ生成される想定の物)などは通知を送らずに直接生成していたりもする。 

  5. ここで言う演出は既存のParticleSystem等を指す。ECSベースで自前でパーティクルシステムを作るとかであればまた話は違ってくる。 

  6. 「共通参照する値ならstaticでも行けるのでは?」とも考えられますが、BurstCompilerが使えなくなります。詳細については後述の「※補足 : static変数について」に記載。 

mao_
エンジニア
showroom
SHOWROOMは、アイドルやアーティストとインターネット上でコミュニケーションが楽しめるライブ動画ストリーミングプラットフォームです。
https://www.showroom-live.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした