e.blog

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

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#で実装したコードが以下になります。

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

    /// <summary>
    /// 調査点が多角形の内外どちらにあるかを判定する
    /// </summary>
    /// <param name="positions">多角形を構成する頂点リスト</param>
    /// <param name="target">調査点</param>
    /// <param name="normal">多角形が存在する平面の法線</param>
    /// <returns>調査点が内側にある場合はtrue</returns>
    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\)を求めることができる、ということのようです。

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

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

/// <summary>
/// 多角形を構成する頂点リストと対象点とを使って、対象点が多角形内に含まれるかをテストする
/// </summary>
static public class PointInArea
{
    /// <summary>
    /// 調査点が多角形の内外どちらにあるかを判定する
    /// </summary>
    /// <param name="positions">多角形を構成する頂点リスト</param>
    /// <param name="target">調査点</param>
    /// <param name="normal">多角形が存在する平面の法線</param>
    /// <returns>調査点が内側にある場合はtrue</returns>
    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. 任意のタイミング(ボタン押下など)で、プロジェクタで表示しているテクスチャを該当オブジェクトのテクスチャに描き込む

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

なお、テクスチャに動的に描き込む機能についてはこちらの書籍の「Projection Spray」という章を参考にさせていただきました。
これ以外にもとても有用な記事がたくさんあるので興味がある方は購入してみてください。

indievisuallab.stores.jp

また、プロジェクタのようにテクスチャを投影する機能については以前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(微妙にハマりどころがあった)

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

Unityでパーリンノイズの実装

概要

今回はパーリンノイズについて書きたいと思います。
以前実装して使ったりしていましたが、しっかりと理論を理解して使ってはいなかったので、改めて、という感じです。

Wikipediaから引用させていただくと、

パーリンノイズ(英: Perlin noise)とは、コンピュータグラフィックスのリアリティを増すために使われるテクスチャ作成技法。擬似乱数的な見た目であるが、同時に細部のスケール感が一定である。このため制御が容易であり、各種スケールのパーリンノイズを数式に入力することで多彩なテクスチャを表現できる。パーリンノイズによるテクスチャは、CGIで自然な外観を物に与えるためによく使われる。

Ken Perlin が Mathematical Applications Group, Inc. で勤務しているときに開発した。彼はこの業績により、1997年、映画芸術科学アカデミーからアカデミー科学技術賞(Technical Achivement)を受賞した。

パーリンノイズは (x,y,z) または (x,y,z,time) の関数として実装され、事前に計算された勾配に従って内挿を行い、時間的/空間的に擬似乱数的に変化する値を生成する。Ken Perlin は2002年に実装を改善し、より自然に見えるようにした(外部リンク参照)。

パーリンノイズは、コンピュータグラフィックスで炎や煙や雲を表現するのによく使われている。また、メモリ使用量が少ないため、メモリ容量が小さい場面でのテクスチャ生成にも使われ、パソコンゲームでのリアルタイムCG生成時にGPU上で使われることが増えている。

実際にレンダリングしてみたのがこれ。Photoshopで「雲模様」ってフィルタ使うと出てくるやつですね。
f:id:edo_m18:20181009215342p:plain

だいぶ前に、JSで実装したものがあるのでリンクを張っておきます。

jsdo.it

パーリンノイズはいたるところで利用されています。 例えば、以前Unityで実装した「カールノイズ」でも内部的にパーリンノイズを使っていたりします。

edom18.hateblo.jp

そして今回改めてこれを書こうと思ったのは、最近ハマっているレイマーチングでフラクタル地形を描きたいな、と思ったのがきっかけです。

ちなみにこちらの記事(非整数ブラウン運動)から引用させていただくと、

周波数を一定の割合で増加させる(lacunarity)と同時に振幅を減らしながら(gain)ノイズを(octaveの数だけ)繰り返し重ねることで、より粒度の細かいディテールを持ったノイズを作り出すことができます。このテクニックは「非整数ブラウン運動(fBM)」または単に「フラクタルノイズ」と呼ばれていて、最も単純な形は下記のコードのようになります。

と書かれています。 これはパーリンノイズの振幅と、周波数をオクターブごとに変えたものを重ね合わせて作るノイズです。 (上のJSでの実装も、まさにこのオクターブを重ね合わせて雲のような模様を作っています)

このフラクタル地形をさらに発展させると以下のようなすばらしい映像を、フラグメントシェーダのみで記述することが可能となります。

fBMを用いた海の表現 https://www.shadertoy.com/view/4sXGRM

目下の目標は上のような海をレンダリングすることです。

なお、今回の記事は以下の記事を大いに参考にさせていただきました。ありがとうございます。

postd.cc

パーリンノイズの考えかた

パーリンノイズは「グラデーションノイズ(勾配ノイズ)」と呼ばれています。
これは後述する、勾配ベクトルを用いたノイズ生成処理に起因しているものと思われます。

パーリンノイズ関数は、0〜1の範囲で値を返す関数です。
ではどのようにしてその値を算出するのでしょうか。

パーリンノイズの値算出

まずは以下の図を見てください。
f:id:edo_m18:20181011101522p:plain

これはパーリンノイズで利用する「単位座標」を図解したものです。
(上記画像は2次元での例ですが、多次元に拡張可能で3次元の場合は図に加えてZの値が入ります)

そして中央やや左上にあるのが入力点の位置です。
つまり、「パーリンノイズ関数への入力」ですね。
(ただし、後述するように0~1clampされた値となります)

そしてその入力点に対して計算を行い、ノイズとしての最終的な値を算出することになります。

単位座標

なぜ単位座標と呼ぶかと言うと、入力されたx, y, zの値を0〜1の間で繰り返させ、1を超える場合はまた0に戻るように「繰り返し」の状況を作るため「単位」なんですね。

ちなみに擬似コードで示すと以下のイメージです。

[x, y, z] = [x, y, z] % 1.0;

x, y, zの値を1で割った余りを利用することで、0〜1の間で繰り返させるわけです。

疑似乱数勾配ベクトル

単位座標について説明しました。
この単位座標は4つの(3次元なら8つの)勾配ベクトルを持ちます。

この勾配ベクトルは擬似乱数によって生成します。
つまり、勾配ベクトルの方程式に入力された整数に対して、常に同じ結果を返すものです。(なので疑似乱数)

これは、与えられた勾配ベクトル方程式が変わらなければ単位座標が持つ勾配ベクトルは固有の値を持つことを意味します。

以下の図を見てください。
f:id:edo_m18:20181011133427p:plain

これは、単位座標のそれぞれの頂点に勾配ベクトルを加えた図です。

距離ベクトルを求める

次に、入力点と各頂点からの距離ベクトルを求めます。
距離ベクトルは単純に、入力点から各頂点の位置ベクトルを減算することで求めることができますね。

f:id:edo_m18:20181011133244p:plain

距離ベクトルと勾配ベクトルの内積がノイズに対する影響値

さて、上記で求めた距離ベクトルですが、このベクトルと各頂点の勾配ベクトルとの内積を取ることで入力点に対する各頂点の勾配ベクトルの影響値が算出できます。

なぜ内積を取ると影響値が算出できるのかというと、ベクトルの内積は、言ってしまえばふたつのベクトルが「どれだけ似通っているか」を判断することができるためです。

ちなみに内積の定義は以下のようになります。

$$ \vec{a} \cdot \vec{b} = \cos(\theta) |\vec{a}| |\vec{b}| $$

そしてもし、ベクトルがまったく同じ方向を向いていたら(つまり平行であったら)、\(\cos(\theta)\)は\(\cos(0) = 1\)となり、結果的にa.length * b.lengthとなります。
(逆に反対を向いていたら反転し-1が乗算されたものと同じ結果になります)

この影響を図示したものを、参考にさせていただいた記事から引用させていただくと以下のようになります。

勾配の正負の影響図
引用:パーリンノイズを理解する | POSTD

加重平均で最終値を求める

これで、各4頂点との影響値を計算することができました。
最後に、この4頂点の勾配ベクトルを加重平均によって求めます。

まず、各4頂点から得られた勾配ベクトルの影響値を以下のように図示します。

f:id:edo_m18:20181011133736p:plain

\(g_1 〜 g_4\)がそれぞれの勾配ベクトルとの内積結果です。
この結果を、それぞれの値の補間を計算し、最終的な値として採用します。

具体的には、疑似コードで表すと以下のようになります。

int g1, g2, g3, g4;
int u, v;

int x1 = lerp(g1, g2, u);
int x2 = lerp(g3, g4, u);

int result = lerp(x1, x2, v);

やっていることは、まず横方向の補間を計算し(\(g_1, g_2\)および\(g_3, g_4\))、その結果をさらに縦方向に補間します。(\(x_1, x_2\)の補間)

これで補間した結果の値が求まりました。

フェード関数

さて、実はこれではまだ問題があります。
というのも、上記は線形補間をしてしまったために、補間値が折れ線グラフのようになってしまいます。
しかし冒頭に載せた図はそこまでパキっとしていませんね。

そこで、フェード関数と呼ばれる関数を用いて線形補間の係数自体をなめらかに変換することで実現します。

フェード関数は以下の形の関数です。

6t5 - 15t4 + 10t3

グラフにしてみると以下の図のようになります。

f:id:edo_m18:20181011134525p:plain

スムーズに変化しているのが分かりますね。

これで、必要な要素が揃いました。

改良版のパーリンノイズ

必要な要素が揃ったと書きましたが、実は改良版のパーリンノイズでは若干異なるアプローチを取っているようです。

ひとつは、勾配ベクトルです。

改良版の勾配ベクトルについてですが、実は正直まだよく理解できていません

参考にさせていただいた記事から引用すると、

しかしながら、上の図は完全に正確ではありません。この記事で扱っているKen Perlinの改良パーリンノイズでは、これらの勾配は全くの無作為ではありません。立方体の中心点のベクトルから辺に向かって取り出されます。

と書かれています。
上の図というのは本記事でのこの図です↓
f:id:edo_m18:20181011133427p:plain

図では無作為(ランダム)なように記載されていますが、これが「無作為ではない」というのが引用した文の主張のようです。

そして取り出されるベクトルは以下の12ベクトルになります。

(1,1,0),(-1,1,0),(1,-1,0),(-1,-1,0),
(1,0,1),(-1,0,1),(1,0,-1),(-1,0,-1),
(0,1,1),(0,-1,1),(0,1,-1),(0,-1,-1) 

だいぶ整理されたベクトルの印象を受けますね。
このベクトルについての根拠を、大元の論文(Ken PerlinのSIFFRAPH 2002の論文「Improving Noise」)から引用させてもらうと以下のように記載されています。

The key to removing directional bias in the gradients is to skew the set of gradient directions away from the coordinate axes and long diagonals. In fact, it is not necessary for G to be random at all, since P provides plenty of randomness. The corrected version replaces G with the 12 vectors defined by the directions from the center of a cube to its edges:

ここで言っているのは、ざっくり言うと、勾配ベクトル\(G\)は、使用している\(P\)が十分にランダムなのでランダムである必要はない、ということのようです。

さらに論文ではこう続きます。

Gradients from this set are chosen by using the result of P, modulo 12.

配列\(P\)の結果から、勾配が選択される、と。
後述する実装例を見てもらうと分かりますが、実際に、計算自体はP配列の結果を元に計算が行われます。

なお、(ここが理解しきれていない点ですが)前段では「距離ベクトルと勾配ベクトルの内積を取る」と説明しました。
しかし、参考にした実装では内積計算を(明示的には)行っていません。

論文をさらに読み進めると以下のように書かれています。

it allows the eight inner products to be effected without requiring any multiplies, thereby removing 24 multiplies from the computation.

乗算を行うことなく、8つの頂点(2次元なら4つ)との内積を得ることができる、と。
このあたりの数学的な理由や原理が理解できていない点です。
しかしながら、「内積を求めてそれを利用する」という「概念」自体は変わっていません。

結局のところ、各頂点と距離ベクトルとの内積を計算しそれを影響値とする、ということ自体は変わらず、その算出方法をより効率的にした、というのが改良版パーリンノイズだと解釈しています。

複数オクターブを利用した表現の拡張

実は今回実装した例では、この「複数オクターブ重ねたノイズ」となっています。
しかしながら、パーリンノイズ自体は前述の説明がすべてです。

これを、さらに「オクターブ」という概念で複数のノイズを生成し、それを合成することでより自然なノイズを生成する、というのが目的です。

ちなみにこの表現を「フラクタルブラウン運動(fractal brownian motion)」と呼ぶようです。
※ おそらく。参考にした記事での実装や、その他の記事を見るに、実装方法はほぼ同じなのでそう解釈しています。

これについては以下の記事を参照ください。

thebookofshaders.com

ちなみに、冒頭でも書いたように、これをさらに発展させた「ドメインワープ」という表現を使って波や動く雲の表現をレイマーチングによって実装するのが目下の目標です。

このドメインワープという方法を実装した例がこちら。(あくまで上の記事で紹介されているコードを、必要な部分だけ抜き出して実装したものです)

最終的にはこれを理解して、実際のコンテンツに盛り込めるようにするのが目標です。
なので今回はこのオクターブでの表現については説明を割愛して、次の記事で詳細に書きたいと思います。


[2018.10.18 追記]
Qiitaでドメインワープについての記事を書きました。

qiita.com


ソースコード

最後に、C#で実装したソースコードを紹介します。

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

/////////////
/// Xorshift。擬似乱数生成用に使用する
public class Xorshift
{
    private uint[] _vec = new uint[4];

