法線の算出と簡単なライティング

実行結果

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

より 3D らしい外見を法線で

前回はレイマーチングの基本の枠組みを一通り実装して、実際にマーチングループを用いつつ球体をレンダリングしました。

実際にレイマーチングを実装したと言っても、レンダリングされるのは球体のシルエットのみでしたので、見た目にはあまりインパクトも面白味もありませんでした。今回は、シェーダ内で法線の算出を行い、簡単な平行光源によるライティングまでやってみます。これにより球体に陰影がつき、より 3D らしいレンダリング結果が得られるようになります。

今回も前回までと同様、若干の数学的知識が必要となる場面がありますが、それほど難しい概念は出てきません。それに、まずは動くシェーダを確実に書いて、徐々に数学的な部分はあとから埋めていけばいいのです。諦めずに、じっくりと取り組んでいきましょう。

distance function と法線

通常の WebGL プログラミングでは、javascript 側で頂点属性として法線のデータを用意しておき、それをシェーダにプッシュする形を用いるのが一般的です。しかし、GLSL のみを用いてレンダリングを行うレイマーチングでは、法線の算出についてもシェーダ内で行うことになります。

法線を求めることで、ライティングをはじめとする様々な効果をレンダリング結果に付与することが可能となります。しかし、法線が非常に大事な情報であるとは言っても、実際にどのように法線を求めればいいのでしょうか。

GLSL 内で完結する法線の算出方法には、いくつかの手法が考えられます。今回解説する方法はそのなかの一例です。そして、前回からたびたび登場している distance function を利用するというのがポイントです。

概念を解説する前に、まずは実際に法線を算出するためのコードを見てみましょう。

法線を算出するための関数

vec3 getNormal(vec3 p){
	float d = 0.0001;
	return normalize(vec3(
		distanceFunc(p + vec3(  d, 0.0, 0.0)) - distanceFunc(p + vec3( -d, 0.0, 0.0)),
		distanceFunc(p + vec3(0.0,   d, 0.0)) - distanceFunc(p + vec3(0.0,  -d, 0.0)),
		distanceFunc(p + vec3(0.0, 0.0,   d)) - distanceFunc(p + vec3(0.0, 0.0,  -d))
	));
}

さて、パッと見ただけでは実際にどんなことをやっているのかちょっとわかりにくいかもしれませんね。

途中で改行してしまっていますが、実際には vec3 型のデータを normalize で正規化して戻り値として返す構造の関数です。この関数が引数として受け取るのは vec3 型のデータがひとつだけ。この引数として受け取るデータはレイとオブジェクトの交点の座標位置です。

この関数は[ 交点の座標 ]を受け取り[ 法線 ]を返すわけですね。

まず関数内の冒頭で変数 d に非常に小さい値を入れているのがわかると思います。そして、戻り値となる vec3型データの X Y Z の各要素に対して、変数 d を利用した計算を行っているのがわかるでしょうか。

ここでは X Y Z のそれぞれを、ほんの少しだけずらした座標を用意して distance function に渡しています。正負それぞれにほんの少しだけずらした座標を distance function に渡すことによって、その戻り値から勾配を計算しているのですね。

このような処理を行うと、各軸に対してどの程度の傾きになっているのかがわかります。そして、この傾きこそがそのまま法線として利用できるデータなのですね。

法線は、一般に正規化されていることが前提です。ですから関数の内部で normalize を使って正規化してから戻り値として返しています。

この関数が実際のシェーダのなかでどのように利用されるのか、確認してみましょう。

今回のサンプルのフラグメントシェーダのコードが以下です。

フラグメントシェーダのコード

precision mediump float;
uniform float time;
uniform vec2  mouse;
uniform vec2  resolution;

const float sphereSize = 1.0;
const vec3 lightDir = vec3(-0.577, 0.577, 0.577);

float distanceFunc(vec3 p){
	return length(p) - sphereSize;
}

