異なる形状のオブジェクトを描く

実行結果

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

より実践的内容に

前回はボックスモデルをレンダリングすることに挑戦しました。

エッジの利いた立方体のレンダリングや、角を少し丸めたような形状の箱型モデルをレンダリングしたのでしたね。ほんの少しのコードの修正で見た目がガラリと変化する、いかにもレイマーチングらしいサンプルだったように思います。

前回までは同じ形状のモデルを複数レンダリングするために repetition などを利用してきましたが、今回は同じシーンのなかで、異なる形状を持つ複数のモデルのレンダリングを行ってみましょう。

考え方さえつかめてしまえば、それほど難しくないと思います。今回は複数の形状を同時に扱いますが、今まで登場しなかったトーラスフロア(床)を同時にレンダリングしてみましょう。それぞれの distance function についても、都度解説したいと思います。

同時に描くという概念

レイマーチングでは、レイの行きつく先にオブジェクトが存在するのかどうかは distance function の結果によって判別されるのでしたね。異なる形状のオブジェクトをシーン内に複数配置するときにも、このことを再度しっかり認識しておくことがポイントになります。

くどいようですが再度復習すると、distance function はレイの先端とシーン内のオブジェクトとの最短距離を返す関数のことでした。そして、球体をレンダリングするための distance function や、前回紹介したようなボックスモデルを表す distance function などがそれぞれあるのでしたね。

これを言い換えると、distance function は各形状ごとにそれぞれ存在していると考えることができます。球体には球体用の、箱型用にはそれ専用の distance function があったわけです。これが第二のポイントになります。

上記で挙げた二つのポイントについてよく考えてみれば、おのずと異なる形状のモデルを同一シーン内に配置する方法もわかるのではないでしょうか。

たとえば、とある形状を表す A という distance function と、それとは別の形状を表す B という distance function があったとします。それらは、いずれも同じようにレイの先端の座標を受け取りオブジェクトまでの最短距離を返してきます。このとき、A が 1.0 という値を返し、B が 2.0 という値を返してきたとしたら、どちらの形状のオブジェクトがよりレイに近いと言えるでしょうか。

これは言うまでもなく A の distance function が返してきた 1.0 という値のほうが小さいですから、A のモデルがよりレイに近いと言えるはずです。

このように、異なる複数の形状を表す distance function について、それぞれの戻り値を取得してから比較すれば、どちらがよりレイの先端に近いのかを明確にすることができますね。このロジックが、同一シーン内に異なる形状のオブジェクトをレンダリングするための考え方です。つまり、より多くの形状を同時に描こうとすれば、それに比例して distance function の数も増えますし、一度に呼び出される処理の数も増えるということです。ですから、将来的に様々な形状や、より複雑な distance function を実装する段階では、負荷が増えることも考慮つつ実装していくことになるでしょう。

トーラスを描くための distance function

さて、複数の形状を同時に描く仕組みは理解できたでしょうか。

わかってしまえば、それほど難しくはないと思います。要は、形状ごとに定義してある distance function の戻り値を見て、どちらを描くべきかを決めればいいだけなのですね。

今回のサンプルでは、先述のとおりトーラスと床を同時にレンダリングしてみます。まずは、トーラスの distance function から見ていきましょう。

トーラスの distance function

float distFuncTorus(vec3 p){
	vec2 t = vec2(0.75, 0.25);
	vec2 r = vec2(length(p.xy) - t.x, p.z);
	return length(r) - t.y;
}

トーラスを表しているにしては、かなりシンプルなコードのように見えるのではないでしょうか。少なくとも私自身は、こんな少ないコードでトーラスが描けるなんて意味がわからないよ! と最初は思いました。

よーく見てみると、それほど難しい構造にはなっていないことがわかると思います。まず distance function の冒頭で vec2 型の変数 t に、なにかしらの値を設定しているのがわかります。これは実は、トーラスの大きさを定義している部分です。

変数 t の第一要素には 0.75 が入っていますね。これが、トーラスの中心から、どれくらいの距離を置いてパイプを作るかを決める要素です。第二要素の 0.25 という値は、パイプ自体の半径ですね。つまり第二要素を大きくするとパイプがどんどん太くなっていくという寸法です。

distance function の二行目の部分では、先ほどの変数 t を用いてなにかの計算を行っていますね。ここで注目してほしいのは、まず length に引数として入ってきた p の X と Y を与えている部分。 p は三次元、つまり vec3 型であるにもかかわらず、どうして二つの要素だけを抽出しているのでしょうか。これにはちゃんと理由があります。こういったわかりにくい計算が出てきたときには、できるかぎり簡素化して物事を捉えるようにすると理解が早いです。

