【Unity】DOTSで作る完璧で究極のデータ指向Roll-a-Ball

予防線

言うほど完璧じゃないしそこまで究極ではないです。(タイトル詐欺)

はじめに

※この記事は12/11分のアドカレです。

こんにちは!RiG++団体長のTenです!そしてごめんなさい!!!大幅な遅刻です……(本アドカレ二度目)。Unityのプロジェクトを用意するのに想定外に時間がかかってしまいました(学祭があったとはいえ2週間かかりました)。

気を取り直して、DOTSで作ったRoll-a-Ballの解説記事です。コレを題材に選んでしまった自信過剰、学園祭の翌日にこの記事を入れてしまった見通しの無さ、自分の技術不足を全て呪いながら頑張って作ったので、クッッッッッッッソ長いですが、どうぞ最後までお付き合いください。

そもそもDOTSとは

DOTS(Data-Oriented Tech Stack)とは、一言で言えばUnityの新しい基盤です。今までのUnityとは、特にコーディング面において、考え方から作り方まで大幅に変化しています。

※最近ようやくバージョン1.0がリリースされたので、Unity 2022 LTS以降でしか利用できません。

具体的には、今までのUnityは「オブジェクト指向」ないし「コンポーネント指向」的なアプローチで開発されていましたが、DOTSを用いたUnityでは「データ指向」的なアプローチが必要になります。これによって、マルチコアプロセッサによるデータ処理の並列化が可能になるため、Unityプロジェクトのパフォーマンスを大幅に向上させることができます。※時間が無かったので、本記事ではパフォーマンスについてはあまり触れていません。腹切って詫びます。

DOTSは以下の三要素によって構成されています。

  • ECS(Entity Component System)
    • データ指向的コーディングをするためのフレームワークを提供します。
  • C# Job System
    • 安全かつ高速に実行できる、マルチスレッドコードを簡単に(どこがやねん)生成する方法を提供します。
  • Burstコンパイラ
    • 高度に最適化されたネイティブコードに変換するコンパイラです。本来は制約によってコーディングを大きく縛ってしまいますが、ECSを利用することで、自然とその制約を満たすことができます。

DOTSの入り口は「ECS」です。もし、新しいUnityが気になるのなら、まずは「Unity ECS」で検索してみてください。

本記事でも、解説のほとんどはECSの話になります。

ECSについて

ECSに関して、解説すべき項目が多いため見出しを分けておきます。

ECS、Entity Component Systemは、Unityでデータ指向的アプローチを実現するために必要なフレームワークになります。

大きく異なる点として、今までの「GameObject」の代わりとして、「Entity」と呼ばれる物を使用します。従来の「GameObject」には、Transformなどの様々な情報が入っていましたが、Entityにはそれらの情報は一切ありません。シンプルな整数型の型エイリアスです。

そんなEntityに今までのように「Component」を付与して、オブジェクトを作っていくわけなのですが、このComponentも従来とは大きく異なっており、そもそもが構造体で作成されていますし、基本的にデータを保持しているだけで、処理は記述していません。さらに、Unity側が提供しているRigidbodyなどは機能が細分化されて別のComponentになっています。

処理を記述しないということは、当然他の場所で処理を記述しなければ、ゲームとして動くわけがないのですが、そんな処理を記述するのが「System」です。こちらも基本的には構造体で作成します。

こうやってデータと処理を分けて記述することで、メモリ内でデータを読み込む領域と処理を読み込む領域を分け、メモリレイアウトを綺麗に、そして効率化しやすくしている……んだと思います。少なくとも、見た目上はデータ指向的な設計になりそうです。知らんけど

Unity ECSでは、極力従来通りの操作方法でECSが実装できるように工夫が凝らされており、例えば専用の手順(後述)を踏めば、従来のGameObjectをEntityに変換できますし、Unityが提供するComponentであれば、従来のコンポーネントをアタッチしたGameObjectをEntityに変換する際に、ECS用のComponentにベイクしてくれます。

プロジェクト作成

では、Roll-a-Ballを作成していきます。

まずはプロジェクトの作成です。今回は以下のプロジェクトを作成しました。

  • バージョン:Unity 2022.3.11f1
  • テンプレート:3D (URP)

使用するパッケージは次の通りです。

  • Burst
  • Collections
  • Entities
  • Entities Graphics(結局使ってないかも)
  • Mathematics
  • Unity Physics

それぞれDOTSを用いた開発では必須 or よく使うので、インストールしておきましょう。

パッケージのインストールが終わったら軽く準備をしていきます。シーンを作成して、名前を「GameScene」とします。

次に、GameScene上で「Crete/New Sub Scene/Empty Scene …」と選択して、「Sub」という名前のシーンを作成します。

