e.blog

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

モデルの頂点を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値をいじる必要が出た場合はこれを思い出すといいかと思います。

CustomRenderTextureを使って波紋エフェクトを作る

概要

何番煎じか分かりませんが、CustomRenderTextureを使ってシェーダでお絵かきができると色々表現の幅が増えるので、それの練習のために表題のサンプルを作ってみました。

↓実際に動かしたの動画

サンプルはCustomRenderTextureを使って波動方程式を解き波形を描画、さらにそれをレンダリング用シェーダの法線マップとして入力し、歪みとライティング(ランバート反射とフォン反射)を適用してみたものです。

ダウンロード

今回のサンプルもGithubにアップしてあります。

github.com

CustomRenderTextureとは

ドキュメントは以下です。
docs.unity3d.com

ドキュメントから引用させてもらうと、

カスタムレンダーテクスチャはレンダーテクスチャの拡張機能で、これを使うと簡単にシェーダー付きのテクスチャを作成できます。これは、コースティクス、雨の効果に使われるリップルシミュレーション、壁面へぶちまけられた液体など、あらゆる種類の複雑なシミュレーションを実装するのに便利です。また、カスタムレンダーテクスチャはスクリプトやシェーダーのフレームワークを提供し、部分的更新、または、マルチパスの更新、更新頻度の変更などのさらに複雑な設定をサポートします。

つまり、波形などの複雑なシミュレーションを必要とする演算をシェーダベースで行い、それをテクスチャとして利用できるようにしてくれる機能です。
この機能の面白いところは、updateや初期化などはある程度テクスチャ側の設定項目でまかなえるので、シェーダだけでも完結できる点にあります。

つまりC#スクリプトはいらないということです。
もちろん、細やかな制御をするためにスクリプトから制御することも可能です。

今回の波紋シミュレーションではスクリプトによる更新はしていません。
そしてそれを法線マップとして利用することで冒頭の動画のようなエフェクトを生成しています。

CustomRenderTexture用のcgincがある

さて、では実際に実装を行っていきます。
CustomRenderTextureは前述の通り、シェーダ(マテリアル)を設定するだけでその結果をRenderTextureに保持してくれる便利なものです。

そのため、RenderTextureに保存したい絵を描くためのシェーダを書く必要があります。
(ちなみに、CustomRenderTextureが登場する前は、C#も動員して自分で保存などの処理を書く必要がありました)

まずはCustomRenderTexture向けのシェーダで利用するものを紹介します。

// 専用のcgincファイル
#include "UnityCustomRenderTexture.cginc"

// 専用の定義済みvertexシェーダ関数
#pragma vertex CustomRenderTextureVertexShader

// 専用の構造体
struct v2f_customrendertexture { ... }

// テクスチャサイズを取得
float width = _CustomRenderTextureWidth;
float height = _CustomRenderTextureHeight;

// UV座標を取得
float2 uv = i.globalTexcoord;

// CustomRenderTextureからテクセルをフェッチ
float3 c = tex2D(_SelfTexture2D, uv);

Unityでシェーダを書いたことがある人であればどう使うのかイメージ付くかと思います。 CustomRenderTexture向けにシェーダを書く場合、上記のようにいくつか専用のものが定義されているのでそれを利用します。

これらを使ってCurstomRenderTextureにお絵かきするシェーダを書いていきます。

CustomRenderTexture用シェーダを書く

では実際のシェーダを書いていきます。

波動方程式を解く

CustomRenderTextureで利用するシェーダが分かったところで、実用的な使い方として波動方程式を解いた波形を描いてみたいと思います。

ちなみに、以前自分もQiitaの記事で波動方程式について書いているので、よかったらそちらも参考にしてみてください。(実装はWebGLですが)

qiita.com

上の記事は以下の動画を元に実装したものになります。
波動方程式について、高校数学でも分かるような感じで丁寧に説明してくれていてとても分かりやすいのでオススメです。

www.nicovideo.jp

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

tips.hecomi.com

波動方程式を解くシェーダ(for CustomRenderTexture)

下記シェーダは上記の凹みさんの記事で書かれているものを利用させていただきました。
それを少し整形し、自分がコメントを追記したものです。

Shader "Hidden/WaveShader"
{
    Properties
    {
        _S2("PhaseVelocity^2", Range(0.0, 0.5)) = 0.2
        _Atten("Attenuation", Range(0.0, 1.0)) = 0.999
        _DeltaUV("Delta UV", Float) = 3
    }

        SubShader
    {
        Cull Off
        ZWrite Off
        ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag

            #include "UnityCustomRenderTexture.cginc"

            half _S2;
            half _Atten;
            float _DeltaUV;
            sampler2D _MainTex;

            float4 frag(v2f_customrendertexture  i) : SV_Target
            {
                float2 uv = i.globalTexcoord;

                // 1pxあたりの単位を計算する
                float du = 1.0 / _CustomRenderTextureWidth;
                float dv = 1.0 / _CustomRenderTextureHeight;
                float3 duv = float3(du, dv, 0) * _DeltaUV;

                // 現在の位置のテクセルをフェッチ
                float2 c = tex2D(_SelfTexture2D, uv);

                // ラプラシアンフィルタをかける
                // |0  1 0|
                // |1 -4 1|
                // |0  1 0|
                float k = (2.0 * c.r) - c.g;
                float p = (k + _S2 * (
                    tex2D(_SelfTexture2D, uv - duv.zy).r +
                    tex2D(_SelfTexture2D, uv + duv.zy).r +
                    tex2D(_SelfTexture2D, uv - duv.xz).r +
                    tex2D(_SelfTexture2D, uv + duv.xz).r - 4.0 * c.r
                )) * _Atten;

                // 現在の状態をテクスチャのR成分に、ひとつ前の(過去の)状態をG成分に書き込む。
                return float4(p, c.r, 0, 0);
            }
            ENDCG
        }
    }
}

