e.blog

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

特定の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コンポーネントを用いた設計とはかなり違いがあるため最初は色々戸惑いそうですが、物量が必要なゲームの場合は必須となる機能なのは間違いないと思います。

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

ECSをそろそろ触ってみる ~ECS入門 シーンにオブジェクトを描画編~

概要

この記事は、Unity Advent Calendar 16日目の記事です。

TwitterのTLを見ているとECSを触ってなにかした、みたいのがよく流れてきていて流石にそろそろ触っておきたいなーと思ったので簡単にまとめてみました。
(Advent Calendarのネタがないから急遽手を出したわけじゃありません)

今回は「シーンにオブジェクトを描画編」ということで、シンプルに画面にオブジェクトを描画するところまでを書きたいと思います。
いきなり色々やってしまうと内容がブレてしまうかなと思ったので。

今回の内容についてはGithubにアップしているので、動作するものを見たい方はダウンロードして見てみてください。

github.com

ECSとは

まずは簡単に。

ECSとは、Unityが新しく導入を進めている新しいシステムです。
ちなみに「Entity Component System」の頭文字を取って「ECS」です。

ものすごく乱暴な言い方をすれば、処理負荷の高い既存のGameObjectの仕組みをイチから見直して高速化を達成するための仕組み、というような感じです。

詳細については以下のテラシュールブログさんの記事を見るといいと思います。

tsubakit1.hateblo.jp

少しだけ言葉を引用させていただくと、

コンポーネント志向に変わる新しいアーキテクチャパターンです。

と書かれています。
これは、今のコンポーネント指向、オブジェクト指向とは異なる「データ指向」のアーキテクチャを意味しています。

データ指向については以下のCygamesの記事が詳細に解説されています。

データ指向設計 | Cygames Engineers' Blog

データ指向設計とは

少しだけデータ指向設計について。
なぜこれを書くかというと、ECSを利用・理解する上でこの知識は必須となりそうだなと感じたからです。
まぁそもそも、この思想をベースにしているので当たり前と言えば当たり前ですが。

データ指向設計とは、今までのオブジェクト指向などと同じように「設計パターン」です。
そして着目するのが「データ」である、というのが大きな特徴です。

オブジェクト指向はデータではなく、あくまで表現したい「オブジェクト」を主体に考えます。
そしてそれに紐づくデータと振る舞いをセットにして表現します。

一方、データ指向では「コンピュータが扱いやすい形としてのデータ」に着目し、それを元に設計、実装していくパターンです。

なぜデータに着目?

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

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

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


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

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

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


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

キャッシュミスを減らして効率化

なぜ効率が良くなるかというと、CPUには以下に示すようにいくつかのキャッシュシステムを備えています。
そしてそのキャッシュには一定の塊(キャッシュライン)でデータが読み込まれそれを利用します。
その読み込まれた塊のデータの中に、次に計算するデータが含まれている場合は計算をすばやく行うことができます。
(これをキャッシュヒットと言います)

しかしもしその中に目的のデータがなかった場合は、改めてメモリからキャッシュにデータを読み込む必要があります。
(これをキャッシュミスと言います)

つまり、前述のように「必要なデータを揃えておく」ことによってこの「キャッシュヒット」を期待することができ、逆にそうでない場合は頻繁に「キャッシュミス」が発生することになってしまいます。

ちなみにキャッシュミスによってどれくらい差が出るかですが、CPUとメモリの関係について見てみましょう。

ここでのメモリは「大容量メモリ」、つまり一般的に言われているメモリです。(最近だと16GBとか、32GBとか積んでるあれです)
しかしCPUからは「とても遠い」存在なんですね。

そのため、CPUが扱いやすい距離にある位置にも少量のメモリが置かれます。
これをキャッシュと呼び、L1キャッシュL2キャッシュなどと呼びます。(場合によってはL3キャッシュもあるものも)

さて、どれくらい距離に差があるかと言うと。
Cygamesの記事から引用させていただくと以下の表のようになります。

種類 サイズ レイテンシ
L1キャッシュ 32KB - 128KB 3 - 4サイクル
L2キャッシュ 4MB - 20MB 20 - 40サイクル
メインメモリ 4GB - 32GB 200サイクル

サイクルとは、大雑把に言うとCPUがひとつの命令を実行する単位。

これを見てもらえば、どれくらい「距離に差がある」のかがイメージできるかと思います。
メインメモリへのアクセスに要する時間の間に、200命令くらいが実行可能、ということなんですね。

イメージ的にはL1キャッシュが机の上、L2キャッシュが部屋の本棚、メインメモリが近くの本屋、くらいの差です。
そりゃ処理が遅くなるわけですよね。

