プログラムdeタマゴ

nodamushiの著作物は、文章、画像、プログラムにかかわらず全てUnlicenseです

Unityでシェーダープログラムに入門3:ディスプレイスメントマップとノーマルマップ

 折角ハルシェーダをやったので、ディスプレイスメントマップをやってみます。ついでにノーマルマップもやってみました。

Blender から Unity にモデルを持っていく

 そもそもの問題として、このレベルのことからわからんゴミ屑です。一歩一歩やっていくしかありません。

 使用している Blender のバージョンは3.0です。

f:id:nodamushi:20220225024200p:plain:w320

 とりあえず、30秒もかけてカワイイキャラクタを作りました。調べると、fbx なるファイルフォーマットで出力すれば Unity で読めるらしい。

f:id:nodamushi:20220225024329p:plain

 保存するときに、直接Unity プロジェクトのAssetに出力すると即座に Asset に反映されました。なるほど、簡単ですね。ドラッグアンドドロップをしてシーンに追加してみるとこんな感じ。

f:id:nodamushi:20220225024522p:plain:w320

 ………?何か面がカクカクしてる。法線がおかしいよね。これってBlenderの設定かな?

 というわけで、 Blender にもどって「編集モード」→「Aで全選択」→「面」→「スムースシェード」で滑らかに表示してから、再度出力してみました。

f:id:nodamushi:20220225050306p:plain

 

 で、Unity で表示してみる。  

f:id:nodamushi:20220225050217p:plain

 おぉ、滑らかになった。ミラーボールみたいな物を出力するわけでもない場合は、スムースシェードを適応してから出力する。おっさん賢くなった。

 

スカルプトでハイポリ作って Unity に持っていく

 Unreal Engine 5の Nanite を使えばハイポリのままゲームに持ち込めるらしいですが、一先ず私がやってるのは Unity なのでハイポリはノーマルマップとかにベイクしてローポリを表示するしかないと思われます。

 折角なのでハルシェーダをやったので、当初の目的通りディスプレイスメントマップを作成してドメインシェーダーで処理したいと思います。

 なので、手順とし以下ですね。

  1. マルチレゾリューションでハイポリとローポリのデータを作成
  2. テクスチャにベイク
  3. テッセレーションに対応したシェーダを作成
  4. マテリアルを割り当てる

ハイポリの作成

 Blender でマルチレゾリューションのモディファイアを追加します。レベルは4ぐらいにしました。

f:id:nodamushi:20220225024951p:plain

 後は、スカルプトモードで丹精込めて10分もかけてカワイイキャラを作ります。

f:id:nodamushi:20220225030640p:plain

 スゴクカワイイ。初めてスカルプティングをして見ましたが、結構難しい………

 ハイポリのキャラクタが完成したら、ベースとなるローポリの球体にも変形を適応します。

f:id:nodamushi:20220225030739p:plain

 元々球体だったのが、何となくハイポリに近い形になりますね。

f:id:nodamushi:20220225030754p:plain

 見やすさのためにフラットシェーディングにしていますが、これもスムースシェーディングに設定することを忘れずに。(一敗)

 

ノーマルマップとディスプレイスメントマップをテクスチャにベイク

 今回はキャラクタをUV球から作ってるので、UV展開とか調整は特にしなくて良いでしょう。

 Blender でShading画面に移動して、レンダープロパティでレンダリングエンジンを Cycles に変更します。(Blender 3.0でもEeveeはベイク出来なかった)

f:id:nodamushi:20220225143103p:plain

 取りあえず適当にマテリアルを作成します。

 で、画像テクスチャノードを追加して、新規にnormal_mapとdisplacement_mapを作成。画像サイズは適当に1024x1024にしました。

f:id:nodamushi:20220225142940p:plain

 で、先ずはnormal_mapのノードを選択してから、レンダープロパティの下の方にあるベイクのランで、「マルチレゾからベイク」をチェックして、ベイクタイプを「ノーマル」を選択肢、「ベイク」ボタンを押す。