vec3 getNormal(vec3 p){
	float d = 0.0001;
	return normalize(vec3(
		distanceFunc(p + vec3(  d, 0.0, 0.0)) - distanceFunc(p + vec3( -d, 0.0, 0.0)),
		distanceFunc(p + vec3(0.0,   d, 0.0)) - distanceFunc(p + vec3(0.0,  -d, 0.0)),
		distanceFunc(p + vec3(0.0, 0.0,   d)) - distanceFunc(p + vec3(0.0, 0.0,  -d))
	));
}

void main(void){
	// fragment position
	vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
	
	// camera
	vec3 cPos = vec3(0.0,  0.0,  2.0);
	vec3 cDir = vec3(0.0,  0.0, -1.0);
	vec3 cUp  = vec3(0.0,  1.0,  0.0);
	vec3 cSide = cross(cDir, cUp);
	float targetDepth = 1.0;
	
	// ray
	vec3 ray = normalize(cSide * p.x + cUp * p.y + cDir * targetDepth);
	
	// marching loop
	float distance = 0.0;
	float rLen = 0.0;
	vec3  rPos = cPos;
	for(int i = 0; i < 16; i++){
		distance = distanceFunc(rPos);
		rLen += distance;
		rPos = cPos + ray * rLen;
	}
	
	// hit check
	if(abs(distance) < 0.001){
		vec3 normal = getNormal(rPos);
		float diff = clamp(dot(lightDir, normal), 0.1, 1.0);
		gl_FragColor = vec4(vec3(diff), 1.0);
	}else{
		gl_FragColor = vec4(vec3(0.0), 1.0);
	}
}

マーチングループを抜けた後、衝突判定を行っている部分で getNormal が呼び出されているのがわかりますね。

スフィアトレーシングにおいては、レイの先端とオブジェクトとの間の距離を表す変数 distance の値が、十分に小さい値であれば衝突していると判断するのでしたね。これを逆に考えてみると、衝突しているということはレイの先端部分とオブジェクトの表面との距離は非常に小さいということであり、レイの先端部分の座標がオブジェクトの表面の座標とほぼ等しいことになります。

ですから getNormal に渡すのはレイの先端座標を表す変数 rPos になるわけです。

法線さえ求まれば、あとはライティングでもなんでも自由に行うことができます。

今回のサンプルでは、平行光源を定義してやり内積を取っただけの簡単なライティングを施しています。しかしこれだけでも見た目は劇的によくなります。前回のサンプルの実行結果と比較すると、より 3D らしくなったと言えるのではないでしょうか。

色で見る法線

さて、先ほどのコードでは法線を求めてそのままライティングまで行いましたが、色の出力部分を次のように修正すると、実際に法線が算出されている様子を視覚的に見ることができます。

法線を色として出力する

if(abs(distance) < 0.001){
	vec3 normal = getNormal(rPos);
	gl_FragColor = vec4(normal, 1.0);
}else{
	gl_FragColor = vec4(vec3(0.0), 1.0);
}

getNormal によって得られた法線を、そのまま色として出力するわけです。

このようなコードを実行すると、次のような結果が得られます。

法線を出力した結果

法線を色として出力した結果

これを見ると、X や Y 方向に正しく法線が出力できていることがわかると思います。Z に関しては色で見てもわかりにくいと思いますが、カメラに向いている面の全体に青の成分が乗っているので、おおよそ正しいであろうことが予想できます。

まとめ

さて、法線の算出について理解できたでしょうか。

もしも、法線や平行光源のライティングについてわからないことがある場合には、まず WebGL カテゴリの過去のテキストなどをよく読んで、どうして内積を用いるとライティングを行うことができるのか、しっかり理解したほうがいいでしょう。

途中でも書いたように、法線さえ求まってしまえばライティングや反射などの処理が書けるようになります。ただし、実際にコードを見ればわかるとおり、distance function を複数回呼び出すことになるわけですから法線を用いない処理と比較すれば負荷はどうしても高くなります。

法線に関しては、個人的には無いと何も始まらないもののひとつなので、多少の負荷は仕方がないのかなとも思います。法線を求める方法はほかにもやり方がありますので、自分なりにより低負荷のものを追求してみるのもいいかもしれません。

実際に動作するサンプルはいつものように以下のリンクから。

entry

PR

press Z key