e.blog

主にUnity/UE周りのことについてまとめていきます

UnityのARKit Pluginのカメラ映像を利用してなにかする

概要

UnityのARKit Pluginを使えばARコンテンツを手軽に作ることができます。
しかし、当然ですがARは外界をカメラで撮影し、それを元に姿勢を判断しています。

つまり、QRコードリーダーやOpenCVなどを利用した画像認識など「カメラを利用した処理」をするには、ARKit Pluginで利用しているカメラ映像を利用しないとなりません。

個別にカメラを起動して、その映像を使う、ということができないからです。

今回はARKit Pluginのカメラの映像を利用して画像処理をするためのTipsをまとめておこうと思います。

大まかな流れ

主な処理はUnityARVideoのコードを参考にしました。

  1. ARKitのセッションからARTextureHandlesを取得する
  2. Texture2D.CreateExternalTextureメソッドを利用して、ネイティブテクスチャのポインタからテクスチャを生成する
  3. UpdateExternalTextureメソッドを利用してテクスチャの内容をアップデートする
  4. UnityのARKit Pluginが提供してくれているマテリアルを利用して、ふたつのテクスチャを合成する(カメラの映像として見れる形に復元する(YCbCrフォーマットでふたつのテクスチャとして取得するため))
  5. RenderTextureの内容をTexture2Dにコピーする

という流れになります。

以下、細かく見ていきましょう。

ARTextureHandlesを取得する

UnityARSessionNativeInterfaceGetARVideoTextureHandlesというメソッドを利用してARTextureHandlesという、テクスチャのハンドルを取得することができます。

このハンドルを利用して、ARKit側で生成したネイティブのテクスチャへ(ポインタを経由して)アクセスすることができます。

ネイティブで生成されたテクスチャからテクスチャの内容を取得するには以下のようにします。

ARTextureHandles handles = UnityARSessionNativeInterface.GetARSessionNativeInterface().GetARVideoTextureHandles();
if (handles.IsNull())
{
    return;
}

Resolution currentResolution = Screen.currentResolution;

// _textureYはTexture2D
if (_textureY == null)
{
    _textureY = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);
    _textureY.filterMode = FilterMode.Bilinear;
    _textureY.wrapMode = TextureWrapMode.Repeat;
    _yuvMat.SetTexture("_textureY", _textureY);
}

handles.TextureYはネイティブテクスチャへのポインタとなっていてSystem.IntPtr型です。
そしてTexture2Dにはこうしたネイティブテクスチャからテクスチャを生成することができるようになっています。

それが以下の部分です。

_textureY = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);

CreateExternalTextureでネイティブテクスチャを元にテクスチャを生成することができます。
そしてテクスチャの内容を実際に取得して更新するにはUpdateExternalTextureメソッドを使います。

_textureY.UpdateExternalTexture(handles.TextureY);

とすることで、生成したTexture2Dの内容をアップデートすることができます。

ふたつのテクスチャを合成する

無事、ネイティブテクスチャからふたつのテクスチャを得ることができました。
ただ、前述のように、これらのテクスチャはカメラの映像がそのまま、というわけではありません。

YCbCrというフォーマットになっていて、適切に合成しないと元のカメラの映像になりません。
Wikipediaから引用させてもらうと以下の意味のようです。

YUVやYCbCrやYPbPrとは、輝度信号Yと、2つの色差信号を使って表現される色空間。

Wikipediaから画像を引用させてもらうと、以下のような感じのテクスチャが得られます。

YCbCr画像イメージ

一番上が元画像、その下が輝度画像、そしてその下がそれぞれ2つの色差信号によって表現されたものです。
これを合成して元の形に復元するために、ARKitPluginが提供してくれている「YUVMaterial」を利用します。

前段でネイティブテクスチャの情報は取得しているので、あとはこれを合成するマテリアルを通してRenderTextureに描き出してやればOKです。

// ネイティブテクスチャからテクスチャを生成し、マテリアルにセットしているところ
_yuvMat.SetTexture("_textureY", _textureY);

private void OnPostRender()
{
    // ... 中略 ...

    // RenderTextureへ、マテリアルの内容を書き込み
    Graphics.Blit(null, _arTexture, _yuvMat);

    // RenderTextureの内容をTexture2Dにコピーするため、AsyncGPUReadbackを利用して読み出し
    _request = AsyncGPUReadback.Request(_arTexture);
}

Graphics.Blitを利用してRenderTextureにマテリアルの内容を書き出します。
最終的に利用する形がRenderTextureなのであれば以上で終了です。
が、大体の場合はTexture2Dにするなり、テクセルの配列を利用して処理するなりの「情報として扱える形」に変換する必要が出てくるでしょう。

RenderTextureの内容をTexture2Dにコピーする

最後の工程は、テクスチャの合成を施した結果であるRenderTextureの内容をTexture2Dにコピーすることです。

コピーは以下のようにします。

RenderTexture back = RenderTexture.active;
RenderTexture.active = _arTexture;
_arTexture2D.ReadPixels(new Rect(0, 0, _arTexture.width, _arTexture.height), 0, 0);
_arTexture2D.Apply();
RenderTexture.active = back;

しかしこれ、実はだいぶ重い処理になります。 おそらくCPUを利用して全テクセルを読み出していると思うので、時間がかかります。
特に問題なのはメインスレッドで実行されるため、UIを停止、あるいはFPSの低下を招きます。

なので数フレームに1度、などの最適化を行わないとならないかもしれません。
幸いにして、今回やりたかったのはARKitのカメラの映像を利用してQRコードの読み取りをする、というものです。

なのでQRコードの読み取りが必要なタイミングでだけ有効にすることで今回は回避しています。

余談:AsyncGPUReadbackを使ってRenderTexutreの内容を読み出す

上で説明したように、ReadPIxelsはとても重い処理です。
そこでAsyncGPUReadbackというメソッドが追加されました。

これはGPURenderTextureの読み取りを実行し、結果を非同期で返してくれるメソッドです。

使い方などについては以下の記事が詳しく解説してくれているので参考にしてみてください。

qiita.com

なお、ここで紹介されている_tex.LoadRawTextureData(buffer);という形で読み込むと正常にデータが読み込めず、おかしな表示になってしまっていました。

keijiroさんのこちらのサンプルを見ると_tex.SetPixels32(buffer.ToArray());という形で読み込んでいて、こちらを試したところ正常に表示されました。

github.com

さて、最後に実際に実装したコードを掲載しておきます。

コード全文

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;
using UnityEngine.Rendering;
using UnityEngine.XR.iOS;

namespace ARKitTextureSample
{
    public delegate void OnReadQRCode(string url);

    public class ARQRReader : MonoBehaviour
    {
        public event OnReadQRCode OnReadQRCode;

        [SerializeField]
        private RawImage _preview = null;

        [SerializeField]
        private Text _text = null;

        [SerializeField]
        private Material _yuvMat = null;

        private string _result = null;

        private Matrix4x4 _displayTransform;
        private Texture2D _textureY = null;
        private Texture2D _textureCbCr = null;
        private RenderTexture _arTexture = null;
        private Texture2D _arTexture2D = null;
        private bool _isActive = false;

#if !UNITY_EDITOR && UNITY_IOS
        private void Start()
        {
            _arTexture = new RenderTexture(Screen.width, Screen.height, 0);
            _arTexture2D = new Texture2D(_arTexture.width, _arTexture.height, TextureFormat.ARGB32, false);
            _yuvMat = Instantiate(_yuvMat);
        }

        public void Active(bool active)
        {
            if (_isActive == active)
            {
                return;
            }

            if (active)
            {
                UnityARSessionNativeInterface.ARFrameUpdatedEvent += UpdateFrame;
            }
            else
            {
                UnityARSessionNativeInterface.ARFrameUpdatedEvent -= UpdateFrame;
            }

            _isActive = active;
        }

        private void UpdateFrame(UnityARCamera cam)
        {
            _displayTransform = new Matrix4x4();
            _displayTransform.SetColumn(0, cam.displayTransform.column0);
            _displayTransform.SetColumn(1, cam.displayTransform.column1);
            _displayTransform.SetColumn(2, cam.displayTransform.column2);
            _displayTransform.SetColumn(3, cam.displayTransform.column3);
        }

        public void OnPreRender()
        {
            if (!_isActive)
            {
                return;
            }

            ARTextureHandles handles = UnityARSessionNativeInterface.GetARSessionNativeInterface().GetARVideoTextureHandles();
            if (handles.IsNull())
            {
                return;
            }

            Resolution currentResolution = Screen.currentResolution;

            if (_textureY == null)
            {
                _textureY = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);
                _textureY.filterMode = FilterMode.Bilinear;
                _textureY.wrapMode = TextureWrapMode.Repeat;
                _yuvMat.SetTexture("_textureY", _textureY);
            }

            if (_textureCbCr == null)
            {
                _textureCbCr = Texture2D.CreateExternalTexture(currentResolution.width, currentResolution.height, TextureFormat.R8, false, false, handles.TextureY);
                _textureCbCr.filterMode = FilterMode.Bilinear;
                _textureCbCr.wrapMode = TextureWrapMode.Repeat;
                _yuvMat.SetTexture("_textureCbCr", _textureCbCr);
            }

            _textureY.UpdateExternalTexture(handles.TextureY);
            _textureCbCr.UpdateExternalTexture(handles.TextureCbCr);