    public Xorshift(uint seed = 100)
    {
        for (uint i = 1; i <= _vec.Length; i++)
        {
            seed = 1812433253 * (seed ^ (seed >> 30)) + i;
            _vec[i - 1] = seed;
        }
    }

    public float Random()
    {
        uint t = _vec[0];
        uint w = _vec[3];

        _vec[0] = _vec[1];
        _vec[1] = _vec[2];
        _vec[2] = w;

        t ^= t << 11;
        t ^= t >> 8;
        w ^= w >> 19;
        w ^= t;

        _vec[3] = w;

        return w * 2.3283064365386963e-10f;
    }
}

public class PerlinNoise
{
    private Xorshift _xorshit;
    private int[] _p;
    public float Frequency = 32.0f;

    /// <summary>
    /// Constructor
    /// </summary>
    public PerlinNoise(uint seed)
    {
        _xorshit = new Xorshift(seed);

        int[] p = new int[256];
        for (int i = 0; i < p.Length; i++)
        {
            // 0 - 255の間のランダムな値を生成する
            p[i] = (int)Mathf.Floor(_xorshit.Random() * 256);
        }

        // pの倍の数の配列を生成する
        int[] p2 = new int[p.Length * 2];
        for (int i = 0; i < p2.Length; i++)
        {
            p2[i] = p[i & 255];
        }

        _p = p2;
    }

    private float Fade(float t)
    {
        // 6t^5 - 15t^4 + 10t^3
        return t * t * t * (t * (t * 6f - 15f) + 10f);
    }

    /// <summary>
    /// Linear interpoloation
    /// </summary>
    private float Lerp(float t, float a, float b)
    {
        return a + t * (b - a);
    }

    /// <summary>
    /// Calculate gradient vector.
    /// </summary>
    private float Grad(int hash, float x, float y, float z)
    {
        // 15 == 0b1111 : Take the first 4 bits of it.
        int h = hash & 15;
        float u = (h < 8) ? x : y;
        float v = (h < 4) ? y : (h == 12 || h == 14) ? x : z;
        return ((h & 1) == 0 ? u : -u) + ((h & 2) == 0 ? v : -v);
    }

