e.blog

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

OBB(Oriented Bounding Box)を計算する

概要

今回はOBB(Oriented Bounding Box)についてのまとめです。
今実装している中でボリュームのサイズをある程度正確に量りたいと思ったのが理由です。

今回の実装はこちらの記事を参考に、いくつかの記事を参照して行いました。

sssiii.seesaa.net

こちらの記事に書かれている手順を引用させていただくと以下のようになります。

  1. 頂点座標で行列を作る(xyz座標3データ×5000個のデータで3×5000の行列)
  2. xyz各行の平均を計算して引く
  3. 引いた行列の転置行列を作る(5000×3の行列)
  4. 掛けてNで割って共分散行列ができる((3×5000)×(5000×3)→3×3の行列)
  5. ヤコビ法などを用いて共分散行列の固有値固有ベクトルを計算する
  6. 固有値固有ベクトルが求まれば、固有値の大きい順に固有ベクトルを並べ替えます。
  7. 固有ベクトルを並べて3×3の行列を作っておきます。
  8. 固有ベクトルの行列と頂点の内積を計算し、各固有ベクトル毎に最大と最小の内積値を計算します。
  9. そのデータを使えば重心、OBBの座標が計算できます。(参考サイト参照)

ひとつひとつ見ていきましょう。

OBBとは

OBBとはOrientedと名前がついている通り、オブジェクトに対してできるだけ余白が出ないようにそれをすっぽりと覆うBoxを定義します。

それとは別にAABB(Axis Aligned Bounding Box)があります。Axis Alignedの名前の通り、座標空間のXYZ軸(Axis)に平行(Aligned)なBoxです。
つまり、オブジェクトが回転すると場合によっては無駄な余白ができてしまいます。

それぞれ図にすると以下のような感じの違いになります。

f:id:edo_m18:20180625140112p:plain
▲球体を半分にカットしてAABBを表示した状態

f:id:edo_m18:20180625140202p:plain
▲上記をOBBで表示した状態

いかがですか。オブジェクトの形状や回転状態によってだいぶ違いが出るのが分かるかと思います。

OBBの実装の概要についてはこちらの記事が参考になります。
記事から引用させていただくと、

簡単に言うと、あるポリゴン群に対してどんな方向を向いていてもかまわないので、それをすっぽりと囲む、できる限り小さな直方体を作りましょうというのがOBBの手法です。
この直方体をいかにして作るのかという所が腕の見せ所になります。
とりあえずここでは、上記論文で紹介されている主成分分析を使うことにしましょう。

と書かれています。

主成分分析とは

記事で書かれている主成分分析とはなんでしょうか。
Wikipediaによるとこう書かれています。

主成分分析(しゅせいぶんぶんせき、英: principal component analysis; PCA)とは、相関のある多数の変数から相関のない少数で全体のばらつきを最もよく表す主成分と呼ばれる変数を合成する多変量解析の一手法[1]。データの次元を削減するために用いられる。

主成分を与える変換は、第一主成分の分散を最大化し、続く主成分はそれまでに決定した主成分と直交するという拘束条件の下で分散を最大化するようにして選ばれる。主成分の分散を最大化することは、観測値の変化に対する説明能力を可能な限り主成分に持たせる目的で行われる。選ばれた主成分は互いに直交し、与えられた観測値のセットを線型結合として表すことができる。言い換えると、主成分は観測値のセットの直交基底となっている。主成分ベクトルの直交性は、主成分ベクトルが共分散行列(あるいは相関行列)の固有ベクトルになっており、共分散行列が実対称行列であることから導かれる。

なんだか分かったような分からないようなw
自分の理解としては、多数ある点群に対して方向性を見出し、それを計算によって求めた値を掛けることで分析しやすい形にする、といった感じでしょうか。

固有値固有ベクトルとは

主成分分析には「固有値固有ベクトル」を使って変換が行われるようです。
固有値固有ベクトルについては以下の記事がとても分かりやすかったです。

qiita.com

なんだかどんどん主題と離れていっている気がしなくもないですが、まぁせっかくなのでまとめておきましょうw

上で紹介した記事を見てもらうとアニメーションで示してくれているのでとても分かりやすいですが、自分の理解として言葉で説明しておくと。

とあるベクトルに対して、とある行列を掛けたとき、そのベクトルが回転せず定数倍になるベクトルのことを「固有ベクトル」、そしてその定数倍の定数が「固有値」、ということのようです。

数式で表すと以下になります。


Ax = \lambda x

※ ただし x \neq 0

 Aが行列で \lambdaが定数(スカラー)です。

行列での演算が定数(スカラー)の演算で表せる、というのが上記式の意味です。

(推測ですが)統計学や主成分分析に使える理由としては、傾向がある=回転しないベクトル(方向)、みたいなことから来ているんでしょうか。
ちょっと数学に疎いのでそのあたりの深い話は分かりません・・・。

閑話休題

さて、最初に紹介した記事を読み進めると以下のように続いています。

OBBではデータとはポリゴンの頂点群なので、x,y,zの3次元のデータが頂点の個数分存在します。 それを使って分散共分散行列を作って、その行列の固有ベクトルを計算すると、 OBBを表す3つの軸が得られます。

さぁ、また新しい単語が出てきましたw

分散共分散行列とは

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

統計学と確率論において分散共分散行列(ぶんさんきょうぶんさんぎょうれつ、英: variance-covariance matrix)や共分散行列(きょうぶんさんぎょうれつ、英: covariance matrix)とは、ベクトルの要素間の共分散の行列である。これは、スカラー値をとる確率変数における分散の概念を、多次元に自然に拡張したものである。

と書かれています。

「分散」と「共分散」があり、それを合成した行列のためこう呼ばれるようです。

ちなみに、「分散共分散行列」については以下の記事がとても分かりやすかったです。

zellij.hatenablog.com

分散

まずは分散について。
これまたWikipedia)から引用すると、

確率論および統計学において、分散(ぶんさん、英: variance)は、確率変数の2次の中心化モーメントのこと。これは確率変数の分布が期待値からどれだけ散らばっているかを示す非負の値である[1]。

つまり、確率変数がどれだけ「分散」しているか、ということかな。

また、上で紹介した記事を見てみると、以下のように説明されています。

具体的には、分散は「(各データの平均値からの距離)の2乗の平均」。
分散は2乗であることに注意。単位をそろえるために、分散の平方根を取ったものが標準偏差
標準偏差をσで表すと、分散はσ2で表される。

とのこと。なるほど。
ちなみに「標準偏差」については以下の記事が分かりやすかったです。

atarimae.biz

いわゆる「偏差値」とか、その類の話ですね。
二乗する辺りが「なるほど」と思わされます。
つまり、平均値からの距離を二乗することにより、大きく散らばっている(大きく分散している)場合ほど偏差が大きくなる、というわけです。(大きな値の二乗はより大きな数値になる)

そして完全に余談ですが、以前、「[数学] 最小二乗平面をプログラムで求める」という記事を書いていてそこでも似た話が出てくるので興味がある方は読んでみてください。

qiita.com

共分散

こちらもWikipediaから引用すると、

共分散(きょうぶんさん、covariance)は、2 組の対応するデータ間での、平均からの偏差の積の平均値である[1]。2 組の確率変数 X, Y の共分散 Cov(X, Y) は、E で期待値を表すことにして、 Cov(X, Y) = E[(X - E[X])(Y - E[Y])] で定義する。

上で、分かりやすいと紹介した記事を読んでの自分の理解を書くと、異なるデータに対して分散を計算し、どれくらい相関があるか、を計算したものが共分散かな、と思っています。

ちなみにその記事では以下のように例えています。非常に分かりやすい。

例えば、生徒の「数学の点数」と「英語の点数」がどのような関係にあるか知りたい。数学ができる生徒はやはり英語ができるのか?(正の相関)、それとも数学ができる生徒は英語が苦手なのか(負の相関)。

そこで、数学の点数(xの値)と英語の点数(yの値)という、2つのデータ群を考慮した分散を「共分散」と呼び、この共分散Sxyは次の式で表される。


S_{xy} = \frac{1}{n}(x - \overline{x})' (y - \overline{y})

 'は転置

つまり、XとYというふたつのデータがあった場合、上記式で求まるXとYの相関を見るのがXYの共分散となります。

さらにXとXの共分散が分散、ということのようです。
式にすると以下。


S_{xy} = \frac{1}{n}(x - \overline{x})' (x - \overline{x})

分散と共分散が分かりました。あとはこれを行列の形に合成すると「分散共分散行列」となります。
行列の形は以下のようになるようです。


S =
\begin{vmatrix}
S_{xx} &S_{xy} \\\
S_{xy} &S_{yy}
\end{vmatrix}

OBBの話に戻すと、「頂点郡」というバラけたデータから、XYZという3要素がどれくらい相関しているか、を主成分分析によって求める、みたいなことなのかなーと想像しています。(あくまで想像)

さて、では実際にどうこの分散共分散行列を求めるかと言うと、冒頭の記事の引用を再掲すると以下のように書かれています。

  1. 頂点座標で行列を作る(xyz座標3データ×5000個のデータで3×5000の行列)
  2. xyz各行の平均を計算して引く
  3. 引いた行列の転置行列を作る(5000×3の行列)
  4. 掛けてNで割って共分散行列ができる((3×5000)×(5000×3)→3×3の行列)

分散共分散行列の意味の解説を元に見てみるとやっていることはまさに同じことですね。

そして冒頭の記事が参考にしている記事には以下のようなコードが示されています。

Vertex配列に頂点データが入っていて、配列の長さがSizeになります。
mは全頂点の平均値です。

float C11 = 0, C22 = 0, C33 = 0, C12 = 0, C13 = 0, C23 = 0;
for( int i = 0; i < Size; ++i ) {
    C11 += ( Vertex[i].x - m.x ) * ( Vertex[i].x - m.x );
    C22 += ( Vertex[i].y - m.y ) * ( Vertex[i].y - m.y );
    C33 += ( Vertex[i].z - m.z ) * ( Vertex[i].z - m.z );
    C12 += ( Vertex[i].x - m.x ) * ( Vertex[i].y - m.y );
    C13 += ( Vertex[i].x - m.x ) * ( Vertex[i].z - m.z );
    C23 += ( Vertex[i].y - m.y ) * ( Vertex[i].z - m.z );
}

C11 /= Size;
C22 /= Size;
C33 /= Size;
C12 /= Size;
C13 /= Size;
C23 /= Size;

float Matrix[3][3] = {
    { C11, C12, C13 },
    { C12, C22, C23 },
    { C13, C23, C33 },
};