これを適切にCustomRenderTextureにセットすると以下のようにシミュレーションが進んでいきます。

f:id:edo_m18:20180823084141g:plain

CustomRenderTextureのセットアップ

シェーダが書けたところで、実際に使用するためにセットアップを行っていきます。

CustomRenderTextureの生成

まずはCustomRenderTextureを生成します。
生成には以下のようにメニューから生成することができます。

f:id:edo_m18:20180823091039p:plain

CustomRenderTexutreの設定

生成したCustomRenderTextureのインスペクタを見ると以下のような設定項目があります。

f:id:edo_m18:20180823091057p:plain

設定項目抜粋

いくつかの設定項目について見ていきます。

Size

CustomRenderTextureのサイズです。

Color Format

今回は凹みさんの記事にならって「RG Float」としています。

これは、現在の波の高さをR要素に、ひとつ前の波の高さをG要素に格納して計算を行うためです。

Material

CustomRenderTextureで使用するシェーダを適用したマテリアルを設定します。
サブ項目に「Shader Pass」がありますが、これは複数パスを定義した場合に、どのパスを利用するか選択できるようになっています。

Initialize Mode

初期化モード。OnLoadとすることで、起動時に初期化されます。
スクリプトから制御する場合は「OnDemand」を選択します。

サブ項目として「Source」があります。
設定できる内容は「Texture and Color」か「Material」があります。
今回は初期化をテクスチャから指定しています。
その場合は初期化に利用するテクスチャを設定する必要があります。

Update Mode

テクスチャの更新に関する設定です。
サンプルでは「Realtime」を設定しています。
こうすることで、スクリプトなしに自動的に更新が行われるようになります。

なお、スクリプトから制御したい場合はこちらも「OnDemand」を選択します。

Double Buffered

ダブルバッファのオン/オフ。
これをオンにすることで、テクスチャの情報をピンポンするように更新することができるようになります。

バンプマップに関して

以上でCustomRenderTextureに関する話はおしまいです。

今回の例ではシミュレーションした波形を利用してバンプマップを行っています。
ただ、シミュレーションした結果はRG要素しかない上に、それぞれのチャンネルはHeightMapとなっているためそのままでは法線マップとして利用できません。

そのため、取得したHeightMapから法線を計算して利用する必要があります。

バンプマップとは