            _yuvMat.SetMatrix("_DisplayTransform", _displayTransform);
        }

        private void OnPostRender()
        {
            if (!_isActive)
            {
                return;
            }

            if (_textureY == null || _textureCbCr == null)
            {
                return;
            }

            Graphics.Blit(null, _arTexture, _yuvMat);

            RenderTexture back = RenderTexture.active;
            RenderTexture.active = _arTexture;
            _arTexture2D.ReadPixels(new Rect(0, 0, _arTexture.width, _arTexture.height), 0, 0);
            _arTexture2D.Apply();
            RenderTexture.active = back;

            _preview.texture = _arTexture2D;

            _result = QRCodeHelper.Read(_arTexture2D);

            if (_result != "error")
            {
                _text.text = _result;
                OnReadQRCode?.Invoke(_result);
            }
        }
#else
        public void Active(bool active)
        {
            _isActive = active;
        }
#endif
    }
}

まとめ

今回はARKitのカメラからの映像を利用して色々してみるという趣旨でしたが、ネイティブテクスチャからのテクスチャ生成および更新など、知らない機能についても知れたのでよかったです。

またそれ以外にも、(それなりに)高速にRenderTextureからTexture2Dへ内容をコピーするAsyncGPUReadback活用の幅が広そうです。

今後、ディープラーニングなど「画像解析」を経てなにかを行うことは増えていきそうなので、このあたりの処理は覚えておくとよさそうです。

特定のUIを除いて画面のスクリーンショットを撮影する

概要

ゲーム画面のスクリーンショットを撮影したい、というのはよくある要望でしょう。
そしてさらに「特定のUIだけを除いて」撮影したい、というのもわりとある要望だと思います。
例えば操作用のUIは非表示だけども、ステータス表示などは表示しておきたい、などですね。

今回はそれを実現する方法について書きたいと思います。

なお、今回の記事の動作サンプルはGithubにアップしてあります。

github.com

実際に動かした動画↓

大まかなフロー

まず詳細を説明する前に、必要な項目について簡単に説明しておきます。

  1. GUI用のカメラを用意する
  2. 無視したいUIを持っているCanvasのレイヤーを適切に設定する
  3. CommandBufferを用いて、UI以外の要素がレンダリングされたあとのタイミングでバッファをコピーする
  4. GUI描画時に、カメラのレンダリングするレイヤー(cullingMask)を変更する
  5. GUI用カメラを、(3)で取得したテクスチャに追加で描画する

という流れになります。

詳細については順に説明していきます。

GUI用カメラを用意する

Canvas要素はデフォルトではRender ModeScreen Space - Overlayになっています。
これをまずScreen Space - Cameraに変更します。

すると以下のように、GUI要素をレンダリングするためのカメラを設定する項目が表示されるのでGUI用に用意したカメラを設定します。

f:id:edo_m18:20190208200618p:plain

なお、GUI用に用意したカメラではClear FlagsDepth Onlyに変更しておきます。

f:id:edo_m18:20190208200730p:plain

これでカメラ側の準備はOKです。

Canvasにレイヤーを設定する

続いて、Layerを設定していきます。
Cameraにはレイヤーごとにレンダリングするかしないか、というマスクが設定できるようになっており、これを利用してキャプチャ時に描画対象とするか、を切り替えます。

なので、まずは無視するUIのレイヤーを設定できるように新しいレイヤーを追加します。
(今回のサンプルではMenuUIという名前にしました)

そして新しいレイヤーが追加できたら、キャプチャ時に無視したいUI Canvasにそのレイヤーを設定します。

f:id:edo_m18:20190208211428p:plain

なお、レイヤーマスクの設定で無視されるかどうかはCanvasのレイヤー設定のみが反映されるようです。
なので、UI要素に対して個別にレイヤーを設定してもレンダリングされてしまいます。

もし細かく制御したい場合は、Canvas入れ子にして、そのCanvasにレイヤーを設定し、さらにそのCanvasの小要素としてキャプチャされたくないUI要素を入れることで対応することができます。

CommandBufferを用いてシーンの状況をキャプチャする

今回はシーンのキャプチャにCommandBufferを用いることにしました。
(もちろん、それ以外の方法でもキャプチャできます)

なお、CommandBuffer自体の詳細については凹みさんの記事がとても分かりやすく書かれているのでオススメです。

tips.hecomi.com

CommandBufferをカメラに登録する

CommandBufferは、カメラのレンダリングのパイプラインの中で、特定のイベントに紐づけて呼び出される「コマンド」を追加できる仕組みです。

今回はすべてのオブジェクトがレンダリングされ終わったタイミングでキャプチャしたかったのでCameraEvent.BeforeImageEffectsというタイミングでキャプチャを行っています。

コマンドバッファの生成は以下のようにしています。

/// <summary>
/// バッファを生成する
/// </summary>
private void CreateBuffer()
{
    _buf = new RenderTexture(Screen.width, Screen.height, 0);

    _commandBuffer = new CommandBuffer();
    _commandBuffer.name = "CaptureScene";
    _commandBuffer.Blit(BuiltinRenderTextureType.CurrentActive, _buf);
}

行っていることは単純に、生成したRenderTextureに、現在の(CurrentActive)状態をコピーしているだけです。

あとは、スクリーンショットを撮影するタイミングでこれをカメラに追加してやれば、適切なタイミングで処理が実行されます。

実際に実行すると以下のような形になります。

private void Update()
{
    if (Input.GetKeyDown(KeyCode.S))
    {
        TakeScreenshot();
    }
}

/// <summary>
/// スクリーンショットを撮影する
/// </summary>
public void TakeScreenshot()
{
    Camera.main.AddCommandBuffer(_cameraEvent, _commandBuffer);

    StartCoroutine(WaitCapture());
}

/// <summary>
/// コマンドバッファの処理を待つ
/// </summary>
private IEnumerator WaitCapture()
{
    yield return new WaitForEndOfFrame();

    BlendGUI();

    Camera.main.RemoveCommandBuffer(_cameraEvent, _commandBuffer);
}

スクリーンショットを撮影したいタイミングでコマンドバッファをカメラに追加し、そのフレームの終わりまで待機した上でコマンドバッファを取り除いています。

ここで取り除いているのは、取り除かないと以後常にコマンドバッファが呼ばれてしまうため一度だけシーンをコピーしたら呼び出されないようにしています。

ここまでで、GUIを除く部分がRenderTextureに描かれている状態となります。
あとはGUI部分を適切に設定して描画してやれば目的の絵を得ることができます。

描画対象のGUIだけを上乗せする

最後は、必要なGUI部分を描くことができれば目的達成です。
GUI部分を描画するには以下のようにします。

/// <summary>
/// GUI要素をブレンドする
/// </summary>
private void BlendGUI()
{
    _guiCamera.targetTexture = _buf;

    int tmp = _guiCamera.cullingMask;
    _guiCamera.cullingMask = _captureTargetLayer;

    _guiCamera.Render();

    _guiCamera.cullingMask = tmp;

    _guiCamera.targetTexture = null;
}

上記でやっていることは、GUI用カメラのtargetTextureに、コマンドバッファによってシーンがコピーされたバッファを適用し、また取り除きたいレイヤーを設定した上でGUI用カメラのレンダリングを行っています。

cullingMaskが、レンダリング対象となるかどうかのマスク情報を設定するレイヤーマスクです。
ここに、今回はMenuUIレイヤーを持つ要素を外してレンダリング_guiCamera.Render())しています。

最後にtargetTexturecullingMaskを元に戻して終わりです。

これで、最終的に得たい、不要なGUI要素が除かれた状態でシーンをキャプチャすることができます。

コード全文

最後に、今回のサンプルのコード全文を掲載しておきます。
なお、撮影したスクショをファイルに保存する処理についてはGithubのプロジェクトを参照してください。
(今回の主題からははずれるので割愛します)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class SelectGUICapture : MonoBehaviour
{
    [SerializeField, Tooltip("GUIをレンダリングしているカメラ")]
    private Camera _guiCamera = null;

    [SerializeField, Tooltip("キャプチャするタイミング")]
    private CameraEvent _cameraEvent = CameraEvent.BeforeImageEffects;

    [SerializeField, Tooltip("合成時に無視されるUIのレイヤー")]
    private LayerMask _captureTargetLayer = -1;

    private Camera _mainCamera = null;
    private RenderTexture _buf = null;
    private CommandBuffer _commandBuffer = null;

    #region ### MonoBehaviour ###
    private void Awake()
    {
        CreateBuffer();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.S))
        {
            TakeScreenshot();
        }
    }

    /// <summary>
    /// 動作確認用にGizmoでテクスチャを表示する
    /// </summary>
    private void OnGUI()
    {
        if (_buf == null) return;
        GUI.DrawTexture(new Rect(5f, 5f, Screen.width * 0.5f, Screen.height * 0.5f), _buf);
    }
    #endregion ### MonoBehaviour ###

    /// <summary>
    /// バッファを生成する
    /// </summary>
    private void CreateBuffer()
    {
        _buf = new RenderTexture(Screen.width, Screen.height, 0);

        _commandBuffer = new CommandBuffer();
        _commandBuffer.name = "CaptureScene";
        _commandBuffer.Blit(BuiltinRenderTextureType.CurrentActive, _buf);
    }

    /// <summary>
    /// スクリーンショットを撮影する
    /// </summary>
    public void TakeScreenshot()
    {
        AddCommandBuffer();

        StartCoroutine(WaitCapture());
    }

    /// <summary>
    /// コマンドバッファの処理を待つ
    /// </summary>
    private IEnumerator WaitCapture()
    {
        yield return new WaitForEndOfFrame();

        BlendGUI();

        RemoveCommandBuffer();
    }

    /// <summary>
    /// メインカメラにコマンドバッファを追加する
    /// </summary>
    private void AddCommandBuffer()
    {
        if (_mainCamera == null)
        {
            _mainCamera = Camera.main;
        }

        _mainCamera.AddCommandBuffer(_cameraEvent, _commandBuffer);
    }

    /// <summary>
    /// メインカメラからコマンドバッファを削除する
    /// </summary>
    private void RemoveCommandBuffer()
    {
        if (_mainCamera == null)
        {
            return;
        }

        _mainCamera.RemoveCommandBuffer(_cameraEvent, _commandBuffer);
    }

    /// <summary>
    /// GUI要素をブレンドする
    /// </summary>
    private void BlendGUI()
    {
        _guiCamera.targetTexture = _buf;

        int tmp = _guiCamera.cullingMask;
        _guiCamera.cullingMask = _captureTargetLayer;

        _guiCamera.Render();

        _guiCamera.cullingMask = tmp;

        _guiCamera.targetTexture = null;
    }
} 