    /// <summary>
    /// To simplify above function to below.
    /// </summary>
    // private float Grad(int hash, float x, float y, float z)
    // {
    //     switch(hash & 0xF)
    //     {
    //         case 0x0: return  x + y;
    //         case 0x1: return -x + y;
    //         case 0x2: return  x - y;
    //         case 0x3: return -x - y;
    //         case 0x4: return  x + z;
    //         case 0x5: return -x + z;
    //         case 0x6: return  x - z;
    //         case 0x7: return -x - z;
    //         case 0x8: return  y + z;
    //         case 0x9: return -y + z;
    //         case 0xA: return  y - z;
    //         case 0xB: return -y - z;
    //         case 0xC: return  y + x;
    //         case 0xD: return -y + z;
    //         case 0xE: return  y - x;
    //         case 0xF: return -y - z;
    //         default: return 0; // never happens
    //     }
    // }

    private float Noise(float x, float y = 0, float z = 0)
    {
        // Repeat while 0 - 255
        int X = (int)Mathf.Floor(x) & 255;
        int Y = (int)Mathf.Floor(y) & 255;
        int Z = (int)Mathf.Floor(z) & 255;

        // trim integer
        x -= Mathf.Floor(x);
        y -= Mathf.Floor(y);
        z -= Mathf.Floor(z);

        float u = Fade(x);
        float v = Fade(y);
        float w = Fade(z);

        int[] p = _p;

        #region ### calulate hashes from array of p ###
        int A, B, AA, AB, BA, BB, AAA, ABA, AAB, ABB, BAA, BBA, BAB, BBB;

        A = p[X + 0] + Y; AA = p[A] + Z; AB = p[A + 1] + Z;
        B = p[X + 1] + Y; BA = p[B] + Z; BB = p[B + 1] + Z;

        AAA = p[AA + 0]; ABA = p[BA + 0]; AAB = p[AB + 0]; ABB = p[BB + 0];
        BAA = p[AA + 1]; BBA = p[BA + 1]; BAB = p[AB + 1]; BBB = p[BB + 1];
        #endregion ### calulate hashes from array of p ###

        float a = Grad(AAA, x + 0, y + 0, z + 0);
        float b = Grad(ABA, x - 1, y + 0, z + 0);
        float c = Grad(AAB, x + 0, y - 1, z + 0);
        float d = Grad(ABB, x - 1, y - 1, z + 0);
        float e = Grad(BAA, x + 0, y + 0, z - 1);
        float f = Grad(BBA, x - 1, y + 0, z - 1);
        float g = Grad(BAB, x + 0, y - 1, z - 1);
        float h = Grad(BBB, x - 1, y - 1, z - 1);

        return Lerp(w, Lerp(v, Lerp(u, a, b),
                               Lerp(u, c, d)),
                       Lerp(v, Lerp(u, e, f),
                               Lerp(u, g, h)));
    }

    public float OctaveNoise(float x, int octaves, float persistence = 0.5f)
    {
        float result = 0;
        float amp = 1.0f;
        float f = Frequency;
        float maxValue = 0;

        for (int i = 0; i < octaves; i++)
        {
            result += Noise(x * f) * amp;
            f *= 2.0f;
            maxValue += amp;
            amp *= persistence;
        }

        return result / maxValue;
    }

    public float OctaveNoise(float x, float y, int octaves, float persistence = 0.5f)
    {
        float result = 0;
        float amp = 1.0f;
        float f = Frequency;
        float maxValue = 0;

        for (int i = 0; i < octaves; i++)
        {
            result += Noise(x * f, y * f) * amp;
            f *= 2.0f;
            maxValue += amp;
            amp *= persistence;
        }

        return result / maxValue;
    }

    public float OctaveNoise(float x, float y, float z, int octaves, float persistence = 0.5f)
    {
        float result = 0;
        float amp = 1.0f;
        float f = Frequency;
        float maxValue = 0;

        for (int i = 0; i < octaves; i++)
        {
            result += Noise(x * f, y * f, z * f) * amp;
            f *= 2.0f;
            maxValue += amp;
            amp *= persistence;
        }

        return result / maxValue;
    }
}

これを実際に利用するコードは以下のようになります。

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

public class PerlinNoiseTest : MonoBehaviour
{
    [SerializeField]
    private GameObject _quad;

    [SerializeField]
    [Range(1, 16)]
    private int _octaves = 5;

    [SerializeField]
    [Range(0.1f, 64.0f)]
    private float _frequency = 32.0f;

    [SerializeField]
    private float _persistence = 0.5f;

    [SerializeField]
    private int _width = 512;

    [SerializeField]
    private int _height = 512;

    [SerializeField]
    private uint _seed = 1000;

    private PerlinNoise _noise;
    private PerlinNoise Noise
    {
        get
        {
            if (_noise == null)
            {
                _noise = new PerlinNoise(_seed);
            }
            return _noise;
        }
    }
    private Texture2D _texture;

    private void Start()
    {
        CreateNoise();
    }

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

    private void CreateNoise()
    {
        _texture = new Texture2D(_width, _height, TextureFormat.RGBA32, false);

        Noise.Frequency = _frequency;

        Color[] pixels = new Color[_width * _height];
        float fx = 1f / (float)_width;
        float fy = 1f / (float)_height;
        for (int i = 0; i < pixels.Length; i++)
        {
            int x = i % _width;
            int y = i / _width;
            float n = Noise.OctaveNoise(x * fx, y * fy, _octaves, _persistence);
            float c = Mathf.Clamp(218f * (0.5f + n * 0.5f), 0f, 255f) / 255f;
            pixels[i] = new Color(c, c, c, 1f);
        }

        _texture.SetPixels(0, 0, _width, _height, pixels);
        _texture.Apply();

        Renderer renderer = _quad.GetComponent<Renderer>();
        renderer.material.mainTexture = _texture;
    }
}

これを、シーンに配置したQuadに設定して実行すると以下のような結果になります。

f:id:edo_m18:20181011095557p:plain

なお、今回のパーリンノイズの実装はGithubにアップしてあります。

github.com

セーブデータを暗号化して保存する

概要

ゲームには状態の保存など、いわゆる「セーブデータ」が必要なケースが多いです。
今回はそんな「セーブデータ」をシリアライズしたものをバイナリ化し、さらに暗号化して保存する方法を書いておきたいと思います。

なお、今回の実装にあたっては以下の記事を参考にさせていただきました。

qiita.com

developer.wonderpla.net

loumo.jp

使うクラス

セーブデータを暗号化して保存するために、以下のクラス群を利用します。 (もちろん、暗号化には様々なアルゴリズムが存在し、今回紹介する以外の方法でももちろん暗号化を行うことが可能です)