ECSを用いた開発ではこのSubシーンという概念が重要です。このSubシーン下に格納したGameObjectはEntityに変換され、コンポーネントはECS用のComponentにベイクされます。

このSubシーンですが、Hierarchyビュー上のチェックボックスにチェックを付けることで、そのまま編集することができるようになります。

さてということで、まずはRoll-a-Ball用のオブジェクトを作成します。スクリプトは後から作成するので、普通のRoll-a-Ball制作攻略サイトを見て、とりあえず以下のようにGameObjectを作成、配置してください。

細かい位置とか大きさは別になんでも大丈夫です。

特別なコンポーネントを付ける必要は特にないので、一般的なMeshやRigidbody、Colliderを付けておいてください。(EntityCamera、Scoreは後ほど使うので、今はEmptyで大丈夫です。)

Materialも一般的な物であればOKです。

UI、Camera、EventSystem、Lightは、ECSには対応していないので、通常のシーンに配置しています。

PickUpのColliderはIsTriggerをオンにしておいてください。

これで準備は整いました。次項からはついにコーディングに移ります。

Playerを動かそう

さて、いよいよコーディングです。とりあえずPlayerを動かしてみましょう。もちろん、普通にMonoBehaviourを作ってUpdateで~、とはいきません。

Playerの定義

はじめに、Playerの情報をPlayer Entityに与えるために、PlayerAuthoring.csというスクリプトを作成します。

using UnityEngine;
using Unity.Entities;
using Unity.Burst;

public struct Player : IComponentData
{
    public float Speed;
    public float Horizontal;
    public float Vertical;
    public float PosY;
}

public class PlayerAuthoring : MonoBehaviour
{
    public float Speed;

    class Baker : Baker<PlayerAuthoring>
    {
        public override void Bake(PlayerAuthoring authoring)
        {
            var data = new Player() {Speed = authoring.Speed };
            AddComponent(GetEntity(TransformUsageFlags.Dynamic), data);
        }
    }
}

PlayerAuthoringクラスではクラスがアタッチされたGameObjectをEntityに変換するとき、変換後のEntityにPlayerというComponentをベイクする処理を記述しています。このような処理はAuthoringクラス内のBakerクラスに記述します。

Playerの方ですが、ECSでのコンポーネントはIComponentDataを継承した構造体で宣言します。この中では、基本的に変数の宣言のみをして、処理は書きません。

このスクリプトをSubシーン中のPlayerオブジェクトにアタッチします。Speedの値は自由に設定してください。

入力を取る

次に処理を書きましょう。今回はwasd、もしくは十字キーから入力を取って、その方向に動くことを目標にします。

PlayerInputSystem.csというスクリプトを作成します。

using Unity.Entities;
using UnityEngine;
using Unity.Burst;

public partial struct PlayerInputSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Player>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float horizontal = Input.GetAxis("Horizontal");
        float vertical = Input.GetAxis("Vertical");

        foreach (var playerInput in SystemAPI.Query<RefRW<Player>>())
        {
            playerInput.ValueRW.Horizontal = horizontal;
            playerInput.ValueRW.Vertical = vertical;
        }
    }
}

ECSでの処理を記述するには、ISystemを継承した構造体を使用します。また、ISystemを継承する際には、必ずpartialキーワードを使用します。ECSでは、C#のSource Generatorsを使用して、いろいろと補助的なコードを生成しているのですが、それらのコードと共存するためにpartialキーワードが必要になるわけです。

さて、PlayerInputSystemでは、プレイヤーの入力を取っています。ISystemでは、更新処理はOnUpdateメソッド内に記述します。内容は至極単純にUnityから入力を受け付け、Playerに結果を渡しているだけなのですが、結果をPlayerに渡す際、少し特殊な書き方をしているのが見て取れると思います。

ここでは、SystemAPIのクエリを用いて、シーン内からPlayerコンポーネントを探し、ヒットしたPlayerコンポーネントにのみ処理を行っています。SystemAPIのクエリは、指定した内容全てを保持しているオブジェクトのみを対象に取ってくれるので、処理を行う対象を絞ることができます。

また、このスクリプトはBurstコンパイラによる高速化が可能になっているので、[BurstCompile]アトリビュートを付けています。

OnCreateメソッドでは、PlayerInputSystemの更新処理を、Playerコンポーネントが存在しているときにのみ行うようにする、という処理を行っています。

ECSではシーンのロードは非同期で行われるため、シーン内に更新処理に必要なコンポーネントが存在しないフレームが存在するのです。

さて、これでプレイヤーの入力を受け付けるスクリプトができたわけですが、このスクリプトはどこにもアタッチしません。ECSでは、ISystemに記述内容の全てを、自動的に処理します。Unityエデェタ上からは何の操作もできないため、わざわざ更新処理内に処理対象を絞る内容を記述した、というわけです。

Playerを動かす