揚力を計算して滑空する

概要

今モックで作成しているVRコンテンツに、滑空の要素を入れたかったので揚力の計算について調べてみたのでそのメモです。

ちなみに適当に飛行機っぽい形状を作って適用した動画はこんな感じです↓

また、実装にあたって参考にさせてもらったのは以下の記事です。

64章:二次元翼の揚力と抗力

www.cfijapan.com

blog.goo.ne.jp

blog.goo.ne.jp

揚力の計算

揚力(Lift)は以下の数式から求めることができるようです。


L = C_L \frac{ρ}{2} q^2 A

D = C_D \frac{ρ}{2} q^2 A

ここで、 C_Lは無次元の係数で揚力係数、 C_Dは無次元の係数で抗力係数といいます。
また、数式の記号の意味は以下となります。

 ρは空気密度、 qは流速、 Aは翼の面積です。

それぞれの単位は以下のようになります。

 
- ρ ... kg/m^3  \\
- q ... m/s \\
- A ... m^2

流体の密度 ρですが、場所によって異なるようで、海面高度の大気中は大体[tex: 1.2250kg/m3]となるようです。
また、揚力係数、抗力係数は実験的に求められているようで、こちらの記事から画像を引用させていただくと以下のようなグラフになるようです。

力のかかる方向

揚力、抗力については上で書いた通りです。
この力のかかる方向を図示すると以下のようになります。

f:id:edo_m18:20190113152919j:plain

 \thetaが進行方向と流体(空気)との成す角度です。
そして Lが揚力(Lift)を、 Dが抗力(Drag)を、 mgが重力方向を表しています。

この図を見てもらうと分かりますが、揚力は進行方向に対して垂直、抗力は進行方向と平行(ただし逆向き)となります。

揚力のかかる方向を求める

色々な記事を見ても、揚力のかかる方向が垂直であることは示されているものの、「じゃあ実際プログラムするときに垂直方向ってどっちさ?」となり、色々考えた結果、以下のようにして求めるようにしたところ、それっぽく動いているのでこれを採用しています。

実際は流体力学などから、流体、そして渦の生成、気圧など様々な条件から方向が定まり、実際に揚力のかかる方向を計算するのだと思いますが、今回はあくまで「それっぽく」動くことが目的だったので単純に垂直な方向を求めています。

考え方としては以下のような感じです。

  1. 進行方向との垂直方向、つまり外積の向く向きを採用する
  2. しかし「飛行機が逆さま」になっていることも考慮すると、もうひとつの軸の取り方で結果が変わってしまう
  3. 平行に近い角度で進行している場合は、翼の「右」方向と進行方向との外積が「上」として都合が良さそう(進行方向との外積なので必ず進行方向に対して垂直となる)
  4. 反対の考慮は?
  5. 翼の「上」ベクトル(leftWing.transform.up)と進行方向との外積方向をまず求める
  6. 通常飛行(翼の前ベクトルと平行的な方向)の場合においては、翼の右ベクトル(leftWing.transform.rightと(5)のベクトルは概ね同じ方向を向く
  7. 翼に対して後ろ方向に進行している場合は右ベクトルとは反対方向を向くようになる
  8. (5)のベクトルと右ベクトルとの「内積」を計算し、マイナスとなる場合は逆と判断
  9. -leftWing.transform.rightを計算のベクトルに用いる

というようなフローで解決するようにしてみました。

実際に、翼の上ベクトルと進行方向との外積で求めたベクトルの動きを動画にしてみると以下のような感じになります。


進行方向の判別(揚力の適用向き用)

黄色のバーが進行方向を表し、紫色のバーが外積によって求めたベクトルです。
動画を見てもらうと分かりますが、進行方向が翼に対して前方に向いている場合は、翼の右ベクトルとの角度は鋭角になっており、進行方向が逆転した場合に鈍角になることが分かります。

このことから、翼の右ベクトルと求めたベクトルとの内積を取り、結果がマイナス(=鈍角)なら進行方向が想定と逆、とみなすことができるわけです。

コードにすると以下のような感じです。

Vector3 dir = v.normalized;

// 進行方向に対しての「右」を識別する
Vector3 lcheckDir = Vector3.Cross(_leftWing.up, dir);

float lcheckd = Vector3.Dot(_leftWing.right, lcheckDir);

Vector3 lright = (lcheckd < 0) ? -_leftWing.right : _leftWing.right;

// ldirが揚力の働く方向(Lift Direction)
Vector3 ldir = Vector3.Cross(dir, lright);

コード全文

そんなに長くないのでコード全文を載せておこうと思います。

ちなみに「揚力係数」と「抗力係数」についてはアニメーションカーブを用いて、概ね以下のような感じで設定してそこから値を取得するようにしています。

揚力係数のアニメーションカーブ(グラフ)

f:id:edo_m18:20190113172042p:plain

抗力係数のアニメーションカーブ(グラフ)

f:id:edo_m18:20190113172125p:plain

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class LiftTest : MonoBehaviour
{
    [SerializeField]
    private Transform _body;
    
    [SerializeField]
    private Transform _leftWing;

    [SerializeField]
    private Transform _rightWing;

    [SerializeField]
    private AnimationCurve _liftCoeff;

    [SerializeField]
    private AnimationCurve _dragCoeff;

    [SerializeField]
    private ParticleSystem _lparticle;

    [SerializeField]
    private ParticleSystem _rparticle;

    [SerializeField]
    private float _pitchSpeed = 0.5f;

    [SerializeField]
    private float _rollSpeed = 0.5f;

    [SerializeField]
    private float _acc = 1000f;

    [SerializeField]
    private float _rho = 1.225f;

    [SerializeField]
    private float _area = 12f;

    [SerializeField]
    private float _initVelocity = 30f;

    [SerializeField]
    private bool _useLift = true;

    private Rigidbody _rigid;

    private void Start()
    {
        _rigid = GetComponent();
        _rigid.velocity = transform.forward * _initVelocity;

        _lparticle.Stop();
        _rparticle.Stop();
    }

    private void Update()
    {
        Control();
    }

    private void FixedUpdate()
    {
        if (_useLift)
        {
            CalcLift();
        }
    }

    private void Control()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            _lparticle.Play();
            _rparticle.Play();
        }

        if (Input.GetKeyUp(KeyCode.Space))
        {
            _lparticle.Stop();
            _rparticle.Stop();
        }

        if (Input.GetKey(KeyCode.Space))
        {
            _rigid.AddForce(_body.forward * _acc);
        }

        if (Input.GetKey(KeyCode.DownArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.right), -_pitchSpeed);
        }

        if (Input.GetKey(KeyCode.UpArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.right), _pitchSpeed);
        }

        if (Input.GetKey(KeyCode.LeftArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.forward), _rollSpeed);
        }

        if (Input.GetKey(KeyCode.RightArrow))
        {
            _body.Rotate(_body.worldToLocalMatrix.MultiplyVector(_body.forward), -_rollSpeed);
        }
    }

    private void CalcLift()
    {
        Vector3 lpos = _leftWing.position;
        Vector3 rpos = _rightWing.position;

        Vector3 lup = _leftWing.up;
        Vector3 rup = _rightWing.up;

        Vector3 v = _rigid.velocity;

        float m = v.magnitude;
        float velocitySqr = m * m;

        Vector3 dir = v.normalized;

        // 揚力、抵抗ともに使う係数の計算(ρ/2 * q^2 * A)
        // ρ ... 密度
        // q ... 速度
        // A ... 面積
        float k = _rho / 2f * _area * velocitySqr;

        Debug.DrawLine(_body.position, _body.position + dir, Color.black);

        float ldot = Vector3.Dot(lup, dir);
        float lrad = Mathf.Acos(ldot);

        float rdot = Vector3.Dot(rup, dir);
        float rrad = Mathf.Acos(rdot);

        float langle = (lrad * Mathf.Rad2Deg) - 90f;
        float rangle = (rrad * Mathf.Rad2Deg) - 90f;

        float lcl = _liftCoeff.Evaluate(langle);
        float rcl = _liftCoeff.Evaluate(rangle);

        // 単位: N = kg・m/s^2
        float ll = lcl * k;
        float rl = rcl * k;

        // 進行方向に対しての「右」を識別する
        Vector3 lcheckDir = Vector3.Cross(_leftWing.up, dir);
        Vector3 rcheckDir = Vector3.Cross(_rightWing.up, dir);

        float lcheckd = Vector3.Dot(_leftWing.right, lcheckDir);
        float rcheckd = Vector3.Dot(_rightWing.right, rcheckDir);

        Vector3 lright = (lcheckd < 0) ? -_leftWing.right : _leftWing.right;
        Vector3 rright = (rcheckd < 0) ? -_rightWing.right : _rightWing.right;

        Vector3 ldir = Vector3.Cross(dir, lright);
        Vector3 rdir = Vector3.Cross(dir, rright);

        Vector3 lv = ldir * ll;
        Vector3 rv = rdir * rl;

        Debug.DrawLine(_leftWing.position, _leftWing.position + lv, Color.cyan);
        Debug.DrawLine(_rightWing.position, _rightWing.position + rv, Color.cyan);

        float lcd = _dragCoeff.Evaluate(langle);
        float rcd = _dragCoeff.Evaluate(rangle);

        float ldrag = lcd * k;
        float rdrag = rcd * k;

        Vector3 drag = -dir * (ldrag + rdrag);

        Debug.DrawLine(_body.position, _body.position + drag);

        Vector3 force = (lv + rv + drag) * Time.deltaTime;;

        _rigid.AddForce(force);
    }
}