こうした、実際の内部構造的にどういう感じで最適化されるのか、なぜそれが必要なのかは前述のCygamesの記事を読んでみてください。

登場人物

ECSは新しい概念となるため、既存のUnityの仕組みをひとつひとつ置き換えながら、というのはむずかしそうです。
今回ははまずセットアップから始めて、シーンにメッシュを表示する流れまでを書きたいと思います。

その中で登場するいくつかの機能を紹介しておきましょう。

名称 意味
Entity エンティティ。GameObjectに変わる「単位」を表す
ComponentData Entityに格納するデータ
ComponentSystem 実際の処理
Group ComponentSystemが要求するComponentDataのグループ

ちなみにこれらの、既存の仕組みとの紐付けですが、上のテラシュールブログさんの記事から引用させていただくと以下のようになるみたいです。

この用語ですが、UnityのGameObject / Componentを差し替えるものだけあって、少し近いところがあります。物凄い大雑把に言ってしまえば、以下のモノと一致します。

ComponentSystemUpdateメソッドに該当するんですね。
ちなみに、ComponentSystemはいくつかのシステムを作ることができ、それぞれ目的のデータ構造を定義してそれを効率よく計算させることができます。

最後のGroupはまさにこの「システム」に渡すためのデータ構造をグループ化したものとなります。
これは前述の通り、新しいアーキテクチャである「データ指向」に依るものなので既存の仕組みにはない概念となっています。

導入方法

さて、まずは導入方法から。
ECSはまだまだ実験段階のため、正式には導入されていません。
そのため、導入するためには「Package Manager」から明示的にインストールする必要があります。

メインメニューから、「Window -> Package Manager」を開くと以下のようなウィンドウが表示されます。

f:id:edo_m18:20181213150808p:plain

そして「All」タブの中から「Entities」を選択してインストールします。

またECSは、.NET4.xを必要とするため「Player Settings」の「Scripting Runtime Version」を「.NET 4.x Equivalent」に変更します。

f:id:edo_m18:20181215011124p:plain

これで準備が整いました。
あとは通常通りコードを書いていくことができます。

ECS Hello World

さぁ、ここからが「Hello World」です。
そして事実、ECSでは「World」というクラスがあり、これがシステム全体を管理する役割を担っています。

ということで、ものすごくざっくりと、画面にメッシュがひとつだけ描画されるサンプルコードを見てみましょう。

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

public class SimpleECSTest : MonoBehaviour
{
    [SerializeField] private Mesh _mesh;
    [SerializeField] private Material _material;

    private void Start()
    {
        World world = World.Active;
        EntityManager entityManager = world.GetOrCreateManager<EntityManager>();

        // Prefabを作成
        Entity prefab = entityManager.CreateEntity(
            ComponentType.Create<Position>(), // 位置
            ComponentType.Create<Prefab>() // Prefab(これがついているEntityはSystemから無視される)
        );

        // 描画用のComponentを追加
        entityManager.AddSharedComponentData(prefab, new MeshInstanceRenderer
        {
            castShadows = UnityEngine.Rendering.ShadowCastingMode.On,
            receiveShadows = true,
            material = _material,
            mesh = _mesh
        });

        // Prefabをインスタンス化
        entityManager.Instantiate(prefab);
    }
}

とてもシンプルなコードですね。これを実行すると以下のようにシーンにひとつだけオブジェクトが表示されます。

f:id:edo_m18:20181215011846p:plain

MeshMaterialはインスペクタから適当なものをセットしています)

コードはそこまで多くないので、ひとつひとつ順を追って見ていきましょう。

全体を管理する「World」クラス

冒頭ではWorldクラスを変数に格納しています。

World world = World.Active;

World.Activeにはデフォルトで生成されたワールドが保持されています。
しかしこれは、後述するように、デフォルトワールドの生成を抑止することもできます。
意図しないシステムが可動して無用な負荷を生まないためにも、実際にはオフにすることが多くなると思われます。

しかしまずは、必要最低限のもので描画まで実現するためにサンプルではデフォルトワールドはオンで説明します。

エンティティを生成する準備

デフォルトワールドはWorld.Activeに格納されています。
が、これは上書きすることができるので、現在メインで作業中の、くらいの感じで捉えておくといいかと思います。

ワールドが取得(あるいは生成)できたら、以下のフローによってエンティティ(ECSの単位)を生成します。

生成フロー

  1. EntityManagerを生成(or 取得)する
  2. アーキタイプEntityArchetype)を定義する
  3. アーキタイプを元にエンティティ(Entity)を生成する
  4. エンティティにコンポーネントデータを設定する
  5. セットアップが終わったエンティティをインスタンス化する