平均からの距離を求めて、それを行列にしている部分ですね。
ただ、いくつかの行列要素は同じ計算結果になるため必要な部分だけを計算して行列にしているのが分かります。

これで「分散共分散行列」を求めることができます。
次にここから「固有ベクトル」を求め、OBBの軸を求めます。

なお、コードを紹介してくれた記事では以下のように続いています。

こうして得られる分散共分散行列ことMatrix[3][3]は、
実対称行列なのでjacobi法で固有ベクトルを求めることができます。

固有ベクトルを求める

さて、だいぶ遠回りしてしまいましたが、ふんわりと、どういう処理を用いてOBBを計算するのかを見てきました。

最後に、求めたいのはOBBの軸と長さです。
3軸の向きと長さが分かればそこからOBBを求めることができますね。

ということで固有ベクトルを求める計算です。

jacobi法(ヤコビ法)で固有ベクトルを求める

前述の通り、分散共分散行列を求め、そこからjacobi法(ヤコビ法)という手法を用いて固有ベクトルを求めます。

jacobi法についてはこちらの記事(Jacobi法 - [物理のかぎしっぽ])を参考にさせていただきました。

該当記事の冒頭の解説を引用させてもらうと、

Jacobi法(ヤコビ法)を用いて行列の固有値固有ベクトルを求めてみたいと思います. Jacobi法では対称行列しか扱えないという制約があるものの,アルゴリズム自体も簡単な上に,解が必ず実数になる事もあり,大変シンプルです.

とあります。
そして分散共分散行列は対称行列のためこの方法が使える、というわけのようです。

さて、実際の求め方ですが、引用させていただくと、

 P^{-1} AP = B
となるとき,行列Aと行列Bの固有値は一致します.また,行列Aが対称行列である場合,直交行列Pを用いて

 P^{-1}AP = \Lambda
と,対角化することができ,行列Λの対角成分は行列Λの固有値となります.ここで直交行列Pの列ベクトルとそれに対応する行列Λの列の対角成分は固有値固有ベクトルの関係となります.

このことから行列Aの固有値固有ベクトルを求めるには,行列Aを対角化する直交行列Pを求めれば良いことになります.

Jacobi法ではこの直交行列Pを反復作業で求めています.つまり,行列Aの非対角成分の1つをゼロにする直交行列を用い,

 P^{-1}AP ⇒ A
とする事を繰り返します.

[tex: P^{-1}n … P^{-1}4 P^{-1}3 P^{-1}2 P^{-1}_1 AP_1 P_2 P_3 P_4 … P_n = \Lambda]
これを繰り返し全ての非対角成分をゼロにし,対角化をするのがJacobi法です.また,

 P_1 P_2 P_3 P_4 … P_n
この部分が行列Aを対角化する直交行列であり,列ベクトルは固有ベクトルとなります.

上記の説明の中で出てくる式や単語を整理しておきます。
直交行列」ですが、意味は逆行列と転置行列が等しくなる正方行列のことです。以下を満たす行列ですね。


M^T M = M M^T = E

また「対角化」を調べてみると以下の記事が分かりやすいです。

mathtrain.jp

記事から説明を引用すると、

与えられた正方行列 A に対して,正則行列 P をうまく取ってきて  P^{−1}AP を対角行列にする操作を対角化と言う。

ヤコビ法で語られていた以下の式が出てきました。


P^{-1} AP

ここで「正則行列Pをうまく取ってきて」とあります。
つまりこの「うまく取ってくる」というのがヤコビ法、ということになりますね。

さて実際のヤコビ法の計算方法ですが、詳細は紹介した記事を読んでみてください。
プログラムでの計算方法も掲載されています。

今回はそのプログラムコードを参考にさせていただき、Unity上で使えるようにC#で書き直しました。

C#コード

最後に、実際に書いたコードを掲載しておきます。よかったら参考にしてみてください。

以下のコードを貼り付けて実行すると、下図のようにOBBが表示されます。

f:id:edo_m18:20180627132122p:plain

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

public class OBBTest : MonoBehaviour
{
    private float _max = 0.0001f;

    private Vector3[] _edges = new Vector3[0];
    private Vector3 _origin;

    private void OnDrawGizmos()
    {
        if (_edges.Length == 0)
        {
            return;
        }

        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(_origin, 0.01f);

        Gizmos.color = Color.blue;

        Vector3 from, to;

        Vector3 ftr = _origin + (_edges[0] * 0.5f) + (_edges[1] * 0.5f) + (_edges[2] * 0.5f);
        Vector3 fbr = ftr - _edges[2];
        Vector3 ftl = ftr - _edges[0];
        Vector3 fbl = ftl - _edges[2];

        Vector3 btr = ftr - _edges[1];
        Vector3 bbr = btr - _edges[2];
        Vector3 btl = btr - _edges[0];
        Vector3 bbl = btl - _edges[2];

        Gizmos.DrawLine(ftr, fbr);
        Gizmos.DrawLine(ftr, ftl);
        Gizmos.DrawLine(ftl, fbl);
        Gizmos.DrawLine(fbl, fbr);

        Gizmos.DrawLine(btr, bbr);
        Gizmos.DrawLine(btr, btl);
        Gizmos.DrawLine(btl, bbl);
        Gizmos.DrawLine(bbl, bbr);

        Gizmos.DrawLine(ftr, btr);
        Gizmos.DrawLine(ftl, btl);
        Gizmos.DrawLine(fbr, bbr);
        Gizmos.DrawLine(fbl, bbl);
    }

    private void Awake()
    {
        CalcEdges();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.U))
        {
            CalcEdges();
        }
    }

    private void CalcEdges()
    {
        MeshFilter filter = GetComponent();

        Vector3[] vertices = filter.mesh.vertices;

        float[,] eigenVectors = GetEigenVectors(CollectMatrix(vertices));

        int rank = eigenVectors.Rank;

        Vector3 vec1, vec2, vec3;
        {
            float x = eigenVectors[0, 0];
            float y = eigenVectors[1, 0];
            float z = eigenVectors[2, 0];

            vec1 = new Vector3(x, y, z);
        }
        {
            float x = eigenVectors[0, 1];
            float y = eigenVectors[1, 1];
            float z = eigenVectors[2, 1];

            vec2 = new Vector3(x, y, z);
        }
        {
            float x = eigenVectors[0, 2];
            float y = eigenVectors[1, 2];
            float z = eigenVectors[2, 2];

            vec3 = new Vector3(x, y, z);
        }

        // 全頂点に対して内積を取り、最小値・最大値を計算する
        float min1 = float.MaxValue;
        float min2 = float.MaxValue;
        float min3 = float.MaxValue;
        float max1 = float.MinValue;
        float max2 = float.MinValue;
        float max3 = float.MinValue;

        for (int i = 0; i < vertices.Length; i++)
        {
            Vector3 pos = vertices[i];
            float dot1 = Vector3.Dot(vec1, pos);
            if (dot1 > max1)
            {
                max1 = dot1;
            }
            if (dot1 < min1)
            {
                min1 = dot1;
            }

            float dot2 = Vector3.Dot(vec2, pos);
            if (dot2 > max2)
            {
                max2 = dot2;
            }
            if (dot2 < min2)
            {
                min2 = dot2;
            }

            float dot3 = Vector3.Dot(vec3, pos);
            if (dot3 > max3)
            {
                max3 = dot3;
            }
            if (dot3 < min3)
            {
                min3 = dot3;
            }
        }

        float len1 = max1 - min1;
        float len2 = max2 - min2;
        float len3 = max3 - min3;

        Vector3 edge1 = transform.localToWorldMatrix.MultiplyVector(vec1 * len1);
        Vector3 edge2 = transform.localToWorldMatrix.MultiplyVector(vec2 * len2);
        Vector3 edge3 = transform.localToWorldMatrix.MultiplyVector(vec3 * len3);

        _edges = new[]
        {
            edge1, edge2, edge3,
        };

        Vector3 center1 = (vec1 * (max1 + min1)) * 0.5f;
        Vector3 center2 = (vec2 * (max2 + min2)) * 0.5f;
        Vector3 center3 = (vec3 * (max3 + min3)) * 0.5f;

        _origin = transform.localToWorldMatrix.MultiplyPoint(center1 + center2 + center3);
    }

    private float[,] CollectMatrix(Vector3[] vertices)
    {
        // 各成分の平均を計算
        Vector3 m = Vector3.zero;

        for (int i = 0; i < vertices.Length; i++)
        {
            m += vertices[i];
        }

        m /= vertices.Length;

        float c11 = 0; float c22 = 0; float c33 = 0;
        float c12 = 0; float c13 = 0; float c23 = 0;

        for (int i = 0; i < vertices.Length; i++)
        {
            c11 += (vertices[i].x - m.x) * (vertices[i].x - m.x);
            c22 += (vertices[i].y - m.y) * (vertices[i].y - m.y);
            c33 += (vertices[i].z - m.z) * (vertices[i].z - m.z);

            c12 += (vertices[i].x - m.x) * (vertices[i].y - m.y);
            c13 += (vertices[i].x - m.x) * (vertices[i].z - m.z);
            c23 += (vertices[i].y - m.y) * (vertices[i].z - m.z);
        }

        c11 /= vertices.Length;
        c22 /= vertices.Length;
        c33 /= vertices.Length;
        c12 /= vertices.Length;
        c13 /= vertices.Length;
        c23 /= vertices.Length;

        float[,] matrix = new float[,]
        {
            { c11, c12, c13 },
            { c12, c22, c23 },
            { c13, c23, c33 },
        };

        return matrix;
    }

    /// 
    /// 行列の中の絶対値の最大値とその位置を返す
    /// 
    /// 評価する行列
    /// 最大値の行位置
    /// 最大値の列位置
    /// 最大値
    private float GetMaxValue(float[,] matrix, out int p, out int q)
    {
        p = 0;
        q = 0;

        int rank = matrix.Rank;

        float max = float.MinValue;

        for (int i = 0; i < rank; i++)
        {
            int len = matrix.GetLength(i);

            for (int j = 0; j < len; j++)
            {
                // 対角成分は評価しない
                if (i == j)
                {
                    continue;
                }

                float absmax = Mathf.Abs(matrix[i, j]);
                if (max <= absmax)
                {
                    max = absmax;
                    p = i;
                    q = j;
                }
            }
        }

        if (p > q)
        {
            int temp = p;
            p = q;
            q = temp;
        }

        return max;
    }

    /// 
    /// 固有ベクトルを得る
    /// 
    /// 評価する行列
    private float[,] GetEigenVectors(float[,] matrix)
    {
        // 固有ベクトルのための行列を正規化
        float[,] eigenVectors = new float[3,3];

        for (int i = 0; i < 3; i++)
        {
            for (int j = 0; j < 3; j++)
            {
                if (i == j)
                {
                    eigenVectors[i,j] = 1f;
                }
                else
                {
                    eigenVectors[i,j] = 0;
                }
            }
        }

        int limit = 100;
        int count = 0;
        int p, q;
        while (true)
        {
            count++;

            if (count >= limit)
            {
                Debug.Log("somothing was wrong.");
                break;
            }

            float max = GetMaxValue(matrix, out p, out q);
            if (max <= _max)
            {
                break;
            }

            float app = matrix[p, p];
            float apq = matrix[p, q];
            float aqq = matrix[q, q];

            float alpha = (app - aqq) / 2f;
            float beta = -apq;
            float gamma = Mathf.Abs(alpha) / Mathf.Sqrt(alpha * alpha + beta * beta);

            float sin = Mathf.Sqrt((1f - gamma) / 2f);
            float cos = Mathf.Sqrt((1f + gamma) / 2f);

            if (alpha * beta < 0)
            {
                sin = -sin;
            }

            for (int i = 0; i < 3; i++)
            {
                float temp = cos * matrix[p, i] - sin * matrix[q, i];
                matrix[q, i] = sin * matrix[p, i] + cos * matrix[q, i];
                matrix[p, i] = temp;
            }

            for (int i = 0; i < 3; i++)
            {
                matrix[i, p] = matrix[p, i];
                matrix[i, q] = matrix[q, i];
            }

            matrix[p, p] = cos * cos * app + sin * sin * aqq - 2 * sin * cos * apq;
            matrix[p, q] = sin * cos * (app - aqq) + (cos * cos - sin * sin) * apq;
            matrix[q, p] = sin * cos * (app - aqq) + (cos * cos - sin * sin) * apq;
            matrix[q, q] = sin * sin * app + cos * cos * aqq + 2 * sin * cos * apq;

            for (int i = 0; i < 3; i++)
            {
                float temp = cos * eigenVectors[i, p] - sin * eigenVectors[i, q];
                eigenVectors[i, + q] = sin * eigenVectors[i, p] + cos * eigenVectors[i, q];
                eigenVectors[i, + p] = temp;
            }
        }

        return eigenVectors;
    }
}

