e.blog

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

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

概要

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

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


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

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

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

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

qiita.com

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

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

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

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

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

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

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

ことの発端

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

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


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

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

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

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


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

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

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

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


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

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

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

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

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

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

ドキュメントはこちら↓

docs.unity3d.com

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UV2に全頂点を書き出す

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

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

UV2の値はUVと違う?

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

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

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

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

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

f:id:edo_m18:20181107094020p:plain

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

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

f:id:edo_m18:20181107094430p:plain

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

f:id:edo_m18:20181107094626p:plain

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

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

f:id:edo_m18:20181107104218p:plain

f:id:edo_m18:20181107103404p:plain

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

コード抜粋

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

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

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

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

    return o;
}

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

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

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

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

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

    RenderTexture temp = RenderTexture.active;

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

    drawingMat.SetPass(0);

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

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

    Swap(_pingPongRts);

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

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

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

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

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

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

github.com

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

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

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

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

まとめ

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

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