バンプマッピング

実行結果

今回のサンプルの実行結果

特殊なライティングテクニック

前回はフレームバッファへのレンダリング結果に対して、ブラーフィルターを適用して全体にぼかし処理を掛ける方法を解説しました。フラグメントシェーダを使ってピクセルごとに処理することで、一度レンダリングした結果にエフェクトを掛けることが可能となり、様々な効果を演出できます。ぼかし処理はその一例でしかありませんが、シェーダの使い方などを参考にしていただけたらと思います。

さて、話は変わって今回は、バンプマッピングをやってみたいと思います。

バンプマッピング(bump mapping)は一般に、法線マップなどを用いることによってあたかも凹凸があるかのように見せることができるライティングテクニックです。バンプマッピングを活用すると、少ないポリゴン数のモデルであっても高い表現力を得ることができるようになります。

と、言葉で説明されてもいまいちわかりにくいですね。

たとえば、すごく使い込まれた金属――たとえば甲冑や盾、剣などをレンダリングしたいとします。このとき、それらの表面につけられた細かい亀裂や傷跡まで表現したい場合、どのような方法を使えばいいでしょうか。

キズなどの表現にテクスチャを使ってしまうのは非常に簡単ですが、あらかじめテクスチャに焼きこまれたキズでは、ライトの当たり方などによって陰影が微妙に変化したりはしませんね。これはリアリティがありません。かといって、全てをポリゴンのモデリングで表現しようとすれば、キズや亀裂の表現をする分だけ総ポリゴン数が膨大になっていってしまうでしょう。

こんなときに活用できるのがバンプマッピングです。バンプマッピングは法線マップと呼ばれる特殊なテクスチャを参照してライティング計算を行うことで、あたかも、そこに凹凸があるかのようにモデルを照らし出します。この方法を用いるとライトの当たり方によって陰影の変化がキチンと起こりますし、ポリゴン数も少ないままで済みます。

ただし、バンプマッピングも万能ではなく、いいこと尽くめというわけではありません。以下の画像を見てください。

バンプマッピングを施したモデルの輪郭

バンプマッピングはあくまでも見せかけの凹凸表現なので、モデルの形状まで変化するというわけではありません。上の画像のようにモデルの輪郭近くをよく見ると、一見して凹凸があるように見えるだけで、実際にはモデルの形状が変化しているわけではないことがわかるでしょう。

とは言え、使いどころさえ間違えなければ、バンプマッピングは多彩な表現を行う上での強力な武器になるテクニックだと思います。是非がんばって習得してください。

法線マップ(ノーマルマップ)

広義のバンプマッピングにはいくつかの手法がありますが、今回は法線マップ(ノーマルマップ)を使ったバンプマッピングを行ないます。

法線マップはその名の通り、法線に関する情報を格納した特殊なテクスチャです。

普通、画像データには RGB という形で色に関する情報が格納されていますね。これはテクスチャの普通の使い方です。一方で、法線マップは色情報の代わりに XYZ の法線に関する値をテクスチャに格納します。RGB の R に相当する部分のデータを、色情報ではなく法線の X 要素として使ってしまうわけです。

今回のサンプルで使う法線マップは以下のものです。

サンプル用法線マップ

法線マップの RGB 値は、そのまま法線の XYZ 値を表します。実際にレンダリングを行なう段階では、シェーダ内でこの法線マップの色情報を抜き出し、R 要素は X 要素として、同様に G 要素は Y 要素、B 要素は Z 要素としてそのまま法線に変換します。

変換された法線を参照しながらライティングを行うことで、全く凹凸のない平坦なモデルに対して、まるで凹凸があるかのように見せることができるわけですね。

ちなみに、法線マップはフォトレタッチソフトのプラグインや、フリーの出力ツールなどを使って比較的簡単に用意できます。興味のある方は調べてみるといろいろなものがありますので探してみてください。

接空間への変換

さて、バンプマッピングのおおまかな概念は理解できたでしょうか。

実はバンプマッピングを行う上で一番やっかいなのがここからです。

先ほども書いたように、バンプマッピングはテクスチャを参照しながら法線に関する情報を処理します。非常に数学的な話になるので小難しい詳細までは説明しませんが、テクスチャ上にある法線の情報は接空間と呼ばれる空間上に存在しています。

ライトやモデルが存在するのはモデル(ワールド)座標空間であったり、ローカル座標空間だったりするのが基本です。これらの接空間とは異なる空間上にあるベクトルと、接空間上にある法線とを演算しても正しい結果を得ることはできません。