さて、晴れて入力を取ることができたので、Playerを動かしてみましょう。

PlayerMovementSystem.csというスクリプトを作成します。

using Unity.Entities;
using Unity.Mathematics;
using Unity.Burst;
using Unity.Physics;
using Unity.Physics.Systems;
using Unity.Physics.Extensions;
using Unity.Transforms;

[UpdateInGroup(typeof(PhysicsSystemGroup))]
public partial struct PlayerMovementSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Player>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var deltaTime = SystemAPI.Time.DeltaTime;
        foreach (var (player, mass, velocity) in SystemAPI.Query<RefRW<Player>, RefRO<PhysicsMass>, RefRW<PhysicsVelocity>>().WithAll<Simulate>())
        {
            var moveInput = new float2(player.ValueRO.Horizontal, player.ValueRO.Vertical);
            moveInput = math.normalizesafe(moveInput) * player.ValueRO.Speed * deltaTime;
            velocity.ValueRW.ApplyLinearImpulse(mass.ValueRO, new float3(moveInput.x, 0, moveInput.y));
        }
    }
}

基本的なことは先ほどと同様です。

Player、つまりは剛体を動かすために、ECS用にUnityが開発した新しい物理演算システムである、Unity Physicsを使用します。PlayerMovementSystemでは、PlayerとUnity PhysicsのPhysicsMass(重さ)、PhysicsVelocity(速度)のコンポーネントを持ったオブジェクトを対象に更新処理を行っています。

更新処理の内容はSystemAPIのデルタタイムと、Player側に設定したSpeedの値、入力結果の値から移動方向、強さを生成し、PhysicsVelocityのApplyLinearImpulseメソッドで、Playerの剛体に力を与えています。一応、Rigidbodyで言うところのAddForceメソッドっぽいやつらしいです(もっと単純なメソッド用意してくれよ)

また、物理的な処理を行っているため、[UpdateInGroup(typeof(PhysicsSystemGroup))]アトリビュートを付与しています。

これで一旦実行してみましょうか。

動きはしましたが、壁にぶつかって宙に浮いてしまいますね。

従来であればRigidbodyのFreezePositionで~、となるところですが、UnityPhysicsにそんなものありません(なんでやねん)

ということで、FreezePositionをスクリプトから擬似的に実装します。

PlayerMovementSystem.csに追記します。

using Unity.Entities;
using Unity.Mathematics;
using Unity.Burst;
using Unity.Physics;
using Unity.Physics.Systems;
using Unity.Physics.Extensions;
using Unity.Transforms;

[UpdateInGroup(typeof(PhysicsSystemGroup))]
public partial struct PlayerMovementSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Player>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var deltaTime = SystemAPI.Time.DeltaTime;
        foreach (var (player, mass, velocity, transform) in SystemAPI.Query<RefRW<Player>, RefRO<PhysicsMass>, RefRW<PhysicsVelocity>, RefRO<LocalTransform>>().WithAll<Simulate>())
        {
            var moveInput = new float2(player.ValueRO.Horizontal, player.ValueRO.Vertical);
            moveInput = math.normalizesafe(moveInput) * player.ValueRO.Speed * deltaTime;
            player.ValueRW.PosY = transform.ValueRO.Position.y; // 追記
            velocity.ValueRW.ApplyLinearImpulse(mass.ValueRO, new float3(moveInput.x, 0, moveInput.y));
        }
    }
}

// 追記
[UpdateInGroup(typeof(AfterPhysicsSystemGroup))]
public partial struct PlayerPosModification : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Player>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var deltaTime = SystemAPI.Time.DeltaTime;
        foreach (var (player, transform) in SystemAPI.Query<RefRO<Player>, RefRW<LocalTransform>>().WithAll<Simulate>())
        {
            transform.ValueRW.Position.y = player.ValueRO.PosY; ;
        }
    }
}

PlayerMovementSystemに、オブジェクトの移動前のY座標の値をPlayerコンポーネント上の変数に記録する処理を追記、それをそのままPlayerPosModificatonという新しいISystem内の更新処理でPlayerのLocalTransform内、Positionに代入しています。

こちらは物理運動を行った後、位置の補正に使用したいので、[UpdateInGroup(typeof(AfterPhysicsSystemGroup))]アトリビュートを付与しています。

もう1度実行してみましょう。

宙に浮いて落ちていくことはなくなりましたね。

しかし、このままでは少し画面が見づらいですね。次はカメラを動かしてみましょう。

カメラの移動

ということで、カメラ、もといMain Cameraを動かしましょう。残念ながら今のところ、ECSにはCameraに相当するコンポーネントはないので、通常のシーン上のカメラを追従させることになります。

今回は以下のように実装しました。

カメラの定義

