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

【Unity】DOTS(ECS)を導入したゲームを作る際に設計してみた話

この記事は【unityプロ技】 Advent Calendar 2019の7日目の記事です。

もう1ヶ月以上前にはなりますが【Unity1週間ゲームジャム - お題「さがす」1】と言うベントが開催されたので、それを機に久しぶりにDOTS2の一環であるECSを導入して簡単なゲームを実装してみました。

実装するにあたっては表題に「設計」とある通り、ECSを使う際の設計部分...もう少し踏み込んで言うと「ECSとMonoBehaviourとの連携周りの設計」を少なからずは意識するようにして実装してみました。3

はじめに

設計を検討し直したと言えども...設計の方針については恐らく正解と呼べるものが無い上に、今回の設計自体もまだ微妙なところが点在していると思ってます。

それにECS自体もまだpreviewの段階という事もあり、将来の変更によってはその影響で今回解説する内容が適用出来なくなる/または考え直す必要が出てくると言った可能性もあり得ます。

そもそもとして「(変更の可能性がある)previewの段階で設計の話を持ち出すのか?」と言う考えもあるかもしれませんが...それでも部分的に参考になるところはあるかもしれない上に「ECSを実際に組み込んでみた」関連の情報があまり出回っていない印象のある今、折角やって出さないのは少し勿体無いとも思えたのでアウトプットすることにしました。

将来そのまま適用できる話ではないかもしれませんが、ECSを触る際の一例として参考になれば幸いです。

● Project Repository

mao-test-h/DOTS-Jungle

※ 記事中で引用しているコードのライセンスは上記リポジトリに準拠

Supported Unity versions

Unity 2019.3.0f1+

DOTS Packages
  • Entities preview.4 - 0.3.0
  • Hybrid Renderer preview.4 - 0.3.0
    • ※開発自体は過去版である0.1.1をメインに行っていたので、最新に追従できていない箇所があります。

※注意点

記事中ではDOTS及びECSの基礎的な部分や概念については触れません。

これらに関する初学者向けの資料としては、今年のUniteであった以下の講演が比較的新しい上に分かりやすくてオススメです。(どちらか一方ではなく、合わせて見た方が良いかも)

TL;DR

先に要点を纏めます。

ECSとMonoBehaviourとの連携周りの設計 → 「必要な箇所だけECSで動かすように」

  • 従来通りMonoBehaviour/GameObjectベースの実装でゲーム全体を構築しつつ、大量に計算する必要がある箇所だけECSで動かすようにする
    • → 「ECSと言うアーキテクチャを高速化のためのモジュールとして取り入れる」と言う考え

MonoBehaviourとECSはレイヤーを分けて「依存関係を制限」

  • MonoBehaviourとECSは「Assembly Definition Files (以降、ADFと表記)」でレイヤーを分けることで依存関係(依存の方向)を制限させる
    • 「MonoBehaviourレイヤー」からは「Providerレイヤー」にあるメソッドを叩いてEntityを生成
      • → MonoBehaviourからComponentSystemなどのコアロジックを見えないようにする
    • 「ECSレイヤー」からは「MonoBehaviourレイヤー」が見えない
      • → MonoBehaviourレイヤーの影響は一切受けない
  • 結果としてMonoBehaviourとECSが分断されるので、「お互いの状態に依存しなくなる」
    • → 依存性がなくなると言うことは「機能単位にテスト/動作確認を行いやすくなる」「変更に強くなる」「設計の見通しが良くなる(何がどこにあるのか?作る際にどこに作ればよいのか把握しやすくなる)」とも言い換えられるので、チーム開発や運用案件で常に動かし続けなくてはならないプロジェクトにとってはメンテナンス性周りで利点に繋がるかも。

※以降の解説でも「レイヤー」と言う単語が出てきますが、こちらは「ADFで区切ったAssembly単位」と同義になります。

0.png

※ なぜ依存関係を制限させるのか?

そもそもとしてMonoBehaviourとECS(ComponentSystem)はお互い別々のアーキテクチャとなっているために、その時点では「既に分断はされている」とも言えるかもしれません。
→ 更に使い方を「MonoBehaviourからEntityManagerを叩いてEntityを生成するだけ」と言ったシンプルな操作に絞るだけなら依存の方向もスッキリします。

しかし、ECSの方は管理によってはWorld.DefaultGameObjectInjectionWorld(従来で言うWorld.Active)を経由したグローバルなアクセスによりMonoBehaviourのどこからでもComponentSystemを引っ張ってこれてしまう上に、ComponentSystem自体も実態としてはピュアなC#のクラスでしか無いので、状態を持たせるような実装もやろうと思えば可能になるかと思います。

そうなると、使い方によっては「MonoBehaviour側から直接ComponentSystemの状態を変更して挙動を変える」と言った実装を行える(言い換えるとMonoBehaviourに依存する)ので、「レイヤー単位で分割することでMonoBehaviourレイヤーからECSレイヤーを見えなくして非依存であることを保証する」と言ったのが今回のアプローチの一つの考えであります。

作ったゲームについて

具体的な解説に入っていく前に作ったゲームについても簡単に触れておきます。
※ 以降の解説に登場人物として出てくるので。

ゲームとしては「主人公であるゴリラがジャングルでバナナを探して集める」と言うシンプルなゴリラシミュレータであり、以下のゲーム画面のようにバナナの木から絶えず射出され続ける光弾に耐えつつ、たまに光弾に混じって射出されるバナナを取得してスコアを稼いでいくゲームになります。
(以降、バナナの木から射出されるバナナをスコアバナナと表記)

あとはゴリラ自体は光弾を受け続けたらゲームオーバーとなるので、ピンチの時用にドラミングで周囲にエネルギー波を飛ばして光弾とスコアバナナ諸共消滅させる必殺技を出すことも可能となってます。

69494705-2565c800-0f02-11ea-![EKnMaH4VAAAhZzD.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/9457b0da-f800-3217-beec-8b4c7a67af6c.png)<br>
8fd1-b25de3fee158.gif

※ ちなみに公開リポジトリの方はゴリラを始めとしたAssetStoreの物は再配布できないので、代わりとなる物に差し替えてあります。

設計について

今回の設計思想を一言で表すと「従来通りMonoBehaviour/GameObjectベースの実装でゲーム全体を構築しつつ、必要な箇所だけECSで動かすようにする」になります。

この「必要な箇所だけECSで動かすようにする」と言う点については、言い換えると「ECSと言うアーキテクチャを高速化のためのモジュールとして取り入れる」とも説明できるかもしれません。

「MonoBehaviourとECSが共存する」となるとイメージがつかないうちは「2つの巨大なアーキテクチャが混在してゴチャゴチャしそう...?4」と思われるかもしれませんが...これらはレイヤーを分けて「お互いの参照方向を明確にしてやる」ことで治安良く管理できるものだと私は考えてます。