f:id:nodamushi:20220225143248p:plain

 次に、displacement_map のノードを選択肢、同様にベイクタイプをディスプレイスメントにしてからベイクボタンを押す。後から理解しましたが、低解像度メッシュにチェックを入れます。

f:id:nodamushi:20220225214845p:plain

 で、適当に保存します。

f:id:nodamushi:20220225143848p:plain

 ノーマルマップはxyzの3次元、ディスプレイスメントマップは距離の1次元なので、RGBAのPNGに納めることが出来ます。RGBにノーマルマップを、Aにディスプレイスメントマップを配置しましょう。

 というわけで、適当に書いたPythonスクリプトで結合してやります。

import sys
import cv2

argv = sys.argv

norm_file = argv[1] # 第1引数 ノーマルマップファイル名
disp_file = argv[2] # 第2引数 ディスプレイスメントマップ
out_file = argv[3] # 出力ファイル

norm_img = cv2.imread(norm_file, -1)
disp_img = cv2.imread(disp_file)
norm_img[:,:,3] = disp_img[:,:,1]

cv2.imwrite(out_file, norm_img)

 pip install opencv-python さえしておけば後は python concat.py ノーマルマップ ディスプレイスメントマップ 出力.png で結合できます。

 ちなみに、Pythonでやる前に、Compositingで結合してみたんだけど、どうも結果がおかしいんだよね………。Blender 力が低すぎてよく分からない。

f:id:nodamushi:20220225144457p:plain:w320

 

Unity で表示

 ディスプレイスメントマップは法線方向にどれだけ点を変動させるのかという情報です。したがってディスプレイスメントマップ適応した点pは元の位置pとマップに記録された値d、法線ベクトルnから以下の様に決まります。

移動後の点の位置
\vec{p}= (d - 0.5) D\vec{n} + \vec{p_0}

 Dはどの程度変位させるかの定数です。0.5を引いているのは、テクスチャは0~1しか保持できないので、中央を0にするため。

 で、この法線なんですが、どう決まるんでしょうか?

  1. 頂点に保存されている法線
  2. ノーマルマップ込みで作る法線

 おっさんはもう頭が腐ってるのでよく分かりません。実際に立方体の辺に膨らみを付けてベイクし、表示してみて確認してみました。

  1. 頂点に保存されている法線

f:id:nodamushi:20220225211706p:plain:w320

 シャープな感じ?

  1. ノーマルマップ込みで作る法線

f:id:nodamushi:20220225211507p:plain:w320

 膨らんでる?

 いやーぶっちゃけ分からん………。Blenderの画面と見比べても視野角とか違うから、細かいことはマジ分からぬ。

 と、思って弄っていたら、ノーマルマップ込みで作る法線で破綻が発生!↓

f:id:nodamushi:20220225212240p:plain

 頂点に保存されている法線では破綻しなかった↓ので、どうやら頂点に保存されている法線nが正解っぽいです。

f:id:nodamushi:20220225212327p:plain

 

 というわけで、答えが分かったので実装しました。

f:id:nodamushi:20220225220111p:plain

 マジでキッメェな、おい、これ、どうすんだよ。夢に出てくるよ。馬鹿じゃねぇのか。これ書いてるの何時だと思ってるんだ、おっさん寝れなくなったらどうするの。

 遠目にはぶっちゃけノーマルマップの所為で、耳の形や足の形が違うかなーぐらいですが、接写するとテッセレーションで頂点数増やしてるため、輪郭線が流石に滑らかですね。ていうか、キッモ。

 