カメラを移動させるために、必要な準備をしましょう。今回は、通常のシーン上のカメラと、Subシーン上のオブジェクトの動きを連動させることで、プレイヤーの追従を実現します。

MainGameObjectCamera.csと、MainEntityCameraAuthoring.csを作成します。

using UnityEngine;

public class MainGameObjectCamera : MonoBehaviour
{
    public static Camera Instance;

    void Awake()
    {
        Instance = GetComponent<Camera>();
    }
}
using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using Unity.Burst;

public struct MainEntityCamera : IComponentData
{
    public float3 Offset;
}

[DisallowMultipleComponent]
public class MainEntityCameraAuthoring : MonoBehaviour
{
    public class Baker : Baker<MainEntityCameraAuthoring>
    {
        public override void Bake(MainEntityCameraAuthoring authoring)
        {
            var data = new MainEntityCamera();
            AddComponent(GetEntity(TransformUsageFlags.Dynamic), data);
        }
    }
}

MainGameObejectCameraクラスは通常のシーン上のMain Cameraオブジェクトに、MainEntityCameraAuthoringクラスはSubシーン上のEntityCameraオブジェクトにそれぞれアタッチします。また、それぞれTransformの値を同値にしておいてください。

GameObject側にはMain Cameraにアクセスするための参照を、Entity側にはオフセットの情報を、それぞれ用意しておきます。

なお、ECSを使う際にはVectorなどの便利な変数は存在しないので、float3で実装しています。

カメラの同期

さて、続いてEntityとGameObjectそれぞれのCameraの位置を同期させましょう。

MainCameraSystem.csを作成します。

using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;

[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial struct MainCameraSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MainEntityCamera>();
    }

    public void OnUpdate(ref SystemState state)
    {
        if (MainGameObjectCamera.Instance != null)
        {
            Entity mainEntityCameraEntity = SystemAPI.GetSingletonEntity<MainEntityCamera>();
            LocalToWorld targetLocalToWorld = SystemAPI.GetComponent<LocalToWorld>(mainEntityCameraEntity);
            MainGameObjectCamera.Instance.transform.SetPositionAndRotation(targetLocalToWorld.Position, targetLocalToWorld.Rotation);
        }
    }
}

[BurstCompile]
public partial struct MainCameraOffSet : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MainEntityCamera>();
        state.RequireForUpdate<Player>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        Entity player = SystemAPI.GetSingletonEntity<Player>();
        RefRW<MainEntityCamera> camera = SystemAPI.GetSingletonRW<MainEntityCamera>();
        camera.ValueRW.Offset = SystemAPI.GetComponent<LocalTransform>(SystemAPI.GetSingletonEntity<MainEntityCamera>()).Position
            - SystemAPI.GetComponent<LocalTransform>(player).Position;

        state.Enabled = false;
    }
}

MainCameraSystemの更新処理では、SystemAPIのGetSingletonEntityメソッドを用いて、シーン内に唯1つだけ存在するMainEntityCameraコンポーネントを持つEntityを取得し、その座標と角度を通常のシーン上のカメラのそれぞれの値に代入しています。残念なことに、UnityEngine.CameraクラスがBurstコンパイラの制約を満たしていないので、[BurstCompile]アトリビュートを使用することはできません。

MainCameraOffSetの更新処理では、SystemAPIのGetSingletonRWメソッドでMainEntityCameraの実体を取得し、その中の変数Offsetにカメラとプレイヤーのオフセットを記録しています。
ただ、このシステムが毎フレーム呼ばれてオフセットが毎フレーム更新されては困るので、最後にstate.Enabledにfalseを代入することで、更新を止めています。

カメラを移動

EntityとGameObjectそれぞれのカメラの位置を同期させることができたので、最後にカメラをプレイヤーの動きに合わせて移動させましょう。

MainCameraSystem.csに追記します。

using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;

[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial struct MainCameraSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MainEntityCamera>();
    }

    public void OnUpdate(ref SystemState state)
    {
        if (MainGameObjectCamera.Instance != null)
        {
            Entity mainEntityCameraEntity = SystemAPI.GetSingletonEntity<MainEntityCamera>();
            LocalToWorld targetLocalToWorld = SystemAPI.GetComponent<LocalToWorld>(mainEntityCameraEntity);
            MainGameObjectCamera.Instance.transform.SetPositionAndRotation(targetLocalToWorld.Position, targetLocalToWorld.Rotation);
        }
    }
}

[BurstCompile, UpdateBefore(typeof(MainEntityCameraSystem)) /* 追記 */]
public partial struct MainCameraOffSet : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MainEntityCamera>();
        state.RequireForUpdate<Player>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        Entity player = SystemAPI.GetSingletonEntity<Player>();
        RefRW<MainEntityCamera> camera = SystemAPI.GetSingletonRW<MainEntityCamera>();
        camera.ValueRW.Offset = SystemAPI.GetComponent<LocalTransform>(SystemAPI.GetSingletonEntity<MainEntityCamera>()).Position
            - SystemAPI.GetComponent<LocalTransform>(player).Position;

        state.Enabled = false;
    }
}