UnityのCamera#ScreenToWorldPointを自前で計算してみる

概要

Screen Spaceの座標をシーンのワールド座標に変換して、その位置になにかする、というのはよくある処理だと思います。
(例えば画面をタップしたらその先にレイを飛ばしてなにかする、とか)

そのあたりは当然、Unityは準備してくれているのだけど、中でなにをしているか知らずに使うのは(毎度のことながら)気持ち悪いので色々やってみたメモです。

Camera#ScreenToWorldPointで簡単に変換

まず、Unityの機能を使う場合であればCamera#ScreenToWorldPointを利用することで簡単に座標を求めることができます。

こんな感じ↓

Camera cam = Camera.main;

Vector2 mousePos = new Vector2();
mousePos.x = Input.mousePosition.x;
mousePos.y = Input.mousePosition.y;

Vector3 point = cam.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, cam.nearClipPlane));

こうすると、Z値にnearClipPlaneを渡しているので、つまりはクリックした位置の表示されるぎりぎりのところの座標を得ることができます。

実際に実行するとこんな感じで、クリックした位置+カメラのnearClipPlane位置にSphereが生成されているのが分かるかと思います↓
f:id:edo_m18:20190106110308g:plain

今回はこれと同じ値を自前で算出するのを目的としています。

座標変換の過程を知る

さて、今回の話は主に座標変換の話となります。3D空間に配置されたオブジェクトを、いくつもの座標変換行列によって変換し、最終的にスクリーン座標系に移動させるのが一連の座標変換です。まずはこれを理解しないことには始まりません。

どういう座標変換が必要かは以下のようになります。

  1. モデル座標変換
  2. ビュー座標変換
  3. プロジェクション座標変換
  4. 正規化デバイス系座標変換
  5. スクリーン座標変換

ひとつのオブジェクトを表示するために、実に5回もの座標変換を行っているわけなんですね。そして大半の変換には「行列」を用います。

座標変換のための行列の掛け算

座標変換には行列を使うと書きました。各座標変換にはそれぞれ行列があり、それをベクトルに掛け算していくことで指定した座標へ変換していくことになります。

具体的には以下のような感じです。

 
\vec{V_s} = \vec{v} \cdot M \cdot V \cdot P \cdot V_p
  •  \vec{V_s} ... スクリーン座標での位置(ベクトル)
  •  \vec{v} ... ローカルの位置ベクトル
  •  M ... モデル座標変換行列
  •  V ... ビュー座標変換行列
  •  P ... プロジェクション座標変換行列
  •  V_p ... ビューポート座標変換行列

正規化デバイス座標系については、プロジェクション座標変換後(同次座標系)のベクトルのw要素で除算することで得られる変換のため、行列は存在しません。

こうしてはるばる変換の旅をしたローカルの位置ベクトルが最終的に画面の特定の位置に表示される、というわけです。

座標変換を「さかのぼる」には逆行列を使う

そして座標変換されたベクトルに対して、逆順にそれぞれの座標変換で用いた行列の「逆行列」を掛けることで変換をもとに戻すことができます。

行列の使い方、座標変換の細かい挙動などについてはマルペケさんの以下の記事がとても参考になります。特に「③ 検証3:あるモデルの世界へ連れ込む」の節が座標変換について詳しく書かれています。

その60 変換行列A×BとB×Aの違いを知ろう

上で説明したスクリーン座標まで旅をしたベクトル\(\vec{v}\)を、再びワールド空間に戻すには以下のようにします。

 
\vec{V_s} \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1} = \vec{v} \cdot M \cdot V \cdot P \cdot V_p \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1}

※ ちなみにUnityの行列では「列オーダー」のメモリレイアウトを採用しているため、行列の掛ける順番が左右反転することに注意してください。

ちなみに行列のオーダーや掛ける順番などについては前回の記事でまとめたのでそちらをご覧ください。

edom18.hateblo.jp

とある行列に、その逆行列を掛けると単位行列となります。つまり、上の計算はそれぞれの逆行列を順番に掛けているのですべてが単位行列\(E\)となり、結果的にもとのベクトルだけが残る、というわけです。

 
\begin{eqnarray}
\vec{V_s} \cdot V_p^{-1} \cdot P^{-1} \cdot V^{-1} &=& \vec{v} \cdot M \cdot V \cdot P \cdot E \cdot P^{-1} \cdot V^{-1}  \\\
&=& \vec{v} \cdot M \cdot V  \cdot E \cdot  V^{-1}  \\\
&=& \vec{v} \cdot M  \cdot E \\\
&=& \vec{v} \cdot M
\end{eqnarray}

最後、 M行列が残っていますが、(今回は)ワールド座標に変換するのが目的なのでワールド変換より前には戻らないためです。( Mを戻してしまうと、該当オブジェクトのローカル空間にまで戻ってしまうためです)

行列自体の話題ではないので、これ以上の細かい話は割愛します。

ビューポート行列はグラフィクスAPIで異なる

上記の V_pはビューポート行列を表しています。そしてこのビューポート行列はグラフィクスAPIによって異なります。 詳細については以下の記事が参考になりました。

blog.natade.net

今回はMacで試していたのでOpenGLでの行列でテストしました。具体的には以下の形の行列です。

 
\begin{vmatrix}
\frac{Screen Width}{2} &0 &0 &0 \\\
0 &\frac{Screen Height}{2} &0 &0 \\\
0 &0 &\frac{MaxZ - MinZ}{2} &0 \\\
Offset_x + \frac{Screen Width}{2} &Offset_y + \frac{Screen Height}{2} &\frac{MaxZ + MinZ}{2} &1
\end{vmatrix}

ちなみにDirextXでは以下のようになるようです。

 
\begin{vmatrix}
\frac{Screen Width}{2} &0 &0 &0 \\\
0 &-\frac{Screen Height}{2} &0 &0 \\\
0 &0 &MaxZ - MinZ &0 \\\
Offset_x + \frac{Screen Width}{2} &Offset_y + \frac{Screen Height}{2} &MinZ &1
\end{vmatrix}

このあたりは、正規化デバイス座標系でのZ値の取る値が違う点によるものだと思います。

C#での実装

さて、OpenGL版のものをC#で表すと以下のようになります。

Camera cam = Camera.main;

Matrix4x4 viewportInv = Matrix4x4.identity;
viewportInv.m00 = Screen.width / 2f;
viewportInv.m03 = Screen.width / 2f;
viewportInv.m11 = Screen.height / 2f;
viewportInv.m13 = Screen.height / 2f;
viewportInv.m22 = (cam.farClipPlane - cam.nearClipPlane) / 2f;
viewportInv.m23 = (cam.farClipPlane + cam.nearClipPlane) / 2f;

そして、生成した行列の逆行列を求めて最終的な結果を得ます。

実際に同じ値を算出したコードは以下のようになります。

// pointはスクリーンの位置
private Vector3 ApplyProjectionMatrix(Vector2 point)
{
    if (_cam == null)
    {
        _cam = Camera.main;
    }

    Matrix4x4 viewportInv = Matrix4x4.identity;
    viewportInv.m00 = viewportInv.m03 = Screen.width / 2f;
    viewportInv.m11 = Screen.height / 2f;
    viewportInv.m13 = Screen.height / 2f;
    viewportInv.m22 = (_cam.farClipPlane - _cam.nearClipPlane) / 2f;
    viewportInv.m23 = (_cam.farClipPlane + _cam.nearClipPlane) / 2f;
    viewportInv = viewportInv.inverse;

    Matrix4x4 viewMatInv = _cam.worldToCameraMatrix.inverse;
    Matrix4x4 projMatInv = _cam.projectionMatrix.inverse;
    Matrix4x4 matrix = viewMatInv * projMatInv * viewportInv;

    Vector3 pos = new Vector3(point.x, point.y, _cam.nearClipPlane);

    float x = pos.x * matrix.m00 + pos.y * matrix.m01 + pos.z * matrix.m02 + matrix.m03;
    float y = pos.x * matrix.m10 + pos.y * matrix.m11 + pos.z * matrix.m12 + matrix.m13;
    float z = pos.x * matrix.m20 + pos.y * matrix.m21 + pos.z * matrix.m22 + matrix.m23;
    float w = pos.x * matrix.m30 + pos.y * matrix.m31 + pos.z * matrix.m32 + matrix.m33;

    x /= w;
    y /= w;
    z /= w;

    return new Vector3(x, y, z);
}