法線マップを使ってポリゴンに凹凸があるように見せる処理です。
以前、Qiitaの記事にも書いたのでよかったら見てみてください。
なお、今回は法線をハイトマップから計算で求めて適用するための方法について書いていきます。

qiita.com

法線をハイトマップから計算で求める

ということで、法線の計算についても書いておきたいと思います。
法線の計算にあたっては、以下のふたつの記事を参考にさせていただきました。

t-pot『動的法線マップ』

esprog.hatenablog.com

法線は接ベクトルに垂直

接ベクトルをWikipediaで調べると以下のようにあります。

数学において、接ベクトル(英: tangent vector)とは、曲線や曲面に接するようなベクトルのことである。

そして、参考にさせていただいた記事(t-pot『動的法線マップ』)から説明を引用させていただくと、

法線ベクトルの求め方ですが、接ベクトルが法線ベクトルに直行することを利用します。 今回の場合は、高さ情報だけしか変化しないので、X軸及びZ軸の方向に関する高さの偏微分が接ベクトルの方向になります。

ということ。
つまり、接ベクトルをX軸およびZ軸に対して求め、それの外積を取ることで法線を求める、ということです。

図にすると以下のようなイメージです。

f:id:edo_m18:20180823122548p:plain

数式にすると以下のようになります。

$$ dy_x = \frac{(y_{i+1j} - y_{ij}) + (y_{ij} - y_{i-1j})}{2} = \frac{y_{i+1j} - y_{i-1j}}{2} \\ dy_z = \frac{(y_{ij+1} - y_{ij}) + (y_{ij} - y_{ij-1})}{2} = \frac{y_{ij+1} - y_{ij-1}}{2} $$

よって、これを利用して以下のように法線を求めることができます。

float3 du = (1, dyx, 0)
float3 dv = (0, dyz, 1)
float3 n = normalize(cross(dv, du))

実装

コードにすると以下のようになります。(一部抜粋)

// _ParallaxMap_TexelSizeは、テクスチャサイズの逆数
// テクセルの「ひとつ隣(シフト)」分の値を計算する
float2 shiftX = float2(_ParallaxMap_TexelSize.x, 0);
float2 shiftZ = float2(0, _ParallaxMap_TexelSize.y);

// 現在計算中のテクセルの上下左右の隣のテクセルを取得
float3 texX = tex2D(_ParallaxMap, float4(i.uv.xy + shiftX, 0, 0)) * 2.0 - 1;
float3 texx = tex2D(_ParallaxMap, float4(i.uv.xy - shiftX, 0, 0)) * 2.0 - 1;
float3 texZ = tex2D(_ParallaxMap, float4(i.uv.xy + shiftZ, 0, 0)) * 2.0 - 1;
float3 texz = tex2D(_ParallaxMap, float4(i.uv.xy - shiftZ, 0, 0)) * 2.0 - 1;

// 偏微分により接ベクトルを求める
float3 du = float3(1, (texX.x - texx.x), 0);
float3 dv = float3(0, (texZ.x - texz.x), 1);

// 接ベクトルの外積によって「法線」を求める
float3 n = normalize(cross(dv, du));

実装は難しい点はありません。
上下の高さ差分と、左右の高さ差分からそれぞれ勾配(接ベクトル)を求め、その2つのベクトルから外積を求めるだけです。
法線は接ベクトルと垂直なので、これで無事、法線が計算できたことになりますね。

最後に、バンプマップを行ったシェーダの全文を載せておきます。