// 追記
[BurstCompile]
public partial struct MainEntityCameraSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MainEntityCamera>();
        state.RequireForUpdate<Player>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (camera, transform) in SystemAPI.Query<RefRO<MainEntityCamera>, RefRW<LocalTransform>>().WithAll<Simulate>())
        {
            Entity playerEntity = SystemAPI.GetSingletonEntity<Player>();
            transform.ValueRW.Position = SystemAPI.GetComponent<LocalTransform>(playerEntity).Position + camera.ValueRO.Offset;
        }
    }
}

MainEntityCameraSystemを追記しました。MainEntityCameraのLocalTransformを対象とし、PlayerのLocalTransformとOffsetの情報から、Playerが視認できる位置に移動させています。

また、Offsetの設定はこの処理よりも先に行って欲しいので、MainCameraOffSetに、[UpdateBefore(typeof(MainEntityCameraSystem))]を付け足しています。

さて、実行してみましょう。

……ま、まあ、少し……、いやかなりカクついていますが、とりあえず動いてるのでヨシ!

プロファイラを確認してみた感じ、処理落ちがあるわけではなさそうなので、完全にコーディングミスですね。正直なところを言うと、僕の技術力では解決できませんでした。助けてドラえもん。

気を取り直して、これでとりあえずプレイヤーの操作は完成しましたね。次は、収集アイテムを作っていきましょう。

アイテム作成

Roll-a-Ballのアイテムは、プレイヤーオブジェクトが接触すると消えてスコアを+1する、全て集めたらクリアテキストを表示する。
プレイヤーオブジェクトが接触するまではその場で回転し続ける。

というものでしたが、この項目ではアイテムオブジェクトが消える処理、及びアイテムオブジェクトがその場で回転する処理を作成していきます。

PickUp(アイテム)の定義

では、アイテム(以降PickUp)のEntityに情報を与えるため、PickUpのコンポーネントを作成しましょう。

PickUpAuthoring.csを作成します。

using UnityEngine;
using Unity.Entities;

public struct PickUp : IComponentData
{
    
}

public class PickUpAuthoring : MonoBehaviour
{
    class Baker : Baker<PickUpAuthoring>
    {
        public override void Bake(PickUpAuthoring authoring)
        {
            var data = new PickUp();
            AddComponent(GetEntity(TransformUsageFlags.Dynamic), data);
        }
    }
}

PickUpのコンポーネントは特に何も情報を持つ必要が無いため、タグとしての役割に準じてもらいます。

ECSにはタグという概念がないため、このようにコンポーネントをタグの代わりとして利用することが多々あります。

このスクリプトを、PickUpのプレハブにアタッチしておきます。

PickUpを回転させる

ここからはシステムの話です。まずはPickUpを回転させましょう。

せっかくなので、今まで使っていなかったC# Job Systemを使って並列化&高速化させます。

PickUpSystem.csを作成しましょう。

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics.Systems;
using Unity.Transforms;

public partial struct PickUpSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<PickUp>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var rotate = new RotationJob
        {
            deltaTime = SystemAPI.Time.DeltaTime
        };
        rotate.ScheduleParallel();
    }
}

[BurstCompile]
partial struct RotationJob : IJobEntity
{
    public float deltaTime;
    [BurstCompile]
    void Execute(in PickUp pickUp, ref LocalTransform transform)
    {
        transform.Rotation = math.mul(quaternion.AxisAngle(new float3(15, 30, 45), 0.05f * deltaTime), math.normalize(transform.Rotation));
    }
}

PickUpSystemではJobの発行のみを行い、実際に回転の処理を行っているのはRotationJobというJobです。

ECSにおけるJobは、IJobEntityを用いて実装します。

Excuteメソッド内で実行される処理を記述します。今回は、デルタタイムに応じてPickUpのLocalTransformの回転角を回しているだけなので、かなり単純です。こちらも、float3を使っています。

C# Job Systemで、Jobはマルチスレッドで処理されるので、シングルスレッドで処理される普通の更新処理よりかなり高速化できている………はずです(元がRoll-a-Ballなので大して違いが分からない)。

一旦実行してみましょうか。

良い感じに回ってますね。当初は、コードからPickUpに回転を与える際、Roll-a-Ballの攻略記事に書いてあるとおりの値をかけていたのですが、視認できなくなるほど高速回転していたので、数字を極端に小さくしています。

トリガーイベントを作る

さて、続いてはトリガーイベント、PlayerとPickUpの接触時にPickUpを消す処理を実装しましょう。

