e.blog

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

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

SteamVR SDK2.0以降でViveトラッカーを適切にアサインする

概要

Kunclesが手元に届いたということもあって、SteamVR SDK2.0について本腰を入れて調べてみようと思い立ちました。が、そもそも以前から適切にViveトラッカーを認識させられず、結局1.0を使うか騙し騙しトラッカーを使っていた状況でした。

そこで、まずは2.0でViveトラッカーを使えるようにしようと奮闘したメモです。

先に結論を書いておきますが、SteamVR SDK側の問題でβ版なら動きました。(おいッ)

なので、ここでまとめていることはおそらく近いうちに対応がされてあまり意味がないものになるかもしれませんが、困っている人もいるかもしれないので記事にしておきます。

そもそもの問題点

そもそもの問題点として、SDK1.0のときは適当にオブジェクトを追加してTrackedObjectコンポーネントとかを適当に設定してマネージャに登録、とすることですぐに認識させることができました。

が、SDK2.0からマネージャ的なものがなくなり、同梱されているPrefabを見てみるとどうやらSteamVR_Behaviour_Poseというコンポーネントで「どのコントローラか」を指定する形になっていました。

Any / LeftHand / RightHandしかない・・・?

インスペクタに表示される「Input Source」を見ると、AnyLeftHandRightHandしか見当たらない・・・。
トラッカーはどう指定したらいいの?

Manage Vive Trackersで管理

色々探していたら、SteamVRのメニューの「バイス > Manage Vive Tracker」という項目が。

f:id:edo_m18:20181210192538p:plain

起動してみると、以下のような設定画面が開きました。

f:id:edo_m18:20181210192622p:plain

Select Roleで役割を設定

上記画像のように、「Select Role」という項目があり、そこから役割を指定する様子。
いくつか項目がありましたが、フルボディトラッキングを想定しているのか、Footとかがリストされてました。

とにかくなにかしらで指定できればいいので、ひとまずLeft Footを選択。

設定する箇所が見当たらない・・・

やっとこれで役割を指定できたから、あとはPose情報をこのLeft Footを指定したら行けるのでは?
という思いで設定を探すも見つからず。

上にも書いたように、AnyLeftHandRightHandしかリストにはない。(どういうことだってばよ)

色々なワードで検索をかけていくうちにひとつのissueが目に入りました。

github.com

issueの内容を見てみると、コントローラは動いてるし、SteamVRのホーム画面とか(つまりUnity以外の場所)ではちゃんとトラッカー認識してるのに、Unityだと出てこないんだけど?

っていうもの。
完全に自分と同じ状態です。

それへの回答が

We've got some more extensive tracker support coming in the next version. I'll be releasing a beta soon that provides better access to this functionality. I'll update here when I release that.