ここで p の X と Y だけを抜き出して length に入れ、そこから t の第一要素を減算しているのは、以前 GLSL カテゴリの最初の頃のテキストで、スクリーン上に真っ白な円をレンダリングしたときにやったことと同じです。たとえば、distance function 云々という話を一度忘れて、スクリーン上にただ円だけをレンダリングする場合を考えてみましょう。横軸が X で、縦軸が Y だとして、スクリーンの中心部分を原点としたとき、そこに円を描くためには処理しようとしているピクセルの位置を元に、スクリーンの中心位置からの距離を測ってやればいいですね。これと同じように、トーラスの中心位置から、X Y 平面上に円形に展開する座標を取得しているわけです。

冒頭にある、今回のサンプルの実行結果を見ると、トーラスの向きはカメラに対して穴が見えるような形で配置されていますよね。これは、カメラが Z 方向にスライドした位置に置かれていて、なおかつ、distance function の中で X Y 平面に展開した座標で計算を行っているからです。

たとえば、distance function の中身を以下のように修正するとどのようなレンダリング結果になるでしょうか。

XZ 平面に展開するように切り替える

vec2 r = vec2(length(p.xz) - t.x, p.y);

先ほどとは違い、上記のように length p.xz を与えるようにしてみます。

すると、レンダリング結果は以下のように変化します。

修正後のレンダリング結果

XZ 平面に展開するように切り替える

トーラスの向きが変わりましたね。

今回のトーラス用の distance function では、二行目の部分で、まずトーラスの中心からパイプまでの距離を算出しているわけです。最終的には、さらにその結果を使って再度 length を通し、そこからパイプの半径分の値を減算してやります。これで、トーラス用の distance function のいっちょあがりというわけです。

細分化して見ていけば、それほど難しくはないのではないでしょうか。

床を描くための distance function

続いてはフロア(床)を描くための distance function についてです。

こちらもまずはコードから見てみましょう。

床をレンダリングするための distance function

float distFuncFloor(vec3 p){
	return dot(p, vec3(0.0, 1.0, 0.0)) + 1.0;
}

正直に書きますが、私自身はこれを初めて目にしたとき、なんでこれで床が出てくんねん! と思いました。

トーラスと比較してもさらに簡素なコードです。パッと見た感じでは、どうしてこれで床がレンダリングされるのか、ちょっと理解できないという人もいるのではないでしょうか。しかし、これも落ち着いて考えればわかるはずです。先ほどと同様に、まずは簡素化して考えましょう。

まず dot が出てきていることから、内積を使っていることはわかりますね。内積の計算は、三次元の各要素をそれぞれ掛け合わせてから、すべて加算するという計算方法です。上記のサンプルでは、内積の結果に対して 1.0 を足す処理が入っていますが、まずはこの 1.0 のことは忘れて、単純に内積についてのみ考えてみましょう。

ポイントとなるのは、引数の p との内積を取っている vec3(0.0, 1.0, 0.0) をどう捉えるかでしょう。このようなベクトル同士の内積の計算では、先ほども書いたように各要素が掛け合わされたあとに全てを合計します。つまり vec3(0.0, 1.0, 0.0) と内積を取るということは、X と Z については 0 を掛けられてしまうことになるので完全に無視されることになります。これが肝です。

もし引数 p の Y 要素がプラスの値なら、内積の結果は当然プラスになりますが、逆にマイナスの数値なら、やはり内積の結果はマイナスになりますね。これをレイベクトルに対して考えてみると、カメラから伸びるレイが少しでも下に向かっている場合は内積の結果がマイナスの数値になることになります。

無限に遠くまで広がる平面(Y 軸に垂直)に対して、少しでも下を向いているベクトルはいつかどこかで平面に衝突するはずです。ですから、内積の計算結果だけを見て、レイがいずれ平面と衝突するかどうかを割り出すことができるわけです。

もちろん、カメラの位置や向きによってこれらの条件は変わってきますが、今回のサンプルの場合はカメラの位置は vec3 cPos = vec3(0.0, 0.0, 3.0) でまっすぐ奥に向かって視線が伸びていくようになっています。この場合は、レイの Y 要素が少しでもマイナスに振れている場合は、いずれ床と衝突することになるわけですね。

ここまでわかれば、先ほどの distance function で内積の結果に対して 1.0 を加算していた意味もわかるでしょう。この 1.0 は床の位置をオフセットするための値だったわけです。内積の結果が -1.0 以下になるまでは、床に衝突したことにならないようにしているのですね。