ここに1週間以上かかりました。マジ卍。

トリガーイベントというと、従来の方法ではOnTrigger~メソッドで行いましたが、もちろんECSにそんなものありません。

代わりに便利な物として、ITriggerEventsJobというインターフェースが用意されています。

コイツがまあ~曲者でして。今まで心の友レベルで使いまくっていたSystemAPIが使えないんですよね(実を言うと先ほどのIJobEntity内でも使えません)。便利なんで使いたいんですけどマルチスレッドで呼び出すとマズいんですかね。

まあ気を取り直して、トリガーイベントを作っていきましょう。

PickUpSystem.csに追記します。

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Physics;
using Unity.Physics.Systems;
using Unity.Transforms;

[UpdateInGroup(typeof(AfterPhysicsSystemGroup))] // 追記
public partial struct PickUpSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<PickUp>();
        state.RequireForUpdate<Player>(); // 追記
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        // 追記
        var simulation = SystemAPI.GetSingleton<SimulationSingleton>();
        var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
        var pickUpJob = new PickUpJob
        {
            PlayerGroup = SystemAPI.GetComponentLookup<Player>(),
            PickUpGroup = SystemAPI.GetComponentLookup<PickUp>(),
            EntityCommandBuffer = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged)
        };
        state.Dependency = pickUpJpb.Schedule(simulation, state.Dependency);

        var rotate = new RotationJob
        {
            deltaTime = SystemAPI.Time.DeltaTime
        };
        rotate.ScheduleParallel();

        JobHandle.ScheduleBatchedJobs(); // 追記
    }
}

// 追記
[BurstCompile]
struct PickUpJob : ITriggerEventsJob
{
    [ReadOnly] public ComponentLookup<Player> PlayerGroup;
    public ComponentLookup<PickUp> PickUpGroup;
    public EntityCommandBuffer EntityCommandBuffer;

    [BurstCompile]
    public void Execute(TriggerEvent ev)
    {
        var aIsPickUp = PickUpGroup.HasComponent(ev.EntityA);
        var bIsPickUp = PickUpGroup.HasComponent(ev.EntityB);

        var aIsPlayer = PlayerGroup.HasComponent(ev.EntityA);
        var bIsPlayer = PlayerGroup.HasComponent(ev.EntityB);

        if (!(aIsPickUp ^ bIsPickUp)) return;
        if (!(aIsPlayer ^ bIsPlayer)) return;

        var (pickUpEntity, playerEntity) =
          aIsPickUp ? (ev.EntityA, ev.EntityB) : (ev.EntityB, ev.EntityA);

        EntityCommandBuffer.AddComponent<Disabled>(pickUpEntity);
    }
}

[BurstCompile]
partial struct RotationJob : IJobEntity
{
    public float deltaTime;
    [BurstCompile]
    void Execute(in PickUp pickUp, ref LocalTransform transform)
    {
        transform.Rotation = math.mul(quaternion.AxisAngle(new float3(15, 30, 45), 0.05f * deltaTime), math.normalize(transform.Rotation));
    }
}

大量に追記しましたね。解説していきます。

まず、新しいJobとしてPickUpJobというトリガーイベントを発生させてくれるJobを作成しました。

この中では、Jobの発行元から変数を受け取る際、ComponentLookupという型を使用しています。これを用いることで、接触のあったオブジェクトのみを対象に取ることができます。

Excuteメソッド内では、まずはじめに接触のあったオブジェクトが本当にPlayerとPickUpなのか判別し、trueであればそれぞれのEntityを取得しています。

その後、EntityCommandBufferSystemに属する、唯1つのEndSimulationEntityCommandBufferSystem.Singletonから発行したCommandBufferを(これにたどり着くのに5日かかった)使って、PickUpのEntityにDisabledというコンポーネントを付与しています。これは、従来で言うところのSetActive(false)に近しいもので、要はDisabledが付与されているEntityは、非アクティブ状態になります。反対にアクティブ状態にしたい場合にはRemoveComponentを使ってDisabledを取り除けばいいそうです。

次に、PickUpSystemの方を見ていきましょう。

こちらには、Playerが確認できるまで更新処理を行わないように登録する処理と、PickUpJobの発行処理を追記しています。また、Playerの物理挙動の後に接触判定を取って欲しいので、[UpdateInGroup(typeof(AfterPhysicsSystemGroup))]アトリビュートを付与しました。

私はこの辺がちんぷんかんぷんなのですが、今回、PickUpを対象にとるJobを2つ作成したので、その依存関係を整理してやる必要があります。そこで、JobHandle.ScheduleBatchedJobsというメソッドを呼び出してやることで、その辺と実行が上手いこと行く……らしいです。完全に勉強不足です。

と、とりあえずトリガーイベントの作成はできたはずなので、実行してみましょう。

