読者です 読者をやめる 読者になる 読者になる

しゅみぷろ

プログラミングとか

法線マップと接空間

はじめに

esprog.hatenablog.com

以前書いた記事で、テクスチャペイントするブラシに凹凸の表現を施したいと思い、法線マップについて勉強してみましたので纏めておきます。

以下のサイトを参考にさせていただきました。

シェーディングについて必要になる知識は以下の記事で纏めてあります。

本記事で作成したサンプルは

github.com

で公開しています。

法線マップとは

物体の表面の凹凸を表現する手法のことです。

3Dモデルにおいて、細かな凹凸を表現するにはメッシュを細分化し、微調整することで表現します。しかしこの方法では、凹凸の多いオブジェクトの場合に頂点数がすごいことになります。そこで、面の細かな凹凸を近似するための方法として考えられたのが法線マップです。同じくこの凹凸を近似するためにバンプマップを用いた方法があります。

法線マップでは物体表面の向いている方向(法線)をテクスチャに書き込み、シェーディングの法線計算処理でこの法線マップの情報を用いることで凹凸を表現します。こちらの記事でマイクロファセットについて言及しましたが、考え方は似ています。マイクロファセットはある一点における極小の面の法線分布を考えていたのに対し、法線マップではある面(主に三角形平面)のある点における法線を定義します(通常のシェーディングではこの「ある点の法線」はモデルの頂点の持つ法線などから計算されます)。

法線マップは実際に凹凸をつけた3Dモデルから専用のアプリケーション等を用いて生成します。 また、グレースケール画像から法線マップを作るサービスもあります。

実際にグレースケール画像から法線マップを作ってみると、以下の様な感じになります。

f:id:es_program:20160430182325p:plain:w100f:id:es_program:20160430182335p:plain:w100

これをUnityのデフォルトのマテリアルに適用してみるとこんな感じになります。

f:id:es_program:20160430182902p:plain:w300

接空間

法線マップが表す法線は接空間上のベクトルです。 接空間とは、3Dモデル表面のある1点に乗っかっている座標空間のことです。

f:id:es_program:20160430150231p:plain:w400

画像の青軸は3Dモデル表面の法線を表しています。通常のシェーディングではこの法線を用いて陰影を付けていくのですが、これは面ごとに固定された値を持っていますので、面の持つ細かな凹凸を表現できないわけです。なので、法線マップを使ってこの接空間上で面内のある点がどの方向を向いているのかを定義しているのですが、この法線マップの存在する空間は接空間です。

シェーディングを行う場合はこのオブジェクトの法線マップの値とライトの方向ベクトル(スペキュラーでも用いる場合は視点の方向ベクトルも)の存在する空間を同じものにする必要があります。空間を統一する手法としては

  1. 法線マップの法線をライトやカメラの座標空間に変換する
  2. ライトやカメラの方向ベクトルを接空間に変換する

といった2通りの方法が考えられます。1の方法の場合法線マップの法線はピクセルシェーダーで取得することになりため、座標変換処理がピクセル処理時に毎回行われることになります。2の方法の場合頂点シェーダーでライトやカメラの方向ベクトルを変換してやるだけで良いため、1の方法に比べて計算量が少なく済みます。なので今回は2の方法について考えていきます。

接空間への変換

接空間への理解を深める目的で、こんなものを作ってみました。

f:id:es_program:20160430183310g:plain

マウスでクリックしたオブジェクト表面上の接空間を表示しています。 リポジトリは以下で公開しています。

github.com

オブジェクトの法線さえわかってしまえば、接空間を求めることは簡単です。

f:id:es_program:20160430153310p:plain:w400

頂点シェーダーにおいて画像の青軸(Normal)は既知です。また、黄色で描かれた矢印は(0, 1, 0)方向を表すベクトルです。この2つのベクトルの外積から赤軸(Tangent)を求めることができます(後述しますが、外積の性質上この方法でTangentを求めるのはBADです)。青軸と赤軸方向のベクトルの外積から緑軸(Binormal)を求めることができます。

つまり、計算としては

//Normal
Vector3 normal = model.normal;
//Tangent
Vector3 tangent = Vector3.Cross(normal, Vector3.up);
//Binormal
Vector3 binormal = Vector3.Cross(tangent, normal);

みたいになります。

外積の性質によるTangent算出の際の問題点

先ほど

頂点シェーダーにおいて画像の青軸(Normal)は既知です。また、黄色で描かれた矢印は(0, 1, 0)方向を表すベクトルです。この2つのベクトルの外積から赤軸(Tangent)を求めることができます(後述しますが、外積の性質上この方法でTangentを求めるのはBADです)。

という記述をしました。この時点で何が問題かわかっている方も多いと思いますが、Tangentを求める際に常にベクトル(0, 1, 0)を用いてることが問題になります。

f:id:es_program:20160501021532g:plain:w300