そこで、バンプマッピングではテクスチャから得られた法線と正しく演算を行なうために、モデルがもともと持っている頂点法線を使って三つのベクトルを定義します。

一つ目は法線ベクトル。これは、頂点法線をそのまま使います。簡単ですね。

二つ目が接線ベクトルです。これは頂点法線に対して垂直なベクトルであり、尚且つテクスチャの横方向に対して平行となるベクトルです。

三つ目は従法線ベクトルで、頂点法線に対して垂直であるということまでは接線ベクトルと同じですが、テクスチャの縦方向に対して平行となるベクトルです。

これらの三つのベクトルがどうして必要なのかは、なかなか直感的には理解しにくいかもしれません。非常に大雑把にざっくりと解説すると、法線ベクトルにモデルの法線をそのまま使うことで、テクスチャ上にある Z 値(つまり RGB のうち B 成分ですね)と本来モデルが持っている法線の向きを揃えることができます。

さらに、そこから接線ベクトルを求めることで、テクスチャの横方向を正しく参照できるようになります。同様に、従法線ベクトルがあることによって、テクスチャの縦方向に対しても問題なく参照できるようになります。

面倒に感じるかもしれませんが、バンプマッピングを行なうためにはこれら三つのベクトルを定義しなければならないわけです。法線ベクトルは先ほども書いたように本来の頂点法線をそのまま使います。接線ベクトルは Y 軸と法線との間で外積を取ることで算出します。※どうして外積で接線ベクトルが求まるのかは説明しませんが、三次元ベクトル同士の外積を取ると結果は三次元ベクトルになります。

最後の従法線ベクトルは、法線と接線ベクトルとの間で外積を取れば求められます。

頂点シェーダの記述

今回は、球体モデルを使いバンプマッピングを行ないますが、各ベクトルの算出はシェーダ内で行なうようにします。今回のシェーダは若干コード量が多いですが、じっくり見ていきましょう。

最初に今回のプログラムの簡単な仕様を列挙しておきます。

  • 球体モデルをレンダリングする
  • 球体モデルは頂点色でカラーリングされる
  • 法線マップを使ってバンプマップを適用する
  • ライティングは点光源+フォンシェーディングモデル
  • 各ベクトルの計算はシェーダ内で行なう

こんな感じですね。それでは早速ですが、まずは頂点シェーダからです。

頂点シェーダ

attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
attribute vec2 textureCoord;
uniform   mat4 mMatrix;
uniform   mat4 mvpMatrix;
uniform   mat4 invMatrix;
uniform   vec3 lightPosition;
uniform   vec3 eyePosition;
varying   vec4 vColor;
varying   vec2 vTextureCoord;
varying   vec3 vEyeDirection;
varying   vec3 vLightDirection;

void main(void){
	vec3 pos      = (mMatrix * vec4(position, 0.0)).xyz;
	vec3 invEye   = (invMatrix * vec4(eyePosition, 0.0)).xyz;
	vec3 invLight = (invMatrix * vec4(lightPosition, 0.0)).xyz;
	vec3 eye      = invEye - pos;
	vec3 light    = invLight - pos;
	vec3 n = normalize(normal);
	vec3 t = normalize(cross(normal, vec3(0.0, 1.0, 0.0)));
	vec3 b = cross(n, t);
	vEyeDirection.x   = dot(t, eye);
	vEyeDirection.y   = dot(b, eye);
	vEyeDirection.z   = dot(n, eye);
	normalize(vEyeDirection);
	vLightDirection.x = dot(t, light);
	vLightDirection.y = dot(b, light);
	vLightDirection.z = dot(n, light);
	normalize(vLightDirection);
	vColor         = color;
	vTextureCoord  = textureCoord;
	gl_Position    = mvpMatrix * vec4(position, 1.0);
}

attribute として入ってくる頂点属性はお馴染みの四種類。頂点位置・法線・色・テクスチャ座標ですね。このうち、色とテクスチャ座標の二つは、そのまま varying 変数としてフラグメントシェーダに送ってしまいます。

頂点の位置情報は、点光源によるライティングを行なうための計算と、従来どおり gl_Position への頂点位置の出力のために使います。

まずモデル(ワールド)座標空間上での頂点の位置と、それに相対した視線ベクトルとライトベクトルを求めます。視線ベクトルとライトベクトルを変換するために invMatrix というモデル座標変換行列の逆行列を uniform 変数としてシェーダ内で受け取っています。

対象コードを一部抜粋