トリガーイベントが動作していますね!

ちなみにこのときHierarchyビューはこんな感じになっています。

アクティブ状態に限らず、どうやらSubシーン内でのあらゆる出来事は、HierarchyビューやSceneビューには反映されないみたいです。まあまだバージョン1.0なんでね。今後のアップデートに期待です。

スコアの実装+UI

ついに最後の工程です。ずっと画面左上で固まっていたスコアくんを動かします。

(画面最大化して実行したら小さすぎて見えなかったんで、これ以降UIを少し大きくしました。)

スコアの定義

ここまで読んでくださった皆さんならもう簡単でしょう。30秒で終わります。

ScoreAuthoring.csを作成しましょう。

using Unity.Entities;
using UnityEngine;

public struct Score : IComponentData
{
    public int Value;
}

public class ScoreAuthoring : MonoBehaviour
{
    public int Initial;

    class Baker : Baker<ScoreAuthoring>
    {
        public override void Bake(ScoreAuthoring authoring)
        {
            var data = new Score() { Value = authoring.Initial };
            AddComponent(GetEntity(TransformUsageFlags.None), data);
        }
    }
}

初期値をUnityエディタから入力したかったので、オーサリングクラスにパブリックフィールドを作ったくらいで、特に解説も必要ないでしょう。もちろん、Scoreオブジェクトにアタッチするのを忘れずに。

UIとの連携

少しズルい、もしくはマズいことをします。

まずはSubシーンからでも通常のシーンの参照を取れるように、ScoreGameObject.csを作成しましょう。

using UnityEngine;
using UnityEngine.UI;

public class ScoreGameObject : MonoBehaviour
{
    public static ScoreGameObject instance;
    private Text _text;
    [SerializeField]
    private Text _winText;

    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            _text = GetComponent<Text>();
        }
        else
        {
            Destroy(gameObject);
        }
    }

    public void SetText(int value)
    {
        _text.text = value.ToString();
        if (value > 11)
        {
            _winText.gameObject.SetActive(true);
        }
    }
}

よく見るシングルトンですね。先ほどカメラを実装する際にも使いました。

今回の主題とは外れる&僕は横着なので、クリアテキストの表示もこのクラスから行います。まあそれ以外は特に解説する必要も無いでしょう。

通常のシーン上のScoreTextにアタッチした上で、WinTextの参照も渡しておいてください。

さて、ここからはズルです。

SubシーンからScoreGameObjectクラスを触りたいのですが、なかなか上手くいかなかったので、最終手段としてScoreコンポーネントにScoreGameObjectクラスを触る処理を追記します。

using Unity.Entities;
using UnityEngine;

public struct Score : IComponentData
{
    public int Value;
    // 追記
    public void SetText()
    {
        ScoreGameObject.instance.SetText(Value);
    }
}

public class ScoreAuthoring : MonoBehaviour
{
    public int Initial;

    class Baker : Baker<ScoreAuthoring>
    {
        public override void Bake(ScoreAuthoring authoring)
        {
            var data = new Score() { Value = authoring.Initial };
            AddComponent(GetEntity(TransformUsageFlags.None), data);
        }
    }
}

はい。データ指向の構成ではデータと処理は分けて記述するべきでしたよね。ということで、この記述はデータ指向的にはふさわしくありません。今回は時間も無いので妥協しますが、この記事を読んでくださっている皆様は他の方法を模索してみてください。そしてあわよくばコメント欄等で教えてください(というかUnity公式は早いとこECS対応のUIを作ってください)

スコアの増やし方

次に、スコアを増やす処理を作成しましょう。こちらは少し特殊な方法を使います。

まずは何も聞かずにCountUpComponent.csというスクリプトを作って下さい。

using Unity.Entities;

public struct CountUpComponent : IComponentData
{
}

はい。何もありません。タグとして利用するのですが、なにかオブジェクトにアタッチする必要もありません。使い方は後述します。

続いて、処理を作成します。CountUpSystem.csを作成しましょう。

using Unity.Entities;

public partial struct CountUpSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Score>();
    }

    public void OnUpdate(ref SystemState state)
    {
        EndSimulationEntityCommandBufferSystem.Singleton ecbSystem = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
        foreach (var(score, countup) in SystemAPI.Query<RefRW<Score>, RefRW<CountUpComponent>>().WithAll<Simulate>())
        {
            score.ValueRW.Value += 1;
            score.ValueRW.SetText();
            ecbSystem.CreateCommandBuffer(state.WorldUnmanaged).RemoveComponent<CountUpComponent>(SystemAPI.GetSingletonEntity<Score>());
        }
    }
}

また登場しましたね。EndSimulationEntityCommandBufferSystem.Singleton。仲良くなれるでしょうか。