Shader "Unlit/HightMapNormal"
{
    Properties
    {
        _Color("Tint color", Color) = (1, 1, 1, 1)
        _MainTex("Texture", 2D) = "white" {}
        _ParallaxMap("Parallax Map", 2D) = "gray" {}
    }

        SubShader
    {
        Tags { "RenderType" = "Transparent" "Queue" = "Transparent" }
        LOD 100

        GrabPass { }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float4 uvgrab : TEXCOORD1;
                float3 worldPos : TEXCOORD2;
            };

            sampler2D _MainTex;
            sampler2D _ParallaxMap;
            sampler2D _GrabTexture;
            float4 _MainTex_ST;
            fixed4 _Color;

            float2 _ParallaxMap_TexelSize;

            v2f vert(appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.worldPos = mul(UNITY_MATRIX_MV, v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);

#if UNITY_UV_STARTS_AT_TOP
                float scale = -1.0;
#else
                float scale = 1.0;
#endif
                o.uvgrab.xy = (float2(o.vertex.x, o.vertex.y * scale) + o.vertex.w) * 0.5;
                o.uvgrab.zw = o.vertex.zw;

                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float2 shiftX = float2(_ParallaxMap_TexelSize.x, 0);
                float2 shiftZ = float2(0, _ParallaxMap_TexelSize.y);

                float3 texX = tex2D(_ParallaxMap, float4(i.uv.xy + shiftX, 0, 0)) * 2.0 - 1;
                float3 texx = tex2D(_ParallaxMap, float4(i.uv.xy - shiftX, 0, 0)) * 2.0 - 1;
                float3 texZ = tex2D(_ParallaxMap, float4(i.uv.xy + shiftZ, 0, 0)) * 2.0 - 1;
                float3 texz = tex2D(_ParallaxMap, float4(i.uv.xy - shiftZ, 0, 0)) * 2.0 - 1;

                float3 du = float3(1, (texX.x - texx.x), 0);
                float3 dv = float3(0, (texZ.x - texz.x), 1);

                float3 n = normalize(cross(dv, du));

                i.uvgrab.xy += n * i.uvgrab.z;

                fixed4 col = tex2Dproj(_GrabTexture, UNITY_PROJ_COORD(i.uvgrab)) * _Color;

                float3 lightDir = normalize(_WorldSpaceLightPos0 - i.worldPos);
                float diff = max(0, dot(n, lightDir)) + 0.5;
                col *= diff;

                float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
                float NdotL = dot(n, lightDir);
                float3 refDir = -lightDir + (2.0 * n * NdotL);
                float spec = pow(max(0, dot(viewDir, refDir)), 10.0);
                col += spec + unity_AmbientSky;

                return col;
            }
            ENDCG
        }
    }
}

このシェーダのマテリアルを平面に適用することで、冒頭の動画の効果を得ることができます。

参考記事

その他、参考になった記事を載せておきます。

esprog.hatenablog.com

esprog.hatenablog.com

カールノイズを使ったパーティクル表現

f:id:edo_m18:20180726190953p:plain

概要

以前、カールノイズについてふたつの記事を書きました。
カールノイズの「流体表現」についてと「衝突判定」についてです。

edom18.hateblo.jp

edom18.hateblo.jp

今回はこれを発展させて、上記のカールノイズを使った具体的なパーティクル表現について書きたいと思います。
実際に動いている様子はこんな感じです↓

移動量に応じて自動でエミットするタイプ↓

モデルの頂点位置からエミットするタイプ↓

上の例では移動距離に応じて発生させるパーティクルと、メッシュの頂点部分にパーティクルを発生させる2パターンを実装しました。

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

github.com

ちなみに、後者の「頂点部分にパーティクル」というのは、ドラクエ11リレミトの表現をイメージしてみました↓


【ドラクエ11】目指せ最速クリア!ぶっ続けでプレイして世界を救う配信!【ネタバレ注意】

前回の実装ではパーティクルはすべて常にアップデートされていた

前の実装ではすべてのパーティクルが生きていて常に更新がかかる実装になっていました。
以前に投稿した動画はこんな感じです↓

見てもらうと分かりますがパーティクルが発生し続けていて「任意のタイミングで追加する」というケースがなかったわけですね。
つまりパーティクルは常に発生し続けライフタイムが0になったらまた新しく生成し直す、というサイクルで実装していました。

具体的には、ライフタイムがゼロになったらライフタイムを回復させ、位置を初期位置(ランダム性あり)として更新していました。

なので毎フレームパーティクルのデータを更新すればよく、休止中のパーティクルを起こす必要もなければ状態を管理する必要もなかったわけです。
しかし移動距離で発生させたりなど、「任意のタイミングで追加」する必要がある場合は「活動中」と「休止中」のパーティクルを管理し、追加の場合は休止中のパーティクルに対して処理を実行する必要が出てきます。