  • System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
  • System.IO.MemoryStream
  • System.IO.FileStream
  • System.Security.Cryptography.MD5CryptoServiceProvider
  • System.Security.Cryptography.RijndaelManaged
  • System.Security.Cryptography.Rfc2898DeriveBytes
  • System.Security.Cryptography.ICryptoTransform

セーブデータをシリアライズしてバイナリデータとしてファイルに保存する

まずは暗号化の話をする前に、セーブデータをシリアライズしてバイナリデータとしてファイルに保存する方法を解説します。

SerializableAttributeでシリアライズ可能なことを明示する

System.SerializableAttributeをclassに指定することでそのクラスがシリアル化可能なことを明示することができます。

ドキュメント↓
docs.microsoft.com

バイナリ化する

データをシリアル化できるよう明示したら、次はそのオブジェクトをバイナリ化します。 バイナリ化にはMemoryStreamBinaryFormatterクラスを使います。

コード断片で示すと以下のようになります。

using (MemoryStream stream = new MemoryStream())
{
    BinaryFormatter formatter = new BinaryFormatter();
    formatter.Serialize(stream, data);

    byte[] source = stream.ToArray();

    using (FileStream fileStream = new FileStream(SavePath, FileMode.Create, FileAccess.Write))
    {
        fileStream.Write(source, 0, source.Length);
    }
}

コード量はそんなに多くないのでぱっと見でなんとなく分かるかと思います。 MemoryStreamを生成し、BinaryFormatterSerializeメソッドを利用してオブジェクトをシリアライズします。 シリアライズしたbyte配列はMemoryStreamに書き込まれます。 結果のbyte配列を取得するにはToArrayメソッドを使います。

そして最後にFileStreamを生成し、byte配列をファイルに書き込みます。 バイナリ化に関してはBinaryFormatterが行ってくれるので、IO周りがしっかり把握できていればさしてむずかしい処理ではないと思います。

データをAESで暗号化する

バイナリデータの保存が分かったところで、次は暗号化についてです。 今回取り上げるのは「AES暗号化」です。

AESとは

AESとは以下の記事から引用させていただくと、

www.atmarkit.co.jp

「AES(Advanced Encryption Standard)」は、DESの後継として米国の国立標準技術研究所(NIST:National Institute of Standards and Technology)によって制定された新しい暗号化規格である。

とのこと。

そしてさらに以下のように続いています。

そして最終的に2001年に「Rijndael(ラインダール)」という暗号化方式が選ばれた。開発者はベルギーの暗号学者、「Joan Daemen(ホァン・ダーメン)」と「Vincent Rijmen(フィンセント・ライメン)」であり、Rijndaelという名称は2人の名前から取られた(とされている)。

実際にコードを見てもらうと分かりますが、実装にはRijndaelManagedというクラスが利用されており、これがまさに上の暗号化方式の名前となっていますね。

暗号化についてのアルゴリズムなどについては上記記事を読んでみるとなんとなく雰囲気は分かるかと思います。
そして完全に余談ですが、以下のiOSアプリが、色々なアルゴリズム(暗号化や証明書などなど)についてアニメーション付きで分かりやすく解説してくれているのでよかったらダウンロードしてみてください。

アルゴリズム図鑑

アルゴリズム図鑑