次のバージョンでサポートするとのこと。ひとまずβ版でリリースするよと。(トラッカーはすでに世に出てるんだから、そこも対応してから出してほしかった・・・(´・ω・`)

そしてβ版のリリースは以下でされていました。

github.com

改めてこちらを入れ直して見たところ、ちゃんとLeft Footなどがメニューにあることを確認。
これを指定したところ、しっかりとViveトラッカーが認識されました。(コントローラも含めて全部ちゃんと動いた!)

f:id:edo_m18:20181210193405p:plain

今、トラッカーをベースにコンテンツを作っているので、やっとStemaVR SDK2.0のほうでもトラッカーを使ったコンテンツが問題なく作れそうです。

点と凹多角形の内外判定を行う

概要

凹多角形の内外判定を行いたく、以下の記事を参考にUnityで判定処理を書いたのでそのメモです。

www.nttpc.co.jp

実際に実装した動画です。ちゃんと内外判定が出来ているのが分かるかと思います。

判定の考え方

参考にした記事によると、内外判定は以下のふたつが用いられることが多いそう。

  1. Crossing Number Algorithm
  2. Winding Number Algorithm

Crossing Number Algorithm

Crossing Number Algorithmは、大雑把に言うと、調査点から水平方向に(つまりX軸方向に)線を伸ばし、多角形の辺との交差回数をカウントするものです。
ちなみに水平方向の線は半直線で、調査方向を定めてそちらの方向に伸ばしたものとのみ判定を行います。

図にすると以下のような感じです。

f:id:edo_m18:20181127234359j:plain

(無駄にiPad ProとApple Pencilで図を書いてみたw)

見てもらうと分かる通り、調査点が多角形の内側にある場合は交差回数は奇数回、外側にある場合は偶数回となることが分かるかと思います。
(ただし、水平線がどれかの辺と平行、あるいは多角形の頂点上にある場合は誤判定してしまうのでそれも考慮しないとなりません)

非常にシンプルなアルゴリズムですね。
ただ、自己交差をしている多角形の場合は正常に判定できないようです。

※ 自己交差とは、辺がどこかで交差してしまっていることを指します。

Winding Number Algorithm

もうひとつのアルゴリズムは「Winding Number Algorithm」です。
こちらは、調査点から見て、多角形の各頂点をぐるっと周回して得られる角度の合計がどうなるかで判定を行うものです。

参考にした記事から引用させていただくと以下のように説明されています。

Winding Number Algorithmは、点Pを中心に多角形Tの辺を順番になぞっていった時に点Pの周りを何回回転するかを計算し、その数( wn)によって内側・外側の判定を行います。
この時、 wn \geq 1であれば多角形Tは点Pを取り囲んでいることになるので、点Pは多角形Tの内側にいると判定します。逆に、点Pが多角形Tの外側にいると判定されるのは wn = 0の時だけです。

※ 余談ですが、「Winding」には「巻き取る」とか「うねり」という意味があるようです。

そして今回実装したのはこちらのアルゴリズムです。

なお、こちらのアルゴリズム、「角度」の算出に \cos^{-1}が出てくるため、調査対象の点や多角形の頂点数が多くなると処理負荷が高まります。
参考にした記事では、最初の「Crossing Number Algorithm」を少し拡張したような別の方法での実装方法も紹介されていました。

以下では、Unityで両方実装したので双方について、Unityでの実装をベースに解説したいと思います。

角度を利用したアルゴリズムの実装解説

「とある点」が凹多角形の内側にあるのか外側にあるのかの判断はとてもシンプルです。
「とある点」と凹多角形の各頂点との成す角の合計が0かそれ以外か、で判定することができます。
※ なお、ここでの角については時計回りを正、反時計回りを負としています。つまり符号付き角度で考えることが重要です。(参考にした記事では反時計回りを正としていましたが、ここは多分、決めの問題です。今回のサンプルでは時計回りを正として実装しました

まずはシンプルに三角形について考えてみましょう。(凹多角形ではないですが、イメージを掴みやすくするために単体で考えます)
以下のように、三角形の内側に点がある場合、それぞれの頂点と「とある点」とで辺を作り、その成す角(偏角)について考えてみます。

f:id:edo_m18:20181126193846j:plain

 \theta_0 〜 \theta_2を合計すると360度になるのが分かります。

角の作り方は点 Pと凹多角形の各頂点とを結び辺を作ります。
頂点を( V_0 ... V_n、V_0 = V_n)としたとき、点 Pとで作る辺を( l_0 ... l_n)とします。

偏角 \theta_i l_iとl_{i+1}とが成す角となります。そしてそれを合計するので、

 
\sum_{i=0}^{n-1}{\theta_i}

さらに、1周は360度(= 2\pi)なので、 2\piで割ることで「何周したか」を計算することができます。まとめると、

 
wn = \frac{1}{2\pi}\sum_{i=0}^{n-1}{\theta_i}

と表すことが出来ます。

さて、同様にして四角形でもやってみましょう。

f:id:edo_m18:20181126194906j:plain

確かに四角形でも360度になることが分かります。

では今度は「とある点」を外側に出して同様の計算をしてみましょう。
すると、以下の図のようにきれいに角度の合計が0にるのが分かるかと思います。
(矢印の向きが違うのは符号付き角度を表しています)

f:id:edo_m18:20181126194044j:plain

この事実を利用して、凹多角形の外側に点がある場合は偏角の合計が0の場合は外、それ以外の場合は内側、として判断します。

ちなみに、参考にした記事では辺が交差している、より複雑な形状についても判定しています。
その場合でも、外側の場合は0、そしてそれ以外の場合は1以上になることもあるようです。

角度を使ったアルゴリズムソースコード

上記の内容を実際にC#で実装したコードが以下になります。

/// 
/// 多角形を構成する頂点リストと対象点とを使って、対象点が多角形内に含まれるかをテストする
/// 
static public class PointInArea
{
    private const float _unit = 1f / 360f;

    /// 
    /// 調査点が多角形の内外どちらにあるかを判定する
    /// 
    /// 多角形を構成する頂点リスト
    /// 調査点
    /// 多角形が存在する平面の法線
    /// 調査点が内側にある場合はtrue
    static public bool _Check(Vector3[] positions, Vector3 target, Vector3 normal)
    {
        float result = 0;

        for (int i = 0; i < positions.Length; i++)
        {
            Vector3 l1 = positions[i] - target;
            Vector3 l2 = positions[(i + 1) % positions.Length] - target;

            float angle = Vector3.Angle(l1, l2);

            Vector3 cross = Vector3.Cross(l1, l2);
            if (Vector3.Dot(cross, normal) < 0)
            {
                angle *= -1;
            }

            result += angle;
        }

        result *= _unit;

        // 時計回り・反時計回りどちらもありえるため絶対値で判定する
        return Mathf.Abs(result) >= 0.01f;
    }
}

コードの行数もそれほど多くなく、とてもシンプルに判定できているのが分かるかと思います。
メソッドの第一引数のpositionsが多角形を構成する頂点配列、targetが調査点P、normalは多角形が存在する平面の法線です。

平面法線を使って回転の方向を判定

法線について少しだけ補足します。
法線を必要としているのは、「偏角の向き」を判断するためです。
2辺の角度を求めるには内積を用いて計算するか、UnityであればVector3.Angleによって角度を求めることができます。

しかしその角度は符号がついていません。つまり、「どちら周りか」が分からないのです。
そこで、平面の法線を利用して外積を求めることでどちら周りの角度なのかを判断しているというわけです。

今回の実装では、選択したふたつの辺の外積と面の法線との外積がマイナス方向だった場合は逆回転として扱うようにしています。

浮動小数点誤差などを考慮

そして最後に、計算結果が0より上かを判定基準としています。
が、ここでも少しだけ細かい処理が入っています。

まず、すべてが逆回転の場合、resultの角度はマイナスになります。
が、それは見る角度が反対だっただけで、内外の判定には関係ありません。
なので絶対値を使って判定するためにMathf.Absを使っています。

最後の比較部分ですが、本来なら0であるかどうか、で判定を行いますが浮動少数点の計算誤差によりきれいに0になりません。
そこで、ある程度0に近い値を利用して、それ以上であれば内側、という判定にしています。

角度を利用したアルゴリズムについては以上です。
次は計算コストを軽量化したアルゴリズムでの実装を解説します。

辺との交差を利用したアルゴリズムの実装解説

こちらはまだしっかりと理解しているわけではないですが、参考にした記事から引用させていただくと以下のようになります。

Crossing Number Algorithmと同様に点Pから伸びる水平線 Rと多角形Tの辺 S_nが交差する数をカウントし、交差する辺が上向きであるか下向きで加算するか減算するかを変えるということです。 つまり、

  • 上向きの辺と水平線Rが交差する場合は wnを+1
  • 下向きの辺と水平線Rが交差する場合は wnを-1

とします。 こうすることで、Crossing Number Algorithmに

  • ルール5. 上向きの辺と交差する場合、 wnを+1する。
  • ルール6. 下向きの辺と交差する場合、 wnを-1する。

を追加したアルゴリズムとなり、三角関数 \cos^{-1} \thetaを計算することなく wnを得ることができます。

Crossing Number Algorithmに、方向を持った辺との交差をルールに加えることによって wnを求めることができる、ということのようです。

まずはソースコードを見たほうが早いと思うので見てみましょう。

辺との交差を用いたアルゴリズムソースコード

/// 
/// 多角形を構成する頂点リストと対象点とを使って、対象点が多角形内に含まれるかをテストする
/// 
static public class PointInArea
{
    /// 
    /// 調査点が多角形の内外どちらにあるかを判定する
    /// 
    /// 多角形を構成する頂点リスト
    /// 調査点
    /// 多角形が存在する平面の法線
    /// 調査点が内側にある場合はtrue
    static public bool Check(Vector3[] points, Vector3 target, Vector3 normal)
    {
        // XY平面上に写像した状態で計算を行う
        Quaternion rot = Quaternion.FromToRotation(normal, -Vector3.forward);

        Vector3[] rotPoints = new Vector3[points.Length];

        for (int i = 0; i < rotPoints.Length; i++)
        {
            rotPoints[i] = rot * points[i];
        }

        target = rot * target;

        int wn = 0;
        float vt = 0;

        for (int i = 0; i < rotPoints.Length; i++)
        {
            // 上向きの辺、下向きの辺によって処理を分ける

            int cur = i;
            int next = (i + 1) % rotPoints.Length;

            // 上向きの辺。点PがY軸方向について、始点と終点の間にある。(ただし、終点は含まない)
            if ((rotPoints[cur].y <= target.y) && (rotPoints[next].y > target.y))
            {
                // 辺は点Pよりも右側にある。ただし重ならない
                // 辺が点Pと同じ高さになる位置を特定し、その時のXの値と点PのXの値を比較する
                vt = (target.y - rotPoints[cur].y) / (rotPoints[next].y - rotPoints[cur].y);

                if (target.x < (rotPoints[cur].x + (vt * (rotPoints[next].x - rotPoints[cur].x))))
                {
                    // 上向きの辺と交差した場合は+1
                    wn++;
                }
            }
            else if ((rotPoints[cur].y > target.y) && (rotPoints[next].y <= target.y))
            {
                // 辺は点Pよりも右側にある。ただし重ならない
                // 辺が点Pと同じ高さになる位置を特定し、その時のXの値と点PのXの値を比較する
                vt = (target.y - rotPoints[cur].y) / (rotPoints[next].y - rotPoints[cur].y);

                if (target.x < (rotPoints[cur].x + (vt * (rotPoints[next].x - rotPoints[cur].x))))
                {
                    // 下向きの辺と交差した場合は-1
                    wn--;
                }
            }
        }

        return wn != 0;
    }
}

冒頭ではまず、平面の計算を行いやすくする目的でXY平面へ全頂点を写像したのちに計算を行っています。
それ以後の実装に関しては冒頭の記事を参考にさせていただきました。

最後の点の評価部分については図解してみました。

f:id:edo_m18:20181128141026j:plain

 v_0から v_1が辺の方向ベクトルとなります。

そしてvtは辺ベクトルのy値の上昇率です。
それをxにも適用することで、点 Pと同じ高さの辺のx位置を知り、それとの比較によって交差判定を行っています。

上の図で言うと、中央付近の赤いラインが該当位置のxの位置を示しています。
この赤いラインと辺ベクトルとの交点が、点 Pと同じ高さの辺上のx位置となります。

これと比較して、点 Pより右側にあれば交差している、というわけです。

こちらのアルゴリズムでは角度は登場せず、辺との交差回数を増減することによって「何周したか」を判定していることになります。

前述の角度を求めるアルゴリズムと比べて、実際に計測してみたらだいぶ計算負荷に差があったので、基本的には下の実装を利用するのが良さそうです。

ただ、アルゴリズム自体の理解は前述のものを最適化したものなので前者をしっかり把握することが大事だと思います。

モデルの頂点をUV展開した先の位置に任意の絵をテクスチャに描き込む

概要

今回は「モデルをUV展開したあとのテクスチャ空間に絵を描く」仕組みについて書きたいと思います。

ざっくりとしたイメージは以下の動画をご覧ください。


動画で行っているのは以下の2点です。

  1. とある位置からプロジェクタの要領でテクスチャをオブジェクトに投影する
  2. 任意のタイミング(ボタン押下など)で、プロジェクタで表示しているテクスチャを該当オブジェクトのテクスチャに描き込む

つまり、プロジェクタで投影している絵をスタンプのように貼り付ける機能、というわけです。

また、プロジェクタのようにテクスチャを投影する機能については以前Qiitaに記事を書いたのでそちらを参照ください。
(実装はWebGLですが、基本的な考え方は同じです)

qiita.com

ちなみにこれを使ってコンテンツを制作中。
オブジェクトに「切り傷」をつけられるようになりました。

UV展開した位置に描き込む、とは?

UV展開した位置に描き込む、と言われてもいまいちピンと来ない方は以下の動画を見てください。

上の動画はUnityのシーンビューを録画したものです。
シーンビューには犬モンスターのモデルと、それにプロジェクタを投影している様子、そしてそのプロジェクタが投影されている頂点位置をUV展開後の座標にマッピングした様子を示しています。

それでなにがうれしいの?

さて、これができるとなにが嬉しいのか。
今回の記事の主題でもありますが、モデルの頂点位置がUV空間に展開した際にどこにあるか、がわかるとその位置に対してGPUを介して描画を行うことで、通常のシェーダで記述した絵をそのままUV空間に転写することができるようになるのです。

そして冒頭の動画ではそれを応用して、プロジェクタで投影した絵をそのまま、モデルで利用するテクスチャに転写している、というわけです。

ことの発端

ことの発端は、以下の動画のようにモンスターに対して切り傷をつけたい、というものでした。

VRゲームで、実際に剣で斬りつけたときにその場所に傷が残る、というのをやりたかったんですね。
イメージはまさにこのSAOですw


【圧巻】-SAO- ソードアート・オンライン 「スターバーストストリーム」

この戦いのくだりはとてもアツい展開ですよね。
こんな感じで巨大ボスと戦ってみたい。それが今回のことの発端です。

どうやって傷(デカール)をつけるか

やりたいことは明確でしたが、どうやって「斬った場所にだけデカールをつけるか、というのが最初の悩みでした。


ちなみにデカールとは、Wikipediaから引用させていただくと以下の意味です。

デカール (decal) は、英語: decalcomania(転写法・転写画)またはフランス語: decalcomanieの略で、印刷・加工工程を終えあとは転写するだけの状態になったシートのことである。

英語の Decal(英語版) には日本で一般的にシール、ステッカー、マーキング、ペイントなどと呼ばれるものも広く含まれる。

要は、メッシュの持つテクスチャとは別に、任意の位置にシールを貼るようにテクスチャを表示する、というものですね。


ただ、シールみたいな任意のテクスチャをそのままペタっと貼るだけならメッシュ上の1点を見つけてテクスチャを書き込んでやればいいのですが、今回やりたかったのは、見てもらうと分かる通り「線」に対してテクスチャを貼りたいのです。

そして色々悩んだ結果、冒頭のようにプロジェクタで投影した位置に描き込めばいけるんじゃないか、と思いつきました。

通常のシェーダのレンダリングをどうテクスチャに展開するか

そう思ってまずはプロジェクタのようにテクスチャをモデルに投影する機能を実装しました。

無事動いたところで、ふと気づきました。
どうやったら、ワールド空間でレンダリングしているものを、テクスチャ空間でレンダリングしたらいいんだろうか、と。

今回使ったメソッドはGraphics.DrawMeshNow(Mesh mesh, Transform transform)です。
これはその名の通り、メソッド実行後すぐに、設定したマテリアルで対象のメッシュをレンダリングする、というものです。

ドキュメントはこちら↓

docs.unity3d.com

しかし、当たり前ですがそのままレンダリングしても、通常のシーンで表示されるのと同じようにレンダリングされてしまって、それをうまくテクスチャ(デカール)として利用することはできません。

つまり、このメソッドでメッシュをレンダリングするが、それはワールド空間ではなく、あくまでそのモデルの「テクスチャ空間」に対してレンダリングしてやる必要があるわけです。

UV値をそのまま頂点位置として利用する

色々悩んでいましたが、実はなんてことありませんでした。
レンダリングパイプラインは頂点シェーダによって頂点変換を経て、ピクセルシェーダによって色を描き込みます。

この「頂点シェーダの頂点位置変換」の際に「UV空間での頂点位置」にうまく頂点を変換できればいいわけです。

ではどうやって。
実はすでにその情報は手元にあります。それはそのまま「UV値」です。
モデルのテクスチャをそのまま見たことがあればピンと来ると思いますが、あれがまさに頂点がテクスチャ空間に展開された「展開図」になっています。

通常のモデルが、UV空間での位置に展開される様子を動画に撮りました。
以下の動画を見てもらうとどういうことか分かりやすいと思います。

最初の状態がワールドに置かれたモデル。そしてスライダーを動かすと徐々にUV空間での座標位置に変化していきます。

やっていることはシンプルで、最初(スライダーの値が0の状態)は通常の座標変換、最後(スライダーの値が1の状態)はUV空間での座標位置になるようlerpを使って変化させているだけです。

該当のシェーダコードを抜粋すると以下のようになっています。

v2f vert (appdata v)
{
    #if UNITY_UV_STARTS_AT_TOP
    v.uv2.y = 1.0 - v.uv2.y;
    #endif

    float4 pos0 = UnityObjectToClipPos(v.vertex);
    float4 pos1 = float4(v.uv2 * 2.0 - 1.0, 0.0, 1.0);

    v2f o;
    o.vertex = lerp(pos0, pos1, _T);
    o.uv2 = v.uv2;
    return o;
}

pos0pos1がそれぞれ最初と最後の頂点位置を格納している変数ですね。
それを、スライダーの値(_T)によってlerpで補間したものを頂点位置としています。

見てもらうと分かると思いますが、なんのことはない、頂点位置をたんにUV値にしているだけ、なんですね。(-1~1の間になるように補正はしていますが)

あとはこれを応用してオフスクリーンレンダリングRenderTextureへの描き込み)を行えば、適切なテクスチャの位置にピクセルシェーダの結果が保存される、というわけです。
(それをオフスクリーンではなくオンスクリーンでレンダリングしたのが上で紹介した、犬モンスターを使ったビジュアライズ結果の動画です)

なお、上記のシェーダコードは冒頭で紹介した「Unity Graphics Programming vol.2」に掲載されていたものを参考にさせていただいています。

UV2に全頂点を書き出す

さて、上記のコードを見てあることに気づいた人もいるかもしれません。
実はコード内でuv2というパラメータを利用しています。
が、通常UV値はuvに保持されていますよね。

もともとUnityではUVは4つほど予約されており、uv〜uv4まで利用することが出来ます。
今回はその中のuv2を使ったというわけです。

UV2の値はUVと違う?

おそらく、通常はまったく同じ値が設定されています。手元で確認したところ、uvuv2で同じ結果になりました。
ではなぜ、今回あえてuv2を使っているかというと、それにはちゃんと理由があります。
デフォルトのUV値は、すべての頂点に対して1対1で対応していない場合があります。
UV値はあくまで、どの頂点に対してどのテクスチャをマッピングするか、という情報です。

ただ、似たような場所だったり同じ色を利用したい場合は、異なる頂点に対して同じUVを割り当てることがありえます。
すると、今回実現したかったデカールを再現するには問題になってきます。

というのも、得たいのは全頂点に1対1対応するUVの位置が知りたいのでした。
しかしそれが重複してしまっていては問題になるのは明らかですね。
なのでこれを対処しないとなりません。

UV2に全頂点と1対1対応するUV値を生成する

実はこの機能、Unityに標準で備わっています。
モデルファイルのインスペクタの情報の中に、以下の項目があります。

f:id:edo_m18:20181107094020p:plain

赤いラインを引いた箇所に「Generate Lightmap UV」とあります。
これにチェックを入れて「Apply」ボタンを押すと、UV2に、全頂点に1対1対応するUVの値が生成されます。

そして犬モンスターから生成されたものは以下のようになります。

f:id:edo_m18:20181107094430p:plain

そしてもともとのUVの値を表示してみたのが以下です。

f:id:edo_m18:20181107094626p:plain

だいぶ様子が違いますね。
これが、全頂点に1対1対応するUVを生成する、と書いた意味です。

これを元に、表示と実際にRenderTextureに描き込んだ結果は以下のようになります。

f:id:edo_m18:20181107104218p:plain

f:id:edo_m18:20181107103404p:plain

プロジェクタで投影されている位置にしっかりと描き込まれているのが分かるかと思います。

コード抜粋

これを実際に行っているコードの抜粋を以下に示します。
まずはRenderTextureに描き込みを行っているシェーダコードから。

// 頂点シェーダ
v2f vert(appdata v)
{
    v2f o;

    float2 uv2 = v.uv2;
#if UNITY_UV_STARTS_AT_TOP
    uv2.y = 1.0 - uv2.y;
#endif
    o.vertex = float4(uv2 * 2.0 - 1.0, 0.0, 1.0);
    o.uv = v.uv;
    o.uv2 = v.uv2;

    float4x4 mat = mul(_ProjectionMatrix, unity_ObjectToWorld);
    o.projUv = mul(mat, v.vertex);
#if UNITY_UV_STARTS_AT_TOP
    o.projUv.y *= -1.0;
#endif

    return o;
}

// フラグメントシェーダ
fixed4 frag(v2f i) : SV_Target
{
    // ... 中略 ...

    // ------------------------------------------------------
    // RenderTextureに描き込まれているテクセルと
    // 現在プロジェクタが投影している位置のテクセルとを合成する

    fixed4 col = tex2D(_MainTex, i.uv2);
    fixed4 proj = tex2Dproj(_ProjectTex, i.projUv);
    
    return lerp(col, proj, proj.a);
}

上記シェーダを使って、以下のようにしてオフスクリーンレンダリングを行います。

/// <summary>
/// スタンプの描き込み
/// </summary>
/// <param name="drawingMat">描き込み用マテリアル</param>
public void Draw(Material drawingMat)
{
    drawingMat.SetTexture("_MainTex", _pingPongRts[0]);

    RenderTexture temp = RenderTexture.active;

    RenderTexture.active = _pingPongRts[1];
    GL.Clear(true, true, Color.clear);

    drawingMat.SetPass(0);

    if (_skinRenderer != null)
    {
        _skinRenderer.BakeMesh(_mesh);
    }

    Graphics.DrawMeshNow(_mesh, transform.localToWorldMatrix);
    RenderTexture.active = temp;

    Swap(_pingPongRts);

    if (_fillCrack != null)
    {
        Graphics.Blit(_pingPongRts[0], _pingPongRts[1], _fillCrack);
        Swap(_pingPongRts);
    }

    Graphics.CopyTexture(_pingPongRts[0], _output);
}

C#側で行っているのは、まずオフスクリーンレンダリングをするためにRenderTexture.activeを、描き込み用のRenderTextureに差し替えています。
そして描き込みと読み込みそれぞれのRenderTextureをふたつ用意して(ダブルバッファ)、ピンポンするように、現在すでに描き込まれている情報を加味しつつ、新しいテクスチャに投影状態を描き込んでいます。

実際に書き込んでいるのは、コード中央あたりにあるGraphics.DrawMeshNow(_mesh, transform.localToWorldMatrix);の部分です。
直前で、上で示したシェーダを適用したマテリアル(drawingMat)のdrawingMat.SetPass(0);を呼んで、該当シェーダでレンダリングを行うようにしています。

これを描き込みのたびに交互に行うことで何度もスタンプを押すようにテクスチャに内容を描き込むことができる、というわけです。

なお、こちらのコードも冒頭で紹介した書籍を参考にさせていただきました。
該当コードはGithubで公開されているので詳細は以下をご覧ください。

github.com

ちなみにひとつ変更点として、書籍ではstaticなメッシュに対して描き込みを行っていたので問題はなかったのですが、今回やりたかったのはモンスターと「戦う」というシチュエーションでした。
そのため、テクスチャを描き込むターゲットはSkinnedMeshRendererによってアニメーションしているメッシュです。

なので、以下のようにして動的にメッシュの状態を更新してから描き込みを行っています。

if (_skinRenderer != null)
{
    _skinRenderer.BakeMesh(_mesh);
}

これをしないと、アニメーションしたあとのメッシュではなくデフォルトの状態の位置に描き込まれてしまって微妙に位置がずれてしまうので注意が必要です。

まとめ

今回の、頂点位置をUV座標に展開してRenderTextureに描き込むテクニックは結構色々なところで使えるんじゃないかなーと思っています。
プロジェクタ風の表現について、Unityでの実装は需要があったら書きたいと思いますw(微妙にハマりどころがあった)

ひとまずこれで、やりたいことができそうなのでコンテンツを作っていこうと思います。