連続してパーティクルをエミットする

冒頭で紹介したパーティクル表現では(Unity標準のパーティクルシステムでも同様の機能がありますが)「移動した距離に応じてエミットする」という実装になっています。
つまり、前に発生させたパーティクルは維持しつつ、新しく追加でパーティクルを発生させる必要がある、というわけです。

パーティクルをプールして管理する

今回の実装ではパーティクルの状態を管理し、必要であれば追加でエミットする必要があることは書きました。
ではそれをどうやったら実現できるのでしょうか。

先に結論を書いてしまうと、休止しているパーティクルのIDを保持するバッファを用意し、休止になったパーティクルのIDはプールへ戻し、追加の必要が出た場合はそのプールからIDを取り出し利用する、という仕組みを作ります。

この実装にあたっては、凹みTipsの以下の記事を参考にさせていただきました。

tips.hecomi.com

まずはザッと今回新しく追加した処理を見てみます。

プールの状態を初期化するInitカーネル

Initカーネルで行っているのは、用意されたパーティクルバッファ分の初期化です。
処理はシンプルに、全パーティクルの非活性化およびそのIDのプールへの保存です。

////////////////////////////////////////////////////////////
///
/// 初期化処理のカーネル関数
///
[numthreads(8, 1, 1)]
void Init(uint id : SV_DispatchThreadID)
{
    _Particles[id].active = false;
    _DeadList.Append(id);
}

上記の_DeadList.Append(id);がプールへIDを保存している箇所ですね。
_DeadListの名前の通り、非活性化状態のパーティクルのIDを保存しているバッファです。

さて、このAppendを実行しているバッファはなんでしょうか。答えは以下です。

Append Buffer、Consume Bufferを利用してプールを管理する

Append BufferConsume Bufferはそれぞれ、追加・取り出し可能なLIFO(Last In First Out)コンテナです。(つまり、最後に入れた要素が最初に取り出されるタイプのコンテナです)

MSDNのドキュメントは以下です。

docs.microsoft.com

docs.microsoft.com

AppendStructuredBufferのドキュメントから引用すると、

Output buffer that appears as a stream the shader may append to. Only structured buffers can take T types that are structures.

シェーダから追加することができるストリームとしてのバッファ、とあります。
このバッファに対して、寿命が来たパーティクルをバッファに入れてあげることで死活管理ができるというわけです。

ちなみにシェーダ内では以下のように定義しています。

AppendStructuredBuffer<uint> _DeadList;
ConsumeStructuredBuffer<uint> _ParticlePool;

さて、ふたつのバッファを定義しているのでふたつのバッファを用意する必要があるのか、と思われるかもしれませんが、インターフェースが違うだけでバッファとしての実態は同じものを利用します。