ここで行っている計算は、ビューポート行列を生成したあと、ビューポート行列、ビュー行列、プロジェクション行列の逆行列を求め、それを合算し、最後にスクリーン座標位置のベクトルにその行列を適用しているところです。そして後半のx, y, z, wは同次座標の計算を行っている部分です。通常のプロジェクション座標変換ではこのwで除算することで遠くものは小さく、近くのものは大きく、というパースが効いた自然な形に変換するための処理です。

そして今回は逆行列を用いているため、その逆変換、つまり「小さいものも大きいものも通常のサイズに直す」という処理になります。あとは算出された値をオブジェクトの位置ベクトルに設定してやれば、冒頭の動画のように、スクリーンをタップした位置にオブジェクトが移動します。

Unityの行列の扱いとベクトルのオーダー周りについてまとめておく

概要

自分で直に行列変換周りの処理を書くときに、掛ける順番やオーダー周りについていつも混乱するのでまとめておきます。

座標系の向き

これは、行列のオーダーには直接関係はありませんがよく混乱するので書いておきます。
Unityでは「左手系」の座標系を採用しています。

エディタ右上の軸情報を見ると、右が正、上が正、そして奥が正となる軸を取っていることが分かります。

f:id:edo_m18:20190103193700p:plain

ちなみに左手系とは、左手の親指をX軸プラス、人差し指をY軸プラスにしたときに、それぞれの指に直行するように中指を曲げたときに指が向く方向がプラスとなる座標系です。

行列の配列要素の並び(メモリレイアウト)

さて、本題の行列に関して。
なぜ、列オーダー、行オーダーという名称があるのでしょうか。

その答えは行列の表現にあります。

数学的な「行列」はm x n行列となり、プログラムでは2次元配列として表すのが直感的です。
しかし3Dグラフィクスで扱う行列は通常、2次元配列ではなく1次元配列で表現されます。

そのため、行列の各要素をどういう順番で1次元配列として表現するか、が2通りあることが分かると思います。
つまり「列オーダー」と「行オーダー」です。

図にすると以下のような感じです。
行列の各要素が配列の添字としてはいくつなのか、を示しています。

f:id:edo_m18:20190103230507p:plain

実際に計算を行ってみると分かりますが、どちらの計算も必ず以下のように行列要素とベクトル要素が掛けられるようになっています。

// xだけ計算してみる
// 列オーダー版
x' = mat[0] * x + mat[4] * y + mat[8] * z + mat[12] * 1

// 行オーダー版
x' = x * mat[0] + y * mat[4] + z * mat[8] + 1 * mat[12]

基本的には「行列の計算」という数学上のルールに変化があるわけではないので、単純に配列のメモリレイアウトに依存して掛ける方向が変わる、ということですね。

列を主とするか、行を主とするかで添字が異なっているのが分かるかと思います。
これをしっかり把握しておかないと、行列の掛ける順番を間違えて想定していた結果にならない、ということが往々にしてあるわけです。

Unityでの掛ける順番は列オーダー

列オーダー、行オーダーを説明したところで、Unityではどういうふうに計算するのでしょうか。
まずはそれぞれのプラットフォーム(API)での規則を見てみます。

APIごとの規則

ちなみに既存のグラフィクスAPIの規則は以下となります。

API 座標系の向き オーダー
OpenGL 右手系 列オーダー
DirectX 左手系 行オーダー
Unity 左手系 列オーダー

こうして並べてみると、Unityは「OpenGL」でさらに「左手系」の規則を採用、とどのAPIとも違う規則になっているのが分かりますね。

UnityのC#は「列オーダー」。でもシェーダは「行オーダー」

Unityのドキュメントを見てみると以下のように記載があります。

Matrices in unity are column major.

このことから、C#(CPU)の世界では「列オーダー」であることが分かります。

そして通常、行列はシェーダで利用するケースが多いでしょう。
マルチプラットフォームをサポートしているUnityのシェーダは「Cg」をベースとしたシェーダを記述するのが一般的です。
どうやらCgでは「行オーダー」であることが基本のようです。

そのためか、シェーダでは行優先としてメモリレイアウトがされるようです。
以下の記事で言及されていました。

tech.drecom.co.jp

Unityのシェーダーの世界は行優先であることが分かりました。

上記記事では、C#側で行と列それぞれに値を入れてシェーダ側でどう扱われるか、で判断したようです。

ただし、メモリレイアウトは切り替わっても転置されるわけではないのでご注意を。基本的に計算は列ベクトル前提で行います。

でも計算はC#、シェーダどちらも「列オーダー」

ということなので、基本的にはUnityで行列を扱っている以上は「列オーダー」で考えておいて大丈夫なようです。
実際、よく目にする頂点シェーダの記述も以下のようになっていて、列ベクトルを右側に置いて掛けているのが分かりますね。

// 列ベクトルなので「右側」にベクトルが置かれて計算されている
mul(UNITY_MATRIX_MVP, v.vertex); 

行列の掛ける順番の意味

最後に、行列の掛ける順番について。
3Dグラフィクスでは行列が頻繁に使われ、特に、頂点シェーダからフラグメントシェーダに値を渡す際、一般的な合成行列を掛けて渡します。

具体的には以下の行列です。

  1. モデル座標変換行列
  2. ビュー座標変換行列
  3. プロジェクション変換行列

そしてそれぞれの行列をひとつに「合算」させたものをシェーダに送り、それを各頂点に掛け算してフラグメントシェーダステージに渡す、というのが基本的な動作です。
そしてこれらの行列の頭文字を取ってM(odel) x V(iew) x P(rojection)でMVP行列、なんて呼ばれたりします。

しかしこれ、行オーダーで計算を行うAPI規則に基づくものです。(つまりDirectX
Unityでは、上で示したように「列オーダー」となります。

そのため掛ける順番がMVPではなくPVMとなる点に注意が必要です。
コード例で示すと以下のようになります。

Matrix4x4 m = /* モデル座標変換行列生成 */;
Matrix4x4 v = anyCamera.worldToCameraMatrix;
Matrix4x4 p = GL.GetGPUProjectionMatrix(anyCamera.projectionMatrix);

// 掛ける順番が逆
Matrix4x4 mvp = p * v * m;

GL.GetGPUProjectionMatrixで変換を行う

少し余談となりますが、上記例にしれっと出てきたGL.GetGPUProjectionMatrix
これは、プラットフォーム依存となる「正規化デバイス座標系」での、near / far表現を適切に変換するための処理をしてくれるヘルパー関数です。

こちらの処理については以下の記事がとても詳しく検証、解説してくれているのでそちらを参考にするのがいいでしょう。

【Unity】【数学】Unityでのビュー&プロジェクション行列とプラットフォームの関係 – 株式会社ロジカルビート

また、数式を用いて解説を行ってくれているこちらの記事も参考に。

tech.drecom.co.jp

ざっくりとだけ解説しておくと、「正規化デバイス座標系」というのは、プロジェクション変換行列を適用し、均一な矩形領域(ビューボリューム)へと変換されたあとの座標系のことです。

そしてこの座標系の取るZの値が、APIによって異なります。
より具体的に言うと0~1となるのか、-1~1となるのか、という違いがあります。

これを適切に設定しないと、シェーダに値を送った際に意図した結果にならくなってしまいます。

なお、これを考慮した、自分で生成した行列をシェーダに送って、標準のMVP行列と同じような動作をさせるためには以下のように計算します。

// シーンビューでも適切に動くように`OnWillRenderObject`を利用して、Cameraの情報をそれぞれ取得
private void OnWillRenderObject()
{
    if (Camera.current == null)
    {
        return;
    }

    Camera cam = Camera.current;

    // スケールの行列
    Matrix4x4 sMat = Matrix4x4.Scale(_scale);
    // 平行移動行列
    Matrix4x4 tMat = Matrix4x4.Translate(_translate);
    // 上記2行列を「合成」し、さらにMVP行列になるように計算
    Matrix4x4 matrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, true) * cam.worldToCameraMatrix * tMat * sMat;

    // シェーダに送る
    _ren.material.SetMatrix("_Matrix", matrix);
}

シェーダで凝ったことをやろうとしたり、CPU側で行列計算しそれをシェーダに送る、みたいな処理が発生した際に混乱しがちなのでまとめてみました。
まとめる際に色々調べて分かったのは「複雑極まりない」ということでしょうかw

どれかひとつのプラットフォーム(API)に絞るだけならこうはならないのかもしれませんが、マルチプラットフォーム対応の弊害でしょう。
このあたりはしっかりと基礎を身につけておかないといつまでも混乱するのでしっかりと身につけておきたいところです。

Unityでガウシアンブラーを実装する

概要