vec3 pos      = (mMatrix * vec4(position, 0.0)).xyz;
vec3 invEye   = (invMatrix * vec4(eyePosition, 0.0)).xyz;
vec3 invLight = (invMatrix * vec4(lightPosition, 0.0)).xyz;
vec3 eye      = invEye - pos;
vec3 light    = invLight - pos;

ここまでの計算を終えると、変数 eye と変数 light に正しい視線ベクトルとライトベクトルが得られた状態になります。

続いては先ほど書いた三つのベクトルの算出です。

法線には、頂点法線をそのまま使いますが、シェーダ内では一応正規化しておきます。そして、法線と Y 軸との間で外積を取ることで接線ベクトルが得られます。さらに、接線ベクトルと法線との間で外積を取るとその結果が従法線ベクトルになります。

対象コードを一部抜粋

vec3 n = normalize(normal);
vec3 t = normalize(cross(normal, vec3(0.0, 1.0, 0.0)));
vec3 b = cross(n, t);

変数 n は normal の n です。変数 t は tangent の頭文字。同様に変数 b は binormal の頭文字ですね。

さあ、ここからが接空間への変換作業です。先ほど得られた視線ベクトルとライトベクトルを、三つのベクトルを使って接空間上に変換します。この座標変換には内積を用います。X 要素には tangent (接線ベクトル)を使います。Y 要素には binormal (従法線ベクトル)を、Z 要素にはそのまま法線を使います。

接空間上への座標変換

vEyeDirection.x   = dot(t, eye);
vEyeDirection.y   = dot(b, eye);
vEyeDirection.z   = dot(n, eye);
normalize(vEyeDirection);
vLightDirection.x = dot(t, light);
vLightDirection.y = dot(b, light);
vLightDirection.z = dot(n, light);
normalize(vLightDirection);

こうして求めた接空間上の視線ベクトルとライトベクトルを正規化し、最終的にフラグメントシェーダに送ります。フラグメントシェーダ内では、ここで得られたベクトルを使ってライティングの計算を行ないます。

フラグメントシェーダの記述

フラグメントシェーダでは uniform 変数としてはテクスチャユニットに関する情報だけを受け取ります。あとは全て頂点シェーダから送られてきたデータを使います。

フラグメントシェーダ

precision mediump float;

uniform sampler2D texture;
varying vec4      vColor;
varying vec2      vTextureCoord;
varying vec3      vEyeDirection;
varying vec3      vLightDirection;

void main(void){
	vec3 mNormal    = (texture2D(texture, vTextureCoord) * 2.0 - 1.0).rgb;
	vec3 light      = normalize(vLightDirection);
	vec3 eye        = normalize(vEyeDirection);
	vec3 halfLE     = normalize(light + eye);
	float diffuse   = clamp(dot(mNormal, light), 0.1, 1.0);
	float specular  = pow(clamp(dot(mNormal, halfLE), 0.0, 1.0), 100.0);
	vec4  destColor = vColor * vec4(vec3(diffuse), 1.0) + vec4(vec3(specular), 1.0);
	gl_FragColor    = destColor;
}

まず最初に、テクスチャ――つまり法線マップから RGB 値を抜き出し、これを法線として扱います。

このとき、抜き出した数値に対して[ 二倍して一減算する ]ということをやっているのがわかると思います。これはどうしてかと言うと、法線マップ上の色データには、当然ですが負の数値は含まれていません。つまり、0 ~ 1 の範囲でデータが抜き出されてきます。しかし法線は -1 ~ 1 の範囲を取るので、二倍して一引くという処理を追加することで正しく法線情報を参照できるわけです。

頂点シェーダから送られてきた接空間上の視線ベクトルとライトベクトルを使って、テクスチャから抜き出した法線との間でのライティング計算を行なえば、これでバンプマッピングシェーダの完成です。

まとめ

今回のサンプルでは、バンプマッピングに関するほとんどの処理はシェーダ内で完結しています。javascript プログラムのほうでは適切にシェーダにデータを送ることさえやれば、特別なことはする必要はありません。あえてコードは掲載しませんので、必要に応じてサンプルページから参照してください。

今回はバンプマッピング用の法線マップテクスチャしか使いませんでしたが、以前のテキストで解説したマルチテクスチャなどのテクニックと併用すれば、テクスチャを貼り付けたポリゴンに対して、さらにバンプマッピングによるライティングを施すことも当然できます。

あまり視線が寄ってしまうような子細な表現には使いにくいバンプマッピングですが、使いどころを間違えなければ非常にリアリティのあるシーンを演出してくれます。どのように活用するのかはプログラマの腕次第でしょう。

サンプルへのリンクはいつものように以下にあります。

entry

PR

press Z key