クォータニオン(回転)についてメモ

概要

Unityなどのゲームエンジンや、3D関連を扱っていると頻繁に出てくるクォータニオン
しかしオイラー角に比べると各要素の意味が分かりづらく、直にクォータニオンをいじるケースはあまり多くないと思います。

しかし、少し凝ったことをしようとしたり、あるいはUnityにない機能を実装しようとした場合、クォータニオンを理解していないと困ることがあります。

ということで、今回はクォータニオンについてのちょっとした整理とTipsなどを書いていきたいと思います。

クォータニオンとは

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

数学における四元数(しげんすう、英: quaternion(クォターニオン))は複素数を拡張した数体系である。四元数についての最初の記述は、1843年にアイルランドの数学者ウィリアム・ローワン・ハミルトンによってなされ[1][2]、三次元空間の力学に応用された。四元数の特徴は、二つの四元数の積が非可換となることである。ハミルトンは、四元数を三次元空間内の二つの有向直線の商として定義した[3]。これは二つのベクトルの商と言っても同じである[4]。四元数スカラーと三次元のベクトルとの和として表すこともできる。

と記載があります。
(ちなみにクォータニオンは「四元数」とも言われます。内容的には4次元ベクトルの形だからでしょうか)

正直なんのこっちゃですが、(逆に「そうそう」って思える人はこの記事を読んでないでしょうw) 要はクォータニオンを使うことで3次元空間のベクトルの回転が行える、ということです。

またクォータニオンには行列のように積が非可換である、という点も挙げられます。
が、Unityで使っている上ではそこは気にしなくて大丈夫です。
Unity側でよしなにしてくれるので。

クォータニオンについての詳細は以下の記事がとても有用です。
記事には数式がたくさん出てきて、さらにそれらを巧みに変換しながら美しく解説していくので正直まだ全然理解できていませんが、雰囲気を掴むにはいいと思います。

nyahoon.com

クォータニオンの各要素

クォータニオンは4次元ベクトルの体をなしているので、(x, y, z, w)の4成分からなります。
ただ、オイラー角のように各成分にそれぞれの回転が格納されているわけではありません。

じゃあなにが入っているか、というと以下のような計算結果が格納されています。

  • axis = 回転軸
  • theta = 回転角

とした場合に、以下の計算結果が格納されている。


x = axis.x * sin\biggl(\frac{\theta}{2}\biggr)  \\\
y = axis.y * sin\biggl(\frac{\theta}{2}\biggr)  \\\
z = axis.z * sin\biggl(\frac{\theta}{2}\biggr)  \\\
w = cos\biggl(\frac{\theta}{2}\biggr)

なのでx要素だけを取り出しても、軸の値と\(sin\)の値の合成なのでそれがどんな意味があるのかは分からない、というわけです。

クォータニオンTips

さてここからは、Unityなど実際の開発に使えるちょっとしたTips、覚えておくと幸せになれるかもしれないものをつらつらと書いていきます。

内積

3次元ベクトルと同様、クォータニオンにも内積が定義されています。

Quaternion q1 = /* any quartenion */
Quaternion q2 = /* any quartenion */
float cos = q1.x * q2.x + q1.y * q2.y + q1.z * q2.z + q1.w * q2.w;
float angle = Mathf.Acos(cos) * Mathf.Rad2Deg;

上記のように、内積は3次元ベクトルと同様、ふたつのクォータニオンが成す角度\(\cos(\theta)\)となります。

UnityのAPIにもQuaternion.Dot(q1, q2)というメソッドが用意されています。

回転軸

先述した通り、クォータニオンは正規化した回転軸の各要素と\(sin\bigl(\frac{\theta}{2}\bigr)\)との掛け算により計算されます。

ただ、掛ける値は\(x, y, z\)各要素とも同じ値なので、これを正規化すれば回転軸のベクトルを得ることができます。

また、クォータニオンの各要素を考えると\(sin\bigl(\frac{\theta}{2}\bigr)\)と同じ値が得られれば、それで割ることで回転軸の値を得ることもできます。

// 2通りの方法で回転軸を計算する
private void ShowRotationAxis()
{
    Quaternion q = transform.rotation;
    
    Vector3 axis1 = new Vector3(q.x, q.y, q.z);
    axis1.Normalize();

    // 以下のようにsinの値で割っても同じ結果がられる
    Vector3 axis2 = new Vector3(q.x, q.y, q.z);
    float theta = Mathf.Acos(q.w);
    float sin = Mathf.Sin(theta);

    axis2.x /= sin;
    axis2.y /= sin;
    axis2.z /= sin;

    Debug.LogFormat("x1: {0}", axis1.x);
    Debug.LogFormat("y1: {0}", axis1.y);
    Debug.LogFormat("z1: {0}", axis1.z);

    Debug.LogFormat("x2: {0}", axis2.x);
    Debug.LogFormat("y2: {0}", axis2.y);
    Debug.LogFormat("z2: {0}", axis2.z);
}

ワールド空間での回転クォータニオンをローカル空間に変換する

あまり実用性はないかもしれませんが、ワールド空間で表されているクォータニオンを、ローカル空間でのクォータニオンに変換する処理です。

transform.rotation = q;とすると決まる回転を、transform.localRotation = q;として設定できるようにするイメージです。

private Quaternion ConvertToLocal(Quaternion q)
{
    Quaternion result = Quaternion.identity;

    Transform current = transform.parent;

    while (current != null)
    {
        result *= Quaternion.Inverse(current.localRotation);
        current = current.parent;
    }

    return result * q;
}

考え方はシンプルに、親のlocalRotationの逆クォータニオンを合算して戻します。
クォータニオンは掛ける順番が変わると結果が変わるので注意が必要です。

クォータニオンのSlerp

クォータニオンにもベクトル同様、LerpSlerpという補間処理があります。
そしてSlerpの意味は「球面線形補間」です。

基本的にはUnity APIにすでにあるのでそれを利用するのがいいと思いますが、どういう処理がされているのかを把握しておくことは大事でしょう。

※ ちなみに余談ですが、今回の実装では補間するweightを0〜1に限定していないので、マイナス値や1以上の値を指定することができます。UnityのAPIでは0〜1の間に限定しているので、それを超えた補間(といっていいか分かりませんがw)をする場合は独自で実装する必要があります。

ということでSlerp処理のアルゴリズムです。
計算のアルゴリズム自体はこちらのC++実装(slerp.cpp - Sony CSL)を参考にしました。

また、いつもお世話になっているマルペケさんの記事にとても分かりやすい解説があるので合わせて読んでみるといいと思います。

参考:その57 クォータニオンを"使わない"球面線形補間

Slerp処理の手順を先に書いておくと、

  1. ふたつのクォータニオンの回転差分を計算する
  2. 補間の係数(tと1-tのふたつ)を元に、(1)で計算した角度の比率として係数を掛けた値を計算する
  3. (2)で求めた値を\(\sin(\theta)\)で割る
  4. (3)で求めた値を係数として、ふたつのクォータニオンを合成する

という流れです。

まずは以下の図を見てください。

f:id:edo_m18:20180624091805p:plain

上図はふたつのベクトルを球面上で線形に補間する様子を示しています。
まず、StartからEndに向かってベクトルが補間されていきます。

その補間の度合いを決めるのが媒介変数\(t\)です。
そしてふたつのベクトルが成す角が\(\theta\)です。

球面線形補間を行うということは、ふたつのベクトル間を等速の角速度で移動する必要があります。
つまり、媒介変数\(t\)を利用すると\(t\theta\)で値が変化していけばいいわけです。

このとき、\(t\)の位置に向かうベクトルはふたつのベクトルの合成で表すことができます。
具体的にはStartベクトルとEndベクトルに平行なベクトルです。
図では黒い矢印になっている部分ですね。

さて、ではそのベクトルの長さはどう求めたらいいでしょうか。
ここで、三角形の比率の話を思い出すと、縦軸に書かれている\(\sin(\theta)\)と\(\sin(\theta(1-t))\)の比率がそのままStartに向かうベクトルと\(t\)の位置のベクトルとの比率になるのが分かるかと思います。