  • Moriteru Ishida
  • 教育
  • 無料

実装

暗号化に関しては前段で解説した際に行ったバイナリ化したデータ(byte配列)に対して操作を行い、暗号化します。 なので暗号化している部分だけを抜粋してコードを紹介します。 (引数のbyte[] dataは、前段で生成したbyte配列です)

static SaveDataManager()
{
    _rijindeal = new RijndaelManaged();
    _rijindeal.KeySize = 128;
    _rijindeal.BlockSize = 128;

    byte[] bsalt = Encoding.UTF8.GetBytes(_salt);
    Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes(_password, bsalt);
    deriveBytes.IterationCount = 1000;

    _rijindeal.Key = deriveBytes.GetBytes(_rijindeal.KeySize / 8);
    _rijindeal.IV = deriveBytes.GetBytes(_rijindeal.BlockSize / 8);
}

static private byte[] Encrypt(byte[] data)
{
    ICryptoTransform encryptor = _rijindeal.CreateEncryptor();
    byte[] encrypted = encryptor.TransformFinalBlock(data, 0, data.Length);

    encryptor.Dispose();

    // Console.WriteLine(string.Join(" ", encrypted));

    return encrypted;
}

static private byte[] Dencrypt(byte[] data)
{
    ICryptoTransform decryptor = _rijindeal.CreateDecryptor();
    byte[] plain = decryptor.TransformFinalBlock(data, 0, data.Length);

    // Console.WriteLine(string.Join(" ", plain));

    return plain;
}

コードの冒頭では静的コンストラクタによってRijndaelManagedオブジェクトを生成しています。

そしてEncryptDencryptメソッドでデータの暗号化、復号化を行っています。 ここでやっていること自体はとてもシンプルですね。 静的コンストラクタで生成したRijndaelManagedオブジェクトから、CreateEncryptorCreateDecryptorをそれぞれ生成し、ICryptorTransformインターフェースのTransformFinalBlockメソッドを実行しているだけです。

戻り値は暗号化、復号化されたbyte配列となります。 あとはこれを、前段のファイル保存の処理で保存してやれば晴れて、セーブデータが暗号化されて保存されたことになります。 (結局のところ、最終的に保存されるのは01で表されるバイナリ表現のデータなので、それ自体が暗号化されているか否かに関わらず、ファイルの保存・読み込みは問題なく行えるというわけですね。(というか、FileStreamはそれを関知しない)

ソースコード

最後に、コンソールアプリとして実行するといくつかの項目を入力するとそれを保存、復元できるものを作ったのでソースコードを載せておきます。
もちろん、Unity上でも動作します。

using System;
using System.IO;
using System.Text;
using System.Runtime.Serialization.Formatters.Binary;
using System.Security.Cryptography;

[System.Serializable]
public class SaveData
{
    public float Number = 0.5f;
    public string Name = "Hoge";
    public int Count = 5;

    public override string ToString()
    {
        return string.Format("Name: {0}, Number: {1}, Count: {2}", Name, Number, Count);
    }
}

static public class SaveDataManager
{
    public const string SavePath = "./test.bytes";
    private const string _password = "passwordstring";
    private const string _salt = "saltstring";
    static private RijndaelManaged _rijindeal;

    static SaveDataManager()
    {
        _rijindeal = new RijndaelManaged();
        _rijindeal.KeySize = 128;
        _rijindeal.BlockSize = 128;

        byte[] bsalt = Encoding.UTF8.GetBytes(_salt);
        Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes(_password, bsalt);
        deriveBytes.IterationCount = 1000;

        _rijindeal.Key = deriveBytes.GetBytes(_rijindeal.KeySize / 8);
        _rijindeal.IV = deriveBytes.GetBytes(_rijindeal.BlockSize / 8);
    }

    static public void Save(SaveData data)
    {
        using (MemoryStream stream = new MemoryStream())
        {
            BinaryFormatter formatter = new BinaryFormatter();
            formatter.Serialize(stream, data);

            byte[] source = stream.ToArray();

            source = AESlize(source);

            using (FileStream fileStream = new FileStream(SavePath, FileMode.Create, FileAccess.Write))
            {
                fileStream.Write(source, 0, source.Length);
            }

            Console.WriteLine("Done [" + data.ToString() + "]");
        }
    }

    static public SaveData Load(string name)
    {
        SaveData data = null;

        using (FileStream stream = new FileStream(name, FileMode.Open, FileAccess.Read))
        {
            using (MemoryStream memStream = new MemoryStream())
            {
                const int size = 4096;
                byte[] buffer = new byte[size];
                int numBytes;

                while ((numBytes = stream.Read(buffer, 0, size)) > 0)
                {
                    memStream.Write(buffer, 0, numBytes);
                }

                byte[] source = memStream.ToArray();
                source = DeAESlize(source);

                using (MemoryStream memStream2 = new MemoryStream(source))
                {
                    BinaryFormatter formatter = new BinaryFormatter();
                    data = formatter.Deserialize(memStream2) as SaveData;

                    Console.WriteLine("Loaded.");
                }
            }
        }

        return data;
    }

    static private byte[] AESlize(byte[] data)
    {
        ICryptoTransform encryptor = _rijindeal.CreateEncryptor();
        byte[] encrypted = encryptor.TransformFinalBlock(data, 0, data.Length);

        encryptor.Dispose();

        // Console.WriteLine(string.Join(" ", encrypted));

        return encrypted;
    }

    static private byte[] DeAESlize(byte[] data)
    {
        ICryptoTransform decryptor = _rijindeal.CreateDecryptor();
        byte[] plain = decryptor.TransformFinalBlock(data, 0, data.Length);

        // Console.WriteLine(string.Join(" ", plain));

        return plain;
    }
}

static public class EntryPoint
{
    static public void Main()
    {
        Console.WriteLine("Save? [y/n]");
        string cond = Console.ReadLine();

        if (cond == "y")
        {
            Save();
        }
        else
        {
            Load();
        }
    }

    static void Save()
    {
        Console.WriteLine("Name?");
        string name = Console.ReadLine();

        Console.WriteLine("Number?");
        float number;
        if (!float.TryParse(Console.ReadLine(), out number))
        {
            Console.WriteLine("Must input float value.");
            return;
        }

        Console.WriteLine("Count?");
        int count;
        if (!int.TryParse(Console.ReadLine(), out count))
        {
            Console.WriteLine("Must input int value.");
            return;
        }

        SaveData data = new SaveData
        {
            Name = name,
            Number = number,
            Count = count,
        };
        SaveDataManager.Save(data);
    }

    static void Load()
    {
        SaveData data = SaveDataManager.Load(SaveDataManager.SavePath);
        Console.WriteLine(data.ToString());
    }
}

UNETのLow Level APIを使ってシンプルな位置同期の仕組みを作る

概要

UNETとはUnityが提供しているネットワークゲームを作るためのシステムです。
UNETには「High Level API」と「Low Level API」があり、High Level APIについては以前、記事に簡単にまとめました。

edom18.hateblo.jp

今回はLow Level APIについて書きたいと思います。
サンプルとして、ごく簡単に位置を同期してみます。

実際に動かしてみたのがこちら↓

なお、今回の実装のサンプルはGithubにアップしてあるので興味がある人は見てみてください。

github.com

また、今回の実装にあたっては以下の記事を参考にさせていただきました。ありがとうございます。

tarukosu.hatenablog.com

概観する

まず、基本的なコネクションのフローなどをざっくりと見ていきたいと思います。

登場人物

ネットワーク対応にするにあたり、必要となるクラスなどを紹介します。

クラス

メインとなるクラスです。NetworkTransportsealedクラスになっていて、実質staticクラスになっています。

ネットワーク関連の初期化(IP AddressやPort番号の設定など)、データの送受信などネットワークに関連する処理は本クラスを用いて行います。

以下のHostTopologyクラスのコンストラクタ引数に指定するコンフィグ情報です。

トポロジーとは

こちらの記事から引用させてもらうと、

元々は位相幾何学の意味であるが、パソコン用語としては、LANの接続形態の総称。ネットワークトポロジーとも呼ばれる。LANには接続形態によって、リング型やバス型、スター型などがあり、接続する機器によって用途や目的に合った接続形態を使用する必要がある。

つまりは、どういう形態のネットワークなのか、を定義します。
コンストラクタにはコンフィグオブジェクトと最大接続数を指定します。

ConnectionConfig config = new ConnectionConfig();

// ... 中略 ...

int maxConnection = 10;
HostTopology topology = new HostTopology(config, maxConnection);

そして生成したホストトポロジーオブジェクトをホストとして登録します。

int hostId = NetworkTransport.AddHost(topology, _localPort);

登録すると、Host IDが返されるので保持しておきます。(後の通信時に利用します)

enum

QosType 説明
Unreliable There is no guarantee of delivery or ordering.
UnreliableFragmented There is no guarantee of delivery or ordering, but allowing fragmented messages with up to 32 fragments per message.
UnreliableSequenced There is no guarantee of delivery and all unordered messages will be dropped. Example: VoIP.
Reliable Each message is guaranteed to be delivered but not guaranteed to be in order.
ReliableFragmented Each message is guaranteed to be delivered, also allowing fragmented messages with up to 32 fragments per message.
ReliableSequenced Each message is guaranteed to be delivered and in order.
StateUpdate An unreliable message. Only the last message in the send buffer is sent. Only the most recent message in the receive buffer will be delivere |d.
ReliableStateUpdate A reliable message. Note: Only the last message in the send buffer is sent. Only the most recent message in the receive buffer will |be delivered.
AllCostDelivery A reliable message that will be re-sent with a high frequency until it is acknowledged.
UnreliableFragmentedSequenced There is garantee of ordering, no guarantee of delivery, but allowing fragmented messages with up to 32 fragments per mess |age.
ReliableFragmentedSequenced Each message is guaranteed to be delivered in order, also allowing fragmented messages with up to 32 fragments per message.

Quality of serviceの略です。到達の保証や順序の保証についてのクオリティを指定します。

NetworkEventType 説明
DataEvent Data event received. Indicating that data was received.
ConnectEvent Connection event received. Indicating that a new connection was established.
DisconnectEvent Disconnection event received.
Nothing No new event was received.
BroadcastEvent Broadcast discovery event received. To obtain sender connection info and possible complimentary message from them, call NetworkTransport.GetBroadcastConnectionInfo() and NetworkTransport.GetBroadcastConnectionMessage() functions.

データを受信した際のイベントタイプです。

フロー

簡単なフローは以下の通りです。

  1. NetworkTransport.Init();で初期化
  2. ConnectionConfigでコンフィグ情報を生成する
  3. 必要なQosType(Quality of service)をチャンネルに追加する
  4. HostTopolosyトポロジーオブジェクトを生成
  5. NetworkTransport.AddHost(...);でホスト(トポロジー)を登録
  6. NetworkTransport.Connect(...);でサーバへ接続(クライアントの場合)

セットアップ

さて、では実際のコードを参考にフローを見てみます。

ネットワークの初期化

サーバ/クライアントに関わらず、ネットワークを開始するためのセットアップを行います。

/// <summary>
/// ネットワークのセットアップ
/// </summary>
private void NetworkSetup()
{
    Debug.Log("Setup network.");

    _isStarted = true;

    // ネットワークの初期化処理
    // NetworkTransportクラスの他のメソッドを呼び出す前に初期化が必要
    NetworkTransport.Init();

    // コンフィグ情報の生成
    ConnectionConfig config = new ConnectionConfig();
    _channelId = config.AddChannel(_qosType);

    // トポロジーオブジェクトの生成
    int maxConnections = 10;
    HostTopology topology = new HostTopology(config, maxConnections);

    _hostId = NetworkTransport.AddHost(topology, _localPort);
}

コネクション、データ受信などのネットワークイベントをハンドリングする

ネットワークの初期化が終わったら、サーバへの接続およびデータの受信についてのハンドリングを行います。

前述の通りNetworkEventTypeというイベントタイプが定義されており、データを受信した際のタイプとして使用します。

データの受信にはNetworkTransport.Receive(...);メソッドを利用します。
詳細はコードを見てください。

/// <summary>
/// ネットワークイベントのハンドリング
/// </summary>
private void Receive()
{
    int receiveHostId;
    int connectionId;
    int channelId;
    byte[] receiveBuffer = new byte[_maxBufferSize];
    int bufferSize = _maxBufferSize;
    int dataSize;
    byte error;

    // 受信データは複数ある場合があるので、ループでデータが無くなるまで処理する
    do
    {
        NetworkEventType eventType = NetworkTransport.Receive(out receiveHostId, out connectionId, out channelId, receiveBuffer, bufferSize, out dataSize, out error);

        switch (eventType)
        {
            case NetworkEventType.Nothing:

                // 受信データなし

                return;
            case NetworkEventType.ConnectEvent:
                Debug.Log("Connected.");

                // Connection処理

                break;
            case NetworkEventType.DisconnectEvent:
                Debug.Log("Disconnected.");

                // Disconnection処理

                break;
            case NetworkEventType.DataEvent:
                
                // データ受信の処理

                break;
        }
    } while(true);
}

サーバへ接続する

サーバへ接続するにはNetworkTransport.Connect(...);メソッドを利用します。
以下のようにして、Host IDServer AddressServerPortExeption Connection IDを指定して接続を試みます。

あくまで試みるだけで、実際に接続が完了した場合はデータ受信(ハンドリング)のところで、ConnectEventが渡ってくるので、そこで適切に処理します。

/// <summary>
/// サーバへコネクションを張る
/// </summary>
private void Connect()
{
    byte error;
    _connectionId = NetworkTransport.Connect(_hostId, _serverAddress, _serverPort, 0, out error);
}

シリアライズしてデータを送受信する

接続が完了したら、次は必要なデータを送信します。
ただ注意が必要なのは、Vector3などの構造体はもちろん、floatなどのプリミティブな値もそのままでは送信することができません。

それらをbyte配列に変換し、シリアライズしてからデータを送信する必要があります。

/// <summary>
/// 位置を同期する
/// </summary>
private void SyncPosition()
{
    if (!_isServer && !_hasConnected)
    {
        return;
    }

    if (!_hasAuthorized)
    {
        return;
    }

    byte[] x = ConversionUtil.ToBytes(_target.position.x);
    byte[] y = ConversionUtil.ToBytes(_target.position.y);
    byte[] z = ConversionUtil.ToBytes(_target.position.z);

    byte[] pos = ConversionUtil.Serialize(x, y, z);

    if (_isServer)
    {
        for (int i = 0; i < _clientIds.Count; i++)
        {
            SendData(pos, _clientIds[i], pos.Length);
        }
    }
    else
    {
        SendData(pos, _connectionId, pos.Length);
    }
}
/// <summary>
/// データ送信
/// </summary>
/// <param name="data">送信するデータ配列</param>
public void SendData(byte[] data, int connectionId, int dataSize)
{
    byte error;
    NetworkTransport.Send(_hostId, connectionId, _channelId, data, dataSize, out error);
}

データのシリアライズ

シリアライズとは、こちらの記事から引用させてもらうと

シリアライズとは、複数の並列データを直列化して送信することである。

具体的には、メモリ上に存在する情報を、ファイルとして保存したり、ネットワークで送受信したりできるように変換することである。他方、既にファイルとして存在しているデータや、一旦シリアライズされたデータがネットワークから送られてきた際に、プログラムで扱えるようにする作業をデシリアライズと呼ぶ。

これが前述の「byte配列に変換して」の部分ですね。
intfloatなどは4バイトで表現されます。
つまり、これを1バイトごとに分解してシリアライズし、それをまとめて送る必要があります。

今回のサンプルでは、位置データ(Vector3)の各要素(x, y, z)をbyte配列に変換し、さらにそれをシリアル化してまとめて送信しています。

変換用のユーティリティクラスは以下のように実装しました。
(あくまで今回の簡易的な位置同期のための実装です)

[StructLayout(LayoutKind.Explicit)]
public struct UIntFloat
{
    [FieldOffset(0)]
    public uint intValue;

    [FieldOffset(0)]
    public float floatValue;

    [FieldOffset(0)]
    public double doubleValue;

    [FieldOffset(0)]
    public ulong longValue;
}

/// <summary>
/// 値をByte列に、Byte列を値に変換する
/// </summary>
public class ConversionUtil
{
    static public float ToFloat(uint value)
    {
        UIntFloat uif = new UIntFloat();
        uif.intValue = value;
        return uif.floatValue;
    }

    static public float ToFloat(byte[] values)
    {
        int x = 0;

        if (System.BitConverter.IsLittleEndian)
        {
            x |= (values[3] <<  0);
            x |= (values[2] <<  8);
            x |= (values[1] << 16);
            x |= (values[0] << 24);
        }
        else
        {
            x |= (values[0] <<  0);
            x |= (values[1] <<  8);
            x |= (values[2] << 16);
            x |= (values[3] << 24);
        }

        UIntFloat uif = new UIntFloat();
        uif.intValue = (uint)x;
        return uif.floatValue;
    }

    static public double ToDouble(ulong value)
    {
        UIntFloat uif = new UIntFloat();
        uif.longValue = value;
        return uif.doubleValue;
    }

    static public double ToDouble(byte[] values)
    {
        long x = 0;

        if (System.BitConverter.IsLittleEndian)
        {
            x |= ((long)values[7] <<  0);
            x |= ((long)values[6] <<  8);
            x |= ((long)values[5] << 16);
            x |= ((long)values[4] << 24);
            x |= ((long)values[3] << 32);
            x |= ((long)values[2] << 40);
            x |= ((long)values[1] << 48);
            x |= ((long)values[0] << 56);
        }
        else
        {
            x |= ((long)values[0] <<  0);
            x |= ((long)values[1] <<  8);
            x |= ((long)values[2] << 16);
            x |= ((long)values[3] << 24);
            x |= ((long)values[4] << 32);
            x |= ((long)values[5] << 40);
            x |= ((long)values[6] << 48);
            x |= ((long)values[7] << 56);
        }

        UIntFloat uif = new UIntFloat();
        uif.longValue = (ulong)x;
        return uif.doubleValue;
    }

    /// <summary>
    /// Floatの値をByte列に変換、ビッグエンディアンとして返す
    /// </summary>
    /// <param name="value">変換するfloat値</param>
    /// <returns>ビッグエンディアンのByte配列</returns>
    static public byte[] ToBytes(float value)
    {
        UIntFloat uif = new UIntFloat();
        uif.floatValue = value;

        uint x = uif.intValue;
        byte a = (byte)((x >>  0) & 0xff);
        byte b = (byte)((x >>  8) & 0xff);
        byte c = (byte)((x >> 16) & 0xff);
        byte d = (byte)((x >> 24) & 0xff);

        if (System.BitConverter.IsLittleEndian)
        {
            return new[] { d, c, b, a };
        }
        else
        {
            return new[] { a, b, c, d };
        }
    }

    /// <summary>
    /// Doubleの値をByte列に変換、ビッグエンディアンとして返す
    /// </summary>
    /// <param name="value">変換するdouble値</param>
    /// <returns>ビッグエンディアンのByte配列</returns>
    static public byte[] ToBytes(double value)
    {
        UIntFloat uif = new UIntFloat();
        uif.doubleValue = value;

        ulong x = uif.longValue;
        byte a = (byte)((x >>  0) & 0xff);
        byte b = (byte)((x >>  8) & 0xff);
        byte c = (byte)((x >> 16) & 0xff);
        byte d = (byte)((x >> 24) & 0xff);
        byte e = (byte)((x >> 32) & 0xff);
        byte f = (byte)((x >> 40) & 0xff);
        byte g = (byte)((x >> 48) & 0xff);
        byte h = (byte)((x >> 56) & 0xff);

        if (System.BitConverter.IsLittleEndian)
        {
            return new[] { h, g, f, e, d, c, b, a };
        }
        else
        {
            return new[] { a, b, c, d, e, f, g, h };
        }
    }

    static public byte[] Serialize(params byte[][] bytesArray)
    {
        int total = 0;
        for (int i = 0; i < bytesArray.Length; i++)
        {
            total += bytesArray[i].Length;
        }

        byte[] result = new byte[total];
        int index = 0;
        for (int i = 0; i < bytesArray.Length; i++)
        {
            System.Array.Copy(bytesArray[i], 0, result, index, bytesArray[i].Length);
            index += bytesArray[i].Length;
        }

        return result;
    }

    static public float Deserialize(byte[] bytes, int start, int end)
    {
        int num = end - start;
        byte[] data = new byte[num];

        for (int i = 0; i < num; i++)
        {
            data[i] = bytes[start + i];
        }

        return ToFloat(data);
    }
}

実際に利用に足るものにするには、以下のUnityのNetworkBufferのように、様々な値を簡単に取り扱えるようにする必要があります。
各値をbyte配列にしたり、などを実装しているので参考にしてみるといいと思います。

Unity-Technologies / Networking / source / Runtime / NetworkBuffer.cs — Bitbucket

ちなみに上で登場しているUIntFloat構造体ですが、メモリレイアウトを明示することによって効率的にintfloatなどを相互変換しています。

細かい点については以下の記事を見てください。

ufcpp.net

そこで示されているのは以下のようなUnionライクな構造体です。

using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit)]
struct Union
{
    [FieldOffset(0)]
    public byte A;
    