具体的な設計について順を追って解説していきます。

必要な箇所だけECSで動かすようにする?

言葉のとおりであって、今回の例で言えば大量に計算する必要がある箇所のみECSで計算出来るような作りにしてあります。

詳細に入る前に...先ずは解説を進めやすくするために、インゲームに登場するオブジェクトとそれの制御関係を以下に纏めます。

GameObjectで管理 (MonoBehaviourで制御)

  • ゴリラ(プレイヤー)
    • 入力操作制御/アニメーション
  • バナナの木
    • 光弾及びスコアバナナの射出制御など

Entityで管理 (ComponentSystemで制御)

  • 弾全般 (光弾, スコアバナナ, エネルギー波)
    • 移動処理 (弾道の計算ロジック)
    • 衝突判定全般
      • 「エネルギー波」 x 「光弾/スコアバナナ」
      • 「ゴリラ」 x 「光弾/スコアバナナ」
        • ※ GameObjectであるゴリラとEntityである光弾/スコアバナナでどうやって衝突判定を取っているのかについては後述
  • バナナの木に実っている大量の演出用バナナ
    • 動作制御
    • ※ このオブジェクト自体は演出用なのでゲームロジックには影響しない

※バナナの木の見かけについてはこちらを参照 (クリックで展開)

banana.gif

こちらがジャングルに自生する今作の「バナナの木」となります。
中央のコアを周回しているのが「バナナの木に実っている大量の演出用バナナ」であり、ComponentSystemで動作制御しているEntity郡となります。

★ ポイント

この様にECSで制御しているのはゲームとして「必然的に大量発生するであろうオブジェクトの移動制御/衝突判定/演出」だけに留まってます。

逆に言うと、それ以外のゲームループやUIの制御、プレイヤーの制御(入力制御/アニメーション)、オーディオやパーティクルなどは全て従来通りの作り方そのままで実装されてます。

レイヤー構成

大まかなレイヤー及び参照方向の図を載せます。

矢印は参照方向を示しており、言葉で簡単に説明すると「MonoBehaviourレイヤーからはProviderレイヤーにあるメソッドを叩いてEntityを生成。ComponentSystemなどを見えないようにする」 「ECSレイヤーからはMonoBehaviourレイヤーが見えないので、変更などの影響は一切受けない」ような構成にしてます。

0.png

★ ポイント

この構成のポイントとしては「MonoBehaviourレイヤーとECSレイヤーが分断されるので、お互いの状態に依存しなくなる」点にあります。

依存性がなくなると言うことは先程の要点の項目でも記載したとおり、「機能単位にテスト/動作確認を行いやすくなる」「設計の見通しが良くなる」「変更に強くなる」とも言い換えられるので、チーム開発や運用案件で常に動かし続けなくてはならないプロジェクトにとってはメンテナンス性周りで利点につながるかと思います。

Assembly Definition Filesを導入しやすくなる

後は参照の方向を明確にすると言うことは、ADFによるAssembly単位の分割も行いやすくなる利点もあります。
今回の設計ではADFを切るところまでを含めて1つのレイヤーとして定義してます。

※ 補足: ADFを切る利点について (クリックで展開)

ADFを切ると何が良いのか?について簡単に所感をまとめます。

  • レイヤー単位の役割が明確になり、不必要なパッケージの参照を切り離すことが出来る
  • レイヤー単位での「Allow unsafe Code」の設定が可能
    • こちらはProject Settingsにある同様の設定とは独立した物となっているいるので、unafeを必要とする範囲を絞ることが可能になります
  • コンパイル時間の短縮

2点目の「Allow unsafe Code」についてはまさに今回のレイヤー設定で言うと、必要となる箇所は「ECSレイヤー」だけとなっているので、こちらのみ有効にして他は使わないことを明示できるような構成となってます。


他のレイヤーも含めると全体としては大雑把に見て以下のような構成となります。5

all2.png

例えば「どのレイヤーからも参照される共通定義(定数、enum定義)」は最小限の単位で1つのレイヤーに纏めることで各レイヤーから参照できるようにしてます。

他にも「ツール類(Editor拡張など)」と言った物についても結合度を下げる目的で1つの独立したレイヤーに分けてやり、外から必要とするレイヤーを覗くような構成としました。

各レイヤーの役割

メインとなるレイヤーの役割について解説していきます。

MonoBehaviour

ベースとなるレイヤーです。

a.png

ECS自体は「大量に動かす必要のあるオブジェクトの移動/衝突判定」ぐらいでしか使っていないので、担当箇所としてはそれ以外の全てとなります。

  • ゲームループの制御
  • プレイヤーであるゴリラの制御
    • 入力に対する操作制御 (移動、ドラミング)
    • ゴリラのアニメーション
      • → 従来通りAnimatorを利用
  • バナナの木の制御
    • 光弾スコアバナナの射出ロジック
      • → 愚直にMonoBehaviour.Updateで実装
  • オーディオ全般
    • → 従来通りAudioSource/AudioListenerを利用
  • パーティクル全般
    • → 従来通りShurikenを利用
  • UI全般
    • uGUI

ECSからのイベント取得について

今回の実装では「ゴリラはGameObject」「光弾とスコアバナナはEntity」となっている上で、衝突判定に関するロジックは全て「ECSレイヤー」のComponentSystemで判定を取ってます。
これは言い換えるとUnityEngine.RigidbodyUnityEngine.Colliderの類は一切使っていない事を指します。

ただ、パーティクルやオーディオの制御は全てMonoBehaviour側が受け持つ形となっているので、実装としては「ComponentSystemからMonoBehaviourに対しイベントを通知する」専用の仕組みを実装して制御できるようにしました。

こちらの詳細については「ECS.Provider.Hybridレイヤー」の項目で解説します。

検討事項

一部のロジックはECSに委譲できるかも

今回は数と負荷的に許容できたので一部のロジックはそのままMonoBehaviourで実装してますが、例えば以下の処理などは数によってはECSの制御下に委譲することができるかもしれません。

  • バナナの木の光弾スコアバナナの射出ロジック
  • パーティクル制御
    • ただしパーティクルシステムの再実装の必要が有りそう
  • オーディオ制御
    • DOTS Audioが使えるようになったら検証の価値あり

ECS.Provider

こちらはMonoBehaviourからECSを操作するためのレイヤーとなります。
(※ 名前にProviderと付いているが、個人的にはもう少し別の名前を検討できたかもしれない感があったりも..)

b.png

例えば「Entityの生成」と言った処理はMonoBehaviourから直接EntityManagerを叩いて生成せずに、全てこちらのレイヤーにあるECSProviderと言うクラスのメソッドが受け持つ形となっております。

この様に分離することで以下の利点が成立します。

  • MonoBehaviourからはECSを知らなくてもメソッドを叩く形でEntityを生成できる
    • → 言い換えるとECSの実装を透過的にすることが出来る
  • ECSからはMonoBehaviourに対し根本にあるEntities関連のクラスを隠すことができる