よく使う&表現力の高いぼかし処理。

以前にもぼかしを利用したコンテンツを作成したり、記事を書いたりしていましたがちゃんとぼかしだけにフォーカスしたことはなかったので改めて書きたいと思います。

今回のサンプルを動かしたデモ↓

ちなみに以前、ぼかし関連の内容が含まれていたコンテンツ/記事はこんな感じ↓

qiita.com

qiita.com


なお、今回の記事はこちらの記事を参考にさせていただきました。

light11.hatenadiary.com

あとこちらも。

wgld.org

また、今回のサンプルは以下にアップしてあります。

github.com

ガウス関数とは

Wikipediaから引用させてもらうと以下のように説明されています。

ガウス関数ガウスかんすう、英: Gaussian function)は、


a\ exp\biggl(- \frac{(x - b)^2}{2c^2}\biggr)

の形の初等関数である。なお、 2c^2 のかわりに  c^2 とするなど、表し方にはいくつかの変種がある。

ガウシアン関数、あるいは単にガウシアンとも呼ばれる。

また、特徴として以下のようにも説明されています。

特徴

正規分布関数(正規分布確率密度関数)として知られる

 \frac{1}{\sqrt{2 \pi  σ}} exp\biggl(- \frac{(x - μ)^2}{2 σ^2}\biggr)

は、ガウス関数の1種である。

ということで、今回話題にするのはこちらの関数です。


y = \frac{1}{\sqrt{2 \pi  σ}} exp\biggl(- \frac{(x - μ)^2}{2 σ^2}\biggr)

ここでの大事な点としては「正規分布関数」ということでしょう。

これもWikipediaから引用させてもらうと、

平均値の付近に集積するようなデータの分布を表した連続的な変数に関する確率分布

と書かれています。
ガウス関数では上記関数の μの値を0にすると、 x = 0を中心とした釣り鐘型のグラフを描きます。
これが、ぼかしを掛ける重み付けとして重宝する点です。
そしてさらに、 σの値を調整することでグラフの形を調整することができるため、ぼかし具合もパラメータのみで設定できるのが利用される理由でしょう。

ちなみにこの関数をdesmosでグラフ化すると以下のようになります。

www.desmos.com

上記グラフは σの値を変化させた結果です。
尖っていたり、平になっていたり、と形が大きく変化しているのが分かるかと思います。

そして今回利用する関数は、定数部分を1にし、中央がx = 0となるよう、 μの値を0とした以下の関数を用います。


y = exp\biggl(- \frac{(x)^2}{2 σ^2}\biggr)

これをグラフにすると以下の形になります。

www.desmos.com

すべてのグラフが1を最大値として色々な形に変化しているのが分かるかと思います。
このグラフの yの値を重みとし、さらに xの値をサンプリング点からの距離として用いることでブラーを実現します。

ブラーの仕組み

さて、ガウス関数を使ってブラーさせることが分かりましたが、これをどう利用するのか。
まずは以下の画像を見てください。

重みの計算

上がガウス関数のグラフの様子、下がテクスチャからテクセルをサンプリングする様子です。
(1)の部分が普通にフェッチするテクスチャの位置を表しています。

そこから(2), (3), (4)が追加でサンプリングするオフセット位置です。
そしてその数字とグラフ上に書かれた数字がそれぞれ、重みとして紐付いた状態を表しています。

つまり、(1)の場合は重み1、(2)の場合はおよそ0.58、という具合です。
中心から離れるにつれて徐々に重みが減っていっているのが分かるかと思います。

ガウス関数を思い出してみると exp\biggl(- \frac{(x - μ)^2}{2 σ^2}\biggr)となっているので、大まかにはexpの値に近いカーブを描くことなるわけです。
(そして引数が x^2を用いているためマイナスが取れて左右対称になっている、というわけですね)

ただし、重みは通常「合計して1になる」必要があるので、最終的な重みは全重みの合計でそれぞれの値を割った値として正規化して利用します。

上下左右は別々に計算できる

概ねブラーの処理についてはイメージできたと思います。
ガウシアンブラーを利用するもうひとつの利点として以下のような性質があります。

wgld.orgの記事から引用させていただくと以下のように記載されています。

さらに、ガウス関数を三次元で用いる場合には x 方向と y 方向を切り離して処理することができます。実はこれが非常に重要です。

どういうことかと言うと、仮に上記のように「切り離して処理することができない」とすると、1テクセルの計算に対しては上の図で言うと、数字の書かれていないテクセルに関してもフェッチして処理しないとなりません。
つまり7 x 7 = 49回のフェッチが必要となります。

しかしこれを分離して考えることができる、という性質から「横方向に7回」、「縦方向に7回」というふうに分けて処理することができることを意味します。
結果、7 x 7 = 49だったフェッチ回数が7 + 7 = 14という回数に劇的に少なくなるわけです。
(そしてこれは、対象とするテクセル数が増えれば増えるほど顕著に差が出てきます)

実際にブラーがかかっていく様子を、Frame Debuggerで出力された画像を見てみると以下のように3回のパスを経て生成されています。

まず、テクスチャをコピーします。(またブラーの性質上、対象画像が縮小されてもあまり問題ないため半分のスケールにしてからコピーしています)

そしてまず、横方向にブラーをかけます↓

さらに横方向ブラーがかかった画像に対して縦にブラーをかけます↓

最終的にしっかりとブラーがかかっているのが分かるかと思います。

シェーダを実装する

仕組みが分かったところでシェーダの実装です。
あまり長いコードではないので全文載せます。

Shader "Custom/BlurEffect"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            ZTest Off
            ZWrite Off
            Cull Back

            CGPROGRAM
            #pragma fragmentoption ARB_precision_hint_fastest
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                half2 uv : TEXCOORD0;
            };

            sampler2D _MainTex;

            half4 _Offsets;

            static const int samplingCount = 10;
            half _Weights[samplingCount];
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;

                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 0;

                [unroll]
                for (int j = samplingCount - 1; j > 0; j--)
                {
                    col += tex2D(_MainTex, i.uv - (_Offsets.xy * j)) * _Weights[j];
                }

                [unroll]
                for (int j = 0; j < samplingCount; j++)
                {
                    col += tex2D(_MainTex, i.uv + (_Offsets.xy * j)) * _Weights[j];
                }

                return col;
            }
            ENDCG
        }
    }
}

一番重要なのはフラグメントシェーダでしょう。
samplingCount分だけfor文でループしているのが分かるかと思います。
ふたつループがあるのは、中心から左右(あるいは上下)にテクセルをフェッチするためそれぞれ2回に分けて書いているだけです。

必要となるオフセット位置についてはC#側から設定して計算しています。
また同様に、重みに関してもC#側から渡しています。

理由としては、パラメータが変化しなければ重みに変化がないためアップデートが必要なときだけ計算を行っているためです。

次に、C#の実装を見てみます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

namespace Sample
{
    public class GaussianBlur : MonoBehaviour
    {
        [SerializeField]
        private Texture _texture;

        [SerializeField]
        private Shader _shader;

        [SerializeField, Range(1f, 10f)]
        private float _offset = 1f;

        [SerializeField, Range(10f, 1000f)]
        private float _blur = 100f;

        private Material _material;

        private Renderer _renderer;

        // Apply sevral blur effect so use as double buffers.
        private RenderTexture _rt1;
        private RenderTexture _rt2;

        private float[] _weights = new float[10];
        private bool _isInitialized = false;

        #region ### MonoBehaviour ###
        private void Awake()
        {
            Initialize();
        }

        private void OnValidate()
        {
            if (!Application.isPlaying)
            {
                return;
            }

            UpdateWeights();

            Blur();
        }
        #endregion ### MonoBehaviour ###

        /// <summary>
        /// Initialize (setup)
        /// </summary>
        private void Initialize()
        {
            if (_isInitialized)
            {
                return;
            }

            _material = new Material(_shader);
            _material.hideFlags = HideFlags.HideAndDontSave;

            // Down scale.
            _rt1 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);
            _rt2 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);

            _renderer = GetComponent<Renderer>();

            UpdateWeights();

            _isInitialized = true;
        }

        /// <summary>
        /// Do blur to the texture.
        /// </summary>
        public void Blur()
        {
            if (!_isInitialized)
            {
                Initialize();
            }

            Graphics.Blit(_texture, _rt1);

            _material.SetFloatArray("_Weights", _weights);

            float x = _offset / _rt1.width;
            float y = _offset / _rt1.height;

            // for horizontal blur.
            _material.SetVector("_Offsets", new Vector4(x, 0, 0, 0));

            Graphics.Blit(_rt1, _rt2, _material);

            // for vertical blur.
            _material.SetVector("_Offsets", new Vector4(0, y, 0, 0));

            Graphics.Blit(_rt2, _rt1, _material);

            _renderer.material.mainTexture = _rt1;
        }

        /// <summary>
        /// Update waiths by gaussian function.
        /// </summary>
        private void UpdateWeights()
        {
            float total = 0;
            float d = _blur * _blur * 0.001f;

            for (int i = 0; i < _weights.Length; i++)
            {
                // Offset position per x.
                float x = i * 2f;
                float w = Mathf.Exp(-0.5f * (x * x) / d);
                _weights[i] = w;

                if (i > 0)
                {
                    w *= 2.0f;
                }

                total += w;
            }

            for (int i = 0; i < _weights.Length; i++)
            {
                _weights[i] /= total;
            }
        }
    }
}