このフローを踏むことによって、無事にシーンにエンティティが表示されるようになります。

さて、上記フローが一体なにをしているのかというと。
アーキタイプの定義によって、エンティティに必要なデータ構造を定義します。

データ指向のための「設計」

「データ指向」がベースなので、まさにこのデータの設計をしているわけですね。(データのアーキテクチャ

そしてデータの設計が済んだら、それをベースにエンティティを生成します。
オブジェクト指向で言うとクラスを定義、に近いイメージでしょうか。
アーキタイプの設計は、オブジェクト指向だとコードを書く前の設計段階に近いかもしれません)

エンティティが生成できたら、次に行うのはそのエンティティの初期値の設定です。
設定はEntityManagerのメソッドを通して設定していきます。
セット用メソッドの第一引数に、生成したエンティティを渡してセットアップしていきます。
(このあたりはC言語っぽいイメージがありますね)

設計を元にしたインスタンス

そして初期値の設定が終わったらいよいよインスタンス化です。
これもまたEntityManagerInstantiateメソッドを利用してインスタンス化します。
こうすることで晴れて、エンティティがワールドに誕生することになります。

ECSを利用してエンティティを画面に表示するだけなら以上です。
意外と拍子抜けするほど簡単ですね。

ただ実は大事な概念がまだ説明されていません。
それが「ComponentSystem」です。

しかしこれは、デフォルトワールドではすでにセットアップ済みで、エンティティの設定とインスタンス化をするだけでワールドに誕生させることができます。

とはいえこれではECSを使いこなすことはむずかしいです。
ということで、次は「ComponentSystem」について解説します。

自前Worldの生成とComponentSystem

さて、最後に書くのは「自前World」の作成と「ComponentSystem」についてです。
前述のように、なにもしないとデフォルトワールドが生成された状態になります。

そしてそのワールドには、現在定義されているすべてのシステムがすでに登録済の状態となっています。
ただ、指摘したように基本はオフにするのが通常のフローになるかなーと思っています。

なので実践で使う場合はワールドを自作し、必要なシステムを自分でセットアップする必要があります。

ということで、ワールドの生成と(描画だけを行う最低限の)システムのセットアップについて、コード断片を載せます。(コード見たほうがイメージ湧きやすいかなと思うので)

必要なシステムの登録(生成)

では自作ワールドを作成して描画に必要なシステムの登録をするところから見てみましょう。

// ひとまず、デフォルトのワールドを削除する
// 
// ※ Player SettingsのDefine Symbolsに
// 「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP」を指定しても
// デフォルトワールドの生成を抑えられる
World.DisposeAllWorlds();

_gameWorld = new World("GameWorld");

// あとからアクセスしやすいように、World.Activeにも設定しておく。
// ※ しなくても大丈夫
World.Active = _gameWorld;

// デフォルトで用意されているTransformSystem
_gameWorld.CreateManager<EndFrameTransformSystem>();

// デフォルトで用意されている描画を行うためのシステム「MeshInstanceRendererSystem」の補助クラス
_gameWorld.CreateManager<RenderingSystemBootstrap>();

// EndFrameTransformSystemなどを先に生成すると、自動的にEntityManagerが生成されるので、GetOrCreateで取得する
EntityManager entityManager = _gameWorld.GetOrCreateManager<EntityManager>();

描画に必要なものとして以下の2つのシステムを登録しています。

  • EndFrameTransformSystem
  • RenderingSystemBootstrap

なお、RenderingSystemBootstrapは描画のシステムであるMeshInstanceRendererSystemの補助クラスとなっているようです。

そして最後に、エンティティ周りを管理するEntityManagerを「取得」しています。

これだけGetOrCreateしているのには訳があります。
実はEndFrameTransformSystemを登録すると内部で自動的にEntityManagerが登録されるようです。

おそらくですが、内部でエンティティ周りの設定などを行うシステムの場合は自動的に生成されるのではないかと思います。
そのため、Createしてしまうと「すでに登録済み」というエラーが発生してしまいます。

エラー内容↓

ArgumentException: An item with the same key has already been added. Key: Unity.Entities.EntityManager

エンティティを作成する

システムの設定が完了したら、前段で取得したEntityManagerを利用してエンティティのセットアップ、生成を行います。
こちらもまずはコードを見てもらうのが早いでしょう。

EntityArchetype archetype = entityManager.CreateArchetype(
    // ComponentType.Create<LocalToWorld>(), // Positionなどがあるとデフォルトシステムなら自動で追加してくれる
    ComponentType.Create<Position>(),
    ComponentType.Create<Rotation>(),
    ComponentType.Create<MeshInstanceRenderer>()
);