    [FieldOffset(1)]
    public byte B;
    
    [FieldOffset(2)]
    public byte C;
    
    [FieldOffset(3)]
    public byte D;
    
    [FieldOffset(0)]
    public int N;
}

public class Hello
{
    public static void Main()
    {
        Union x = new Union{ N = 0x12345678 };
        Console.WriteLine(x.A.ToString("x")); // => 78
        Console.WriteLine(x.B.ToString("x")); // => 56
        Console.WriteLine(x.C.ToString("x")); // => 34
        Console.WriteLine(x.D.ToString("x")); // => 12
    }
}

プロトコルについて考える

最後に少しだけプロトコルについて。

上記記事から引用させてもらうと、

コンピューター同士が通信をする際の手順や規約などの約束事。ネットワークでコンピューターが使う言語のようなもので、双方が理解できる同じプロトコルを使わないと通信は成立しない。

ということ。

Low Level APIではデータ送信のルールは実装者が決める必要があります。
どういう順番でデータを格納し、先頭から何バイトまでがヘッダを表すのか、などです。

コンピュータが認識できるのは01のビット信号のみです。
そしてそれを「バイト」という単位で扱い、それがどういう順番で並び、それがどういう意味を持っているのか、は人間が考えないとなりません。

今回のサンプルでは位置同期だけを行い、さらには同期するオブジェクトはひとつだけに限定していたため、送られてきたデータが位置を表すx, y, zのfloat値が3要素分、と固定して解釈していました。