そしてStartベクトルは単位ベクトルなので長さは1です。
また求めたいベクトルを仮に\(V(t)\)と置くと、 「薄い緑のベクトル(1) : 濃い緑のベクトル(\(V(t)\))」の比率は「\(\sin(\theta)\) : \(\sin(\theta(1-t))\)」という比率になります。

これを整理すると以下のようになります。


1 : V(t) = \sin(\theta) : \sin(\theta(1-t))  \\\
V(t) = \frac{\sin(\theta(1-t))}{\sin(\theta)}

以上を踏まえて以下の実装を見てみると、\(t\theta\)と\((1-t)\theta\)で係数を計算して、それぞれのクォータニオンを合成しているのが分かるかと思います。

/// 
/// Quaternion's slerp
///
/// Refer: https://www.sonycsl.co.jp/person/nielsen/visualcomputing/programs/slerp.cpp
/// 
/// Start Quaternion
/// End Quaternion
/// Weight
/// Mixed quaternion by weight.
static public Quaternion Slerp(Quaternion a, Quaternion b, float t)
{
    float dot = Quaternion.Dot(a, b);
    float theta = Mathf.Acos(dot);

    if (theta < 0.0)
    {
        theta = -theta;
    }

    float st = Mathf.Sin(theta);

    if (st == 0)
    {
        return a;
    }

    float sut = Mathf.Sin(theta * t);
    float sout = Mathf.Sin(theta * (1f - t));

    float coeff1 = sout / st;
    float coeff2 = sut / st;

    float x = coeff1 * a.x + coeff2 * b.x;
    float y = coeff1 * a.y + coeff2 * b.y;
    float z = coeff1 * a.z + coeff2 * b.z;
    float w = coeff1 * a.w + coeff2 * b.w;

    return new Quaternion(x, y, z, w);
}

オイラー角からクォータニオンを求める

ちょっとしたメモということで、今度はオイラー角からクォータニオンを求めることをやってみようと思います。

計算式についてはWikipediaに記載があるので引用させてもらうと、


q\_{lB} = 
\begin{vmatrix}
\cos\bigl(\frac{\psi}{2}\bigr) \\\
0 \\\
0 \\\
\sin\bigl(\frac{\psi}{2}\bigr)
\end{vmatrix} 
\begin{vmatrix}
\cos\bigl(\frac{\theta}{2}\bigr) \\\
0 \\\
\sin\bigl(\frac{\theta}{2}\bigr) \\\
0 \\\
\end{vmatrix} 
\begin{vmatrix}
\cos\bigl(\frac{\phi}{2}\bigr) \\\
\sin\bigl(\frac{\phi}{2}\bigr) \\\
0 \\\
0 \\\
\end{vmatrix} \\\ = 
\begin{vmatrix}
\cos\bigl(\frac{\phi}{2}\bigr) \cos\bigl(\frac{\theta}{2}\bigr) \cos\bigl(\frac{\psi}{2}\bigr) + \sin\bigl(\frac{\phi}{2}\bigr) \sin\bigl(\frac{\theta}{2}\bigr) \sin\bigl(\frac{\psi}{2}\bigr) \\\
\sin\bigl(\frac{\phi}{2}\bigr) \cos\bigl(\frac{\theta}{2}\bigr) \cos\bigl(\frac{\psi}{2}\bigr) - \cos\bigl(\frac{\phi}{2}\bigr) \sin\bigl(\frac{\theta}{2}\bigr) \sin\bigl(\frac{\psi}{2}\bigr) \\\
\cos\bigl(\frac{\phi}{2}\bigr) \sin\bigl(\frac{\theta}{2}\bigr) \cos\bigl(\frac{\psi}{2}\bigr) + \sin\bigl(\frac{\phi}{2}\bigr) \cos\bigl(\frac{\theta}{2}\bigr) \sin\bigl(\frac{\psi}{2}\bigr) \\\
\cos\bigl(\frac{\phi}{2}\bigr) \cos\bigl(\frac{\theta}{2}\bigr) \sin\bigl(\frac{\psi}{2}\bigr) - \sin\bigl(\frac{\phi}{2}\bigr) \sin\bigl(\frac{\theta}{2}\bigr) \cos\bigl(\frac{\psi}{2}\bigr) \\\
\end{vmatrix} 

という計算を行うことで求めることができます。
ここで最初のベクトルの計算は、オイラー角なのでXYZ軸が回転していない通常の座標空間において、XYZ軸それぞれの回転を合成したクォータニオン、と見ることができます。

なので、上記の合成では「ZYX」の順で合成していることになりますね。
ただこの合成の順番は特に決まりがあるわけではなく、エンジンごとにどういう順番で掛けるのか、を決めておく必要があります。
調べたところ、Unityでは「YXZ」の順で掛けているようです。

※ 余談ですが、Three.jsの実装を見てみると、オーダーを指定できるようになっており、そこから色々試したところ「YXZ」が同じ結果になりました。

それらを踏まえて、実際のC#コードは以下のようになります。

mary>
/// Convert euler to quaternion
/// 
/// Euler's x
/// Euler's y
/// Euler's z
/// Quternion from euler angles.
static public Quaternion Euler(float x, float y, float z)
{
    x *= Mathf.Deg2Rad;
    y *= Mathf.Deg2Rad;
    z *= Mathf.Deg2Rad;

    float c1 = Mathf.Cos(x * 0.5f);
    float c2 = Mathf.Cos(y * 0.5f);
    float c3 = Mathf.Cos(z * 0.5f);

    float s1 = Mathf.Sin(x * 0.5f);
    float s2 = Mathf.Sin(y * 0.5f);
    float s3 = Mathf.Sin(z * 0.5f);

    // Unity's order is 'YXZ'
    float qx = s1 * c2 * c3 + c1 * s2 * s3;
    float qy = c1 * s2 * c3 - s1 * c2 * s3;
    float qz = c1 * c2 * s3 - s1 * s2 * c3;
    float qw = c1 * c2 * c3 + s1 * s2 * s3;

    // 'ZXY'
    // float qx = s1 * c2 * c3 - c1 * s2 * s3;
    // float qy = c1 * s2 * c3 + s1 * c2 * s3;
    // float qz = c1 * c2 * s3 + s1 * s2 * c3;
    // float qw = c1 * c2 * c3 - s1 * s2 * s3;
    
    // 'XYZ'
    // float qx = s1 * c2 * c3 + c1 * s2 * s3;
    // float qy = c1 * s2 * c3 - s1 * c2 * s3;
    // float qz = c1 * c2 * s3 + s1 * s2 * c3;
    // float qw = c1 * c2 * c3 - s1 * s2 * s3;

    // 'ZYX'
    // float qx = s1 * c2 * c3 - c1 * s2 * s3;
    // float qy = c1 * s2 * c3 + s1 * c2 * s3;
    // float qz = c1 * c2 * s3 - s1 * s2 * c3;
    // float qw = c1 * c2 * c3 + s1 * s2 * s3;

    // 'YZX'
    // float qx = s1 * c2 * c3 + c1 * s2 * s3;
    // float qy = c1 * s2 * c3 + s1 * c2 * s3;
    // float qz = c1 * c2 * s3 - s1 * s2 * c3;
    // float qw = c1 * c2 * c3 - s1 * s2 * s3;

    // 'XZY'
    // float qx = s1 * c2 * c3 - c1 * s2 * s3;
    // float qy = c1 * s2 * c3 - s1 * c2 * s3;
    // float qz = c1 * c2 * s3 + s1 * s2 * c3;
    // float qw = c1 * c2 * c3 + s1 * s2 * s3;

    return new Quaternion(qx, qy, qz, qw);
}

クォータニオンからオイラー角を求める

さて最後に、逆のクォータニオンからオイラー角を求める計算・・なのですが、それについては以前Qiitaで書いたのでそちらを参照ください。

qiita.com

回転の方向

クォータニオンを利用して回転を制御していると、たまに意図しない方向に回転することがあるかもしれません。特にアニメーションなどをしていると、思っている方向と逆に回る、など。

その答えは、安原さんのこちらのスライドのP.111あたりを見ると分かります。

www.slideshare.net

クォータニオンのすべての要素を反転(マイナスを掛ける)と、最終的な姿勢は同じでも回転方向が異なるということです。なので、スライドでは w の要素をチェックし、マイナスだった場合は全体を反転させる、という方法で回転を制御しています。

回転に困った場合はこのあたりを考えてみると解決するかもしれません。

最後に

クォータニオン、奥が深いですね。
Unityなどで利用する限りにおいては基本的にエンジン側で色々よしなにしてくれるので気にすることはありませんが、だいぶ複雑なことをやってくれているんだなーと実感させられます。

PlayerPrefsを操作するエディタウィンドウを作成する

概要

EditorWindowを継承した、エディタ拡張のメモです。
普段、エディタ作成をそんなにしていないので忘れがちなので。

ちなみに今回は、汎用的になるようにPlayerPrefsを操作するっていう名目でサンプルを作成しました。

準備

エディタ拡張の詳細はここでは触れません。
ここではEditorWindowクラスを継承して、ウィンドウタイプのエディタ拡張を制作するまでを書きます。

まず、UnityEditor.EditorWindowクラスを継承したクラスを作成します。
ウィンドウのインスタンスは初回のみ生成するようにしています。
ウィンドウの生成にはCreateInstance<T>();スタティックメソッドを利用します。

メニューから生成されるには以下のようにします。

public class PlayerPrefsEditor : EditorWindow
{
    static private PlayerPrefsEditor _window;

    [MenuItem("Window/PlayerPrefsEditor")]
    static private void Open()
    {
        if (_window == null)
        {
            _window = CreateInstance<PlayerPrefsEditor>();
        }
    }
}

メニューには以下のように表示され、これを実行するとウィンドウが開きます。

f:id:edo_m18:20180520110044p:plain

ウィンドウの描画処理

ウィンドウが生成されると、インスペクタのエディタ拡張などと同じくOnGUIメソッドが定期的に呼ばれるようになります。
そこに、ボタンやラベルなどを表示させ、インタラクションさせることによってエディタを実装します。

private void OnGUI()
{
    EditorGUILayout.LabelField("---- PlayerPrefs List ----");
}

この中に様々な処理を書けばリッチなエディタを作ることができます。
今回は実際に使用した部分だけをメモとして残しておきます。

スクロールビューを作る

項目が増えてくるとスクロールしないと収まらなくなってきます。
そんなときはスクロールビューを実装します。

スクロールビューにしたい箇所をGUILayout.BeginScrollView()GUILayout.EndScrollView()ではさみます。

具体的には以下のようにします。

private Vector2 _scrollPos;