// アーキタイプを元にエンティティを生成する
Entity entity = entityManager.CreateEntity(archetype);

// Rendererを設定
entityManager.SetSharedComponentData(entity, new MeshInstanceRenderer
{
    mesh = _mesh,
    material = _material
});

// Transform関連の情報を設定
entityManager.SetComponentData(entity, new Position
{
    Value = new float3(0, 0, 0)
});

entityManager.SetComponentData(entity, new Rotation
{
    Value = Quaternion.Euler(0f, 180f, 0f)
});

// インスタンス化
entityManager.Instantiate(entity);

コード的には、デフォルトワールドを利用してエンティティを表示したときとほとんど変わりがありませんね。
そして最後でエンティティをインスタンス化しています。

ワールドを有効化する

さあ、これですべてセットアップが終わりました。
が、実はこれだけだとワールドは動作しません。生成しただけでは動かないんですね。

ということで、動作するように有効化します。

// イベントループにワールドを登録する
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_gameWorld);

有効化自体はシンプルです。
ScriptBehaviourUpdateOrderUpdatePlayerLoopメソッドを利用してアップデートループに登録するだけです。

これで自作ワールドが動きはじめ、エディタを再生すると画面にメッシュが表示されるようになります。

まとめ

今回は以上です。
もう少し実践的に利用する作りについては別記事で書きたいと思います。
まずは自作ワールドを生成し、システムの登録を経てオブジェクトを表示するシンプルな例を示しました。

ECSの作法に慣れれば、あとは専用のシステムなどの制作を通してECSを自由に扱えるようになるはずです。

その他Tips

基本的なECSの使い方と概念の説明は以上です。
あとは、知っておいたほうがいいTipsなどを簡単にまとめておきます。

デフォルトのワールドを生成させないようにする

前述のように、デフォルトのワールドを最初から生成しない方法があります。
それは、「Player Settings」の「Scripting Define Symbols」に以下のシンボルを定義することで無効化することができます。

UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP

以下のように設定する。

f:id:edo_m18:20181213150959p:plain

生成しても、それを消すこともできます。
以下のようにするとすべてのワールドを破棄することができます。

World.DisposeAllWorlds();

ECSで構築された「世界」を見る

ECSで構築された世界は、通常のシーンのヒエラルキーには表示されません。
前述の青い球体がシーンビューには存在していても、ヒエラルキーにはなにもない状態となっています。

しかしそれでは色々と開発が大変になってしまうので、それを見る方法があります。
メインメニューの「Window > Analysis > Entity Debugger」を起動することで現在のシステムとエンティティを確認することができます。

f:id:edo_m18:20181213172056p:plain

これを開いて実行すると以下のように、システムとエンティティのリストを見ることができます。

f:id:edo_m18:20181216184231p:plain

その他ハマりどころ

まだまだ策定途中、実装途中な感じのECS。
そのため仕様変更なども比較的頻繁に行われているようで、調べて出てきた情報がすでに古い、なんてこともあります。

色々ぷちハマりしたところをメモとして残しておこうと思います。

TransfdormMatrixはなくなった

Twitterでつぶやいたところ、以下のようにコメントもらいました。

確かに、TransformMatrixよりもより「データ感」のある名称に変更されたということでしょう。

TransformSystemは抽象クラス

これ、もしかしたら途中でそうなったのかもしれませんが。
調べているときに出てきたコードをそのまま書いていたらエラーが。

どうやらTransformSystemは抽象クラスで、実際に利用する場合はその派生クラスであるEndFrameTransformSystemを利用する必要があるとのこと。
(あるいは自作する場合はこれを継承する)

EntityManagerは自動で生成される

システムの説明のときにも触れましたが改めて書いておきます。
前述のEndFrameTransformSystemを先に生成すると、内部的にどうやら自動的にEntityManagerが生成されるようです。

そのため、そのあとに以下のようにセットアップを行おうとするとすでに生成されてるよ! っていうエラーが出ます。

_world.CreateManager<EndFrameTransformSystem>();
EntityManager entityManager = _world.CreateManager<EntityManager>(); // => ここでエラー

エラー内容はこんな感じ↓

ArgumentException: An item with the same key has already been added. Key: Unity.Entities.EntityManager

なので最初に生成するか、あるいはGetOrCreateで取得してあげるとうまくいきます。

EntityManager entityManager = _world.GetOrCreateManager<EntityManager>();

参考にした記事

qiita.com

qiita.com

qiita.com

tsubakit1.hateblo.jp