しかし、実際に使えるものにするためには、「どのオブジェクトの位置なのか」や「誰から送られてきたのか」などの制御が必要になってきます。

そしてそれらは、ヘッダなど「データの構造を示すデータ」を有することで実現します。
このヘッダなどの「メタデータ」が何バイトから何バイトまでか、を規定しそれに基づいて処理を行う仕様が「プロトコル」というわけですね。

なので、Low Level APIを使って実際に使えるレベルの通信を行うためにはまだ実装しないとならないことがたくさんあります。
が、最低限の通信に関してはそこまで複雑なことはやっていません。

これを元に、簡単な通信の仕組みを作れれば色々と捗りそうですね。

頂点座標とUV座標から接ベクトルを求める

概要

法線マップを利用する際に使われる「接ベクトル空間」。
今回はその接ベクトルを頂点座標とUV座標から求めるくだりを書いてみます。

先に断っておくと、今回の記事の内容はマルペケさんのこちらの記事(その12 頂点座標とUV座標から接ベクトルを求めるちょっと眠い話)を参考に、Unityで実装したものになります。

なので、今回の記事は上記記事を参考にUnityで実装するにあたって自分の理解をメモするためのものです。

ちなみにUnityで動かすとこんな感じ↓

今回実装したやつはGithubにアップしてあります。

github.com

今回は接ベクトルについての話のみになります。
バンプマップについては以前、Qiitaの記事で書いているのでそちらをご覧ください。

qiita.com

余談ですが、前回の記事では(法線マップは使っていませんが)バンプマップを使って波紋エフェクトを表示する内容で記事を書いたのでよかったら読んでみてください。

edom18.hateblo.jp

接ベクトル空間

法線マップを利用する上で「接ベクトル空間」の話は外せません。
なぜなら、法線マップの情報はこの接ベクトル空間での値が格納されているからです。

ということで、今回は接ベクトル空間、接ベクトルを求める話です。

接ベクトルはその名の通り、「とある点(ピクセル)に接している」ベクトルのことです。
図にすると以下のような感じ。

f:id:edo_m18:20180827084802p:plain

見てもらうと分かる通り、とある点(ピクセル)の上に乗っている直交座標系と見ることができます。
ただ、「乗せる」と一口に言っても、「どう乗せるか」が問題です。

もちろん座標系をどう取ろうが本来は自由です。
しかし、今回は「法線マップ」の法線を適切に扱うことを目標としているので、自分勝手に定義してしまっては適切な法線を得ることはできません。

そしてこれが今回の主旨である「頂点情報から接ベクトルを求める」になりますが、座標系をどう取ったら適切な法線が取れるのか、を解説していきます。

接平面

接平面とは、今回の接ベクトル空間のUVベクトルが成す平面です。
つまり接ベクトルが成す平面ということですね。

接平面については以下の記事が分かりやすいでしょう。

接平面と接ベクトル - 物理とか

詳細は記事を読んでいただくとして、ざっくりと説明します。

記事を引用すると、

接平面を求めるには、曲面のある点において2つの接ベクトルを求めればいい

ということなのでまずは曲面を表す式を以下のように定義します。

$$ p = p ( u_1, u_2 ) $$

これはふたつのパラメータによって曲面が表されています。
方針としては曲面のある点において2つの接ベクトルを求めるので、この点を含むふたつの曲線を表現しそれの接線を求めることで接平面を求めることにします。

そしてできるだけ分かりやすい曲線を選ぶようにするために、以下のようにふたつの関数を定義してみます。

$$ x_1 = p( u_1(t), u_2 ) \\ x_2 = p( u_1, u_2(t) ) \ $$

これは、曲面の定義から媒介変数\(t\)によってパラメータが変化する2つの曲線の関数を取り出したと見ることができます。

さて、この曲線の変化を見ることで接線を求めることができるわけですね。
曲線の変化は微分の出番なので、以下のようにして偏微分で求めます。

$$ v_i = \frac{\partial p}{\partial u_i} $$

頂点にある5要素から接ベクトルを求める

接ベクトルを求めるのに「接平面」を考えることを書きました。
だいぶざっくり書いてしまったので、細かな点については紹介した記事を読んでください。

ここで言いたかったのは、接平面をどう定義し、どうやって必要なベクトルを求めるか、のヒントを得ることです。

さて、話は変わって。
3Dの世界では頂点はポリゴンを形成するひとつの点を表します
なにを当たり前のことを、と思うかもしれませんが、頂点を表す要素のうち、(x, y, z)要素が頂点の「位置」を決めます。
昔ながらの3Dであれば「頂点カラー」と呼ばれる、頂点ごとに設定された色情報を読み出して処理することもありました。(最近ではカラーとしてそのまま使うケースは稀でしょう)

このように、「頂点ごとに様々な情報」を付与してレンダリングを行うのが3Dです。
そしてテクスチャ空間をあらわす(u, v)値も頂点に付与される情報です。
これはもちろん、テクスチャの位置を決める値です。

つまり、位置を司る情報としては(x, y, z)に加え、(u, v)の合計5要素がある、というわけですね。

そして見出しの通り、この(u, v)値を利用して接ベクトルを求めます。

テクスチャ座標のUV値の変化を観察する

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

f:id:edo_m18:20180830131534p:plain

図ではU軸の方向に移動すると(x, y)の値がどう変化するかを示したものです。
(本来は(z)の値も同様に考える必要がありますが、図的に分かりづらいので(x, y)に絞って説明しています)

よくよく見てみると、UVの値の変化の方向はすでに「接平面」に存在していることが分かります。
そしてu方向に移動したとき、xyの値がどう変化するかを図示したのが上の図です。

u方向に少しだけ移動したとき、xの値がどう変化するのか、あるいはyの値がどう変化するのか。
それぞれの要素に絞って変化を見ているわけです。
これって「偏微分」の考え方ですよね。

そうです。U軸方向への(x, y, z)それぞれの要素の変化量を見ることでU軸の方向ベクトルを得ることができるのです。
これは言ってみれば、UV値の勾配を求めることでそれぞれのベクトルが求まる、ということですね。

そして今回求めたいのは接ベクトル空間の接ベクトルおよび従法線ベクトルです。
これはまさにU軸、V軸ベクトルの方向ですね。

偏微分の記号を使ってU軸およびV軸を表してみると、

$$ U軸 = \biggr(\frac{\partial x}{\partial u}, \frac{\partial y}{\partial u}, \frac{\partial z}{\partial u}\biggl) $$

$$ V軸 = \biggr(\frac{\partial x}{\partial v}, \frac{\partial y}{\partial v}, \frac{\partial z}{\partial v}\biggl) $$