C#のコードのほうがやや長いですね。
ただ、フィールドの定義以外はそこまで処理は多くありません。

まず、今回の主題である「ガウス関数」での重み付けを更新しているのがUpdateWeightsメソッドです。

private void UpdateWeights()
{
    float total = 0;
    float d = _blur * _blur * 0.001f;

    for (int i = 0; i < _weights.Length; i++)
    {
        // Offset position per x.
        float x = i * 2f;
        float w = Mathf.Exp(-0.5f * (x * x) / d);
        _weights[i] = w;

        if (i > 0)
        {
            w *= 2.0f;
        }

        total += w;
    }

    for (int i = 0; i < _weights.Length; i++)
    {
        _weights[i] /= total;
    }
}

_blurはブラーの強さの係数です。
続くループ処理では各テクセルの重みを計算しています。

xは中心からどれくらい離れているか、を示す値です。まさに関数のxと同義ですね。
ただ、2倍にしているのは入力であるxの値を若干オフセットさせています。(オフセットは値が大きめにばらけるようにしているだけなので、なくても大丈夫です)

そして最後に、求めたそれぞれの重みを、重み全体の合計で割ることで正規化しています。

ブラーのためのダブルバッファ

次に、バッファの準備です。
上で書いたように、縦横それぞれのブラーを適用するためふたつのバッファを用意して処理を行います。
そのため、以下のようにふたつのRenderTexutreを用意します。(と同時に、対象となるテクスチャの半分のサイズにしてダウンスケールしています)

また、ブラーに利用するマテリアルを生成しておきます。

_material = new Material(_shader);
_material.hideFlags = HideFlags.HideAndDontSave;

// Down scale.
_rt1 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);
_rt2 = new RenderTexture(_texture.width / 2, _texture.height / 2, 0, RenderTextureFormat.ARGB32);

そして実際にブラー処理を行っているのが以下の箇所です。

public void Blur()
{
    Graphics.Blit(_texture, _rt1);

    _material.SetFloatArray("_Weights", _weights);

    float x = _offset / _rt1.width;
    float y = _offset / _rt1.height;

    // for horizontal blur.
    _material.SetVector("_Offsets", new Vector4(x, 0, 0, 0));

    Graphics.Blit(_rt1, _rt2, _material);

    // for vertical blur.
    _material.SetVector("_Offsets", new Vector4(0, y, 0, 0));

    Graphics.Blit(_rt2, _rt1, _material);

    _renderer.material.mainTexture = _rt1;
}

冒頭でテクスチャのコピーを行い、必要な重みを設定しています。
その後、必要なテクセルフェッチ位置のオフセットを計算し設定しています。

ちなみにここでのオフセットは、シェーダを見てもらうと分かりますが、フェッチするテクセルごとのオフセットです。
つまり、仮にここで2(相当。実際はUV値なので少数になる)を渡したとすると、通常のフェッチ位置を0として、2, 4, 6, 8...と、2テクセル隣のテクセルを、サンプリング回数分飛び飛びにフェッチしていくことになるわけです。

なのでオフセットの値を大きくするとボケをより大きくすることができます。
(ただし、飛び飛びでの処理になるので大きすぎるとブラーというより「ズレ」のような効果になります)

今回実装したブラー処理は以上です。

なお、この処理をCommand Bufferなどを用いて適切なタイミングでキャプチャした映像を用いると以下のような、擦りガラス的な表現を行うこともできます。

これは公式のサンプルで掲載されているものですが、こちらのサンプルではもう少しシンプルなブラー処理になっています。

あるいは場面転換とかで全体にブラーを適用してもいいかもしれませんね。

ECSでComponentSystemを自作する

カヤックと自分との関わり

この記事はex-KAYAC Advent Calendar 2018の22日目の記事です。
ということで、少しだけカヤックについての話を。

自分はカヤックへはWebのフロントエンドエンジニアとして入社。
その後は4年ほどWebのフロントエンドエンジニアとして働き、当時は「HTMLファイ部」という部署のリーダーをしていまいた。

そしてある日突然、「iOSやってくんない?」というオーダーを受け、Lobiというゲーム向けのSNSサービスのiOS版アプリの開発に携わることに。

それまでiOSを触ったことがなかったので1ヶ月間、ひたすらiOSのドキュメントや記事を読み漁り、それをひたすらQiitaにまとめる毎日でした。
なので自分の記事リストを見るとiOSObjective-C要素がだいぶ多めになっていますw

さらにその後、Oculus Rift DK1が登場しカヤック内でそれを持っている人がいたため体験することに。このときにVRに一気に惚れ込み、個人的にVR制作を始めました。

そしていよいよVRメインでやりたいと思った折に、コロプラにいた友人からVRエンジニアの募集を強化していることを聞いて今に至る、という感じです。

概要

ということで本題へ。

前回書いたECS入門の続編です。
前の記事ではECSを利用して画面にオブジェクトを描画するまでを書きました。

edom18.hateblo.jp

今回はもう少し踏み込んで、自分でカスタムのシステムを作って利用する流れを書きたいと思います。
このあたりがしっかりと身につけば、あとは応用で色々なシステムが作れるようになると思います。

今回の記事を書くにあたっては、のたぐすさんの以下の記事を参考にさせていただきました。

notargs.hateblo.jp

なお、今回の記事で書いているコードは前回の記事にも掲載したGithubに追記という形で公開しています。

github.com

大まかな考え方

まず必要な考え方は「データ指向設計」です。
データ指向については前回の記事で少しだけ書いたので詳細はそちらをご覧ください。

前回書いた部分を抜粋すると、

なぜデータに着目?

オブジェクト指向は人間がイメージしやすい形でプログラムを書いていくことができるので理解しやすい部類でしょう。(色々解釈などによる議論とかは見ますが)


しかし、コンピュータは「オブジェクト」という概念で物を見るのではなく、あくまでバイナリ表現されたデータを、それがなにかを考えずに黙々と処理していきます。


つまり、オブジェクトごとにまとめられたデータというのは、コンピュータからはまったく関係ない・・・どころか、作業効率の邪魔になり得ます。


実際の例で考えてみると、例えばこう考えてみてください。 あなたは自動販売機に飲み物を補充している店員だとします。


そして補充用の箱には「キリン」とか「サントリー」とか「明治」とかメーカーごとに商品が分けられて入れられています。


しかし、補充する側としては「今補充したい飲み物」だけを詰めてくれた箱があったほうが効率がいいですよね。 例えばコーヒーを補充しているのに、水やら別のものが箱に入っているとより分けて補充しなければなりません。


データ指向はまさにこの「補充したい飲み物だけ」を提供する形にデータを整形して処理するもの、と考えることができます。 こうすることによってCPUが効率よく処理することができるようになるというわけなんですね。

大雑把に言えば、CPUが効率よく処理するためにデータの構造を最適化する設計、という感じです。
そのためComponentSystemを理解する上で、この「データ指向」の考え方は重要となります。

「ComponentSystem」は2つある

ECSを利用するにあたって「ComponentSystem」がデータの処理を行います。
前回の記事では描画周りについて既存のシステムを利用してレンダリングしていました。

そして当然ですが、この「システム」は自作することができます。
必要な手順に沿ってクラスを実装することで自作したデータを利用した処理系を作ることができます。

ComponentSystemとJobComponentSystem

ふたつあるシステムとは「ComponentSystem」と「JobComponentSystem」のふたつです。

役割としてはどちらも同様ですが、JobComponentSystemはUnityが実装を進めている「JobSystem」を利用するところが異なります。

JobSystemについても後日記事を書きたいと思っていますが、ものすごくざっくりと言うと「Unityの持っているスレッドの空き時間に、ユーザのスクリプト実行を差し込める」というものです。

要は、Unityの処理の中で余らせている時間を有効に使おう、という趣旨の仕組みです。

JobSystemについてはテラシュールブログさんの以下の記事が詳しく書かれています。

tsubakit1.hateblo.jp

ComponentSystemを自作する勘所

さて、ふたつあるComponentSystemですが、作る際の勘所というか、どう理解していったらいいかは「データの構造の定義と利用」というイメージです。

以下から、実際のコードを例にしながら解説していきます。

データの構造を決める

冒頭で書いたように、ECSは「データ指向設計」になっているので、なにはなくとも「データの構造」を定義するところから始めます。

そして「定義した構造別にシステムを作っていく」イメージです。

データの構造のサンプルを見てみましょう。(データの構造自体はのたぐすさんの記事を参考にしたものになっています)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;

// 速度を表すデータ
public struct Velocity : IComponentData
{
    public float3 Value;

    public Velocity(float3 value)
    {
        Value = value;
    }
}

なんのことはない、速度データを表す構造体です。
IComponentDataを実装した構造体を作るとComponentSystemで利用することができるようになります。

速度なので、位置の更新が必要となります。
ということで、システムが要求するデータの構造定義を以下のように行います。

Entityとデータ構造を結びつける

システムの要求する構造の前に、Entityと構造の定義を先に解説します。

Entityとシステムを結びつけるのは「データの構造」です。
そのためにはEntityがどんなデータ構造を持っているかの定義が必要です。

定義は以下のように行います。

// ワールドに所属する「EntityManager」を作成する
EntityManager manager = World.Active.CreateManager<EntityManager>();

// ... 中略 ...