private void OnGUI()
{
    _scrollPos = GUILayout.BeginScrollView(_scrollPos, GUI.skin.box);

    // ... do anything.

    GUILayout.EndScrollView();
}

_scrollPosは現在のスクロール状態を保持するためにインスタンス変数として保持しておきます。

項目を枠でグルーピングする

また、項目が増えてくると各項目ごとにグルーピングしたくなってきます。
その場合はスクロールビューと似た感じでEditorGUILayout.BeginVertical()EditorGUILayout.EndVertical()のメソッドで項目をはさみます。

private void OnGUI()
{
    EditorGUILayout.BeginVertical(GUI.skin.box, GUILayout.ExpandWidth(true));

    // ... do anything.

    EditorGUILayout.EndVertical();
}

最後に、最近のプロジェクトで使ったやつを一部を伏せつつ載せておきます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using ElminaAR.Systems;

public class PlayerPrefsEditor : EditorWindow
{
    static private PlayerPrefsEditor _window;
    static private readonly string _userDataFoldoutKey = "UserDataFoldoutKey";

    private Vector2 _scrollPos;

    private bool _isFirst;

    [MenuItem("Window/PlayerPrefsEditor")]
    static private void Open()
    {
        if (_window == null)
        {
            _window = CreateInstance<PlayerPrefsEditor>();
        }

       #region ### Player Config ###
        _window._isFirst = PlayerConfig.IsFirstPlay;
       #endregion ### Player Config ###

        _window.ShowUtility();
    }

    private void OnGUI()
    {
        ShowEdit();
    }

    private void OnDestroy()
    {
        //
    }

    /// <summary>
    /// エディット画面を表示する
    /// </summary>
    private void ShowEdit()
    {
        _scrollPos = GUILayout.BeginScrollView(_scrollPos, GUI.skin.box);
        EditorGUILayout.LabelField("---- Player Config ----");
        _isFirst = EditorGUILayout.Toggle("初回プレイ: ", _isFirst);
        GUILayout.EndScrollView();

        if (GUILayout.Button("更新"))
        {
            Apply();
        }
    }

    /// <summary>
    /// PlayerPrefsを更新
    /// </summary>
    private void Apply()
    {
        PlayerConfig.IsFirstPlay = _isFirst;
    }
}

これを実際に表示すると以下のようなウィンドウになります。(一部マスク)

f:id:edo_m18:20180612095428p:plain

ライトマップデータから影の情報を抜き出し頂点カラーに焼く

概要

とあるプロジェクトで頂点カラーにライトマップの影情報を焼き込めないか、という話があって試してみました。
結局その機能自体は使いませんでしたが、ライトマップデータへのアクセスなど学びがあったのでまとめておきます。

ライトマップデータの読み取り

まずはライトマップデータの取り扱いです。
ライトマップは、Lightの「Mode」をMixedBakedにし、Lightingウィンドウの「Generate Lighting」ボタンを押すことで生成されます。

生成されたライトマップのデータはこんな感じです↓
f:id:edo_m18:20180517113239p:plain

ライトマップの扱いはLightmapDataクラスを使う

ライトマップのデータは、LightmapDataクラスを介して取得します。

ライトマップデータを取り出す

オブジェクトに紐づくデータは以下のようにして取り出します。

MeshRenderer renderer = GetComponent<MeshRenderer>();
LightmapData data = LightmapSettings.lightmaps[renderer.lightmapIndex];
Texture2D lightmap = data.lightmapColor;

RendererクラスのlightmapIndexプロパティに、該当オブジェクトのライトマップデータの配列のindexが格納されているので、それを利用してLightmapSettings.lightmaps配列からライトマップデータを取得することができます。

ライトマップデータ(テクスチャ)はLightmapData.lightmapColorに格納されています。

ライトマップデータからカラー情報を取得する

ライトマップデータを取り出したら、そこからカラー情報を取得します。
ライトマップは通常、複数オブジェクトの情報がひとつ(ないし複数)のテクスチャに、テクスチャアトラスとして書き出されます。

つまり、ライトマップデータは複数オブジェクトの情報を含んでいるため、適切にテクスチャからカラー情報を取得(フェッチ)しないとなりません。

※ 注意点として、生成されたライトマップデータは読み取りができない状態になっているので、インスペクタの設定から「Read/Write Enabled」のチェックをオンにする必要があります。

f:id:edo_m18:20180517114150p:plain

そのための処理は以下のようになります。

int width = lightmap.Width;
int height = lightmap.Height;

Vector4 scaleOffset = renderer.lightmapScaleOffset;
Color[] colors = new Color[mesh.uv.Length];

for (int i = 0; i < mesh.uv.Length; i++)
{
    Vector2 uv = mesh.uv[i];
    float uv_x = (uv.x * scaleOffset.x) + scaleOffset.z;
    float uv_y = (uv.y * scaleOffset.y) + scaleOffset.w;
    Color pixel = lightmap.GetPixelBilinear(uv_x, uv_y);
    colors[i] = pixel;
}

mesh.colors = colors;

上記のように、Renderer.lightmapScaleOffsetに、ライトマップデータのオフセット情報が入っています。
それに、メッシュが持つ元々のUV情報と組み合わせてフェッチすることで、目的のライトマップのカラー情報を取得することができます。

今回はメッシュの頂点カラーに焼き込む、というのが目的なので、最終的に取得したピクセル配列をメッシュの頂点カラーとして設定しています。

そして焼き込んだメッシュ情報を利用する場合、アセットとして新規作成しておかないとならないため、今回のサンプルでは元のメッシュを複製、アセットとして保存する処理もあります。

ということで、今回実装した内容全体を下記に記載しておきます。

Meshを生成する

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

public static class BakeLightMap2VC
{
    /// <summary>
    /// Clone mesh asset.
    /// 
    /// If folder in path doesn't exist, will create all folders in path.
    /// </summary>
    public static Mesh CloneMesh(GameObject target)
    {
        if (target == null)
        {
            Debug.LogWarning("Must select a GameObject.");
            return null;
        }

        MeshFilter filter = target.GetComponent<MeshFilter>();
        if (filter == null)
        {
            Debug.LogWarning("CloneMesh need a MeshFilter component.");
            return null;
        }

        Mesh newMesh = GameObject.Instantiate(filter.sharedMesh);

        string[] folderPaths = new[]
        {
            "ClonedMesh",
        };

        string path = "Assets";
        for (int i = 0; i < folderPaths.Length; i++)
        {
            string folderName = folderPaths[i];
            string checkPath = path + "/" + folderName;
            if (!AssetDatabase.IsValidFolder(checkPath))
            {
                AssetDatabase.CreateFolder(path, folderName);
            }
            path = checkPath;
        }

        // Create and save a mesh asset.
        string assetPath = path + "/" + target.name + ".asset";
        string[] findAssets = AssetDatabase.FindAssets(target.name, new[] { path });
        if (findAssets.Length != 0)
        {
            int result = EditorUtility.DisplayDialogComplex("重複チェック", "既存のファイルがあります。上書きしますか?", "はい", "いいえ", "別名で保存");
            if (result == 1)
            {
                return null;
            }
            else if (result == 2)
            {
                assetPath = AssetDatabase.GenerateUniqueAssetPath(assetPath);
            }
        }

        AssetDatabase.CreateAsset(newMesh, assetPath);
        AssetDatabase.SaveAssets();

        EditorGUIUtility.PingObject(newMesh);

        return newMesh;
    }

    /// <summary>
    /// Bake a lightmap shadow to vertex colors.
    /// </summary>
    [MenuItem("Assets/Tools/BakeLightMap2VC")]
    public static void Bake()
    {
        GameObject target = Selection.activeGameObject;
        if (target == null)
        {
            Debug.LogWarning("Must select a GameObject.");
            return;
        }

        // Clone selected object's mesh.
        Mesh clonedMesh = CloneMesh(target);
        if (clonedMesh == null)
        {
            Debug.LogError("Can't clone mesh data.");
            return;
        }

        MeshRenderer renderer = target.GetComponent<MeshRenderer>();
        if (renderer == null)
        {
            Debug.LogWarning("Must have a MeshRenderer.");
            return;
        }
        LightmapData data = LightmapSettings.lightmaps[renderer.lightmapIndex];
        Texture2D lightMap = data.lightmapColor;

        MeshFilter filter = target.GetComponent<MeshFilter>();
        filter.sharedMesh = clonedMesh;

        #region ### Sample LightMap data and bake it to vertex colors ###
        int width = lightMap.width;
        int height = lightMap.height;
        Vector4 scaleOffset = renderer.lightmapScaleOffset;
        Color[] colors = new Color[clonedMesh.uv.Length];
        for (int i = 0; i < clonedMesh.uv.Length; i++)
        {
            Vector2 uv = clonedMesh.uv[i];
            float uv_x = (uv.x * scaleOffset.x) + scaleOffset.z;
            float uv_y = (uv.y * scaleOffset.y) + scaleOffset.w;
            Color pixel = lightMap.GetPixelBilinear(uv_x, uv_y);
            colors[i] = pixel;
        }
        clonedMesh.colors = colors;
        #endregion ### Sampling LightMap data ###
    }
}

頂点カラーを利用するシェーダ

さて最後に。
頂点カラーに情報を書き込んでも、それを読み出さなくては意味がありません。

そして残念ながら、最近の3Dではあまり頂点カラーを使わないため、Unityのデフォルトのシェーダでは頂点カラー情報は処理されません。
なので、頂点カラーを読み出す処理は自分で書く必要があります。

といっても、頂点カラー情報を取得するにはシェーダへの入力に頂点カラーのパラメータを追加するだけです。

struct v2f
{
    fixed4 vertex : SV_POSITION;
    fixed4 color : COLOR; // <- これを追加
    fixed2 uv : TEXCOORD0;
};

あとはこれを、最終出力に乗算してやるだけでOKです。

half4 frag(v2f i) : SV_Target
{
    fixed2 uv = (i.uv * _MainTex_ST.xy) + _MainTex_ST.zw;
    fixed4 col = tex2D(_MainTex, uv);
    return col * i.color * _Color;
}

シェーダ全体も載せておきます。