CPU(C#)側での処理は以下のようになっています。

private ComputeBuffer _particlePoolBuffer;
// ---------------------
_particlePoolBuffer = new ComputeBuffer(_particleNumLimit, sizeof(int), ComputeBufferType.Append);
// ---------------------
_computeShader.SetBuffer(_curlnoiseKernel, _deadListId, _particlePoolBuffer);
// ---------------------
_computeShader.SetBuffer(_emitKernel, _particlePoolId, _particlePoolBuffer);

まず、ComputeBuffer型の_particlePoolBufferを定義し、タイプをComputeBufferType.Appendとしてバッファを生成しています。

その後SetBufferによってバッファをセットしていますが、見て分かる通り実際に渡しているバッファの参照はどちらも同じ_particlePoolBufferです。

つまり、追加する場合はAppendStructuredBufferとして参照を渡し、取り出すときはConsumeStructuredBufferとして参照を渡す、というわけですね。

これで、寿命が尽きたパーティクルはバッファ(リスト)に戻され、必要になったときに、非活性化しているパーティクルのIDを「取り出して」値を設定することで、今回のように追加でエミットできるようになる、というわけです。

パーティクルを追加でエミットする

さて、仕組みが分かったところで実際にエミットしているところを見てみましょう。

////////////////////////////////////////////////////////////
///
/// パーティクルをエミットさせるカーネル関数
///
[numthreads(8, 1, 1)]
void Emit()
{
    uint id = _ParticlePool.Consume();

    float2 seed = float2(id + 1, id + 2);
    float3 randomPosition = rand3(seed);

    Particle p = _Particles[id];

    p.active = true;
    p.position = _Position + (randomPosition * 0.05);
    p.velocity = _Velocity;
    p.color = _Color;
    p.scale = _Scale;
    p.baseScale = _BaseScale;
    p.time = 0;
    p.lifeTime = randRange(seed + 1, _MinLifeTime, _MaxLifeTime);
    p.delay = _Delay;

    _Particles[id] = p;
}

冒頭のuint id = _ParticlePool.Consume();がIDを取り出しているところですね。
こうして、非活性化しているパーティクルのIDを取り出し、取り出したパーティクルに対して新しくライフタイムや色、位置などを設定することで無事、該当のパーティクルが動き出す、というわけです。

パーティクルの死活管理

さて、パーティクル生成の最後に実際にパーティクルをアップデートしている箇所を見てみます。
前回の実装ではライフタイムがゼロになった場合はまた新しいライフタイムを即座に設定し、すぐに復活させていました。

が、今回は「非活性化状態リスト」に追加する必要があります。
該当の処理は以下になります。

////////////////////////////////////////////////////////////
///
/// カールノイズのカーネル関数
///
[numthreads(8, 1, 1)]
void CurlNoiseMain(uint id : SV_DispatchThreadID)
{
    // ... 前略

    if (p.active)
    {
        // ... 中略

        if (p.time >= p.lifeTime)
        {
            p.active = false;
            _DeadList.Append(id);
            p.scale = 0;
        }
    }

    // ... 後略
}

ライフタイムがゼロ以下になった場合は_DeadList.Append(id);として、非活性化リストに戻しているのが分かるかと思います。

これで晴れて、パーティクルの死活管理ができる、というわけです。

パーティクルの残り数を考慮する

以上でパーティクルを追加でエミットすることが可能になりました。
しかし今のままだと少し問題が残っています。

というのは、パーティクルの残数を管理する必要があることです。
パーティクルの計算にはComputeBufferによって渡されたパーティクル情報を元に計算を行います。
このとき、初期化のタイミングでパーティクル数を規定します。(バッファサイズを明示する必要があります)
サンプルでは30000という数字でバッファを生成しています。

つまり、(当然ですが)このサイズを超えるパーティクルは生成することができません。
しかし、今回の追加エミットの処理ではその上限を超えてパーティクルの追加を要求することができてしまいます。

当然、バッファサイズを超えた数をリクエストしてもプールには休止中のIDがなく、結果生成されることがありません。
自分が遭遇した問題としては、上限を超えてリクエストを行ってしまうとプールに戻るパーティクルがなくなり、結果的にパーティクルが再生されない、という問題がありました。
しかもその状態が発生してしまうと以後、まったくパーティクルが発生しなくなる、という問題に遭遇しました。

なので「上限を超えないように」エミットを調整しないとならない、というわけです。

現在のプールに残っているIDの数を取得する

ではどうするのか。
結論から先に書いてしまうと、プールに残っているIDの数を取得して調整を行います。

プールのカウントを取得するにはComputeBuffer.CopyCountを利用します。
取得、確認しているコードは以下です。

_particleArgsBuffer = new ComputeBuffer(1, sizeof(int), ComputeBufferType.IndirectArguments);
_particleArgs = new int[] { 0 };

// --------------------

_particleArgsBuffer.SetData(_particleArgs);
ComputeBuffer.CopyCount(_particlePoolBuffer, _particleArgsBuffer, 0);

_particleArgsBuffer.GetData(_particleArgs);

return _particleArgs[0];

最終的に_particleArgsint型の配列に結果が格納されます。
今回のサンプルでは30000のパーティクルを生成しているので、まったくの未使用状態だと_particleArgs[0] == 30000となります。

MSDNのドキュメント↓
ComputeBuffer.CopyCount - Unity スクリプトリファレンス

あとは、ここで取得した数と実際にエミットしたい数とを比較すればバッファ以上のパーティクルを生成してしまうのを防ぐことができるようになります。