// 「データの構造(Archetype = データアーキテクチャ)」を定義する
EntityArchetype archetype = manager.CreateArchetype(
    typeof(Velocity),
    typeof(Position),
    typeof(MeshInstanceRenderer)
);

CreateArchetypeメソッドによってデータ構造(データアーキテクチャ)を定義しています。
Entityの生成にはこのEntityArchetypeオブジェクトを引数に取るため、そこで構造とEntityが結び付けられます。
生成は以下のようになります。

Entity entity = manager.CreateEntity(archetype);

これで、定義したデータ構造を持ったEntityがひとつ、ワールドに生成されました。

システムが要求する「グループ」を定義する

次にEntityとシステムを結びつける定義を行います。

なお、この結びつけはComponentSystemとJobComponentSystemで若干異なります。
が、基本的には「データ構造を結びつける」という考え方はどちらも同じです。

まずはComponentSystemのほうから見てみましょう。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
using Unity.Rendering;
using Unity.Collections;

// ComponentSystemが要求するデータ構造を定義する
public struct VelocityGroup
{
    // ここでは「Position」と「Velocity」の2つのデータを要求することを定義している
    public ComponentDataArray<Position> Position;
    public ComponentDataArray<Velocity> Velocity;

    // 速度更新のシステムでは利用しないが、
    // SharedComponentを要求する場合は`ReadOnly`属性をつける必要がある
    // 例)
    // [ReadOnly]
    // public SharedComponentDataArray<MeshInstanceRenderer> Renderer;

   // Entityの数を示すLengthフィールドを定義
    public readonly int Length;
}

まず、冒頭でVelocityGroupという構造体を定義しています。
その定義の内容はシステムが利用するために必要なComponentDataArrayです。

具体的にはPositionVelocityを要求する形になっています。
そして最後に、該当するデータ構造を持ったEntityの数を示すLengthフィールドを定義しています。

ただ、インターフェースもなにも実装していないしなにを持ってシステムと結びつけるのかと疑問に持たれる人もいるかもしれません。

紐づけに関しては、システムの定義側でInjectすることで解決しています。

システムとグループは「Inject」によって紐付ける

システムのコードを見てみましょう。

// ... VelocityGroupの定義

public class VelocitySystem : ComponentSystem
{
    [Inject]
    private VelocityGroup _velocityGroup;

    protected override void OnUpdate()
    {
        float deltaTime = Time.deltaTime;

        for (int i = 0; i < _velocityGroup.Length; i++)
        {
            Position pos = _velocityGroup.Position[i];
            pos.Value += _velocityGroup.Velocity[i].Value * deltaTime;
            _velocityGroup.Position[i] = pos;
        }
    }
}

意外にシンプルですね。
まずComponentSystemを継承し、必要なメソッドをオーバーライドします。

上で定義したグループとの紐づけですが、[Inject]アトリビュートを使って注入しています。

こうすることでシステムが要求するデータ構造が決まり、該当するEntityがワールドに存在している場合にシステムが起動され、処理が実行される、という仕組みになっています。


ComponentTypes

ここは余談になりますが、ComponentTypesというものが存在します。
これはなにかというと、Entityのデータ構造を認識するためのものです。

このあたりについては以下の記事がとても詳細にまとめてくれているので、興味がある方は見てみるといいでしょう。

qiita.com

そこから引用させてもらうと以下のように記載されています。

ComponentGroupはComponentTypes(ComponentDataの型に応じたint型の識別用タグ)の配列を持ち、初期化後変更されることはないです。

つまり、ComponentGroupというフィルターの役割をするものがあり、そこで利用されるのがComponentTypesということです。
そして初期化時にComponentDataを識別するタグの配列を持ち、以降変更されることがありません。
この情報を元に、該当するデータを持つEntityを決定している、というわけなんですね。


ワールドを生成し、システムを登録する

最後はWorldを生成し、その中で必要なシステムを登録します。

こちらもコードを先に見てみましょう。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Rendering;
using Unity.Transforms;
using Unity.Mathematics;

public class VelocityWorld : MonoBehaviour
{
    [SerializeField]
    private Mesh _mesh;

    [SerializeField]
    private Material _material;

    [SerializeField]
    private bool _useJobSystem = false;

    private void Start()
    {
        World.DisposeAllWorlds();

        World.Active = new World("VelocityWorld");

        EntityManager manager = World.Active.CreateManager<EntityManager>();
        World.Active.CreateManager<EndFrameTransformSystem>();
        World.Active.CreateManager<RenderingSystemBootstrap>();

        if (_useJobSystem)
        {
            World.Active.CreateManager<VelocityJobSystem>();
        }
        else
        {
            World.Active.CreateManager<VelocitySystem>();
        }

        EntityArchetype archetype = manager.CreateArchetype(
            typeof(Velocity),
            typeof(Position),
            typeof(MeshInstanceRenderer)
        );
        Entity entity = manager.CreateEntity(archetype);

        manager.SetSharedComponentData(entity, new MeshInstanceRenderer
        {
            mesh = _mesh,
            material = _material,
        });

        manager.SetComponentData(entity, new Velocity { Value = new float3(0, 1f, 0) });
        manager.SetComponentData(entity, new Position { Value = new float3(0, 0, 0) });

        manager.Instantiate(entity);

        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(World.Active);
    }
}

JobComponentSystem版も含まれているので分岐が入っていますが、そのシステムの登録部分以外はまったく同じなのが分かるかと思います。

冒頭でワールドの作成と、必要なシステムの登録を行っています。
そして最後の部分でEntityのデータ構造を定義し、その定義を元にEntityを生成しているわけです。

これを実行すると以下のように、少しずつ上に上昇するCubeが表示されます。

f:id:edo_m18:20181222132744g:plain

速度を上方向に設定しているのでそちらの方向に動いているわけですね。

JobComponentSystemの作成

さて、一歩戻ってJobComponentSystem版を見てみましょう。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Jobs;
using Unity.Burst;
using Unity.Transforms;

public class VelocityJobSystem : JobComponentSystem
{
    [BurstCompile]
    struct Job : IJobProcessComponentData<Velocity, Position>
    {
        readonly float _deltaTime;

        public Job(float deltaTime)
        {
            _deltaTime = deltaTime;
        }

        public void Execute(ref Velocity velocity, ref Position position)
        {
            position.Value += velocity.Value * _deltaTime;
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        Job job = new Job(Time.deltaTime);
        return job.Schedule(this, inputDeps);
    }
}

こちらもシンプルですね。
ComponentSystem版との違いを見ていきましょう。

IJobProcessComponentDataを定義に用いる

ComponentSystemではGroupの構造体を定義し、それを[Inject]アトリビュートによって構造とシステムとの紐づけを行っていました。
しかし、JobComponentSystemではIJobProcessComponentDataを利用して紐づけを行います。

IJobProcessComponentDataはインターフェースになっていて、これを実装したJobという単位を作成します。
JobSystemを利用するわけなので、Jobで処理をする形になります。

また注意点として、Jobは別スレッドで実行されるためTime.deltaTimeが利用できません。
そのため、Jobのコンストラクタで値を設定し、それを利用して位置の更新を行っています。

あとは、MeshInstanceRendererSystemなどのシステムがPositionデータなどを用いてレンダリングを行ってくれるためそれ以外の処理は必要ありません。
ECSは比較的データを少なくし、処理単位もミニマムにしていくのが適しているのでひとつひとつのシステムはとてもシンプルになりますね。

IJobProcessComponentDataの拡張メソッド

さて、唐突にJob構造体のScheduleというメソッドを実行していますが、これは拡張メソッドとして定義されいます。
そのため、IJobProcessComponentDataインターフェースを実装すると自動的に拡張されるようになっています。

定義を見てみると以下のように、いくつかの拡張メソッドが定義されているのが分かります。

namespace Unity.Entities
{
    public static class JobProcessComponentDataExtensions
    {
        public static ComponentGroup GetComponentGroupForIJobProcessComponentData(this ComponentSystemBase system, Type jobType);
        public static void PrepareComponentGroup<T>(this T jobData, ComponentSystemBase system) where T : struct, IBaseJobProcessComponentData;
        public static void Run<T>(this T jobData, ComponentSystemBase system) where T : struct, IBaseJobProcessComponentData;
        public static JobHandle Schedule<T>(this T jobData, ComponentSystemBase system, JobHandle dependsOn = default(JobHandle)) where T : struct, IBaseJobProcessComponentData;
        public static JobHandle ScheduleSingle<T>(this T jobData, ComponentSystemBase system, JobHandle dependsOn = default(JobHandle)) where T : struct, IBaseJobProcessComponentData;
    }
}

実は、インターフェースに拡張メソッドが定義できるの知らなかったので驚きでした。
意外と色々なところで使えそうなテクニックなので使っていこうと思います。

まとめ

ECSのシステム生成について見てきました。
データを定義し、小さく分割したシステムを生成し、それらを連携させて処理をしていく、というのが感じ取れたのではないでしょうか。

既存のGameObjectコンポーネントを用いた設計とはかなり違いがあるため最初は色々戸惑いそうですが、物量が必要なゲームの場合は必須となる機能なのは間違いないと思います。

まだプレビュー版なので色々とドラスティックに仕様が変わったりしていますが、今後も情報を追っていって、正式リリースした暁にはすぐに使い始められるようにしておきたい機能ですね。