レイマーチングソフトシャドウ

実行結果

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

意外と気軽に使えるソフトシャドウ

前回はレイマーチングでテクスチャやプロシージャルに生成した模様を投影する方法について解説しました。

レイマーチングはその名のとおり、シェーダ内で疑似的にレイを定義して衝突判定などを行います。結果、自然とオブジェクトとの交点座標が求まることになるため、その特性をうまく利用することで、比較的簡単に投影が行えるというものでした。

今回もこの交点に関する情報を利用するテクニックのひとつであるソフトシャドウを実現してみたいと思います。

3D プログラミングをある程度習熟してくると、必ず挑戦することになるであろう技術のひとつにシャドウィング(影のレンダリング)があります。一般的な影のレンダリングには様々な手法が存在し、そしてそのどれもが得てして難解であることが多いですね。

当サイトでも影に関するテキストを公開していますが、WebGL で美しい影をレンダリングするのはそう簡単なことではありません。

レイマーチングで影を出すとなったらそれはそれは大変なのではないか? そんな風に考えてしまう人も多いかもしれません。しかし、意外なことに、レイマーチングでは影をレンダリングするのはそれほど難しくありません。負荷はけして軽いとは言えないものの、極めて重いというほどのものでもなく、割と簡単に影のレンダリングができてしまいます。

今回は、レイマーチングにおける影のレンダリングの考え方、そして具体的な実装方法までしっかり見ていきましょう。

影が落ちるということの前提

具体的に、どうしたら影を表現することができるのかを考えてみましょう。

いわゆるレイトレーシング的な考え方をすることができれば、今回も前回と同じように、レイとオブジェクトとの交点座標がカギになることがわかるはずです。

影が落ちるという結果が起こりうるためには、大前提として次のような状況がそろっている必要があります。

  • レイとオブジェクトが衝突している
  • ライトの位置が明確(点光源の場合)

スポットライトのような特殊な光の表現を用いる場合は別として、普通、オブジェクトが存在しない部分には何もレンダリングが行われないはずです。ですから、影を表現すべきかどうかという点で言えば、まずなによりオブジェクトとレイが衝突していて、そこにオブジェクトをレンダリングすべき状況である必要がありますね。

そして、影を表現するということは、言い換えればその三次元空間にライトが存在し、何かしらのライティング処理が行われているという前提が必要です。ライトの概念がないシーンでは、影はレンダリングすることができないわけです。

これらのことを踏まえるとレイマーチングでシャドウィングを行うためにはまず、少なくとも光源の位置が明確になっていなければならないことがわかりますね。

それでは、レイとオブジェクトの交点、さらには光源の位置、このふたつの情報がそろったとしていったいどのように影をレンダリングすればいいのでしょうか。

光の経路を考える

レイトレーシングという言葉は、光線を追跡するという意味の言葉です。レイマーチングはレイトレの一種ですから、やはりここでも光の経路(光の道筋)をよく考えることが影のレンダリングを実現するためのヒントになります。

影が落ちるということは、光源から発せられた光が何かしらの物体によってさえぎられている状態であると言えます。つまり言い換えれば、レイとオブジェクトが衝突した地点、つまり交点の座標から、再度レイを光源に向けて飛ばしたとき、光源と交点座標との間に何かしらのオブジェクトが存在するとすれば、これはすなわち影が落ちる状態であると考えることができますね。

影が落ちる条件の図式

影が落ちる条件の図式

つまり考え方としては非常に単純で、レイとオブジェクトが衝突したと判定できた場合、光源に向かってレイを再度飛ばしてみればいいわけです。

レイを再度飛ばす場合、レイの原点となる座標は、最初のオブジェクトとレイとが衝突した交点の座標位置になります。そしてレイの向きはそこから光源へと向かうものに切り替わります。利用する distance function はまったく同じものを利用すればいいので簡単ですね。

この再度飛ばしなおしたレイでレイマーチングを行ってやり、何かしらのオブジェクトと衝突した場合には、光源とレンダリングしようとしているオブジェクトとの間に、なにかしらの別のオブジェクトが存在することになります。この場合は光源からの光がさえぎられていると判断して影を描画してやればいいのですね。

仕組みとしてはレイを再度光源に向かって飛ばしなおすだけですが、結局レイマーチングをもう一度やることになるわけですから、負荷はそれなりに高くなる場合もあるでしょう。シーンの中にオブジェクトがそもそもほとんどない場合は、衝突する座標そのものが少ないわけですから、シャドウィングによる負荷の増加も少なくなります。

逆にシーンのなかに大量のオブジェクトが描かれているようなケースでは、多くの座標でレイを再度飛ばしなおすことになるため、負荷も増加しやすくなります。ちなみに後述する今回のサンプルでは、シャドウィング用のレイに関しては若干マーチングループの回数を少な目にしています。

