オブジェクト同士を補間して結合する
今回のサンプルの実行結果
単なる同時描画のその先
前回は複数のオブジェクトを同時にレンダリングしたり、あるいは排他的に一部だけを抽出しつつレンダリングしたりする方法について解説しました。
複数の distance function を用いる場合は、その戻り値を比較したり調整したりすることで、様々なレンダリング結果が得られるのでしたね。まるでプログラマにはおなじみの論理演算のように、重なっている部分だけが抽出されてレンダリングされる様子はなんとも不思議でした。
さて今回は、前回と同様に、複数の異なる形状のオブジェクトをレンダリングしていきます。ただし、前回とは違いオブジェクト同士をスムーズに補間しつつ結合してレンダリングします。言葉だけではイメージしにくいかもしれませんが、冒頭のサンプル実行時のキャプチャ画像を見てみると意味がわかるのではないでしょうか。
補間の考え方
今回紹介する方法以外にも、スムーズに結合するための補間方法にはいくつか実装方法があると思いますが、今回用いる方法では以下のようなユーザー定義の関数をシェーダ内に記述します。
補間のための smoothMin 関数
float smoothMin(float d1, float d2, float k){
float h = exp(-k * d1) + exp(-k * d2);
return -log(h) / k;
}
当サイトのテキストではあまり登場してこなかったふたつのビルトイン関数が登場しています。それが exp
と log
の両者です。これらのビルトイン関数は、実は javascript にも同様の機能を持つものが実装されているのですが、普段はなかなか利用する機会がないものだと思います。
シェーダのコードの具体的な解説の前に、まずはこの見慣れない exp
と log
について簡単に説明します。
これらの関数は、解析学や数学などの分野では非常にメジャーなもので、様々な分野で利用されています。ただ、一般には馴染みもないですし、なかなか理解するのが難しいのではないかと思います。かくいう私自身も、詳細に理解できているのかと言えば疑問です。
ただ、ざっくりと理解するうえでは次のようなキーワードについておおよそ理解できていればいいと思います。
- 自然対数の底(ネイピア数 e)
- 指数
- 対数
ここでは本格的なキーワードの解説はしません……というか自分には無理ですので、詳しく知りたい人は各自調べていただくとして。
まず自然対数の底とは、javascript であれば Math.E
で得られる約 2.71828 の値のことです。ためしにブラウザのコンソールで叩いてみれば、その数値が得られるはずです。これは (1 + 1 / n)^n
という式で、n の値を無限大に大きくした場合に得られる極限値というものだそうです。
たとえば n が 5 だとして先ほどの式を計算してみると……
(1 + 1 / 5)^5 = 2.48832
となります。では同様に、n が先ほどの倍となる 10 だったらどういう結果になるのでしょうか。
(1 + 1 / 10)^10 = 2.5937424601...
このように、n の値が増えると、計算結果もそれに伴って大きくなっていくのですね。
では仮に n が無限大に大きな数値であったら結果はどうなるのでしょうか。この場合は、式の計算結果も無限大に大きくなるのでしょうか。
実は、この結果は n をどんどん大きくしていくにしたがってある数値に収束していきます。それが、ネイピア数とも呼ばれる自然対数の底です。これがどんなときに利用できるのか、これがあることによって何がうれしいのかはここでは深く考えずに、そういうものなのだと一応覚えておいてください。
そして exp
という関数は、さきほどの自然対数の底に対して、[ 引数として受け取った値を指数として計算 ]した結果を返してくれる関数です。指数ってなんやねん! という方もいらっしゃるかもしれませんが、指数とはいわゆる累乗の計算の上付き文字のことですね。nx でいう x が指数です。
exp(x) = e^x
上記の e
は、自然対数の底、つまりネイピア数です。ここまでくれば、 exp
関数がなかで何をやっているのかはわかったと思います。要は、2.71828... の値を x 乗した値が得られるということです。
自然対数の底、そして指数、ここまではわかりましたね。
それでは先ほどのみっつのキーワードの最後、対数とはなんなのでしょう。
対数は、超ざっくり言うと何乗したらその数になるかを表す数です。たとえばxy = z
という式を例にすると、x を y 乗すると z になるわけですから[ x を底とする z の対数は y ]というように表現することができます。
ちょっとややこしくなってきましたね。でも落ち着いて考えれば大丈夫です。
累乗される元の数、上記でいう x のことを底(てい)と言います。そして、上付き文字で表されている上記でいう y が対数ですね。
そして log
という関数は、この対数を計算してくれる関数なのですが、その計算は常に自然対数を底として計算されます。つまり、ネイピア数を何乗したら、引数で与えた数値が得られるのかを計算するわけです。
log(10) = 2.302585092994...
実際に log
を使って引数に 10 を与えると上記のようになります。
これはつまり、ネイピア数を 2.30258... 乗すると 10 になる! という意味になるんですね。
smoothMin 関数を読み解く
さて、自然対数の底(ネイピア数 e)、指数、そして対数。これらについて理解できた今なら、先ほどの smoothMin
関数が何をやっているのかもなんとなくわかるはずです。
smoothMin 関数
float smoothMin(float d1, float d2, float k){
float h = exp(-k * d1) + exp(-k * d2);
return -log(h) / k;
}
まずこの関数は、引数をみっつ取ります。 d1
と d2
は、ふたつの distance function の結果をそれぞれ受け取ります。最後の k
は、補間のかかり具合を調整するための係数の役割を果たします。
関数のなかでは、まず exp
を使った計算をしています。引数に与えるのは distance function の結果に対して、負の係数を掛けたものですね。その合計を log
に渡して、正負を反転してから係数で除算します。
何をやっているのかイメージしにくいかもしれませんが、 exp
と log
がそれぞれどのような意味を持っていたのかをよく考えてみれば、なんとなく理解できるはずです。
この smoothMin
関数を通すと、ふたつの distance function の結果がいい感じに補間されます。ちなみに、第三引数に与える数値を大きなものにすればするほど、補間を行っていない場合に近いレンダリング結果になっていきます。※要はシャープな印象になる
その仕組みは先ほどの exp
と log
の仕組みが理解できていればわかるでしょう。落ちついて考えれば、きっと理解できます。ブラウザのコンソールなどを利用して、様々な数値を与えて同様の計算をしてみると理解が深まるかもしれません。
シェーダ全体のコード
さてそれでは、今回のサンプルのシェーダソース全体を見ながら仕上げです。
precision mediump float;
uniform float time;
uniform vec2 mouse;
uniform vec2 resolution;
const vec3 cPos = vec3(-3.0, 3.0, 3.0);
const vec3 cDir = vec3(0.577, -0.577, -0.577);
const vec3 cUp = vec3(0.577, 0.577, -0.577);
const vec3 lightDir = vec3(-0.577, 0.577, 0.577);
// smoothing min
float smoothMin(float d1, float d2, float k){
float h = exp(-k * d1) + exp(-k * d2);
return -log(h) / k;
}
// box distance function
float distFuncBox(vec3 p){
return length(max(abs(p) - vec3(2.0, 0.1, 0.5), 0.0)) - 0.1;
}
// torus distance function
float distFuncTorus(vec3 p){
vec2 t = vec2(1.5, 0.25);
vec2 r = vec2(length(p.xy) - t.x, p.z);
return length(r) - t.y;
}
// distance function
float distFunc(vec3 p){
float d1 = distFuncTorus(p);
float d2 = distFuncBox(p);
return smoothMin(d1, d2, 8.0);
}
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、そしてトーラスを描くための distance function のふたつが定義されています。
前回は双方の戻り値を min
や max
などのビルトイン関数に渡して選別していましたが、今回は先ほどから登場している自前の smoothMin
関数に渡すようにしています。
distance function の結果を用いた処理
float distFunc(vec3 p){
float d1 = distFuncTorus(p);
float d2 = distFuncBox(p);
return smoothMin(d1, d2, 8.0);
}
先述の通り smoothMin
関数はみっつの引数を取りますが、第三引数には 8.0 を指定しました。特に深い意味はありませんが、もう少し大きめの数値を指定したほうが、より自然な補間具合になるような気がします。
前回と異なるのは smoothMin
の記述が増えたことと、上記の部分に対する修正だけですね。
レイマーチングは本当にちょっとしたコードの変化でレンダリング結果が劇的に変わるので、毎回不思議な気分になります。
まとめ
さて、スムーズに補間しながらオブジェクトを結合する処理について見てきました。
途中、自然対数だの指数だのと、ちょっと小難しい概念も出てきましたが、確実にひとつずつ順を追って理解していけばきっと大丈夫です。
レイマーチングに限らず、3D プログラミングには完全な理解よりもまず、おおよその概念をつかんで動くものを作ってしまうということが非常に大事だと思います。知識は蓄積されていくものですし、どちらかというと長期間の熟成期間を経て、唐突に理解できたりするものだと私は考えています。
一度読んで意味がわからなくても、がんばって読み解こうと努力したことは無駄になりません。確実に、種は蒔かれているのです。焦らずじっくり取り組んでいきましょう。
実際に動作するサンプルはいつものように以下のリンクから。