Shader "VertexShadow" {
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 150
        
        Pass
        {
            Tags { "LightMode"="ForwardBase" "Queue"="Geometry-10" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0
            #include "UnityCG.cginc"

            sampler2D _MainTex;
            fixed4 _MainTex_ST;
            fixed4 _Color;

            struct v2f
            {
                fixed4 vertex : SV_POSITION;
                fixed4 color : COLOR;
                fixed2 uv : TEXCOORD0;
            };

            UNITY_INSTANCING_CBUFFER_START(Props)
            UNITY_INSTANCING_CBUFFER_END

            v2f vert(appdata_full i)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(i.vertex);
                o.color = i.color;
                o.uv = i.texcoord;
                return o;
            }

            half4 frag(v2f i) : SV_Target
            {
                fixed2 uv = (i.uv * _MainTex_ST.xy) + _MainTex_ST.zw;
                fixed4 col = tex2D(_MainTex, uv);
                return col * i.color * _Color;
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

参考

tsubakit1.hateblo.jp

tsubakit1.hateblo.jp

簡易的なiOS向けネイティブプラグインを書いてみる

概要

少し前にAndroid向けのネイティブプラグインを書くための記事を書きました。

edom18.hateblo.jp

edom18.hateblo.jp

今回はiOS向けのプラグインです。
が、今回書くのはごく簡単なプラグイン機能の実装です。

つまりmmファイルを作成してそれを利用するだけの簡単な実装です。
発端はARKitアプリで画面をキャプチャする必要があったのでそのための準備です。

こちらの記事を参考にさせていただきました。

marunouchi-tech.i-studio.co.jp

ネイティブ側の処理を「.mm」ファイルに書く

Plugins/iOS以下にファイルを置く

なにはなくともネイティブ側で動作する処理を書かないとなりません。
UnityではPluginsフォルダ以下にiOSというフォルダを作ることで、自動的にXcodeプロジェクトにファイルがエクスポートされるようになっています。

今回実装した例では以下のようにファイルを配置しています。

f:id:edo_m18:20180430050855p:plain

CaptureCallbackScreenshotPluginプラグイン用のファイルです。今回は.h.mmファイルを配置しています。

今回はこの.mmファイルにネイティブ側の処理を書いていきます。
Xcodeプロジェクトにエクスポートされるため、普通のiOS開発と同じ感覚で処理を記述することができます。(つまりObjective-Cをそのまま書ける)

※ エクスポートされた状態↓
f:id:edo_m18:20180509093631p:plain

参考にさせていただいた記事から引用させてもらうと、以下のような形で書くことでネイティブ側の処理を書くことができます。

Cの関数として定義

// ScreenshotPlugin.mmの一部

extern "C" void _PlaySystemShutterSound()
{
    AudioServicesPlaySystemSound(1108);
}

// ... 後略

Objective-Cも当然使える

// CaptureCallback.h

#import <Foundation/Foundation.h>
 
@interface CaptureCallback : NSObject

@property (nonatomic, copy) NSString *objectName;
@property (nonatomic, copy) NSString *methodName;

- (id)initWithObjectName:(NSString *)_objectName methodName:(NSString *)_methodName;
 
- (void)savingImageIsFinished:(UIImage *)_image didFinishSavingWithError:(NSError *)_error contextInfo:(void *)_contextInfo;
 
@end

要はiOS開発でやっていることをUnity上で管理させてエクスポート、ビルド時に結合する、という形です。
なので、iOS開発をしたことがある人であれば新しいことはほぼないと思います。

Unityとネイティブ側で連携する

さて、ネイティブ側の処理は上記のように記述することで対応できます。
あとは今実装したネイティブ側の処理とUnity側で連携させれば完成です。

作成したネイティブ側の機能を利用するには以下のようにC#で記述します。

using System.Runtime.InteropServices; // DLLImport

public class Screenshot : MonoBehaviour
{
    [DllImport("__Internal")]
    static private extern void _PlaySystemShutterSound();

    // ... 以下略
}

DllImportアトリビュートを使って、そのメソッドが外部で定義されていることを伝えます。
また、static externを付ける必要があります。
関数名とメソッド名が同じになっていることを確認してください。

あとは通常のUnity開発と同様に、上記コードのように宣言だけを行ったメソッドを実行してやればXcodeプロジェクトのビルド時に自動的にリンクが行われ、正常に動作するようになります。

ハマった点

Photos.frameworkを追加する

今回のプラグインではスクリーンショットを撮ってそれをカメラロールに保存する、というものです。
そのためPHPhotoLibraryを使っているのですが、必要なPhotos.frameworkはデフォルトで追加されないので、Xcode上で追加してやるかポストプロセスで自動化する必要があります。

既存のフレームワークを追加するだけなので、以下のようにポストプロセス用のエディタスクリプトを書くだけで簡単に追加することができます。

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;

public class XcodePostProcess : MonoBehaviour
{
    [PostProcessBuild]
    static public void OnPostProcessBuild(BuildTarget buildTarget, string path)
    {
        if (buildTarget != BuildTarget.iOS)
        {
            return;
        }

        string projPath = PBXProject.GetPBXProjectPath(path);
        PBXProject proj = new PBXProject();
        proj.ReadFromString(File.ReadAllText(projPath));

        string target = proj.TargetGuidByName("Unity-iPhone");

        // フレームワークを追加する
        proj.AddFrameworkToProject(target, "Photos.framework", false);

        // 反映させる
        File.WriteAllText(projPath, proj.WriteToString());
    }
}

NSPhotoLibraryUsageDescription keyを追記する

上記でも書いたように、カメラロールへのアクセスが必要です。
いつかのupdateでiOSでは、明確に、権限を求める理由を記載する必要があります。

UnitySendMessageのためのstring変換

Objective-Cでは通常、stringはNSStringを利用します。
しかし、UnitySendMessageはchar *型を受け取るため、変換が必要です。
また、今回は写真を保存したあとにコールバックを呼ぶ関係で、オブジェクト名をネイティブ側に渡す必要があるため、相互の変換が必要となります。

相互変換

// NSString* -> char*
NSString *hogeStr = @"hoge";
char *hogeChar = [hogeStr UTF8String];

// char* -> NSString*
const char* hogeChar = "hoge";
NSString *hogeStr = [NSString stringWithCString:hogeChar encoding:NSUTF8StringEncoding];

実際に定義、呼び出す場合は以下のようになります。

extern "C" void _AnyFunction(const char* objectName, const char* methodName)
{   
    NSString* objName = [NSString stringWithCString:objectName encoding:NSUTF8StringEncoding];
    NSString* metName = [NSString stringWithCString:methodName encoding:NSUTF8StringEncoding];

    // do something.
}

UnitySendMessageを安全に使うために、というこちらの記事も合わせて読んでみてください。

kan-kikuchi.hatenablog.com

Unityでクリッピング平面を実装する

概要

今作っているARアプリでクリッピング平面を作る必要が出てきたのでそのメモです。

動作サンプル↓ f:id:edo_m18:20180508201124g:plain

動画サンプルで動かしたものはGitHubで公開しています。

ちなみに、クリッピング平面自体の説明は以前Qiitaに書いたのでそちらをご覧ください。
今回書くのはARアプリで使用したUnityでのクリッピング平面の実装メモです。

qiita.com

実装

今回、Unityで実装するに当たってクリッピング平面の位置や方向などはC#側で計算し、それをシェーダに渡す形で実装しました。

まずはC#コードを。

C#コード

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

public class ClippingPlanes : MonoBehaviour
{
    [SerializeField]
    private Transform[] _clippingPlanes;

    private Material _material;

    private void Awake()
    {
        _material = GetComponent<MeshRenderer>().material;
    }

    private void Update()
    {
        Vector4[] planes = new Vector4[_clippingPlanes.Length];
        Matrix4x4 viewMatrix = Camera.main.worldToCameraMatrix;

        for (int i = 0; i < planes.Length; i++)
        {
            Vector3 viewUp = viewMatrix.MultiplyVector(_clippingPlanes[i].up);
            Vector3 viewPos = viewMatrix.MultiplyPoint(_clippingPlanes[i].position);
            float distance = Vector3.Dot(viewUp, viewPos);
            planes[i] = new Vector4(viewUp.x, viewUp.y, viewUp.z, distance);
        }

        _material.SetVectorArray("_ClippingPlanes", planes);
    }
}

上記は、平面の方向と距離を算出するコードです。
大事なポイントは3つ。

  1. worldToCameraMatrixがビューマトリクス
  2. worldToCameraMatrix.MultiplyVectorで平面方向をビュー座標に変換
  3. worldToCameraMatrix.MultiplyPointで平面位置を変換、方向ベクトルに射影して距離を得る

です。

そして以下のコードで「平面の方向と距離」をVector4としてシェーダに渡します。

planes[i] = new Vector4(viewUp.x, viewUp.y, viewUp.z, distance);

// ... 中略

_material.SetVectorArray("_ClippingPlanes", planes);

ちなみに、複数プレーンでクリッピングができるようにしてみたのでプレーンの方向と距離は配列でシェーダに渡しています。

シェーダでは_ClippingPlanesを利用してクリッピングを実行します。
シェーダコードは以下です。

Shader "Unlit/ClippingPlane"
{
    Properties
    {
        [Toggle] _Positive("Cull Positive", Float) = 1
        _PlaneCount("Plane count", Range(0, 10)) = 0
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Cull Off

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

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 viewVertex : TEXCOORD2;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float _Positive;
            uniform float _PlaneCount;
            uniform float4 _ClippingPlanes[10];
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                o.viewVertex = mul(UNITY_MATRIX_MV, v.vertex);
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                int count = int(_PlaneCount);
                for (int idx = 0; idx < count; idx++)
                {
                    float4 plane = _ClippingPlanes[idx];
                    if (_Positive == 0)
                    {
                        if (dot(plane.xyz, i.viewVertex.xyz) > plane.w)
                        {
                            discard;
                        }
                    }
                    else 
                    {
                        if (dot(plane.xyz, i.viewVertex.xyz) < plane.w)
                        {
                            discard;
                        }
                    }
                }

                fixed4 col = tex2D(_MainTex, i.uv);
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

最後に判定部分。
C#から送られてきた、ビュー座標空間での方向と距離を元にビュー座標空間上でのフラグメントの位置を内積を使って計算し、距離が平面より遠かったらクリッピングdiscard)する、という処理です。

また、今回のサンプルでは_PlaneCountで平面の数を指定できるようにしています。
というのもシェーダでは配列を可変にできないため予め領域を確保しておく必要があります。

なので、外部からいくつの平面が渡ってくるかを指定できるようにしている、というわけです。
今回はサンプルなのでひとまず最大10平面まで受け取れるようにしています。

実際問題、クリッピングを実行したい、という平面の数はそこまで多くならないと思います。
もし多数必要になったらテクスチャにして渡すなどある程度工夫が必要でしょう。

が、今回はあくまでクリッピング平面の実装メモなのでそのあたりは適当です。

余談

ちなみにこの判定は面白くて、どこかの質問サイトで見たのが、キャラクターが地面に接地しているか、の判定にまさにこの仕組みを利用していて、なぜこれで接地が判定できるのですか? という質問がありました。

方向と距離を渡して、あとは内積結果と比較するだけで判定が済むので圧倒的な省コストで判定できることになります。
ロジックとしては覚えておくと色々役に立ちそうです。

余談2

ちなみに、これとは別視点で実装した「異空間から現れてた」ような演出をするシェーダについて過去に書いたので興味があったら読んでみてください。

edom18.hateblo.jp

UnityのCompute ShaderでCurl Noiseを実装(衝突判定編)

概要

今回は「衝突判定編」です。
前回の「流体編」の続編です。

edom18.hateblo.jp


さて、今回は論文で発表されたカールノイズの『衝突判定』について書きたいと思います。

実際に動いている動画↓


本題

カールノイズによる流体のような移動表現については前回書きました。
論文によると、これに加えて剛体などの境界を考慮した動きを実現できるそう。

境界による干渉(論文より抜粋)

境界による干渉について、論文では以下のように書かれています。
原文と、それに対する翻訳(Google翻訳+若干の意訳)を併記します。


境界(Boundaries)

Consider a motionless solid object in the flow. The boundary condition viscous flow must satisfy is \vec{v} = 0.

流れの中に動かないオブジェクトを考えてみる。
その境界条件粘性流は\vec{v} = 0を満たさなければならない。

This can be achieved simply by modulating the potential down to zero with a smoothed step function of distance, so that all the partial derivatives (and hence the curl) of the new potential are zero at the boundary.

これは、距離の平滑化された段階関数を用いてポテンシャルをゼロに変調することでシンプルに達成できる。
つまり、新しいポテンシャルのすべての偏微分(したがってカール)境界でゼロになる。

Of more interest in animation is the inviscid boundary condition, \vec{v} \cdot \vec{n} = 0, requiring that the component of velocity normal to the boundary is zero.

アニメーションで関心があるのは、非粘性境界条件(v \cdot n = 0)である。
必要なのは、境界に垂直な速度の成分がゼロであることだ。

allowing the fluid to slip past tangentially but not to flow through a solid. Most turbulent fluids have such small viscosities that this is a more reasonable approximation.

流体が接線方向に滑ることを可能にするが、固体を通して流れないようにする。
ほとんどの乱流は、これがより合理的な近似であるような小さな粘性を有する。

In two dimensions, note that our velocity field is just the 90◦ rotation of the gradient ∇ψ: if we want the velocity field to be tangent to the boundary, we need the gradient to be perpendicular to the boundary.

二次元では、速度場は勾配(\nabla \psi)の90°回転に過ぎないことに注意。
速度場を境界線に接したい場合は、境界線に対して垂直な勾配が必要だ。

This happens precisely when the boundary is an isocontour of ψ, i.e. when ψ has some constant value along the boundary.

これは、境界がψの等値であるとき、つまりψが境界に沿ってある一定の値を有するときに正確に起こる。

We can achieve this without eliminating the gradient altogether by modulating ψ with a ramp through zero based on distance to the closest boundary point.

最も近い境界点までの、距離に基いてゼロを通るRampでψを変調することによって、勾配を完全になくすことなくこれを達成できる。


ψ_{constrained}(\vec{x}) = ramp\biggl(\frac{d(\vec{x})}{d_0}\biggr) ψ(\vec{x}) ... (3)

where d(\vec{x}) is the distance to all solid boundaries and d_0 is the width of the modified region—when using noise with length scale L, it makes sense to set d_0 = L. We use the following smooth ramp:

d(\vec{x})は、すべてのソリッド境界までの距離であり、d_0は修正された領域の幅である。
長さスケールLのノイズを使用する場合、d_0 = Lに設定することは理にかなっている。
次の、平滑化Ramp関数を利用する:


\begin{eqnarray}
ramp(r){=}
\begin{cases}
1 &amp; : r \geq 1 \\
\frac{15}{8}r - \frac{10}{8}{r}^3 + \frac{3}{8}{r}^5 &amp;: 1 > r > -1 \\
-1 &amp; : r \leq -1
\end{cases}
\end{eqnarray}
... (4)

In three dimensions things are a little more complicated.

3次元の場合はもう少し複雑だ。


α = \biggl|ramp\biggl(\frac{d(\vec{x})}{d_0}\biggr)\biggr|

\vec{n} be the normal to the boundary at the closest point to \vec{x}, we use

nx に最も近い点の境界の法線である。
したがって、次の式を使う。


\vec{ψ}_{constrained}(\vec{x}) = α\vec{ψ} (\vec{x}) + (1 − α) \hat{n} (\hat{n} \cdot \vec{ψ} (\vec{x})) ... (5)

That is, we ramp down the tangential component of \vec{ψ} near the boundary, but leave the normal component unchanged. This can be proven to give a tangential velocity field at smooth object boundaries, using the fact that \vec{n} is the gradient of signed distance from the boundary (hence its curl vanishes).

つまり、\vec{\psi}の接線成分を境界付近で下降(ramp down)させるが、通常の成分は変化させない。
これは、nが境界からの符号付き距離の勾配であるという事実を利用して、滑らかなオブジェクト境界で接線方向の速度場を与えることが証明される。
(したがってそのカールは消える)

Unfortunately, the normal field may be discontinuous along the medial axis of the geometry: na¨ıvely using equation (5) can result in spikes when we take the curl, particularly near sharp edges.

残念なことに、通常のフィールドは、ジオメトリの内側軸に沿って不連続である可能性がある。
式(5)を使用すると、カールを取るとき、特に鋭いエッジ付近でスパイクが発生する可能性がある。

This isn’t a problem for equation (3) since the distance field is Lipschitz continuous, which is adequate for our purposes.

これは、距離場がLipschitz連続であるため、式(3)の問題ではない。
これは、目的に対して適切である。

Thus within distance d_0 of edges flagged as sharp in the system we default to (3), i.e. drop the normal component.

したがって、システム内でシャープとフラグ立てられたエッジの距離d0内では、デフォルトで(3)になる。
すなわち、通常の成分を落とす。


さて、初見ではなんのこっちゃですが、(自分の浅い理解では)要するに、境界付近では接線方向にのみ速度を持たせてやることで、そちらに流れていくよね、ということを言いたいのだと思います。

そのことを示す式が以下です。


\vec{ψ}_{constrained}(\vec{x}) = α\vec{ψ} (\vec{x}) + (1 − α) \hat{n} (\hat{n} \cdot \vec{ψ} (\vec{x})) ... (5)

constrained(拘束)が示すように、境界付近での速度の方向を拘束し、流れを変えることを表す式です。

もう少し具体的に見てみましょう。
式の右辺を見ると、\vec{\psi}(\vec{x})に対してなにやら計算しています。

この\psiはポテンシャル、つまりカールノイズの計算部分です。
その得られた速度ベクトルに対してなにやら計算しているわけです。

式を少し簡単にしてみると、(\vec{\psi}(\vec{x})Pと置きます)


αP + (1 - α)P

ということです。

つまり、なんらかの係数に応じて徐々にそちらのベクトルを採用する、いわゆる線形補間的な計算ですね。

ただ、右側に置かれているほうはもう少し複雑で、正確には以下です。


(1 − α) \hat{n} (\hat{n} \cdot \vec{ψ} (\vec{x}))

ただ、これも分解して考えると、いわゆる「法線方向ベクトルにどれだけ沿っているか」の計算です。
一般的な計算に書き直してみると、


\hat{n}(\hat{n} \cdot \vec{x})

ですね。

コードで表してみる

さて、上記の部分をコードで表したのが以下になります。

境界の法線を計算する

式では、境界付近の法線を利用しています。
そして法線の計算は以下のようになります。

// 勾配(gradient)を計算する
// 基本的な考えは偏微分が法線ベクトルとなることを利用している?
float3 ComputeGradient(float3 p)
{
    const float e = 0.01f;

    // 偏微分するため、各軸の微小値を計算する
    const float3 dx = float3(e, 0, 0);
    const float3 dy = float3(0, e, 0);
    const float3 dz = float3(0, 0, e);

    float d = SampleDistance(p);
    float dfdx = SampleDistance(p + dx) - d;
    float dfdy = SampleDistance(p + dy) - d;
    float dfdz = SampleDistance(p + dz) - d;

    return normalize(float3(dfdx, dfdy, dfdz));
}

// 計算点から、障害物への距離を計算する
float SampleDistance(float3 p)
{
    float3 u = _SphereParam.xyz - p;
    float d = length(u);
    return d - _SphereParam.w;
}

なぜ偏微分したらそれが法線ベクトルになるのかについては以下を参照。

mathtrain.jp

また、距離関数については、(今回は)単純なSphereのみとしているので、比較的シンプルな式で距離計算が出来ています。
その他の距離関数については、こちらのサイトを参照ください。

modeling with distance functions

そして、ポテンシャルの計算を行っているコードが以下。

// α = ramp(d(x)/d0)
// ψ_constrainted(x) = αψ(x) + (1 - α)n(n・ψ(x))
float3 SamplePotential(float3 pos, float time)
{
    float3 normal = ComputeGradient(pos);
    float distance = SampleDistance(pos);

    float3 psi = float3(0, 0, 0);

    // 高さに応じて乱流の度合いを変化させる(上にいくほど拡散するように)
    float heightFactor = Ramp((pos.y - _PlumeBase) / _PlumeHeight);
    for (int i = 0; i < 3; i++)
    {
        float alpha = Ramp(abs(distance) / _NoiseScales[i]);

        float3 s = pos / _NoiseScales[i];

        float3 psi_i = Constraint(Pnoise(s), normal, alpha);
        psi += psi_i * heightFactor * _NoiseGain[i];
    }

    float3 risingForce = _SphereParam.xyz - pos;
    risingForce = float3(-risingForce.z, 0, risingForce.x);

    // ringの半径?
    // XZ平面の中心からの半径? RingRadius?
    float rr = sqrt(pos.x * pos.x + pos.z * pos.z);
    float temp = sqr(rr - _RingRadius) + sqr(rr + _RingRadius) + _RingFalloff;
    float invSecond = 1.0 / _RingPerSecond;
    float ringY = _PlumeCeiling;
    float alpha = Ramp(abs(distance) / _RingRadius);

    // 「煙の柱(Plume)」の下端以下になるまで繰り返す
    while (ringY > _PlumeBase)
    {
        // ringの位置とパーティクルのYの差分
        float ry = pos.y - ringY;

        float b = temp + sqr(ry);
        float rmag = _RingMagnitude / b;

        float3 rpsi = rmag * risingForce;
        psi += Constraint(rpsi, normal, alpha);
        ringY -= _RingSpeed * invSecond;
    }

    return psi;
}

なお、上記コードの乱流生成部分は以下の記事を参考にさせてもらいました。

prideout.net

カールノイズを実装したコンピュートシェーダ全文はこちら↓

github.com

論文翻訳メモ

さて、最後に、論文を理解するにあたってちょいちょい(Google翻訳などを利用しながら)翻訳していったものがあるので、せっかくなのでメモとして残しておこうと思います。

※ 注意! いくらかは自分の意訳部分があるので、完全な翻訳ではありません。

論文はこちら(PDF)


​速度は、ポテンシャル(ベクトル場)(\vec{ψ})に対して回転(curl)をかけたものとして得られる。


\vec{v} = \nabla \times ψ

カールされたベクトル場は自動的にダイバージェンスフリーとなる。

A classic vector calculus identity is that the curl of a smooth potential is automatically divergence-free


\nabla \cdot \nabla \times = 0

よって、


\nabla \cdot \vec{v} = 0

パーリンノイズN(\vec{x})を使ってベクトル場を形成する。
2Dの場合は、単純にψ=N

To construct a randomly varying velocity field we use Perlin noise N(\vec{x}) in our potential. In 2D, [tex:]\psi = N].

3Dの場合は、3要素(ベクトル)が必要となるが、同じノイズ関数を、明らかに大きなオフセットをかけた入力ベクトルを用いて表現する。
普段は、いくつかの異なったスケールを使ったオクターブノイズを乱流の形成のために使う。

In 3D we need three components for the potential: three apparently uncorrelated noise functions (a vector \vec{N}(\vec{x})) do the job, which in practice can be the same noise function evaluated at large offsets.


\psi(x, t) = A(x)\psi_T(x, t) + ramp\biggl(\frac{d(x)}{d_L}\biggr)\psi_L(x)

3Dの場合は、速度ノイズの接線成分のみ、ゼロに減少する。

In 3D, only tangential components of vector noise are ramped down.

シーン内のオブジェクトからゼロに減衰するか、または煙の列の高さに応じて変化させる。

decay to zero away from objects in the scene, or changing it according to the height in a column of smoke.

速度場 A(\vec{x})\vec{v}(\vec{x}) はもはやdivergence-freeを与えないが、それにカールのトリックで成す。

While simply modulating the velocity field A(\vec{x})\vec{v}(\vec{x}) no longer gives a divergnce-free field, modulating the potential, \vec{v} = \nabla \times (A(\vec{x})\psi(\vec{x})), does the trick.

他のポテンシャル(Other Potentials)

これまでは、動かない境界線に対するノイズだけを構築してきた。
もちろん、既存のプリミティブのフローを速度場に使って、よりリッチな機能を実現することもできる。
そして、ポテンシャルをより高めることができる。

So far we have only constructed noise that respects unmoving solid boundaries. While of course we can superimpose existing flow primitives on the velocity field for richer capabilities, we can also do more with the potential itself.

線形速度Vと角速度ωを持つRigidbodyに対応するポテンシャルを導出することはシンプルにできる。

It is simple to derive a potential corresponding to a rigid body motion with linear velocity \vec{V} and angular velocity \vec{ω} :


\vec{\psi}_{rigid}(\vec{x}) = \vec{V} \times (\vec{x} − \vec{x_0}) + \frac{{R}^2 - {|| \vec{x} − \vec{x_0} ||}^2}{2} \vec{ω}

ここで、\vec{x_0}は任意の参照点であり、Rは任意の参照レベルを表す。

where {x_0} is an arbitrary reference point and R is an arbitrary reference level.

Note:
同じvに対応する無限に多くのポテンシャルが常に存在することに気をつける。
スカラー場の勾配だけが異なるふたつのポテンシャルは、同じカールを有する。

Note that there are always infinitely many potentials corresponding to the same \vec{v}: any two potentials which differ by only the gradient of some scalar field have exactly the same curl.

動くRigidbodyの境界条件を満たすように修正したポテンシャル\vec{\psi}を仮定する。
まず、物体の表面上速度の法線成分をゼロにするために方程式(5)を使用し、\vec{\psi_0}を与える。
次に、x_0を剛体の中心として選択した式(6)を使用し、オブジェクトからゼロに滑らかにブレンドする。
Rブレンド領域の半径とすると、ブレンドされた回転項が単調に0に落ちる)

Suppose we have a potential \vec{ψ} we wish to modify to respect boundary conditions on a moving rigid object. First we use equation (5) to zero out the normal component of velocity on the object’s surface, giving \vec{ψ_0}. Then we use equation (6) with x_0 chosen, say, as the center of the rigid body, smoothly blend it to zero away from the object (choosing R to be the radius of the blend region, so that the blended rotational term drops monotonically to zero),

それを加えて


\vec{ψ} (\vec{x}) = \vec{ψ}_0 + A(\vec{x})\vec{ψ}_{rigid}(\vec{x})

を得る。

例えば、各剛体への逆二乗距離に基づくブレンド関数A(\vec{x})の実験を行った。

We have experimented, for example, with a blending function A(\vec{x}) based on inverse squared distance to each rigid body.

剛体の境界では、速度は剛体モーションと、表面に接するベクトル場の合計となる。
これは構成上、非粘性境界条件を尊重する。

Note that at the boundary of the rigid object, the velocity is the sum of the rigid motion and a vector field tangent to the surface: by construction this respects the inviscid boundary condition.

単一のボディにてついては参照点は任意だが、複数のボディがある場合は同じ参照点を使用してブレンドをよりよくするのに役立つ。
特に、すべてのボディが同じ剛体モーションで動いている場合は、そのポテンシャルを合わせてブレンドを完璧にしたいと考えている。

While for a single body the reference point is arbitrary, if we have multiple bodies it can help to use the same reference point to make the blend better. In particular, if all bodies are moving with the same rigid motion, then of course we want their potentials to match up to make the blend perfect.

変形する物体の周りの流れ場はよりトリッキーである。
閉じた変形表面の場合、もしボディがその体積を保持しなければ不可能である。
したがって、将来の作業のためにそれは残しておく。

Flow fields around deforming bodies are trickier. For a closed deforming surface, it may even be impossible—if the body doesn’t conserve its volume—and thus we leave that for future work.

いくつかのプリミティブな渦(Vortex)は、\vec{x}位置の角速度\vec{ω}、半径R、および平滑化フォールオフ関数を有する単純なパーティクルを含む。

Some vortex primitives include a simple vortex particle at \vec{x_0} with angular velocity \vec{ω} , radius R, and smooth fall-off function f :


\vec{\psi}_{vort}(\vec{x}) = f\Biggl(\frac{||\vec{x} − \vec{x_0}||}{R}\Biggr) \frac{{R}^2 − ||\vec{x} − \vec{x_0}||^2}{2} \vec{ω} ... (7)

そして、スモーク・リングやプルーム(Plumes)に役立つ、単純な渦カーブ:

and a simple vortex curve, useful for smoke rings and plumes:


\vec{\psi}_{curve}(\vec{x}) = f\Biggl(\frac{||\vec{x} − \vec{x_C}||}{R}\Biggr) \frac{{R}^2 − ||\vec{x} − \vec{x_C}||^2}{2} \vec{ω_C} ... (8)

\vec{x_C}\vec{x}までの、曲線上の最も近い点であり、\vec{ω_C}は角速度(曲線に接する)である。

他の興味深いフロー構造も、剛体運動式を念頭に置いて同様に作成することができる。

where \vec{x_C} is the closest point on the curve to \vec{x}, and \vec{ω} C is the angular velocity (tangent to the curve). Other interesting flow structures can be similarly created with the rigid motion formulas in mind.

備考メモ

論文に書かれている備考部分に対するメモです。(あくまで個人的な解釈です)

f:id:edo_m18:20180111134000p:plain
↑論文に添えられていた図

左手側は、剛体の背後に乱流を起こしたもの。右手側は、流体を後ろに押し出したあと渦輪を設定したもの。(あってる?)

どちらのケースも、乱流ノイズの各オクターブは、ジオメトリに対してスケールに適した方法で調整される。


\psi_t(x) = \sum_i a_i ramp\left(\frac{d(x)}{d_i}\right) N\left(\frac{x}{d_i}, \frac{t}{d_i}\right)

そして、障害物の背後にのみ生成した乱流に平滑化された振幅関数を乗算する。
これを、基礎の層流(lamninar(ラミナ))に加える。これも、境界付近でスムーズにゼロにランプされる。

※ 流体の流れは2つに分けられ、流体部分が秩序正しく流れる場合を層流と呼び、不規則に混合しながら流れる場合を乱流と呼ぶ。
出典: https://www.weblio.jp/content/laminar+flow


\psi(x, t) = A(x) \psi_T(x, t) + ramp\left(\frac{d(x)}{d_L}\right) \psi_L(x)

個人的見解

以下は、論文を読んでいくにあたって、記号など「こういう意味かなー」と思ったことのメモです。
記号や単語の意味を類推して読んでいくと意外とすっと頭に入ってくることもあるので。

\psi_T(x)が、乱流(Trubulent)を表す式。(なのでT
それを、ジオメトリに応じてAdjustする変数a
これが「乱流」部分になるので、それを、基礎層流(Laminar Flow)に加える、ということになる。
(という意味かな?)

そして、基礎層流を表すのが


ramp\left(\frac{d(x)}{d_L}\right) \psi_L(x)

の式。

仮にこれをLと置き、さらに乱流をTと置くと、最後の式は


\psi(x, t) = A(x) T + L

と書ける。

このA(x)が、文章で書かれている「We then multiply by a smooth AMPLITUDE function」のことだと思う。

ただ、平滑化された振幅関数ってどう表現するのだろう・・。ひとまず適当にSinとか掛けておけばいいんだろうか。
なんとなく、Sinを使った値を加算してみたら煙が立ち上る感じに見えたので、多分そんな感じなんだと思うw

その他、役立ちそうなリンク

prideout.net

catlikecoding.com

github.com

リンクではありませんが、MacにはGrapherというアプリが標準でついていて、以下のキャプチャのように、数式を入れるとそれを可視化してくれる、というものがあります。

数式だけだと一体なにをしているんだ? となることが多いですが、視覚化されると「ああ、なるほど。こういう形を求めているのだな」と分かることが少なくないので、利用して色々な数式を視覚化してみることをオススメします。

f:id:edo_m18:20171210134609p:plain