シェーダのソースから読み解く

さて、それでは具体的にシェーダのソースを見ながら考えていきましょう。

先ほども書いたとおり、まず前提として、オブジェクトが何も描かれない場合にはそもそも影になるかどうかを判断する必要性がありません。衝突を検知できた場合にだけ、影を落とすべきかどうかチェックするようにシェーダを書いていきます。

シェーダ全体のコードの量が比較的多くなってしまうので、ここではまず main 関数だけを抜粋してみましょう。

main 関数

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);
		if(dist < 0.001){break;}
		tmp += dist;
		dPos = cPos + tmp * ray;
	}
	
	// light offset
	vec3 light = normalize(lightDir + vec3(sin(time), 0.0, 0.0));
	
	// hit check
	vec3 color;
	float shadow = 1.0;
	if(abs(dist) < 0.001){
		// generate normal
		vec3 normal = genNormal(dPos);
		
		// light
		vec3 halfLE = normalize(light - ray);
		float diff = clamp(dot(light, normal), 0.1, 1.0);
		float spec = pow(clamp(dot(halfLE, normal), 0.0, 1.0), 50.0);
		
		// generate shadow
		shadow = genShadow(dPos + normal * 0.001, light);
		
		// generate tile pattern
		float u = 1.0 - floor(mod(dPos.x, 2.0));
		float v = 1.0 - floor(mod(dPos.z, 2.0));
		if((u == 1.0 && v < 1.0) || (u < 1.0 && v == 1.0)){
			diff *= 0.7;
		}
		
		color = vec3(1.0, 1.0, 1.0) * diff + vec3(spec);
	}else{
		color = vec3(0.0);
	}
	gl_FragColor = vec4(color * max(0.5, shadow), 1.0);
}

main 関数だけを抜粋しても結構なコード量がありますね。ですが、見た目にはコードの量が多く見えるだけで、実際にやっていることは前回までとほとんど変わりません。

注目すべきは中団ほどにある、コメントで light offset と書かれているところ。ここでは、ライトに関するデータを作っています。

先ほどの解説では、光源の位置が明確になっていなければ影のレンダリングを行うことはできないと書きました。ですがここではちょっとそれとは違うことをやっているのがわかるでしょうか。

今回のコードの場合は、光源の位置というよりも、直接ライトベクトルを操作してしまっています。つまり、ディレクショナルライト、いわゆる平行光源のライトベクトルを、時間の経過に応じて変化させている形になっています。

もし、シーンのなかで点光源を用いている場合には、光源の位置を元にしてレイとオブジェクトの交点へと向かうライトベクトルを算出して利用するようにコードを修正すればいいわけですね。光源の種類がなんであれ、シェーダのなかでライトベクトルが求まってさえいれば問題ありません。

ライトベクトルの算出が完了したら、このライトベクトルと交点の座標を使って影をレンダリングすべきなのかチェックしなければなりません。今回のサンプルでは genShadow というオリジナルの関数を定義して、それを呼び出す仕様になっています。

genShadow 関数の中身は後述するとして、まず最初に注目してもらいたいのが、この関数の引数に与えている値です。

genShadow 関数の引数

// generate shadow
shadow = genShadow(dPos + normal * 0.001, light);

これを見ると、なにやらちょっとした計算をしてから値を渡しているのがわかると思います。

変数 dPos には、レイとオブジェクトとの交点の座標が入っています。そこに、非常に小さい値にスケーリングした法線を加算しているのがわかるでしょうか。

この genShadow というオリジナルの関数には、第一引数に光源に向けて飛ばしなおすレイの原点座標を与える仕様になっています。なぜ、単純に交点の座標をレイの原点とするのではなく、法線を加算した状態の座標にしてから与えているのか理由がわかるでしょうか。

レイマーチングでは、distance function の戻り値が非常に小さい(非常に 0.0 に近い)場合を衝突の判断基準とします。当サイトの今までのサンプルでは、戻り値を abs に通してから衝突判定を行っていました。わざわざ abs を通して絶対値を取っているのは、場合によりレイがオブジェクトの中にめり込んでしまうことがあるためでした。

つまり、単純に交点からレイを光源に向かって飛ばしなおしてしまうと、場合によってはオブジェクトの中を通過していくような、不自然なレイの軌道になってしまう場合があるわけです。

レイが貫通してしまった場合の図式

レイが貫通してしまった場合の図式

このような状況になると、本来であれば影が落ちないような場所にまでノイズのように誤った影が落ちてしまう可能性があります。先ほどの genShadow の第一引数で法線を小さくスケーリングしてから加算していたのには、この問題を解消する役割があったのです。

光源に向かって飛ばすレイの始点を法線方向に少しだけ移動させるようにしてやれば、仮に最初のレイが若干めり込んだ状態になっていたとしても、オブジェクトの表面部分から光源に向かって伸びるレイを正しく飛ばすことができるわけですね。