このISystemの更新処理では何を行っているのかというと、ScoreとCountUpComponentを持っているEntityを対象に、Scoreを増やしてUIに反映させる処理を行った後、RemoveComponentメソッドでCountUpComponentを除外しています。

これでScoreEntityは、CountUpComponentを取得した後1度だけ、ScoreTextに表示する値を+1します。

つまりはスコアが増えるイベント時に、ScoreEntityにCountUpComponentを付与してやればいいわけです。

ただ、この処理もカメラ同様、UnityEngine.UI.TextがBurstコンパイラの制約に引っかかるため、高速化できていません。Burstが恋しい。

スコアを増やす

最後に、実際にスコアを増やしましょう。

Roll-a-BallではPlayerとPickUpの接触イベント時にスコアを増やしていたので、こちらでもトリガーイベント時にスコアを増やします。

ということで、PickUpSystem.csに追記します。

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Physics;
using Unity.Physics.Systems;
using Unity.Transforms;

[UpdateInGroup(typeof(AfterPhysicsSystemGroup))]
public partial struct PickUpSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<PickUp>();
        state.RequireForUpdate<Player>();
        state.RequireForUpdate<Score>(); // 追記
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var simulation = SystemAPI.GetSingleton<SimulationSingleton>();
        var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
        var job = new PickUpJob
        {
            PlayerGroup = SystemAPI.GetComponentLookup<Player>(),
            PickUpGroup = SystemAPI.GetComponentLookup<PickUp>(),
            EntityCommandBuffer = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged),
            scoreEntity = SystemAPI.GetSingletonEntity<Score>() // 追記
        };
        state.Dependency = job.Schedule(simulation, state.Dependency);

        var rotate = new RotationJob
        {
            deltaTime = SystemAPI.Time.DeltaTime
        };
        rotate.ScheduleParallel();

        JobHandle.ScheduleBatchedJobs();
    }
}

[BurstCompile]
struct PickUpJob : ITriggerEventsJob
{
    [ReadOnly] public ComponentLookup<Player> PlayerGroup;
    public ComponentLookup<PickUp> PickUpGroup;
    public EntityCommandBuffer EntityCommandBuffer;
    public Entity scoreEntity; // 追記

    [BurstCompile]
    public void Execute(TriggerEvent ev)
    {
        var aIsPickUp = PickUpGroup.HasComponent(ev.EntityA);
        var bIsPickUp = PickUpGroup.HasComponent(ev.EntityB);

        var aIsPlayer = PlayerGroup.HasComponent(ev.EntityA);
        var bIsPlayer = PlayerGroup.HasComponent(ev.EntityB);

        if (!(aIsPickUp ^ bIsPickUp)) return;
        if (!(aIsPlayer ^ bIsPlayer)) return;

        var (pickUpEntity, playerEntity) =
          aIsPickUp ? (ev.EntityA, ev.EntityB) : (ev.EntityB, ev.EntityA);

        EntityCommandBuffer.AddComponent<Disabled>(pickUpEntity);
        EntityCommandBuffer.AddComponent<CountUpComponent>(scoreEntity); //追記
    }
}

[BurstCompile]
partial struct RotationJob : IJobEntity
{
    public float deltaTime;
    [BurstCompile]
    void Execute(in PickUp pickUp, ref LocalTransform transform)
    {
        transform.Rotation = math.mul(quaternion.AxisAngle(new float3(15, 30, 45), 0.05f * deltaTime), math.normalize(transform.Rotation));
    }
}

PickUpJobにScoreEntityの受け皿を用意してあげて、ジョブ発行時にSystemAPIからScoreEntityを渡しています。

そして、ジョブのおわりに、仲良くなったアイツを利用してCountUpComponentをScoreEntityにAddComponentしてやります。

さて、実行してみましょう。

CLEARじゃあ!!!

おわりに

ということで、「DOTSでRoll-a-Ball作ってみた」でした。お疲れ様でした。

いや~キツかったですね。

何がキツいって参考になるサイトや文献がほとんど無いうえ今までの慣習が使えない。

正式リリースされて日が浅いからか、リファレンスは全部英語だし、解説ブログはECSが開発中の時に書かれた物ばかりで既に使えないスクリプトばかりだし、そして何より対応してないUnityの機能が多すぎるし。

パフォーマンスで考えると、いつかはUnity全てがDOTSに移行するのかもしれないですが、この様子だとまだしばらくかかりそうですね。

とりあえずは多言語のリファレンスとECS対応のUIとCameraが欲しいっす。よろです、Unity公式サマ。

ではまた。といっても、今日(12/14)の分の記事も私なんですけどね(この記事遅刻しすぎだろ……)

GitHub共有

今回のRoll-a-Ballですが、GitHubで完成版を公開しているので、興味がある方はぜひ。

コメントを残す

CAPTCHA