今回はECSProviderは直接具象クラスとして実装してますが、Interfaceで分けることも検討できるかもしれません。

Entityの生成をメソッド化

ECSProviderでは以下の様にEntityの生成処理をメソッド単位で持たせるようにしてます。
→ 例として光弾スコアバナナの生成メソッドの一部を引用

※ しれっとPrefabProviderなるクラスが出てきてますが、こちらについてはEntityのPrefab Workflowについての章にて解説

ECSProvider.cs
/// <summary>
/// バナナの木から「光弾 or バナナ」を生成
/// </summary>
/// <param name="position">バナナの木の位置</param>
/// <param name="isBanana">trueならスコアバナナを生成</param>
public void CreateCircleBullet(in float3 position, bool isBanana)
{
    // ベースとなるPrefabEntityを取得
    var prefabEntity = _prefabProvider.PrefabEntities[(int) (isBanana ? BulletIndex.Banana : BulletIndex.Damage)];
    var barrageParam = _entityManager.GetComponentData<BarrageConfig>(prefabEntity).Config.Circle;

    .....

    var count = barrageParam.BurstCount;
    for (int i = 0; i < count; i++)
    {
        // 計算いろいろ

        // Entityの生成
        var entity = Instantiate(prefabEntity, position, rotResult);
        _entityManager.SetComponentData(entity, new Angle {Value = dir});
        _entityManager.SetComponentData(entity, new Destroyable
        {
            IsKilled = false,
            Lifespan = barrageParam.Lifespan,
        });
        _entityManager.AddComponentData(entity, new CircleTag());
    }
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
Entity Instantiate(in Entity prefabEntity, in float3 position, in quaternion rotation)
{
    var entity = _entityManager.Instantiate(prefabEntity);
    _entityManager.SetComponentData(entity, new Translation {Value = position});
    _entityManager.SetComponentData(entity, new Rotation {Value = rotation});
    return entity;
}

使う側である「バナナの木の射出ロジック」としては以下のようにUpdateで時間を計測して一定時間経過したらECSProviderの生成メソッドを呼び出して生成だけです。

BananaTreeLogic.cs
public ECSProvider Provider { get; private set; }

void Update()
{
    // HACK: Updateで直にEntityを生成しているが、パフォーマンスが気になるなら生成機構をECSに回しても良いかも
    if (_time > _barrageConfig.ShotSpan)
    {
        var trs = transform;
        var isBanana = UnityEngine.Random.value <= _barrageConfig.BananaRate;

        // 一定時間経過したら弾幕の種類に応じてEntity(光弾/バナナ)を生成
        switch (_barrageType)
        {
            case Barrage.Aiming:
                Provider.CreateAimingBullet(
                    trs.position, trs.rotation,
                    _targetTrs.position, isBanana);
                break;
            case Barrage.Circle:
                Provider.CreateCircleBullet(
                    trs.position, isBanana);
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }

        _time = 0f;
        return;
    }

    _time += Time.deltaTime;
}

ECSProviderを必要とするクラスに対し参照を注入

ECSProviderを必要とする各クラスに対する参照解決について、こちらは「IECSProviderUsableと言うinterfaceを実装したクラスに対し、初期化時のタイミングでインスタンスを流し込む」形で参照解決してます。

IECSProviderUsable.cs
namespace Main.ECS.Provider
{
    public interface IECSProviderUsable
    {
        ECSProvider Provider { get; }
        void SetProvider(in ECSProvider provider);
    }
}

処理としては非常に単純であり、ゲームループの初期化時にECSProviderをインスタンス化 → 全GameObjectを総なめして流し込んでいるだけです。
(恐らくはZenjectなど導入すればもうちょっとスマートに書けるかもしれないので要検証...)

GameLoop.cs
// ゲームループのコンストラクタ
public GameLoop(GameLoopType startType, GameSettings settings, IAudioPlayer audioPlayer)
{
    // ECSProviderのインスタンス化
    _ecsProvider = new ECSProvider();

    // HACK. 面倒なので全オブジェクト拾ってきてinterfaceを対象に初期化していく.
    var objs = Object.FindObjectsOfType<GameObject>();
    foreach (var obj in objs)
    {
        // ECS利用箇所にPresenterを流し込む
        foreach (var usable in obj.GetComponents<IECSProviderUsable>())
        {
            usable.SetProvider(_ecsProvider);
        }
    }

先に例として挙げたバナナの木以外でECSProviderを必要とするクラスとしては、プレイヤーであるゴリラもこちらを必要としており、例えばドラミングが入力されたタイミングでECSProviderの「エネルギー波の生成メソッド」を呼び出します。

PlayerLogic.cs
/// <summary>
/// ドラミングが入力されたタイミングで呼び出されるイベント
/// ※イベント自体はAnimationClipから発火される
/// </summary>
public void StartDrumming()
{
    // カメラシェイク
    _cinemachineImpulseSource.GenerateImpulse();
    // 効果音再生
    _audioPlayer.PlayDrumming();
    // ドラミングのエネルギー波(Entity)生成
    Provider.CreateDrummingBarrage(_trs.position, _trs.rotation);
}

Entityの生存管理について

Entityも種類によってはMonoBehaviour側で生存管理をしたい時があります。

生存管理を行いたい意図としては「GameObjectが破棄されたタイミングで紐付いているEntityも一緒に破棄したい」と言う要件であり、今回で言えば「バナナの木に実っている大量の演出用バナナ」などがそれにあたります。

こちらを解決する手段としては、ECSProviderで生成メソッドを呼び出したタイミングで「戻り値として生存管理用のIDisposable」を返す形で管理できるようにしました。

具体例

以下のメソッドはECSProviderにある演出用バナナの生成メソッドです。
内部で生成したEntityは一度NativeArrayに保持をして後述のEntityDisposerに渡します。

ECSProvider.cs
/// <summary>
/// バナナの木に実っている演出用バナナの生成
/// </summary>
/// <returns>Entityを破棄する際には戻り値のDisposeを呼び出すこと</returns>
public IDisposable CreateFruitfulBananas(
    in float3 position, in quaternion rotation,
    int createCount, float defaultRadius,
    Vector2 rotationSpeedRange, Vector2 thetaRange)
{
    // ベースとなるPrefabEntityを取得
    var prefabEntity = _prefabProvider.PrefabEntities[FruitfulBananaIndex];
    var arr = new NativeArray<Entity>(createCount, Allocator.Temp);
    for (int i = 0; i < createCount; i++)
    {
        // Entityの生成
        var entity = Instantiate(prefabEntity, position, rotation);
        _entityManager.SetComponentData(entity, new OriginalPosition {Value = position});

        // 必要な情報をSetCompoonentData... (省略)

        arr[i] = entity;
    }

    // POINT: 生存間利用に作ったEntityは保持して返す
    var disposer = new EntityDisposer(_entityManager, arr);
    arr.Dispose();
    return disposer;
}

EntityDisposerの実装としては以下のようになっていて、IDisposableを実装したシンプルな破棄管理用のクラスになります。

MonoBehaviourには生成時にIDisposableとして返されるので、内部実装を意図せずとも透過的にEntityを破棄することが可能になります。6

EntityDisposer.cs
public class EntityDisposer : IDisposable
{
    readonly EntityManager _entityManager;
    NativeArray<Entity> _entities;

    public EntityDisposer(EntityManager entityManager, NativeArray<Entity> entities)
    {
        _entityManager = entityManager;
        _entities = new NativeArray<Entity>(entities, Allocator.Persistent);
    }

    public void Dispose()
    {
        try
        {
            foreach (var entity in _entities)
            {
                _entityManager.DestroyEntity(entity);
            }
        }
        catch (NullReferenceException e)
        {
            // ゲーム終了タイミングでDisposeが呼び出されるとEntityManagerが先に破棄されている事があり、
            // 内部的にNullReferenceExceptionが飛んでくるのでNativeArrayが正しく破棄されるように例外を潰しておく.
            // FIXME: 逆にアプリ終了以外で呼び出されたら想定外なので適切に対処すること.
            Debug.LogWarning($"    >>> {e}, {e.Message}");
        }

        _entities.Dispose();
    }
}

生存管理しなくても良いEntityもある

ゲーム中に存在するEntityとしては「演出用バナナ」の他に「弾全般」がありますが、こちらについては「一度生成されたらComponentSystem内で確実に破棄される仕組み」となっているために、特にIDisposableを返すような生存管理などは行ってません。

具体的に言うと、弾は以下の条件を満たしたときに破棄されるので基本残り続けることがありません。

  • 衝突判定時に条件を満たしたとき
  • Y軸の位置を見て床よりも下に移動したとき
  • 生存管理用のComponentDataが持つ生存時間が経過したとき
Destroyable.cs
// 生存管理用のComponentData
public struct Destroyable : IComponentData
{
    public float Lifespan;  // 生存時間
    public bool IsKilled;   // trueで破棄確定
}

ゲーム側の仕様として「バナナの木が破壊可能であり、破棄されたタイミングで射出された弾を全て消す」と言った仕様にしたい場合であれば、仕組みを改修して生存管理できるようにするのは有りかもしれません。

ECS

「ECS.Provider.Hybridレイヤー」の解説に入る前に先にこちらの方から解説していきます。

メインとなるレイヤーの一番上に位置するこちらは他のレイヤーに依存することなく、独立して稼働するようなイメージとなります。

d.png

レイヤーが持つ役割をザックリと纏めると以下のようなものと定義できます。

  • ComponentDataComponentSystemの実装
  • オーサリングコンポーネントの定義
  • ECSレイヤーで使用するunsafeなScriptableObjectの管理

ComponentSystemについて

今回のゲームで動かすComponentSystemとしては以下のようなものを実装しました。

  • BarrageSystem
    • 弾幕の弾道計算(弾の移動)
  • UpdateColliderSystem
  • CheckIntersectSystem
    • 衝突判定
  • DestroySystem
    • Entityの破棄
  • FruitBananaSystem
    • バナナの木に実っている演出用バナナの動作

こちらに記載しているComponentSystemについては、主に「PureECSを対象としたEntityの制御」を行うものとなります。

これとは別に後述の「ECS.Provider.Hybridレイヤー」でも幾つかのComponentSystemを定義してますが、こちらは「HybridECS」を想定したものとなるため、Entity以外にもMonoBehaviour(従来のコンポーネント)に対しても処理を行うと言った違いがあります。

分けている意図としてはHybridECSの特性上「MonoBehaviourレイヤー」から「ECSレイヤー」を直接参照する必要性が出てきたので別レイヤーに分断しました。

※補足: ComponentSystemの実行順について (クリックで展開)

ComponentSystemの実行順については基本的にDefault Worldにある既定のComponentSystemGroupなどをベースに設定してます。

こちらについては以下の3つのグループが既定のものとして定義されており、全てのPlayerLoopを含めた実行位置についてはEntity Debuggerより確認可能となるので、それを見つつ独自のグループを挟むなりして調整してます。

  • InitializationSystemGroup
  • SimulationSystemGroup
  • PresentationSystemGroup

※ちなみに、おなじみのMonoBehaviour.Updateが呼ばれるタイミングは「Update.ScriptRunBehaviourUpdate」が該当します。SimulationSystemGroupよりも前に呼び出されてますね。

Art000.png

詳細については以下の記事がすごく参考になります。

オーサリングコンポーネントについて

オーサリングコンポーネントを簡単に説明すると、従来のコンポーネントをECSのComponentDataに変換する物を指します。

このレイヤーで管理しているオーサリングコンポーネントは通常のPrefab(NestedPrefab)にアタッチして使う想定が有り、主に「PureECSで処理するPrefabEntityに変換を行うためのコンポーネント」として機能します。

また、後述の「ECS.Provider.Hybridレイヤー」でもオーサリングコンポーネントを定義してありますが、こちらは「HybridECS側で処理するコンポーネント」を想定していると言った違いがあります。

このオーサリングコンポーネントを主に用いているところとしては、今回で言うPrefab Workflowが該当しますが、こちらについてはEntityのPrefab Workflowについてと言う章に纏めてあるのでそちらをご覧ください。

ECSレイヤーで使用するunsafeなScriptableObjectの管理

ECS側で共通値参照を行う際のテクニックとなりますが、こちらは本題とは少しズレるので「付録: ECSレイヤーで使用するunsafeなScriptableObjectの管理について」と言う章に纏めました。

宜しければ御覧ください。

ECS.Provider.Hybrid

最後にHybridについて解説します。

c.png

こちらはHybridECSと言う性質上、「MonoBehaviourレイヤー」から一部を参照する必要があるために、位置的にはECSレイヤーの一つ下に配置するようにしてます。

このレイヤーが存在する大きな意図としては「MonoBehaviourとECSの相互連結」であり、今回で言えばECS側で計算を行っている衝突判定時のイベント通知などがそれに該当します。

以降、衝突判定処理を中心に解説していきます。

衝突判定で用いるオーサリングコンポーネントを定義

キーとなるオーサリングコンポーネントは以下のHitReceiverComponentです。

役割としてはHybridECSの特性である「GameObjectを保ったままのEntity」に対し、衝突判定の各種設定を適用すると言ったものになります。

今回の使用箇所で言うと、プレイヤーであるゴリラのGameObjectにこちらをアタッチすることで「ゴリラと弾各種(光弾/スコアバナナ)の衝突判定」を取れるようにします。

HitReceiverComponent.cs
namespace Main.ECS.Provider.Hybrid
{
    [RequireComponent(typeof(ECSSphereColliderComponent))]
    [RequireComponent(typeof(ConvertToEntity))]
    [RequiresEntityConversion]
    public sealed class HitReceiverComponent : MonoBehaviour, IConvertGameObjectToEntity
    {
        public event Action<float3> OnDamageHitEvent = default;
        public event Action<float3> OnBananaHitEvent = default;

        Entity _entity;
        ECSSphereColliderComponent _sphereColliderComponent;

        void IConvertGameObjectToEntity.Convert(
            Entity entity, EntityManager dstManager,
            GameObjectConversionSystem conversionSystem)
        {
            // GameObject → Entityの方向で情報を同期させる.
            _entity = entity;
            dstManager.AddComponentData(entity, new SyncTRSFromGameObject());

            // 衝突判定用のComponentDataを追加
            _sphereColliderComponent = GetComponent<ECSSphereColliderComponent>();
            var sphereCollider = _sphereColliderComponent.GetComponentData;
            dstManager.AddComponentData(entity, sphereCollider);

            dstManager.AddComponentData(entity, new HitReceiverTag());
        }

        void OnDestroy()
        {
            var world = World.Active;
            if (world == null) return;

            var entityManager = World.Active.EntityManager;
            if (entityManager.Exists(_entity))
            {
                entityManager.DestroyEntity(_entity);
            }
        }

        // 衝突時にComponentSystemから呼び出される
        public void OnCollisionHit(float3 hitPosition, Bullet bullet)
        {
            //Debug.Log($"    hit >>> {hitPosition}, {bullet}");
            switch (bullet)
            {
                case Bullet.Damage:
                    OnDamageHitEvent?.Invoke(hitPosition);
                    break;
                case Bullet.Banana:
                    OnBananaHitEvent?.Invoke(hitPosition);
                    break;
            }
        }
    }
}

今回の作りとしてはプレイヤーはシーンに直に置かれているので、以下のようにアタッチして衝突判定に必要となるパラメータを設定します。

gorilla.png

※Hybrid想定なのでConvertToEntityConversionModeは「Convert And Injection GameObject」に設定。これでGameObjectは破棄されずにEntityと共存可能となる。

衝突判定の通知について

こちらは「ECS.Provider.Hybridレイヤー」が持つComponentSystemで通知を行います。

処理としてはHitEventDispatcherSystemと言うComponentSystemがそれにあたり、OnUpdateの中でPlayerHitEventStateと言うISystemStateComponentDataを見てぶつかったことを通知していきます。
(PlayerHitEventStateがどこでAddComponentDataされるのかについては後述)

※補足: ISystemStateComponentDataについて (クリックで展開)

一言で言うと「EntityがDestroyEntityされても破棄されずに残り続ける特殊なComponentData」であり、言い換えると「ISystemStateComponentDataを持つEntityはISystemStateComponentDataを取り除かない限りずっと残り続ける」ことになります。

ISystemStateComponentDataを取り除く方法としては、これ自体を明示的にRemoveComponentする事で取り除くことが可能です。

※参考 : 【Unity】ISystemStateComponentDataという機能

今回の利用箇所としては「ヒットした弾のEntity」に対してこれをAddComponentDataする事により、弾自体はDestroyEntityを呼び出して機能を停止させることが出来る上で、今回のようにイベント用のEntityとして回収することが可能となります。
※この使い方が思想的に正しいのかは少し見えておらず...

HitEventDispatcherSystem.cs
// ECSからMonoBehaviourへのヒット通知(Hybridで処理)
[UpdateInGroup(typeof(InitializationEventGroup))]
sealed class HitEventDispatcherSystem : ComponentSystem
{
    EntityQuery _playerHybridQuery;
    EntityQuery _playerHitEventQuery;

    protected override void OnCreate()
    {
        _playerHybridQuery = GetEntityQuery(new EntityQueryDesc()
        {
            All = new ComponentType[]
            {
                ComponentType.ReadWrite<HitReceiverTag>(),
                typeof(Transform),
            },
        });

        _playerHitEventQuery = GetEntityQuery(new EntityQueryDesc()
        {
            All = new ComponentType[]
            {
                ComponentType.ReadWrite<PlayerHitEventState>(),
            },
        });
    }


    protected override void OnUpdate()
    {
        var transforms = _playerHybridQuery.GetTransformAccessArray();

        Entities.With(_playerHitEventQuery).ForEach((
            Entity entity,
            ref PlayerHitEventState state) =>
        {
            for (int i = 0; i < transforms.length; i++)
            {
                // ヒットしたらHitReceiverComponent.OnCollisionHitを呼び出す
                var authoring = transforms[i].gameObject.GetComponent<HitReceiverComponent>();
                authoring.OnCollisionHit(state.HitPosition, state.BulletType);
            }

            EntityManager.RemoveComponent<PlayerHitEventState>(entity);
            EntityManager.DestroyEntity(entity);
        });
    }
}

一方のMonoBehaviour側での通知の受け取り方としては、先ほども出てきたオーサリングコンポーネントを経由して受け取る形となります。
HitReceiverComponentが「OnDamageHitEventOnBananaHitEvent」と言うイベントを外に公開しているので、必要とするクラスがこちらを購読してイベントを受け取ります。

HitReceiverComponent.cs
namespace Main.ECS.Provider.Hybrid
{
    [RequireComponent(typeof(ECSSphereColliderComponent))]
    [RequireComponent(typeof(ConvertToEntity))]
    [RequiresEntityConversion]
    public sealed class HitReceiverComponent : MonoBehaviour, IConvertGameObjectToEntity
    {
        // 必要とするクラスにてこちらを購読してイベントを受け取る
        public event Action<float3> OnDamageHitEvent = default;
        public event Action<float3> OnBananaHitEvent = default;

        .......

        // 衝突時にComponentSystemから呼び出される
        public void OnCollisionHit(float3 hitPosition, Bullet bullet)
        {
            //Debug.Log($"    hit >>> {hitPosition}, {bullet}");
            switch (bullet)
            {
                case Bullet.Damage:
                    OnDamageHitEvent?.Invoke(hitPosition);
                    break;
                case Bullet.Banana:
                    OnBananaHitEvent?.Invoke(hitPosition);
                    break;
            }
        }
    }
}

※衝突判定の計算そのものは「ECSレイヤー」で処理

上記のHitEventDispatcherSystemでは「通知を行う」とだけあり、実際の衝突判定の計算そのものは行ってません。
では衝突判定の計算はどこで行っているのかと言うと、こちらは「ECSレイヤー」にあるCheckIntersectSystemUpdateColliderSystemと言う衝突判定用のComponentSystemで処理してます。

分けてる意図としては、計算量が多い衝突判定はPureECS側で計算させることで「BurstCompilerの最適化を適用しつつ効率良く処理」できるようにしてます。
※特に今回の衝突判定は愚直に総当りで計算しているので量が多い。空間分割とかしてやれば最適化出来るかもしれないが未対応。

ソースについては引用しないので以下を参照してください。

衝突判定の流れとしては、CheckIntersectSystem「ヒットした弾のEntity」に対してPlayerHitEventStateをAddComponentData → こちらを上述のHitEventDispatcherSystemで回収して通知を行うと行った流れになります。

検討事項

こちらのHybridレイヤーですが、個人的には色々と検討の余地があると思っているので最後に纏めておきます。

レイヤー構成について

現状は「ECS.Providerレイヤー」の内部に含まれてますが...改めて考え直すとECS.Providerの外に出してECS.Hybridと言う別レイヤーで管理した方が良かったかな〜と思ってます。

更にその上で「Hybridレイヤー」自体も「MonoBehaviourレイヤーに向けての公開層」と「ComponentSystemを含んだ内部実装層」の複数階層に分けたほうが良かったかとも考えてます。
※今回の実装の反省点として、「MonoBehaciourレイヤー」と直に結びついている「Hybridレイヤー」がComponentSystemと言ったコアロジックを持ってしまった点があり..。(アクセス修飾子レベルでアクセスできないようにはしている)

図に纏めると以下のような構成になります。

hybrid_internal.png

どちらにせよHybridECSの構成については色々と検討の余地が残っていると感じてます...。

衝突判定用オーサリングコンポーネントの役割

このレイヤーにあるHitReceiverComponentがそれに当たりますが...こちらは機能的には「Entityに対するComponentDataの付与」以外にも「衝突時のイベント通知」も持っちゃってます。

ここらはinterfaceで実装を分けるのも有りかな~と思いました。。
(手を抜いて具象クラスを直接覗きに行ってしまった... :innocent: )

HitEventDispatcherSystem.cs
    protected override void OnUpdate()
    {
        var transforms = _playerHybridQuery.GetTransformAccessArray();

        Entities.With(_playerHitEventQuery).ForEach((
            Entity entity,
            ref PlayerHitEventState state) =>
        {
            for (int i = 0; i < transforms.length; i++)
            {
                // HACK: 具象クラスを直接見に行かないほうが良いかも
                var authoring = transforms[i].gameObject.GetComponent<HitReceiverComponent>();
                authoring.OnCollisionHit(state.HitPosition, state.BulletType);
            }

            EntityManager.RemoveComponent<PlayerHitEventState>(entity);
            EntityManager.DestroyEntity(entity);
        });
    }

EntityのPrefab Workflowについて

現在のECSには「GameObjectからECSに変換するための機構」が幾つか用意されてます。7
今回の運用ではそれを基にして「一部のEntityを従来のPrefab Workflowで管理する」と言った実装を行いました。
それをどの様に管理したのかについて簡単にまとめていきます。

ちなみに管理方法自体は「DOTS-Shmup3D-sample」と言うプロジェクトにある各種マネージャークラスによるPrefabEntityの管理を参考にさせていただきました。
→ e.g. BeamManager

※ 本題に入る前に...Prefabと言う単語が入り混じって分かりづらいので...名称に関しては以下の呼び分けで解説します。

  • GameObjectベースのPrefab → NestedPrefabと表記
  • EntityベースのPrefab → PrefabEntityと表記

NestedPrefabにアタッチするオーサリングコンポーネントを定義

今回のプロジェクトではこちらは「ECSレイヤー」にて定義してます。

定義するオーサリングコンポーネントの単位としては、「1つのオーサリングコンポーネントを1つのアーキタイプとして定義」するようにしました。(故にConvert内では複数のComponentDataをAddしている)

最初の頃は「1つのオーサリングコンポーネントを1つのComponentDataに対応させる形で定義」してましたが...コードベースでこちらのほうがPrefabEntityが持つアーキタイプの見通しが良かったために意図的にこうしてます。
※ この管理単位については検討事項であり、以後のアップデートを踏まえた所感を後述。

DamageBulletAuthoring.cs
/// <summary>
/// 光弾のオーサリングコンポーネント
/// </summary>
[RequireComponent(typeof(ECSSphereColliderComponent))]
sealed unsafe class DamageBulletAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    [SerializeField] ECSSettings _settings = default;
    [SerializeField] Bullet _bullet = default;
    ECSSphereColliderComponent _sphereColliderComponent;

    void IConvertGameObjectToEntity.Convert(
        Entity entity, EntityManager dstManager,
        GameObjectConversionSystem conversionSystem)
    {
        dstManager.AddComponentData(entity, new BulletType {Value = _bullet});
        dstManager.AddComponentData(entity, new BarrageConfig
        {
            ConfigPtr = _settings.NativeBarrageConfig.GetUnsafePtr,
        });

        _sphereColliderComponent = GetComponent<ECSSphereColliderComponent>();
        var sphereCollider = _sphereColliderComponent.GetComponentData;
        dstManager.AddComponentData(entity, sphereCollider);

        // non Serialize ComponentData.
        dstManager.AddComponentData(entity, new Destroyable());
        dstManager.AddComponentData(entity, new Angle());
        dstManager.AddComponentData(entity, new SphericalCoordinates());
    }
}

これをEntityに変換する想定のNestedPrefabにアタッチして各種パラメータを設定します。
ポイントとしてはこのPrefab自体にはConvertToEntityはアタッチしません。

Art000.png

※補足: 描画周りの設定について (クリックで展開)

描画周りの設定については従来通りMeshRendererコンポーネントに設定します。

こちらが適切に設定されていればECSの描画パッケージであるHybridRendererNestedPrefabをPrefabEntityに変換する際に自動的に変換をかけてくれます。
※ただしSkinnedMeshRendererは対応していないので注意。最新である0.2.0以降からは対応しているっぽいが、恐らくはDOTS Animationと呼ばれる機能が追加されるまでは無意味説がある...?

PrefabEntityへの変換

一通り設定の終えた変換対象のNestedPrefabを実際のPrefabEntityに変換するのは、「ECS.Providerレイヤー」に所属するPrefabProviderと言うクラスが担当します。

こちらは以下のような役割を持つクラスとなります。

  • Inspectorから変換対象のNestedPrefab及び参照用のIDを登録
  • DeclareReferencedPrefabsPrefabEntityとして登録
  • ConvertでEntityに変換し、変換したPrefabEntityを保持
PrefabProvider.cs
namespace Main.ECS.Provider
{
    [RequiresEntityConversion, DisallowMultipleComponent]
    sealed class PrefabProvider : MonoBehaviour, IDeclareReferencedPrefabs, IConvertGameObjectToEntity
    {
        [Serializable]
        class PrefabDetail
        {
            public int ID = default;
            public GameObject Prefab = default;
        }

        [SerializeField] PrefabDetail[] _prefabDetails = default;
        public Dictionary<int, Entity> PrefabEntities { get; } = new Dictionary<int, Entity>();

        public void DeclareReferencedPrefabs(List<GameObject> gameObjects)
        {
            foreach (var detail in _prefabDetails)
            {
                gameObjects.Add(detail.Prefab);
            }
        }

        public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
        {
            foreach (var detail in _prefabDetails)
            {
                PrefabEntities.Add(detail.ID, conversionSystem.GetPrimaryEntity(detail.Prefab));
            }
        }
    }
}

PrefabProvider自体もNestedPrefabで管理

PrefabProvider自体は以下のようにNestedPrefabにアタッチする形で管理してます。
そして先程は変換対象のPrefabには付けていなかったConvertToEntityはこちらにアタッチします。

そして設定が完了したPrefabProviderConvertと言った変換フローを呼び出させるために、今回の実装ではシーン中に配置してあります。

PrefabEntityの管理と利用

管理対象となるPrefabは_prefabDetailsと言うフィールドに「変換対象のNestedPrefab」と「変換後のPreafabEntity取得用のID」を合わせて設定していきます。

prefabprovider_.png

最後に設定が完了したPrefabProviderの取得及び呼び出しを行うのが、同じく「ECS.Providerレイヤー」に所属するECSProviderです。

ECSProvider.cs
namespace Main.ECS.Provider
{
    [RequireComponent(typeof(PrefabProvider))]
    public sealed class ECSProvider
    {
        enum BulletIndex
        {
            Drumming = 0,
            Damage = 2,
            Banana = 3,
        }

        const int FruitfulBananaIndex = 1;

        readonly EntityManager _entityManager;
        readonly PrefabProvider _prefabProvider;

        public ECSProvider()
        {
            // Get PrefabProvider Reference
            // HACK: もう少しスマートな取得方法があるかも...
            _prefabProvider = GameObject.FindObjectOfType<PrefabProvider>();
            _entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        }

    ....
}

前述のコード中にPrefabEntityを取得している箇所がチラホラと出てきましたが、こちらは今回のフローで変換したものを取得する形になってます。

ECSProvider.cs
/// <summary>
/// バナナの木から「光弾 or バナナ」を生成
/// </summary>
/// <param name="position">バナナの木の位置</param>
/// <param name="isBanana">trueならスコアバナナを生成</param>
public void CreateCircleBullet(in float3 position, bool isBanana)
{
    // ベースとなるPrefabEntityを取得
    var prefabEntity = _prefabProvider.PrefabEntities[(int) (isBanana ? BulletIndex.Banana : BulletIndex.Damage)];

検討事項

ざっと解説しましたが、個人的にはこの管理方法についても幾つか検討の余地があると感じてます。
(もう少しスマートに管理できないものか。。。)

オーサリングコンポーネントの単位

今回の例では「1つのオーサリングコンポーネントを1つのアーキタイプとして定義」する様な形で実装しましたが、他の例などを見ていると「1つのオーサリングコンポーネントを1つのComponentDataに対応させる形で定義」が多い?様に見受けられました。

裏付ける話として、Entitiesの最新である0.2.0以降からは今年のUniteなどで発表もされていた「オーサリングコンポーネントを簡単に書くための属性」に対応しており、機能的に見ても後者の「1オーサリングコンポーネント - 1ComponentData」が思想的には正しいのかもしれません。

オーサリングコンポーネントとComponentDataが1対1の関係になったら、恐らくはPrefabにアタッチされているオーサリング用コンポーネントがArchetypeになるかと思われる...?

オーサリングコンポーネントの所属レイヤーについて

今回はPureECSで使うPrefabEntity用のオーサリングコンポーネントは「ECSレイヤー」で管理し、HybridECSで使うオーサリングコンポーネントは「ECS.Provider.Hybridレイヤー」で管理すると言った棲み分けにしてますが、個人的に引っかかる点としては「ECSレイヤーでUnityEngine.MonoBehaviourを参照していた」点があります。

これは言い換えるとエンジンコードと結びついてしまっていることを指しており、完全なピュアC#なクラスとして分けていくのであればもう少しレイヤーを細かく分けても良いかな〜と思いました。
(ここらについてはEntitiesパッケージの分け方に倣うのが良いのか...?)

最後に

以上が今回実装したゲームの設計になります。

とは言えども検討事項はまだまだ残っていると考えてます。
例えば気になるポイントとしては「ECSレイヤー」がUnityEngineと完全に切り離せていない8ので、ECSレイヤーにComponentSystemを直に触れるMonoBehaviourを実装したら今回防ごうと意識していたポイントも防げなかったりします。。

ここらについては最新である0.2.0以降から幾つか切り離せそうな機能が追加されていたので、今後のアップデートに合わせて設計を変動させていくのも有りかと思いました。
→ 例えばUnityEngine.Timeに依存せずに時間を取得することが可能になったり

この設計は正しい?

冒頭にも書いたとおり、今回のこの設計が「正解」だとは思ってません。

ひょっとしたら実現する内容によっては遠回りな形式にもなり得るかもしれませんし、以前書いたやり方でも十分な可能性もありえます。
※例えば「意図的にMonoBehaviourからWorld.Active経由でComponentSystemを引っ張ってきて状態を変更させたい」場合や「ComponentSystemそのものにイベントを持たせてMonoBehaviour側で購読したい」場合など。

何が正しいのかについては明確な答えが存在しない分野かと思われるので、今回の設計自体はあくまで参考の一例程度に留めていただけると幸いです。(ECSがまだpreviewだという事も踏まえつつ)

付録: ECSレイヤーで使用するunsafeなScriptableObjectの管理について

ECS側のパラメータ調整に関する話です。

例として「光弾/スコアバナナ」と言ったECS側で制御されている処理も、既存の作りと同じくScriptableObjectを用いてバランス調整してます。

ecs_settings.png

ただ、ECSのIComponentDataに持たせる際には参照型のままだと都合が悪く、Blittable型を満たす必要が出てくるのでそのまま渡すことが出来ません。

今回はこれをどうやって渡したのかについて解説します。

アンマネージドメモリに確保してポインタでやり取り

結論から言うと「ScriptableObjectに持たせるパラメータをBlittableな構造体として定義し、確保したアンマネージドメモリにコピー → ポインタを必要とするデータに渡して間接参照」と言った流れになります。9

実装としては以下のようにScriptableObject内で「定義したパラメータのメモリ確保/解放」を出来るような形にしてます。

ECSSettings.cs
public sealed class ECSSettings : UnmanagedScriptableObjectBase
{
    public NativeObject<BarrageConfig> NativeBarrageConfig => _nativeBarrageConfig;

    // Blittableな構造体として定義
    [Serializable]
    public struct BarrageConfig
    {
        [Serializable]
        public struct DrummingParams
        {
            public int BurstCount;
            public int DefaultRadius;
            public float RotationSpeed;
            public float BulletSpeed;
            public float Lifespan;
        }

        [Serializable]
        public struct AimingParams
        {
            public int BurstCount;
            public float BulletSpeed;
            public float ConeRadius;
            public float ConeAngle;
            public float Lifespan;
        }

        [Serializable]
        public struct CircleParams
        {
            public int BurstCount;
            public float BulletSpeed;
            public float2 PitchRange;
            public float PitchSpeed;
            public float YawSpeed;
            public float Lifespan;
        }

        public DrummingParams Drumming;
        public AimingParams Aiming;
        public CircleParams Circle;
        public float KillLineY;
    }


    [SerializeField] BarrageConfig _barrageConfig = default;

    NativeObject<BarrageConfig> _nativeBarrageConfig;


    // ScriptableObject内でメモリ確保/破棄を行う
    public override void Initialize()
    {
        _nativeBarrageConfig = new NativeObject<BarrageConfig>(Allocator.Persistent, _barrageConfig);
    }

    public override void CallUpdate()
    {
        _nativeBarrageConfig.Value = _barrageConfig;
    }

    public override void Dispose()
    {
        _nativeBarrageConfig.Dispose();
    }
}

メモリの確保と解放は自動で呼び出し

上記のScriptableObjectのメモリ確保/解放の呼び出しについては、以下のUnmanagedScriptableObjectLifecycleと言うクラスを経由して自動的に呼び出されるようにしてます。

自動的に呼び出す仕組みとしては(若干力技感ありますが...)RuntimeInitializeOnLoadMethod属性にて自動生成できるようにしてます。10

UnmanagedScriptableObjectLifecycle.cs
sealed class UnmanagedScriptableObjectLifecycle : MonoBehaviour
{
    const string PrefabPath = "Prefabs/Systems/" + nameof(UnmanagedScriptableObjectLifecycle);

    [SerializeField] UnmanagedScriptableObjectBase _scriptableObject = default;

    // Resources以下より自身を拾ってきて自動生成
    [RuntimeInitializeOnLoadMethod]
    static void Bootstrap()
    {
        var prefab = Resources.Load<GameObject>(PrefabPath);
        var instance = Instantiate(prefab);
        instance.hideFlags = HideFlags.HideInHierarchy;
        var lifecycle = instance.GetComponent<UnmanagedScriptableObjectLifecycle>();
        lifecycle.Initialize();
    }

    void Initialize() => _scriptableObject.Initialize();

#if UNITY_EDITOR
    void Update() => _scriptableObject.CallUpdate();
#endif

    void OnDestroy() => _scriptableObject.Dispose();
}

Updateを呼び出す理由

UNITY_EDITOR定義時のみUpdateを呼び出すようにしている理由としては、実行中にInspectorからの操作による動的な値変更を反映できるようにするためです。

後述のIComponentDataなどに渡すポインタは「コピーした値」でしかなく、そのまま使うだけだとInspectorからの動的な変更は反映されません。

それを反映出来るように開発用の機能としてEditor時のみScriptableObject側から毎回値を上書きして反映できるようにしてます。
※前提として、このプロジェクトでは実行中にScriptableObjectの値を動的に変更する想定が無い。

ECSSettings.cs
public sealed class ECSSettings : UnmanagedScriptableObjectBase
{
    .....

    // ScriptableObject側ではUpdate時に毎回値を上書きしている
    public override void CallUpdate()
    {
        _nativeBarrageConfig.Value = _barrageConfig;
    }

    .....
}

オーサリングコンポーネント経由でIComponentDataにポインタを渡して参照

上記で設定したメモリのポインタを渡しているのはPrefabEntityのオーサリングコンポーネントとなります。

以下のようにオーサリングコンポーネントのInspectorから設定されたScriptableObjectを経由してIComponentDataに渡します。

DamageBulletAuthoring.cs
sealed unsafe class DamageBulletAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    [SerializeField] ECSSettings _settings = default;
    [SerializeField] Bullet _bullet = default;
    ECSSphereColliderComponent _sphereColliderComponent;

    void IConvertGameObjectToEntity.Convert(
        Entity entity, EntityManager dstManager,
        GameObjectConversionSystem conversionSystem)
    {
        ....

        // ここでIComponentDataにポインタを渡す
        dstManager.AddComponentData(entity, new BarrageConfig
        {
            ConfigPtr = _settings.NativeBarrageConfig.GetUnsafePtr,
        });

        ....
    }

IComponentDataとしては以下のような実装となります。

BarrageConfig.cs
public unsafe struct BarrageConfig : IComponentData
{
    public ECSSettings.BarrageConfig* ConfigPtr;

    public ECSSettings.BarrageConfig Config => *ConfigPtr;
}

後はこちらを必要とするComponentSystemで取得して参照するだけです。

※補足

今回は上述のようなポインタ経由で共通値を参照しましたが、他にもやり方は色々検討できるかと思います。
最後に幾つかをまとめます。

  • ISharedComponentData
    • 実はあまり使ったこと無いので何とも言えず...
    • ドキュメント曰く値は変更される想定のないものらしいが...ScriptableObject自体の参照を変えなければ使える..?
  • Managed IComponentData (Entities-0.2.0以降より)
  • staticなフィールド
    • Burstが使えなくなるので注意

参考/関連サイト

DOTS基礎

DOTS設計

ドキュメント


  1. 結局は開発に1週間以上掛かってしまったために、イベント自体には参加していないという... :innocent:  

  2. DOTS・・・Data-Oriented Technology Stackの略。「NativeContainer」「JobSystem」「ECS」「BurstCompiler」と言ったデータ指向型の総称と言う認識 

  3. 実は以前にも同様の話題に関する記事を書いたことがあるのですが、こちらは連携周りこそ考慮すれど...MonoBehaviourとECSが同じレイヤーに混在していて少しカオスな感じとなっていたので、ここらの反省点を踏まえつつ検討し直した内容となります。 

  4. 自分も最初はこんなイメージだった... 

  5. 具体的に言えば必要に応じてこれ以外のADFも参照している。この図中ではそれらの表記は割愛 

  6. コメントにも記載しているが...実行終了時に先にECS関連から破棄されるためか挙動が怪しくなる減少を確認...実装として微妙かもしれないが...終了時に例外を握りつぶすような形にしている... 

  7. 例えばConvertToEntityとかSubSceneとか 

  8. これを切り離すため?にADFには「No Engine References」と言う項目がある 

  9. 実は以前書いた記事にある「パラメータ調整について」とほぼ同じ。ただし管理方法を少し変えている 

  10. 一応ScriptableObectにもAwakeOnDestroyと言ったイベントがありますが...呼び出されるタイミングが不明確だったのでこのように明示的に呼び出されるようにしました。 

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
ユーザーは見つかりませんでした