もし法線がこのベクトルと同じだった場合(つまり法線が真上を向いていた場合)、外積はゼロベクトルを返します。

これでは都合が悪いので回避する必要があります。

回避する方法は適当で良ければどうにでもなるのですが、シェーダープログラムの場合はTangentをモデルからの入力として取得することができます。Tangentセマンティクスをつけたfloat4型の変数をvertexシェーダーで受け取る構造体に定義するだけです。

ちなみにBinormalもTangent同様にセマンティクスをつけたfloat4型の変数を定義すればモデルデータから受け取れるはずなのですが、Unityではシェーダーコンパイル時に怒られました。面倒だったので、これについてはNormalとTangentの外積から算出しました。

シェーダーの実装

Unityでなるべくシェーディング処理を自作してみる - しゅみぷろ同様、ライトはカスタムライト(といってもただの位置情報ですが...)を用いています。 まずはシェーダーにパラメータを渡すC#スクリプトです。

using System.Collections;
using UnityEngine;

[ExecuteInEditMode]
public class BumpMapShader : MonoBehaviour
{
  [SerializeField]
  private Material bump;

  [SerializeField]
  private Transform lightTransform;

  [SerializeField]
  private float k_diffuse;

  public void OnWillRenderObject()
  {
    Vector4 lightPos = lightTransform.position;

    bump.SetVector("light_pos", lightPos);
    bump.SetFloat("k_diffuse", k_diffuse);
  }
}

特に説明は不要かと思います。

次に実際に法線マップから法線を取得し、シェーディングに凹凸を反映させるシェーダーです。

Shader "Custom/BumpMapShader"{
    Properties{
        _BumpMap("BumpMap", 2D) = "white" {}
        _DiffuseColor("Diffuse Color", COLOR) = (1,1,1,1)

    }
    SubShader{
        Pass{
            CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
            struct app_data {
                float4 vertex:POSITION;
                float3 normal:NORMAL;
                float2 texcoord:TEXCOORD0;
                float4 tangent:TANGENT0;
            };
            struct v2f {
                float4 screen:SV_POSITION;
                float2 uv : TEXCOORD0;
                float4 tangentLightDir : TEXCOORD1;
            };

            sampler2D _BumpMap;
            uniform float4 light_pos;
            uniform float k_diffuse;
            uniform float k_ambient;

            float4 _DiffuseColor;
            float4 _SpecularColor;

            float4x4 InvTangentMatrix(
                float3 t,
                float3 b,
                float3 n)
            {
                float4x4 mat = float4x4(
                    float4(t.x, t.y, t.z, 0),
                    float4(b.x, b.y, b.z, 0),
                    float4(n.x, n.y, n.z, 0),
                    float4(0, 0, 0, 1)
                    );
                return transpose(mat);
            }

            v2f vert(app_data i) {
                v2f o;
                o.screen = mul(UNITY_MATRIX_MVP, i.vertex);
                o.uv = i.texcoord;

                float4 ms_normal = normalize(mul(i.normal, _World2Object));

                float3 n = normalize(ms_normal);
                float3 t = i.tangent;
                float3 b = cross(n, t);

                o.tangentLightDir = mul(light_pos, InvTangentMatrix(t, b, n));
                return o;
            }

            float4 frag(v2f i) :SV_TARGET{
                //float3 normal = normalize(tex2D(_BumpMap, i.uv).xyz * 2.0 - 1.0);
                float3 normal = float4(UnpackNormal(tex2D(_BumpMap, i.uv)),1);
                float3 light = normalize(i.tangentLightDir.xyz);
                float  diffuse = max(0, dot(normal, light)) * k_diffuse;
                return diffuse * _DiffuseColor + k_ambient;
            }
            ENDCG
        }
    }
}

これもコードを追っていけばそこまで難しいことはしていません。 座標変換も、元の座標(単位ベクトル)に接空間の方向ベクトルをそれぞれの軸方向について掛けてやるだけです。 変換行列は直交行列なのでInvTangentMatrixでは行列を転置することで逆行列を得ています。

実装していて詰まった部分は、法線マップから法線情報を抽出する際に、法線マップのテクスチャデータフォーマットに気をつけなければならないことです。通常であれば

normalize(tex2D(_BumpMap, i.uv).xyz * 2.0 - 1.0)

といった方法で法線を取得できるのですが、UnityではDXT5nmというフォーマットに変換される場合があるそうです。 どちらにせよ法線情報を正確に取得するには、UnityCG.cgincで定義されているUnpackNormalを使います。 このNormalを用いてディフューズやスペキュラーを計算してやれば、凹凸をうまく表現できるわけです。

Unity、DirectX環境でのNormalMapの内部的なフォーマットお勉強 - チリペヂィア

今回のシェーダーではディフューズのみ計算していますが、結構いい感じに凸凹っぽく見えます。

f:id:es_program:20160501030246p:plain:w400