ノーマルマップを使う

 上の画像では、ローポリ状態でもノーマルマップの御陰で言うほど差はわかりませんね。このノーマルマップも今回は自分で実装しました。

 ノーマルマップのデータというのは接空間という直交座標系で保存されているようです。

 UV方向と、法線方向Nからなる座標系のようで、モデルの変形などが起こっても同じように扱うためにこうなっているようです。

 接空間のベクトル方向qから、ローカル空間(メッシュの空間)の方向ベクトルpに変換するには以下の様にします。

qからpへの変換
\begin{aligned}
\vec{p} & =q_u\vec{u} + q_v\vec{v} + q_n\vec{n} \\
 & = q_u\vec{u} \pm q_v(\vec{n}\times\vec{u}) + q_n\vec{n} \\
 & = \vec{q} \matrix M 
\end{aligned}

(※プログラム的には与えられるのはuとnだけなので、vをクロス積から求める。±はソフトごとの座標系の違いによる物………かな。左手系とか右手系とか。)

ここで M は以下の様に定義される。

M
\matrix M = \left(\begin{array}c
u_x & u_y & u_z\\
v_x & v_y & v_z\\
n_x & n_y & n_z
\end{array}\right)
=
\left(\begin{array}c
\vec{u}\\
\vec{v}\\
\vec{n}
\end{array}\right)
=
\left(\begin{array}c
\vec{u}\\
\pm\vec{n}\times\vec{u}\\
\vec{n}
\end{array}\right)

 u,v,nはそれぞれ直交してるので、Mの逆行列はすぐに求まる。

Mの逆行列
\matrix M ^ {-1}  = \left(\begin{array}c \vec{u}^T & \vec{v}^T  &\vec{n}^T \end{array}\right) = M^T

 なので、逆にローカル空間から接空間に変換する際には単純に以下の様になる。

pからqへの変換
\begin{aligned}
\vec{q} & = \vec{p} \matrix M^{-1} = \vec{p} \matrix M^T\\
\vec{q}^T & = M \vec{p}^T
\end{aligned}

 転置の形で最後のqの答えを出しているのは、hlslの関数を使えば以下の様に書けるから。

float3x3 M = float3x3( u.xyz, cross(n, u.xyz) * u.w, n);
float3 q = mul(M, p);

 ±の情報は、u.w にシェーダに渡されるっぽい。ここらへんはエンジンごとに違うっぽいし、実はUnityでもバージョンによって違うのかな。

 後はこの式を使ってドメインシェーダでライト方向と視線方向を接空間に変換してやれば、フラグメントシェーダでは、接空間で光の計算をすれば良いだけになる。この場合、法線方向は単にテクスチャから読み出した値をそのまま使えばいい。

 と、いうわけで、piyoシェーダの全体は後述の様になりました。

 あー………シェーダー全然わからんわぁ

作ったシェーダ