影になるかどうかを判定する関数

さて、それでは肝心の genShadow 関数の中身を見てみることにしましょう。

ちなみに、この関数の第一引数は、先ほども書いたように光源に向かって飛ばすレイの始点位置になります。そして第二引数は、ライトベクトル(正確には光源に向かうレイのベクトル)を与えるようになっています。

それらのことを踏まえつつ、実際の関数のコードを見てみましょう。

genShadow 関数

float genShadow(vec3 ro, vec3 rd){
	float h = 0.0;
	float c = 0.001;
	float r = 1.0;
	float shadowCoef = 0.5;
	for(float t = 0.0; t < 50.0; t++){
		h = distFunc(ro + rd * c);
		if(h < 0.001){
			return shadowCoef;
		}
		r = min(r, h * 16.0 / c);
		c += h;
	}
	return mix(shadowCoef, 1.0, r);
}

関数の内部で for 文によるループ処理が組まれています。このことからもわかるように、光源に向かってレイを飛ばしなおし、従来と同じようにマーチングループによってオブジェクトとの衝突があるのかどうかをチェックしていることがわかると思います。

今回の場合は、光源に平行光源を想定しているので一切チェックしていないのですが、仮に光源が点光源となる場合は、その光源よりも向こう側にオブジェクトがあった場合に、誤って衝突検知をしてしまわないように気を配る必要がありますので注意しましょう。

実際に衝突が起こっていた場合には、関数内で宣言している影係数 shadowCoef をそのまま返しています。これが最も濃い影が落ちる場合になります。

衝突が検知できなかった場合には、何かしらの計算を行ってから値を返しているのがわかると思います。マーチングループの中身の構造を見ると、distance function の戻り値を使ってなにやら計算をしているのがわかりますね。

実は、この謎の計算を行っている部分こそ、影をソフトシャドウとして表現するための肝になる部分です。

よーく処理の流れを見ながら考えていけばなんとなくわかると思いますが、マーチングループの中で使われている変数 h には、distance function が返してくる最も近くにあるオブジェクトまでの最短距離が入っています。

その得られた最短距離を 16 倍してから、変数 c で除算し、さらに min 関数を使って値を処理しています。変数 c の中身は、ループ回数が多くなればなるほど大きな値になっていくことも、順にコードを追っていけばわかりますね。

ここはどういう計算が行われているのか、またそれがなんのためにそういう計算をしているのかイメージしにくいと思いますが、簡単に言うと距離に応じて影のかかり具合をぼかすような効果を出すための計算をしています。

変数 h に対して、今回のサンプルの場合は 16.0 を乗算しています。この唐突に出てくる 16.0 という数値には、特に大きな意味があるわけではなく、この数字がソフトシャドウのぼかし具合の係数になります。

たとえば、この係数をほかの値に変更してレンダリングしてみると、以下のように異なる結果が得られます。

係数の違いによるレンダリング結果の差異

4.0の場合

16.0の場合

32.0の場合

これを見ると一目瞭然ですが、係数を大きな数字にすればするほど、影はより鮮明になっていきます。

シェーダ内のコードの処理遷移と、この係数を大きくするほど鮮明になるという結果を踏まえて、じっくり考えてみてください。

まとめ

さて、ソフトシャドウの実装について見てきましたが、いかがでしたでしょうか。

影を描くために、ライトベクトルや光源の位置を活用しながら、再度レイを飛ばしなおせばいいのだという基本的な考え方さえわかってしまえば、それほど実装すること自体は難しくないと思います。

ソフトシャドウを表現するための genShadow 関数内の計算に関しては、若干イメージしにくいところがあるかもしれません。こういったわかりにくい概念が登場するケースでは、焦らずループ中に値がどのように遷移していくのか、落ち着いてイメージするようにしてみましょう。

最初はよくわからなくても、じっくり取り組んでみれば唐突に理解できるときがやってきたりするものです。今すぐにわからくても落胆せず、とりあえず先に実装を組んでまずはやってみるというのも、時には大事です。

途中でも触れましたが、今回の実装は平行光源の場合の実装なので、点光源を扱いたい場合には適宜計算やチェックの方式を書き換えて使ってみてください。概念が理解できていれば、それほど流用するのは難しくないはずです。

また、よく見てみないとわからないかもしれませんが、今回のソフトシャドウは影の落ち方が少々極端なので、たとえば影の係数を 32.0 にした先ほどのキャプチャ画像を見てみると、トーラスの内側などで色の表現がおかしくなってしまっている部分が見受けられると思います。

ディフューズやその他のライティングでどのように処理しているのかとの相性もありますので、適宜このあたりも状況に合わせてチューニングする必要があるでしょう。ご自身の実装に合わせて修正して使うようにしてください。

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

entry

PR

press Z key