そしてさらに、勘のいい方ならもう分かっていると思いますが、先ほどの内積に使われた vec3(0.0, 1.0, 0.0) というベクトルは、これは床の面法線です。このベクトルの代わりに正規化されたベクトルを与えてやれば、指定された法線を持つ面がレンダリングされます。天井のように上側に面を描きたければ、たとえば次のようにコードを修正すればいいわけです。

床を上下反転する

float distFuncFloor(vec3 p){
	return dot(p, vec3(0.0, -1.0, 0.0)) + 1.0;
}

複数の distance function の戻り値を参照する

トーラスと床のそれぞれの distance function が実装できたら、これを双方とも参照する形で処理できるように、全体の構成を考えていきます。今回のサンプルのシェーダのすべてのコードを見てみます。

サンプルのフラグメントシェーダ

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

const vec3 cPos = vec3(0.0,  0.0,  3.0);
const vec3 cDir = vec3(0.0,  0.0, -1.0);
const vec3 cUp  = vec3(0.0,  1.0,  0.0);

const vec3 lightDir = vec3(-0.57, 0.57, 0.57);

// torus distance function
float distFuncTorus(vec3 p){
	vec2 t = vec2(0.75, 0.25);
	vec2 r = vec2(length(p.xy) - t.x, p.z);
	return length(r) - t.y;
}

// floor distance function
float distFuncFloor(vec3 p){
	return dot(p, vec3(0.0, 1.0, 0.0)) + 1.0;
}

// distance function
float distFunc(vec3 p){
	float d1 = distFuncTorus(p);
	float d2 = distFuncFloor(p);
	return min(d1, d2);
}

vec3 genNormal(vec3 p){
	float d = 0.0001;
	return normalize(vec3(
		distFunc(p + vec3(  d, 0.0, 0.0)) - distFunc(p + vec3( -d, 0.0, 0.0)),
		distFunc(p + vec3(0.0,   d, 0.0)) - distFunc(p + vec3(0.0,  -d, 0.0)),
		distFunc(p + vec3(0.0, 0.0,   d)) - distFunc(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 and ray
	vec3 cSide = cross(cDir, cUp);
	float targetDepth = 1.0;
	vec3 ray = normalize(cSide * p.x + cUp * p.y + cDir * targetDepth);
	
	// marching loop
	float tmp, dist;
	tmp = 0.0;
	vec3 dPos = cPos;
	for(int i = 0; i < 256; i++){
		dist = distFunc(dPos);
		tmp += dist;
		dPos = cPos + tmp * ray;
	}
	
	// hit check
	vec3 color;
	if(abs(dist) < 0.001){
		vec3 normal = genNormal(dPos);
		float diff = clamp(dot(lightDir, normal), 0.1, 1.0);
		color = vec3(1.0, 1.0, 1.0) * diff;
	}else{
		color = vec3(0.0);
	}
	gl_FragColor = vec4(color, 1.0);
}

今回のサンプルでは、先ほど解説した二つの distance function は、直接 main 関数から呼ばれるわけではありません。一度それを中継する distFunc という関数を定義しているのがわかると思います。

その部分だけを抜粋してみます。

distFunc 関数

float distFunc(vec3 p){
	float d1 = distFuncTorus(p);
	float d2 = distFuncFloor(p);
	return min(d1, d2);
}

これを見ると、トーラス用とフロア用の二つの distance function が呼び出された後、双方の結果をビルトイン関数の min に渡しているのがわかると思います。これにより、より小さな戻り値を返したほうの結果だけが返されるようになっているのがわかりますね。

また、今回は前回までと比較して、より厳密な結果になるようにするために、マーチングループの回数が 256 回に設定されています。それ以外の部分は、レイの定義や法線の算出など、今までに解説してきた概念をそのまま使っているだけです。

床のレンダリングに関しては、法線から平行光源でライティングしているだけなので、どうしてものっぺりとした描画結果になってしまっています。これは単に光源を点光源などに変更してやればいいだけなので、興味のある方はチャレンジしてみてください。

まとめ

さて、異なる形状のオブジェクトをシーン内に複数レンダリングする方法について見てきましたが、理解できたでしょうか。

3D に関する基礎知識が高ければ高いほど、すんなり理解できるのではないかと思います。

本文中にも書きましたが、かくいう私自身、これらの distance function などについて勉強している最中に最初はよくわからない部分がたくさんありました。今現在ですら、もっと基礎がしっかりしていれば、もう少しわかりやすく解説することができるのになあと感じます。はじめはすごくわかりにくい概念でも、できるだけ細分化したり簡素化したりして、段階的に理解していくことでそれが知識として蓄積されていくと思います。

ぜひ、諦めずに挑戦していただければと思います。

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

entry

PR

press Z key