Shader "Unlit/piyo"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _NormalTex ("Normal", 2D) = "bump" {}
        _TessFactor("Tessellation", Range(1, 50)) = 10
        _Displacement("Displacement", Range(0, 1.0)) = 0.3
        _Shininess ("Shininess", Range(0.0, 1.0)) = 0.078125
        _RimColor ("RimColor", Color) = (1,1,1,1)
        _RimPower("RimPower", float) = 0.0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Tags { "LightMode"="ForwardBase" }
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma domain domain
            #pragma hull hullMain

            #include "UnityCG.cginc"
            #include "Tessellation.cginc"
            #include "AutoLight.cginc"
            #define INPUT_PATCH_SIZE 3
            #define OUTPUT_PATCH_SIZE 3



            //---- Define structure ----
            struct appdata {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float2 uv : TEXCOORD0;
            };

            struct v2h {
                float3 pos: POS;
                float3 normal: NORMAL;
                float4 tangent : TANGENT;
                float2 uv : TEXCOORD0;
            };

            struct h2dMain {
                float3 pos: POS;
                float3 normal: NORMAL;
                float4 tangent : TANGENT;
                float2 uv : TEXCOORD0;
            };

            struct h2dConst {
                float edgeFactor[3] : SV_TessFactor;
                float insideFactor : SV_InsideTessFactor;
            };

            struct d2f {
                float4 pos: SV_Position;
                float2 uv : TEXCOORD0;
                half3 lightDir : TEXCOORD1;
                half3 viewDir : TEXCOORD2;
            };

            // --------------------
            sampler2D _MainTex;
            sampler2D _NormalTex;
            float _Displacement;
            float _TessFactor;
            float _LineWidth;
            half4 _LightColor0;
            half _Shininess;
            half4 _RimColor;
            half _RimPower;



            // 頂点シェーダ
            v2h vert(appdata i)
            {
                v2h o;
                o.pos = i.vertex;
                o.normal = i.normal;
                o.uv = i.uv;
                o.tangent = i.tangent;
                return o;
            }


            [domain("tri")]
            [partitioning("integer")]
            [outputtopology("triangle_cw")]
            [outputcontrolpoints(OUTPUT_PATCH_SIZE)]
            [patchconstantfunc("hullConst")]
            h2dMain hullMain(InputPatch<v2h, INPUT_PATCH_SIZE> i, uint id: SV_OutputControlPointID)
            {
                h2dMain o;
                o.pos = i[id].pos.xyz;
                o.normal = i[id].normal;
                o.uv = i[id].uv;
                o.tangent = i[id].tangent;
                return o;
            }

            h2dConst hullConst(InputPatch<v2h, INPUT_PATCH_SIZE> i)
            {
                h2dConst o;
                o.edgeFactor[0] = _TessFactor;
                o.edgeFactor[1] = _TessFactor;
                o.edgeFactor[2] = _TessFactor;
                o.insideFactor = _TessFactor;
                return o;
            }

            // domainシェーダ
            #define NDS_UTIL_DOMAIN_CALC(name) (i[0].name * bary.x + i[1].name * bary.y + i[2].name * bary.z)
            [domain("tri")]
            d2f domain(h2dConst c, const OutputPatch<h2dMain, OUTPUT_PATCH_SIZE> i, float3 bary: SV_DomainLocation) {
                d2f o;
                o.uv = NDS_UTIL_DOMAIN_CALC(uv);

                float3 normal = normalize(NDS_UTIL_DOMAIN_CALC(normal));
                float displace = (tex2Dlod(_NormalTex, float4(o.uv, 0, 0)).a - 0.5) * _Displacement;

                float3 pos = NDS_UTIL_DOMAIN_CALC(pos) + normal * displace;
                o.pos = UnityObjectToClipPos(pos);

                float4 tangent = normalize(NDS_UTIL_DOMAIN_CALC(tangent));
                float3x3 rotation = float3x3(tangent.xyz, cross(normal, tangent.xyz) * tangent.w, normal);
                o.lightDir = mul(rotation, ObjSpaceLightDir(float4(pos, 1)));
                o.viewDir = mul(rotation, ObjSpaceViewDir(float4(pos, 1)));

                return o;
            }

            float4 frag(d2f i): SV_Target {
                i.lightDir = normalize(i.lightDir);
                i.viewDir = normalize(i.viewDir);
                half3 halfDir = normalize(i.lightDir + i.viewDir);
                half3 baseColor = tex2D(_MainTex, i.uv);
                half3 normal = UnpackNormal(tex2D(_NormalTex, i.uv));
                half diff = saturate(dot(normal, i.lightDir));
                half r0 = 1- max(0, dot(normal, i.viewDir));
                half r1 = 1- max(0, dot(normal, -i.lightDir));
                half rim =  pow(r0 * r1, _RimPower);
                half spec = pow(max(0, dot(normal, halfDir)), _Shininess * 128.0);
                half4 col;
                col.rgb  = (diff + spec) * baseColor * _LightColor0 + _RimColor.rgb * rim;
                col.a = 1;
                return col;
            }
            ENDCG
        }
    }
}