と表すことができます。

ローカル座標をUV座標で表す

前述のように位置に関する頂点の情報は全部で5つあります。

$$ P_0 = (x_0, y_0, z_0, u_0, v_0) $$

ですね。

マルペケさんの記事を引用させていただくと、

ここでうまい事を考えます。5つの成分のうち、例えば(x, u, v)の3成分だけに注目し、3頂点から平面を作ってみます。平面は点の数が3つあればできるわけです。平面の方程式にすると、

\(A_0 x + B_0 u + C_0 v + D_0 = 0\)

です。両辺を\(D_0\)で割ると正規化されて、

\(A_0 x + B_0 u + C_0 v + 1 = 0\)

となります。(ABCの記号が同じ添え字なのは目を瞑ってください(^-^;)。このABCは未知の係数ですが、今頂点が3つあるので解く事ができます。連立方程式を立てても良いのですが、平面の方程式の(A,B,C)は平面の法線である事を利用すると、ポリゴンの法線から一発で求まります。

とのこと。

さて、上記の式を整理すると以下のようになります。

$$ \begin{eqnarray} A_0 x + B_0 u + C_0 v + 1 &=& 0 \\ A_0 x &=& -B_0 u - C_0 v - 1 \\ x &=& -\frac{B_0}{A_0}u - \frac{C_0}{A_0}v - \frac{1}{A_0} \end{eqnarray} $$

\(x\)についての式に変形したわけですね。
さて、これを\(u\)について偏微分してみます。すると以下のようになります。

$$ \frac{\partial x}{\partial u} = -\frac{B_0}{A_0} $$

お。これは先に書いた偏微分の\(x\)要素じゃないですか。
そう、この値を導き出すために上記のように平面の方程式を持ち出したのですね。

なお、引用した文章でも示されている通り、この\(ABC\)は平面の方程式の意味から「法線ベクトルの各要素(\(x, y, z\))」です。
そして平面の法線は3頂点からベクトルを作り、その外積によって求めることができます。

ちなみに平面の方程式についてはこちらを参照↓

mathtrain.jp

そしてU軸およびV軸のベクトルの各要素は、上の例を(y, z)にも適用してやることで以下のように求まります。

$$ U = \biggr(\frac{\partial x}{\partial u}, \frac{\partial y}{\partial u}, \frac{\partial z}{\partial u}\biggl) = \biggr(-\frac{B_0}{A_0}, -\frac{B_1}{A_1}, -\frac{B_2}{A_2}\biggl) $$

$$ V = \biggr(\frac{\partial x}{\partial v}, \frac{\partial y}{\partial v}, \frac{\partial z}{\partial v}\biggl) = \biggr(-\frac{C_0}{A_0}, -\frac{C_1}{A_1}, -\frac{C_2}{A_2}\biggl) $$

さぁ、これを元に実際のコードに落とし込むと以下のようになります。

実装コード

今回はUnityで実装したのでC#コードです。

static public Vector3[] GetTangentSpaceVectors(Vector3[] p, Vector2[] uv)
{
    Vector3[] cp0 = new[]
    {
        new Vector3(p[0].x, uv[0].x, uv[0].y),
        new Vector3(p[0].y, uv[0].x, uv[0].y),
        new Vector3(p[0].z, uv[0].x, uv[0].y),
    };

    Vector3[] cp1 = new[]
    {
        new Vector3(p[1].x, uv[1].x, uv[1].y),
        new Vector3(p[1].y, uv[1].x, uv[1].y),
        new Vector3(p[1].z, uv[1].x, uv[1].y),
    };

    Vector3[] cp2 = new[]
    {
        new Vector3(p[2].x, uv[2].x, uv[2].y),
        new Vector3(p[2].y, uv[2].x, uv[2].y),
        new Vector3(p[2].z, uv[2].x, uv[2].y),
    };

    Vector3 u = Vector3.zero;
    Vector3 v = Vector3.zero;

    for (int i = 0; i < 3; i++)
    {
        Vector3 v1 = cp1[i] - cp0[i];
        Vector3 v2 = cp2[i] - cp0[i];
        Vector3 ABC = Vector3.Cross(v1, v2).normalized;

        if (ABC.x == 0)
        {
            Debug.LogWarning("ポリゴンかUV上のポリゴンが縮退しています");
            return new[] { Vector3.zero, Vector3.zero };
        }

        u[i] = -(ABC.y / ABC.x);
        v[i] = -(ABC.z / ABC.x);
    }

    u.Normalize();
    v.Normalize();

    return new[] { u, v };
}

やっていることは前述の文章の説明をそのままプログラムしただけです。
それぞれのベクトルの外積を取って\(ABC\)を求め、そこからUV軸のベクトル要素としているのが分かるかと思います。

以上で接ベクトルを求めることができました。
もしバンプマップなどの計算に用いたい場合は、この接ベクトル空間へライトなどの位置を変換してやり、そこでのシェーディングを計算することで法線マップを利用したライティングが可能になる、というわけです。

パースペクティブコレクト

さて、最後は少しだけ余談です。

今回の色々を実装する際に初めて聞いた単語。

以下の記事に詳しく書かれていました。

ラスタライザを作る人の古文書集 - ushiroad

どういうものかざっくり書くと。

通常、3DCGで画面にレンダリングを行う際は頂点情報を入力しそれをいくつかの座標変換を経て、最終的にスクリーンに表示します。
そしてこのとき、ポリゴンに指定された値(色とUV値など)はSlopeとSpanという処理を行い補間します。

冒頭の記事から引用させていただくと、

ポリゴンを描画するとき、頂点の属性(典型的には色、UV座標)を滑らかに変化させながら各ピクセルを描きます。このとき、頂点の属性を頂点間を結ぶ辺上で内挿したもの(属性値の"坂")はSlopeと呼ばれます。 さらに、2本のSlopeの間にSpan(橋)を架け、この両端の値を内挿することにより、三角形内部の全ての点で内挿値を得ることができます。

ということ。
この補間処理の際、UV値を色などと同じ方法で補間してしまうと問題が生じる、ということのようです。
紹介した記事にはどんな感じになるのかの画像があるので見てみてください。

さて、ではどういうふうに補間処理をしたらいいのかというと。
これも冒頭の記事に解説があります。引用させていただくと、

パースペクティブコレクトの具体的な方法ですが、テクスチャ座標(u, v)をそのまま補間するのではなく、透視変換で出てくる斉次のwを使い

(u/w, v/w, 1/w) という値を頂点毎に作り、これでSlope/Spanの処理を行います。補間結果を

{ (u/w)' , (v/w)' , (1/w)' } としたら、(u/w)' および (v/w)' を (1/w)' で割ります(つまり、補間前にwの逆数をとり、補間後にもう一度wをひっくり返します)

自分の理解を書くと、射影変換された空間で補間処理を行い、かつ1/wも同様に補間しておく。そして補間後の値((u/w)', (v/w)')を、補間した(1/w)'で元に戻すことで正常な値が得られる、ということのようです。
(射影空間上で補完処理をして戻す、ということかもしれません)

パースペクティブコレクトを用いるとき

なぜパースペクティブコレクトについて書いたかというと。
前回書いた記事↓

edom18.hateblo.jp

これを実装しようと考えたとき、法線の方向の計算がおかしくてバンプマップについて改めて調べていたときにInk Painter - Asset Storeを作られている方のブログを読んでいて知った単語でした。

そのときに読んだ記事↓

esprog.hatenablog.com

最終的にはこの話はまったく関係なかったのですが、調べていた過程で見つけたってことで備忘録的に書いてみました。

ちなみに、普通はパースペクティブコレクトされた状態でUV座標は補間されるので意識することはあまりないと思いますが、上記記事では「とある点」に近い位置のUV値を算出するようにしています。

その際にまさにこのパースペクティブコレクトが必要となります。
UV値をいじる必要が出た場合はこれを思い出すといいかと思います。